diff --git a/.github/workflows/update-homebrew-cask.yml b/.github/workflows/update-homebrew-cask.yml new file mode 100644 index 00000000..ddf7f4a8 --- /dev/null +++ b/.github/workflows/update-homebrew-cask.yml @@ -0,0 +1,64 @@ +name: Update Homebrew cask on release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to sync (defaults to latest release)" + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + update-cask: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Capture release payload + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euxo pipefail + if [ "${{ github.event_name }}" = "release" ]; then + cp "$GITHUB_EVENT_PATH" /tmp/mouser-release-event.json + elif [ -n "$INPUT_TAG" ]; then + python3 - <<'PY' > /tmp/mouser-release-event.json + import json, os + print(json.dumps({"tag_name": os.environ["INPUT_TAG"]})) + PY + fi + + - name: Update cask for the target release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euxo pipefail + if [ -f /tmp/mouser-release-event.json ]; then + python3 scripts/update_homebrew_cask.py --event-path /tmp/mouser-release-event.json + else + python3 scripts/update_homebrew_cask.py + fi + + - name: Create pull request if needed + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "ci: update Homebrew cask" + title: "ci: update Homebrew cask" + body: | + Updates the in-repo Homebrew cask to the latest published release. + branch: ci/update-homebrew-cask + delete-branch: true diff --git a/Casks/mouser.rb b/Casks/mouser.rb new file mode 100644 index 00000000..199b1553 --- /dev/null +++ b/Casks/mouser.rb @@ -0,0 +1,32 @@ +cask "mouser" do + arch arm: "", intel: "-intel" + + version "3.6.0" + sha256 :no_check + + url "https://github.com/TomBadash/Mouser/releases/download/v#{version}/Mouser-macOS#{arch}.zip", + verified: "github.com/TomBadash/Mouser/" + name "Mouser" + desc "Open-source Logitech mouse remapper" + homepage "https://github.com/TomBadash/Mouser" + + auto_updates true + depends_on macos: :monterey + + app "Mouser.app" + + zap trash: [ + "~/Library/Application Support/Mouser", + "~/Library/Caches/io.github.tombadash.mouser", + "~/Library/HTTPStorages/io.github.tombadash.mouser", + "~/Library/Preferences/io.github.tombadash.mouser.plist", + "~/Library/Saved Application State/io.github.tombadash.mouser.savedState", + ] + + caveats <<~EOS + Mouser needs Accessibility permission to intercept mouse events. + Open System Settings → Privacy & Security → Accessibility and enable Mouser.app. + + Logitech Options+ must not be running while Mouser is active. + EOS +end diff --git a/README.md b/README.md index 3a4f422b..4e4cea4b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,19 @@ Action labels adapt per platform. Windows exposes `Win+D` and `Task View`; macOS --- +## Homebrew (macOS) + +You can install Mouser from this repository with Homebrew Cask: + +```bash +brew tap TomBadash/Mouser +brew install --cask tombadash/mouser/mouser +``` + +The bundled tap is kept current automatically by an on-release workflow, and manual runs can target a specific tag. + +--- + ## Build from source You only need this if you want to hack on Mouser or run a development build. Most users should grab a release zip — see [Download & Run](#download--run). diff --git a/readme_mac_osx.md b/readme_mac_osx.md index 2d1557fe..e82c33b3 100644 --- a/readme_mac_osx.md +++ b/readme_mac_osx.md @@ -20,6 +20,17 @@ On macOS, this will also install: - `pyobjc-framework-Quartz` — for CGEventTap (mouse hooking) and CGEvent (key simulation) - `pyobjc-framework-Cocoa` — for NSWorkspace (app detection) and NSEvent (media keys) +## Homebrew Cask + +If you prefer Homebrew, you can install Mouser from this repository: + +```bash +brew tap TomBadash/Mouser +brew install --cask tombadash/mouser/mouser +``` + +The cask is kept current automatically by an on-release workflow, and maintainers can manually sync a specific tag when needed. + ## Granting Accessibility Permission Mouser uses a **CGEventTap** to intercept and suppress mouse button events. macOS requires Accessibility permission for this: diff --git a/scripts/update_homebrew_cask.py b/scripts/update_homebrew_cask.py new file mode 100644 index 00000000..c8ba3e5d --- /dev/null +++ b/scripts/update_homebrew_cask.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Update the Homebrew cask version from the latest Mouser GitHub release.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import urllib.error +import urllib.request +from pathlib import Path + +REPO = "TomBadash/Mouser" +API_LATEST_RELEASE = f"https://api.github.com/repos/{REPO}/releases/latest" +API_RELEASE_BY_TAG = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}" +ARM_ASSET = "Mouser-macOS.zip" +INTEL_ASSET = "Mouser-macOS-intel.zip" +CASK_PATH = Path("Casks/mouser.rb") + + +def request_json(url: str) -> dict: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "mouser-homebrew-cask-updater", + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def load_release(path: str | None) -> dict: + if path: + with open(path, encoding="utf-8") as file: + event = json.load(file) + release = event.get("release") if isinstance(event, dict) else None + if isinstance(release, dict) and release.get("tag_name"): + return release + if isinstance(event, dict) and event.get("tag_name"): + if event.get("assets"): + return event + return request_json(API_RELEASE_BY_TAG.format(tag=event["tag_name"])) + return request_json(API_LATEST_RELEASE) + + +def normalize_version(tag: str) -> str: + return tag.removeprefix("v") + + +def find_asset(release: dict, name: str) -> str: + for asset in release.get("assets", []): + if asset.get("name") == name: + url = asset.get("browser_download_url") + if url: + return url + available = ", ".join(sorted(a.get("name", "") for a in release.get("assets", []))) + raise SystemExit(f"Release {release.get('tag_name')} is missing {name}. Available assets: {available}") + + +def replace_once(pattern: str, replacement: str, text: str) -> str: + updated, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise SystemExit(f"Expected exactly one match for pattern: {pattern}") + return updated + + +def render_updated_cask(text: str, version: str) -> str: + return replace_once(r' version "[^"]+"', f' version "{version}"', text) + + +def update_cask(version: str) -> bool: + text = CASK_PATH.read_text(encoding="utf-8") + updated = render_updated_cask(text, version) + if updated == text: + return False + CASK_PATH.write_text(updated, encoding="utf-8") + return True + + +def validate_cask_text() -> None: + text = CASK_PATH.read_text(encoding="utf-8") + required_patterns = [ + r'cask "mouser" do', + r'arch arm: "", intel: "-intel"', + r'version "[^"]+"', + r'releases/download/v#\{version\}/Mouser-macOS#\{arch\}\.zip', + r'sha256 :no_check', + r'auto_updates true', + r'depends_on macos: :monterey', + r'app "Mouser\.app"', + ] + for pattern in required_patterns: + if not re.search(pattern, text): + raise SystemExit(f"Cask is missing expected pattern: {pattern}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--event-path", + default=os.environ.get("GITHUB_EVENT_PATH"), + help="Path to a GitHub release event payload. Falls back to the latest release API.", + ) + parser.add_argument("--check", action="store_true", help="Only report whether the cask is current.") + args = parser.parse_args() + + validate_cask_text() + try: + release = load_release(args.event_path) + except urllib.error.HTTPError as exc: + if exc.code == 403: + raise SystemExit( + "GitHub API rate limit exceeded while fetching release metadata. " + "Set GITHUB_TOKEN or pass --event-path with a saved release payload." + ) from exc + raise + tag = release.get("tag_name") + if not tag: + raise SystemExit("Release payload does not include tag_name") + + find_asset(release, ARM_ASSET) + find_asset(release, INTEL_ASSET) + + version = normalize_version(tag) + current = CASK_PATH.read_text(encoding="utf-8") + updated = render_updated_cask(current, version) + changed = updated != current + + if args.check: + if changed: + print(f"{CASK_PATH} is not current for {tag}") + return 1 + print(f"{CASK_PATH} is current for {tag}") + return 0 + + if changed: + CASK_PATH.write_text(updated, encoding="utf-8") + print(f"Updated {CASK_PATH} to {tag}") + else: + print(f"{CASK_PATH} is already current for {tag}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())