1 Commits
Author SHA1 Message Date
bdeshi 815f0306c7 dump script 2026-08-04 20:32:14 +06:00
25 changed files with 182 additions and 3697 deletions
-118
View File
@@ -1,118 +0,0 @@
# Local build configuration. Copy to `.env` (gitignored) and fill in.
#
# cp .env.example .env
#
# build.sh reads `.env` automatically if it exists. Anything already set in
# the environment wins over this file, so a one-off
#
# SHANNONCOAT_SIGN_IDENTITY= ./build.sh
#
# still forces an ad-hoc build without editing anything.
# ---------------------------------------------------------------------------
# Code-signing identity
# ---------------------------------------------------------------------------
# The common name of the code-signing certificate in your keychain. Not a
# secret — it's a label, not a credential — but it lives here so every build
# picks it up without you having to remember the variable.
#
# Why bother signing at all: macOS pins an *ad-hoc* signed app's
# Accessibility grant to its exact cdhash, which changes on every build. So
# an unsigned local build silently loses the permission each time you
# rebuild and reinstall, while still appearing listed and ticked under
# Privacy & Security → Accessibility. Signing with a certificate gives the
# app a stable designated requirement, and the grant survives.
#
# Leave empty (or delete the line) to build ad-hoc.
SHANNONCOAT_SIGN_IDENTITY="shannoncoat Signing"
# ---------------------------------------------------------------------------
# Certificate material — only needed to publish the GitHub Actions secrets
# ---------------------------------------------------------------------------
# The build itself never reads these: once the certificate is imported, the
# private key lives in your keychain and codesign finds it by the identity
# name above. They're recorded here purely so the `gh secret set` commands
# at the bottom have somewhere to read from.
#
# Keep the .p12 OUTSIDE the working tree. A gitignored file is still one
# `git add -f`, one editor-indexed backup, or one shared folder away from
# leaking, and this one holds a private key.
#
# The $HOME below is expanded by the shell when you `source .env` (which is
# how the gh commands at the bottom read it). build.sh parses rather than
# sources this file, so it takes values literally — which costs nothing
# here, as the only variable it actually reads is the identity above.
SHANNONCOAT_P12_PATH="$HOME/.shannoncoat-signing/shannoncoat-signing.p12"
# The .p12 export password. Storing it in plaintext here is weaker than
# leaving it in your password manager and typing it when prompted — prefer
# leaving this field empty and letting `gh secret set` ask for it
# interactively.
#
# Leaving the *field* empty is fine. Exporting the .p12 itself with an
# empty password is not: macOS cannot import one, so CI would fail even
# though local builds carry on working (they import the PEM pair, which
# needs no password). Give the export a real password.
SHANNONCOAT_P12_PASSWORD=""
# ---------------------------------------------------------------------------
# One-time setup
# ---------------------------------------------------------------------------
# Generate a ten-year self-signed code-signing certificate (codesign refuses
# an expired one, and replacing it later resets every user's Accessibility
# permission again):
#
# mkdir -p ~/.shannoncoat-signing && chmod 700 ~/.shannoncoat-signing
# openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
# -keyout ~/.shannoncoat-signing/key.pem \
# -out ~/.shannoncoat-signing/cert.pem \
# -subj "/CN=shannoncoat Signing" \
# -addext "basicConstraints=critical,CA:false" \
# -addext "keyUsage=critical,digitalSignature" \
# -addext "extendedKeyUsage=critical,codeSigning"
#
# Bundle it as a .p12. `-legacy` is required: OpenSSL 3's default encoding
# is one macOS cannot read, and `security import` then fails with a
# misleading "MAC verification failed (wrong password?)".
#
# openssl pkcs12 -export -legacy \
# -inkey ~/.shannoncoat-signing/key.pem \
# -in ~/.shannoncoat-signing/cert.pem \
# -name "shannoncoat Signing" \
# -out ~/.shannoncoat-signing/shannoncoat-signing.p12
#
# Import for local builds, then delete the now-redundant loose private key
# (the .p12 remains your only backup, so keep that):
#
# security import ~/.shannoncoat-signing/shannoncoat-signing.p12 \
# -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign
# rm ~/.shannoncoat-signing/key.pem
#
# The first signed build raises a "codesign wants to use key…" dialog —
# choose Always Allow. Then re-grant Accessibility one final time; from
# then on it persists across rebuilds.
#
# Note that `security find-identity -v -p codesigning` will report "0 valid
# identities": -v filters to *trusted* certificates and a self-signed one
# reads as CSSMERR_TP_NOT_TRUSTED. codesign uses it regardless. Drop the -v
# to see it.
# ---------------------------------------------------------------------------
# Publishing the same certificate to GitHub Actions
# ---------------------------------------------------------------------------
# Releases need this too — an ad-hoc release revokes the Accessibility
# permission of everyone who updates. .github/workflows/release.yml stays
# inert until all three secrets exist, so nothing breaks in the meantime.
#
# source .env
# base64 -i "$SHANNONCOAT_P12_PATH" | gh secret set SIGNING_CERTIFICATE_P12
# gh secret set SIGNING_IDENTITY --body "$SHANNONCOAT_SIGN_IDENTITY"
# gh secret set SIGNING_CERTIFICATE_PASSWORD # prompts, so it stays out of shell history
#
# This buys permission persistence, not Gatekeeper approval: a self-signed
# certificate isn't notarized, so downloads are still blocked on first open
# and need System Settings → Privacy & Security → Open Anyway.
-155
View File
@@ -1,155 +0,0 @@
name: Release
# Two ways in: a manually pushed version tag, or a direct call from
# tag-on-version-bump.yml (which fires on VERSION changes on main). The
# workflow_call path exists because pushes made with the default
# GITHUB_TOKEN don't trigger other workflows' `on.push` — a tag pushed by
# that workflow wouldn't reach this one's `tags:` trigger on its own.
on:
push:
tags:
- "*"
workflow_call:
inputs:
tag:
required: true
type: string
permissions:
contents: write
jobs:
build:
runs-on: macos-latest
env:
# Not a secret (it's just the certificate's common name), and needed
# in a step `if:` — where the `secrets` context isn't available, but
# `env` is. Empty when signing isn't configured, which every step
# below treats as "fall back to ad-hoc".
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
# Releases need a *stable* code identity, not an ad-hoc one. macOS
# pins an ad-hoc app's Accessibility grant to its exact cdhash, which
# changes on every build — so each release would silently revoke the
# permission users had already granted, leaving a stale entry sitting
# in System Settings still ticked (and un-fixable by toggling it).
# A certificate gives the app a designated requirement that survives
# updates instead.
#
# This does NOT make the app Gatekeeper-clean: a self-signed cert
# isn't notarized, so downloads still need right-click → Open. Only
# a paid Developer ID plus notarization changes that.
#
# One-time setup, ideally with the same self-signed certificate used
# for local builds (create it with a long validity — codesign refuses
# to use an expired cert, and re-signing under a new one resets every
# user's permission again):
# security export -k login.keychain -t identities -f pkcs12 \
# -P '<password>' -o cert.p12
# base64 -i cert.p12
# then add three repository secrets — SIGNING_CERTIFICATE_P12 (that
# base64 blob), SIGNING_CERTIFICATE_PASSWORD, and SIGNING_IDENTITY
# (the certificate's common name).
#
# If you build the .p12 with OpenSSL 3 rather than exporting it, pass
# `-legacy`: its default AES-256/SHA-256 PKCS#12 encoding is one the
# macOS Security framework can't read, and `security import` fails
# with a misleading "MAC verification failed (wrong password?)".
- name: Import signing certificate
if: env.SIGNING_IDENTITY != ''
env:
CERT_P12: ${{ secrets.SIGNING_CERTIFICATE_P12 }}
CERT_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
# macOS cannot import an OpenSSL-produced PKCS#12 that has an
# empty password — the two disagree over the spec's empty-string
# vs. NULL password ambiguity, and `security import` reports it
# as "MAC verification failed (wrong password?)", which sends you
# hunting for a wrong password rather than a missing one. Say
# what's actually wrong instead.
#
# Easy to hit, because a local build never exercises this: the
# local keychain imports the PEM pair directly and needs no
# password at all.
if [[ -z "${CERT_PASSWORD:-}" ]]; then
echo "error: SIGNING_CERTIFICATE_PASSWORD is empty." >&2
echo "The .p12 must be exported with a non-empty password; regenerate it with" >&2
echo " openssl pkcs12 -export -legacy -inkey key.pem -in cert.pem -out cert.p12" >&2
echo "and update both SIGNING_CERTIFICATE_P12 and SIGNING_CERTIFICATE_PASSWORD." >&2
exit 1
fi
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
CERT_PATH="$RUNNER_TEMP/cert.p12"
printf '%s' "$CERT_P12" | base64 --decode > "$CERT_PATH"
# A dedicated throwaway keychain, not the login one: it starts
# unlocked, needs no interactive prompt, and is deleted at the
# end of the job regardless of outcome.
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign
# Without this, codesign hits an interactive "allow access to
# key?" prompt that nothing can answer on a headless runner.
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
# codesign only searches keychains on the search list.
security list-keychain -d user -s "$KEYCHAIN" login.keychain-db
rm -f "$CERT_PATH"
- name: Build shannoncoat.app
run: ./build.sh
env:
# This workflow only ever runs against a tag (see the trigger
# comment above) — told to build.sh directly rather than having
# it re-derive that via `git describe`, which needs full tag
# refs a CI runner's checkout isn't guaranteed to have fetched.
SHANNONCOAT_RELEASE_BUILD: "1"
# Empty when unconfigured, which build.sh reads as ad-hoc.
SHANNONCOAT_SIGN_IDENTITY: ${{ env.SIGNING_IDENTITY }}
# Catches a release that silently went out ad-hoc — the failure this
# whole arrangement exists to prevent, and one that's invisible in
# the artifact until someone's permission stops working.
- name: Verify signature
run: |
set -euo pipefail
codesign --verify --strict --verbose=2 ".build/shannoncoat.app"
codesign -dvvv ".build/shannoncoat.app" 2>&1 \
| grep -E 'Identifier=|Authority=|Signature=' || true
if [[ -n "$SIGNING_IDENTITY" ]] \
&& codesign -dvvv ".build/shannoncoat.app" 2>&1 | grep -q 'Signature=adhoc'; then
echo "error: signing was configured but the app is still ad-hoc signed" >&2
exit 1
fi
- name: Zip app bundle
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
- name: Publish release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ inputs.tag || github.ref_name }}
files: |
shannoncoat.app.zip
generate_release_notes: true
# `if: always()` so a failed build can't leave the signing key
# sitting in a keychain on the runner.
- name: Clean up keychain
if: always() && env.SIGNING_IDENTITY != ''
run: |
security list-keychain -d user -s login.keychain-db || true
security delete-keychain "$RUNNER_TEMP/signing.keychain-db" || true
-55
View File
@@ -1,55 +0,0 @@
name: Tag on version bump
on:
push:
branches: [main]
paths: ["VERSION"]
permissions:
contents: write
jobs:
tag:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
created: ${{ steps.tag.outputs.created }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# Guards against re-tagging on a no-op push (e.g. VERSION edited then
# reverted to the same value in a later commit) — only pushes a tag
# that doesn't exist yet.
- name: Create tag if new
id: tag
run: |
VERSION="$(tr -d '[:space:]' < VERSION)"
TAG="$VERSION"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists, skipping."
echo "created=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
echo "created=true" >> "$GITHUB_OUTPUT"
release:
needs: tag
if: needs.tag.outputs.created == 'true'
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
@@ -1,156 +0,0 @@
# Icon generation: raw Claude.app icon assets extracted as source material
# for icon/generate-icon.swift — the derived composite (icon/AppIcon.icns)
# is committed, but Anthropic's own unmodified icon files aren't.
icon/claude-src/
icon/AppIcon.iconset/
# Session handoff notes — local only, never committed
CHECKPOINT.md
# Local build configuration, read by build.sh — holds the code-signing
# identity and, optionally, the certificate password. See .env.example for
# the shape of it. Never committed.
.env
# Belt and braces: private key material must never land in the repo, even
# gitignored. Keep the certificate itself outside the working tree (the
# setup notes in .env.example use ~/.shannoncoat-signing/) — this pattern
# only exists to catch a stray copy before it becomes a commit.
*.p12
*.pem
# Local reference material (e.g. nested checkouts of other projects) — not
# part of this project, never meant to be committed.
.scratch/
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# AI agents and assistants
#
# Some common agent instruction and project configuration files are listed
# below as commented-out examples. They are often intentionally committed and
# shared with a team, so only uncomment them if they are local-only in your
# project.
# GEMINI.md
# WARP.md
# CRUSH.md
# QWEN.md
# OpenAI Codex
# AGENTS.md
# .codex/
# Aider
# .aider.input.history
# .aider.chat.history.md
# .aider.llm.history
# .aider.tags.cache.v*
# .aiderignore
# Claude Code
.claude/*.local.json
.claude/**/*.log
CLAUDE.local.md
# .claude/
# Gemini CLI
# gemini-debug.log
# .gemini-clipboard/
# .gemini/
# Cursor AI
# .cursorrules
# .cursor/
# .cursor.json
# .cursor-settings.yaml
# Continue
# .continue/
# .continuerc.json
# Cline
# .cline/
# .clinerules
# cline.json
# Other agent/editor project config
# .warp/
# .crush/
# .codeium/
# .deepseek/
# .amazon-codewhisperer/
# .tabnineignore
# .tabnine/
# GitHub Copilot
# .github/copilot-instructions.md
# Windsurf Editor
# .windsurfrules
# .windsurf/
# Replit AI Development
# .replit
# replit.nix
-150
View File
@@ -1,150 +0,0 @@
![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 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
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.
-420
View File
@@ -1,420 +0,0 @@
// Menu bar behavior: title dots, per-profile menu, Manage Profiles window.
// Reads live state from LiveState (push-based, no polling) and calls
// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing.
import AppKit
import ApplicationServices
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let menu = NSMenu()
let liveState = LiveState()
private var currentInfos: [ProfileInfo] = []
private var collisionAlertShown = false
private var windowOverlays: [String: WindowOverlay] = [:]
private var pendingWindowOverlays: Set<String> = []
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.image()
statusItem.button?.imagePosition = .imageLeft
liveState.delegate = self
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
ManageWindowController.shared.onOverlayStyleChanged = { [weak self] in self?.rebuildWindowOverlays() }
// Every app activation, not just Claude's — LiveState deliberately
// filters to the Claude binary, so on its own it never hears that
// some unrelated app came forward, which is exactly when the tags
// need to get out of the way.
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
// menu-bar title/dots to click yet — offer a picker up front
// instead of leaving the user to discover the dropdown.
if currentInfos.filter(\.running).isEmpty {
showStartupPicker(currentInfos)
}
UpdateChecker.checkAutomaticallyIfDue()
}
// 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
func liveStateDidChange(_ infos: [ProfileInfo]) {
collisionAlertShown = false
currentInfos = infos
applyTitle(infos)
ManageWindowController.shared.update(infos)
updateWindowOverlays(infos)
}
// Each tag decides for itself whether the window it labels is actually
// visible underneath it (see WindowOverlay.isTargetVisible) — this just
// says "now would be a good time to look again". Activation is the
// usual way a window gets buried or uncovered without moving at all,
// which no AX notification reports.
private func updateOverlayVisibility() {
for overlay in windowOverlays.values { overlay.refreshVisibility() }
}
// macOS doesn't notify an app that it has just been granted
// Accessibility, and overlays are otherwise only reconsidered when a
// Claude window launches, quits or activates — so a permission granted
// while this is running would do nothing visible until the user
// happened to touch a Claude window, or restarted the app. Polling for
// it costs a cheap local check every couple of seconds, and only while
// the permission is actually missing: the moment it lands, the guard in
// `updateWindowOverlays` passes and this stops rescheduling itself.
//
// Also covers a subtler case — replacing the installed bundle and
// relaunching immediately can start the app before TCC has settled on
// the new copy, and a one-shot check there would strand the tags for
// the rest of the session over a denial that was only ever momentary.
private func scheduleAccessibilityRecheck() {
guard !accessibilityRecheckScheduled else { return }
accessibilityRecheckScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self else { return }
accessibilityRecheckScheduled = false
updateWindowOverlays(currentInfos)
}
}
// A style change rewrites the tag's shape and size, which is decided
// when its panel is built — so the existing ones are dropped (deinit
// tears down each panel and its AXObserver) and rebuilt rather than
// patched in place.
private func rebuildWindowOverlays() {
windowOverlays.removeAll()
updateWindowOverlays(currentInfos)
}
// Shown on every running *managed* profile — never on "default",
// which isn't a shannoncoat profile at all, just the real underlying
// Claude install. Removing an entry here deinits its WindowOverlay,
// which tears down its AXObserver and hides the tag panel.
private func updateWindowOverlays(_ infos: [ProfileInfo]) {
let managed = infos.filter { $0.running && $0.profile.name != "default" }
let managedNames = Set(managed.map(\.profile.name))
windowOverlays = windowOverlays.filter { managedNames.contains($0.key) }
pendingWindowOverlays.formIntersection(managedNames)
// A tag is positioned entirely from Accessibility data, so without
// that permission there is nothing to position: `attach` would
// burn its dozen retries per profile and give up without a word,
// which reads as "the feature is broken" rather than "macOS said
// no". Prompt instead — the same call `ClaudeControl.focus` makes
// for the same reason.
//
// Worth knowing when this bites, because it looks impossible:
// a local build gets a fresh code identity every time build.sh
// runs, and that silently invalidates an existing grant while
// leaving the app still listed *and still ticked* under Privacy &
// Security → Accessibility. Re-granting means removing that stale
// row with "−" and adding the newly built app back; toggling it
// off and on again is not enough. (Note that running the binary
// straight out of the bundle from a terminal will appear to work
// regardless — it inherits the terminal's grant, not the app's,
// so it's useless for testing this.)
if !managed.isEmpty, !AXIsProcessTrusted() {
if !accessibilityPromptShown {
accessibilityPromptShown = true
ClaudeControl.promptForAccessibility()
}
scheduleAccessibilityRecheck()
return
}
for info in managed
where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) {
guard let pid = info.pid else { continue }
let name = info.profile.name
pendingWindowOverlays.insert(name)
WindowOverlay.attach(profileName: name, color: ProfileColor.dotColor(for: name), pid: pid) { [weak self] overlay in
guard let self else { return }
pendingWindowOverlays.remove(name)
// The profile may have quit again while this was retrying
// for its window — don't attach a stale overlay if so.
guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return }
windowOverlays[name] = overlay
// A tag created while some other app is frontmost must not
// appear over it — `attach` can complete seconds after the
// launch that triggered it, by which point focus has often
// moved on.
updateOverlayVisibility()
}
}
updateOverlayVisibility()
}
// Two profiles sharing a dir is a fatal misconfiguration (would
// silently merge their Claude sessions) — surfaced with an alert
// rather than crashing the app outright, since that would leave the
// user with no way to fix it short of hand-editing files blind.
func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) {
guard !collisionAlertShown else { return }
collisionAlertShown = true
let lines = collisions.map { "\u{2022} \($0.kind): \"\($0.profileA)\" and \"\($0.profileB)\" both use \($0.dir)" }
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "shannoncoat: configuration error"
alert.informativeText = "Two profiles can't share a directory:\n\n" + lines.joined(separator: "\n")
alert.addButton(withTitle: "OK")
alert.runModal()
}
// Title reflects what's ACTUALLY running — one profile shows as a dot
// + name; several show as dots only, to keep the menu bar from growing
// unbounded, with the full list available via tooltip and the dropdown.
private func applyTitle(_ profiles: [ProfileInfo]) {
guard let button = statusItem.button else { return }
let running = profiles.filter(\.running)
switch running.count {
case 0:
button.attributedTitle = NSAttributedString(string: "")
button.toolTip = "Claude not running"
case 1:
let name = running[0].profile.name
let title = NSMutableAttributedString(
string: "\u{25cf} ", attributes: [.foregroundColor: ProfileColor.dotColor(for: name)])
title.append(NSAttributedString(string: name, attributes: [.foregroundColor: NSColor.labelColor]))
button.attributedTitle = title
button.toolTip = nil
default:
let title = NSMutableAttributedString()
for info in running {
title.append(NSAttributedString(
string: "\u{25cf}", attributes: [.foregroundColor: ProfileColor.dotColor(for: info.profile.name)]))
}
button.attributedTitle = title
button.toolTip = running.map(\.profile.name).joined(separator: ", ")
}
}
// MARK: - Menu
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
liveState.reconcile() // correctness backstop; live updates are the primary mechanism
let profiles = currentInfos
let runningCount = profiles.filter(\.running).count
let header = NSMenuItem(
title: runningCount == 0 ? "Claude not running"
: runningCount == 1 ? "1 profile running" : "\(runningCount) profiles running",
action: nil, keyEquivalent: "")
header.isEnabled = false
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)
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.
let title = info.profile.name == liveState.lastActiveName
? "\(info.profile.name) \u{21c6}" : info.profile.name
let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "")
item.target = self
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"
menu.addItem(item)
}
menu.addItem(.separator())
let manageItem = NSMenuItem(
title: "Manage Profiles\u{2026}", action: #selector(openManageWindow), keyEquivalent: ",")
manageItem.keyEquivalentModifierMask = [.command]
manageItem.target = self
menu.addItem(manageItem)
let closeItem = NSMenuItem(title: "Close Profile", action: nil, keyEquivalent: "")
let closeMenu = NSMenu()
let closeable = profiles.filter(\.running)
if closeable.isEmpty {
let none = NSMenuItem(title: "(none running)", action: nil, keyEquivalent: "")
none.isEnabled = false
closeMenu.addItem(none)
} else {
for info in closeable {
let mi = NSMenuItem(title: info.profile.name, action: #selector(closeProfile(_:)), keyEquivalent: "")
mi.target = self
mi.representedObject = info.profile.name
closeMenu.addItem(mi)
}
}
closeItem.submenu = closeMenu
menu.addItem(closeItem)
menu.addItem(.separator())
let aboutItem = NSMenuItem(title: "About shannoncoat", action: #selector(showAbout), keyEquivalent: "")
aboutItem.target = self
menu.addItem(aboutItem)
menu.addItem(.separator())
let quitClaudeItem = NSMenuItem(title: "Quit Claude", action: #selector(quitClaude), keyEquivalent: "")
quitClaudeItem.target = self
menu.addItem(quitClaudeItem)
let quitSelfItem = NSMenuItem(title: "Quit shannoncoat", action: #selector(quitSelf), keyEquivalent: "q")
quitSelfItem.target = self
menu.addItem(quitSelfItem)
}
// 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
// "alongside" of, so this lets several profiles be selected to start
// together in one go.
private func showStartupPicker(_ profiles: [ProfileInfo]) {
guard !profiles.isEmpty else { return }
let alert = NSAlert()
alert.messageText = "Start Claude"
alert.informativeText = "Nothing is running. Choose which profiles to start:"
alert.addButton(withTitle: "Start")
alert.addButton(withTitle: "Cancel")
let rowHeight: CGFloat = 22
let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(profiles.count)))
var checkboxes: [(box: NSButton, name: String)] = []
for (i, info) in profiles.enumerated() {
let y = rowHeight * CGFloat(profiles.count - 1 - i)
let box = NSButton(checkboxWithTitle: info.profile.name, target: nil, action: nil)
box.frame = NSRect(x: 0, y: y, width: 240, height: rowHeight)
box.state = info.profile.name == liveState.lastActiveName ? .on : .off
container.addSubview(box)
checkboxes.append((box, info.profile.name))
}
alert.accessoryView = container
guard alert.runModal() == .alertFirstButtonReturn else { return }
for (box, name) in checkboxes where box.state == .on {
guard let profile = profiles.first(where: { $0.profile.name == name })?.profile else { continue }
liveState.markActive(name)
ClaudeControl.launch(profile)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() }
}
// MARK: - Actions
@objc func pick(_ sender: NSMenuItem) {
guard let name = sender.representedObject as? String,
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)
}
}
private func openProfile(_ profile: ResolvedProfile) {
if let pid = ClaudeControl.pid(for: profile) {
ClaudeControl.focus(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
} else {
ClaudeControl.launch(profile)
// The launch notification usually beats this, but a short
// explicit poke keeps the UI snappy even if it doesn't.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() }
}
}
// 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
for info in others {
guard let pid = info.pid else { remaining -= 1; continue }
ClaudeControl.quit(pid: pid) { [weak self] _ in
remaining -= 1
if remaining == 0 {
DispatchQueue.main.async { self?.openProfile(profile) }
}
}
}
}
@objc func closeProfile(_ sender: NSMenuItem) {
guard let name = sender.representedObject as? String,
let info = currentInfos.first(where: { $0.profile.name == name }), let pid = info.pid
else { return }
ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
}
@objc func openManageWindow() {
ManageWindowController.shared.update(currentInfos)
ManageWindowController.shared.show()
}
// About lives as a tab in the same window (see ManageWindow.swift)
// rather than the standard NSApp About panel — the standard panel
// auto-appends its own "Version X (Y)" line from Info.plist's
// CFBundleVersion/CFBundleShortVersionString, which are identical in
// this build, so it was showing that duplicated alongside our own
// version line.
@objc func showAbout() {
ManageWindowController.shared.update(currentInfos)
ManageWindowController.shared.show(tab: .about)
}
@objc func quitClaude() {
ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } }
}
@objc func quitSelf() {
NSApp.terminate(nil)
}
}
-240
View File
@@ -1,240 +0,0 @@
// Everything about controlling the Claude process itself: launch, focus,
// quit. Only two real primitives — launch/focus one profile ("open"), and
// quit one profile ("close"); "switch" (exclusive) is composed from these
// two in AppDelegate rather than being a third code path here.
import AppKit
import ApplicationServices
import Foundation
import UniformTypeIdentifiers
enum ClaudeControl {
private static let appPathKey = "ClaudeAppPath"
// Both places an app legitimately lives on macOS: the machine-wide
// /Applications and a per-user ~/Applications. Checked in that order,
// after any location the user has pointed us at themselves.
private static var candidatePaths: [String] {
[
"/Applications/Claude.app",
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Applications/Claude.app").path,
]
}
private static var cachedAppPath: String?
// Resolved once per launch, because this is consulted on every process
// enumeration — which happens on every workspace notification.
static var appPath: String {
if let cachedAppPath { return cachedAppPath }
let resolved = resolveAppPath()
cachedAppPath = resolved
return resolved
}
static var binaryPath: String { "\(appPath)/Contents/MacOS/Claude" }
static var isInstalled: Bool { FileManager.default.fileExists(atPath: binaryPath) }
private static func holdsClaude(_ path: String) -> Bool {
FileManager.default.fileExists(atPath: "\(path)/Contents/MacOS/Claude")
}
private static func resolveAppPath() -> String {
if let saved = UserDefaults.standard.string(forKey: appPathKey), holdsClaude(saved) {
return saved
}
if let found = candidatePaths.first(where: holdsClaude) {
return found
}
// Found nothing. Report against the standard location anyway, so
// anything that surfaces this path names somewhere meaningful
// rather than an empty string.
return candidatePaths[0]
}
// For an install in neither standard location. Modal by nature, but only
// ever reached from an explicit user action that can't proceed without
// an answer — better than a menu click that silently does nothing.
@discardableResult
static func promptForAppLocation() -> Bool {
let panel = NSOpenPanel()
panel.message = "Couldn't find Claude in Applications. Choose Claude.app to continue."
panel.prompt = "Choose"
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedContentTypes = [.application]
panel.directoryURL = URL(fileURLWithPath: "/Applications")
NSApp.activate(ignoringOtherApps: true)
guard panel.runModal() == .OK, let url = panel.url else { return false }
guard holdsClaude(url.path) else {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "That doesn't look like Claude."
alert.informativeText = "\(url.lastPathComponent) doesn't contain a Claude executable."
alert.runModal()
return false
}
UserDefaults.standard.set(url.path, forKey: appPathKey)
cachedAppPath = url.path
return true
}
static func runningInstances() -> [RunningClaude] {
ProcessInspector.listRunningClaude(binaryPath: binaryPath)
}
static func pid(for profile: ResolvedProfile) -> pid_t? {
let dir = profile.name == "default" ? nil : profile.appDir
return ProcessInspector.pid(forUserDataDir: dir, binaryPath: binaryPath)
}
// MARK: - Launch
@discardableResult
static func launch(_ profile: ResolvedProfile) -> Bool {
// Ask once rather than fail silently: a launch that does nothing
// gives the user no way to work out that Claude simply isn't where
// this expected it to be.
if !isInstalled, !promptForAppLocation() { return false }
let process = Process()
process.executableURL = URL(fileURLWithPath: binaryPath)
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
if profile.name != "default" {
try? FileManager.default.createDirectory(atPath: profile.codeDir, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(atPath: profile.appDir, withIntermediateDirectories: true)
var env = ProcessInfo.processInfo.environment
env["CLAUDE_CONFIG_DIR"] = profile.codeDir
process.environment = env
process.arguments = ["--user-data-dir=\(profile.appDir)"]
}
do {
try process.run()
return true
} catch {
return false
}
}
// MARK: - Focus
// Robust focus: unhide, un-minimize, raise across Spaces via the
// Accessibility API directly (replacing the old AppleScript/System
// Events call), then NSRunningApplication.activate() as a second push.
// Waits briefly for a window to exist if the process was just launched,
// rather than giving up immediately. Best-effort by design (a focus
// request shouldn't be able to crash or block the caller), but no
// longer silent about the one failure that's actually actionable:
// Accessibility not granted.
static func focus(_ profile: ResolvedProfile, completion: @escaping (Bool) -> Void) {
guard let pid = pid(for: profile) else { completion(false); return }
focus(pid: pid, completion: completion)
}
static func focus(pid: pid_t, completion: @escaping (Bool) -> Void) {
if !AXIsProcessTrusted() {
promptForAccessibility()
}
guard let app = NSRunningApplication(processIdentifier: pid) else { completion(false); return }
if app.isHidden { app.unhide() }
let axApp = AXUIElementCreateApplication(pid)
waitForWindow(axApp: axApp, attemptsRemaining: 12) { window in
if let window {
setMinimized(window, false)
AXUIElementPerformAction(window, kAXRaiseAction as CFString)
}
app.activate()
completion(window != nil)
}
}
private static func waitForWindow(
axApp: AXUIElement, attemptsRemaining: Int, completion: @escaping (AXUIElement?) -> Void
) {
if let window = firstWindow(of: axApp) {
completion(window)
} else if attemptsRemaining <= 0 {
completion(nil)
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
waitForWindow(axApp: axApp, attemptsRemaining: attemptsRemaining - 1, completion: completion)
}
}
}
private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
let windows = value as? [AXUIElement], let first = windows.first
else { return nil }
return first
}
private static func setMinimized(_ window: AXUIElement, _ minimized: Bool) {
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
}
static func promptForAccessibility() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
// MARK: - Quit
enum QuitResult { case exited, blockedByOwnDialog, forceKilled, alreadyGone }
// Fixes a real bug in the old shell version: it waited a flat 5s then
// unconditionally SIGKILLed, which would kill Claude out from under its
// own "unsaved work" / "generation in progress" confirmation if it
// showed one. `.terminate()` is a request the target can legitimately
// delay or decline (it can return `.terminateCancel` from its own
// applicationShouldTerminate:), not a command it's forced to obey — so
// this waits much longer, and if Claude still has a window up at the
// end of that window (a sign it's showing its own dialog, or otherwise
// isn't done with something) it stops short of forceTerminate and
// reports that back instead of killing underneath it.
static func quit(pid: pid_t, completion: @escaping (QuitResult) -> Void) {
guard let app = NSRunningApplication(processIdentifier: pid) else { completion(.alreadyGone); return }
app.terminate()
pollForExit(app: app, attemptsRemaining: 40, completion: completion) // 40 * 0.25s = 10s grace period
}
static func quitAll(completion: @escaping () -> Void) {
let pids = runningInstances().map(\.pid)
guard !pids.isEmpty else { completion(); return }
var remaining = pids.count
for pid in pids {
quit(pid: pid) { _ in
remaining -= 1
if remaining == 0 { completion() }
}
}
}
private static func pollForExit(
app: NSRunningApplication, attemptsRemaining: Int, completion: @escaping (QuitResult) -> Void
) {
if app.isTerminated {
completion(.exited)
} else if attemptsRemaining <= 0 {
if firstWindow(of: AXUIElementCreateApplication(app.processIdentifier)) != nil {
completion(.blockedByOwnDialog)
} else {
app.forceTerminate()
completion(.forceKilled)
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
pollForExit(app: app, attemptsRemaining: attemptsRemaining - 1, completion: completion)
}
}
}
}
-21
View File
@@ -1,21 +0,0 @@
// Thin wrapper around the modern login-item API, toggled from the Manage
// window's Settings tab. Standard, expected baseline behavior for a
// persistent menu-bar utility that's otherwise easy to forget to reopen
// after a restart.
import ServiceManagement
enum LaunchAtLogin {
static var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
static func enable() throws {
guard SMAppService.mainApp.status != .enabled else { return }
try SMAppService.mainApp.register()
}
static func disable() throws {
guard SMAppService.mainApp.status == .enabled else { return }
try SMAppService.mainApp.unregister()
}
}
-87
View File
@@ -1,87 +0,0 @@
// Keeps the menu bar in sync with reality without ever polling. Registers
// NSWorkspace notifications for launch/terminate/activate/hide/unhide,
// filtered to Claude's own binary, and reconciles an in-memory
// [ProfileInfo] the instant one fires (sub-second, push-based) — this is
// what makes external changes (Force Quit, Spotlight, Dock, a hand quit)
// show up immediately instead of needing a menu click or an app restart.
import AppKit
import Foundation
struct ProfileInfo {
let profile: ResolvedProfile
let running: Bool
let pid: pid_t?
}
protocol LiveStateDelegate: AnyObject {
func liveStateDidChange(_ infos: [ProfileInfo])
func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision])
}
final class LiveState {
weak var delegate: LiveStateDelegate?
// Best-effort "last switched to" pointer for the ⇆ marker in the menu —
// reflects profiles opened/switched through this app; there's no
// ordered history to fall back through if that one gets hand-quit
// (matches the "don't overdo it" brief — reality-vs-stopped state is
// still always ground-truth, this only affects the ⇆ hint).
private(set) var lastActiveName = "default"
private var observers: [NSObjectProtocol] = []
init() {
let center = NSWorkspace.shared.notificationCenter
let names: [Notification.Name] = [
NSWorkspace.didLaunchApplicationNotification,
NSWorkspace.didTerminateApplicationNotification,
NSWorkspace.didActivateApplicationNotification,
NSWorkspace.didHideApplicationNotification,
NSWorkspace.didUnhideApplicationNotification,
]
observers = names.map { name in
center.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in
self?.handle(note)
}
}
// Deliberately not calling reconcile() here — `delegate` is set by
// the caller right after init, and the caller does the first
// reconcile itself once it's ready to receive the callback.
}
deinit {
let center = NSWorkspace.shared.notificationCenter
observers.forEach { center.removeObserver($0) }
}
private func handle(_ note: Notification) {
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.executableURL?.path == ClaudeControl.binaryPath
else { return }
reconcile()
}
func markActive(_ name: String) {
lastActiveName = name
}
// Public so callers (menu open, right after a command completes) can
// force an immediate reconcile as a correctness backstop, without that
// being the primary update mechanism.
func reconcile() {
let profiles = ProfileStore.loadAll()
let collisions = ProfileStore.findCollisions(among: profiles)
guard collisions.isEmpty else {
delegate?.liveStateFoundCollisions(collisions)
return
}
let running = ClaudeControl.runningInstances()
let infos = profiles.map { profile -> ProfileInfo in
let dir = profile.name == "default" ? nil : profile.appDir
let match = running.first { $0.userDataDir == dir }
return ProfileInfo(profile: profile, running: match != nil, pid: match?.pid)
}
delegate?.liveStateDidChange(infos)
}
}
-719
View File
@@ -1,719 +0,0 @@
// One ordinary, non-modal window for everything profile-management related
// — replaces what would otherwise be a string of separate popup alerts.
// Opened from the menu bar's "Manage Profiles…" item or the standard
// Cmd+, shortcut. Fixed-size (not user-resizable) — each tab reports its
// own natural size via preferredContentSize, and NSTabViewController
// resizes the window to match automatically, so it's never left showing
// empty space sized for a different tab or state.
import AppKit
final class ManageWindowController: NSWindowController {
static let shared = ManageWindowController()
enum Tab: Int { case profiles = 0, settings = 1, about = 2 }
private let profilesVC = ProfilesViewController()
private let settingsVC = SettingsViewController()
private let aboutVC = AboutViewController()
private let tabs = NSTabViewController()
// Called after a profile is added/removed so the caller can trigger an
// immediate LiveState reconcile instead of waiting on the next
// notification.
var onProfilesChanged: (() -> Void)? {
didSet { profilesVC.onChange = onProfilesChanged }
}
// Called when the window-tag style changes, so open tags can be
// rebuilt in the new style immediately rather than at the next
// launch/quit of a Claude window.
var onOverlayStyleChanged: (() -> Void)? {
didSet { settingsVC.onOverlayStyleChanged = onOverlayStyleChanged }
}
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered, defer: false)
window.title = "shannoncoat"
window.isReleasedWhenClosed = false
window.center()
profilesVC.title = "Profiles"
settingsVC.title = "Settings"
aboutVC.title = "About"
tabs.addTabViewItem(NSTabViewItem(viewController: profilesVC))
tabs.addTabViewItem(NSTabViewItem(viewController: settingsVC))
tabs.addTabViewItem(NSTabViewItem(viewController: aboutVC))
window.contentViewController = tabs
super.init(window: window)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show(tab: Tab? = nil) {
NSApp.activate(ignoringOtherApps: true)
if let tab {
tabs.selectedTabViewItemIndex = tab.rawValue
}
window?.makeKeyAndOrderFront(nil)
}
func update(_ infos: [ProfileInfo]) {
profilesVC.update(infos)
}
}
// MARK: - Profiles tab
private final class ProfilesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate,
NSTextFieldDelegate
{
var onChange: (() -> Void)?
private var infos: [ProfileInfo] = []
private let tableView = NSTableView()
private let addRemoveControl = NSSegmentedControl()
private let detailStack = NSStackView()
private let detailAppValue = NSTextField(labelWithString: "")
private let detailCodeValue = NSTextField(labelWithString: "")
private let detailAppCopyItem = NSMenuItem(title: "Copy Path", action: #selector(copyDetailPath(_:)), keyEquivalent: "")
private let detailCodeCopyItem = NSMenuItem(title: "Copy Path", action: #selector(copyDetailPath(_:)), keyEquivalent: "")
private let addForm = NSStackView()
private let nameField = NSTextField()
private let codeField = NSTextField()
private let appField = NSTextField()
private let errorLabel = NSTextField(labelWithString: "")
private var outerStack: NSStackView!
private var scrollHeightConstraint: NSLayoutConstraint!
private static let margin: CGFloat = 20
private static let tableWidth: CGFloat = 340
private static let minVisibleRows = 3
private static let maxVisibleRows = 8
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 260))
let column = NSTableColumn(identifier: .init("profile"))
column.title = "Profile"
column.width = 340
tableView.addTableColumn(column)
tableView.headerView = nil
tableView.dataSource = self
tableView.delegate = self
tableView.rowHeight = 24
tableView.usesAlternatingRowBackgroundColors = true
tableView.menu = buildContextMenu()
let scroll = NSScrollView()
scroll.documentView = tableView
scroll.hasVerticalScroller = true
scroll.borderType = .bezelBorder
scroll.translatesAutoresizingMaskIntoConstraints = false
addRemoveControl.segmentCount = 2
addRemoveControl.trackingMode = .momentary
addRemoveControl.segmentStyle = .smallSquare
addRemoveControl.setImage(NSImage(systemSymbolName: "plus", accessibilityDescription: "Add Profile"), forSegment: 0)
addRemoveControl.setImage(
NSImage(systemSymbolName: "minus", accessibilityDescription: "Remove Profile"), forSegment: 1)
addRemoveControl.setWidth(28, forSegment: 0)
addRemoveControl.setWidth(28, forSegment: 1)
addRemoveControl.setEnabled(false, forSegment: 1)
addRemoveControl.setToolTip("Add Profile", forSegment: 0)
addRemoveControl.setToolTip("Remove Profile", forSegment: 1)
addRemoveControl.target = self
addRemoveControl.action = #selector(addRemoveClicked(_:))
let buttonRow = NSStackView(views: [addRemoveControl, NSView()])
buttonRow.orientation = .horizontal
buttonRow.translatesAutoresizingMaskIntoConstraints = false
buildDetailStack()
detailStack.isHidden = true
detailStack.translatesAutoresizingMaskIntoConstraints = false
buildAddForm()
addForm.isHidden = true
addForm.translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [scroll, buttonRow, detailStack, addForm])
stack.orientation = .vertical
stack.spacing = 12
stack.edgeInsets = NSEdgeInsets(top: Self.margin, left: Self.margin, bottom: Self.margin, right: Self.margin)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
outerStack = stack
scrollHeightConstraint = scroll.heightAnchor.constraint(equalToConstant: heightForRows(0))
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
scroll.widthAnchor.constraint(equalToConstant: Self.tableWidth),
scrollHeightConstraint,
// `scroll` is the one arranged subview with a real, absolute
// width — everything else in this stack ties its width to
// *that*, not to `stack` itself (whose own frame spans
// edge-to-edge; edgeInsets only govern how the stack lays out
// its children, not its own bounds). Left unconstrained, these
// rows have no hard width anywhere in their constraint chain —
// NSStackView doesn't fill them to the inset content width by
// default, so they end up sized/positioned ambiguously (in
// practice: flush against the window edge, sometimes wider
// than the window).
buttonRow.widthAnchor.constraint(equalTo: scroll.widthAnchor),
detailStack.widthAnchor.constraint(equalTo: scroll.widthAnchor),
addForm.widthAnchor.constraint(equalTo: scroll.widthAnchor),
])
updatePreferredSize()
}
// Sized to the actual row count (clamped to a sensible range) rather
// than one fixed height — with only a couple of profiles that avoids a
// big block of empty striped background below the last row; with many
// it caps out and scrolls instead of taking over the window.
private func heightForRows(_ count: Int) -> CGFloat {
let rows = max(Self.minVisibleRows, min(count, Self.maxVisibleRows))
return CGFloat(rows) * tableView.rowHeight + 2
}
// NSTabViewController watches its selected child's preferredContentSize
// and resizes the window to match live — so recomputing this after
// toggling the add form or the path-details row is what makes the
// window grow/shrink to fit instead of sitting at one fixed size with
// empty space in the states that don't need it.
private func updatePreferredSize() {
view.layoutSubtreeIfNeeded()
preferredContentSize = outerStack.fittingSize
}
// A second, authoritative recompute after every real layout pass —
// explicit calls from action handlers (below) update it eagerly for
// responsiveness, but this is the one that can't be thrown off by
// metrics not being fully resolved yet at the point an eager call runs.
override func viewDidLayout() {
super.viewDidLayout()
preferredContentSize = outerStack.fittingSize
}
// Shows the selected profile's two dirs — the thing you'd otherwise
// have to right-click "Reveal" to find out. Not `.isSelectable` — that
// makes a label click-into-edit, and the field editor that appears
// draws the *untruncated* string at its full intrinsic width, which
// can extend past the window and get clipped by it rather than
// wrapping or scrolling cleanly. A tooltip (full path) plus a
// right-click "Copy Path" menu covers the same "get the value out"
// need without that.
private func buildDetailStack() {
detailAppCopyItem.target = self
detailCodeCopyItem.target = self
let appMenu = NSMenu()
appMenu.addItem(detailAppCopyItem)
let codeMenu = NSMenu()
codeMenu.addItem(detailCodeCopyItem)
for (value, menu) in [(detailAppValue, appMenu), (detailCodeValue, codeMenu)] {
value.textColor = .secondaryLabelColor
value.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
value.lineBreakMode = .byTruncatingMiddle
value.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
value.menu = menu
}
let appRow = NSStackView(views: [labeled("Claude Desktop:"), detailAppValue])
appRow.orientation = .horizontal
appRow.spacing = 8
let codeRow = NSStackView(views: [labeled("Claude Code:"), detailCodeValue])
codeRow.orientation = .horizontal
codeRow.spacing = 8
detailStack.orientation = .vertical
detailStack.alignment = .leading
detailStack.spacing = 4
[appRow, codeRow].forEach(detailStack.addArrangedSubview)
appRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: detailStack.widthAnchor).isActive = true
}
private func buildAddForm() {
nameField.placeholderString = "Profile name"
nameField.delegate = self
for field in [codeField, appField] {
field.lineBreakMode = .byTruncatingMiddle
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
codeField.placeholderString = "~/.shannoncoat/data/<name>/code"
let codeChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseCodeDir))
let codeRow = NSStackView(views: [labeled("Claude Code:"), codeField, codeChoose])
codeRow.orientation = .horizontal
codeRow.spacing = 8
appField.placeholderString = "~/.shannoncoat/data/<name>/app"
let appChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseAppDir))
let appRow = NSStackView(views: [labeled("Claude Desktop:"), appField, appChoose])
appRow.orientation = .horizontal
appRow.spacing = 8
errorLabel.textColor = .systemRed
errorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
errorLabel.maximumNumberOfLines = 0
errorLabel.lineBreakMode = .byWordWrapping
let createButton = NSButton(title: "Create", target: self, action: #selector(createProfile))
createButton.keyEquivalent = "\r"
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(toggleAddForm))
let formButtonRow = NSStackView(views: [NSView(), cancelButton, createButton])
formButtonRow.orientation = .horizontal
formButtonRow.spacing = 8
let divider = NSBox()
divider.boxType = .separator
addForm.orientation = .vertical
addForm.alignment = .leading
addForm.spacing = 10
[divider, nameField, codeRow, appRow, errorLabel, formButtonRow].forEach(addForm.addArrangedSubview)
divider.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
nameField.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
codeRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
appRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
formButtonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true
}
// Fixed-width, trailing-aligned label so the fields in a two-row form
// (differing label lengths: "Claude Code:" vs "Claude Desktop:") start
// at the same x position instead of a ragged left edge.
private func labeled(_ text: String) -> NSTextField {
let field = NSTextField(labelWithString: text)
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.textColor = .secondaryLabelColor
field.setContentHuggingPriority(.required, for: .horizontal)
field.alignment = .right
field.widthAnchor.constraint(equalToConstant: 104).isActive = true
return field
}
func update(_ infos: [ProfileInfo]) {
self.infos = infos
tableView.reloadData()
scrollHeightConstraint.constant = heightForRows(infos.count)
refreshForSelection()
}
private func refreshForSelection() {
let row = tableView.selectedRow
guard infos.indices.contains(row) else {
addRemoveControl.setEnabled(false, forSegment: 1)
detailStack.isHidden = true
updatePreferredSize()
return
}
let profile = infos[row].profile
addRemoveControl.setEnabled(profile.name != "default", forSegment: 1)
detailAppValue.stringValue = profile.appDir
detailAppValue.toolTip = profile.appDir
detailAppCopyItem.representedObject = profile.appDir
detailCodeValue.stringValue = profile.codeDir
detailCodeValue.toolTip = profile.codeDir
detailCodeCopyItem.representedObject = profile.codeDir
detailStack.isHidden = false
updatePreferredSize()
}
@objc private func copyDetailPath(_ sender: NSMenuItem) {
guard let path = sender.representedObject as? String else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(path, forType: .string)
}
// MARK: NSTableViewDataSource / Delegate
func numberOfRows(in tableView: NSTableView) -> Int { infos.count }
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("profileCell")
let info = infos[row]
let cell: NSTableCellView
if let reused = tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView {
cell = reused
} else {
cell = NSTableCellView()
cell.identifier = identifier
let imageView = NSImageView()
let textField = NSTextField(labelWithString: "")
imageView.translatesAutoresizingMaskIntoConstraints = false
textField.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(imageView)
cell.addSubview(textField)
cell.imageView = imageView
cell.textField = textField
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 6),
imageView.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
imageView.widthAnchor.constraint(equalToConstant: 10),
imageView.heightAnchor.constraint(equalToConstant: 10),
textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8),
textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -6),
textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
])
}
cell.imageView?.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
let text = NSMutableAttributedString(
string: info.profile.name, attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)])
text.append(NSAttributedString(
string: " \(info.running ? "Running" : "Stopped")",
attributes: [
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
.foregroundColor: NSColor.secondaryLabelColor,
]))
cell.textField?.attributedStringValue = text
return cell
}
func tableViewSelectionDidChange(_ notification: Notification) {
refreshForSelection()
}
// MARK: NSTextFieldDelegate — live default-path preview as the name is typed
func controlTextDidChange(_ obj: Notification) {
guard (obj.object as? NSTextField) === nameField else { return }
let name = nameField.stringValue.isEmpty ? "<name>" : nameField.stringValue
codeField.placeholderString = "~/.shannoncoat/data/\(name)/code"
appField.placeholderString = "~/.shannoncoat/data/\(name)/app"
}
// MARK: Actions
@objc private func addRemoveClicked(_ sender: NSSegmentedControl) {
switch sender.selectedSegment {
case 0: toggleAddForm()
case 1: removeSelected()
default: break
}
}
@objc private func toggleAddForm() {
addForm.isHidden.toggle()
if !addForm.isHidden {
nameField.stringValue = ""
codeField.stringValue = ""
appField.stringValue = ""
errorLabel.stringValue = ""
view.window?.makeFirstResponder(nameField)
}
updatePreferredSize()
}
@objc private func chooseCodeDir() { choose(into: codeField) }
@objc private func chooseAppDir() { choose(into: appField) }
private func choose(into field: NSTextField) {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
guard let window = view.window else { return }
panel.beginSheetModal(for: window) { response in
if response == .OK, let url = panel.url {
field.stringValue = url.path
}
}
}
@objc private func createProfile() {
let name = nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
let code = codeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
let app = appField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
do {
try ProfileStore.create(name: name, codeDir: code.isEmpty ? nil : code, appDir: app.isEmpty ? nil : app)
toggleAddForm()
onChange?()
} catch {
errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)"
updatePreferredSize()
}
}
@objc private func removeSelected() {
let row = tableView.selectedRow
guard infos.indices.contains(row) else { return }
let info = infos[row]
guard info.profile.name != "default", let window = view.window else { return }
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Delete profile \u{201C}\(info.profile.name)\u{201D}?"
alert.informativeText = info.running
? "This profile is currently running \u{2014} deleting it will quit Claude first. Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed."
: "Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed."
alert.addButton(withTitle: "Delete")
alert.addButton(withTitle: "Cancel")
alert.buttons.first?.hasDestructiveAction = true
alert.beginSheetModal(for: window) { [weak self] response in
guard response == .alertFirstButtonReturn else { return }
let finish = {
try? ProfileStore.delete(info.profile.name)
self?.onChange?()
}
if info.running, let pid = info.pid {
ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async(execute: finish) }
} else {
finish()
}
}
}
private func buildContextMenu() -> NSMenu {
let menu = NSMenu()
let revealCode = NSMenuItem(title: "Reveal Code Folder", action: #selector(revealCodeDir), keyEquivalent: "")
revealCode.target = self
let revealApp = NSMenuItem(
title: "Reveal Desktop Folder", action: #selector(revealAppDir), keyEquivalent: "")
revealApp.target = self
menu.addItem(revealCode)
menu.addItem(revealApp)
return menu
}
private func clickedInfo() -> ProfileInfo? {
let row = tableView.clickedRow
guard infos.indices.contains(row) else { return nil }
return infos[row]
}
@objc private func revealCodeDir() {
guard let info = clickedInfo() else { return }
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.codeDir)
}
@objc private func revealAppDir() {
guard let info = clickedInfo() else { return }
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.appDir)
}
}
// MARK: - Settings tab
private final class SettingsViewController: NSViewController {
private let autoUpdateCheckbox = NSButton(
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil)
private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil)
private let tagStyleLabel = NSTextField(labelWithString: "Window tag:")
private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var tagStyleRow: NSStackView!
private var stack: NSStackView!
var onOverlayStyleChanged: (() -> Void)?
// Order matches the popup's items, so the selected index maps straight
// onto a style without a title-string comparison.
private static let tagStyles: [(title: String, style: WindowOverlayStyle)] = [
("Name chip", .chip),
("Colored dot", .dot),
]
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
tagStylePopUp.addItems(withTitles: Self.tagStyles.map(\.title))
tagStylePopUp.selectItem(at: Self.tagStyles.firstIndex { $0.style == WindowOverlayPosition.style } ?? 0)
tagStylePopUp.target = self
tagStylePopUp.action = #selector(changeTagStyle)
tagStyleRow = NSStackView(views: [tagStyleLabel, tagStylePopUp])
tagStyleRow.orientation = .horizontal
tagStyleRow.spacing = 8
launchAtLoginCheckbox.target = self
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
autoUpdateCheckbox.target = self
autoUpdateCheckbox.action = #selector(toggleAutoUpdate)
autoUpdateCheckbox.state = UpdateChecker.automaticCheckEnabled ? .on : .off
checkNowButton.target = self
checkNowButton.action = #selector(checkNow)
checkNowButton.bezelStyle = .rounded
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 14
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
updatePreferredSize()
}
override func viewDidLayout() {
super.viewDidLayout()
updatePreferredSize()
}
// NOT `stack.fittingSize` for the width — that was measured observing
// it clip a checkbox's label by a consistent amount regardless of
// when it's read (loadView vs. viewDidLayout: same result), so this
// isn't a measure-too-early timing issue, it's `NSStackView.fittingSize`
// itself undercounting a checkbox's true rendered width. Asking each
// control directly for its own `intrinsicContentSize` instead — which
// 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
// 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
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
}
@objc private func changeTagStyle() {
let index = tagStylePopUp.indexOfSelectedItem
guard Self.tagStyles.indices.contains(index) else { return }
WindowOverlayPosition.style = Self.tagStyles[index].style
onOverlayStyleChanged?()
}
@objc private func toggleLaunchAtLogin() {
do {
if launchAtLoginCheckbox.state == .on {
try LaunchAtLogin.enable()
} else {
try LaunchAtLogin.disable()
}
} catch {
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
NSAlert(error: error).runModal()
}
}
@objc private func toggleAutoUpdate() {
UpdateChecker.automaticCheckEnabled = autoUpdateCheckbox.state == .on
}
@objc private func checkNow() {
UpdateChecker.checkManually { result in
DispatchQueue.main.async {
let alert = NSAlert()
switch result {
case .upToDate:
alert.messageText = "You're up to date."
alert.runModal()
case .updateAvailable(let version, let url):
alert.messageText = "shannoncoat \(version) is available"
alert.addButton(withTitle: "View Release")
alert.addButton(withTitle: "Later")
if alert.runModal() == .alertFirstButtonReturn {
NSWorkspace.shared.open(url)
}
case .failed:
alert.alertStyle = .warning
alert.messageText = "Couldn't check for updates."
alert.runModal()
}
}
}
}
}
// MARK: - About tab
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
private final class AboutViewController: NSViewController {
private static let contentWidth: CGFloat = 340
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
// Centered within a full-width wrapper rather than centered in the
// stack directly — the stack is leading-aligned (for the text
// rows below), and its own per-arranged-subview alignment
// constraint on the icon would conflict with an added centering
// constraint on the icon itself.
let icon = NSImageView()
icon.image = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName)
icon.translatesAutoresizingMaskIntoConstraints = false
let iconContainer = NSView()
iconContainer.translatesAutoresizingMaskIntoConstraints = false
iconContainer.addSubview(icon)
NSLayoutConstraint.activate([
icon.widthAnchor.constraint(equalToConstant: 96),
icon.heightAnchor.constraint(equalToConstant: 96),
icon.topAnchor.constraint(equalTo: iconContainer.topAnchor),
icon.bottomAnchor.constraint(equalTo: iconContainer.bottomAnchor),
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:
"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 sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
for field in [sourceField, versionField] {
field.textColor = .secondaryLabelColor
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
}
let stack = NSStackView(views: [iconContainer, taglineContainer, description,
sourceField, versionField])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 10
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
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()
preferredContentSize = NSSize(width: Self.contentWidth + 40, height: stack.fittingSize.height)
}
}
-82
View File
@@ -1,82 +0,0 @@
// 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
}
}
-86
View File
@@ -1,86 +0,0 @@
// Enumerates running Claude processes and their --user-data-dir, without
// spawning pgrep (avoids both the subprocess-per-check overhead and an
// assumption that /usr/bin/pgrep exists at a fixed path). Uses the same
// sysctl(KERN_PROC_ALL)/sysctl(KERN_PROCARGS2) technique ps/pgrep use
// internally for same-user processes — no extra entitlement needed.
//
// Called on demand from live-state notification handlers (see AppDelegate),
// never on a timer.
import Darwin
import Foundation
struct RunningClaude {
let pid: pid_t
// nil means launched with no --user-data-dir flag at all (i.e. the
// "default" profile).
let userDataDir: String?
}
enum ProcessInspector {
static func listRunningClaude(binaryPath: String) -> [RunningClaude] {
allPIDs().compactMap { pid in
guard let info = execInfo(pid: pid), info.execPath == binaryPath else { return nil }
let flagPrefix = "--user-data-dir="
let dir = info.args.first(where: { $0.hasPrefix(flagPrefix) }).map { String($0.dropFirst(flagPrefix.count)) }
return RunningClaude(pid: pid, userDataDir: dir)
}
}
static func pid(forUserDataDir dir: String?, binaryPath: String) -> pid_t? {
listRunningClaude(binaryPath: binaryPath).first { $0.userDataDir == dir }?.pid
}
private static func allPIDs() -> [pid_t] {
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
var size = 0
guard sysctl(&mib, 4, nil, &size, nil, 0) == 0, size > 0 else { return [] }
let capacity = size / MemoryLayout<kinfo_proc>.stride + 1
var procs = [kinfo_proc](repeating: kinfo_proc(), count: capacity)
var actualSize = capacity * MemoryLayout<kinfo_proc>.stride
guard sysctl(&mib, 4, &procs, &actualSize, nil, 0) == 0 else { return [] }
let actualCount = actualSize / MemoryLayout<kinfo_proc>.stride
return procs[0..<actualCount].map { $0.kp_proc.p_pid }
}
private struct ExecInfo { let execPath: String; let args: [String] }
// KERN_PROCARGS2 buffer layout: argc (Int32), then the exec path
// (NUL-terminated, followed by NUL padding), then argv[0..<argc] each
// NUL-terminated, then envp (which we don't read). Same layout `ps`
// itself parses.
private static func execInfo(pid: pid_t) -> ExecInfo? {
var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid]
var size = 0
guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > MemoryLayout<Int32>.size else { return nil }
var buffer = [UInt8](repeating: 0, count: size)
guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return nil }
var argc = Int32(0)
withUnsafeMutableBytes(of: &argc) { dst in
dst.copyBytes(from: buffer[0..<MemoryLayout<Int32>.size])
}
var offset = MemoryLayout<Int32>.size
func readCString() -> String? {
guard offset < size else { return nil }
let start = offset
while offset < size, buffer[offset] != 0 { offset += 1 }
guard offset > start else { return nil }
let string = String(decoding: buffer[start..<offset], as: UTF8.self)
while offset < size, buffer[offset] == 0 { offset += 1 } // skip padding NULs
return string
}
guard let execPath = readCString() else { return nil }
var args: [String] = []
var i: Int32 = 0
while i < argc, let arg = readCString() {
args.append(arg)
i += 1
}
return ExecInfo(execPath: execPath, args: args)
}
}
-72
View File
@@ -1,72 +0,0 @@
// A stable, well-distributed color per profile name, shared by the menu
// bar dots and the Manage window's profile list so the same name always
// reads as the same color everywhere.
import AppKit
enum ProfileColor {
// FNV-1a: a proper avalanching hash, not a preset-palette lookup — the
// full hue wheel is available, and single-character name edits (e.g.
// "personal" vs "personal2") land on unrelated hues instead of an
// adjacent bucket the way a plain sum-of-scalars hash mod'd into a
// short color list would.
private static func fnv1aHash(_ s: String) -> UInt32 {
var hash: UInt32 = 0x811c_9dc5
for byte in s.utf8 {
hash ^= UInt32(byte)
hash = hash &* 0x0100_0193
}
return hash
}
// A brightness/saturation pair that reads clearly on its own
// background — a color bright enough to pop on a dark menu would wash
// out on a light one, and vice versa, so this picks per current
// appearance rather than using one fixed value for both.
private static func isDarkAppearance() -> Bool {
NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
static func dotColor(for name: String) -> NSColor {
let hue = CGFloat(fnv1aHash(name) % 360) / 360.0
let (saturation, brightness): (CGFloat, CGFloat) = isDarkAppearance() ? (0.90, 1.0) : (1.0, 0.55)
return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
}
// Light or dark text, whichever reads better on the given background.
// Profile colours come off the full hue wheel (see `dotColor`), whose
// luminance varies enormously — yellow against white text is barely
// legible where indigo against black text is worse — so this can't be
// one fixed choice.
//
// Chooses on WCAG relative luminance rather than raw brightness,
// because the eye is roughly six times more sensitive to green than to
// blue and an unweighted average badly misjudges both. 0.179 is where
// contrast against white and against black come out equal, so it's the
// threshold that maximises contrast either way. Near-white and
// near-black rather than pure, which reads as less harsh at 10pt
// without measurably costing contrast.
static func contrastingTextColor(on background: NSColor) -> NSColor {
guard let rgb = background.usingColorSpace(.sRGB) else { return .white }
func linear(_ channel: CGFloat) -> CGFloat {
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
let luminance = 0.2126 * linear(rgb.redComponent)
+ 0.7152 * linear(rgb.greenComponent)
+ 0.0722 * linear(rgb.blueComponent)
return luminance > 0.179 ? NSColor(white: 0.10, alpha: 1) : NSColor(white: 0.98, alpha: 1)
}
// `dimmed` marks a profile that isn't actually running right now — the
// colored dot still identifies it, but faded, so "open" vs "known but
// closed" is visible without reading a tooltip.
static func dotImage(for name: String, dimmed: Bool = false) -> NSImage {
let size = NSSize(width: 10, height: 10)
let image = NSImage(size: size)
image.lockFocus()
dotColor(for: name).withAlphaComponent(dimmed ? 0.3 : 1.0).setFill()
NSBezierPath(ovalIn: NSRect(origin: .zero, size: size)).fill()
image.unlockFocus()
image.isTemplate = false
return image
}
}
-180
View File
@@ -1,180 +0,0 @@
// Profile config storage: one ~/.shannoncoat/<name>.json per profile,
// holding the two dirs a profile needs — Claude Desktop's --user-data-dir,
// and the CLAUDE_CONFIG_DIR paired with it so any Claude Code session
// launched from that Desktop instance gets its own isolated config too,
// instead of sharing one with every other profile. (Claude Code already
// has its own profile mechanism for use outside Desktop — this isn't a
// second one, just making sure a managed Desktop session doesn't leak
// into it.) "default" is implicit — no file for it — and always means the
// real, untouched ~/.claude + Application Support/Claude.
import Foundation
struct ResolvedProfile: Equatable {
let name: String
let codeDir: String
let appDir: String
}
enum ProfileError: LocalizedError {
case emptyName
case reservedName
case invalidName(String)
case alreadyExists(String)
case notFound(String)
case dirCollision(existingProfile: String, dir: String, kind: String)
var errorDescription: String? {
switch self {
case .emptyName:
return "Name is required."
case .reservedName:
return "\"default\" is reserved for the real, untouched Claude install."
case .invalidName(let name):
return "\"\(name)\" isn't a valid profile name (no spaces, slashes, colons, or leading dots)."
case .alreadyExists(let name):
return "A profile named \"\(name)\" already exists."
case .notFound(let name):
return "No profile named \"\(name)\"."
case .dirCollision(let existingProfile, let dir, let kind):
return "\(kind) dir \(dir) is already used by profile \"\(existingProfile)\"."
}
}
}
// What lives on disk. Profile name comes from the filename, not a field in
// here, so there's only one place a profile's name can disagree with
// itself. `paths` is nested (matching the old YAML's shape) rather than
// flattened, so a later profile-specific feature has an obvious place to
// add its own top-level key alongside `paths` without disturbing this one.
private struct ProfileFile: Codable {
var paths: Paths
struct Paths: Codable {
var code: String
var desktop: String
}
}
enum ProfileStore {
static let root: URL = {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".shannoncoat")
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}()
static let defaultProfile = ResolvedProfile(
name: "default",
codeDir: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude").path,
appDir: FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/Claude").path
)
// "~" expands to home; anything else non-absolute resolves relative to
// `root` (the config dir itself) rather than the process's cwd; an
// already-absolute path is left alone.
static func expand(_ raw: String) -> String {
if raw.hasPrefix("~") {
return (raw as NSString).expandingTildeInPath
}
if raw.hasPrefix("/") {
return raw
}
return root.appendingPathComponent(raw).path
}
static func validateName(_ name: String) throws {
if name.isEmpty { throw ProfileError.emptyName }
if name == "default" { throw ProfileError.reservedName }
let hasBadChar = name.contains("/") || name.contains(":") || name.hasPrefix(".")
|| name.rangeOfCharacter(from: .whitespacesAndNewlines) != nil
if hasBadChar { throw ProfileError.invalidName(name) }
}
private static func configURL(for name: String) -> URL {
root.appendingPathComponent("\(name).json")
}
// Every configured profile plus the implicit `default`, in no
// particular order. Config files that fail to decode are skipped
// rather than surfaced here — a hand-edited-into-garbage file
// shouldn't take the whole menu down.
static func loadAll() -> [ResolvedProfile] {
var profiles = [defaultProfile]
let files = (try? FileManager.default.contentsOfDirectory(
at: root, includingPropertiesForKeys: nil)) ?? []
for file in files where file.pathExtension == "json" {
guard let data = try? Data(contentsOf: file),
let profileFile = try? JSONDecoder().decode(ProfileFile.self, from: data)
else { continue }
let name = file.deletingPathExtension().lastPathComponent
profiles.append(ResolvedProfile(
name: name, codeDir: expand(profileFile.paths.code), appDir: expand(profileFile.paths.desktop)))
}
return profiles
}
struct DirCollision { let profileA: String; let profileB: String; let dir: String; let kind: String }
// Two profiles secretly sharing a dir would silently merge their Claude
// sessions — defeats the entire point of switching. Checked on every
// load (see `loadAll` call sites), not just when a profile is created,
// so a hand-edited file can't sneak one in unnoticed.
static func findCollisions(among profiles: [ResolvedProfile]) -> [DirCollision] {
var collisions: [DirCollision] = []
for i in profiles.indices {
for j in profiles.index(after: i)..<profiles.endIndex {
if profiles[i].codeDir == profiles[j].codeDir {
collisions.append(DirCollision(
profileA: profiles[i].name, profileB: profiles[j].name,
dir: profiles[i].codeDir, kind: "Claude Code"))
}
if profiles[i].appDir == profiles[j].appDir {
collisions.append(DirCollision(
profileA: profiles[i].name, profileB: profiles[j].name,
dir: profiles[i].appDir, kind: "Claude Desktop"))
}
}
}
return collisions
}
@discardableResult
static func create(name: String, codeDir: String?, appDir: String?) throws -> ResolvedProfile {
try validateName(name)
let url = configURL(for: name)
guard !FileManager.default.fileExists(atPath: url.path) else {
throw ProfileError.alreadyExists(name)
}
let rawCode = codeDir?.isEmpty == false ? codeDir! : "data/\(name)/code"
let rawApp = appDir?.isEmpty == false ? appDir! : "data/\(name)/app"
let resolvedCode = expand(rawCode)
let resolvedApp = expand(rawApp)
for existing in loadAll() {
if existing.codeDir == resolvedCode {
throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedCode, kind: "Claude Code")
}
if existing.appDir == resolvedApp {
throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedApp, kind: "Claude Desktop")
}
}
try FileManager.default.createDirectory(atPath: resolvedCode, withIntermediateDirectories: true)
try FileManager.default.createDirectory(atPath: resolvedApp, withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(ProfileFile(paths: .init(code: rawCode, desktop: rawApp)))
try data.write(to: url)
return ResolvedProfile(name: name, codeDir: resolvedCode, appDir: resolvedApp)
}
static func delete(_ name: String) throws {
guard name != "default" else { throw ProfileError.reservedName }
let url = configURL(for: name)
guard FileManager.default.fileExists(atPath: url.path) else { throw ProfileError.notFound(name) }
try FileManager.default.removeItem(at: url)
}
}
-105
View File
@@ -1,105 +0,0 @@
// Deliberately minimal: fetch the raw VERSION file from `main` on GitHub
// and compare semver to the bundled version. No silent binary-replacing
// auto-update — that needs signing/notarization/an appcast server this
// project doesn't have. A manual check shows an alert (an explicit action
// that expects a response); the periodic automatic check instead posts a
// quiet system notification, since that one wasn't asked for in the
// moment.
import Foundation
import UserNotifications
enum UpdateChecker {
enum Outcome {
case upToDate
case updateAvailable(version: String, url: URL)
case failed
}
private static let versionURL = URL(string: "https://raw.githubusercontent.com/bdeshi/shannoncoat/main/VERSION")!
private static let releaseURL = URL(string: "https://github.com/bdeshi/shannoncoat/releases/latest")!
private static let enabledDefaultsKey = "AutomaticUpdateCheckEnabled"
private static let lastCheckDefaultsKey = "LastUpdateCheckDate"
private static let checkInterval: TimeInterval = 24 * 60 * 60
static var automaticCheckEnabled: Bool {
get { UserDefaults.standard.object(forKey: enabledDefaultsKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: enabledDefaultsKey) }
}
static var currentVersion: String {
guard let resourceURL = Bundle.main.resourceURL,
let contents = try? String(contentsOf: resourceURL.appendingPathComponent("VERSION"), encoding: .utf8)
else { return "unknown" }
return contents.trimmingCharacters(in: .whitespacesAndNewlines)
}
// What the About panel shows: the version alone for a real release
// build (COMMIT is written empty when build.sh finds HEAD sitting
// exactly on a tag), or version + short SHA for a dev build, so two
// local builds off the same version number are distinguishable.
static var displayVersion: String {
commitSHA.isEmpty ? currentVersion : "\(currentVersion) (\(commitSHA))"
}
private static var commitSHA: String {
guard let resourceURL = Bundle.main.resourceURL,
let contents = try? String(contentsOf: resourceURL.appendingPathComponent("COMMIT"), encoding: .utf8)
else { return "" }
return contents.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func checkManually(completion: @escaping (Outcome) -> Void) {
fetchLatestVersion { latest in
guard let latest else { completion(.failed); return }
if isNewer(latest, than: currentVersion) {
completion(.updateAvailable(version: latest, url: releaseURL))
} else {
completion(.upToDate)
}
}
}
// Called once at launch; a no-op unless the toggle is on and it's been
// at least a day since the last check.
static func checkAutomaticallyIfDue() {
guard automaticCheckEnabled else { return }
let last = UserDefaults.standard.object(forKey: lastCheckDefaultsKey) as? Date ?? .distantPast
guard Date().timeIntervalSince(last) > checkInterval else { return }
UserDefaults.standard.set(Date(), forKey: lastCheckDefaultsKey)
fetchLatestVersion { latest in
guard let latest, isNewer(latest, than: currentVersion) else { return }
postAvailableNotification(version: latest)
}
}
private static func fetchLatestVersion(completion: @escaping (String?) -> Void) {
URLSession.shared.dataTask(with: versionURL) { data, _, _ in
let version = data.flatMap { String(data: $0, encoding: .utf8) }?
.trimmingCharacters(in: .whitespacesAndNewlines)
completion((version?.isEmpty == false) ? version : nil)
}.resume()
}
private static func isNewer(_ a: String, than b: String) -> Bool {
let av = a.split(separator: ".").compactMap { Int($0) }
let bv = b.split(separator: ".").compactMap { Int($0) }
for i in 0..<max(av.count, bv.count) {
let x = i < av.count ? av[i] : 0
let y = i < bv.count ? bv[i] : 0
if x != y { return x > y }
}
return false
}
private static func postAvailableNotification(version: String) {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert]) { granted, _ in
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = "shannoncoat \(version) is available"
content.body = "Click to view the release notes."
center.add(UNNotificationRequest(identifier: "shannoncoat-update-available", content: content, trigger: nil))
}
}
}
-604
View File
@@ -1,604 +0,0 @@
// A small floating tag pinned to a Claude window's corner, showing which
// profile it belongs to — every profile launches the same app with the
// same icon and title, so there's otherwise no way to tell two open
// Claude windows apart at a glance. Kept in sync live via AXObserver
// notifications (move/resize/miniaturize/destroy) rather than polling —
// only a persistent process can hold a watcher like this at all, which is
// exactly the gap the old one-shot CLI script couldn't close.
import AppKit
import ApplicationServices
// How the tag is drawn. A dot is for when the colour alone already
// identifies the window and the name is just clutter; it's never rotated,
// having no reading direction to preserve.
enum WindowOverlayStyle: String {
case chip, dot
}
// The window corner a tag is anchored to. Anchoring to the *nearest*
// corner rather than always the top-right is what holds a tag still
// through a resize: one parked near the bottom-left tracks that corner,
// so dragging the top-right handle no longer drags the tag with it.
enum WindowOverlayCorner: String {
case topLeft, topRight, bottomLeft, bottomRight
var isTop: Bool { self == .topLeft || self == .topRight }
var isLeft: Bool { self == .topLeft || self == .bottomLeft }
// Quadrant test rather than four distance comparisons — same answer,
// and it stays well-defined for a tag sitting dead centre.
static func nearest(to point: NSPoint, in frame: NSRect) -> WindowOverlayCorner {
switch (point.y > frame.midY, point.x < frame.midX) {
case (true, true): return .topLeft
case (true, false): return .topRight
case (false, true): return .bottomLeft
case (false, false): return .bottomRight
}
}
}
// Where one profile's tag sits on its window, persisted so a drag survives
// a relaunch. Stored per profile rather than once for everything: two
// windows side by side are exactly the case these tags exist for, and
// wanting each one's tag somewhere different is the normal outcome — one
// window's sidebar is not another's.
//
// Offsets are stored as the user dropped them and are never written back
// clamped. A window too small to honour the full offset shows the tag
// pushed in as far as it fits (see `WindowOverlay.frame(window:screen:)`), but the
// stored distance is left intact, so widening the window again restores
// the chosen spot instead of leaving the tag stuck where a temporary
// resize squeezed it.
struct WindowOverlayPlacement {
// Top-right by default — top-left is where every window's close/
// minimize/zoom controls live, which the tag must never sit over.
var corner: WindowOverlayCorner = .topRight
var insetX: CGFloat = 8
var insetY: CGFloat = 6
var isVertical: Bool = false
private static func key(_ field: String, _ profile: String) -> String {
"WindowOverlay\(field).\(profile)"
}
// From the version that kept a single shared offset. Read as a seed for
// a profile with nothing stored yet, so an existing tag stays where it
// already is instead of jumping on the first launch after upgrading.
private static let legacyInsetXKey = "WindowOverlayRightInset"
private static let legacyInsetYKey = "WindowOverlayTopInset"
private static func number(_ defaults: UserDefaults, _ keys: String...) -> CGFloat? {
for key in keys {
if let value = defaults.object(forKey: key) as? Double { return CGFloat(value) }
}
return nil
}
static func load(for profile: String) -> WindowOverlayPlacement {
let defaults = UserDefaults.standard
var placement = WindowOverlayPlacement()
if let raw = defaults.string(forKey: key("Corner", profile)),
let corner = WindowOverlayCorner(rawValue: raw) {
placement.corner = corner
}
if let x = number(defaults, key("InsetX", profile), legacyInsetXKey) { placement.insetX = x }
if let y = number(defaults, key("InsetY", profile), legacyInsetYKey) { placement.insetY = y }
placement.isVertical = defaults.bool(forKey: key("Vertical", profile))
return placement
}
func save(for profile: String) {
let defaults = UserDefaults.standard
defaults.set(corner.rawValue, forKey: Self.key("Corner", profile))
defaults.set(Double(insetX), forKey: Self.key("InsetX", profile))
defaults.set(Double(insetY), forKey: Self.key("InsetY", profile))
defaults.set(isVertical, forKey: Self.key("Vertical", profile))
}
}
// Chip or dot. Unlike placement this really is one setting for the whole
// app — it's a single control in Settings, not something chosen per window.
enum WindowOverlayPosition {
private static let styleKey = "WindowOverlayStyle"
static var style: WindowOverlayStyle {
get {
(UserDefaults.standard.string(forKey: styleKey)).flatMap(WindowOverlayStyle.init(rawValue:)) ?? .chip
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: styleKey) }
}
}
// Draws the tag itself. Custom drawing rather than a laid-out NSTextField
// because both of the things this has to get right — centring the text on
// the chip's actual midline, and rotating the whole chip when it's parked
// against a side edge — are a transform and two draw calls here, versus
// fighting a label's intrinsic baseline placement and its unrotatable
// frame.
private final class TagView: NSView {
var text: String
var color: NSColor
var style: WindowOverlayStyle
var isVertical: Bool
// Which side edge a vertical chip is against, which decides the way it
// reads: bottom-to-top on the left, top-to-bottom on the right, so the
// text always leans into the window rather than away from it. Ignored
// when horizontal.
var isLeftSide: Bool
static let thickness: CGFloat = 13
static let dotDiameter: CGFloat = 11
private static let horizontalPadding: CGFloat = 7
private static let maxLength: CGFloat = 140
static let font = NSFont.systemFont(ofSize: 10, weight: .semibold)
init(text: String, color: NSColor, style: WindowOverlayStyle, isVertical: Bool, isLeftSide: Bool) {
self.text = text
self.color = color
self.style = style
self.isVertical = isVertical
self.isLeftSide = isLeftSide
super.init(frame: .zero)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
// The chip's extent along its reading direction, before any rotation.
private static func chipLength(for text: String) -> CGFloat {
let width = (text as NSString).size(withAttributes: [.font: font]).width
return min(maxLength, (width + horizontalPadding * 2).rounded(.up))
}
// Rotation is a property of the drawing, not of the panel, so the
// panel's own width and height swap over for a vertical chip.
static func size(text: String, style: WindowOverlayStyle, isVertical: Bool) -> NSSize {
switch style {
case .dot:
return NSSize(width: dotDiameter, height: dotDiameter)
case .chip:
let length = chipLength(for: text)
return isVertical
? NSSize(width: thickness, height: length)
: NSSize(width: length, height: thickness)
}
}
override func draw(_ dirtyRect: NSRect) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
if style == .dot {
color.setFill()
NSBezierPath(ovalIn: bounds).fill()
return
}
ctx.saveGState()
defer { ctx.restoreGState() }
// Lay the chip out once, horizontally, around the origin — then
// rotate the whole coordinate space if it belongs on a side edge.
// One layout path serves both orientations, so the vertical case
// can't drift out of step with the horizontal one.
ctx.translateBy(x: bounds.midX, y: bounds.midY)
if isVertical { ctx.rotate(by: isLeftSide ? .pi / 2 : -.pi / 2) }
let length = Self.chipLength(for: text)
let rect = NSRect(
x: -length / 2, y: -Self.thickness / 2, width: length, height: Self.thickness)
color.setFill()
NSBezierPath(roundedRect: rect, xRadius: Self.thickness / 2, yRadius: Self.thickness / 2).fill()
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = .center
paragraph.lineBreakMode = .byTruncatingTail
let attributes: [NSAttributedString.Key: Any] = [
.font: Self.font,
.foregroundColor: ProfileColor.contrastingTextColor(on: color),
.paragraphStyle: paragraph,
]
let attributed = NSAttributedString(string: text, attributes: attributes)
// Drawn into a rect exactly one line tall and centred on the
// chip's midline. Handing it the chip's full height instead would
// top-align the text inside it — the reason the old label sat
// high rather than centred.
let lineHeight = attributed.size().height
attributed.draw(in: NSRect(
x: rect.minX + Self.horizontalPadding, y: rect.midY - lineHeight / 2,
width: rect.width - Self.horizontalPadding * 2, height: lineHeight))
}
}
final class WindowOverlay: NSObject, NSWindowDelegate {
let pid: pid_t
private let axWindow: AXUIElement
private var observer: AXObserver?
private let panel: NSPanel
private let tagView: TagView
private let profileName: String
private var placement: WindowOverlayPlacement
// True only while `reposition` is moving the panel itself — see
// `windowDidMove`, which must ignore those moves.
private var isRepositioning = false
private var dragSettle: DispatchWorkItem?
private var isMinimized = false
init?(profileName: String, color: NSColor, pid: pid_t) {
let axApp = AXUIElementCreateApplication(pid)
guard let window = Self.firstWindow(of: axApp) else { return nil }
self.pid = pid
self.axWindow = window
self.profileName = profileName
let placement = WindowOverlayPlacement.load(for: profileName)
self.placement = placement
let style = WindowOverlayPosition.style
let tagView = TagView(
text: profileName, color: color, style: style,
isVertical: style == .chip && placement.isVertical,
isLeftSide: placement.corner.isLeft)
tagView.autoresizingMask = [.width, .height]
self.tagView = tagView
let panel = NSPanel(
contentRect: NSRect(origin: .zero, size: NSSize(width: 1, height: 1)),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .floating
// Draggable (so it can be moved off whatever it's obstructing),
// but .nonactivatingPanel keeps a click/drag from stealing focus
// away from the Claude window underneath.
panel.isMovableByWindowBackground = true
panel.collectionBehavior = [.stationary, .ignoresCycle]
panel.contentView = tagView
self.panel = panel
super.init()
panel.delegate = self
guard reposition() else { return nil }
startObserving()
}
// A cold Electron launch can take a second or more between the
// process starting (which is what triggers `updateWindowOverlays`,
// via NSWorkspace's launch notification) and its first window
// actually existing — the same race ClaudeControl.focus's
// waitForWindow already handles for the same reason. `init?` can't
// retry on its own (once it returns nil, that attempt is done), so
// this polls it every quarter second for up to ~3s before giving up.
static func attach(
profileName: String, color: NSColor, pid: pid_t, attemptsRemaining: Int = 12,
completion: @escaping (WindowOverlay?) -> Void
) {
if let overlay = WindowOverlay(profileName: profileName, color: color, pid: pid) {
completion(overlay)
} else if attemptsRemaining > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
attach(
profileName: profileName, color: color, pid: pid, attemptsRemaining: attemptsRemaining - 1,
completion: completion)
}
} else {
completion(nil)
}
}
deinit {
dragSettle?.cancel()
stopObserving()
panel.orderOut(nil)
}
private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
let windows = value as? [AXUIElement], let first = windows.first
else { return nil }
return first
}
private static func frame(of window: AXUIElement) -> CGRect? {
var posValue: CFTypeRef?
var sizeValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posValue) == .success,
AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeValue) == .success,
CFGetTypeID(posValue) == AXValueGetTypeID(), CFGetTypeID(sizeValue) == AXValueGetTypeID()
else { return nil }
var point = CGPoint.zero
var size = CGSize.zero
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &point),
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
else { return nil }
return CGRect(origin: point, size: size)
}
private static func screen(containing axFrame: CGRect) -> NSScreen? {
NSScreen.screens.first { $0.frame.minX <= axFrame.midX && axFrame.midX <= $0.frame.maxX } ?? NSScreen.main
}
// AX coordinates are top-left-origin (screen top = y 0); AppKit screen
// coordinates are bottom-left-origin. Returns the window's frame
// translated into AppKit's space.
private static func appKitFrame(of axFrame: CGRect, on screen: NSScreen) -> NSRect {
NSRect(x: axFrame.origin.x, y: screen.frame.maxY - axFrame.origin.y - axFrame.height,
width: axFrame.width, height: axFrame.height)
}
private var currentGeometry: (window: NSRect, screen: NSScreen)? {
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame)
else { return nil }
return (Self.appKitFrame(of: axFrame, on: screen), screen)
}
// The tag's frame for a given window frame: measured inward from its
// anchored corner, with the offsets clamped so it always stays over
// the window no matter how small that window gets. Only the *drawn*
// position is clamped — see WindowOverlayPlacement.
//
// Then clamped again, into whatever part of the window is actually on
// screen. A window dragged half off the edge takes its anchored corner
// with it, and a tag that follows it out of view identifies nothing —
// the whole point is telling, at a glance, which profile the window you
// can still see belongs to. Clamped to the window's *visible* portion
// rather than to the screen at large so the tag stays on the thing it
// labels instead of drifting off onto its own. `visibleFrame` keeps it
// clear of the menu bar and Dock, which would hide it just as
// effectively as the screen edge.
//
// Both clamps are presentational only — neither is written back to
// `placement`, which changes solely when the user drags the tag. So the
// anchor is remembered throughout, and the tag returns to it the moment
// the window is fully back on screen.
private func frame(window: NSRect, screen: NSScreen) -> NSRect {
let style = WindowOverlayPosition.style
let size = TagView.size(
text: profileName, style: style,
isVertical: style == .chip && placement.isVertical)
let corner = placement.corner
let insetX = placement.insetX
.clamped(to: 0...max(0, window.width - size.width))
let insetY = placement.insetY
.clamped(to: 0...max(0, window.height - size.height))
var origin = NSPoint(
x: corner.isLeft ? window.minX + insetX : window.maxX - insetX - size.width,
y: corner.isTop ? window.maxY - insetY - size.height : window.minY + insetY)
// A window entirely off screen leaves nothing to clamp into; fall
// back to the screen so the tag stays reachable rather than being
// pinned to an empty rect.
let onScreen = window.intersection(screen.visibleFrame)
let bounds = onScreen.isEmpty ? screen.visibleFrame : onScreen
origin.x = origin.x.clamped(to: bounds.minX...max(bounds.minX, bounds.maxX - size.width))
origin.y = origin.y.clamped(to: bounds.minY...max(bounds.minY, bounds.maxY - size.height))
return NSRect(origin: origin, size: size)
}
// Puts the tag where the current placement says it belongs. Returns
// false (and hides the tag) if the window has no readable frame right
// now — minimized, or Accessibility not granted.
@discardableResult
private func reposition() -> Bool {
guard let (windowFrame, screen) = currentGeometry else {
panel.orderOut(nil)
return false
}
let style = WindowOverlayPosition.style
tagView.style = style
tagView.isVertical = style == .chip && placement.isVertical
tagView.isLeftSide = placement.corner.isLeft
tagView.needsDisplay = true
isRepositioning = true
defer { isRepositioning = false }
let tagFrame = frame(window: windowFrame, screen: screen)
panel.setFrame(tagFrame, display: true)
// The shadow is derived from the content's alpha, so a shape or
// size change leaves a stale one behind without this.
panel.invalidateShadow()
// Positioned either way — only the showing is conditional, so a
// hidden tag still knows where it belongs and can be revealed later
// without recomputing anything.
if isMinimized || !isTargetVisible(tagFrame: tagFrame) {
panel.orderOut(nil)
} else if !panel.isVisible {
panel.orderFrontRegardless()
}
return true
}
// Re-evaluate whether the tag should currently be on screen. Called
// when some app activates, which is the usual way a window ends up
// buried or uncovered without moving at all.
func refreshVisibility() {
reposition()
}
// A tag is a floating panel, which puts it above every ordinary window
// on the system rather than merely above the window it belongs to. Left
// alone it hovers over the browser, the editor, everything — even when
// its own window is buried or on another Space.
//
// There is no cross-process way to attach one window above another:
// `addChildWindow` is same-process only, and the private ordering call
// window managers use is a one-shot operation that goes stale the
// moment anything else reorders, so it wouldn't avoid this work either.
// So the tag is shown only when the window it labels is genuinely
// visible underneath it.
//
// The window server returns its list strictly front-to-back, which
// answers both halves in one pass: walk forward, and anything
// overlapping the tag before we reach our own window is covering it.
// Reaching our window with nothing in the way means the tag is showing
// real estate that actually belongs to that window; never reaching it
// means the window isn't on screen at all — minimized, hidden, or on
// another Space — and the tag has nothing to label.
private func isTargetVisible(tagFrame: NSRect) -> Bool {
guard let listing = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]
else { return true } // can't tell — better a stray tag than a missing one
let tag = Self.cgRect(of: tagFrame)
let ownPid = getpid()
for entry in listing {
guard let owner = entry[kCGWindowOwnerPID as String] as? pid_t,
let boundsDict = entry[kCGWindowBounds as String],
let bounds = CGRect(dictionaryRepresentation: boundsDict as! CFDictionary)
else { continue }
// Identified by owner alone, deliberately. Matching the exact
// rect meant comparing a freshly-read AX frame against the
// window server's snapshot, and mid-move the two disagree —
// AX already reports the new position while the listing still
// has the old one. The match then failed, the walk continued
// past our own window, and the first unrelated window that
// happened to overlap the tag "occluded" it. Whether that
// occurred came down to what was nearby, which is why it
// looked as though only certain drag directions broke it.
//
// A dialog of the same app landing here counts as reaching our
// window, which is right: Claude covering its own window is
// not a reason to disown the tag.
if owner == pid, bounds.intersects(tag) { return true }
// Our own tags are in this list too, and one tag sitting over
// another's window must not hide it.
if owner == ownPid { continue }
// Only ordinary windows can bury another ordinary window.
// Everything above that band — the menu bar, the Dock,
// notification banners, and the invisible one-pixel markers
// some utilities park in a screen corner — is permanently in
// front of everything and would veto the tag forever. One such
// marker at the bottom-left corner is what made a tag vanish
// there, and only there: it takes a move off two edges at once
// for the tag to clamp into that exact pixel.
guard (entry[kCGWindowLayer as String] as? Int) == 0 else { continue }
if bounds.intersects(tag) { return false }
}
return false
}
// AppKit is bottom-left-origin per screen; the window server is
// top-left-origin from the primary screen (screens[0], the one with the
// menu bar).
private static func cgRect(of rect: NSRect) -> CGRect {
let primaryMaxY = NSScreen.screens.first?.frame.maxY ?? rect.maxY
return CGRect(x: rect.minX, y: primaryMaxY - rect.maxY, width: rect.width, height: rect.height)
}
// MARK: - User placement
// Fires for every panel move — ours (from `reposition`, when the
// tracked window itself moves/resizes) and the user's (dragging the
// tag). Only the latter should redefine the placement, which is what
// the `isRepositioning` guard filters for.
//
// Re-deriving the offset from our own moves too looks like it should
// be a harmless no-op — recomputing the very offset we just positioned
// against — but it isn't, because the two halves read the window at
// different instants. AppKit posts this notification synchronously
// from inside `setFrame`, and during a live window drag the AX frame
// read here is already newer than the one `reposition` derived the
// origin from, so each move event bakes in the few points the window
// travelled in between. Those errors accumulate across the hundreds
// of events one drag produces, until the offset saturates and the tag
// has walked clear across its window — off-screen entirely, if that
// edge of the window is.
func windowDidMove(_ notification: Notification) {
guard !isRepositioning else { return }
scheduleDragSettle()
}
// A tag drag emits a continuous stream of moves, and re-anchoring on
// each one would snap the tag out from under the cursor mid-gesture
// (and flip its orientation repeatedly on the way past an edge). So
// the placement is only committed once the moves stop, which is as
// close to a "drag ended" signal as a background-movable window gets
// — NSWindow has no such delegate callback, unlike live resize.
private func scheduleDragSettle() {
dragSettle?.cancel()
let work = DispatchWorkItem { [weak self] in self?.commitUserPlacement() }
dragSettle = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
}
private func commitUserPlacement() {
guard let windowFrame = currentGeometry?.window else { return }
let tagFrame = panel.frame
let centre = NSPoint(x: tagFrame.midX, y: tagFrame.midY)
let corner = WindowOverlayCorner.nearest(to: centre, in: windowFrame)
// Orientation follows whichever edge the tag ended up nearest: a
// chip against a side reads vertically, so it can sit close in
// without covering the window's own controls. Dots have no
// reading direction, so they're never rotated.
let toSide = min(centre.x - windowFrame.minX, windowFrame.maxX - centre.x)
let toTopOrBottom = min(windowFrame.maxY - centre.y, centre.y - windowFrame.minY)
let isVertical = WindowOverlayPosition.style == .chip && toSide < toTopOrBottom
// Offsets run inward from the anchored corner. Stored unclamped at
// the top end (a drag outside the window is clamped to zero, but a
// long reach into a wide window is kept in full) so a later resize
// can restore it.
let insetX = corner.isLeft
? tagFrame.minX - windowFrame.minX : windowFrame.maxX - tagFrame.maxX
let insetY = corner.isTop
? windowFrame.maxY - tagFrame.maxY : tagFrame.minY - windowFrame.minY
placement = WindowOverlayPlacement(
corner: corner, insetX: max(0, insetX), insetY: max(0, insetY), isVertical: isVertical)
placement.save(for: profileName)
// Snap into the committed placement — this is what applies a
// rotation the drag just earned, and squares the tag up against
// its corner.
reposition()
}
// MARK: - Live tracking
private func startObserving() {
var newObserver: AXObserver?
guard AXObserverCreate(pid, WindowOverlay.axCallback, &newObserver) == .success, let newObserver
else { return }
observer = newObserver
let refcon = Unmanaged.passUnretained(self).toOpaque()
for name in [
kAXMovedNotification, kAXResizedNotification, kAXUIElementDestroyedNotification,
kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification,
] {
AXObserverAddNotification(newObserver, axWindow, name as CFString, refcon)
}
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(newObserver), .defaultMode)
}
private func stopObserving() {
guard let observer else { return }
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode)
self.observer = nil
}
private static let axCallback: AXObserverCallback = { _, _, notification, refcon in
guard let refcon else { return }
let overlay = Unmanaged<WindowOverlay>.fromOpaque(refcon).takeUnretainedValue()
switch notification as String {
case kAXUIElementDestroyedNotification:
overlay.panel.orderOut(nil)
case kAXWindowMiniaturizedNotification:
overlay.isMinimized = true
overlay.reposition()
case kAXWindowDeminiaturizedNotification:
overlay.isMinimized = false
overlay.reposition()
default:
overlay.reposition()
}
}
}
extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
-10
View File
@@ -1,10 +0,0 @@
// Entry point. Its own file only because swiftc restricts top-level
// executable statements to a file named main.swift once a module has more
// than one source file.
import Cocoa
let delegate = AppDelegate()
let app = NSApplication.shared
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
-1
View File
@@ -1 +0,0 @@
1.0.0
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
# Builds "shannoncoat.app" — a menu bar app (no Dock icon, no window) with a
# custom icon (icon/AppIcon.icns — see icon/generate-icon.swift) for
# Finder/Spotlight. It stays running in the menu bar: click the status item
# to switch profiles, or open Manage Profiles… (Cmd+,) to add/remove one.
# Launch at Login is a toggle inside the app itself.
#
# Builds into ./.build (gitignored) rather than installing straight into an
# Applications dir — copy/drag the .app from there yourself. It's a fully
# native, self-contained bundle — no bundled script, so copying it anywhere
# after the build is safe.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCES_DIR="$SCRIPT_DIR/Sources"
# Local, gitignored build configuration — currently just the code-signing
# identity, which otherwise has to be remembered and retyped on every
# build. See .env.example.
#
# Parsed rather than sourced, for two reasons: a stray command in the file
# shouldn't execute as a side effect of building, and this lets an existing
# environment variable win, so `SHANNONCOAT_SIGN_IDENTITY= ./build.sh`
# still forces a one-off ad-hoc build without editing the file.
ENV_FILE="$SCRIPT_DIR/.env"
if [[ -f "$ENV_FILE" ]]; then
while IFS='=' read -r key value || [[ -n "$key" ]]; do
key="${key#"${key%%[![:space:]]*}"}" # strip leading whitespace
key="${key#export }"
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue # skips blanks and # comments
value="${value%"${value##*[![:space:]]}"}" # strip trailing whitespace
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
[[ -n "${!key+set}" ]] && continue # already in the environment; leave it
printf -v "$key" '%s' "$value"
export "${key?}"
done <"$ENV_FILE"
fi
VERSION_FILE="$SCRIPT_DIR/VERSION"
ICON_SRC="$SCRIPT_DIR/icon/AppIcon.icns"
OUT_DIR="${1:-$SCRIPT_DIR/.build}"
APP="$OUT_DIR/shannoncoat.app"
# Read from the repo's VERSION file so the .app and the script it bundles
# never report different numbers.
VERSION="$(cat "$VERSION_FILE" 2>/dev/null)"
[[ -n "$VERSION" ]] || { echo "error: couldn't read $VERSION_FILE" >&2; exit 1; }
mkdir -p "$OUT_DIR" "$APP/Contents/MacOS" "$APP/Contents/Resources"
[[ -f "$ICON_SRC" ]] || { echo "error: $ICON_SRC missing (see icon/generate-icon.swift)" >&2; exit 1; }
cp "$ICON_SRC" "$APP/Contents/Resources/AppIcon.icns"
cat >"$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>shannoncoat</string>
<key>CFBundleDisplayName</key><string>shannoncoat</string>
<key>CFBundleIdentifier</key><string>com.local.shannoncoat</string>
<key>CFBundleVersion</key><string>$VERSION</string>
<key>CFBundleShortVersionString</key><string>$VERSION</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleExecutable</key><string>launcher</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>LSUIElement</key><true/>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
PLIST
command -v swiftc >/dev/null || { echo "error: swiftc not found (install Xcode Command Line Tools: xcode-select --install)" >&2; exit 1; }
# VERSION travels in Resources (not baked into the binary) so the About
# panel and the update checker read the same file this script stamped into
# Info.plist above.
cp "$VERSION_FILE" "$APP/Contents/Resources/VERSION"
# COMMIT is the short SHA the About tab appends to the version, so one dev
# build is distinguishable from another — left empty for a real release
# build, since the version number alone is unambiguous there. A release
# build is one of:
# - SHANNONCOAT_RELEASE_BUILD set by release.yml, which already knows
# for certain whether it's building a tag — trusted outright rather
# than re-derived, since `git describe` needs full tag refs that a
# CI runner's shallow checkout isn't guaranteed to have fetched.
# - a local/manual build with HEAD sitting exactly on a tag (checked
# via `git describe`, which is reliable here since a normal
# non-shallow local clone has its full tag history).
GIT_SHA=""
if [[ -n "${SHANNONCOAT_RELEASE_BUILD:-}" ]]; then
: # release build — leave GIT_SHA empty
elif git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1 \
&& ! git -C "$SCRIPT_DIR" describe --tags --exact-match >/dev/null 2>&1; then
GIT_SHA="$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || true)"
fi
printf '%s' "$GIT_SHA" >"$APP/Contents/Resources/COMMIT"
swiftc -O "$SOURCES_DIR"/*.swift -o "$APP/Contents/MacOS/launcher"
# Sign the bundle, not just the binary swiftc linker-signed for us — and
# with the bundle's real identifier rather than the "launcher" executable
# name codesign would otherwise infer.
#
# This matters well beyond tidiness, because the window tags need
# Accessibility. macOS ties that grant to a *properly signed* app's
# designated requirement, which survives a rebuild, but falls back to
# pinning an ad-hoc-signed app to its exact cdhash — and every build
# produces a new one. So with the ad-hoc default below, each build +
# reinstall silently invalidates the permission, while the app stays
# listed and stays ticked under Privacy & Security → Accessibility. It
# looks granted and isn't, and no amount of toggling that row fixes it:
# the stale entry has to be removed with "−" and the new build added back.
#
# To stop paying that tax on every build, make a self-signed code-signing
# certificate once (Keychain Access → Certificate Assistant → Create a
# Certificate…, type "Code Signing", self-signed) and point this at it:
#
# SHANNONCOAT_SIGN_IDENTITY="My Local Signing Cert" ./build.sh
#
# then re-grant Accessibility one final time. The identity is stable from
# then on, so later builds keep the permission.
if [[ -n "${SHANNONCOAT_SIGN_IDENTITY:-}" ]]; then
# An identity was asked for explicitly, so falling back to ad-hoc would
# quietly reintroduce the very problem it was set to avoid — and a
# release that ships ad-hoc revokes the Accessibility permission of
# everyone who updates. Fail the build instead.
codesign --force --sign "$SHANNONCOAT_SIGN_IDENTITY" \
--identifier "com.local.shannoncoat" "$APP" \
|| { echo "error: signing with \"$SHANNONCOAT_SIGN_IDENTITY\" failed" >&2; exit 1; }
else
codesign --force --sign - --identifier "com.local.shannoncoat" "$APP" >/dev/null 2>&1 \
|| echo "warning: codesign failed; Accessibility permission may not stick" >&2
fi
touch "$APP"
echo "Built: $APP"
echo "Copy it to /Applications (or ~/Applications) to install."
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

BIN
View File
Binary file not shown.
-83
View File
@@ -1,83 +0,0 @@
// 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)")
-214
View File
@@ -1,214 +0,0 @@
// 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
// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns
// 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.
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
) else { fatalError("could not create bitmap rep") }
rep.size = NSSize(width: side, height: side)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.current?.imageInterpolation = .high
// 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()
drawIcon()
NSGraphicsContext.restoreGraphicsState()
guard let png = rep.representation(using: .png, properties: [:]) else {
fatalError("failed to encode PNG at \(pixels)px")
}
return png
}
let sizes: [(name: String, px: Int)] = [
("icon_16x16", 16), ("icon_16x16@2x", 32),
("icon_32x32", 32), ("icon_32x32@2x", 64),
("icon_128x128", 128), ("icon_128x128@2x", 256),
("icon_256x256", 256), ("icon_256x256@2x", 512),
("icon_512x512", 512), ("icon_512x512@2x", 1024),
]
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset"
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
for (name, px) in sizes {
let path = "\(outDir)/\(name).png"
try? renderIcon(pixels: px).write(to: URL(fileURLWithPath: path))
print("wrote \(path)")
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Executable
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# shannoncoat - run more than one claude desktop profile on mac.
#
# claude desktop app seems to keep everything important in one config directory,
# so a different --user-data-dir gives a practically separate install.
# and CLAUDE_CONFIG_DIR env var can isolate claude code sessions natively.
# this script combines these to isolate both desktop sessions and the claude
# code sessions they run.
#
# profiles are yaml files in ~/.shannoncoat:
# # ~/.shannoncoat/client1.yaml -> profile named "client1"
# paths:
# code: ~/work/client1/.claude/code
# desktop: ~/work/client1/.claude/desktop
#
# claude code inside the desktop app can run in some folder which already
# defines a CLAUDE_CONFIG_DIR with direnv etc, that variable can be defined
# this way to allow both individual and desktop sessions work properly:
# export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
#
set -euo pipefail
APP="${CLAUDE_APP_PATH:-/Applications/Claude.app}"
BIN="$APP/Contents/MacOS/Claude"
ROOT="$HOME/.shannoncoat"
DEFAULT="default"
APP_DIR=""
CODE_DIR=""
CODE_KEY="code"
DESKTOP_KEY="desktop"
show_usage() {
cat <<EOF
Usage: $0 <command> [profile]
launch <profile> open a profile, leaving others running
switch <profile> quit all other profiles, then open this one
quit <profile> quit a profile
list list all running profiles
Profiles are yaml files in ~/.shannoncoat/. Syntax:
# profile-name.yaml
paths:
$CODE_KEY: ~/path/to/claude/code
$DESKTOP_KEY: ~/path/to/claude/desktop
EOF
}
# expand leading ~ in path string to $HOME
tilde_path() {
local p="$1"
echo "${p/#\~/$HOME}"
}
# pull one key out of the yaml, simple job avoid yq for now
read_key() {
local file="$1" key="$2"
sed -EHn "s/^\s+$key:\s+//p" "$file" | tr -d '"'
}
# set APP_DIR and CODE_DIR for a profile, or keep empty for $DEFAULT
get_dirs() {
local name="$1" file="$ROOT/$1.yaml"
[[ "$name" == "$DEFAULT" ]] && { return; }
if [[ ! -f "$file" ]]; then
echo "no profile named $name (expected $file)" >&2
exit 1
fi
APP_DIR="$(tilde_path "$(read_key "$file" $DESKTOP_KEY)")"
CODE_DIR="$(tilde_path "$(read_key "$file" $CODE_KEY)")"
}
# match a claude data dir to its profile name
dir_to_profile() {
local dir="$1" file name
[[ "$dir" == "$DEFAULT" ]] && { echo "$DEFAULT"; return; }
for file in "$ROOT"/*.yaml; do
[[ -f "$file" ]] || continue
name="$(basename "$file" .yaml)"
if [[ "$(tilde_path "$(read_key "$file" $DESKTOP_KEY)")" == "$dir" ]]; then
echo "$name"
return
fi
done
echo "$dir"
}
# get all running claude processes as "pid<TAB>user-data-dir"
running_info() {
local pid args dir
while read -r pid args; do
# if args has --user-data-dir=, extract it, otherwise use $DEFAULT
if [[ "$args" == *--user-data-dir=* ]]; then
dir="${args#*--user-data-dir=}"
dir="${dir%% *}"
else
dir="$DEFAULT"
fi
printf '%s\t%s\n' "$pid" "$dir"
done < <(pgrep -fl "$BIN" || true)
}
# get pid of a profile's claude process
profile_to_pid() {
get_dirs "$1"
running_info | awk -F'\t' -v d="${APP_DIR:-default}" '$2 == d { print $1; exit }'
}
# switch focus to a process window, shell/term needs accessibility permissions
app_focus() {
local pid="$1"
osascript > /dev/null 2>&1 <<EOF || true
tell application "System Events"
set proc to first process whose unix id is $pid
set frontmost of proc to true
try
perform action "AXRaise" of window 1 of proc
end try
end tell
EOF
}
# start a claude desktop process with the right env vars and user-data-dir
app_start() {
get_dirs "$1"
# app dir empty means default profile, just open normally
[[ -z "$APP_DIR" ]] && { open -a "$APP"; return; }
mkdir -p "$APP_DIR" "$CODE_DIR"
CLAUDE_CONFIG_DIR="$CODE_DIR" "$BIN" --user-data-dir="$APP_DIR" > /dev/null 2>&1 &
disown
}
# list all running profiles
cmd_list() {
local pid dir
while read -r pid dir; do
printf 'profile: %s\tpid: %s\n' "$(dir_to_profile "$dir")" "$pid"
done < <(running_info)
}
# launch a profile or focus window if already running
cmd_launch() {
local name="${1:?profile name required}" pid
pid="$(profile_to_pid "$name")"
[[ -n "$pid" ]] && { app_focus "$pid"; return; }
app_start "$name"
}
# switch to a profile quitting all others
cmd_switch() {
local name="${1:?profile name required}" pid dir keep
# marks which profile to keep running, "default" if empty
get_dirs "$name"
keep="${APP_DIR:-"$DEFAULT"}"
# quit all other running claudes except the one to $keep
while read -r pid dir; do
[[ "$dir" == "$keep" ]] && continue
kill "$pid" 2>/dev/null || true
done < <(running_info)
# then launch selected profile
cmd_launch "$name"
}
# quit a profile if running
cmd_quit() {
local name="${1:?profile name required}" pid
pid="$(profile_to_pid "$name")"
[[ -z "$pid" ]] && { echo "$name isn't running"; return; }
kill "$pid"
}
# main entry point
case "${1:-}" in
launch) cmd_launch "${2:-}" ;;
switch) cmd_switch "${2:-}" ;;
quit) cmd_quit "${2:-}" ;;
list) cmd_list ;;
-h|--help) show_usage; exit 0 ;;
*) show_usage; exit 1 ;;
esac