2026-10-18 22:17:54 -07:00
|
|
|
import ByreAPI
|
2026-10-18 16:14:03 -07:00
|
|
|
import Foundation
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
2026-10-18 22:17:54 -07:00
|
|
|
typealias Permission = Components.Schemas.Permission
|
|
|
|
|
|
2026-10-18 16:14:03 -07:00
|
|
|
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?
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 22:17:54 -07:00
|
|
|
struct Check: Decodable {
|
|
|
|
|
let name: String
|
|
|
|
|
let need: String
|
|
|
|
|
let `for`: String
|
|
|
|
|
let ok: Bool
|
2026-10-19 11:03:31 -07:00
|
|
|
let command: String
|
2026-10-18 22:17:54 -07:00
|
|
|
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 {
|
2026-10-19 11:03:31 -07:00
|
|
|
if !check.command.isEmpty {
|
|
|
|
|
CommandBox(command: check.command)
|
|
|
|
|
}
|
|
|
|
|
if !check.fix.isEmpty {
|
|
|
|
|
Text(check.fix)
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
}
|
2026-10-18 22:17:54 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 11:03:31 -07:00
|
|
|
private struct CommandBox: View {
|
|
|
|
|
let command: String
|
|
|
|
|
@ViewState private var copied = false
|
|
|
|
|
@ViewState private var failure: String?
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
box
|
|
|
|
|
if let failure {
|
|
|
|
|
Text(failure)
|
|
|
|
|
.foregroundStyle(Palette.danger)
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var box: some View {
|
|
|
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
|
|
|
Text(verbatim: command)
|
|
|
|
|
.font(Fonts.font(12))
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
Button(copied ? "Copied" : "Copy", action: copy)
|
|
|
|
|
.buttonStyle(SmallLinkButtonStyle())
|
|
|
|
|
.help("Copy the command to the clipboard")
|
|
|
|
|
Button("Run in Terminal", action: run)
|
|
|
|
|
.buttonStyle(SmallLinkButtonStyle())
|
|
|
|
|
.help("Open Terminal and run the command")
|
|
|
|
|
}
|
|
|
|
|
.padding(.horizontal, 8)
|
|
|
|
|
.padding(.vertical, 6)
|
|
|
|
|
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
|
|
|
|
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
|
|
|
|
|
.padding(.vertical, 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func copy() {
|
|
|
|
|
NSPasteboard.general.clearContents()
|
|
|
|
|
NSPasteboard.general.setString(command, forType: .string)
|
|
|
|
|
copied = true
|
|
|
|
|
Task {
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(1500))
|
|
|
|
|
copied = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func run() {
|
|
|
|
|
let shell = unsafe String(cString: getpwuid(getuid()).pointee.pw_shell)
|
|
|
|
|
let file = FileManager.default.temporaryDirectory.appending(path: "byre-install-\(UUID().uuidString).command")
|
|
|
|
|
do {
|
|
|
|
|
try "#!\(shell) -l\n\(command)\n".write(to: file, atomically: true, encoding: .utf8)
|
|
|
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path)
|
|
|
|
|
failure = nil
|
|
|
|
|
} catch {
|
|
|
|
|
failure = errorText(error)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
NSWorkspace.shared.open([file], withApplicationAt: URL(filePath: "/System/Applications/Utilities/Terminal.app"), configuration: NSWorkspace.OpenConfiguration())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 22:17:54 -07:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 16:14:03 -07:00
|
|
|
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.
|
2026-10-18 22:17:54 -07:00
|
|
|
private static func helper(_ command: String) async throws -> (status: Int32, output: String) {
|
2026-10-18 16:14:03 -07:00
|
|
|
let shell = unsafe String(cString: getpwuid(getuid()).pointee.pw_shell)
|
2026-10-18 22:17:54 -07:00
|
|
|
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")
|
2026-10-18 16:14:03 -07:00
|
|
|
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?
|
2026-10-18 22:17:54 -07:00
|
|
|
@ViewState private var checks: Result<Checks, any Error>?
|
2026-10-18 16:14:03 -07:00
|
|
|
@ViewState private var busy = false
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
ScrollView {
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
2026-10-19 12:05:52 -07:00
|
|
|
Text("Server on \(macName)")
|
2026-10-18 16:14:03 -07:00
|
|
|
.font(Fonts.font(13, weight: 700))
|
|
|
|
|
.padding(.top, 16)
|
|
|
|
|
if let state {
|
|
|
|
|
details(state)
|
|
|
|
|
}
|
2026-10-19 12:05:52 -07:00
|
|
|
Text("Tools on \(macName)")
|
2026-10-18 22:17:54 -07:00
|
|
|
.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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-18 16:14:03 -07:00
|
|
|
}
|
|
|
|
|
.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 {
|
2026-10-18 22:17:54 -07:00
|
|
|
async let checked = check()
|
2026-10-18 16:14:03 -07:00
|
|
|
state = await Server.state()
|
2026-10-18 22:17:54 -07:00
|
|
|
checks = await checked
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func check() async -> Result<Checks, any Error> {
|
|
|
|
|
do {
|
|
|
|
|
return .success(try await Server.checks())
|
|
|
|
|
} catch {
|
|
|
|
|
return .failure(error)
|
|
|
|
|
}
|
2026-10-18 16:14:03 -07:00
|
|
|
}
|
|
|
|
|
}
|