From 08360e35ae0230cf55bd92e15a57f1cbd1fdbdff Mon Sep 17 00:00:00 2001 From: Matt Clarke Date: Sun, 8 Mar 2026 13:58:03 -0400 Subject: [PATCH 1/2] add claude.md --- CLAUDE.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5ef0bfd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Build +swift build # Debug build +swift build -c release # Release build +./build.sh release # Release build with .app bundle + +# Test +swift test # All tests +swift test --filter INIParserTests # Single test class +swift test --filter INIParserTests.testParseSimple # Single test method + +# CI validates: swift test && swift build && ./build.sh release +``` + +## Architecture + +**Switchboard** is a native macOS menu bar app (SwiftUI/MVVM) for managing AWS CLI profiles and launching terminals with assumed credentials. + +**Stack:** SwiftUI + Swift Package Manager, no external dependencies, macOS 13+. + +### Layers + +- **Models** (`Models/AWSProfile.swift`) — Core data model. Supports four profile types: static credentials, AWS SSO, assume-role, and environment. + +- **Services** (`Services/`) — Business logic, each independently testable: + - `AWSConfigService` — Parses `~/.aws/config` and `~/.aws/credentials` via `INIParser`; watches files with `DispatchSourceFileSystemObject` (0.5s debounce). + - `TerminalService` — Launches Terminal.app, iTerm2, Warp, or Ghostty via AppleScript (`Utilities/AppleScriptTemplates.swift`). Requires Accessibility permissions. + - `GrantedService` — Detects Granted CLI via PATH for credential assumption. + - `ProfileHistoryService` — Persists favorites and recents to `UserDefaults` as JSON. + +- **ViewModel** (`ViewModels/ProfileViewModel.swift`) — Single `@MainActor` class coordinating all services; owns filtered/favorite profile state. + +- **Views** (`Views/`) — `SwitchboardApp` registers a `MenuBarExtra` + `WindowGroup` + `Settings` scene. `MainWindowView` is the primary window with search and profile list. `PreferencesView` handles terminal selection and file-watching toggle. + +- **Utilities** (`Utilities/INIParser.swift`) — Custom INI parser (no external libs); `AppleScriptTemplates.swift` contains per-terminal AppleScript strings. + +### Key Patterns + +- Services use constructor injection for testability. +- All UI state flows through `ProfileViewModel`; views are read-only consumers. +- Terminal launching uses `NSAppleScript` (synchronous); errors surface as `TerminalLaunchError`. +- Tests live in `tests/` and cover `INIParser`, `AWSProfile` parsing, and AppleScript string escaping. + +### Release + +Releases are triggered by `v*` tags. The `release.yml` workflow runs tests, builds the `.app`, creates a tarball + SHA256, uploads artifacts, creates a GitHub release, and updates the Homebrew tap. The current version is tracked in `VERSION`. From 5aedd7f21ce37b814a8330f9fe4b6d8e02fbd0cf Mon Sep 17 00:00:00 2001 From: Matt Clarke Date: Sun, 8 Mar 2026 14:02:53 -0400 Subject: [PATCH 2/2] fix(core): update accessibility permission handling --- Package.swift | 3 +- switchboard/Services/TerminalService.swift | 14 ++-- tests/TerminalServiceTests.swift | 90 ++++++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 tests/TerminalServiceTests.swift diff --git a/Package.swift b/Package.swift index 347f5ae..41c6bdd 100644 --- a/Package.swift +++ b/Package.swift @@ -45,7 +45,8 @@ let package = Package( sources: [ "INIParserTests.swift", "AWSProfileTests.swift", - "StringEscapingTests.swift" + "StringEscapingTests.swift", + "TerminalServiceTests.swift" ] ) ] diff --git a/switchboard/Services/TerminalService.swift b/switchboard/Services/TerminalService.swift index 35e2856..8f39edf 100644 --- a/switchboard/Services/TerminalService.swift +++ b/switchboard/Services/TerminalService.swift @@ -73,10 +73,14 @@ class TerminalService { NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil } - /// Whether this terminal supports AppleScript automation - var supportsAppleScript: Bool { - // All supported terminals now use AppleScript - return true + /// Whether this terminal requires Accessibility permissions (System Events keystroke access) + /// Terminal.app and iTerm2 have native AppleScript dictionaries and don't need this. + /// Warp and Ghostty rely on System Events keystrokes, which require Accessibility access. + var requiresSystemEventsAccess: Bool { + switch self { + case .terminal, .iTerm2: return false + case .warp, .ghostty: return true + } } } @@ -92,7 +96,7 @@ class TerminalService { /// - Parameter terminal: Terminal to check /// - Returns: True if the terminal requires Accessibility permissions func requiresAccessibilityPermissions(_ terminal: Terminal) -> Bool { - return terminal.supportsAppleScript + return terminal.requiresSystemEventsAccess } /// Open System Settings to the Accessibility privacy pane diff --git a/tests/TerminalServiceTests.swift b/tests/TerminalServiceTests.swift new file mode 100644 index 0000000..5343689 --- /dev/null +++ b/tests/TerminalServiceTests.swift @@ -0,0 +1,90 @@ +// +// TerminalServiceTests.swift +// SwitchboardTests +// +// Unit tests for TerminalService terminal type properties and accessibility permission logic +// + +import XCTest +@testable import Switchboard + +final class TerminalServiceTests: XCTestCase { + + // MARK: - requiresSystemEventsAccess + + // Terminal.app and iTerm2 have native AppleScript scripting dictionaries and + // do not drive input via System Events keystrokes, so they do not need + // Accessibility permissions. + + func testRequiresSystemEventsAccess_TerminalApp_IsFalse() { + XCTAssertFalse(TerminalService.Terminal.terminal.requiresSystemEventsAccess) + } + + func testRequiresSystemEventsAccess_iTerm2_IsFalse() { + XCTAssertFalse(TerminalService.Terminal.iTerm2.requiresSystemEventsAccess) + } + + // Warp and Ghostty have no AppleScript dictionary, so the scripts use + // System Events keystrokes — which requires Accessibility permissions. + + func testRequiresSystemEventsAccess_Warp_IsTrue() { + XCTAssertTrue(TerminalService.Terminal.warp.requiresSystemEventsAccess) + } + + func testRequiresSystemEventsAccess_Ghostty_IsTrue() { + XCTAssertTrue(TerminalService.Terminal.ghostty.requiresSystemEventsAccess) + } + + // MARK: - requiresAccessibilityPermissions + + func testRequiresAccessibilityPermissions_TerminalApp_IsFalse() { + let service = TerminalService() + XCTAssertFalse(service.requiresAccessibilityPermissions(.terminal)) + } + + func testRequiresAccessibilityPermissions_iTerm2_IsFalse() { + let service = TerminalService() + XCTAssertFalse(service.requiresAccessibilityPermissions(.iTerm2)) + } + + func testRequiresAccessibilityPermissions_Warp_IsTrue() { + let service = TerminalService() + XCTAssertTrue(service.requiresAccessibilityPermissions(.warp)) + } + + func testRequiresAccessibilityPermissions_Ghostty_IsTrue() { + let service = TerminalService() + XCTAssertTrue(service.requiresAccessibilityPermissions(.ghostty)) + } + + // MARK: - Warp and Ghostty AppleScript templates use System Events + + // These templates must use System Events keystrokes (the reason they need + // Accessibility permissions in the first place). + + func testWarpScript_UsesSystemEvents() { + let script = AppleScriptTemplates.warp(profileName: "test") + XCTAssertTrue(script.contains("tell application \"System Events\"")) + XCTAssertTrue(script.contains("keystroke")) + } + + func testGhosttyScript_UsesSystemEvents() { + let script = AppleScriptTemplates.ghostty(profileName: "test") + XCTAssertTrue(script.contains("tell application \"System Events\"")) + XCTAssertTrue(script.contains("keystroke")) + } + + // MARK: - Terminal.app and iTerm2 do NOT use System Events + + func testTerminalAppScript_DoesNotUseSystemEvents() { + let script = AppleScriptTemplates.terminalApp(profileName: "test") + XCTAssertFalse(script.contains("System Events")) + XCTAssertFalse(script.contains("keystroke")) + } + + func testITerm2Script_DoesNotUseSystemEvents() { + let script = AppleScriptTemplates.iTerm2(profileName: "test") + XCTAssertFalse(script.contains("System Events")) + XCTAssertFalse(script.contains("keystroke")) + } +}