diff --git a/.gitignore b/.gitignore index 169c32d..3151c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target +/build +/.env /crates/e2e/artifacts/ diff --git a/CLAUDE.md b/CLAUDE.md index 84bd646..3e544c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,9 @@ Standalone crates (no workspace deps): `platform`, `encoding`, `css`, `dom`, `cr # Build cargo build --workspace +# Build and install signed macOS app bundle +./build.sh + # Run the browser cargo run -p we-browser @@ -52,6 +55,11 @@ cargo test -p we-html cargo clippy -p we-css -- -D warnings ``` +`./build.sh` produces a signed `we.app` bundle and installs it to +`/Applications`. It reads optional signing configuration from `./.env`; set +`CERT_NAME=` to use a Developer ID certificate. +If `CERT_NAME` is omitted, the script falls back to ad-hoc signing. + ## Conventions ### Zero External Dependencies diff --git a/Cargo.lock b/Cargo.lock index d0d1930..863ead9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,13 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "generate-icon" +version = "0.1.0" +dependencies = [ + "we-image", +] + [[package]] name = "we-browser" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3433c15..0d87a56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/memory", "crates/browser", "crates/e2e", + "tools/generate_icon", ] [workspace.package] diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ed0e1ac --- /dev/null +++ b/build.sh @@ -0,0 +1,171 @@ +#!/bin/bash +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="$PROJECT_DIR/.env" + +if [ -f "$ENV_FILE" ]; then + set -a + # shellcheck source=/dev/null + source "$ENV_FILE" + set +a +fi + +CERT_NAME="${CERT_NAME:-}" +SIGN_IDENTITY="-" +if [ -n "$CERT_NAME" ]; then + SIGN_IDENTITY="$CERT_NAME" +fi + +APP_DIR="$PROJECT_DIR/build/we.app" +INSTALL_DIR="/Applications/we.app" +ICON_CACHE_DIR="$PROJECT_DIR/build/icon" +ICONSET_DIR="$ICON_CACHE_DIR/AppIcon.iconset" +ICON_PNG="$ICON_CACHE_DIR/icon_1024.png" +ICON_ICNS="$ICON_CACHE_DIR/AppIcon.icns" +ICON_TOOL_SRC="$PROJECT_DIR/tools/generate_icon/src/main.rs" +ENTITLEMENTS="$PROJECT_DIR/tools/we.entitlements" +BROWSER_BIN="$PROJECT_DIR/target/aarch64-apple-darwin/release/browser" +VERSION="$(awk ' + $0 == "[package]" { in_package = 1; next } + /^\[/ { in_package = 0 } + in_package && $1 == "version" { + gsub(/"/, "", $3) + print $3 + exit + } +' "$PROJECT_DIR/crates/browser/Cargo.toml")" + +if [ -z "$VERSION" ]; then + echo "error: could not read crates/browser package version" >&2 + exit 1 +fi + +cd "$PROJECT_DIR" + +echo "Building we release binary..." +cargo build --release -p we-browser --target aarch64-apple-darwin + +echo "Preparing app bundle..." +pkill -x we 2>/dev/null || true +rm -rf "$APP_DIR" +mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" +cp "$BROWSER_BIN" "$APP_DIR/Contents/MacOS/we" + +if [ ! -f "$ICON_ICNS" ] || [ "$ICON_TOOL_SRC" -nt "$ICON_ICNS" ]; then + echo "Generating app icon..." + rm -rf "$ICONSET_DIR" + mkdir -p "$ICONSET_DIR" + cargo run --release -p generate-icon -- "$ICON_PNG" + + sips -z 16 16 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null + sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null + sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null + sips -z 64 64 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null + sips -z 128 128 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null + sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null + sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null + sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null + sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null + cp "$ICON_PNG" "$ICONSET_DIR/icon_512x512@2x.png" + iconutil --convert icns "$ICONSET_DIR" --output "$ICON_ICNS" +fi +cp "$ICON_ICNS" "$APP_DIR/Contents/Resources/AppIcon.icns" + +cat > "$APP_DIR/Contents/Info.plist" << PLIST + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + we + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + com.we.browser + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + we + CFBundleDisplayName + we + CFBundlePackageType + APPL + CFBundleShortVersionString + $VERSION + CFBundleVersion + $VERSION + LSMinimumSystemVersion + 14.0 + LSApplicationCategoryType + public.app-category.web-browser + CFBundleURLTypes + + + CFBundleURLName + HTTP + CFBundleURLSchemes + + http + + + + CFBundleURLName + HTTPS + CFBundleURLSchemes + + https + + + + CFBundleDocumentTypes + + + CFBundleTypeName + HTML document + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.html + public.xhtml + + CFBundleTypeExtensions + + html + htm + xhtml + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + +PLIST + +printf "APPL????" > "$APP_DIR/Contents/PkgInfo" + +echo "Signing app bundle..." +codesign --force --options runtime --entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$APP_DIR" + +echo "Installing to $INSTALL_DIR..." +pkill -x we 2>/dev/null || true +rm -rf "$INSTALL_DIR" +cp -R "$APP_DIR" "$INSTALL_DIR" +codesign --force --options runtime --entitlements "$ENTITLEMENTS" --sign "$SIGN_IDENTITY" "$INSTALL_DIR" + +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -f "$INSTALL_DIR" + +open "$INSTALL_DIR" +echo "Installed and launched: $INSTALL_DIR" diff --git a/tools/generate_icon/Cargo.toml b/tools/generate_icon/Cargo.toml new file mode 100644 index 0000000..07fed80 --- /dev/null +++ b/tools/generate_icon/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "generate-icon" +version = "0.1.0" +edition.workspace = true + +[dependencies] +we-image = { path = "../../crates/image" } diff --git a/tools/generate_icon/src/main.rs b/tools/generate_icon/src/main.rs new file mode 100644 index 0000000..0d7b13a --- /dev/null +++ b/tools/generate_icon/src/main.rs @@ -0,0 +1,264 @@ +use std::env; +use std::fs; +use std::io; +use std::path::Path; + +use we_image::png::encode_png_rgba; + +const SIZE: u32 = 1024; +const RADIUS: f32 = 220.0; + +fn main() -> Result<(), Box> { + let mut args = env::args_os(); + let program = args.next().unwrap_or_default(); + let Some(output) = args.next() else { + eprintln!("usage: {} ", Path::new(&program).display()); + std::process::exit(2); + }; + + if args.next().is_some() { + eprintln!("usage: {} ", Path::new(&program).display()); + std::process::exit(2); + } + + let pixels = render_icon(); + let png = encode_png_rgba(&pixels, SIZE, SIZE); + if let Some(parent) = Path::new(&output).parent() { + fs::create_dir_all(parent)?; + } + fs::write(output, png) + .map_err(|err| io::Error::new(err.kind(), format!("write icon: {err}")))?; + Ok(()) +} + +fn render_icon() -> Vec { + let mut pixels = vec![0; (SIZE * SIZE * 4) as usize]; + let size = SIZE as usize; + + for y in 0..size { + for x in 0..size { + let idx = (y * size + x) * 4; + let fx = x as f32 + 0.5; + let fy = y as f32 + 0.5; + let mask = rounded_rect_coverage( + fx, + fy, + 58.0, + 58.0, + SIZE as f32 - 116.0, + SIZE as f32 - 116.0, + RADIUS, + ); + + if mask <= 0.0 { + pixels[idx + 3] = 0; + continue; + } + + let u = fx / SIZE as f32; + let v = fy / SIZE as f32; + let glow = (1.0 - ((u - 0.68).powi(2) + (v - 0.24).powi(2)).sqrt()).clamp(0.0, 1.0); + let bg = [ + lerp(18.0, 26.0, v) + 42.0 * glow, + lerp(86.0, 178.0, u) + 20.0 * glow, + lerp(142.0, 212.0, (u + v) * 0.5) + 22.0 * glow, + ]; + let shade = if fy > 760.0 { 0.88 } else { 1.0 }; + + pixels[idx] = (bg[0] * shade).clamp(0.0, 255.0) as u8; + pixels[idx + 1] = (bg[1] * shade).clamp(0.0, 255.0) as u8; + pixels[idx + 2] = (bg[2] * shade).clamp(0.0, 255.0) as u8; + pixels[idx + 3] = (mask * 255.0) as u8; + } + } + + draw_wordmark(&mut pixels); + pixels +} + +fn draw_wordmark(pixels: &mut [u8]) { + let white = [244, 249, 255, 255]; + let shadow = [4, 24, 42, 96]; + + for offset in [(0, 18), (0, 10)] { + draw_w(pixels, 160 + offset.0, 312 + offset.1, shadow); + draw_e(pixels, 560 + offset.0, 320 + offset.1, 300, 360, shadow); + } + + draw_w(pixels, 160, 292, white); + draw_e(pixels, 560, 300, 300, 360, white); +} + +fn draw_w(pixels: &mut [u8], x: i32, y: i32, color: [u8; 4]) { + let points = [ + (x + 12, y + 18), + (x + 112, y + 362), + (x + 210, y + 154), + (x + 308, y + 362), + (x + 408, y + 18), + ]; + + for pair in points.windows(2) { + let [(x1, y1), (x2, y2)] = pair else { + continue; + }; + draw_round_line(pixels, *x1, *y1, *x2, *y2, 78, color); + } +} + +fn draw_e(pixels: &mut [u8], x: i32, y: i32, width: i32, height: i32, color: [u8; 4]) { + let stroke = 82; + draw_round_rect(pixels, x, y, stroke, height, 34, color); + draw_round_rect(pixels, x, y, width, stroke, 34, color); + draw_round_rect( + pixels, + x, + y + height / 2 - stroke / 2, + width - 36, + stroke, + 34, + color, + ); + draw_round_rect(pixels, x, y + height - stroke, width, stroke, 34, color); +} + +fn draw_round_rect( + pixels: &mut [u8], + x: i32, + y: i32, + width: i32, + height: i32, + radius: i32, + color: [u8; 4], +) { + let min_x = x.max(0) as usize; + let min_y = y.max(0) as usize; + let max_x = (x + width).min(SIZE as i32) as usize; + let max_y = (y + height).min(SIZE as i32) as usize; + + for py in min_y..max_y { + for px in min_x..max_x { + let coverage = rounded_rect_coverage( + px as f32 + 0.5, + py as f32 + 0.5, + x as f32, + y as f32, + width as f32, + height as f32, + radius as f32, + ); + if coverage > 0.0 { + blend_pixel(pixels, px, py, color, coverage); + } + } + } +} + +fn draw_round_line( + pixels: &mut [u8], + x1: i32, + y1: i32, + x2: i32, + y2: i32, + width: i32, + color: [u8; 4], +) { + let radius = width as f32 / 2.0; + let min_x = (x1.min(x2) - width).max(0) as usize; + let min_y = (y1.min(y2) - width).max(0) as usize; + let max_x = (x1.max(x2) + width).min(SIZE as i32) as usize; + let max_y = (y1.max(y2) + width).min(SIZE as i32) as usize; + + for py in min_y..max_y { + for px in min_x..max_x { + let dist = distance_to_segment( + px as f32 + 0.5, + py as f32 + 0.5, + x1 as f32, + y1 as f32, + x2 as f32, + y2 as f32, + ); + let coverage = (radius + 0.5 - dist).clamp(0.0, 1.0); + if coverage > 0.0 { + blend_pixel(pixels, px, py, color, coverage); + } + } + } +} + +fn blend_pixel(pixels: &mut [u8], x: usize, y: usize, color: [u8; 4], coverage: f32) { + let idx = (y * SIZE as usize + x) * 4; + let src_a = (color[3] as f32 / 255.0) * coverage; + let dst_a = pixels[idx + 3] as f32 / 255.0; + let out_a = src_a + dst_a * (1.0 - src_a); + if out_a <= 0.0 { + return; + } + + for channel in 0..3 { + let src = color[channel] as f32 / 255.0; + let dst = pixels[idx + channel] as f32 / 255.0; + let out = (src * src_a + dst * dst_a * (1.0 - src_a)) / out_a; + pixels[idx + channel] = (out * 255.0).round() as u8; + } + pixels[idx + 3] = (out_a * 255.0).round() as u8; +} + +fn distance_to_segment(px: f32, py: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 { + let vx = x2 - x1; + let vy = y2 - y1; + let len_sq = vx * vx + vy * vy; + if len_sq == 0.0 { + return ((px - x1).powi(2) + (py - y1).powi(2)).sqrt(); + } + + let t = (((px - x1) * vx + (py - y1) * vy) / len_sq).clamp(0.0, 1.0); + let cx = x1 + t * vx; + let cy = y1 + t * vy; + ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() +} + +fn rounded_rect_coverage( + px: f32, + py: f32, + x: f32, + y: f32, + width: f32, + height: f32, + radius: f32, +) -> f32 { + let cx = px.clamp(x + radius, x + width - radius); + let cy = py.clamp(y + radius, y + height - radius); + let dx = px - cx; + let dy = py - cy; + let dist = (dx * dx + dy * dy).sqrt(); + (radius + 0.5 - dist).clamp(0.0, 1.0) +} + +fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_icon_has_expected_size_and_visible_wordmark() { + let pixels = render_icon(); + assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize); + + let stroke = (340 * SIZE as usize + 600) * 4; + assert_eq!(pixels[stroke + 3], 255); + assert!(pixels[stroke] > 180); + assert!(pixels[stroke + 1] > 180); + assert!(pixels[stroke + 2] > 180); + } + + #[test] + fn render_icon_has_transparent_corners() { + let pixels = render_icon(); + assert_eq!(pixels[3], 0); + } +} diff --git a/tools/we.entitlements b/tools/we.entitlements new file mode 100644 index 0000000..bc04cfb --- /dev/null +++ b/tools/we.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.network.client + + +