diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3587a99 --- /dev/null +++ b/CLAUDE.md @@ -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 ` 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`. diff --git a/README.md b/README.md index 8516f4c..1308c35 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_DEV.md b/README_DEV.md index 7baba83..408f0e7 100644 --- a/README_DEV.md +++ b/README_DEV.md @@ -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`. diff --git a/Sources/KlangLadderApp/PopoverView.swift b/Sources/KlangLadderApp/PopoverView.swift index 1a6a236..6467098 100644 --- a/Sources/KlangLadderApp/PopoverView.swift +++ b/Sources/KlangLadderApp/PopoverView.swift @@ -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) } @@ -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) } @@ -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 @@ -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 } } @@ -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) } } diff --git a/Sources/KlangLadderCore/Model.swift b/Sources/KlangLadderCore/Model.swift index e3c6553..b4eac74 100644 --- a/Sources/KlangLadderCore/Model.swift +++ b/Sources/KlangLadderCore/Model.swift @@ -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) { diff --git a/Tests/KlangLadderCoreTests/RulesTests.swift b/Tests/KlangLadderCoreTests/RulesTests.swift index 5e47787..e3f0cf9 100644 --- a/Tests/KlangLadderCoreTests/RulesTests.swift +++ b/Tests/KlangLadderCoreTests/RulesTests.swift @@ -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"]) }