Run byre and the app on their own embedded Tailscale nodes
This commit is contained in:
@@ -4,13 +4,43 @@ 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 address: String?
|
||||
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 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 {
|
||||
@@ -25,6 +55,7 @@ struct Check: Decodable {
|
||||
|
||||
struct Checks: Decodable {
|
||||
let ready: Bool
|
||||
let loopback: String
|
||||
let checks: [Check]
|
||||
}
|
||||
|
||||
@@ -309,22 +340,22 @@ private func launchctl(_ args: String...) async throws {
|
||||
}
|
||||
|
||||
enum Server {
|
||||
static func state() async -> ServerState {
|
||||
static func state(loopback: URL?) async -> ServerState {
|
||||
guard let r = try? await execute("/bin/launchctl", ["print", service]), r.status == 0 else {
|
||||
return ServerState()
|
||||
}
|
||||
return ServerState(on: true, address: await address())
|
||||
guard let loopback else {
|
||||
return ServerState(on: true)
|
||||
}
|
||||
return ServerState(on: true, tailnet: try? await tailnet(loopback))
|
||||
}
|
||||
|
||||
private static func address() async -> String? {
|
||||
guard let r = try? await execute("/Applications/Tailscale.app/Contents/MacOS/Tailscale", ["serve", "status", "--json"], env: ["TAILSCALE_BE_CLI": "1"]), r.status == 0,
|
||||
let json = try? JSONSerialization.jsonObject(with: Data(r.output.utf8)) as? [String: Any],
|
||||
let web = json["Web"] as? [String: Any],
|
||||
let host = web.keys.first(where: { $0.hasSuffix(":443") })
|
||||
else {
|
||||
return nil
|
||||
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 "https://" + host.dropLast(4)
|
||||
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.
|
||||
@@ -341,22 +372,19 @@ enum Server {
|
||||
return checks
|
||||
}
|
||||
|
||||
static func install() async throws -> URL {
|
||||
static func install() async throws {
|
||||
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 {
|
||||
guard r.status == 0 else {
|
||||
throw ProcessFailure(errorDescription: "byre install failed: \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// Reinstalling a running service restarts it, so an address read right after could still be answered by the old one.
|
||||
static func turnOn() async throws -> URL {
|
||||
let state = await state()
|
||||
if state.on, let address = state.address, let url = URL(string: address) {
|
||||
return url
|
||||
// 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
|
||||
}
|
||||
return try await install()
|
||||
try await install()
|
||||
}
|
||||
|
||||
static func turnOff() async throws {
|
||||
@@ -420,6 +448,12 @@ struct ServerView: View {
|
||||
.background(Palette.bg)
|
||||
.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 {
|
||||
@@ -428,19 +462,31 @@ struct ServerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
ServiceLoginView(info: tailnet)
|
||||
} 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") {
|
||||
_ = try await Server.turnOn()
|
||||
}
|
||||
button("Turn on", Server.turnOn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,9 +509,8 @@ struct ServerView: View {
|
||||
}
|
||||
|
||||
private func refresh() async {
|
||||
async let checked = check()
|
||||
state = await Server.state()
|
||||
checks = await checked
|
||||
checks = await check()
|
||||
state = await Server.state(loopback: loopback)
|
||||
}
|
||||
|
||||
private func check() async -> Result<Checks, any Error> {
|
||||
|
||||
Reference in New Issue
Block a user