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
51 changes: 51 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

KlangLadder is a macOS 14+ menu bar app (Swift 5.10, SwiftPM, no Xcode project). It switches the default audio input/output device by a per-scope priority list. `DESIGN.md` is the spec. `README_DEV.md` is the detailed implementation guide. Code comments cite design sections as `(4.4)`, `(6.2)` and goals as `(G6)`, `(G19)`. Keep using these references and look them up in `DESIGN.md`.

## Commands

```sh
swift build # debug build
swift test # all tests (swift-testing, not XCTest)
swift test --filter connectRanksAboveActive_switches # one test, by function name
./bundle.sh # release build → build/KlangLadder.app (ad-hoc signed)
open build/KlangLadder.app
cd raycast && npm ci && npm run lint # Raycast extension lint (ray lint), runs in CI
cd raycast && npm run build # ray build; needs full Xcode 16.3+, not just CLT
```

`.build/debug/KlangLadder` runs without a bundle ID or Info.plist. The URL scheme, the single-instance check and launch at login only work in the bundled app.

## Architecture

Three targets in the root package:

- **KlangLadderCore**: all logic, no UI.
- `Model.swift`: `ScopeConfig` and `Rules.target`. These are pure and unit-tested in `Tests/KlangLadderCoreTests/RulesTests.swift`. The tests mirror table 6.4.
- `Engine.swift`: `@MainActor @Observable`. Wires Core Audio events to the rules. Also holds `ConfigStore` and `LoginSession`.
- `CoreAudio.swift`: thin HAL adapter.
- **KlangLadderApp**: a *library* containing the AppKit shell (`App.swift`), the SwiftUI popover and `AppBundle`. It is a library so the Raycast binary can link it and call `runApp()`.
- **KlangLadder**: a thin executable. `--bundle <path>` assembles the .app (used by `bundle.sh`). Otherwise it calls `runApp()`.

Invariants to preserve:

- **Rank = array index** in `ScopeConfig.priority`. Never store positions. A device is in exactly one of `priority` or `disabled`. Disabled or unknown means rank −∞ (`rank()` returns nil).
- **Only connect/disconnect events (and the first start per login session) switch devices.** Config edits, `delete` and manual default changes never do. User actions go through `engine.edit(scope) { ... }`, which only mutates and saves.
- **Telling a macOS auto-switch from a manual choice** (4.4): device-list events are debounced (`debounceDelay`). `defaultChanged` ignores events while an evaluation is pending, and handles late auto-switches via `recentConnect` within `autoSwitchWindow`. `active[scope]` is the *recorded* default and is passed as `previous` to `Rules.target`, not whatever Core Audio reports now. The two timing knobs are unverified guesses (spike S5).
- `config.json` in `~/Library/Application Support/KlangLadder/` is written only by the running app, atomically. An unreadable file or `version > 1` gets moved aside, never overwritten. Add schema migrations in `ConfigStore.load`.
- The URL scheme is a trust boundary. Only `klangladder://open` is accepted, and everything else is ignored.
- Everything runs on the main thread. Core Audio listeners deliver on `.main`, and callbacks use `MainActor.assumeIsolated`.

## Raycast extension (`raycast/`)

`raycast/swift` is a separate package. It depends on the root package via `.package(path: "../..")` and refers to it as `package: "KlangLadder"`. SwiftPM derives that identity from the folder name, so the repo folder must be named `KlangLadder`. The Raycast package uses its own `main.swift` (only `RaycastTypeScriptPlugin`, not `RaycastSwiftPlugin`). The one compiled binary is both the Raycast bridge and the app. `Install.swift` installs or updates `~/Applications/KlangLadder.app` from that binary. It never touches a standalone copy, and it detects updates by SHA-256 stored in a marker file.

## CI / release

- `build.yml` runs on PRs to non-main branches: swift build, swift test, ray lint.
- `package.yml` runs on PRs to `main`: bundle, Homebrew formula install/test/audit from a local tap, and `ray build`.
- `release.yml` runs on every push to `main`. It auto-bumps the patch version, creates a GitHub release, rewrites the `url`/`tag`/`revision` in `Formula/klangladder.rb` and commits it to `main` as the bot, then publishes to the Raycast Store. It also swaps the `../..` path dependency for the GitHub URL.

Don't hand-edit the formula's `url` block. The release job owns it. `bundle.sh` forwards its arguments to `swift build`, because the formula needs `--disable-sandbox`.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ Left-click the menu bar icon to open the device lists. Right-click it for settin

- **Output / Input tabs:** each tab has its own priority list. The popover opens on the tab you used last.
- **Make a device active:** click a connected device.
- **Change the ranking:** drag a device up or down. The device at the top is number 1.
- **Change the ranking:** drag a device to a new place, or use **Move Up** / **Move Down** to shift it one place. The device at the top is number 1.
- **More actions:** right-click a device, or hover over it and click the **…** button that appears.
- Move to Top / Move to Bottom
- Move Up / Move Down: one place at a time
- Disable: moves the device to the Disabled list
- Enable (in the Disabled list): adds the device to the bottom of the priority list
- Delete: only for disconnected devices
Expand Down
3 changes: 2 additions & 1 deletion README_DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ Styled after the system menu bar extras (Sound, Wi-Fi, Bluetooth): stock SwiftUI
The width is 320. The height follows the content: rows are exactly 28 points tall, so the view gives the `List` `rows * 28`, capped at twelve rows, and `App.swift` lets the hosting controller report that size (`sizingOptions = [.preferredContentSize]`). A `List` has no ideal height of its own, so without that explicit height the popover grew past the screen.

- The segmented tab is stored in `@AppStorage("lastTab")`.
- The priority list is a `ForEach` with `.onMove` for drag and drop. It writes through `engine.edit`.
- Clicking and dragging are native `List` behavior. A click selects the row, and the selection binding's setter calls `makeActive` for connected devices; its getter is the active device, so the active row shows the system selection. Drag and drop is `.onMove`. Rows must not get their own tap gesture: on macOS it swallows the mouse-down, so the list never starts a drag.
- Move Up and Move Down go through `ScopeConfig.move(_:to:)`, which clamps the index.
- The Disabled list is a `DisclosureGroup`. `isExpanded` is the user's manual toggle if set, else "the active device is disabled" (G11).
- **`DeviceRow`**
- Tap on a connected device: `makeActive`.
Expand Down
19 changes: 13 additions & 6 deletions Sources/KlangLadderApp/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ struct PopoverView: View {
.padding(.horizontal, 12)
.padding(.top, 12)

List {
// Native click and drag: a tap gesture on the rows would stop the list from starting drags.
List(selection: Binding(
get: { active },
set: { uid in if let uid, connected.contains(uid) { engine.makeActive(uid, scope) } }
)) {
ForEach(Array(config.priority.enumerated()), id: \.element.id) { index, entry in
DeviceRow(engine: engine, scope: scope, entry: entry, position: index + 1,
isLast: index == config.priority.count - 1,
ambiguous: ambiguous.contains(entry.displayName),
isConnected: connected.contains(entry.uid), isActive: active == entry.uid)
}
Expand All @@ -47,7 +52,7 @@ struct PopoverView: View {
set: { disabledExpanded[scope] = $0 }
)) {
ForEach(config.disabled) { entry in
DeviceRow(engine: engine, scope: scope, entry: entry, position: nil,
DeviceRow(engine: engine, scope: scope, entry: entry, position: nil, isLast: false,
ambiguous: ambiguous.contains(entry.displayName),
isConnected: connected.contains(entry.uid), isActive: active == entry.uid)
}
Expand Down Expand Up @@ -81,6 +86,7 @@ struct DeviceRow: View {
let scope: Scope
let entry: DeviceEntry
let position: Int? // nil = in Disabled list
let isLast: Bool
let ambiguous: Bool
let isConnected: Bool
let isActive: Bool
Expand Down Expand Up @@ -130,7 +136,6 @@ struct DeviceRow: View {
.listRowSeparator(.hidden)
.help("\(isConnected ? "Connected" : "Disconnected") · \(entry.transport) · last seen \(entry.lastSeen.formatted(date: .abbreviated, time: .shortened)) · \(entry.uid)")
.onHover { hovering = $0 }
.onTapGesture { if isConnected { engine.makeActive(entry.uid, scope) } }
.contextMenu { actions }
}

Expand All @@ -149,9 +154,11 @@ struct DeviceRow: View {

@ViewBuilder private var actions: some View {
let uid = entry.uid
if position != nil {
Button("Move to Top") { engine.edit(scope) { $0.moveToTop(uid) } }
Button("Move to Bottom") { engine.edit(scope) { $0.moveToBottom(uid) } }
if let position {
Button("Move Up") { engine.edit(scope) { $0.move(uid, to: position - 2) } }
.disabled(position == 1)
Button("Move Down") { engine.edit(scope) { $0.move(uid, to: position) } }
.disabled(isLast)
Button("Disable") { engine.edit(scope) { $0.disable(uid) } }
} else {
Button("Enable") { engine.edit(scope) { $0.enable(uid) } }
Expand Down
11 changes: 4 additions & 7 deletions Sources/KlangLadderCore/Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,11 @@ public struct ScopeConfig: Codable, Equatable, Sendable {
if let i = disabled.firstIndex(where: { $0.uid == uid }) { change(&disabled[i]) }
}

public mutating func moveToTop(_ uid: String) {
/// Moves a priority entry to `index`, clamped to the list. Used by Move Up/Down and drag and drop.
public mutating func move(_ uid: String, to index: Int) {
guard let i = rank(uid) else { return }
priority.insert(priority.remove(at: i), at: 0)
}

public mutating func moveToBottom(_ uid: String) {
guard let i = rank(uid) else { return }
priority.append(priority.remove(at: i))
let entry = priority.remove(at: i)
priority.insert(entry, at: min(max(index, 0), priority.count))
}

public mutating func disable(_ uid: String) {
Expand Down
19 changes: 11 additions & 8 deletions Tests/KlangLadderCoreTests/RulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,17 @@ private func cfg(_ priority: [String], disabled: [String] = []) -> ScopeConfig {
#expect(c.disabled.map(\.uid) == ["b"])
}

@Test func moveToTopMovesEntryToFront() {
@Test func moveShiftsEntryOneSlotOrToDropTarget() {
var c = cfg(["a", "b", "c"])
c.moveToTop("c")
c.move("c", to: 1) // Move Up
#expect(c.priority.map(\.uid) == ["a", "c", "b"])
c.move("a", to: 1) // Move Down
#expect(c.priority.map(\.uid) == ["c", "a", "b"])
}

@Test func moveToBottomMovesEntryToEnd() {
var c = cfg(["a", "b", "c"])
c.moveToBottom("a")
#expect(c.priority.map(\.uid) == ["b", "c", "a"])
c.move("c", to: 2) // dropped on the last row
#expect(c.priority.map(\.uid) == ["a", "b", "c"])
c.move("a", to: -1) // Move Up on the first row
c.move("c", to: 3) // Move Down on the last row
#expect(c.priority.map(\.uid) == ["a", "b", "c"])
c.move("x", to: 0) // not in the priority list
#expect(c.priority.map(\.uid) == ["a", "b", "c"])
}
Loading