Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ let package = Package(
sources: [
"INIParserTests.swift",
"AWSProfileTests.swift",
"StringEscapingTests.swift"
"StringEscapingTests.swift",
"TerminalServiceTests.swift"
]
)
]
Expand Down
14 changes: 9 additions & 5 deletions switchboard/Services/TerminalService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand All @@ -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
Expand Down
90 changes: 90 additions & 0 deletions tests/TerminalServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}