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
46 changes: 39 additions & 7 deletions build_macos_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,34 @@ SOURCE_ICON="$ROOT_DIR/images/logo_icon.png"
ENTITLEMENTS="$ROOT_DIR/build_resources/Mouser.entitlements"
TARGET_ARCH="${PYINSTALLER_TARGET_ARCH:-}"
SIGN_IDENTITY="${MOUSER_SIGN_IDENTITY:-}"
export PYINSTALLER_CONFIG_DIR="$BUILD_DIR/pyinstaller"
# ── Build artifact location ─────────────────────────────────────────
# codesign refuses to sign Mach-O files that carry a resource fork or
# Finder-info metadata, and SMB/AFP network volumes attach exactly that to
# files written to them. When the repository itself lives on such a volume,
# redirect every build artifact (PyInstaller cache, work dir, dist bundle) to
# local disk so the in-build and post-build signing steps see clean files.
# Set MOUSER_BUILD_DIR to choose the location explicitly.
if [[ -n "${MOUSER_BUILD_DIR:-}" ]]; then
BUILD_OUTPUT_DIR="$MOUSER_BUILD_DIR"
else
ROOT_DEVICE=""
if command -v df >/dev/null 2>&1 && command -v awk >/dev/null 2>&1; then
ROOT_DEVICE="$(df -P "$ROOT_DIR" 2>/dev/null | awk 'NR==2 {print $1}' 2>/dev/null || true)"
fi
# Empty (detection unavailable) or a /dev/* device → treat as local disk.
if [[ -z "$ROOT_DEVICE" || "$ROOT_DEVICE" == /dev/* ]]; then
BUILD_OUTPUT_DIR="$ROOT_DIR"
else
BUILD_OUTPUT_DIR="$HOME/Library/Caches/Mouser/macos-build"
echo "Repository volume ($ROOT_DEVICE) is not local disk;"
echo "building in $BUILD_OUTPUT_DIR to avoid codesign metadata errors."
fi
fi
DIST_DIR="$BUILD_OUTPUT_DIR/dist"
WORK_DIR="$BUILD_OUTPUT_DIR/build"
DIST_APP="$DIST_DIR/Mouser.app"
mkdir -p "$DIST_DIR" "$WORK_DIR"
export PYINSTALLER_CONFIG_DIR="$BUILD_OUTPUT_DIR/pyinstaller"
PYTHON=""
PYTHON_SOURCE=""

Expand Down Expand Up @@ -148,20 +175,25 @@ log_python_provenance() {
run_pyinstaller() {
# PYTHONHASHSEED=0 pins set iteration so PyInstaller's base_library.zip
# layout is byte-identical across rebuilds for the same toolchain inputs.
PYTHONHASHSEED=0 "$PYTHON" -m PyInstaller "$ROOT_DIR/Mouser-mac.spec" --noconfirm
# Run inside ROOT_DIR so the spec's `ROOT = abspath(".")` resolves to the
# repo no matter where this script was invoked from, and pin work/dist
# paths to BUILD_OUTPUT_DIR (kept on local disk for network repos).
( cd "$ROOT_DIR" && PYTHONHASHSEED=0 "$PYTHON" -m PyInstaller \
"$ROOT_DIR/Mouser-mac.spec" --noconfirm \
--workpath "$WORK_DIR" --distpath "$DIST_DIR" )
}

sign_ad_hoc() {
echo "Signing mode: ad-hoc"
codesign --force --deep --sign - "$ROOT_DIR/dist/Mouser.app"
codesign --force --deep --sign - "$DIST_APP"
}

entitlements_sha256() {
shasum -a 256 "$ENTITLEMENTS" | awk '{print $1}'
}

sign_nested_code() {
local frameworks_dir="$ROOT_DIR/dist/Mouser.app/Contents/Frameworks"
local frameworks_dir="$DIST_APP/Contents/Frameworks"
[[ -d "$frameworks_dir" ]] || return 0

while IFS= read -r -d '' nested; do
Expand All @@ -173,7 +205,7 @@ sign_nested_code() {
}

verify_bundle() {
codesign --verify --deep --strict --verbose=2 "$ROOT_DIR/dist/Mouser.app"
codesign --verify --deep --strict --verbose=2 "$DIST_APP"
}

sign_with_identity() {
Expand All @@ -188,7 +220,7 @@ sign_with_identity() {
codesign --force --options runtime --timestamp=none \
--entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" \
"$ROOT_DIR/dist/Mouser.app"
"$DIST_APP"
verify_bundle
}

Expand All @@ -211,4 +243,4 @@ log_python_provenance
run_pyinstaller
sign_app

echo "Build complete: $ROOT_DIR/dist/Mouser.app"
echo "Build complete: $DIST_APP"
85 changes: 72 additions & 13 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
else:
CONFIG_DIR = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "Mouser")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
# Last-good HID++ device identity, written by hid_gesture on every successful
# connect so the next launch can skip the slow multi-receiver device scan.
DEVICE_CACHE_FILE = os.path.join(CONFIG_DIR, "device_cache.json")

# Which mouse events map to which friendly button names
# Order matches the Logi Options+ diagram (top view then side view)
Expand All @@ -42,6 +45,22 @@
"gesture_down",
)

# Swipe sensitivity presets — px of travel the recognizer needs to commit a
# swipe (stored as `gesture_threshold`). Ordered most → least sensitive; the
# UI exposes these as a discrete "Sensitivity" selector rather than a raw px
# slider. A lower value fires on shorter strokes.
GESTURE_SENSITIVITY_PX = (18, 25, 33, 44, 56)
GESTURE_DEFAULT_SENSITIVITY_INDEX = 1 # 25 px — leans toward the sensitive end


def gesture_sensitivity_index_for(threshold_px):
"""Return the sensitivity preset index nearest to a stored px threshold."""
return min(
range(len(GESTURE_SENSITIVITY_PX)),
key=lambda i: abs(GESTURE_SENSITIVITY_PX[i] - int(threshold_px)),
)


PROFILE_BUTTON_NAMES = {
**BUTTON_NAMES,
"gesture_left": "Gesture swipe left",
Expand All @@ -67,7 +86,7 @@
}

DEFAULT_CONFIG = {
"version": 9,
"version": 10,
"active_profile": "default",
"profiles": {
"default": {
Expand Down Expand Up @@ -98,10 +117,12 @@
"smart_shift_mode": "ratchet",
"smart_shift_enabled": False,
"smart_shift_threshold": 25,
"gesture_threshold": 50,
"gesture_deadzone": 40,
"gesture_timeout_ms": 3000,
"gesture_cooldown_ms": 500,
# px of travel along the swipe axis; UI sets this via the discrete
# GESTURE_SENSITIVITY_PX presets ("Sensitivity" selector).
"gesture_threshold": GESTURE_SENSITIVITY_PX[GESTURE_DEFAULT_SENSITIVITY_INDEX],
"gesture_commit_window_ms": 400, # that travel must finish this fast
"gesture_settle_ms": 90, # pause that ends a stroke
"gesture_cross_ratio": 0.5, # tolerated off-axis travel ratio
"appearance_mode": "system",
"debug_mode": False,
"device_layout_overrides": {},
Expand Down Expand Up @@ -173,18 +194,20 @@ def load_config():
return json.loads(json.dumps(DEFAULT_CONFIG)) # deep copy


def save_config(cfg):
"""Persist config to disk via atomic write with restrictive permissions."""
def _atomic_write_json(path, obj):
"""Write obj to path as JSON via a temp file plus atomic rename, with
restrictive (owner-only) permissions. Used for config.json and the HID
device cache so a crash mid-write can never leave a partial file."""
ensure_config_dir()
fd, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=CONFIG_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
json.dump(obj, f, indent=2)
f.flush()
os.fsync(f.fileno())
if sys.platform != "win32":
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
os.replace(tmp_path, CONFIG_FILE)
os.replace(tmp_path, path)
except BaseException:
try:
os.unlink(tmp_path)
Expand All @@ -193,6 +216,31 @@ def save_config(cfg):
raise


def save_config(cfg):
"""Persist config to disk via atomic write with restrictive permissions."""
_atomic_write_json(CONFIG_FILE, cfg)


def load_device_cache():
"""Return the last-good HID++ device identity dict, or None.

Lets hid_gesture._try_connect re-open the device that connected last time
before falling back to a full multi-receiver scan — the difference between
a ~1 s reconnect and a multi-second one.
"""
try:
with open(DEVICE_CACHE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None


def save_device_cache(identity):
"""Persist the last-good HID++ device identity (atomic write)."""
_atomic_write_json(DEVICE_CACHE_FILE, identity)


def get_active_mappings(cfg):
"""Return the mappings dict for the currently active profile."""
profile_name = cfg.get("active_profile", "default")
Expand Down Expand Up @@ -272,10 +320,10 @@ def _migrate(cfg):

if version < 3:
settings = cfg.setdefault("settings", {})
settings.setdefault("gesture_threshold", 50)
settings.setdefault("gesture_deadzone", 40)
settings.setdefault("gesture_timeout_ms", 3000)
settings.setdefault("gesture_cooldown_ms", 500)
settings.setdefault(
"gesture_threshold",
GESTURE_SENSITIVITY_PX[GESTURE_DEFAULT_SENSITIVITY_INDEX],
)
for pdata in cfg.get("profiles", {}).values():
mappings = pdata.setdefault("mappings", {})
mappings.setdefault("gesture", "none")
Expand Down Expand Up @@ -329,6 +377,17 @@ def _migrate(cfg):
settings.setdefault("ignore_trackpad", True)
cfg["version"] = 9

if version < 10:
# Gesture swipe detection was rewritten: the fixed-pixel-threshold
# accumulator (gesture_deadzone / gesture_timeout_ms /
# gesture_cooldown_ms) is replaced by a stroke-aware recognizer.
# The obsolete keys are left in place but ignored.
settings = cfg.setdefault("settings", {})
settings.setdefault("gesture_commit_window_ms", 400)
settings.setdefault("gesture_settle_ms", 90)
settings.setdefault("gesture_cross_ratio", 0.5)
cfg["version"] = 10

cfg.setdefault("settings", {})
cfg["settings"].setdefault("appearance_mode", "system")
cfg["settings"].setdefault("debug_mode", False)
Expand Down
6 changes: 3 additions & 3 deletions core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,9 @@ def _setup_hooks(self):
enabled=any(mappings.get(key, "none") != "none"
for key in GESTURE_DIRECTION_BUTTONS),
threshold=settings.get("gesture_threshold", 50),
deadzone=settings.get("gesture_deadzone", 40),
timeout_ms=settings.get("gesture_timeout_ms", 3000),
cooldown_ms=settings.get("gesture_cooldown_ms", 500),
commit_window_ms=settings.get("gesture_commit_window_ms", 400),
settle_ms=settings.get("gesture_settle_ms", 90),
cross_ratio=settings.get("gesture_cross_ratio", 0.5),
)
# Divert mode shift CID only when the device has the button and
# at least one profile maps it to an action. When no device is
Expand Down
Loading