4 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
14 changed files with 829 additions and 93 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.
+108
View File
@@ -21,11 +21,94 @@ permissions:
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:
@@ -34,6 +117,23 @@ jobs:
# 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"
@@ -45,3 +145,11 @@ jobs:
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
+12
View File
@@ -7,6 +7,18 @@ 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/
+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.
+35 -12
View File
@@ -15,12 +15,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
private var accessibilityPromptShown = false
private var accessibilityRecheckScheduled = false
private var activationObserver: NSObjectProtocol?
private var appearanceObserver: NSKeyValueObservation?
func applicationDidFinishLaunching(_ notification: Notification) {
menu.delegate = self
menu.autoenablesItems = false
statusItem.menu = menu
statusItem.button?.image = menuBarIcon()
statusItem.button?.image = MenuBarIcon.image()
statusItem.button?.imagePosition = .imageLeft
liveState.delegate = self
@@ -34,6 +35,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
) { [weak self] _ in self?.updateOverlayVisibility() }
// The glyph is a template image and re-tints itself, but the coloured
// dots beside it don't: ProfileColor picks a brightness per current
// appearance at the moment it's asked, and the title is only rebuilt
// when profiles start or stop. Without this, switching to dark mode
// leaves dots mixed for that appearance sitting in the menu bar until
// something unrelated happens to redraw them.
appearanceObserver = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.applyTitle(self.currentInfos)
ManageWindowController.shared.update(self.currentInfos)
}
}
liveState.reconcile()
// Launching this app fresh with nothing running means there's no
@@ -46,17 +62,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
UpdateChecker.checkAutomaticallyIfDue()
}
// Claude's own app icon (bundled as Contents/Resources/AppIcon.icns
// currently a placeholder, see icon/generate-icon.swift), scaled down
// for the menu bar.
private func menuBarIcon() -> NSImage {
let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
let resized = NSImage(size: NSSize(width: 18, height: 18))
resized.lockFocus()
source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0)
resized.unlockFocus()
return resized
}
// The menu bar draws its own glyph (see MenuBarIcon) rather than the app
// icon scaled down, which is what it used to do: at 18pt the app icon's
// three coats collapse into a smudge, and its colours ignore the menu
// bar's appearance entirely. MenuBarIcon returns a template image, so
// macOS tints it for light/dark and for the open-menu inversion, and
// re-tints it by itself when the theme changes.
// MARK: - LiveStateDelegate
@@ -349,7 +360,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
}
}
// Switching means "make this the one that's running" so it only has to
// quit anything when the profile isn't up yet. Clicking one that's
// already open is a request to look at it, not to tear down the window
// beside it: the menu shows every running profile at once, so the click
// that reaches for the second one is almost always someone moving
// between two open windows rather than asking for one of them to go.
// Quitting the neighbour there costs a relaunch and whatever was on
// screen, to carry out an instruction nobody gave.
private func switchTo(_ profile: ResolvedProfile) {
guard !currentInfos.contains(where: { $0.profile.name == profile.name && $0.running }) else {
openProfile(profile)
return
}
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
guard !others.isEmpty else { openProfile(profile); return }
var remaining = others.count
+22 -5
View File
@@ -640,7 +640,6 @@ private final class SettingsViewController: NSViewController {
// MARK: - About tab
private let projectAuthor = "bdeshi"
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
private final class AboutViewController: NSViewController {
@@ -668,19 +667,36 @@ private final class AboutViewController: NSViewController {
icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
])
// Centred under the icon, same wrapper trick as the icon itself.
let tagline = NSTextField(labelWithString: "which coat will Claude wear today?")
tagline.font = .systemFont(ofSize: NSFont.systemFontSize)
tagline.textColor = .secondaryLabelColor
tagline.translatesAutoresizingMaskIntoConstraints = false
let taglineContainer = NSView()
taglineContainer.translatesAutoresizingMaskIntoConstraints = false
taglineContainer.addSubview(tagline)
NSLayoutConstraint.activate([
tagline.topAnchor.constraint(equalTo: taglineContainer.topAnchor),
tagline.bottomAnchor.constraint(equalTo: taglineContainer.bottomAnchor),
tagline.centerXAnchor.constraint(equalTo: taglineContainer.centerXAnchor),
])
// Names itself rather than opening with a bare verb this panel is
// reachable without the window title in view.
let description = NSTextField(wrappingLabelWithString:
"Runs multiple Claude Desktop profiles side by side, each paired with its own isolated Claude Code config.")
"shannoncoat runs multiple Claude Desktop profiles side by side, each paired with its own "
+ "isolated Claude Code config.")
description.font = .systemFont(ofSize: NSFont.systemFontSize)
let authorField = NSTextField(labelWithString: "Author: \(projectAuthor)")
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
for field in [authorField, sourceField, versionField] {
for field in [sourceField, versionField] {
field.textColor = .secondaryLabelColor
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
}
let stack = NSStackView(views: [iconContainer, description, authorField, sourceField, versionField])
let stack = NSStackView(views: [iconContainer, taglineContainer, description,
sourceField, versionField])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 10
@@ -694,6 +710,7 @@ private final class AboutViewController: NSViewController {
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
taglineContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
])
view.layoutSubtreeIfNeeded()
+82
View File
@@ -0,0 +1,82 @@
// The menu bar's own glyph Claude's asterisk over a coat's collar and
// shoulders. Drawn here rather than scaled down from the app icon, which is
// what the menu bar used to show: at 18pt the app icon's three coats, lapels
// and pockets collapse into a smudge, and its colours fight the menu bar
// instead of following it.
//
// Returned as a *template* image, which is what makes it track the system
// theme with no code of ours involved. A template carries only an alpha
// channel; macOS supplies the colour dark on a light menu bar, light on a
// dark one, inverted again while the menu is open and re-tints it on the fly
// when the theme switches. That also dictates the drawing: no fills of our
// own, and any internal detail has to be a hole punched through the alpha.
import AppKit
enum MenuBarIcon {
// Six rays rather than the app icon's eight. At 18pt an eight-ray asterisk
// has under a pixel of gap between arms, so they merge into a blob; six
// resolve as separate arms. Lengths stay slightly uneven, as on the app
// icon, so it doesn't read as a snowflake.
private static let rayCount = 6
private static let rayLengths: [CGFloat] = [1.0, 0.88, 0.97, 0.86, 1.0, 0.9]
private static let rayRadius: CGFloat = 22
private static let rayWidth: CGFloat = 7.2
private static let asteriskCentre = NSPoint(x: 50, y: 28)
static func image(pointSize: CGFloat = 18) -> NSImage {
let image = NSImage(size: NSSize(width: pointSize, height: pointSize))
image.lockFocusFlipped(false)
// Authored y-down like the app icon; flip once rather than mirroring
// every coordinate.
let flip = NSAffineTransform()
flip.translateX(by: 0, yBy: pointSize)
flip.scaleX(by: pointSize / 100, yBy: -pointSize / 100)
flip.concat()
NSColor.black.setStroke()
asterisk().stroke()
NSColor.black.setFill()
coat().fill()
image.unlockFocus()
image.isTemplate = true
return image
}
private static func asterisk() -> NSBezierPath {
let path = NSBezierPath()
for index in 0..<rayCount {
let angle = (CGFloat(index) * 360 / CGFloat(rayCount) + 8) * .pi / 180
let radius = rayRadius * rayLengths[index]
path.move(to: asteriskCentre)
path.line(to: NSPoint(x: asteriskCentre.x + radius * cos(angle),
y: asteriskCentre.y + radius * sin(angle)))
}
path.lineWidth = rayWidth
path.lineCapStyle = .round
return path
}
// Read along the top edge from one side to the other: shoulder, a slight
// tilt up, a flat collar top, the valley at the neck, then the mirror of
// that. Square sides and a flat hem below.
//
// The flat sections are 13 units wide because anything narrower stops
// reading as flat at this size and just looks like part of the slope
// 13 units is roughly 2.3px at 18pt, about the floor for that.
private static func coat() -> NSBezierPath {
let path = NSBezierPath()
path.move(to: NSPoint(x: 15, y: 72)) // left shoulder
path.line(to: NSPoint(x: 27, y: 60)) // slight tilt up
path.line(to: NSPoint(x: 40, y: 60)) // flat collar top
path.line(to: NSPoint(x: 50, y: 78)) // valley at the neck
path.line(to: NSPoint(x: 60, y: 60))
path.line(to: NSPoint(x: 73, y: 60))
path.line(to: NSPoint(x: 85, y: 72)) // right shoulder
path.line(to: NSPoint(x: 85, y: 94))
path.line(to: NSPoint(x: 15, y: 94))
path.close()
return path
}
}
+1 -1
View File
@@ -1 +1 @@
0.0.7
0.0.9
+58
View File
@@ -13,6 +13,29 @@ 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}"
@@ -76,6 +99,41 @@ 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)")
+174 -75
View File
@@ -1,8 +1,11 @@
// Generates a placeholder AppIcon.iconset a plain rounded-square
// background with the coat glyph on top. Deliberately not derived from
// Claude's own icon assets (unlike the old version of this file, which
// composited on top of Claude.app's icon) this is a stand-in until a
// real icon is designed, so it draws everything itself from vector shapes.
// Generates AppIcon.iconset Claude's asterisk above a rack of overcoats, on
// Claude's own coral. The wardrobe is the app in one picture: several coats to
// pick between, one currently worn.
//
// Everything is drawn from vector paths here rather than exported from a
// design tool, so a size can be tuned independently (small sizes need fatter
// strokes than a straight downscale gives) and nothing binary but the derived
// .icns is committed. Deliberately not derived from Claude's own icon assets.
//
// Usage:
// swift icon/generate-icon.swift icon/AppIcon.iconset
@@ -10,85 +13,182 @@
// rm -rf icon/AppIcon.iconset
import Cocoa
// Claude's coral and cloud, plus tints of each for depth. The lapel is a
// lighter grey than the coat and the back coats are darker corals tone
// separation survives downscaling, where outlines don't.
private enum Palette {
static func rgb(_ hex: UInt32) -> NSColor {
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
blue: CGFloat(hex & 255) / 255, alpha: 1)
}
static let tile = rgb(0xD9_77_57)
static let coat = rgb(0xF0_EE_E6)
static let lapel = rgb(0xB3_AA_97)
static let pocket = rgb(0xB8_AF_9C)
static let backLeft = rgb(0x8A_3E_2A)
static let backLeftPocket = rgb(0x6E_35_24)
static let backRight = rgb(0xB4_61_4A)
static let backRightPocket = rgb(0x94_4A_34)
}
private func p(_ x: CGFloat, _ y: CGFloat) -> NSPoint { NSPoint(x: x, y: y) }
// MARK: - Shapes, drawn in a 100x100 space with y pointing down
// Eight rays of slightly uneven length, a few degrees off the compass points
// a mechanically regular asterisk reads as a snowflake. Wider than tall, so it
// doesn't look like it's standing on end.
private func asterisk() -> NSBezierPath {
let centre = p(50, 24)
let tips = [p(65, 24), p(59, 33.3), p(49.5, 38.5), p(40.8, 32.6),
p(35, 23.7), p(40.8, 14.5), p(50.2, 9.8), p(58.6, 15.1)]
let path = NSBezierPath()
for tip in tips { path.move(to: centre); path.line(to: tip) }
path.lineWidth = 5.6
path.lineCapStyle = .round
return path
}
// One coat in its own 40x44 box, so the three can be placed at different
// scales and angles. The centre split is a true hole, so the tile shows through.
private func coatBody() -> NSBezierPath {
let b = NSBezierPath()
b.move(to: p(16, 2))
b.curve(to: p(3, 6.5), controlPoint1: p(12, 2.5), controlPoint2: p(6, 4))
b.curve(to: p(2.5, 42), controlPoint1: p(1.5, 16), controlPoint2: p(1.5, 30))
b.line(to: p(37.5, 42))
b.curve(to: p(37, 6.5), controlPoint1: p(38.5, 30), controlPoint2: p(38.5, 16))
b.curve(to: p(24, 2), controlPoint1: p(34, 4), controlPoint2: p(28, 2.5))
b.line(to: p(20, 26))
b.close()
b.appendRect(NSRect(x: 19.15, y: 26, width: 1.7, height: 16))
b.windingRule = .evenOdd
return b
}
// One connected band round the neck rather than two tabs on the shoulders,
// dipping at centre where the lapel opening begins. Its top corners sit at
// x15/x25 so the lapels cover them any further out and they poke through as
// spikes either side of the neck.
private func coatCollar() -> NSBezierPath {
let c = NSBezierPath()
c.move(to: p(15, -1.6)); c.line(to: p(25, -1.6))
c.line(to: p(29, 2.6)); c.line(to: p(21.6, 2.6))
c.line(to: p(20, 1)); c.line(to: p(18.4, 2.6))
c.line(to: p(11, 2.6))
c.close()
return c
}
// Notch lapels, each one shape running from the collar's outer corner down to
// the button point. The notch sits at (12.5, 8) pulled down and outward
// deliberately, because nearer the inner edge it pinches the polygon to a
// hairline and the lapel renders as two separate triangles.
private func coatLapels() -> NSBezierPath {
let l = NSBezierPath()
l.move(to: p(15, -1.6)); l.line(to: p(10.5, 3.5)); l.line(to: p(12.5, 8))
l.line(to: p(9, 14)); l.line(to: p(20, 26)); l.close()
l.move(to: p(25, -1.6)); l.line(to: p(29.5, 3.5)); l.line(to: p(27.5, 8))
l.line(to: p(31, 14)); l.line(to: p(20, 26)); l.close()
return l
}
// Filled marks, not holes a hole would show whichever coat is behind it.
private func coatPockets() -> NSBezierPath {
let path = NSBezierPath()
path.appendRect(NSRect(x: 6.5, y: 22, width: 9, height: 1.7))
path.appendRect(NSRect(x: 24.5, y: 22, width: 9, height: 1.7))
return path
}
// A hairline along the lapel edges separating the grey lapel from the tile
// showing through the opening. Deliberately fine: a detail for 128px and up.
// Thickening it to survive smaller sizes only blurs the lapel edge, because at
// this diagonal a one-pixel stroke covers a fraction of each pixel it crosses.
private func coatOpeningBorder() -> NSBezierPath {
let path = NSBezierPath()
path.move(to: p(15, -1.6)); path.line(to: p(20, 26)); path.line(to: p(25, -1.6))
path.lineWidth = 0.7
path.lineJoinStyle = .round
path.lineCapStyle = .round
return path
}
private func placed(_ path: NSBezierPath, x: CGFloat, y: CGFloat,
rotation: CGFloat, scale: CGFloat) -> NSBezierPath {
let transform = NSAffineTransform()
transform.translateX(by: x, yBy: y)
if rotation != 0 { transform.rotate(byDegrees: rotation) }
transform.scale(by: scale)
let copy = path.copy() as! NSBezierPath
copy.transform(using: transform as AffineTransform)
return copy
}
// Each coat is finished before the next is laid over it. Drawing all three
// bodies and then all three sets of details would put the back coats' collars
// and pockets on top of the front coat.
private func drawCoat(x: CGFloat, y: CGFloat, rotation: CGFloat, scale: CGFloat,
body: NSColor, pocket: NSColor, lapel: NSColor? = nil) {
body.setFill()
placed(coatBody(), x: x, y: y, rotation: rotation, scale: scale).fill()
placed(coatCollar(), x: x, y: y, rotation: rotation, scale: scale).fill()
if let lapel {
lapel.setFill()
placed(coatLapels(), x: x, y: y, rotation: rotation, scale: scale).fill()
body.setStroke()
let border = placed(coatOpeningBorder(), x: x, y: y, rotation: rotation, scale: scale)
border.lineWidth = 0.7 * scale
border.stroke()
}
pocket.setFill()
placed(coatPockets(), x: x, y: y, rotation: rotation, scale: scale).fill()
}
private func drawIcon() {
Palette.tile.setFill()
NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: 100, height: 100),
xRadius: 22, yRadius: 22).fill()
Palette.coat.setStroke()
asterisk().stroke()
drawCoat(x: 11, y: 50, rotation: -8, scale: 0.78,
body: Palette.backLeft, pocket: Palette.backLeftPocket)
drawCoat(x: 60, y: 47, rotation: 8, scale: 0.78,
body: Palette.backRight, pocket: Palette.backRightPocket)
drawCoat(x: 31.5, y: 44, rotation: 0, scale: 0.92,
body: Palette.coat, pocket: Palette.pocket, lapel: Palette.lapel)
}
// MARK: - Rendering
// Renders at exact pixel dimensions via an explicit NSBitmapImageRep rather
// than NSImage.lockFocus() lockFocus rasterizes at the *main screen's*
// backing scale factor, which would silently double every size on a Retina
// display instead of producing the exact px asked for.
func renderIcon(pixels: Int) -> Data {
let s = CGFloat(pixels)
private func renderIcon(pixels: Int) -> Data {
let side = CGFloat(pixels)
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixels,
pixelsHigh: pixels,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { fatalError("could not create bitmap rep") }
rep.size = NSSize(width: s, height: s)
rep.size = NSSize(width: side, height: side)
NSGraphicsContext.saveGraphicsState()
let ctx = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.current = ctx
ctx?.imageInterpolation = .high
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.current?.imageInterpolation = .high
// Base: a plain rounded-square background (macOS "squircle"-ish corner
// radius), a neutral slate color no borrowed assets.
let bg = NSColor(calibratedRed: 0.30, green: 0.32, blue: 0.36, alpha: 1.0)
let bgPath = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s),
xRadius: s * 0.22, yRadius: s * 0.22)
bg.setFill()
bgPath.fill()
// The shapes are authored y-down, the way the drawing reads; flip once
// here rather than mirroring every coordinate.
let flip = NSAffineTransform()
flip.translateX(by: 0, yBy: side)
flip.scaleX(by: side / 100, yBy: -side / 100)
flip.concat()
// Foreground: the same coat glyph as before an original shape, not
// derived from any borrowed icon.
let coat = NSColor(calibratedRed: 0.62, green: 0.44, blue: 0.24, alpha: 1.0)
let coatShadow = NSColor(calibratedRed: 0.48, green: 0.33, blue: 0.16, alpha: 1.0)
let midX = s * 0.5
let topY = s * 0.62
let collarOutX = s * 0.34
let collarInX = s * 0.11
let shoulderY = s * 0.53
let waistY = s * 0.30
let waistHalf = s * 0.30
let hemY = s * 0.06
let hemHalf = s * 0.40
drawIcon()
let path = NSBezierPath()
path.move(to: NSPoint(x: midX, y: topY))
path.line(to: NSPoint(x: midX - collarOutX, y: shoulderY))
path.line(to: NSPoint(x: midX - collarInX, y: shoulderY - s * 0.07))
path.line(to: NSPoint(x: midX - waistHalf, y: waistY))
path.line(to: NSPoint(x: midX - hemHalf, y: hemY))
path.line(to: NSPoint(x: midX + hemHalf, y: hemY))
path.line(to: NSPoint(x: midX + waistHalf, y: waistY))
path.line(to: NSPoint(x: midX + collarInX, y: shoulderY - s * 0.07))
path.line(to: NSPoint(x: midX + collarOutX, y: shoulderY))
path.close()
coat.setFill()
path.fill()
// A thin darker strip down the centerline reads as the coat's front
// seam/placket, breaking up the flat fill at larger sizes.
let seam = NSBezierPath()
seam.move(to: NSPoint(x: midX, y: topY))
seam.line(to: NSPoint(x: midX, y: hemY))
seam.lineWidth = max(s * 0.012, 1)
coatShadow.setStroke()
seam.stroke()
let buttonRadius = max(s * 0.02, 0.75)
for t: CGFloat in [0.20, 0.42, 0.64] {
let y = hemY + (shoulderY - hemY) * t
let r = NSRect(x: midX - buttonRadius, y: y - buttonRadius, width: buttonRadius * 2, height: buttonRadius * 2)
coatShadow.setFill()
NSBezierPath(ovalIn: r).fill()
}
ctx?.flushGraphics()
NSGraphicsContext.restoreGraphicsState()
guard let png = rep.representation(using: .png, properties: [:]) else {
@@ -108,8 +208,7 @@ let sizes: [(name: String, px: Int)] = [
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset"
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
for (name, px) in sizes {
let data = renderIcon(pixels: px)
let path = "\(outDir)/\(name).png"
try? data.write(to: URL(fileURLWithPath: path))
try? renderIcon(pixels: px).write(to: URL(fileURLWithPath: path))
print("wrote \(path)")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB