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"""
+ ___ __ __
+ / _ \ _ __ ___ _ _ | \/ |___ _ _ ___ ___
+| (_) | '_ \/ -_) ' \| |\/| / _ \ || (_- -_)
+ \___/| .__/\___|_||_|_| |_\___/\_,_/__/\___|
+ |_|
+""".strip("\n")
+
+MONO = "/System/Library/Fonts/Menlo.ttc"
+DIM = (62, 70, 80)
+TEXT = (184, 191, 201)
+
+
+def background(scale):
+ width, height = WINDOW[0] * scale, WINDOW[1] * scale
+ image = Image.new("RGB", (width, height))
+ draw = ImageDraw.Draw(image)
+ for y in range(height):
+ t = y / (height - 1)
+ draw.line([(0, y), (width, y)], fill=(int(14 - 6 * t), int(16 - 7 * t), int(20 - 9 * t)))
+
+ def mono(px):
+ return ImageFont.truetype(MONO, int(px * scale))
+
+ def text_width(text, f):
+ return draw.textlength(text, font=f)
+
+ def centered(text, y, f, fill):
+ draw.text(((width - text_width(text, f)) / 2, y * scale), text, font=f, fill=fill)
+
+ # Frame rules.
+ rule = mono(10)
+ cols = int(width / text_width("-", rule)) - 2
+ border = "+" + "-" * cols + "+"
+ centered(border, 10, rule, DIM)
+ centered(border, 380, rule, DIM)
+
+ # Banner, drawn line by line so the glyph grid stays aligned.
+ f = mono(12)
+ lines = BANNER.split("\n")
+ block = max(text_width(line, f) for line in lines)
+ x = (width - block) / 2
+ for i, line in enumerate(lines):
+ draw.text((x, (30 + i * 14.5) * scale), line, font=f, fill=GREEN)
+ centered("B R I D G E // HID COMPANION", 110, mono(10), DIM)
+
+ # Arrow: chevrons brightening toward the Applications icon.
+ f = mono(26)
+ chevron = text_width(">", 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();
+}