Files
byre/mac/Sources/Byre/Server.swift
T

515 lines
19 KiB
Swift

import ByreAPI
import Foundation
import SwiftUI
typealias Permission = Components.Schemas.Permission
typealias UsageCommands = Components.Schemas.UsageCommands
typealias TailnetInfo = Components.Schemas.Tailnet
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 tailnet: TailnetInfo?
var address: String? {
guard let tailnet, tailnet.state == "Running", !tailnet.origin.isEmpty else {
return nil
}
return tailnet.origin
}
}
let noCertificates = "byre's Tailscale node is signed in, but the tailnet has no HTTPS certificates, so byre can't serve its API. Turn on HTTPS Certificates on the DNS page of Tailscale's admin console, https://login.tailscale.com/admin/dns, then turn the server off and on in the Server tab."
struct Check: Decodable {
let name: String
let need: String
let `for`: String
let ok: Bool
let command: String
let fix: String
let blocking: Bool
}
struct Checks: Decodable {
let ready: Bool
let loopback: String
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 {
if !check.command.isEmpty {
CommandBox(command: check.command)
}
if !check.fix.isEmpty {
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"
}
}
}
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())
}
}
// 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
}
}
}
private struct UsageCommandsForm: View {
let api: API
@ViewState private var saved: UsageCommands?
@ViewState private var draft: UsageCommands?
@ViewState private var error = ""
@ViewState private var busy = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if draft != nil {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) {
row("claude", \.claude)
row("codex", \.codex)
row("antigravity", \.antigravity)
}
Button(busy ? "Saving…" : "Save", action: save)
.buttonStyle(WebButtonStyle())
.disabled(busy || draft == saved)
}
if !error.isEmpty {
Text(error)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.top, 4)
.task {
do {
saved = try await api.client.getUsageCommands().ok.body.json
draft = saved
error = ""
} catch {
self.error = "Couldn't load usage commands: \(errorText(error))"
}
}
}
private func row(_ vendor: String, _ key: WritableKeyPath<UsageCommands, String>) -> some View {
GridRow {
Text(vendor)
TextField("Built in", text: Binding(get: { draft?[keyPath: key] ?? "" }, set: { draft?[keyPath: key] = $0 }))
.modifier(FieldStyle())
.onSubmit(save)
}
}
private func save() {
guard let draft, draft != saved, !busy else {
return
}
busy = true
Task {
do {
saved = try await api.client.updateUsageCommands(body: .json(draft)).ok.body.json
self.draft = saved
error = ""
} catch {
self.error = "Couldn't save usage commands: \(errorText(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(loopback: URL?) async -> ServerState {
guard let r = try? await execute("/bin/launchctl", ["print", service]), r.status == 0 else {
return ServerState()
}
guard let loopback else {
return ServerState(on: true)
}
return ServerState(on: true, tailnet: try? await tailnet(loopback))
}
static func tailnet(_ loopback: URL) async throws -> TailnetInfo {
let credential = URL.applicationSupportDirectory.appending(path: "byre/loopback-token")
let token = try String(contentsOf: credential, encoding: .utf8)
var request = URLRequest(url: loopback.appending(path: "api/tailnet"))
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw ProcessFailure(errorDescription: "\(loopback.absoluteString)/api/tailnet: \(String(decoding: data, as: UTF8.self))")
}
return try JSONDecoder().decode(TailnetInfo.self, from: data)
}
// 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 {
let r = try await helper("install")
guard r.status == 0 else {
throw ProcessFailure(errorDescription: "byre install failed: \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))")
}
}
// Reinstalling a running service restarts it, so a state read right after could still be answered by the old one.
static func turnOn() async throws {
if await state(loopback: nil).on {
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<Checks, any Error>?
@ViewState private var busy = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text("Server on \(macName)")
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
if let state {
details(state)
}
Text("Tools on \(macName)")
.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
}
Text("Usage commands")
.font(Fonts.font(13, weight: 700))
.padding(.top, 16)
Text("A shell command per vendor that prints its usage as JSON for the Status tab, run after each of that vendor's turns. Empty uses what the vendor itself reports.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
UsageCommandsForm(api: API(origin: origin))
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.bg)
.onAppear {
Tailnet.shared.serviceWatchers += 1
}
.onDisappear {
Tailnet.shared.serviceWatchers -= 1
}
.task {
await refresh()
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
if let current = state, current.on, current.address == nil {
state = await Server.state(loopback: loopback)
}
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
await refresh()
}
}
}
private var loopback: URL? {
(try? checks?.get()).flatMap { URL(string: $0.loopback) }
}
@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)
} else if let tailnet = state.tailnet, tailnet.state == "Running" {
Text(noCertificates)
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
} else if let tailnet = state.tailnet {
Text("Tailscale node \(tailnet.hostname): \(tailnet.state)")
.foregroundStyle(Palette.muted)
} else {
Text("Not answering at \(loopback?.absoluteString ?? "its loopback address") yet.")
.foregroundStyle(Palette.muted)
}
button("Turn off", Server.turnOff)
} else {
Text("Off").foregroundStyle(Palette.muted)
button("Turn on", 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 {
checks = await check()
state = await Server.state(loopback: loopback)
}
private func check() async -> Result<Checks, any Error> {
do {
return .success(try await Server.checks())
} catch {
return .failure(error)
}
}
}