Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ No cloud, no account, no telemetry: BarKeep talks directly to the bar's local HT

## Features

**Menu bar app** (tabbed popover: Device / Message / Timers / Settings)
**Menu bar app** (tabbed popover: Device / Message / Timers / Arcade / Settings)

- 🎙 **Auto On-Call** — flips the bar to *On Air* the moment any app opens your microphone (Teams, Zoom, FaceTime…), clears when the mic goes idle. Uses CoreAudio device state — no microphone permission needed, no audio ever captured.
- 📺 **Live preview** — see what's on the bar's display, right in the popover.
Expand All @@ -16,6 +16,9 @@ No cloud, no account, no telemetry: BarKeep talks directly to the bar's local HT
- 📅 **Calendar** — auto-busy during calendar events; one-click countdown-to-next-meeting on the bar.
- 🔔 **Notification forwarding** — scroll macOS notifications (Teams by default, any app by filter) across the bar with per-app LED colors, optional chime, and queue-during-calls replay.
- 🌐 **Ambient widgets** — live ping latency badge and local weather (icon + temperature) in the corners of the display.
- 🕹 **Busy Bar Arcade** — play Snake, Tetris, Pong, and Breakout on the
physical 72×16 display using your Mac keyboard. The Mac preview is optional
and off by default.
- 💤 **Slack sync** — bar goes busy → your Slack status becomes "🎧 On a call" + DND; clears after.
- ⚙️ Brightness/volume control, device rename, firmware update check, launch at login.

Expand Down Expand Up @@ -132,6 +135,28 @@ ad-hoc-signed copies may require the permission to be granted again.
3. *Install to Workspace*, copy the **User OAuth Token** (`xoxp-…`)
4. Paste into BarKeep → Settings → Slack

## Busy Bar Arcade

Open BarKeep → **Arcade**, then choose a game. BarKeep captures keyboard input
in a transparent input-only window, so the physical Busy Bar is the game
display and no game window needs to remain visible on the Mac.

| Key | Action |
|---|---|
| `1` / `2` / `3` / `4` | Switch to Snake / Tetris / Pong / Breakout |
| Arrow keys | Move (all games) |
| `W` / `S` | Alternate Pong controls |
| `↑` | Rotate a Tetris piece |
| `↓` | Soft-drop a Tetris piece |
| Space | Hard-drop a Tetris piece |
| `R` | Restart the current game |
| Escape | Stop the arcade and return keyboard focus to the previous Mac app |

Enable **Show preview in BarKeep** if you want a troubleshooting preview in
the Arcade tab. Games cannot run while a native busy/timer session is active,
because Busy Bar firmware rejects custom drawing during those sessions.
Starting an on-call session stops the arcade automatically.

## Configuration

Everything is configured in the app's Settings tab — device host, API token (needed for Wi-Fi), busy theme, notification filter, Slack token, ping target, weather unit and location (type a city, it's geocoded for you; leave empty for automatic IP-based location). No config files, no terminal required.
Expand Down
28 changes: 26 additions & 2 deletions Sources/BarKeep/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ final class AppState {
var calendarAccessGranted: Bool { calendarMonitor.accessGranted }

let client: BusyBarClient
let arcade: ArcadeController
private let localNetworkPermissionTrigger = LocalNetworkPermissionTrigger()
private let micMonitor = MicMonitor()
private let notificationWatcher = NotificationWatcher()
Expand Down Expand Up @@ -206,6 +207,7 @@ final class AppState {
self.pingHost = defaults.string(forKey: "pingHost") ?? "1.1.1.1"
self.weatherCelsius = defaults.bool(forKey: "weatherCelsius")
self.client = BusyBarClient(host: host, token: token)
self.arcade = ArcadeController(client: self.client)
localNetworkPermissionTrigger.onAccessAvailable = { [weak self] in
Task { @MainActor [weak self] in
await self?.refreshDeviceStatus()
Expand Down Expand Up @@ -245,6 +247,9 @@ final class AppState {
firmwareVersion = status.firmware.version
let busyType = try await client.currentBusyType()
onCall = busyType != "NOT_STARTED"
if onCall, arcade.isActive {
arcade.stop()
}
if let themes = try? await client.listThemes(), !themes.isEmpty {
availableThemes = themes
}
Expand All @@ -256,6 +261,9 @@ final class AppState {
} catch {
deviceReachable = false
batteryCharge = nil
if arcade.isActive {
arcade.stop()
}
}
}

Expand Down Expand Up @@ -395,7 +403,7 @@ final class AppState {
self.latestPingMs = ms
// The badge self-expires (10 s timeout), so a stopped loop
// or an active busy session just lets it fade out.
if !self.onCall {
if !self.onCall && !self.arcade.isActive {
let text: String
let color: String
if let ms {
Expand Down Expand Up @@ -470,7 +478,7 @@ final class AppState {
lastFetch = Date()
}
}
if let reading = self.latestWeather, !self.onCall {
if let reading = self.latestWeather, !self.onCall, !self.arcade.isActive {
var iconOK = self.lastWeatherIconEmoji == reading.emoji
if !iconOK, let png = MessageRenderer.renderEmojiIcon(reading.emoji) {
iconOK = (try? await self.client.uploadAsset(filename: "wx.png", data: png)) != nil
Expand All @@ -494,6 +502,10 @@ final class AppState {

func sendMeetingCountdown() {
guard let date = nextMeetingDate else { return }
guard !arcade.isActive else {
lastError = "Stop the arcade before drawing another item."
return
}
Task {
do {
try await client.drawCountdown(to: date, colorHex: "#FFAA00FF", timeout: 0, priority: 95)
Expand Down Expand Up @@ -534,6 +546,9 @@ final class AppState {
func setOnCall(_ on: Bool, automatic: Bool = false) {
Task {
do {
if on, arcade.isActive {
arcade.stop()
}
if on && automatic {
// Don't stomp a busy session the user started elsewhere.
let current = try await client.currentBusyType()
Expand Down Expand Up @@ -563,6 +578,7 @@ final class AppState {
// MARK: - Notification forwarding

private func handleNotification(_ note: ForwardedNotification) {
guard !arcade.isActive else { return }
let filters = notificationAppFilter
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
Expand Down Expand Up @@ -646,6 +662,10 @@ final class AppState {
// MARK: - Messages

func sendMessage(_ text: String, font: TextFont, color: NSColor, timeoutSeconds: Int) {
guard !arcade.isActive else {
lastError = "Stop the arcade before sending a message."
return
}
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let hex = MessageRenderer.rgbaHex(from: color)
Expand Down Expand Up @@ -674,6 +694,10 @@ final class AppState {
}

func sendDrawing(_ grid: [[String?]], timeoutSeconds: Int) {
guard !arcade.isActive else {
lastError = "Stop the arcade before sending a drawing."
return
}
Task {
do {
guard let png = MessageRenderer.renderGridToPNG(grid) else {
Expand Down
Loading
Loading