3 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi 061ae098da Sign builds and releases with a stable code identity
swiftc linker-signs only the inner binary, leaving the bundle unsigned
and its codesign identifier as "launcher" rather than the bundle id. More
importantly it leaves the app ad-hoc signed, and macOS pins an ad-hoc
app's Accessibility grant to its exact cdhash instead of to a designated
requirement. Every build mints a new cdhash, so each rebuild-and-replace
silently revoked the permission while the app stayed listed and ticked
under Privacy & Security — and every release did the same to everyone who
updated. That was the root cause of the window tags never appearing.

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

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

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

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

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

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

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

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

Tag placement is also stored per profile rather than once for the whole
app. Two windows side by side are the case these tags exist for, and
wanting each one's tag somewhere different is the normal outcome — one
window's sidebar is not another's. Previously a single shared offset made
them look independent (only the dragged tag moved at once) while every
other tag snapped to it on its next reposition.

A tag is a floating panel, which puts it above every ordinary window on
the system rather than merely above the window it labels — so it hovered
over the browser, the editor, and everything else, even when its own
window was buried or on another Space. There is no cross-process way to
attach one window above another (addChildWindow is same-process only, and
the private ordering call window managers use is a one-shot that goes
stale on the next reorder), so each tag now checks whether the window it
labels is genuinely visible beneath it. The window server returns its list
front-to-back, so one pass answers it: anything overlapping the tag before
we reach our own window is covering it, and never reaching that window
means it isn't on screen at all.

Tag position is also clamped into whatever part of the window is on
screen. A window dragged half off the edge takes its anchored corner with
it, and a tag that follows it out of view identifies nothing. The clamp is
presentational only — the stored anchor is untouched, so the tag returns
to it once the window is fully back.

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

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

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

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

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

Also ignores CHECKPOINT.md, a local session-handoff note.
2026-08-05 11:40:56 +06:00
10 changed files with 1087 additions and 4 deletions
+118
View File
@@ -0,0 +1,118 @@
# Local build configuration. Copy to `.env` (gitignored) and fill in.
#
# cp .env.example .env
#
# build.sh reads `.env` automatically if it exists. Anything already set in
# the environment wins over this file, so a one-off
#
# SHANNONCOAT_SIGN_IDENTITY= ./build.sh
#
# still forces an ad-hoc build without editing anything.
# ---------------------------------------------------------------------------
# Code-signing identity
# ---------------------------------------------------------------------------
# The common name of the code-signing certificate in your keychain. Not a
# secret — it's a label, not a credential — but it lives here so every build
# picks it up without you having to remember the variable.
#
# Why bother signing at all: macOS pins an *ad-hoc* signed app's
# Accessibility grant to its exact cdhash, which changes on every build. So
# an unsigned local build silently loses the permission each time you
# rebuild and reinstall, while still appearing listed and ticked under
# Privacy & Security → Accessibility. Signing with a certificate gives the
# app a stable designated requirement, and the grant survives.
#
# Leave empty (or delete the line) to build ad-hoc.
SHANNONCOAT_SIGN_IDENTITY="shannoncoat Signing"
# ---------------------------------------------------------------------------
# Certificate material — only needed to publish the GitHub Actions secrets
# ---------------------------------------------------------------------------
# The build itself never reads these: once the certificate is imported, the
# private key lives in your keychain and codesign finds it by the identity
# name above. They're recorded here purely so the `gh secret set` commands
# at the bottom have somewhere to read from.
#
# Keep the .p12 OUTSIDE the working tree. A gitignored file is still one
# `git add -f`, one editor-indexed backup, or one shared folder away from
# leaking, and this one holds a private key.
#
# The $HOME below is expanded by the shell when you `source .env` (which is
# how the gh commands at the bottom read it). build.sh parses rather than
# sources this file, so it takes values literally — which costs nothing
# here, as the only variable it actually reads is the identity above.
SHANNONCOAT_P12_PATH="$HOME/.shannoncoat-signing/shannoncoat-signing.p12"
# The .p12 export password. Storing it in plaintext here is weaker than
# leaving it in your password manager and typing it when prompted — prefer
# leaving this field empty and letting `gh secret set` ask for it
# interactively.
#
# Leaving the *field* empty is fine. Exporting the .p12 itself with an
# empty password is not: macOS cannot import one, so CI would fail even
# though local builds carry on working (they import the PEM pair, which
# needs no password). Give the export a real password.
SHANNONCOAT_P12_PASSWORD=""
# ---------------------------------------------------------------------------
# One-time setup
# ---------------------------------------------------------------------------
# Generate a ten-year self-signed code-signing certificate (codesign refuses
# an expired one, and replacing it later resets every user's Accessibility
# permission again):
#
# mkdir -p ~/.shannoncoat-signing && chmod 700 ~/.shannoncoat-signing
# openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
# -keyout ~/.shannoncoat-signing/key.pem \
# -out ~/.shannoncoat-signing/cert.pem \
# -subj "/CN=shannoncoat Signing" \
# -addext "basicConstraints=critical,CA:false" \
# -addext "keyUsage=critical,digitalSignature" \
# -addext "extendedKeyUsage=critical,codeSigning"
#
# Bundle it as a .p12. `-legacy` is required: OpenSSL 3's default encoding
# is one macOS cannot read, and `security import` then fails with a
# misleading "MAC verification failed (wrong password?)".
#
# openssl pkcs12 -export -legacy \
# -inkey ~/.shannoncoat-signing/key.pem \
# -in ~/.shannoncoat-signing/cert.pem \
# -name "shannoncoat Signing" \
# -out ~/.shannoncoat-signing/shannoncoat-signing.p12
#
# Import for local builds, then delete the now-redundant loose private key
# (the .p12 remains your only backup, so keep that):
#
# security import ~/.shannoncoat-signing/shannoncoat-signing.p12 \
# -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign
# rm ~/.shannoncoat-signing/key.pem
#
# The first signed build raises a "codesign wants to use key…" dialog —
# choose Always Allow. Then re-grant Accessibility one final time; from
# then on it persists across rebuilds.
#
# Note that `security find-identity -v -p codesigning` will report "0 valid
# identities": -v filters to *trusted* certificates and a self-signed one
# reads as CSSMERR_TP_NOT_TRUSTED. codesign uses it regardless. Drop the -v
# to see it.
# ---------------------------------------------------------------------------
# Publishing the same certificate to GitHub Actions
# ---------------------------------------------------------------------------
# Releases need this too — an ad-hoc release revokes the Accessibility
# permission of everyone who updates. .github/workflows/release.yml stays
# inert until all three secrets exist, so nothing breaks in the meantime.
#
# source .env
# base64 -i "$SHANNONCOAT_P12_PATH" | gh secret set SIGNING_CERTIFICATE_P12
# gh secret set SIGNING_IDENTITY --body "$SHANNONCOAT_SIGN_IDENTITY"
# gh secret set SIGNING_CERTIFICATE_PASSWORD # prompts, so it stays out of shell history
#
# This buys permission persistence, not Gatekeeper approval: a self-signed
# certificate isn't notarized, so downloads are still blocked on first open
# and need System Settings → Privacy & Security → Open Anyway.
+108
View File
@@ -21,11 +21,94 @@ permissions:
jobs:
build:
runs-on: macos-latest
env:
# Not a secret (it's just the certificate's common name), and needed
# in a step `if:` — where the `secrets` context isn't available, but
# `env` is. Empty when signing isn't configured, which every step
# below treats as "fall back to ad-hoc".
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
# Releases need a *stable* code identity, not an ad-hoc one. macOS
# pins an ad-hoc app's Accessibility grant to its exact cdhash, which
# changes on every build — so each release would silently revoke the
# permission users had already granted, leaving a stale entry sitting
# in System Settings still ticked (and un-fixable by toggling it).
# A certificate gives the app a designated requirement that survives
# updates instead.
#
# This does NOT make the app Gatekeeper-clean: a self-signed cert
# isn't notarized, so downloads still need right-click → Open. Only
# a paid Developer ID plus notarization changes that.
#
# One-time setup, ideally with the same self-signed certificate used
# for local builds (create it with a long validity — codesign refuses
# to use an expired cert, and re-signing under a new one resets every
# user's permission again):
# security export -k login.keychain -t identities -f pkcs12 \
# -P '<password>' -o cert.p12
# base64 -i cert.p12
# then add three repository secrets — SIGNING_CERTIFICATE_P12 (that
# base64 blob), SIGNING_CERTIFICATE_PASSWORD, and SIGNING_IDENTITY
# (the certificate's common name).
#
# If you build the .p12 with OpenSSL 3 rather than exporting it, pass
# `-legacy`: its default AES-256/SHA-256 PKCS#12 encoding is one the
# macOS Security framework can't read, and `security import` fails
# with a misleading "MAC verification failed (wrong password?)".
- name: Import signing certificate
if: env.SIGNING_IDENTITY != ''
env:
CERT_P12: ${{ secrets.SIGNING_CERTIFICATE_P12 }}
CERT_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
# macOS cannot import an OpenSSL-produced PKCS#12 that has an
# empty password — the two disagree over the spec's empty-string
# vs. NULL password ambiguity, and `security import` reports it
# as "MAC verification failed (wrong password?)", which sends you
# hunting for a wrong password rather than a missing one. Say
# what's actually wrong instead.
#
# Easy to hit, because a local build never exercises this: the
# local keychain imports the PEM pair directly and needs no
# password at all.
if [[ -z "${CERT_PASSWORD:-}" ]]; then
echo "error: SIGNING_CERTIFICATE_PASSWORD is empty." >&2
echo "The .p12 must be exported with a non-empty password; regenerate it with" >&2
echo " openssl pkcs12 -export -legacy -inkey key.pem -in cert.pem -out cert.p12" >&2
echo "and update both SIGNING_CERTIFICATE_P12 and SIGNING_CERTIFICATE_PASSWORD." >&2
exit 1
fi
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
CERT_PATH="$RUNNER_TEMP/cert.p12"
printf '%s' "$CERT_P12" | base64 --decode > "$CERT_PATH"
# A dedicated throwaway keychain, not the login one: it starts
# unlocked, needs no interactive prompt, and is deleted at the
# end of the job regardless of outcome.
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign
# Without this, codesign hits an interactive "allow access to
# key?" prompt that nothing can answer on a headless runner.
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null
# codesign only searches keychains on the search list.
security list-keychain -d user -s "$KEYCHAIN" login.keychain-db
rm -f "$CERT_PATH"
- name: Build shannoncoat.app
run: ./build.sh
env:
@@ -34,6 +117,23 @@ jobs:
# it re-derive that via `git describe`, which needs full tag
# refs a CI runner's checkout isn't guaranteed to have fetched.
SHANNONCOAT_RELEASE_BUILD: "1"
# Empty when unconfigured, which build.sh reads as ad-hoc.
SHANNONCOAT_SIGN_IDENTITY: ${{ env.SIGNING_IDENTITY }}
# Catches a release that silently went out ad-hoc — the failure this
# whole arrangement exists to prevent, and one that's invisible in
# the artifact until someone's permission stops working.
- name: Verify signature
run: |
set -euo pipefail
codesign --verify --strict --verbose=2 ".build/shannoncoat.app"
codesign -dvvv ".build/shannoncoat.app" 2>&1 \
| grep -E 'Identifier=|Authority=|Signature=' || true
if [[ -n "$SIGNING_IDENTITY" ]] \
&& codesign -dvvv ".build/shannoncoat.app" 2>&1 | grep -q 'Signature=adhoc'; then
echo "error: signing was configured but the app is still ad-hoc signed" >&2
exit 1
fi
- name: Zip app bundle
run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip"
@@ -45,3 +145,11 @@ jobs:
files: |
shannoncoat.app.zip
generate_release_notes: true
# `if: always()` so a failed build can't leave the signing key
# sitting in a keychain on the runner.
- name: Clean up keychain
if: always() && env.SIGNING_IDENTITY != ''
run: |
security list-keychain -d user -s login.keychain-db || true
security delete-keychain "$RUNNER_TEMP/signing.keychain-db" || true
+15
View File
@@ -4,6 +4,21 @@
icon/claude-src/
icon/AppIcon.iconset/
# Session handoff notes — local only, never committed
CHECKPOINT.md
# Local build configuration, read by build.sh — holds the code-signing
# identity and, optionally, the certificate password. See .env.example for
# the shape of it. Never committed.
.env
# Belt and braces: private key material must never land in the repo, even
# gitignored. Keep the certificate itself outside the working tree (the
# setup notes in .env.example use ~/.shannoncoat-signing/) — this pattern
# only exists to catch a stray copy before it becomes a commit.
*.p12
*.pem
# Local reference material (e.g. nested checkouts of other projects) — not
# part of this project, never meant to be committed.
.scratch/
+116
View File
@@ -2,6 +2,7 @@
// Reads live state from LiveState (push-based, no polling) and calls
// ProfileStore/ClaudeControl in-process no subprocess, no text parsing.
import AppKit
import ApplicationServices
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -9,6 +10,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
let liveState = LiveState()
private var currentInfos: [ProfileInfo] = []
private var collisionAlertShown = false
private var windowOverlays: [String: WindowOverlay] = [:]
private var pendingWindowOverlays: Set<String> = []
private var accessibilityPromptShown = false
private var accessibilityRecheckScheduled = false
private var activationObserver: NSObjectProtocol?
func applicationDidFinishLaunching(_ notification: Notification) {
menu.delegate = self
@@ -19,6 +25,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
liveState.delegate = self
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
ManageWindowController.shared.onOverlayStyleChanged = { [weak self] in self?.rebuildWindowOverlays() }
// Every app activation, not just Claude's LiveState deliberately
// filters to the Claude binary, so on its own it never hears that
// some unrelated app came forward, which is exactly when the tags
// need to get out of the way.
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
) { [weak self] _ in self?.updateOverlayVisibility() }
liveState.reconcile()
// Launching this app fresh with nothing running means there's no
@@ -50,6 +65,107 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
currentInfos = infos
applyTitle(infos)
ManageWindowController.shared.update(infos)
updateWindowOverlays(infos)
}
// Each tag decides for itself whether the window it labels is actually
// visible underneath it (see WindowOverlay.isTargetVisible) this just
// says "now would be a good time to look again". Activation is the
// usual way a window gets buried or uncovered without moving at all,
// which no AX notification reports.
private func updateOverlayVisibility() {
for overlay in windowOverlays.values { overlay.refreshVisibility() }
}
// macOS doesn't notify an app that it has just been granted
// Accessibility, and overlays are otherwise only reconsidered when a
// Claude window launches, quits or activates so a permission granted
// while this is running would do nothing visible until the user
// happened to touch a Claude window, or restarted the app. Polling for
// it costs a cheap local check every couple of seconds, and only while
// the permission is actually missing: the moment it lands, the guard in
// `updateWindowOverlays` passes and this stops rescheduling itself.
//
// Also covers a subtler case replacing the installed bundle and
// relaunching immediately can start the app before TCC has settled on
// the new copy, and a one-shot check there would strand the tags for
// the rest of the session over a denial that was only ever momentary.
private func scheduleAccessibilityRecheck() {
guard !accessibilityRecheckScheduled else { return }
accessibilityRecheckScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self else { return }
accessibilityRecheckScheduled = false
updateWindowOverlays(currentInfos)
}
}
// A style change rewrites the tag's shape and size, which is decided
// when its panel is built so the existing ones are dropped (deinit
// tears down each panel and its AXObserver) and rebuilt rather than
// patched in place.
private func rebuildWindowOverlays() {
windowOverlays.removeAll()
updateWindowOverlays(currentInfos)
}
// Shown on every running *managed* profile never on "default",
// which isn't a shannoncoat profile at all, just the real underlying
// Claude install. Removing an entry here deinits its WindowOverlay,
// which tears down its AXObserver and hides the tag panel.
private func updateWindowOverlays(_ infos: [ProfileInfo]) {
let managed = infos.filter { $0.running && $0.profile.name != "default" }
let managedNames = Set(managed.map(\.profile.name))
windowOverlays = windowOverlays.filter { managedNames.contains($0.key) }
pendingWindowOverlays.formIntersection(managedNames)
// A tag is positioned entirely from Accessibility data, so without
// that permission there is nothing to position: `attach` would
// burn its dozen retries per profile and give up without a word,
// which reads as "the feature is broken" rather than "macOS said
// no". Prompt instead the same call `ClaudeControl.focus` makes
// for the same reason.
//
// Worth knowing when this bites, because it looks impossible:
// a local build gets a fresh code identity every time build.sh
// runs, and that silently invalidates an existing grant while
// leaving the app still listed *and still ticked* under Privacy &
// Security Accessibility. Re-granting means removing that stale
// row with "" and adding the newly built app back; toggling it
// off and on again is not enough. (Note that running the binary
// straight out of the bundle from a terminal will appear to work
// regardless it inherits the terminal's grant, not the app's,
// so it's useless for testing this.)
if !managed.isEmpty, !AXIsProcessTrusted() {
if !accessibilityPromptShown {
accessibilityPromptShown = true
ClaudeControl.promptForAccessibility()
}
scheduleAccessibilityRecheck()
return
}
for info in managed
where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) {
guard let pid = info.pid else { continue }
let name = info.profile.name
pendingWindowOverlays.insert(name)
WindowOverlay.attach(profileName: name, color: ProfileColor.dotColor(for: name), pid: pid) { [weak self] overlay in
guard let self else { return }
pendingWindowOverlays.remove(name)
// The profile may have quit again while this was retrying
// for its window don't attach a stale overlay if so.
guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return }
windowOverlays[name] = overlay
// A tag created while some other app is frontmost must not
// appear over it `attach` can complete seconds after the
// launch that triggered it, by which point focus has often
// moved on.
updateOverlayVisibility()
}
}
updateOverlayVisibility()
}
// Two profiles sharing a dir is a fatal misconfiguration (would
+1 -1
View File
@@ -182,7 +182,7 @@ enum ClaudeControl {
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
}
private static func promptForAccessibility() {
static func promptForAccessibility() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
+42 -2
View File
@@ -24,6 +24,13 @@ final class ManageWindowController: NSWindowController {
didSet { profilesVC.onChange = onProfilesChanged }
}
// Called when the window-tag style changes, so open tags can be
// rebuilt in the new style immediately rather than at the next
// launch/quit of a Claude window.
var onOverlayStyleChanged: (() -> Void)? {
didSet { settingsVC.onOverlayStyleChanged = onOverlayStyleChanged }
}
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
@@ -502,11 +509,32 @@ private final class SettingsViewController: NSViewController {
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil)
private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil)
private let tagStyleLabel = NSTextField(labelWithString: "Window tag:")
private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var tagStyleRow: NSStackView!
private var stack: NSStackView!
var onOverlayStyleChanged: (() -> Void)?
// Order matches the popup's items, so the selected index maps straight
// onto a style without a title-string comparison.
private static let tagStyles: [(title: String, style: WindowOverlayStyle)] = [
("Name chip", .chip),
("Colored dot", .dot),
]
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
tagStylePopUp.addItems(withTitles: Self.tagStyles.map(\.title))
tagStylePopUp.selectItem(at: Self.tagStyles.firstIndex { $0.style == WindowOverlayPosition.style } ?? 0)
tagStylePopUp.target = self
tagStylePopUp.action = #selector(changeTagStyle)
tagStyleRow = NSStackView(views: [tagStyleLabel, tagStylePopUp])
tagStyleRow.orientation = .horizontal
tagStyleRow.spacing = 8
launchAtLoginCheckbox.target = self
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
@@ -519,7 +547,7 @@ private final class SettingsViewController: NSViewController {
checkNowButton.action = #selector(checkNow)
checkNowButton.bezelStyle = .rounded
stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 14
@@ -550,12 +578,24 @@ private final class SettingsViewController: NSViewController {
// is what AutoLayout itself uses to size an unconstrained control
// sidesteps whatever the stack's own aggregation is getting wrong.
private func updatePreferredSize() {
// Same reasoning for the tag-style row: summed from its two
// controls plus the stack spacing rather than trusting the row's
// own fittingSize.
let rowWidth = tagStyleLabel.intrinsicContentSize.width
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
.map(\.intrinsicContentSize.width)
.max() ?? 200
.max().map { max($0, rowWidth) } ?? rowWidth
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
}
@objc private func changeTagStyle() {
let index = tagStylePopUp.indexOfSelectedItem
guard Self.tagStyles.indices.contains(index) else { return }
WindowOverlayPosition.style = Self.tagStyles[index].style
onOverlayStyleChanged?()
}
@objc private func toggleLaunchAtLogin() {
do {
if launchAtLoginCheckbox.state == .on {
+24
View File
@@ -32,6 +32,30 @@ enum ProfileColor {
return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
}
// Light or dark text, whichever reads better on the given background.
// Profile colours come off the full hue wheel (see `dotColor`), whose
// luminance varies enormously yellow against white text is barely
// legible where indigo against black text is worse so this can't be
// one fixed choice.
//
// Chooses on WCAG relative luminance rather than raw brightness,
// because the eye is roughly six times more sensitive to green than to
// blue and an unweighted average badly misjudges both. 0.179 is where
// contrast against white and against black come out equal, so it's the
// threshold that maximises contrast either way. Near-white and
// near-black rather than pure, which reads as less harsh at 10pt
// without measurably costing contrast.
static func contrastingTextColor(on background: NSColor) -> NSColor {
guard let rgb = background.usingColorSpace(.sRGB) else { return .white }
func linear(_ channel: CGFloat) -> CGFloat {
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
let luminance = 0.2126 * linear(rgb.redComponent)
+ 0.7152 * linear(rgb.greenComponent)
+ 0.0722 * linear(rgb.blueComponent)
return luminance > 0.179 ? NSColor(white: 0.10, alpha: 1) : NSColor(white: 0.98, alpha: 1)
}
// `dimmed` marks a profile that isn't actually running right now the
// colored dot still identifies it, but faded, so "open" vs "known but
// closed" is visible without reading a tooltip.
+604
View File
@@ -0,0 +1,604 @@
// A small floating tag pinned to a Claude window's corner, showing which
// profile it belongs to every profile launches the same app with the
// same icon and title, so there's otherwise no way to tell two open
// Claude windows apart at a glance. Kept in sync live via AXObserver
// notifications (move/resize/miniaturize/destroy) rather than polling
// only a persistent process can hold a watcher like this at all, which is
// exactly the gap the old one-shot CLI script couldn't close.
import AppKit
import ApplicationServices
// How the tag is drawn. A dot is for when the colour alone already
// identifies the window and the name is just clutter; it's never rotated,
// having no reading direction to preserve.
enum WindowOverlayStyle: String {
case chip, dot
}
// The window corner a tag is anchored to. Anchoring to the *nearest*
// corner rather than always the top-right is what holds a tag still
// through a resize: one parked near the bottom-left tracks that corner,
// so dragging the top-right handle no longer drags the tag with it.
enum WindowOverlayCorner: String {
case topLeft, topRight, bottomLeft, bottomRight
var isTop: Bool { self == .topLeft || self == .topRight }
var isLeft: Bool { self == .topLeft || self == .bottomLeft }
// Quadrant test rather than four distance comparisons same answer,
// and it stays well-defined for a tag sitting dead centre.
static func nearest(to point: NSPoint, in frame: NSRect) -> WindowOverlayCorner {
switch (point.y > frame.midY, point.x < frame.midX) {
case (true, true): return .topLeft
case (true, false): return .topRight
case (false, true): return .bottomLeft
case (false, false): return .bottomRight
}
}
}
// Where one profile's tag sits on its window, persisted so a drag survives
// a relaunch. Stored per profile rather than once for everything: two
// windows side by side are exactly the case these tags exist for, and
// wanting each one's tag somewhere different is the normal outcome one
// window's sidebar is not another's.
//
// Offsets are stored as the user dropped them and are never written back
// clamped. A window too small to honour the full offset shows the tag
// pushed in as far as it fits (see `WindowOverlay.frame(window:screen:)`), but the
// stored distance is left intact, so widening the window again restores
// the chosen spot instead of leaving the tag stuck where a temporary
// resize squeezed it.
struct WindowOverlayPlacement {
// Top-right by default top-left is where every window's close/
// minimize/zoom controls live, which the tag must never sit over.
var corner: WindowOverlayCorner = .topRight
var insetX: CGFloat = 8
var insetY: CGFloat = 6
var isVertical: Bool = false
private static func key(_ field: String, _ profile: String) -> String {
"WindowOverlay\(field).\(profile)"
}
// From the version that kept a single shared offset. Read as a seed for
// a profile with nothing stored yet, so an existing tag stays where it
// already is instead of jumping on the first launch after upgrading.
private static let legacyInsetXKey = "WindowOverlayRightInset"
private static let legacyInsetYKey = "WindowOverlayTopInset"
private static func number(_ defaults: UserDefaults, _ keys: String...) -> CGFloat? {
for key in keys {
if let value = defaults.object(forKey: key) as? Double { return CGFloat(value) }
}
return nil
}
static func load(for profile: String) -> WindowOverlayPlacement {
let defaults = UserDefaults.standard
var placement = WindowOverlayPlacement()
if let raw = defaults.string(forKey: key("Corner", profile)),
let corner = WindowOverlayCorner(rawValue: raw) {
placement.corner = corner
}
if let x = number(defaults, key("InsetX", profile), legacyInsetXKey) { placement.insetX = x }
if let y = number(defaults, key("InsetY", profile), legacyInsetYKey) { placement.insetY = y }
placement.isVertical = defaults.bool(forKey: key("Vertical", profile))
return placement
}
func save(for profile: String) {
let defaults = UserDefaults.standard
defaults.set(corner.rawValue, forKey: Self.key("Corner", profile))
defaults.set(Double(insetX), forKey: Self.key("InsetX", profile))
defaults.set(Double(insetY), forKey: Self.key("InsetY", profile))
defaults.set(isVertical, forKey: Self.key("Vertical", profile))
}
}
// Chip or dot. Unlike placement this really is one setting for the whole
// app it's a single control in Settings, not something chosen per window.
enum WindowOverlayPosition {
private static let styleKey = "WindowOverlayStyle"
static var style: WindowOverlayStyle {
get {
(UserDefaults.standard.string(forKey: styleKey)).flatMap(WindowOverlayStyle.init(rawValue:)) ?? .chip
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: styleKey) }
}
}
// Draws the tag itself. Custom drawing rather than a laid-out NSTextField
// because both of the things this has to get right centring the text on
// the chip's actual midline, and rotating the whole chip when it's parked
// against a side edge are a transform and two draw calls here, versus
// fighting a label's intrinsic baseline placement and its unrotatable
// frame.
private final class TagView: NSView {
var text: String
var color: NSColor
var style: WindowOverlayStyle
var isVertical: Bool
// Which side edge a vertical chip is against, which decides the way it
// reads: bottom-to-top on the left, top-to-bottom on the right, so the
// text always leans into the window rather than away from it. Ignored
// when horizontal.
var isLeftSide: Bool
static let thickness: CGFloat = 13
static let dotDiameter: CGFloat = 11
private static let horizontalPadding: CGFloat = 7
private static let maxLength: CGFloat = 140
static let font = NSFont.systemFont(ofSize: 10, weight: .semibold)
init(text: String, color: NSColor, style: WindowOverlayStyle, isVertical: Bool, isLeftSide: Bool) {
self.text = text
self.color = color
self.style = style
self.isVertical = isVertical
self.isLeftSide = isLeftSide
super.init(frame: .zero)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
// The chip's extent along its reading direction, before any rotation.
private static func chipLength(for text: String) -> CGFloat {
let width = (text as NSString).size(withAttributes: [.font: font]).width
return min(maxLength, (width + horizontalPadding * 2).rounded(.up))
}
// Rotation is a property of the drawing, not of the panel, so the
// panel's own width and height swap over for a vertical chip.
static func size(text: String, style: WindowOverlayStyle, isVertical: Bool) -> NSSize {
switch style {
case .dot:
return NSSize(width: dotDiameter, height: dotDiameter)
case .chip:
let length = chipLength(for: text)
return isVertical
? NSSize(width: thickness, height: length)
: NSSize(width: length, height: thickness)
}
}
override func draw(_ dirtyRect: NSRect) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
if style == .dot {
color.setFill()
NSBezierPath(ovalIn: bounds).fill()
return
}
ctx.saveGState()
defer { ctx.restoreGState() }
// Lay the chip out once, horizontally, around the origin then
// rotate the whole coordinate space if it belongs on a side edge.
// One layout path serves both orientations, so the vertical case
// can't drift out of step with the horizontal one.
ctx.translateBy(x: bounds.midX, y: bounds.midY)
if isVertical { ctx.rotate(by: isLeftSide ? .pi / 2 : -.pi / 2) }
let length = Self.chipLength(for: text)
let rect = NSRect(
x: -length / 2, y: -Self.thickness / 2, width: length, height: Self.thickness)
color.setFill()
NSBezierPath(roundedRect: rect, xRadius: Self.thickness / 2, yRadius: Self.thickness / 2).fill()
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = .center
paragraph.lineBreakMode = .byTruncatingTail
let attributes: [NSAttributedString.Key: Any] = [
.font: Self.font,
.foregroundColor: ProfileColor.contrastingTextColor(on: color),
.paragraphStyle: paragraph,
]
let attributed = NSAttributedString(string: text, attributes: attributes)
// Drawn into a rect exactly one line tall and centred on the
// chip's midline. Handing it the chip's full height instead would
// top-align the text inside it the reason the old label sat
// high rather than centred.
let lineHeight = attributed.size().height
attributed.draw(in: NSRect(
x: rect.minX + Self.horizontalPadding, y: rect.midY - lineHeight / 2,
width: rect.width - Self.horizontalPadding * 2, height: lineHeight))
}
}
final class WindowOverlay: NSObject, NSWindowDelegate {
let pid: pid_t
private let axWindow: AXUIElement
private var observer: AXObserver?
private let panel: NSPanel
private let tagView: TagView
private let profileName: String
private var placement: WindowOverlayPlacement
// True only while `reposition` is moving the panel itself see
// `windowDidMove`, which must ignore those moves.
private var isRepositioning = false
private var dragSettle: DispatchWorkItem?
private var isMinimized = false
init?(profileName: String, color: NSColor, pid: pid_t) {
let axApp = AXUIElementCreateApplication(pid)
guard let window = Self.firstWindow(of: axApp) else { return nil }
self.pid = pid
self.axWindow = window
self.profileName = profileName
let placement = WindowOverlayPlacement.load(for: profileName)
self.placement = placement
let style = WindowOverlayPosition.style
let tagView = TagView(
text: profileName, color: color, style: style,
isVertical: style == .chip && placement.isVertical,
isLeftSide: placement.corner.isLeft)
tagView.autoresizingMask = [.width, .height]
self.tagView = tagView
let panel = NSPanel(
contentRect: NSRect(origin: .zero, size: NSSize(width: 1, height: 1)),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .floating
// Draggable (so it can be moved off whatever it's obstructing),
// but .nonactivatingPanel keeps a click/drag from stealing focus
// away from the Claude window underneath.
panel.isMovableByWindowBackground = true
panel.collectionBehavior = [.stationary, .ignoresCycle]
panel.contentView = tagView
self.panel = panel
super.init()
panel.delegate = self
guard reposition() else { return nil }
startObserving()
}
// A cold Electron launch can take a second or more between the
// process starting (which is what triggers `updateWindowOverlays`,
// via NSWorkspace's launch notification) and its first window
// actually existing the same race ClaudeControl.focus's
// waitForWindow already handles for the same reason. `init?` can't
// retry on its own (once it returns nil, that attempt is done), so
// this polls it every quarter second for up to ~3s before giving up.
static func attach(
profileName: String, color: NSColor, pid: pid_t, attemptsRemaining: Int = 12,
completion: @escaping (WindowOverlay?) -> Void
) {
if let overlay = WindowOverlay(profileName: profileName, color: color, pid: pid) {
completion(overlay)
} else if attemptsRemaining > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
attach(
profileName: profileName, color: color, pid: pid, attemptsRemaining: attemptsRemaining - 1,
completion: completion)
}
} else {
completion(nil)
}
}
deinit {
dragSettle?.cancel()
stopObserving()
panel.orderOut(nil)
}
private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
let windows = value as? [AXUIElement], let first = windows.first
else { return nil }
return first
}
private static func frame(of window: AXUIElement) -> CGRect? {
var posValue: CFTypeRef?
var sizeValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posValue) == .success,
AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeValue) == .success,
CFGetTypeID(posValue) == AXValueGetTypeID(), CFGetTypeID(sizeValue) == AXValueGetTypeID()
else { return nil }
var point = CGPoint.zero
var size = CGSize.zero
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &point),
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
else { return nil }
return CGRect(origin: point, size: size)
}
private static func screen(containing axFrame: CGRect) -> NSScreen? {
NSScreen.screens.first { $0.frame.minX <= axFrame.midX && axFrame.midX <= $0.frame.maxX } ?? NSScreen.main
}
// AX coordinates are top-left-origin (screen top = y 0); AppKit screen
// coordinates are bottom-left-origin. Returns the window's frame
// translated into AppKit's space.
private static func appKitFrame(of axFrame: CGRect, on screen: NSScreen) -> NSRect {
NSRect(x: axFrame.origin.x, y: screen.frame.maxY - axFrame.origin.y - axFrame.height,
width: axFrame.width, height: axFrame.height)
}
private var currentGeometry: (window: NSRect, screen: NSScreen)? {
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame)
else { return nil }
return (Self.appKitFrame(of: axFrame, on: screen), screen)
}
// The tag's frame for a given window frame: measured inward from its
// anchored corner, with the offsets clamped so it always stays over
// the window no matter how small that window gets. Only the *drawn*
// position is clamped see WindowOverlayPlacement.
//
// Then clamped again, into whatever part of the window is actually on
// screen. A window dragged half off the edge takes its anchored corner
// with it, and a tag that follows it out of view identifies nothing
// the whole point is telling, at a glance, which profile the window you
// can still see belongs to. Clamped to the window's *visible* portion
// rather than to the screen at large so the tag stays on the thing it
// labels instead of drifting off onto its own. `visibleFrame` keeps it
// clear of the menu bar and Dock, which would hide it just as
// effectively as the screen edge.
//
// Both clamps are presentational only neither is written back to
// `placement`, which changes solely when the user drags the tag. So the
// anchor is remembered throughout, and the tag returns to it the moment
// the window is fully back on screen.
private func frame(window: NSRect, screen: NSScreen) -> NSRect {
let style = WindowOverlayPosition.style
let size = TagView.size(
text: profileName, style: style,
isVertical: style == .chip && placement.isVertical)
let corner = placement.corner
let insetX = placement.insetX
.clamped(to: 0...max(0, window.width - size.width))
let insetY = placement.insetY
.clamped(to: 0...max(0, window.height - size.height))
var origin = NSPoint(
x: corner.isLeft ? window.minX + insetX : window.maxX - insetX - size.width,
y: corner.isTop ? window.maxY - insetY - size.height : window.minY + insetY)
// A window entirely off screen leaves nothing to clamp into; fall
// back to the screen so the tag stays reachable rather than being
// pinned to an empty rect.
let onScreen = window.intersection(screen.visibleFrame)
let bounds = onScreen.isEmpty ? screen.visibleFrame : onScreen
origin.x = origin.x.clamped(to: bounds.minX...max(bounds.minX, bounds.maxX - size.width))
origin.y = origin.y.clamped(to: bounds.minY...max(bounds.minY, bounds.maxY - size.height))
return NSRect(origin: origin, size: size)
}
// Puts the tag where the current placement says it belongs. Returns
// false (and hides the tag) if the window has no readable frame right
// now minimized, or Accessibility not granted.
@discardableResult
private func reposition() -> Bool {
guard let (windowFrame, screen) = currentGeometry else {
panel.orderOut(nil)
return false
}
let style = WindowOverlayPosition.style
tagView.style = style
tagView.isVertical = style == .chip && placement.isVertical
tagView.isLeftSide = placement.corner.isLeft
tagView.needsDisplay = true
isRepositioning = true
defer { isRepositioning = false }
let tagFrame = frame(window: windowFrame, screen: screen)
panel.setFrame(tagFrame, display: true)
// The shadow is derived from the content's alpha, so a shape or
// size change leaves a stale one behind without this.
panel.invalidateShadow()
// Positioned either way only the showing is conditional, so a
// hidden tag still knows where it belongs and can be revealed later
// without recomputing anything.
if isMinimized || !isTargetVisible(tagFrame: tagFrame) {
panel.orderOut(nil)
} else if !panel.isVisible {
panel.orderFrontRegardless()
}
return true
}
// Re-evaluate whether the tag should currently be on screen. Called
// when some app activates, which is the usual way a window ends up
// buried or uncovered without moving at all.
func refreshVisibility() {
reposition()
}
// A tag is a floating panel, which puts it above every ordinary window
// on the system rather than merely above the window it belongs to. Left
// alone it hovers over the browser, the editor, everything even when
// its own window is buried or on another Space.
//
// There is no cross-process way to attach one window above another:
// `addChildWindow` is same-process only, and the private ordering call
// window managers use is a one-shot operation that goes stale the
// moment anything else reorders, so it wouldn't avoid this work either.
// So the tag is shown only when the window it labels is genuinely
// visible underneath it.
//
// The window server returns its list strictly front-to-back, which
// answers both halves in one pass: walk forward, and anything
// overlapping the tag before we reach our own window is covering it.
// Reaching our window with nothing in the way means the tag is showing
// real estate that actually belongs to that window; never reaching it
// means the window isn't on screen at all minimized, hidden, or on
// another Space and the tag has nothing to label.
private func isTargetVisible(tagFrame: NSRect) -> Bool {
guard let listing = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]
else { return true } // can't tell better a stray tag than a missing one
let tag = Self.cgRect(of: tagFrame)
let ownPid = getpid()
for entry in listing {
guard let owner = entry[kCGWindowOwnerPID as String] as? pid_t,
let boundsDict = entry[kCGWindowBounds as String],
let bounds = CGRect(dictionaryRepresentation: boundsDict as! CFDictionary)
else { continue }
// Identified by owner alone, deliberately. Matching the exact
// rect meant comparing a freshly-read AX frame against the
// window server's snapshot, and mid-move the two disagree
// AX already reports the new position while the listing still
// has the old one. The match then failed, the walk continued
// past our own window, and the first unrelated window that
// happened to overlap the tag "occluded" it. Whether that
// occurred came down to what was nearby, which is why it
// looked as though only certain drag directions broke it.
//
// A dialog of the same app landing here counts as reaching our
// window, which is right: Claude covering its own window is
// not a reason to disown the tag.
if owner == pid, bounds.intersects(tag) { return true }
// Our own tags are in this list too, and one tag sitting over
// another's window must not hide it.
if owner == ownPid { continue }
// Only ordinary windows can bury another ordinary window.
// Everything above that band the menu bar, the Dock,
// notification banners, and the invisible one-pixel markers
// some utilities park in a screen corner is permanently in
// front of everything and would veto the tag forever. One such
// marker at the bottom-left corner is what made a tag vanish
// there, and only there: it takes a move off two edges at once
// for the tag to clamp into that exact pixel.
guard (entry[kCGWindowLayer as String] as? Int) == 0 else { continue }
if bounds.intersects(tag) { return false }
}
return false
}
// AppKit is bottom-left-origin per screen; the window server is
// top-left-origin from the primary screen (screens[0], the one with the
// menu bar).
private static func cgRect(of rect: NSRect) -> CGRect {
let primaryMaxY = NSScreen.screens.first?.frame.maxY ?? rect.maxY
return CGRect(x: rect.minX, y: primaryMaxY - rect.maxY, width: rect.width, height: rect.height)
}
// MARK: - User placement
// Fires for every panel move ours (from `reposition`, when the
// tracked window itself moves/resizes) and the user's (dragging the
// tag). Only the latter should redefine the placement, which is what
// the `isRepositioning` guard filters for.
//
// Re-deriving the offset from our own moves too looks like it should
// be a harmless no-op recomputing the very offset we just positioned
// against but it isn't, because the two halves read the window at
// different instants. AppKit posts this notification synchronously
// from inside `setFrame`, and during a live window drag the AX frame
// read here is already newer than the one `reposition` derived the
// origin from, so each move event bakes in the few points the window
// travelled in between. Those errors accumulate across the hundreds
// of events one drag produces, until the offset saturates and the tag
// has walked clear across its window off-screen entirely, if that
// edge of the window is.
func windowDidMove(_ notification: Notification) {
guard !isRepositioning else { return }
scheduleDragSettle()
}
// A tag drag emits a continuous stream of moves, and re-anchoring on
// each one would snap the tag out from under the cursor mid-gesture
// (and flip its orientation repeatedly on the way past an edge). So
// the placement is only committed once the moves stop, which is as
// close to a "drag ended" signal as a background-movable window gets
// NSWindow has no such delegate callback, unlike live resize.
private func scheduleDragSettle() {
dragSettle?.cancel()
let work = DispatchWorkItem { [weak self] in self?.commitUserPlacement() }
dragSettle = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
}
private func commitUserPlacement() {
guard let windowFrame = currentGeometry?.window else { return }
let tagFrame = panel.frame
let centre = NSPoint(x: tagFrame.midX, y: tagFrame.midY)
let corner = WindowOverlayCorner.nearest(to: centre, in: windowFrame)
// Orientation follows whichever edge the tag ended up nearest: a
// chip against a side reads vertically, so it can sit close in
// without covering the window's own controls. Dots have no
// reading direction, so they're never rotated.
let toSide = min(centre.x - windowFrame.minX, windowFrame.maxX - centre.x)
let toTopOrBottom = min(windowFrame.maxY - centre.y, centre.y - windowFrame.minY)
let isVertical = WindowOverlayPosition.style == .chip && toSide < toTopOrBottom
// Offsets run inward from the anchored corner. Stored unclamped at
// the top end (a drag outside the window is clamped to zero, but a
// long reach into a wide window is kept in full) so a later resize
// can restore it.
let insetX = corner.isLeft
? tagFrame.minX - windowFrame.minX : windowFrame.maxX - tagFrame.maxX
let insetY = corner.isTop
? windowFrame.maxY - tagFrame.maxY : tagFrame.minY - windowFrame.minY
placement = WindowOverlayPlacement(
corner: corner, insetX: max(0, insetX), insetY: max(0, insetY), isVertical: isVertical)
placement.save(for: profileName)
// Snap into the committed placement this is what applies a
// rotation the drag just earned, and squares the tag up against
// its corner.
reposition()
}
// MARK: - Live tracking
private func startObserving() {
var newObserver: AXObserver?
guard AXObserverCreate(pid, WindowOverlay.axCallback, &newObserver) == .success, let newObserver
else { return }
observer = newObserver
let refcon = Unmanaged.passUnretained(self).toOpaque()
for name in [
kAXMovedNotification, kAXResizedNotification, kAXUIElementDestroyedNotification,
kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification,
] {
AXObserverAddNotification(newObserver, axWindow, name as CFString, refcon)
}
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(newObserver), .defaultMode)
}
private func stopObserving() {
guard let observer else { return }
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode)
self.observer = nil
}
private static let axCallback: AXObserverCallback = { _, _, notification, refcon in
guard let refcon else { return }
let overlay = Unmanaged<WindowOverlay>.fromOpaque(refcon).takeUnretainedValue()
switch notification as String {
case kAXUIElementDestroyedNotification:
overlay.panel.orderOut(nil)
case kAXWindowMiniaturizedNotification:
overlay.isMinimized = true
overlay.reposition()
case kAXWindowDeminiaturizedNotification:
overlay.isMinimized = false
overlay.reposition()
default:
overlay.reposition()
}
}
}
extension Comparable {
func clamped(to range: ClosedRange<Self>) -> Self {
min(max(self, range.lowerBound), range.upperBound)
}
}
+1 -1
View File
@@ -1 +1 @@
0.0.3
0.0.8
+58
View File
@@ -13,6 +13,29 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCES_DIR="$SCRIPT_DIR/Sources"
# Local, gitignored build configuration — currently just the code-signing
# identity, which otherwise has to be remembered and retyped on every
# build. See .env.example.
#
# Parsed rather than sourced, for two reasons: a stray command in the file
# shouldn't execute as a side effect of building, and this lets an existing
# environment variable win, so `SHANNONCOAT_SIGN_IDENTITY= ./build.sh`
# still forces a one-off ad-hoc build without editing the file.
ENV_FILE="$SCRIPT_DIR/.env"
if [[ -f "$ENV_FILE" ]]; then
while IFS='=' read -r key value || [[ -n "$key" ]]; do
key="${key#"${key%%[![:space:]]*}"}" # strip leading whitespace
key="${key#export }"
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue # skips blanks and # comments
value="${value%"${value##*[![:space:]]}"}" # strip trailing whitespace
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
[[ -n "${!key+set}" ]] && continue # already in the environment; leave it
printf -v "$key" '%s' "$value"
export "${key?}"
done <"$ENV_FILE"
fi
VERSION_FILE="$SCRIPT_DIR/VERSION"
ICON_SRC="$SCRIPT_DIR/icon/AppIcon.icns"
OUT_DIR="${1:-$SCRIPT_DIR/.build}"
@@ -76,6 +99,41 @@ printf '%s' "$GIT_SHA" >"$APP/Contents/Resources/COMMIT"
swiftc -O "$SOURCES_DIR"/*.swift -o "$APP/Contents/MacOS/launcher"
# Sign the bundle, not just the binary swiftc linker-signed for us — and
# with the bundle's real identifier rather than the "launcher" executable
# name codesign would otherwise infer.
#
# This matters well beyond tidiness, because the window tags need
# Accessibility. macOS ties that grant to a *properly signed* app's
# designated requirement, which survives a rebuild, but falls back to
# pinning an ad-hoc-signed app to its exact cdhash — and every build
# produces a new one. So with the ad-hoc default below, each build +
# reinstall silently invalidates the permission, while the app stays
# listed and stays ticked under Privacy & Security → Accessibility. It
# looks granted and isn't, and no amount of toggling that row fixes it:
# the stale entry has to be removed with "" and the new build added back.
#
# To stop paying that tax on every build, make a self-signed code-signing
# certificate once (Keychain Access → Certificate Assistant → Create a
# Certificate…, type "Code Signing", self-signed) and point this at it:
#
# SHANNONCOAT_SIGN_IDENTITY="My Local Signing Cert" ./build.sh
#
# then re-grant Accessibility one final time. The identity is stable from
# then on, so later builds keep the permission.
if [[ -n "${SHANNONCOAT_SIGN_IDENTITY:-}" ]]; then
# An identity was asked for explicitly, so falling back to ad-hoc would
# quietly reintroduce the very problem it was set to avoid — and a
# release that ships ad-hoc revokes the Accessibility permission of
# everyone who updates. Fail the build instead.
codesign --force --sign "$SHANNONCOAT_SIGN_IDENTITY" \
--identifier "com.local.shannoncoat" "$APP" \
|| { echo "error: signing with \"$SHANNONCOAT_SIGN_IDENTITY\" failed" >&2; exit 1; }
else
codesign --force --sign - --identifier "com.local.shannoncoat" "$APP" >/dev/null 2>&1 \
|| echo "warning: codesign failed; Accessibility permission may not stick" >&2
fi
touch "$APP"
echo "Built: $APP"
echo "Copy it to /Applications (or ~/Applications) to install."