Merge branch 'main' into issue-151-ssh-remote-port-proxying

# Conflicts:
#	CLI/cmux.swift
#	Sources/ContentView.swift
#	Sources/GhosttyTerminalView.swift
#	Sources/Panels/BrowserPanel.swift
#	Sources/Panels/BrowserPanelView.swift
#	Sources/TabManager.swift
#	Sources/TerminalController.swift
#	Sources/Workspace.swift
#	Sources/WorkspaceContentView.swift
#	ghostty
This commit is contained in:
Lawrence Chen 2026-03-09 18:36:59 -07:00
commit bdebc8ecc9
205 changed files with 107859 additions and 6333 deletions

View file

@ -61,7 +61,7 @@ echo "Pre-flight checks passed"
# --- Build GhosttyKit (if needed) ---
if [ ! -d "GhosttyKit.xcframework" ]; then
echo "Building GhosttyKit..."
cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast && cd ..
cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast && cd ..
rm -rf GhosttyKit.xcframework
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
else
@ -177,6 +177,7 @@ cask "cmux" do
depends_on macos: ">= :ventura"
app "cmux.app"
binary "#{appdir}/cmux.app/Contents/Resources/bin/cmux"
zap trash: [
"~/Library/Application Support/cmux",

View file

@ -0,0 +1,93 @@
// Creates a virtual display on headless macOS (CI runners without a physical monitor).
// Uses the private CGVirtualDisplay API from CoreGraphics.
// The display stays alive as long as this process runs.
//
// Build: clang -framework Foundation -framework CoreGraphics -o create-virtual-display create-virtual-display.m
// Usage: ./create-virtual-display &
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
// Private CoreGraphics classes (declared here since they're not in public headers)
@interface CGVirtualDisplayMode : NSObject
- (instancetype)initWithWidth:(unsigned int)width height:(unsigned int)height refreshRate:(double)refreshRate;
@end
@interface CGVirtualDisplayDescriptor : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic) unsigned int maxPixelsWide;
@property (nonatomic) unsigned int maxPixelsHigh;
@property (nonatomic) CGSize sizeInMillimeters;
@property (nonatomic) unsigned int vendorID;
@property (nonatomic) unsigned int productID;
@property (nonatomic) unsigned int serialNum;
@property (nonatomic, strong) dispatch_queue_t queue;
@end
@interface CGVirtualDisplaySettings : NSObject
@property (nonatomic) unsigned int hiDPI;
@property (nonatomic, strong) NSArray *modes;
@end
@interface CGVirtualDisplay : NSObject
- (instancetype)initWithDescriptor:(CGVirtualDisplayDescriptor *)descriptor;
- (BOOL)applySettings:(CGVirtualDisplaySettings *)settings;
@property (nonatomic, readonly) unsigned int displayID;
@end
int main(int argc, const char *argv[]) {
@autoreleasepool {
unsigned int width = 1920;
unsigned int height = 1080;
// Verify the private classes exist
if (!NSClassFromString(@"CGVirtualDisplay")) {
fprintf(stderr, "ERROR: CGVirtualDisplay API not available on this system\n");
return 1;
}
// Create display mode
CGVirtualDisplayMode *mode = [[CGVirtualDisplayMode alloc] initWithWidth:width height:height refreshRate:60.0];
if (!mode) {
fprintf(stderr, "ERROR: Failed to create CGVirtualDisplayMode\n");
return 1;
}
// Configure descriptor
CGVirtualDisplayDescriptor *descriptor = [[CGVirtualDisplayDescriptor alloc] init];
descriptor.name = @"CI Virtual Display";
descriptor.maxPixelsWide = width;
descriptor.maxPixelsHigh = height;
descriptor.sizeInMillimeters = CGSizeMake(530, 300);
descriptor.vendorID = 0x1234;
descriptor.productID = 0x5678;
descriptor.serialNum = 0x0001;
descriptor.queue = dispatch_get_main_queue();
// Create virtual display
CGVirtualDisplay *display = [[CGVirtualDisplay alloc] initWithDescriptor:descriptor];
if (!display) {
fprintf(stderr, "ERROR: Failed to create CGVirtualDisplay\n");
return 1;
}
// Apply settings with display mode
CGVirtualDisplaySettings *settings = [[CGVirtualDisplaySettings alloc] init];
settings.hiDPI = 0;
settings.modes = @[mode];
BOOL ok = [display applySettings:settings];
if (!ok) {
fprintf(stderr, "ERROR: Failed to apply display settings\n");
return 1;
}
printf("Virtual display created: %ux%u@60Hz (displayID: %u)\n", width, height, display.displayID);
printf("PID: %d\n", getpid());
fflush(stdout);
// Keep alive so the display persists
dispatch_main();
}
return 0;
}

View file

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [ -n "${GHOSTTY_SHA:-}" ]; then
GHOSTTY_SHA="$GHOSTTY_SHA"
else
if [ ! -d "$REPO_ROOT/ghostty" ] || ! git -C "$REPO_ROOT/ghostty" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Missing ghostty submodule. Run ./scripts/setup.sh or git submodule update --init --recursive first." >&2
exit 1
fi
GHOSTTY_SHA="$(git -C "$REPO_ROOT/ghostty" rev-parse HEAD)"
fi
TAG="xcframework-$GHOSTTY_SHA"
ARCHIVE_NAME="${GHOSTTYKIT_ARCHIVE_NAME:-GhosttyKit.xcframework.tar.gz}"
OUTPUT_DIR="${GHOSTTYKIT_OUTPUT_DIR:-GhosttyKit.xcframework}"
CHECKSUMS_FILE="${GHOSTTYKIT_CHECKSUMS_FILE:-$SCRIPT_DIR/ghosttykit-checksums.txt}"
DOWNLOAD_URL="${GHOSTTYKIT_URL:-https://github.com/manaflow-ai/ghostty/releases/download/$TAG/$ARCHIVE_NAME}"
DOWNLOAD_RETRIES="${GHOSTTYKIT_DOWNLOAD_RETRIES:-30}"
DOWNLOAD_RETRY_DELAY="${GHOSTTYKIT_DOWNLOAD_RETRY_DELAY:-20}"
if [ ! -f "$CHECKSUMS_FILE" ]; then
echo "Missing checksum file: $CHECKSUMS_FILE" >&2
exit 1
fi
EXPECTED_SHA256="$(
awk -v sha="$GHOSTTY_SHA" '
$1 == sha {
print $2
found = 1
exit
}
END {
if (!found) {
exit 1
}
}
' "$CHECKSUMS_FILE" || true
)"
if [ -z "$EXPECTED_SHA256" ]; then
echo "Missing pinned GhosttyKit checksum for ghostty $GHOSTTY_SHA in $CHECKSUMS_FILE" >&2
exit 1
fi
echo "Downloading $ARCHIVE_NAME for ghostty $GHOSTTY_SHA"
curl --fail --show-error --location \
--retry "$DOWNLOAD_RETRIES" \
--retry-delay "$DOWNLOAD_RETRY_DELAY" \
--retry-all-errors \
-o "$ARCHIVE_NAME" \
"$DOWNLOAD_URL"
ACTUAL_SHA256="$(shasum -a 256 "$ARCHIVE_NAME" | awk '{print $1}')"
if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then
echo "$ARCHIVE_NAME checksum mismatch" >&2
echo "Expected: $EXPECTED_SHA256" >&2
echo "Actual: $ACTUAL_SHA256" >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
tar xzf "$ARCHIVE_NAME"
rm "$ARCHIVE_NAME"
test -d "$OUTPUT_DIR"
echo "Verified and extracted $OUTPUT_DIR"

229
scripts/generate_dark_icon.py Executable file
View file

@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Generate dark mode app icon variants.
Composites the Figma chevron layer (on transparent background) over a dark
squircle background derived from the light icon's alpha channel. This
preserves the exact chevron colors and glow without any halo artifacts.
Requires the Figma export at: design/cmux-icon-chevron.png
Falls back to mathematical recomposition if the Figma layer is missing.
"""
import json
import os
import sys
from PIL import Image, ImageFilter
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Apple systemBackground dark
DARK_BG = (28, 28, 30)
# Figma chevron layer (exported from Figma at native resolution)
FIGMA_CHEVRON = os.path.join(REPO, "design", "cmux-icon-chevron.png")
# The Figma export is ~25% larger than the repo icon. Scale and offset
# computed by matching the solid chevron (sat>0.5) bounding box center
# between the repo light icon and the scaled Figma chevron layer.
FIGMA_SCALE = 0.7996
FIGMA_OFFSET = (290, 187)
SIZES = [
("16.png", 16),
("16@2x.png", 32),
("32.png", 32),
("32@2x.png", 64),
("128.png", 128),
("128@2x.png", 256),
("256.png", 256),
("256@2x.png", 512),
("512.png", 512),
("512@2x.png", 1024),
]
def make_dark_from_figma(light_1024: Image.Image, chevron: Image.Image) -> Image.Image:
"""Create dark icon by compositing Figma chevron over dark background.
Uses the light icon's alpha channel for the squircle shape mask,
fills it with the dark background color, then composites the
chevron layer on top at the precomputed offset.
"""
size = 1024
light = light_1024.convert("RGBA")
if light.size != (size, size):
light = light.resize((size, size), Image.LANCZOS)
# Create dark background with the squircle shape from the light icon
dark_bg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
light_px = light.load()
dark_px = dark_bg.load()
for y in range(size):
for x in range(size):
_, _, _, a = light_px[x, y]
if a > 0:
dark_px[x, y] = (DARK_BG[0], DARK_BG[1], DARK_BG[2], a)
# Scale chevron
chev = chevron.convert("RGBA")
cw, ch = chev.size
scaled_w = int(cw * FIGMA_SCALE)
scaled_h = int(ch * FIGMA_SCALE)
chev = chev.resize((scaled_w, scaled_h), Image.LANCZOS)
ox, oy = FIGMA_OFFSET
# Build enhanced glow: brighten the chevron, blur at two radii
glow_src = chev.copy()
glow_px = glow_src.load()
for y in range(scaled_h):
for x in range(scaled_w):
r, g, b, a = glow_px[x, y]
if a > 0:
glow_px[x, y] = (
min(255, int(r * 1.2)),
min(255, int(g * 1.2)),
min(255, int(b * 1.2)),
min(255, int(a * 1.1)),
)
glow_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
glow_canvas.paste(glow_src, (ox, oy), glow_src)
# Wide soft glow + tighter glow
glow_wide = glow_canvas.filter(ImageFilter.GaussianBlur(radius=25))
glow_tight = glow_canvas.filter(ImageFilter.GaussianBlur(radius=12))
# Reduce glow opacity
for glow, factor in [(glow_wide, 0.45), (glow_tight, 0.35)]:
gpx = glow.load()
for y in range(size):
for x in range(size):
r, g, b, a = gpx[x, y]
gpx[x, y] = (r, g, b, int(a * factor))
# Composite: dark bg -> wide glow -> tight glow -> sharp chevron
result = Image.alpha_composite(dark_bg, glow_wide)
result = Image.alpha_composite(result, glow_tight)
chev_canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
chev_canvas.paste(chev, (ox, oy), chev)
result = Image.alpha_composite(result, chev_canvas)
return result
def make_dark_fallback(img: Image.Image) -> Image.Image:
"""Fallback: mathematical recomposition when Figma layer is unavailable."""
img = img.convert("RGBA")
w, h = img.size
pixels = img.load()
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
if a == 0:
continue
max_dev = max(255 - r, 255 - g, 255 - b)
fg_alpha = min(max_dev / 60.0, 1.0)
bg_factor = 1.0 - fg_alpha
nr = max(0, int(r - bg_factor * (255 - DARK_BG[0])))
ng = max(0, int(g - bg_factor * (255 - DARK_BG[1])))
nb = max(0, int(b - bg_factor * (255 - DARK_BG[2])))
pixels[x, y] = (nr, ng, nb, a)
return img
def update_contents_json(icon_dir: str) -> None:
"""Add dark appearance entries to Contents.json."""
contents_path = os.path.join(icon_dir, "Contents.json")
with open(contents_path) as f:
contents = json.load(f)
# Remove any existing dark entries to avoid duplicates
images = [
img for img in contents["images"]
if not any(
ap.get("value") == "dark"
for ap in img.get("appearances", [])
)
]
dark_images = []
for img in images:
filename = img.get("filename", "")
if not filename:
continue
base, ext = os.path.splitext(filename)
dark_entry = {
"appearances": [
{"appearance": "luminosity", "value": "dark"}
],
"filename": f"{base}_dark{ext}",
"idiom": img["idiom"],
"scale": img["scale"],
"size": img["size"],
}
dark_images.append(dark_entry)
# Interleave: light, dark, light, dark, ...
merged = []
for i, img in enumerate(images):
merged.append(img)
if i < len(dark_images):
merged.append(dark_images[i])
contents["images"] = merged
with open(contents_path, "w") as f:
json.dump(contents, f, indent=2)
f.write("\n")
print(f" Updated {contents_path}")
def generate_dark_icons(icon_set: str) -> None:
"""Generate dark variants for an icon set."""
src_dir = os.path.join(REPO, "Assets.xcassets", f"{icon_set}.appiconset")
if not os.path.isdir(src_dir):
print(f"SKIP {icon_set} (not found)")
return
use_figma = os.path.exists(FIGMA_CHEVRON)
if use_figma:
print(f"\n{icon_set} (using Figma chevron layer):")
chevron = Image.open(FIGMA_CHEVRON)
light_1024_path = os.path.join(src_dir, "512@2x.png")
light_1024 = Image.open(light_1024_path)
dark_1024 = make_dark_from_figma(light_1024, chevron)
else:
print(f"\n{icon_set} (fallback: mathematical recomposition):")
dark_1024 = None
for filename, pixel_size in SIZES:
src_path = os.path.join(src_dir, filename)
if not os.path.exists(src_path):
print(f" SKIP {filename} (not found)")
continue
base, ext = os.path.splitext(filename)
dst_path = os.path.join(src_dir, f"{base}_dark{ext}")
if use_figma:
# Downscale the 1024x1024 Figma composite
dark_img = dark_1024.resize((pixel_size, pixel_size), Image.LANCZOS)
else:
img = Image.open(src_path)
if img.size != (pixel_size, pixel_size):
img = img.resize((pixel_size, pixel_size), Image.LANCZOS)
dark_img = make_dark_fallback(img)
dark_img.save(dst_path, "PNG")
print(f" {base}_dark{ext} ({pixel_size}x{pixel_size})")
update_contents_json(src_dir)
def main():
generate_dark_icons("AppIcon")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,4 @@
# Pinned GhosttyKit.xcframework.tar.gz checksums keyed by ghostty submodule SHA.
# Update this file in a reviewed PR whenever the ghostty submodule SHA changes.
# Format: <ghostty_sha> <sha256>
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207

View file

@ -414,15 +414,14 @@ OPEN_CLEAN_ENV=(
if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides.
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH"
elif [[ -n "${TAG_SLUG:-}" ]]; then
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH"
else
echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true
echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true
"${OPEN_CLEAN_ENV[@]}" open "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" open -g "$APP_PATH"
fi
osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true
# Safety: ensure only one instance is running.
sleep 0.2

View file

@ -17,5 +17,4 @@ if [[ -z "${APP_PATH}" ]]; then
fi
# Dev shells (including CI/Codex) often force-disable paging by exporting these.
# Don't leak that into cmux, otherwise `git diff` won't page even with PAGER=less.
env -u GIT_PAGER -u GH_PAGER open "$APP_PATH"
osascript -e 'tell application "cmux" to activate' || true
env -u GIT_PAGER -u GH_PAGER open -g "$APP_PATH"

View file

@ -251,8 +251,7 @@ OPEN_CLEAN_ENV=(
# Always inject staging socket paths via env to ensure they take effect
# (LSEnvironment requires app restart to pick up plist changes).
"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH"
osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true
"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open -g "$APP_PATH"
# Safety: ensure only one instance is running.
sleep 0.2

101
scripts/run-e2e.sh Executable file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Trigger the test-e2e.yml workflow and optionally wait for results.
#
# Usage:
# ./scripts/run-e2e.sh UpdatePillUITests
# ./scripts/run-e2e.sh UpdatePillUITests --wait
# ./scripts/run-e2e.sh UpdatePillUITests/testFoo --ref my-branch
# ./scripts/run-e2e.sh UpdatePillUITests --no-video --timeout 300
set -euo pipefail
REPO="manaflow-ai/cmux"
WORKFLOW="test-e2e.yml"
# Defaults
REF=""
WAIT=false
RECORD_VIDEO=true
TIMEOUT=120
usage() {
cat <<EOF
Usage: $(basename "$0") <test_filter> [options]
Arguments:
test_filter Test class or class/method (e.g. UpdatePillUITests)
Options:
--ref <ref> Branch or SHA to test (default: current branch)
--wait Wait for the run to complete and print result
--no-video Disable video recording
--timeout <sec> Per-test timeout in seconds (default: 120)
-h, --help Show this help
EOF
exit 0
}
if [ $# -lt 1 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
usage
fi
TEST_FILTER="$1"
shift
while [ $# -gt 0 ]; do
case "$1" in
--ref)
REF="$2"
shift 2
;;
--wait)
WAIT=true
shift
;;
--no-video)
RECORD_VIDEO=false
shift
;;
--timeout)
TIMEOUT="$2"
shift 2
;;
*)
echo "Unknown option: $1" >&2
usage
;;
esac
done
# Build workflow dispatch fields
FIELDS=(-f "test_filter=$TEST_FILTER" -f "record_video=$RECORD_VIDEO" -f "test_timeout=$TIMEOUT")
if [ -n "$REF" ]; then
FIELDS+=(-f "ref=$REF")
fi
echo "Triggering $WORKFLOW with test_filter=$TEST_FILTER ref=${REF:-<default>} video=$RECORD_VIDEO timeout=$TIMEOUT"
gh workflow run "$WORKFLOW" --repo "$REPO" "${FIELDS[@]}"
# Wait a moment for the run to register
sleep 3
# Get the latest run ID
RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" --limit 1 --json databaseId --jq '.[0].databaseId')
RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID"
echo "Run: $RUN_URL"
if [ "$WAIT" = true ]; then
echo "Waiting for run to complete..."
gh run watch --repo "$REPO" "$RUN_ID" --exit-status || true
STATUS=$(gh run view --repo "$REPO" "$RUN_ID" --json conclusion --jq '.conclusion')
echo ""
echo "Result: $STATUS"
echo "Run: $RUN_URL"
# Find the issue created for this run (search by run ID in body)
ISSUE_URL=$(gh search issues "$RUN_ID" --repo manaflow-ai/cmux-dev-artifacts --limit 1 --json url --jq '.[0].url' 2>/dev/null || true)
if [ -n "$ISSUE_URL" ]; then
echo "Issue: $ISSUE_URL"
fi
fi

View file

@ -58,7 +58,7 @@ else
echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..."
(
cd ghostty
zig build -Demit-xcframework=true -Doptimize=ReleaseFast
zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast
)
# Stamp the build output with the SHA it was built from
echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP"

144
scripts/smoke-test-ci.sh Executable file
View file

@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Smoke test for CI: launch the app, send a command, verify it stays alive for 15 seconds.
set -euo pipefail
SOCKET_PATH="/tmp/cmux-debug.sock"
STABILITY_WAIT=15
echo "=== Smoke Test ==="
# --- Find the built app ---
APP=$(find ~/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit 2>/dev/null || true)
if [ -z "$APP" ]; then
echo "ERROR: Built app not found in DerivedData"
exit 1
fi
echo "App: $APP"
BINARY="$APP/Contents/MacOS/cmux DEV"
if [ ! -x "$BINARY" ]; then
echo "ERROR: App binary not found or not executable: $BINARY"
exit 1
fi
# --- Clean up stale socket and any existing instances ---
rm -f "$SOCKET_PATH"
pkill -x "cmux DEV" 2>/dev/null || true
sleep 1
# --- Launch the app directly (not via `open`, which can silently fail on CI) ---
echo "Launching app..."
CMUX_SOCKET_MODE=allowAll CMUX_UI_TEST_MODE=1 "$BINARY" > /tmp/cmux-smoke-stdout.log 2>&1 &
APP_PID=$!
echo "App PID: $APP_PID"
# --- Verify process is alive after 2s ---
sleep 2
if ! kill -0 "$APP_PID" 2>/dev/null; then
echo "ERROR: App exited immediately after launch"
echo "--- stdout/stderr ---"
cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -50 || true
echo "--- debug log ---"
tail -50 /tmp/cmux-debug.log 2>/dev/null || true
echo "--- crash reports ---"
ls -lt ~/Library/Logs/DiagnosticReports/*cmux* 2>/dev/null | head -5 || echo "(none)"
exit 1
fi
# --- Wait for socket (up to 30s) ---
echo "Waiting for socket at $SOCKET_PATH..."
SOCKET_READY=false
for i in $(seq 1 60); do
if [ -S "$SOCKET_PATH" ]; then
echo "Socket ready after $((i / 2))s"
SOCKET_READY=true
break
fi
# Check if process died while waiting
if ! kill -0 "$APP_PID" 2>/dev/null; then
echo "ERROR: App crashed while waiting for socket"
echo "--- stdout/stderr ---"
cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -50 || true
echo "--- debug log ---"
tail -50 /tmp/cmux-debug.log 2>/dev/null || true
exit 1
fi
sleep 0.5
done
if [ "$SOCKET_READY" != "true" ]; then
echo "ERROR: Socket not ready after 30s"
echo "--- stdout/stderr ---"
cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -30 || true
echo "--- debug log ---"
tail -30 /tmp/cmux-debug.log 2>/dev/null || true
ls -la /tmp/cmux-debug* 2>/dev/null || true
pgrep -la "cmux" || echo "No cmux processes found"
exit 1
fi
# --- Ping the socket ---
echo "Pinging socket..."
PING_RESPONSE=$(python3 -c "
import socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('$SOCKET_PATH')
s.settimeout(5.0)
s.sendall(b'ping\n')
data = s.recv(1024).decode().strip()
s.close()
print(data)
")
echo "Ping response: $PING_RESPONSE"
if [ "$PING_RESPONSE" != "PONG" ]; then
echo "ERROR: Expected PONG, got: $PING_RESPONSE"
exit 1
fi
# --- Send a command to the terminal ---
echo "Sending 'time' command to terminal..."
SEND_RESPONSE=$(python3 -c "
import socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('$SOCKET_PATH')
s.settimeout(5.0)
s.sendall(b'send time\\\n\n')
data = s.recv(1024).decode().strip()
s.close()
print(data)
")
echo "Send response: $SEND_RESPONSE"
# --- Wait and verify stability ---
echo "Waiting ${STABILITY_WAIT}s to verify stability..."
sleep "$STABILITY_WAIT"
if ! kill -0 "$APP_PID" 2>/dev/null; then
echo "ERROR: App crashed during ${STABILITY_WAIT}s stability check"
echo "--- stdout/stderr ---"
cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -30 || true
echo "--- debug log ---"
tail -30 /tmp/cmux-debug.log 2>/dev/null || true
exit 1
fi
# --- Final ping ---
FINAL_PING=$(python3 -c "
import socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('$SOCKET_PATH')
s.settimeout(5.0)
s.sendall(b'ping\n')
data = s.recv(1024).decode().strip()
s.close()
print(data)
")
echo "Final ping: $FINAL_PING"
if [ "$FINAL_PING" != "PONG" ]; then
echo "ERROR: App not responsive after ${STABILITY_WAIT}s"
exit 1
fi
echo "=== Smoke test passed ==="
# --- Cleanup ---
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true

View file

@ -70,14 +70,23 @@ while (( ${#padded_key} % 4 != 0 )); do
done
printf "%s" "$padded_key" > "$key_file"
generated_appcast_path="$archives_dir/$(basename "$OUT_PATH")"
"$generate_appcast" \
--ed-key-file "$key_file" \
--download-url-prefix "$DOWNLOAD_URL_PREFIX" \
--full-release-notes-url "$RELEASE_NOTES_URL" \
"$archives_dir"
if [[ ! -f "$archives_dir/appcast.xml" ]]; then
echo "appcast.xml not generated." >&2
if [[ ! -f "$generated_appcast_path" ]]; then
fallback_generated_appcast="$(find "$archives_dir" -maxdepth 1 -name '*.xml' | head -n 1)"
if [[ -n "$fallback_generated_appcast" ]]; then
generated_appcast_path="$fallback_generated_appcast"
fi
fi
if [[ ! -f "$generated_appcast_path" ]]; then
echo "Expected appcast was not generated." >&2
exit 1
fi
@ -85,7 +94,7 @@ fi
# to sign the DMG and inject the signature. generate_appcast silently skips
# signing when the public key derived from the private key doesn't match the
# SUPublicEDKey in the app's Info.plist.
if ! grep -q 'sparkle:edSignature' "$archives_dir/appcast.xml"; then
if ! grep -q 'sparkle:edSignature' "$generated_appcast_path"; then
echo "Warning: generate_appcast did not add edSignature. Using sign_update fallback..."
SIGNATURE=$("$sign_update" -p --ed-key-file "$key_file" "$DMG_PATH")
DMG_LENGTH=$(stat -f%z "$DMG_PATH")
@ -95,7 +104,7 @@ if ! grep -q 'sparkle:edSignature' "$archives_dir/appcast.xml"; then
# Inject sparkle:edSignature and correct length into the enclosure element
python3 -c "
import sys
xml = open('$archives_dir/appcast.xml').read()
xml = open('$generated_appcast_path').read()
sig = '$SIGNATURE'
length = '$DMG_LENGTH'
# Add edSignature to enclosure
@ -103,12 +112,12 @@ xml = xml.replace(
'type=\"application/octet-stream\"',
'sparkle:edSignature=\"' + sig + '\" length=\"' + length + '\" type=\"application/octet-stream\"'
)
open('$archives_dir/appcast.xml', 'w').write(xml)
open('$generated_appcast_path', 'w').write(xml)
print(' Injected edSignature into appcast.xml')
"
fi
cp "$archives_dir/appcast.xml" "$OUT_PATH"
cp "$generated_appcast_path" "$OUT_PATH"
echo "Generated appcast at $OUT_PATH"
# Verify the appcast has a signature