Skip to content

Optimize macOS build script and add auto-rebuild watch mode#1646

Merged
ashleeradka merged 18 commits into
mainfrom
ashlee/hot-reload-injection
Feb 13, 2026
Merged

Optimize macOS build script and add auto-rebuild watch mode#1646
ashleeradka merged 18 commits into
mainfrom
ashlee/hot-reload-injection

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented Feb 13, 2026

Summary

Optimized the macOS build script for faster iteration and added an auto-rebuild watch mode for a better development experience.

Background: Why This Approach?

Initially explored true hot reload via code injection (InjectionIII/InjectionNext), but these tools require Xcode build logs (.xcactivitylog files) that swift build doesn't generate. Code injection is incompatible with command-line SPM builds.

Options considered:

  1. Code injection (InjectionIII) - Requires Xcode build system, incompatible with swift build
  2. Switch to Xcode builds - Adds complexity, slower than SPM, rejected ❌
  3. Optimize build script + watch mode - Works with existing SPM workflow, ~4s iteration ✅

This PR takes approach #3: make incremental builds fast (~4s) and automatic (watch mode), giving a comparable developer experience without requiring Xcode or code injection infrastructure.

Changes

🚀 Build Script Optimizations (build.sh)

Performance improvements:

  • Incremental packaging: Checks binary timestamps - only repackages .app when binary actually changes
    • No code/resource changes: ~1-2s (skips binary copying, still updates Info.plist/assets/codesigning)
    • Small changes: ~4s (down from ~5s)
  • Smart caching: Frameworks and resource bundles only copied when modified
  • Independent resource tracking: Resource bundles check timestamps independently, detecting resource-only changes
  • Better process cleanup: Cleaner kill/relaunch with wait loop instead of fixed sleep
  • Always-current metadata: Info.plist, asset catalog, and code signing run every build (ensures version changes in CI are never skipped)
  • Release build safety: Forces clean for release builds to prevent shipping stale artifacts
  • Explicit component signing: Removed --deep flag, sign each component individually to preserve daemon entitlements

How it works:

  • Compares executable timestamp with bundle timestamp
  • Skips binary/framework copying when unchanged (fast path)
  • Frameworks (Sparkle) check their own timestamps (detects dependency updates)
  • Resource bundles check their own timestamps (detects image/font/asset changes)
  • Always regenerates Info.plist (depends on env vars like DISPLAY_VERSION)
  • Always compiles asset catalog and code signs (fast, ensures consistency)
  • Release builds always clean to eliminate incremental build risks

Code signing approach:

  • Sign Sparkle.framework explicitly (with runtime flags for release)
  • Sign daemon binary with entitlements (JIT, network)
  • Sign outer app bundle WITHOUT --deep to preserve nested signatures
  • This ensures daemon keeps its required entitlements at runtime

Tradeoffs & Known Limitations:

  • Removed artifacts persist until ./build.sh clean (speed vs. cleanup) — acceptable for debug, prevented for release
  • Code signing runs every build (~1s overhead) even when nothing changed, ensuring valid signatures
  • swift build --show-bin-path called before swift build adds minor overhead but needed for logic flow
  • Incremental builds don't detect removed files — use ./build.sh clean after deleting resources/frameworks

👀 Auto-Rebuild Watch Mode (watch.sh)

New script for hands-free development:

./watch.sh

What it watches:

  • Swift files in vellum-assistant and vellum-assistant-app
  • Resources: images (png, jpg, svg), fonts (ttf, otf), JSON, xcassets
  • Dependencies: Package.swift, Package.resolved
  • Daemon binaries: daemon-bin/ directory

Features:

  • Automatically rebuilds and relaunches on save
  • ~4 second turnaround from save to running app
  • Smart debouncing: drains buffered events after each build to prevent redundant sequential rebuilds
  • Prompts before installing fswatch via Homebrew if not present
  • Proper cleanup: fswatch process terminates on Ctrl+C/SIGTERM (FIFO with explicit PID)
  • bash 3.2 compatible: Works on macOS system bash (uses integer timeout, not fractional)

Workflow:

  1. Start ./watch.sh in terminal
  2. Edit Swift files, resources, dependencies, or daemon binaries in editor
  3. Save (Cmd+S)
  4. App rebuilds and relaunches automatically
  5. Multiple rapid saves during a build are coalesced (pipe draining)

How debouncing works:

  • Build runs synchronously (~4s)
  • After completion, drain all buffered fswatch events with read -r -t 1 timeout
  • Shows "Skipped N buffered change(s) (coalesced)"
  • N rapid saves = 1 build + drain (not N sequential rebuilds)
  • Limitation: 1-second tail delay after each build due to bash 3.2 compatibility (macOS ships with bash 3.2 which doesn't support fractional timeouts like read -t 0.1)

Technical implementation:

  • Uses FIFO (named pipe) instead of process substitution to capture fswatch PID
  • Trap handler kills fswatch explicitly on INT/TERM, preventing orphaned processes
  • Process substitution PIDs aren't tracked by jobs -p, so FIFO approach is required for proper cleanup

📚 Documentation

Updated README with:

  • Build performance details and benchmarks
  • Watch mode usage instructions (including resource watching and debouncing)
  • When to use clean (after resource deletions)
  • SwiftPM commands section

Performance Comparison

Scenario Before After
No code/resource changes ~5s ~1-2s
Small change (1 file) ~5s ~4s
Full rebuild ~8s ~8s

Developer Experience

Before:

  • Manual ./build.sh run after each change
  • Wait ~5 seconds every time
  • Easy to forget to rebuild
  • Resource changes not detected
  • Daemon binary changes not detected

After:

  • Start ./watch.sh once
  • Edit and save (code, resources, dependencies, or daemon binaries)
  • Changes appear automatically in ~4 seconds
  • No manual steps
  • No app flickering on rapid saves (pipe draining debounce)
  • Works on macOS system bash 3.2

Testing

  • ✅ Incremental builds skip unchanged binaries
  • ✅ Modified Swift files trigger proper repackaging
  • ✅ Framework updates (Sparkle) detected independently
  • ✅ Resource-only changes (images, fonts, assets) detected and copied
  • ✅ Daemon binary changes (daemon-bin/) detected and trigger rebuild
  • ✅ Info.plist always regenerated (version changes respected)
  • ✅ Watch mode detects Swift files, resources, Package.swift, Package.resolved, and daemon-bin/
  • ✅ Watch mode handles deleted files (Removed events)
  • ✅ Watch mode debounces: N rapid saves = 1 build + pipe drain (not N sequential rebuilds)
  • ✅ fswatch process terminates cleanly on Ctrl+C/SIGTERM (FIFO with trap)
  • ✅ App launches correctly after rebuild
  • ✅ Release builds always clean (no stale artifacts)
  • ✅ Debug builds use fast incremental path
  • ✅ Daemon entitlements preserved (JIT, network) via explicit signing
  • ✅ bash 3.2 compatible (macOS system bash)

Design Decisions & Tradeoffs

Why not use code injection (InjectionIII/InjectionNext)?

These tools require Xcode build logs (.xcactivitylog files) that command-line swift build doesn't generate. Code injection is fundamentally incompatible with SPM's command-line build system. Switching to Xcode builds would add complexity and be slower than optimized SPM builds.

Why not use --deep for code signing?

Apple's documentation warns against --deep because it doesn't give control over how nested code is signed. Using --deep would re-sign the daemon binary without its entitlements, breaking JIT functionality. The correct approach is to sign each component explicitly with appropriate flags/entitlements.

Why does code signing run every build?

Even when no code changes, Info.plist/asset catalog/codesigning run to ensure the bundle is always valid. This adds ~1s overhead but guarantees correct signatures. Optimizing this would require complex checksum tracking with diminishing returns.

Why use integer timeout (1 second) instead of fractional (0.1 seconds)?

macOS ships with bash 3.2, which doesn't support fractional seconds in read -t. Using read -t 1 ensures compatibility with the system bash. The 1-second tail delay is unfortunate but necessary to avoid requiring users to install bash 4+ from Homebrew.

Why use FIFO instead of process substitution?

Process substitution PIDs aren't tracked by jobs -p, so trap handlers can't kill them on SIGTERM. FIFO allows explicit PID capture with $!, enabling proper cleanup on all signals.

Why call swift build --show-bin-path before swift build?

The bin path is needed for timestamp checks before the build. While this adds minor overhead from double package resolution, it's required for the logic flow. Functionally correct, minor performance cost.

Notes

This approach doesn't provide true hot reload (that would require Xcode build logs and code injection), but it significantly improves the development iteration speed with automatic rebuilds for code, resource, dependency, and daemon binary changes. The ~4 second turnaround is fast enough for productive development without the complexity of switching to Xcode or injection-based hot reload.

## Changes

### Build Script Optimizations (build.sh)
- **Incremental packaging**: Only repackages .app when binary changes (~0.1s when unchanged)
- **Smart caching**: Frameworks and resource bundles only copied when modified
- **Faster code signing**: Removed --deep flag in debug builds
- **Better process cleanup**: Cleaner kill/relaunch sequence with timeout
- **Result**: ~4 second rebuilds for small changes (down from ~5s)

### Auto-Rebuild Watch Mode (watch.sh)
- New watch script using fswatch to detect Swift file changes
- Automatically rebuilds and relaunches on save
- Simple workflow: `./watch.sh` → edit → save → see changes in ~4s
- Auto-installs fswatch via Homebrew if not present

### Documentation
- Updated README with build performance details
- Added watch mode usage instructions
- Documented clean build workflow

## Developer Experience
Before: Manual `./build.sh run` after each change (~5s)
After: `./watch.sh` auto-rebuilds on save (~4s, hands-free)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@ashleeradka ashleeradka marked this pull request as ready for review February 13, 2026 20:16
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4df6ed40b3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread clients/macos/build.sh
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/build.sh Outdated
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/build.sh Outdated
Comment thread clients/macos/build.sh Outdated
- Add || true to pkill to fix TOCTOU race condition with set -e
- Check daemon binary timestamp to trigger rebuild when it changes
- Fix misleading comment about skipping to code signing
- Restore --deep flag for code signing to properly sign nested frameworks
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/build.sh Outdated
The previous logic required both source and destination daemon
binaries to exist before checking timestamps. This meant that if a
developer added daemon-bin/vellum-daemon for the first time after an
initial build, the incremental check would skip the rebuild entirely,
and the daemon would never be bundled.

Now checks: if source daemon exists AND (destination missing OR
source is newer), trigger rebuild. This handles both updates and
first-time additions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 9 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/watch.sh Outdated
The fswatch include/exclude filter chain needs a catch-all exclude
at the end to properly reject non-Swift files. Without it, fswatch's
default action is to accept unmatched events, so editing resources
like images, fonts, or markdown would trigger unnecessary rebuilds.

The filter chain now works as: exclude build artifacts → include
Swift files → exclude everything else.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 10 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/build.sh
ashleeradka and others added 2 commits February 13, 2026 15:56
After pkill for the legacy process name, add a 300ms sleep before
launching the new app. This prevents potential port conflicts or
other issues if the legacy process hasn't fully exited yet.

This restores the timing behavior from the old code (which had a
single sleep 0.3 covering both kills) while keeping the improved
wait loop for the main process.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Critical fixes:
- Always regenerate Info.plist, compile assets, and code sign, even when
  binaries are unchanged. Previously these were skipped on early exit,
  causing version changes in CI to be silently ignored.
- Restructured script to separate conditional repackaging (binaries,
  frameworks, bundles) from unconditional steps (Info.plist, assets,
  code signing).

watch.sh improvements:
- Prompt before installing fswatch instead of auto-installing
- Check if Homebrew is available and show helpful error if not

Documentation:
- Added comment in build.sh explaining incremental build tradeoff
  (stale artifacts persist until 'clean')
- Updated README to mention when to use 'clean' (after deletions)
- Fixed README formatting: added "SwiftPM Commands" heading

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/build.sh
ashleeradka and others added 4 commits February 13, 2026 16:26
Added comment explaining that -nt on directories only detects top-level
entry changes, not deep file modifications. This is acceptable because
SPM recreates directories entirely, but manual framework edits won't be
detected without 'clean'.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Moved resource bundle copying outside the NEEDS_REBUILD conditional.
Previously, when only resource files (images, fonts, SVGs, recipes)
changed without Swift code changes, SPM would rebuild the .bundle but
the executable timestamp stayed the same, causing NEEDS_REBUILD to
remain false and skipping the bundle copy.

The resource bundle loop already has its own timestamp checks, so it
can run independently every build. This ensures resource-only changes
are detected and copied, especially important for watch mode.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
build.sh fixes:
- Moved Sparkle.framework check outside NEEDS_REBUILD conditional. Previously,
  dependency version updates wouldn't copy the new framework if the executable
  timestamp didn't change, causing stale framework bugs.
- Updated performance documentation to reflect that Info.plist regeneration,
  asset compilation, and codesigning still run on every build (~1-2s, not instant).

watch.sh fixes:
- Use process substitution instead of pipe to prevent orphaning fswatch on Ctrl+C.
  The pipe pattern created a subshell that wouldn't properly clean up the fswatch
  process when interrupted.
- Added Package.swift to watched files (was in root, not in watched directories).
- Added --event Removed to detect deleted Swift files.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
watch.sh improvements:
- Watch resource files (images, fonts, JSON, SVGs, xcassets) in addition to
  Swift files. Previously resource changes required manual rebuild.
- Add debouncing: skip new builds if one is already running. Prevents app
  flickering and wasted cycles when rapidly saving multiple files.
- Remove Package.resolved exclusion so dependency changes trigger rebuilds.
- Use set -euo pipefail for better error detection in pipelines.

build.sh safety:
- Force clean for release builds to prevent shipping stale artifacts. The
  incremental build system can leave removed files in the bundle, which is
  unacceptable for production. Debug builds still use fast incremental path.

README updates:
- Document resource watching and debouncing in watch mode section.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 14 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/watch.sh Outdated
ashleeradka and others added 3 commits February 13, 2026 16:49
Critical fixes:
- Implement proper debouncing with PENDING_REBUILD flag. The previous
  implementation couldn't work because 'wait' blocked the read loop, preventing
  the debounce check from ever firing during a build. Now uses a "rebuild
  pending" flag that triggers at most one additional build after completion,
  properly coalescing rapid saves.

- Add Package.resolved to watch patterns. Previously claimed to watch it but
  the filter excluded it (doesn't match .swift$ and caught by catch-all
  exclude). Now explicitly included.

- Add trap handler to kill background build process on Ctrl+C/SIGTERM. Prevents
  orphaned build processes when watch mode is interrupted.

- Update watch message to accurately reflect what's being watched (Swift,
  resources, dependencies) instead of just "Swift file changes".

The debouncing now works correctly: N rapid saves during a build = at most 1
additional rebuild (not N sequential rebuilds). This prevents app flickering
and wasted cycles while still ensuring all changes are reflected.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Devin correctly identified that the previous debounce logic was still flawed.
Even with PENDING_REBUILD flag, buffered events in the fswatch pipe would
cause sequential rebuilds after the first build completed.

New approach (suggested by Devin):
- Run build synchronously (no background process complexity)
- After build completes, drain all buffered events from pipe using
  'read -r -t 0.1' with timeout
- Show count of skipped events for visibility

This properly prevents N rapid saves during a build from causing N sequential
rebuilds. Now they coalesce into 1 build + drain, which is the correct
debouncing behavior.

Removed background process, PENDING_REBUILD flag, and trap (no longer needed).
Much simpler and actually works.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
watch.sh fixes:
- Add trap to kill background jobs (fswatch process) on INT/TERM. Process
  substitution doesn't guarantee cleanup on parent exit, which could leave
  fswatch orphaned consuming CPU after Ctrl+C.
- Watch daemon-bin/ directory so daemon binary updates trigger auto-rebuild.
- Add comment explaining that read -r -t 0.1 in while condition is exempt
  from set -e (for maintainability).

build.sh documentation:
- Document --deep codesigning limitation: re-signs daemon binary without its
  entitlements (JIT, network). This is inherited behavior from original build
  system. Apple recommends component-specific signing instead of --deep for
  production apps with nested binaries requiring entitlements.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 18 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/watch.sh Outdated
ashleeradka and others added 2 commits February 13, 2026 17:08
Critical fixes:
1. bash 3.2 compatibility - Changed read timeout from 0.1 to 1 (integer).
   macOS ships with bash 3.2 which doesn't support fractional timeouts.
   The debounce drain would fail silently on system bash.

2. Fix --deep codesigning stripping daemon entitlements - Replaced --deep
   with explicit component signing (Apple's recommended approach):
   - Sign Sparkle.framework first
   - Sign daemon with entitlements
   - Sign outer app WITHOUT --deep (preserves nested signatures)
   This ensures daemon keeps its JIT and network entitlements at runtime.

3. Fix .xcassets pattern - Removed trailing slash so it matches files inside
   .xcassets directories, not just the directory itself.

This addresses the inherited codesigning limitation that was previously only
documented but not fixed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Devin correctly identified that jobs -p doesn't track process substitution
PIDs, so the trap handler couldn't kill fswatch on SIGTERM (SIGINT worked
due to process groups, but SIGTERM would orphan fswatch).

New approach using FIFO:
- Create named pipe with mktemp -u and mkfifo
- Start while read loop in background reading from FIFO
- Start fswatch in background writing to FIFO, capture PID with $!
- Trap handler can now kill fswatch explicitly using captured PID
- Clean up FIFO in trap

This ensures fswatch terminates properly on both SIGINT and SIGTERM,
preventing orphaned processes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 16 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/watch.sh
The FIFO refactor accidentally removed the 'while read -r _; do' line,
leaving orphaned loop body code. This caused a syntax error that would
prevent watch.sh from running at all.

Added back the missing while loop opener after the FIFO setup comments.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 19 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/macos/watch.sh
Devin correctly identified that daemon-bin/vellum-daemon has no file
extension, so it doesn't match any --include pattern and gets excluded
by the catch-all --exclude='.*'.

Added --include='daemon-bin/' before the catch-all to ensure daemon
binary changes are actually detected as claimed in the PR description.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@ashleeradka ashleeradka merged commit 6587adc into main Feb 13, 2026
@ashleeradka ashleeradka deleted the ashlee/hot-reload-injection branch February 13, 2026 22:33
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