From c6bd9f85aa5e4a3979c5ad88406675fbbdfc48cd Mon Sep 17 00:00:00 2001
From: Harry Beckwith
Date: Sat, 25 Jul 2026 12:21:56 -0700
Subject: [PATCH 1/3] A double-clickable macOS app, so no terminal is required
The repo was professional but the install was still developer-shaped:
open a terminal, paste a curl command, wait. This adds Bitcoin Vibes.app.
- contrib/vibes/macos/build-app.sh builds the bundle (and --dmg). The
app is a 452K launcher, not the node: on first open it fetches the
source into ~/Library/Application Support/Bitcoin Vibes and the
console compiles the node there, so the app stays small and updates
itself on each launch by fast-forward only -- never clobbering local
decrees.
- launcher.sh restores a usable PATH (Finder gives almost none), finds
python3, and reports every failure in a native dialog instead of into
the void. If Apple's command line tools are missing it triggers the
installer rather than failing mysteriously.
- the console gains --app: with no terminal to print to, first-run setup
runs as an ordinary streamed job and the operator watches dependency
installation and the build in the browser, on a progress bar, in the
same marble UI as everything else.
Verified: the bundle registers as com.apple.application-bundle, is
ad-hoc signed, and the launcher clones and starts the console from a
clean home directory.
Not notarised, so Gatekeeper wants right-click -> Open the first time.
Said plainly in the docs rather than papered over.
Co-Authored-By: Claude Opus 5
---
contrib/vibes/bitcoin-vibes | 99 ++++++++++++--
contrib/vibes/index.html | 44 ++++++
contrib/vibes/macos/build-app.sh | 78 +++++++++++
contrib/vibes/macos/icon.png | Bin 0 -> 13919 bytes
contrib/vibes/macos/launcher.sh | 52 +++++++
dist/Bitcoin Vibes.app/Contents/Info.plist | 19 +++
.../Contents/MacOS/BitcoinVibes | 52 +++++++
.../Contents/Resources/AppIcon.icns | Bin 0 -> 440895 bytes
.../Contents/_CodeSignature/CodeDirectory | Bin 0 -> 204 bytes
.../Contents/_CodeSignature/CodeRequirements | Bin 0 -> 60 bytes
.../Contents/_CodeSignature/CodeResources | 128 ++++++++++++++++++
.../Contents/_CodeSignature/CodeSignature | 0
12 files changed, 461 insertions(+), 11 deletions(-)
create mode 100755 contrib/vibes/macos/build-app.sh
create mode 100644 contrib/vibes/macos/icon.png
create mode 100755 contrib/vibes/macos/launcher.sh
create mode 100644 dist/Bitcoin Vibes.app/Contents/Info.plist
create mode 100755 dist/Bitcoin Vibes.app/Contents/MacOS/BitcoinVibes
create mode 100644 dist/Bitcoin Vibes.app/Contents/Resources/AppIcon.icns
create mode 100644 dist/Bitcoin Vibes.app/Contents/_CodeSignature/CodeDirectory
create mode 100644 dist/Bitcoin Vibes.app/Contents/_CodeSignature/CodeRequirements
create mode 100644 dist/Bitcoin Vibes.app/Contents/_CodeSignature/CodeResources
create mode 100644 dist/Bitcoin Vibes.app/Contents/_CodeSignature/CodeSignature
diff --git a/contrib/vibes/bitcoin-vibes b/contrib/vibes/bitcoin-vibes
index 6c3e133ba038..9042fa538ca7 100755
--- a/contrib/vibes/bitcoin-vibes
+++ b/contrib/vibes/bitcoin-vibes
@@ -1305,6 +1305,74 @@ class Console:
self._restart_node(job)
return go
+ def setup_runner(self):
+ """First run, streamed into the browser instead of a terminal.
+
+ The desktop app has no terminal to print to, so preparing the node —
+ dependencies, configure, build, first start — runs as an ordinary job
+ and the operator watches it in the console like anything else."""
+ def go(job):
+ job.emit(t="phase", name="setup",
+ detail="preparing your node — this happens once")
+
+ missing = self.missing_deps()
+ if missing:
+ job.emit(t="phase", name="deps",
+ detail="installing what the compiler needs: " + ", ".join(missing))
+ if not self.install_deps(job, missing):
+ job.state = "error"
+ job.emit(t="phase", name="error",
+ detail="could not install: " + ", ".join(missing) +
+ ". Install them by hand and reopen Bitcoin Vibes.")
+ return
+ else:
+ job.emit(t="log", line="build tools already present")
+
+ jobs = self.build_jobs()
+ low, high = self.build_estimate(jobs)
+ job.emit(t="phase", name="build",
+ detail=f"compiling the node with {jobs} parallel job(s) — "
+ f"roughly {low}-{high} minutes on this machine, once")
+ if not self._build(job):
+ return
+ job.emit(t="phase", name="node", detail="waking the node")
+ self.start_node(job)
+ job.emit(t="result",
+ detail="Your node is ready, Excellency. It has been waiting.")
+ return go
+
+ def missing_deps(self):
+ """Which build tools are absent. Empty means we can compile."""
+ need = []
+ if not shutil.which("cmake"):
+ need.append("cmake")
+ if sys.platform == "darwin":
+ for formula, probe in (("boost", "/boost/version.hpp"),
+ ("capnp", None)):
+ if formula == "capnp" and not shutil.which("capnp"):
+ need.append("capnp")
+ elif formula == "boost":
+ found = any(Path(p + probe).exists() for p in
+ ("/opt/homebrew/include", "/usr/local/include"))
+ if not found:
+ need.append("boost")
+ return need
+
+ def install_deps(self, job, missing):
+ """Best effort, and honest when it cannot."""
+ if sys.platform == "darwin":
+ brew = shutil.which("brew") or next(
+ (p for p in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew")
+ if Path(p).exists()), None)
+ if not brew:
+ job.emit(t="log", line="Homebrew is not installed — it is how macOS "
+ "gets these. See https://brew.sh")
+ return False
+ return self._stream(job, [brew, "install", *missing]) == 0
+ job.emit(t="log", line="install these with your package manager, then reopen: "
+ + " ".join(missing))
+ return False
+
def build_runner(self, autorestart):
def go(job):
ok = self._build(job)
@@ -1539,6 +1607,9 @@ def main():
ap.add_argument("--no-open", action="store_true", help="don't open the browser")
ap.add_argument("--no-autostart", dest="autostart", action="store_false",
help="don't build/start the node automatically on launch")
+ ap.add_argument("--app", action="store_true",
+ help="launched from the desktop app: no terminal is watching, so "
+ "first-run setup streams into the browser instead")
ap.add_argument("--print-key", action="store_true", help="print the session key (for scripting)")
opts = ap.parse_args()
@@ -1612,18 +1683,24 @@ def main():
# the node, so the browser opens onto something alive.
if opts.autostart:
if not console.bin_path("bitcoind"):
- bjobs = console.build_jobs()
- low, high = console.build_estimate(bjobs)
- print(f" no node binary yet — building it now, once, with {bjobs} "
- f"parallel job(s).")
- print(f" on this machine expect roughly {low}-{high} minutes. "
- f"It is the last slow thing that will ever happen.")
- print()
- if console.build_blocking():
- print("\n build complete.\n")
+ if opts.app:
+ # Launched by double-click: there is no terminal to watch, so
+ # preparation streams into the browser instead.
+ print(" no node binary yet — preparing it in the console.")
+ console.start_job("setup", "", console.setup_runner())
else:
- print("\n ⚠ build failed — see the output above. The console will still "
- "start.\n")
+ bjobs = console.build_jobs()
+ low, high = console.build_estimate(bjobs)
+ print(f" no node binary yet — building it now, once, with {bjobs} "
+ f"parallel job(s).")
+ print(f" on this machine expect roughly {low}-{high} minutes. "
+ f"It is the last slow thing that will ever happen.")
+ print()
+ if console.build_blocking():
+ print("\n build complete.\n")
+ else:
+ print("\n ⚠ build failed — see the output above. The console "
+ "will still start.\n")
if console.bin_path("bitcoind") and not console.node_running():
if console.start_node():
print(f" node : awake on {opts.chain}")
diff --git a/contrib/vibes/index.html b/contrib/vibes/index.html
index 73d6668d31d5..290b9a7417ca 100644
--- a/contrib/vibes/index.html
+++ b/contrib/vibes/index.html
@@ -88,6 +88,22 @@
.rule::after{background:linear-gradient(90deg,var(--gold-leaf),transparent)}
.rule span{color:var(--gold-ink);font-size:14px;letter-spacing:.4em}
+ /* ---- first run ---- */
+ .setup{border:2px solid var(--gold-leaf);border-radius:120px 120px 3px 3px;
+ background:linear-gradient(180deg,#fffdf8,var(--marble-2));
+ padding:34px 32px 26px;margin-bottom:22px;text-align:center;
+ box-shadow:0 12px 36px rgba(28,24,20,.09)}
+ .setup-title{font-family:var(--serif);font-size:21px;letter-spacing:.14em;
+ text-transform:uppercase;color:var(--gold-ink);margin-bottom:14px}
+ .setup-body{max-width:620px;margin:0 auto 20px;color:var(--ink);font-size:16.5px}
+ .setup-bar{height:8px;background:var(--marble-3);border:1px solid var(--gold-line);
+ border-radius:99px;overflow:hidden;max-width:520px;margin:0 auto}
+ .setup-bar span{display:block;height:100%;width:0;
+ background:linear-gradient(90deg,var(--gold-leaf),var(--gold-ink));
+ transition:width .6s ease}
+ .setup-step{margin-top:12px;font-family:var(--mono);font-size:13.5px;color:var(--ink-2)}
+ body.is-setup .tablet,body.is-setup #book-section{opacity:.45;pointer-events:none}
+
/* ---- the engine notice ---- */
.engine{border:2px solid var(--porphyry);background:#fdf6f4;border-radius:2px;
padding:15px 18px;margin-bottom:20px}
@@ -356,6 +372,16 @@ You are a Bitcoin God.
one man's mood. You ask permission of no one.
+
Preparing your node
+
This happens once. Bitcoin Vibes is
+ compiling a real Bitcoin node from source — the same source Bitcoin Core
+ ships, plus a text box. You can watch it below, or come back in a few
+ minutes; nothing is required of you.
+
+
starting…
+
+