Skip to content

feat: brew cask + curl installer — no more "damaged" dialog (no Apple cert) - #11

Merged
todddickerson merged 3 commits into
mainfrom
feat-brew-cask
Aug 19, 2026
Merged

feat: brew cask + curl installer — no more "damaged" dialog (no Apple cert)#11
todddickerson merged 3 commits into
mainfrom
feat-brew-cask

Conversation

@todddickerson

@todddickerson todddickerson commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Kill the "damaged" dialog with no Apple Developer cert

The "FunButton is damaged and can't be opened" dialog has hit four separate times
(2026-08-02, 08-14, 08-15, and 08-17 on v0.1.8). This ships a certificate-free fix
so brew/curl users never see it again — and it's verified for real on the Mac
Studio, end to end.

The single most important proof

A brew-installed FunButton opens with no "damaged" dialog and no manual xattr:

$ brew tap todddickerson/funbutton
$ brew install --cask funbutton
==> Fetching downloads for: todddickerson/funbutton/funbutton
==> Installing Cask funbutton
==> Moving App 'FunButton.app' to '/Applications/FunButton.app'
🍺  funbutton was successfully installed!        # exit 0

$ xattr /Applications/FunButton.app
com.apple.provenance                             # <-- NO com.apple.quarantine

# engines up on the brew-installed bundle (RUST_LOG=info):
Fn key tap installed (Input Monitoring granted); running CFRunLoop
whisper: using metal backend: MTL0
embedded STT model loaded ("MTL0" backend, 120ms)
llama-server ready at http://127.0.0.1:65155 (1009ms)
shutdown: teardown complete                       # clean quit, no ggml abort

From: https://github.com/todddickerson/homebrew-funbutton/blob/HEAD/Casks/funbutton.rb

Premise corrected + real root cause

  • brew install --cask does NOT strip quarantine by default. On Homebrew
    6.0.17 (default opts) a plain cask install left com.apple.quarantine
    (0381;…;;…) on the app. So the cask strips it explicitly in a postflight
    that touches only FunButton.app; the curl installer does it inline. Neither
    disables Gatekeeper globally.
  • Root cause of "damaged" is a broken ad-hoc signature + quarantine, not
    quarantine alone. spctl/codesign report "code has no resources but
    signature indicates they must be present"
    (Sealed Resources=none). Remove
    quarantine (any path) → it launches. The two OSS competitors (freeflow,
    unramble) ship plain casks that work only because their bundles are validly
    signed; ours isn't.

What's here

This PR (funbutton repo):

  • scripts/install.sh — auditable curl … | bash installer (no sudo, fails loudly
    on wrong arch / download failure / running app).
  • scripts/update-cask.sh — recompute the published DMG's sha256 and push the tap.
  • scripts/sync-install-sh.sh — keep the web copy of install.sh in lockstep.
  • apps/web/app/install.sh/route.ts (+ generated installer.json) — serves the
    installer at funbutton.ai/install.sh as text/plain.
  • apps/web/app/page.tsx — install section reordered brew → curl → manual .dmg;
    xattr demoted to the manual path; hero's "damaged" box replaced with "one
    command, no warnings."
  • SIGNING.md, PROGRESS.md (+ ship checklist), GAUNTLET-FINDINGS.md.

Separate repo (already public):
todddickerson/homebrew-funbutton
— the tap with Casks/funbutton.rb.

Verification (real evidence only — no faked screenshots)

  • brew install/uninstall/zap all verified; --zap trashed app + models + caches
    • prefs + saved state + WebKit (models backed up + restored, no 1.1 GB re-download).
  • scripts/install.sh run end-to-end → installed, no quarantine, launched.
  • /install.sh route: next build registers it as a static route (○ /install.sh);
    served content-type: text/plain; charset=utf-8 and byte-identical to
    scripts/install.sh on both next dev and the production next start.
  • A Vercel preview deploy built cleanly (Next 16, TS passed). The preview URL
    itself sits behind Vercel's SSO deployment-protection wall (302 → sso-api), so
    it can't be curled without auth — prod (funbutton.ai) is public and will serve
    /install.sh once deployed. I did not deploy to prod.
  • brew style: clean. brew audit --cask: clean (exit 0). brew audit --cask --new: 2 residual, both inherent to an unsigned alpha in a third-party tap —
    repo "not notable enough" (homebrew-core submission rule, N/A here) and "signature
    verification failed" (needs Developer ID + notarization).

Gates

  • apps/web tsc + eslint: clean. apps/worker tsc: clean. cargo fmt --check:
    clean. macOS-26 crash-guard grep: doc comments only.
  • No Rust or worker source touched — cargo build/test/clippy unaffected.

Todd's turn

  • Deploy apps/web to prod so funbutton.ai/install.sh and the reordered
    landing go live (I only deployed a preview — see below — never prod).
  • Permanent fix is still Developer ID signing (SIGNING.md Steps 1–2, human-gated),
    which also clears the manual .dmg path.

🤖 Generated with Claude Code

Summary by Sourcery

Provide certificate-free Homebrew and curl installation paths that avoid macOS damaged-app warnings while keeping manual DMG installation and future code signing documented.

New Features:

  • Add Homebrew and curl-based macOS installation paths that remove quarantine from the installed app to avoid the damaged-app dialog.
  • Serve the auditable curl installer publicly at /install.sh and provide scripts to synchronize it and update the Homebrew cask for releases.

Bug Fixes:

  • Prevent the unsigned, quarantined application bundle from being presented as damaged for Homebrew and curl installations.

Enhancements:

  • Reorder the landing page to prioritize Homebrew and curl installation while retaining manual DMG instructions as a fallback.
  • Document the quarantine workaround, its limitations, validation findings, and the remaining need for Developer ID signing and notarization.

Documentation:

  • Document the interim certificate-free distribution solution and update signing and release progress guidance.

Tests:

  • Record end-to-end verification of Homebrew installation, curl installation, quarantine removal, application launch, cleanup, and web installer serving.

Chores:

  • Add release tooling to keep the Homebrew tap and web-hosted installer synchronized with published releases.

todddickerson and others added 3 commits August 17, 2026 15:34
- scripts/install.sh: auditable one-line installer (curl | bash). Downloads
  the latest DMG from the version-agnostic /download, installs to /Applications,
  clears com.apple.quarantine from our own bundle so the unsigned alpha opens
  without the "damaged" dialog. No sudo, no global Gatekeeper changes; fails
  loudly on wrong arch / download failure / running app.
- scripts/update-cask.sh: recompute the published DMG's sha256 and push the
  refreshed cask to the todddickerson/homebrew-funbutton tap. Idempotent; gh
  CLI for auth (no .env token needed).
- scripts/sync-install-sh.sh: regenerate the web copy of install.sh so it never
  drifts from the source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app/install.sh/route.ts: serves scripts/install.sh verbatim at
  funbutton.ai/install.sh as text/plain (body imported from installer.json,
  generated from the source), so `curl -fsSL https://funbutton.ai/install.sh | bash`
  works and a human can read it in the browser first.
- page.tsx: reorder the install section to brew (#1, recommended) -> curl (#2)
  -> manual .dmg (#3). The xattr steps are demoted to the manual path only; the
  hero's scary "damaged" callout is replaced with "one command, no warnings."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SIGNING.md gains an interim-mitigation section (brew/curl clear quarantine now;
Developer ID signing is still the permanent fix that also clears the manual .dmg
path) and records the 4th occurrence. PROGRESS.md adds the work entry plus a
durable ship checklist that includes "update the Homebrew tap."
GAUNTLET-FINDINGS.md documents the corrected premise (brew does NOT strip
quarantine by default) and the real root cause (broken ad-hoc signature seal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a Homebrew + curl based installation flow that explicitly clears macOS quarantine on FunButton.app, exposes the installer via a static Next.js route, and rewrites the landing page + docs to make brew/curl the primary, non-warning paths while demoting manual .dmg + xattr instructions.

Sequence diagram for curl-based installer flow clearing quarantine

sequenceDiagram
  actor User
  participant Bash
  participant FunbuttonSite
  participant macOS

  User->>Bash: curl -fsSL https://funbutton.ai/install.sh | bash
  Bash->>FunbuttonSite: GET /install.sh
  FunbuttonSite-->>Bash: installer script (scripts/install.sh)
  Bash->>Bash: uname -s / uname -m checks
  alt not Darwin or not arm64
    Bash-->>User: die "FunButton ships for Apple Silicon (arm64) only"
  else Apple Silicon macOS
    Bash->>macOS: pgrep -x funbutton
    alt funbutton running
      Bash-->>User: die "FunButton is currently running"
    else not running
      Bash->>FunbuttonSite: curl -fL -o FunButton.dmg GET /download
      FunbuttonSite-->>Bash: FunButton.dmg
      Bash->>macOS: hdiutil attach FunButton.dmg
      macOS-->>Bash: mountpoint with FunButton.app
      Bash->>macOS: ditto FunButton.app /Applications/FunButton.app
      Bash->>macOS: xattr -dr com.apple.quarantine /Applications/FunButton.app
      Bash->>macOS: open /Applications/FunButton.app
      macOS-->>User: FunButton launches (no "damaged" dialog)
    end
  end
Loading

File-Level Changes

Change Details Files
Introduce a curl-based installer script for macOS Apple Silicon that downloads the latest DMG, installs FunButton.app into /Applications, and clears quarantine only on that bundle.
  • Add platform/arch checks and refusal to run on non-Darwin or non-arm64 systems.
  • Prevent clobbering a running FunButton instance by checking for the process before installation.
  • Download the latest DMG from a canonical /download URL, mount it, copy FunButton.app to /Applications, and unmount with robust cleanup.
  • Ensure /Applications is user-writable and provide manual instructions if not.
  • Clear com.apple.quarantine on the installed FunButton.app only, then optionally launch the app.
scripts/install.sh
Automate keeping the Homebrew tap cask in sync with GitHub releases by recomputing the DMG sha256 and updating the cask repo.
  • Resolve target release tag (explicit argument or latest) via gh CLI.
  • Locate the macOS arm64 DMG asset from the release and download it to compute sha256.
  • Clone the homebrew-funbutton tap, rewrite version and sha256 fields in the cask file using sed.
  • Commit and push tap changes only when version or sha256 differ, using gh-managed git auth.
scripts/update-cask.sh
Ensure the web-served installer script stays byte-identical to the repo source via a sync helper and JSON payload.
  • Generate apps/web/app/install.sh/installer.json from scripts/install.sh using a small Node script wrapper.
  • Make sync script idempotent and print whether installer.json changed.
  • Establish scripts/install.sh as the single source of truth for the curl installer.
scripts/sync-install-sh.sh
apps/web/app/install.sh/installer.json
Expose the installer script via a static Next.js route at /install.sh, served as text/plain for curl and browser readability.
  • Import installer content from installer.json and return it directly in the HTTP response body.
  • Configure the route as force-static for Next.js so it’s built once and cached.
  • Set appropriate headers for text/plain content-type and short public caching.
apps/web/app/install.sh/route.ts
Rework the landing page hero and install section to promote Homebrew and curl paths, demote the manual .dmg + xattr instructions, and add a reusable InstallMethod component.
  • Replace the hero "damaged" warning box with a brew-first guidance block that highlights one-command install with no xattr.
  • Reorder install flows into three options: Homebrew (recommended), curl one-liner, and manual .dmg with explicit xattr instructions.
  • Introduce an InstallMethod React component to render numbered command blocks with badges and notes for brew and curl.
  • Add a dedicated manual .dmg block with download button, explicit quarantine clearing commands, and a link to SIGNING.md.
  • Adjust copy to explain that brew/curl paths clear quarantine for you and that only the manual .dmg still needs xattr until signing is in place.
apps/web/app/page.tsx
Document the distribution findings and interim Homebrew/curl fix, and update project progress/history around the "damaged" dialog.
  • Add a GAUNTLET-FINDINGS.md section describing the root cause (invalid ad-hoc signature plus quarantine), corrected Homebrew premise, shipped fix, and residual risks.
  • Extend SIGNING.md with an "Interim fix" section detailing how Homebrew and curl clear quarantine without weakening Gatekeeper and what will be removed once signing lands.
  • Update SIGNING.md history to include the fourth "damaged" occurrence and tie it to this mitigation.
  • Add a PROGRESS.md entry summarizing the tap, curl installer, landing changes, verification steps, and release checklist updates including the tap.
  • Update the ship checklist to include running scripts/update-cask.sh and syncing/install.sh for every release.
GAUNTLET-FINDINGS.md
SIGNING.md
PROGRESS.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The new Bash helpers (install.sh, update-cask.sh, sync-install-sh.sh) assume tools like curl, hdiutil, ditto, xattr, shasum, git, gh, and node exist; consider adding early explicit checks with clear error messages so failures are easier to diagnose on partially configured machines.
  • The curl installer currently hardcodes /Applications as the install target; if you expect power users or managed/macOS setups with nonstandard app locations, it might be worth allowing an override via an env var (e.g. FUNBUTTON_APP_DIR) while keeping /Applications as the default.
  • scripts/update-cask.sh picks the .dmg asset using fairly loose grep patterns and falls back to the first .dmg; tightening this selection (or failing loudly when multiple candidates are found) would make it safer against future changes in asset naming or layout.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new Bash helpers (`install.sh`, `update-cask.sh`, `sync-install-sh.sh`) assume tools like `curl`, `hdiutil`, `ditto`, `xattr`, `shasum`, `git`, `gh`, and `node` exist; consider adding early explicit checks with clear error messages so failures are easier to diagnose on partially configured machines.
- The curl installer currently hardcodes `/Applications` as the install target; if you expect power users or managed/macOS setups with nonstandard app locations, it might be worth allowing an override via an env var (e.g. `FUNBUTTON_APP_DIR`) while keeping `/Applications` as the default.
- `scripts/update-cask.sh` picks the .dmg asset using fairly loose `grep` patterns and falls back to the first `.dmg`; tightening this selection (or failing loudly when multiple candidates are found) would make it safer against future changes in asset naming or layout.

## Individual Comments

### Comment 1
<location path="scripts/sync-install-sh.sh" line_range="22-24" />
<code_context>
+[ -f "$SRC" ] || { echo "!! $SRC not found" >&2; exit 1; }
+mkdir -p "$(dirname "$OUT")"
+
+node -e '
+  const fs = require("fs");
+  const [src, out] = process.argv.slice(1);
+  const script = fs.readFileSync(src, "utf8");
+  const next = JSON.stringify({ script }) + "\n";
</code_context>
<issue_to_address>
**issue (bug_risk):** Node inline script reads the wrong argv indices, so it will fail to read the intended files.

For `node -e`, `process.argv` is `[nodePath, '-e', scriptSource, SRC, OUT]`, so `slice(1)` yields `['-e', scriptSource, SRC, OUT]`. That makes `src = '-e'` and `out = scriptSource`, so `readFileSync` tries to read a file named `-e` and `existsSync` checks the inline script instead of `$OUT`.

Index from the correct position instead, for example:

```js
const [src, out] = process.argv.slice(3);
// or
const [,, , src, out] = process.argv;
```

This aligns `src` and `out` with the `$SRC` and `$OUT` shell args.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +22 to +24
node -e '
const fs = require("fs");
const [src, out] = process.argv.slice(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Node inline script reads the wrong argv indices, so it will fail to read the intended files.

For node -e, process.argv is [nodePath, '-e', scriptSource, SRC, OUT], so slice(1) yields ['-e', scriptSource, SRC, OUT]. That makes src = '-e' and out = scriptSource, so readFileSync tries to read a file named -e and existsSync checks the inline script instead of $OUT.

Index from the correct position instead, for example:

const [src, out] = process.argv.slice(3);
// or
const [,, , src, out] = process.argv;

This aligns src and out with the $SRC and $OUT shell args.

@todddickerson
todddickerson merged commit f2b2841 into main Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant