From 59021bdc8b71b16df29c8a3cfa954c357a604289 Mon Sep 17 00:00:00 2001 From: Brian Phillips Date: Thu, 23 Jul 2026 15:42:00 -0500 Subject: [PATCH 1/4] fix: support authenticated Busy Bar Wi-Fi connections Use the device-local HTTP API password and X-API-Token header across the app, CLI, and MCP server. Improve settings feedback, normalize pasted device URLs, and keep local builds on the distributed signing identity. --- README.md | 8 +- Sources/BarKeep/AppState.swift | 23 +++++- Sources/BarKeep/BusyBarClient.swift | 40 ++++++++-- Sources/BarKeep/MenuView.swift | 85 +++++++++++++++++++-- Tests/BarKeepTests/AppVersionTests.swift | 28 +++++++ Tests/BarKeepTests/BusyBarClientTests.swift | 44 +++++++++++ bin/barkeep | 12 ++- make-app.sh | 17 ++++- mcp/barkeep_mcp.py | 4 +- 9 files changed, 236 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index b0f2e38..3f8e660 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A macOS menu bar companion for the [Busy Bar](https://busy.app) — control your bar over USB or Wi-Fi, automate your busy status, and turn the little LED display into a proper developer peripheral. -No cloud, no account, no telemetry: BarKeep talks directly to the bar's local HTTP API, either over USB (`http://10.0.4.20/api`, no authentication) or Wi-Fi (the bar's local IP address and API token). +No cloud, no account, no telemetry: BarKeep talks directly to the bar's local HTTP API, either over USB (`http://10.0.4.20/api`, no authentication) or Wi-Fi (the bar's local IP address and local HTTP API password). ## Features @@ -102,7 +102,7 @@ For a Busy Bar reached over Wi-Fi: ```bash codex mcp add barkeep \ --env BARKEEP_HOST=YOUR_BAR_IP \ - --env BARKEEP_TOKEN=YOUR_API_TOKEN \ + --env BARKEEP_TOKEN=YOUR_HTTP_API_PASSWORD \ -- /usr/bin/python3 "$(brew --prefix barkeep-cli)/libexec/barkeep_mcp.py" ``` @@ -172,7 +172,7 @@ Bar. Return to the Arcade tab and click **Capture Keyboard** to resume controls. ## 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. +Everything is configured in the app's Settings tab — device host, local HTTP API password (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. CLI env: `BARKEEP_HOST` (device address, default `10.0.4.20`), `BARKEEP_THEME` (busy theme, default `on_air`). @@ -180,7 +180,7 @@ CLI env: `BARKEEP_HOST` (device address, default `10.0.4.20`), `BARKEEP_THEME` ( Verified against firmware 1.0.2 / API 24.3.0 ([official docs](https://api.busy.app/busybar/docs)): -- Over USB the API is served at `http://10.0.4.20/api/*` with no authentication. Over Wi-Fi, enter the bar's local IP address and bearer token in Settings. The docs' `/busybar/*` prefix is for the cloud proxy; BarKeep communicates with the device locally. +- Over USB the API is served at `http://10.0.4.20/api/*` with no authentication. Over Wi-Fi, enable HTTP API access in the bar's local web interface, configure its numeric password, then enter that same password in BarKeep Settings. BarKeep sends it using the firmware API's documented `X-API-Token` header. API tokens generated at `cloud.busy.app` are for the internet API and do not authenticate requests to a local IP address. The docs' `/busybar/*` prefix is for the cloud proxy; BarKeep communicates with the device locally. - Text elements accept printable ASCII only (bitmap fonts); BarKeep renders emoji/unicode to PNGs and uploads them as assets. - `/api/screen` returns base64 of raw **GRB** pixel data (LED byte order), 72×16×3. - The firmware rejects *all* draw requests while a busy session is active, regardless of priority. diff --git a/Sources/BarKeep/AppState.swift b/Sources/BarKeep/AppState.swift index 7fe2950..5229e3a 100644 --- a/Sources/BarKeep/AppState.swift +++ b/Sources/BarKeep/AppState.swift @@ -19,7 +19,13 @@ final class AppState { // MARK: - Settings (persisted) var host: String { - didSet { UserDefaults.standard.set(host, forKey: "host"); client.host = host } + didSet { + UserDefaults.standard.set(host, forKey: "host") + client.host = host + if !deviceReachable { + transportType = Self.configuredTransport(for: host) + } + } } var token: String { didSet { UserDefaults.standard.set(token, forKey: "token"); client.token = token } @@ -117,6 +123,7 @@ final class AppState { private(set) var activeMicrophoneNames: [String] = [] private(set) var onCall = false private(set) var deviceReachable = false + private(set) var authenticationRejected = false private(set) var batteryCharge: Int? private(set) var firmwareVersion: String? private(set) var availableThemes = [ @@ -207,6 +214,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.transportType = Self.configuredTransport(for: host) self.arcade = ArcadeController(client: self.client) localNetworkPermissionTrigger.onAccessAvailable = { [weak self] in Task { @MainActor [weak self] in @@ -243,6 +251,7 @@ final class AppState { do { let status = try await client.status() deviceReachable = true + authenticationRejected = false batteryCharge = status.power.battery_charge firmwareVersion = status.firmware.version let busyType = try await client.currentBusyType() @@ -260,13 +269,25 @@ final class AppState { if let value = try? await client.deviceName() { deviceNameText = value } } catch { deviceReachable = false + authenticationRejected = Self.isAuthenticationError(error) batteryCharge = nil + transportType = Self.configuredTransport(for: host) if arcade.isActive { arcade.stop() } } } + nonisolated static func configuredTransport(for host: String) -> String { + BusyBarClient.normalizedHost(host) == "10.0.4.20" + ? "usb" + : "wifi" + } + + nonisolated static func isAuthenticationError(_ error: Error) -> Bool { + (error as? BusyBarError)?.statusCode == 403 + } + /// Runs while the popover is visible; keeps the live preview fresh. func previewLoop() async { while !Task.isCancelled { diff --git a/Sources/BarKeep/BusyBarClient.swift b/Sources/BarKeep/BusyBarClient.swift index 5cec911..a338d83 100644 --- a/Sources/BarKeep/BusyBarClient.swift +++ b/Sources/BarKeep/BusyBarClient.swift @@ -2,6 +2,7 @@ import Foundation struct BusyBarError: Error, LocalizedError { let message: String + var statusCode: Int? = nil var errorDescription: String? { message } } @@ -51,7 +52,7 @@ final class BusyBarClient: @unchecked Sendable { private let session: URLSession init(host: String, token: String = "") { - self.host = host + self.host = Self.normalizedHost(host) self.token = token let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 6 @@ -60,7 +61,8 @@ final class BusyBarClient: @unchecked Sendable { } private func request(_ method: String, _ path: String, query: [String: String] = [:], body: Data? = nil, contentType: String = "application/json") throws -> URLRequest { - var components = URLComponents(string: "http://\(host)/api\(path)") + let normalizedHost = Self.normalizedHost(host) + var components = URLComponents(string: "http://\(normalizedHost)/api\(path)") if !query.isEmpty { components?.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) } } @@ -73,10 +75,35 @@ final class BusyBarClient: @unchecked Sendable { req.setValue(contentType, forHTTPHeaderField: "Content-Type") req.httpBody = body } + try Self.applyAuthentication(to: &req, token: token) + return req + } + + static func applyAuthentication(to request: inout URLRequest, token: String) throws { + if token.rangeOfCharacter(from: .newlines.union(.controlCharacters)) != nil { + throw BusyBarError( + message: "Wi-Fi password contains pasted line breaks. Clear it and enter only the Busy Bar's local HTTP API password." + ) + } if !token.isEmpty { - req.setValue("bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue(token, forHTTPHeaderField: "X-API-Token") } - return req + } + + static func normalizedHost(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let components = URLComponents(string: trimmed), + components.scheme != nil, + let hostname = components.host { + if let port = components.port { + return "\(hostname):\(port)" + } + return hostname + } + return trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + .components(separatedBy: "/") + .first ?? trimmed } @discardableResult @@ -87,7 +114,10 @@ final class BusyBarClient: @unchecked Sendable { } guard (200..<300).contains(http.statusCode) else { let bodyText = String(data: data, encoding: .utf8) ?? "" - throw BusyBarError(message: "HTTP \(http.statusCode) \(req.url?.path ?? ""): \(bodyText.prefix(200))") + throw BusyBarError( + message: "HTTP \(http.statusCode) \(req.url?.path ?? ""): \(bodyText.prefix(200))", + statusCode: http.statusCode + ) } return data } diff --git a/Sources/BarKeep/MenuView.swift b/Sources/BarKeep/MenuView.swift index a26c729..f39c78b 100644 --- a/Sources/BarKeep/MenuView.swift +++ b/Sources/BarKeep/MenuView.swift @@ -1,5 +1,55 @@ import SwiftUI +struct CompactSecureField: NSViewRepresentable { + @Binding var text: String + let placeholder: String + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text) + } + + func makeNSView(context: Context) -> NSSecureTextField { + let field = NSSecureTextField() + field.placeholderString = placeholder + field.isBezeled = true + field.isBordered = true + field.drawsBackground = true + field.focusRingType = .default + field.delegate = context.coordinator + field.stringValue = text + return field + } + + func updateNSView(_ field: NSSecureTextField, context: Context) { + context.coordinator.text = $text + field.placeholderString = placeholder + if field.stringValue != text { + field.stringValue = text + } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView: NSSecureTextField, + context: Context + ) -> CGSize? { + CGSize(width: proposal.width ?? 140, height: 22) + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var text: Binding + + init(text: Binding) { + self.text = text + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField else { return } + text.wrappedValue = field.stringValue + } + } +} + @MainActor struct MenuView: View { @Environment(AppState.self) private var state @@ -61,7 +111,11 @@ struct MenuView: View { Circle() .fill(state.deviceReachable ? .green : .orange) .frame(width: 9, height: 9) - Text(state.deviceReachable ? "Busy Bar" : "Unreachable") + Text( + state.deviceReachable + ? "Busy Bar" + : state.authenticationRejected ? "Token rejected" : "Unreachable" + ) .font(.headline) Text(state.transportType.uppercased()) .font(.caption2) @@ -657,10 +711,23 @@ struct SettingsTab: View { .frame(width: 140) .onSubmit { state.applyDeviceName() } } - LabeledContent("API token") { - SecureField("needed for Wi-Fi", text: $state.token) - .textFieldStyle(.roundedBorder) - .frame(width: 140) + LabeledContent("Wi-Fi password") { + CompactSecureField( + text: $state.token, + placeholder: "HTTP API password" + ) + .frame(width: 140, height: 22) + } + if state.authenticationRejected { + Text("Busy Bar reached, but it rejected the local HTTP API password (HTTP 403). Cloud API tokens do not work with a local IP address.") + .font(.caption2) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } else if !state.deviceReachable && state.transportType == "wifi" { + Text("Wi-Fi device unreachable. Confirm the IP and use a network that allows devices to communicate with each other. Some phone hotspots isolate connected devices.") + .font(.caption2) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) } Picker("On-call theme", selection: $state.theme) { ForEach(state.availableThemes, id: \.self) { theme in @@ -726,9 +793,11 @@ struct SettingsTab: View { Toggle("Sync Slack status when busy", isOn: $state.slackSyncEnabled) if state.slackSyncEnabled { LabeledContent("User token") { - SecureField("xoxp-…", text: $state.slackToken) - .textFieldStyle(.roundedBorder) - .frame(width: 160) + CompactSecureField( + text: $state.slackToken, + placeholder: "xoxp-…" + ) + .frame(width: 160, height: 22) } Text("Sets “🎧 On a call” + DND while busy; clears after. Token needs users.profile:write and dnd:write — see README.") .font(.caption2) diff --git a/Tests/BarKeepTests/AppVersionTests.swift b/Tests/BarKeepTests/AppVersionTests.swift index 5480b08..69dab53 100644 --- a/Tests/BarKeepTests/AppVersionTests.swift +++ b/Tests/BarKeepTests/AppVersionTests.swift @@ -11,4 +11,32 @@ final class AppVersionTests: XCTestCase { func testFallsBackForDevelopmentBuilds() { XCTAssertEqual(AppVersion.displayVersion(from: nil), "development") } + + func testConfiguredTransportUsesUSBOnlyForTheUSBInterface() { + XCTAssertEqual(AppState.configuredTransport(for: "10.0.4.20"), "usb") + XCTAssertEqual(AppState.configuredTransport(for: " 10.0.4.20 "), "usb") + XCTAssertEqual(AppState.configuredTransport(for: "172.20.10.9"), "wifi") + XCTAssertEqual(AppState.configuredTransport(for: "busybar.local"), "wifi") + } + + func testAuthenticationFailureIsDistinguishedFromReachability() { + XCTAssertTrue( + AppState.isAuthenticationError( + BusyBarError( + message: "HTTP 403 /status: Forbidden", + statusCode: 403 + ) + ) + ) + XCTAssertFalse( + AppState.isAuthenticationError( + URLError(.timedOut) + ) + ) + XCTAssertFalse( + AppState.isAuthenticationError( + BusyBarError(message: "Device response mentioned HTTP 403") + ) + ) + } } diff --git a/Tests/BarKeepTests/BusyBarClientTests.swift b/Tests/BarKeepTests/BusyBarClientTests.swift index 6a3e1eb..2980a6c 100644 --- a/Tests/BarKeepTests/BusyBarClientTests.swift +++ b/Tests/BarKeepTests/BusyBarClientTests.swift @@ -2,6 +2,50 @@ import XCTest @testable import BarKeep final class BusyBarClientTests: XCTestCase { + func testBrowserURLIsNormalizedToAnAPIHost() { + XCTAssertEqual( + BusyBarClient.normalizedHost("http://10.69.1.15/login"), + "10.69.1.15" + ) + XCTAssertEqual( + BusyBarClient.normalizedHost("https://busybar.local:8080/settings"), + "busybar.local:8080" + ) + XCTAssertEqual( + BusyBarClient.normalizedHost(" 10.0.4.20/ "), + "10.0.4.20" + ) + } + + func testMultilineTokenIsRejectedBeforeSendingARequest() async { + let client = BusyBarClient( + host: "192.0.2.1", + token: "token\nterminal output" + ) + + do { + _ = try await client.status() + XCTFail("Expected a malformed-token error") + } catch { + XCTAssertEqual( + error.localizedDescription, + "Wi-Fi password contains pasted line breaks. Clear it and enter only the Busy Bar's local HTTP API password." + ) + } + } + + func testAuthenticationUsesDocumentedAPITokenHeader() throws { + var request = URLRequest(url: URL(string: "http://busybar.local/api/status")!) + + try BusyBarClient.applyAuthentication(to: &request, token: "valid-token") + + XCTAssertEqual( + request.value(forHTTPHeaderField: "X-API-Token"), + "valid-token" + ) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + } + func testDisplayPayloadUsesFirmwareApplicationNamespace() { let payload = BusyBarClient.displayPayload( elements: [["id": "frame", "type": "image", "path": "arcade.png"]], diff --git a/bin/barkeep b/bin/barkeep index 3a2b61c..9727186 100755 --- a/bin/barkeep +++ b/bin/barkeep @@ -9,10 +9,13 @@ # barkeep sound # barkeep status # -# Env: BARKEEP_HOST (default 10.0.4.20), BARKEEP_THEME (default on_air) +# Env: BARKEEP_HOST (default 10.0.4.20), BARKEEP_TOKEN (the local HTTP API +# password configured on the bar; needed for Wi-Fi), +# BARKEEP_THEME (default on_air) set -o pipefail HOST="${BARKEEP_HOST:-10.0.4.20}" +TOKEN="${BARKEEP_TOKEN:-}" API="http://$HOST/api" THEME="${BARKEEP_THEME:-on_air}" CURL=/usr/bin/curl @@ -45,10 +48,13 @@ color_hex() { api() { local method=$1 path=$2 body=$3 local out + local auth=() + [[ "$TOKEN" == *$'\n'* || "$TOKEN" == *$'\r'* ]] && die "BARKEEP_TOKEN contains line breaks" + [[ -n "$TOKEN" ]] && auth=(-H "X-API-Token: $TOKEN") if [[ -n "$body" ]]; then - out=$($CURL -sS -m 8 -X "$method" "$API$path" -H 'Content-Type: application/json' -d "$body") || die "cannot reach bar at $HOST" + out=$($CURL -sS -m 8 -X "$method" "$API$path" "${auth[@]}" -H 'Content-Type: application/json' -d "$body") || die "cannot reach bar at $HOST" else - out=$($CURL -sS -m 8 -X "$method" "$API$path") || die "cannot reach bar at $HOST" + out=$($CURL -sS -m 8 -X "$method" "$API$path" "${auth[@]}") || die "cannot reach bar at $HOST" fi if [[ "$out" == *'"error"'* ]]; then die "device error: $out" diff --git a/make-app.sh b/make-app.sh index f696a96..87e41a2 100755 --- a/make-app.sh +++ b/make-app.sh @@ -18,9 +18,20 @@ cp packaging/Info.plist "$APP/Contents/Info.plist" cp .build/release/BarKeep "$APP/Contents/MacOS/BarKeep" [ -f assets/AppIcon.icns ] && cp assets/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" -IDENTITY="${BARKEEP_SIGN_IDENTITY:-$(security find-identity -v -p codesigning 2>/dev/null \ - | grep -E 'Developer ID Application|Apple Development' | grep -v REVOKED \ - | head -1 | sed 's/^[^"]*"//; s/"$//')}" +if [ -n "${BARKEEP_SIGN_IDENTITY:-}" ]; then + IDENTITY="$BARKEEP_SIGN_IDENTITY" +else + # Keychain output is not ordered by certificate type. Select Developer ID + # explicitly so local rebuilds keep the same TCC identity as distributed builds. + IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ + | grep 'Developer ID Application' | grep -v REVOKED \ + | head -1 | sed 's/^[^"]*"//; s/"$//')" + if [ -z "$IDENTITY" ]; then + IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ + | grep 'Apple Development' | grep -v REVOKED \ + | head -1 | sed 's/^[^"]*"//; s/"$//')" + fi +fi if [ -n "$IDENTITY" ]; then if [[ "$IDENTITY" == Developer\ ID\ Application:* ]]; then codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP" diff --git a/mcp/barkeep_mcp.py b/mcp/barkeep_mcp.py index 590d0c4..5a489e9 100644 --- a/mcp/barkeep_mcp.py +++ b/mcp/barkeep_mcp.py @@ -3,6 +3,8 @@ Tools: bar_send_message, bar_set_busy, bar_start_timer, bar_clear, bar_status. Host defaults to the USB address (10.0.4.20); override with BARKEEP_HOST. +For Wi-Fi, BARKEEP_TOKEN is the local HTTP API password configured on the bar, +not a cloud.busy.app API token. """ import json import os @@ -85,7 +87,7 @@ def api(method, path, body=None, query=None): if data: req.add_header("Content-Type", "application/json") if TOKEN: - req.add_header("Authorization", f"bearer {TOKEN}") + req.add_header("X-API-Token", TOKEN) with urllib.request.urlopen(req, timeout=8) as resp: return json.loads(resp.read().decode() or "{}") From 94b4c997e708840809998843048ec4a99f8092be Mon Sep 17 00:00:00 2001 From: Brian Phillips Date: Thu, 23 Jul 2026 15:42:00 -0500 Subject: [PATCH 2/4] chore: bump BarKeep to 1.0.13 --- packaging/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/Info.plist b/packaging/Info.plist index cffefb4..39d5729 100644 --- a/packaging/Info.plist +++ b/packaging/Info.plist @@ -13,7 +13,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.12 + 1.0.13 LSMinimumSystemVersion 14.0 LSUIElement From 78701ed27d7588609e86313eb2e4f0819f6ae492 Mon Sep 17 00:00:00 2001 From: Brian Phillips Date: Thu, 23 Jul 2026 15:47:43 -0500 Subject: [PATCH 3/4] fix: harden URL and signing fallbacks --- Sources/BarKeep/BusyBarClient.swift | 4 ++++ Sources/BarKeep/MenuView.swift | 4 ++-- Tests/BarKeepTests/BusyBarClientTests.swift | 4 ++++ make-app.sh | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/BarKeep/BusyBarClient.swift b/Sources/BarKeep/BusyBarClient.swift index a338d83..ddbb84f 100644 --- a/Sources/BarKeep/BusyBarClient.swift +++ b/Sources/BarKeep/BusyBarClient.swift @@ -106,6 +106,10 @@ final class BusyBarClient: @unchecked Sendable { .first ?? trimmed } + static func webInterfaceURL(for host: String) -> URL? { + URL(string: "http://\(normalizedHost(host))/") + } + @discardableResult private func send(_ req: URLRequest) async throws -> Data { let (data, response) = try await session.data(for: req) diff --git a/Sources/BarKeep/MenuView.swift b/Sources/BarKeep/MenuView.swift index f39c78b..c5866a8 100644 --- a/Sources/BarKeep/MenuView.swift +++ b/Sources/BarKeep/MenuView.swift @@ -129,14 +129,14 @@ struct MenuView: View { .foregroundStyle(.secondary) } Button { - if let url = URL(string: "http://\(state.host)/") { + if let url = BusyBarClient.webInterfaceURL(for: state.host) { NSWorkspace.shared.open(url) } } label: { Image(systemName: "globe") } .buttonStyle(.borderless) - .help("Open the bar's web interface (http://\(state.host))") + .help("Open the bar's web interface (http://\(BusyBarClient.normalizedHost(state.host)))") Button { NSApp.terminate(nil) } label: { diff --git a/Tests/BarKeepTests/BusyBarClientTests.swift b/Tests/BarKeepTests/BusyBarClientTests.swift index 2980a6c..d9919db 100644 --- a/Tests/BarKeepTests/BusyBarClientTests.swift +++ b/Tests/BarKeepTests/BusyBarClientTests.swift @@ -15,6 +15,10 @@ final class BusyBarClientTests: XCTestCase { BusyBarClient.normalizedHost(" 10.0.4.20/ "), "10.0.4.20" ) + XCTAssertEqual( + BusyBarClient.webInterfaceURL(for: "http://10.69.1.15/login")?.absoluteString, + "http://10.69.1.15/" + ) } func testMultilineTokenIsRejectedBeforeSendingARequest() async { diff --git a/make-app.sh b/make-app.sh index 87e41a2..b7659f8 100755 --- a/make-app.sh +++ b/make-app.sh @@ -25,11 +25,11 @@ else # explicitly so local rebuilds keep the same TCC identity as distributed builds. IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ | grep 'Developer ID Application' | grep -v REVOKED \ - | head -1 | sed 's/^[^"]*"//; s/"$//')" + | head -1 | sed 's/^[^"]*"//; s/"$//' || true)" if [ -z "$IDENTITY" ]; then IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \ | grep 'Apple Development' | grep -v REVOKED \ - | head -1 | sed 's/^[^"]*"//; s/"$//')" + | head -1 | sed 's/^[^"]*"//; s/"$//' || true)" fi fi if [ -n "$IDENTITY" ]; then From e1ff9e926f7c0627e726d637fcc21f56bb254e29 Mon Sep 17 00:00:00 2001 From: Brian Phillips Date: Thu, 23 Jul 2026 15:52:39 -0500 Subject: [PATCH 4/4] docs: clarify local Wi-Fi authentication --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3f8e660..b939a71 100644 --- a/README.md +++ b/README.md @@ -174,11 +174,14 @@ Bar. Return to the Arcade tab and click **Capture Keyboard** to resume controls. Everything is configured in the app's Settings tab — device host, local HTTP API password (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. -CLI env: `BARKEEP_HOST` (device address, default `10.0.4.20`), `BARKEEP_THEME` (busy theme, default `on_air`). +CLI env: `BARKEEP_HOST` (device address, default `10.0.4.20`), +`BARKEEP_TOKEN` (the local HTTP API password for Wi-Fi), and +`BARKEEP_THEME` (busy theme, default `on_air`). ## Device API notes -Verified against firmware 1.0.2 / API 24.3.0 ([official docs](https://api.busy.app/busybar/docs)): +Verified against firmware 1.0.2 / API 24.3.0 +([official local HTTP API docs](https://docs.busy.app/bar/dev/http-api)): - Over USB the API is served at `http://10.0.4.20/api/*` with no authentication. Over Wi-Fi, enable HTTP API access in the bar's local web interface, configure its numeric password, then enter that same password in BarKeep Settings. BarKeep sends it using the firmware API's documented `X-API-Token` header. API tokens generated at `cloud.busy.app` are for the internet API and do not authenticate requests to a local IP address. The docs' `/busybar/*` prefix is for the cloud proxy; BarKeep communicates with the device locally. - Text elements accept printable ASCII only (bitmap fonts); BarKeep renders emoji/unicode to PNGs and uploads them as assets.