Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ jobs:
include:
- { target: linux-x64, os: ubuntu-24.04 }
- { target: linux-arm64, os: ubuntu-24.04-arm }
# macOS is on hold; scripts/build.sh builds it as hub + web server
# - { target: darwin-arm64, os: macos-15 }
# no X11 on a Mac: scripts/build.sh builds it as hub + web UI
- { target: darwin-arm64, os: macos-15 }
runs-on: ${{ matrix.os }}
env:
TAG: ${{ inputs.tag || github.ref_name }}
Expand Down Expand Up @@ -84,6 +84,7 @@ jobs:
- run: scripts/smoke.sh
- run: scripts/package.sh "$TAG" "${{ matrix.target }}"
- run: scripts/package-linux.sh "$TAG" "${{ matrix.target }}"
if: runner.os == 'Linux'
- uses: actions/upload-artifact@v4
with:
name: backplane-${{ matrix.target }}
Expand Down Expand Up @@ -157,7 +158,11 @@ jobs:
set -eu
pre=""
case "$TAG" in *-*) pre="--prerelease" ;; esac
notes="Install: curl -fsSL https://github.com/${{ github.repository }}/releases/latest/download/install.sh | sh"
# each release's installer installs that release (latest/ never
# names a prerelease, so a nightly's must be its own)
notes="Linux (x64, arm64) and macOS (Apple silicon; the hub and web UI at http://127.0.0.1:3787, no native window yet).

Install: \`curl -fsSL https://github.com/${{ github.repository }}/releases/download/$TAG/install.sh | sh\`"
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" out/* --clobber
else
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Headless UI checks: start Xvfb on `:77`, run `DISPLAY=:77 BACKPLANE_SNAP=/tmp/sn
- Base's `String.to_lower`/`to_upper` recurse once per character outside a tail call. On a long message they overflowed the phones' V8 sandbox, which crashes natively (no RangeError) and took the Android app with it. Never lower a whole message text; look at the few characters you need (`Img.may` in images.bend). Android now starts the client again in QuickJS if the sandbox dies (`Engine.eval`, `Core`'s `onReset`). Replay a phone launch on real data at a small stack to find these: `node --stack-size=500` with `test/tools/launch_bench.ts`.
- The hub must not read the text of log lines (`String.length`, joining, parsing) per chunk or per frame: a log line is shared, and reading a shared String's characters cost ~1 碌s a character (a join's 128 KB chunk took 200 ms on the hub, and every other client waited). Count lines, hand the list to the writer (`WItems`), and let it put the frame together once (core/outbox.bend, server.bend's `Hub.boot`).
- The event loop polls sockets only when no fiber can run: a fiber that always has work (a join's chunks back to back, a reader decoding a ping flood) starves every request. Space repeated work out (`Self.post.later`) and keep per-frame work O(frame): `Ws.decode` once checked `List.length` of the whole buffer per frame (`Ws.has` now), so 20000 pings froze the hub for 7 s. `bun test/tools/stall_e2e.ts` measures both.
- The hub runs on macOS too (release target darwin-arm64: `backplane-serve` as `backplane`, no window). Shell it runs must not assume GNU or Linux: macOS has no `timeout` (use `Port.tmo()`'s `tmo SECS CMD` in a script, or `P.Proc.run("sh", Port.timeout([SECS, CMD, ...]))`; src/core/port.bend), no `setsid` (perl's `POSIX::setsid`), no `realpath -e`, `find -printf`, `stat -c` or `/proc`. Try a change to such a script on a Mac (`hs-mac-mini` has bend and bun; `scripts/build.sh && scripts/smoke.sh` there).
- `Bool.and(a, b)` in a recursive equality walks everything even after a mismatch. Carry the verdict as a parameter so the walk stops (`Eq.ops.go` in tiles.bend) and prove it once (`ops_go_sound`).

## House rules
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,15 @@ curl -fsSL https://github.com/i2cjak/Backplane/releases/latest/download/install.
backplane
```

It installs into `~/.local/share/backplane` and keeps itself up to date (`BACKPLANE_NO_UPDATE=1` turns that off). Releases also carry an AppImage, a `.deb`, an AUR `PKGBUILD` and the Android APK. You need at least one agent CLI (`claude`, `codex` or `grok`) and KiCad; FreeCAD for mechanical work. `backplane --headless` runs it without a window. To pair a phone, scan the QR code in Settings (Pairing link, QR code) with the app, or paste the pairing link.
Linux (x64, arm64) and macOS (Apple silicon). It installs into `~/.local/share/backplane` and keeps itself up to date (`BACKPLANE_NO_UPDATE=1` turns that off). Releases also carry an AppImage, a `.deb`, an AUR `PKGBUILD` and the Android APK. You need at least one agent CLI (`claude`, `codex` or `grok`) and KiCad; FreeCAD for mechanical work. `backplane --headless` runs it without a window. To pair a phone, scan the QR code in Settings (Pairing link, QR code) with the app, or paste the pairing link.

On a Mac there is no native window yet: `backplane` runs the hub and you use it in the browser at http://127.0.0.1:3787 (and from your phone, as anywhere). Add `~/.local/bin` to your `PATH` if the installer says so.

Nightly builds of `main` are prereleases on the [Releases](https://github.com/i2cjak/Backplane/releases) page, each with its own installer: `curl -fsSL https://github.com/i2cjak/Backplane/releases/download/<tag>/install.sh | sh`.

## Build

Needs [Bend](https://bend-lang.com), clang 19+, X11 headers and bun.
Needs [Bend](https://bend-lang.com), clang 19+, X11 headers and bun. On macOS, Xcode's clang is enough and there are no X11 headers to find: `scripts/build.sh` builds the hub and web client only.

```sh
scripts/check.sh # type-check everything, prove every law
Expand Down
14 changes: 14 additions & 0 deletions src/core/port.bend
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Base

# Shell that runs on Linux and macOS alike. macOS has no timeout(1) or
# setsid(1) (coreutils and util-linux are GNU's); it does have perl, whose
# alarm survives exec and whose POSIX::setsid does what setsid(1) does.

# `tmo SECS CMD ARGS...`: CMD, stopped after SECS (killed a second later)
def Port.tmo() -> String:
"tmo() { t=$1; shift; if command -v timeout >/dev/null 2>&1; then timeout -k 1 \"$t\" \"$@\"; elif command -v gtimeout >/dev/null 2>&1; then gtimeout -k 1 \"$t\" \"$@\"; else perl -e 'alarm shift; exec {$ARGV[0]} @ARGV or exit 127' \"$t\" \"$@\"; fi; }; "

# sh's arguments that run timeout(1)'s (SECS CMD ARGS...):
# P.Proc.run("sh", Port.timeout(["5", "tailscale", ...]))
def Port.timeout(+args: List<&2, String>) -> List<&2, String>:
"-c" <> (Port.tmo() ++ "tmo \"$@\"") <> "sh" <> args
3 changes: 2 additions & 1 deletion src/server/botnet.bend
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Base
import ../core/port.bend as PT
import ./host.bend as H
import ./proc.bend as P
import ../core/json.bend as J
Expand Down Expand Up @@ -579,7 +580,7 @@ def Bn.url.here(tailnet: Bool, +host: String, +port: U32) -> IO(String):
match tailnet:
case True{}:
do IO<String>:
r : P.Ran <- P.Proc.run("sh", ["-c", "command -v tailscale >/dev/null && timeout 5 tailscale status --json"], "/")
r : P.Ran <- P.Proc.run("sh", ["-c", (PT.Port.tmo() ++ "command -v tailscale >/dev/null && tmo 5 tailscale status --json")], "/")
+name : String = Bn.dns(r)
return Bool.pick(String, String.is_empty(name), Bn.at(host, port), Bn.at(name, port))
case False{}:
Expand Down
5 changes: 3 additions & 2 deletions src/server/gh.bend
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Base
import ./proc.bend as P
import ../core/gh.bend as G
import ../core/port.bend as PT

# Running gh as the right GitHub account (src/core/gh.bend decides which).
# With one account (or none, or a token already in the environment) gh
Expand All @@ -21,7 +22,7 @@ def GhRun.plain(+cmd: String, +args: List<&2, String>, +cwd: String) -> IO(P.Ran
P.Proc.run("env", List.append(&2, String, GhRun.quiet(), cmd <> args), cwd)

def GhRun.as.sh() -> String:
"h=$1 u=$2 v=$3; shift 3; t=$(timeout 20 gh auth token --hostname \"$h\" --user \"$u\" 2>/dev/null) && [ -n \"$t\" ] || exit 97; export \"$v=$t\" GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 GIT_TERMINAL_PROMPT=0; exec \"$@\""
(PT.Port.tmo() ++ "h=$1 u=$2 v=$3; shift 3; t=$(tmo 20 gh auth token --hostname \"$h\" --user \"$u\" 2>/dev/null) && [ -n \"$t\" ] || exit 97; export \"$v=$t\" GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 GIT_TERMINAL_PROMPT=0; exec \"$@\"")

# cmd with the account's token (exit 97: gh has no token for it)
def GhRun.as(+cwd: String, +host: String, +user: String, +cmd: String, +args: List<&2, String>) -> IO(P.Ran):
Expand Down Expand Up @@ -58,7 +59,7 @@ def GhRun.try(us: List<&2, String>, first: Bool, +cwd: String, +host: String, +c
IO.bind(P.Ran, P.Ran, GhRun.as(cwd, host, u, cmd, args), r => GhRun.next(r, first, cwd, u, _ => GhRun.try(t, False{}, cwd, host, cmd, args, plain)))

def GhRun.accounts(+cwd: String, +host: String) -> IO(List<&2, G.GhAcct>):
IO.bind(P.Ran, List<&2, G.GhAcct>, GhRun.plain("timeout", ["20", "gh", "auth", "status", "--hostname", host], cwd),
IO.bind(P.Ran, List<&2, G.GhAcct>, GhRun.plain("sh", PT.Port.timeout(["20", "gh", "auth", "status", "--hostname", host]), cwd),
r => IO.pure(List<&2, G.GhAcct>, G.Gh.accounts(GhRun.text(r), host)))

def GhRun.saved(+cwd: String) -> IO(String):
Expand Down
Loading
Loading