Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/update-homebrew-cask.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions Casks/mouser.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 11 additions & 0 deletions readme_mac_osx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
148 changes: 148 additions & 0 deletions scripts/update_homebrew_cask.py
Original file line number Diff line number Diff line change
@@ -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", "<unnamed>") 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())