Add signed, notarized releases with the server inside the app

This commit is contained in:
Ian Gulliver
2026-10-18 15:19:57 -07:00
parent 10a80ccb22
commit 6b86a7d5a9
20 changed files with 388 additions and 66 deletions
+13 -3
View File
@@ -20,11 +20,21 @@ A server and macOS app for running many coding agents (Claude Code and Codex) in
## Requirements
macOS, Go, Xcode, [Tailscale](https://tailscale.com), and the `claude` and `codex` CLIs. Identity comes from Tailscale: byre only serves requests that arrive through `tailscale serve`.
macOS, [Tailscale](https://tailscale.com) on every Mac, and on the Mac that runs the server, `git`, the `claude` and `codex` CLIs, Node.js (agents' Playwright browser tools run through `npx`) and Google Chrome (`brew install git node --cask google-chrome`). Identity comes from Tailscale: byre only serves requests that arrive through `tailscale serve`.
## Running
## Installing
Install the server as a launchd service and build the app as described under "Running as a service" and "macOS app" in [docs/operations.md](docs/operations.md). Point the app at byre's `tailscale serve` URL (`tailscale serve status` prints it).
1. Download the latest `byre-<version>.dmg` from [the releases page](https://g.fc.run/misc/byre/releases) and drag `byre.app` to Applications, on every Mac you'll use byre from.
2. On the Mac that runs the server, from a terminal whose `PATH` finds `claude`, `codex`, `git` and `npx`:
```
/Applications/byre.app/Contents/Helpers/byre install
```
It installs the server as a launchd service and serves it through `tailscale serve`, printing the URL.
3. Open byre on each Mac and enter that URL.
The app updates itself, and the server along with it on the server's Mac. To build from source instead, see "Running as a service" and "macOS app" in [docs/operations.md](docs/operations.md); that needs Go and Xcode.
## Docs
+4 -2
View File
@@ -26,5 +26,7 @@
- Phone app
A native client for the phone over the same API, sharing the macOS client's SwiftUI code where it can. Every surveyed tool has one (Claude, Codex, Cursor and Conductor apps, GitHub Mobile, Superset for iPhone); it needs its own layout, since two half-width panes don't fit a phone.
- Real release
A tagged version with a downloadable, signed and notarized app that runs on Macs outside the team. Sign `Byre` and `byre-hands` with a Developer ID certificate and hardened runtime (`codesign --options runtime`), embed a Developer ID provisioning profile (iCloud needs one outside the App Store), switch `aps-environment` to `production` and deploy the CloudKit schema to Production (its store is separate from Development's), notarize with `notarytool` and staple, and ship a zip or dmg. `mac/bundle.sh` takes the version from the tag instead of the commit hash. The README gets install steps for the server (`go install g.fc.run/misc/byre/cmd/byre@<tag>`, plus building `byre-hands`) and for building the app under one's own Apple team, since `mac/byre.entitlements` and `mac/byre.provisionprofile` name this team.
- Move to g.fc.run/ai/byre
Before tagging v0.1.0, since the update feed URL is baked into every installed app. The owner creates the `ai` org on g.fc.run and transfers the repo (Settings → Danger Zone → Transfer ownership). Then rename all the way down: `go.mod`'s module and every import, `SUFeedURL` in `mac/Info.plist`, `repo=` in `mac/release.sh`, README and docs links, and the service checkout's `origin` (`git@g.fc.run:ai/byre.git`).
- Server setup in the app
Setting up the server's Mac from the app instead of a terminal: on the connection screen, a choice to run the server on this Mac, which does what `Contents/Helpers/byre install` does (the launch agent, `tailscale serve`), checks that `claude`, `codex`, `git` and Tailscale are there, and connects to the URL it set up.
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"context"
"encoding/xml"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"g.fc.run/misc/byre/internal/tailscale"
)
const serviceLabel = "run.fc.byre"
const plistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>serve</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>%s</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>%s</string>
<key>StandardErrorPath</key>
<string>%s</string>
</dict>
</plist>
`
func installCmd() {
exe, err := os.Executable()
if err != nil {
fatal("find executable", "err", err)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
fatal("resolve executable", "err", err)
}
home, err := os.UserHomeDir()
if err != nil {
fatal("home dir", "err", err)
}
plistPath := filepath.Join(home, "Library", "LaunchAgents", serviceLabel+".plist")
logPath := filepath.Join(home, "Library", "Logs", "byre.log")
plist := fmt.Sprintf(plistTemplate, xmlText(serviceLabel), xmlText(exe), xmlText(filepath.Dir(exe)+":"+os.Getenv("PATH")), xmlText(logPath), xmlText(logPath))
err = os.MkdirAll(filepath.Dir(plistPath), 0o755)
if err != nil {
fatal("create launch agents dir", "err", err)
}
err = os.WriteFile(plistPath, []byte(plist), 0o644)
if err != nil {
fatal("write launch agent", "err", err)
}
fmt.Println("wrote", plistPath)
domain := fmt.Sprintf("gui/%d", os.Getuid())
service := domain + "/" + serviceLabel
if exec.Command("launchctl", "print", service).Run() == nil {
run("launchctl", "bootout", service)
}
run("launchctl", "bootstrap", domain, plistPath)
fmt.Println("started", service, "running", exe)
url, err := tailscale.ServeRoot(context.Background(), "http://"+defaultListen)
if err != nil {
fatal("tailscale serve", "err", err)
}
fmt.Println("byre is at", url)
}
func run(name string, args ...string) {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
fatal("run "+name, "args", args, "err", err)
}
}
func xmlText(s string) string {
var b strings.Builder
xml.EscapeText(&b, []byte(s))
return b.String()
}
+3 -1
View File
@@ -14,6 +14,8 @@ func main() {
switch os.Args[1] {
case "serve":
serveCmd(os.Args[2:])
case "install":
installCmd()
case "run":
runCmd(os.Args[2:])
default:
@@ -22,7 +24,7 @@ func main() {
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: byre serve | byre run [--resume ID]")
fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre run [--resume ID]")
os.Exit(2)
}
+3 -1
View File
@@ -17,6 +17,8 @@ import (
"g.fc.run/misc/byre/internal/store"
)
const defaultListen = "127.0.0.1:8270"
func serveCmd(args []string) {
configDir, err := os.UserConfigDir()
if err != nil {
@@ -25,7 +27,7 @@ func serveCmd(args []string) {
flags := flag.NewFlagSet("serve", flag.ExitOnError)
dataDir := flags.String("data", filepath.Join(configDir, "byre"), "directory holding byre's database, worktrees, leads and blobs")
listenAddr := flags.String("listen", "127.0.0.1:8270", "address to listen on")
listenAddr := flags.String("listen", defaultListen, "address to listen on")
ports := flags.Int("ports", store.DefaultPortFirst, "first port of the tasks' port blocks")
flags.Parse(args)
-26
View File
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>run.fc.byre</string>
<key>ProgramArguments</key>
<array>
<string>/Users/flamingcow/bin/byre</string>
<string>serve</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/flamingcow/bin:/Users/flamingcow/.local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/flamingcow/Library/Logs/byre.log</string>
<key>StandardErrorPath</key>
<string>/Users/flamingcow/Library/Logs/byre.log</string>
</dict>
</plist>
+10 -5
View File
@@ -14,11 +14,13 @@ The app connects to byre's `tailscale serve` HTTPS URL, so its requests get the
- AppKit only through `NSViewRepresentable`, and only where the "Gaps in SwiftUI" table below says so: the terminal.
- Foundation's `URLSession` for REST, the SSE streams and the terminal WebSocket.
- `UserNotifications` for notifications.
- Third-party code: Apple's `swift-openapi-generator` (with `swift-openapi-runtime` and `swift-openapi-urlsession`) for the API types and client, and SwiftTerm for the terminal.
- Third-party code: Apple's `swift-openapi-generator` (with `swift-openapi-runtime` and `swift-openapi-urlsession`) for the API types and client, SwiftTerm for the terminal, and Sparkle for updates (its framework is a binary target, loaded from `Contents/Frameworks` through the `@executable_path/../Frameworks` rpath set in `mac/Package.swift`).
The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/`, `api/` and `hands/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables out to `mac/.build/Byre` and `mac/.build/byre-hands`. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/byre.app` (the executables, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, and `CFBundleShortVersionString` and `CFBundleVersion` added from the same commit for the About panel, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier.
The project is a Swift package under `mac/`, built with `swift build`, not an Xcode project. It builds with Xcode's toolchain: `xcode-select --print-path` must print `/Applications/Xcode.app/Contents/Developer`. With the Command Line Tools selected while Xcode is installed, Swift Build finds two toolchains for the Command Line Tools path and picks one at random on each run; the pick is part of the build request, so every change of pick rebuilds every target. `mac/bundle.sh` builds the release configuration in one directory shared by every worktree, `~/Library/Caches/byre/build`: SwiftPM keys a build on the package's absolute path, so a build in a fresh worktree, or one sharing a `--scratch-path`, recompiles every dependency. Under a `lockf` lock on `~/Library/Caches/byre/build.lock`, so builds from different worktrees take turns, it copies the worktree's `mac/`, `api/` and `hands/` there with `rsync --checksum`, which rewrites only files whose contents differ and leaves the rest's modification times alone, builds both packages, and copies the executables and Sparkle's framework out to `mac/.build/Byre`, `mac/.build/byre-hands` and `mac/.build/Sparkle.framework`. The server is built outside the lock with `go build -o mac/.build/server/byre`, since Go's own cache is shared and safe to use concurrently. It passes `--no-color-diagnostics` because `COLOR_DIAGNOSTICS` otherwise follows whether output is a terminal and is part of the build request, so agents whose shells differ would each rebuild every target in turn; `build-request.json` under `.build/out/Intermediates.noindex/XCBuildData/` shows what a build was keyed on. It then assembles `mac/.build/byre.app` (`Byre` in `Contents/MacOS`, the server and `byre-hands` in `Contents/Helpers`, Sparkle in `Contents/Frameworks`, `mac/Info.plist` with the fixed bundle identifier `run.fc.byre.mac` and a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, which `API` sends as the `Byre-Commit` header on every request beside `Byre-Host`, the Mac's name, `CFBundleShortVersionString` from `git describe --tags --always` and `CFBundleVersion` from `git rev-list --count HEAD`, the `mac/fonts/` files and an `.icns` rendered from `mac/icon.svg`, cached as `mac/.build/byre.icns` until the SVG changes) and signs it; notification permission needs the bundle identifier. `docs/operations.md`, "macOS app", lists the signing order.
The app is signed with the team's Apple Development certificate (`codesign --sign "Apple Development"`), with the entitlements in `mac/byre.entitlements` (the team-prefixed application identifier, the iCloud container with CloudKit, and development push for sync), and `mac/byre.provisionprofile` embedded as `Contents/embedded.provisionprofile`. The profile is a development profile Xcode manages: it allows the team's Apple Development certificates and the Macs registered to the team, so it runs only on those Macs, and its CloudKit environment is Development. `security cms -D -i mac/byre.provisionprofile` prints it, `ProvisionedDevices` and `DeveloperCertificates` included, and `security find-identity -v -p codesigning` lists the certificates on this Mac. Building on a Mac not yet in the profile, or one without an Apple Development certificate, means regenerating it with Xcode's automatic signing: an Xcode app project with bundle identifier `run.fc.byre.mac`, the team, and an entitlements file naming the iCloud container with CloudKit, built with `xcodebuild -allowProvisioningUpdates -allowProvisioningDeviceRegistration` while the team's Apple ID is signed in to Xcode, creates the certificate, registers the Mac and writes the new profile to `~/Library/Developer/Xcode/UserData/Provisioning Profiles/`; copy it over `mac/byre.provisionprofile` and commit it.
## Signing
Every build is signed for distribution outside the App Store: the team's Developer ID Application certificate (`codesign --sign "Developer ID Application"`), `--options runtime` (the hardened runtime notarization requires) and `--timestamp`, with the entitlements in `mac/byre.entitlements` (the team-prefixed application identifier, the iCloud container with CloudKit in its Production environment, production push for sync, and Apple Events for driving Orion) and `mac/byre.provisionprofile` embedded as `Contents/embedded.provisionprofile`. iCloud outside the App Store needs that profile: a Developer ID profile for the App ID `run.fc.byre.mac` with iCloud (CloudKit, container `iCloud.run.fc.byre.mac`) enabled, made at developer.apple.com under Certificates, Identifiers & Profiles → Profiles → Developer ID. It names no devices, so the app runs on any Mac. `security cms -D -i mac/byre.provisionprofile` prints it, `DeveloperCertificates` included, and `security find-identity -v -p codesigning` lists the certificates on this Mac. Only the team's Account Holder can create a Developer ID Application certificate (Xcode → Settings → Accounts → Manage Certificates → + → Developer ID Application, signed in as the Account Holder); a Mac that builds the app needs that certificate and its private key in its keychain. A build that isn't notarized still runs on a Mac that didn't download it, such as one it was copied to with `client_upload`, since only downloaded files are quarantined; `mac/release.sh` notarizes releases (see "Releases" in `docs/operations.md`).
Only the release configuration is built. A debug build stops at an `#error` in `ByreApp.swift`, and even a failed one leaves the next release build in the same build directory recompiling every target, since the two configurations share it. The generated API client is its own target, `ByreAPI`, so an edit to the app recompiles only the `Byre` target. SwiftPM builds release targets whole-module, so that is one compile of the whole `Byre` target, and a one-file change still costs that. The app keeps its server origin, drafts and visits in SwiftData (see "Persistence").
@@ -145,12 +147,16 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima
## Keyboard and menus
The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands are disabled while an alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay.
The app menu has About, Check for Updates…, Change Server…, Hide, Hide Others and Quit; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands are disabled while an alert or modal is open (`dialogOpen`); the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. The Ctrl variants (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay.
## Connection
On first launch the window shows one field, Server URL (an `https` origin with no path), and Connect. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); 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 kept as the app's one `ServerRecord` (see "Persistence") and startup runs; later launches start against it directly. While the connection screen shows with an empty field, each finished CloudKit import (`NSPersistentCloudKitContainer.eventChangedNotification`) looks for a `ServerRecord` again and starts against it, so on a new Mac the address another Mac saved connects without typing.
## Updates
`Updater.swift` runs Sparkle's standard updater (`SPUStandardUpdaterController`), started at launch. It reads `SUFeedURL` from `mac/Info.plist`, the appcast of the newest release (see "Releases" in `docs/operations.md`), checks it daily (`SUEnableAutomaticChecks`, so Sparkle doesn't first ask whether to), compares the appcast's `sparkle:version` with `CFBundleVersion`, and verifies the download's EdDSA signature against `SUPublicEDKey` and its Developer ID signature against the running app's. Check for Updates… in the app menu checks at once. Sparkle's own windows show the offer and the progress; the appcast carries no release notes, so its release-notes view, a web view, is never shown. At launch, the app compares its `CFBundleVersion` with `lastLaunchedVersion` in `UserDefaults` and stores the new one; when a stored version differs, however the bundle was replaced (Sparkle relaunching, installing on quit, or a new copy dragged in), it reads `~/Library/LaunchAgents/run.fc.byre.plist`, and when the service's program is this bundle's `Contents/Helpers/byre`, runs `launchctl kickstart -k gui/<uid>/run.fc.byre`, so the server updates with the app. A server installed from a checkout is left alone.
## Persistence
`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (the server origin), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the newest `ServerRecord` and `DraftRecord` and the latest `VisitRecord` per task, and the next write keeps one and deletes the rest. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. Change Server… (in the app menu) flushes drafts, reports pane focus false, drops the store and streams, and shows the connection screen again, its field holding the current origin. Quitting does the same flush and report, waiting up to 2 s for the report before exiting (`applicationShouldTerminate` in `ByreApp.swift`).
@@ -179,4 +185,3 @@ The font files need one thing beyond loading. The latin and latin-ext files are
- A Dock badge, a `byre:` URL scheme, Quick Look, a closed-task browser, editing files.
- Several servers or windows.
- Automatic updates.
+27 -12
View File
@@ -110,35 +110,50 @@ A team task has several agents working at once in its one worktree, branch and p
## Running as a service
The listen address is `byre serve --listen`, whose default is in `cmd/byre/serve.go` (`go run ./cmd/byre serve --help` prints it); keep it on loopback, since identity relies on requests arriving from `tailscale serve`.
The listen address is `byre serve --listen`, whose default is `defaultListen` in `cmd/byre/serve.go` (`go run ./cmd/byre serve --help` prints it); keep it on loopback, since identity relies on requests arriving from `tailscale serve`.
`byre install` (`cmd/byre/install.go`) sets the service up for the binary it is run as, with `byre-hands` beside it: it writes `~/Library/LaunchAgents/run.fc.byre.plist` running `<that binary> serve`, with a `PATH` of that binary's directory (so `byre-hands` beside it is found) followed by the `PATH` of the shell it was run from (which must find `claude`, `codex`, `git` and `npx`, the last for the Playwright MCP server, which also needs Google Chrome) and logs to `~/Library/Logs/byre.log`; boots out the service if it is loaded, so the new plist is read; bootstraps it; and runs `tailscale serve --bg --https=443 http://<default listen address>` through the Tailscale app's CLI (`tailscale.ServeRoot`, see "Previews"), printing `byre is at https://<machine>.<tailnet>.ts.net`. Running it again after an update restarts the service on the new binary. From a release, that is `/Applications/byre.app/Contents/Helpers/byre install`. From a checkout:
```
go install ./cmd/byre
codesign --force --sign "Apple Development" --identifier run.fc.byre ~/bin/byre
swift build --package-path hands --configuration release
install hands/.build/release/byre-hands ~/bin/
cp deploy/run.fc.byre.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/run.fc.byre.plist
tailscale serve --bg --https=443 http://<listen address>
~/bin/byre install
```
byre is served at the root of the machine's `ts.net` name on 443 (`https://<machine>.<tailnet>.ts.net`), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. `tailscale serve status` shows the URL and what else the machine serves.
byre is served at the root of the machine's `ts.net` name on 443 (`https://<machine>.<tailnet>.ts.net`), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. `tailscale serve status` shows the URL and what else the machine serves. `launchctl print gui/$(id -u)/run.fc.byre` shows the service and the program it runs.
After rebuilding: step 1 of "Deploying" in `AGENTS.md`. Logs go to `~/Library/Logs/byre.log`.
After rebuilding from a checkout: step 1 of "Deploying" in `AGENTS.md`, which restarts whatever the plist runs, so it only takes effect when the plist points at `~/bin/byre`. Logs go to `~/Library/Logs/byre.log`.
## Releases
A release is one notarized disk image, `byre-<version>.dmg`, holding `byre.app`: the macOS app, with the server (`Contents/Helpers/byre`) and `byre-hands` (`Contents/Helpers/byre-hands`) inside it. Every Mac installs the same app; the server's Mac also runs `byre install` from it (see "Running as a service"). Releases are on the repo's releases page on g.fc.run, `https://g.fc.run/misc/byre/releases`.
`mac/release.sh` makes one, run on byre's host from a checkout whose `HEAD` carries a `v<version>` tag already pushed to `origin`, with a clean tree; it fetches the tag from `origin` and stops unless it names `HEAD`. It builds with `mac/bundle.sh` (signed with Developer ID, see `docs/macos.md`), zips the app and notarizes it with `xcrun notarytool submit --keychain-profile byre-notary --wait`, staples the ticket, builds the disk image with `hdiutil` (the app and an `/Applications` link), signs, notarizes and staples that too, and writes its SHA-256 beside it. It then signs the image for Sparkle with `sign_update --account byre` and writes `appcast.xml`, a feed with that one release. Last, it creates the release as a draft through the Gitea API (`POST /api/v1/repos/misc/byre/releases`, notes the commit subjects since the previous tag), uploads the image, its `.sha256` and `appcast.xml` as assets, and only then publishes it (`PATCH` with `draft: false`), so the `latest` release always has its appcast. A run that fails part way leaves the draft on the releases page; delete it there before running again. Everything lands in `mac/.build/release/<version>/`, and the script prints the release page.
It needs, on that Mac:
- a Developer ID Application certificate in the keychain (`security find-identity -v -p codesigning` lists it), with its key's access answered **Always Allow** for `codesign` (the first signing asks for the login keychain password on the Mac's screen, and a prompt left waiting fails that run with "timestamps differ"), and `mac/byre.provisionprofile` being the Developer ID profile for `run.fc.byre.mac` with iCloud (`security cms -D -i mac/byre.provisionprofile` prints it);
- a `notarytool` keychain profile named `byre-notary`, made with `xcrun notarytool store-credentials byre-notary --key <AuthKey.p8> --key-id <id> --issuer <issuer>` from an App Store Connect API key;
- the Sparkle signing key in the login keychain under account `byre` (`~/Library/Caches/byre/sparkle-<version>/bin/generate_keys --account byre -p` prints its public key, which must match `SUPublicEDKey` in `mac/Info.plist`; `-x <file>` exports it for safekeeping). The script downloads Sparkle's tools to that cache directory when they are missing;
- a g.fc.run token that can write the repo's releases, as the generic password with service `byre-release` (`security add-generic-password -s byre-release -a g.fc.run -w <token>`).
The app checks `https://g.fc.run/misc/byre/releases/download/latest/appcast.xml` for updates (see "Updates" in `docs/macos.md`); Gitea serves the `latest` name from the newest release, so each release's appcast replaces the last.
Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered.
## macOS app
**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/byre.app`: the `Byre` executable, `byre-hands` built from `hands/` beside it in `Contents/MacOS` (signed on its own, as `run.fc.byre.hands`, before the bundle), `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, `CFBundleShortVersionString` its `git rev-parse --short HEAD` and `CFBundleVersion` its `git rev-list --count HEAD` (the About panel shows them as "Version <short hash> (<count>)"), the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`, then signs the bundle with the Mac's Apple Development certificate and `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), `rsvg-convert` (Homebrew's `librsvg`), an Apple Development certificate for the team (`security find-identity -v -p codesigning` lists them) and a Mac registered in the profile (`security cms -D -i mac/byre.provisionprofile` prints `ProvisionedDevices`); `docs/macos.md`, "Stack", says how to regenerate the profile for a new Mac. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour.
**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` in `~/Library/Caches/byre/build`, one build directory every worktree shares and takes turns in (`docs/macos.md`, "Stack", says why), and assembles `mac/.build/byre.app`: the `Byre` executable in `Contents/MacOS`; the server, built with `go build ./cmd/byre`, and `byre-hands`, built from `hands/`, in `Contents/Helpers` (not beside `Byre`: the default APFS volume ignores case, so `byre` and `Byre` would be one file); Sparkle's framework in `Contents/Frameworks`; `mac/Info.plist` with a `ByreCommit` key added holding the worktree's `git rev-parse HEAD`, `CFBundleShortVersionString` its `git describe --tags --always` without the leading `v` (`0.1.0` on a release tag, `0.1.0-12-gabc1234` after it) and `CFBundleVersion` its `git rev-list --count HEAD` (the About panel shows them as "Version <version> (<count>)", and Sparkle compares the count); the `mac/fonts/` woff2 files, an icon rendered from `mac/icon.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`. It signs each piece with the Developer ID Application certificate, a secure timestamp and the hardened runtime, innermost first: `byre-hands` as `run.fc.byre.hands` and the server as `run.fc.byre`, both with `mac/automation.entitlements` (Apple Events, which the hardened runtime otherwise refuses), Sparkle's `Autoupdate`, `Updater.app` and framework (its XPC services, only needed by sandboxed apps, are removed), then the bundle with `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), Go, `rsvg-convert` (Homebrew's `librsvg`), the team's Developer ID Application certificate and network access for the timestamp, and it stops first unless `mac/byre.provisionprofile` has `ProvisionsAllDevices` (a Developer ID profile; a development profile can't authorize the Developer ID signature); `docs/macos.md`, "Signing", covers the certificate and profile. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build on a Mac fetches the Swift packages pinned in `mac/Package.resolved` and compiles them and the generated API client; later builds, from any worktree, recompile only the targets whose sources differ from the last build's, usually just `Byre`. The API client is generated from `api/openapi.yaml` at build time. The palette is `mac/Sources/Byre/Palette.swift`, one light and one dark value per colour.
**Installing.** The app runs from where it was built: `open mac/.build/byre.app` in the worktree that built it. Nothing copies it to `/Applications`. A new build replaces the bundle, so quit the running app first (`pkill -x Byre` quits every running copy, whichever worktree built it) and open the new one; step 4 of "Deploying" in `AGENTS.md` does this after a deploy. Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the server the last one used. Closing the window leaves the app running for notifications; ⌘Q quits it.
**Installing.** A release is installed from its disk image into `/Applications` (see "Releases") and updates itself (`docs/macos.md`, "Updates"). A build from a checkout runs from where it was built: `open mac/.build/byre.app` in the worktree that built it. A new build replaces the bundle, so quit the running app first (`pkill -x Byre` quits every running copy, whichever worktree built it) and open the new one; step 4 of "Deploying" in `AGENTS.md` does this after a deploy. Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the server the last one used. Closing the window leaves the app running for notifications; ⌘Q quits it.
**Storage.** The app keeps three kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): the server origin, when each task was last selected, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence" and "Drafts"). The store is `~/Library/Application Support/run.fc.byre.mac/byre.store`, with its `-shm` and `-wal` files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default `default.store`, which every unsandboxed app shares. The store syncs through the iCloud private database of container `iCloud.run.fc.byre.mac`, in CloudKit's Development environment; the CloudKit console (icloud.developer.apple.com, the container, Development, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Change Server… and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
**Storage.** The app keeps three kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): the server origin, when each task was last selected, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence" and "Drafts"). The store is `~/Library/Application Support/run.fc.byre.mac/byre.store`, with its `-shm` and `-wal` files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default `default.store`, which every unsandboxed app shares. The store syncs through the iCloud private database of container `iCloud.run.fc.byre.mac`, in CloudKit's Production environment (`com.apple.developer.icloud-container-environment` in `mac/byre.entitlements`), whose schema is deployed from Development in the CloudKit console before a release that changes the SwiftData models; the CloudKit console (icloud.developer.apple.com, the container, Production, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Change Server… and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it.
## Screen and app control
Implementer sessions get `byre-hands` as a stdio MCP server named `hands` (`handsMCP` in `internal/agents/hands.go`), passed the same way as reviewers' Playwright server (see "Reviews"); reviewers and lead agents don't get it. It is a Swift executable built from `hands/` and installed in `~/bin`, which is on the service's `PATH`. Each session runs its own process from its own file: when the session starts, byre copies the `byre-hands` it finds on `PATH` to `hands/<session-id>/byre-hands` in the data directory and runs that, and it removes the directory when the session's agent process ends. A hung or crashed one affects only that agent. The separate files matter because replayd, the daemon behind ScreenCaptureKit, keys its clients by executable path: two processes run from one path keep cancelling each other's connection, and every capture from that path hangs. replayd's log shows it as `accepted client connection PID: <a>` and `invalidated because the current process cancelled the connection` alternating between two pids, thousands of times a second. It speaks MCP's JSON-RPC over stdin and stdout, one message per line, and returns captures as inline PNG image content. Its tools, whose descriptions in `hands/Sources/byre-hands/Tools.swift` are what agents read:
Implementer sessions get `byre-hands` as a stdio MCP server named `hands` (`handsMCP` in `internal/agents/hands.go`), passed the same way as reviewers' Playwright server (see "Reviews"); reviewers and lead agents don't get it. It is a Swift executable built from `hands/`, installed beside the `byre` binary (in `~/bin` from a checkout, in `Contents/Helpers` of the app from a release), and found on the service's `PATH`, which `byre install` starts with that directory. Each session runs its own process from its own file: when the session starts, byre copies the `byre-hands` it finds on `PATH` to `hands/<session-id>/byre-hands` in the data directory and runs that, and it removes the directory when the session's agent process ends. A hung or crashed one affects only that agent. The separate files matter because replayd, the daemon behind ScreenCaptureKit, keys its clients by executable path: two processes run from one path keep cancelling each other's connection, and every capture from that path hangs. replayd's log shows it as `accepted client connection PID: <a>` and `invalidated because the current process cancelled the connection` alternating between two pids, thousands of times a second. It speaks MCP's JSON-RPC over stdin and stdout, one message per line, and returns captures as inline PNG image content. Its tools, whose descriptions in `hands/Sources/byre-hands/Tools.swift` are what agents read:
- `screen_list`: running apps, on-screen windows (front to back) and displays.
- `capture`: one window, or a display, through ScreenCaptureKit. A window is captured as its rectangle of its display with only its own app's windows included, so other apps covering it don't hide it and its menus, popups and sheets show; capturing the window alone fails while a popup is attached to it. The image is scaled to `max_edge` (default 1568, as much as a model sees; 16 to 8192) and the reply gives the pixel-to-point scale. With `save`, it is also written as `capture-<random>.png` in the directory `byre-hands` was started with as `--media-dir`, and the reply gives the path; a `byre-hands` started without one (the macOS app's, which `client_hands` reaches) refuses `save`.
@@ -149,7 +164,7 @@ Implementer sessions get `byre-hands` as a stdio MCP server named `hands` (`hand
`byre-hands` reads stdin on a background thread and starts each request as its own detached task as soon as it arrives, so one call stuck on an app doesn't hold up the others; responses are written whole, one line each, under a lock, in the order they finish. Ref names come from one counter for the life of the process, so no two trees ever share a name, even trees built at the same time; the ref table is swapped in under a lock once a tree is complete, so a ref from any tree but the last published one fails as unknown instead of naming another tree's element. Each `act` call takes the table as it stands when the call arrives and resolves every step's refs from that copy, so a tree finishing during its waits can't point a ref at a different element. AppleScript needs the main thread with a run loop, so every script runs there (`osa` in `Browser.swift`, `DispatchQueue.main.sync`, while the main thread runs `RunLoop.main`), one at a time, wrapped in `with timeout of 10 seconds`; `NSApplication.shared` connects the process to the window server at startup, without which ScreenCaptureKit asserts. An app that is in a menu-tracking loop or otherwise busy answers neither Apple events nor accessibility requests: a script fails with AppleScript's timeout error after 10s, and accessibility requests time out after 3s (the messaging timeout set on the system-wide element at startup), and every accessibility read (`attr` in `AX.swift`, and the action-name and settable checks in the tree walk) throws on the first timeout, so a tree walk, `wait_for`, a ref's position or the front-window check stops with an error saying the app isn't answering instead of waiting 3s on every attribute of every element.
**Permissions.** Agents' processes, `byre-hands` included, run as byre's descendants, so macOS privacy controls (TCC) hold byre responsible for them: the Screen Recording, Accessibility and Automation grants in System Settings → Privacy & Security are byre's, made by clicking at the Mac's screen, and each app scripted for the first time asks for its own Automation grant there. `ax_tree` and `act` check `AXIsProcessTrusted` first and fail saying byre lacks Accessibility when it isn't granted, since an untrusted process otherwise just sees no windows and no menu bar. Accessibility can list byre switched off; only its switch being on counts. `codesign` gives byre the identifier `run.fc.byre` and the Apple Development certificate, which TCC keys grants on, so they carry over from build to build. `codesign -dv ~/bin/byre` shows the signature the running binary has.
**Permissions.** Agents' processes, `byre-hands` included, run as byre's descendants, so macOS privacy controls (TCC) hold byre responsible for them: the Screen Recording, Accessibility and Automation grants in System Settings → Privacy & Security are byre's, made by clicking at the Mac's screen, and each app scripted for the first time asks for its own Automation grant there. `ax_tree` and `act` check `AXIsProcessTrusted` first and fail saying byre lacks Accessibility when it isn't granted, since an untrusted process otherwise just sees no windows and no menu bar. Accessibility can list byre switched off; only its switch being on counts. `codesign` gives byre the identifier `run.fc.byre` and the team's certificate (Apple Development from a checkout, Developer ID in a release), which TCC keys grants on, so they carry over from build to build, but not between a checkout's build and a release's. `codesign -dv <binary>` shows the signature a binary has, and `launchctl print gui/$(id -u)/run.fc.byre` which binary the service runs.
## Screenshots from implementers
@@ -334,7 +349,7 @@ Implementer sessions get eight MCP tools for it (`internal/agents/clients.go`).
- `client_edit`: a `read_file`, then a `write_file` of the text with `old_string` replaced by `new_string`, refused unless `old_string` appears exactly once. Nothing locks the file or checks it is unchanged between the two, so a write in between, whether another agent's `client_edit` of the same file or the human's or an app's, is lost.
`client_read`, `client_write` and `client_edit` wait at most 2 minutes for each reply, and their paths, like `client_download`'s `path`, are absolute or start with `~/`, which the app expands.
- `client_hands`: `op: hands`, with `tool` and `arguments` as the `hands` tools take them (see "Screen and app control"). The app runs one `byre-hands`, bundled at `Contents/MacOS/byre-hands`, started on first use and again after it exits, writes each call to its stdin as a `tools/call` line and replies with the line's `result`, which byre returns to the agent as the tool's result, images included (`mcp.Tool.Rich`; Codex gets them as `inputImage` data URLs). The macOS privacy grants it needs (Screen Recording, Accessibility, Automation of Orion) belong to byre's app on that Mac, since the app is the responsible process; `NSAppleEventsUsageDescription` in `mac/Info.plist` is what lets macOS ask for the Automation grant. Each `byre-hands` process has its own pending calls, so one that exits fails only the calls sent to it.
- `client_hands`: `op: hands`, with `tool` and `arguments` as the `hands` tools take them (see "Screen and app control"). The app runs one `byre-hands`, bundled at `Contents/Helpers/byre-hands`, started on first use and again after it exits, writes each call to its stdin as a `tools/call` line and replies with the line's `result`, which byre returns to the agent as the tool's result, images included (`mcp.Tool.Rich`; Codex gets them as `inputImage` data URLs). The macOS privacy grants it needs (Screen Recording, Accessibility, Automation of Orion) belong to byre's app on that Mac, since the app is the responsible process; `NSAppleEventsUsageDescription` in `mac/Info.plist` is what lets macOS ask for the Automation grant. Each `byre-hands` process has its own pending calls, so one that exits fails only the calls sent to it.
A byre restart drops every connection; each app reconnects 2s later, and calls in flight at the restart fail as disconnected. Updating a client's app goes through the tools themselves: `client_upload` the new `byre.app`, then `client_bash` a command that quits the old app and opens the new one, which outlives the app and reconnects. An app that doesn't know the `upload` op can't be updated this way, so its update is by hand.
+19 -6
View File
@@ -13,28 +13,41 @@ import (
const cli = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
func Serve(ctx context.Context, port int) error {
_, err := run(ctx, "serve", "--bg", "--https="+strconv.Itoa(port), "http://127.0.0.1:"+strconv.Itoa(port))
_, err := serve(ctx, port, "http://127.0.0.1:"+strconv.Itoa(port))
return err
}
func ServeRoot(ctx context.Context, target string) (string, error) {
host, err := serve(ctx, 443, target)
if err != nil {
return err
return "", err
}
return "https://" + strings.TrimSuffix(host, ":443"), nil
}
func serve(ctx context.Context, port int, target string) (string, error) {
_, err := run(ctx, "serve", "--bg", "--https="+strconv.Itoa(port), target)
if err != nil {
return "", err
}
out, err := run(ctx, "serve", "status", "--json")
if err != nil {
return err
return "", err
}
status := struct {
Web map[string]json.RawMessage
}{}
err = json.Unmarshal(out, &status)
if err != nil {
return fmt.Errorf("parse tailscale serve status: %w", err)
return "", fmt.Errorf("parse tailscale serve status: %w", err)
}
suffix := ":" + strconv.Itoa(port)
for host := range status.Web {
if strings.HasSuffix(host, suffix) {
return nil
return host, nil
}
}
return fmt.Errorf("tailscale serve --https=%d exited 0 but port %d is not in serve status", port, port)
return "", fmt.Errorf("tailscale serve --https=%d exited 0 but port %d is not in serve status", port, port)
}
func Off(ctx context.Context, port int) error {
+6
View File
@@ -16,6 +16,12 @@
<string>27.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>SUFeedURL</key>
<string>https://g.fc.run/misc/byre/releases/download/latest/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>350+qbxFCpm+LhbRlCxtBHST+FTz6o19jhGOSs8D0XU=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>byre's agents drive Orion on this Mac through AppleScript when a task asks them to.</string>
</dict>
+10 -1
View File
@@ -1,5 +1,5 @@
{
"originHash" : "c8eddc113e1728df643cb0e886195a9376637e4c97c431bbf2201b07be34a7c6",
"originHash" : "1e64f8e620bfd60399ac1276949a869cf223612c3875be1481772d36a8a2181d",
"pins" : [
{
"identity" : "h",
@@ -19,6 +19,15 @@
"version" : "6.4.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "eef1a539a373c1f1a320624b1130fc5de7b2e100",
"version" : "2.10.0"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
+4 -1
View File
@@ -9,6 +9,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-openapi-generator", from: "1.10.0"),
.package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.0"),
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.1.0"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.10.0"),
],
targets: [
.target(
@@ -26,8 +27,10 @@ let package = Package(
.product(name: "SwiftTerm", package: "SwiftTerm"),
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
.product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"),
.product(name: "Sparkle", package: "Sparkle"),
],
swiftSettings: [.strictMemorySafety()]
swiftSettings: [.strictMemorySafety()],
linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])]
),
]
)
+4
View File
@@ -14,6 +14,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
Notifier.shared.install()
Updater.shared.start()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@@ -64,6 +65,9 @@ struct ByreApp: App {
.defaultSize(width: 1280, height: 820)
.commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates…") {
Updater.shared.check()
}
Button("Change Server…") {
connection.changeServer()
}
+1 -1
View File
@@ -371,7 +371,7 @@ private actor Hands {
let stdin = Pipe()
let stdout = Pipe()
let p = Process()
p.executableURL = Bundle.main.executableURL!.deletingLastPathComponent().appending(path: "byre-hands")
p.executableURL = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre-hands")
p.standardInput = stdin
p.standardOutput = stdout
p.standardError = FileHandle.nullDevice
+53
View File
@@ -0,0 +1,53 @@
import Foundation
import Sparkle
private struct LaunchAgent: Decodable {
let programArguments: [String]
enum CodingKeys: String, CodingKey {
case programArguments = "ProgramArguments"
}
}
private let lastVersionKey = "lastLaunchedVersion"
@MainActor
final class Updater {
static let shared = Updater()
private lazy var controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
func start() {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
let last = UserDefaults.standard.string(forKey: lastVersionKey)
UserDefaults.standard.set(version, forKey: lastVersionKey)
if last != nil && last != version {
restartServer()
}
_ = controller
}
func check() {
controller.checkForUpdates(nil)
}
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),
let plist = try? PropertyListDecoder().decode(LaunchAgent.self, from: data),
plist.programArguments.first == server
else {
return
}
let p = Process()
p.executableURL = URL(filePath: "/bin/launchctl")
p.arguments = ["kickstart", "-k", "gui/\(getuid())/run.fc.byre"]
do {
try p.run()
p.waitUntilExit()
log.info("restarted byre server: status \(p.terminationStatus)")
} catch {
log.error("restart byre server: \(errorText(error), privacy: .public)")
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
+27 -6
View File
@@ -7,14 +7,22 @@ build=$mac/.build
app=$build/byre.app
svg=$mac/icon.svg
icns=$build/byre.icns
identity="Developer ID Application"
mkdir -p "$shared" "$build"
if [ "$(security cms -D -i "$mac/byre.provisionprofile" | plutil -extract ProvisionsAllDevices raw -)" != true ]; then
echo "bundle: mac/byre.provisionprofile is not a Developer ID profile" >&2
exit 1
fi
lockf -k "$shared.lock" sh -ec '
rsync -rlc --delete --exclude /mac/.build --exclude /hands/.build "$1/mac" "$1/api" "$1/hands" "$2/"
swift build --package-path "$2/mac" --configuration release --no-color-diagnostics
swift build --package-path "$2/hands" --configuration release --no-color-diagnostics
cp "$2/mac/.build/release/Byre" "$3"
cp "$2/hands/.build/release/byre-hands" "$4"
' sh "$root" "$shared" "$build/Byre" "$build/byre-hands"
rm -rf "$5"
ditto "$2/mac/.build/release/Sparkle.framework" "$5"
' sh "$root" "$shared" "$build/Byre" "$build/byre-hands" "$build/Sparkle.framework"
go -C "$root" build -trimpath -o "$build/server/byre" ./cmd/byre
if [ ! "$icns" -nt "$svg" ]; then
iconset=$build/byre.iconset
rm -rf "$iconset"
@@ -25,17 +33,30 @@ if [ ! "$icns" -nt "$svg" ]; then
done
iconutil --convert icns --output "$icns" "$iconset"
fi
version=$(git -C "$root" describe --tags --always)
version=${version#v}
rm -rf "$app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources/fonts"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Helpers" "$app/Contents/Frameworks" "$app/Contents/Resources/fonts"
cp "$build/Byre" "$app/Contents/MacOS/Byre"
cp "$build/byre-hands" "$app/Contents/MacOS/byre-hands"
codesign --force --sign "Apple Development" --identifier run.fc.byre.hands "$app/Contents/MacOS/byre-hands"
cp "$build/server/byre" "$app/Contents/Helpers/byre"
cp "$build/byre-hands" "$app/Contents/Helpers/byre-hands"
ditto "$build/Sparkle.framework" "$app/Contents/Frameworks/Sparkle.framework"
cp "$mac/Info.plist" "$app/Contents/Info.plist"
plutil -insert ByreCommit -string "$(git -C "$root" rev-parse HEAD)" "$app/Contents/Info.plist"
plutil -insert CFBundleShortVersionString -string "$(git -C "$root" rev-parse --short HEAD)" "$app/Contents/Info.plist"
plutil -insert CFBundleShortVersionString -string "$version" "$app/Contents/Info.plist"
plutil -insert CFBundleVersion -string "$(git -C "$root" rev-list --count HEAD)" "$app/Contents/Info.plist"
cp "$mac"/fonts/*.woff2 "$app/Contents/Resources/fonts/"
cp "$icns" "$app/Contents/Resources/byre.icns"
cp "$mac/byre.provisionprofile" "$app/Contents/embedded.provisionprofile"
codesign --force --sign "Apple Development" --entitlements "$mac/byre.entitlements" "$app"
sparkle=$app/Contents/Frameworks/Sparkle.framework
rm -rf "$sparkle/Versions/B/XPCServices"
sign() {
codesign --force --timestamp --options runtime --sign "$identity" "$@"
}
sign --identifier run.fc.byre.hands --entitlements "$mac/automation.entitlements" "$app/Contents/Helpers/byre-hands"
sign --identifier run.fc.byre --entitlements "$mac/automation.entitlements" "$app/Contents/Helpers/byre"
sign "$sparkle/Versions/B/Autoupdate"
sign "$sparkle/Versions/B/Updater.app"
sign "$sparkle"
sign --entitlements "$mac/byre.entitlements" "$app"
echo "$app"
+5 -1
View File
@@ -14,7 +14,11 @@
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<string>production</string>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
Binary file not shown.
Executable
+90
View File
@@ -0,0 +1,90 @@
#!/bin/sh
set -eu
mac=$(cd "$(dirname "$0")" && pwd)
root=$(dirname "$mac")
repo=misc/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
exit 1
fi
tag=$(git -C "$root" describe --tags --exact-match HEAD)
version=${tag#v}
git -C "$root" fetch origin "refs/tags/$tag"
if [ "$(git -C "$root" rev-parse "FETCH_HEAD^{commit}")" != "$(git -C "$root" rev-parse HEAD)" ]; then
echo "release: $tag on origin is not HEAD" >&2
exit 1
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
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
notes=$(git -C "$root" log --format='- %s' "$previous..$tag")
else
notes="First release."
fi
request=$out/release.json
plutil -create xml1 "$request"
plutil -insert tag_name -string "$tag" "$request"
plutil -insert name -string "byre $version" "$request"
plutil -insert body -string "$notes" "$request"
plutil -insert draft -bool true "$request"
plutil -convert json "$request"
id=$(curl -fsS -X POST -H "Authorization: token $token" -H "Content-Type: application/json" --data-binary "@$request" "$api/releases" | plutil -extract id raw -)
for file in "$dmg" "$dmg.sha256" appcast.xml; do
curl -fsS -o /dev/null -H "Authorization: token $token" -F "attachment=@$out/$file" "$api/releases/$id/assets?name=$file"
done
curl -fsS -o /dev/null -X PATCH -H "Authorization: token $token" -H "Content-Type: application/json" --data '{"draft":false}' "$api/releases/$id"
echo "https://g.fc.run/$repo/releases/tag/$tag"