Show startup as one step checklist and re-check tools on the Server tab

This commit is contained in:
Ian Gulliver
2026-10-23 05:42:30 -07:00
parent 522c903344
commit dfb114ea64
9 changed files with 242 additions and 162 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ The Mac that runs the server needs `git` and at least one agent CLI: Claude Code
## Installing
1. Download [byre](https://dl.byre.run/byre.dmg) from [byre.run](https://byre.run) and drag `Byre.app` to Applications, on every Mac you'll use byre from. Open it and click **Sign in to Tailscale** to put that Mac's byre on your tailnet; byre servers on your tailnet show up on their own.
1. Download [byre](https://dl.byre.run/byre.dmg) from [byre.run](https://byre.run) and drag `Byre.app` to Applications, on every Mac you'll use byre from. Open it and click **Sign in** on the "Sign this app in to Tailscale" step to put that Mac's byre on your tailnet; byre servers on your tailnet show up on their own.
2. On the Mac that runs the server, click **Run byre on this Mac**. It first checks for `git` and at least one of `claude`, `codex` and `agy`, and lists anything missing with how to install it. Then it installs the server as a launchd service, using your login shell's `PATH` to find `claude`, `codex`, `agy`, `git` and `npx`, asks you to sign the server's own Tailscale node in, and connects to it. The Server tab shows its URL, turns it off and on, and lists the tools it found and how to install the missing ones.
3. Open byre on each other Mac; it picks up the server's URL through iCloud, or enter that URL.
+2 -3
View File
@@ -26,9 +26,8 @@ The app's Tailscale sign-in opens Safari in the VM. To have the human approve it
## Driving the VM's screen
- Click the `tart` window's title bar first: the VM takes clicks and keys only while its window is the key window.
- Keys reach the VM without their modifiers through hands, so Shift, Cmd and symbols typed with `type` or `key` arrive wrong. Type only lowercase text and plain symbols there, and do the rest over SSH.
- Scrolling doesn't reach the VM; drag the scroll bar instead.
- Start each `act` with a click on an empty part of the VM's screen, not its title bar: the VM takes clicks and keys only after a click inside it.
- `tart run` in a terminal runs until the VM stops, and Ctrl-C in that terminal shuts the VM down.
- `screencapture` over SSH fails for lack of Screen Recording in the VM; capture the `tart` window from the host with hands.
- Check `screen_list` before acting: another task's `tart` window can sit exactly on top of yours.
+1 -1
View File
@@ -32,7 +32,7 @@ Tailscale identifies the connecting Mac, including requests from a web page open
Loopback access requires the bearer credential in `<data>/loopback-token`, regenerated on service startup. It grants both server status (including the node's sign-in URL) and command execution on connected client Macs. Keep it private: loopback is reachable by other OS accounts, so the address alone provides no protection.
A new node needs signing in: `curl -H "Authorization: Bearer $(cat "$HOME/Library/Application Support/byre/loopback-token")" <loopback>/api/tailnet` (`byre check` prints the loopback address) shows `NeedsLogin` and the `auth_url`, and the app on the same Mac shows it as **Sign in to Tailscale**. The tailnet must have MagicDNS and HTTPS certificates turned on (the DNS page of Tailscale's admin console); without certificates byre logs `tailnet has no https certificates` and serves only the loopback address until it is restarted.
A new node needs signing in: `curl -H "Authorization: Bearer $(cat "$HOME/Library/Application Support/byre/loopback-token")" <loopback>/api/tailnet` (`byre check` prints the loopback address) shows `NeedsLogin` and the `auth_url`, and the app on the same Mac shows it as a "Sign this Mac's server in to Tailscale" step with a **Sign in** button. The tailnet must have MagicDNS and HTTPS certificates turned on (the DNS page of Tailscale's admin console); without certificates byre logs `tailnet has no https certificates` and serves only the loopback address until it is restarted.
Each preview gets its own port on byre's node rather than a path under byre's origin, since path-prefix proxying breaks apps that use absolute paths. Previews use the same user-only tailnet trust boundary as the API; tagged nodes are excluded.
+4
View File
@@ -22,6 +22,10 @@ It prints one JSON line: `ready` (false while something blocking is missing) and
- `byre install` and the app's Server tab (Turn off, Turn on) are the only ways to restart byre's service. Never run `launchctl` on it yourself, and never tell the human to.
- Google Chrome, for agents' browser tools, asks "downloaded from the Internet" on its first launch, and can't be driven until the human answers Open.
## Git identity
Until a user sets one, the app shows a banner asking for a name and email. byre writes them as the author of that user's agents' commits and as their Co-authored-by trailer on merges. Tell the human it is only that author line: not a git login, SSH key or token. Pushing uses whatever credentials git already has on the server's Mac.
## Permissions on the server's Mac
Agents see and control the server's Mac, and read its Desktop, Documents, Downloads and iCloud Drive folders, through macOS privacy grants that belong to byre. Check them with a copy of `byre-hands` (each run needs its own path):
+1 -1
View File
@@ -558,7 +558,7 @@ struct IdentityBanner: View {
let valid = !gitName.isEmpty && emailShape.test(email)
let row = touchOnly ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout(spacing: 6))
row {
Text("Set the identity agents commit as: ")
Text("Name and email for the author line of agents' commits (not a git login or credentials):")
TextField("Git name", text: Binding(get: { gitName }, set: { name = $0 }))
.modifier(FieldStyle())
.frame(width: touchOnly ? nil : 220)
+7 -2
View File
@@ -444,9 +444,14 @@ struct ServerView: View {
}
.task {
await refresh()
var tick = 0
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
if let current = state, current.on, current.address == nil {
try? await Task.sleep(for: .seconds(1))
tick += 1
if tick % 5 == 0, !busy {
checks = await check()
}
if tick % 2 == 0, let current = state, current.on, current.address == nil {
state = await Server.state(loopback: loopback)
}
}
+73 -52
View File
@@ -298,26 +298,49 @@ final class ConnectForm {
}
#if os(macOS)
var installStep: Int?
var installSteps: [SetupStep] {
let names = ["Check the tools", "Install the server", "Sign the server in to Tailscale", "Wait for the server to answer", "Connect"]
return names.enumerated().map { i, name in
guard let installStep, i == installStep else {
return SetupStep(name: name, status: i < (installStep ?? 0) ? .done : .todo)
}
if i == 2, let service = Tailnet.shared.service {
return signInStep(name, hostname: service.hostname, state: service.state, authURL: URL(string: service.authUrl))
}
return SetupStep(name: name, status: .active)
}
}
func installHere() async -> URL? {
connecting = true
problem = nil
missing = []
defer {
installStep = nil
}
do {
installStep = 0
let checks = try await Server.checks()
if !checks.ready {
missing = checks.checks.filter { !$0.ok }
connecting = false
return nil
}
installStep = 1
try await Server.turnOn()
installStep = 2
let origin = try await waitForTailnet(URL(string: checks.loopback)!)
serverText = origin.absoluteString
installStep = 3
try await waitForServer(origin)
} catch {
connecting = false
problem = errorText(error)
return nil
}
installStep = 4
return await connect()
}
@@ -459,31 +482,6 @@ struct ConnectView: View {
#endif
}
struct ConnectingView: View {
let step: String
var problem: String?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text(step)
}
if let problem {
Text("Retrying: \(problem)")
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: 460)
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ServerFocus {
let store: Store?
let overlays: Overlays
@@ -514,40 +512,63 @@ struct ServerWindow: View {
@ViewBuilder
private var content: some View {
if Tailnet.shared.needsLogin {
TailnetLoginView()
} else if !Tailnet.shared.running {
ConnectingView(step: "Starting Tailscale…")
} else if let connection {
let step = "Connecting to \(serverName(connection.origin.absoluteString))…"
switch connection.phase {
case .starting:
ConnectingView(step: step)
case .failed(let problem):
ConnectingView(step: step, problem: problem)
case .running(let store):
#if os(macOS)
MainView()
.environment(store)
.environment(connection.overlays)
.id(ObjectIdentifier(store))
#else
PhoneView()
.environment(store)
.environment(connection.overlays)
.id(ObjectIdentifier(store))
#endif
}
} else if !Tailnet.shared.searched {
ConnectingView(step: "Looking for byre servers on the tailnet…")
} else {
let tailnet = Tailnet.shared
let ready = tailnet.running && !tailnet.needsLogin
#if os(macOS)
let installing = form.installStep != nil
#else
let installing = false
#endif
if installing {
#if os(macOS)
StepsCard(title: "Running byre on this Mac", detail: "byre sets up its server here, then connects to it.", steps: form.installSteps)
#endif
} else if ready, let connection, case .running(let store) = connection.phase {
#if os(macOS)
MainView()
.environment(store)
.environment(connection.overlays)
.id(ObjectIdentifier(store))
#else
PhoneView()
.environment(store)
.environment(connection.overlays)
.id(ObjectIdentifier(store))
#endif
} else if ready, connection == nil, tailnet.searched {
ConnectView(form: form) { connected in
form.serverText = ""
Servers.shared.current = connected.absoluteString
}
} else {
StepsCard(title: "Opening byre", detail: "This app reaches its servers through Tailscale.", steps: launchSteps)
}
}
private var launchSteps: [SetupStep] {
let tailnet = Tailnet.shared
let started = tailnet.state != "Starting" && tailnet.state != "NoState"
var steps = [SetupStep(name: "Start Tailscale", status: started ? .done : .active)]
steps.append(started ? signInStep("Sign this app in to Tailscale", hostname: tailnetNode.hostname, state: tailnet.state, authURL: tailnet.authURL) : SetupStep(name: "Sign this app in to Tailscale", status: .todo))
if let service = tailnet.service {
steps.append(signInStep("Sign this Mac's server in to Tailscale", hostname: service.hostname, state: service.state, authURL: URL(string: service.authUrl)))
}
let ready = tailnet.running && !tailnet.needsLogin
guard let connection else {
steps.append(SetupStep(name: "Find byre servers", status: ready ? .active : .todo))
steps.append(SetupStep(name: "Connect", status: .todo))
return steps
}
let name = serverName(connection.origin.absoluteString)
steps.append(SetupStep(name: "Find byre servers", status: .done, detail: "Found \(name)"))
var connect = SetupStep(name: "Connect to \(name)", status: ready ? .active : .todo)
if ready, case .failed(let problem) = connection.phase {
connect.detail = "Retrying: \(problem)"
}
steps.append(connect)
return steps
}
#if os(macOS)
private var framed: some View {
content
+153
View File
@@ -0,0 +1,153 @@
import SwiftUI
#if os(iOS)
import SafariServices
#endif
struct SetupStep: Identifiable {
enum Status {
case done, active, todo
}
let name: String
var status: Status
var detail: String?
var signIn = false
var signInURL: URL?
var id: String {
name
}
}
func signInStep(_ name: String, hostname: String, state: String, authURL: URL?) -> SetupStep {
if state == "Running" {
return SetupStep(name: name, status: .done)
}
if state == "NeedsMachineAuth" {
return SetupStep(name: name, status: .active, detail: "\(hostname) is waiting for a tailnet admin to approve it")
}
if state == "NeedsLogin" {
return SetupStep(name: name, status: .active, detail: authURL == nil ? "Waiting for Tailscale's sign-in link…" : "\(hostname) is waiting for you", signIn: true, signInURL: authURL)
}
return SetupStep(name: name, status: .active, detail: "Waiting for Tailscale (\(state))…")
}
struct StepsCard: View {
let title: String
let detail: String
let steps: [SetupStep]
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(Fonts.font(17, weight: 700))
Text(detail)
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
.padding(.bottom, 10)
ForEach(Array(steps.enumerated()), id: \.element.id) { i, step in
if i > 0 {
Rectangle().fill(Palette.border).frame(height: 1)
}
StepRow(step: step)
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
.frame(maxWidth: 460)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#if os(macOS)
.onAppear {
Tailnet.shared.serviceWatchers += 1
}
.onDisappear {
Tailnet.shared.serviceWatchers -= 1
}
#endif
}
}
private struct StepRow: View {
let step: SetupStep
@State private var signingIn = false
var body: some View {
HStack(alignment: .center, spacing: 10) {
icon
.frame(width: 18, height: 18)
VStack(alignment: .leading, spacing: 2) {
Text(step.name)
.foregroundStyle(step.status == .todo ? Palette.muted : Palette.text)
if let detail = step.detail {
Text(detail)
.font(Fonts.font(12))
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if step.signIn {
Button("Sign in") {
#if os(macOS)
if let url = step.signInURL {
openExternal(url)
}
#else
signingIn = true
#endif
}
.buttonStyle(WebButtonStyle(primary: true))
.disabled(step.signInURL == nil)
.fixedSize()
}
}
.padding(.vertical, 8)
#if os(iOS)
.sheet(isPresented: $signingIn) {
if let url = step.signInURL {
SafariView(url: url)
.ignoresSafeArea()
}
}
.onChange(of: step.signIn) {
if !step.signIn {
signingIn = false
}
}
#endif
}
@ViewBuilder
private var icon: some View {
switch step.status {
case .done:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Palette.ok)
case .active:
ProgressView()
.controlSize(.small)
case .todo:
Image(systemName: "circle")
.font(.system(size: 8))
.foregroundStyle(Palette.muted)
}
}
}
#if os(iOS)
private struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_ controller: SFSafariViewController, context: Context) {}
}
#endif
-102
View File
@@ -3,9 +3,6 @@ import Network
import Observation
import SwiftUI
import TailscaleKit
#if os(iOS)
import SafariServices
#endif
func slug(_ text: String) -> String {
var out = ""
@@ -242,102 +239,3 @@ final class Tailnet {
}
}
struct TailnetLoginView: View {
var body: some View {
let tailnet = Tailnet.shared
VStack(alignment: .leading, spacing: 12) {
Text("Sign in to Tailscale")
.font(Fonts.font(17, weight: 700))
Text("byre reaches its servers through Tailscale. Sign each of these nodes in to the tailnet your byre servers are on.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 4)
NodeLoginRow(hostname: tailnetNode.hostname, role: "this app", state: tailnet.state, authURL: tailnet.authURL)
if let service = tailnet.service {
NodeLoginRow(hostname: service.hostname, role: "this Mac's server", state: service.state, authURL: URL(string: service.authUrl))
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
.frame(maxWidth: 460)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
Tailnet.shared.serviceWatchers += 1
}
.onDisappear {
Tailnet.shared.serviceWatchers -= 1
}
}
}
private struct NodeLoginRow: View {
let hostname: String
let role: String
let state: String
let authURL: URL?
@State private var signingIn = false
var body: some View {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(hostname).font(Fonts.font(13, weight: 700))
Text(role).foregroundStyle(Palette.muted)
}
if state == "Running" {
Text("Signed in").foregroundStyle(Palette.ok)
} else {
Text(authURL == nil ? "Waiting for Tailscale (\(state))…" : "Waiting for the sign-in (\(state))…")
.font(Fonts.font(12))
.foregroundStyle(Palette.muted)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if state != "Running" {
Button("Sign in") {
#if os(macOS)
if let authURL {
openExternal(authURL)
}
#else
signingIn = true
#endif
}
.buttonStyle(WebButtonStyle(primary: true))
.disabled(authURL == nil)
.fixedSize()
}
}
.padding(12)
.background(Palette.bg, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.border))
#if os(iOS)
.sheet(isPresented: $signingIn) {
if let authURL {
SafariView(url: authURL)
.ignoresSafeArea()
}
}
.onChange(of: state) {
if state == "Running" {
signingIn = false
}
}
#endif
}
}
#if os(iOS)
private struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_ controller: SFSafariViewController, context: Context) {}
}
#endif