Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
840a97c
chore: scaffold packages/bitbardd workspace package
geersch May 11, 2026
3ac9244
feat(bitbardd): add Protocol.swift with Codable request/response types
geersch May 11, 2026
07876e8
feat(bitbardd): add FluxHandler.swift
geersch May 11, 2026
dc90337
feat(bitbardd): add TrueToneHandler.swift
geersch May 11, 2026
a846cd4
feat(bitbardd): add LockHandler.swift
geersch May 11, 2026
c0067e4
feat(bitbardd): add AlertHandler.swift
geersch May 11, 2026
63a6a53
feat(bitbardd): add SocketServer.swift
geersch May 11, 2026
435175e
feat(bitbardd): add main.swift AppKit entry point
geersch May 11, 2026
38e87f7
build: integrate bitbardd into turbo build graph, remove swiftPlugin
geersch May 11, 2026
7184c86
chore: remove individual Swift source files from packages/core
geersch May 11, 2026
6af5760
feat(core): add daemon.ts sendCommand() TypeScript client with tests
geersch May 11, 2026
c357606
refactor(core): caffeinate.ts lockScreen() uses daemon sendCommand
geersch May 11, 2026
a0d4e76
refactor(core): alert.ts showAlert() uses daemon sendCommand
geersch May 11, 2026
61a6c10
refactor(cli): flux command uses daemon sendCommand, add tests
geersch May 11, 2026
7aa9418
refactor(cli): truetone command uses daemon sendCommand
geersch May 11, 2026
37ecfbd
feat(install): install bitbardd LaunchAgent on macOS
geersch May 11, 2026
231f3a6
docs: add uninstall instructions to README
geersch May 11, 2026
49c883d
chore(bitbardd): rename build.mjs to build.js
geersch May 11, 2026
5a8b9c1
chore(cli): remove @bitbard/bitbardd development dependency
geersch May 11, 2026
5934242
chore(bitbardd): specify module as type
geersch May 11, 2026
1b743cc
fix(bitbardd): rewrite AlertHandler with plain AppKit to fix segfault…
geersch May 11, 2026
b6f40ab
feat(bitbardd): add AnyCodable, nested request protocol, unified resu…
geersch May 12, 2026
198ce13
feat(bitbardd): add AudioDeviceHandler — defaultInput/Output, mute/un…
geersch May 12, 2026
df2c8fa
feat(core): update DaemonRequest to nested wire format, unify result …
geersch May 12, 2026
ac61333
feat(core): add AudioDevice class — defaultInput/Output, mute, unmute
geersch May 12, 2026
21fa275
fix(bitbardd): ignore SIGPIPE so liveness probe can't kill the runnin…
geersch May 12, 2026
ba8801c
feat: add audio input command with mute, unmute, and status subcommands
geersch May 13, 2026
72991c2
feat: add audio output command with mute, unmute, and status subcommands
geersch May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).

Expand Down
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 45 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.bitbard.bitbardd</string>
<key>ProgramArguments</key>
<array>
<string>${daemon_bin}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/bitbardd.log</string>
<key>StandardErrorPath</key>
<string>/tmp/bitbardd.log</string>
</dict>
</plist>
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 <<EOF
bitbard installer
Expand Down Expand Up @@ -180,12 +222,13 @@ mv "${tmp_dir}/${APP}" "${INSTALL_BIN_DIR}/${APP}"
chmod 755 "${INSTALL_BIN_DIR}/${APP}"
bb_echo "=> 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

# ---------------------------------------------------------------------------
Expand Down
121 changes: 121 additions & 0 deletions packages/bitbardd/Sources/AlertHandler.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
98 changes: 98 additions & 0 deletions packages/bitbardd/Sources/AnyCodable.swift
Original file line number Diff line number Diff line change
@@ -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<T: Encodable>(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))")
)
}
}
}
Loading