diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5c4b3b..1ddad41 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,15 @@ jobs: macos: name: macOS universal runs-on: macos-14 + # Signing and notarization are automatic when these secrets exist; + # without them the app is ad-hoc signed. + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -105,7 +114,7 @@ jobs: "node-$NATIVE_HID_NODE_VERSION-darwin-x64/bin/node" \ -output native-hid/node/bin/node chmod +x native-hid/node/bin/node - - name: Package universal binary + - name: Package universal binary (existing bare-binary installs) run: | lipo -create \ target/aarch64-apple-darwin/release/openmouse-bridge \ @@ -115,12 +124,62 @@ jobs: cp assets/fonts/DM-Sans-OFL.txt ./DM-Sans-OFL.txt zip -r openmouse-bridge-macos-universal.zip openmouse-bridge README.md DM-Sans-OFL.txt native-hid shasum -a 256 openmouse-bridge-macos-universal.zip > openmouse-bridge-macos-universal.zip.sha256 + - name: Import signing certificate + if: env.MACOS_CERTIFICATE_BASE64 != '' + run: | + set -euo pipefail + keychain="$RUNNER_TEMP/openmouse-signing.keychain-db" + keychain_password="$(uuidgen)" + echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$RUNNER_TEMP/certificate.p12" -P "$MACOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain" + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" + security list-keychains -d user -s "$keychain" login.keychain-db + echo "MACOS_SIGN_IDENTITY=$MACOS_SIGN_IDENTITY" >> "$GITHUB_ENV" + - name: Build OpenMouse Bridge.app + run: | + version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)" + mkdir -p dist + packaging/macos/build-app.sh openmouse-bridge native-hid "$version" dist + - name: Notarize app + if: env.MACOS_CERTIFICATE_BASE64 != '' && env.APPLE_ID != '' + run: | + set -euo pipefail + ditto -c -k --keepParent "dist/OpenMouse Bridge.app" notarize-app.zip + xcrun notarytool submit notarize-app.zip --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD" --wait + xcrun stapler staple "dist/OpenMouse Bridge.app" + - name: Package DMG and app update archive + run: | + set -euo pipefail + python3 -m pip install --quiet dmgbuild + packaging/macos/build-dmg.sh "dist/OpenMouse Bridge.app" OpenMouse-Bridge-macos-universal.dmg + # Plain Info-ZIP (not ditto): the updater's strict extractor expects ordinary zip entries. + (cd dist && zip -r -X -q "../openmouse-bridge-macos-app.zip" "OpenMouse Bridge.app") + shasum -a 256 openmouse-bridge-macos-app.zip > openmouse-bridge-macos-app.zip.sha256 + - name: Notarize DMG + if: env.MACOS_CERTIFICATE_BASE64 != '' && env.APPLE_ID != '' + run: | + set -euo pipefail + xcrun notarytool submit OpenMouse-Bridge-macos-universal.dmg --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_PASSWORD" --wait + xcrun stapler staple OpenMouse-Bridge-macos-universal.dmg + - name: Verify DMG + run: | + set -euo pipefail + hdiutil attach OpenMouse-Bridge-macos-universal.dmg -nobrowse -readonly -mountpoint "$RUNNER_TEMP/dmg" + test -d "$RUNNER_TEMP/dmg/OpenMouse Bridge.app" + codesign --verify --deep --strict "$RUNNER_TEMP/dmg/OpenMouse Bridge.app" + hdiutil detach "$RUNNER_TEMP/dmg" - uses: actions/upload-artifact@v4 with: name: bridge-macos path: | openmouse-bridge-macos-universal.zip openmouse-bridge-macos-universal.zip.sha256 + openmouse-bridge-macos-app.zip + openmouse-bridge-macos-app.zip.sha256 + OpenMouse-Bridge-macos-universal.dmg linux: name: Linux x64 runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 1ca09c9..65bfcab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target native-hid/node_modules .DS_Store +__pycache__/ diff --git a/README.md b/README.md index 0c203ad..6bbe239 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,15 @@ cargo test cargo clippy --all-targets -- -D warnings ``` +## Install on macOS + +Download `OpenMouse-Bridge-macos-universal.dmg` from the latest release, open +it, and drag OpenMouse Bridge to Applications. It runs as a menu bar app with +no Terminal window. `packaging/macos/` holds the scripts that build the app +bundle and DMG; the release workflow signs and notarizes them when the +`MACOS_CERTIFICATE_BASE64`, `MACOS_CERTIFICATE_PASSWORD`, `MACOS_SIGN_IDENTITY`, +`APPLE_ID`, `APPLE_TEAM_ID` and `APPLE_APP_PASSWORD` secrets are set. + ## Install on Linux One-line install from the latest stable release (detects missing system diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist new file mode 100644 index 0000000..38621b3 --- /dev/null +++ b/packaging/macos/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + openmouse-bridge + CFBundleIconFile + OpenMouse + CFBundleIdentifier + io.OpenMouse.OpenMouse-Bridge + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OpenMouse Bridge + CFBundleDisplayName + OpenMouse Bridge + CFBundlePackageType + APPL + CFBundleShortVersionString + @VERSION@ + CFBundleVersion + @BUILD@ + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + MIT License + + diff --git a/packaging/macos/build-app.sh b/packaging/macos/build-app.sh new file mode 100755 index 0000000..b66d1fe --- /dev/null +++ b/packaging/macos/build-app.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Assembles "OpenMouse Bridge.app" from a built Bridge binary and a prepared +# native-hid directory (deps + bundled Node.js, see release.yml). +# +# build-app.sh +# +# Signs ad-hoc by default. Set MACOS_SIGN_IDENTITY to a Developer ID +# Application identity for a distributable (notarizable) build. +set -euo pipefail + +binary="$1" +native_hid="$2" +version="$3" +out_dir="$4" +identity="${MACOS_SIGN_IDENTITY:--}" + +here="$(cd "$(dirname "$0")" && pwd)" +repo="$(cd "$here/../.." && pwd)" +app="$out_dir/OpenMouse Bridge.app" + +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + +cp "$binary" "$app/Contents/MacOS/openmouse-bridge" +chmod +x "$app/Contents/MacOS/openmouse-bridge" + +ditto "$native_hid" "$app/Contents/Resources/native-hid" +# Bridge's updater refuses packages that omit the bundled font's license. +cp "$repo/assets/fonts/DM-Sans-OFL.txt" "$app/Contents/Resources/DM-Sans-OFL.txt" +# npm's .bin entries are symlinks, which the update zip cannot carry. +find "$app/Contents/Resources/native-hid" -name .bin -type d -prune -exec rm -rf {} + +if [ -n "$(find "$app" -type l)" ]; then + echo "bundle contains symlinks:" >&2 + find "$app" -type l >&2 + exit 1 +fi + +# 1.0.0-beta.19 -> short version as-is, build number numeric. +build="${version%%-*}" +sed -e "s/@VERSION@/$version/" -e "s/@BUILD@/$build/" \ + "$here/Info.plist" > "$app/Contents/Info.plist" + +iconset="$(mktemp -d)/OpenMouse.iconset" +mkdir -p "$iconset" +icon="$here/icon-1024.png" # regenerate with make-assets.py +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$icon" --out "$iconset/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z "$double" "$double" "$icon" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$iconset" -o "$app/Contents/Resources/OpenMouse.icns" + +sign_flags=(--force --sign "$identity" --entitlements "$here/entitlements.plist") +if [ "$identity" != "-" ]; then + sign_flags+=(--options runtime --timestamp) +fi + +# Inside-out: nested Mach-O (Node, node-hid addon) first, then the app. +while IFS= read -r -d '' file; do + if file -b "$file" | grep -q "Mach-O"; then + codesign "${sign_flags[@]}" "$file" + fi +done < <(find "$app/Contents/Resources" -type f -print0) +codesign "${sign_flags[@]}" "$app" + +codesign --verify --deep --strict "$app" +echo "$app" diff --git a/packaging/macos/build-dmg.sh b/packaging/macos/build-dmg.sh new file mode 100755 index 0000000..c261ba2 --- /dev/null +++ b/packaging/macos/build-dmg.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Wraps the app in a styled drag-to-Applications disk image. Needs dmgbuild +# (pip install dmgbuild). +# +# build-dmg.sh +set -euo pipefail + +app="$1" +dmg="$2" +identity="${MACOS_SIGN_IDENTITY:--}" +here="$(cd "$(dirname "$0")" && pwd)" + +rm -f "$dmg" +dmgbuild -s "$here/dmg-settings.py" \ + -D app="$app" \ + -D icon="$app/Contents/Resources/OpenMouse.icns" \ + -D background="$here/dmg-background.tiff" \ + "OpenMouse Bridge" "$dmg" + +if [ "$identity" != "-" ]; then + codesign --force --sign "$identity" --timestamp "$dmg" +fi +echo "$dmg" diff --git a/packaging/macos/dmg-background.tiff b/packaging/macos/dmg-background.tiff new file mode 100644 index 0000000..3cffb16 Binary files /dev/null and b/packaging/macos/dmg-background.tiff differ diff --git a/packaging/macos/dmg-settings.py b/packaging/macos/dmg-settings.py new file mode 100644 index 0000000..4906c97 --- /dev/null +++ b/packaging/macos/dmg-settings.py @@ -0,0 +1,27 @@ +# dmgbuild settings for the installer window; see build-dmg.sh. +# defines: app (path to the .app), icon (volume icon .icns), background (.tiff) +import os + +app = defines["app"] +app_name = os.path.basename(app) + +format = "UDZO" +files = [app] +symlinks = {"Applications": "/Applications"} +background = defines["background"] +icon = defines["icon"] + +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +default_view = "icon-view" + +# Frame = 32pt title bar + 400pt content + 28pt path bar (bg is 660x428 so it +# also fills the frame when the path bar is hidden). Icon y matches make-assets.py. +window_rect = ((200, 120), (660, 460)) +icon_size = 112 +text_size = 13 +arrange_by = None +icon_locations = {app_name: (170, 185), "Applications": (490, 185)} diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist new file mode 100644 index 0000000..f83db85 --- /dev/null +++ b/packaging/macos/entitlements.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/packaging/macos/icon-1024.png b/packaging/macos/icon-1024.png new file mode 100644 index 0000000..e29283c Binary files /dev/null and b/packaging/macos/icon-1024.png differ diff --git a/packaging/macos/make-assets.py b/packaging/macos/make-assets.py new file mode 100644 index 0000000..dca23f9 --- /dev/null +++ b/packaging/macos/make-assets.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Regenerates the committed macOS artwork from assets/openmouse-app-icon.png: +icon-1024.png (app icon on a macOS-style tile) and the DMG window background +(dmg-background.tiff, 1x + 2x). Needs Pillow and macOS' tiffutil and Menlo. + + python3 packaging/macos/make-assets.py +""" +import subprocess +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont + +HERE = Path(__file__).resolve().parent +REPO = HERE.parent.parent +SOURCE = REPO / "assets/openmouse-app-icon.png" +GREEN = (107, 208, 140) + +# Content area. The Finder frame adds a 32pt title bar and, when shown, a 28pt +# path bar, so key content stays in the top 400pt and dmg-settings.py asks +# for a 460pt-tall frame. +WINDOW = (660, 428) +ICON_Y = 185 # keep in sync with dmg-settings.py +APP_X, APPS_X = 170, 490 + + +def superellipse_mask(size, margin, n=5.0): + """macOS-style continuous-corner tile, antialiased by 4x supersampling.""" + scale = 4 + big = size * scale + half = (big - 2 * margin * scale) / 2 + center = big / 2 + mask = Image.new("L", (big, big), 0) + pixels = mask.load() + for y in range(big): + dy = abs(y - center) / half + if dy > 1: + continue + span = (1 - dy**n) ** (1 / n) + x0 = int(center - half * span) + x1 = int(center + half * span) + for x in range(max(x0, 0), min(x1, big)): + pixels[x, y] = 255 + return mask.resize((size, size), Image.LANCZOS) + + +def make_icon(): + size = 1024 + tile = superellipse_mask(size, margin=100) + + gradient = Image.new("RGB", (size, size)) + draw = ImageDraw.Draw(gradient) + for y in range(size): + t = y / (size - 1) + draw.line( + [(0, y), (size, y)], + fill=(int(34 - 16 * t), int(38 - 18 * t), int(46 - 22 * t)), + ) + + icon = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + + shadow = Image.new("L", (size, size), 0) + shadow.paste(tile, (0, 12)) + shadow = shadow.filter(ImageFilter.GaussianBlur(14)).point(lambda v: v * 0.45) + icon.paste(Image.new("RGBA", (size, size), (0, 0, 0, 255)), mask=shadow) + icon.paste(gradient, mask=tile) + + mouse = Image.open(SOURCE).convert("RGBA") + mouse = mouse.crop(mouse.getbbox()) + height = 520 + mouse = mouse.resize((round(mouse.width * height / mouse.height), height), Image.LANCZOS) + icon.alpha_composite(mouse, ((size - mouse.width) // 2, (size - mouse.height) // 2)) + icon.save(HERE / "icon-1024.png") + return icon + + +BANNER = r""" + ___ __ __ + / _ \ _ __ ___ _ _ | \/ |___ _ _ ___ ___ +| (_) | '_ \/ -_) ' \| |\/| / _ \ || (_-", f) + gap = 8 * scale + count = 5 + total = count * chevron + (count - 1) * gap + x = ((APP_X + APPS_X) * scale - total) / 2 + for i in range(count): + t = (i + 1) / count + color = tuple(int(DIM[c] + (GREEN[c] - DIM[c]) * t) for c in range(3)) + draw.text((x + i * (chevron + gap), (ICON_Y - 19) * scale), ">", font=f, fill=color) + + # Finder always draws DMG icon labels in black over a picture background, + # so give them light chips to sit on (label centre is ~74pt below the icon). + chip_w, chip_h, chip_y = 140, 22, ICON_Y + 74 + for cx in (APP_X, APPS_X): + draw.rounded_rectangle( + [(cx - chip_w / 2) * scale, (chip_y - chip_h / 2) * scale, + (cx + chip_w / 2) * scale, (chip_y + chip_h / 2) * scale], + radius=chip_h / 2 * scale, + fill=(226, 231, 238), + ) + + # Terminal-style caption with a cursor block. + f = mono(12) + caption = "drag OpenMouse Bridge.app -> /Applications" + prompt = "$ " + cursor = 8 * scale + total = text_width(prompt + caption, f) + 6 * scale + cursor + x = (width - total) / 2 + y = 330 * scale + draw.text((x, y), prompt, font=f, fill=GREEN) + draw.text((x + text_width(prompt, f), y), caption, font=f, fill=TEXT) + cx = x + text_width(prompt + caption, f) + 6 * scale + draw.rectangle([cx, y + 1 * scale, cx + cursor, y + 15 * scale], fill=GREEN) + return image + + +def main(): + make_icon() + one, two = background(1), background(2) + one.save(HERE / "bg.png", dpi=(72, 72)) + two.save(HERE / "bg@2x.png", dpi=(144, 144)) + subprocess.run( + ["tiffutil", "-cathidpicheck", HERE / "bg.png", HERE / "bg@2x.png", + "-out", HERE / "dmg-background.tiff"], + check=True, + ) + (HERE / "bg.png").unlink() + (HERE / "bg@2x.png").unlink() + + +if __name__ == "__main__": + main() diff --git a/src/drivers/native_hid.rs b/src/drivers/native_hid.rs index 8a0514f..d94417a 100644 --- a/src/drivers/native_hid.rs +++ b/src/drivers/native_hid.rs @@ -312,10 +312,15 @@ fn locate_node_binary() -> Option { } /// The `native-hid/` directory a packaged Bridge release ships next to its -/// executable, if the executable's location can be determined at all (it's -/// an `Option` rather than a hard error because the fallback paths this -/// module's callers use don't need it to succeed). +/// executable (or, in a macOS `.app`, under `Contents/Resources/`), if the +/// executable's location can be determined at all (it's an `Option` rather +/// than a hard error because the fallback paths this module's callers use +/// don't need it to succeed). fn installed_native_hid_dir() -> Option { + #[cfg(target_os = "macos")] + if let Some(bundle) = crate::platform::bundle_root() { + return Some(bundle.join("Contents/Resources/native-hid")); + } Some(std::env::current_exe().ok()?.parent()?.join("native-hid")) } diff --git a/src/platform/macos.rs b/src/platform/macos.rs index b970da8..f60d388 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -1,4 +1,7 @@ -use std::{env, fs, path::PathBuf}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; use anyhow::{Context, Result}; use directories::BaseDirs; @@ -13,6 +16,22 @@ pub fn linux_distribution() -> Option { None } +/// The `.app` directory the running executable lives in, if it was launched +/// from a bundle (`X.app/Contents/MacOS/`) rather than as a bare binary. +pub fn bundle_root() -> Option { + bundle_root_of(&env::current_exe().ok()?) +} + +fn bundle_root_of(executable: &Path) -> Option { + let macos_dir = executable.parent()?; + let contents = macos_dir.parent()?; + let bundle = contents.parent()?; + (macos_dir.file_name()? == "MacOS" + && contents.file_name()? == "Contents" + && bundle.extension()? == "app") + .then(|| bundle.to_path_buf()) +} + pub fn autostart_enabled() -> bool { launch_agent_path().is_ok_and(|path| path.exists()) } @@ -72,6 +91,20 @@ fn xml_escape(value: &str) -> String { mod tests { use super::*; + #[test] + fn bundle_root_is_found_only_inside_an_app() { + assert_eq!( + bundle_root_of(Path::new( + "/Applications/OpenMouse Bridge.app/Contents/MacOS/openmouse-bridge" + )), + Some(PathBuf::from("/Applications/OpenMouse Bridge.app")) + ); + assert_eq!( + bundle_root_of(Path::new("/tmp/openmouse-bridge/openmouse-bridge")), + None + ); + } + #[test] fn executable_paths_are_escaped_for_plists() { assert_eq!(xml_escape("A&B "), "A&B <Bridge>"); diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 9452878..04477d4 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -21,7 +21,7 @@ pub use windows::{ }; #[cfg(target_os = "macos")] -pub use macos::{autostart_enabled, linux_distribution, platform_name, set_autostart}; +pub use macos::{autostart_enabled, bundle_root, linux_distribution, platform_name, set_autostart}; #[cfg(target_os = "linux")] pub use linux::{autostart_enabled, linux_distribution, platform_name, set_autostart}; @@ -158,9 +158,7 @@ pub fn notify(summary: &str, body: &str) -> Result<()> { #[cfg(target_os = "macos")] fn macos_is_bundled() -> bool { - std::env::current_exe() - .map(|path| path.to_string_lossy().contains(".app/Contents/MacOS/")) - .unwrap_or(false) + bundle_root().is_some() } #[cfg(target_os = "macos")] diff --git a/src/updater.rs b/src/updater.rs index 282d4cb..321b429 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -22,6 +22,9 @@ const UPDATE_URL_ENV: &str = "OPENMOUSE_BRIDGE_UPDATE_API_URL"; const USER_AGENT: &str = "OpenMouse-Bridge-Updater"; const CHECK_TIMEOUT: Duration = Duration::from_secs(8); const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60); +/// The bundle a macOS app-update archive carries at its root. +#[cfg(any(target_os = "macos", test))] +const MACOS_APP_NAME: &str = "OpenMouse Bridge.app"; #[derive(Clone, Debug)] pub struct UpdateInfo { @@ -153,8 +156,14 @@ fn asset_url(assets: &[ReleaseAsset], name: &str) -> Result { } fn platform_archive_name() -> Result<&'static str> { + // Installs from the DMG run as a signed .app and are replaced as a whole + // bundle; bare-binary installs keep following the original zip. #[cfg(target_os = "macos")] - return Ok("openmouse-bridge-macos-universal.zip"); + return Ok(if crate::platform::bundle_root().is_some() { + "openmouse-bridge-macos-app.zip" + } else { + "openmouse-bridge-macos-universal.zip" + }); #[cfg(target_os = "windows")] return Ok("openmouse-bridge-windows-x64.zip"); #[cfg(target_os = "linux")] @@ -232,22 +241,80 @@ fn stage_archive(archive: &[u8], version: &str) -> Result<()> { verify_package(&staging.path)?; let current_executable = env::current_exe().context("could not locate the Bridge executable")?; - let install_directory = current_executable - .parent() - .context("Bridge executable has no parent directory")?; ensure!( current_executable.file_name() == Some(std::ffi::OsStr::new(platform_binary_name())), "Bridge executable has an unexpected name" ); + // A bundled install replaces the whole .app so its code signature stays + // intact; the directory that must be writable is the one holding it. + #[cfg(target_os = "macos")] + if let Some(bundle) = crate::platform::bundle_root() { + let install_directory = bundle + .parent() + .context("Bridge bundle has no parent directory")?; + let bundle_name = bundle + .file_name() + .context("Bridge bundle has no file name")?; + verify_install_targets(install_directory, &[bundle_name])?; + spawn_bundle_install_helper(&staging.path, install_directory, bundle_name)?; + staging.handed_off = true; + return Ok(()); + } + let install_directory = current_executable + .parent() + .context("Bridge executable has no parent directory")?; verify_install_directory(install_directory)?; spawn_install_helper(&staging.path, install_directory, ¤t_executable)?; staging.handed_off = true; Ok(()) } +/// Where an update package keeps the files Bridge needs, relative to the +/// extracted archive: flat next to the executable, or inside a macOS bundle. +struct PackageLayout { + binary: std::path::PathBuf, + license: std::path::PathBuf, + native_hid: std::path::PathBuf, + extra: Vec, +} + +impl PackageLayout { + fn flat() -> Self { + Self { + binary: platform_binary_name().into(), + license: "DM-Sans-OFL.txt".into(), + native_hid: "native-hid".into(), + extra: Vec::new(), + } + } + + #[cfg(any(target_os = "macos", test))] + fn macos_bundle() -> Self { + let app = Path::new(MACOS_APP_NAME); + Self { + binary: app.join("Contents/MacOS/openmouse-bridge"), + license: app.join("Contents/Resources/DM-Sans-OFL.txt"), + native_hid: app.join("Contents/Resources/native-hid"), + extra: vec![app.join("Contents/Info.plist")], + } + } + + fn current() -> Self { + #[cfg(target_os = "macos")] + if crate::platform::bundle_root().is_some() { + return Self::macos_bundle(); + } + Self::flat() + } +} + fn verify_package(staging: &Path) -> Result<()> { + verify_package_with(staging, &PackageLayout::current()) +} + +fn verify_package_with(staging: &Path, layout: &PackageLayout) -> Result<()> { verify_tree(staging)?; - let license = staging.join("DM-Sans-OFL.txt"); + let license = staging.join(&layout.license); let expected = include_bytes!("../assets/fonts/DM-Sans-OFL.txt"); ensure!( reject_link(&license)?.is_file() && fs::metadata(&license)?.len() == expected.len() as u64, @@ -258,36 +325,41 @@ fn verify_package(staging: &Path) -> Result<()> { "update package contains an incomplete DM Sans license" ); let node = if cfg!(windows) { - "native-hid/node/node.exe" + layout.native_hid.join("node/node.exe") } else { - "native-hid/node/bin/node" + layout.native_hid.join("node/bin/node") }; - for name in [ - platform_binary_name(), - "native-hid/src/apply.mjs", - "native-hid/src/brands.mjs", - "native-hid/src/hid-device-adapter.mjs", - "native-hid/src/attack-shark-x11.mjs", - "native-hid/package.json", - node, - "native-hid/node_modules/node-hid/package.json", - "native-hid/node_modules/@openmouse/protocol/package.json", - "native-hid/node_modules/@openmouse/keyboard-protocol/package.json", - ] { + let hid = &layout.native_hid; + let mut required = vec![ + layout.binary.clone(), + hid.join("src/apply.mjs"), + hid.join("src/brands.mjs"), + hid.join("src/hid-device-adapter.mjs"), + hid.join("src/attack-shark-x11.mjs"), + hid.join("package.json"), + node.clone(), + hid.join("node_modules/node-hid/package.json"), + hid.join("node_modules/@openmouse/protocol/package.json"), + hid.join("node_modules/@openmouse/keyboard-protocol/package.json"), + ]; + required.extend(layout.extra.iter().cloned()); + for name in &required { let path = staging.join(name); verify_directory_chain(path.parent().context("missing package parent")?)?; - let metadata = - reject_link(&path).with_context(|| format!("update package is missing {name}"))?; + let metadata = reject_link(&path) + .with_context(|| format!("update package is missing {}", name.display()))?; ensure!( metadata.is_file() && metadata.len() > 0, - "update package file is empty or invalid: {name}" + "update package file is empty or invalid: {}", + name.display() ); #[cfg(unix)] - if name == platform_binary_name() || name == node { + if *name == layout.binary || *name == node { use std::os::unix::fs::PermissionsExt; ensure!( metadata.permissions().mode() & 0o500 == 0o500, - "update executable requires owner read and execute access: {name}" + "update executable requires owner read and execute access: {}", + name.display() ); } } @@ -458,8 +530,17 @@ fn extract_archive_with_limits( inner: Cursor::new(archive), metadata_budget: &metadata_budget, }; - let mut zip = zip::ZipArchive::new(reader) - .context("update archive is invalid or exceeds metadata budget")?; + // Update archives never carry prepended data. Without a known offset the + // zip crate scans the whole file forward looking for the central + // directory, which spends the entire metadata budget on any archive + // larger than it - that is, every real release. + let mut zip = zip::ZipArchive::with_config( + zip::read::Config { + archive_offset: zip::read::ArchiveOffset::Known(0), + }, + reader, + ) + .context("update archive is invalid or exceeds metadata budget")?; metadata_budget.set(None); ensure!(zip.len() <= entries, "update archive has too many entries"); let mut declared_total = 0u64; @@ -566,25 +647,34 @@ fn extract_archive_with_limits( } } ensure!( - reject_link(&staging.join(platform_binary_name()))?.is_file(), + reject_link(&staging.join(&PackageLayout::current().binary))?.is_file(), "update archive does not contain the Bridge executable" ); Ok(()) } fn verify_install_directory(directory: &Path) -> Result<()> { + verify_install_targets( + directory, + &[ + std::ffi::OsStr::new(platform_binary_name()), + std::ffi::OsStr::new("native-hid"), + std::ffi::OsStr::new("openmouse-app-icon.png"), + std::ffi::OsStr::new("DM-Sans-OFL.txt"), + ], + ) +} + +/// Checks that `directory` is a plain, unlocked, writable directory and that +/// every existing entry named in `names` is a link-free tree. +fn verify_install_targets(directory: &Path, names: &[&std::ffi::OsStr]) -> Result<()> { verify_directory_chain(directory)?; match fs::symlink_metadata(directory.join(".openmouse-update.lock")) { Ok(_) => anyhow::bail!("Bridge update lock already exists"), Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error).context("could not inspect Bridge update lock"), } - for name in [ - platform_binary_name(), - "native-hid", - "openmouse-app-icon.png", - "DM-Sans-OFL.txt", - ] { + for name in names { let path = directory.join(name); match fs::symlink_metadata(&path) { Ok(_) => verify_tree(&path)?, @@ -741,6 +831,100 @@ fn spawn_install_helper(staging: &Path, destination: &Path, executable: &Path) - Ok(()) } +/// Replaces one directory (a macOS `.app`) as a unit. Same lock, backup and +/// rollback discipline as `UNIX_TRANSACTION`: status 2 means nothing changed. +#[cfg(any(target_os = "macos", all(unix, test)))] +const BUNDLE_TRANSACTION: &str = r#"install_bundle() ( +set -eu +stage="$1" +destination="$2" +staged="$3" +installed="$4" +[ -d "$stage/$staged/Contents/MacOS" ] || exit 1 +for tree_root in "$stage" "$destination"; do + parent="$tree_root" + while [ "$parent" != / ]; do + [ -d "$parent" ] && [ ! -L "$parent" ] || exit 1 + parent=$(dirname "$parent") || exit 1 + done +done +special=$(find "$stage" ! -type f ! -type d -print) || exit 1 +[ -z "$special" ] || exit 1 +lock="$destination/.openmouse-update.lock" +(umask 077; mkdir "$lock") || exit 2 +transaction=$(umask 077; mktemp -d "$destination/.openmouse-update.XXXXXXXX") || { rmdir "$lock" || :; exit 2; } +committed=no +rollback() { + status=$? + trap - 0 1 2 15 + failed=no + if [ "$committed" != yes ]; then + if [ -e "$transaction/old/$installed" ]; then + rm -rf "$destination/$installed" && mv "$transaction/old/$installed" "$destination/$installed" || failed=yes + elif [ -f "$transaction/installed/marker" ]; then + rm -rf "$destination/$installed" || failed=yes + fi + fi + if [ "$failed" = yes ]; then + printf '%s\n' "Update rollback failed; backups retained at $transaction" >&2 + exit 1 + fi + rm -rf "$transaction" + rmdir "$lock" + if [ "$committed" != yes ]; then exit 2; fi + exit "$status" +} +trap rollback 0 +trap 'exit 1' 1 2 15 +mkdir "$transaction/new" "$transaction/old" "$transaction/installed" || exit 1 +if [ -e "$destination/$installed" ] || [ -L "$destination/$installed" ]; then + special=$(find "$destination/$installed" ! -type f ! -type d -print) || exit 1 + [ -z "$special" ] || exit 1 +fi +cp -R "$stage/$staged" "$transaction/new/$installed" || exit 1 +chmod u+x "$transaction/new/$installed/Contents/MacOS/openmouse-bridge" || exit 1 +if [ -e "$destination/$installed" ]; then mv "$destination/$installed" "$transaction/old/$installed" || exit 1; fi +: > "$transaction/installed/marker" || exit 1 +mv "$transaction/new/$installed" "$destination/$installed" || exit 1 +committed=yes +) +"#; + +#[cfg(any(target_os = "macos", all(unix, test)))] +const BUNDLE_COMPLETION: &str = r#"complete_bundle_update() { + if install_bundle "$1" "$2" "$3" "$4"; then + : + else + status=$? + [ "$status" -eq 2 ] || return 1 + fi + "$5" "$2" "$4" +} +"#; + +#[cfg(target_os = "macos")] +fn spawn_bundle_install_helper( + staging: &Path, + destination: &Path, + installed: &std::ffi::OsStr, +) -> Result<()> { + let script = staging.join("install-update.sh"); + let contents = format!( + "#!/bin/sh\n{BUNDLE_TRANSACTION}\n{BUNDLE_COMPLETION}\npid=\"$1\"\nstage=\"$2\"\ndestination=\"$3\"\nstaged=\"$4\"\ninstalled=\"$5\"\ntrap 'rm -rf \"$stage\"' 0\nwhile kill -0 \"$pid\" 2>/dev/null; do sleep 1; done\nlaunch_bundle() {{ open \"$1/$2\"; }}\ncomplete_bundle_update \"$stage\" \"$destination\" \"$staged\" \"$installed\" launch_bundle\n" + ); + write_helper(&script, &contents)?; + Command::new("/bin/sh") + .arg(&script) + .arg(std::process::id().to_string()) + .arg(staging) + .arg(destination) + .arg(MACOS_APP_NAME) + .arg(installed) + .spawn() + .context("could not launch the update helper")?; + Ok(()) +} + #[cfg(windows)] const WINDOWS_TRANSACTION: &str = r#"function Assert-CleanTree([string]$Path) { $Pending = [System.Collections.Generic.Stack[string]]::new() diff --git a/src/updater/tests.rs b/src/updater/tests.rs index 09c1849..331c2c8 100644 --- a/src/updater/tests.rs +++ b/src/updater/tests.rs @@ -720,3 +720,206 @@ fn checksum_must_match_archive() { .is_err() ); } + +#[test] +fn macos_bundle_package_layout_requires_every_bundled_file() { + let root = fixture(); + let layout = PackageLayout::macos_bundle(); + let node = layout.native_hid.join(if cfg!(windows) { + "node/node.exe" + } else { + "node/bin/node" + }); + let mut names = vec![ + layout.binary.clone(), + layout.native_hid.join("src/apply.mjs"), + layout.native_hid.join("src/brands.mjs"), + layout.native_hid.join("src/hid-device-adapter.mjs"), + layout.native_hid.join("src/attack-shark-x11.mjs"), + layout.native_hid.join("package.json"), + node, + layout.native_hid.join("node_modules/node-hid/package.json"), + layout + .native_hid + .join("node_modules/@openmouse/protocol/package.json"), + layout + .native_hid + .join("node_modules/@openmouse/keyboard-protocol/package.json"), + ]; + names.extend(layout.extra.iter().cloned()); + for name in &names { + let path = root.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"inert-fixture").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + } + assert!( + verify_package_with(&root, &layout).is_err(), + "font license is mandatory inside the bundle" + ); + fs::write( + root.join(&layout.license), + include_bytes!("../../assets/fonts/DM-Sans-OFL.txt"), + ) + .unwrap(); + verify_package_with(&root, &layout).unwrap(); + assert!( + verify_package_with(&root, &PackageLayout::flat()).is_err(), + "a bundle is not a flat package" + ); + for name in &names { + let path = root.join(name); + fs::remove_file(&path).unwrap(); + assert!( + verify_package_with(&root, &layout).is_err(), + "accepted a bundle missing {}", + name.display() + ); + fs::write(&path, b"inert-fixture").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + } + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn bundle_archive_paths_are_accepted_by_the_extractor() { + let bytes = archive(&[ + // The extractor's executable check follows the running install's + // layout, which is flat under test. + (platform_binary_name(), b"flat"), + ("OpenMouse Bridge.app/Contents/Info.plist", b"plist"), + ( + "OpenMouse Bridge.app/Contents/MacOS/openmouse-bridge", + b"bin", + ), + ]); + let root = fixture(); + extract_archive(&bytes, &root).unwrap(); + assert!( + root.join("OpenMouse Bridge.app/Contents/MacOS/openmouse-bridge") + .is_file() + ); + fs::remove_dir_all(root).unwrap(); +} + +#[cfg(unix)] +#[test] +fn generated_bundle_transaction_replaces_or_restores_the_app() { + for case in [ + "success", + "existing-lock", + "copy-failure", + "commit-failure", + "rollback-failure", + ] { + let root = fixture(); + let stage = root.join("stage"); + let destination = root.join("install"); + let staged_exe = stage.join("OpenMouse Bridge.app/Contents/MacOS"); + let installed_exe = destination.join("Renamed Bridge.app/Contents/MacOS"); + for directory in [&staged_exe, &installed_exe] { + fs::create_dir_all(directory).unwrap(); + } + fs::write(root.join("sandbox-marker"), b"bridge-update-fixture-only").unwrap(); + fs::write(staged_exe.join("openmouse-bridge"), b"new-binary").unwrap(); + fs::write(installed_exe.join("openmouse-bridge"), b"old-binary").unwrap(); + fs::write(destination.join("sentinel"), b"untouched").unwrap(); + if case == "existing-lock" { + fs::create_dir(destination.join(".openmouse-update.lock")).unwrap(); + } + let script = root.join("sandbox.sh"); + let contents = format!( + "{BUNDLE_TRANSACTION}\n{BUNDLE_COMPLETION}\n{}", + r#" +root="$1" +fixture_case="$2" +[ "$(cat "$root/sandbox-marker")" = bridge-update-fixture-only ] || exit 90 +cp() { + if [ "$fixture_case" = copy-failure ]; then return 1; fi + command cp "$@" +} +mv() { + case "$1" in */new/*) if [ "$fixture_case" = commit-failure ] || [ "$fixture_case" = rollback-failure ]; then return 1; fi ;; esac + case "$1" in */old/*) if [ "$fixture_case" = rollback-failure ]; then return 1; fi ;; esac + command mv "$@" +} +record_launch() { cat "$1/$2/Contents/MacOS/openmouse-bridge" > "$root/launch-record"; } +complete_bundle_update "$root/stage" "$root/install" "OpenMouse Bridge.app" "Renamed Bridge.app" record_launch +"# + ); + write_helper(&script, &contents).unwrap(); + let output = Command::new("/bin/sh") + .arg(&script) + .arg(root.to_string_lossy().as_ref()) + .arg(case) + .output() + .unwrap(); + assert_eq!( + output.status.success(), + case != "rollback-failure", + "{case}: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + root.join("launch-record").exists(), + case != "rollback-failure", + "{case}: relaunch eligibility" + ); + if case == "rollback-failure" { + assert!(destination.join(".openmouse-update.lock").exists()); + fs::remove_dir_all(root).unwrap(); + continue; + } + let expected: &[u8] = if case == "success" { + b"new-binary" + } else { + b"old-binary" + }; + assert_eq!(fs::read(root.join("launch-record")).unwrap(), expected); + assert_eq!( + fs::read(installed_exe.join("openmouse-bridge")).unwrap(), + expected, + "{case}: installed app" + ); + assert_eq!( + fs::read(destination.join("sentinel")).unwrap(), + b"untouched" + ); + assert!(!destination.join("OpenMouse Bridge.app").exists()); + assert_eq!( + fs::read_dir(&destination).unwrap().count(), + if case == "existing-lock" { 3 } else { 2 }, + "{case}: no transaction leftovers" + ); + fs::remove_dir_all(root).unwrap(); + } +} + +#[test] +fn archives_larger_than_the_metadata_budget_still_open() { + use std::io::Write; + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let stored = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + writer.start_file(platform_binary_name(), stored).unwrap(); + // Real release archives are tens of megabytes; opening one must not read + // the whole file as "metadata". + let block = vec![0x5a; 1024 * 1024]; + for _ in 0..(MAX_ZIP_METADATA_READ_BYTES / block.len() + 4) { + writer.write_all(&block).unwrap(); + } + let bytes = writer.finish().unwrap().into_inner(); + assert!(bytes.len() > MAX_ZIP_METADATA_READ_BYTES); + let root = fixture(); + extract_archive(&bytes, &root).unwrap(); + assert!(root.join(platform_binary_name()).is_file()); + fs::remove_dir_all(root).unwrap(); +}