Tag v0.1.0
This commit is contained in:
@@ -37,7 +37,7 @@ struct OverlayAlert: Identifiable {
|
||||
}
|
||||
|
||||
enum LeftTab {
|
||||
case lead, status, settings
|
||||
case lead, status, server, settings
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -138,6 +138,9 @@ private struct LeftPane: View {
|
||||
PaneTabButton(label: "Status", active: tab == .status) {
|
||||
overlays.leftTab = .status
|
||||
}
|
||||
PaneTabButton(label: "Server", active: tab == .server) {
|
||||
overlays.leftTab = .server
|
||||
}
|
||||
PaneTabButton(label: "Settings", active: tab == .settings) {
|
||||
overlays.leftTab = .settings
|
||||
}
|
||||
@@ -169,6 +172,8 @@ private struct LeftPane: View {
|
||||
.accessibilityHidden(tab != .lead)
|
||||
if tab == .status {
|
||||
StatusView()
|
||||
} else if tab == .server {
|
||||
ServerView()
|
||||
} else if tab == .settings {
|
||||
SettingsView()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,46 @@ final class Connection {
|
||||
await start(origin)
|
||||
}
|
||||
|
||||
func installHere() async {
|
||||
connecting = true
|
||||
problem = nil
|
||||
let gen = generation
|
||||
do {
|
||||
let origin = try await Server.turnOn()
|
||||
serverText = origin.absoluteString
|
||||
try await waitForServer(origin)
|
||||
} catch {
|
||||
if gen == generation {
|
||||
connecting = false
|
||||
problem = errorText(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard gen == generation else {
|
||||
return
|
||||
}
|
||||
await connect()
|
||||
}
|
||||
|
||||
private func waitForServer(_ origin: URL) async throws {
|
||||
let api = API(origin: origin)
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
while (try? await api.client.getStatus().ok) == nil {
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: .seconds(10))
|
||||
throw ProcessFailure(errorDescription: "byre was installed but \(origin.absoluteString) didn't answer within 10 s.")
|
||||
}
|
||||
defer {
|
||||
group.cancelAll()
|
||||
}
|
||||
try await group.next()
|
||||
}
|
||||
}
|
||||
|
||||
func changeServer() {
|
||||
generation += 1
|
||||
if let store {
|
||||
@@ -175,8 +215,11 @@ struct ConnectView: View {
|
||||
TextField("https://byre.example.ts.net", text: $connection.serverText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(connect)
|
||||
Button("Connect", action: connect)
|
||||
.disabled(connection.connecting)
|
||||
HStack(spacing: 10) {
|
||||
Button("Connect", action: connect)
|
||||
Button("Run byre on this Mac", action: installHere)
|
||||
}
|
||||
.disabled(connection.connecting)
|
||||
if let problem = connection.problem {
|
||||
Text(problem)
|
||||
.foregroundStyle(Palette.danger)
|
||||
@@ -193,6 +236,12 @@ struct ConnectView: View {
|
||||
await connection.connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func installHere() {
|
||||
Task {
|
||||
await connection.installHere()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FatalView: View {
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import Foundation
|
||||
import Sparkle
|
||||
|
||||
private struct LaunchAgent: Decodable {
|
||||
let programArguments: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case programArguments = "ProgramArguments"
|
||||
}
|
||||
}
|
||||
|
||||
private let lastVersionKey = "lastLaunchedVersion"
|
||||
|
||||
@MainActor
|
||||
@@ -31,11 +23,9 @@ final class Updater {
|
||||
}
|
||||
|
||||
private func restartServer() {
|
||||
let agent = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/LaunchAgents/run.fc.byre.plist")
|
||||
let server = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre").path
|
||||
guard let data = try? Data(contentsOf: agent),
|
||||
guard let data = try? Data(contentsOf: agentPlist),
|
||||
let plist = try? PropertyListDecoder().decode(LaunchAgent.self, from: data),
|
||||
plist.programArguments.first == server
|
||||
plist.programArguments.first == serverHelper
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mac=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
"$mac/package.sh"
|
||||
version=$(plutil -extract CFBundleShortVersionString raw "$mac/.build/Byre.app/Contents/Info.plist")
|
||||
dmg=$mac/.build/release/$version/byre-$version.dmg
|
||||
"$mac/install.sh" "$dmg"
|
||||
if pgrep -xq Byre; then
|
||||
pkill -x Byre
|
||||
while pgrep -xq Byre; do
|
||||
sleep 0.2
|
||||
done
|
||||
open /Applications/Byre.app
|
||||
fi
|
||||
launchctl kickstart -k "gui/$(id -u)/run.fc.byre"
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
dmg=$1
|
||||
mount=$(mktemp -d)
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$dmg"
|
||||
rm -rf /Applications/Byre.app.new
|
||||
ditto "$mount/Byre.app" /Applications/Byre.app.new
|
||||
hdiutil detach "$mount"
|
||||
rm -rf /Applications/Byre.app
|
||||
mv /Applications/Byre.app.new /Applications/Byre.app
|
||||
plutil -extract ByreCommit raw /Applications/Byre.app/Contents/Info.plist
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mac=$(cd "$(dirname "$0")" && pwd)
|
||||
repo=ai/byre
|
||||
sparkle_version=2.10.0
|
||||
sparkle=$HOME/Library/Caches/byre/sparkle-$sparkle_version
|
||||
identity="Developer ID Application"
|
||||
|
||||
if [ ! -x "$sparkle/bin/sign_update" ]; then
|
||||
mkdir -p "$sparkle"
|
||||
curl -fsSL "https://github.com/sparkle-project/Sparkle/releases/download/$sparkle_version/Sparkle-$sparkle_version.tar.xz" | tar -xJ -C "$sparkle"
|
||||
fi
|
||||
|
||||
"$mac/bundle.sh"
|
||||
app=$mac/.build/Byre.app
|
||||
version=$(plutil -extract CFBundleShortVersionString raw "$app/Contents/Info.plist")
|
||||
tag=v$version
|
||||
out=$mac/.build/release/$version
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
|
||||
ditto -c -k --keepParent "$app" "$out/byre.zip"
|
||||
xcrun notarytool submit "$out/byre.zip" --keychain-profile byre-notary --wait
|
||||
xcrun stapler staple "$app"
|
||||
rm "$out/byre.zip"
|
||||
|
||||
dmg=byre-$version.dmg
|
||||
stage=$out/stage
|
||||
mkdir "$stage"
|
||||
ditto "$app" "$stage/Byre.app"
|
||||
ln -s /Applications "$stage/Applications"
|
||||
hdiutil create -volname byre -srcfolder "$stage" -format UDZO "$out/$dmg"
|
||||
rm -rf "$stage"
|
||||
codesign --force --timestamp --sign "$identity" "$out/$dmg"
|
||||
xcrun notarytool submit "$out/$dmg" --keychain-profile byre-notary --wait
|
||||
xcrun stapler staple "$out/$dmg"
|
||||
(cd "$out" && shasum -a 256 "$dmg" > "$dmg.sha256")
|
||||
|
||||
build=$(plutil -extract CFBundleVersion raw "$app/Contents/Info.plist")
|
||||
minimum=$(plutil -extract LSMinimumSystemVersion raw "$app/Contents/Info.plist")
|
||||
signature=$("$sparkle/bin/sign_update" --account byre "$out/$dmg")
|
||||
cat > "$out/appcast.xml" <<EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
||||
<channel>
|
||||
<title>byre</title>
|
||||
<item>
|
||||
<title>byre $version</title>
|
||||
<pubDate>$(LC_ALL=C date -u "+%a, %d %b %Y %H:%M:%S +0000")</pubDate>
|
||||
<sparkle:version>$build</sparkle:version>
|
||||
<sparkle:shortVersionString>$version</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>$minimum</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://g.fc.run/$repo/releases/download/$tag/$dmg" type="application/octet-stream" $signature/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
EOF
|
||||
echo "$out/$dmg"
|
||||
+1
-48
@@ -4,9 +4,6 @@ mac=$(cd "$(dirname "$0")" && pwd)
|
||||
root=$(dirname "$mac")
|
||||
repo=ai/byre
|
||||
api=https://g.fc.run/api/v1/repos/$repo
|
||||
sparkle_version=2.10.0
|
||||
sparkle=$HOME/Library/Caches/byre/sparkle-$sparkle_version
|
||||
identity="Developer ID Application"
|
||||
|
||||
if [ -n "$(git -C "$root" status --porcelain)" ]; then
|
||||
echo "release: the tree has uncommitted changes" >&2
|
||||
@@ -21,53 +18,9 @@ if [ "$(git -C "$root" rev-parse "FETCH_HEAD^{commit}")" != "$(git -C "$root" re
|
||||
fi
|
||||
token=$(security find-generic-password -s byre-release -w)
|
||||
|
||||
if [ ! -x "$sparkle/bin/sign_update" ]; then
|
||||
mkdir -p "$sparkle"
|
||||
curl -fsSL "https://github.com/sparkle-project/Sparkle/releases/download/$sparkle_version/Sparkle-$sparkle_version.tar.xz" | tar -xJ -C "$sparkle"
|
||||
fi
|
||||
|
||||
"$mac/bundle.sh"
|
||||
app=$mac/.build/Byre.app
|
||||
"$mac/package.sh"
|
||||
out=$mac/.build/release/$version
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
|
||||
ditto -c -k --keepParent "$app" "$out/byre.zip"
|
||||
xcrun notarytool submit "$out/byre.zip" --keychain-profile byre-notary --wait
|
||||
xcrun stapler staple "$app"
|
||||
rm "$out/byre.zip"
|
||||
|
||||
dmg=byre-$version.dmg
|
||||
stage=$out/stage
|
||||
mkdir "$stage"
|
||||
ditto "$app" "$stage/Byre.app"
|
||||
ln -s /Applications "$stage/Applications"
|
||||
hdiutil create -volname byre -srcfolder "$stage" -format UDZO "$out/$dmg"
|
||||
rm -rf "$stage"
|
||||
codesign --force --timestamp --sign "$identity" "$out/$dmg"
|
||||
xcrun notarytool submit "$out/$dmg" --keychain-profile byre-notary --wait
|
||||
xcrun stapler staple "$out/$dmg"
|
||||
(cd "$out" && shasum -a 256 "$dmg" > "$dmg.sha256")
|
||||
|
||||
build=$(plutil -extract CFBundleVersion raw "$app/Contents/Info.plist")
|
||||
minimum=$(plutil -extract LSMinimumSystemVersion raw "$app/Contents/Info.plist")
|
||||
signature=$("$sparkle/bin/sign_update" --account byre "$out/$dmg")
|
||||
cat > "$out/appcast.xml" <<EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
||||
<channel>
|
||||
<title>byre</title>
|
||||
<item>
|
||||
<title>byre $version</title>
|
||||
<pubDate>$(LC_ALL=C date -u "+%a, %d %b %Y %H:%M:%S +0000")</pubDate>
|
||||
<sparkle:version>$build</sparkle:version>
|
||||
<sparkle:shortVersionString>$version</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>$minimum</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://g.fc.run/$repo/releases/download/$tag/$dmg" type="application/octet-stream" $signature/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
EOF
|
||||
|
||||
previous=$(git -C "$root" describe --tags --abbrev=0 "$tag^" || true)
|
||||
if [ -n "$previous" ]; then
|
||||
|
||||
Reference in New Issue
Block a user