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). 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/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 # --------------------------------------------------------------------------- diff --git a/packages/bitbardd/Sources/AlertHandler.swift b/packages/bitbardd/Sources/AlertHandler.swift new file mode 100644 index 0000000..2399b72 --- /dev/null +++ b/packages/bitbardd/Sources/AlertHandler.swift @@ -0,0 +1,121 @@ +import AppKit + +// MARK: - Style constants + +private let pillRadius: CGFloat = 27 +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: - 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 { + // 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 pill = PillView(message: message) + let size = pill.frame.size + + let screenFrame = screen.frame + let x = screenFrame.midX - size.width / 2 + let y = screenFrame.midY - size.height / 2 + screenFrame.height * 0.05 + + let win = NSWindow( + contentRect: NSRect(x: x, y: y, width: size.width, height: size.height), + 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 + win.contentView = pill + + 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.orderOut(nil) + self?.activeWindows.removeAll { $0 === win } + }) + } + + return .success("ok") + } +} 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/AudioDeviceHandler.swift b/packages/bitbardd/Sources/AudioDeviceHandler.swift new file mode 100644 index 0000000..33ab3d6 --- /dev/null +++ b/packages/bitbardd/Sources/AudioDeviceHandler.swift @@ -0,0 +1,151 @@ +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 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: isInput, + isOutput: isOutput, + isMuted: isMuted(deviceId: deviceId, scope: scope) + ) + 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 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) + 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 + let isMuted: Bool +} 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.. 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") + } +} diff --git a/packages/bitbardd/Sources/Protocol.swift b/packages/bitbardd/Sources/Protocol.swift new file mode 100644 index 0000000..c2f29c0 --- /dev/null +++ b/packages/bitbardd/Sources/Protocol.swift @@ -0,0 +1,94 @@ +import Foundation + +// MARK: - Request + +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: 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 { + 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) + } + } + + 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" + ) + } + } +} + +// MARK: - Response + +struct DaemonResponse: Codable { + let ok: Bool + let result: AnyCodable? // wraps String or object; nil on error + let error: String? + + static func success(_ result: String) -> DaemonResponse { + 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 { + DaemonResponse(ok: false, result: nil, error: error) + } +} diff --git a/packages/bitbardd/Sources/SocketServer.swift b/packages/bitbardd/Sources/SocketServer.swift new file mode 100644 index 0000000..90f7ccd --- /dev/null +++ b/packages/bitbardd/Sources/SocketServer.swift @@ -0,0 +1,131 @@ +import Foundation + +class SocketServer { + private let flux = FluxHandler() + private let truetone = TrueToneHandler() + private let lock = LockHandler() + private let alert = AlertHandler() + private let audiodevice = AudioDeviceHandler() + + 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 { + 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 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) } + } + } +} 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.") + } + } +} diff --git a/packages/bitbardd/Sources/main.swift b/packages/bitbardd/Sources/main.swift new file mode 100644 index 0000000..f06def9 --- /dev/null +++ b/packages/bitbardd/Sources/main.swift @@ -0,0 +1,24 @@ +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")) + // 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() diff --git a/packages/bitbardd/build.js b/packages/bitbardd/build.js new file mode 100644 index 0000000..ac166e4 --- /dev/null +++ b/packages/bitbardd/build.js @@ -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..aa117ec --- /dev/null +++ b/packages/bitbardd/package.json @@ -0,0 +1,8 @@ +{ + "name": "@bitbard/bitbardd", + "private": true, + "type": "module", + "scripts": { + "build": "node build.js" + } +} 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', 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..5366f1c --- /dev/null +++ b/packages/cli/src/commands/audio/index.ts @@ -0,0 +1,16 @@ +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: { + name: 'audio', + description: 'Audio-related commands', + }, + plugins: [macosPlugin], + subCommands: { + input, + output, + }, +}); 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/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}`))); + } + }, +}); 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..0628932 --- /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({ 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..1ff8733 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({ 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.'))); + } + } catch (err) { + console.log(chalk.bold(chalk.red(`Failed to toggle flux: ${(err as Error).message}`))); } }, }); diff --git a/packages/cli/src/commands/display/truetone.ts b/packages/cli/src/commands/display/truetone.ts index 53cfd33..709a00b 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({ 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/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'), diff --git a/packages/core/src/alert.ts b/packages/core/src/alert.ts index ed86ba1..0d83f16 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({ alert: { message, duration } }).catch((err: Error) => { + process.stderr.write(`bitbard: ${err.message}\n`); }); - child.unref(); } diff --git a/packages/core/src/audiodevice.spec.ts b/packages/core/src/audiodevice.spec.ts new file mode 100644 index 0000000..2b5d20e --- /dev/null +++ b/packages/core/src/audiodevice.spec.ts @@ -0,0 +1,140 @@ +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, isMuted: 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); + 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, isMuted: false }; + 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); + expect(device!.isMuted).toBe(false); + }); + + 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, isMuted: 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 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, isMuted: true }; + 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..bc6f3c5 --- /dev/null +++ b/packages/core/src/audiodevice.ts @@ -0,0 +1,68 @@ +import { sendCommand, DaemonError } from './daemon.js'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface AudioDeviceData { + id: number; + name: string; + isInput: boolean; + isOutput: boolean; + isMuted: 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' && + typeof v.isMuted === 'boolean' + ); +} + +// ─── Class ──────────────────────────────────────────────────────────────────── + +export class AudioDevice { + readonly id: number; + 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 { + 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; + } + } +} diff --git a/packages/core/src/caffeinate.spec.ts b/packages/core/src/caffeinate.spec.ts index 36869f2..d841ac3 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({ lock: {} }); }); }); diff --git a/packages/core/src/caffeinate.ts b/packages/core/src/caffeinate.ts index 53cf206..7bd1a38 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({ lock: {} }).catch((err: Error) => { + process.stderr.write(`bitbard: ${err.message}\n`); }); - child.unref(); } export function startScreensaver(): void { diff --git a/packages/core/src/daemon.spec.ts b/packages/core/src/daemon.spec.ts new file mode 100644 index 0000000..50352b2 --- /dev/null +++ b/packages/core/src/daemon.spec.ts @@ -0,0 +1,98 @@ +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({ 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({ 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({ 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({ 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 new file mode 100644 index 0000000..d81e4c7 --- /dev/null +++ b/packages/core/src/daemon.ts @@ -0,0 +1,80 @@ +import * as net from 'node:net'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type AudioDevicePayload = + | { action: 'defaultInput' | 'defaultOutput' } + | { action: 'mute' | 'unmute'; deviceId: number }; + +export type DaemonRequest = + | { 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?: unknown; + 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); + } + } + }); + }); +} 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) -}