9 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi e7777d5dfc Don't quit the neighbour when switching to an already-open profile
Clicking a profile quit every other running one first, unconditionally —
including when the profile clicked was already open. The menu lists every
running profile at once, so the click that reaches for the second one is
almost always someone moving between two open windows, and it cost them a
relaunch and whatever was on screen to carry out an instruction nobody
gave.

Switching now only quits anything when the target isn't up yet. An
already-running profile is simply brought forward, which is what
shift-click did and what the click on a running profile always looked
like it would do.
2026-08-06 00:01:46 +06:00
Claude Opus 5andbdeshi cf7d9a4583 Draw the real icon, and give the menu bar its own glyph
Replaces the placeholder coat silhouette with the app's actual subject:
Claude's asterisk above a rack of overcoats, on Claude's own coral. The
wardrobe is the app in one picture — several coats to pick between, one
currently worn.

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

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

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

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

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

Also bumps the version to 0.0.9. Nothing has been published yet, so the
numbering stays below 1.0.0 until a release has been tested for real.
2026-08-05 15:06:30 +06:00
Claude Opus 5andbdeshi 061ae098da Sign builds and releases with a stable code identity
swiftc linker-signs only the inner binary, leaving the bundle unsigned
and its codesign identifier as "launcher" rather than the bundle id. More
importantly it leaves the app ad-hoc signed, and macOS pins an ad-hoc
app's Accessibility grant to its exact cdhash instead of to a designated
requirement. Every build mints a new cdhash, so each rebuild-and-replace
silently revoked the permission while the app stayed listed and ticked
under Privacy & Security — and every release did the same to everyone who
updated. That was the root cause of the window tags never appearing.

- build.sh signs the bundle with its real identifier, honours
  SHANNONCOAT_SIGN_IDENTITY, and fails outright rather than falling back
  to ad-hoc when an identity was asked for explicitly.
- The identity is read from a gitignored .env, so it doesn't have to be
  retyped every build. Parsed rather than sourced, so a stray command in
  the file can't execute as a side effect of building, and so an existing
  environment variable still wins. .env.example carries the full one-time
  setup.
- release.yml imports the certificate into a throwaway keychain, builds,
  verifies, and deletes the keychain on if: always(). It stays inert
  until the three secrets exist, and fails the release rather than
  shipping ad-hoc.
- Guards the empty-password case explicitly: macOS cannot import an
  OpenSSL-produced PKCS#12 with an empty password, and reports it as "MAC
  verification failed (wrong password?)", which sends you hunting for a
  wrong password rather than a missing one. Nothing local catches this,
  since the login keychain imports the PEM pair and needs no password.
- Ignores *.p12 and *.pem as a backstop; the certificate belongs outside
  the working tree entirely.

Verified end-to-end: two from-scratch builds produce byte-identical
designated requirements where ad-hoc differs every time, and six
rebuild-reinstall cycles under a real certificate kept the Accessibility
grant with no System Settings interaction.

This buys permission persistence, not Gatekeeper approval — a
self-signed certificate isn't notarized, so downloads still need
System Settings -> Privacy & Security -> Open Anyway.
2026-08-05 13:23:09 +06:00
Claude Opus 5andbdeshi 5191a38f7a Fix the window tags: permission, drift, anchoring, and styling
Four separate defects, each reproduced before being fixed.

Accessibility was silently denied. AXIsProcessTrusted() returned false,
so every AX read failed, WindowOverlay.init? failed all twelve retries,
and attach gave up without a word — indistinguishable from the feature
being broken. The cause was outside this file: an ad-hoc-signed app has
its permission pinned to its exact cdhash, which changes on every build,
so the rebuild-and-replace workflow silently revoked the grant each time
while the app stayed listed and ticked in System Settings. Now it prompts
rather than failing mutely, and keeps rechecking — macOS never tells an
app it has just been granted Accessibility, and overlays were otherwise
only reconsidered when a Claude window launched, quit or activated, so a
grant made while running did nothing visible until the user happened to
touch a Claude window.

The tag walked off its window on every drag. windowDidMove re-derived and
persisted the corner offset for the app's own programmatic moves too, on
the assumption that recomputing an offset it had just positioned against
was a no-op. It isn't: AppKit posts that notification synchronously from
inside setFrame, and during a live drag the AX frame read there is
already newer than the one reposition used, so each event banked the few
points the window had moved in between. Observed saturating at 952,
which parks the tag at the far edge of the window or off-screen.

Placement was anchored to the top-right unconditionally, so any resize
moving that corner dragged the tag along. It now anchors to whichever
corner it was dropped nearest. Offsets are no longer written back
clamped either: a window too small to honour one shows the tag pushed in
as far as it fits but keeps the stored distance, so widening the window
restores the chosen spot.

Styling: thinner (13pt) and pill-shaped so it can sit close to an edge
without covering window controls; rotated when against a side edge,
reading bottom-to-top on the left and top-to-bottom on the right so the
text leans into the window; text colour chosen per profile colour by WCAG
relative luminance rather than fixed white, which was as low as ~1.1:1 on
a yellow chip against 4.50:1 worst-case now; and text drawn into a
one-line-tall rect centred on the chip's midline, fixing its high
seating. Settings gains a choice between the name chip and a plain
coloured dot, which is never rotated, having no reading direction.

Verified live throughout: a 400pt offset squeezed to 260 on a narrowed
window and returned to 400 on restore with the stored value untouched;
a drag committed the expected corner and orientation; and contrast was
measured across the hue wheel.

Tag placement is also stored per profile rather than once for the whole
app. Two windows side by side are 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. Previously a single shared offset made
them look independent (only the dragged tag moved at once) while every
other tag snapped to it on its next reposition.

A tag is a floating panel, which puts it above every ordinary window on
the system rather than merely above the window it labels — so it hovered
over the browser, the editor, and everything else, even when its own
window was 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 that goes
stale on the next reorder), so each tag now checks whether the window it
labels is genuinely visible beneath it. The window server returns its list
front-to-back, so one pass answers it: anything overlapping the tag before
we reach our own window is covering it, and never reaching that window
means it isn't on screen at all.

Tag position is also clamped into whatever part of the window is 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 clamp is
presentational only — the stored anchor is untouched, so the tag returns
to it once the window is fully back.

Occlusion is judged from the window server's listing, which it returns
front-to-back. Our own window is identified by its owning process rather
than by matching rectangles: AX reports a new position the instant a
window moves while the listing still holds the previous one, so a
geometric match fails almost continuously mid-drag — measured at 87 of 92
frames, during which the walk ran past our own window and mistook
whatever else overlapped the tag for something covering it.

Only ordinary windows count as occluders. Everything above that band is
permanently in front and would veto the tag forever — including the
invisible one-pixel markers some utilities park in a screen corner, which
is what made a tag vanish at the bottom-left and nowhere else: it takes a
window moved off two edges at once for the tag to clamp into that pixel.
2026-08-05 13:08:11 +06:00
Claude Opus 5andbdeshi 1d3a539edd Add the window identification overlay
Every profile launches the same Claude Desktop binary with the same icon
and title, so with two or more open there was no way to tell which window
belonged to which profile at a glance. WindowOverlay pins a small tag —
profile name, in that profile's own dot colour — to each running managed
profile's window, tracked live via AXObserver notifications rather than
polling. Only a persistent process can hold a watcher like that, which is
exactly the gap the old one-shot CLI could never close.

Shown for every running managed profile, not just when two or more are
up: the tag is an identity marker, not merely a disambiguator. Never
shown on "default", which isn't a shannoncoat profile at all.

Positioned top-right, since top-left is where a window's close/minimize/
zoom controls live. Draggable in case that still obstructs something in a
particular layout, on a .nonactivatingPanel so dragging it doesn't steal
focus from the window underneath, with the offset persisted.

Attachment retries for ~3s rather than requiring the target's first
window to already exist: a cold Electron launch can take a second or more
between the process starting — which is what triggers the update, via
NSWorkspace's launch notification — and its first window existing. Losing
that race previously meant the tag silently never appeared at all.

Also ignores CHECKPOINT.md, a local session-handoff note.
2026-08-05 11:40:56 +06:00
Claude Opus 5andbdeshi 9673dc2792 Redesign the Manage window and fold About into it as a tab
The previous layout was a fixed 460x360 window regardless of tab or
state, with a plain unbordered table and text +/- buttons — it read as a
generic cross-platform port rather than something native. Several rows
also had no real width anchor at all, so they floated: flush against the
window's left edge with no margin, and in the add form's case wide enough
to overflow past the right edge.

- The window is no longer manually resizable. Each tab reports its own
  preferredContentSize, recomputed in viewDidLayout and after every state
  change, and NSTabViewController resizes the window to match. That is
  what actually fixes the large empty areas: a tab with less content gets
  a smaller window rather than sitting inside a fixed larger one.
- Every row now anchors its width to the one element with a real absolute
  width, fixing both the edge-touching and the overflow.
- Table gains a bezel border and alternating rows; form labels get a
  fixed trailing-aligned width so fields line up; the error label wraps
  instead of truncating; tab padding is consistent.
- About moves out of a separate panel into a third tab, with the app icon
  centred and enlarged to 96px.

Verified visually with a standalone harness that drives the real window
and screenshots it, rather than simulating input — scripting clicks into
the app itself needs an Accessibility grant this environment lacks.
2026-08-04 23:54:56 +06:00
Claude Opus 5andbdeshi c6a7da54ca Show profile paths, report the build version, and colour each profile
- Selecting a row in the Profiles tab shows its Claude Desktop and Claude
  Code dirs inline and selectable, instead of requiring a right-click
  "Reveal in Finder" to find out where a profile actually lives.
- build.sh stamps Contents/Resources/COMMIT with the short HEAD SHA, left
  empty when HEAD sits exactly on a tag (how a real release is built),
  since the version number alone is unambiguous there. About appends it
  when present, so two dev builds off the same VERSION are
  distinguishable. The update checker still compares plain semver.
- Profile dot colours are picked per NSApp.effectiveAppearance at render
  time: same hue either way, but full saturation with brightness split
  per background (1.0 dark, 0.55 light), so they read as bold hues rather
  than washing out on white or muddying against a dark menu.
2026-08-04 22:44:21 +06:00
Claude Opus 5andbdeshi 135db9b308 Rewrite shannoncoat as a native Swift menu-bar app
Replaces the original shannoncoat.sh entirely with in-process Swift. The
script stopped earning its keep once the rest went native, and being a
persistent process rather than a one-shot CLI is what later makes live
window tracking possible at all.

- ProfileStore: JSON config at ~/.shannoncoat/<name>.json (was hand-rolled
  YAML), with ~ expansion, paths relative to the config dir, and
  name/dir-collision validation in one place.
- ProcessInspector: sysctl(KERN_PROC_ALL/KERN_PROCARGS2) enumeration
  instead of shelling out to pgrep.
- ClaudeControl: launch via Process, focus via direct Accessibility calls
  (unhide, un-minimize, poll-for-window, AXRaise) instead of AppleScript,
  and a quit that respects Claude's own termination handling rather than
  unconditionally SIGKILLing after a flat 5s timeout.
- LiveState: NSWorkspace notification-driven state with no polling timer,
  so the menu bar reflects reality within about a second — including
  changes made outside the app entirely.
- ManageWindow: one non-modal window replacing what would otherwise be a
  string of separate popup alerts, with inline add/remove and directory
  pickers.
- LaunchAtLogin / UpdateChecker: SMAppService login-item toggle, and a
  minimal VERSION-file update check that alerts on a manual check and
  posts a quiet notification on the automatic one.

Also drops the standalone CLI in favour of GUI-only, and adds a
placeholder app icon drawn from vector shapes rather than composited on
top of Claude's own icon assets.

Claude Desktop is located at runtime rather than assumed: a path the user
picked previously, then /Applications, then a per-user ~/Applications
install, each accepted only if it actually contains the executable. If
none match, launching a profile asks the user to locate it once and
remembers the answer — a launch that silently does nothing gives them no
way to work out what's wrong.
2026-08-04 21:56:22 +06:00
25 changed files with 3677 additions and 182 deletions
+118
View File
@@ -0,0 +1,118 @@
# 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
@@ -0,0 +1,155 @@
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@v4
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@v2
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
+49
View File
@@ -0,0 +1,49 @@
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@v4
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 }}
permissions:
contents: write
+156
View File
@@ -0,0 +1,156 @@
# 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
+136
View File
@@ -0,0 +1,136 @@
![shannoncoat — which coat will Claude wear today?](docs/hero.png)
Run multiple isolated Claude profiles on macOS — each one its own Claude Desktop
**and** its own paired Claude Code setup — and tell their windows apart at a
glance.
Your normal Claude install is left completely alone. It stays exactly as it is
and keeps working normally; shannoncoat only adds profiles alongside it.
A menu-bar item allows quick switching and profile management.
## Requirements
- macOS 13 or later
- Claude Desktop
## Install
1. Download `shannoncoat.app.zip` from the
[latest release](https://github.com/bdeshi/shannoncoat/releases/latest).
2. Unzip it and move **shannoncoat.app** to `/Applications` or `~/Applications`.
3. Open it. macOS will block the first launch, because the app isn't notarized.
Go to **System Settings → Privacy & Security**, find the blocked item, and
click **Open Anyway**.
4. When prompted, grant **Accessibility**. The window tags are drawn from
Accessibility data and won't appear without it.
## First run
With nothing running, shannoncoat offers a list of your profiles and starts
whichever you tick. You can start several at once.
## Everyday use
The menu bar shows a coloured dot per running profile — dot and name when only
one is up, dots alone when there are more (hover for the names).
Open the menu and:
- **Click a profile that isn't running** to switch to it. Every other running
profile is quit first, so you end up with just that one.
- **Click a profile that is already running** to bring its window forward,
leaving everything else where it is.
- **Shift-click a profile** to open it *alongside* whatever is already running.
A `⇆` marks the profile you last switched to. **Close Profile** quits one
profile; **Quit Claude** quits all of them and leaves shannoncoat running.
**Manage Profiles…** opens the main window.
Quitting Claude by hand, from the Dock, or via Force Quit is picked up within
about a second — the menu bar always reflects what is actually running.
## Window tags
Each managed Claude window carries a small tag in that profile's colour, so two
otherwise identical windows are easy to tell apart.
Drag a tag anywhere on its window; each profile remembers where you put its own.
If you'd rather have something smaller, **Settings → Window tag → Colored dot**
replaces the name chip with a plain dot.
## Profiles
**default** is your real, untouched Claude install. shannoncoat can start and
stop it but never manages it, so it gets no tag and can't be deleted.
To add a custom isolated profile, open **Manage Profiles… → +** and enter a
name. The directories are optional — leave them blank and the profile gets
`~/.shannoncoat/data/<name>/app` and `~/.shannoncoat/data/<name>/code`. Point
them anywhere you like if you'd rather keep a profile's data with a project.
Selecting a profile shows both paths, ready to copy.
Each profile is a small JSON file at `~/.shannoncoat/<name>.json`, named after
the profile. Two profiles may not share a directory — that would silently merge
their Claude sessions, so shannoncoat refuses to start and tells you which files
collide.
Deleting a profile removes only that pointer file. **Its Claude data stays on
disk**, so you can recreate the profile later and pick up where you left off. If
it's running, it's quit first.
## Settings
- **Launch at Login** — start shannoncoat automatically.
- **Window tag** — name chip or coloured dot.
- **Automatically check for updates** — once a day at most.
## Updates
shannoncoat compares its version against the one published on GitHub. The
automatic check posts a quiet notification; **Check for Updates…** in Settings
reports either way.
It never replaces itself. When a new version exists, download it from the
releases page and swap the app in yourself.
## Troubleshooting
**The window tags don't appear.** Almost always Accessibility. Check
**System Settings → Privacy & Security → Accessibility**.
If shannoncoat is listed *and already ticked* but the tags still don't show,
select it, remove it with ****, then add it back. This happens after replacing
the app with a build that has a different code signature; the old entry stays
visible but no longer applies, and toggling it off and on won't fix it.
**Claude Desktop can't be found.** shannoncoat looks in `/Applications` and
`~/Applications`. If yours is somewhere else, you'll be asked to locate it the
first time you start a profile.
**A profile won't quit.** Claude was asked to quit and declined — usually its
own "unsaved work" or "still generating" dialog. shannoncoat deliberately waits
rather than killing Claude underneath it. Answer the dialog.
## Build from source
Requires the Xcode Command Line Tools.
```bash
./build.sh
```
The app is written to `.build/shannoncoat.app`; copy it to `/Applications` or
`~/Applications`.
One thing worth setting up: an unsigned local build gets a new code identity
every time, which silently invalidates its Accessibility grant on each rebuild.
`.env.example` walks through creating a self-signed certificate once so the
permission survives.
---
shannoncoat started as the shell script in the first commit, written by hand.
Claude Code built the app out of it — most of the Swift, the icon, and this
README — working from direction, review and testing.
+420
View File
@@ -0,0 +1,420 @@
// 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
@@ -0,0 +1,240 @@
// 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
@@ -0,0 +1,21 @@
// 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
@@ -0,0 +1,87 @@
// 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
@@ -0,0 +1,719 @@
// 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
@@ -0,0 +1,82 @@
// The menu bar's own glyph Claude's asterisk over a coat's collar and
// shoulders. Drawn here rather than scaled down from the app icon, which is
// what the menu bar used to show: at 18pt the app icon's three coats, lapels
// and pockets collapse into a smudge, and its colours fight the menu bar
// instead of following it.
//
// Returned as a *template* image, which is what makes it track the system
// theme with no code of ours involved. A template carries only an alpha
// channel; macOS supplies the colour dark on a light menu bar, light on a
// dark one, inverted again while the menu is open and re-tints it on the fly
// when the theme switches. That also dictates the drawing: no fills of our
// own, and any internal detail has to be a hole punched through the alpha.
import AppKit
enum MenuBarIcon {
// Six rays rather than the app icon's eight. At 18pt an eight-ray asterisk
// has under a pixel of gap between arms, so they merge into a blob; six
// resolve as separate arms. Lengths stay slightly uneven, as on the app
// icon, so it doesn't read as a snowflake.
private static let rayCount = 6
private static let rayLengths: [CGFloat] = [1.0, 0.88, 0.97, 0.86, 1.0, 0.9]
private static let rayRadius: CGFloat = 22
private static let rayWidth: CGFloat = 7.2
private static let asteriskCentre = NSPoint(x: 50, y: 28)
static func image(pointSize: CGFloat = 18) -> NSImage {
let image = NSImage(size: NSSize(width: pointSize, height: pointSize))
image.lockFocusFlipped(false)
// Authored y-down like the app icon; flip once rather than mirroring
// every coordinate.
let flip = NSAffineTransform()
flip.translateX(by: 0, yBy: pointSize)
flip.scaleX(by: pointSize / 100, yBy: -pointSize / 100)
flip.concat()
NSColor.black.setStroke()
asterisk().stroke()
NSColor.black.setFill()
coat().fill()
image.unlockFocus()
image.isTemplate = true
return image
}
private static func asterisk() -> NSBezierPath {
let path = NSBezierPath()
for index in 0..<rayCount {
let angle = (CGFloat(index) * 360 / CGFloat(rayCount) + 8) * .pi / 180
let radius = rayRadius * rayLengths[index]
path.move(to: asteriskCentre)
path.line(to: NSPoint(x: asteriskCentre.x + radius * cos(angle),
y: asteriskCentre.y + radius * sin(angle)))
}
path.lineWidth = rayWidth
path.lineCapStyle = .round
return path
}
// Read along the top edge from one side to the other: shoulder, a slight
// tilt up, a flat collar top, the valley at the neck, then the mirror of
// that. Square sides and a flat hem below.
//
// The flat sections are 13 units wide because anything narrower stops
// reading as flat at this size and just looks like part of the slope
// 13 units is roughly 2.3px at 18pt, about the floor for that.
private static func coat() -> NSBezierPath {
let path = NSBezierPath()
path.move(to: NSPoint(x: 15, y: 72)) // left shoulder
path.line(to: NSPoint(x: 27, y: 60)) // slight tilt up
path.line(to: NSPoint(x: 40, y: 60)) // flat collar top
path.line(to: NSPoint(x: 50, y: 78)) // valley at the neck
path.line(to: NSPoint(x: 60, y: 60))
path.line(to: NSPoint(x: 73, y: 60))
path.line(to: NSPoint(x: 85, y: 72)) // right shoulder
path.line(to: NSPoint(x: 85, y: 94))
path.line(to: NSPoint(x: 15, y: 94))
path.close()
return path
}
}
+86
View File
@@ -0,0 +1,86 @@
// 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
@@ -0,0 +1,72 @@
// 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
@@ -0,0 +1,180 @@
// 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
@@ -0,0 +1,105 @@
// 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
@@ -0,0 +1,604 @@
// 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
@@ -0,0 +1,10 @@
// 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
@@ -0,0 +1 @@
0.0.9
Executable
+139
View File
@@ -0,0 +1,139 @@
#!/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.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
View File
Binary file not shown.
+83
View File
@@ -0,0 +1,83 @@
// Generates docs/hero.png the banner at the top of the README.
//
// Composes the already-generated app icon with the wordmark and tagline rather
// than redrawing the coats, so the banner can never drift out of step with the
// icon itself. Run generate-icon.swift first.
//
// Usage:
// swift icon/generate-icon.swift icon/AppIcon.iconset
// cp icon/AppIcon.iconset/icon_512x512.png icon/shannoncoat.png
// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns
// rm -rf icon/AppIcon.iconset
// swift icon/generate-hero.swift icon/shannoncoat.png docs/hero.png
import Cocoa
private func rgb(_ hex: UInt32) -> NSColor {
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
blue: CGFloat(hex & 255) / 255, alpha: 1)
}
// Slate ground rather than cloud: GitHub renders READMEs on both a light and a
// dark page, and a dark banner sits comfortably on either, where a near-white
// one glares against a dark page. It also lets the coral icon carry the colour.
private let ground = rgb(0x26_26_25)
private let wordmark = rgb(0xF0_EE_E6)
private let subtitle = rgb(0xA8_A2_98)
private let width = 1200
private let height = 360
let args = CommandLine.arguments
let iconPath = args.count > 1 ? args[1] : "icon/shannoncoat.png"
let outPath = args.count > 2 ? args[2] : "docs/hero.png"
guard let icon = NSImage(contentsOfFile: iconPath) else {
fatalError("couldn't read \(iconPath) — run generate-icon.swift first")
}
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { fatalError("could not create bitmap rep") }
rep.size = NSSize(width: width, height: height)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.current?.imageInterpolation = .high
ground.setFill()
NSBezierPath(rect: NSRect(x: 0, y: 0, width: width, height: height)).fill()
let iconSide: CGFloat = 184
icon.draw(in: NSRect(x: 96, y: (CGFloat(height) - iconSide) / 2, width: iconSide, height: iconSide))
let textX: CGFloat = 96 + iconSide + 56
let name = NSAttributedString(string: "shannoncoat", attributes: [
.font: NSFont.systemFont(ofSize: 76, weight: .semibold),
.foregroundColor: wordmark,
])
let tagline = NSAttributedString(string: "which coat will Claude wear today?", attributes: [
.font: NSFont.systemFont(ofSize: 30, weight: .regular),
.foregroundColor: subtitle,
])
// Stacked around the vertical centre, using each string's own measured height
// so the pair sits optically centred against the icon rather than by guesswork.
let nameSize = name.size(), taglineSize = tagline.size()
let gap: CGFloat = 14
let blockHeight = nameSize.height + gap + taglineSize.height
let top = (CGFloat(height) + blockHeight) / 2
name.draw(at: NSPoint(x: textX, y: top - nameSize.height))
tagline.draw(at: NSPoint(x: textX + 3, y: top - nameSize.height - gap - taglineSize.height))
NSGraphicsContext.restoreGraphicsState()
let outURL = URL(fileURLWithPath: outPath)
try? FileManager.default.createDirectory(
at: outURL.deletingLastPathComponent(), withIntermediateDirectories: true)
guard let png = rep.representation(using: .png, properties: [:]) else {
fatalError("failed to encode hero PNG")
}
try! png.write(to: outURL)
print("wrote \(outPath)")
+214
View File
@@ -0,0 +1,214 @@
// 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.

After

Width:  |  Height:  |  Size: 32 KiB

-182
View File
@@ -1,182 +0,0 @@
#!/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