import ByreAPI import Foundation import SwiftUI typealias Permission = Components.Schemas.Permission let serverHelper = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre").path private let service = "gui/\(getuid())/run.fc.byre" struct ServerState { var on = false var address: String? } struct Check: Decodable { let name: String let need: String let `for`: String let ok: Bool let fix: String let blocking: Bool } struct Checks: Decodable { let ready: Bool let checks: [Check] } struct ChecksView: View { let checks: [Check] var body: some View { VStack(alignment: .leading, spacing: 10) { ForEach(checks, id: \.name) { check in VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { Text(check.name).font(Fonts.font(13, weight: 700)) status(check) } Text("\(need(check)), for \(check.for)").foregroundStyle(Palette.muted) if !check.ok { Text(check.fix) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } } } } private func status(_ check: Check) -> some View { if check.ok { return Text("Found").foregroundStyle(Palette.ok) } return Text("Missing").foregroundStyle(check.blocking ? Palette.danger : Palette.muted) } private func need(_ check: Check) -> String { switch check.need { case "required": return "Required" case "agent": return "One of claude, codex and agy required" default: return "Optional" } } } // replayd keys ScreenCaptureKit clients by executable path, so the check runs from its own copy beside client_hands' byre-hands. func checkLocalPermissions() async throws -> [Permission] { let dir = FileManager.default.temporaryDirectory.appending(path: "byre-hands-check-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } let bin = dir.appending(path: "byre-hands") try FileManager.default.copyItem(at: Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre-hands"), to: bin) let r = try await execute(bin.path, ["check"]) guard r.status == 0, let perms = try? JSONDecoder().decode([Permission].self, from: Data(r.output.utf8)) else { throw ProcessFailure(errorDescription: "byre-hands check failed: \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))") } return perms } struct PermissionsCheck: View { let run: () async throws -> [Permission] @ViewState private var result: Result<[Permission], any Error>? @ViewState private var busy = false var body: some View { VStack(alignment: .leading, spacing: 10) { Button(busy ? "Checking…" : "Check permissions", action: check) .buttonStyle(WebButtonStyle()) .disabled(busy) switch result { case .success(let perms): ForEach(perms, id: \.name) { perm in row(perm) } case .failure(let error): Text(errorText(error)) .foregroundStyle(Palette.danger) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) case nil: EmptyView() } } .padding(.top, 4) } private func row(_ perm: Permission) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { Text(perm.name).font(Fonts.font(13, weight: 700)) switch perm.status { case .granted: Text("Granted").foregroundStyle(Palette.ok) case .missing: Text("Missing").foregroundStyle(Palette.danger) case .unchecked: Text("Not checked").foregroundStyle(Palette.muted) } } Text(perm.detail) .foregroundStyle(Palette.muted) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) if perm.status == .missing, let url = URL(string: perm.settings) { Button("Open System Settings") { NSWorkspace.shared.open(url) } .buttonStyle(SmallLinkButtonStyle()) } } } private func check() { busy = true Task { do { result = .success(try await run()) } catch { result = .failure(error) } busy = false } } } struct ProcessFailure: LocalizedError { let errorDescription: String? } private func execute(_ path: String, _ args: [String], env: [String: String] = [:]) async throws -> (status: Int32, output: String) { try await Task.detached { let p = Process() p.executableURL = URL(filePath: path) p.arguments = args p.environment = ProcessInfo.processInfo.environment.merging(env) { $1 } let out = Pipe() p.standardOutput = out p.standardError = out try p.run() let data = out.fileHandleForReading.readDataToEndOfFile() p.waitUntilExit() return (p.terminationStatus, String(decoding: data, as: UTF8.self)) }.value } private func launchctl(_ args: String...) async throws { let r = try await execute("/bin/launchctl", args) if r.status != 0 { throw ProcessFailure(errorDescription: "launchctl \(args.joined(separator: " ")): \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))") } } enum Server { static func state() async -> ServerState { guard let r = try? await execute("/bin/launchctl", ["print", service]), r.status == 0 else { return ServerState() } return ServerState(on: true, address: await address()) } private static func address() async -> String? { guard let r = try? await execute("/Applications/Tailscale.app/Contents/MacOS/Tailscale", ["serve", "status", "--json"], env: ["TAILSCALE_BE_CLI": "1"]), r.status == 0, let json = try? JSONSerialization.jsonObject(with: Data(r.output.utf8)) as? [String: Any], let web = json["Web"] as? [String: Any], let host = web.keys.first(where: { $0.hasSuffix(":443") }) else { return nil } return "https://" + host.dropLast(4) } // byre install copies its PATH into the service, which needs the login shell's to find claude, codex, agy and npx. private static func helper(_ command: String) async throws -> (status: Int32, output: String) { let shell = unsafe String(cString: getpwuid(getuid()).pointee.pw_shell) return try await execute(shell, ["-l", "-c", "exec \"$BYRE_HELPER\" \(command)"], env: ["BYRE_HELPER": serverHelper]) } static func checks() async throws -> Checks { let r = try await helper("check") guard r.status == 0, let line = r.output.split(separator: "\n").last, let checks = try? JSONDecoder().decode(Checks.self, from: Data(line.utf8)) else { throw ProcessFailure(errorDescription: "byre check failed: \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))") } return checks } static func install() async throws -> URL { let r = try await helper("install") let prefix = "byre is at " guard r.status == 0, let line = r.output.split(separator: "\n").first(where: { $0.hasPrefix(prefix) }), let url = URL(string: String(line.dropFirst(prefix.count))) else { throw ProcessFailure(errorDescription: "byre install failed: \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))") } return url } // Reinstalling a running service restarts it, so an address read right after could still be answered by the old one. static func turnOn() async throws -> URL { let state = await state() if state.on, let address = state.address, let url = URL(string: address) { return url } return try await install() } static func turnOff() async throws { try await launchctl("bootout", service) } } struct ServerView: View { @Environment(Overlays.self) private var overlays @ViewState private var state: ServerState? @ViewState private var checks: Result? @ViewState private var busy = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 8) { Text("Server on this Mac") .font(Fonts.font(13, weight: 700)) .padding(.top, 16) if let state { details(state) } Text("Tools on this Mac") .font(Fonts.font(13, weight: 700)) .padding(.top, 16) switch checks { case .success(let checks): ChecksView(checks: checks.checks) case .failure(let error): Text(errorText(error)) .foregroundStyle(Palette.danger) .textSelection(.enabled) case nil: EmptyView() } if let address = state?.address, let origin = URL(string: address) { Text("Agents' permissions") .font(Fonts.font(13, weight: 700)) .padding(.top, 16) Text("The macOS privacy grants the server's agents use to see and control this Mac. Checking asks macOS for any that are missing.") .foregroundStyle(Palette.muted) .fixedSize(horizontal: false, vertical: true) PermissionsCheck { try await API(origin: origin).client.checkPermissions().ok.body.json } } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Palette.bg) .task { await refresh() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { await refresh() } } } @ViewBuilder private func details(_ state: ServerState) -> some View { if state.on { Text("On").foregroundStyle(Palette.ok) if let address = state.address { Text(address).textSelection(.enabled) } button("Turn off", Server.turnOff) } else { Text("Off").foregroundStyle(Palette.muted) button("Turn on") { _ = try await Server.turnOn() } } } private func button(_ label: String, _ action: @escaping () async throws -> Void) -> some View { Button(label) { busy = true Task { do { try await action() } catch { overlays.showAlert(errorText(error)) } await refresh() busy = false } } .buttonStyle(WebButtonStyle()) .disabled(busy) .padding(.top, 4) } private func refresh() async { async let checked = check() state = await Server.state() checks = await checked } private func check() async -> Result { do { return .success(try await Server.checks()) } catch { return .failure(error) } } }