diff --git a/README.md b/README.md index 8494642..1b63eb7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/byre/serve.go b/cmd/byre/serve.go index 956167a..d8a6837 100644 --- a/cmd/byre/serve.go +++ b/cmd/byre/serve.go @@ -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 { diff --git a/docs/macos.md b/docs/macos.md index 3b3e43d..1c76752 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -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-` (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-` (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-`, 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 diff --git a/docs/operations.md b/docs/operations.md index db9613f..32b113d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -132,7 +132,7 @@ byre is served at the root of its node's `ts.net` name on 443 (`https://.< ## Tailnet -byre doesn't use the Tailscale app. `byre serve` runs its own node with `tailscale.com/tsnet` (`cmd/byre/serve.go`), its state in `/tsnet`, its hostname `--ts-hostname`, by default `byre-` (`scutil --get ComputerName`, lower-cased, each run of other characters a `-`), and its logs in byre's log as `tsnet` lines. A new node needs signing in: until it is, the log repeats its sign-in URL every few seconds, and `GET /api/tailnet` on the loopback address reports `state` `NeedsLogin` and the URL as `auth_url` (`hostname`, and `origin` once the node has a certificate domain, are the rest). The macOS app's Server tab and connection screen show it as **Sign in to Tailscale** (see "UI"). Once the node is up (`tsnet.Server.Up`), byre listens on its port 443 with TLS from the node's certificate (`local.Client.GetCertificate`) offering HTTP/2, and serves the API there. 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. +byre doesn't use the Tailscale app. `byre serve` runs its own node with `tailscale.com/tsnet` (`cmd/byre/serve.go`), its state in `/tsnet`, its hostname `--ts-hostname`, by default `byre-` (`scutil --get ComputerName`, lower-cased, each run of other characters a `-`), and its logs in byre's log as `tsnet` lines. A new node needs signing in: until it is, the log has its sign-in URL (each distinct `tsnet` line is logged once, though tsnet repeats it every few seconds), and `GET /api/tailnet` on the loopback address reports `state` `NeedsLogin` and the URL as `auth_url` (`hostname`, and `origin` once the node has a certificate domain, are the rest). The macOS app on the same Mac shows it on its sign-in screen as **Sign in to Tailscale**, and once the node runs saves its `origin` as a server, which iCloud carries to the user's other Macs (`docs/macos.md`, "Tailnet"). Once the node is up (`tsnet.Server.Up`), byre listens on its port 443 with TLS from the node's certificate (`local.Client.GetCertificate`) offering HTTP/2, and serves the API there. 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. The macOS app joins as a node of its own (see "Tailnet" in `docs/macos.md`), and every byre server it uses is on the same tailnet. @@ -308,7 +308,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie Task agents get notes too. An implementer session's instructions end with `taskNotesGuidance` and `notesText` for the task's creator, limited to the every-project notes and the task's project's (`startLive` in `internal/agents/manager.go`), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a `suggest_note` tool (`text`, `evidence`) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with `remember`, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's `lead.note_saved`. - **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input. -The UI is two panes filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`), so its headings name the Mac (`Host.current().localizedName`, as in `Byre-Host`): Server on is On when launchd has the service loaded (`launchctl print gui//run.fc.byre` succeeds), showing Turn off (`launchctl bootout`) and, from `GET /api/tailnet` at the `loopback` address `byre check` prints, the service's `origin` once its node is `Running`, or else **Sign in to Tailscale** for the service's node, which opens its `auth_url`, polling every 2 s until the node runs; Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a `.command` file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac leave the service running instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Below it, Usage commands has one field per vendor, empty meaning the vendor's own reports, loaded from and saved to that service's `/api/admin/usage-commands` (see "Usage" under "Status"). Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the status in the transcript's composer bar points at what needs the user there (see "Attention" under "Inbox"). The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled; while the task is `discard_suggested` it opens the Discard confirmation instead. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. +The UI is two panes filling the window, with no navbar. The left has six tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Workflows, Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Workflows (`mac/Sources/Byre/Workflows.swift`) lists `GET /api/workflows` by name with each description, refetching on `workflow.updated` and `workflow.deleted`; a workflow's run button doesn't start anything itself, since a run needs a project and a brief: it appends `Run the "" workflow: ` to the lead's composer draft, selects the Lead tab and focuses the composer, for the human to finish and send. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`), so its headings name the Mac (`Host.current().localizedName`, as in `Byre-Host`): Server on is On when launchd has the service loaded (`launchctl print gui//run.fc.byre` succeeds), showing Turn off (`launchctl bootout`) and, from `GET /api/tailnet` at the `loopback` address `byre check` prints, the service's `origin` once its node is `Running`, or else the node's hostname and state, polling every 2 s until the node runs (a node that needs signing in takes the window to the sign-in screen); Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one: its command in a box with Copy and Run in Terminal, which writes the command to a `.command` file in the temporary directory, run by the user's login shell, and opens it in Terminal, so prompts like sudo's work and the list updates when the app becomes active again; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac leave the service running instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Below it, Usage commands has one field per vendor, empty meaning the vendor's own reports, loaded from and saved to that service's `/api/admin/usage-commands` (see "Usage" under "Status"). Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Workflows, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Workflow and Project, always in that order. Board shows only on a team task (see "Teams") and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there. Questions and reviews have no tabs: both live in the transcript (see "Inbox" and "Reviews"), and the status in the transcript's composer bar points at what needs the user there (see "Attention" under "Inbox"). The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled; while the task is `discard_suggested` it opens the Discard confirmation instead. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open. ## TODO diff --git a/mac/Sources/Byre/Server.swift b/mac/Sources/Byre/Server.swift index 2df1e14..c0becf8 100644 --- a/mac/Sources/Byre/Server.swift +++ b/mac/Sources/Byre/Server.swift @@ -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) diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index 09fd9b0..ef1ab5f 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -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 { diff --git a/mac/Sources/Byre/Tailnet.swift b/mac/Sources/Byre/Tailnet.swift index 25192f7..d40f987 100644 --- a/mac/Sources/Byre/Tailnet.swift +++ b/mac/Sources/Byre/Tailnet.swift @@ -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? 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) + } + } + } +}