511 lines
18 KiB
Swift
511 lines
18 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 (data, response) = try await URLSession.shared.data(from: loopback.appending(path: "api/tailnet"))
|
|
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)
|
|
}
|
|
}
|
|
}
|