From 7f2bf3c481cb309ea2491c13c7b4330859c31df4 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 10:42:06 -0700 Subject: [PATCH 01/18] Lower deployment target to macOS 14.5 The project file targeted 26.2 while CI, release and the DMG script overrode it to 15.0. Use 14.5 everywhere and update the README, site and Homebrew cask (which can only express :sonoma). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 2 +- Focusrite Volume Control.xcodeproj/project.pbxproj | 8 ++++---- README.md | 2 +- homebrew/Casks/focusrite-volume-control.rb | 2 +- scripts/build-dmg.sh | 2 +- site/index.html | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53cf250..61be61e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: env: # Override deployment target for CI runner compatibility - MACOSX_DEPLOYMENT_TARGET: '15.0' + MACOSX_DEPLOYMENT_TARGET: '14.5' # Skip code signing on CI CODE_SIGN_IDENTITY: '' CODE_SIGNING_REQUIRED: 'NO' @@ -23,12 +23,12 @@ jobs: - name: Build run: > xcodebuild -scheme "Focusrite Volume Control" -configuration Debug - MACOSX_DEPLOYMENT_TARGET=15.0 + MACOSX_DEPLOYMENT_TARGET=14.5 CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO build - name: Test run: > xcodebuild test -scheme "Focusrite Volume Control" -configuration Debug - MACOSX_DEPLOYMENT_TARGET=15.0 + MACOSX_DEPLOYMENT_TARGET=14.5 CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f95ab14..9e33ad8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Build Release run: > xcodebuild -scheme "Focusrite Volume Control" -configuration Release - MACOSX_DEPLOYMENT_TARGET=15.0 + MACOSX_DEPLOYMENT_TARGET=14.5 CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO build diff --git a/Focusrite Volume Control.xcodeproj/project.pbxproj b/Focusrite Volume Control.xcodeproj/project.pbxproj index 8466843..bc1bc5f 100644 --- a/Focusrite Volume Control.xcodeproj/project.pbxproj +++ b/Focusrite Volume Control.xcodeproj/project.pbxproj @@ -254,7 +254,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 14.5; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -312,7 +312,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 14.5; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -394,7 +394,7 @@ CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = 5FNR593BWG; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 14.5; MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = solutions.enum.FocusriteVolumeControlTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -415,7 +415,7 @@ CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = 5FNR593BWG; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; + MACOSX_DEPLOYMENT_TARGET = 14.5; MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = solutions.enum.FocusriteVolumeControlTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/README.md b/README.md index ebc9c47..6107e0e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Download the latest DMG from [Releases](https://github.com/enum-labs/focusrite-v ## Requirements -- macOS 15.0 (Sequoia) or later +- macOS 14.5 (Sonoma) or later - [Focusrite Control 2](https://focusrite.com/downloads) installed - Accessibility permission (prompted on first launch) diff --git a/homebrew/Casks/focusrite-volume-control.rb b/homebrew/Casks/focusrite-volume-control.rb index 1368462..4dde3b2 100644 --- a/homebrew/Casks/focusrite-volume-control.rb +++ b/homebrew/Casks/focusrite-volume-control.rb @@ -7,7 +7,7 @@ desc "Control Focusrite Scarlett 4th gen volume with media keys via Focusrite Control 2" homepage "https://enum-labs.github.io/focusrite-volume-control/" - depends_on macos: ">= :sequoia" + depends_on macos: ">= :sonoma" app "Focusrite Volume Control.app" diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index 2f51d7a..aabdcac 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -37,7 +37,7 @@ echo "==> Building Release..." xcodebuild -project "$PROJECT_DIR/Focusrite Volume Control.xcodeproj" \ -scheme "$SCHEME" \ -configuration Release \ - MACOSX_DEPLOYMENT_TARGET=15.0 \ + MACOSX_DEPLOYMENT_TARGET=14.5 \ $SIGN_ARGS \ build \ -quiet diff --git a/site/index.html b/site/index.html index 0c26221..a57dd9b 100644 --- a/site/index.html +++ b/site/index.html @@ -84,7 +84,7 @@

Auto-Detect

Download

-

Free and open source. Requires macOS 15+ and Focusrite Control 2.

+

Free and open source. Requires macOS 14.5+ and Focusrite Control 2.

From 02aa808d3838b02f79edba105aabdeac3b73178d Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 10:42:16 -0700 Subject: [PATCH 02/18] Match Direct Monitor checkbox by prefix FC2 titles the checkbox "Direct Monitor" in some versions and "Direct Monitor mode" in others, so the exact-title lookup failed for some users. Match any checkbox whose name starts with "Direct Monitor". Fixes #1 Co-Authored-By: Claude Opus 5.5 --- Focusrite Volume Control/AppleScriptBackend.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Focusrite Volume Control/AppleScriptBackend.swift b/Focusrite Volume Control/AppleScriptBackend.swift index cf73774..d9ebd51 100644 --- a/Focusrite Volume Control/AppleScriptBackend.swift +++ b/Focusrite Volume Control/AppleScriptBackend.swift @@ -18,6 +18,12 @@ class AppleScriptBackend: VolumeBackend { private let fc2Process = "Focusrite Control 2" private let logger = Logger(subsystem: "solutions.enum.FocusriteVolumeControl", category: "AppleScriptBackend") + /// The Direct Monitor checkbox is titled "Direct Monitor" in some FC2 versions and + /// "Direct Monitor mode" in others, so match on the prefix (issue #1) + private var directMonitorCheckbox: String { + "first checkbox of window \"\(fc2Process)\" whose name starts with \"Direct Monitor\"" + } + // MARK: - State private var _state = VolumeState() @@ -459,7 +465,7 @@ class AppleScriptBackend: VolumeBackend { let script = """ tell application "System Events" tell process "\(fc2Process)" - return value of checkbox "Direct Monitor" of window "\(fc2Process)" + return value of (\(directMonitorCheckbox)) end tell end tell """ @@ -478,7 +484,7 @@ class AppleScriptBackend: VolumeBackend { let script = """ tell application "System Events" tell process "\(fc2Process)" - set cb to checkbox "Direct Monitor" of window "\(fc2Process)" + set cb to \(directMonitorCheckbox) if (value of cb) is not \(targetValue) then click cb end if From 91286dd1a5d0cd62cd5136995bafc3a24e53adf8 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 10:42:41 -0700 Subject: [PATCH 03/18] Fix shortcut recorder lifecycle and protect navigation keys The recorder's native view kept keyboard focus after recording finished or was cancelled, and its keyDown handler never checked whether it was still recording, so the next key (for example Left Arrow) was silently saved as a global shortcut. The global handler also accepted bare navigation keys. - Accept keys only while recording; release focus on finish, cancel or focus loss; ignore key repeats - One shared recordingAction; global shortcuts pause while recording - Arrow, navigation and editing keys need Command, Control or Option - Repair unsafe saved bindings on load - Monitors update from the newly published values Ported from William Chan's MIT-licensed version (wc2184/focusrite-volume-control, commit 1c204be) with its regression tests and postmortem. Adds his copyright line and a README credit. Co-authored-by: William Chan <68214261+wc2184@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- Focusrite Volume Control/HotkeyManager.swift | 19 +- .../KeyboardShortcuts.swift | 36 +++- .../ShortcutRecorderView.swift | 59 +++++- .../ShortcutRecorderTests.swift | 199 ++++++++++++++++++ LICENSE.md | 1 + README.md | 4 + docs/shortcut-bug-postmortem.md | 59 ++++++ 7 files changed, 361 insertions(+), 16 deletions(-) create mode 100644 Focusrite Volume ControlTests/ShortcutRecorderTests.swift create mode 100644 docs/shortcut-bug-postmortem.md diff --git a/Focusrite Volume Control/HotkeyManager.swift b/Focusrite Volume Control/HotkeyManager.swift index f5f3ceb..3adcbfc 100644 --- a/Focusrite Volume Control/HotkeyManager.swift +++ b/Focusrite Volume Control/HotkeyManager.swift @@ -26,27 +26,31 @@ class HotkeyManager { // Observe shortcut changes to update handlers shortcutManager.$shortcuts - .sink { [weak self] _ in - self?.updateMonitors() + .combineLatest(shortcutManager.$recordingAction) + .sink { [weak self] shortcuts, recordingAction in + self?.updateMonitors(shortcuts: shortcuts, recordingAction: recordingAction) } .store(in: &cancellables) } func start() { - updateMonitors() + updateMonitors(shortcuts: shortcutManager.shortcuts, + recordingAction: shortcutManager.recordingAction) } func stop() { removeMonitors() } - private func updateMonitors() { + private func updateMonitors(shortcuts: [ShortcutAction: KeyboardShortcut?], + recordingAction: ShortcutAction?) { removeMonitors() + guard recordingAction == nil else { return } // Check if any custom (non-media-key) shortcuts are configured let hasCustomShortcuts = ShortcutAction.allCases.contains { action in - if let shortcut = shortcutManager.shortcut(for: action) { - return !shortcut.isMediaKey && shortcut.keyCode != nil + if let shortcut = shortcuts[action] ?? nil { + return !shortcut.isMediaKey && shortcut.isAllowedGlobalShortcut } return false } @@ -80,6 +84,7 @@ class HotkeyManager { @discardableResult private func handleKeyEvent(_ event: NSEvent) -> Bool { + guard shortcutManager.recordingAction == nil else { return false } let keyCode = event.keyCode let modifiers = event.modifierFlags.intersection([.control, .option, .shift, .command]) @@ -87,6 +92,7 @@ class HotkeyManager { for action in ShortcutAction.allCases { guard let shortcut = shortcutManager.shortcut(for: action), !shortcut.isMediaKey, + shortcut.isAllowedGlobalShortcut, let shortcutKeyCode = shortcut.keyCode, shortcutKeyCode == keyCode, shortcut.modifiers == modifiers else { @@ -95,6 +101,7 @@ class HotkeyManager { // Found a match - execute the action DispatchQueue.main.async { [weak self] in + guard self?.shortcutManager.recordingAction == nil else { return } self?.executeAction(action) } return true diff --git a/Focusrite Volume Control/KeyboardShortcuts.swift b/Focusrite Volume Control/KeyboardShortcuts.swift index 6d19084..062630e 100644 --- a/Focusrite Volume Control/KeyboardShortcuts.swift +++ b/Focusrite Volume Control/KeyboardShortcuts.swift @@ -61,6 +61,23 @@ struct KeyboardShortcut: Codable, Equatable { self.mediaKeyType = mediaKeyType } + /// Navigation keys belong to the focused app unless an explicit modifier + /// distinguishes the global shortcut from ordinary navigation/selection. + var isAllowedGlobalShortcut: Bool { + if isMediaKey { return mediaKeyType != nil } + guard let keyCode else { return false } + let navigationKeys: Set = [ + UInt16(kVK_LeftArrow), UInt16(kVK_RightArrow), + UInt16(kVK_UpArrow), UInt16(kVK_DownArrow), + UInt16(kVK_Home), UInt16(kVK_End), + UInt16(kVK_PageUp), UInt16(kVK_PageDown), + UInt16(kVK_Tab), UInt16(kVK_Return), + UInt16(kVK_Delete), UInt16(kVK_ForwardDelete), UInt16(kVK_Escape) + ] + return !navigationKeys.contains(keyCode) + || !modifiers.intersection([.command, .control, .option]).isEmpty + } + // Custom Codable for NSEvent.ModifierFlags enum CodingKeys: String, CodingKey { case keyCode, modifiersRaw, isMediaKey, mediaKeyType @@ -189,23 +206,33 @@ class ShortcutManager: ObservableObject { static let shared = ShortcutManager() @Published var shortcuts: [ShortcutAction: KeyboardShortcut?] = [:] + @Published var recordingAction: ShortcutAction? private let userDefaultsKey = "keyboardShortcuts" + private let preferences: UserDefaults? - init() { + init(preferences: UserDefaults? = .standard) { + self.preferences = preferences loadShortcuts() } func loadShortcuts() { - if let data = UserDefaults.standard.data(forKey: userDefaultsKey), + if let data = preferences?.data(forKey: userDefaultsKey), let decoded = try? JSONDecoder().decode([String: KeyboardShortcut].self, from: data) { + var repaired = false for action in ShortcutAction.allCases { if let shortcut = decoded[action.rawValue] { - shortcuts[action] = shortcut + if shortcut.isAllowedGlobalShortcut { + shortcuts[action] = shortcut + } else { + shortcuts[action] = action.defaultShortcut + repaired = true + } } else { shortcuts[action] = action.defaultShortcut } } + if repaired { saveShortcuts() } } else { // Set defaults for action in ShortcutAction.allCases { @@ -222,11 +249,12 @@ class ShortcutManager: ObservableObject { } } if let data = try? JSONEncoder().encode(toEncode) { - UserDefaults.standard.set(data, forKey: userDefaultsKey) + preferences?.set(data, forKey: userDefaultsKey) } } func setShortcut(_ shortcut: KeyboardShortcut?, for action: ShortcutAction) { + guard shortcut?.isAllowedGlobalShortcut != false else { return } shortcuts[action] = shortcut saveShortcuts() } diff --git a/Focusrite Volume Control/ShortcutRecorderView.swift b/Focusrite Volume Control/ShortcutRecorderView.swift index ebc225b..54067ab 100644 --- a/Focusrite Volume Control/ShortcutRecorderView.swift +++ b/Focusrite Volume Control/ShortcutRecorderView.swift @@ -13,7 +13,20 @@ import AppKit struct ShortcutRecorderView: View { let action: ShortcutAction @ObservedObject var shortcutManager: ShortcutManager - @State private var isRecording = false + private var isRecording: Bool { shortcutManager.recordingAction == action } + + private var recordingBinding: Binding { + Binding( + get: { isRecording }, + set: { recording in + if recording { + shortcutManager.recordingAction = action + } else if isRecording { + shortcutManager.recordingAction = nil + } + } + ) + } var body: some View { HStack(spacing: 8) { @@ -22,7 +35,7 @@ struct ShortcutRecorderView: View { ShortcutRecorderButton( shortcut: shortcutManager.shortcuts[action] ?? nil, - isRecording: $isRecording, + isRecording: recordingBinding, onShortcutRecorded: { shortcut in shortcutManager.setShortcut(shortcut, for: action) } @@ -39,6 +52,9 @@ struct ShortcutRecorderView: View { .opacity(isDefault ? 0.3 : 1.0) .disabled(isDefault) } + .onDisappear { + if isRecording { shortcutManager.recordingAction = nil } + } } private var isDefault: Bool { @@ -107,6 +123,12 @@ struct ShortcutRecorderEventHandler: NSViewRepresentable { func makeNSView(context: Context) -> ShortcutRecorderNSView { let view = ShortcutRecorderNSView() + configure(view) + return view + } + + private func configure(_ view: ShortcutRecorderNSView) { + view.isRecording = isRecording view.onShortcutRecorded = { shortcut in onShortcutRecorded(shortcut) isRecording = false @@ -114,14 +136,17 @@ struct ShortcutRecorderEventHandler: NSViewRepresentable { view.onCancel = { isRecording = false } - return view } func updateNSView(_ nsView: ShortcutRecorderNSView, context: Context) { + configure(nsView) if isRecording { - DispatchQueue.main.async { + DispatchQueue.main.async { [weak nsView] in + guard let nsView, nsView.isRecording else { return } nsView.window?.makeFirstResponder(nsView) } + } else if nsView.window?.firstResponder === nsView { + nsView.window?.makeFirstResponder(nil) } } } @@ -129,14 +154,31 @@ struct ShortcutRecorderEventHandler: NSViewRepresentable { // MARK: - NSView for Key Event Handling class ShortcutRecorderNSView: NSView { + var isRecording = false var onShortcutRecorded: ((KeyboardShortcut) -> Void)? var onCancel: (() -> Void)? - override var acceptsFirstResponder: Bool { true } + override var acceptsFirstResponder: Bool { isRecording } + + override func resignFirstResponder() -> Bool { + let resigned = super.resignFirstResponder() + if resigned && isRecording { + isRecording = false + onCancel?() + } + return resigned + } + + private func finishRecording() { + isRecording = false + if window?.firstResponder === self { window?.makeFirstResponder(nil) } + } override func keyDown(with event: NSEvent) { + guard isRecording, !event.isARepeat else { return } // Escape cancels recording if event.keyCode == 53 { // Escape key + finishRecording() onCancel?() return } @@ -155,6 +197,11 @@ class ShortcutRecorderNSView: NSView { mediaKeyType: nil ) + guard shortcut.isAllowedGlobalShortcut else { + NSSound.beep() + return + } + finishRecording() onShortcutRecorded?(shortcut) } @@ -173,7 +220,7 @@ struct ShortcutsSectionView: View { Text("Keyboard Shortcuts") .font(.headline) - Text("By default, system volume keys are used. Click to set a custom shortcut.") + Text("System volume keys are the default. Click to record a shortcut; Esc cancels. Navigation keys require Command, Control, or Option.") .font(.caption) .foregroundColor(.secondary) diff --git a/Focusrite Volume ControlTests/ShortcutRecorderTests.swift b/Focusrite Volume ControlTests/ShortcutRecorderTests.swift new file mode 100644 index 0000000..c96f2ba --- /dev/null +++ b/Focusrite Volume ControlTests/ShortcutRecorderTests.swift @@ -0,0 +1,199 @@ +// +// ShortcutRecorderTests.swift +// Focusrite Volume ControlTests +// +// Regression tests for the shortcut recorder lifecycle bug (docs/shortcut-bug-postmortem.md). +// Ported from https://github.com/wc2184/focusrite-volume-control (MIT, William Chan). +// + +import XCTest +import AppKit +import Carbon.HIToolbox +import class SwiftUI.NSHostingView +@testable import Focusrite_Volume_Control + +final class ShortcutRecorderTests: XCTestCase { + func testNativeF12AndLeftArrowAreDistinctAndOnlyF12IsAllowedBare() { + let left = KeyboardShortcut(keyCode: UInt16(kVK_LeftArrow), modifiers: []) + let f12 = KeyboardShortcut(keyCode: UInt16(kVK_F12), modifiers: []) + XCTAssertEqual(left.keyCode, 123) + XCTAssertEqual(f12.keyCode, 111) + XCTAssertEqual(left.displayString, "←") + XCTAssertEqual(f12.displayString, "F12") + XCTAssertFalse(left.isAllowedGlobalShortcut) + XCTAssertTrue(f12.isAllowedGlobalShortcut) + } + + func testNavigationNeedsAnExplicitShortcutModifier() { + for code in [kVK_LeftArrow, kVK_RightArrow, kVK_UpArrow, kVK_DownArrow, + kVK_Home, kVK_End, kVK_PageUp, kVK_PageDown, + kVK_Tab, kVK_Return, kVK_Delete, kVK_ForwardDelete, kVK_Escape] { + XCTAssertFalse(KeyboardShortcut(keyCode: UInt16(code), modifiers: []).isAllowedGlobalShortcut) + XCTAssertFalse(KeyboardShortcut(keyCode: UInt16(code), modifiers: [.shift]).isAllowedGlobalShortcut) + for modifier: NSEvent.ModifierFlags in [.command, .control, .option] { + XCTAssertTrue(KeyboardShortcut(keyCode: UInt16(code), modifiers: modifier).isAllowedGlobalShortcut) + } + } + } + + func testLoadingRepairsUnsafeSavedNavigationAndPreservesValidShortcuts() throws { + let domain = "FocusriteShortcutTests.\(UUID().uuidString)" + let preferences = UserDefaults(suiteName: domain)! + defer { preferences.removePersistentDomain(forName: domain) } + let saved: [String: KeyboardShortcut] = [ + "volumeUp": KeyboardShortcut(keyCode: UInt16(kVK_LeftArrow), modifiers: []), + "volumeDown": KeyboardShortcut(keyCode: UInt16(kVK_F12), modifiers: []), + "mute": KeyboardShortcut(keyCode: UInt16(kVK_RightArrow), modifiers: [.option]), + "directMonitor": KeyboardShortcut(keyCode: UInt16(kVK_UpArrow), modifiers: [.shift]) + ] + preferences.set(try JSONEncoder().encode(saved), forKey: "keyboardShortcuts") + for manager in [ShortcutManager(preferences: preferences), ShortcutManager(preferences: preferences)] { + XCTAssertEqual(manager.shortcut(for: .volumeUp), ShortcutAction.volumeUp.defaultShortcut) + XCTAssertEqual(manager.shortcut(for: .volumeDown), saved["volumeDown"]) + XCTAssertEqual(manager.shortcut(for: .mute), saved["mute"]) + XCTAssertNil(manager.shortcut(for: .directMonitor)) + } + } + + func testSettingRejectsBareNavigationWithoutReplacingExistingShortcut() { + let manager = ShortcutManager(preferences: nil) + manager.setShortcut(KeyboardShortcut(keyCode: UInt16(kVK_LeftArrow), modifiers: []), for: .volumeUp) + XCTAssertEqual(manager.shortcut(for: .volumeUp), ShortcutAction.volumeUp.defaultShortcut) + } + + @MainActor + func testRecorderCancellationReleasesFocusAndCannotCaptureAnotherKey() { + let recorder = ShortcutRecorderNSView() + var captures: [KeyboardShortcut] = [] + var cancellations = 0 + recorder.onShortcutRecorded = { captures.append($0) } + recorder.onCancel = { cancellations += 1 } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 40), + styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = recorder + recorder.isRecording = true + XCTAssertTrue(window.makeFirstResponder(recorder)) + recorder.keyDown(with: keyEvent(UInt16(kVK_Escape), window: window)) + XCTAssertFalse(recorder.isRecording) + XCTAssertFalse(window.firstResponder === recorder) + recorder.keyDown(with: keyEvent(UInt16(kVK_F12), window: window)) + XCTAssertEqual(cancellations, 1) + XCTAssertTrue(captures.isEmpty) + } + + @MainActor + func testRecorderCompletesExactlyOnceAndIgnoresInactiveAndRepeatedKeys() { + let recorder = ShortcutRecorderNSView() + var captures: [KeyboardShortcut] = [] + recorder.onShortcutRecorded = { captures.append($0) } + recorder.keyDown(with: keyEvent(UInt16(kVK_F12))) + XCTAssertTrue(captures.isEmpty) + recorder.isRecording = true + recorder.keyDown(with: keyEvent(UInt16(kVK_F12), repeated: true)) + XCTAssertTrue(captures.isEmpty) + recorder.keyDown(with: keyEvent(UInt16(kVK_F12))) + XCTAssertFalse(recorder.isRecording) + recorder.keyDown(with: keyEvent(UInt16(kVK_F11))) + XCTAssertEqual(captures.map(\.keyCode), [UInt16(kVK_F12)]) + } + + @MainActor + func testRecorderLosingFocusCancelsRecording() { + let recorder = ShortcutRecorderNSView() + var cancellations = 0 + recorder.onCancel = { cancellations += 1 } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 40), + styleMask: .borderless, backing: .buffered, defer: false) + window.contentView = recorder + recorder.isRecording = true + XCTAssertTrue(window.makeFirstResponder(recorder)) + XCTAssertTrue(window.makeFirstResponder(nil)) + XCTAssertFalse(recorder.isRecording) + XCTAssertEqual(cancellations, 1) + } + + @MainActor + func testSwiftUIRecorderBindingCancelsAndCompletesWithoutLaterAssignments() throws { + let shortcuts = ShortcutManager(preferences: nil) + let host = NSHostingView(rootView: ShortcutRecorderView(action: .volumeUp, shortcutManager: shortcuts)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 50), + styleMask: .titled, backing: .buffered, defer: false) + window.contentView = host + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + func recorder(in view: NSView) -> ShortcutRecorderNSView? { + if let found = view as? ShortcutRecorderNSView { return found } + return view.subviews.lazy.compactMap { recorder(in: $0) }.first + } + shortcuts.recordingAction = .volumeUp + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + let native = try XCTUnwrap(recorder(in: host)) + XCTAssertTrue(native.isRecording) + XCTAssertTrue(window.firstResponder === native) + native.keyDown(with: keyEvent(UInt16(kVK_Escape), window: window)) + XCTAssertNil(shortcuts.recordingAction) + XCTAssertFalse(window.firstResponder === native) + native.keyDown(with: keyEvent(UInt16(kVK_LeftArrow), window: window)) + XCTAssertEqual(shortcuts.shortcut(for: .volumeUp), ShortcutAction.volumeUp.defaultShortcut) + + shortcuts.recordingAction = .volumeUp + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue(native.isRecording) + native.keyDown(with: keyEvent(UInt16(kVK_F12), window: window)) + XCTAssertNil(shortcuts.recordingAction) + native.keyDown(with: keyEvent(UInt16(kVK_F11), window: window)) + XCTAssertEqual(shortcuts.shortcut(for: .volumeUp)?.keyCode, UInt16(kVK_F12)) + } + + @MainActor + func testHotkeysUpdateImmediatelyPauseForRecordingAndPassOrdinaryArrows() { + let shortcuts = ShortcutManager(preferences: nil) + let hotkeys = HotkeyManager(shortcutManager: shortcuts) + var increases = 0 + hotkeys.onVolumeUp = { increases += 1 } + hotkeys.start() + defer { hotkeys.stop() } + let sink = KeyEventSinkView() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 100, height: 40), + styleMask: .titled, backing: .buffered, defer: false) + window.contentView = sink + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + window.makeFirstResponder(sink) + func press(_ code: Int) { + let app = NSApplication.shared + app.postEvent(keyEvent(UInt16(code), window: window), atStart: true) + if let event = app.nextEvent(matching: .keyDown, until: Date().addingTimeInterval(0.03), + inMode: .default, dequeue: true) { app.sendEvent(event) } + RunLoop.main.run(until: Date().addingTimeInterval(0.01)) + } + shortcuts.setShortcut(KeyboardShortcut(keyCode: UInt16(kVK_F12), modifiers: []), for: .volumeUp) + press(kVK_F12) + XCTAssertEqual(increases, 1) + shortcuts.recordingAction = .volumeDown + press(kVK_F12) + XCTAssertEqual(increases, 1) + shortcuts.recordingAction = nil + press(kVK_F12) + XCTAssertEqual(increases, 2) + for code in [kVK_LeftArrow, kVK_RightArrow] { press(code) } + XCTAssertEqual(increases, 2) + XCTAssertTrue(sink.receivedKeys.contains(UInt16(kVK_LeftArrow))) + XCTAssertTrue(sink.receivedKeys.contains(UInt16(kVK_RightArrow))) + shortcuts.resetToDefault(for: .volumeUp) + press(kVK_F12) + XCTAssertEqual(increases, 2) + } + + private func keyEvent(_ code: UInt16, window: NSWindow? = nil, repeated: Bool = false) -> NSEvent { + NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: window?.windowNumber ?? 0, context: nil, + characters: "", charactersIgnoringModifiers: "", isARepeat: repeated, keyCode: code)! + } +} + +private final class KeyEventSinkView: NSView { + var receivedKeys: [UInt16] = [] + override var acceptsFirstResponder: Bool { true } + override func keyDown(with event: NSEvent) { receivedKeys.append(event.keyCode) } +} diff --git a/LICENSE.md b/LICENSE.md index afa5b59..28fa95c 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,7 @@ MIT License Copyright (c) 2026 Enum Solutions Inc. +Copyright (c) 2026 William Chan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6107e0e..6bc8900 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,7 @@ The app drives Focusrite Control 2 via UI automation (AppleScript + Accessibilit xcodebuild -scheme "Focusrite Volume Control" -configuration Release \ CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO build ``` + +## Credits + +The native Accessibility control path, rapid key-press handling and the shortcut recorder fix are ported from [William Chan's version](https://github.com/wc2184/focusrite-volume-control) (MIT). diff --git a/docs/shortcut-bug-postmortem.md b/docs/shortcut-bug-postmortem.md new file mode 100644 index 0000000..0a177df --- /dev/null +++ b/docs/shortcut-bug-postmortem.md @@ -0,0 +1,59 @@ +# Shortcut recording bug: recap and fix + +> Imported from William Chan's fork, [wc2184/focusrite-volume-control](https://github.com/wc2184/focusrite-volume-control), together with the fix. Version numbers and release notes below refer to that fork's releases. + +October 12, 2026 · Fixed in v1.1.1 + +## What happened + +A saved custom shortcut assigned **Left Arrow to Volume Up**, without any modifier keys. With Focusrite selected as the sound output, pressing Left Arrow could therefore change the interface volume while navigating another app. The normal defaults are the system volume and mute keys. + +The affected setting contained `keyCode: 123`, `modifiersRaw: 0`, and `isMediaKey: false`. In native macOS key codes, **123 is Left Arrow and 111 is F12**. JavaScript's legacy `event.keyCode` uses 123 for F12, but this app records native `NSEvent.keyCode` values. + +## Confirmed defects + +The shortcut recorder tracked its visible recording state in SwiftUI, but its native keyboard handler did not check that state. After recording finished, or Escape canceled it, the native view could retain keyboard focus and keep capturing subsequent keys. The capture callback immediately saved those keys against the recorder's action. + +This was reproduced in an isolated native window: Escape set the visible recording state to false, but a subsequent Left Arrow was still captured. The test used a mock callback and did not change actual audio or saved preferences. + +The global shortcut handler also accepted unmodified navigation keys. Consequently, an accidentally recorded navigation key could become a persistent global volume shortcut. Its monitor-update subscription read the old published shortcut state, which could delay registration changes until another update or restart. + +## Where the code originated + +The recorder, shortcut model, and global handler came from [Nick Morozov's Focusrite Volume Control](https://github.com/enum-dot-dev/focusrite-volume-control). Before this fix, all three files were byte-identical to upstream revision `7972b39b21afa88ecff98a1a5395b3d4766f887c`. + +The earliest upstream commit containing the defective recorder is [`739ab79d49ed9390bae2c51a6398f442a925b87a`](https://github.com/enum-dot-dev/focusrite-volume-control/commit/739ab79d49ed9390bae2c51a6398f442a925b87a), **January 27, 2026**, titled "Add customizable keyboard shortcuts with recorder UI." Since that commit, the three files changed only in project-name comments before they were copied into this version. + +The earliest local commit containing them is `61bde8d0a37ba05dd1c93b49d7615f04d979a90e`, **October 10, 2026**, a checkpoint of existing work. The first available project investigation already found those files staged before its source edits. The first public commit, [`d56c23b`](https://github.com/wc2184/focusrite-volume-control/commit/d56c23b6fd649e2b21d47b7a63ff47ec5463f22b), included the same implementation. + +The defect was inherited and missed during review. This identifies its source history, but does not establish how the particular saved Left Arrow assignment was created. + +## What remains unknown + +The available local Codex history was searched across 79 session transcripts, thread metadata, and cached conversation records, including sessions outside the project folder. No original command assigning 123 to Volume Up, instruction to bind F12, or JavaScript/native key-code conversion was found before the incident. + +The original preference writer and creation time remain unknown. Accidental recorder capture and an externally seeded key-code mix-up are possible explanations; neither is established as the cause of this particular setting. Upstream's 46 available commits contain no hardcoded numeric 123 or `0x7B` in text files and default to media keys. + +## The emergency fix also went wrong + +The initial response changed the configuration to Left Arrow → Volume Down and Right Arrow → Volume Up. That fixed direction while preserving unwanted global arrow behavior and introduced a new Right Arrow shortcut without a request for one. + +The verification checked that arrows moved one volume tick in the chosen direction. It failed to check the more important requirement: ordinary navigation keys should remain navigation keys. Both assignments were subsequently removed and the system media-key defaults restored. + +Earlier volume tests covered tick movement, pending writes, mute, and the perceptual curve. They did not exercise recorder cancellation, completed recording, or ordinary-key pass-through. Their passing results did not establish keyboard-interaction correctness. + +## What v1.1.1 changes + +- The native recorder accepts keys only while explicitly recording. Completion and cancellation immediately stop capture and release keyboard focus. Losing responder focus cancels recording, and held-key repeats cannot become another assignment. +- One shared recording action keeps only one recorder active. Global custom shortcuts pause during recording so an existing shortcut cannot consume the key being recorded. +- Global arrow, navigation, and editing shortcuts require Command, Control, or Option. Shift alone is insufficient because it is used for normal text selection. Bare function keys, including native F12, remain supported. +- On launch, unsafe saved navigation bindings are replaced with the corresponding default. Valid custom shortcuts and other preferences are preserved. New unsafe assignments are rejected at capture, save, and dispatch boundaries. +- Monitor registration uses the newly published values so adding, changing, resetting, or recording a shortcut takes effect immediately. + +## Verification and release status + +Regression tests cover native F12/Left Arrow distinction, navigation-key policy, persisted-setting repair, rejection of unsafe assignments, cancellation and focus release, completion and repeat handling, focus-loss cancellation, and real AppKit event routing while shortcuts are added, recorded, and reset. The original volume-controller tests remain in place. + +Modern, Ventura, and Monterey packages include the correction. Older published packages contain the recorder defect; their release notes identify v1.1.1 as the replacement. The preserved v1.0.1 Modern archive remains byte-identical as requested. + +Ventura and Monterey builds remain experimental: build and test checks run on Sonoma do not establish behavior on those older operating systems. From 54772bd01e07a4b6f009c315b17ddf6eba58802b Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 10:47:05 -0700 Subject: [PATCH 04/18] Control FC2 in-process via Accessibility instead of osascript Replace AppleScriptBackend (an osascript process per operation, routed through System Events) with AccessibilityBackend, which walks FC2's window with AXUIElement and caches the elements it finds. A key press now touches only the slider and Direct Monitor checkbox it changes. - FC2Accessibility runs AX calls on its own serial queue (nonisolated, off the main actor); cached elements are dropped when FC2 relaunches or a call fails - VolumeController uses a single writer task: presses during a write only update the pending value, so writes stay in order and send the newest volume; FC2 is minimized once after the last write - Direct Monitor is checked and enabled in the same write as the slider; a refresh started before a write can't overwrite it - Launch FC2 and reopen a closed window with NSWorkspace - Direct Monitor matched by prefix ("Direct Monitor" or "Direct Monitor mode", issue #1) - Readback values that aren't a dB level raise an error instead of defaulting to 0 dB (full volume) - No Apple Events remain: drop the Automation onboarding note and NSAppleEventsUsageDescription; errors and BackendType renamed - Test for write coalescing; error tests updated Native AX approach, cached elements and the single-writer queue are ported from William Chan's MIT-licensed version (wc2184/focusrite-volume-control). Co-authored-by: William Chan <68214261+wc2184@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../project.pbxproj | 2 - .../AccessibilityBackend.swift | 576 ++++++++++++++++++ Focusrite Volume Control/AppDelegate.swift | 4 +- .../AppleScriptBackend.swift | 573 ----------------- .../FCServerBackend.swift | 2 +- Focusrite Volume Control/OnboardingView.swift | 43 +- Focusrite Volume Control/VolumeBackend.swift | 27 +- .../VolumeController.swift | 58 +- .../MockVolumeBackend.swift | 2 +- .../VolumeBackendTests.swift | 16 +- .../VolumeControllerTests.swift | 22 +- README.md | 2 +- 12 files changed, 667 insertions(+), 660 deletions(-) create mode 100644 Focusrite Volume Control/AccessibilityBackend.swift delete mode 100644 Focusrite Volume Control/AppleScriptBackend.swift diff --git a/Focusrite Volume Control.xcodeproj/project.pbxproj b/Focusrite Volume Control.xcodeproj/project.pbxproj index bc1bc5f..a75c9cd 100644 --- a/Focusrite Volume Control.xcodeproj/project.pbxproj +++ b/Focusrite Volume Control.xcodeproj/project.pbxproj @@ -334,7 +334,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "Focusrite Volume Control needs to send Apple events to Focusrite Control 2 to adjust volume and mute settings."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -367,7 +366,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "Focusrite Volume Control needs to send Apple events to Focusrite Control 2 to adjust volume and mute settings."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Focusrite Volume Control/AccessibilityBackend.swift b/Focusrite Volume Control/AccessibilityBackend.swift new file mode 100644 index 0000000..eeddb31 --- /dev/null +++ b/Focusrite Volume Control/AccessibilityBackend.swift @@ -0,0 +1,576 @@ +// +// AccessibilityBackend.swift +// Focusrite Volume Control +// +// Controls Focusrite Control 2 in-process through the Accessibility API (AXUIElement). +// Element lookups are cached, so a key press touches only the controls it changes +// instead of spawning osascript and routing through System Events. +// +// The native Accessibility approach is ported from William Chan's version +// (https://github.com/wc2184/focusrite-volume-control, MIT). +// + +import Foundation +import Combine +import os.log +import AppKit +import ApplicationServices + +/// Accessibility-based volume control backend +class AccessibilityBackend: VolumeBackend { + + // MARK: - Constants + + private let logger = Logger(subsystem: "solutions.enum.FocusriteVolumeControl", category: "AccessibilityBackend") + + // MARK: - State + + private var _state = VolumeState() + private let stateSubject = CurrentValueSubject(VolumeState()) + private let fc2 = FC2Accessibility() + + /// Track if FC2 is verified ready (reset on errors) + private var isFC2Verified = false + + /// Bumped on every playback write so a refresh that started earlier can't overwrite it + private var playbackWriteVersion = 0 + + var statePublisher: AnyPublisher { + stateSubject.eraseToAnyPublisher() + } + + var state: VolumeState { + _state + } + + // MARK: - Initialization + + init() {} + + // MARK: - Connection + + func connect() async -> VolumeResult { + // Ensure FC2 is running and Direct tab is active (retry for startup timing) + var lastError = "Unknown error" + for attempt in 1...3 { + do { + try await ensureFC2Ready() + isFC2Verified = true + return await refresh() + } catch { + lastError = error.localizedDescription + logger.info("⏳ Connect attempt \(attempt) failed: \(lastError, privacy: .public), retrying...") + } + try? await Task.sleep(for: .seconds(1)) + } + return .error(lastError) + } + + func disconnect() { + isFC2Verified = false + fc2.invalidate() + _state.isConnected = false + _state.statusMessage = "Disconnected" + stateSubject.send(_state) + } + + func refresh() async -> VolumeResult { + do { + let writeVersion = playbackWriteVersion + try await ensureReady() + let snapshot = try await perform { try $0.readSnapshot() } + + // A refresh started before a key press must not replace the newer value + if writeVersion == playbackWriteVersion { + _state.playbackVolume = snapshot.playback.volume + _state.playbackMuted = snapshot.playback.muted + } + _state.input1Volume = snapshot.input1.volume + _state.input1Muted = snapshot.input1.muted + _state.input2Volume = snapshot.input2.volume + _state.input2Muted = snapshot.input2.muted + _state.directMonitorEnabled = snapshot.directMonitorEnabled + _state.isConnected = true + _state.statusMessage = "Connected" + stateSubject.send(_state) + return .success + } catch { + _state.isConnected = false + _state.statusMessage = error.localizedDescription + stateSubject.send(_state) + return .error(error.localizedDescription) + } + } + + // MARK: - Playback Volume + + func getPlaybackVolume() async -> Result { + await read { try $0.level(of: .playback) } + } + + func setPlaybackVolume(_ db: Double) async -> VolumeResult { + await setPlaybackVolume(db, ensureDirectMonitor: false) + } + + /// Checks Direct Monitor at write time, so a change made in FC2 can't leave + /// the keyboard writing to a slider that is no longer audible + func setPlaybackVolume(_ db: Double, ensureDirectMonitor: Bool) async -> VolumeResult { + playbackWriteVersion += 1 + do { + try await ensureReady() + let actual = try await perform { fc2 -> Double in + if ensureDirectMonitor { try fc2.setDirectMonitor(true) } + return try fc2.setLevel(db, of: .playback) + } + _state.playbackVolume = actual + _state.playbackMuted = false + if ensureDirectMonitor { _state.directMonitorEnabled = true } + stateSubject.send(_state) + return .success + } catch { + return .error(error.localizedDescription) + } + } + + func setPlaybackMuted(_ muted: Bool) async -> VolumeResult { + await write { try $0.setMuted(muted, of: .playback) } apply: { $0.playbackMuted = muted } + } + + // MARK: - Input 1 + + func getInput1Volume() async -> Result { + await read { try $0.level(of: .input1) } + } + + func setInput1Volume(_ db: Double) async -> VolumeResult { + await write { _ = try $0.setLevel(db, of: .input1) } apply: { $0.input1Volume = db } + } + + func setInput1Muted(_ muted: Bool) async -> VolumeResult { + await write { try $0.setMuted(muted, of: .input1) } apply: { $0.input1Muted = muted } + } + + // MARK: - Input 2 + + func getInput2Volume() async -> Result { + await read { try $0.level(of: .input2) } + } + + func setInput2Volume(_ db: Double) async -> VolumeResult { + await write { _ = try $0.setLevel(db, of: .input2) } apply: { $0.input2Volume = db } + } + + func setInput2Muted(_ muted: Bool) async -> VolumeResult { + await write { try $0.setMuted(muted, of: .input2) } apply: { $0.input2Muted = muted } + } + + // MARK: - Direct Monitor + + func setDirectMonitorEnabled(_ enabled: Bool) async -> VolumeResult { + await write { try $0.setDirectMonitor(enabled) } apply: { $0.directMonitorEnabled = enabled } + } + + // MARK: - FC2 Window Control + + /// Minimize FC2's window via AXMinimized, which doesn't steal focus from our popover + func minimizeFC2IfNeeded() async { + do { + if try await perform({ try $0.minimizeWindowIfNeeded() }) { + logger.info("📦 FC2: minimized") + } + } catch { + logger.warning("⚠️ Failed to minimize FC2: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: - Readiness + + /// Ensure FC2 is ready before performing an action (fast path once verified) + private func ensureReady() async throws { + if isFC2Verified { return } + logger.info("🔍 ensureReady: checking FC2 status...") + try await ensureFC2Ready() + isFC2Verified = true + } + + /// Launch FC2 if needed, reopen its window if it was closed, and switch to the Direct tab + private func ensureFC2Ready() async throws { + guard AXIsProcessTrusted() else { throw VolumeBackendError.accessibilityNotGranted } + + if !FC2Accessibility.isRunning { + logger.info("🚀 FC2 not running, launching...") + try await openFC2(activate: false) + // Wait for FC2 to start (up to 10 seconds), then give its UI a moment + for i in 1...20 { + try await Task.sleep(for: .milliseconds(500)) + if FC2Accessibility.isRunning { + logger.info("✅ FC2 launched after \(i * 500)ms") + try await Task.sleep(for: .milliseconds(1000)) + break + } + } + guard FC2Accessibility.isRunning else { throw VolumeBackendError.fc2NotRunning } + } + + // A closed FC2 window disappears from the AX tree, while a minimized one stays + // accessible, so reopen FC2 only when it has no window at all + if try await perform({ $0.windowCount() }) == 0 { + logger.info("🪟 FC2 has no window, reopening...") + try await openFC2(activate: true) + var foundWindow = false + for _ in 1...15 { + if try await perform({ $0.windowCount() }) > 0 { + foundWindow = true + break + } + try await Task.sleep(for: .milliseconds(200)) + } + if !foundWindow { throw VolumeBackendError.fc2WindowNotFound } + } + + if try await perform({ $0.isOnDirectTab() }) { + return + } + logger.info("🔀 Switching to Direct tab...") + try await perform { try $0.switchToDirectTab() } + // Give FC2 a moment to build the Direct tab's controls + for _ in 1...10 { + if try await perform({ $0.isOnDirectTab() }) { return } + try await Task.sleep(for: .milliseconds(100)) + } + throw VolumeBackendError.sliderNotFound("Playback 1 - 2") + } + + /// Launch FC2, or send it a reopen event if it's already running (recreates a closed window) + private func openFC2(activate: Bool) async throws { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: FC2Accessibility.bundleIdentifier) else { + throw VolumeBackendError.fc2NotInstalled + } + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = activate + _ = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration) + } + + // MARK: - Helpers + + /// Run AX work off the main actor, mapping AX failures to backend errors + private func perform(_ body: @escaping @Sendable (FC2Accessibility) throws -> T) async throws -> T { + do { + return try await fc2.run(body) + } catch let error as FC2AccessibilityError { + // FC2 may have changed tabs or relaunched; re-check before the next action + isFC2Verified = false + throw backendError(for: error) + } + } + + private func backendError(for error: FC2AccessibilityError) -> VolumeBackendError { + switch error { + case .notRunning: return .fc2NotRunning + case .windowNotFound: return .fc2WindowNotFound + case .elementNotFound(let name): return .sliderNotFound(name) + case .unexpectedValue(let detail): return .accessibilityError("Unexpected value: \(detail)") + case .ax(let code): return .accessibilityError("AXError \(code)") + } + } + + private func read(_ body: @escaping @Sendable (FC2Accessibility) throws -> Double) async -> Result { + do { + try await ensureReady() + return .success(try await perform(body)) + } catch { + return .failure(error) + } + } + + private func write(_ body: @escaping @Sendable (FC2Accessibility) throws -> Void, + apply: (inout VolumeState) -> Void) async -> VolumeResult { + do { + try await ensureReady() + try await perform(body) + apply(&_state) + stateSubject.send(_state) + return .success + } catch { + return .error(error.localizedDescription) + } + } +} + +// MARK: - FC2 Accessibility Tree + +/// Errors raised while walking FC2's accessibility tree +nonisolated enum FC2AccessibilityError: Error { + case notRunning + case windowNotFound + case elementNotFound(String) + case unexpectedValue(String) + case ax(Int32) +} + +/// FC2 channel strips on the Direct tab +nonisolated enum FC2Channel: String, Sendable { + case playback = "Playback 1 - 2" + case input1 = "Analogue 1" + case input2 = "Analogue 2" +} + +nonisolated struct FC2ChannelReading: Sendable { + let volume: Double + let muted: Bool +} + +nonisolated struct FC2Snapshot: Sendable { + let playback: FC2ChannelReading + let input1: FC2ChannelReading + let input2: FC2ChannelReading + let directMonitorEnabled: Bool +} + +/// Walks and caches FC2's accessibility tree on a private serial queue. Elements are +/// cached per FC2 process and dropped on any failure, so a relaunch or a tab change +/// costs one fresh lookup instead of breaking later key presses. +nonisolated final class FC2Accessibility { + static let bundleIdentifier = "com.focusrite.control" + private static let windowTitle = "Focusrite Control 2" + + private let queue = DispatchQueue(label: "solutions.enum.FocusriteVolumeControl.fc2-ax", qos: .userInitiated) + + // Accessed only on queue + private var pid: pid_t = 0 + private var cache: [String: AXUIElement] = [:] + + static var isRunning: Bool { + !NSRunningApplication.runningApplications(withBundleIdentifier: Self.bundleIdentifier).isEmpty + } + + func invalidate() { + queue.async { self.reset() } + } + + func run(_ body: @escaping @Sendable (FC2Accessibility) throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + continuation.resume(returning: try body(self)) + } catch { + self.reset() + continuation.resume(throwing: error) + } + } + } + } + + // MARK: Operations (call only through run) + + func windowCount() -> Int { + guard let app = try? application() else { return 0 } + return (try? elements(kAXWindowsAttribute, of: app))?.count ?? 0 + } + + /// The Direct tab is active when its playback strip exists (works while minimized) + func isOnDirectTab() -> Bool { + (try? channelGroup(.playback)) != nil + } + + func switchToDirectTab() throws { + let navigation = try child(of: try window(), role: kAXGroupRole, named: "Navigation bar") + let direct = try child(of: navigation, role: kAXCheckBoxRole, named: "Direct") + try check(AXUIElementPerformAction(direct, kAXPressAction as CFString)) + // The Direct tab builds new elements; look them up fresh + cache = cache.filter { $0.key == "window" } + } + + func readSnapshot() throws -> FC2Snapshot { + FC2Snapshot( + playback: try reading(of: .playback), + input1: try reading(of: .input1), + input2: try reading(of: .input2), + directMonitorEnabled: try boolValue(of: try directMonitor()) + ) + } + + func level(of channel: FC2Channel) throws -> Double { + try parseDb(try stringValue(of: try slider(of: channel))) + } + + /// Writes an integer dB value and returns what FC2 reports back + func setLevel(_ db: Double, of channel: FC2Channel) throws -> Double { + let slider = try slider(of: channel) + try check(AXUIElementSetAttributeValue(slider, kAXValueAttribute as CFString, + "\(Int(db.rounded()))dB" as CFString)) + let actual = try parseDb(try stringValue(of: slider)) + guard abs(actual - db.rounded()) < 0.5 else { + throw FC2AccessibilityError.unexpectedValue("\(channel.rawValue) is \(actual) dB after writing \(db) dB") + } + return actual + } + + func setMuted(_ muted: Bool, of channel: FC2Channel) throws { + let mute = try muteCheckbox(of: channel) + if try boolValue(of: mute) != muted { + try check(AXUIElementPerformAction(mute, kAXPressAction as CFString)) + } + } + + func setDirectMonitor(_ enabled: Bool) throws { + let checkbox = try directMonitor() + if try boolValue(of: checkbox) != enabled { + try check(AXUIElementPerformAction(checkbox, kAXPressAction as CFString)) + } + } + + /// Returns true if the window was minimized by this call + func minimizeWindowIfNeeded() throws -> Bool { + let window = try window() + let minimized = try attribute(kAXMinimizedAttribute, of: window) + if (minimized as? NSNumber)?.boolValue == true { return false } + try check(AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, kCFBooleanTrue)) + return true + } + + // MARK: Element lookup + + private func reset() { + pid = 0 + cache = [:] + } + + private func application() throws -> AXUIElement { + guard let app = NSRunningApplication.runningApplications( + withBundleIdentifier: Self.bundleIdentifier).first else { + throw FC2AccessibilityError.notRunning + } + if app.processIdentifier != pid { + reset() + pid = app.processIdentifier + } + let element = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(element, 0.5) + return element + } + + private func cached(_ key: String, _ lookup: () throws -> AXUIElement) throws -> AXUIElement { + _ = try application() // resets the cache if FC2 relaunched + if let element = cache[key] { return element } + let element = try lookup() + cache[key] = element + return element + } + + private func window() throws -> AXUIElement { + try cached("window") { + let windows = try elements(kAXWindowsAttribute, of: try application()) + guard let window = windows.first(where: { name(of: $0) == Self.windowTitle }) ?? windows.first else { + throw FC2AccessibilityError.windowNotFound + } + return window + } + } + + /// The Direct tab's channel strips live in the window's fifth group + private func directGroup() throws -> AXUIElement { + try cached("direct") { + let groups = try elements(kAXChildrenAttribute, of: try window()) + .filter { role(of: $0) == kAXGroupRole } + guard groups.count >= 5 else { throw FC2AccessibilityError.elementNotFound("Direct tab") } + return groups[4] + } + } + + private func channelGroup(_ channel: FC2Channel) throws -> AXUIElement { + try cached("group:\(channel.rawValue)") { + try child(of: try directGroup(), role: kAXGroupRole, named: channel.rawValue) + } + } + + private func slider(of channel: FC2Channel) throws -> AXUIElement { + try cached("slider:\(channel.rawValue)") { + try child(of: try channelGroup(channel), role: kAXSliderRole, named: "Level") + } + } + + private func muteCheckbox(of channel: FC2Channel) throws -> AXUIElement { + try cached("mute:\(channel.rawValue)") { + try child(of: try channelGroup(channel), role: kAXCheckBoxRole, named: "Mute") + } + } + + /// Titled "Direct Monitor" in some FC2 versions and "Direct Monitor mode" in others (issue #1) + private func directMonitor() throws -> AXUIElement { + try cached("directMonitor") { + let match = try elements(kAXChildrenAttribute, of: try window()).first { + role(of: $0) == kAXCheckBoxRole && name(of: $0)?.hasPrefix("Direct Monitor") == true + } + guard let match else { throw FC2AccessibilityError.elementNotFound("Direct Monitor") } + return match + } + } + + private func child(of parent: AXUIElement, role wanted: String, named title: String) throws -> AXUIElement { + let match = try elements(kAXChildrenAttribute, of: parent).first { + role(of: $0) == wanted && name(of: $0) == title + } + guard let match else { throw FC2AccessibilityError.elementNotFound(title) } + return match + } + + private func reading(of channel: FC2Channel) throws -> FC2ChannelReading { + FC2ChannelReading(volume: try level(of: channel), + muted: try boolValue(of: try muteCheckbox(of: channel))) + } + + // MARK: Attribute access + + private func elements(_ key: String, of element: AXUIElement) throws -> [AXUIElement] { + guard let result = try attribute(key, of: element) as? [AXUIElement] else { + throw FC2AccessibilityError.unexpectedValue(key) + } + return result + } + + private func attribute(_ key: String, of element: AXUIElement) throws -> CFTypeRef { + var value: CFTypeRef? + try check(AXUIElementCopyAttributeValue(element, key as CFString, &value)) + guard let value else { throw FC2AccessibilityError.unexpectedValue(key) } + return value + } + + private func stringValue(of element: AXUIElement) throws -> String { + let value = try attribute(kAXValueAttribute, of: element) + if let string = value as? String { return string } + if let number = value as? NSNumber { return number.stringValue } + throw FC2AccessibilityError.unexpectedValue("value of \(name(of: element) ?? "element")") + } + + /// FC2 reports checkbox state as a number or as "true"/"false" depending on the control + private func boolValue(of element: AXUIElement) throws -> Bool { + let value = try attribute(kAXValueAttribute, of: element) + if let number = value as? NSNumber { return number.boolValue } + if let string = (value as? String)?.lowercased() { return string == "true" || string == "1" } + throw FC2AccessibilityError.unexpectedValue("state of \(name(of: element) ?? "checkbox")") + } + + private func role(of element: AXUIElement) -> String? { + try? attribute(kAXRoleAttribute, of: element) as? String + } + + private func name(of element: AXUIElement) -> String? { + for key in [kAXTitleAttribute, kAXDescriptionAttribute, kAXIdentifierAttribute] { + if let value = try? attribute(key, of: element) as? String, !value.isEmpty { return value } + } + return nil + } + + /// Parse "-6dB" or "0 dB" into a number; never default to 0 dB, which is full volume + private func parseDb(_ text: String) throws -> Double { + let numeric = text.replacingOccurrences(of: "dB", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Double(numeric) else { throw FC2AccessibilityError.unexpectedValue(text) } + return value + } + + private func check(_ error: AXError) throws { + guard error == .success else { throw FC2AccessibilityError.ax(error.rawValue) } + } +} diff --git a/Focusrite Volume Control/AppDelegate.swift b/Focusrite Volume Control/AppDelegate.swift index 1a853ea..bdb73fb 100644 --- a/Focusrite Volume Control/AppDelegate.swift +++ b/Focusrite Volume Control/AppDelegate.swift @@ -30,7 +30,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var preferencesWindow: NSWindow? private var onboardingWindow: NSWindow? - // Volume controller with AppleScript backend + // Volume controller with Accessibility backend var volumeController: VolumeController! private var cancellables = Set() @@ -50,7 +50,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - App Lifecycle func applicationDidFinishLaunching(_ notification: Notification) { - // Initialize volume controller with AppleScript backend + // Initialize volume controller with Accessibility backend volumeController = VolumeController() // Monitor default audio output device for Focusrite detection diff --git a/Focusrite Volume Control/AppleScriptBackend.swift b/Focusrite Volume Control/AppleScriptBackend.swift deleted file mode 100644 index d9ebd51..0000000 --- a/Focusrite Volume Control/AppleScriptBackend.swift +++ /dev/null @@ -1,573 +0,0 @@ -// -// AppleScriptBackend.swift -// Focusrite Volume Control -// -// Controls Focusrite Control 2 via osascript (command-line AppleScript). -// Uses the same approach as the working Node.js volume-control.js script. -// - -import Foundation -import Combine -import os.log - -/// AppleScript-based volume control backend using osascript subprocess -class AppleScriptBackend: VolumeBackend { - - // MARK: - Constants - - private let fc2Process = "Focusrite Control 2" - private let logger = Logger(subsystem: "solutions.enum.FocusriteVolumeControl", category: "AppleScriptBackend") - - /// The Direct Monitor checkbox is titled "Direct Monitor" in some FC2 versions and - /// "Direct Monitor mode" in others, so match on the prefix (issue #1) - private var directMonitorCheckbox: String { - "first checkbox of window \"\(fc2Process)\" whose name starts with \"Direct Monitor\"" - } - - // MARK: - State - - private var _state = VolumeState() - private let stateSubject = CurrentValueSubject(VolumeState()) - - /// Track if FC2 is verified ready (reset on errors) - private var isFC2Verified = false - - var statePublisher: AnyPublisher { - stateSubject.eraseToAnyPublisher() - } - - var state: VolumeState { - _state - } - - // MARK: - Initialization - - init() {} - - // MARK: - Connection - - func connect() async -> VolumeResult { - // Ensure FC2 is running and Direct tab is active (retry for startup timing) - var lastError: String = "Unknown error" - for attempt in 1...3 { - let result = await ensureDirectTab() - if case .success = result { - return await refresh() - } - if case .error(let msg) = result { - lastError = msg - logger.info("⏳ Connect attempt \(attempt) failed: \(msg, privacy: .public), retrying...") - } - try? await Task.sleep(for: .seconds(1)) - } - return .error(lastError) - } - - func disconnect() { - isFC2Verified = false - _state.isConnected = false - _state.statusMessage = "Disconnected" - stateSubject.send(_state) - } - - func refresh() async -> VolumeResult { - do { - let playbackVol = try await readSliderValue(channel: "Playback 1 - 2") - let playbackMute = try await readMuteState(channel: "Playback 1 - 2") - let input1Vol = try await readSliderValue(channel: "Analogue 1") - let input1Mute = try await readMuteState(channel: "Analogue 1") - let input2Vol = try await readSliderValue(channel: "Analogue 2") - let input2Mute = try await readMuteState(channel: "Analogue 2") - let directMon = try await readDirectMonitorState() - - _state.playbackVolume = playbackVol - _state.playbackMuted = playbackMute - _state.input1Volume = input1Vol - _state.input1Muted = input1Mute - _state.input2Volume = input2Vol - _state.input2Muted = input2Mute - _state.directMonitorEnabled = directMon - _state.isConnected = true - _state.statusMessage = "Connected" - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false // Reset on error to force re-check next time - _state.isConnected = false - _state.statusMessage = error.localizedDescription - stateSubject.send(_state) - return .error(error.localizedDescription) - } - } - - // MARK: - Playback Volume - - func getPlaybackVolume() async -> Result { - do { - let vol = try await readSliderValue(channel: "Playback 1 - 2") - return .success(vol) - } catch { - isFC2Verified = false - return .failure(error) - } - } - - func setPlaybackVolume(_ db: Double) async -> VolumeResult { - do { - try await setSliderValue(channel: "Playback 1 - 2", db: db) - _state.playbackVolume = db - _state.playbackMuted = false - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - func setPlaybackMuted(_ muted: Bool) async -> VolumeResult { - do { - try await setMuteState(channel: "Playback 1 - 2", muted: muted) - _state.playbackMuted = muted - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - // MARK: - Input 1 - - func getInput1Volume() async -> Result { - do { - let vol = try await readSliderValue(channel: "Analogue 1") - return .success(vol) - } catch { - isFC2Verified = false - return .failure(error) - } - } - - func setInput1Volume(_ db: Double) async -> VolumeResult { - do { - try await setSliderValue(channel: "Analogue 1", db: db) - _state.input1Volume = db - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - func setInput1Muted(_ muted: Bool) async -> VolumeResult { - do { - try await setMuteState(channel: "Analogue 1", muted: muted) - _state.input1Muted = muted - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - // MARK: - Input 2 - - func getInput2Volume() async -> Result { - do { - let vol = try await readSliderValue(channel: "Analogue 2") - return .success(vol) - } catch { - isFC2Verified = false - return .failure(error) - } - } - - func setInput2Volume(_ db: Double) async -> VolumeResult { - do { - try await setSliderValue(channel: "Analogue 2", db: db) - _state.input2Volume = db - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - func setInput2Muted(_ muted: Bool) async -> VolumeResult { - do { - try await setMuteState(channel: "Analogue 2", muted: muted) - _state.input2Muted = muted - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - // MARK: - Direct Monitor - - func setDirectMonitorEnabled(_ enabled: Bool) async -> VolumeResult { - do { - try await setDirectMonitor(enabled: enabled) - _state.directMonitorEnabled = enabled - stateSubject.send(_state) - return .success - } catch { - isFC2Verified = false - return .error(error.localizedDescription) - } - } - - // MARK: - FC2 Window Control - - /// Minimize FC2 window to Dock (if not already minimized) - /// Uses AXMinimized attribute for both detection and minimization (no focus stealing) - func minimizeFC2IfNeeded() async { - // Check and set AXMinimized attribute directly - avoids stealing focus from our popover - let checkAndMinimizeScript = """ - tell application "System Events" - tell process "\(fc2Process)" - if (count of windows) = 0 then - return "no_windows" - end if - set isMin to value of attribute "AXMinimized" of window 1 - if isMin then - return "already_minimized" - end if - -- Minimize by setting AXMinimized directly (doesn't steal focus) - set value of attribute "AXMinimized" of window 1 to true - return "minimized" - end tell - end tell - """ - - do { - let result = try await runOsascript(checkAndMinimizeScript) - if result == "minimized" { - logger.info("📦 FC2: \(result, privacy: .public)") - } - } catch { - logger.warning("⚠️ Failed to minimize FC2: \(error.localizedDescription, privacy: .public)") - } - } - - // MARK: - AppleScript Commands via osascript - - /// Ensure FC2 is running and Direct tab is active, minimize if needed - private func ensureDirectTab() async -> VolumeResult { - do { - try await ensureFC2Ready() - return .success - } catch { - return .error(error.localizedDescription) - } - } - - /// Launch FC2 if not running, ensure Direct tab is active - private func ensureFC2Ready() async throws { - logger.info("🔧 ensureFC2Ready: checking if FC2 is running...") - - // Check if FC2 is running - let checkScript = """ - tell application "System Events" - return exists process "\(fc2Process)" - end tell - """ - let isRunning = try await runOsascript(checkScript) == "true" - - if !isRunning { - logger.info("🚀 FC2 not running, launching...") - // Launch FC2 - let launchScript = """ - tell application "\(fc2Process)" to launch - """ - _ = try await runOsascript(launchScript) - - // Wait for FC2 to be ready (up to 10 seconds) - for i in 1...20 { - try await Task.sleep(for: .milliseconds(500)) - let ready = try await runOsascript(checkScript) == "true" - if ready { - logger.info("✅ FC2 launched after \(i * 500)ms") - // Give it a moment more to fully initialize UI - try await Task.sleep(for: .milliseconds(1000)) - break - } - } - } else { - logger.info("✅ FC2 already running") - } - - // Check if Direct tab is already active (works even with minimized window) - let tabCheckScript = """ - tell application "System Events" - tell process "\(fc2Process)" - if (count of windows) = 0 then - return "no_window" - end if - try - get group "Playback 1 - 2" of group 5 of window "\(fc2Process)" - return "ready" - on error - return "wrong_tab" - end try - end tell - end tell - """ - - let tabStatus = try await runOsascript(tabCheckScript) - - if tabStatus == "ready" { - logger.info("✅ FC2 already on Direct tab") - return - } - - // Direct tab not active — need to switch - logger.info("🔀 Switching to Direct tab...") - let switchScript = """ - tell application "System Events" - tell process "\(fc2Process)" - if (count of windows) > 0 then - click checkbox "Direct" of group "Navigation bar" of window "\(fc2Process)" - end if - end tell - end tell - """ - _ = try await runOsascript(switchScript) - logger.info("✅ FC2 ready on Direct tab") - } - - /// Ensure FC2 is ready before performing an action - private func ensureReady() async throws { - // Skip check if already verified (fast path for repeated actions) - if isFC2Verified { - return - } - - logger.info("🔍 ensureReady: checking FC2 status...") - - // Quick check if process exists and Direct tab is active - let checkScript = """ - tell application "System Events" - if not (exists process "\(fc2Process)") then - return "not_running" - end if - tell process "\(fc2Process)" - if (count of windows) = 0 then - return "no_window" - end if - -- Check if Direct tab is active by checking if expected elements exist - try - get group "Playback 1 - 2" of group 5 of window "\(fc2Process)" - return "ready" - on error - return "wrong_tab" - end try - end tell - end tell - """ - - let status = try await runOsascript(checkScript) - logger.info("📊 FC2 status: \(status, privacy: .public)") - - if status == "ready" { - isFC2Verified = true - logger.info("✅ FC2 verified ready") - } else { - logger.info("⚠️ FC2 not ready (status: \(status, privacy: .public)), setting up...") - // Need to set up FC2 - try await ensureFC2Ready() - isFC2Verified = true - } - } - - /// Reset the verified state (call on errors to force re-check) - private func resetVerifiedState() { - isFC2Verified = false - } - - /// Read slider value from a channel group - private func readSliderValue(channel: String) async throws -> Double { - try await ensureReady() - - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - return value of slider "Level" of group "\(channel)" of group 5 of window "\(fc2Process)" - end tell - end tell - """ - - let result = try await runOsascript(script) - return parseDbValue(result) - } - - /// Set slider value for a channel group - private func setSliderValue(channel: String, db: Double) async throws { - try await ensureReady() - - let dbString = formatDbValue(db) - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - set value of slider "Level" of group "\(channel)" of group 5 of window "\(fc2Process)" to "\(dbString)" - end tell - end tell - """ - - _ = try await runOsascript(script) - } - - /// Read mute state from a channel group - private func readMuteState(channel: String) async throws -> Bool { - try await ensureReady() - - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - return value of checkbox "Mute" of group "\(channel)" of group 5 of window "\(fc2Process)" - end tell - end tell - """ - - let result = try await runOsascript(script) - return result.trimmingCharacters(in: .whitespacesAndNewlines) == "1" - } - - /// Set mute state for a channel group - private func setMuteState(channel: String, muted: Bool) async throws { - try await ensureReady() - - let targetValue = muted ? 1 : 0 - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - set cb to checkbox "Mute" of group "\(channel)" of group 5 of window "\(fc2Process)" - if (value of cb) is not \(targetValue) then - click cb - end if - end tell - end tell - """ - - _ = try await runOsascript(script) - } - - /// Read Direct Monitor state - private func readDirectMonitorState() async throws -> Bool { - try await ensureReady() - - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - return value of (\(directMonitorCheckbox)) - end tell - end tell - """ - - let result = try await runOsascript(script) - // FC2 returns "true" or "false" as strings - return result.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true" - } - - /// Set Direct Monitor state - private func setDirectMonitor(enabled: Bool) async throws { - try await ensureReady() - - // FC2 returns true/false for checkbox values - let targetValue = enabled ? "true" : "false" - let script = """ - tell application "System Events" - tell process "\(fc2Process)" - set cb to \(directMonitorCheckbox) - if (value of cb) is not \(targetValue) then - click cb - end if - end tell - end tell - """ - - _ = try await runOsascript(script) - } - - // MARK: - osascript Execution - - /// Run AppleScript via osascript subprocess (same as Node.js script) - private func runOsascript(_ script: String) async throws -> String { - // Extract first line for logging (avoid logging full scripts) - let scriptPreview = script.components(separatedBy: .newlines).first ?? "script" - - return try await withCheckedThrowingContinuation { [logger] continuation in - DispatchQueue.global(qos: .userInitiated).async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", script] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - do { - try process.run() - process.waitUntilExit() - - let outputData = stdout.fileHandleForReading.readDataToEndOfFile() - let errorData = stderr.fileHandleForReading.readDataToEndOfFile() - - if process.terminationStatus != 0 { - let rawError = String(data: errorData, encoding: .utf8) ?? "Unknown error" - logger.error("❌ osascript failed: \(rawError, privacy: .public) | script: \(scriptPreview, privacy: .public)") - - // Parse osascript error: "138:237: execution error: System Events got an error: ... (-600)" - // Extract the meaningful part after "execution error: " - var cleanMessage = rawError.trimmingCharacters(in: .whitespacesAndNewlines) - if let range = cleanMessage.range(of: "execution error: ") { - cleanMessage = String(cleanMessage[range.upperBound...]) - } - // Strip trailing error codes like "(-600)" and period - cleanMessage = cleanMessage.replacingOccurrences( - of: #"\s*\(-?\d+\)\s*$"#, with: "", options: .regularExpression) - - // Map known errors to specific types - let error: VolumeBackendError - if cleanMessage.contains("Can\u{2019}t get process") || cleanMessage.contains("(-600)") || rawError.contains("(-600)") { - error = .fc2NotRunning - } else if cleanMessage.contains("Can\u{2019}t get window") || cleanMessage.contains("count of windows") { - error = .fc2WindowNotFound - } else { - error = .appleScriptError(cleanMessage) - } - continuation.resume(throwing: error) - } else { - let output = String(data: outputData, encoding: .utf8) ?? "" - continuation.resume(returning: output.trimmingCharacters(in: .whitespacesAndNewlines)) - } - } catch { - logger.error("❌ osascript exception: \(error.localizedDescription, privacy: .public)") - continuation.resume(throwing: error) - } - } - } - } - - // MARK: - Utilities - - /// Parse dB string like "-6dB" or "0dB" to Double - private func parseDbValue(_ str: String) -> Double { - let cleaned = str.trimmingCharacters(in: .whitespacesAndNewlines) - let numStr = cleaned.replacingOccurrences(of: "dB", with: "") - .replacingOccurrences(of: " ", with: "") - return Double(numStr) ?? 0.0 - } - - /// Format Double to dB string like "-6dB" - private func formatDbValue(_ db: Double) -> String { - return "\(Int(round(db)))dB" - } -} diff --git a/Focusrite Volume Control/FCServerBackend.swift b/Focusrite Volume Control/FCServerBackend.swift index f56b870..79e1f25 100644 --- a/Focusrite Volume Control/FCServerBackend.swift +++ b/Focusrite Volume Control/FCServerBackend.swift @@ -34,7 +34,7 @@ class FCServerBackend: VolumeBackend { newState.statusMessage = "FC Server backend not yet implemented" stateSubject.send(newState) } - return .error("FC Server backend is not yet implemented. Please use AppleScript backend.") + return .error("FC Server backend is not yet implemented. Please use the Accessibility backend.") } func disconnect() { diff --git a/Focusrite Volume Control/OnboardingView.swift b/Focusrite Volume Control/OnboardingView.swift index 8668292..c4f41b7 100644 --- a/Focusrite Volume Control/OnboardingView.swift +++ b/Focusrite Volume Control/OnboardingView.swift @@ -2,8 +2,8 @@ // OnboardingView.swift // Focusrite Volume Control // -// Permission onboarding wizard — shown on launch until both -// Accessibility and Automation permissions are granted. +// Permission onboarding wizard — shown on launch until the +// Accessibility permission is granted. // import SwiftUI @@ -16,34 +16,8 @@ func isAccessibilityGranted() -> Bool { AXIsProcessTrusted() } -func isAutomationGranted() -> Bool { - // Test automation against Focusrite Control 2 — the actual target app. - // Falls back to System Events if FC2 isn't installed/running. - let scripts = [ - "tell application \"Focusrite Control 2\" to name", - "tell application \"System Events\" to return name of first process" - ] - for script in scripts { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", script] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - do { - try process.run() - process.waitUntilExit() - if process.terminationStatus == 0 { return true } - } catch { - continue - } - } - return false -} - func areAllPermissionsGranted() -> Bool { - // Only gate on Accessibility — it can be reliably checked via AXIsProcessTrusted(). - // Automation permission is prompted automatically by macOS on first osascript use, - // so there's no need to pre-check it (and doing so is unreliable). + // FC2 is controlled in-process through the Accessibility API, so this is the only permission isAccessibilityGranted() } @@ -96,17 +70,6 @@ struct OnboardingView: View { .padding(.vertical, 20) .padding(.horizontal, 24) - // Note about Automation - HStack(spacing: 8) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - Text("macOS will also ask to allow Automation for Focusrite Control 2 on first use — click OK when prompted.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 24) - .padding(.bottom, 16) - Divider() .padding(.horizontal, 24) diff --git a/Focusrite Volume Control/VolumeBackend.swift b/Focusrite Volume Control/VolumeBackend.swift index 219ea03..d177bcd 100644 --- a/Focusrite Volume Control/VolumeBackend.swift +++ b/Focusrite Volume Control/VolumeBackend.swift @@ -3,7 +3,7 @@ // Focusrite Volume Control // // Protocol defining the interface for volume control backends. -// This allows swapping between AppleScript UI automation and future AES70 API. +// This allows swapping between Accessibility control of FC2 and a future direct protocol. // import Foundation @@ -51,6 +51,10 @@ protocol VolumeBackend: AnyObject { func setPlaybackVolume(_ db: Double) async -> VolumeResult func setPlaybackMuted(_ muted: Bool) async -> VolumeResult + /// Set playback volume, enabling Direct Monitor first when requested. + /// Backends can override this to do both in one round trip. + func setPlaybackVolume(_ db: Double, ensureDirectMonitor: Bool) async -> VolumeResult + // MARK: - Input 1 func getInput1Volume() async -> Result @@ -73,12 +77,23 @@ protocol VolumeBackend: AnyObject { func minimizeFC2IfNeeded() async } +extension VolumeBackend { + func setPlaybackVolume(_ db: Double, ensureDirectMonitor: Bool) async -> VolumeResult { + if ensureDirectMonitor && !state.directMonitorEnabled { + _ = await setDirectMonitorEnabled(true) + } + return await setPlaybackVolume(db) + } +} + /// Errors that can occur during volume operations enum VolumeBackendError: Error, LocalizedError { case fc2NotRunning case fc2WindowNotFound case sliderNotFound(String) - case appleScriptError(String) + case fc2NotInstalled + case accessibilityNotGranted + case accessibilityError(String) case timeout case notConnected @@ -90,8 +105,12 @@ enum VolumeBackendError: Error, LocalizedError { return "Could not find Focusrite Control 2 window" case .sliderNotFound(let name): return "Could not find slider: \(name)" - case .appleScriptError(let msg): - return "AppleScript error: \(msg)" + case .fc2NotInstalled: + return "Focusrite Control 2 is not installed" + case .accessibilityNotGranted: + return "Accessibility permission is not granted" + case .accessibilityError(let msg): + return "Accessibility error: \(msg)" case .timeout: return "Operation timed out" case .notConnected: diff --git a/Focusrite Volume Control/VolumeController.swift b/Focusrite Volume Control/VolumeController.swift index ac4b734..6612cfe 100644 --- a/Focusrite Volume Control/VolumeController.swift +++ b/Focusrite Volume Control/VolumeController.swift @@ -3,7 +3,7 @@ // Focusrite Volume Control // // High-level volume control logic for Focusrite devices. -// Uses a pluggable backend (AppleScript now, AES70 API later). +// Uses a pluggable backend (Accessibility control of FC2 now, AES70 API later). // import Foundation @@ -37,14 +37,14 @@ enum VolumeStepSize: String, CaseIterable, Identifiable { /// Available backend types for volume control enum BackendType: String, CaseIterable, Identifiable { - case appleScript = "AppleScript" + case accessibility = "Accessibility" case fcServer = "FC Server" var id: String { rawValue } var description: String { switch self { - case .appleScript: + case .accessibility: return "Controls via Focusrite Control 2 app" case .fcServer: return "Direct protocol (not yet implemented)" @@ -83,7 +83,7 @@ class VolumeController: ObservableObject { } @Published var ensureDirectMonitorOn: Bool = true // Auto-enable Direct Monitor before volume changes @Published var allowGain: Bool = false // Allow volume above 0dB (up to +6dB) - @Published var backendType: BackendType = .appleScript // Which backend to use + @Published var backendType: BackendType = .accessibility // Which backend to use let minVolume: Double = -127.0 // FC2's actual minimum /// Maximum volume depends on allowGain setting @@ -100,18 +100,19 @@ class VolumeController: ObservableObject { // Tracks the most recent pending volume to prevent stale backend responses // from overwriting optimistic updates during rapid key presses private var pendingVolume: Double? + private var volumeWriteTask: Task? // MARK: - Initialization - init(backend: VolumeBackend, backendType: BackendType = .appleScript) { + init(backend: VolumeBackend, backendType: BackendType = .accessibility) { self.backend = backend self.backendType = backendType setupBindings() } - /// Convenience init with default AppleScript backend + /// Convenience init with default Accessibility backend convenience init() { - self.init(backend: AppleScriptBackend(), backendType: .appleScript) + self.init(backend: AccessibilityBackend(), backendType: .accessibility) } private func setupBindings() { @@ -162,10 +163,15 @@ class VolumeController: ObservableObject { } func disconnect() { + volumeWriteTask?.cancel() + volumeWriteTask = nil + pendingVolume = nil backend.disconnect() } func refresh() { + // A read started mid-write could report the old value + guard pendingVolume == nil else { return } Task { _ = await backend.refresh() } @@ -189,8 +195,8 @@ class VolumeController: ObservableObject { // Create new backend switch newType { - case .appleScript: - backend = AppleScriptBackend() + case .accessibility: + backend = AccessibilityBackend() case .fcServer: backend = FCServerBackend() } @@ -216,23 +222,23 @@ class VolumeController: ObservableObject { playbackMuted = clamped <= minVolume pendingVolume = clamped - DispatchQueue.main.async { - Task { - await self.ensureDirectMonitorIfNeeded() - - let result = await self.backend.setPlaybackVolume(clamped) + // A single writer preserves key-press order. Presses that arrive while FC2 + // is busy only update pendingVolume, so the next write sends the newest value + guard volumeWriteTask == nil else { return } + volumeWriteTask = Task { [weak self] in + guard let self else { return } + while let target = self.pendingVolume, !Task.isCancelled { + let result = await self.backend.setPlaybackVolume( + target, ensureDirectMonitor: self.ensureDirectMonitorOn) if case .error(let msg) = result { print("Failed to set playback volume: \(msg)") } - await self.minimizeFC2IfNeeded() - - // Clear pending if this was the most recent request - await MainActor.run { - if self.pendingVolume == clamped { - self.pendingVolume = nil - } + if self.pendingVolume == target { + self.pendingVolume = nil } } + self.volumeWriteTask = nil + await self.minimizeFC2IfNeeded() } } @@ -244,16 +250,6 @@ class VolumeController: ObservableObject { } } - /// Ensure Direct Monitor is enabled if the setting is on - /// The app requires Direct Monitor to be enabled for volume control to work - private func ensureDirectMonitorIfNeeded() async { - let shouldEnsure = await MainActor.run { ensureDirectMonitorOn } - let isEnabled = await MainActor.run { directMonitorEnabled } - if shouldEnsure && !isEnabled { - _ = await backend.setDirectMonitorEnabled(true) - } - } - /// Play system sound for volume feedback if enabled private func playVolumeFeedback() { guard playVolumeSound else { return } diff --git a/Focusrite Volume ControlTests/MockVolumeBackend.swift b/Focusrite Volume ControlTests/MockVolumeBackend.swift index fa4c325..01ef596 100644 --- a/Focusrite Volume ControlTests/MockVolumeBackend.swift +++ b/Focusrite Volume ControlTests/MockVolumeBackend.swift @@ -3,7 +3,7 @@ // Focusrite Volume ControlTests // // Mock implementation of VolumeBackend for unit testing. -// Allows verifying VolumeController behavior without real AppleScript/FC2. +// Allows verifying VolumeController behavior without a real FC2. // import Foundation diff --git a/Focusrite Volume ControlTests/VolumeBackendTests.swift b/Focusrite Volume ControlTests/VolumeBackendTests.swift index e7609e6..527c22f 100644 --- a/Focusrite Volume ControlTests/VolumeBackendTests.swift +++ b/Focusrite Volume ControlTests/VolumeBackendTests.swift @@ -92,9 +92,19 @@ final class VolumeBackendTests: XCTestCase { XCTAssertEqual(error.errorDescription, "Could not find slider: Playback 1 - 2") } - func testVolumeBackendError_AppleScriptError_Description() { - let error = VolumeBackendError.appleScriptError("execution error: System Events got an error") - XCTAssertEqual(error.errorDescription, "AppleScript error: execution error: System Events got an error") + func testVolumeBackendError_AccessibilityError_Description() { + let error = VolumeBackendError.accessibilityError("AXError -25204") + XCTAssertEqual(error.errorDescription, "Accessibility error: AXError -25204") + } + + func testVolumeBackendError_FC2NotInstalled_Description() { + let error = VolumeBackendError.fc2NotInstalled + XCTAssertEqual(error.errorDescription, "Focusrite Control 2 is not installed") + } + + func testVolumeBackendError_AccessibilityNotGranted_Description() { + let error = VolumeBackendError.accessibilityNotGranted + XCTAssertEqual(error.errorDescription, "Accessibility permission is not granted") } func testVolumeBackendError_Timeout_Description() { diff --git a/Focusrite Volume ControlTests/VolumeControllerTests.swift b/Focusrite Volume ControlTests/VolumeControllerTests.swift index 3b74dff..60c80bf 100644 --- a/Focusrite Volume ControlTests/VolumeControllerTests.swift +++ b/Focusrite Volume ControlTests/VolumeControllerTests.swift @@ -702,8 +702,8 @@ final class VolumeControllerTests: XCTestCase { // MARK: - Backend Type Tests - func testBackendType_DefaultIsAppleScript() { - XCTAssertEqual(controller.backendType, .appleScript) + func testBackendType_DefaultIsAccessibility() { + XCTAssertEqual(controller.backendType, .accessibility) } func testRestart_CallsDisconnectThenConnect() async throws { @@ -769,4 +769,22 @@ final class VolumeControllerTests: XCTestCase { let directMonitorCalls = calls(named: "setDirectMonitorEnabled") XCTAssertTrue(directMonitorCalls.isEmpty, "should not call setDirectMonitorEnabled when already on") } + + // MARK: - Rapid Write Coalescing Tests + + func testSetPlaybackVolume_RapidCalls_CoalesceToNewestValue() async throws { + mockBackend.setPlaybackVolumeState(-40.0) + try await waitForStateUpdate() + + mockBackend.clearCalls() + controller.setPlaybackVolume(-30.0) + controller.setPlaybackVolume(-25.0) + controller.setPlaybackVolume(-20.0) + try await waitForStateUpdate() + + // Presses made before the writer starts collapse into a single write of the newest value + let written = calls(named: "setPlaybackVolume").compactMap { $0.parameters["db"] } + XCTAssertEqual(written, ["-20.0"]) + XCTAssertEqual(controller.playbackVolume, -20.0) + } } diff --git a/README.md b/README.md index 6bc8900..c31f33c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Download the latest DMG from [Releases](https://github.com/enum-labs/focusrite-v ## How it works -The app drives Focusrite Control 2 via UI automation (AppleScript + Accessibility API). It intercepts system media keys through a `CGEventTap` and routes them to FC2's sliders instead of macOS's built-in volume control. +The app drives Focusrite Control 2's window in-process through the macOS Accessibility API, caching the controls it looks up so rapid key presses stay fast and in order. It intercepts system media keys through a `CGEventTap` and routes them to FC2's sliders instead of macOS's built-in volume control. Only the Accessibility permission is needed; there is no Automation (Apple Events) prompt. ## Known issues From aa45fb3aeb2ef1686afdf7f47a03482cb3fe6aca Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 10:50:05 -0700 Subject: [PATCH 05/18] Sign and notarize releases when Developer ID secrets are set release.yml now archives, exports with method developer-id, notarizes and staples the app, then signs, notarizes and staples the DMG via scripts/build-dmg.sh (App Store Connect API key). The team ID comes from APPLE_TEAM_ID at build time; nothing new is hardcoded. Until every signing secret exists (enrollment is pending) the workflow falls back to the previous unsigned build and emits a ::warning:: naming the missing secrets. - build-dmg.sh: package a prebuilt APP_PATH, hdiutil fallback when create-dmg is missing, sign the DMG, notarytool API-key auth - scripts/render_cask.py renders the tap cask from this repo's cask; unsigned builds keep the quarantine-clearing postflight - Cask, tap and release notes point at enum-dot-dev - CI and release select Xcode 26, which the project's build settings require Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++ .github/workflows/release.yml | 195 ++++++++++++++++++--- ExportOptions.plist | 5 + homebrew/Casks/focusrite-volume-control.rb | 11 +- scripts/build-dmg.sh | 139 +++++++++------ scripts/render_cask.py | 69 ++++++++ 6 files changed, 339 insertions(+), 90 deletions(-) create mode 100644 scripts/render_cask.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61be61e..ebff2fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,16 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Select Xcode 26 + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$XCODE" ]; then + echo "::error::No Xcode 26 on this runner. The project relies on Xcode 26 build settings (SWIFT_DEFAULT_ACTOR_ISOLATION)." + exit 1 + fi + sudo xcode-select -s "$XCODE" + xcodebuild -version + - name: Build run: > xcodebuild -scheme "Focusrite Volume Control" -configuration Debug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e33ad8..19750ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,57 +10,188 @@ jobs: runs-on: macos-15 permissions: contents: write + env: + APP_NAME: Focusrite Volume Control + DMG_PATH: build/FocusriteVolumeControl.dmg steps: - uses: actions/checkout@v4 - - name: Build Release - run: > - xcodebuild -scheme "Focusrite Volume Control" -configuration Release - MACOSX_DEPLOYMENT_TARGET=14.5 - CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO - build + - name: Select Xcode 26 + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$XCODE" ]; then + echo "::error::No Xcode 26 on this runner. The project relies on Xcode 26 build settings (SWIFT_DEFAULT_ACTOR_ISOLATION)." + exit 1 + fi + sudo xcode-select -s "$XCODE" + xcodebuild -version + + # Signing and notarization run only when every secret below is set. Until then the + # release is built unsigned, as before, and the Homebrew cask clears quarantine. + - name: Check signing secrets + id: signing + env: + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID || secrets.APPLE_TEAM_ID }} + DEVELOPER_ID_CERTIFICATE_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATE_P12 }} + DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} + APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }} + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} + run: | + missing=() + for name in APPLE_TEAM_ID DEVELOPER_ID_CERTIFICATE_P12 DEVELOPER_ID_CERTIFICATE_PASSWORD \ + APP_STORE_CONNECT_API_KEY_P8 APP_STORE_CONNECT_API_KEY_ID APP_STORE_CONNECT_API_ISSUER_ID; do + [ -n "${!name}" ] || missing+=("$name") + done + if [ ${#missing[@]} -eq 0 ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "Signing and notarization enabled" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Unsigned release::Building an UNSIGNED, un-notarized DMG. Missing secrets: ${missing[*]}" + fi + + - name: Import Developer ID certificate + if: steps.signing.outputs.enabled == 'true' + env: + DEVELOPER_ID_CERTIFICATE_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATE_P12 }} + DEVELOPER_ID_CERTIFICATE_PASSWORD: ${{ secrets.DEVELOPER_ID_CERTIFICATE_PASSWORD }} + APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }} + run: | + KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + echo "::add-mask::$KEYCHAIN_PASSWORD" + echo "$DEVELOPER_ID_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 3600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$RUNNER_TEMP/certificate.p12" -P "$DEVELOPER_ID_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$KEYCHAIN" + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + rm "$RUNNER_TEMP/certificate.p12" + + IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN" | grep -m1 "Developer ID Application" | sed -E 's/.*"(.*)"/\1/') + if [ -z "$IDENTITY" ]; then + echo "::error::DEVELOPER_ID_CERTIFICATE_P12 does not contain a Developer ID Application identity" + exit 1 + fi + echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" + echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + + # notarytool accepts the .p8 either raw or base64-encoded in the secret + KEY_PATH="$RUNNER_TEMP/AuthKey.p8" + if printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | grep -q "BEGIN PRIVATE KEY"; then + printf '%s\n' "$APP_STORE_CONNECT_API_KEY_P8" > "$KEY_PATH" + else + printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | base64 --decode > "$KEY_PATH" + fi + echo "NOTARY_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV" + + - name: Build signed app (archive + Developer ID export) + if: steps.signing.outputs.enabled == 'true' + env: + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID || secrets.APPLE_TEAM_ID }} + run: | + xcodebuild archive \ + -scheme "$APP_NAME" -configuration Release \ + -archivePath build/App.xcarchive \ + MACOSX_DEPLOYMENT_TARGET=14.5 \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ + ENABLE_HARDENED_RUNTIME=YES \ + OTHER_CODE_SIGN_FLAGS="--timestamp --keychain $KEYCHAIN" + + # The team ID isn't committed; add it to a copy of ExportOptions.plist + cp ExportOptions.plist build/ExportOptions.plist + plutil -replace teamID -string "$APPLE_TEAM_ID" build/ExportOptions.plist + xcodebuild -exportArchive \ + -archivePath build/App.xcarchive \ + -exportPath build/export \ + -exportOptionsPlist build/ExportOptions.plist + + APP_PATH="build/export/$APP_NAME.app" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + echo "APP_PATH=$APP_PATH" >> "$GITHUB_ENV" + + - name: Notarize and staple app + if: steps.signing.outputs.enabled == 'true' + env: + NOTARY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} + run: | + ditto -c -k --keepParent "$APP_PATH" build/app.zip + xcrun notarytool submit build/app.zip \ + --key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER" --wait + xcrun stapler staple "$APP_PATH" + spctl --assess --type execute --verbose=2 "$APP_PATH" + + - name: Build unsigned app + if: steps.signing.outputs.enabled != 'true' + run: | + xcodebuild -scheme "$APP_NAME" -configuration Release \ + -derivedDataPath build/DerivedData \ + MACOSX_DEPLOYMENT_TARGET=14.5 \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ + build + echo "APP_PATH=build/DerivedData/Build/Products/Release/$APP_NAME.app" >> "$GITHUB_ENV" - name: Package DMG + env: + SIGNED: ${{ steps.signing.outputs.enabled }} + NOTARY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} run: | - BUILT_APP=$(find ~/Library/Developer/Xcode/DerivedData/Focusrite_Volume_Control-* \ - -path "*/Build/Products/Release/Focusrite Volume Control.app" -maxdepth 5 | head -1) - mkdir -p build/dmg - cp -R "$BUILT_APP" build/dmg/ - ln -s /Applications build/dmg/Applications - hdiutil create \ - -volname "Focusrite Volume Control" \ - -srcfolder build/dmg \ - -ov -format UDZO \ - build/FocusriteVolumeControl.dmg + if [ "$SIGNED" != "true" ]; then + # build-dmg.sh signs and notarizes only when these are set + unset SIGN_IDENTITY NOTARY_KEY_PATH NOTARY_KEY_ID NOTARY_ISSUER + fi + bash scripts/build-dmg.sh - name: Compute DMG SHA256 id: sha - run: echo "sha256=$(shasum -a 256 build/FocusriteVolumeControl.dmg | awk '{print $1}')" >> "$GITHUB_OUTPUT" + run: echo "sha256=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Write release notes + env: + SIGNED: ${{ steps.signing.outputs.enabled }} + DMG_SHA256: ${{ steps.sha.outputs.sha256 }} + run: | + { + echo '**Homebrew:**' + echo '```' + echo 'brew tap enum-dot-dev/focusrite-volume-control' + echo 'brew install --cask focusrite-volume-control' + echo '```' + echo + echo "**DMG SHA256:** \`$DMG_SHA256\`" + if [ "$SIGNED" != "true" ]; then + echo + echo '> This build is not signed or notarized. On first launch, right-click the app and choose Open.' + fi + } > build/release-notes.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - files: build/FocusriteVolumeControl.dmg + files: ${{ env.DMG_PATH }} generate_release_notes: true - body: | - **Homebrew:** - ``` - brew tap enum-labs/focusrite-volume-control - brew install --cask focusrite-volume-control - ``` - - **DMG SHA256:** `${{ steps.sha.outputs.sha256 }}` + body_path: build/release-notes.md - name: Update Homebrew tap env: GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} DMG_SHA256: ${{ steps.sha.outputs.sha256 }} + SIGNED: ${{ steps.signing.outputs.enabled }} run: | VERSION="${GITHUB_REF_NAME#v}" - git clone https://x-access-token:${GH_TOKEN}@github.com/enum-labs/homebrew-focusrite-volume-control.git tap + CASK_FLAGS=() + [ "$SIGNED" = "true" ] || CASK_FLAGS+=(--unsigned) + git clone "https://x-access-token:${GH_TOKEN}@github.com/enum-dot-dev/homebrew-focusrite-volume-control.git" tap + python3 scripts/render_cask.py --version "$VERSION" --sha256 "$DMG_SHA256" ${CASK_FLAGS[@]+"${CASK_FLAGS[@]}"} \ + homebrew/Casks/focusrite-volume-control.rb tap/Casks/focusrite-volume-control.rb cd tap - sed -i '' "s/version \".*\"/version \"${VERSION}\"/" Casks/focusrite-volume-control.rb - sed -i '' "s/sha256 \".*\"/sha256 \"${DMG_SHA256}\"/" Casks/focusrite-volume-control.rb git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Casks/focusrite-volume-control.rb @@ -68,3 +199,9 @@ jobs: git commit -m "bump to v${VERSION}" git push fi + + - name: Remove signing keychain + if: always() + run: | + [ -n "${KEYCHAIN:-}" ] && security delete-keychain "$KEYCHAIN" || true + rm -f "$RUNNER_TEMP/AuthKey.p8" diff --git a/ExportOptions.plist b/ExportOptions.plist index 6cd4546..7fc1efb 100644 --- a/ExportOptions.plist +++ b/ExportOptions.plist @@ -6,5 +6,10 @@ developer-id destination export + signingStyle + manual + signingCertificate + Developer ID Application + diff --git a/homebrew/Casks/focusrite-volume-control.rb b/homebrew/Casks/focusrite-volume-control.rb index 4dde3b2..c4870a0 100644 --- a/homebrew/Casks/focusrite-volume-control.rb +++ b/homebrew/Casks/focusrite-volume-control.rb @@ -1,11 +1,13 @@ +# Template: the release workflow renders this with scripts/render_cask.py (version, sha256, +# and a quarantine postflight for unsigned builds) and pushes it to the tap repository. cask "focusrite-volume-control" do - version "1.2" - sha256 :no_check # Updated automatically by release workflow + version "1.5.1" + sha256 :no_check - url "https://github.com/enum-labs/focusrite-volume-control/releases/download/v#{version}/FocusriteVolumeControl.dmg" + url "https://github.com/enum-dot-dev/focusrite-volume-control/releases/download/v#{version}/FocusriteVolumeControl.dmg" name "Focusrite Volume Control" desc "Control Focusrite Scarlett 4th gen volume with media keys via Focusrite Control 2" - homepage "https://enum-labs.github.io/focusrite-volume-control/" + homepage "https://enum-dot-dev.github.io/focusrite-volume-control/" depends_on macos: ">= :sonoma" @@ -18,6 +20,7 @@ EOS zap trash: [ + "~/Library/Preferences/net.nickmorozov.FocusriteVolumeControl.plist", "~/Library/Preferences/solutions.enum.FocusriteVolumeControl.plist", ] end diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index aabdcac..fc212bd 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -1,15 +1,26 @@ #!/bin/bash # -# build-dmg.sh — Build a distributable DMG for Focusrite Volume Control +# build-dmg.sh - Package Focusrite Volume Control into a DMG, optionally signed and notarized # # Usage: -# bash scripts/build-dmg.sh # uses project signing settings -# ADHOC=1 bash scripts/build-dmg.sh # force ad-hoc signing (for CI) +# bash scripts/build-dmg.sh # build with project signing settings, then package +# ADHOC=1 bash scripts/build-dmg.sh # force ad-hoc signing +# APP_PATH=build/export/"Focusrite Volume Control.app" bash scripts/build-dmg.sh +# # package an already built (and signed) app # -# Optional environment variables for notarization: -# APPLE_ID — Apple ID email for notarization -# TEAM_ID — Developer Team ID -# APP_PASSWORD — App-specific password for notarization +# Optional environment variables: +# APP_PATH - prebuilt .app to package instead of building one +# DMG_PATH - output path (default: build/Focusrite Volume Control.dmg) +# SIGN_IDENTITY - codesign identity for the DMG, e.g. "Developer ID Application: Name (TEAMID)" +# KEYCHAIN - keychain holding SIGN_IDENTITY (default: search list) +# +# Notarization (App Store Connect API key, preferred): +# NOTARY_KEY_PATH - path to AuthKey_XXXX.p8 +# NOTARY_KEY_ID - API key ID +# NOTARY_ISSUER - API issuer ID +# +# Notarization (legacy Apple ID): +# APPLE_ID, TEAM_ID, APP_PASSWORD # set -euo pipefail @@ -20,71 +31,85 @@ BUILD_DIR="$PROJECT_DIR/build" SCHEME="Focusrite Volume Control" APP_NAME="Focusrite Volume Control" DMG_VOLUME_NAME="Focusrite Volume Control" +DMG_FINAL="${DMG_PATH:-$BUILD_DIR/$APP_NAME.dmg}" -# Only override signing when explicitly requested (e.g., CI without certificates) -SIGN_ARGS="" -if [ "${ADHOC:-}" = "1" ] || [ "${CI:-}" = "true" ]; then - echo "==> Using ad-hoc signing (no certificate)" - SIGN_ARGS="CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO" -fi - -echo "==> Cleaning build directory..." -rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" -# Build Release -echo "==> Building Release..." -xcodebuild -project "$PROJECT_DIR/Focusrite Volume Control.xcodeproj" \ - -scheme "$SCHEME" \ - -configuration Release \ - MACOSX_DEPLOYMENT_TARGET=14.5 \ - $SIGN_ARGS \ - build \ - -quiet +if [ -n "${APP_PATH:-}" ]; then + echo "==> Packaging prebuilt app: $APP_PATH" + [ -d "$APP_PATH" ] || { echo "ERROR: APP_PATH does not exist: $APP_PATH"; exit 1; } +else + # Only override signing when explicitly requested (e.g., CI without certificates) + SIGN_ARGS=() + if [ "${ADHOC:-}" = "1" ] || [ "${CI:-}" = "true" ]; then + echo "==> Using ad-hoc signing (no certificate)" + SIGN_ARGS=(CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO) + fi + + echo "==> Building Release..." + rm -rf "$BUILD_DIR/DerivedData" "$BUILD_DIR/export" + xcodebuild -project "$PROJECT_DIR/Focusrite Volume Control.xcodeproj" \ + -scheme "$SCHEME" \ + -configuration Release \ + -derivedDataPath "$BUILD_DIR/DerivedData" \ + MACOSX_DEPLOYMENT_TARGET=14.5 \ + ${SIGN_ARGS[@]+"${SIGN_ARGS[@]}"} \ + build \ + -quiet -# Find the built app -BUILT_APP=$(find ~/Library/Developer/Xcode/DerivedData/Focusrite_Volume_Control-* \ - -path "*/Build/Products/Release/${APP_NAME}.app" -maxdepth 5 2>/dev/null | head -1) -if [ -z "$BUILT_APP" ]; then - echo "ERROR: Could not find built app in DerivedData" - exit 1 + mkdir -p "$BUILD_DIR/export" + cp -R "$BUILD_DIR/DerivedData/Build/Products/Release/$APP_NAME.app" "$BUILD_DIR/export/" + APP_PATH="$BUILD_DIR/export/$APP_NAME.app" fi -mkdir -p "$BUILD_DIR/export" -cp -R "$BUILT_APP" "$BUILD_DIR/export/" -# Create DMG with drag-to-Applications layout using create-dmg echo "==> Creating DMG..." -DMG_FINAL="$BUILD_DIR/$APP_NAME.dmg" rm -f "$DMG_FINAL" - -create-dmg \ - --volname "$DMG_VOLUME_NAME" \ - --window-pos 200 120 \ - --window-size 540 380 \ - --icon-size 80 \ - --icon "$APP_NAME.app" 140 180 \ - --app-drop-link 400 180 \ - --no-internet-enable \ - "$DMG_FINAL" \ - "$BUILD_DIR/export/$APP_NAME.app" - +if command -v create-dmg >/dev/null 2>&1; then + # Drag-to-Applications layout + create-dmg \ + --volname "$DMG_VOLUME_NAME" \ + --window-pos 200 120 \ + --window-size 540 380 \ + --icon-size 80 \ + --icon "$APP_NAME.app" 140 180 \ + --app-drop-link 400 180 \ + --no-internet-enable \ + "$DMG_FINAL" \ + "$APP_PATH" +else + STAGING="$(mktemp -d)" + cp -R "$APP_PATH" "$STAGING/" + ln -s /Applications "$STAGING/Applications" + hdiutil create -volname "$DMG_VOLUME_NAME" -srcfolder "$STAGING" -ov -format UDZO "$DMG_FINAL" + rm -rf "$STAGING" +fi echo "==> DMG created: $DMG_FINAL" -# Optional notarization -if [ -n "${APPLE_ID:-}" ] && [ -n "${TEAM_ID:-}" ] && [ -n "${APP_PASSWORD:-}" ]; then - echo "==> Submitting for notarization..." - xcrun notarytool submit "$DMG_FINAL" \ - --apple-id "$APPLE_ID" \ - --team-id "$TEAM_ID" \ - --password "$APP_PASSWORD" \ - --wait +if [ -n "${SIGN_IDENTITY:-}" ]; then + echo "==> Signing DMG..." + KEYCHAIN_ARGS=() + [ -n "${KEYCHAIN:-}" ] && KEYCHAIN_ARGS=(--keychain "$KEYCHAIN") + codesign --force --timestamp --sign "$SIGN_IDENTITY" ${KEYCHAIN_ARGS[@]+"${KEYCHAIN_ARGS[@]}"} "$DMG_FINAL" + codesign --verify --verbose=2 "$DMG_FINAL" +fi + +NOTARY_ARGS=() +if [ -n "${NOTARY_KEY_PATH:-}" ] && [ -n "${NOTARY_KEY_ID:-}" ] && [ -n "${NOTARY_ISSUER:-}" ]; then + NOTARY_ARGS=(--key "$NOTARY_KEY_PATH" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER") +elif [ -n "${APPLE_ID:-}" ] && [ -n "${TEAM_ID:-}" ] && [ -n "${APP_PASSWORD:-}" ]; then + NOTARY_ARGS=(--apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$APP_PASSWORD") +fi +if [ ${#NOTARY_ARGS[@]} -gt 0 ]; then + echo "==> Submitting DMG for notarization..." + xcrun notarytool submit "$DMG_FINAL" "${NOTARY_ARGS[@]}" --wait echo "==> Stapling notarization ticket..." xcrun stapler staple "$DMG_FINAL" - echo "==> Notarization complete!" + xcrun stapler validate "$DMG_FINAL" + echo "==> Notarization complete" else echo "" - echo "Skipping notarization (set APPLE_ID, TEAM_ID, APP_PASSWORD to enable)." + echo "Skipping notarization (set NOTARY_KEY_PATH, NOTARY_KEY_ID, NOTARY_ISSUER to enable)." fi echo "" diff --git a/scripts/render_cask.py b/scripts/render_cask.py new file mode 100644 index 0000000..ae0f55b --- /dev/null +++ b/scripts/render_cask.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Render the Homebrew cask for a release from homebrew/Casks/focusrite-volume-control.rb. + +The release workflow copies the rendered cask into the tap repository, so this repo's +cask stays the single source of truth. Unsigned builds get a postflight that clears +the quarantine attribute, since Gatekeeper blocks un-notarized apps downloaded by brew. + +Usage: + render_cask.py --version 1.6 --sha256 [--unsigned] [--auto-updates] SRC DEST +""" + +import argparse +import re +import sys + +QUARANTINE_POSTFLIGHT = ''' + # Unsigned build: clear quarantine so Gatekeeper doesn't block the app + postflight do + system_command "/usr/bin/xattr", + args: ["-cr", "#{appdir}/Focusrite Volume Control.app"] + end +''' + + +def render(source: str, version: str, sha256: str, unsigned: bool, auto_updates: bool) -> str: + if not re.fullmatch(r"[0-9]+(\.[0-9]+)*", version): + raise ValueError(f"unexpected version: {version!r}") + if not re.fullmatch(r"[0-9a-f]{64}", sha256): + raise ValueError(f"unexpected sha256: {sha256!r}") + + def sub(pattern: str, replacement: str, text: str) -> str: + result, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise ValueError(f"pattern not found in cask: {pattern}") + return result + + cask = sub(r'^ version ".*"$', f' version "{version}"', source) + cask = sub(r'^ sha256 .*$', f' sha256 "{sha256}"', cask) + if auto_updates: + cask = sub(r'^( depends_on .*)$', r' auto_updates true\n\1', cask) + if unsigned: + cask = sub(r'^( app ".*")$', lambda m: m.group(1) + "\n" + QUARANTINE_POSTFLIGHT.rstrip("\n"), cask) + return cask + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--version", required=True) + parser.add_argument("--sha256", required=True) + parser.add_argument("--unsigned", action="store_true", help="add the quarantine-clearing postflight") + parser.add_argument("--auto-updates", action="store_true", help="the app updates itself (Sparkle)") + parser.add_argument("source") + parser.add_argument("dest") + args = parser.parse_args() + + with open(args.source) as f: + source = f.read() + try: + cask = render(source, args.version, args.sha256, args.unsigned, args.auto_updates) + except ValueError as error: + print(f"render_cask: {error}", file=sys.stderr) + return 1 + with open(args.dest, "w") as f: + f.write(cask) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f16775992f40818bf89a8e92f2c20527c6ca684f Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 10:52:43 -0700 Subject: [PATCH 06/18] Add Sparkle 2 updates with an appcast on GitHub Pages - Sparkle 2.10.0 via SPM, "Check for Updates..." in the status menu - SUFeedURL points at enum-dot-dev.github.io/.../appcast.xml; SUPublicEDKey is a placeholder and the updater stays off (menu item disabled) until it is replaced, since Sparkle errors at launch on an invalid key - Info.plist keys without INFOPLIST_KEY_ settings live in Focusrite-Volume-Control-Info.plist, merged into the generated plist - Release stamps MARKETING_VERSION and CURRENT_PROJECT_VERSION from the tag (CFBundleVersion was fixed at 2, which Sparkle compares) - Signed releases are signed with sign_update (key from SPARKLE_ED_PRIVATE_KEY via stdin), added to site/appcast.xml by scripts/update_appcast.py, committed to main and deployed to Pages; unsigned releases are never advertised - The tap cask gets auto_updates true once Sparkle publishes Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 61 +++++++++++- .../project.pbxproj | 30 ++++++ Focusrite Volume Control/AppDelegate.swift | 21 +++++ Focusrite-Volume-Control-Info.plist | 17 ++++ scripts/update_appcast.py | 92 +++++++++++++++++++ site/appcast.xml | 9 ++ 6 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 Focusrite-Volume-Control-Info.plist create mode 100644 scripts/update_appcast.py create mode 100644 site/appcast.xml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19750ab..5006b3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,8 @@ jobs: build-dmg: runs-on: macos-15 permissions: - contents: write + contents: write # release upload, appcast commit + actions: write # trigger the Pages deploy after the appcast changes env: APP_NAME: Focusrite Volume Control DMG_PATH: build/FocusriteVolumeControl.dmg @@ -26,6 +27,10 @@ jobs: sudo xcode-select -s "$XCODE" xcodebuild -version + # Sparkle compares CFBundleVersion, so stamp both version keys from the tag + - name: Read version from tag + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + # Signing and notarization run only when every secret below is set. Until then the # release is built unsigned, as before, and the Homebrew cask clears quarantine. - name: Check signing secrets @@ -51,6 +56,25 @@ jobs: echo "::warning title=Unsigned release::Building an UNSIGNED, un-notarized DMG. Missing secrets: ${missing[*]}" fi + # The appcast only advertises signed, notarized builds: Gatekeeper would block an + # unsigned app that Sparkle installed. + - name: Check Sparkle configuration + id: sparkle + env: + SIGNED: ${{ steps.signing.outputs.enabled }} + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + run: | + PUBLIC_KEY=$(/usr/libexec/PlistBuddy -c "Print :SUPublicEDKey" Focusrite-Volume-Control-Info.plist) + if [ "$SIGNED" != "true" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Appcast not updated::Unsigned builds are not published to the Sparkle appcast." + elif [ -z "$SPARKLE_ED_PRIVATE_KEY" ] || [[ "$PUBLIC_KEY" == REPLACE_* ]]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Appcast not updated::Set the SPARKLE_ED_PRIVATE_KEY secret and SUPublicEDKey in Focusrite-Volume-Control-Info.plist to publish Sparkle updates." + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + - name: Import Developer ID certificate if: steps.signing.outputs.enabled == 'true' env: @@ -97,6 +121,7 @@ jobs: -scheme "$APP_NAME" -configuration Release \ -archivePath build/App.xcarchive \ MACOSX_DEPLOYMENT_TARGET=14.5 \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$VERSION" \ DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ @@ -133,6 +158,7 @@ jobs: xcodebuild -scheme "$APP_NAME" -configuration Release \ -derivedDataPath build/DerivedData \ MACOSX_DEPLOYMENT_TARGET=14.5 \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$VERSION" \ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ build echo "APP_PATH=build/DerivedData/Build/Products/Release/$APP_NAME.app" >> "$GITHUB_ENV" @@ -179,15 +205,46 @@ jobs: generate_release_notes: true body_path: build/release-notes.md + - name: Publish Sparkle appcast + if: steps.sparkle.outputs.enabled == 'true' + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + GH_TOKEN: ${{ github.token }} + SPARKLE_VERSION: 2.10.0 # keep in step with the Sparkle package version in the project + run: | + curl -fsSL -o "$RUNNER_TEMP/sparkle.tar.xz" \ + "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + mkdir -p "$RUNNER_TEMP/sparkle" && tar -xJf "$RUNNER_TEMP/sparkle.tar.xz" -C "$RUNNER_TEMP/sparkle" + SIGNATURE=$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | "$RUNNER_TEMP/sparkle/bin/sign_update" --ed-key-file - -p "$DMG_PATH") + LENGTH=$(stat -f%z "$DMG_PATH") + RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/FocusriteVolumeControl.dmg" + + # The tag checkout is detached; commit the appcast on top of main + git fetch origin main + git checkout -B appcast-update origin/main + python3 scripts/update_appcast.py site/appcast.xml \ + --version "$VERSION" --url "$RELEASE_URL" --length "$LENGTH" --signature "$SIGNATURE" \ + --minimum-system-version 14.5 \ + --release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add site/appcast.xml + git commit -m "appcast: v${VERSION}" + git push origin HEAD:main + + # Pushes made with GITHUB_TOKEN don't trigger workflows, but workflow_dispatch does + gh workflow run pages.yml --ref main + - name: Update Homebrew tap env: GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} DMG_SHA256: ${{ steps.sha.outputs.sha256 }} SIGNED: ${{ steps.signing.outputs.enabled }} + SPARKLE: ${{ steps.sparkle.outputs.enabled }} run: | - VERSION="${GITHUB_REF_NAME#v}" CASK_FLAGS=() [ "$SIGNED" = "true" ] || CASK_FLAGS+=(--unsigned) + [ "$SPARKLE" = "true" ] && CASK_FLAGS+=(--auto-updates) git clone "https://x-access-token:${GH_TOKEN}@github.com/enum-dot-dev/homebrew-focusrite-volume-control.git" tap python3 scripts/render_cask.py --version "$VERSION" --sha256 "$DMG_SHA256" ${CASK_FLAGS[@]+"${CASK_FLAGS[@]}"} \ homebrew/Casks/focusrite-volume-control.rb tap/Casks/focusrite-volume-control.rb diff --git a/Focusrite Volume Control.xcodeproj/project.pbxproj b/Focusrite Volume Control.xcodeproj/project.pbxproj index a75c9cd..da7c38c 100644 --- a/Focusrite Volume Control.xcodeproj/project.pbxproj +++ b/Focusrite Volume Control.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 01A89DA32F2A00000084DBD8 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 01A89DA22F2A00000084DBD8 /* Sparkle */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 01A89D6E2F280BA90084DBD8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -39,6 +43,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 01A89DA32F2A00000084DBD8 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,6 +95,7 @@ ); name = "Focusrite Volume Control"; packageProductDependencies = ( + 01A89DA22F2A00000084DBD8 /* Sparkle */, ); productName = "Focusrite Volume Control"; productReference = 01A89D572F280BA90084DBD8 /* Focusrite Volume Control.app */; @@ -146,6 +152,9 @@ ); mainGroup = 01A89D4E2F280BA90084DBD8; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 01A89DA12F2A00000084DBD8 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 01A89D582F280BA90084DBD8 /* Products */; projectDirPath = ""; @@ -333,6 +342,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Focusrite-Volume-Control-Info.plist"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -365,6 +375,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Focusrite-Volume-Control-Info.plist"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -457,6 +468,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 01A89DA12F2A00000084DBD8 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = exactVersion; + version = 2.10.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 01A89DA22F2A00000084DBD8 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 01A89DA12F2A00000084DBD8 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 01A89D4F2F280BA90084DBD8 /* Project object */; } diff --git a/Focusrite Volume Control/AppDelegate.swift b/Focusrite Volume Control/AppDelegate.swift index bdb73fb..6354667 100644 --- a/Focusrite Volume Control/AppDelegate.swift +++ b/Focusrite Volume Control/AppDelegate.swift @@ -11,6 +11,7 @@ import Cocoa import SwiftUI import Combine +import Sparkle // Media key codes (from IOKit/hidsystem/ev_keymap.h) private let NX_KEYTYPE_SOUND_UP: Int = 0 @@ -47,6 +48,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Custom hotkey manager for non-media-key shortcuts private var hotkeyManager: HotkeyManager! + // Sparkle updater; nil until a real SUPublicEDKey replaces the placeholder + private var updaterController: SPUStandardUpdaterController? + // MARK: - App Lifecycle func applicationDidFinishLaunching(_ notification: Notification) { @@ -57,6 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { audioDeviceMonitor = AudioDeviceMonitor() // Set up menu bar (always visible, even before permissions) + setupUpdater() setupMenuBar() setupStatusMenu() @@ -195,6 +200,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { popover.contentViewController = NSHostingController(rootView: contentView) } + /// Sparkle shows an error at launch if SUPublicEDKey is invalid, so only start it with a real key + private func setupUpdater() { + let publicKey = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String ?? "" + guard !publicKey.isEmpty, !publicKey.hasPrefix("REPLACE_") else { return } + updaterController = SPUStandardUpdaterController(startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil) + } + private func setupStatusMenu() { statusMenu = NSMenu() @@ -208,6 +222,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { prefsItem.target = self statusMenu.addItem(prefsItem) + // Check for Updates (disabled while the updater isn't configured) + let updateItem = NSMenuItem(title: "Check for Updates...", + action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), + keyEquivalent: "") + updateItem.target = updaterController + statusMenu.addItem(updateItem) + // Report Issue let issueItem = NSMenuItem(title: "Report Issue / Request Feature...", action: #selector(openIssues), keyEquivalent: "") issueItem.target = self diff --git a/Focusrite-Volume-Control-Info.plist b/Focusrite-Volume-Control-Info.plist new file mode 100644 index 0000000..d4e4860 --- /dev/null +++ b/Focusrite-Volume-Control-Info.plist @@ -0,0 +1,17 @@ + + + + + + SUFeedURL + https://enum-dot-dev.github.io/focusrite-volume-control/appcast.xml + + SUPublicEDKey + REPLACE_WITH_SPARKLE_PUBLIC_ED_KEY + + diff --git a/scripts/update_appcast.py b/scripts/update_appcast.py new file mode 100644 index 0000000..d417de6 --- /dev/null +++ b/scripts/update_appcast.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Add a release to the Sparkle appcast (site/appcast.xml), newest first. + +The release workflow runs this after signing the DMG with Sparkle's sign_update, +then commits the appcast so GitHub Pages serves it. An existing item for the same +version is replaced, so re-running a release is safe. + +Usage: + update_appcast.py APPCAST --version 1.6 --url URL --length BYTES --signature SIG + [--minimum-system-version 14.5] [--release-notes-url URL] [--pub-date RFC822] +""" + +import argparse +import sys +import xml.etree.ElementTree as ET +from typing import Optional +from email.utils import formatdate + +SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +ET.register_namespace("sparkle", SPARKLE_NS) + + +def sparkle(tag: str) -> str: + return f"{{{SPARKLE_NS}}}{tag}" + + +def add_release(tree: ET.ElementTree, version: str, url: str, length: int, signature: str, + minimum_system_version: str, release_notes_url: Optional[str], pub_date: str) -> None: + channel = tree.getroot().find("channel") + if channel is None: + raise ValueError("appcast has no ") + + for item in channel.findall("item"): + if item.findtext(sparkle("version")) == version: + channel.remove(item) + + item = ET.Element("item") + ET.SubElement(item, "title").text = f"Version {version}" + ET.SubElement(item, "pubDate").text = pub_date + # CFBundleVersion and CFBundleShortVersionString are both stamped from the tag + ET.SubElement(item, sparkle("version")).text = version + ET.SubElement(item, sparkle("shortVersionString")).text = version + ET.SubElement(item, sparkle("minimumSystemVersion")).text = minimum_system_version + if release_notes_url: + ET.SubElement(item, sparkle("fullReleaseNotesLink")).text = release_notes_url + ET.SubElement(item, "enclosure", { + "url": url, + "length": str(length), + "type": "application/octet-stream", + sparkle("edSignature"): signature, + }) + + # Keep items newest first, after the channel's own metadata + items = sorted(channel.findall("item") + [item], + key=lambda i: version_key(i.findtext(sparkle("version")) or ""), reverse=True) + for existing in channel.findall("item"): + channel.remove(existing) + channel.extend(items) + + +def version_key(version: str) -> tuple: + return tuple(int(part) if part.isdigit() else 0 for part in version.split(".")) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("appcast") + parser.add_argument("--version", required=True) + parser.add_argument("--url", required=True) + parser.add_argument("--length", required=True, type=int) + parser.add_argument("--signature", required=True) + parser.add_argument("--minimum-system-version", default="14.5") + parser.add_argument("--release-notes-url") + parser.add_argument("--pub-date", default=formatdate(usegmt=True)) + args = parser.parse_args() + + tree = ET.parse(args.appcast) + try: + add_release(tree, args.version, args.url, args.length, args.signature, + args.minimum_system_version, args.release_notes_url, args.pub_date) + except ValueError as error: + print(f"update_appcast: {error}", file=sys.stderr) + return 1 + ET.indent(tree, space=" ") + tree.write(args.appcast, encoding="utf-8", xml_declaration=True) + with open(args.appcast, "a") as f: + f.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/appcast.xml b/site/appcast.xml new file mode 100644 index 0000000..5728e13 --- /dev/null +++ b/site/appcast.xml @@ -0,0 +1,9 @@ + + + + Focusrite Volume Control + https://enum-dot-dev.github.io/focusrite-volume-control/appcast.xml + Updates for Focusrite Volume Control. Maintained by the release workflow. + en + + From 78ad04414f7515f3119164d3885b3ba7b14750c0 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 10:53:26 -0700 Subject: [PATCH 07/18] Update README and site: compatibility, permissions, org links - Compatibility section: tested only with the Scarlett Solo 4th Gen; older Scarlett models aren't supported by Focusrite Control 2; users of larger interfaces can open an issue - Requirements and site setup steps no longer mention Automation permission (the app no longer sends Apple Events) - Note on unsigned releases and Check for Updates - Build from source requires Xcode 26 and fetches Sparkle - Links point at enum-dot-dev (the org was renamed from enum-labs) Co-Authored-By: Claude Opus 5.5 --- Focusrite Volume Control/AppDelegate.swift | 2 +- README.md | 20 ++++++++++++--- TODO.md | 2 +- site/index.html | 30 ++++++++-------------- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/Focusrite Volume Control/AppDelegate.swift b/Focusrite Volume Control/AppDelegate.swift index 6354667..1c28d8d 100644 --- a/Focusrite Volume Control/AppDelegate.swift +++ b/Focusrite Volume Control/AppDelegate.swift @@ -360,7 +360,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func openIssues() { - if let url = URL(string: "https://github.com/enum-labs/focusrite-volume-control/issues") { + if let url = URL(string: "https://github.com/enum-dot-dev/focusrite-volume-control/issues") { NSWorkspace.shared.open(url) } } diff --git a/README.md b/README.md index c31f33c..e4b7a3f 100644 --- a/README.md +++ b/README.md @@ -17,19 +17,27 @@ A macOS menu bar app that lets you control your Focusrite Scarlett Solo volume w ### Homebrew ```bash -brew tap enum-labs/focusrite-volume-control +brew tap enum-dot-dev/focusrite-volume-control brew install --cask focusrite-volume-control ``` ### Manual -Download the latest DMG from [Releases](https://github.com/enum-labs/focusrite-volume-control/releases/latest) and drag to Applications. +Download the latest DMG from [Releases](https://github.com/enum-dot-dev/focusrite-volume-control/releases/latest) and drag to Applications. + +Releases are moving to Developer ID signing and notarization. Until a release says it is signed, macOS will block the first launch: right-click the app and choose **Open**. Signed releases update themselves through **Check for Updates...** in the menu bar menu. ## Requirements - macOS 14.5 (Sonoma) or later -- [Focusrite Control 2](https://focusrite.com/downloads) installed -- Accessibility permission (prompted on first launch) +- A Focusrite interface managed by [Focusrite Control 2](https://focusrite.com/downloads) (Scarlett 4th Gen), with FC2 installed +- Accessibility permission (prompted on first launch). No Automation permission is needed. + +## Compatibility + +This has only been tested with a **Scarlett Solo 4th Gen**. Older Scarlett models (3rd Gen and earlier) aren't supported by Focusrite Control 2, so they can't work with this app either. + +If you use a larger interface (2i2, 4i4, 16i16 and up) and something doesn't work, its FC2 window is probably laid out differently. [Open an issue](https://github.com/enum-dot-dev/focusrite-volume-control/issues) with your model and FC2 version and I'll try to patch it. Requests for things like preset key bindings are welcome there too. ## Features @@ -54,11 +62,15 @@ The app drives Focusrite Control 2's window in-process through the macOS Accessi ## Build from source +Requires Xcode 26. Swift Package Manager fetches [Sparkle](https://sparkle-project.org) on the first build. + ```bash xcodebuild -scheme "Focusrite Volume Control" -configuration Release \ CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO build ``` +`bash scripts/build-dmg.sh` builds and packages a DMG; see the script header for signing and notarization options. + ## Credits The native Accessibility control path, rapid key-press handling and the shortcut recorder fix are ported from [William Chan's version](https://github.com/wc2184/focusrite-volume-control) (MIT). diff --git a/TODO.md b/TODO.md index d328d44..eb572a2 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ - Shortcuts to switch presets and change inputs volume, with e.g., shift or control + volume - ... - PROFIT! -- [Propose your own ideas with the `enhancement` label](https://github.com/enum-labs/focusrite-volume-control/issues/new) +- [Propose your own ideas with the `enhancement` label](https://github.com/enum-dot-dev/focusrite-volume-control/issues/new) ## Known Issues ## - Mute button causes popover to shift a few pixels when menu bar is set to "Always Hidden". This appears to be a macOS/SwiftUI interaction issue with auto-hiding menu bars and popover repositioning. diff --git a/site/index.html b/site/index.html index a57dd9b..54acdb6 100644 --- a/site/index.html +++ b/site/index.html @@ -17,10 +17,10 @@

Focusrite Volume Control

Control your Focusrite Scarlett Solo volume with your Mac's media keys and custom shortcuts.

@@ -90,7 +90,7 @@

Download

Homebrew

- brew tap enum-labs/focusrite-volume-control + brew tap enum-dot-dev/focusrite-volume-control brew install --cask focusrite-volume-control
@@ -99,14 +99,14 @@

Homebrew

Direct Download

- + ⬇ Download DMG

- The app requires Accessibility and Automation permissions — a setup wizard will guide you on first launch. + The app needs the Accessibility permission — a setup wizard will guide you on first launch. Tested with the Scarlett Solo 4th Gen; other models, open an issue if something doesn't work.

@@ -129,8 +129,8 @@

Install Focusrite Control 2

2

Install the app

-

Via Homebrew: brew tap enum-labs/focusrite-volume-control && brew install --cask focusrite-volume-control
- Or download the DMG and drag to Applications.

+

Via Homebrew: brew tap enum-dot-dev/focusrite-volume-control && brew install --cask focusrite-volume-control
+ Or download the DMG and drag to Applications.

@@ -138,20 +138,12 @@

Install the app

3

Grant Accessibility permission

-

The setup wizard will prompt you. This lets the app intercept your volume keys. Go to System Settings → Privacy & Security → Accessibility and enable the app.

+

The setup wizard will prompt you. This lets the app intercept your volume keys and adjust Focusrite Control 2's sliders. Go to System Settings → Privacy & Security → Accessibility and enable the app.

4
-
-

Grant Automation permission

-

The app will ask to control System Events and Focusrite Control 2. Click Allow when prompted. This lets it read and set volume levels.

-
-
- -
-
5

Use your volume keys

That's it! Your Mac's volume keys now control your Focusrite. Customize shortcuts and preferences from the menu bar icon.

@@ -165,9 +157,9 @@

Use your volume keys

Focusrite Volume Control is open source software. Not affiliated with Focusrite plc.

From 80751e9e6f58ac2f7a7a5bf8f3bf7cc614be4b6e Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 10:54:01 -0700 Subject: [PATCH 08/18] Document the FC2 remote-protocol prototype Records how FocusriteClient talks to FC2 (XML over local TCP, lsof port discovery, Length-prefixed framing, approval in FC2 Remote Devices), what is missing (item IDs, keep-alive, reconnect, the FCServerBackend stub) and the commit history suggesting it was dropped within hours. Includes a non-binding proposal for an "Enable experimental remote protocol" setting with a setup wizard. Co-Authored-By: Claude Opus 5.5 --- docs/remote-protocol.md | 101 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/remote-protocol.md diff --git a/docs/remote-protocol.md b/docs/remote-protocol.md new file mode 100644 index 0000000..208e38e --- /dev/null +++ b/docs/remote-protocol.md @@ -0,0 +1,101 @@ +# Remote protocol prototype (FC2 over TCP) + +Status: **experimental, unwired, unverified.** Nobody has confirmed that it ever controlled +volume end to end. This page records what the code does so the work isn't lost. + +## Why it exists + +The shipping app controls Focusrite Control 2 (FC2) by operating its window through the +Accessibility API. FC2 also accepts remote-control clients over a local TCP socket, the same +mechanism Focusrite's own remote apps use. Talking that protocol directly would not need FC2's +window, the Direct tab, or the Accessibility permission for FC2 control. + +## What's in the repo + +| File | What it is | +|---|---| +| `Focusrite Volume Control/FocusriteClient.swift` | A working-looking client for FC2's XML-over-TCP protocol. Nothing creates it today. | +| `Focusrite Volume Control/FCServerBackend.swift` | A `VolumeBackend` stub. Every method returns "not yet implemented"; it does not use `FocusriteClient`. Its header says AES70/OCA, but no AES70 code exists; the client speaks the XML protocol below. | +| `Focusrite Volume ControlTests/FCServerBackendTests.swift` | Tests that the stub returns its errors. Nothing tests `FocusriteClient`. | +| `BackendType.fcServer` in `VolumeController.swift` | Selectable in code via `switchBackend(to:)`. There is no UI for it (a picker existed until `4d12ee2` removed it). | + +## How the client works + +**Transport.** TCP to `127.0.0.1` with `NWConnection`. No Bonjour, no UDP. + +**Finding the port.** The client runs `lsof -i TCP -s TCP:LISTEN -n -P`, keeps lines from a +process containing "Focusrite" owned by the current user, and takes ports written as `*:PORT`. +It then appends fallback ports `58323, 58322, 49152, 30096` (a code comment guesses 58323 is +the control port) and tries each one, highest first, moving on when a connection fails or waits. + +**Framing.** Each message is `Length=XXXXXX ` (six hex digits and a space) followed by an XML +body. The client counts characters rather than bytes, which only matters for non-ASCII text. + +**Messages sent** + +```xml + + + + +``` + +**Messages handled** + +- ``: FC2 assigns the client an ID +- ``: the client parses the device's items and subscribes +- `` +- ``: `setValue` refuses to send until this is `true` +- ``: live value updates for subscribed items + +**Pairing.** There is no pairing code in the code. The client sends a fixed `client-key` +("like the original Focusrite Midi Control app", per a comment) and waits for FC2 to approve +it. The original UI told the user to approve the app in **FC2 → Settings → Remote Devices**, +where it appears as "FocusriteVolumeControl". Whether FC2 still offers that approval without a +paired phone is unknown. + +## What's missing + +- **Item IDs.** The client knows nothing about which item is the playback level, an input, or + Direct Monitor. The first prototype guessed "the first item whose value is between -70 and 6". +- **Keep-alive probably never fires.** The timer is created on the connection's callback queue, + which has no run loop. +- **No reconnect** when FC2 restarts. +- **`FCServerBackend` doesn't use the client** at all. + +## History + +All on January 26, 2026: + +1. `17e3ff7` "add stuff..." wired `FocusriteClient` into the app. +2. `b314269` "we have a working solution!!! telinta style" added only the `VolumeBackend` protocol. +3. `d36006c` "something worked?" added the AppleScript backend and removed every use of + `FocusriteClient`. + +The TCP route was dropped about five hours after it was added, in favour of UI scripting. That +suggests, but doesn't prove, that it never worked end to end. A later roadmap item still listed +"Focusrite Control Protocol/Server support". + +## Proposal: "Enable experimental remote protocol" + +Not implemented. A sketch of what a settings toggle and setup wizard would need, based on the +code above: + +1. **Toggle** in Preferences, off by default, labelled experimental. Turning it on opens the + wizard; turning it off switches back to the Accessibility backend. +2. **Wizard steps** + 1. *Check FC2 is running* (port discovery reads FC2's live sockets). + 2. *Connect*: show the discovered and fallback ports and which one answered; allow a manual + port as a fallback. + 3. *Approve*: tell the user to open FC2 → Settings → Remote Devices and approve + "FocusriteVolumeControl"; wait for ``, and explain what to do + on `false` or a timeout. + 4. *Map controls*: list the device's items and identify playback level, mute and Direct + Monitor, either automatically (by matching values the Accessibility backend reads) or by + asking the user to move a slider in FC2 and watching which item changes. + 5. *Test*: nudge the volume one step and confirm FC2 reflects it, then save the mapping. +3. **Before shipping it:** fix the keep-alive timer, add reconnect, implement `FCServerBackend` + on top of `FocusriteClient`, and add tests that feed recorded protocol traffic to the client. + +The first thing to do is a manual spike on a real Mac: run the client, approve it in FC2, and +log the items a Scarlett exposes. That answers whether this works at all before any UI is built. From dcbafb07c3e77781e898fd194da8701b7f83fed7 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 11:32:31 -0700 Subject: [PATCH 09/18] Add brew trust step to Homebrew install instructions Recent Homebrew only loads casks from third-party taps the user has trusted, so `brew install --cask` fails after a plain `brew tap`. Add `brew trust --tap enum-dot-dev/focusrite-volume-control` to the README, the site and the release notes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 1 + README.md | 3 +++ site/index.html | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5006b3e..6c64bb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -188,6 +188,7 @@ jobs: echo '**Homebrew:**' echo '```' echo 'brew tap enum-dot-dev/focusrite-volume-control' + echo 'brew trust --tap enum-dot-dev/focusrite-volume-control' echo 'brew install --cask focusrite-volume-control' echo '```' echo diff --git a/README.md b/README.md index e4b7a3f..25abaae 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,12 @@ A macOS menu bar app that lets you control your Focusrite Scarlett Solo volume w ```bash brew tap enum-dot-dev/focusrite-volume-control +brew trust --tap enum-dot-dev/focusrite-volume-control brew install --cask focusrite-volume-control ``` +Recent Homebrew versions only load casks from third-party taps you've trusted, so `brew trust` is needed once per Mac. If Homebrew says `trust` is an unknown command, your version predates it; skip that line. + ### Manual Download the latest DMG from [Releases](https://github.com/enum-dot-dev/focusrite-volume-control/releases/latest) and drag to Applications. diff --git a/site/index.html b/site/index.html index 54acdb6..d182ae1 100644 --- a/site/index.html +++ b/site/index.html @@ -91,6 +91,7 @@

Download

Homebrew

brew tap enum-dot-dev/focusrite-volume-control + brew trust --tap enum-dot-dev/focusrite-volume-control brew install --cask focusrite-volume-control
@@ -129,7 +130,7 @@

Install Focusrite Control 2

2

Install the app

-

Via Homebrew: brew tap enum-dot-dev/focusrite-volume-control && brew install --cask focusrite-volume-control
+

Via Homebrew: brew tap enum-dot-dev/focusrite-volume-control && brew trust --tap enum-dot-dev/focusrite-volume-control && brew install --cask focusrite-volume-control
Or download the DMG and drag to Applications.

From d3d783a08e15dfc3d7d7649329823ea966fda925 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 11:51:30 -0700 Subject: [PATCH 10/18] Link William Chan's shortcut postmortem from the README credit Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 25abaae..46822e3 100644 --- a/README.md +++ b/README.md @@ -76,4 +76,4 @@ xcodebuild -scheme "Focusrite Volume Control" -configuration Release \ ## Credits -The native Accessibility control path, rapid key-press handling and the shortcut recorder fix are ported from [William Chan's version](https://github.com/wc2184/focusrite-volume-control) (MIT). +The native Accessibility control path, rapid key-press handling and the shortcut recorder fix are ported from [William Chan's version](https://github.com/wc2184/focusrite-volume-control) (MIT). His write-up of the shortcut bug is in [docs/shortcut-bug-postmortem.md](docs/shortcut-bug-postmortem.md). From bfa475111fc9589843699ed8475c0acf170ceb32 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 11:52:55 -0700 Subject: [PATCH 11/18] Install via the fully qualified cask name Homebrew 6.0+ only loads casks from trusted third-party taps. Installing by the fully qualified name taps the repository and trusts only this cask, which Homebrew recommends over trusting the whole tap, and works on older Homebrew too. Replaces the separate tap/trust/install steps in the README, the site and the release notes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 4 +--- README.md | 6 ++---- site/index.html | 6 ++---- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c64bb3..2edd360 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,9 +187,7 @@ jobs: { echo '**Homebrew:**' echo '```' - echo 'brew tap enum-dot-dev/focusrite-volume-control' - echo 'brew trust --tap enum-dot-dev/focusrite-volume-control' - echo 'brew install --cask focusrite-volume-control' + echo 'brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control' echo '```' echo echo "**DMG SHA256:** \`$DMG_SHA256\`" diff --git a/README.md b/README.md index 46822e3..d1fcfb9 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,10 @@ A macOS menu bar app that lets you control your Focusrite Scarlett Solo volume w ### Homebrew ```bash -brew tap enum-dot-dev/focusrite-volume-control -brew trust --tap enum-dot-dev/focusrite-volume-control -brew install --cask focusrite-volume-control +brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control ``` -Recent Homebrew versions only load casks from third-party taps you've trusted, so `brew trust` is needed once per Mac. If Homebrew says `trust` is an unknown command, your version predates it; skip that line. +Homebrew 6.0 and later only load casks from third-party taps you've trusted. Installing by the full name, as above, adds the tap and trusts just this cask. If you'd rather tap first and install by the short name, run `brew trust --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control` before `brew install --cask focusrite-volume-control`. ### Manual diff --git a/site/index.html b/site/index.html index d182ae1..ad75e56 100644 --- a/site/index.html +++ b/site/index.html @@ -90,9 +90,7 @@

Download

Homebrew

- brew tap enum-dot-dev/focusrite-volume-control - brew trust --tap enum-dot-dev/focusrite-volume-control - brew install --cask focusrite-volume-control + brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control
@@ -130,7 +128,7 @@

Install Focusrite Control 2

2

Install the app

-

Via Homebrew: brew tap enum-dot-dev/focusrite-volume-control && brew trust --tap enum-dot-dev/focusrite-volume-control && brew install --cask focusrite-volume-control
+

Via Homebrew: brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control
Or download the DMG and drag to Applications.

From 61756d49146e1097dcf17d82721df24abe8f815b Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 13:28:24 -0700 Subject: [PATCH 12/18] CI: report test failures and crash logs Parallel test runners only print "failed (0.000 seconds)" when the test host crashes. Write a result bundle, print failure messages and recent crash reports on failure, and upload the bundle as an artifact. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebff2fb..0ec4e2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,5 +40,35 @@ jobs: - name: Test run: > xcodebuild test -scheme "Focusrite Volume Control" -configuration Debug + -resultBundlePath build/TestResults.xcresult MACOSX_DEPLOYMENT_TARGET=14.5 CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO + + # Parallel test runners don't print why a test host crashed; surface it here + - name: Show test failures and crash reports + if: failure() + run: | + if [ -d build/TestResults.xcresult ]; then + xcrun xcresulttool get test-results tests --path build/TestResults.xcresult \ + | python3 -c 'import json,sys + def walk(n, path=""): + name = n.get("name", "") + if n.get("nodeType") == "Failure Message": + print(f"{path}: {name}") + for c in n.get("children", []): + walk(c, name if n.get("nodeType") == "Test Case" else path) + for t in json.load(sys.stdin).get("testNodes", []): + walk(t)' || true + fi + for f in $(find ~/Library/Logs/DiagnosticReports -name 'Focusrite*' -mmin -60 2>/dev/null | head -3); do + echo "=== $f" + head -120 "$f" + done + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: build/TestResults.xcresult + retention-days: 7 From 64ba7198eca84338ce51e53b88c02dfc1dbe2d23 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 13:33:07 -0700 Subject: [PATCH 13/18] Run shortcut recorder tests on the main actor Three ported tests aborted when ShortcutManager / HotkeyManager were torn down: under this project's default MainActor isolation they are main-actor types, but only some test methods were @MainActor. Mark the whole class @MainActor, matching the other test classes. Co-Authored-By: Claude Opus 5.5 --- Focusrite Volume ControlTests/ShortcutRecorderTests.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Focusrite Volume ControlTests/ShortcutRecorderTests.swift b/Focusrite Volume ControlTests/ShortcutRecorderTests.swift index c96f2ba..ec08c9c 100644 --- a/Focusrite Volume ControlTests/ShortcutRecorderTests.swift +++ b/Focusrite Volume ControlTests/ShortcutRecorderTests.swift @@ -12,6 +12,9 @@ import Carbon.HIToolbox import class SwiftUI.NSHostingView @testable import Focusrite_Volume_Control +// Main-actor like the other test classes: ShortcutManager and HotkeyManager are main-actor +// types (default isolation), and tearing them down outside the main actor aborts +@MainActor final class ShortcutRecorderTests: XCTestCase { func testNativeF12AndLeftArrowAreDistinctAndOnlyF12IsAllowedBare() { let left = KeyboardShortcut(keyCode: UInt16(kVK_LeftArrow), modifiers: []) @@ -61,7 +64,6 @@ final class ShortcutRecorderTests: XCTestCase { XCTAssertEqual(manager.shortcut(for: .volumeUp), ShortcutAction.volumeUp.defaultShortcut) } - @MainActor func testRecorderCancellationReleasesFocusAndCannotCaptureAnotherKey() { let recorder = ShortcutRecorderNSView() var captures: [KeyboardShortcut] = [] @@ -81,7 +83,6 @@ final class ShortcutRecorderTests: XCTestCase { XCTAssertTrue(captures.isEmpty) } - @MainActor func testRecorderCompletesExactlyOnceAndIgnoresInactiveAndRepeatedKeys() { let recorder = ShortcutRecorderNSView() var captures: [KeyboardShortcut] = [] @@ -97,7 +98,6 @@ final class ShortcutRecorderTests: XCTestCase { XCTAssertEqual(captures.map(\.keyCode), [UInt16(kVK_F12)]) } - @MainActor func testRecorderLosingFocusCancelsRecording() { let recorder = ShortcutRecorderNSView() var cancellations = 0 @@ -112,7 +112,6 @@ final class ShortcutRecorderTests: XCTestCase { XCTAssertEqual(cancellations, 1) } - @MainActor func testSwiftUIRecorderBindingCancelsAndCompletesWithoutLaterAssignments() throws { let shortcuts = ShortcutManager(preferences: nil) let host = NSHostingView(rootView: ShortcutRecorderView(action: .volumeUp, shortcutManager: shortcuts)) @@ -145,7 +144,6 @@ final class ShortcutRecorderTests: XCTestCase { XCTAssertEqual(shortcuts.shortcut(for: .volumeUp)?.keyCode, UInt16(kVK_F12)) } - @MainActor func testHotkeysUpdateImmediatelyPauseForRecordingAndPassOrdinaryArrows() { let shortcuts = ShortcutManager(preferences: nil) let hotkeys = HotkeyManager(shortcutManager: shortcuts) From 5ddf7124ff044ab3e3c3d752d3e9d8b4e9c81dbf Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 13:37:46 -0700 Subject: [PATCH 14/18] CI: print crash reasons from .ips reports Crash report names contain spaces, which the previous loop split into words, so no report was printed. Read them NUL-separated and print the termination reason, application-specific info and crashing frames. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ec4e2f..d1e0302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,10 +60,22 @@ jobs: for t in json.load(sys.stdin).get("testNodes", []): walk(t)' || true fi - for f in $(find ~/Library/Logs/DiagnosticReports -name 'Focusrite*' -mmin -60 2>/dev/null | head -3); do - echo "=== $f" - head -120 "$f" - done + # Report names contain spaces ("Focusrite Volume Control-.ips") + find ~/Library/Logs/DiagnosticReports -name 'Focusrite*' -mmin -60 -print0 2>/dev/null | + while IFS= read -r -d '' f; do + echo "=== $f" + # .ips files are one JSON header line plus a JSON body; print the abort reason and crashing frames + tail -n +2 "$f" | python3 -c 'import json,sys + r = json.load(sys.stdin) + print("termination:", r.get("termination", {}).get("indicator")) + print("asi:", json.dumps(r.get("asi"), indent=1)) + t = next((t for t in r.get("threads", []) if t.get("triggered")), None) + imgs = r.get("usedImages", []) + for fr in (t or {}).get("frames", [])[:25]: + img = imgs[fr["imageIndex"]].get("name", "?") if fr.get("imageIndex") is not None and fr["imageIndex"] < len(imgs) else "?" + sym = fr.get("symbol") or hex(fr.get("imageOffset", 0)) + print(" " + img + ": " + sym)' || head -60 "$f" + done - name: Upload test results if: failure() From 61f99766607299e1731626906fa69ab01a4422cb Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 13:42:51 -0700 Subject: [PATCH 15/18] Make ShortcutManager and HotkeyManager deinits nonisolated Crash reports from CI show the shortcut tests aborting in ShortcutManager.__deallocating_deinit -> swift_task_deinitOnExecutorMainActorBackDeploy -> TaskLocal StopLookupScope ("pointer being freed was not allocated"). Default MainActor isolation makes the implicit deinit isolated, and below macOS 26 it goes through the back-deployment shim, which aborts when the object is freed outside a task. Neither deinit needs the main actor, so declare them nonisolated. Co-Authored-By: Claude Opus 5.5 --- Focusrite Volume Control/HotkeyManager.swift | 7 +++++-- Focusrite Volume Control/KeyboardShortcuts.swift | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Focusrite Volume Control/HotkeyManager.swift b/Focusrite Volume Control/HotkeyManager.swift index 3adcbfc..8fb5f1c 100644 --- a/Focusrite Volume Control/HotkeyManager.swift +++ b/Focusrite Volume Control/HotkeyManager.swift @@ -123,7 +123,10 @@ class HotkeyManager { } } - deinit { - removeMonitors() + // Nonisolated for the same reason as ShortcutManager's deinit (isolated deinits abort below + // macOS 26 when freed outside a task); removing NSEvent monitors doesn't need the main actor + nonisolated deinit { + if let globalMonitor { NSEvent.removeMonitor(globalMonitor) } + if let localMonitor { NSEvent.removeMonitor(localMonitor) } } } diff --git a/Focusrite Volume Control/KeyboardShortcuts.swift b/Focusrite Volume Control/KeyboardShortcuts.swift index 062630e..f9d28ed 100644 --- a/Focusrite Volume Control/KeyboardShortcuts.swift +++ b/Focusrite Volume Control/KeyboardShortcuts.swift @@ -216,6 +216,11 @@ class ShortcutManager: ObservableObject { loadShortcuts() } + // Main-actor default isolation makes the implicit deinit isolated. Below macOS 26 that runs + // through Swift's back-deployment shim, which aborts ("pointer being freed was not allocated") + // when the object is freed outside a task. Nothing here needs the main actor. + nonisolated deinit {} + func loadShortcuts() { if let data = preferences?.data(forKey: userDefaultsKey), let decoded = try? JSONDecoder().decode([String: KeyboardShortcut].self, from: data) { From 1371cf41e97404b7e2ec3b65a2f5ab0ef838ecb7 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 14:18:12 -0700 Subject: [PATCH 16/18] Use enum.dev URLs for the appcast and cask homepage enum.dev is now the org's GitHub Pages custom domain, so the project site's canonical URL is https://enum.dev/focusrite-volume-control/ and the github.io URLs 301 there. Point SUFeedURL, the appcast and the cask homepage at enum.dev so nothing depends on the redirect. Co-Authored-By: Claude Opus 5.5 --- Focusrite-Volume-Control-Info.plist | 2 +- homebrew/Casks/focusrite-volume-control.rb | 2 +- site/appcast.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Focusrite-Volume-Control-Info.plist b/Focusrite-Volume-Control-Info.plist index d4e4860..acab772 100644 --- a/Focusrite-Volume-Control-Info.plist +++ b/Focusrite-Volume-Control-Info.plist @@ -8,7 +8,7 @@ SUFeedURL - https://enum-dot-dev.github.io/focusrite-volume-control/appcast.xml + https://enum.dev/focusrite-volume-control/appcast.xml SUPublicEDKey diff --git a/homebrew/Casks/focusrite-volume-control.rb b/homebrew/Casks/focusrite-volume-control.rb index c4870a0..13d5ffb 100644 --- a/homebrew/Casks/focusrite-volume-control.rb +++ b/homebrew/Casks/focusrite-volume-control.rb @@ -7,7 +7,7 @@ url "https://github.com/enum-dot-dev/focusrite-volume-control/releases/download/v#{version}/FocusriteVolumeControl.dmg" name "Focusrite Volume Control" desc "Control Focusrite Scarlett 4th gen volume with media keys via Focusrite Control 2" - homepage "https://enum-dot-dev.github.io/focusrite-volume-control/" + homepage "https://enum.dev/focusrite-volume-control/" depends_on macos: ">= :sonoma" diff --git a/site/appcast.xml b/site/appcast.xml index 5728e13..dcf6e7f 100644 --- a/site/appcast.xml +++ b/site/appcast.xml @@ -2,7 +2,7 @@ Focusrite Volume Control - https://enum-dot-dev.github.io/focusrite-volume-control/appcast.xml + https://enum.dev/focusrite-volume-control/appcast.xml Updates for Focusrite Volume Control. Maintained by the release workflow. en From 75da1928d4d8badffbde0dc1a1fb89fb9cc7695f Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 15:56:12 -0700 Subject: [PATCH 17/18] Rename the app to "Volume Control" (display name only) User-facing rename. The bundle becomes "Volume Control.app" and the DMG VolumeControl.dmg; Focusrite stays only as compatibility wording. - PRODUCT_NAME "Volume Control", CFBundleDisplayName "Volume Control"; PRODUCT_MODULE_NAME pinned to Focusrite_Volume_Control so tests and imports are unaffected; test host path and scheme BuildableName updated - Popover title, About menu item and panel, onboarding title - build-dmg.sh, release.yml (separate SCHEME and APP_NAME), cask name, app stanza and DMG URL, render_cask.py postflight path, CI crash-report pattern (reports are named after the executable) - README title, site title/h1/footer, appcast title Unchanged: bundle ID solutions.enum.FocusriteVolumeControl (keeps the Accessibility grant and preferences), repo, enum.dev URL path, Sparkle feed, cask token, tap repo, scheme and target names, the tccutil caveat. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 11 ++++++----- .../project.pbxproj | 18 +++++++++++------- .../Focusrite Volume Control.xcscheme | 6 +++--- Focusrite Volume Control/AppDelegate.swift | 4 ++-- Focusrite Volume Control/OnboardingView.swift | 2 +- Focusrite Volume Control/PopoverView.swift | 2 +- README.md | 2 +- homebrew/Casks/focusrite-volume-control.rb | 6 +++--- scripts/build-dmg.sh | 12 ++++++------ scripts/generate-icon.py | 2 +- scripts/render_cask.py | 2 +- site/appcast.xml | 4 ++-- site/index.html | 6 +++--- 14 files changed, 43 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1e0302..aafb37c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,8 +60,8 @@ jobs: for t in json.load(sys.stdin).get("testNodes", []): walk(t)' || true fi - # Report names contain spaces ("Focusrite Volume Control-.ips") - find ~/Library/Logs/DiagnosticReports -name 'Focusrite*' -mmin -60 -print0 2>/dev/null | + # Report names contain spaces ("Volume Control-.ips") + find ~/Library/Logs/DiagnosticReports -name 'Volume Control*' -mmin -60 -print0 2>/dev/null | while IFS= read -r -d '' f; do echo "=== $f" # .ips files are one JSON header line plus a JSON body; print the abort reason and crashing frames diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2edd360..6d9bd8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,8 +12,9 @@ jobs: contents: write # release upload, appcast commit actions: write # trigger the Pages deploy after the appcast changes env: - APP_NAME: Focusrite Volume Control - DMG_PATH: build/FocusriteVolumeControl.dmg + SCHEME: Focusrite Volume Control # Xcode scheme/target name (unchanged) + APP_NAME: Volume Control # built product: "Volume Control.app" + DMG_PATH: build/VolumeControl.dmg steps: - uses: actions/checkout@v4 @@ -118,7 +119,7 @@ jobs: APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID || secrets.APPLE_TEAM_ID }} run: | xcodebuild archive \ - -scheme "$APP_NAME" -configuration Release \ + -scheme "$SCHEME" -configuration Release \ -archivePath build/App.xcarchive \ MACOSX_DEPLOYMENT_TARGET=14.5 \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$VERSION" \ @@ -155,7 +156,7 @@ jobs: - name: Build unsigned app if: steps.signing.outputs.enabled != 'true' run: | - xcodebuild -scheme "$APP_NAME" -configuration Release \ + xcodebuild -scheme "$SCHEME" -configuration Release \ -derivedDataPath build/DerivedData \ MACOSX_DEPLOYMENT_TARGET=14.5 \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$VERSION" \ @@ -216,7 +217,7 @@ jobs: mkdir -p "$RUNNER_TEMP/sparkle" && tar -xJf "$RUNNER_TEMP/sparkle.tar.xz" -C "$RUNNER_TEMP/sparkle" SIGNATURE=$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | "$RUNNER_TEMP/sparkle/bin/sign_update" --ed-key-file - -p "$DMG_PATH") LENGTH=$(stat -f%z "$DMG_PATH") - RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/FocusriteVolumeControl.dmg" + RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/VolumeControl.dmg" # The tag checkout is detached; commit the appcast on top of main git fetch origin main diff --git a/Focusrite Volume Control.xcodeproj/project.pbxproj b/Focusrite Volume Control.xcodeproj/project.pbxproj index da7c38c..9b0e4c0 100644 --- a/Focusrite Volume Control.xcodeproj/project.pbxproj +++ b/Focusrite Volume Control.xcodeproj/project.pbxproj @@ -21,7 +21,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 01A89D572F280BA90084DBD8 /* Focusrite Volume Control.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Focusrite Volume Control.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 01A89D572F280BA90084DBD8 /* Volume Control.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Volume Control.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 01A89D652F280BA90084DBD8 /* Focusrite Volume ControlTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Focusrite Volume ControlTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -69,7 +69,7 @@ 01A89D582F280BA90084DBD8 /* Products */ = { isa = PBXGroup; children = ( - 01A89D572F280BA90084DBD8 /* Focusrite Volume Control.app */, + 01A89D572F280BA90084DBD8 /* Volume Control.app */, 01A89D652F280BA90084DBD8 /* Focusrite Volume ControlTests.xctest */, ); name = Products; @@ -98,7 +98,7 @@ 01A89DA22F2A00000084DBD8 /* Sparkle */, ); productName = "Focusrite Volume Control"; - productReference = 01A89D572F280BA90084DBD8 /* Focusrite Volume Control.app */; + productReference = 01A89D572F280BA90084DBD8 /* Volume Control.app */; productType = "com.apple.product-type.application"; }; 01A89D6A2F280BA90084DBD8 /* Focusrite Volume ControlTests */ = { @@ -343,6 +343,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Focusrite-Volume-Control-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Volume Control"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -351,7 +352,8 @@ ); MARKETING_VERSION = 1.4.5; PRODUCT_BUNDLE_IDENTIFIER = solutions.enum.FocusriteVolumeControl; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_MODULE_NAME = Focusrite_Volume_Control; + PRODUCT_NAME = "Volume Control"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -376,6 +378,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Focusrite-Volume-Control-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Volume Control"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -384,7 +387,8 @@ ); MARKETING_VERSION = 1.4.5; PRODUCT_BUNDLE_IDENTIFIER = solutions.enum.FocusriteVolumeControl; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_MODULE_NAME = Focusrite_Volume_Control; + PRODUCT_NAME = "Volume Control"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -412,7 +416,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Focusrite Volume Control.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Focusrite Volume Control"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Volume Control.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Volume Control"; }; name = Debug; }; @@ -433,7 +437,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Focusrite Volume Control.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Focusrite Volume Control"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Volume Control.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Volume Control"; }; name = Release; }; diff --git a/Focusrite Volume Control.xcodeproj/xcshareddata/xcschemes/Focusrite Volume Control.xcscheme b/Focusrite Volume Control.xcodeproj/xcshareddata/xcschemes/Focusrite Volume Control.xcscheme index 3fbe009..4f9ea86 100644 --- a/Focusrite Volume Control.xcodeproj/xcshareddata/xcschemes/Focusrite Volume Control.xcscheme +++ b/Focusrite Volume Control.xcodeproj/xcshareddata/xcschemes/Focusrite Volume Control.xcscheme @@ -16,7 +16,7 @@ @@ -58,7 +58,7 @@ @@ -75,7 +75,7 @@ diff --git a/Focusrite Volume Control/AppDelegate.swift b/Focusrite Volume Control/AppDelegate.swift index 1c28d8d..e458e3e 100644 --- a/Focusrite Volume Control/AppDelegate.swift +++ b/Focusrite Volume Control/AppDelegate.swift @@ -213,7 +213,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { statusMenu = NSMenu() // About - let aboutItem = NSMenuItem(title: "About Focusrite Volume Control", action: #selector(showAbout), keyEquivalent: "") + let aboutItem = NSMenuItem(title: "About Volume Control", action: #selector(showAbout), keyEquivalent: "") aboutItem.target = self statusMenu.addItem(aboutItem) @@ -283,7 +283,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func showAbout() { let alert = NSAlert() - alert.messageText = "Focusrite Volume Control" + alert.messageText = "Volume Control" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" alert.informativeText = "Control your Focusrite Scarlett Solo volume with system media keys.\n\nVersion \(version) (\(build))\n\n\u{00A9} 2026" diff --git a/Focusrite Volume Control/OnboardingView.swift b/Focusrite Volume Control/OnboardingView.swift index c4f41b7..989abda 100644 --- a/Focusrite Volume Control/OnboardingView.swift +++ b/Focusrite Volume Control/OnboardingView.swift @@ -39,7 +39,7 @@ struct OnboardingView: View { .font(.system(size: 48)) .foregroundStyle(.blue) - Text("Focusrite Volume Control Setup") + Text("Volume Control Setup") .font(.title2) .fontWeight(.semibold) diff --git a/Focusrite Volume Control/PopoverView.swift b/Focusrite Volume Control/PopoverView.swift index 23bb9dd..e0f3610 100644 --- a/Focusrite Volume Control/PopoverView.swift +++ b/Focusrite Volume Control/PopoverView.swift @@ -50,7 +50,7 @@ struct HeaderView: View { .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 2) { - Text("Focusrite Volume Control") + Text("Volume Control") .font(.headline) Text(volumeController.statusMessage) diff --git a/README.md b/README.md index d1fcfb9..1aa1253 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Focusrite Volume Control +# Volume Control A macOS menu bar app that lets you control your Focusrite Scarlett Solo volume with your keyboard's media keys and custom shortcuts. diff --git a/homebrew/Casks/focusrite-volume-control.rb b/homebrew/Casks/focusrite-volume-control.rb index 13d5ffb..f07ca18 100644 --- a/homebrew/Casks/focusrite-volume-control.rb +++ b/homebrew/Casks/focusrite-volume-control.rb @@ -4,14 +4,14 @@ version "1.5.1" sha256 :no_check - url "https://github.com/enum-dot-dev/focusrite-volume-control/releases/download/v#{version}/FocusriteVolumeControl.dmg" - name "Focusrite Volume Control" + url "https://github.com/enum-dot-dev/focusrite-volume-control/releases/download/v#{version}/VolumeControl.dmg" + name "Volume Control" desc "Control Focusrite Scarlett 4th gen volume with media keys via Focusrite Control 2" homepage "https://enum.dev/focusrite-volume-control/" depends_on macos: ">= :sonoma" - app "Focusrite Volume Control.app" + app "Volume Control.app" caveats <<~EOS If media keys stop working after an update, reset the accessibility permission: diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index fc212bd..c4c1620 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -1,16 +1,16 @@ #!/bin/bash # -# build-dmg.sh - Package Focusrite Volume Control into a DMG, optionally signed and notarized +# build-dmg.sh - Package Volume Control into a DMG, optionally signed and notarized # # Usage: # bash scripts/build-dmg.sh # build with project signing settings, then package # ADHOC=1 bash scripts/build-dmg.sh # force ad-hoc signing -# APP_PATH=build/export/"Focusrite Volume Control.app" bash scripts/build-dmg.sh +# APP_PATH=build/export/"Volume Control.app" bash scripts/build-dmg.sh # # package an already built (and signed) app # # Optional environment variables: # APP_PATH - prebuilt .app to package instead of building one -# DMG_PATH - output path (default: build/Focusrite Volume Control.dmg) +# DMG_PATH - output path (default: build/VolumeControl.dmg) # SIGN_IDENTITY - codesign identity for the DMG, e.g. "Developer ID Application: Name (TEAMID)" # KEYCHAIN - keychain holding SIGN_IDENTITY (default: search list) # @@ -29,9 +29,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" BUILD_DIR="$PROJECT_DIR/build" SCHEME="Focusrite Volume Control" -APP_NAME="Focusrite Volume Control" -DMG_VOLUME_NAME="Focusrite Volume Control" -DMG_FINAL="${DMG_PATH:-$BUILD_DIR/$APP_NAME.dmg}" +APP_NAME="Volume Control" +DMG_VOLUME_NAME="Volume Control" +DMG_FINAL="${DMG_PATH:-$BUILD_DIR/VolumeControl.dmg}" mkdir -p "$BUILD_DIR" diff --git a/scripts/generate-icon.py b/scripts/generate-icon.py index 32360cb..de2c059 100644 --- a/scripts/generate-icon.py +++ b/scripts/generate-icon.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Generate app icon for Focusrite Volume Control. +Generate app icon for Volume Control. Creates a 1024x1024 master icon and all required sizes for AppIcon.appiconset. Uses only Python standard library + macOS sips for resizing. """ diff --git a/scripts/render_cask.py b/scripts/render_cask.py index ae0f55b..b6f3c72 100644 --- a/scripts/render_cask.py +++ b/scripts/render_cask.py @@ -17,7 +17,7 @@ # Unsigned build: clear quarantine so Gatekeeper doesn't block the app postflight do system_command "/usr/bin/xattr", - args: ["-cr", "#{appdir}/Focusrite Volume Control.app"] + args: ["-cr", "#{appdir}/Volume Control.app"] end ''' diff --git a/site/appcast.xml b/site/appcast.xml index dcf6e7f..a4939f0 100644 --- a/site/appcast.xml +++ b/site/appcast.xml @@ -1,9 +1,9 @@ - Focusrite Volume Control + Volume Control https://enum.dev/focusrite-volume-control/appcast.xml - Updates for Focusrite Volume Control. Maintained by the release workflow. + Updates for Volume Control. Maintained by the release workflow. en diff --git a/site/index.html b/site/index.html index ad75e56..f852dfe 100644 --- a/site/index.html +++ b/site/index.html @@ -3,7 +3,7 @@ - Focusrite Volume Control — Control your Focusrite with keyboard shortcuts + Volume Control — media keys for Focusrite Scarlett interfaces @@ -13,7 +13,7 @@
-

Focusrite Volume Control

+

Volume Control

Control your Focusrite Scarlett Solo volume with your Mac's media keys and custom shortcuts.

@@ -161,7 +161,7 @@

Use your volume keys

Releases ☕ Buy me a coffee
-

Focusrite Volume Control is open source software. Not affiliated with Focusrite plc.

+

Volume Control is open source software. Works with Focusrite Scarlett interfaces via Focusrite Control 2; not affiliated with Focusrite plc.

From 8089e482853f8a3005671cb8649904f5a8cf7564 Mon Sep 17 00:00:00 2001 From: Nick Morozov Date: Tue, 13 Oct 2026 16:01:59 -0700 Subject: [PATCH 18/18] Release: prerelease path for test builds Tags containing a hyphen (v1.6.0-beta.1) create a GitHub prerelease with VolumeControl.dmg and tester instructions (Gatekeeper bypass when unsigned, Accessibility permission, FC2 running), are not marked as the latest release, and skip the Homebrew tap update and the Sparkle appcast. Bundle versions use the numeric part of the tag, since CFBundleVersion must be dot-separated numbers. vX.Y.Z tags behave as before. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 66 +++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d9bd8b..b549103 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,9 +28,23 @@ jobs: sudo xcode-select -s "$XCODE" xcodebuild -version - # Sparkle compares CFBundleVersion, so stamp both version keys from the tag + # Sparkle compares CFBundleVersion, so stamp both version keys from the tag. + # A hyphen marks a prerelease (v1.6.0-beta.1): a GitHub prerelease for testers, + # with no Homebrew tap update and no Sparkle appcast entry. - name: Read version from tag - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + id: version + run: | + VERSION="${GITHUB_REF_NAME#v}" + # Bundle versions must be dot-separated numbers, so drop the prerelease suffix there + BUNDLE_VERSION="${VERSION%%-*}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "BUNDLE_VERSION=$BUNDLE_VERSION" >> "$GITHUB_ENV" + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + echo "::notice title=Prerelease::$GITHUB_REF_NAME is a prerelease: no Homebrew tap update, no Sparkle appcast entry" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi # Signing and notarization run only when every secret below is set. Until then the # release is built unsigned, as before, and the Homebrew cask clears quarantine. @@ -63,10 +77,14 @@ jobs: id: sparkle env: SIGNED: ${{ steps.signing.outputs.enabled }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | PUBLIC_KEY=$(/usr/libexec/PlistBuddy -c "Print :SUPublicEDKey" Focusrite-Volume-Control-Info.plist) - if [ "$SIGNED" != "true" ]; then + if [ "$PRERELEASE" = "true" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Prerelease: not published to the Sparkle appcast" + elif [ "$SIGNED" != "true" ]; then echo "enabled=false" >> "$GITHUB_OUTPUT" echo "::warning title=Appcast not updated::Unsigned builds are not published to the Sparkle appcast." elif [ -z "$SPARKLE_ED_PRIVATE_KEY" ] || [[ "$PUBLIC_KEY" == REPLACE_* ]]; then @@ -122,7 +140,7 @@ jobs: -scheme "$SCHEME" -configuration Release \ -archivePath build/App.xcarchive \ MACOSX_DEPLOYMENT_TARGET=14.5 \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$VERSION" \ + MARKETING_VERSION="$BUNDLE_VERSION" CURRENT_PROJECT_VERSION="$BUNDLE_VERSION" \ DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ @@ -159,7 +177,7 @@ jobs: xcodebuild -scheme "$SCHEME" -configuration Release \ -derivedDataPath build/DerivedData \ MACOSX_DEPLOYMENT_TARGET=14.5 \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$VERSION" \ + MARKETING_VERSION="$BUNDLE_VERSION" CURRENT_PROJECT_VERSION="$BUNDLE_VERSION" \ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \ build echo "APP_PATH=build/DerivedData/Build/Products/Release/$APP_NAME.app" >> "$GITHUB_ENV" @@ -183,19 +201,36 @@ jobs: - name: Write release notes env: SIGNED: ${{ steps.signing.outputs.enabled }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} DMG_SHA256: ${{ steps.sha.outputs.sha256 }} run: | { - echo '**Homebrew:**' - echo '```' - echo 'brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control' - echo '```' - echo - echo "**DMG SHA256:** \`$DMG_SHA256\`" - if [ "$SIGNED" != "true" ]; then + if [ "$PRERELEASE" = "true" ]; then + echo '**Test build for hardware testing.** Homebrew and in-app updates do not offer it.' + echo + echo '1. Download `VolumeControl.dmg` and drag **Volume Control** to Applications.' + if [ "$SIGNED" != "true" ]; then + echo '2. This build is not signed or notarized. On first launch, right-click the app and choose **Open**, or run:' + echo ' `xattr -dr com.apple.quarantine "/Applications/Volume Control.app"`' + else + echo '2. This build is signed and notarized; open it normally.' + fi + echo '3. Grant the **Accessibility** permission when prompted (System Settings → Privacy & Security → Accessibility).' + echo '4. **Focusrite Control 2** must be installed and running.' echo - echo '> This build is not signed or notarized. On first launch, right-click the app and choose Open.' + echo "Please report results in [Issues](https://github.com/${GITHUB_REPOSITORY}/issues) with your interface model and FC2 version." + else + echo '**Homebrew:**' + echo '```' + echo 'brew install --cask enum-dot-dev/focusrite-volume-control/focusrite-volume-control' + echo '```' + if [ "$SIGNED" != "true" ]; then + echo + echo '> This build is not signed or notarized. On first launch, right-click the app and choose Open.' + fi fi + echo + echo "**DMG SHA256:** \`$DMG_SHA256\`" } > build/release-notes.md - name: Create GitHub Release @@ -204,9 +239,11 @@ jobs: files: ${{ env.DMG_PATH }} generate_release_notes: true body_path: build/release-notes.md + prerelease: ${{ steps.version.outputs.prerelease == 'true' }} + make_latest: ${{ steps.version.outputs.prerelease == 'true' && 'false' || 'true' }} - name: Publish Sparkle appcast - if: steps.sparkle.outputs.enabled == 'true' + if: steps.sparkle.outputs.enabled == 'true' && steps.version.outputs.prerelease != 'true' env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} GH_TOKEN: ${{ github.token }} @@ -236,6 +273,7 @@ jobs: gh workflow run pages.yml --ref main - name: Update Homebrew tap + if: steps.version.outputs.prerelease != 'true' env: GH_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} DMG_SHA256: ${{ steps.sha.outputs.sha256 }}