Compare commits
5
Commits
1762dd6afd
..
0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3a539edd | ||
|
|
9673dc2792 | ||
|
|
c6a7da54ca | ||
|
|
135db9b308 | ||
|
|
815f0306c7 |
-118
@@ -1,118 +0,0 @@
|
|||||||
# Local build configuration. Copy to `.env` (gitignored) and fill in.
|
|
||||||
#
|
|
||||||
# cp .env.example .env
|
|
||||||
#
|
|
||||||
# build.sh reads `.env` automatically if it exists. Anything already set in
|
|
||||||
# the environment wins over this file, so a one-off
|
|
||||||
#
|
|
||||||
# SHANNONCOAT_SIGN_IDENTITY= ./build.sh
|
|
||||||
#
|
|
||||||
# still forces an ad-hoc build without editing anything.
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Code-signing identity
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The common name of the code-signing certificate in your keychain. Not a
|
|
||||||
# secret — it's a label, not a credential — but it lives here so every build
|
|
||||||
# picks it up without you having to remember the variable.
|
|
||||||
#
|
|
||||||
# Why bother signing at all: macOS pins an *ad-hoc* signed app's
|
|
||||||
# Accessibility grant to its exact cdhash, which changes on every build. So
|
|
||||||
# an unsigned local build silently loses the permission each time you
|
|
||||||
# rebuild and reinstall, while still appearing listed and ticked under
|
|
||||||
# Privacy & Security → Accessibility. Signing with a certificate gives the
|
|
||||||
# app a stable designated requirement, and the grant survives.
|
|
||||||
#
|
|
||||||
# Leave empty (or delete the line) to build ad-hoc.
|
|
||||||
SHANNONCOAT_SIGN_IDENTITY="shannoncoat Signing"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Certificate material — only needed to publish the GitHub Actions secrets
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The build itself never reads these: once the certificate is imported, the
|
|
||||||
# private key lives in your keychain and codesign finds it by the identity
|
|
||||||
# name above. They're recorded here purely so the `gh secret set` commands
|
|
||||||
# at the bottom have somewhere to read from.
|
|
||||||
#
|
|
||||||
# Keep the .p12 OUTSIDE the working tree. A gitignored file is still one
|
|
||||||
# `git add -f`, one editor-indexed backup, or one shared folder away from
|
|
||||||
# leaking, and this one holds a private key.
|
|
||||||
#
|
|
||||||
# The $HOME below is expanded by the shell when you `source .env` (which is
|
|
||||||
# how the gh commands at the bottom read it). build.sh parses rather than
|
|
||||||
# sources this file, so it takes values literally — which costs nothing
|
|
||||||
# here, as the only variable it actually reads is the identity above.
|
|
||||||
SHANNONCOAT_P12_PATH="$HOME/.shannoncoat-signing/shannoncoat-signing.p12"
|
|
||||||
|
|
||||||
# The .p12 export password. Storing it in plaintext here is weaker than
|
|
||||||
# leaving it in your password manager and typing it when prompted — prefer
|
|
||||||
# leaving this field empty and letting `gh secret set` ask for it
|
|
||||||
# interactively.
|
|
||||||
#
|
|
||||||
# Leaving the *field* empty is fine. Exporting the .p12 itself with an
|
|
||||||
# empty password is not: macOS cannot import one, so CI would fail even
|
|
||||||
# though local builds carry on working (they import the PEM pair, which
|
|
||||||
# needs no password). Give the export a real password.
|
|
||||||
SHANNONCOAT_P12_PASSWORD=""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# One-time setup
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Generate a ten-year self-signed code-signing certificate (codesign refuses
|
|
||||||
# an expired one, and replacing it later resets every user's Accessibility
|
|
||||||
# permission again):
|
|
||||||
#
|
|
||||||
# mkdir -p ~/.shannoncoat-signing && chmod 700 ~/.shannoncoat-signing
|
|
||||||
# openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
|
||||||
# -keyout ~/.shannoncoat-signing/key.pem \
|
|
||||||
# -out ~/.shannoncoat-signing/cert.pem \
|
|
||||||
# -subj "/CN=shannoncoat Signing" \
|
|
||||||
# -addext "basicConstraints=critical,CA:false" \
|
|
||||||
# -addext "keyUsage=critical,digitalSignature" \
|
|
||||||
# -addext "extendedKeyUsage=critical,codeSigning"
|
|
||||||
#
|
|
||||||
# Bundle it as a .p12. `-legacy` is required: OpenSSL 3's default encoding
|
|
||||||
# is one macOS cannot read, and `security import` then fails with a
|
|
||||||
# misleading "MAC verification failed (wrong password?)".
|
|
||||||
#
|
|
||||||
# openssl pkcs12 -export -legacy \
|
|
||||||
# -inkey ~/.shannoncoat-signing/key.pem \
|
|
||||||
# -in ~/.shannoncoat-signing/cert.pem \
|
|
||||||
# -name "shannoncoat Signing" \
|
|
||||||
# -out ~/.shannoncoat-signing/shannoncoat-signing.p12
|
|
||||||
#
|
|
||||||
# Import for local builds, then delete the now-redundant loose private key
|
|
||||||
# (the .p12 remains your only backup, so keep that):
|
|
||||||
#
|
|
||||||
# security import ~/.shannoncoat-signing/shannoncoat-signing.p12 \
|
|
||||||
# -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign
|
|
||||||
# rm ~/.shannoncoat-signing/key.pem
|
|
||||||
#
|
|
||||||
# The first signed build raises a "codesign wants to use key…" dialog —
|
|
||||||
# choose Always Allow. Then re-grant Accessibility one final time; from
|
|
||||||
# then on it persists across rebuilds.
|
|
||||||
#
|
|
||||||
# Note that `security find-identity -v -p codesigning` will report "0 valid
|
|
||||||
# identities": -v filters to *trusted* certificates and a self-signed one
|
|
||||||
# reads as CSSMERR_TP_NOT_TRUSTED. codesign uses it regardless. Drop the -v
|
|
||||||
# to see it.
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Publishing the same certificate to GitHub Actions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Releases need this too — an ad-hoc release revokes the Accessibility
|
|
||||||
# permission of everyone who updates. .github/workflows/release.yml stays
|
|
||||||
# inert until all three secrets exist, so nothing breaks in the meantime.
|
|
||||||
#
|
|
||||||
# source .env
|
|
||||||
# base64 -i "$SHANNONCOAT_P12_PATH" | gh secret set SIGNING_CERTIFICATE_P12
|
|
||||||
# gh secret set SIGNING_IDENTITY --body "$SHANNONCOAT_SIGN_IDENTITY"
|
|
||||||
# gh secret set SIGNING_CERTIFICATE_PASSWORD # prompts, so it stays out of shell history
|
|
||||||
#
|
|
||||||
# This buys permission persistence, not Gatekeeper approval: a self-signed
|
|
||||||
# certificate isn't notarized, so downloads are still blocked on first open
|
|
||||||
# and need System Settings → Privacy & Security → Open Anyway.
|
|
||||||
@@ -21,94 +21,11 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: macos-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag || github.ref }}
|
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
|
- name: Build shannoncoat.app
|
||||||
run: ./build.sh
|
run: ./build.sh
|
||||||
env:
|
env:
|
||||||
@@ -117,39 +34,14 @@ jobs:
|
|||||||
# it re-derive that via `git describe`, which needs full tag
|
# it re-derive that via `git describe`, which needs full tag
|
||||||
# refs a CI runner's checkout isn't guaranteed to have fetched.
|
# refs a CI runner's checkout isn't guaranteed to have fetched.
|
||||||
SHANNONCOAT_RELEASE_BUILD: "1"
|
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
|
- name: Zip app bundle
|
||||||
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
|
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
tag_name: ${{ inputs.tag || github.ref_name }}
|
||||||
files: |
|
files: |
|
||||||
shannoncoat.app.zip
|
shannoncoat.app.zip
|
||||||
generate_release_notes: true
|
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
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
tag: ${{ steps.tag.outputs.tag }}
|
tag: ${{ steps.tag.outputs.tag }}
|
||||||
created: ${{ steps.tag.outputs.created }}
|
created: ${{ steps.tag.outputs.created }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -45,11 +45,5 @@ jobs:
|
|||||||
uses: ./.github/workflows/release.yml
|
uses: ./.github/workflows/release.yml
|
||||||
with:
|
with:
|
||||||
tag: ${{ needs.tag.outputs.tag }}
|
tag: ${{ needs.tag.outputs.tag }}
|
||||||
# A called workflow gets no secrets unless they're handed over, so
|
|
||||||
# without this the signing secrets are empty inside release.yml and it
|
|
||||||
# quietly builds ad-hoc — which is exactly what signing exists to
|
|
||||||
# prevent, and which its own ad-hoc guard misses, since that guard only
|
|
||||||
# fires when an identity was configured.
|
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
-12
@@ -7,18 +7,6 @@ icon/AppIcon.iconset/
|
|||||||
# Session handoff notes — local only, never committed
|
# Session handoff notes — local only, never committed
|
||||||
CHECKPOINT.md
|
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
|
# Local reference material (e.g. nested checkouts of other projects) — not
|
||||||
# part of this project, never meant to be committed.
|
# part of this project, never meant to be committed.
|
||||||
.scratch/
|
.scratch/
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||

|
|
||||||
|
|
||||||
Run multiple isolated [**\***](#isolation) Claude profiles on macOS — each one
|
|
||||||
its own Claude Desktop **and** its own paired Claude Code setup — and tell their
|
|
||||||
windows apart at a glance.
|
|
||||||
|
|
||||||
Your normal Claude install is left alone. It stays as it is and keeps working
|
|
||||||
normally; shannoncoat only adds profiles alongside it.
|
|
||||||
|
|
||||||
A menu-bar item allows quick switching and profile management.
|
|
||||||
|
|
||||||
---
|
|
||||||
> [!NOTE]
|
|
||||||
> shannoncoat started as the shell script in the first commit, written by hand.
|
|
||||||
Claude built the GUI app out of it — most of the Swift, the icon, and this
|
|
||||||
README — working from human direction, review, and dogfooding.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- macOS 13 or later
|
|
||||||
- Claude Desktop
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
1. Download `shannoncoat.app.zip` from the
|
|
||||||
[latest release](https://github.com/bdeshi/shannoncoat/releases/latest).
|
|
||||||
2. Unzip it and move **shannoncoat.app** to `/Applications` or `~/Applications`.
|
|
||||||
3. Open it. macOS will block the first launch, because the app isn't notarized.
|
|
||||||
Go to **System Settings → Privacy & Security**, find the blocked item, and
|
|
||||||
click **Open Anyway**.
|
|
||||||
4. When prompted, grant **Accessibility**. The window tags are drawn from
|
|
||||||
Accessibility data and won't appear without it.
|
|
||||||
|
|
||||||
## First run
|
|
||||||
|
|
||||||
With nothing running, shannoncoat offers a list of your profiles and starts
|
|
||||||
whichever you tick. You can start several at once.
|
|
||||||
|
|
||||||
## Everyday use
|
|
||||||
|
|
||||||
The menu bar shows a coloured dot per running profile — dot and name when only
|
|
||||||
one is up, dots alone when there are more (hover for the names).
|
|
||||||
|
|
||||||
Open the menu and:
|
|
||||||
|
|
||||||
- **Click a profile that isn't running** to switch to it. Every other running
|
|
||||||
profile is quit first, so you end up with just that one.
|
|
||||||
- **Click a profile that is already running** to bring its window forward,
|
|
||||||
leaving everything else where it is.
|
|
||||||
- **Shift-click a profile** to open it *alongside* whatever is already running.
|
|
||||||
|
|
||||||
A `⇆` marks the profile you last switched to. **Close Profile** quits one
|
|
||||||
profile; **Quit Claude** quits all of them and leaves shannoncoat running.
|
|
||||||
**Manage Profiles…** opens the main window.
|
|
||||||
|
|
||||||
Quitting Claude by hand, from the Dock, or via Force Quit is picked up within
|
|
||||||
about a second — the menu bar always reflects what is actually running.
|
|
||||||
|
|
||||||
## Window tags
|
|
||||||
|
|
||||||
Each managed Claude window carries a small tag in that profile's colour, so two
|
|
||||||
otherwise identical windows are easy to tell apart.
|
|
||||||
|
|
||||||
Drag a tag anywhere on its window; each profile remembers where you put its own.
|
|
||||||
If you'd rather have something smaller, **Settings → Window tag → Colored dot**
|
|
||||||
replaces the name chip with a plain dot.
|
|
||||||
|
|
||||||
## Profiles
|
|
||||||
|
|
||||||
**default** is your real, untouched Claude install. shannoncoat can start and
|
|
||||||
stop it but never manages it, so it gets no tag and can't be deleted.
|
|
||||||
|
|
||||||
To add a custom isolated profile, open **Manage Profiles… → +** and enter a
|
|
||||||
name. The directories are optional — leave them blank and the profile gets
|
|
||||||
`~/.shannoncoat/data/<name>/app` and `~/.shannoncoat/data/<name>/code`. Point
|
|
||||||
them anywhere you like if you'd rather keep a profile's data with a project.
|
|
||||||
|
|
||||||
Selecting a profile shows both paths, ready to copy.
|
|
||||||
|
|
||||||
Each profile is a small JSON file at `~/.shannoncoat/<name>.json`, named after
|
|
||||||
the profile. Two profiles may not share a directory — that would silently merge
|
|
||||||
their Claude sessions, so shannoncoat refuses to start and tells you which files
|
|
||||||
collide.
|
|
||||||
|
|
||||||
Deleting a profile removes only that pointer file. **Its Claude data stays on
|
|
||||||
disk**, so you can recreate the profile later and pick up where you left off. If
|
|
||||||
it's running, it's quit first.
|
|
||||||
|
|
||||||
## Settings
|
|
||||||
|
|
||||||
- **Launch at Login** — start shannoncoat automatically.
|
|
||||||
- **Window tag** — name chip or coloured dot.
|
|
||||||
- **Automatically check for updates** — once a day at most.
|
|
||||||
|
|
||||||
## Updates
|
|
||||||
|
|
||||||
shannoncoat compares its version against the one published on GitHub. The
|
|
||||||
automatic check posts a quiet notification; **Check for Updates…** in Settings
|
|
||||||
reports either way.
|
|
||||||
|
|
||||||
It never replaces itself. When a new version exists, download it from the
|
|
||||||
releases page and swap the app in yourself.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**The window tags don't appear.** Almost always Accessibility. Check
|
|
||||||
**System Settings → Privacy & Security → Accessibility**.
|
|
||||||
|
|
||||||
If shannoncoat is listed *and already ticked* but the tags still don't show,
|
|
||||||
select it, remove it with **−**, then add it back. This happens after replacing
|
|
||||||
the app with a build that has a different code signature; the old entry stays
|
|
||||||
visible but no longer applies, and toggling it off and on won't fix it.
|
|
||||||
|
|
||||||
**Claude Desktop can't be found.** shannoncoat looks in `/Applications` and
|
|
||||||
`~/Applications`. If yours is somewhere else, you'll be asked to locate it the
|
|
||||||
first time you start a profile.
|
|
||||||
|
|
||||||
**A profile won't quit.** Claude was asked to quit and declined — usually its
|
|
||||||
own "unsaved work" or "still generating" dialog. shannoncoat deliberately waits
|
|
||||||
rather than killing Claude underneath it. Answer the dialog.
|
|
||||||
|
|
||||||
## Build from source
|
|
||||||
|
|
||||||
Local builds require the Xcode Command Line Tools.
|
|
||||||
```
|
|
||||||
xcode-select --install
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the build script
|
|
||||||
```bash
|
|
||||||
./build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The app is written to `.build/shannoncoat.app`; copy it to `/Applications` or
|
|
||||||
`~/Applications`.
|
|
||||||
|
|
||||||
One thing worth setting up: an unsigned local build gets a new code identity
|
|
||||||
every time, which silently invalidates its Accessibility grant on each rebuild.
|
|
||||||
`.env.example` walks through creating a self-signed certificate once so the
|
|
||||||
permission survives.
|
|
||||||
|
|
||||||
## Isolation
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Perfect isolation is not guaranteed. Claude and Claude Code can have functionally
|
|
||||||
> full disk access regardless of which profile it's running from, including access
|
|
||||||
> to default configuration folders.
|
|
||||||
>
|
|
||||||
> Some Claude integrations — skills, plugins, etc — may also hardcode the
|
|
||||||
> `~/.claude` path which may modify content in there.
|
|
||||||
+12
-122
@@ -2,7 +2,6 @@
|
|||||||
// Reads live state from LiveState (push-based, no polling) and calls
|
// Reads live state from LiveState (push-based, no polling) and calls
|
||||||
// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing.
|
// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing.
|
||||||
import AppKit
|
import AppKit
|
||||||
import ApplicationServices
|
|
||||||
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
|
||||||
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
@@ -12,44 +11,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
|||||||
private var collisionAlertShown = false
|
private var collisionAlertShown = false
|
||||||
private var windowOverlays: [String: WindowOverlay] = [:]
|
private var windowOverlays: [String: WindowOverlay] = [:]
|
||||||
private var pendingWindowOverlays: Set<String> = []
|
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) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
menu.delegate = self
|
menu.delegate = self
|
||||||
menu.autoenablesItems = false
|
menu.autoenablesItems = false
|
||||||
statusItem.menu = menu
|
statusItem.menu = menu
|
||||||
statusItem.button?.image = MenuBarIcon.image()
|
statusItem.button?.image = menuBarIcon()
|
||||||
statusItem.button?.imagePosition = .imageLeft
|
statusItem.button?.imagePosition = .imageLeft
|
||||||
|
|
||||||
liveState.delegate = self
|
liveState.delegate = self
|
||||||
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
|
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()
|
liveState.reconcile()
|
||||||
|
|
||||||
// Launching this app fresh with nothing running means there's no
|
// Launching this app fresh with nothing running means there's no
|
||||||
@@ -62,12 +33,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
|||||||
UpdateChecker.checkAutomaticallyIfDue()
|
UpdateChecker.checkAutomaticallyIfDue()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The menu bar draws its own glyph (see MenuBarIcon) rather than the app
|
// Claude's own app icon (bundled as Contents/Resources/AppIcon.icns —
|
||||||
// icon scaled down, which is what it used to do: at 18pt the app icon's
|
// currently a placeholder, see icon/generate-icon.swift), scaled down
|
||||||
// three coats collapse into a smudge, and its colours ignore the menu
|
// for the menu bar.
|
||||||
// bar's appearance entirely. MenuBarIcon returns a template image, so
|
private func menuBarIcon() -> NSImage {
|
||||||
// macOS tints it for light/dark and for the open-menu inversion, and
|
let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage()
|
||||||
// re-tints it by itself when the theme changes.
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - LiveStateDelegate
|
// MARK: - LiveStateDelegate
|
||||||
|
|
||||||
@@ -79,47 +55,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
|||||||
updateWindowOverlays(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",
|
// Shown on every running *managed* profile — never on "default",
|
||||||
// which isn't a shannoncoat profile at all, just the real underlying
|
// which isn't a shannoncoat profile at all, just the real underlying
|
||||||
// Claude install. Removing an entry here deinits its WindowOverlay,
|
// Claude install. Removing an entry here deinits its WindowOverlay,
|
||||||
@@ -130,32 +65,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
|||||||
windowOverlays = windowOverlays.filter { managedNames.contains($0.key) }
|
windowOverlays = windowOverlays.filter { managedNames.contains($0.key) }
|
||||||
pendingWindowOverlays.formIntersection(managedNames)
|
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
|
for info in managed
|
||||||
where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) {
|
where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) {
|
||||||
guard let pid = info.pid else { continue }
|
guard let pid = info.pid else { continue }
|
||||||
@@ -168,15 +77,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
|||||||
// for its window — don't attach a stale overlay if so.
|
// for its window — don't attach a stale overlay if so.
|
||||||
guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return }
|
guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return }
|
||||||
windowOverlays[name] = overlay
|
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
|
// Two profiles sharing a dir is a fatal misconfiguration (would
|
||||||
@@ -360,19 +262,7 @@ 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) {
|
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 }
|
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
|
||||||
guard !others.isEmpty else { openProfile(profile); return }
|
guard !others.isEmpty else { openProfile(profile); return }
|
||||||
var remaining = others.count
|
var remaining = others.count
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ enum ClaudeControl {
|
|||||||
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func promptForAccessibility() {
|
private static func promptForAccessibility() {
|
||||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
||||||
_ = AXIsProcessTrustedWithOptions(options)
|
_ = AXIsProcessTrustedWithOptions(options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,6 @@ final class ManageWindowController: NSWindowController {
|
|||||||
didSet { profilesVC.onChange = onProfilesChanged }
|
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() {
|
private init() {
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
||||||
@@ -509,32 +502,11 @@ private final class SettingsViewController: NSViewController {
|
|||||||
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
|
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
|
||||||
private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", 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 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!
|
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() {
|
override func loadView() {
|
||||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
|
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.target = self
|
||||||
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
|
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
|
||||||
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
|
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
|
||||||
@@ -547,7 +519,7 @@ private final class SettingsViewController: NSViewController {
|
|||||||
checkNowButton.action = #selector(checkNow)
|
checkNowButton.action = #selector(checkNow)
|
||||||
checkNowButton.bezelStyle = .rounded
|
checkNowButton.bezelStyle = .rounded
|
||||||
|
|
||||||
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
|
stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
|
||||||
stack.orientation = .vertical
|
stack.orientation = .vertical
|
||||||
stack.alignment = .leading
|
stack.alignment = .leading
|
||||||
stack.spacing = 14
|
stack.spacing = 14
|
||||||
@@ -578,24 +550,12 @@ private final class SettingsViewController: NSViewController {
|
|||||||
// is what AutoLayout itself uses to size an unconstrained control —
|
// is what AutoLayout itself uses to size an unconstrained control —
|
||||||
// sidesteps whatever the stack's own aggregation is getting wrong.
|
// sidesteps whatever the stack's own aggregation is getting wrong.
|
||||||
private func updatePreferredSize() {
|
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]
|
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
|
||||||
.map(\.intrinsicContentSize.width)
|
.map(\.intrinsicContentSize.width)
|
||||||
.max().map { max($0, rowWidth) } ?? rowWidth
|
.max() ?? 200
|
||||||
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
|
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() {
|
@objc private func toggleLaunchAtLogin() {
|
||||||
do {
|
do {
|
||||||
if launchAtLoginCheckbox.state == .on {
|
if launchAtLoginCheckbox.state == .on {
|
||||||
@@ -640,6 +600,7 @@ private final class SettingsViewController: NSViewController {
|
|||||||
|
|
||||||
// MARK: - About tab
|
// MARK: - About tab
|
||||||
|
|
||||||
|
private let projectAuthor = "bdeshi"
|
||||||
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
|
private let projectHomepage = "https://github.com/bdeshi/shannoncoat"
|
||||||
|
|
||||||
private final class AboutViewController: NSViewController {
|
private final class AboutViewController: NSViewController {
|
||||||
@@ -667,36 +628,19 @@ private final class AboutViewController: NSViewController {
|
|||||||
icon.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
|
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:
|
let description = NSTextField(wrappingLabelWithString:
|
||||||
"shannoncoat runs multiple Claude Desktop profiles side by side, each paired with its own "
|
"Runs multiple Claude Desktop profiles side by side, each paired with its own isolated Claude Code config.")
|
||||||
+ "isolated Claude Code config.")
|
|
||||||
description.font = .systemFont(ofSize: NSFont.systemFontSize)
|
description.font = .systemFont(ofSize: NSFont.systemFontSize)
|
||||||
|
|
||||||
|
let authorField = NSTextField(labelWithString: "Author: \(projectAuthor)")
|
||||||
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
|
let sourceField = NSTextField(labelWithString: "Source: \(projectHomepage)")
|
||||||
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
|
let versionField = NSTextField(labelWithString: "Version: \(UpdateChecker.displayVersion)")
|
||||||
for field in [sourceField, versionField] {
|
for field in [authorField, sourceField, versionField] {
|
||||||
field.textColor = .secondaryLabelColor
|
field.textColor = .secondaryLabelColor
|
||||||
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
field.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
let stack = NSStackView(views: [iconContainer, taglineContainer, description,
|
let stack = NSStackView(views: [iconContainer, description, authorField, sourceField, versionField])
|
||||||
sourceField, versionField])
|
|
||||||
stack.orientation = .vertical
|
stack.orientation = .vertical
|
||||||
stack.alignment = .leading
|
stack.alignment = .leading
|
||||||
stack.spacing = 10
|
stack.spacing = 10
|
||||||
@@ -710,7 +654,6 @@ private final class AboutViewController: NSViewController {
|
|||||||
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
|
description.widthAnchor.constraint(equalToConstant: Self.contentWidth),
|
||||||
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
|
iconContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
|
||||||
taglineContainer.widthAnchor.constraint(equalTo: description.widthAnchor),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
view.layoutSubtreeIfNeeded()
|
view.layoutSubtreeIfNeeded()
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
// The menu bar's own glyph — Claude's asterisk over a coat's collar and
|
|
||||||
// shoulders. Drawn here rather than scaled down from the app icon, which is
|
|
||||||
// what the menu bar used to show: at 18pt the app icon's three coats, lapels
|
|
||||||
// and pockets collapse into a smudge, and its colours fight the menu bar
|
|
||||||
// instead of following it.
|
|
||||||
//
|
|
||||||
// Returned as a *template* image, which is what makes it track the system
|
|
||||||
// theme with no code of ours involved. A template carries only an alpha
|
|
||||||
// channel; macOS supplies the colour — dark on a light menu bar, light on a
|
|
||||||
// dark one, inverted again while the menu is open — and re-tints it on the fly
|
|
||||||
// when the theme switches. That also dictates the drawing: no fills of our
|
|
||||||
// own, and any internal detail has to be a hole punched through the alpha.
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
enum MenuBarIcon {
|
|
||||||
// Six rays rather than the app icon's eight. At 18pt an eight-ray asterisk
|
|
||||||
// has under a pixel of gap between arms, so they merge into a blob; six
|
|
||||||
// resolve as separate arms. Lengths stay slightly uneven, as on the app
|
|
||||||
// icon, so it doesn't read as a snowflake.
|
|
||||||
private static let rayCount = 6
|
|
||||||
private static let rayLengths: [CGFloat] = [1.0, 0.88, 0.97, 0.86, 1.0, 0.9]
|
|
||||||
private static let rayRadius: CGFloat = 22
|
|
||||||
private static let rayWidth: CGFloat = 7.2
|
|
||||||
private static let asteriskCentre = NSPoint(x: 50, y: 28)
|
|
||||||
|
|
||||||
static func image(pointSize: CGFloat = 18) -> NSImage {
|
|
||||||
let image = NSImage(size: NSSize(width: pointSize, height: pointSize))
|
|
||||||
image.lockFocusFlipped(false)
|
|
||||||
|
|
||||||
// Authored y-down like the app icon; flip once rather than mirroring
|
|
||||||
// every coordinate.
|
|
||||||
let flip = NSAffineTransform()
|
|
||||||
flip.translateX(by: 0, yBy: pointSize)
|
|
||||||
flip.scaleX(by: pointSize / 100, yBy: -pointSize / 100)
|
|
||||||
flip.concat()
|
|
||||||
|
|
||||||
NSColor.black.setStroke()
|
|
||||||
asterisk().stroke()
|
|
||||||
NSColor.black.setFill()
|
|
||||||
coat().fill()
|
|
||||||
|
|
||||||
image.unlockFocus()
|
|
||||||
image.isTemplate = true
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func asterisk() -> NSBezierPath {
|
|
||||||
let path = NSBezierPath()
|
|
||||||
for index in 0..<rayCount {
|
|
||||||
let angle = (CGFloat(index) * 360 / CGFloat(rayCount) + 8) * .pi / 180
|
|
||||||
let radius = rayRadius * rayLengths[index]
|
|
||||||
path.move(to: asteriskCentre)
|
|
||||||
path.line(to: NSPoint(x: asteriskCentre.x + radius * cos(angle),
|
|
||||||
y: asteriskCentre.y + radius * sin(angle)))
|
|
||||||
}
|
|
||||||
path.lineWidth = rayWidth
|
|
||||||
path.lineCapStyle = .round
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read along the top edge from one side to the other: shoulder, a slight
|
|
||||||
// tilt up, a flat collar top, the valley at the neck, then the mirror of
|
|
||||||
// that. Square sides and a flat hem below.
|
|
||||||
//
|
|
||||||
// The flat sections are 13 units wide because anything narrower stops
|
|
||||||
// reading as flat at this size and just looks like part of the slope —
|
|
||||||
// 13 units is roughly 2.3px at 18pt, about the floor for that.
|
|
||||||
private static func coat() -> NSBezierPath {
|
|
||||||
let path = NSBezierPath()
|
|
||||||
path.move(to: NSPoint(x: 15, y: 72)) // left shoulder
|
|
||||||
path.line(to: NSPoint(x: 27, y: 60)) // slight tilt up
|
|
||||||
path.line(to: NSPoint(x: 40, y: 60)) // flat collar top
|
|
||||||
path.line(to: NSPoint(x: 50, y: 78)) // valley at the neck
|
|
||||||
path.line(to: NSPoint(x: 60, y: 60))
|
|
||||||
path.line(to: NSPoint(x: 73, y: 60))
|
|
||||||
path.line(to: NSPoint(x: 85, y: 72)) // right shoulder
|
|
||||||
path.line(to: NSPoint(x: 85, y: 94))
|
|
||||||
path.line(to: NSPoint(x: 15, y: 94))
|
|
||||||
path.close()
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,30 +32,6 @@ enum ProfileColor {
|
|||||||
return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
|
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
|
// `dimmed` marks a profile that isn't actually running right now — the
|
||||||
// colored dot still identifies it, but faded, so "open" vs "known but
|
// colored dot still identifies it, but faded, so "open" vs "known but
|
||||||
// closed" is visible without reading a tooltip.
|
// closed" is visible without reading a tooltip.
|
||||||
|
|||||||
+62
-435
@@ -8,203 +8,29 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ApplicationServices
|
import ApplicationServices
|
||||||
|
|
||||||
// How the tag is drawn. A dot is for when the colour alone already
|
// The tag's position relative to its window's top-right corner, shared
|
||||||
// identifies the window and the name is just clutter; it's never rotated,
|
// across every open tag and persisted so a drag survives a relaunch.
|
||||||
// having no reading direction to preserve.
|
// Anchored from the right/top edges (not left) because that's the corner
|
||||||
enum WindowOverlayStyle: String {
|
// that stays put under the common resize gesture (dragging the bottom-right
|
||||||
case chip, dot
|
// handle) — an offset-from-left would drift away from a user's chosen spot
|
||||||
}
|
// every time the window changed width.
|
||||||
|
enum WindowOverlayPosition {
|
||||||
|
private static let rightInsetKey = "WindowOverlayRightInset"
|
||||||
|
private static let topInsetKey = "WindowOverlayTopInset"
|
||||||
|
|
||||||
// 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/
|
// Top-right by default — top-left is where every window's close/
|
||||||
// minimize/zoom controls live, which the tag must never sit over.
|
// minimize/zoom controls live, which the tag must never sit over.
|
||||||
var corner: WindowOverlayCorner = .topRight
|
static let defaultRightInset: CGFloat = 10
|
||||||
var insetX: CGFloat = 8
|
static let defaultTopInset: CGFloat = 6
|
||||||
var insetY: CGFloat = 6
|
|
||||||
var isVertical: Bool = false
|
|
||||||
|
|
||||||
private static func key(_ field: String, _ profile: String) -> String {
|
static var rightInset: CGFloat {
|
||||||
"WindowOverlay\(field).\(profile)"
|
get { (UserDefaults.standard.object(forKey: rightInsetKey) as? Double).map { CGFloat($0) } ?? defaultRightInset }
|
||||||
|
set { UserDefaults.standard.set(Double(newValue), forKey: rightInsetKey) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// From the version that kept a single shared offset. Read as a seed for
|
static var topInset: CGFloat {
|
||||||
// a profile with nothing stored yet, so an existing tag stays where it
|
get { (UserDefaults.standard.object(forKey: topInsetKey) as? Double).map { CGFloat($0) } ?? defaultTopInset }
|
||||||
// already is instead of jumping on the first launch after upgrading.
|
set { UserDefaults.standard.set(Double(newValue), forKey: topInsetKey) }
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,34 +39,17 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
|
|||||||
private let axWindow: AXUIElement
|
private let axWindow: AXUIElement
|
||||||
private var observer: AXObserver?
|
private var observer: AXObserver?
|
||||||
private let panel: NSPanel
|
private let panel: NSPanel
|
||||||
private let tagView: TagView
|
|
||||||
private let profileName: String
|
private static let tagSize = NSSize(width: 110, height: 20)
|
||||||
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) {
|
init?(profileName: String, color: NSColor, pid: pid_t) {
|
||||||
let axApp = AXUIElementCreateApplication(pid)
|
let axApp = AXUIElementCreateApplication(pid)
|
||||||
guard let window = Self.firstWindow(of: axApp) else { return nil }
|
guard let window = Self.firstWindow(of: axApp) else { return nil }
|
||||||
self.pid = pid
|
self.pid = pid
|
||||||
self.axWindow = window
|
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(
|
let panel = NSPanel(
|
||||||
contentRect: NSRect(origin: .zero, size: NSSize(width: 1, height: 1)),
|
contentRect: NSRect(origin: .zero, size: Self.tagSize),
|
||||||
styleMask: [.borderless, .nonactivatingPanel],
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
backing: .buffered, defer: false)
|
backing: .buffered, defer: false)
|
||||||
panel.isOpaque = false
|
panel.isOpaque = false
|
||||||
@@ -252,8 +61,22 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
|
|||||||
// away from the Claude window underneath.
|
// away from the Claude window underneath.
|
||||||
panel.isMovableByWindowBackground = true
|
panel.isMovableByWindowBackground = true
|
||||||
panel.collectionBehavior = [.stationary, .ignoresCycle]
|
panel.collectionBehavior = [.stationary, .ignoresCycle]
|
||||||
panel.contentView = tagView
|
|
||||||
|
|
||||||
|
let background = NSView(frame: NSRect(origin: .zero, size: Self.tagSize))
|
||||||
|
background.wantsLayer = true
|
||||||
|
background.layer?.backgroundColor = color.cgColor
|
||||||
|
background.layer?.cornerRadius = 5
|
||||||
|
|
||||||
|
let label = NSTextField(labelWithString: profileName)
|
||||||
|
label.frame = NSRect(origin: .zero, size: Self.tagSize)
|
||||||
|
label.alignment = .center
|
||||||
|
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||||
|
label.textColor = .white
|
||||||
|
label.drawsBackground = false
|
||||||
|
label.lineBreakMode = .byTruncatingTail
|
||||||
|
background.addSubview(label)
|
||||||
|
|
||||||
|
panel.contentView = background
|
||||||
self.panel = panel
|
self.panel = panel
|
||||||
super.init()
|
super.init()
|
||||||
panel.delegate = self
|
panel.delegate = self
|
||||||
@@ -287,7 +110,6 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
dragSettle?.cancel()
|
|
||||||
stopObserving()
|
stopObserving()
|
||||||
panel.orderOut(nil)
|
panel.orderOut(nil)
|
||||||
}
|
}
|
||||||
@@ -327,232 +149,43 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
|
|||||||
width: axFrame.width, height: axFrame.height)
|
width: axFrame.width, height: axFrame.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentGeometry: (window: NSRect, screen: NSScreen)? {
|
// Positions the tag using the persisted (or default) offset from the
|
||||||
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame)
|
// window's top-right corner. Returns false (and hides the tag) if the
|
||||||
else { return nil }
|
// window has no readable frame right now — minimized, or Accessibility
|
||||||
return (Self.appKitFrame(of: axFrame, on: screen), screen)
|
// not granted.
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
@discardableResult
|
||||||
private func reposition() -> Bool {
|
private func reposition() -> Bool {
|
||||||
guard let (windowFrame, screen) = currentGeometry else {
|
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else {
|
||||||
panel.orderOut(nil)
|
panel.orderOut(nil)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let style = WindowOverlayPosition.style
|
let windowFrame = Self.appKitFrame(of: axFrame, on: screen)
|
||||||
tagView.style = style
|
let origin = NSPoint(
|
||||||
tagView.isVertical = style == .chip && placement.isVertical
|
x: windowFrame.maxX - WindowOverlayPosition.rightInset - Self.tagSize.width,
|
||||||
tagView.isLeftSide = placement.corner.isLeft
|
y: windowFrame.maxY - WindowOverlayPosition.topInset - Self.tagSize.height)
|
||||||
tagView.needsDisplay = true
|
panel.setFrameOrigin(origin)
|
||||||
|
if !panel.isVisible { panel.orderFrontRegardless() }
|
||||||
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
|
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
|
// Fires for every panel move — ours (from `reposition`, when the
|
||||||
// tracked window itself moves/resizes) and the user's (dragging the
|
// tracked window itself moves/resizes) and the user's (dragging the
|
||||||
// tag). Only the latter should redefine the placement, which is what
|
// tag). Either way, re-deriving the offset from the panel's actual
|
||||||
// the `isRepositioning` guard filters for.
|
// current position and persisting it is correct: our own moves just
|
||||||
//
|
// recompute the same offset they were placed with, a no-op; a user
|
||||||
// Re-deriving the offset from our own moves too looks like it should
|
// drag captures the new spot they chose so it survives the next
|
||||||
// be a harmless no-op — recomputing the very offset we just positioned
|
// reposition and the next launch. Clamped to stay within the window
|
||||||
// against — but it isn't, because the two halves read the window at
|
// so a sloppy drag can't wander off it entirely.
|
||||||
// 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) {
|
func windowDidMove(_ notification: Notification) {
|
||||||
guard !isRepositioning else { return }
|
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else { return }
|
||||||
scheduleDragSettle()
|
let windowFrame = Self.appKitFrame(of: axFrame, on: screen)
|
||||||
}
|
let panelOrigin = panel.frame.origin
|
||||||
|
let rightInset = (windowFrame.maxX - (panelOrigin.x + Self.tagSize.width))
|
||||||
// A tag drag emits a continuous stream of moves, and re-anchoring on
|
.clamped(to: 0...(max(0, windowFrame.width - Self.tagSize.width)))
|
||||||
// each one would snap the tag out from under the cursor mid-gesture
|
let topInset = (windowFrame.maxY - (panelOrigin.y + Self.tagSize.height))
|
||||||
// (and flip its orientation repeatedly on the way past an edge). So
|
.clamped(to: 0...(max(0, windowFrame.height - Self.tagSize.height)))
|
||||||
// the placement is only committed once the moves stop, which is as
|
WindowOverlayPosition.rightInset = rightInset
|
||||||
// close to a "drag ended" signal as a background-movable window gets
|
WindowOverlayPosition.topInset = topInset
|
||||||
// — 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
|
// MARK: - Live tracking
|
||||||
@@ -583,14 +216,8 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
|
|||||||
guard let refcon else { return }
|
guard let refcon else { return }
|
||||||
let overlay = Unmanaged<WindowOverlay>.fromOpaque(refcon).takeUnretainedValue()
|
let overlay = Unmanaged<WindowOverlay>.fromOpaque(refcon).takeUnretainedValue()
|
||||||
switch notification as String {
|
switch notification as String {
|
||||||
case kAXUIElementDestroyedNotification:
|
case kAXUIElementDestroyedNotification, kAXWindowMiniaturizedNotification:
|
||||||
overlay.panel.orderOut(nil)
|
overlay.panel.orderOut(nil)
|
||||||
case kAXWindowMiniaturizedNotification:
|
|
||||||
overlay.isMinimized = true
|
|
||||||
overlay.reposition()
|
|
||||||
case kAXWindowDeminiaturizedNotification:
|
|
||||||
overlay.isMinimized = false
|
|
||||||
overlay.reposition()
|
|
||||||
default:
|
default:
|
||||||
overlay.reposition()
|
overlay.reposition()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,29 +13,6 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SOURCES_DIR="$SCRIPT_DIR/Sources"
|
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"
|
VERSION_FILE="$SCRIPT_DIR/VERSION"
|
||||||
ICON_SRC="$SCRIPT_DIR/icon/AppIcon.icns"
|
ICON_SRC="$SCRIPT_DIR/icon/AppIcon.icns"
|
||||||
OUT_DIR="${1:-$SCRIPT_DIR/.build}"
|
OUT_DIR="${1:-$SCRIPT_DIR/.build}"
|
||||||
@@ -99,41 +76,6 @@ printf '%s' "$GIT_SHA" >"$APP/Contents/Resources/COMMIT"
|
|||||||
|
|
||||||
swiftc -O "$SOURCES_DIR"/*.swift -o "$APP/Contents/MacOS/launcher"
|
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"
|
touch "$APP"
|
||||||
echo "Built: $APP"
|
echo "Built: $APP"
|
||||||
echo "Copy it to /Applications (or ~/Applications) to install."
|
echo "Copy it to /Applications (or ~/Applications) to install."
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
Binary file not shown.
@@ -1,83 +0,0 @@
|
|||||||
// Generates docs/hero.png — the banner at the top of the README.
|
|
||||||
//
|
|
||||||
// Composes the already-generated app icon with the wordmark and tagline rather
|
|
||||||
// than redrawing the coats, so the banner can never drift out of step with the
|
|
||||||
// icon itself. Run generate-icon.swift first.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
|
||||||
// cp icon/AppIcon.iconset/icon_512x512.png icon/shannoncoat.png
|
|
||||||
// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns
|
|
||||||
// rm -rf icon/AppIcon.iconset
|
|
||||||
// swift icon/generate-hero.swift icon/shannoncoat.png docs/hero.png
|
|
||||||
import Cocoa
|
|
||||||
|
|
||||||
private func rgb(_ hex: UInt32) -> NSColor {
|
|
||||||
NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, green: CGFloat((hex >> 8) & 255) / 255,
|
|
||||||
blue: CGFloat(hex & 255) / 255, alpha: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slate ground rather than cloud: GitHub renders READMEs on both a light and a
|
|
||||||
// dark page, and a dark banner sits comfortably on either, where a near-white
|
|
||||||
// one glares against a dark page. It also lets the coral icon carry the colour.
|
|
||||||
private let ground = rgb(0x26_26_25)
|
|
||||||
private let wordmark = rgb(0xF0_EE_E6)
|
|
||||||
private let subtitle = rgb(0xA8_A2_98)
|
|
||||||
|
|
||||||
private let width = 1200
|
|
||||||
private let height = 360
|
|
||||||
|
|
||||||
let args = CommandLine.arguments
|
|
||||||
let iconPath = args.count > 1 ? args[1] : "icon/shannoncoat.png"
|
|
||||||
let outPath = args.count > 2 ? args[2] : "docs/hero.png"
|
|
||||||
|
|
||||||
guard let icon = NSImage(contentsOfFile: iconPath) else {
|
|
||||||
fatalError("couldn't read \(iconPath) — run generate-icon.swift first")
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let rep = NSBitmapImageRep(
|
|
||||||
bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
|
|
||||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
|
||||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
|
||||||
) else { fatalError("could not create bitmap rep") }
|
|
||||||
rep.size = NSSize(width: width, height: height)
|
|
||||||
|
|
||||||
NSGraphicsContext.saveGraphicsState()
|
|
||||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
|
||||||
NSGraphicsContext.current?.imageInterpolation = .high
|
|
||||||
|
|
||||||
ground.setFill()
|
|
||||||
NSBezierPath(rect: NSRect(x: 0, y: 0, width: width, height: height)).fill()
|
|
||||||
|
|
||||||
let iconSide: CGFloat = 184
|
|
||||||
icon.draw(in: NSRect(x: 96, y: (CGFloat(height) - iconSide) / 2, width: iconSide, height: iconSide))
|
|
||||||
|
|
||||||
let textX: CGFloat = 96 + iconSide + 56
|
|
||||||
let name = NSAttributedString(string: "shannoncoat", attributes: [
|
|
||||||
.font: NSFont.systemFont(ofSize: 76, weight: .semibold),
|
|
||||||
.foregroundColor: wordmark,
|
|
||||||
])
|
|
||||||
let tagline = NSAttributedString(string: "which coat will Claude wear today?", attributes: [
|
|
||||||
.font: NSFont.systemFont(ofSize: 30, weight: .regular),
|
|
||||||
.foregroundColor: subtitle,
|
|
||||||
])
|
|
||||||
|
|
||||||
// Stacked around the vertical centre, using each string's own measured height
|
|
||||||
// so the pair sits optically centred against the icon rather than by guesswork.
|
|
||||||
let nameSize = name.size(), taglineSize = tagline.size()
|
|
||||||
let gap: CGFloat = 14
|
|
||||||
let blockHeight = nameSize.height + gap + taglineSize.height
|
|
||||||
let top = (CGFloat(height) + blockHeight) / 2
|
|
||||||
name.draw(at: NSPoint(x: textX, y: top - nameSize.height))
|
|
||||||
tagline.draw(at: NSPoint(x: textX + 3, y: top - nameSize.height - gap - taglineSize.height))
|
|
||||||
|
|
||||||
NSGraphicsContext.restoreGraphicsState()
|
|
||||||
|
|
||||||
let outURL = URL(fileURLWithPath: outPath)
|
|
||||||
try? FileManager.default.createDirectory(
|
|
||||||
at: outURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
||||||
guard let png = rep.representation(using: .png, properties: [:]) else {
|
|
||||||
fatalError("failed to encode hero PNG")
|
|
||||||
}
|
|
||||||
try! png.write(to: outURL)
|
|
||||||
print("wrote \(outPath)")
|
|
||||||
+75
-174
@@ -1,11 +1,8 @@
|
|||||||
// Generates AppIcon.iconset — Claude's asterisk above a rack of overcoats, on
|
// Generates a placeholder AppIcon.iconset — a plain rounded-square
|
||||||
// Claude's own coral. The wardrobe is the app in one picture: several coats to
|
// background with the coat glyph on top. Deliberately not derived from
|
||||||
// pick between, one currently worn.
|
// 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
|
||||||
// Everything is drawn from vector paths here rather than exported from a
|
// real icon is designed, so it draws everything itself from vector shapes.
|
||||||
// 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:
|
// Usage:
|
||||||
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
// swift icon/generate-icon.swift icon/AppIcon.iconset
|
||||||
@@ -13,182 +10,85 @@
|
|||||||
// rm -rf icon/AppIcon.iconset
|
// rm -rf icon/AppIcon.iconset
|
||||||
import Cocoa
|
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
|
// Renders at exact pixel dimensions via an explicit NSBitmapImageRep rather
|
||||||
// than NSImage.lockFocus() — lockFocus rasterizes at the *main screen's*
|
// than NSImage.lockFocus() — lockFocus rasterizes at the *main screen's*
|
||||||
// backing scale factor, which would silently double every size on a Retina
|
// backing scale factor, which would silently double every size on a Retina
|
||||||
// display instead of producing the exact px asked for.
|
// display instead of producing the exact px asked for.
|
||||||
private func renderIcon(pixels: Int) -> Data {
|
func renderIcon(pixels: Int) -> Data {
|
||||||
let side = CGFloat(pixels)
|
let s = CGFloat(pixels)
|
||||||
guard let rep = NSBitmapImageRep(
|
guard let rep = NSBitmapImageRep(
|
||||||
bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels,
|
bitmapDataPlanes: nil,
|
||||||
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
|
pixelsWide: pixels,
|
||||||
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
|
pixelsHigh: pixels,
|
||||||
|
bitsPerSample: 8,
|
||||||
|
samplesPerPixel: 4,
|
||||||
|
hasAlpha: true,
|
||||||
|
isPlanar: false,
|
||||||
|
colorSpaceName: .deviceRGB,
|
||||||
|
bytesPerRow: 0,
|
||||||
|
bitsPerPixel: 0
|
||||||
) else { fatalError("could not create bitmap rep") }
|
) else { fatalError("could not create bitmap rep") }
|
||||||
rep.size = NSSize(width: side, height: side)
|
rep.size = NSSize(width: s, height: s)
|
||||||
|
|
||||||
NSGraphicsContext.saveGraphicsState()
|
NSGraphicsContext.saveGraphicsState()
|
||||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
let ctx = NSGraphicsContext(bitmapImageRep: rep)
|
||||||
NSGraphicsContext.current?.imageInterpolation = .high
|
NSGraphicsContext.current = ctx
|
||||||
|
ctx?.imageInterpolation = .high
|
||||||
|
|
||||||
// The shapes are authored y-down, the way the drawing reads; flip once
|
// Base: a plain rounded-square background (macOS "squircle"-ish corner
|
||||||
// here rather than mirroring every coordinate.
|
// radius), a neutral slate color — no borrowed assets.
|
||||||
let flip = NSAffineTransform()
|
let bg = NSColor(calibratedRed: 0.30, green: 0.32, blue: 0.36, alpha: 1.0)
|
||||||
flip.translateX(by: 0, yBy: side)
|
let bgPath = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s),
|
||||||
flip.scaleX(by: side / 100, yBy: -side / 100)
|
xRadius: s * 0.22, yRadius: s * 0.22)
|
||||||
flip.concat()
|
bg.setFill()
|
||||||
|
bgPath.fill()
|
||||||
|
|
||||||
drawIcon()
|
// 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
|
||||||
|
|
||||||
|
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()
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
|
||||||
guard let png = rep.representation(using: .png, properties: [:]) else {
|
guard let png = rep.representation(using: .png, properties: [:]) else {
|
||||||
@@ -208,7 +108,8 @@ let sizes: [(name: String, px: Int)] = [
|
|||||||
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset"
|
let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset"
|
||||||
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
|
||||||
for (name, px) in sizes {
|
for (name, px) in sizes {
|
||||||
|
let data = renderIcon(pixels: px)
|
||||||
let path = "\(outDir)/\(name).png"
|
let path = "\(outDir)/\(name).png"
|
||||||
try? renderIcon(pixels: px).write(to: URL(fileURLWithPath: path))
|
try? data.write(to: URL(fileURLWithPath: path))
|
||||||
print("wrote \(path)")
|
print("wrote \(path)")
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user