diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..270c171 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +# Two ways in: a manually pushed version tag, or a direct call from +# tag-on-version-bump.yml (which fires on VERSION changes on main). The +# workflow_call path exists because pushes made with the default +# GITHUB_TOKEN don't trigger other workflows' `on.push` — a tag pushed by +# that workflow wouldn't reach this one's `tags:` trigger on its own. +on: + push: + tags: + - "*" + workflow_call: + inputs: + tag: + required: true + type: string + +permissions: + contents: write + +jobs: + build: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Build shannoncoat.app + run: ./build.sh + + - name: Zip app bundle + run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip" + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.ref_name }} + files: | + shannoncoat.app.zip + generate_release_notes: true diff --git a/.github/workflows/tag-on-version-bump.yml b/.github/workflows/tag-on-version-bump.yml new file mode 100644 index 0000000..0054ebc --- /dev/null +++ b/.github/workflows/tag-on-version-bump.yml @@ -0,0 +1,49 @@ +name: Tag on version bump + +on: + push: + branches: [main] + paths: ["VERSION"] + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.tag.outputs.tag }} + created: ${{ steps.tag.outputs.created }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Guards against re-tagging on a no-op push (e.g. VERSION edited then + # reverted to the same value in a later commit) — only pushes a tag + # that doesn't exist yet. + - name: Create tag if new + id: tag + run: | + VERSION="$(tr -d '[:space:]' < VERSION)" + TAG="$VERSION" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists, skipping." + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "created=true" >> "$GITHUB_OUTPUT" + + release: + needs: tag + if: needs.tag.outputs.created == 'true' + uses: ./.github/workflows/release.yml + with: + tag: ${{ needs.tag.outputs.tag }} + permissions: + contents: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee98377 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Icon generation: raw Claude.app icon assets extracted as source material +# for icon/generate-icon.swift — the derived composite (icon/AppIcon.icns) +# is committed, but Anthropic's own unmodified icon files aren't. +icon/claude-src/ +icon/AppIcon.iconset/ + +# Local reference material (e.g. nested checkouts of other projects) — not +# part of this project, never meant to be committed. +.scratch/ + +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# AI agents and assistants +# +# Some common agent instruction and project configuration files are listed +# below as commented-out examples. They are often intentionally committed and +# shared with a team, so only uncomment them if they are local-only in your +# project. + +# GEMINI.md +# WARP.md +# CRUSH.md +# QWEN.md + +# OpenAI Codex +# AGENTS.md +# .codex/ + +# Aider +# .aider.input.history +# .aider.chat.history.md +# .aider.llm.history +# .aider.tags.cache.v* +# .aiderignore + +# Claude Code +.claude/*.local.json +.claude/**/*.log +CLAUDE.local.md +# .claude/ + +# Gemini CLI +# gemini-debug.log +# .gemini-clipboard/ +# .gemini/ + +# Cursor AI +# .cursorrules +# .cursor/ +# .cursor.json +# .cursor-settings.yaml + +# Continue +# .continue/ +# .continuerc.json + +# Cline +# .cline/ +# .clinerules +# cline.json + +# Other agent/editor project config +# .warp/ +# .crush/ +# .codeium/ +# .deepseek/ +# .amazon-codewhisperer/ +# .tabnineignore +# .tabnine/ + +# GitHub Copilot +# .github/copilot-instructions.md + +# Windsurf Editor +# .windsurfrules +# .windsurf/ + +# Replit AI Development +# .replit +# replit.nix diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift new file mode 100644 index 0000000..45418f6 --- /dev/null +++ b/Sources/AppDelegate.swift @@ -0,0 +1,292 @@ +// Menu bar behavior: title dots, per-profile menu, Manage Profiles window. +// Reads live state from LiveState (push-based, no polling) and calls +// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing. +import AppKit + +private let author = "bdeshi" +private let homepage = "https://github.com/bdeshi/shannoncoat" + +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + let menu = NSMenu() + let liveState = LiveState() + private var currentInfos: [ProfileInfo] = [] + private var collisionAlertShown = false + + func applicationDidFinishLaunching(_ notification: Notification) { + menu.delegate = self + menu.autoenablesItems = false + statusItem.menu = menu + statusItem.button?.image = menuBarIcon() + statusItem.button?.imagePosition = .imageLeft + + liveState.delegate = self + ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() } + liveState.reconcile() + + // Launching this app fresh with nothing running means there's no + // menu-bar title/dots to click yet — offer a picker up front + // instead of leaving the user to discover the dropdown. + if currentInfos.filter(\.running).isEmpty { + showStartupPicker(currentInfos) + } + + UpdateChecker.checkAutomaticallyIfDue() + } + + // Claude's own app icon (bundled as Contents/Resources/AppIcon.icns — + // currently a placeholder, see icon/generate-icon.swift), scaled down + // for the menu bar. + private func menuBarIcon() -> NSImage { + let source = NSApp.applicationIconImage ?? NSImage(named: NSImage.applicationIconName) ?? NSImage() + let resized = NSImage(size: NSSize(width: 18, height: 18)) + resized.lockFocus() + source.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18), from: .zero, operation: .sourceOver, fraction: 1.0) + resized.unlockFocus() + return resized + } + + // MARK: - LiveStateDelegate + + func liveStateDidChange(_ infos: [ProfileInfo]) { + collisionAlertShown = false + currentInfos = infos + applyTitle(infos) + ManageWindowController.shared.update(infos) + } + + // Two profiles sharing a dir is a fatal misconfiguration (would + // silently merge their Claude sessions) — surfaced with an alert + // rather than crashing the app outright, since that would leave the + // user with no way to fix it short of hand-editing files blind. + func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) { + guard !collisionAlertShown else { return } + collisionAlertShown = true + let lines = collisions.map { "\u{2022} \($0.kind): \"\($0.profileA)\" and \"\($0.profileB)\" both use \($0.dir)" } + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "shannoncoat: configuration error" + alert.informativeText = "Two profiles can't share a directory:\n\n" + lines.joined(separator: "\n") + alert.addButton(withTitle: "OK") + alert.runModal() + } + + // Title reflects what's ACTUALLY running — one profile shows as a dot + // + name; several show as dots only, to keep the menu bar from growing + // unbounded, with the full list available via tooltip and the dropdown. + private func applyTitle(_ profiles: [ProfileInfo]) { + guard let button = statusItem.button else { return } + let running = profiles.filter(\.running) + switch running.count { + case 0: + button.attributedTitle = NSAttributedString(string: "") + button.toolTip = "Claude not running" + case 1: + let name = running[0].profile.name + let title = NSMutableAttributedString( + string: "\u{25cf} ", attributes: [.foregroundColor: ProfileColor.dotColor(for: name)]) + title.append(NSAttributedString(string: name, attributes: [.foregroundColor: NSColor.labelColor])) + button.attributedTitle = title + button.toolTip = nil + default: + let title = NSMutableAttributedString() + for info in running { + title.append(NSAttributedString( + string: "\u{25cf}", attributes: [.foregroundColor: ProfileColor.dotColor(for: info.profile.name)])) + } + button.attributedTitle = title + button.toolTip = running.map(\.profile.name).joined(separator: ", ") + } + } + + // MARK: - Menu + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + liveState.reconcile() // correctness backstop; live updates are the primary mechanism + let profiles = currentInfos + let runningCount = profiles.filter(\.running).count + + let header = NSMenuItem( + title: runningCount == 0 ? "Claude not running" + : runningCount == 1 ? "1 profile running" : "\(runningCount) profiles running", + action: nil, keyEquivalent: "") + header.isEnabled = false + menu.addItem(header) + menu.addItem(.separator()) + + let hint = NSMenuItem(title: "\u{21e7}-click to open alongside", action: nil, keyEquivalent: "") + hint.isEnabled = false + menu.addItem(hint) + menu.addItem(.separator()) + + for info in profiles { + // Click = exclusive switch (quits every other running profile). + // Shift-click = open alongside instead, without disturbing + // anything else that's running. + let title = info.profile.name == liveState.lastActiveName + ? "\(info.profile.name) \u{21c6}" : info.profile.name + let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "") + item.target = self + item.representedObject = info.profile.name + item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running) + item.state = info.running ? .on : .off + item.toolTip = info.running ? "Running — click to focus" : "Click to switch • Shift-click to open alongside" + menu.addItem(item) + } + + menu.addItem(.separator()) + let manageItem = NSMenuItem( + title: "Manage Profiles\u{2026}", action: #selector(openManageWindow), keyEquivalent: ",") + manageItem.keyEquivalentModifierMask = [.command] + manageItem.target = self + menu.addItem(manageItem) + + let closeItem = NSMenuItem(title: "Close Profile", action: nil, keyEquivalent: "") + let closeMenu = NSMenu() + let closeable = profiles.filter(\.running) + if closeable.isEmpty { + let none = NSMenuItem(title: "(none running)", action: nil, keyEquivalent: "") + none.isEnabled = false + closeMenu.addItem(none) + } else { + for info in closeable { + let mi = NSMenuItem(title: info.profile.name, action: #selector(closeProfile(_:)), keyEquivalent: "") + mi.target = self + mi.representedObject = info.profile.name + closeMenu.addItem(mi) + } + } + closeItem.submenu = closeMenu + menu.addItem(closeItem) + + menu.addItem(.separator()) + let aboutItem = NSMenuItem(title: "About shannoncoat", action: #selector(showAbout), keyEquivalent: "") + aboutItem.target = self + menu.addItem(aboutItem) + + menu.addItem(.separator()) + let quitClaudeItem = NSMenuItem(title: "Quit Claude", action: #selector(quitClaude), keyEquivalent: "") + quitClaudeItem.target = self + menu.addItem(quitClaudeItem) + + let quitSelfItem = NSMenuItem(title: "Quit shannoncoat", action: #selector(quitSelf), keyEquivalent: "q") + quitSelfItem.target = self + menu.addItem(quitSelfItem) + } + + // Shown once at launch when nothing is running. A checklist rather + // than a single pick: unlike the menu's click/Shift-click (one + // profile, exclusive vs. alongside), there's nothing running yet to be + // "alongside" of, so this lets several profiles be selected to start + // together in one go. + private func showStartupPicker(_ profiles: [ProfileInfo]) { + guard !profiles.isEmpty else { return } + let alert = NSAlert() + alert.messageText = "Start Claude" + alert.informativeText = "Nothing is running. Choose which profiles to start:" + alert.addButton(withTitle: "Start") + alert.addButton(withTitle: "Cancel") + + let rowHeight: CGFloat = 22 + let container = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: rowHeight * CGFloat(profiles.count))) + var checkboxes: [(box: NSButton, name: String)] = [] + for (i, info) in profiles.enumerated() { + let y = rowHeight * CGFloat(profiles.count - 1 - i) + let box = NSButton(checkboxWithTitle: info.profile.name, target: nil, action: nil) + box.frame = NSRect(x: 0, y: y, width: 240, height: rowHeight) + box.state = info.profile.name == liveState.lastActiveName ? .on : .off + container.addSubview(box) + checkboxes.append((box, info.profile.name)) + } + alert.accessoryView = container + + guard alert.runModal() == .alertFirstButtonReturn else { return } + + for (box, name) in checkboxes where box.state == .on { + guard let profile = profiles.first(where: { $0.profile.name == name })?.profile else { continue } + liveState.markActive(name) + ClaudeControl.launch(profile) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } + } + + // MARK: - Actions + + @objc func pick(_ sender: NSMenuItem) { + guard let name = sender.representedObject as? String, + let profile = currentInfos.first(where: { $0.profile.name == name })?.profile + else { return } + liveState.markActive(name) + if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { + openProfile(profile) + } else { + switchTo(profile) + } + } + + private func openProfile(_ profile: ResolvedProfile) { + if let pid = ClaudeControl.pid(for: profile) { + ClaudeControl.focus(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } + } else { + ClaudeControl.launch(profile) + // The launch notification usually beats this, but a short + // explicit poke keeps the UI snappy even if it doesn't. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.liveState.reconcile() } + } + } + + private func switchTo(_ profile: ResolvedProfile) { + let others = currentInfos.filter { $0.running && $0.profile.name != profile.name } + guard !others.isEmpty else { openProfile(profile); return } + var remaining = others.count + for info in others { + guard let pid = info.pid else { remaining -= 1; continue } + ClaudeControl.quit(pid: pid) { [weak self] _ in + remaining -= 1 + if remaining == 0 { + DispatchQueue.main.async { self?.openProfile(profile) } + } + } + } + } + + @objc func closeProfile(_ sender: NSMenuItem) { + guard let name = sender.representedObject as? String, + let info = currentInfos.first(where: { $0.profile.name == name }), let pid = info.pid + else { return } + ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } + } + + @objc func openManageWindow() { + ManageWindowController.shared.update(currentInfos) + ManageWindowController.shared.show() + } + + @objc func showAbout() { + // Accessory apps (no Dock icon) don't auto-activate for a window + // they open, so the standard About panel would appear behind + // whatever's frontmost without this. + NSApp.activate(ignoringOtherApps: true) + let credits = """ + Which coat will Claude wear today? Menu bar switcher for running multiple isolated Claude Desktop profiles side by side — each paired with its own Claude Code config so the two never cross-contaminate. + + Author: \(author) + Source: \(homepage) + + Version: \(UpdateChecker.currentVersion) + """ + NSApp.orderFrontStandardAboutPanel(options: [ + .applicationName: "shannoncoat", + .credits: NSAttributedString(string: credits), + ]) + } + + @objc func quitClaude() { + ClaudeControl.quitAll { DispatchQueue.main.async { [weak self] in self?.liveState.reconcile() } } + } + + @objc func quitSelf() { + NSApp.terminate(nil) + } +} diff --git a/Sources/ClaudeControl.swift b/Sources/ClaudeControl.swift new file mode 100644 index 0000000..6409009 --- /dev/null +++ b/Sources/ClaudeControl.swift @@ -0,0 +1,240 @@ +// Everything about controlling the Claude process itself: launch, focus, +// quit. Only two real primitives — launch/focus one profile ("open"), and +// quit one profile ("close"); "switch" (exclusive) is composed from these +// two in AppDelegate rather than being a third code path here. +import AppKit +import ApplicationServices +import Foundation +import UniformTypeIdentifiers + +enum ClaudeControl { + private static let appPathKey = "ClaudeAppPath" + + // Both places an app legitimately lives on macOS: the machine-wide + // /Applications and a per-user ~/Applications. Checked in that order, + // after any location the user has pointed us at themselves. + private static var candidatePaths: [String] { + [ + "/Applications/Claude.app", + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Applications/Claude.app").path, + ] + } + + private static var cachedAppPath: String? + + // Resolved once per launch, because this is consulted on every process + // enumeration — which happens on every workspace notification. + static var appPath: String { + if let cachedAppPath { return cachedAppPath } + let resolved = resolveAppPath() + cachedAppPath = resolved + return resolved + } + + static var binaryPath: String { "\(appPath)/Contents/MacOS/Claude" } + + static var isInstalled: Bool { FileManager.default.fileExists(atPath: binaryPath) } + + private static func holdsClaude(_ path: String) -> Bool { + FileManager.default.fileExists(atPath: "\(path)/Contents/MacOS/Claude") + } + + private static func resolveAppPath() -> String { + if let saved = UserDefaults.standard.string(forKey: appPathKey), holdsClaude(saved) { + return saved + } + if let found = candidatePaths.first(where: holdsClaude) { + return found + } + // Found nothing. Report against the standard location anyway, so + // anything that surfaces this path names somewhere meaningful + // rather than an empty string. + return candidatePaths[0] + } + + // For an install in neither standard location. Modal by nature, but only + // ever reached from an explicit user action that can't proceed without + // an answer — better than a menu click that silently does nothing. + @discardableResult + static func promptForAppLocation() -> Bool { + let panel = NSOpenPanel() + panel.message = "Couldn't find Claude in Applications. Choose Claude.app to continue." + panel.prompt = "Choose" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + NSApp.activate(ignoringOtherApps: true) + + guard panel.runModal() == .OK, let url = panel.url else { return false } + guard holdsClaude(url.path) else { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "That doesn't look like Claude." + alert.informativeText = "\(url.lastPathComponent) doesn't contain a Claude executable." + alert.runModal() + return false + } + + UserDefaults.standard.set(url.path, forKey: appPathKey) + cachedAppPath = url.path + return true + } + + static func runningInstances() -> [RunningClaude] { + ProcessInspector.listRunningClaude(binaryPath: binaryPath) + } + + static func pid(for profile: ResolvedProfile) -> pid_t? { + let dir = profile.name == "default" ? nil : profile.appDir + return ProcessInspector.pid(forUserDataDir: dir, binaryPath: binaryPath) + } + + // MARK: - Launch + + @discardableResult + static func launch(_ profile: ResolvedProfile) -> Bool { + // Ask once rather than fail silently: a launch that does nothing + // gives the user no way to work out that Claude simply isn't where + // this expected it to be. + if !isInstalled, !promptForAppLocation() { return false } + + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + if profile.name != "default" { + try? FileManager.default.createDirectory(atPath: profile.codeDir, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(atPath: profile.appDir, withIntermediateDirectories: true) + var env = ProcessInfo.processInfo.environment + env["CLAUDE_CONFIG_DIR"] = profile.codeDir + process.environment = env + process.arguments = ["--user-data-dir=\(profile.appDir)"] + } + + do { + try process.run() + return true + } catch { + return false + } + } + + // MARK: - Focus + + // Robust focus: unhide, un-minimize, raise across Spaces via the + // Accessibility API directly (replacing the old AppleScript/System + // Events call), then NSRunningApplication.activate() as a second push. + // Waits briefly for a window to exist if the process was just launched, + // rather than giving up immediately. Best-effort by design (a focus + // request shouldn't be able to crash or block the caller), but no + // longer silent about the one failure that's actually actionable: + // Accessibility not granted. + static func focus(_ profile: ResolvedProfile, completion: @escaping (Bool) -> Void) { + guard let pid = pid(for: profile) else { completion(false); return } + focus(pid: pid, completion: completion) + } + + static func focus(pid: pid_t, completion: @escaping (Bool) -> Void) { + if !AXIsProcessTrusted() { + promptForAccessibility() + } + guard let app = NSRunningApplication(processIdentifier: pid) else { completion(false); return } + if app.isHidden { app.unhide() } + + let axApp = AXUIElementCreateApplication(pid) + waitForWindow(axApp: axApp, attemptsRemaining: 12) { window in + if let window { + setMinimized(window, false) + AXUIElementPerformAction(window, kAXRaiseAction as CFString) + } + app.activate() + completion(window != nil) + } + } + + private static func waitForWindow( + axApp: AXUIElement, attemptsRemaining: Int, completion: @escaping (AXUIElement?) -> Void + ) { + if let window = firstWindow(of: axApp) { + completion(window) + } else if attemptsRemaining <= 0 { + completion(nil) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + waitForWindow(axApp: axApp, attemptsRemaining: attemptsRemaining - 1, completion: completion) + } + } + } + + private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success, + let windows = value as? [AXUIElement], let first = windows.first + else { return nil } + return first + } + + private static func setMinimized(_ window: AXUIElement, _ minimized: Bool) { + AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef) + } + + private static func promptForAccessibility() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } + + // MARK: - Quit + + enum QuitResult { case exited, blockedByOwnDialog, forceKilled, alreadyGone } + + // Fixes a real bug in the old shell version: it waited a flat 5s then + // unconditionally SIGKILLed, which would kill Claude out from under its + // own "unsaved work" / "generation in progress" confirmation if it + // showed one. `.terminate()` is a request the target can legitimately + // delay or decline (it can return `.terminateCancel` from its own + // applicationShouldTerminate:), not a command it's forced to obey — so + // this waits much longer, and if Claude still has a window up at the + // end of that window (a sign it's showing its own dialog, or otherwise + // isn't done with something) it stops short of forceTerminate and + // reports that back instead of killing underneath it. + static func quit(pid: pid_t, completion: @escaping (QuitResult) -> Void) { + guard let app = NSRunningApplication(processIdentifier: pid) else { completion(.alreadyGone); return } + app.terminate() + pollForExit(app: app, attemptsRemaining: 40, completion: completion) // 40 * 0.25s = 10s grace period + } + + static func quitAll(completion: @escaping () -> Void) { + let pids = runningInstances().map(\.pid) + guard !pids.isEmpty else { completion(); return } + var remaining = pids.count + for pid in pids { + quit(pid: pid) { _ in + remaining -= 1 + if remaining == 0 { completion() } + } + } + } + + private static func pollForExit( + app: NSRunningApplication, attemptsRemaining: Int, completion: @escaping (QuitResult) -> Void + ) { + if app.isTerminated { + completion(.exited) + } else if attemptsRemaining <= 0 { + if firstWindow(of: AXUIElementCreateApplication(app.processIdentifier)) != nil { + completion(.blockedByOwnDialog) + } else { + app.forceTerminate() + completion(.forceKilled) + } + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + pollForExit(app: app, attemptsRemaining: attemptsRemaining - 1, completion: completion) + } + } + } +} diff --git a/Sources/LaunchAtLogin.swift b/Sources/LaunchAtLogin.swift new file mode 100644 index 0000000..be52542 --- /dev/null +++ b/Sources/LaunchAtLogin.swift @@ -0,0 +1,21 @@ +// Thin wrapper around the modern login-item API, toggled from the Manage +// window's Settings tab. Standard, expected baseline behavior for a +// persistent menu-bar utility that's otherwise easy to forget to reopen +// after a restart. +import ServiceManagement + +enum LaunchAtLogin { + static var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + static func enable() throws { + guard SMAppService.mainApp.status != .enabled else { return } + try SMAppService.mainApp.register() + } + + static func disable() throws { + guard SMAppService.mainApp.status == .enabled else { return } + try SMAppService.mainApp.unregister() + } +} diff --git a/Sources/LiveState.swift b/Sources/LiveState.swift new file mode 100644 index 0000000..7286a58 --- /dev/null +++ b/Sources/LiveState.swift @@ -0,0 +1,87 @@ +// Keeps the menu bar in sync with reality without ever polling. Registers +// NSWorkspace notifications for launch/terminate/activate/hide/unhide, +// filtered to Claude's own binary, and reconciles an in-memory +// [ProfileInfo] the instant one fires (sub-second, push-based) — this is +// what makes external changes (Force Quit, Spotlight, Dock, a hand quit) +// show up immediately instead of needing a menu click or an app restart. +import AppKit +import Foundation + +struct ProfileInfo { + let profile: ResolvedProfile + let running: Bool + let pid: pid_t? +} + +protocol LiveStateDelegate: AnyObject { + func liveStateDidChange(_ infos: [ProfileInfo]) + func liveStateFoundCollisions(_ collisions: [ProfileStore.DirCollision]) +} + +final class LiveState { + weak var delegate: LiveStateDelegate? + + // Best-effort "last switched to" pointer for the ⇆ marker in the menu — + // reflects profiles opened/switched through this app; there's no + // ordered history to fall back through if that one gets hand-quit + // (matches the "don't overdo it" brief — reality-vs-stopped state is + // still always ground-truth, this only affects the ⇆ hint). + private(set) var lastActiveName = "default" + + private var observers: [NSObjectProtocol] = [] + + init() { + let center = NSWorkspace.shared.notificationCenter + let names: [Notification.Name] = [ + NSWorkspace.didLaunchApplicationNotification, + NSWorkspace.didTerminateApplicationNotification, + NSWorkspace.didActivateApplicationNotification, + NSWorkspace.didHideApplicationNotification, + NSWorkspace.didUnhideApplicationNotification, + ] + observers = names.map { name in + center.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in + self?.handle(note) + } + } + // Deliberately not calling reconcile() here — `delegate` is set by + // the caller right after init, and the caller does the first + // reconcile itself once it's ready to receive the callback. + } + + deinit { + let center = NSWorkspace.shared.notificationCenter + observers.forEach { center.removeObserver($0) } + } + + private func handle(_ note: Notification) { + guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.executableURL?.path == ClaudeControl.binaryPath + else { return } + reconcile() + } + + func markActive(_ name: String) { + lastActiveName = name + } + + // Public so callers (menu open, right after a command completes) can + // force an immediate reconcile as a correctness backstop, without that + // being the primary update mechanism. + func reconcile() { + let profiles = ProfileStore.loadAll() + let collisions = ProfileStore.findCollisions(among: profiles) + guard collisions.isEmpty else { + delegate?.liveStateFoundCollisions(collisions) + return + } + + let running = ClaudeControl.runningInstances() + let infos = profiles.map { profile -> ProfileInfo in + let dir = profile.name == "default" ? nil : profile.appDir + let match = running.first { $0.userDataDir == dir } + return ProfileInfo(profile: profile, running: match != nil, pid: match?.pid) + } + delegate?.liveStateDidChange(infos) + } +} diff --git a/Sources/ManageWindow.swift b/Sources/ManageWindow.swift new file mode 100644 index 0000000..20fbd6a --- /dev/null +++ b/Sources/ManageWindow.swift @@ -0,0 +1,390 @@ +// One ordinary, non-modal window for everything profile-management related +// — replaces what would otherwise be a string of separate popup alerts. +// Opened from the menu bar's "Manage Profiles…" item or the standard +// Cmd+, shortcut. +import AppKit + +final class ManageWindowController: NSWindowController { + static let shared = ManageWindowController() + + private let profilesVC = ProfilesViewController() + private let settingsVC = SettingsViewController() + + // Called after a profile is added/removed so the caller can trigger an + // immediate LiveState reconcile instead of waiting on the next + // notification. + var onProfilesChanged: (() -> Void)? { + didSet { profilesVC.onChange = onProfilesChanged } + } + + private init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 360), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "shannoncoat" + window.isReleasedWhenClosed = false + window.center() + + let tabs = NSTabViewController() + profilesVC.title = "Profiles" + settingsVC.title = "Settings" + tabs.addTabViewItem(NSTabViewItem(viewController: profilesVC)) + tabs.addTabViewItem(NSTabViewItem(viewController: settingsVC)) + window.contentViewController = tabs + + super.init(window: window) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func show() { + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } + + func update(_ infos: [ProfileInfo]) { + profilesVC.update(infos) + } +} + +// MARK: - Profiles tab + +private final class ProfilesViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, + NSTextFieldDelegate +{ + var onChange: (() -> Void)? + + private var infos: [ProfileInfo] = [] + private let tableView = NSTableView() + private let removeButton = NSButton(title: "\u{2212}", target: nil, action: nil) + private let addForm = NSStackView() + private let nameField = NSTextField() + private let codeField = NSTextField() + private let appField = NSTextField() + private let errorLabel = NSTextField(labelWithString: "") + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360)) + + let column = NSTableColumn(identifier: .init("profile")) + column.title = "Profile" + column.width = 380 + tableView.addTableColumn(column) + tableView.headerView = nil + tableView.dataSource = self + tableView.delegate = self + tableView.rowHeight = 22 + tableView.menu = buildContextMenu() + + let scroll = NSScrollView() + scroll.documentView = tableView + scroll.hasVerticalScroller = true + scroll.translatesAutoresizingMaskIntoConstraints = false + + let addButton = NSButton(title: "+", target: self, action: #selector(toggleAddForm)) + removeButton.target = self + removeButton.action = #selector(removeSelected) + removeButton.isEnabled = false + let buttonRow = NSStackView(views: [addButton, removeButton, NSView()]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 4 + buttonRow.translatesAutoresizingMaskIntoConstraints = false + + buildAddForm() + addForm.isHidden = true + addForm.translatesAutoresizingMaskIntoConstraints = false + + let stack = NSStackView(views: [scroll, buttonRow, addForm]) + stack.orientation = .vertical + stack.spacing = 8 + stack.edgeInsets = NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 160), + ]) + } + + private func buildAddForm() { + nameField.placeholderString = "Profile name" + nameField.delegate = self + + codeField.placeholderString = "~/.shannoncoat/data//code" + let codeChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseCodeDir)) + let codeRow = NSStackView(views: [labeled("Claude Code:"), codeField, codeChoose]) + codeRow.orientation = .horizontal + codeRow.spacing = 6 + + appField.placeholderString = "~/.shannoncoat/data//app" + let appChoose = NSButton(title: "Choose\u{2026}", target: self, action: #selector(chooseAppDir)) + let appRow = NSStackView(views: [labeled("Claude Desktop:"), appField, appChoose]) + appRow.orientation = .horizontal + appRow.spacing = 6 + + errorLabel.textColor = .systemRed + errorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + + let createButton = NSButton(title: "Create", target: self, action: #selector(createProfile)) + createButton.keyEquivalent = "\r" + let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(toggleAddForm)) + let buttonRow = NSStackView(views: [NSView(), cancelButton, createButton]) + buttonRow.orientation = .horizontal + buttonRow.spacing = 8 + + addForm.orientation = .vertical + addForm.alignment = .leading + addForm.spacing = 6 + [nameField, codeRow, appRow, errorLabel, buttonRow].forEach(addForm.addArrangedSubview) + nameField.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true + codeRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true + appRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true + buttonRow.widthAnchor.constraint(equalTo: addForm.widthAnchor).isActive = true + } + + private func labeled(_ text: String) -> NSTextField { + let field = NSTextField(labelWithString: text) + field.setContentHuggingPriority(.required, for: .horizontal) + return field + } + + func update(_ infos: [ProfileInfo]) { + self.infos = infos + tableView.reloadData() + removeButton.isEnabled = tableView.selectedRow >= 0 + && infos.indices.contains(tableView.selectedRow) + && infos[tableView.selectedRow].profile.name != "default" + } + + // MARK: NSTableViewDataSource / Delegate + + func numberOfRows(in tableView: NSTableView) -> Int { infos.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let identifier = NSUserInterfaceItemIdentifier("profileCell") + let info = infos[row] + let cell: NSTableCellView + if let reused = tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView { + cell = reused + } else { + cell = NSTableCellView() + cell.identifier = identifier + let imageView = NSImageView() + let textField = NSTextField(labelWithString: "") + imageView.translatesAutoresizingMaskIntoConstraints = false + textField.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(imageView) + cell.addSubview(textField) + cell.imageView = imageView + cell.textField = textField + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 4), + imageView.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + imageView.widthAnchor.constraint(equalToConstant: 10), + imageView.heightAnchor.constraint(equalToConstant: 10), + textField.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 6), + textField.trailingAnchor.constraint(lessThanOrEqualTo: cell.trailingAnchor, constant: -4), + textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + } + cell.imageView?.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running) + cell.textField?.stringValue = "\(info.profile.name) — \(info.running ? "running" : "stopped")" + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + removeButton.isEnabled = tableView.selectedRow >= 0 + && infos.indices.contains(tableView.selectedRow) + && infos[tableView.selectedRow].profile.name != "default" + } + + // MARK: NSTextFieldDelegate — live default-path preview as the name is typed + + func controlTextDidChange(_ obj: Notification) { + guard (obj.object as? NSTextField) === nameField else { return } + let name = nameField.stringValue.isEmpty ? "" : nameField.stringValue + codeField.placeholderString = "~/.shannoncoat/data/\(name)/code" + appField.placeholderString = "~/.shannoncoat/data/\(name)/app" + } + + // MARK: Actions + + @objc private func toggleAddForm() { + addForm.isHidden.toggle() + if !addForm.isHidden { + nameField.stringValue = "" + codeField.stringValue = "" + appField.stringValue = "" + errorLabel.stringValue = "" + view.window?.makeFirstResponder(nameField) + } + } + + @objc private func chooseCodeDir() { choose(into: codeField) } + @objc private func chooseAppDir() { choose(into: appField) } + + private func choose(into field: NSTextField) { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + guard let window = view.window else { return } + panel.beginSheetModal(for: window) { response in + if response == .OK, let url = panel.url { + field.stringValue = url.path + } + } + } + + @objc private func createProfile() { + let name = nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let code = codeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let app = appField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + do { + try ProfileStore.create(name: name, codeDir: code.isEmpty ? nil : code, appDir: app.isEmpty ? nil : app) + toggleAddForm() + onChange?() + } catch { + errorLabel.stringValue = (error as? LocalizedError)?.errorDescription ?? "\(error)" + } + } + + @objc private func removeSelected() { + let row = tableView.selectedRow + guard infos.indices.contains(row) else { return } + let info = infos[row] + guard info.profile.name != "default", let window = view.window else { return } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Delete profile \u{201C}\(info.profile.name)\u{201D}?" + alert.informativeText = info.running + ? "This profile is currently running \u{2014} deleting it will quit Claude first. Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed." + : "Its Claude Desktop and Claude Code data stay on disk; only the profile pointer is removed." + alert.addButton(withTitle: "Delete") + alert.addButton(withTitle: "Cancel") + alert.buttons.first?.hasDestructiveAction = true + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + let finish = { + try? ProfileStore.delete(info.profile.name) + self?.onChange?() + } + if info.running, let pid = info.pid { + ClaudeControl.quit(pid: pid) { _ in DispatchQueue.main.async(execute: finish) } + } else { + finish() + } + } + } + + private func buildContextMenu() -> NSMenu { + let menu = NSMenu() + let revealCode = NSMenuItem(title: "Reveal Code Folder", action: #selector(revealCodeDir), keyEquivalent: "") + revealCode.target = self + let revealApp = NSMenuItem( + title: "Reveal Desktop Folder", action: #selector(revealAppDir), keyEquivalent: "") + revealApp.target = self + menu.addItem(revealCode) + menu.addItem(revealApp) + return menu + } + + private func clickedInfo() -> ProfileInfo? { + let row = tableView.clickedRow + guard infos.indices.contains(row) else { return nil } + return infos[row] + } + + @objc private func revealCodeDir() { + guard let info = clickedInfo() else { return } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.codeDir) + } + + @objc private func revealAppDir() { + guard let info = clickedInfo() else { return } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: info.profile.appDir) + } +} + +// MARK: - Settings tab + +private final class SettingsViewController: NSViewController { + private let autoUpdateCheckbox = NSButton( + checkboxWithTitle: "Automatically check for updates", target: nil, action: nil) + private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil) + private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil) + + override func loadView() { + view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 360)) + + launchAtLoginCheckbox.target = self + launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin) + launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off + + autoUpdateCheckbox.target = self + autoUpdateCheckbox.action = #selector(toggleAutoUpdate) + autoUpdateCheckbox.state = UpdateChecker.automaticCheckEnabled ? .on : .off + + checkNowButton.target = self + checkNowButton.action = #selector(checkNow) + + let stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 20), + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + ]) + } + + @objc private func toggleLaunchAtLogin() { + do { + if launchAtLoginCheckbox.state == .on { + try LaunchAtLogin.enable() + } else { + try LaunchAtLogin.disable() + } + } catch { + launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off + NSAlert(error: error).runModal() + } + } + + @objc private func toggleAutoUpdate() { + UpdateChecker.automaticCheckEnabled = autoUpdateCheckbox.state == .on + } + + @objc private func checkNow() { + UpdateChecker.checkManually { result in + DispatchQueue.main.async { + let alert = NSAlert() + switch result { + case .upToDate: + alert.messageText = "You're up to date." + alert.runModal() + case .updateAvailable(let version, let url): + alert.messageText = "shannoncoat \(version) is available" + alert.addButton(withTitle: "View Release") + alert.addButton(withTitle: "Later") + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.open(url) + } + case .failed: + alert.alertStyle = .warning + alert.messageText = "Couldn't check for updates." + alert.runModal() + } + } + } + } +} diff --git a/Sources/ProcessInspector.swift b/Sources/ProcessInspector.swift new file mode 100644 index 0000000..f088fad --- /dev/null +++ b/Sources/ProcessInspector.swift @@ -0,0 +1,86 @@ +// Enumerates running Claude processes and their --user-data-dir, without +// spawning pgrep (avoids both the subprocess-per-check overhead and an +// assumption that /usr/bin/pgrep exists at a fixed path). Uses the same +// sysctl(KERN_PROC_ALL)/sysctl(KERN_PROCARGS2) technique ps/pgrep use +// internally for same-user processes — no extra entitlement needed. +// +// Called on demand from live-state notification handlers (see AppDelegate), +// never on a timer. +import Darwin +import Foundation + +struct RunningClaude { + let pid: pid_t + // nil means launched with no --user-data-dir flag at all (i.e. the + // "default" profile). + let userDataDir: String? +} + +enum ProcessInspector { + static func listRunningClaude(binaryPath: String) -> [RunningClaude] { + allPIDs().compactMap { pid in + guard let info = execInfo(pid: pid), info.execPath == binaryPath else { return nil } + let flagPrefix = "--user-data-dir=" + let dir = info.args.first(where: { $0.hasPrefix(flagPrefix) }).map { String($0.dropFirst(flagPrefix.count)) } + return RunningClaude(pid: pid, userDataDir: dir) + } + } + + static func pid(forUserDataDir dir: String?, binaryPath: String) -> pid_t? { + listRunningClaude(binaryPath: binaryPath).first { $0.userDataDir == dir }?.pid + } + + private static func allPIDs() -> [pid_t] { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0] + var size = 0 + guard sysctl(&mib, 4, nil, &size, nil, 0) == 0, size > 0 else { return [] } + + let capacity = size / MemoryLayout.stride + 1 + var procs = [kinfo_proc](repeating: kinfo_proc(), count: capacity) + var actualSize = capacity * MemoryLayout.stride + guard sysctl(&mib, 4, &procs, &actualSize, nil, 0) == 0 else { return [] } + + let actualCount = actualSize / MemoryLayout.stride + return procs[0.. ExecInfo? { + var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid] + var size = 0 + guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > MemoryLayout.size else { return nil } + + var buffer = [UInt8](repeating: 0, count: size) + guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0 else { return nil } + + var argc = Int32(0) + withUnsafeMutableBytes(of: &argc) { dst in + dst.copyBytes(from: buffer[0...size]) + } + + var offset = MemoryLayout.size + func readCString() -> String? { + guard offset < size else { return nil } + let start = offset + while offset < size, buffer[offset] != 0 { offset += 1 } + guard offset > start else { return nil } + let string = String(decoding: buffer[start.. UInt32 { + var hash: UInt32 = 0x811c_9dc5 + for byte in s.utf8 { + hash ^= UInt32(byte) + hash = hash &* 0x0100_0193 + } + return hash + } + + static func dotColor(for name: String) -> NSColor { + let hue = CGFloat(fnv1aHash(name) % 360) / 360.0 + return NSColor(calibratedHue: hue, saturation: 0.65, brightness: 0.9, alpha: 1.0) + } + + // `dimmed` marks a profile that isn't actually running right now — the + // colored dot still identifies it, but faded, so "open" vs "known but + // closed" is visible without reading a tooltip. + static func dotImage(for name: String, dimmed: Bool = false) -> NSImage { + let size = NSSize(width: 10, height: 10) + let image = NSImage(size: size) + image.lockFocus() + dotColor(for: name).withAlphaComponent(dimmed ? 0.3 : 1.0).setFill() + NSBezierPath(ovalIn: NSRect(origin: .zero, size: size)).fill() + image.unlockFocus() + image.isTemplate = false + return image + } +} diff --git a/Sources/ProfileStore.swift b/Sources/ProfileStore.swift new file mode 100644 index 0000000..652af10 --- /dev/null +++ b/Sources/ProfileStore.swift @@ -0,0 +1,180 @@ +// Profile config storage: one ~/.shannoncoat/.json per profile, +// holding the two dirs a profile needs — Claude Desktop's --user-data-dir, +// and the CLAUDE_CONFIG_DIR paired with it so any Claude Code session +// launched from that Desktop instance gets its own isolated config too, +// instead of sharing one with every other profile. (Claude Code already +// has its own profile mechanism for use outside Desktop — this isn't a +// second one, just making sure a managed Desktop session doesn't leak +// into it.) "default" is implicit — no file for it — and always means the +// real, untouched ~/.claude + Application Support/Claude. +import Foundation + +struct ResolvedProfile: Equatable { + let name: String + let codeDir: String + let appDir: String +} + +enum ProfileError: LocalizedError { + case emptyName + case reservedName + case invalidName(String) + case alreadyExists(String) + case notFound(String) + case dirCollision(existingProfile: String, dir: String, kind: String) + + var errorDescription: String? { + switch self { + case .emptyName: + return "Name is required." + case .reservedName: + return "\"default\" is reserved for the real, untouched Claude install." + case .invalidName(let name): + return "\"\(name)\" isn't a valid profile name (no spaces, slashes, colons, or leading dots)." + case .alreadyExists(let name): + return "A profile named \"\(name)\" already exists." + case .notFound(let name): + return "No profile named \"\(name)\"." + case .dirCollision(let existingProfile, let dir, let kind): + return "\(kind) dir \(dir) is already used by profile \"\(existingProfile)\"." + } + } +} + +// What lives on disk. Profile name comes from the filename, not a field in +// here, so there's only one place a profile's name can disagree with +// itself. `paths` is nested (matching the old YAML's shape) rather than +// flattened, so a later profile-specific feature has an obvious place to +// add its own top-level key alongside `paths` without disturbing this one. +private struct ProfileFile: Codable { + var paths: Paths + + struct Paths: Codable { + var code: String + var desktop: String + } +} + +enum ProfileStore { + static let root: URL = { + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".shannoncoat") + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + }() + + static let defaultProfile = ResolvedProfile( + name: "default", + codeDir: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude").path, + appDir: FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Claude").path + ) + + // "~" expands to home; anything else non-absolute resolves relative to + // `root` (the config dir itself) rather than the process's cwd; an + // already-absolute path is left alone. + static func expand(_ raw: String) -> String { + if raw.hasPrefix("~") { + return (raw as NSString).expandingTildeInPath + } + if raw.hasPrefix("/") { + return raw + } + return root.appendingPathComponent(raw).path + } + + static func validateName(_ name: String) throws { + if name.isEmpty { throw ProfileError.emptyName } + if name == "default" { throw ProfileError.reservedName } + let hasBadChar = name.contains("/") || name.contains(":") || name.hasPrefix(".") + || name.rangeOfCharacter(from: .whitespacesAndNewlines) != nil + if hasBadChar { throw ProfileError.invalidName(name) } + } + + private static func configURL(for name: String) -> URL { + root.appendingPathComponent("\(name).json") + } + + // Every configured profile plus the implicit `default`, in no + // particular order. Config files that fail to decode are skipped + // rather than surfaced here — a hand-edited-into-garbage file + // shouldn't take the whole menu down. + static func loadAll() -> [ResolvedProfile] { + var profiles = [defaultProfile] + let files = (try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil)) ?? [] + for file in files where file.pathExtension == "json" { + guard let data = try? Data(contentsOf: file), + let profileFile = try? JSONDecoder().decode(ProfileFile.self, from: data) + else { continue } + let name = file.deletingPathExtension().lastPathComponent + profiles.append(ResolvedProfile( + name: name, codeDir: expand(profileFile.paths.code), appDir: expand(profileFile.paths.desktop))) + } + return profiles + } + + struct DirCollision { let profileA: String; let profileB: String; let dir: String; let kind: String } + + // Two profiles secretly sharing a dir would silently merge their Claude + // sessions — defeats the entire point of switching. Checked on every + // load (see `loadAll` call sites), not just when a profile is created, + // so a hand-edited file can't sneak one in unnoticed. + static func findCollisions(among profiles: [ResolvedProfile]) -> [DirCollision] { + var collisions: [DirCollision] = [] + for i in profiles.indices { + for j in profiles.index(after: i).. ResolvedProfile { + try validateName(name) + let url = configURL(for: name) + guard !FileManager.default.fileExists(atPath: url.path) else { + throw ProfileError.alreadyExists(name) + } + + let rawCode = codeDir?.isEmpty == false ? codeDir! : "data/\(name)/code" + let rawApp = appDir?.isEmpty == false ? appDir! : "data/\(name)/app" + let resolvedCode = expand(rawCode) + let resolvedApp = expand(rawApp) + + for existing in loadAll() { + if existing.codeDir == resolvedCode { + throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedCode, kind: "Claude Code") + } + if existing.appDir == resolvedApp { + throw ProfileError.dirCollision(existingProfile: existing.name, dir: resolvedApp, kind: "Claude Desktop") + } + } + + try FileManager.default.createDirectory(atPath: resolvedCode, withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: resolvedApp, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(ProfileFile(paths: .init(code: rawCode, desktop: rawApp))) + try data.write(to: url) + + return ResolvedProfile(name: name, codeDir: resolvedCode, appDir: resolvedApp) + } + + static func delete(_ name: String) throws { + guard name != "default" else { throw ProfileError.reservedName } + let url = configURL(for: name) + guard FileManager.default.fileExists(atPath: url.path) else { throw ProfileError.notFound(name) } + try FileManager.default.removeItem(at: url) + } +} diff --git a/Sources/UpdateChecker.swift b/Sources/UpdateChecker.swift new file mode 100644 index 0000000..8258fe9 --- /dev/null +++ b/Sources/UpdateChecker.swift @@ -0,0 +1,90 @@ +// Deliberately minimal: fetch the raw VERSION file from `main` on GitHub +// and compare semver to the bundled version. No silent binary-replacing +// auto-update — that needs signing/notarization/an appcast server this +// project doesn't have. A manual check shows an alert (an explicit action +// that expects a response); the periodic automatic check instead posts a +// quiet system notification, since that one wasn't asked for in the +// moment. +import Foundation +import UserNotifications + +enum UpdateChecker { + enum Outcome { + case upToDate + case updateAvailable(version: String, url: URL) + case failed + } + + private static let versionURL = URL(string: "https://raw.githubusercontent.com/bdeshi/shannoncoat/main/VERSION")! + private static let releaseURL = URL(string: "https://github.com/bdeshi/shannoncoat/releases/latest")! + private static let enabledDefaultsKey = "AutomaticUpdateCheckEnabled" + private static let lastCheckDefaultsKey = "LastUpdateCheckDate" + private static let checkInterval: TimeInterval = 24 * 60 * 60 + + static var automaticCheckEnabled: Bool { + get { UserDefaults.standard.object(forKey: enabledDefaultsKey) as? Bool ?? true } + set { UserDefaults.standard.set(newValue, forKey: enabledDefaultsKey) } + } + + static var currentVersion: String { + guard let resourceURL = Bundle.main.resourceURL, + let contents = try? String(contentsOf: resourceURL.appendingPathComponent("VERSION"), encoding: .utf8) + else { return "unknown" } + return contents.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func checkManually(completion: @escaping (Outcome) -> Void) { + fetchLatestVersion { latest in + guard let latest else { completion(.failed); return } + if isNewer(latest, than: currentVersion) { + completion(.updateAvailable(version: latest, url: releaseURL)) + } else { + completion(.upToDate) + } + } + } + + // Called once at launch; a no-op unless the toggle is on and it's been + // at least a day since the last check. + static func checkAutomaticallyIfDue() { + guard automaticCheckEnabled else { return } + let last = UserDefaults.standard.object(forKey: lastCheckDefaultsKey) as? Date ?? .distantPast + guard Date().timeIntervalSince(last) > checkInterval else { return } + UserDefaults.standard.set(Date(), forKey: lastCheckDefaultsKey) + + fetchLatestVersion { latest in + guard let latest, isNewer(latest, than: currentVersion) else { return } + postAvailableNotification(version: latest) + } + } + + private static func fetchLatestVersion(completion: @escaping (String?) -> Void) { + URLSession.shared.dataTask(with: versionURL) { data, _, _ in + let version = data.flatMap { String(data: $0, encoding: .utf8) }? + .trimmingCharacters(in: .whitespacesAndNewlines) + completion((version?.isEmpty == false) ? version : nil) + }.resume() + } + + private static func isNewer(_ a: String, than b: String) -> Bool { + let av = a.split(separator: ".").compactMap { Int($0) } + let bv = b.split(separator: ".").compactMap { Int($0) } + for i in 0.. y } + } + return false + } + + private static func postAvailableNotification(version: String) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert]) { granted, _ in + guard granted else { return } + let content = UNMutableNotificationContent() + content.title = "shannoncoat \(version) is available" + content.body = "Click to view the release notes." + center.add(UNNotificationRequest(identifier: "shannoncoat-update-available", content: content, trigger: nil)) + } + } +} diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..f3ea456 --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,10 @@ +// Entry point. Its own file only because swiftc restricts top-level +// executable statements to a file named main.swift once a module has more +// than one source file. +import Cocoa + +let delegate = AppDelegate() +let app = NSApplication.shared +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8acdd82 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..9c91167 --- /dev/null +++ b/build.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Builds "shannoncoat.app" — a menu bar app (no Dock icon, no window) with a +# custom icon (icon/AppIcon.icns — see icon/generate-icon.swift) for +# Finder/Spotlight. It stays running in the menu bar: click the status item +# to switch profiles, or open Manage Profiles… (Cmd+,) to add/remove one. +# Launch at Login is a toggle inside the app itself. +# +# Builds into ./.build (gitignored) rather than installing straight into an +# Applications dir — copy/drag the .app from there yourself. It's a fully +# native, self-contained bundle — no bundled script, so copying it anywhere +# after the build is safe. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SOURCES_DIR="$SCRIPT_DIR/Sources" +VERSION_FILE="$SCRIPT_DIR/VERSION" +ICON_SRC="$SCRIPT_DIR/icon/AppIcon.icns" +OUT_DIR="${1:-$SCRIPT_DIR/.build}" +APP="$OUT_DIR/shannoncoat.app" + +# Read from the repo's VERSION file so the .app and the script it bundles +# never report different numbers. +VERSION="$(cat "$VERSION_FILE" 2>/dev/null)" +[[ -n "$VERSION" ]] || { echo "error: couldn't read $VERSION_FILE" >&2; exit 1; } + +mkdir -p "$OUT_DIR" "$APP/Contents/MacOS" "$APP/Contents/Resources" + +[[ -f "$ICON_SRC" ]] || { echo "error: $ICON_SRC missing (see icon/generate-icon.swift)" >&2; exit 1; } +cp "$ICON_SRC" "$APP/Contents/Resources/AppIcon.icns" + +cat >"$APP/Contents/Info.plist" < + + + + CFBundleNameshannoncoat + CFBundleDisplayNameshannoncoat + CFBundleIdentifiercom.local.shannoncoat + CFBundleVersion$VERSION + CFBundleShortVersionString$VERSION + CFBundlePackageTypeAPPL + CFBundleExecutablelauncher + CFBundleIconFileAppIcon + LSUIElement + NSHighResolutionCapable + + +PLIST + +command -v swiftc >/dev/null || { echo "error: swiftc not found (install Xcode Command Line Tools: xcode-select --install)" >&2; exit 1; } + +# VERSION travels in Resources (not baked into the binary) so the About +# panel and the update checker read the same file this script stamped into +# Info.plist above. +cp "$VERSION_FILE" "$APP/Contents/Resources/VERSION" +swiftc -O "$SOURCES_DIR"/*.swift -o "$APP/Contents/MacOS/launcher" + +touch "$APP" +echo "Built: $APP" +echo "Copy it to /Applications (or ~/Applications) to install." diff --git a/icon/AppIcon.icns b/icon/AppIcon.icns new file mode 100644 index 0000000..39e3506 Binary files /dev/null and b/icon/AppIcon.icns differ diff --git a/icon/generate-icon.swift b/icon/generate-icon.swift new file mode 100644 index 0000000..f8de3b2 --- /dev/null +++ b/icon/generate-icon.swift @@ -0,0 +1,115 @@ +// Generates a placeholder AppIcon.iconset — a plain rounded-square +// background with the coat glyph on top. Deliberately not derived from +// Claude's own icon assets (unlike the old version of this file, which +// composited on top of Claude.app's icon) — this is a stand-in until a +// real icon is designed, so it draws everything itself from vector shapes. +// +// Usage: +// swift icon/generate-icon.swift icon/AppIcon.iconset +// iconutil -c icns icon/AppIcon.iconset -o icon/AppIcon.icns +// rm -rf icon/AppIcon.iconset +import Cocoa + +// Renders at exact pixel dimensions via an explicit NSBitmapImageRep rather +// than NSImage.lockFocus() — lockFocus rasterizes at the *main screen's* +// backing scale factor, which would silently double every size on a Retina +// display instead of producing the exact px asked for. +func renderIcon(pixels: Int) -> Data { + let s = CGFloat(pixels) + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: pixels, + pixelsHigh: pixels, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) else { fatalError("could not create bitmap rep") } + rep.size = NSSize(width: s, height: s) + + NSGraphicsContext.saveGraphicsState() + let ctx = NSGraphicsContext(bitmapImageRep: rep) + NSGraphicsContext.current = ctx + ctx?.imageInterpolation = .high + + // Base: a plain rounded-square background (macOS "squircle"-ish corner + // radius), a neutral slate color — no borrowed assets. + let bg = NSColor(calibratedRed: 0.30, green: 0.32, blue: 0.36, alpha: 1.0) + let bgPath = NSBezierPath(roundedRect: NSRect(x: 0, y: 0, width: s, height: s), + xRadius: s * 0.22, yRadius: s * 0.22) + bg.setFill() + bgPath.fill() + + // 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() + + guard let png = rep.representation(using: .png, properties: [:]) else { + fatalError("failed to encode PNG at \(pixels)px") + } + return png +} + +let sizes: [(name: String, px: Int)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), + ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), + ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), +] + +let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "AppIcon.iconset" +try? FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true) +for (name, px) in sizes { + let data = renderIcon(pixels: px) + let path = "\(outDir)/\(name).png" + try? data.write(to: URL(fileURLWithPath: path)) + print("wrote \(path)") +} diff --git a/shannoncoat.sh b/shannoncoat.sh deleted file mode 100755 index ff9aae1..0000000 --- a/shannoncoat.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env bash -# shannoncoat - run more than one claude desktop profile on mac. -# -# claude desktop app seems to keep everything important in one config directory, -# so a different --user-data-dir gives a practically separate install. -# and CLAUDE_CONFIG_DIR env var can isolate claude code sessions natively. -# this script combines these to isolate both desktop sessions and the claude -# code sessions they run. -# -# profiles are yaml files in ~/.shannoncoat: -# # ~/.shannoncoat/client1.yaml -> profile named "client1" -# paths: -# code: ~/work/client1/.claude/code -# desktop: ~/work/client1/.claude/desktop -# -# claude code inside the desktop app can run in some folder which already -# defines a CLAUDE_CONFIG_DIR with direnv etc, that variable can be defined -# this way to allow both individual and desktop sessions work properly: -# export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" -# - -set -euo pipefail - -APP="${CLAUDE_APP_PATH:-/Applications/Claude.app}" -BIN="$APP/Contents/MacOS/Claude" -ROOT="$HOME/.shannoncoat" -DEFAULT="default" -APP_DIR="" -CODE_DIR="" -CODE_KEY="code" -DESKTOP_KEY="desktop" - -show_usage() { - cat < [profile] - - launch open a profile, leaving others running - switch quit all other profiles, then open this one - quit quit a profile - list list all running profiles - -Profiles are yaml files in ~/.shannoncoat/. Syntax: - # profile-name.yaml - paths: - $CODE_KEY: ~/path/to/claude/code - $DESKTOP_KEY: ~/path/to/claude/desktop -EOF -} - -# expand leading ~ in path string to $HOME -tilde_path() { - local p="$1" - echo "${p/#\~/$HOME}" -} - -# pull one key out of the yaml, simple job avoid yq for now -read_key() { - local file="$1" key="$2" - sed -EHn "s/^\s+$key:\s+//p" "$file" | tr -d '"' -} - -# set APP_DIR and CODE_DIR for a profile, or keep empty for $DEFAULT -get_dirs() { - local name="$1" file="$ROOT/$1.yaml" - - [[ "$name" == "$DEFAULT" ]] && { return; } - if [[ ! -f "$file" ]]; then - echo "no profile named $name (expected $file)" >&2 - exit 1 - fi - APP_DIR="$(tilde_path "$(read_key "$file" $DESKTOP_KEY)")" - CODE_DIR="$(tilde_path "$(read_key "$file" $CODE_KEY)")" -} - -# match a claude data dir to its profile name -dir_to_profile() { - local dir="$1" file name - [[ "$dir" == "$DEFAULT" ]] && { echo "$DEFAULT"; return; } - for file in "$ROOT"/*.yaml; do - [[ -f "$file" ]] || continue - name="$(basename "$file" .yaml)" - if [[ "$(tilde_path "$(read_key "$file" $DESKTOP_KEY)")" == "$dir" ]]; then - echo "$name" - return - fi - done - echo "$dir" -} - -# get all running claude processes as "piduser-data-dir" -running_info() { - local pid args dir - while read -r pid args; do - # if args has --user-data-dir=, extract it, otherwise use $DEFAULT - if [[ "$args" == *--user-data-dir=* ]]; then - dir="${args#*--user-data-dir=}" - dir="${dir%% *}" - else - dir="$DEFAULT" - fi - printf '%s\t%s\n' "$pid" "$dir" - done < <(pgrep -fl "$BIN" || true) -} - -# get pid of a profile's claude process -profile_to_pid() { - get_dirs "$1" - running_info | awk -F'\t' -v d="${APP_DIR:-default}" '$2 == d { print $1; exit }' -} - -# switch focus to a process window, shell/term needs accessibility permissions -app_focus() { - local pid="$1" - osascript > /dev/null 2>&1 < /dev/null 2>&1 & - disown -} - -# list all running profiles -cmd_list() { - local pid dir - while read -r pid dir; do - printf 'profile: %s\tpid: %s\n' "$(dir_to_profile "$dir")" "$pid" - done < <(running_info) -} - -# launch a profile or focus window if already running -cmd_launch() { - local name="${1:?profile name required}" pid - pid="$(profile_to_pid "$name")" - [[ -n "$pid" ]] && { app_focus "$pid"; return; } - app_start "$name" -} - -# switch to a profile quitting all others -cmd_switch() { - local name="${1:?profile name required}" pid dir keep - # marks which profile to keep running, "default" if empty - get_dirs "$name" - keep="${APP_DIR:-"$DEFAULT"}" - # quit all other running claudes except the one to $keep - while read -r pid dir; do - [[ "$dir" == "$keep" ]] && continue - kill "$pid" 2>/dev/null || true - done < <(running_info) - # then launch selected profile - cmd_launch "$name" -} - -# quit a profile if running -cmd_quit() { - local name="${1:?profile name required}" pid - pid="$(profile_to_pid "$name")" - [[ -z "$pid" ]] && { echo "$name isn't running"; return; } - kill "$pid" -} - -# main entry point -case "${1:-}" in - launch) cmd_launch "${2:-}" ;; - switch) cmd_switch "${2:-}" ;; - quit) cmd_quit "${2:-}" ;; - list) cmd_list ;; - -h|--help) show_usage; exit 0 ;; - *) show_usage; exit 1 ;; -esac