Show both sign-ins on the first screen and find servers automatically

This commit is contained in:
Ian Gulliver
2026-10-20 14:08:35 -07:00
parent a728cb2c96
commit da7f952855
7 changed files with 130 additions and 48 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ macOS, a [Tailscale](https://tailscale.com) tailnet with MagicDNS and HTTPS cert
## 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.
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.
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.
+18 -3
View File
@@ -14,6 +14,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -52,9 +53,7 @@ func serveCmd(args []string) {
ts := &tsnet.Server{
Dir: filepath.Join(*dataDir, "tsnet"),
Hostname: *hostname,
UserLogf: func(format string, args ...any) {
slog.Info("tsnet", "msg", fmt.Sprintf(format, args...))
},
UserLogf: tsnetLogf(),
}
err = ts.Start()
if err != nil {
@@ -124,6 +123,22 @@ func serveTailnet(ctx context.Context, ts *tsnet.Server, hs *http.Server) {
}
}
func tsnetLogf() func(string, ...any) {
mu := sync.Mutex{}
last := ""
return func(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
mu.Lock()
repeat := msg == last
last = msg
mu.Unlock()
if repeat {
return
}
slog.Info("tsnet", "msg", msg)
}
}
func macSlug() string {
out, err := exec.Command("scutil", "--get", "ComputerName").Output()
if err != nil {
+2 -2
View File
@@ -10,7 +10,7 @@ The app joins the tailnet as its own node and reaches byre's HTTPS origin on byr
## Tailnet
`Tailnet.swift` runs one node per app install with libtailscale, through the C API of TailscaleKit (`tailscale_new` and the rest, from the umbrella header): hostname `byre-app-<slug of the Mac's name>` (the slug as the server's, see "Tailnet" in `docs/operations.md`), not ephemeral, its state in `tsnet` in `run.fc.byre.mac` under Application Support. It starts at launch (`tailnetNode`, which exits the app if libtailscale can't start) and opens the node's loopback SOCKS5 proxy (`tailscale_loopback`). `API` sets that proxy, with user `tsnet` and the proxy credential, as the `proxyConfigurations` of its `URLSession`, so REST, the SSE streams (`EventStream`), the command channel (`Remote`) and terminals (`Terminal`) all go over the tailnet; only the Server tab's `GET /api/tailnet` to the local service's loopback address uses `URLSession.shared`. `Tailnet` polls the node's status (`tailscale_status_json`) every second, and until its `BackendState` is `Running` the window shows only **Sign in to Tailscale**, which opens the node's `AuthURL` in the browser. Preview forwards dial through the node too (see "Previews" in `docs/operations.md`).
`Tailnet.swift` runs one node per app install with libtailscale, through the C API of TailscaleKit (`tailscale_new` and the rest, from the umbrella header): hostname `byre-app-<slug of the Mac's name>` (the slug as the server's, see "Tailnet" in `docs/operations.md`), not ephemeral, its state in `tsnet` in `run.fc.byre.mac` under Application Support. It starts at launch (`tailnetNode`, which exits the app if libtailscale can't start) and opens the node's loopback SOCKS5 proxy (`tailscale_loopback`). `API` sets that proxy, with user `tsnet` and the proxy credential, as the `proxyConfigurations` of its `URLSession`, so REST, the SSE streams (`EventStream`), the command channel (`Remote`) and terminals (`Terminal`) all go over the tailnet; only `GET /api/tailnet` to the local service's loopback address uses `URLSession.shared`. `Tailnet` polls the node's status (`tailscale_status_json`) every second, and every 2 s reads `GET /api/tailnet` at the local service's loopback address (the `loopback` `byre check` prints, found once at launch). Until the app's node is `Running`, and while the local service's node is `NeedsLogin`, the window shows only the sign-in screen: a row per node (the app's, and the local service's when one answers), each **Signed in**, or **Sign in to Tailscale**, which opens that node's `AuthURL` in the browser. Servers are added without typing in two ways. When the local service's node runs with an `origin`, that origin is saved as a server (a `ServerRecord`, so iCloud carries it to every Mac on the account). Every 10 s, the app also takes each online tailnet peer owned by its node's user whose MagicDNS name starts with `byre-` but not `byre-app-`, and saves it when `GET /api/admin/status` answers there. Either way a forgotten server comes back the next time it is found. Preview forwards dial through the node too (see "Previews" in `docs/operations.md`).
`mac/bundle.sh` builds TailscaleKit from `github.com/tailscale/libtailscale` at the commit pinned in the script (`libtailscale=`): it clones it into `~/Library/Caches/byre/build/libtailscale-<commit>`, runs its `make -C swift macos` (Go's `c-archive` build, then `xcodebuild` of the framework, unsigned) and wraps the framework with `xcodebuild -create-xcframework`, once per commit, under the build lock. It copies that into the shared build directory's `mac/Frameworks/TailscaleKit.xcframework`, which `mac/Package.swift` names as a binary target, and bundles and signs the framework in `Contents/Frameworks` like Sparkle's. To move to a newer libtailscale, change the pinned commit.
@@ -160,7 +160,7 @@ The app menu has About, Check for Updates…, Forget Server (for the server the
## Connection
Once the app's node runs (see "Tailnet"), on first launch and from Add Server…, the window shows one field, Server URL (an `https` origin with no path, a byre node's `ts.net` origin), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `byre check`, then, unless the service is already loaded, `Contents/Helpers/byre install`, both through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`. It then polls `GET /api/tailnet` at the check's `loopback` address every 500 ms, failing if nothing answers for 10 s, and while the service's node isn't `Running` shows **Sign in to Tailscale** for it (opening its `auth_url`), waiting as long as that takes; a node that runs with no `origin` (the tailnet has no HTTPS certificates) stops it with how to turn them on and restart the service, which the Server tab shows too; once the node runs it takes its `origin`, polls `GET /api/admin/status` there for up to 15 s while the app's node learns of the new one, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is byre running there?"), 401 (byre couldn't identify the app's node), 403 (a tagged node); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window switches to that server; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a saved server switches to it, so each server has one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and when the window shows the connection screen with an empty field it takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the current server's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen with the old origin in the field.
Once the app's node runs (see "Tailnet"), on first launch and from Add Server…, the window shows one field, Server URL (an `https` origin with no path, a byre node's `ts.net` origin), Connect, and Run byre on this Mac. Run byre on this Mac runs the bundle's `byre check`, then, unless the service is already loaded, `Contents/Helpers/byre install`, both through the user's login shell (`pw_shell -l -c 'exec "$BYRE_HELPER" install'`, which sh, zsh, bash and fish all accept), since `byre install` copies its `PATH` into the service and the app's own `PATH` from launchd finds none of `claude`, `codex`, `agy` or `npx`. It then polls `GET /api/tailnet` at the check's `loopback` address every 500 ms, failing if nothing answers for 10 s, and waits as long as the service's node takes to run (while it needs signing in, the window shows the sign-in screen, see "Tailnet"); a node that runs with no `origin` (the tailnet has no HTTPS certificates) stops it with how to turn them on and restart the service, which the Server tab shows too; once the node runs it takes its `origin`, polls `GET /api/admin/status` there for up to 15 s while the app's node learns of the new one, then connects to it as Connect does. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is byre running there?"), 401 (byre couldn't identify the app's node), 403 (a tagged node); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is saved as a `ServerRecord` (see "Persistence") with the first task colour no other saved server uses, startup runs, and the window switches to that server; later launches start against every saved server directly. Connecting to a server whose startup failed replaces its failed connection with a new one, keeping its record and colour. Connecting to a saved server switches to it, so each server has one `Store`; worktree images (`ImageCache`) and review patches (keyed by origin) are cached per server. Each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) reloads the saved servers: it starts a connection for each new one and stops the connection of each one forgotten on another Mac, and when the window shows the connection screen with an empty field it takes the first new one, so on a new Mac the address another Mac saved connects without typing. Forget Server deletes the current server's `ServerRecord` (on every Mac, through iCloud), stops its connection and shows the connection screen with the old origin in the field.
## Updates
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -21
View File
@@ -23,26 +23,6 @@ struct ServerState {
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 ServiceLoginView: View {
let info: TailnetInfo
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("byre's server on this Mac has its own Tailscale node, \(info.hostname). Sign it in to the same tailnet.")
.fixedSize(horizontal: false, vertical: true)
Button("Sign in to Tailscale") {
if let url = URL(string: info.authUrl) {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(WebButtonStyle())
.disabled(URL(string: info.authUrl) == nil)
Text("Waiting for the sign-in (\(info.state))…")
.foregroundStyle(Palette.muted)
}
}
}
struct Check: Decodable {
let name: String
let need: String
@@ -478,7 +458,8 @@ struct ServerView: View {
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
} else if let tailnet = state.tailnet {
ServiceLoginView(info: 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)
+1 -8
View File
@@ -265,7 +265,6 @@ final class ConnectForm {
var problem: String?
var missing: [Check] = []
var connecting = false
var serviceLogin: TailnetInfo?
func connect() async -> URL? {
guard let origin = parseOrigin(serverText) else {
@@ -303,7 +302,6 @@ final class ConnectForm {
try await waitForServer(origin)
} catch {
connecting = false
serviceLogin = nil
problem = errorText(error)
return nil
}
@@ -315,13 +313,11 @@ final class ConnectForm {
while true {
let info = try? await Server.tailnet(loopback)
if let info, info.state == "Running" {
serviceLogin = nil
guard let origin = URL(string: info.origin), !info.origin.isEmpty else {
throw ProcessFailure(errorDescription: noCertificates)
}
return origin
}
serviceLogin = info
if info == nil, Date().timeIntervalSince(started) > 10 {
throw ProcessFailure(errorDescription: "byre was installed but \(loopback.absoluteString) didn't answer within 10 s.")
}
@@ -408,9 +404,6 @@ struct ConnectView: View {
Button("Run byre on this Mac", action: installHere)
}
.disabled(form.connecting)
if let login = form.serviceLogin {
ServiceLoginView(info: login)
}
if let problem = form.problem {
Text(problem)
.foregroundStyle(Palette.danger)
@@ -484,7 +477,7 @@ struct ServerWindow: View {
@ViewBuilder
private var content: some View {
if !Tailnet.shared.running {
if !Tailnet.shared.signedIn {
TailnetLoginView()
} else if let connection {
switch connection.phase {
+104 -11
View File
@@ -72,7 +72,7 @@ final class TailnetNode: Sendable {
return proxy
}
func status() -> (state: String, authURL: URL?)? {
func status() -> (state: String, authURL: URL?, servers: [URL])? {
var out: UnsafeMutablePointer<CChar>?
guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else {
return nil
@@ -80,14 +80,34 @@ final class TailnetNode: Sendable {
defer {
unsafe free(out)
}
struct Node: Decodable {
let DNSName: String
let UserID: Int64
let Online: Bool
}
struct Status: Decodable {
let BackendState: String
let AuthURL: String
let me: Node?
let Peer: [String: Node]?
enum CodingKeys: String, CodingKey {
case BackendState, AuthURL, Peer
case me = "Self"
}
}
guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else {
return nil
}
return (status.BackendState, URL(string: status.AuthURL))
let servers = (status.Peer ?? [:]).values.compactMap { peer -> URL? in
let host = peer.DNSName.hasSuffix(".") ? String(peer.DNSName.dropLast()) : peer.DNSName
let name = host.split(separator: ".").first ?? ""
guard peer.Online, peer.UserID == status.me?.UserID, name.hasPrefix("byre-"), !name.hasPrefix("byre-app-") else {
return nil
}
return parseOrigin("https://\(host)")
}
return (status.BackendState, URL(string: status.AuthURL), servers)
}
// Blocks until the connection is made or fails.
@@ -109,41 +129,114 @@ final class Tailnet {
private(set) var state = "Starting"
private(set) var authURL: URL?
private(set) var service: TailnetInfo?
@ObservationIgnored private var servers: [URL] = []
var running: Bool {
state == "Running"
}
var signedIn: Bool {
running && service?.state != "NeedsLogin"
}
private init() {
Task {
while true {
if let status = tailnetNode.status() {
state = status.state
authURL = status.authURL
servers = status.servers
}
try? await Task.sleep(for: .seconds(1))
}
}
Task {
await watchService()
}
Task {
await discover()
}
}
private func watchService() async {
var loopback: URL?
while true {
if loopback == nil {
do {
loopback = URL(string: try await Server.checks().loopback)
} catch {
log.error("find the local service: \(errorText(error), privacy: .public)")
}
}
if let loopback {
service = try? await Server.tailnet(loopback)
if let service, service.state == "Running", let origin = parseOrigin(service.origin), !Servers.shared.origins.contains(origin.absoluteString) {
Servers.shared.add(origin)
}
}
try? await Task.sleep(for: .seconds(2))
}
}
private func discover() async {
while true {
if running {
for origin in servers where !Servers.shared.origins.contains(origin.absoluteString) {
let api = API(origin: origin)
if (try? await api.client.getStatus().ok) != nil {
log.notice("found \(origin.absoluteString, privacy: .public) on the tailnet")
Servers.shared.add(origin)
}
api.session.finishTasksAndInvalidate()
}
}
try? await Task.sleep(for: .seconds(10))
}
}
}
struct TailnetLoginView: View {
var body: some View {
let tailnet = Tailnet.shared
VStack(alignment: .leading, spacing: 10) {
Text("byre reaches its servers through its own Tailscale node, \(tailnetNode.hostname). Sign it in to the tailnet your byre servers are on.")
VStack(alignment: .leading, spacing: 16) {
Text("byre reaches its servers through Tailscale. Sign each of these nodes in to the tailnet your byre servers are on.")
.fixedSize(horizontal: false, vertical: true)
Button("Sign in to Tailscale") {
if let url = tailnet.authURL {
NSWorkspace.shared.open(url)
}
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))
}
.disabled(tailnet.authURL == nil)
Text(tailnet.authURL == nil ? "Waiting for Tailscale (\(tailnet.state))…" : "Waiting for the sign-in (\(tailnet.state))…")
.foregroundStyle(Palette.muted)
}
.frame(width: 460)
.padding(.vertical, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct NodeLoginRow: View {
let hostname: String
let role: String
let state: String
let authURL: URL?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(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 {
Button("Sign in to Tailscale") {
if let authURL {
NSWorkspace.shared.open(authURL)
}
}
.disabled(authURL == nil)
Text(authURL == nil ? "Waiting for Tailscale (\(state))…" : "Waiting for the sign-in (\(state))…")
.foregroundStyle(Palette.muted)
}
}
}
}