Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4df6ed4
Optimize build script and add auto-rebuild watch mode
ashleeradka Feb 13, 2026
f3459f7
Update README to focus on usage not implementation details
ashleeradka Feb 13, 2026
aff2971
Address PR review feedback
ashleeradka Feb 13, 2026
de50928
Fix daemon binary detection for first-time addition
ashleeradka Feb 13, 2026
e3c6ed7
Fix fswatch filter to exclude non-Swift files
ashleeradka Feb 13, 2026
d22ec54
Add delay after killing legacy vellum-assistant process
ashleeradka Feb 13, 2026
8434ba5
Fix critical issues and improve documentation
ashleeradka Feb 13, 2026
bfd4db2
Document directory timestamp limitation for framework checks
ashleeradka Feb 13, 2026
c4bb656
Fix resource bundle updates when only resources change
ashleeradka Feb 13, 2026
24d4872
Fix critical incremental build and watch mode issues
ashleeradka Feb 13, 2026
79e8234
Fix critical watch mode UX and release build safety
ashleeradka Feb 13, 2026
57a05df
Fix critical debouncing logic and Package.resolved watching
ashleeradka Feb 13, 2026
dc041b5
Fix debouncing by draining pipe after build completes
ashleeradka Feb 13, 2026
097dc58
Add fswatch cleanup trap and daemon-bin watching
ashleeradka Feb 13, 2026
5d34b7d
Fix critical bash 3.2 compatibility and codesigning issues
ashleeradka Feb 13, 2026
0791968
Fix fswatch cleanup by using FIFO instead of process substitution
ashleeradka Feb 13, 2026
fab8976
Fix critical syntax error: missing while read loop opener
ashleeradka Feb 13, 2026
f456575
Fix daemon-bin filter: add include pattern before catch-all
ashleeradka Feb 13, 2026
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
26 changes: 26 additions & 0 deletions clients/macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,34 @@ This builds a debug `.app` bundle, codesigns it, and launches it immediately.

# Run all tests
./build.sh test

# Clean build artifacts
./build.sh clean
```

The build script uses incremental compilation and caching:

- Running `./build.sh` again without code changes takes ~1-2s (skips binary copying, still updates Info.plist/assets/codesigning)
- Small code changes rebuild in ~4 seconds
- Use `./build.sh clean` if you encounter build issues, need to force a complete rebuild, or after removing resources/frameworks (incremental builds don't detect deletions)

## Auto-Rebuild on Save (Watch Mode)

For faster development iteration, use the watch script to automatically rebuild and relaunch when you save Swift files or resources:

```bash
./watch.sh
```

**Workflow:**
1. Start `./watch.sh` in a terminal
2. Edit Swift files or resources (images, fonts, JSON, assets) in your editor
3. Save (Cmd+S)
4. App automatically rebuilds and relaunches in ~4 seconds!
5. Multiple rapid saves are debounced automatically

## SwiftPM Commands

The raw SwiftPM commands also work if you prefer:

```bash
Expand Down
141 changes: 103 additions & 38 deletions clients/macos/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,19 @@ SWIFT_FLAGS=""
if [ "$CMD" = "release" ]; then
CONFIG="release"
SWIFT_FLAGS="-c release"
# Force clean for release builds to prevent stale artifacts in production
echo "Release build: forcing clean to ensure no stale artifacts..."
rm -rf "$SCRIPT_DIR/dist" "$SCRIPT_DIR/.build"
fi

# 1. Build with SPM
echo "Building ($CONFIG)..."
swift build $SWIFT_FLAGS

# Get bin path first (fast, doesn't rebuild)
BIN_PATH=$(swift build $SWIFT_FLAGS --show-bin-path)

# Then build (or use cached if nothing changed)
swift build $SWIFT_FLAGS


EXECUTABLE="$BIN_PATH/$APP_NAME"

Expand All @@ -88,35 +93,85 @@ if [ ! -f "$EXECUTABLE" ]; then
fi

# 2. Create .app bundle structure
echo "Packaging $BUNDLE_DISPLAY_NAME.app..."
rm -rf "$APP_DIR"
# Check if we need to rebuild the bundle
#
# INCREMENTAL BUILD TRADEOFF:
# We only repackage when source binaries change (executable, daemon, frameworks, bundles).
# This makes rebuilds fast (~4s) but means removed artifacts persist in the .app until 'clean'.
# If you delete a resource bundle, framework, or daemon binary from the source, the old copy
# stays in Contents/ until you run './build.sh clean'. This is intentional — the speed gain
# is worth the occasional manual clean. Always use 'clean' before release builds.
NEEDS_REBUILD=false
if [ ! -f "$MACOS_DIR/$BUNDLE_DISPLAY_NAME" ] || [ "$EXECUTABLE" -nt "$MACOS_DIR/$BUNDLE_DISPLAY_NAME" ]; then
NEEDS_REBUILD=true
Comment thread
ashleeradka marked this conversation as resolved.
fi

# Also rebuild if daemon binary changed or newly added
if [ -f "$SCRIPT_DIR/daemon-bin/vellum-daemon" ]; then
if [ ! -f "$MACOS_DIR/vellum-daemon" ] || [ "$SCRIPT_DIR/daemon-bin/vellum-daemon" -nt "$MACOS_DIR/vellum-daemon" ]; then
NEEDS_REBUILD=true
fi
fi
Comment thread
ashleeradka marked this conversation as resolved.

# Ensure .app bundle structure exists
FRAMEWORKS_DIR="$CONTENTS/Frameworks"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" "$FRAMEWORKS_DIR"

# Copy executable (renamed to match display name) and add Frameworks rpath
cp "$EXECUTABLE" "$MACOS_DIR/$BUNDLE_DISPLAY_NAME"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$BUNDLE_DISPLAY_NAME" 2>/dev/null || true
if [ "$NEEDS_REBUILD" = true ]; then
echo "Packaging $BUNDLE_DISPLAY_NAME.app..."

# Copy executable (renamed to match display name) and add Frameworks rpath
cp "$EXECUTABLE" "$MACOS_DIR/$BUNDLE_DISPLAY_NAME"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$BUNDLE_DISPLAY_NAME" 2>/dev/null || true

# Copy bundled daemon binary (if available — built by CI or locally)
DAEMON_BIN="$SCRIPT_DIR/daemon-bin/vellum-daemon"
if [ -f "$DAEMON_BIN" ]; then
echo "Bundling daemon binary..."
cp "$DAEMON_BIN" "$MACOS_DIR/vellum-daemon"
chmod +x "$MACOS_DIR/vellum-daemon"
else
echo "No daemon binary at $DAEMON_BIN — skipping (dev mode)"
fi
else
echo "Binaries unchanged, skipping binary repackaging"
fi

# Always check frameworks (they change independently via dependency updates)
# Copy Sparkle.framework into bundle (required — it's a dynamic framework)
# Only copy if missing or changed (has its own timestamp check)
# Note: Directory timestamp (-nt) only updates when direct entries are added/removed,
# not when files inside subdirectories change. This is reliable for SPM-built artifacts
# since SPM recreates directories entirely, but manual edits inside .framework bundles
# won't be detected. Use './build.sh clean' if you manually modify frameworks.
SPARKLE_FW="$BIN_PATH/Sparkle.framework"
if [ -d "$SPARKLE_FW" ]; then
echo "Bundling Sparkle.framework..."
cp -R "$SPARKLE_FW" "$FRAMEWORKS_DIR/"
if [ ! -d "$FRAMEWORKS_DIR/Sparkle.framework" ] || [ "$SPARKLE_FW" -nt "$FRAMEWORKS_DIR/Sparkle.framework" ]; then
echo "Bundling Sparkle.framework..."
rm -rf "$FRAMEWORKS_DIR/Sparkle.framework"
cp -R "$SPARKLE_FW" "$FRAMEWORKS_DIR/"
fi
else
echo "WARNING: Sparkle.framework not found at $SPARKLE_FW"
fi

# Copy bundled daemon binary (if available — built by CI or locally)
DAEMON_BIN="$SCRIPT_DIR/daemon-bin/vellum-daemon"
if [ -f "$DAEMON_BIN" ]; then
echo "Bundling daemon binary..."
cp "$DAEMON_BIN" "$MACOS_DIR/vellum-daemon"
chmod +x "$MACOS_DIR/vellum-daemon"
else
echo "No daemon binary at $DAEMON_BIN — skipping (dev mode)"
fi
# Always check resource bundles (they change independently of binaries)
# Copy SPM resource bundles into Contents/Resources/
# ResourceBundle.swift checks Bundle.main.resourceURL (Contents/Resources/) first,
# then falls back to Bundle.main.bundleURL (for direct `swift run`).
# Only copy if missing or changed (has its own timestamp check)
for SPM_BUNDLE in "$BIN_PATH"/*.bundle; do
if [ -d "$SPM_BUNDLE" ]; then
BUNDLE_NAME=$(basename "$SPM_BUNDLE")
if [ ! -d "$RESOURCES_DIR/$BUNDLE_NAME" ] || [ "$SPM_BUNDLE" -nt "$RESOURCES_DIR/$BUNDLE_NAME" ]; then
echo "Bundling $BUNDLE_NAME"
rm -rf "$RESOURCES_DIR/$BUNDLE_NAME"
cp -R "$SPM_BUNDLE" "$RESOURCES_DIR/"
fi
fi
done

# 3. Generate Info.plist with resolved values
# Always regenerate Info.plist (fast, depends on env vars like DISPLAY_VERSION)
cat > "$CONTENTS/Info.plist" <<PLIST
<?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">
Expand Down Expand Up @@ -164,17 +219,7 @@ cat > "$CONTENTS/Info.plist" <<PLIST
</plist>
PLIST

# 4. Copy SPM resource bundles into Contents/Resources/
# ResourceBundle.swift checks Bundle.main.resourceURL (Contents/Resources/) first,
# then falls back to Bundle.main.bundleURL (for direct `swift run`).
for SPM_BUNDLE in "$BIN_PATH"/*.bundle; do
if [ -d "$SPM_BUNDLE" ]; then
echo "Bundling $(basename "$SPM_BUNDLE")"
cp -R "$SPM_BUNDLE" "$RESOURCES_DIR/"
fi
done

# 5. Compile asset catalog (if actool is available)
# Always compile asset catalog (fast, ensures AppIcon changes are picked up)
XCASSETS="$SCRIPT_DIR/vellum-assistant/Resources/Assets.xcassets"
if [ -d "$XCASSETS" ]; then
xcrun actool "$XCASSETS" \
Expand All @@ -189,30 +234,50 @@ fi
# 6. Code sign
echo "Signing with: $SIGN_IDENTITY"

# Sign daemon binary separately with its own entitlements (JIT, network)
# Sign components explicitly (Apple's recommended approach instead of --deep)
# This ensures nested binaries with specific entitlements aren't overwritten

# Sign Sparkle.framework first
if [ -d "$FRAMEWORKS_DIR/Sparkle.framework" ]; then
FW_SIGN_FLAGS=(--force --sign "$SIGN_IDENTITY")
if [ "$CONFIG" = "release" ] && [ "$SIGN_IDENTITY" != "-" ]; then
FW_SIGN_FLAGS+=(--timestamp --options runtime)
fi
codesign "${FW_SIGN_FLAGS[@]}" "$FRAMEWORKS_DIR/Sparkle.framework"
echo "Sparkle.framework signed"
fi

# Sign daemon binary with its own entitlements (JIT, network)
if [ -f "$MACOS_DIR/vellum-daemon" ]; then
DAEMON_SIGN_FLAGS=(--force --sign "$SIGN_IDENTITY" --entitlements "$SCRIPT_DIR/daemon-entitlements.plist")
if [ "$CONFIG" = "release" ] && [ "$SIGN_IDENTITY" != "-" ]; then
DAEMON_SIGN_FLAGS+=(--timestamp --options runtime)
fi
codesign "${DAEMON_SIGN_FLAGS[@]}" "$MACOS_DIR/vellum-daemon"
echo "Daemon binary signed"
echo "Daemon binary signed with entitlements"
fi

CODESIGN_FLAGS=(--force --sign "$SIGN_IDENTITY" --deep)
# Sign the outer app bundle (without --deep to preserve nested signatures)
APP_SIGN_FLAGS=(--force --sign "$SIGN_IDENTITY")
if [ "$CONFIG" = "release" ] && [ "$SIGN_IDENTITY" != "-" ]; then
CODESIGN_FLAGS+=(--timestamp --options runtime)
APP_SIGN_FLAGS+=(--timestamp --options runtime)
fi
codesign "${CODESIGN_FLAGS[@]}" "$APP_DIR"
codesign "${APP_SIGN_FLAGS[@]}" "$APP_DIR"

echo "Built: $APP_DIR"

# 7. Run if requested
if [ "$CMD" = "run" ]; then
echo "Launching..."
# Kill existing instance if running
pkill -x "$BUNDLE_DISPLAY_NAME" 2>/dev/null || true
# Also kill legacy pre-rename process name if still running
# Kill existing instance if running (SIGTERM for clean shutdown)
if pgrep -x "$BUNDLE_DISPLAY_NAME" > /dev/null; then
pkill -x "$BUNDLE_DISPLAY_NAME" 2>/dev/null || true
# Wait for clean exit (max 1 second)
for i in {1..10}; do
pgrep -x "$BUNDLE_DISPLAY_NAME" > /dev/null || break
sleep 0.1
done
fi
pkill -x "vellum-assistant" 2>/dev/null || true
sleep 0.3
# Launch via `open` so Launch Services registers the bundle —
Expand Down
110 changes: 110 additions & 0 deletions clients/macos/watch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/bin/bash
set -euo pipefail

# Auto-rebuild and relaunch on file save
# Usage: ./watch.sh

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

# Colors
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color

# Check if fswatch is installed
if ! command -v fswatch &> /dev/null; then
if ! command -v brew &> /dev/null; then
echo -e "${RED}Error: fswatch is required but not installed, and Homebrew is not available.${NC}"
echo -e "Install fswatch manually: ${BLUE}https://github.com/emcrisostomo/fswatch${NC}"
exit 1
fi

echo -e "${YELLOW}fswatch is not installed. Install it via Homebrew? (y/N)${NC}"
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
brew install fswatch
else
echo -e "${RED}fswatch is required for watch mode. Exiting.${NC}"
exit 1
fi
fi

echo -e "${BLUE}👀 Watching for file changes (Swift, resources, dependencies)...${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop${NC}"
echo ""

# Initial build and launch
echo -e "${BLUE}🔨 Initial build...${NC}"
./build.sh run
echo ""

# Set up FIFO for fswatch communication (allows capturing PID for cleanup)
FIFO=$(mktemp -u)
mkfifo "$FIFO"
FSWATCH_PID=""

# Trap to clean up fswatch process and FIFO on exit
trap 'if [ -n "$FSWATCH_PID" ]; then kill $FSWATCH_PID 2>/dev/null; fi; rm -f "$FIFO"; exit' INT TERM

# Read from FIFO in a loop - this processes fswatch events
while read -r _; do
echo ""
echo -e "${YELLOW}📝 Change detected - rebuilding...${NC}"

# Run build synchronously
if ./build.sh run; then
echo -e "${GREEN}✅ Build successful${NC}"
else
echo -e "${RED}❌ Build failed${NC}"
fi

# Drain any events that accumulated during the build (debounce)
# This prevents N rapid saves from triggering N sequential rebuilds
# Note: Use integer timeout (bash 3.2 on macOS doesn't support fractional seconds)
# read -r -t 1 returns exit code 1 on timeout, but bash exempts
# commands in while conditions from set -e, so this is safe
DRAINED=0
while read -r -t 1 _; do
DRAINED=$((DRAINED + 1))
done
if [ "$DRAINED" -gt 0 ]; then
echo -e "${YELLOW}⏭️ Skipped $DRAINED buffered change(s) (coalesced)${NC}"
fi

echo -e "${BLUE}👀 Watching...${NC}"
done < "$FIFO" &
Comment thread
ashleeradka marked this conversation as resolved.

# Start fswatch in background, writing to FIFO, and capture its PID
fswatch -o \
--exclude='\.build/' \
--exclude='dist/' \
--exclude='\.swiftpm/' \
--exclude='\.git/' \
--include='\.swift$' \
--include='\.png$' \
--include='\.jpg$' \
--include='\.jpeg$' \
--include='\.svg$' \
--include='\.json$' \
--include='\.ttf$' \
--include='\.otf$' \
--include='\.xcassets' \
--include='Package\.resolved$' \
--include='daemon-bin/' \
--exclude='.*' \
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
--event Created \
--event Updated \
--event Removed \
--latency 0.5 \
vellum-assistant \
vellum-assistant-app \
daemon-bin \
Package.swift \
Package.resolved > "$FIFO" &
FSWATCH_PID=$!

# Wait for the read loop to finish (it runs in background via &)
wait