From 840a97c93f8fe65fc74a0d25b3528f5685266f02 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:06:40 +0200 Subject: [PATCH 01/28] chore: scaffold packages/bitbardd workspace package --- bun.lock | 5 +++++ packages/bitbardd/build.mjs | 18 ++++++++++++++++++ packages/bitbardd/package.json | 7 +++++++ 3 files changed, 30 insertions(+) create mode 100644 packages/bitbardd/build.mjs create mode 100644 packages/bitbardd/package.json diff --git a/bun.lock b/bun.lock index ac0a02f..602467c 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,9 @@ "typescript": "catalog:", }, }, + "packages/bitbardd": { + "name": "@bitbard/bitbardd", + }, "packages/cli": { "name": "@bitbard/cli", "bin": { @@ -73,6 +76,8 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@bitbard/bitbardd": ["@bitbard/bitbardd@workspace:packages/bitbardd"], + "@bitbard/cli": ["@bitbard/cli@workspace:packages/cli"], "@bitbard/core": ["@bitbard/core@workspace:packages/core"], diff --git a/packages/bitbardd/build.mjs b/packages/bitbardd/build.mjs new file mode 100644 index 0000000..ac166e4 --- /dev/null +++ b/packages/bitbardd/build.mjs @@ -0,0 +1,18 @@ +import { execSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +if (process.platform !== 'darwin') { + console.log('Skipping bitbardd Swift build on non-darwin platform.'); + process.exit(0); +} + +mkdirSync('dist', { recursive: true }); + +const sourcesDir = resolve(__dirname, 'Sources'); +execSync(`swiftc "${sourcesDir}"/*.swift -o dist/bitbardd -O -framework AppKit -framework CoreGraphics`, { + stdio: 'inherit', +}); diff --git a/packages/bitbardd/package.json b/packages/bitbardd/package.json new file mode 100644 index 0000000..9a41b9b --- /dev/null +++ b/packages/bitbardd/package.json @@ -0,0 +1,7 @@ +{ + "name": "@bitbard/bitbardd", + "private": true, + "scripts": { + "build": "node build.mjs" + } +} From 3ac92440007a378ccbb596a0cbc84c0720c8913a Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:07:29 +0200 Subject: [PATCH 02/28] feat(bitbardd): add Protocol.swift with Codable request/response types --- packages/bitbardd/Sources/Protocol.swift | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/bitbardd/Sources/Protocol.swift diff --git a/packages/bitbardd/Sources/Protocol.swift b/packages/bitbardd/Sources/Protocol.swift new file mode 100644 index 0000000..596836a --- /dev/null +++ b/packages/bitbardd/Sources/Protocol.swift @@ -0,0 +1,35 @@ +import Foundation + +// MARK: - Request + +enum DaemonCommand: String, Codable { + case flux + case truetone + case lock + case alert +} + +struct DaemonRequest: Codable { + let command: DaemonCommand + // flux / truetone + let action: String? + // alert + let message: String? + let duration: Double? +} + +// MARK: - Response + +struct DaemonResponse: Codable { + let ok: Bool + let result: String? + let error: String? + + static func success(_ result: String) -> DaemonResponse { + DaemonResponse(ok: true, result: result, error: nil) + } + + static func failure(_ error: String) -> DaemonResponse { + DaemonResponse(ok: false, result: nil, error: error) + } +} From 07876e8ac70797cb35c46398309472e90517cc3f Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:08:05 +0200 Subject: [PATCH 03/28] feat(bitbardd): add FluxHandler.swift --- packages/bitbardd/Sources/FluxHandler.swift | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/bitbardd/Sources/FluxHandler.swift diff --git a/packages/bitbardd/Sources/FluxHandler.swift b/packages/bitbardd/Sources/FluxHandler.swift new file mode 100644 index 0000000..8626d5f --- /dev/null +++ b/packages/bitbardd/Sources/FluxHandler.swift @@ -0,0 +1,60 @@ +import CoreGraphics +import Foundation + +class FluxHandler { + private var isEnabled = false + + func handle(action: String?) -> DaemonResponse { + switch action { + case "enable": + enable() + return .success("enabled") + case "disable": + disable() + return .success("disabled") + case "toggle": + if isEnabled { + disable() + return .success("disabled") + } else { + enable() + return .success("enabled") + } + case "status": + return .success(isEnabled ? "enabled" : "disabled") + default: + return .failure("flux: unknown action '\(action ?? "nil")'. Use enable, disable, toggle, or status.") + } + } + + // MARK: - Private + + private func enable() { + guard !isEnabled else { return } + var displayCount: UInt32 = 0 + CGGetActiveDisplayList(0, nil, &displayCount) + var displayIDs = [CGDirectDisplayID](repeating: 0, count: Int(displayCount)) + CGGetActiveDisplayList(displayCount, &displayIDs, &displayCount) + + for displayID in displayIDs { + let cap = Int(CGDisplayGammaTableCapacity(displayID)) + var redTable = [CGGammaValue](repeating: 0, count: cap) + var greenTable = [CGGammaValue](repeating: 0, count: cap) + var blueTable = [CGGammaValue](repeating: 0, count: cap) + + for i in 0.. Date: Mon, 11 May 2026 20:08:29 +0200 Subject: [PATCH 04/28] feat(bitbardd): add TrueToneHandler.swift --- .../bitbardd/Sources/TrueToneHandler.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 packages/bitbardd/Sources/TrueToneHandler.swift diff --git a/packages/bitbardd/Sources/TrueToneHandler.swift b/packages/bitbardd/Sources/TrueToneHandler.swift new file mode 100644 index 0000000..0cff80b --- /dev/null +++ b/packages/bitbardd/Sources/TrueToneHandler.swift @@ -0,0 +1,59 @@ +import Darwin +import Foundation +import ObjectiveC + +class TrueToneHandler { + private var client: NSObject? + private var getEnabled: ((AnyObject, Selector) -> Bool)? + private var setEnabled: ((AnyObject, Selector, Bool) -> Void)? + private let enabledSel = NSSelectorFromString("enabled") + private let setEnabledSel = NSSelectorFromString("setEnabled:") + + private func loadClient() -> String? { + let frameworkPath = "/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness" + guard dlopen(frameworkPath, RTLD_NOW) != nil else { + return "Failed to load CoreBrightness: \(String(cString: dlerror()))" + } + guard let cls = NSClassFromString("CBTrueToneClient") as? NSObject.Type else { + return "CBTrueToneClient not found — this macOS version may not support True Tone via this API" + } + let c = cls.init() + guard c.responds(to: enabledSel), + let imp = class_getMethodImplementation(cls, enabledSel) else { + return "'enabled' selector not found on CBTrueToneClient" + } + typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool + getEnabled = unsafeBitCast(imp, to: BoolGetter.self) + + guard c.responds(to: setEnabledSel), + let setImp = class_getMethodImplementation(cls, setEnabledSel) else { + return "'setEnabled:' selector not found on CBTrueToneClient" + } + typealias BoolSetter = @convention(c) (AnyObject, Selector, Bool) -> Void + setEnabled = unsafeBitCast(setImp, to: BoolSetter.self) + + client = c + return nil + } + + func handle(action: String?) -> DaemonResponse { + if client == nil { + if let err = loadClient() { + return .failure(err) + } + } + guard let c = client else { return .failure("truetone: client unavailable") } + + switch action { + case "status": + let on = getEnabled!(c, enabledSel) + return .success(on ? "enabled" : "disabled") + case "toggle": + let current = getEnabled!(c, enabledSel) + setEnabled!(c, setEnabledSel, !current) + return .success(!current ? "enabled" : "disabled") + default: + return .failure("truetone: unknown action '\(action ?? "nil")'. Use status or toggle.") + } + } +} From a846cd411021a514d938bde68ac6d040317b26f7 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:08:47 +0200 Subject: [PATCH 05/28] feat(bitbardd): add LockHandler.swift --- packages/bitbardd/Sources/LockHandler.swift | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/bitbardd/Sources/LockHandler.swift diff --git a/packages/bitbardd/Sources/LockHandler.swift b/packages/bitbardd/Sources/LockHandler.swift new file mode 100644 index 0000000..b3fb980 --- /dev/null +++ b/packages/bitbardd/Sources/LockHandler.swift @@ -0,0 +1,23 @@ +import Foundation + +class LockHandler { + func handle() -> DaemonResponse { + guard let bundleURL = URL(string: "file:///System/Library/PrivateFrameworks/login.framework"), + let bundle = Bundle(url: bundleURL) + else { + return .failure("lock: failed to locate login.framework") + } + bundle.load() + + guard let cfBundle = CFBundleCreate(kCFAllocatorDefault, bundleURL as CFURL), + let sym = CFBundleGetFunctionPointerForName(cfBundle, "SACLockScreenImmediate" as CFString) + else { + return .failure("lock: SACLockScreenImmediate not found in login.framework") + } + + typealias SACLockScreenImmediateFn = @convention(c) () -> Void + let lockScreen = unsafeBitCast(sym, to: SACLockScreenImmediateFn.self) + lockScreen() + return .success("ok") + } +} From c0067e405081269bef40a3c47338e6985175af14 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:09:17 +0200 Subject: [PATCH 06/28] feat(bitbardd): add AlertHandler.swift --- packages/bitbardd/Sources/AlertHandler.swift | 94 ++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 packages/bitbardd/Sources/AlertHandler.swift diff --git a/packages/bitbardd/Sources/AlertHandler.swift b/packages/bitbardd/Sources/AlertHandler.swift new file mode 100644 index 0000000..970dbb6 --- /dev/null +++ b/packages/bitbardd/Sources/AlertHandler.swift @@ -0,0 +1,94 @@ +import AppKit +import SwiftUI + +// MARK: - Style constants (mirrors alert.swift) + +private let pillRadius: CGFloat = 27 +private let pillFillColor = NSColor(white: 0, alpha: 0.75) +private let pillStrokeColor = NSColor(white: 1, alpha: 1) +private let pillStrokeWidth: CGFloat = 2 +private let pillTextSize: CGFloat = 27 +private let pillFadeIn = 0.15 +private let pillFadeOut = 0.15 + +// MARK: - Alert view + +private struct AlertView: View { + let message: String + + var body: some View { + Text(message) + .font(.system(size: pillTextSize)) + .foregroundColor(.white) + .padding(.horizontal, pillTextSize) + .padding(.vertical, pillTextSize / 2) + .background { + RoundedRectangle(cornerRadius: pillRadius, style: .continuous) + .fill(Color(nsColor: pillFillColor)) + .overlay { + RoundedRectangle(cornerRadius: pillRadius, style: .continuous) + .stroke(Color(nsColor: pillStrokeColor), lineWidth: pillStrokeWidth) + } + } + } +} + +// MARK: - Handler + +class AlertHandler { + // Keep strong references to active windows so they are not deallocated early. + private var activeWindows: [NSWindow] = [] + + func handle(message: String, duration: Double) -> DaemonResponse { + guard let screen = NSScreen.main else { + return .failure("alert: no main screen available") + } + + let hosting = NSHostingView(rootView: AlertView(message: message)) + hosting.translatesAutoresizingMaskIntoConstraints = false + let idealSize = hosting.fittingSize + + let screenFrame = screen.frame + let x = screenFrame.midX - idealSize.width / 2 + let y = screenFrame.midY - idealSize.height / 2 + screenFrame.height * 0.05 + let windowRect = NSRect(x: x, y: y, width: idealSize.width, height: idealSize.height) + + let win = NSWindow( + contentRect: windowRect, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + win.isOpaque = false + win.backgroundColor = .clear + win.level = .floating + win.ignoresMouseEvents = true + win.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + win.hasShadow = false + hosting.frame = NSRect(origin: .zero, size: idealSize) + win.contentView = hosting + + activeWindows.append(win) + win.alphaValue = 0 + win.orderFront(nil) + + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = pillFadeIn + win.animator().alphaValue = 1 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self, weak win] in + guard let win else { return } + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = pillFadeOut + win.animator().alphaValue = 0 + }, completionHandler: { [weak self, weak win] in + guard let win else { return } + win.close() + self?.activeWindows.removeAll { $0 === win } + }) + } + + return .success("ok") + } +} From 63a6a5394b651877824b2d99e9ce8fdf2be9c3d2 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:09:46 +0200 Subject: [PATCH 07/28] feat(bitbardd): add SocketServer.swift --- packages/bitbardd/Sources/SocketServer.swift | 127 +++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 packages/bitbardd/Sources/SocketServer.swift diff --git a/packages/bitbardd/Sources/SocketServer.swift b/packages/bitbardd/Sources/SocketServer.swift new file mode 100644 index 0000000..777e8e8 --- /dev/null +++ b/packages/bitbardd/Sources/SocketServer.swift @@ -0,0 +1,127 @@ +import Foundation + +class SocketServer { + private let flux = FluxHandler() + private let truetone = TrueToneHandler() + private let lock = LockHandler() + private let alert = AlertHandler() + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + func start(at path: String) { + removeStaleSocket(at: path) + + let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFd >= 0 else { + fputs("bitbardd: socket() failed: \(String(cString: strerror(errno)))\n", stderr) + exit(1) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + // sun_path is 104 bytes on macOS + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: 104) { chars in + _ = strlcpy(chars, path, 104) + } + } + + let bindResult = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(serverFd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + fputs("bitbardd: bind() failed: \(String(cString: strerror(errno)))\n", stderr) + exit(1) + } + + guard listen(serverFd, 16) == 0 else { + fputs("bitbardd: listen() failed: \(String(cString: strerror(errno)))\n", stderr) + exit(1) + } + + DispatchQueue.global(qos: .default).async { + while true { + let clientFd = accept(serverFd, nil, nil) + guard clientFd >= 0 else { continue } + DispatchQueue.global(qos: .default).async { + self.handle(clientFd) + } + } + } + } + + // MARK: - Private + + private func removeStaleSocket(at path: String) { + guard FileManager.default.fileExists(atPath: path) else { return } + + let testFd = socket(AF_UNIX, SOCK_STREAM, 0) + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: 104) { chars in + _ = strlcpy(chars, path, 104) + } + } + let connected = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(testFd, $0, socklen_t(MemoryLayout.size)) + } + } + close(testFd) + + if connected == 0 { + // Another instance is running + fputs("bitbardd: another instance is already running at \(path)\n", stderr) + exit(0) + } + // Stale socket — remove it + try? FileManager.default.removeItem(atPath: path) + } + + private func handle(_ fd: Int32) { + defer { close(fd) } + + // Read until newline + var data = Data() + var byte = UInt8(0) + while recv(fd, &byte, 1, 0) == 1 { + if byte == UInt8(ascii: "\n") { break } + data.append(byte) + } + + let response: DaemonResponse + do { + let request = try decoder.decode(DaemonRequest.self, from: data) + response = dispatch(request) + } catch { + response = .failure("Failed to parse request: \(error)") + } + + do { + var responseData = try encoder.encode(response) + responseData.append(UInt8(ascii: "\n")) + _ = responseData.withUnsafeBytes { send(fd, $0.baseAddress!, responseData.count, 0) } + } catch { + fputs("bitbardd: failed to encode response: \(error)\n", stderr) + } + } + + private func dispatch(_ request: DaemonRequest) -> DaemonResponse { + switch request.command { + case .flux: + return DispatchQueue.main.sync { flux.handle(action: request.action) } + case .truetone: + return DispatchQueue.main.sync { truetone.handle(action: request.action) } + case .lock: + return DispatchQueue.main.sync { lock.handle() } + case .alert: + let message = request.message ?? "" + let duration = request.duration ?? 2.0 + return DispatchQueue.main.sync { alert.handle(message: message, duration: duration) } + } + } +} From 435175ecdfdf730d845f96d738bf8b0b1094ff75 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:15:08 +0200 Subject: [PATCH 08/28] feat(bitbardd): add main.swift AppKit entry point --- packages/bitbardd/Sources/main.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/bitbardd/Sources/main.swift diff --git a/packages/bitbardd/Sources/main.swift b/packages/bitbardd/Sources/main.swift new file mode 100644 index 0000000..4227c33 --- /dev/null +++ b/packages/bitbardd/Sources/main.swift @@ -0,0 +1,20 @@ +import AppKit +import Foundation + +let socketPath: String = { + let dataDir = (ProcessInfo.processInfo.environment["BITBARD_DATA_DIR"] + ?? (NSHomeDirectory() + "/.local/share/bitbard")) + // Ensure the directory exists before the socket server tries to bind + try? FileManager.default.createDirectory(atPath: dataDir, + withIntermediateDirectories: true) + return dataDir + "/bitbardd.sock" +}() + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) + +let server = SocketServer() +server.start(at: socketPath) + +print("bitbardd: listening at \(socketPath)") +app.run() From 38e87f773997dc26d6a99701f690a7cbc22219ff Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:15:50 +0200 Subject: [PATCH 09/28] build: integrate bitbardd into turbo build graph, remove swiftPlugin --- bun.lock | 1 + packages/cli/package.json | 1 + packages/cli/rolldown.config.ts | 21 +-------------------- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/bun.lock b/bun.lock index 602467c..3a8c86c 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "citty": "catalog:", }, "devDependencies": { + "@bitbard/bitbardd": "workspace:*", "@bitbard/typescript-config": "workspace:*", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", diff --git a/packages/cli/package.json b/packages/cli/package.json index 702c83a..cd39cc1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,6 +17,7 @@ "citty": "catalog:" }, "devDependencies": { + "@bitbard/bitbardd": "workspace:*", "@bitbard/typescript-config": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:", diff --git a/packages/cli/rolldown.config.ts b/packages/cli/rolldown.config.ts index 20c7025..ceb3e8e 100644 --- a/packages/cli/rolldown.config.ts +++ b/packages/cli/rolldown.config.ts @@ -1,9 +1,7 @@ import { defineConfig } from 'rolldown'; -import { mkdirSync } from 'node:fs'; import { execSync } from 'node:child_process'; -import { resolve, dirname } from 'node:path'; +import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Plugin } from 'rolldown'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -22,22 +20,6 @@ function buildVersion(): string { return `${yy}.${MM}${DD}.${HH}${mm}-${sha}`; } -function swiftPlugin(): Plugin { - return { - name: 'swift-compiler', - closeBundle() { - // CoreGraphics / AppKit are macOS-only; skip Swift compilation on Linux CI runners - if (process.platform !== 'darwin') { - return; - } - mkdirSync('dist/bin', { recursive: true }); - const swiftDir = resolve(__dirname, '../core/src/swift'); - const script = `for f in "${swiftDir}"/*.swift; do [ -f "$f" ] || continue; name=$(basename "$f" .swift); swiftc "$f" -o "dist/bin/$name" -O; done`; - execSync(script, { stdio: 'inherit' }); - }, - }; -} - export default defineConfig({ input: 'src/bitbard.ts', platform: 'node', @@ -47,7 +29,6 @@ export default defineConfig({ __APP_VERSION__: JSON.stringify(buildVersion()), }, }, - plugins: [swiftPlugin()], output: { format: 'esm', dir: 'dist', From 7184c86eac5e6bbb8445c9bf20fb269f260be70a Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:16:03 +0200 Subject: [PATCH 10/28] chore: remove individual Swift source files from packages/core --- packages/core/src/swift/alert.swift | 117 ------------------------- packages/core/src/swift/flux.swift | 44 ---------- packages/core/src/swift/lock.swift | 27 ------ packages/core/src/swift/truetone.swift | 61 ------------- 4 files changed, 249 deletions(-) delete mode 100644 packages/core/src/swift/alert.swift delete mode 100644 packages/core/src/swift/flux.swift delete mode 100644 packages/core/src/swift/lock.swift delete mode 100644 packages/core/src/swift/truetone.swift diff --git a/packages/core/src/swift/alert.swift b/packages/core/src/swift/alert.swift deleted file mode 100644 index d3342e7..0000000 --- a/packages/core/src/swift/alert.swift +++ /dev/null @@ -1,117 +0,0 @@ -import AppKit -import SwiftUI - -// Usage: screen-alert [duration] -// message - text to display -// duration - seconds to show (default 2.0) - -let args = CommandLine.arguments - -guard args.count >= 2 else { - fputs("Usage: screen-alert [duration]\n", stderr) - exit(1) -} - -let alertMessage = args[1] -let alertDuration = args.count >= 3 ? (Double(args[2]) ?? 2.0) : 2.0 - -// MARK: - Style constants (mirrors hs.alert.defaultStyle) - -// Variable names intentionally avoid SwiftUI modifier names (e.g. "cornerRadius") -private let pillRadius: CGFloat = 27 -private let pillFillColor = NSColor(white: 0, alpha: 0.75) -private let pillStrokeColor = NSColor(white: 1, alpha: 1) -private let pillStrokeWidth: CGFloat = 2 -private let pillTextSize: CGFloat = 27 -private let pillFadeIn = 0.15 -private let pillFadeOut = 0.15 - -// MARK: - Alert view - -struct AlertView: View { - let message: String - - var body: some View { - Text(message) - .font(.system(size: pillTextSize)) - .foregroundColor(.white) - .padding(.horizontal, pillTextSize) - .padding(.vertical, pillTextSize / 2) - .background { - RoundedRectangle(cornerRadius: pillRadius, style: .continuous) - .fill(Color(nsColor: pillFillColor)) - .overlay { - RoundedRectangle(cornerRadius: pillRadius, style: .continuous) - .stroke(Color(nsColor: pillStrokeColor), lineWidth: pillStrokeWidth) - } - } - } -} - -// MARK: - App delegate - -class AppDelegate: NSObject, NSApplicationDelegate { - var window: NSWindow? - - func applicationDidFinishLaunching(_ notification: Notification) { - guard let screen = NSScreen.main else { exit(0) } - - let hosting = NSHostingView(rootView: AlertView(message: alertMessage)) - hosting.translatesAutoresizingMaskIntoConstraints = false - - // Size to fit content - let idealSize = hosting.fittingSize - - // Position: horizontally centred, slight vertical offset above centre - // (mirrors hs.alert default placement) - let screenFrame = screen.frame - let x = screenFrame.midX - idealSize.width / 2 - let y = screenFrame.midY - idealSize.height / 2 + screenFrame.height * 0.05 - - let windowRect = NSRect(x: x, y: y, width: idealSize.width, height: idealSize.height) - - let win = NSWindow( - contentRect: windowRect, - styleMask: .borderless, - backing: .buffered, - defer: false - ) - win.isOpaque = false - win.backgroundColor = .clear - win.level = .floating - win.ignoresMouseEvents = true - win.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] - win.hasShadow = false - - hosting.frame = NSRect(origin: .zero, size: idealSize) - win.contentView = hosting - - self.window = win - - // Fade in - win.alphaValue = 0 - win.orderFront(nil) - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = pillFadeIn - win.animator().alphaValue = 1 - } - - // Schedule fade-out then quit - DispatchQueue.main.asyncAfter(deadline: .now() + alertDuration) { - NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = pillFadeOut - win.animator().alphaValue = 0 - }, completionHandler: { - NSApp.terminate(nil) - }) - } - } -} - -// MARK: - Run - -let delegate = AppDelegate() -let app = NSApplication.shared -app.setActivationPolicy(.accessory) // no Dock icon, no menu bar -app.delegate = delegate -app.run() diff --git a/packages/core/src/swift/flux.swift b/packages/core/src/swift/flux.swift deleted file mode 100644 index d7aac8b..0000000 --- a/packages/core/src/swift/flux.swift +++ /dev/null @@ -1,44 +0,0 @@ -import CoreGraphics -import Foundation - -// Usage: tint-helper enable | disable -// -// When enabling: applies warm gamma table and sleeps indefinitely (run as background process). -// When disabling: restores color sync settings. - -let args = CommandLine.arguments -guard args.count == 2, args[1] == "enable" || args[1] == "disable" else { - fputs("Usage: tint-helper enable|disable\n", stderr) - exit(1) -} - -if args[1] == "disable" { - CGDisplayRestoreColorSyncSettings() - exit(0) -} - -// enable: enumerate all active displays, apply warm gamma table to each, and hold it -var displayCount: UInt32 = 0 -CGGetActiveDisplayList(0, nil, &displayCount) -var displayIDs = [CGDirectDisplayID](repeating: 0, count: Int(displayCount)) -CGGetActiveDisplayList(displayCount, &displayIDs, &displayCount) - -for displayID in displayIDs { - let cap = Int(CGDisplayGammaTableCapacity(displayID)) - - var redTable = [CGGammaValue](repeating: 0, count: cap) - var greenTable = [CGGammaValue](repeating: 0, count: cap) - var blueTable = [CGGammaValue](repeating: 0, count: cap) - - for i in 0.. Void - -guard let cfBundle = CFBundleCreate(kCFAllocatorDefault, bundleURL as CFURL), - let sym = CFBundleGetFunctionPointerForName(cfBundle, "SACLockScreenImmediate" as CFString) -else { - fputs("lock: SACLockScreenImmediate not found in login.framework\n", stderr) - exit(1) -} - -let lockScreen = unsafeBitCast(sym, to: SACLockScreenImmediateFn.self) -lockScreen() diff --git a/packages/core/src/swift/truetone.swift b/packages/core/src/swift/truetone.swift deleted file mode 100644 index 08f2690..0000000 --- a/packages/core/src/swift/truetone.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Darwin -import Foundation -import ObjectiveC - -// Usage: truetone status | toggle -// -// Uses CBTrueToneClient from the CoreBrightness private framework to read -// and toggle the macOS True Tone setting. -// -// NOTE: CoreBrightness.framework and CBTrueToneClient are private Apple APIs. -// They may change or disappear across macOS versions. - -let frameworkPath = "/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness" -guard dlopen(frameworkPath, RTLD_NOW) != nil else { - let reason = String(cString: dlerror()) - fputs("Failed to load CoreBrightness: \(reason)\n", stderr) - exit(1) -} - -guard let cls = NSClassFromString("CBTrueToneClient") as? NSObject.Type else { - fputs("CBTrueToneClient not found — this macOS version may not support True Tone via this API\n", stderr) - exit(1) -} - -let client = cls.init() - -let enabledSel = NSSelectorFromString("enabled") -guard client.responds(to: enabledSel), - let enabledIMP = class_getMethodImplementation(cls, enabledSel) else { - fputs("'enabled' selector not found on CBTrueToneClient\n", stderr) - exit(1) -} -typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool -let getEnabled = unsafeBitCast(enabledIMP, to: BoolGetter.self) - -let setEnabledSel = NSSelectorFromString("setEnabled:") -guard client.responds(to: setEnabledSel), - let setEnabledIMP = class_getMethodImplementation(cls, setEnabledSel) else { - fputs("'setEnabled:' selector not found on CBTrueToneClient\n", stderr) - exit(1) -} -typealias BoolSetter = @convention(c) (AnyObject, Selector, Bool) -> Void -let setEnabled = unsafeBitCast(setEnabledIMP, to: BoolSetter.self) - -let args = CommandLine.arguments -guard args.count == 2 else { - fputs("Usage: truetone status|toggle\n", stderr) - exit(1) -} - -switch args[1] { -case "status": - print(getEnabled(client, enabledSel) ? "enabled" : "disabled") -case "toggle": - let current = getEnabled(client, enabledSel) - setEnabled(client, setEnabledSel, !current) - print(!current ? "enabled" : "disabled") -default: - fputs("Usage: truetone status|toggle\n", stderr) - exit(1) -} From 6af57602c897046056f20fb6d47a90c93f64e254 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:16:57 +0200 Subject: [PATCH 11/28] feat(core): add daemon.ts sendCommand() TypeScript client with tests --- packages/core/src/daemon.spec.ts | 89 ++++++++++++++++++++++++++++++++ packages/core/src/daemon.ts | 75 +++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 packages/core/src/daemon.spec.ts create mode 100644 packages/core/src/daemon.ts diff --git a/packages/core/src/daemon.spec.ts b/packages/core/src/daemon.spec.ts new file mode 100644 index 0000000..50fd727 --- /dev/null +++ b/packages/core/src/daemon.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as net from 'node:net'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +// We test sendCommand by spinning up a real Unix socket server in the test. + +describe('sendCommand', () => { + let sockPath: string; + let server: net.Server; + + beforeEach(async () => { + sockPath = path.join(os.tmpdir(), `bitbardd-test-${process.pid}.sock`); + try { + fs.unlinkSync(sockPath); + } catch {} + process.env.BITBARD_SOCKET_PATH = sockPath; + }); + + afterEach(async () => { + await new Promise((resolve) => server?.close(() => resolve())); + try { + fs.unlinkSync(sockPath); + } catch {} + delete process.env.BITBARD_SOCKET_PATH; + vi.resetModules(); + }); + + function startMockServer(response: object): Promise { + return new Promise((resolve) => { + server = net.createServer((conn) => { + let buf = ''; + conn.setEncoding('utf8'); + conn.on('data', (chunk) => { + buf += chunk; + if (buf.includes('\n')) { + conn.write(JSON.stringify(response) + '\n'); + conn.end(); + } + }); + }); + server.listen(sockPath, () => resolve()); + }); + } + + it('resolves with result when ok:true', async () => { + await startMockServer({ ok: true, result: 'disabled' }); + const { sendCommand } = await import('./daemon.js'); + + const res = await sendCommand({ command: 'flux', action: 'status' }); + expect(res).toEqual({ ok: true, result: 'disabled' }); + }); + + it('throws DaemonError when ok:false', async () => { + await startMockServer({ ok: false, error: 'flux: unknown action' }); + const { sendCommand } = await import('./daemon.js'); + + await expect(sendCommand({ command: 'flux', action: 'bad' as never })).rejects.toThrow('flux: unknown action'); + }); + + it('throws with helpful message when socket does not exist', async () => { + const { sendCommand } = await import('./daemon.js'); + + await expect(sendCommand({ command: 'lock' })).rejects.toThrow('bitbardd is not running'); + }); + + it('sends the request as newline-terminated JSON', async () => { + let received = ''; + await new Promise((resolve) => { + server = net.createServer((conn) => { + conn.setEncoding('utf8'); + conn.on('data', (chunk) => { + received += chunk; + if (received.includes('\n')) { + conn.write(JSON.stringify({ ok: true, result: 'ok' }) + '\n'); + conn.end(); + } + }); + }); + server.listen(sockPath, () => resolve()); + }); + + const { sendCommand } = await import('./daemon.js'); + await sendCommand({ command: 'lock' }); + + expect(received.trimEnd()).toBe(JSON.stringify({ command: 'lock' })); + }); +}); diff --git a/packages/core/src/daemon.ts b/packages/core/src/daemon.ts new file mode 100644 index 0000000..2d5513a --- /dev/null +++ b/packages/core/src/daemon.ts @@ -0,0 +1,75 @@ +import * as net from 'node:net'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type DaemonRequest = + | { command: 'flux'; action: 'enable' | 'disable' | 'toggle' | 'status' } + | { command: 'truetone'; action: 'toggle' | 'status' } + | { command: 'lock' } + | { command: 'alert'; message: string; duration?: number }; + +export interface DaemonResponse { + ok: boolean; + result?: string; + error?: string; +} + +export class DaemonError extends Error { + constructor(message: string) { + super(message); + this.name = 'DaemonError'; + } +} + +// ─── Client ─────────────────────────────────────────────────────────────────── + +function socketPath(): string { + return process.env.BITBARD_SOCKET_PATH ?? join(homedir(), '.local', 'share', 'bitbard', 'bitbardd.sock'); +} + +export function sendCommand(request: DaemonRequest): Promise { + return new Promise((resolve, reject) => { + const sock = socketPath(); + const conn = net.createConnection(sock); + + conn.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') { + reject( + new DaemonError( + 'bitbardd is not running. Start it with: launchctl load ~/Library/LaunchAgents/com.bitbard.bitbardd.plist', + ), + ); + } else { + reject(new DaemonError(`bitbardd connection error: ${err.message}`)); + } + }); + + conn.once('connect', () => { + conn.write(JSON.stringify(request) + '\n'); + }); + + let buf = ''; + conn.on('data', (chunk) => { + buf += chunk.toString(); + const nl = buf.indexOf('\n'); + if (nl !== -1) { + conn.destroy(); + const line = buf.slice(0, nl); + let parsed: DaemonResponse; + try { + parsed = JSON.parse(line) as DaemonResponse; + } catch { + reject(new DaemonError(`bitbardd: invalid response: ${line}`)); + return; + } + if (!parsed.ok) { + reject(new DaemonError(parsed.error ?? 'bitbardd: unknown error')); + } else { + resolve(parsed); + } + } + }); + }); +} From c357606b90a76b122c36384618d6cb976796d9af Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:19:14 +0200 Subject: [PATCH 12/28] refactor(core): caffeinate.ts lockScreen() uses daemon sendCommand --- packages/core/src/caffeinate.spec.ts | 41 ++++++---------------------- packages/core/src/caffeinate.ts | 14 +++------- 2 files changed, 13 insertions(+), 42 deletions(-) diff --git a/packages/core/src/caffeinate.spec.ts b/packages/core/src/caffeinate.spec.ts index 36869f2..a6327a9 100644 --- a/packages/core/src/caffeinate.spec.ts +++ b/packages/core/src/caffeinate.spec.ts @@ -1,50 +1,27 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; -vi.mock('node:child_process', () => ({ - spawn: vi.fn(() => ({ unref: vi.fn() })), - execSync: vi.fn(), +vi.mock('./daemon.js', () => ({ + sendCommand: vi.fn().mockResolvedValue({ ok: true, result: 'ok' }), })); -vi.mock('node:os', () => ({ - homedir: vi.fn(() => '/home/user'), +vi.mock('node:child_process', () => ({ + execSync: vi.fn(), })); describe('caffeinate', () => { - let originalEnv: NodeJS.ProcessEnv; - - beforeEach(() => { - originalEnv = process.env; - process.env = { ...process.env }; - delete process.env.BITBARD_BIN_DIR; - }); - afterEach(() => { - process.env = originalEnv; vi.clearAllMocks(); + vi.resetModules(); }); describe('lockScreen', () => { - it('spawns the lock binary detached with no args', async () => { - const { spawn } = await import('node:child_process'); + it('calls sendCommand with lock command', async () => { + const { sendCommand } = await import('./daemon.js'); const { lockScreen } = await import('./caffeinate.js'); lockScreen(); - expect(spawn).toHaveBeenCalledWith('/home/user/.local/share/bitbard/bin/lock', [], { - detached: true, - stdio: 'ignore', - }); - }); - - it('calls unref() on the child process', async () => { - const { spawn } = await import('node:child_process'); - const mockChild = { unref: vi.fn() }; - vi.mocked(spawn).mockReturnValueOnce(mockChild as never); - - const { lockScreen } = await import('./caffeinate.js'); - lockScreen(); - - expect(mockChild.unref).toHaveBeenCalledOnce(); + expect(sendCommand).toHaveBeenCalledWith({ command: 'lock' }); }); }); diff --git a/packages/core/src/caffeinate.ts b/packages/core/src/caffeinate.ts index 53cf206..7c4628f 100644 --- a/packages/core/src/caffeinate.ts +++ b/packages/core/src/caffeinate.ts @@ -1,16 +1,10 @@ -import { spawn, execSync } from 'node:child_process'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -const binDir = process.env.BITBARD_BIN_DIR ?? join(homedir(), '.local', 'share', 'bitbard', 'bin'); -const LOCK_BINARY = join(binDir, 'lock'); +import { execSync } from 'node:child_process'; +import { sendCommand } from './daemon.js'; export function lockScreen(): void { - const child = spawn(LOCK_BINARY, [], { - detached: true, - stdio: 'ignore', + sendCommand({ command: 'lock' }).catch((err: Error) => { + process.stderr.write(`bitbard: ${err.message}\n`); }); - child.unref(); } export function startScreensaver(): void { From a0d4e76f248afc4dd26d5c12504c48820c3ebe6a Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:19:30 +0200 Subject: [PATCH 13/28] refactor(core): alert.ts showAlert() uses daemon sendCommand --- packages/core/src/alert.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/core/src/alert.ts b/packages/core/src/alert.ts index ed86ba1..867cc2a 100644 --- a/packages/core/src/alert.ts +++ b/packages/core/src/alert.ts @@ -1,16 +1,11 @@ -import { spawn } from 'node:child_process'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { sendCommand } from './daemon.js'; import { isMacOS } from './platform.js'; -const binDir = process.env.BITBARD_BIN_DIR ?? join(homedir(), '.local', 'share', 'bitbard', 'bin'); -const BINARY = join(binDir, 'alert'); - /** * Simple on-screen alerts. * * Displays a dark, semi-transparent rounded pill with white text centred on - * the main screen. + * the main screen. Delegated to the bitbardd daemon. * * @param message Text to display. * @param duration Seconds to keep the alert visible (default: 2). @@ -18,9 +13,7 @@ const BINARY = join(binDir, 'alert'); export function showAlert(message: string, duration = 2): void { if (!isMacOS()) return; - const child = spawn(BINARY, [message, String(duration)], { - detached: true, - stdio: 'ignore', + sendCommand({ command: 'alert', message, duration }).catch((err: Error) => { + process.stderr.write(`bitbard: ${err.message}\n`); }); - child.unref(); } From 61a6c10d102e544fd8d74f694dbf85dd38dd081e Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:20:22 +0200 Subject: [PATCH 14/28] refactor(cli): flux command uses daemon sendCommand, add tests --- .../cli/src/commands/display/flux.spec.ts | 53 +++++++++++++ packages/cli/src/commands/display/flux.ts | 78 ++++--------------- packages/cli/src/plugins/macos.spec.ts | 4 + 3 files changed, 70 insertions(+), 65 deletions(-) create mode 100644 packages/cli/src/commands/display/flux.spec.ts diff --git a/packages/cli/src/commands/display/flux.spec.ts b/packages/cli/src/commands/display/flux.spec.ts new file mode 100644 index 0000000..ff7223b --- /dev/null +++ b/packages/cli/src/commands/display/flux.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { runCommand } from 'citty'; + +vi.mock('@bitbard/core/daemon.js', () => ({ + sendCommand: vi.fn().mockResolvedValue({ ok: true, result: 'enabled' }), +})); + +vi.mock('@bitbard/core/platform.js', () => ({ + isMacOS: vi.fn().mockReturnValue(true), +})); + +describe('flux command', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('calls sendCommand with flux toggle', async () => { + const { sendCommand } = await import('@bitbard/core/daemon.js'); + const fluxCmd = (await import('./flux.js')).default; + + await runCommand(fluxCmd, { rawArgs: [] }); + + expect(sendCommand).toHaveBeenCalledWith({ command: 'flux', action: 'toggle' }); + }); + + it('prints enabled message when result is enabled', async () => { + const { sendCommand } = await import('@bitbard/core/daemon.js'); + vi.mocked(sendCommand).mockResolvedValueOnce({ ok: true, result: 'enabled' }); + const fluxCmd = (await import('./flux.js')).default; + + await runCommand(fluxCmd, { rawArgs: [] }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('enabled')); + }); + + it('prints disabled message when result is disabled', async () => { + const { sendCommand } = await import('@bitbard/core/daemon.js'); + vi.mocked(sendCommand).mockResolvedValueOnce({ ok: true, result: 'disabled' }); + const fluxCmd = (await import('./flux.js')).default; + + await runCommand(fluxCmd, { rawArgs: [] }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('disabled')); + }); +}); diff --git a/packages/cli/src/commands/display/flux.ts b/packages/cli/src/commands/display/flux.ts index 2032ead..df71367 100644 --- a/packages/cli/src/commands/display/flux.ts +++ b/packages/cli/src/commands/display/flux.ts @@ -1,76 +1,24 @@ import { defineCommand } from 'citty'; -import { execSync, spawn } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { sendCommand } from '@bitbard/core/daemon.js'; import chalk from 'chalk'; -const STATE_DIR = join(homedir(), '.config', 'bitbard'); -const STATE_FILE = join(STATE_DIR, 'flux.json'); - -const binDir = process.env.BITBARD_BIN_DIR ?? join(homedir(), '.local', 'share', 'bitbard', 'bin'); -const BINARY = join(binDir, 'flux'); - -interface State { - enabled: boolean; - pid?: number; -} - -function readState(): State { - if (!existsSync(STATE_FILE)) return { enabled: false }; - try { - return JSON.parse(readFileSync(STATE_FILE, 'utf8')) as State; - } catch { - return { enabled: false }; - } -} - -function writeState(state: State): void { - mkdirSync(STATE_DIR, { recursive: true }); - writeFileSync(STATE_FILE, JSON.stringify(state), 'utf8'); -} - -function enable(): number { - const child = spawn(BINARY, ['enable'], { - detached: true, - stdio: 'ignore', - }); - child.unref(); - return child.pid!; -} - -function disable(pid?: number): void { - // Kill the daemon if we have its PID - if (pid) { - try { - process.kill(pid, 'SIGTERM'); - } catch { - // already gone - } - } - // Also restore gamma via the binary (handles CGDisplayRestoreColorSyncSettings) - try { - execSync(`"${BINARY}" disable`, { stdio: 'ignore' }); - } catch { - // ignore - } -} +import { macosPlugin } from '../../plugins/macos.js'; export default defineCommand({ meta: { name: 'flux', description: 'Toggle warm screen tint (reduces blue light)', }, - run() { - const state = readState(); - - if (state.enabled) { - disable(state.pid); - writeState({ enabled: false }); - console.log(chalk.bold(chalk.green('Screen tint disabled.'))); - } else { - const pid = enable(); - writeState({ enabled: true, pid }); - console.log(chalk.bold(chalk.yellow('Screen tint enabled.'))); + plugins: [macosPlugin], + async run() { + try { + const res = await sendCommand({ command: 'flux', action: 'toggle' }); + if (res.result === 'enabled') { + console.log(chalk.bold(chalk.yellow('Screen tint enabled.'))); + } else { + console.log(chalk.bold(chalk.green('Screen tint disabled.'))); + } + } catch (err) { + console.log(chalk.bold(chalk.red(`Failed to toggle flux: ${(err as Error).message}`))); } }, }); diff --git a/packages/cli/src/plugins/macos.spec.ts b/packages/cli/src/plugins/macos.spec.ts index 98947b2..8d4a0cc 100644 --- a/packages/cli/src/plugins/macos.spec.ts +++ b/packages/cli/src/plugins/macos.spec.ts @@ -30,6 +30,10 @@ vi.mock('@bitbard/core/applescript.js', () => ({ runAppleScript: vi.fn(), })); +vi.mock('@bitbard/core/daemon.js', () => ({ + sendCommand: vi.fn().mockResolvedValue({ ok: true, result: 'ok' }), +})); + // Mock all clack/prompts symbols used by leaf commands so nothing blocks. vi.mock('@clack/prompts', () => ({ select: vi.fn().mockResolvedValue('light'), From 7aa941852555e61bf3988d87af7c380f5e6e3298 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:20:40 +0200 Subject: [PATCH 15/28] refactor(cli): truetone command uses daemon sendCommand --- packages/cli/src/commands/display/truetone.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/display/truetone.ts b/packages/cli/src/commands/display/truetone.ts index 53cfd33..9ecada6 100644 --- a/packages/cli/src/commands/display/truetone.ts +++ b/packages/cli/src/commands/display/truetone.ts @@ -1,20 +1,16 @@ import { defineCommand } from 'citty'; -import { execFileSync } from 'node:child_process'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { sendCommand } from '@bitbard/core/daemon.js'; import chalk from 'chalk'; -const binDir = process.env.BITBARD_BIN_DIR ?? join(homedir(), '.local', 'share', 'bitbard', 'bin'); -const BINARY = join(binDir, 'truetone'); export default defineCommand({ meta: { name: 'truetone', description: 'Toggle macOS True Tone.', }, - run() { + async run() { try { - const result = execFileSync(BINARY, ['toggle'], { encoding: 'utf8' }).trim(); - if (result === 'enabled') { + const res = await sendCommand({ command: 'truetone', action: 'toggle' }); + if (res.result === 'enabled') { console.log(chalk.bold(chalk.green('True Tone enabled.'))); } else { console.log(chalk.bold(chalk.yellow('True Tone disabled.'))); From 37ecfbd7bf70da22ba31394aebda9ad6a42a5825 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:21:07 +0200 Subject: [PATCH 16/28] feat(install): install bitbardd LaunchAgent on macOS --- install.sh | 47 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 302766d..46e7268 100755 --- a/install.sh +++ b/install.sh @@ -35,6 +35,48 @@ bb_warn() { printf "${ORANGE}Warning: %s${NC}\n" "$*" >&2 } +bb_install_launchagent() { + local plist_dir="${HOME}/Library/LaunchAgents" + local plist_path="${plist_dir}/com.bitbard.bitbardd.plist" + local daemon_bin="${INSTALL_DATA_DIR}/bitbardd" + + mkdir -p "$plist_dir" + + # Unload existing agent before replacing it + if [[ -f "$plist_path" ]]; then + launchctl unload "$plist_path" 2>/dev/null || true + fi + + cat > "$plist_path" < + + + + Label + com.bitbard.bitbardd + ProgramArguments + + ${daemon_bin} + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/bitbardd.log + StandardErrorPath + /tmp/bitbardd.log + + +PLIST + + if launchctl load "$plist_path" 2>/dev/null; then + bb_echo "=> bitbardd LaunchAgent installed and started" + else + bb_warn "launchctl load failed. Start manually: launchctl load ${plist_path}" + fi +} + usage() { cat < Installed ${APP} to ${INSTALL_BIN_DIR}/${APP}" -# Install Swift helpers (may not exist on all platforms / in all releases) +# Install bitbardd daemon (may not exist on all platforms / in all releases) if [[ -d "${tmp_dir}/bin" ]]; then mkdir -p "$INSTALL_DATA_DIR" cp -r "${tmp_dir}/bin/." "$INSTALL_DATA_DIR/" chmod 755 "${INSTALL_DATA_DIR}"/* - bb_echo "=> Installed Swift helpers to ${INSTALL_DATA_DIR}" + bb_echo "=> Installed bitbardd to ${INSTALL_DATA_DIR}" + bb_install_launchagent fi # --------------------------------------------------------------------------- From 231f3a67d22209abf2ae4dbd6b9192a82bb5aa13 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:21:28 +0200 Subject: [PATCH 17/28] docs: add uninstall instructions to README --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3a90563..e9eb8f6 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,22 @@ curl -fsSL https://raw.githubusercontent.com/geersch/bitbard-cli/master/install. ### Uninstalling +Run these commands to fully remove bitbard: + ```sh +# Stop and remove the background daemon +launchctl unload ~/Library/LaunchAgents/com.bitbard.bitbardd.plist +rm ~/Library/LaunchAgents/com.bitbard.bitbardd.plist + +# Remove installed binaries +rm ~/.local/share/bitbard/bin/bitbardd rm ~/.local/bin/bitbard -rm -rf ~/.local/share/bitbard + +# Optional: remove config and state rm -rf ~/.config/bitbard ``` -Then remove the `# Added by bitbard installer` block from your shell profile (`~/.zshrc`, `~/.bashrc`, etc.). +Then remove the `export PATH` line added by the installer from your shell config file (e.g. `~/.zshrc`). ## Commands @@ -171,7 +180,7 @@ Toggle a warm screen tint similar to [f.lux](https://justgetflux.com/). Reduces bitbard display flux ``` -Each invocation toggles the tint on or off. State is persisted in `~/.config/bitbard/flux.json`. +Each invocation toggles the tint on or off. State is held in memory by the bitbardd daemon. > **Note:** macOS only. Requires permission to control display gamma (no additional entitlements needed beyond running as the current user). From 49c883d91fcdc7ba155bad61ec6977899606f795 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:25:58 +0200 Subject: [PATCH 18/28] chore(bitbardd): rename build.mjs to build.js --- packages/bitbardd/{build.mjs => build.js} | 0 packages/bitbardd/package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/bitbardd/{build.mjs => build.js} (100%) diff --git a/packages/bitbardd/build.mjs b/packages/bitbardd/build.js similarity index 100% rename from packages/bitbardd/build.mjs rename to packages/bitbardd/build.js diff --git a/packages/bitbardd/package.json b/packages/bitbardd/package.json index 9a41b9b..a6b2284 100644 --- a/packages/bitbardd/package.json +++ b/packages/bitbardd/package.json @@ -2,6 +2,6 @@ "name": "@bitbard/bitbardd", "private": true, "scripts": { - "build": "node build.mjs" + "build": "node build.js" } } From 5a8b9c1fe09d5f1a3bd9669c393ac51be77f18a4 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:30:35 +0200 Subject: [PATCH 19/28] chore(cli): remove @bitbard/bitbardd development dependency --- bun.lock | 1 - packages/cli/package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/bun.lock b/bun.lock index 3a8c86c..602467c 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,6 @@ "citty": "catalog:", }, "devDependencies": { - "@bitbard/bitbardd": "workspace:*", "@bitbard/typescript-config": "workspace:*", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", diff --git a/packages/cli/package.json b/packages/cli/package.json index cd39cc1..702c83a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,7 +17,6 @@ "citty": "catalog:" }, "devDependencies": { - "@bitbard/bitbardd": "workspace:*", "@bitbard/typescript-config": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:", From 5934242a4235c895b6a4814c6c30f76a6264abfa Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:52:15 +0200 Subject: [PATCH 20/28] chore(bitbardd): specify module as type --- packages/bitbardd/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bitbardd/package.json b/packages/bitbardd/package.json index a6b2284..aa117ec 100644 --- a/packages/bitbardd/package.json +++ b/packages/bitbardd/package.json @@ -1,6 +1,7 @@ { "name": "@bitbard/bitbardd", "private": true, + "type": "module", "scripts": { "build": "node build.js" } From 1b743ccbee35d6df7aebb4804ffcf245bf807c43 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 11 May 2026 20:59:21 +0200 Subject: [PATCH 21/28] fix(bitbardd): rewrite AlertHandler with plain AppKit to fix segfault on repeated alerts NSHostingView and NSHostingController both leave SwiftUI attribute-graph objects in the Objective-C autorelease pool. When a second alert is shown before the first window's pool drains, objc_release dereferences a stale pointer into the first view's deallocated SwiftUI graph -> EXC_BAD_ACCESS in NSApp.run(). Replace SwiftUI with a plain NSView (PillView) that draws the pill using CALayer properties and an NSTextField label. No autorelease pool involvement, no SwiftUI graph lifetime issues. Also switch win.close() to win.orderOut(nil) so the window is hidden before the strong reference is removed from activeWindows, preventing a brief window where the pool could hold a dangling pointer to a deallocated NSWindow. --- packages/bitbardd/Sources/AlertHandler.swift | 97 +++++++++++++------- packages/bitbardd/Sources/SocketServer.swift | 10 +- 2 files changed, 71 insertions(+), 36 deletions(-) diff --git a/packages/bitbardd/Sources/AlertHandler.swift b/packages/bitbardd/Sources/AlertHandler.swift index 970dbb6..2399b72 100644 --- a/packages/bitbardd/Sources/AlertHandler.swift +++ b/packages/bitbardd/Sources/AlertHandler.swift @@ -1,60 +1,88 @@ import AppKit -import SwiftUI -// MARK: - Style constants (mirrors alert.swift) +// MARK: - Style constants private let pillRadius: CGFloat = 27 -private let pillFillColor = NSColor(white: 0, alpha: 0.75) -private let pillStrokeColor = NSColor(white: 1, alpha: 1) -private let pillStrokeWidth: CGFloat = 2 +private let pillFillAlpha: CGFloat = 0.75 +private let pillPaddingH: CGFloat = 27 +private let pillPaddingV: CGFloat = 13 private let pillTextSize: CGFloat = 27 private let pillFadeIn = 0.15 private let pillFadeOut = 0.15 -// MARK: - Alert view - -private struct AlertView: View { - let message: String - - var body: some View { - Text(message) - .font(.system(size: pillTextSize)) - .foregroundColor(.white) - .padding(.horizontal, pillTextSize) - .padding(.vertical, pillTextSize / 2) - .background { - RoundedRectangle(cornerRadius: pillRadius, style: .continuous) - .fill(Color(nsColor: pillFillColor)) - .overlay { - RoundedRectangle(cornerRadius: pillRadius, style: .continuous) - .stroke(Color(nsColor: pillStrokeColor), lineWidth: pillStrokeWidth) - } - } +// MARK: - Pill view +// +// Pure AppKit, no SwiftUI. +// +// NSHostingView and NSHostingController both register SwiftUI attribute-graph +// objects in the Objective-C autorelease pool. When a second alert is shown +// before the first window closes, the pool accumulates stale back-pointers into +// the first view's already-deallocated SwiftUI graph. When NSApp.run() drains +// the pool between run-loop cycles, objc_release dereferences a dangling +// pointer → EXC_BAD_ACCESS / SIGSEGV. Using plain AppKit drawing avoids the +// autorelease pool entirely and is simpler for a static pill label. + +private class PillView: NSView { + private let label: NSTextField + + init(message: String) { + label = NSTextField(labelWithString: message) + label.font = NSFont.systemFont(ofSize: pillTextSize, weight: .regular) + label.textColor = .white + label.backgroundColor = .clear + label.isBezeled = false + label.isEditable = false + label.sizeToFit() + + let size = NSSize( + width: label.frame.width + pillPaddingH * 2, + height: label.frame.height + pillPaddingV * 2 + ) + super.init(frame: NSRect(origin: .zero, size: size)) + wantsLayer = true + + label.frame = NSRect( + x: pillPaddingH, + y: pillPaddingV, + width: label.frame.width, + height: label.frame.height + ) + addSubview(label) + } + + required init?(coder: NSCoder) { fatalError() } + + override func layout() { + super.layout() + guard let layer else { return } + layer.backgroundColor = NSColor(white: 0, alpha: pillFillAlpha).cgColor + layer.cornerRadius = pillRadius + layer.borderColor = NSColor.white.cgColor + layer.borderWidth = 2 } } // MARK: - Handler class AlertHandler { - // Keep strong references to active windows so they are not deallocated early. + // Strong references keep windows alive until their fade-out completes. private var activeWindows: [NSWindow] = [] + @discardableResult func handle(message: String, duration: Double) -> DaemonResponse { guard let screen = NSScreen.main else { return .failure("alert: no main screen available") } - let hosting = NSHostingView(rootView: AlertView(message: message)) - hosting.translatesAutoresizingMaskIntoConstraints = false - let idealSize = hosting.fittingSize + let pill = PillView(message: message) + let size = pill.frame.size let screenFrame = screen.frame - let x = screenFrame.midX - idealSize.width / 2 - let y = screenFrame.midY - idealSize.height / 2 + screenFrame.height * 0.05 - let windowRect = NSRect(x: x, y: y, width: idealSize.width, height: idealSize.height) + let x = screenFrame.midX - size.width / 2 + let y = screenFrame.midY - size.height / 2 + screenFrame.height * 0.05 let win = NSWindow( - contentRect: windowRect, + contentRect: NSRect(x: x, y: y, width: size.width, height: size.height), styleMask: .borderless, backing: .buffered, defer: false @@ -65,8 +93,7 @@ class AlertHandler { win.ignoresMouseEvents = true win.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] win.hasShadow = false - hosting.frame = NSRect(origin: .zero, size: idealSize) - win.contentView = hosting + win.contentView = pill activeWindows.append(win) win.alphaValue = 0 @@ -84,7 +111,7 @@ class AlertHandler { win.animator().alphaValue = 0 }, completionHandler: { [weak self, weak win] in guard let win else { return } - win.close() + win.orderOut(nil) self?.activeWindows.removeAll { $0 === win } }) } diff --git a/packages/bitbardd/Sources/SocketServer.swift b/packages/bitbardd/Sources/SocketServer.swift index 777e8e8..3081af5 100644 --- a/packages/bitbardd/Sources/SocketServer.swift +++ b/packages/bitbardd/Sources/SocketServer.swift @@ -121,7 +121,15 @@ class SocketServer { case .alert: let message = request.message ?? "" let duration = request.duration ?? 2.0 - return DispatchQueue.main.sync { alert.handle(message: message, duration: duration) } + // Use async instead of sync: alert.handle() schedules AppKit animations that post + // completion handlers back onto the main run loop. If a second alert request arrives + // while the first alert's fade-out completion is pending, main.sync blocks the + // background socket thread waiting for main — while main is itself blocked trying to + // drain its run loop — causing a deadlock that manifests as a segfault. Since the + // alert is fire-and-forget (the client only cares that it was accepted, not that it + // has finished fading), we dispatch asynchronously and return success immediately. + DispatchQueue.main.async { [self] in _ = alert.handle(message: message, duration: duration) } + return .success("ok") } } } From b6f40ab5e9abfc251034e00bc420f8bd00eb0a05 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Tue, 12 May 2026 21:10:05 +0200 Subject: [PATCH 22/28] feat(bitbardd): add AnyCodable, nested request protocol, unified result field --- packages/bitbardd/Sources/AnyCodable.swift | 98 ++++++++++++++++++++ packages/bitbardd/Sources/Protocol.swift | 95 ++++++++++++++++--- packages/bitbardd/Sources/SocketServer.swift | 26 +++--- 3 files changed, 189 insertions(+), 30 deletions(-) create mode 100644 packages/bitbardd/Sources/AnyCodable.swift diff --git a/packages/bitbardd/Sources/AnyCodable.swift b/packages/bitbardd/Sources/AnyCodable.swift new file mode 100644 index 0000000..c2da5fb --- /dev/null +++ b/packages/bitbardd/Sources/AnyCodable.swift @@ -0,0 +1,98 @@ +import Foundation + +/// A type-erased `Codable` wrapper for arbitrary JSON-compatible values. +/// Supported types: `nil`, `Bool`, `Int`, `Double`, `String`, +/// `[AnyCodable]`, `[String: AnyCodable]`. +struct AnyCodable: Codable { + let value: Any? + + init(_ value: Any?) { + self.value = value + } + + /// Encode an `Encodable` value into an `AnyCodable` tree via JSONSerialization. + /// encode → Data → JSONSerialization.jsonObject → recursive AnyCodable tree + init(encoding value: T, encoder: JSONEncoder = JSONEncoder()) throws { + let data = try encoder.encode(value) + let obj = try JSONSerialization.jsonObject(with: data, options: []) + self.value = AnyCodable.fromFoundation(obj) + } + + /// Recursively convert a Foundation JSON object into an AnyCodable-compatible value. + private static func fromFoundation(_ obj: Any) -> Any? { + switch obj { + case let dict as [String: Any]: + return dict.mapValues { AnyCodable(fromFoundation($0)) } + case let arr as [Any]: + return arr.map { AnyCodable(fromFoundation($0)) } + case let num as NSNumber: + // CFBooleanGetTypeID distinguishes NSNumber-wrapping-bool from numeric NSNumber + if CFGetTypeID(num) == CFBooleanGetTypeID() { + return num.boolValue + } else if num === kCFBooleanTrue || num === kCFBooleanFalse { + return num.boolValue + } else { + // Prefer Int if it round-trips, else Double + let d = num.doubleValue + let i = num.intValue + return Double(i) == d ? i : d + } + case let str as String: + return str + case is NSNull: + return nil + default: + return nil + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + value = nil + } else if let b = try? container.decode(Bool.self) { + value = b + } else if let i = try? container.decode(Int.self) { + value = i + } else if let d = try? container.decode(Double.self) { + value = d + } else if let s = try? container.decode(String.self) { + value = s + } else if let arr = try? container.decode([AnyCodable].self) { + value = arr + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "AnyCodable: unsupported JSON value" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case nil: + try container.encodeNil() + case let b as Bool: + try container.encode(b) + case let i as Int: + try container.encode(i) + case let d as Double: + try container.encode(d) + case let s as String: + try container.encode(s) + case let arr as [AnyCodable]: + try container.encode(arr) + case let dict as [String: AnyCodable]: + try container.encode(dict) + default: + throw EncodingError.invalidValue( + value as Any, + .init(codingPath: encoder.codingPath, + debugDescription: "AnyCodable: unsupported value type \(type(of: value as Any))") + ) + } + } +} diff --git a/packages/bitbardd/Sources/Protocol.swift b/packages/bitbardd/Sources/Protocol.swift index 596836a..ae5678c 100644 --- a/packages/bitbardd/Sources/Protocol.swift +++ b/packages/bitbardd/Sources/Protocol.swift @@ -2,31 +2,96 @@ import Foundation // MARK: - Request -enum DaemonCommand: String, Codable { - case flux - case truetone - case lock - case alert -} - -struct DaemonRequest: Codable { - let command: DaemonCommand - // flux / truetone - let action: String? - // alert - let message: String? +struct FluxRequest: Codable { + let action: String // "enable" | "disable" | "toggle" | "status" +} + +struct TrueToneRequest: Codable { + let action: String // "toggle" | "status" +} + +struct LockRequest: Codable {} // empty — produces {"lock":{}} on the wire + +struct AlertRequest: Codable { + let message: String let duration: Double? } +struct AudioDeviceRequest: Codable { + let action: String // "defaultInput" | "defaultOutput" | "mute" | "unmute" + let deviceId: UInt32? // present for "mute" / "unmute" +} + +/// Swift enum `Codable` synthesis wraps associated values under `_0`, producing +/// `{"flux":{"_0":{"action":"toggle"}}}` — not what we want. +/// We hand-write `encode(to:)` and `init(from:)` to produce the flat nested format: +/// `{"flux":{"action":"toggle"}}`, `{"lock":{}}`, etc. +enum DaemonRequest { + case flux(FluxRequest) + case truetone(TrueToneRequest) + case lock(LockRequest) + case alert(AlertRequest) + case audiodevice(AudioDeviceRequest) +} + +extension DaemonRequest: Encodable { + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .flux(let r): try container.encode(r, forKey: .flux) + case .truetone(let r): try container.encode(r, forKey: .truetone) + case .lock(let r): try container.encode(r, forKey: .lock) + case .alert(let r): try container.encode(r, forKey: .alert) + case .audiodevice(let r): try container.encode(r, forKey: .audiodevice) + } + } + + private enum CodingKeys: String, CodingKey { + case flux, truetone, lock, alert, audiodevice + } +} + +extension DaemonRequest: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let r = try? container.decode(FluxRequest.self, forKey: .flux) { + self = .flux(r) + } else if let r = try? container.decode(TrueToneRequest.self, forKey: .truetone) { + self = .truetone(r) + } else if let r = try? container.decode(LockRequest.self, forKey: .lock) { + self = .lock(r) + } else if let r = try? container.decode(AlertRequest.self, forKey: .alert) { + self = .alert(r) + } else if let r = try? container.decode(AudioDeviceRequest.self, forKey: .audiodevice) { + self = .audiodevice(r) + } else { + throw DecodingError.dataCorruptedError( + forKey: .flux, + in: container, + debugDescription: "DaemonRequest: unrecognised command" + ) + } + } + + private enum CodingKeys: String, CodingKey { + case flux, truetone, lock, alert, audiodevice + } +} + // MARK: - Response struct DaemonResponse: Codable { let ok: Bool - let result: String? + let result: AnyCodable? // wraps String or object; nil on error let error: String? static func success(_ result: String) -> DaemonResponse { - DaemonResponse(ok: true, result: result, error: nil) + DaemonResponse(ok: true, result: AnyCodable(result), error: nil) + } + + static func successData(_ data: T, encoder: JSONEncoder = JSONEncoder()) -> DaemonResponse { + let coded = try? AnyCodable(encoding: data, encoder: encoder) + return DaemonResponse(ok: true, result: coded, error: nil) } static func failure(_ error: String) -> DaemonResponse { diff --git a/packages/bitbardd/Sources/SocketServer.swift b/packages/bitbardd/Sources/SocketServer.swift index 3081af5..90f7ccd 100644 --- a/packages/bitbardd/Sources/SocketServer.swift +++ b/packages/bitbardd/Sources/SocketServer.swift @@ -5,6 +5,7 @@ class SocketServer { private let truetone = TrueToneHandler() private let lock = LockHandler() private let alert = AlertHandler() + private let audiodevice = AudioDeviceHandler() private let decoder = JSONDecoder() private let encoder = JSONEncoder() @@ -111,25 +112,20 @@ class SocketServer { } private func dispatch(_ request: DaemonRequest) -> DaemonResponse { - switch request.command { - case .flux: - return DispatchQueue.main.sync { flux.handle(action: request.action) } - case .truetone: - return DispatchQueue.main.sync { truetone.handle(action: request.action) } + switch request { + case .flux(let r): + return DispatchQueue.main.sync { flux.handle(action: r.action) } + case .truetone(let r): + return DispatchQueue.main.sync { truetone.handle(action: r.action) } case .lock: return DispatchQueue.main.sync { lock.handle() } - case .alert: - let message = request.message ?? "" - let duration = request.duration ?? 2.0 - // Use async instead of sync: alert.handle() schedules AppKit animations that post - // completion handlers back onto the main run loop. If a second alert request arrives - // while the first alert's fade-out completion is pending, main.sync blocks the - // background socket thread waiting for main — while main is itself blocked trying to - // drain its run loop — causing a deadlock that manifests as a segfault. Since the - // alert is fire-and-forget (the client only cares that it was accepted, not that it - // has finished fading), we dispatch asynchronously and return success immediately. + case .alert(let r): + let message = r.message + let duration = r.duration ?? 2.0 DispatchQueue.main.async { [self] in _ = alert.handle(message: message, duration: duration) } return .success("ok") + case .audiodevice(let r): + return DispatchQueue.main.sync { audiodevice.handle(request: r) } } } } From 198ce1315150e26009b2f5acd26231af9ec86d69 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Tue, 12 May 2026 21:15:43 +0200 Subject: [PATCH 23/28] =?UTF-8?q?feat(bitbardd):=20add=20AudioDeviceHandle?= =?UTF-8?q?r=20=E2=80=94=20defaultInput/Output,=20mute/unmute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bitbardd/Sources/AudioDeviceHandler.swift | 131 ++++++++++++++++++ packages/bitbardd/Sources/Protocol.swift | 16 +-- 2 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 packages/bitbardd/Sources/AudioDeviceHandler.swift diff --git a/packages/bitbardd/Sources/AudioDeviceHandler.swift b/packages/bitbardd/Sources/AudioDeviceHandler.swift new file mode 100644 index 0000000..45734b2 --- /dev/null +++ b/packages/bitbardd/Sources/AudioDeviceHandler.swift @@ -0,0 +1,131 @@ +import CoreAudio +import Foundation + +class AudioDeviceHandler { + private let encoder = JSONEncoder() + + func handle(request: AudioDeviceRequest) -> DaemonResponse { + switch request.action { + case "defaultInput": + return defaultDevice(selector: kAudioHardwarePropertyDefaultInputDevice) + case "defaultOutput": + return defaultDevice(selector: kAudioHardwarePropertyDefaultOutputDevice) + case "mute": + guard let deviceId = request.deviceId else { + return .failure("audiodevice: mute requires deviceId") + } + return setMute(deviceId: deviceId, muted: true) + case "unmute": + guard let deviceId = request.deviceId else { + return .failure("audiodevice: unmute requires deviceId") + } + return setMute(deviceId: deviceId, muted: false) + default: + return .failure("audiodevice: unknown action '\(request.action)'") + } + } + + // MARK: - Private + + private func defaultDevice(selector: AudioObjectPropertySelector) -> DaemonResponse { + var propertyAddress = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var deviceId: AudioDeviceID = kAudioObjectUnknown + var size = UInt32(MemoryLayout.size) + + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, nil, + &size, &deviceId + ) + guard status == noErr, deviceId != kAudioObjectUnknown else { + let label = selector == kAudioHardwarePropertyDefaultInputDevice ? "input" : "output" + return .failure("audiodevice: no default \(label) device") + } + + guard let name = deviceName(deviceId) else { + return .failure("audiodevice: could not read device name") + } + + let data = AudioDeviceData( + id: deviceId, + name: name, + isInput: hasStreams(deviceId: deviceId, scope: kAudioObjectPropertyScopeInput), + isOutput: hasStreams(deviceId: deviceId, scope: kAudioObjectPropertyScopeOutput) + ) + return .successData(data, encoder: encoder) + } + + private func deviceName(_ deviceId: AudioDeviceID) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioObjectPropertyName, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + // CFString is a reference type; use Unmanaged? so CoreAudio writes + // a CFStringRef into a pointer-sized slot and ARC is handled via takeRetainedValue(). + var nameRef: Unmanaged? = nil + var size = UInt32(MemoryLayout?>.size) + let status = AudioObjectGetPropertyData(deviceId, &address, 0, nil, &size, &nameRef) + guard status == noErr, let nameRef else { return nil } + return nameRef.takeRetainedValue() as String + } + + private func hasStreams(deviceId: AudioDeviceID, scope: AudioObjectPropertyScope) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, + mScope: scope, + mElement: kAudioObjectPropertyElementMain + ) + var size: UInt32 = 0 + let status = AudioObjectGetPropertyDataSize(deviceId, &address, 0, nil, &size) + return status == noErr && size > 0 + } + + private func setMute(deviceId: AudioDeviceID, muted: Bool) -> DaemonResponse { + var muteValue: UInt32 = muted ? 1 : 0 + let size = UInt32(MemoryLayout.size) + var errors: [String] = [] + + let scopes: [(AudioObjectPropertyScope, String)] = [ + (kAudioObjectPropertyScopeInput, "input"), + (kAudioObjectPropertyScopeOutput, "output"), + ] + + for (scope, label) in scopes { + guard hasStreams(deviceId: deviceId, scope: scope) else { continue } + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: scope, + mElement: kAudioObjectPropertyElementMain + ) + // Check mute is settable for this scope + var isSettable: DarwinBoolean = false + AudioObjectIsPropertySettable(deviceId, &address, &isSettable) + guard isSettable.boolValue else { continue } + + let status = AudioObjectSetPropertyData(deviceId, &address, 0, nil, size, &muteValue) + if status != noErr { + errors.append("audiodevice: failed to set mute on \(label) scope (err \(status))") + } + } + + if errors.isEmpty { + return .success("ok") + } + return .failure(errors.joined(separator: "; ")) + } +} + +// MARK: - Data model + +struct AudioDeviceData: Codable { + let id: UInt32 + let name: String + let isInput: Bool + let isOutput: Bool +} diff --git a/packages/bitbardd/Sources/Protocol.swift b/packages/bitbardd/Sources/Protocol.swift index ae5678c..c2f29c0 100644 --- a/packages/bitbardd/Sources/Protocol.swift +++ b/packages/bitbardd/Sources/Protocol.swift @@ -34,7 +34,11 @@ enum DaemonRequest { case audiodevice(AudioDeviceRequest) } -extension DaemonRequest: Encodable { +extension DaemonRequest: Codable { + private enum CodingKeys: String, CodingKey { + case flux, truetone, lock, alert, audiodevice + } + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { @@ -46,12 +50,6 @@ extension DaemonRequest: Encodable { } } - private enum CodingKeys: String, CodingKey { - case flux, truetone, lock, alert, audiodevice - } -} - -extension DaemonRequest: Decodable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let r = try? container.decode(FluxRequest.self, forKey: .flux) { @@ -72,10 +70,6 @@ extension DaemonRequest: Decodable { ) } } - - private enum CodingKeys: String, CodingKey { - case flux, truetone, lock, alert, audiodevice - } } // MARK: - Response From df2c8faa0cd5effb71889c22e63b1e6dafdd79d3 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Tue, 12 May 2026 21:18:26 +0200 Subject: [PATCH 24/28] feat(core): update DaemonRequest to nested wire format, unify result to unknown --- .../cli/src/commands/display/flux.spec.ts | 2 +- packages/cli/src/commands/display/flux.ts | 4 ++-- packages/cli/src/commands/display/truetone.ts | 4 ++-- packages/core/src/alert.ts | 2 +- packages/core/src/caffeinate.spec.ts | 2 +- packages/core/src/caffeinate.ts | 2 +- packages/core/src/daemon.spec.ts | 19 ++++++++++++++----- packages/core/src/daemon.ts | 15 ++++++++++----- 8 files changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/display/flux.spec.ts b/packages/cli/src/commands/display/flux.spec.ts index ff7223b..0628932 100644 --- a/packages/cli/src/commands/display/flux.spec.ts +++ b/packages/cli/src/commands/display/flux.spec.ts @@ -28,7 +28,7 @@ describe('flux command', () => { await runCommand(fluxCmd, { rawArgs: [] }); - expect(sendCommand).toHaveBeenCalledWith({ command: 'flux', action: 'toggle' }); + expect(sendCommand).toHaveBeenCalledWith({ flux: { action: 'toggle' } }); }); it('prints enabled message when result is enabled', async () => { diff --git a/packages/cli/src/commands/display/flux.ts b/packages/cli/src/commands/display/flux.ts index df71367..1ff8733 100644 --- a/packages/cli/src/commands/display/flux.ts +++ b/packages/cli/src/commands/display/flux.ts @@ -11,8 +11,8 @@ export default defineCommand({ plugins: [macosPlugin], async run() { try { - const res = await sendCommand({ command: 'flux', action: 'toggle' }); - if (res.result === 'enabled') { + const res = await sendCommand({ flux: { action: 'toggle' } }); + if ((res.result as string) === 'enabled') { console.log(chalk.bold(chalk.yellow('Screen tint enabled.'))); } else { console.log(chalk.bold(chalk.green('Screen tint disabled.'))); diff --git a/packages/cli/src/commands/display/truetone.ts b/packages/cli/src/commands/display/truetone.ts index 9ecada6..709a00b 100644 --- a/packages/cli/src/commands/display/truetone.ts +++ b/packages/cli/src/commands/display/truetone.ts @@ -9,8 +9,8 @@ export default defineCommand({ }, async run() { try { - const res = await sendCommand({ command: 'truetone', action: 'toggle' }); - if (res.result === 'enabled') { + const res = await sendCommand({ truetone: { action: 'toggle' } }); + if ((res.result as string) === 'enabled') { console.log(chalk.bold(chalk.green('True Tone enabled.'))); } else { console.log(chalk.bold(chalk.yellow('True Tone disabled.'))); diff --git a/packages/core/src/alert.ts b/packages/core/src/alert.ts index 867cc2a..0d83f16 100644 --- a/packages/core/src/alert.ts +++ b/packages/core/src/alert.ts @@ -13,7 +13,7 @@ import { isMacOS } from './platform.js'; export function showAlert(message: string, duration = 2): void { if (!isMacOS()) return; - sendCommand({ command: 'alert', message, duration }).catch((err: Error) => { + sendCommand({ alert: { message, duration } }).catch((err: Error) => { process.stderr.write(`bitbard: ${err.message}\n`); }); } diff --git a/packages/core/src/caffeinate.spec.ts b/packages/core/src/caffeinate.spec.ts index a6327a9..d841ac3 100644 --- a/packages/core/src/caffeinate.spec.ts +++ b/packages/core/src/caffeinate.spec.ts @@ -21,7 +21,7 @@ describe('caffeinate', () => { lockScreen(); - expect(sendCommand).toHaveBeenCalledWith({ command: 'lock' }); + expect(sendCommand).toHaveBeenCalledWith({ lock: {} }); }); }); diff --git a/packages/core/src/caffeinate.ts b/packages/core/src/caffeinate.ts index 7c4628f..7bd1a38 100644 --- a/packages/core/src/caffeinate.ts +++ b/packages/core/src/caffeinate.ts @@ -2,7 +2,7 @@ import { execSync } from 'node:child_process'; import { sendCommand } from './daemon.js'; export function lockScreen(): void { - sendCommand({ command: 'lock' }).catch((err: Error) => { + sendCommand({ lock: {} }).catch((err: Error) => { process.stderr.write(`bitbard: ${err.message}\n`); }); } diff --git a/packages/core/src/daemon.spec.ts b/packages/core/src/daemon.spec.ts index 50fd727..50352b2 100644 --- a/packages/core/src/daemon.spec.ts +++ b/packages/core/src/daemon.spec.ts @@ -48,7 +48,7 @@ describe('sendCommand', () => { await startMockServer({ ok: true, result: 'disabled' }); const { sendCommand } = await import('./daemon.js'); - const res = await sendCommand({ command: 'flux', action: 'status' }); + const res = await sendCommand({ flux: { action: 'status' } }); expect(res).toEqual({ ok: true, result: 'disabled' }); }); @@ -56,13 +56,13 @@ describe('sendCommand', () => { await startMockServer({ ok: false, error: 'flux: unknown action' }); const { sendCommand } = await import('./daemon.js'); - await expect(sendCommand({ command: 'flux', action: 'bad' as never })).rejects.toThrow('flux: unknown action'); + await expect(sendCommand({ flux: { action: 'bad' as never } })).rejects.toThrow('flux: unknown action'); }); it('throws with helpful message when socket does not exist', async () => { const { sendCommand } = await import('./daemon.js'); - await expect(sendCommand({ command: 'lock' })).rejects.toThrow('bitbardd is not running'); + await expect(sendCommand({ lock: {} })).rejects.toThrow('bitbardd is not running'); }); it('sends the request as newline-terminated JSON', async () => { @@ -82,8 +82,17 @@ describe('sendCommand', () => { }); const { sendCommand } = await import('./daemon.js'); - await sendCommand({ command: 'lock' }); + await sendCommand({ lock: {} }); - expect(received.trimEnd()).toBe(JSON.stringify({ command: 'lock' })); + expect(received.trimEnd()).toBe(JSON.stringify({ lock: {} })); + }); + + it('passes through an object result as-is', async () => { + const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false }; + await startMockServer({ ok: true, result: deviceData }); + const { sendCommand } = await import('./daemon.js'); + + const res = await sendCommand({ audiodevice: { action: 'defaultInput' } }); + expect(res).toEqual({ ok: true, result: deviceData }); }); }); diff --git a/packages/core/src/daemon.ts b/packages/core/src/daemon.ts index 2d5513a..d81e4c7 100644 --- a/packages/core/src/daemon.ts +++ b/packages/core/src/daemon.ts @@ -4,15 +4,20 @@ import { join } from 'node:path'; // ─── Types ──────────────────────────────────────────────────────────────────── +type AudioDevicePayload = + | { action: 'defaultInput' | 'defaultOutput' } + | { action: 'mute' | 'unmute'; deviceId: number }; + export type DaemonRequest = - | { command: 'flux'; action: 'enable' | 'disable' | 'toggle' | 'status' } - | { command: 'truetone'; action: 'toggle' | 'status' } - | { command: 'lock' } - | { command: 'alert'; message: string; duration?: number }; + | { flux: { action: 'enable' | 'disable' | 'toggle' | 'status' } } + | { truetone: { action: 'toggle' | 'status' } } + | { lock: Record } + | { alert: { message: string; duration?: number } } + | { audiodevice: AudioDevicePayload }; export interface DaemonResponse { ok: boolean; - result?: string; + result?: unknown; error?: string; } From ac61333eccaae7fdc92355dfde147a6d26f94d5d Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Tue, 12 May 2026 21:20:57 +0200 Subject: [PATCH 25/28] =?UTF-8?q?feat(core):=20add=20AudioDevice=20class?= =?UTF-8?q?=20=E2=80=94=20defaultInput/Output,=20mute,=20unmute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/audiodevice.spec.ts | 128 ++++++++++++++++++++++++++ packages/core/src/audiodevice.ts | 64 +++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 packages/core/src/audiodevice.spec.ts create mode 100644 packages/core/src/audiodevice.ts diff --git a/packages/core/src/audiodevice.spec.ts b/packages/core/src/audiodevice.spec.ts new file mode 100644 index 0000000..955ef34 --- /dev/null +++ b/packages/core/src/audiodevice.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as net from 'node:net'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +describe('AudioDevice', () => { + let sockPath: string; + let server: net.Server; + + beforeEach(async () => { + sockPath = path.join(os.tmpdir(), `bitbardd-test-audio-${process.pid}.sock`); + try { + fs.unlinkSync(sockPath); + } catch {} + process.env.BITBARD_SOCKET_PATH = sockPath; + }); + + afterEach(async () => { + await new Promise((resolve) => server?.close(() => resolve())); + try { + fs.unlinkSync(sockPath); + } catch {} + delete process.env.BITBARD_SOCKET_PATH; + vi.resetModules(); + }); + + function startMockServer(response: object): Promise { + return new Promise((resolve) => { + server = net.createServer((conn) => { + let buf = ''; + conn.setEncoding('utf8'); + conn.on('data', (chunk) => { + buf += chunk; + if (buf.includes('\n')) { + conn.write(JSON.stringify(response) + '\n'); + conn.end(); + } + }); + }); + server.listen(sockPath, () => resolve()); + }); + } + + function startCapturingServer(response: object): Promise<() => string> { + let received = ''; + return new Promise((resolve) => { + server = net.createServer((conn) => { + conn.setEncoding('utf8'); + conn.on('data', (chunk) => { + received += chunk; + if (received.includes('\n')) { + conn.write(JSON.stringify(response) + '\n'); + conn.end(); + } + }); + }); + server.listen(sockPath, () => resolve(() => received.trimEnd())); + }); + } + + it('defaultInput() returns an AudioDevice with correct properties', async () => { + const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false }; + await startMockServer({ ok: true, result: deviceData }); + const { AudioDevice } = await import('./audiodevice.js'); + + const device = await AudioDevice.defaultInput(); + expect(device).not.toBeNull(); + expect(device!.id).toBe(67); + expect(device!.name).toBe('MacBook Pro Microphone'); + expect(device!.isInput).toBe(true); + expect(device!.isOutput).toBe(false); + }); + + it('defaultOutput() returns an AudioDevice with correct properties', async () => { + const deviceData = { id: 72, name: 'MacBook Pro Speakers', isInput: false, isOutput: true }; + await startMockServer({ ok: true, result: deviceData }); + const { AudioDevice } = await import('./audiodevice.js'); + + const device = await AudioDevice.defaultOutput(); + expect(device).not.toBeNull(); + expect(device!.id).toBe(72); + expect(device!.name).toBe('MacBook Pro Speakers'); + expect(device!.isInput).toBe(false); + expect(device!.isOutput).toBe(true); + }); + + it('defaultInput() returns null when daemon returns no device error', async () => { + await startMockServer({ ok: false, error: 'audiodevice: no default input device' }); + const { AudioDevice } = await import('./audiodevice.js'); + + const device = await AudioDevice.defaultInput(); + expect(device).toBeNull(); + }); + + it('defaultInput() returns null when socket does not exist (daemon not running)', async () => { + const { AudioDevice } = await import('./audiodevice.js'); + + const device = await AudioDevice.defaultInput(); + expect(device).toBeNull(); + }); + + it('mute() sends the correct request to the daemon', async () => { + const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false }; + const _getReceived = await startCapturingServer({ ok: true, result: deviceData }); + const { AudioDevice } = await import('./audiodevice.js'); + const device = await AudioDevice.defaultInput(); + + await new Promise((resolve) => server.close(() => resolve())); + const getMuteReceived = await startCapturingServer({ ok: true, result: 'ok' }); + + await device!.mute(); + expect(getMuteReceived()).toBe(JSON.stringify({ audiodevice: { action: 'mute', deviceId: 67 } })); + }); + + it('unmute() sends the correct request to the daemon', async () => { + const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false }; + await startCapturingServer({ ok: true, result: deviceData }); + const { AudioDevice } = await import('./audiodevice.js'); + const device = await AudioDevice.defaultInput(); + + await new Promise((resolve) => server.close(() => resolve())); + const getReceived = await startCapturingServer({ ok: true, result: 'ok' }); + + await device!.unmute(); + expect(getReceived()).toBe(JSON.stringify({ audiodevice: { action: 'unmute', deviceId: 67 } })); + }); +}); diff --git a/packages/core/src/audiodevice.ts b/packages/core/src/audiodevice.ts new file mode 100644 index 0000000..0a3c3f0 --- /dev/null +++ b/packages/core/src/audiodevice.ts @@ -0,0 +1,64 @@ +import { sendCommand, DaemonError } from './daemon.js'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface AudioDeviceData { + id: number; + name: string; + isInput: boolean; + isOutput: boolean; +} + +function isAudioDeviceData(value: unknown): value is AudioDeviceData { + if (typeof value !== 'object' || value === null) return false; + const v = value as Record; + return ( + typeof v.id === 'number' && + typeof v.name === 'string' && + typeof v.isInput === 'boolean' && + typeof v.isOutput === 'boolean' + ); +} + +// ─── Class ──────────────────────────────────────────────────────────────────── + +export class AudioDevice { + readonly id: number; + readonly name: string; + readonly isInput: boolean; + readonly isOutput: boolean; + + constructor(data: AudioDeviceData) { + this.id = data.id; + this.name = data.name; + this.isInput = data.isInput; + this.isOutput = data.isOutput; + } + + async mute(): Promise { + await sendCommand({ audiodevice: { action: 'mute', deviceId: this.id } }); + } + + async unmute(): Promise { + await sendCommand({ audiodevice: { action: 'unmute', deviceId: this.id } }); + } + + static async defaultInput(): Promise { + return AudioDevice.fetchDefault('defaultInput'); + } + + static async defaultOutput(): Promise { + return AudioDevice.fetchDefault('defaultOutput'); + } + + private static async fetchDefault(action: 'defaultInput' | 'defaultOutput'): Promise { + try { + const res = await sendCommand({ audiodevice: { action } }); + if (!isAudioDeviceData(res.result)) return null; + return new AudioDevice(res.result); + } catch (err) { + if (err instanceof DaemonError) return null; + throw err; + } + } +} From 21fa275481a08802eb06589b2db68e9504b2a5a8 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Tue, 12 May 2026 22:00:48 +0200 Subject: [PATCH 26/28] fix(bitbardd): ignore SIGPIPE so liveness probe can't kill the running daemon --- packages/bitbardd/Sources/main.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bitbardd/Sources/main.swift b/packages/bitbardd/Sources/main.swift index 4227c33..f06def9 100644 --- a/packages/bitbardd/Sources/main.swift +++ b/packages/bitbardd/Sources/main.swift @@ -1,6 +1,10 @@ import AppKit import Foundation +// Ignore SIGPIPE so a client closing the connection mid-send (e.g. the stale-socket +// liveness probe in a second instance) doesn't kill the daemon. +signal(SIGPIPE, SIG_IGN) + let socketPath: String = { let dataDir = (ProcessInfo.processInfo.environment["BITBARD_DATA_DIR"] ?? (NSHomeDirectory() + "/.local/share/bitbard")) From ba8801ca1088335c1d94784667899e146e7a6828 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Wed, 13 May 2026 17:46:34 +0200 Subject: [PATCH 27/28] feat: add audio input command with mute, unmute, and status subcommands --- .../bitbardd/Sources/AudioDeviceHandler.swift | 24 ++- packages/cli/src/bitbard.ts | 2 + packages/cli/src/commands/audio/index.ts | 14 ++ .../cli/src/commands/audio/input/index.ts | 18 +++ .../src/commands/audio/input/input.spec.ts | 137 ++++++++++++++++++ packages/cli/src/commands/audio/input/mute.ts | 25 ++++ .../cli/src/commands/audio/input/status.ts | 21 +++ .../cli/src/commands/audio/input/unmute.ts | 25 ++++ packages/core/src/audiodevice.spec.ts | 22 ++- packages/core/src/audiodevice.ts | 6 +- 10 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/commands/audio/index.ts create mode 100644 packages/cli/src/commands/audio/input/index.ts create mode 100644 packages/cli/src/commands/audio/input/input.spec.ts create mode 100644 packages/cli/src/commands/audio/input/mute.ts create mode 100644 packages/cli/src/commands/audio/input/status.ts create mode 100644 packages/cli/src/commands/audio/input/unmute.ts diff --git a/packages/bitbardd/Sources/AudioDeviceHandler.swift b/packages/bitbardd/Sources/AudioDeviceHandler.swift index 45734b2..33ab3d6 100644 --- a/packages/bitbardd/Sources/AudioDeviceHandler.swift +++ b/packages/bitbardd/Sources/AudioDeviceHandler.swift @@ -51,11 +51,18 @@ class AudioDeviceHandler { return .failure("audiodevice: could not read device name") } + let isInput = hasStreams(deviceId: deviceId, scope: kAudioObjectPropertyScopeInput) + let isOutput = hasStreams(deviceId: deviceId, scope: kAudioObjectPropertyScopeOutput) + let scope: AudioObjectPropertyScope = isInput + ? kAudioObjectPropertyScopeInput + : kAudioObjectPropertyScopeOutput + let data = AudioDeviceData( id: deviceId, name: name, - isInput: hasStreams(deviceId: deviceId, scope: kAudioObjectPropertyScopeInput), - isOutput: hasStreams(deviceId: deviceId, scope: kAudioObjectPropertyScopeOutput) + isInput: isInput, + isOutput: isOutput, + isMuted: isMuted(deviceId: deviceId, scope: scope) ) return .successData(data, encoder: encoder) } @@ -86,6 +93,18 @@ class AudioDeviceHandler { return status == noErr && size > 0 } + private func isMuted(deviceId: AudioDeviceID, scope: AudioObjectPropertyScope) -> Bool { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyMute, + mScope: scope, + mElement: kAudioObjectPropertyElementMain + ) + var muteValue: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(deviceId, &address, 0, nil, &size, &muteValue) + return status == noErr && muteValue != 0 + } + private func setMute(deviceId: AudioDeviceID, muted: Bool) -> DaemonResponse { var muteValue: UInt32 = muted ? 1 : 0 let size = UInt32(MemoryLayout.size) @@ -128,4 +147,5 @@ struct AudioDeviceData: Codable { let name: String let isInput: Bool let isOutput: Bool + let isMuted: Bool } diff --git a/packages/cli/src/bitbard.ts b/packages/cli/src/bitbard.ts index 7605796..6105374 100644 --- a/packages/cli/src/bitbard.ts +++ b/packages/cli/src/bitbard.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { defineCommand, runMain } from 'citty'; +import audio from './commands/audio/index.js'; import display from './commands/display/index.js'; import spotify from './commands/spotify/index.js'; import system from './commands/system/index.js'; @@ -13,6 +14,7 @@ const main = defineCommand({ description: 'Bitbard CLI', }, subCommands: { + audio, display, spotify, system, diff --git a/packages/cli/src/commands/audio/index.ts b/packages/cli/src/commands/audio/index.ts new file mode 100644 index 0000000..f84f319 --- /dev/null +++ b/packages/cli/src/commands/audio/index.ts @@ -0,0 +1,14 @@ +import { defineCommand } from 'citty'; +import { macosPlugin } from '../../plugins/macos.js'; +import input from './input/index.js'; + +export default defineCommand({ + meta: { + name: 'audio', + description: 'Audio-related commands', + }, + plugins: [macosPlugin], + subCommands: { + input, + }, +}); diff --git a/packages/cli/src/commands/audio/input/index.ts b/packages/cli/src/commands/audio/input/index.ts new file mode 100644 index 0000000..45f8ace --- /dev/null +++ b/packages/cli/src/commands/audio/input/index.ts @@ -0,0 +1,18 @@ +import { defineCommand } from 'citty'; +import { macosPlugin } from '../../../plugins/macos.js'; +import mute from './mute.js'; +import unmute from './unmute.js'; +import status from './status.js'; + +export default defineCommand({ + meta: { + name: 'input', + description: 'Audio input device commands', + }, + plugins: [macosPlugin], + subCommands: { + mute, + unmute, + status, + }, +}); diff --git a/packages/cli/src/commands/audio/input/input.spec.ts b/packages/cli/src/commands/audio/input/input.spec.ts new file mode 100644 index 0000000..8c20831 --- /dev/null +++ b/packages/cli/src/commands/audio/input/input.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { runCommand } from 'citty'; +import muteCmd from './mute.js'; +import unmuteCmd from './unmute.js'; +import statusCmd from './status.js'; + +vi.mock('@bitbard/core/audiodevice.js', () => ({ + AudioDevice: { + defaultInput: vi.fn().mockResolvedValue({ + id: 67, + name: 'MacBook Pro Microphone', + isInput: true, + isOutput: false, + isMuted: false, + mute: vi.fn().mockResolvedValue(undefined), + unmute: vi.fn().mockResolvedValue(undefined), + }), + }, +})); + +vi.mock('@bitbard/core/platform.js', () => ({ + isMacOS: vi.fn().mockReturnValue(true), +})); + +describe('audio input mute', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('mutes the default input device', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + + await runCommand(muteCmd, { rawArgs: [] }); + + expect(AudioDevice.defaultInput).toHaveBeenCalledOnce(); + const device = await AudioDevice.defaultInput(); + expect(device!.mute).toHaveBeenCalled(); + }); + + it('logs an error when no default input device is found', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + vi.mocked(AudioDevice.defaultInput).mockResolvedValueOnce(null); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(muteCmd, { rawArgs: [] }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No default input device found')); + consoleSpy.mockRestore(); + }); + + it('exits when not on macOS', async () => { + const { isMacOS } = await import('@bitbard/core/platform.js'); + vi.mocked(isMacOS).mockReturnValueOnce(false); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + + await expect(runCommand(muteCmd, { rawArgs: [] })).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); +}); + +describe('audio input unmute', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('unmutes the default input device', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + + await runCommand(unmuteCmd, { rawArgs: [] }); + + expect(AudioDevice.defaultInput).toHaveBeenCalledOnce(); + const device = await AudioDevice.defaultInput(); + expect(device!.unmute).toHaveBeenCalled(); + }); + + it('logs an error when no default input device is found', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + vi.mocked(AudioDevice.defaultInput).mockResolvedValueOnce(null); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(unmuteCmd, { rawArgs: [] }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No default input device found')); + consoleSpy.mockRestore(); + }); +}); + +describe('audio input status', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('prints the default input device name', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(statusCmd, { rawArgs: [] }); + + expect(AudioDevice.defaultInput).toHaveBeenCalledOnce(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('MacBook Pro Microphone')); + consoleSpy.mockRestore(); + }); + + it('shows muted status when device is muted', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + vi.mocked(AudioDevice.defaultInput).mockResolvedValueOnce({ + id: 67, + name: 'MacBook Pro Microphone', + isInput: true, + isOutput: false, + isMuted: true, + mute: vi.fn().mockResolvedValue(undefined), + unmute: vi.fn().mockResolvedValue(undefined), + }); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(statusCmd, { rawArgs: [] }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('muted')); + consoleSpy.mockRestore(); + }); + + it('logs an error when no default input device is found', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + vi.mocked(AudioDevice.defaultInput).mockResolvedValueOnce(null); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(statusCmd, { rawArgs: [] }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No default input device found')); + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/commands/audio/input/mute.ts b/packages/cli/src/commands/audio/input/mute.ts new file mode 100644 index 0000000..d2fe5bb --- /dev/null +++ b/packages/cli/src/commands/audio/input/mute.ts @@ -0,0 +1,25 @@ +import { defineCommand } from 'citty'; +import chalk from 'chalk'; +import { AudioDevice } from '@bitbard/core/audiodevice.js'; +import { macosPlugin } from '../../../plugins/macos.js'; + +export default defineCommand({ + meta: { + name: 'mute', + description: 'Mute the default audio input device', + }, + plugins: [macosPlugin], + async run() { + const device = await AudioDevice.defaultInput(); + if (!device) { + console.log(chalk.bold(chalk.red('No default input device found.'))); + return; + } + try { + await device.mute(); + console.log(chalk.bold(chalk.green(`Muted: ${device.name}`))); + } catch (err) { + console.log(chalk.bold(chalk.red(`Failed to mute: ${(err as Error).message}`))); + } + }, +}); diff --git a/packages/cli/src/commands/audio/input/status.ts b/packages/cli/src/commands/audio/input/status.ts new file mode 100644 index 0000000..2ecd18e --- /dev/null +++ b/packages/cli/src/commands/audio/input/status.ts @@ -0,0 +1,21 @@ +import { defineCommand } from 'citty'; +import chalk from 'chalk'; +import { AudioDevice } from '@bitbard/core/audiodevice.js'; +import { macosPlugin } from '../../../plugins/macos.js'; + +export default defineCommand({ + meta: { + name: 'status', + description: 'Show the default audio input device', + }, + plugins: [macosPlugin], + async run() { + const device = await AudioDevice.defaultInput(); + if (!device) { + console.log(chalk.bold(chalk.red('No default input device found.'))); + return; + } + const muteStatus = device.isMuted ? chalk.red('muted') : chalk.green('active'); + console.log(`${chalk.bold(device.name)} — ${muteStatus}`); + }, +}); diff --git a/packages/cli/src/commands/audio/input/unmute.ts b/packages/cli/src/commands/audio/input/unmute.ts new file mode 100644 index 0000000..7249f69 --- /dev/null +++ b/packages/cli/src/commands/audio/input/unmute.ts @@ -0,0 +1,25 @@ +import { defineCommand } from 'citty'; +import chalk from 'chalk'; +import { AudioDevice } from '@bitbard/core/audiodevice.js'; +import { macosPlugin } from '../../../plugins/macos.js'; + +export default defineCommand({ + meta: { + name: 'unmute', + description: 'Unmute the default audio input device', + }, + plugins: [macosPlugin], + async run() { + const device = await AudioDevice.defaultInput(); + if (!device) { + console.log(chalk.bold(chalk.red('No default input device found.'))); + return; + } + try { + await device.unmute(); + console.log(chalk.bold(chalk.green(`Unmuted: ${device.name}`))); + } catch (err) { + console.log(chalk.bold(chalk.red(`Failed to unmute: ${(err as Error).message}`))); + } + }, +}); diff --git a/packages/core/src/audiodevice.spec.ts b/packages/core/src/audiodevice.spec.ts index 955ef34..2b5d20e 100644 --- a/packages/core/src/audiodevice.spec.ts +++ b/packages/core/src/audiodevice.spec.ts @@ -60,7 +60,7 @@ describe('AudioDevice', () => { } it('defaultInput() returns an AudioDevice with correct properties', async () => { - const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false }; + const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false, isMuted: false }; await startMockServer({ ok: true, result: deviceData }); const { AudioDevice } = await import('./audiodevice.js'); @@ -70,10 +70,21 @@ describe('AudioDevice', () => { expect(device!.name).toBe('MacBook Pro Microphone'); expect(device!.isInput).toBe(true); expect(device!.isOutput).toBe(false); + expect(device!.isMuted).toBe(false); + }); + + it('defaultInput() returns an AudioDevice with isMuted true when muted', async () => { + const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false, isMuted: true }; + await startMockServer({ ok: true, result: deviceData }); + const { AudioDevice } = await import('./audiodevice.js'); + + const device = await AudioDevice.defaultInput(); + expect(device).not.toBeNull(); + expect(device!.isMuted).toBe(true); }); it('defaultOutput() returns an AudioDevice with correct properties', async () => { - const deviceData = { id: 72, name: 'MacBook Pro Speakers', isInput: false, isOutput: true }; + const deviceData = { id: 72, name: 'MacBook Pro Speakers', isInput: false, isOutput: true, isMuted: false }; await startMockServer({ ok: true, result: deviceData }); const { AudioDevice } = await import('./audiodevice.js'); @@ -83,6 +94,7 @@ describe('AudioDevice', () => { expect(device!.name).toBe('MacBook Pro Speakers'); expect(device!.isInput).toBe(false); expect(device!.isOutput).toBe(true); + expect(device!.isMuted).toBe(false); }); it('defaultInput() returns null when daemon returns no device error', async () => { @@ -101,8 +113,8 @@ describe('AudioDevice', () => { }); it('mute() sends the correct request to the daemon', async () => { - const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false }; - const _getReceived = await startCapturingServer({ ok: true, result: deviceData }); + const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false, isMuted: false }; + await startCapturingServer({ ok: true, result: deviceData }); const { AudioDevice } = await import('./audiodevice.js'); const device = await AudioDevice.defaultInput(); @@ -114,7 +126,7 @@ describe('AudioDevice', () => { }); it('unmute() sends the correct request to the daemon', async () => { - const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false }; + const deviceData = { id: 67, name: 'MacBook Pro Microphone', isInput: true, isOutput: false, isMuted: true }; await startCapturingServer({ ok: true, result: deviceData }); const { AudioDevice } = await import('./audiodevice.js'); const device = await AudioDevice.defaultInput(); diff --git a/packages/core/src/audiodevice.ts b/packages/core/src/audiodevice.ts index 0a3c3f0..bc6f3c5 100644 --- a/packages/core/src/audiodevice.ts +++ b/packages/core/src/audiodevice.ts @@ -7,6 +7,7 @@ interface AudioDeviceData { name: string; isInput: boolean; isOutput: boolean; + isMuted: boolean; } function isAudioDeviceData(value: unknown): value is AudioDeviceData { @@ -16,7 +17,8 @@ function isAudioDeviceData(value: unknown): value is AudioDeviceData { typeof v.id === 'number' && typeof v.name === 'string' && typeof v.isInput === 'boolean' && - typeof v.isOutput === 'boolean' + typeof v.isOutput === 'boolean' && + typeof v.isMuted === 'boolean' ); } @@ -27,12 +29,14 @@ export class AudioDevice { readonly name: string; readonly isInput: boolean; readonly isOutput: boolean; + readonly isMuted: boolean; constructor(data: AudioDeviceData) { this.id = data.id; this.name = data.name; this.isInput = data.isInput; this.isOutput = data.isOutput; + this.isMuted = data.isMuted; } async mute(): Promise { From 72991c242441b0cfe8ebb626914f6e88cd397650 Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Wed, 13 May 2026 17:51:28 +0200 Subject: [PATCH 28/28] feat: add audio output command with mute, unmute, and status subcommands --- packages/cli/src/commands/audio/index.ts | 2 + .../cli/src/commands/audio/output/index.ts | 18 +++ .../cli/src/commands/audio/output/mute.ts | 25 ++++ .../src/commands/audio/output/output.spec.ts | 138 ++++++++++++++++++ .../cli/src/commands/audio/output/status.ts | 21 +++ .../cli/src/commands/audio/output/unmute.ts | 25 ++++ 6 files changed, 229 insertions(+) create mode 100644 packages/cli/src/commands/audio/output/index.ts create mode 100644 packages/cli/src/commands/audio/output/mute.ts create mode 100644 packages/cli/src/commands/audio/output/output.spec.ts create mode 100644 packages/cli/src/commands/audio/output/status.ts create mode 100644 packages/cli/src/commands/audio/output/unmute.ts diff --git a/packages/cli/src/commands/audio/index.ts b/packages/cli/src/commands/audio/index.ts index f84f319..5366f1c 100644 --- a/packages/cli/src/commands/audio/index.ts +++ b/packages/cli/src/commands/audio/index.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty'; import { macosPlugin } from '../../plugins/macos.js'; import input from './input/index.js'; +import output from './output/index.js'; export default defineCommand({ meta: { @@ -10,5 +11,6 @@ export default defineCommand({ plugins: [macosPlugin], subCommands: { input, + output, }, }); diff --git a/packages/cli/src/commands/audio/output/index.ts b/packages/cli/src/commands/audio/output/index.ts new file mode 100644 index 0000000..70b63b4 --- /dev/null +++ b/packages/cli/src/commands/audio/output/index.ts @@ -0,0 +1,18 @@ +import { defineCommand } from 'citty'; +import { macosPlugin } from '../../../plugins/macos.js'; +import mute from './mute.js'; +import unmute from './unmute.js'; +import status from './status.js'; + +export default defineCommand({ + meta: { + name: 'output', + description: 'Audio output device commands', + }, + plugins: [macosPlugin], + subCommands: { + mute, + unmute, + status, + }, +}); diff --git a/packages/cli/src/commands/audio/output/mute.ts b/packages/cli/src/commands/audio/output/mute.ts new file mode 100644 index 0000000..d66e680 --- /dev/null +++ b/packages/cli/src/commands/audio/output/mute.ts @@ -0,0 +1,25 @@ +import { defineCommand } from 'citty'; +import chalk from 'chalk'; +import { AudioDevice } from '@bitbard/core/audiodevice.js'; +import { macosPlugin } from '../../../plugins/macos.js'; + +export default defineCommand({ + meta: { + name: 'mute', + description: 'Mute the default audio output device', + }, + plugins: [macosPlugin], + async run() { + const device = await AudioDevice.defaultOutput(); + if (!device) { + console.log(chalk.bold(chalk.red('No default output device found.'))); + return; + } + try { + await device.mute(); + console.log(chalk.bold(chalk.green(`Muted: ${device.name}`))); + } catch (err) { + console.log(chalk.bold(chalk.red(`Failed to mute: ${(err as Error).message}`))); + } + }, +}); diff --git a/packages/cli/src/commands/audio/output/output.spec.ts b/packages/cli/src/commands/audio/output/output.spec.ts new file mode 100644 index 0000000..c586ac8 --- /dev/null +++ b/packages/cli/src/commands/audio/output/output.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { runCommand } from 'citty'; +import muteCmd from './mute.js'; +import unmuteCmd from './unmute.js'; +import statusCmd from './status.js'; + +vi.mock('@bitbard/core/audiodevice.js', () => ({ + AudioDevice: { + defaultOutput: vi.fn().mockResolvedValue({ + id: 72, + name: 'MacBook Pro Speakers', + isInput: false, + isOutput: true, + isMuted: false, + mute: vi.fn().mockResolvedValue(undefined), + unmute: vi.fn().mockResolvedValue(undefined), + }), + }, +})); + +vi.mock('@bitbard/core/platform.js', () => ({ + isMacOS: vi.fn().mockReturnValue(true), +})); + +describe('audio output mute', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('mutes the default output device', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + + await runCommand(muteCmd, { rawArgs: [] }); + + expect(AudioDevice.defaultOutput).toHaveBeenCalledOnce(); + const device = await AudioDevice.defaultOutput(); + expect(device!.mute).toHaveBeenCalled(); + }); + + it('logs an error when no default output device is found', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + vi.mocked(AudioDevice.defaultOutput).mockResolvedValueOnce(null); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(muteCmd, { rawArgs: [] }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No default output device found')); + consoleSpy.mockRestore(); + }); + + it('exits when not on macOS', async () => { + const { isMacOS } = await import('@bitbard/core/platform.js'); + vi.mocked(isMacOS).mockReturnValueOnce(false); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + + await expect(runCommand(muteCmd, { rawArgs: [] })).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); +}); + +describe('audio output unmute', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('unmutes the default output device', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + + await runCommand(unmuteCmd, { rawArgs: [] }); + + expect(AudioDevice.defaultOutput).toHaveBeenCalledOnce(); + const device = await AudioDevice.defaultOutput(); + expect(device!.unmute).toHaveBeenCalled(); + }); + + it('logs an error when no default output device is found', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + vi.mocked(AudioDevice.defaultOutput).mockResolvedValueOnce(null); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(unmuteCmd, { rawArgs: [] }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No default output device found')); + consoleSpy.mockRestore(); + }); +}); + +describe('audio output status', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('prints the default output device name and active status', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(statusCmd, { rawArgs: [] }); + + expect(AudioDevice.defaultOutput).toHaveBeenCalledOnce(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('MacBook Pro Speakers')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('active')); + consoleSpy.mockRestore(); + }); + + it('shows muted status when device is muted', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + vi.mocked(AudioDevice.defaultOutput).mockResolvedValueOnce({ + id: 72, + name: 'MacBook Pro Speakers', + isInput: false, + isOutput: true, + isMuted: true, + mute: vi.fn().mockResolvedValue(undefined), + unmute: vi.fn().mockResolvedValue(undefined), + }); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(statusCmd, { rawArgs: [] }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('muted')); + consoleSpy.mockRestore(); + }); + + it('logs an error when no default output device is found', async () => { + const { AudioDevice } = await import('@bitbard/core/audiodevice.js'); + vi.mocked(AudioDevice.defaultOutput).mockResolvedValueOnce(null); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runCommand(statusCmd, { rawArgs: [] }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No default output device found')); + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/commands/audio/output/status.ts b/packages/cli/src/commands/audio/output/status.ts new file mode 100644 index 0000000..52cc4d7 --- /dev/null +++ b/packages/cli/src/commands/audio/output/status.ts @@ -0,0 +1,21 @@ +import { defineCommand } from 'citty'; +import chalk from 'chalk'; +import { AudioDevice } from '@bitbard/core/audiodevice.js'; +import { macosPlugin } from '../../../plugins/macos.js'; + +export default defineCommand({ + meta: { + name: 'status', + description: 'Show the default audio output device', + }, + plugins: [macosPlugin], + async run() { + const device = await AudioDevice.defaultOutput(); + if (!device) { + console.log(chalk.bold(chalk.red('No default output device found.'))); + return; + } + const muteStatus = device.isMuted ? chalk.red('muted') : chalk.green('active'); + console.log(`${chalk.bold(device.name)} — ${muteStatus}`); + }, +}); diff --git a/packages/cli/src/commands/audio/output/unmute.ts b/packages/cli/src/commands/audio/output/unmute.ts new file mode 100644 index 0000000..52dc83b --- /dev/null +++ b/packages/cli/src/commands/audio/output/unmute.ts @@ -0,0 +1,25 @@ +import { defineCommand } from 'citty'; +import chalk from 'chalk'; +import { AudioDevice } from '@bitbard/core/audiodevice.js'; +import { macosPlugin } from '../../../plugins/macos.js'; + +export default defineCommand({ + meta: { + name: 'unmute', + description: 'Unmute the default audio output device', + }, + plugins: [macosPlugin], + async run() { + const device = await AudioDevice.defaultOutput(); + if (!device) { + console.log(chalk.bold(chalk.red('No default output device found.'))); + return; + } + try { + await device.unmute(); + console.log(chalk.bold(chalk.green(`Unmuted: ${device.name}`))); + } catch (err) { + console.log(chalk.bold(chalk.red(`Failed to unmute: ${(err as Error).message}`))); + } + }, +});