Server setup in the app

This commit is contained in:
Ian Gulliver
2026-10-18 22:17:54 -07:00
parent 327aab069d
commit 602e6703b4
16 changed files with 825 additions and 269 deletions
+189 -2
View File
@@ -1,6 +1,9 @@
import ByreAPI
import Foundation
import SwiftUI
typealias Permission = Components.Schemas.Permission
struct LaunchAgent: Decodable {
let programArguments: [String]
@@ -18,6 +21,143 @@ struct ServerState {
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?
}
@@ -65,9 +205,21 @@ enum Server {
}
// byre install copies its PATH into the service, which needs the login shell's to find claude, codex, agy and npx.
static func install() async throws -> URL {
private static func helper(_ command: String) async throws -> (status: Int32, output: String) {
let shell = unsafe String(cString: getpwuid(getuid()).pointee.pw_shell)
let r = try await execute(shell, ["-l", "-c", "exec \"$BYRE_HELPER\" install"], env: ["BYRE_HELPER": serverHelper])
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))")
@@ -92,6 +244,7 @@ enum Server {
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 {
@@ -103,6 +256,30 @@ struct ServerView: View {
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)
@@ -156,6 +333,16 @@ struct ServerView: View {
}
private func refresh() async {
async let checked = check()
state = await Server.state()
checks = await checked
}
private func check() async -> Result<Checks, any Error> {
do {
return .success(try await Server.checks())
} catch {
return .failure(error)
}
}
}
+5
View File
@@ -35,6 +35,11 @@ struct SettingsView: View {
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
DefaultModelMenu()
heading("This Mac's permissions")
Text("The macOS privacy grants byre's app needs for agents to see and control this Mac. Checking asks macOS for any that are missing.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
PermissionsCheck(run: checkLocalPermissions)
heading("What your lead has learned")
Text("Your lead notes your priorities and preferences as it works with you, and starts every session with them.")
.foregroundStyle(Palette.muted)
+24 -1
View File
@@ -199,6 +199,7 @@ final class Connection {
final class ConnectForm {
var serverText = ""
var problem: String?
var missing: [Check] = []
var connecting = false
func connect() async -> URL? {
@@ -223,7 +224,14 @@ final class ConnectForm {
func installHere() async -> URL? {
connecting = true
problem = nil
missing = []
do {
let checks = try await Server.checks()
if !checks.ready {
missing = checks.checks.filter { !$0.ok }
connecting = false
return nil
}
let origin = try await Server.turnOn()
serverText = origin.absoluteString
try await waitForServer(origin)
@@ -294,6 +302,16 @@ struct ConnectView: View {
let connected: (URL) -> Void
var body: some View {
GeometryReader { g in
ScrollView {
fields
.padding(.vertical, 24)
.frame(maxWidth: .infinity, minHeight: g.size.height)
}
}
}
private var fields: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Server URL")
TextField("https://byre.example.ts.net", text: $form.serverText)
@@ -310,9 +328,14 @@ struct ConnectView: View {
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
if !form.missing.isEmpty {
Text("byre can't run on this Mac yet. Install what's missing, then click Run byre on this Mac again.")
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 6)
ChecksView(checks: form.missing)
}
}
.frame(width: 460)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func connect() {