Tag v0.1.0
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct LaunchAgent: Decodable {
|
||||
let programArguments: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case programArguments = "ProgramArguments"
|
||||
}
|
||||
}
|
||||
|
||||
let agentPlist = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/LaunchAgents/run.fc.byre.plist")
|
||||
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?
|
||||
}
|
||||
|
||||
struct ProcessFailure: LocalizedError {
|
||||
let errorDescription: String?
|
||||
}
|
||||
|
||||
private func execute(_ path: String, _ args: [String], env: [String: String] = [:]) async throws -> (status: Int32, output: String) {
|
||||
try await Task.detached {
|
||||
let p = Process()
|
||||
p.executableURL = URL(filePath: path)
|
||||
p.arguments = args
|
||||
p.environment = ProcessInfo.processInfo.environment.merging(env) { $1 }
|
||||
let out = Pipe()
|
||||
p.standardOutput = out
|
||||
p.standardError = out
|
||||
try p.run()
|
||||
let data = out.fileHandleForReading.readDataToEndOfFile()
|
||||
p.waitUntilExit()
|
||||
return (p.terminationStatus, String(decoding: data, as: UTF8.self))
|
||||
}.value
|
||||
}
|
||||
|
||||
private func launchctl(_ args: String...) async throws {
|
||||
let r = try await execute("/bin/launchctl", args)
|
||||
if r.status != 0 {
|
||||
throw ProcessFailure(errorDescription: "launchctl \(args.joined(separator: " ")): \(r.output.trimmingCharacters(in: .whitespacesAndNewlines))")
|
||||
}
|
||||
}
|
||||
|
||||
enum Server {
|
||||
static func state() 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())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return "https://" + host.dropLast(4)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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])
|
||||
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))")
|
||||
}
|
||||
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
|
||||
}
|
||||
return try await install()
|
||||
}
|
||||
|
||||
static func turnOff() async throws {
|
||||
try await launchctl("bootout", service)
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerView: View {
|
||||
@Environment(Overlays.self) private var overlays
|
||||
@ViewState private var state: ServerState?
|
||||
@ViewState private var busy = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Server on this Mac")
|
||||
.font(Fonts.font(13, weight: 700))
|
||||
.padding(.top, 16)
|
||||
if let state {
|
||||
details(state)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Palette.bg)
|
||||
.task {
|
||||
await refresh()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
Task {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
button("Turn off", Server.turnOff)
|
||||
} else {
|
||||
Text("Off").foregroundStyle(Palette.muted)
|
||||
button("Turn on") {
|
||||
_ = try await Server.turnOn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func button(_ label: String, _ action: @escaping () async throws -> Void) -> some View {
|
||||
Button(label) {
|
||||
busy = true
|
||||
Task {
|
||||
do {
|
||||
try await action()
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
await refresh()
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.disabled(busy)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private func refresh() async {
|
||||
state = await Server.state()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user