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:
commit
bdebc8ecc9
205 changed files with 107859 additions and 6333 deletions
|
|
@ -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",
|
||||
|
|
|
|||
93
scripts/create-virtual-display.m
Normal file
93
scripts/create-virtual-display.m
Normal 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;
|
||||
}
|
||||
71
scripts/download-prebuilt-ghosttykit.sh
Executable file
71
scripts/download-prebuilt-ghosttykit.sh
Executable 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
229
scripts/generate_dark_icon.py
Executable 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()
|
||||
4
scripts/ghosttykit-checksums.txt
Normal file
4
scripts/ghosttykit-checksums.txt
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
101
scripts/run-e2e.sh
Executable 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
|
||||
|
|
@ -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
144
scripts/smoke-test-ci.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue