diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..9dc27a0 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,82 @@ +# .github/release-drafter.yml +# +# Configuration for release-drafter/release-drafter. +# Generates release notes from merged PRs (with contributors) and conventional +# commits that are not part of a merged PR. + +name-template: 'Mouser $RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' + +# ── Category mapping (by PR label) ──────────────────────────────────────────── +categories: + - title: '🚀 Features' + labels: + - 'feature' + - 'enhancement' + - 'feat' + - title: '🐛 Bug Fixes' + labels: + - 'bug' + - 'bugfix' + - 'fix' + - title: '📝 Documentation' + labels: + - 'documentation' + - 'docs' + - title: '⚡ Performance' + labels: + - 'performance' + - 'perf' + - title: '🔧 Maintenance' + labels: + - 'chore' + - 'maintenance' + - 'refactor' + - 'ci' + - 'build' + - title: '🔒 Security' + labels: + - 'security' + +# ── Autolabeler: map conventional-commit prefixes in PR titles to labels ────── +autolabeler: + - label: 'feature' + title: + - '/^feat(\(.+\))?:/i' + - label: 'fix' + title: + - '/^fix(\(.+\))?:/i' + - label: 'docs' + title: + - '/^docs(\(.+\))?:/i' + - label: 'perf' + title: + - '/^perf(\(.+\))?:/i' + - label: 'chore' + title: + - '/^(chore|refactor|ci|build|style|test)(\(.+\))?:/i' + +# ── Version resolver (used when release-drafter is kept as a draft) ─────────── +version-resolver: + major: + labels: ['major', 'breaking'] + minor: + labels: ['minor', 'feature', 'enhancement', 'feat'] + patch: + labels: ['patch', 'bug', 'bugfix', 'fix', 'chore'] + default: patch + +# ── Templates ───────────────────────────────────────────────────────────────── +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' + +template: | + ## What's Changed + + $CHANGES + + $CONTRIBUTORS + + **Full Changelog**: $PREVIOUS_TAG...$TAG + +no-changes-template: 'No significant changes in this release.' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 196cd2a..e7471a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,19 +4,119 @@ on: push: tags: ["v*"] workflow_dispatch: + inputs: + bump: + description: 'Version bump type (ignored when triggered by a tag push)' + required: true + type: choice + options: [none, patch, minor, major] + default: patch + release_on_github: + description: 'Create / update GitHub release (false = build artifacts only)' + required: true + type: boolean + default: true + +# One release pipeline per tag to prevent duplicate runs. +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false permissions: contents: write -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - MOUSER_VERSION: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }} - MOUSER_GIT_COMMIT: ${{ github.sha }} - MOUSER_GIT_DIRTY: "false" - jobs: + # ── Determine version ──────────────────────────────────────────────────────── + # Skip tag-push events that this workflow itself creates (loop prevention). + prepare: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.actor != 'github-actions[bot]') + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + tag: ${{ steps.ver.outputs.tag }} + should_release: ${{ steps.ver.outputs.should_release }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + # Use a PAT or the default token — PAT required only if branch protection + # prevents github-actions[bot] from pushing to the default branch. + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute version (and bump if requested) + id: ver + shell: bash + run: | + # ── Read current version from version.py ────────────────────────── + CURRENT=$(python3 - <<'PY' + with open("core/version.py") as f: + for line in f: + if "_DEFAULT_APP_VERSION" in line and "=" in line: + v = line.split("=", 1)[1].strip().strip('"').strip("'") + print(v) + break + PY + ) + echo "Current version in version.py: ${CURRENT}" + + if [[ "${{ github.event_name }}" == "push" ]]; then + # ── Tag-push path: use the tag (strip leading 'v') ─────────────── + TAG="${{ github.ref_name }}" + VERSION="${TAG#v}" + SHOULD_RELEASE="true" + else + # ── workflow_dispatch path ──────────────────────────────────────── + BUMP="${{ inputs.bump }}" + SHOULD_RELEASE="${{ inputs.release_on_github }}" + + if [[ "$BUMP" != "none" ]]; then + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + case "$BUMP" in + major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR+1)); PATCH=0 ;; + patch) PATCH=$((PATCH+1)) ;; + esac + VERSION="${MAJOR}.${MINOR}.${PATCH}" + + # Update version.py in-place + sed -i "s/_DEFAULT_APP_VERSION = \"${CURRENT}\"/_DEFAULT_APP_VERSION = \"${VERSION}\"/" core/version.py + echo "Bumped version.py: ${CURRENT} → ${VERSION}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add core/version.py + git commit -m "chore: bump version to ${VERSION} [skip ci]" + git push origin HEAD + else + VERSION="$CURRENT" + fi + + TAG="v${VERSION}" + + # Push the tag so release-drafter can resolve the correct commit range + # and so the release step can reference it. The resulting push:tags event + # is skipped above because actor == github-actions[bot]. + if [[ "$SHOULD_RELEASE" == "true" ]]; then + git tag "$TAG" || true # no-op if it already exists + git push origin "$TAG" || true + fi + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "should_release=${SHOULD_RELEASE}" >> "$GITHUB_OUTPUT" + + # ── Platform builds (all depend on prepare) ────────────────────────────────── build-windows: + needs: prepare runs-on: windows-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + MOUSER_VERSION: ${{ needs.prepare.outputs.version }} + MOUSER_GIT_COMMIT: ${{ github.sha }} + MOUSER_GIT_DIRTY: "false" steps: - uses: actions/checkout@v6 @@ -27,6 +127,9 @@ jobs: - name: Install dependencies run: pip install -r requirements.txt + - name: Install UPX + run: choco install upx --no-progress -y + - name: Build with PyInstaller run: pyinstaller Mouser.spec --noconfirm @@ -39,6 +142,7 @@ jobs: path: Mouser-Windows.zip build-macos: + needs: prepare name: build-macos (${{ matrix.name }}) strategy: fail-fast: false @@ -57,6 +161,11 @@ jobs: artifact_name: Mouser-macOS-intel archive_name: Mouser-macOS-intel.zip runs-on: ${{ matrix.runner }} + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + MOUSER_VERSION: ${{ needs.prepare.outputs.version }} + MOUSER_GIT_COMMIT: ${{ github.sha }} + MOUSER_GIT_DIRTY: "false" steps: - uses: actions/checkout@v6 @@ -84,7 +193,13 @@ jobs: path: ${{ matrix.archive_name }} build-linux: + needs: prepare runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + MOUSER_VERSION: ${{ needs.prepare.outputs.version }} + MOUSER_GIT_COMMIT: ${{ github.sha }} + MOUSER_GIT_DIRTY: "false" steps: - uses: actions/checkout@v6 @@ -93,7 +208,7 @@ jobs: python-version: "3.12" - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libhidapi-dev + run: sudo apt-get update && sudo apt-get install -y libhidapi-dev dpkg-dev imagemagick upx-ucl - name: Install dependencies run: pip install -r requirements.txt @@ -101,41 +216,113 @@ jobs: - name: Build with PyInstaller run: pyinstaller Mouser-linux.spec --noconfirm - - name: Create archive + - name: Create zip archive run: cd dist && zip -r -y ../Mouser-Linux.zip Mouser + - name: Build .deb package + run: | + chmod +x build_deb.sh + ./build_deb.sh + - uses: actions/upload-artifact@v7 with: name: Mouser-Linux path: Mouser-Linux.zip + - uses: actions/upload-artifact@v7 + with: + name: Mouser-Linux-deb + path: dist/Mouser-Linux.deb + + # ── Create GitHub release (conditional) ────────────────────────────────────── release: - if: startsWith(github.ref, 'refs/tags/') - needs: [build-windows, build-macos, build-linux] + if: needs.prepare.outputs.should_release == 'true' + needs: [prepare, build-windows, build-macos, build-linux] runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/download-artifact@v8 + - name: Generate latest.json + shell: bash + run: | + VERSION="${{ needs.prepare.outputs.version }}" + TAG="${{ needs.prepare.outputs.tag }}" + REPO="${{ github.repository }}" + BASE_URL="https://github.com/${REPO}/releases/download/latest" + # Build JSON with jq to avoid shell-variable injection in Python + jq -n \ + --arg version "$VERSION" \ + --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg win "${BASE_URL}/Mouser-Windows.zip" \ + --arg mac_arm "${BASE_URL}/Mouser-macOS.zip" \ + --arg mac_x64 "${BASE_URL}/Mouser-macOS-intel.zip" \ + --arg linux_deb "${BASE_URL}/Mouser-Linux.deb" \ + --arg linux_zip "${BASE_URL}/Mouser-Linux.zip" \ + '{ + version: $version, + date: $date, + downloads: { + "windows-x64": $win, + "macos-arm64": $mac_arm, + "macos-x86_64": $mac_x64, + "linux-deb": $linux_deb, + "linux-zip": $linux_zip + } + }' | tee latest.json + + # Generate release notes from merged PRs (with contributors) and any + # conventional commits that are not part of a merged PR. + - name: Generate changelog + id: changelog + uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + config-name: release-drafter.yml + tag: ${{ needs.prepare.outputs.tag }} + name: "Mouser ${{ needs.prepare.outputs.tag }}" + # publish=false: only draft so we can read the body; we publish via gh below + publish: false + disable-autolabeler: true + - name: Create or update GitHub Release env: GH_TOKEN: ${{ github.token }} + shell: bash run: | - if gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" > /dev/null 2>&1; then - # Release already exists — just upload the assets - gh release upload "${{ github.ref_name }}" \ - --repo "${{ github.repository }}" \ - --clobber \ - Mouser-Windows/Mouser-Windows.zip \ - Mouser-macOS/Mouser-macOS.zip \ - Mouser-macOS-intel/Mouser-macOS-intel.zip \ - Mouser-Linux/Mouser-Linux.zip + TAG="${{ needs.prepare.outputs.tag }}" + REPO="${{ github.repository }}" + NOTES_FILE="$(mktemp)" + cat > "$NOTES_FILE" <<'NOTES' + ${{ steps.changelog.outputs.body }} + NOTES + + ASSETS=( + "Mouser-Windows/Mouser-Windows.zip" + "Mouser-macOS/Mouser-macOS.zip" + "Mouser-macOS-intel/Mouser-macOS-intel.zip" + "Mouser-Linux/Mouser-Linux.zip" + "Mouser-Linux-deb/Mouser-Linux.deb" + "latest.json" + ) + + if gh release view "$TAG" --repo "$REPO" > /dev/null 2>&1; then + gh release upload "$TAG" --repo "$REPO" --clobber "${ASSETS[@]}" + gh release edit "$TAG" --repo "$REPO" \ + --title "Mouser ${TAG}" \ + --notes-file "$NOTES_FILE" \ + --draft=false else - gh release create "${{ github.ref_name }}" \ - --repo "${{ github.repository }}" \ - --title "Mouser ${{ github.ref_name }}" \ - --generate-notes \ - Mouser-Windows/Mouser-Windows.zip \ - Mouser-macOS/Mouser-macOS.zip \ - Mouser-macOS-intel/Mouser-macOS-intel.zip \ - Mouser-Linux/Mouser-Linux.zip + gh release create "$TAG" \ + --repo "$REPO" \ + --title "Mouser ${TAG}" \ + --notes-file "$NOTES_FILE" \ + "${ASSETS[@]}" fi + diff --git a/Mouser-linux.spec b/Mouser-linux.spec index c7f471d..938d86c 100644 --- a/Mouser-linux.spec +++ b/Mouser-linux.spec @@ -1,114 +1,143 @@ # -*- mode: python ; coding: utf-8 -*- -""" -PyInstaller spec for building a portable Linux distribution. - -Run: - python3 -m PyInstaller Mouser-linux.spec --noconfirm - -Output: dist/Mouser/ (directory with Mouser executable + dependencies) -""" import os import json import subprocess +import sysconfig +import shutil +from PySide6 import QtCore ROOT = os.path.abspath(".") +DIST_DIR = os.path.join(ROOT, "dist", "Mouser") BUILD_INFO_PATH = os.path.join(ROOT, "build", "mouser_build_info.json") -def _load_app_version() -> str: +# ========================= +# BUILD INFO +# ========================= + +def _load_app_version(): version_path = os.path.join(ROOT, "core", "version.py") - namespace = {"__file__": version_path} - with open(version_path, encoding="utf-8") as version_file: - exec(version_file.read(), namespace) - return namespace["APP_VERSION"] - - -def _run_git(args): - try: - return subprocess.check_output( - ["git", *args], - cwd=ROOT, - stderr=subprocess.DEVNULL, - text=True, - timeout=0.5, - ).strip() - except (OSError, subprocess.SubprocessError): - return "" - - -def _git_dirty(): - try: - result = subprocess.run( - ["git", "status", "--porcelain", "--untracked-files=no"], - cwd=ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - timeout=0.5, - check=False, - ) - except (OSError, subprocess.SubprocessError): - return False - return result.returncode == 0 and bool(result.stdout.strip()) - - -def _write_build_info(version: str) -> str: - commit = os.environ.get("MOUSER_GIT_COMMIT", "").strip() or _run_git(["rev-parse", "HEAD"]) - dirty_env = os.environ.get("MOUSER_GIT_DIRTY") - if dirty_env: - dirty = dirty_env.strip().lower() in {"1", "true", "yes", "on"} - else: - dirty = _git_dirty() + ns = {"__file__": version_path} + with open(version_path, encoding="utf-8") as f: + exec(f.read(), ns) + return ns["APP_VERSION"] + +def _write_build_info(v): os.makedirs(os.path.dirname(BUILD_INFO_PATH), exist_ok=True) - with open(BUILD_INFO_PATH, "w", encoding="utf-8") as build_info_file: - json.dump( - { - "version": version, - "commit": commit, - "dirty": dirty, - }, - build_info_file, - ) + with open(BUILD_INFO_PATH, "w", encoding="utf-8") as f: + json.dump({"version": v}, f) return BUILD_INFO_PATH APP_VERSION = _load_app_version() BUILD_INFO_DATA = _write_build_info(APP_VERSION) + +# ========================= +# PYTHON LIB +# ========================= + +libpython_path = os.path.join( + sysconfig.get_config_var("LIBDIR"), + sysconfig.get_config_var("INSTSONAME"), +) + + +# ========================= +# QT PATHS +# ========================= + +qt_lib_path = QtCore.QLibraryInfo.path(QtCore.QLibraryInfo.LibrariesPath) +qt_plugin_path = QtCore.QLibraryInfo.path(QtCore.QLibraryInfo.PluginsPath) + + +# ========================= +# ICU DETECTION (DYNAMIC) +# ========================= + +def find_icu_lib(name): + for f in os.listdir(qt_lib_path): + if f.startswith(name): + return os.path.join(qt_lib_path, f) + raise RuntimeError(f"Missing ICU lib: {name}") + + +icu_libs = [ + (find_icu_lib("libicudata"), "."), + (find_icu_lib("libicuuc"), "."), + (find_icu_lib("libicui18n"), "."), +] + + +# ========================= +# MINIMAL QT LIBS +# ========================= + +qt_binaries = [ + (os.path.join(qt_lib_path, "libQt6Core.so.6"), "."), + (os.path.join(qt_lib_path, "libQt6Gui.so.6"), "."), + (os.path.join(qt_lib_path, "libQt6Qml.so.6"), "."), + (os.path.join(qt_lib_path, "libQt6Quick.so.6"), "."), + (os.path.join(qt_lib_path, "libQt6Network.so.6"), "."), + + *icu_libs, + + (libpython_path, "."), +] + + +# ========================= +# MINIMAL QT PLUGINS +# ========================= + +qt_plugins = [ + (os.path.join(qt_plugin_path, "platforms"), "platforms"), + (os.path.join(qt_plugin_path, "imageformats"), "imageformats"), +] + + +# ========================= +# ANALYSIS (NO QT AUTO EXPANSION) +# ========================= + a = Analysis( ["main_qml.py"], pathex=[ROOT], - binaries=[], + + binaries=qt_binaries + qt_plugins, + datas=[ - (os.path.join(ROOT, "ui", "qml"), os.path.join("ui", "qml")), + (os.path.join(ROOT, "ui/qml"), "ui/qml"), (os.path.join(ROOT, "images"), "images"), (BUILD_INFO_DATA, "."), ], + hiddenimports=[ - "hid", - "logging.handlers", - "evdev", - "ui.locale_manager", - "PySide6.QtQuick", - "PySide6.QtQuickControls2", + "PySide6.QtCore", + "PySide6.QtGui", "PySide6.QtQml", + "PySide6.QtQuick", "PySide6.QtNetwork", - "PySide6.QtOpenGL", - "PySide6.QtSvg", + "PySide6.QtQuickControls2", ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], + + # disable PySide6 auto collection + hooksconfig={ + "PySide6": { + "exclude_qml": True, + "exclude_plugins": True, + } + }, + excludes=[ - # Trim PySide6 modules the app does not import at runtime. "PySide6.QtWebEngine", "PySide6.QtWebEngineCore", "PySide6.QtWebEngineWidgets", - "PySide6.QtWebChannel", - "PySide6.QtWebSockets", + "PySide6.QtMultimedia", "PySide6.Qt3DCore", + "PySide6.QtQuick3D", "PySide6.Qt3DRender", "PySide6.Qt3DInput", "PySide6.Qt3DLogic", @@ -159,141 +188,109 @@ a = Analysis( "idlelib", "turtledemo", "turtle", - "sqlite3", - "multiprocessing", ], + noarchive=False, ) -# Keep only the Qt runtime pieces Mouser actually uses. The negative-match -# approach still let large transitive Qt payload through on Linux. -QT_KEEP = { - "Qt6Core", - "Qt6Gui", - "Qt6Widgets", - "Qt6Network", - "Qt6OpenGL", - "Qt6Qml", - "Qt6QmlCore", - "Qt6QmlMeta", - "Qt6QmlModels", - "Qt6QmlNetwork", - "Qt6QmlWorkerScript", - "Qt6Quick", - "Qt6QuickControls2", - "Qt6QuickControls2Impl", - "Qt6QuickControls2Basic", - "Qt6QuickControls2BasicStyleImpl", - "Qt6QuickControls2Material", - "Qt6QuickControls2MaterialStyleImpl", - "Qt6QuickTemplates2", - "Qt6QuickLayouts", - "Qt6QuickEffects", - "Qt6QuickShapes", - "Qt6ShaderTools", - "Qt6Svg", - "pyside6", - "pyside6qml", - "shiboken6", -} - -KEEP_PLUGIN_DIRS = { - "platforms", - "imageformats", - "styles", - "iconengines", - "platforminputcontexts", - "xcbglintegrations", - "platformthemes", - "tls", - "egldeviceintegrations", - "networkinformation", - "generic", - "wayland-decoration-client", - "wayland-graphics-integration-client", - "wayland-shell-integration", -} - -KEEP_QML_TOP = {"QtCore", "QtQml", "QtQuick", "QtNetwork"} -KEEP_QTQUICK = {"Controls", "Layouts", "Templates", "Window"} - - -def normalized_stem(path): - base = os.path.basename(path) - if ".so" in base: - return base.split(".so", 1)[0].removeprefix("lib") - stem = os.path.splitext(base)[0] - if stem.endswith(".abi3"): - stem = stem[:-5] - return stem - - -def should_keep(path): - normalized = path.replace("\\", "/") - lower = normalized.lower() - - if "PySide6" not in normalized and "pyside6" not in lower: - return True - - stem = normalized_stem(normalized) - if stem in QT_KEEP: - return True - - base = os.path.basename(normalized) - if base.endswith(".abi3.so"): - return True - - plugin_marker = "/plugins/" - plugin_index = lower.find(plugin_marker) - if plugin_index != -1: - plugin_path = normalized[plugin_index + len(plugin_marker) :] - plugin_dir = plugin_path.split("/", 1)[0] - return plugin_dir in KEEP_PLUGIN_DIRS and base != "libqpdf.so" - - qml_marker = "/qml/" - qml_index = lower.find(qml_marker) - if qml_index != -1: - qml_path = normalized[qml_index + len(qml_marker) :] - parts = [part for part in qml_path.split("/") if part] - if not parts: - return True - if parts[0] not in KEEP_QML_TOP: - return False - if parts[0] == "QtQuick" and len(parts) > 1 and parts[1] not in KEEP_QTQUICK: - return False - style_parts = {part.lower() for part in parts} - if style_parts & {"fusion", "imagine", "universal", "fluentwinui3", "ios", "macos"}: - return False - return True - - return False - - -a.binaries = [b for b in a.binaries if should_keep(b[0])] -a.datas = [d for d in a.datas if should_keep(d[0])] pyz = PYZ(a.pure) + exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name="Mouser", - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=False, console=False, + upx=False, + upx_exclude=[ + # Qt shared libraries use mmap-based resource loading; compressing + # them with UPX can break resource access at runtime. + "libQt6*.so*", + # ICU and Python shared libs — large and sensitive to UPX rewriting. + "libicu*.so*", + "libpython*.so*", + ], ) + coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, - strip=False, - upx=False, - upx_exclude=[], name="Mouser", + upx=False, + upx_exclude=[ + "libQt6*.so*", + "libicu*.so*", + "libpython*.so*", + ], ) + + +# ========================= +# PRUNE UNUSED FILES +# ========================= + +def prune(): + if not os.path.exists(DIST_DIR): + return + + print("==> Pruning build...") + + for root, dirs, files in os.walk(DIST_DIR, topdown=False): + + for f in files: + p = os.path.join(root, f) + lower = f.lower() + + # remove Qt junk + if any(x in lower for x in [ + "webengine", "pdf", "multimedia", + "3d", "charts" + ]): + os.remove(p) + continue + + for d in dirs: + dp = os.path.join(root, d) + + if any(x in d.lower() for x in [ + "webengine", + "translations", + "examples", + ]): + shutil.rmtree(dp, ignore_errors=True) + + +prune() + + +# ========================= +# DEBUG: BIG FILES +# ========================= + +def print_big_files(): + if not os.path.exists(DIST_DIR): + return + + files = [] + for r, _, f in os.walk(DIST_DIR): + for x in f: + p = os.path.join(r, x) + try: + files.append((os.path.getsize(p), p)) + except: + pass + + files.sort(reverse=True) + + print("\n== TOP 30 FILES ==") + for s, p in files[:30]: + print(f"{s/1024/1024:.2f} MB {p}") + + +print_big_files() \ No newline at end of file diff --git a/Mouser.spec b/Mouser.spec index 0619a44..ba1e184 100644 --- a/Mouser.spec +++ b/Mouser.spec @@ -237,7 +237,17 @@ exe = EXE( debug=False, bootloader_ignore_signals=False, strip=False, - upx=False, # UPX OFF — decompression at startup is very slow + upx=True, # compress main executable + upx_exclude=[ + # Qt DLLs use mmap-based resource loading; compressing them can + # break resource access and slow startup significantly. + "Qt6*.dll", + # Python runtime + "python3*.dll", + # VC runtime + "MSVCP*.dll", + "VCRUNTIME*.dll", + ], console=False, # windowed app (no terminal) icon=os.path.join(ROOT, "images", "logo.ico"), uac_admin=False, # does NOT require admin @@ -249,8 +259,16 @@ coll = COLLECT( a.zipfiles, a.datas, strip=False, - upx=False, # UPX OFF — faster cold start - upx_exclude=[], + upx=True, # compress collected binaries where safe + upx_exclude=[ + # Qt DLLs — mmap-based resource loading, risky to UPX-compress + "Qt6*.dll", + # Python runtime + "python3*.dll", + # VC runtime DLLs + "MSVCP*.dll", + "VCRUNTIME*.dll", + ], name="Mouser", ) diff --git a/build_deb.sh b/build_deb.sh new file mode 100755 index 0000000..a7f4a62 --- /dev/null +++ b/build_deb.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# build_deb.sh — Build a Debian (.deb) package from the PyInstaller dist output. +# +# Usage: +# ./build_deb.sh [--version VERSION] [--arch ARCH] +# +# Prerequisites: +# • PyInstaller dist already built at dist/Mouser/ (run build with +# `python -m PyInstaller Mouser-linux.spec --noconfirm` first) +# • dpkg-deb available (from the `dpkg-dev` or `dpkg` package) +# +# Output: dist/Mouser-Linux.deb + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ── Parameters ──────────────────────────────────────────────────────────────── +VERSION="${MOUSER_VERSION:-}" +ARCH="${MOUSER_ARCH:-amd64}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# Read version from core/version.py if not set externally +if [[ -z "$VERSION" ]]; then + VERSION=$(python3 -c " +import sys, os +sys.path.insert(0, '.') +# Avoid importing the full module (needs PySide6); just parse the string. +with open('core/version.py') as f: + for line in f: + if line.strip().startswith('_DEFAULT_APP_VERSION'): + VERSION = line.split('=')[1].strip().strip('\"').strip(\"'\") + print(VERSION) + break +") +fi + +if [[ -z "$VERSION" ]]; then + echo "ERROR: Could not determine application version." + exit 1 +fi + +APP_NAME="mouser" +APP_DISPLAY="Mouser" +DIST_DIR="$SCRIPT_DIR/dist/Mouser" +DEB_STAGE="$SCRIPT_DIR/build/deb_stage" +DEB_OUT="$SCRIPT_DIR/dist/Mouser-Linux.deb" +INSTALL_PREFIX="/opt/mouser" +DESKTOP_DIR="/usr/share/applications" +ICONS_DIR="/usr/share/icons/hicolor" + +echo "==> Building .deb for Mouser v${VERSION} (${ARCH})" + +# ── Sanity checks ───────────────────────────────────────────────────────────── +if [[ ! -d "$DIST_DIR" ]]; then + echo "ERROR: dist/Mouser/ not found. Run the PyInstaller build first:" + echo " python3 -m PyInstaller Mouser-linux.spec --noconfirm" + exit 1 +fi + +if ! command -v dpkg-deb &>/dev/null; then + echo "ERROR: dpkg-deb not found. Install it with: sudo apt install dpkg-dev" + exit 1 +fi + +# ── Stage directory ─────────────────────────────────────────────────────────── +rm -rf "$DEB_STAGE" +mkdir -p "$DEB_STAGE/DEBIAN" +mkdir -p "$DEB_STAGE${INSTALL_PREFIX}" +mkdir -p "$DEB_STAGE${DESKTOP_DIR}" + +# ── Copy application files ──────────────────────────────────────────────────── +echo " Copying application files…" +cp -a "$DIST_DIR/." "$DEB_STAGE${INSTALL_PREFIX}/" + +# Remove files that are provided by system packages or unnecessary in the deb. +# These are typically found in the host OS and add megabytes without benefit. +STRIP_PATTERNS=( + "libz.so*" + "libbz2.so*" + "libexpat.so*" + "libffi.so*" + "libm.so*" + "libpthread.so*" + "libdl.so*" + "libutil.so*" + "librt.so*" + "libgcc_s.so*" + "libstdc++.so*" +) +for pat in "${STRIP_PATTERNS[@]}"; do + find "$DEB_STAGE${INSTALL_PREFIX}" -name "$pat" -delete 2>/dev/null || true +done + +# ── Launcher symlink ───────────────────────────────────────────────────────── +mkdir -p "$DEB_STAGE/usr/local/bin" +ln -sf "${INSTALL_PREFIX}/Mouser" "$DEB_STAGE/usr/local/bin/mouser" + +# ── Icons ───────────────────────────────────────────────────────────────────── +SRC_ICON="$SCRIPT_DIR/images/logo_icon.png" +if [[ -f "$SRC_ICON" ]]; then + echo " Installing icons…" + for SIZE in 16 32 48 64 128 256 512; do + ICON_DEST="$DEB_STAGE${ICONS_DIR}/${SIZE}x${SIZE}/apps" + mkdir -p "$ICON_DEST" + if command -v convert &>/dev/null; then + convert -resize "${SIZE}x${SIZE}" "$SRC_ICON" "$ICON_DEST/${APP_NAME}.png" 2>/dev/null \ + || cp "$SRC_ICON" "$ICON_DEST/${APP_NAME}.png" + else + cp "$SRC_ICON" "$ICON_DEST/${APP_NAME}.png" + fi + done +fi + +# ── Desktop entry ───────────────────────────────────────────────────────────── +echo " Creating desktop entry…" +cat > "$DEB_STAGE${DESKTOP_DIR}/${APP_NAME}.desktop" << EOF +[Desktop Entry] +Version=1.0 +Type=Application +Name=${APP_DISPLAY} +Comment=Logitech mouse button remapper +Exec=${INSTALL_PREFIX}/Mouser %U +Icon=${APP_NAME} +Terminal=false +Categories=Utility;Settings; +Keywords=mouse;logitech;remap; +StartupNotify=false +EOF + +# ── Calculate installed size ────────────────────────────────────────────────── +INSTALLED_SIZE_KB=$(du -sk "$DEB_STAGE${INSTALL_PREFIX}" | awk '{print $1}') + +# ── DEBIAN/control ──────────────────────────────────────────────────────────── +echo " Writing DEBIAN/control…" +cat > "$DEB_STAGE/DEBIAN/control" << EOF +Package: ${APP_NAME} +Version: ${VERSION} +Architecture: ${ARCH} +Section: utils +Priority: optional +Installed-Size: ${INSTALLED_SIZE_KB} +Maintainer: Mouser Contributors +Homepage: https://github.com/TomBadash/Mouser +Description: Logitech mouse button remapper + Mouser lets you remap the buttons of your Logitech mouse on Linux, + macOS and Windows. It supports per-application profiles, DPI control, + SmartShift scroll wheel management, and gesture buttons. +Depends: libxcb1, libxcb-cursor0, libegl1 +Recommends: libinput-tools +EOF + +# ── DEBIAN/postinst ─────────────────────────────────────────────────────────── +cat > "$DEB_STAGE/DEBIAN/postinst" << 'EOF' +#!/bin/sh +set -e +# Update icon cache if gtk-update-icon-cache is available +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true +fi +# Update desktop database +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database /usr/share/applications 2>/dev/null || true +fi +EOF +chmod 755 "$DEB_STAGE/DEBIAN/postinst" + +# ── DEBIAN/postrm ───────────────────────────────────────────────────────────── +cat > "$DEB_STAGE/DEBIAN/postrm" << 'EOF' +#!/bin/sh +set -e +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true +fi +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database /usr/share/applications 2>/dev/null || true +fi +EOF +chmod 755 "$DEB_STAGE/DEBIAN/postrm" + +# ── Build the .deb ──────────────────────────────────────────────────────────── +echo " Building .deb package…" +mkdir -p "$(dirname "$DEB_OUT")" +dpkg-deb --build --root-owner-group "$DEB_STAGE" "$DEB_OUT" + +DEB_SIZE=$(du -sh "$DEB_OUT" | awk '{print $1}') +echo "" +echo "==> Package built successfully!" +echo " Output: $DEB_OUT" +echo " Size: ${DEB_SIZE}" +echo "" +echo " Install with:" +echo " sudo dpkg -i $DEB_OUT" +echo " Or:" +echo " sudo apt install $DEB_OUT" diff --git a/core/config.py b/core/config.py index 746e525..60c032c 100644 --- a/core/config.py +++ b/core/config.py @@ -106,6 +106,7 @@ "debug_mode": False, "device_layout_overrides": {}, "language": "en", + "auto_update": True, }, } diff --git a/core/updater.py b/core/updater.py new file mode 100644 index 0000000..b770b99 --- /dev/null +++ b/core/updater.py @@ -0,0 +1,488 @@ +""" +Auto-updater for Mouser. + +Checks the GitHub releases page for a newer version, downloads the +appropriate archive for the current platform, and installs it. + +Update discovery uses a ``latest.json`` file uploaded to every GitHub +release. If that file is unavailable the module falls back to the +GitHub Releases REST API. + +``latest.json`` format:: + + { + "version": "3.6.0", + "date": "2025-01-15T00:00:00Z", + "downloads": { + "windows-x64": "https://…/Mouser-Windows.zip", + "macos-arm64": "https://…/Mouser-macOS.zip", + "macos-x86_64": "https://…/Mouser-macOS-intel.zip", + "linux-deb": "https://…/Mouser-Linux.deb", + "linux-zip": "https://…/Mouser-Linux.zip" + } + } +""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import subprocess +import sys +import tempfile +import threading +import urllib.request +from pathlib import Path +from typing import Callable, Optional + +# Lazy import so the module loads fast even in the packaged app. +# core.version uses only stdlib; no circular import risk. +from core.version import APP_VERSION + +LATEST_JSON_URL = ( + "https://github.com/farfromrefug/Mouser/releases/latest/download/latest.json" +) +GITHUB_API_LATEST = ( + "https://api.github.com/repos/farfromrefug/Mouser/releases/latest" +) +_REQUEST_TIMEOUT = 20 # seconds + + +# ── helpers ──────────────────────────────────────────────────────────────── + +def _version_tuple(version_str: str) -> tuple: + """Convert a semver string to a comparable tuple of ints.""" + try: + return tuple(int(p) for p in version_str.lstrip("v").split(".") if p.strip().isdigit()) + except (ValueError, AttributeError): + return (0,) + + +def _platform_key() -> str: + """Return the key identifying this platform in ``downloads``.""" + plat = sys.platform + arch = platform.machine().lower() + if plat == "win32": + return "windows-x64" + if plat == "darwin": + return "macos-arm64" if arch in ("arm64", "aarch64") else "macos-x86_64" + if plat.startswith("linux"): + return "linux-deb" if shutil.which("dpkg") else "linux-zip" + return "" + + +def _fetch_latest_info() -> dict: + """Download and return the latest release info dict.""" + headers = {"User-Agent": f"Mouser/{APP_VERSION}"} + + def _get(url: str) -> bytes: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp: + return resp.read() + + # Primary: pre-built latest.json uploaded to the release + try: + return json.loads(_get(LATEST_JSON_URL)) + except Exception: + pass + + +# ── public status constants ───────────────────────────────────────────────── + +STATUS_IDLE = "idle" +STATUS_CHECKING = "checking" +STATUS_UP_TO_DATE = "up_to_date" +STATUS_AVAILABLE = "available" +STATUS_DOWNLOADING = "downloading" +STATUS_INSTALLING = "installing" +STATUS_INSTALLED = "installed" +STATUS_NEEDS_MANUAL = "needs_manual" +STATUS_CANCELLED = "cancelled" +STATUS_ERROR = "error" + + +# ── main class ───────────────────────────────────────────────────────────── + +class Updater: + """ + Handles checking, downloading, and installing updates. + + All public methods are thread-safe; callbacks are invoked from + the background worker thread — callers must marshal them to the + main thread as needed (e.g. via Qt signals). + + Callback signatures:: + + on_progress(status: str, fraction: float) + Called repeatedly during a download. ``fraction`` is in [0, 1]. + + on_finished(status: str, detail: Optional[str]) + Called once when the operation completes. + ``detail`` carries the new version string or an error message. + """ + + def __init__( + self, + on_progress: Optional[Callable[[str, float], None]] = None, + on_finished: Optional[Callable[[str, Optional[str]], None]] = None, + ): + self._on_progress = on_progress + self._on_finished = on_finished + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._cancel = threading.Event() + self._latest_info: Optional[dict] = None + + # ── public API ───────────────────────────────────────────────────────── + + @property + def latest_info(self) -> Optional[dict]: + return self._latest_info + + @property + def latest_version(self) -> str: + return (self._latest_info or {}).get("version", "") + + def check(self) -> None: + """Start a background check for a newer release.""" + self._start_thread(self._do_check) + + def download_and_install(self) -> None: + """Download and install the latest update (call after ``check``).""" + if not self._latest_info: + self._emit_finished(STATUS_ERROR, "No update info — run check() first") + return + self._start_thread(self._do_download_install) + + def cancel(self) -> None: + """Request cancellation of the current operation.""" + self._cancel.set() + + def is_busy(self) -> bool: + with self._lock: + return bool(self._thread and self._thread.is_alive()) + + # ── internals ────────────────────────────────────────────────────────── + + def _start_thread(self, target: Callable) -> None: + with self._lock: + if self._thread and self._thread.is_alive(): + return + self._cancel.clear() + self._thread = threading.Thread(target=target, daemon=True) + self._thread.start() + + def _emit_progress(self, status: str, fraction: float = 0.0) -> None: + if self._on_progress: + try: + self._on_progress(status, fraction) + except Exception: + pass + + def _emit_finished(self, status: str, detail: Optional[str] = None) -> None: + if self._on_finished: + try: + self._on_finished(status, detail) + except Exception: + pass + + # ── worker: check ────────────────────────────────────────────────────── + + def _do_check(self) -> None: + self._emit_progress(STATUS_CHECKING, 0.0) + try: + info = _fetch_latest_info() + except Exception as exc: + self._emit_finished(STATUS_ERROR, str(exc)) + return + + latest = info.get("version", "") + if not latest: + self._emit_finished(STATUS_ERROR, "Could not read version from release info") + return + + self._latest_info = info + key = _platform_key() + if not key: + self._emit_finished(STATUS_ERROR, f"Unsupported platform: {sys.platform}") + return + # test for url to ensure this update contains a package for this platform + url = (self._latest_info or {}).get("downloads", {}).get(key, "") + if _version_tuple(latest) > _version_tuple(APP_VERSION) and url: + self._emit_finished(STATUS_AVAILABLE, latest) + else: + self._emit_finished(STATUS_UP_TO_DATE, latest) + + # ── worker: download + install ───────────────────────────────────────── + + def _do_download_install(self) -> None: + key = _platform_key() + if not key: + self._emit_finished(STATUS_ERROR, f"Unsupported platform: {sys.platform}") + return + url = (self._latest_info or {}).get("downloads", {}).get(key, "") + if not url: + self._emit_finished(STATUS_ERROR, f"No download URL for {key}") + return + + tmpdir = tempfile.mkdtemp(prefix="mouser_update_") + try: + filename = url.split("/")[-1] + dest = os.path.join(tmpdir, filename) + + self._download_file(url, dest) + + if self._cancel.is_set(): + self._emit_finished(STATUS_CANCELLED) + return + + self._emit_progress(STATUS_INSTALLING, 1.0) + self._install(dest, key) + except Exception as exc: + self._emit_finished(STATUS_ERROR, str(exc)) + finally: + try: + shutil.rmtree(tmpdir, ignore_errors=True) + except Exception: + pass + + def _download_file(self, url: str, dest: str) -> None: + headers = {"User-Agent": f"Mouser/{APP_VERSION}"} + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp: + total = int(resp.headers.get("Content-Length", 0) or 0) + downloaded = 0 + chunk_size = 65536 + with open(dest, "wb") as f: + while True: + if self._cancel.is_set(): + return + chunk = resp.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + fraction = downloaded / total if total > 0 else 0.0 + self._emit_progress(STATUS_DOWNLOADING, fraction) + + # ── install helpers ──────────────────────────────────────────────────── + + def _install(self, path: str, key: str) -> None: + if key == "linux-deb": + self._install_deb(path) + elif key.startswith("macos"): + self._install_macos(path) + elif key == "windows-x64": + self._install_windows(path) + elif key == "linux-zip": + self._install_linux_zip(path) + else: + self._emit_finished(STATUS_ERROR, f"No installer for platform key: {key}") + + # ── Linux .deb ───────────────────────────────────────────────────────── + + def _install_deb(self, deb_path: str) -> None: + """Install a .deb file via pkexec + apt/dpkg, then schedule a restart.""" + candidates: list[list[str]] = [] + if shutil.which("pkexec"): + if shutil.which("apt"): + candidates.append(["pkexec", "apt", "install", "-y", deb_path]) + if shutil.which("dpkg"): + candidates.append(["pkexec", "dpkg", "-i", deb_path]) + # gksudo/gksu fallback (older distros) + for sudo_tool in ("gksudo", "gksu"): + if shutil.which(sudo_tool): + if shutil.which("dpkg"): + candidates.append([sudo_tool, "dpkg", "-i", deb_path]) + break + + for cmd in candidates: + try: + result = subprocess.run(cmd, timeout=180, check=False) + if result.returncode == 0: + # New binary is now on disk. Spawn a small helper script + # that waits for this process to exit and then relaunches + # the (now-updated) executable — the same pattern used by + # _install_linux_zip so that "Restart Now" works correctly. + exe_path = Path(sys.executable) + restart_dir = Path(tempfile.mkdtemp(prefix="mouser_deb_restart_")) + sh_path = restart_dir / "mouser_restart.sh" + sh_content = ( + "#!/bin/sh\n" + "sleep 2\n" + f'"{exe_path}" &\n' + f'rm -rf "{restart_dir}"\n' + 'rm -f "$0"\n' + ) + sh_path.write_text(sh_content) + sh_path.chmod(0o755) + subprocess.Popen([str(sh_path)], close_fds=True) + self._emit_finished(STATUS_INSTALLED) + return + except (subprocess.TimeoutExpired, OSError): + continue + + # Could not install automatically — give user the path + self._emit_finished(STATUS_NEEDS_MANUAL, deb_path) + + # ── macOS .app ───────────────────────────────────────────────────────── + + def _install_macos(self, zip_path: str) -> None: + """Extract .app from zip and replace the running bundle in-place.""" + import subprocess + # Maximum directory levels to walk up when searching for the .app bundle. + _MAX_APP_SEARCH_DEPTH = 8 + + exe = Path(sys.executable) + # Walk up the path to locate the .app bundle directory + bundle: Optional[Path] = None + candidate = exe + for _ in range(_MAX_APP_SEARCH_DEPTH): + if candidate.suffix == ".app": + bundle = candidate + break + candidate = candidate.parent + + if bundle is None: + self._emit_finished( + STATUS_ERROR, + "Could not locate .app bundle. Please reinstall manually.", + ) + return + + extract_dir = Path(tempfile.mkdtemp(prefix="mouser_macos_new_")) + try: + subprocess.run(["unzip", zip_path, "-d", extract_dir], check=True) + + new_app: Optional[Path] = None + for entry in extract_dir.iterdir(): + if entry.suffix == ".app": + new_app = entry + break + + if new_app is None: + self._emit_finished(STATUS_ERROR, "No .app found in update archive") + return + + backup = bundle.with_suffix(".app.bak") + if backup.exists(): + shutil.rmtree(str(backup), ignore_errors=True) + shutil.move(str(bundle), str(backup)) + try: + shutil.move(str(new_app), str(bundle)) + except Exception: + # Restore backup if move failed + shutil.move(str(backup), str(bundle)) + raise + + # Ad-hoc codesign so macOS Gatekeeper does not block the app + if shutil.which("codesign"): + subprocess.run( + ["codesign", "--force", "--deep", "--sign", "-", str(bundle)], + timeout=60, + check=False, + ) + + # Remove the backup only after success + shutil.rmtree(str(backup), ignore_errors=True) + self._emit_finished(STATUS_INSTALLED) + except Exception as exc: + self._emit_finished(STATUS_ERROR, str(exc)) + finally: + shutil.rmtree(str(extract_dir), ignore_errors=True) + + # ── Windows zip ──────────────────────────────────────────────────────── + + def _install_windows(self, zip_path: str) -> None: + """ + Extract the update zip to a staging area and schedule an in-place + replacement via a batch script that runs after Mouser exits. + """ + import zipfile + + exe_path = Path(sys.executable) + install_dir = exe_path.parent + + # Stage the new files in a sibling temp directory + stage_dir = Path(tempfile.mkdtemp(prefix="mouser_win_new_")) + try: + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(str(stage_dir)) + + # The zip contains a single top-level directory (e.g. "Mouser/") + new_dir: Optional[Path] = None + for entry in stage_dir.iterdir(): + if entry.is_dir(): + new_dir = entry + break + + if new_dir is None: + self._emit_finished(STATUS_ERROR, "No directory found in update archive") + return + + bat_path = stage_dir / "mouser_update.bat" + # /MOVE deletes source files after a successful copy + bat_content = ( + "@echo off\r\n" + "timeout /t 2 /nobreak >nul\r\n" + # /E copies subdirectories; /IS, /IT, /IM overwrite even identical/tweaked files + f'robocopy "{new_dir}" "{install_dir}" /E /IS /IT /IM /NFL /NDL /NJH /NJS\r\n' + f'start "" "{install_dir / exe_path.name}"\r\n' + 'del "%~f0"\r\n' + f'rmdir /s /q "{stage_dir}"\r\n' + ) + bat_path.write_text(bat_content, encoding="utf-8") + + subprocess.Popen( + ["cmd", "/c", str(bat_path)], + creationflags=getattr(subprocess, "CREATE_NEW_CONSOLE", 0), + close_fds=True, + ) + # Signal the UI that it should quit so the batch script can replace files + self._emit_finished(STATUS_INSTALLED) + except Exception as exc: + shutil.rmtree(str(stage_dir), ignore_errors=True) + self._emit_finished(STATUS_ERROR, str(exc)) + + # ── Linux zip (non-deb) ──────────────────────────────────────────────── + + def _install_linux_zip(self, zip_path: str) -> None: + """Extract the Linux zip and replace files via a shell script after exit.""" + import zipfile + + exe_path = Path(sys.executable) + install_dir = exe_path.parent + + stage_dir = Path(tempfile.mkdtemp(prefix="mouser_linux_new_")) + try: + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(str(stage_dir)) + + new_dir: Optional[Path] = None + for entry in stage_dir.iterdir(): + if entry.is_dir(): + new_dir = entry + break + + if new_dir is None: + self._emit_finished(STATUS_ERROR, "No directory found in update archive") + return + + sh_path = stage_dir / "mouser_update.sh" + sh_content = ( + "#!/bin/sh\n" + "sleep 2\n" + f'cp -a "{new_dir}/." "{install_dir}/"\n' + f'exec "{install_dir}/{exe_path.name}" &\n' + f'rm -rf "{stage_dir}"\n' + 'rm -f "$0"\n' + ) + sh_path.write_text(sh_content) + sh_path.chmod(0o755) + subprocess.Popen([str(sh_path)], close_fds=True) + self._emit_finished(STATUS_INSTALLED) + except Exception as exc: + shutil.rmtree(str(stage_dir), ignore_errors=True) + self._emit_finished(STATUS_ERROR, str(exc)) diff --git a/images/icons/download.svg b/images/icons/download.svg new file mode 100644 index 0000000..aad9be4 --- /dev/null +++ b/images/icons/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/main_qml.py b/main_qml.py index fe081c5..392b477 100644 --- a/main_qml.py +++ b/main_qml.py @@ -183,20 +183,20 @@ def _render_svg_pixmap(path: str, color: QColor, size: int) -> QPixmap: def _tray_icon() -> QIcon: - if sys.platform != "darwin": - return _app_icon() tray_svg = os.path.join(ROOT, "images", "icons", "mouse-simple.svg") icon = QIcon() # Provide both Normal (black, for light menu bar) and Selected (white, # for dark menu bar) modes so macOS always picks the correct contrast. for size in (18, 36): + normalColor = "#FFFFFF" if sys.platform == "linux" else "#000000" icon.addPixmap( - _render_svg_pixmap(tray_svg, QColor("#000000"), size), + _render_svg_pixmap(tray_svg, QColor(normalColor), size), QIcon.Mode.Normal) icon.addPixmap( _render_svg_pixmap(tray_svg, QColor("#FFFFFF"), size), QIcon.Mode.Selected) + icon.setIsMask(True) return icon @@ -464,6 +464,16 @@ def show_main_window(): root_window.requestActivate() _activate_macos_window() + def toggle_main_window(): + if root_window.isVisible() and root_window.isActive(): + root_window.hide() + else: + root_window.show() + root_window.raise_() + root_window.requestActivate() + _activate_macos_window() + + def _on_second_instance_activate(): _drain_local_activate_socket(single_server.nextPendingConnection()) show_main_window() @@ -529,6 +539,17 @@ def toggle_debug_mode(): tray_menu.addSeparator() + check_update_action = QAction(locale_mgr.tr("tray.check_for_update"), tray_menu) + + def _tray_check_for_update(): + show_main_window() + backend.checkForUpdate() + + check_update_action.triggered.connect(_tray_check_for_update) + tray_menu.addAction(check_update_action) + + tray_menu.addSeparator() + quit_action = QAction(locale_mgr.tr("tray.quit"), tray_menu) def quit_app(): @@ -542,6 +563,7 @@ def quit_app(): def _update_tray_texts(): """Refresh tray menu labels after a language change.""" open_action.setText(locale_mgr.tr("tray.open_settings")) + check_update_action.setText(locale_mgr.tr("tray.check_for_update")) quit_action.setText(locale_mgr.tr("tray.quit")) sync_debug_action() # Re-sync toggle text based on current engine state @@ -563,7 +585,7 @@ def _save_language(): tray.setContextMenu(tray_menu) tray.activated.connect(lambda reason: ( - show_main_window() + toggle_main_window() ) if reason in ( QSystemTrayIcon.ActivationReason.Trigger, QSystemTrayIcon.ActivationReason.DoubleClick, diff --git a/ui/backend.py b/ui/backend.py index d16f7ad..0859940 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -31,6 +31,25 @@ supports_login_startup, sync_from_config as sync_login_startup_from_config, ) +from core.updater import ( + Updater, + STATUS_IDLE, + STATUS_CHECKING, + STATUS_UP_TO_DATE, + STATUS_AVAILABLE, + STATUS_DOWNLOADING, + STATUS_INSTALLING, + STATUS_INSTALLED, + STATUS_NEEDS_MANUAL, + STATUS_CANCELLED, + STATUS_ERROR, +) + +# Delay (ms) before the startup auto-update check so the UI is fully ready. +_AUTO_UPDATE_CHECK_DELAY_MS = 5_000 +# How often to repeat the auto-update check (24 hours). +_AUTO_UPDATE_CHECK_INTERVAL_S = 86_400 +_AUTO_UPDATE_CHECK_INTERVAL_MS = _AUTO_UPDATE_CHECK_INTERVAL_S * 1_000 def _action_label(action_id): @@ -60,6 +79,7 @@ class Backend(QObject): deviceInfoChanged = Signal() deviceLayoutChanged = Signal() knownAppsChanged = Signal() + updateStatusChanged = Signal() # Internal cross-thread signals _profileSwitchRequest = Signal(str) @@ -70,6 +90,8 @@ class Backend(QObject): _gestureEventRequest = Signal(object) _smartShiftReadRequest = Signal() _statusMessageRequest = Signal(str) + _updateProgressRequest = Signal(str, float) + _updateFinishedRequest = Signal(str, str) def __init__(self, engine=None, parent=None): super().__init__(parent) @@ -104,6 +126,21 @@ def __init__(self, engine=None, parent=None): self._connected_device_refresh_pending = False self._connected_device_refresh_attempts = 0 + # ── Updater state ─────────────────────────────────────── + self._update_status = STATUS_IDLE + self._update_latest_version = "" + self._update_download_progress = 0.0 + self._update_detail = "" + self._updater = Updater( + on_progress=self._onUpdaterProgress, + on_finished=self._onUpdaterFinished, + ) + # Daily repeating timer for auto-update checks. + # Only started when auto_update is enabled. + self._update_check_timer = QTimer(self) + self._update_check_timer.setInterval(_AUTO_UPDATE_CHECK_INTERVAL_MS) + self._update_check_timer.timeout.connect(self._periodicUpdateCheck) + # Cross-thread signal connections self._profileSwitchRequest.connect( self._handleProfileSwitch, Qt.QueuedConnection) @@ -121,6 +158,10 @@ def __init__(self, engine=None, parent=None): self._handleSmartShiftRead, Qt.QueuedConnection) self._statusMessageRequest.connect( self._handleStatusMessage, Qt.QueuedConnection) + self._updateProgressRequest.connect( + self._handleUpdateProgress, Qt.QueuedConnection) + self._updateFinishedRequest.connect( + self._handleUpdateFinished, Qt.QueuedConnection) # Wire engine callbacks if engine: @@ -148,6 +189,10 @@ def __init__(self, engine=None, parent=None): else: self._cfg.setdefault("settings", {})["start_at_login"] = False self._sync_connected_device_info() + # Schedule auto-update check on startup (delayed so the UI is fully ready). + if self._cfg.get("settings", {}).get("auto_update", True): + QTimer.singleShot(_AUTO_UPDATE_CHECK_DELAY_MS, self._updater.check) + self._update_check_timer.start() # ── Properties ───────────────────────────────────────────── @@ -323,6 +368,28 @@ def debugMode(self): def debugEventsEnabled(self): return self._debug_events_enabled + # ── Update properties ─────────────────────────────────────── + + @Property(bool, notify=settingsChanged) + def autoUpdate(self): + return bool(self._cfg.get("settings", {}).get("auto_update", True)) + + @Property(str, notify=updateStatusChanged) + def updateStatus(self): + return self._update_status + + @Property(str, notify=updateStatusChanged) + def updateLatestVersion(self): + return self._update_latest_version + + @Property(float, notify=updateStatusChanged) + def updateDownloadProgress(self): + return self._update_download_progress + + @Property(str, notify=updateStatusChanged) + def updateDetail(self): + return self._update_detail + @Property(bool, constant=True) def supportsGestureDirections(self): return sys.platform in ("darwin", "win32", "linux") @@ -664,6 +731,94 @@ def setDebugMode(self, value): self.settingsChanged.emit() self.debugEventsEnabledChanged.emit() + # ── Update slots ──────────────────────────────────────────── + + @Slot(bool) + def setAutoUpdate(self, value): + enabled = bool(value) + self._cfg.setdefault("settings", {})["auto_update"] = enabled + save_config(self._cfg) + if enabled: + self._update_check_timer.start() + else: + self._update_check_timer.stop() + self.settingsChanged.emit() + + @Slot() + def checkForUpdate(self): + """Trigger a background update check.""" + if not self._updater.is_busy(): + self._update_status = STATUS_CHECKING + self._update_download_progress = 0.0 + self.updateStatusChanged.emit() + self._updater.check() + + @Slot() + def downloadAndInstallUpdate(self): + """Download and install the latest update.""" + if not self._updater.is_busy(): + self._update_status = STATUS_DOWNLOADING + self._update_download_progress = 0.0 + self.updateStatusChanged.emit() + self._updater.download_and_install() + + @Slot() + def restartApp(self): + """Restart the application (used after a successful update install).""" + from PySide6.QtGui import QGuiApplication + if sys.platform == "darwin": + # Locate the .app bundle and reopen it, then quit this process. + import subprocess as _sp + from pathlib import Path as _Path + candidate = _Path(sys.executable) + bundle = None + for _ in range(8): + if candidate.suffix == ".app": + bundle = candidate + break + candidate = candidate.parent + if bundle and bundle.exists(): + _sp.Popen(["open", "-n", str(bundle)]) + QGuiApplication.quit() + + @Slot() + def _periodicUpdateCheck(self): + """Called every 24 h by the daily timer to silently check for updates.""" + if not self._updater.is_busy(): + self._update_status = STATUS_CHECKING + self._update_download_progress = 0.0 + self.updateStatusChanged.emit() + self._updater.check() + + # Updater background callbacks → marshal to Qt main thread + + def _onUpdaterProgress(self, status: str, fraction: float): + self._updateProgressRequest.emit(status, fraction) + + def _onUpdaterFinished(self, status: str, detail): + self._updateFinishedRequest.emit(status, detail or "") + + @Slot(str, float) + def _handleUpdateProgress(self, status: str, fraction: float): + self._update_status = status + self._update_download_progress = fraction + self.updateStatusChanged.emit() + + @Slot(str, str) + def _handleUpdateFinished(self, status: str, detail: str): + self._update_status = status + self._update_detail = detail + if status == STATUS_AVAILABLE: + self._update_latest_version = detail + # Persist the last-check time after any completed check operation + # (not during download/install which are follow-on operations). + if status not in ( + STATUS_DOWNLOADING, STATUS_INSTALLING, + STATUS_INSTALLED, STATUS_NEEDS_MANUAL, + ): + save_config(self._cfg) + self.updateStatusChanged.emit() + @Slot(bool) def setDebugEventsEnabled(self, value): value = bool(value) diff --git a/ui/locale_manager.py b/ui/locale_manager.py index 7dd1cd8..8dbfd7f 100644 --- a/ui/locale_manager.py +++ b/ui/locale_manager.py @@ -155,9 +155,29 @@ "tray.enable_remapping": "Enable Remapping", "tray.enable_debug": "Enable Debug Mode", "tray.disable_debug": "Disable Debug Mode", + "tray.check_for_update": "Check for Update", "tray.quit": "Quit Mouser", "tray.tray_message": "Mouser is running in the system tray. Click the icon to open settings.", + # Updates section (settings page) + "update.title": "Updates", + "update.desc": "Automatically check for and install new versions of Mouser.", + "update.auto_update": "Auto-update", + "update.check_now": "Check for Update", + "update.checking": "Checking for updates…", + "update.up_to_date": "Mouser is up to date.", + "update.available_prefix": "Version ", + "update.available_suffix": " is available!", + "update.download_install": "Download & Install", + "update.downloading": "Downloading update…", + "update.installing": "Installing…", + "update.installed": "Update installed — please restart Mouser to apply it.", + "update.needs_manual_prefix": "Download ready at: ", + "update.needs_manual_suffix": "\nInstall manually with: sudo dpkg -i ", + "update.cancelled": "Update cancelled.", + "update.error_prefix": "Error: ", + "update.restart": "Restart Now", + # Accessibility dialog (macOS) "accessibility.title": "Accessibility Permission Required", "accessibility.text": ( @@ -319,9 +339,28 @@ "tray.enable_remapping": "\u542f\u7528\u6309\u952e\u91cd\u6620\u5c04", "tray.enable_debug": "\u542f\u7528\u8c03\u8bd5\u6a21\u5f0f", "tray.disable_debug": "\u7981\u7528\u8c03\u8bd5\u6a21\u5f0f", + "tray.check_for_update": "\u68c0\u67e5\u66f4\u65b0", "tray.quit": "\u9000\u51fa Mouser", "tray.tray_message": "Mouser \u6b63\u5728\u7cfb\u7edf\u6258\u76d8\u4e2d\u8fd0\u884c\u3002\u70b9\u51fb\u56fe\u6807\u6253\u5f00\u8bbe\u7f6e\u3002", + "update.title": "\u66f4\u65b0", + "update.desc": "\u81ea\u52a8\u68c0\u67e5\u5e76\u5b89\u88c5\u65b0\u7248\u672c\u7684 Mouser\u3002", + "update.auto_update": "\u81ea\u52a8\u66f4\u65b0", + "update.check_now": "\u68c0\u67e5\u66f4\u65b0", + "update.checking": "\u6b63\u5728\u68c0\u67e5\u66f4\u65b0\u2026", + "update.up_to_date": "Mouser \u5df2\u662f\u6700\u65b0\u7248\u672c\u3002", + "update.available_prefix": "\u7248\u672c ", + "update.available_suffix": " \u53ef\u7528\uff01", + "update.download_install": "\u4e0b\u8f7d\u5e76\u5b89\u88c5", + "update.downloading": "\u6b63\u5728\u4e0b\u8f7d\u66f4\u65b0\u2026", + "update.installing": "\u6b63\u5728\u5b89\u88c5\u2026", + "update.installed": "\u66f4\u65b0\u5df2\u5b89\u88c5 \u2014 \u8bf7\u91cd\u65b0\u542f\u52a8 Mouser\u3002", + "update.needs_manual_prefix": "\u4e0b\u8f7d\u8def\u5f84\uff1a", + "update.needs_manual_suffix": "\n\u624b\u52a8\u5b89\u88c5\uff1asudo dpkg -i <\u6587\u4ef6>", + "update.cancelled": "\u66f4\u65b0\u5df2\u53d6\u6d88\u3002", + "update.error_prefix": "\u9519\u8bef\uff1a", + "update.restart": "\u7acb\u5373\u91cd\u542f", + "accessibility.title": "\u9700\u8981\u8f85\u52a9\u529f\u80fd\u6743\u9650", "accessibility.text": ( "Mouser \u9700\u8981\u8f85\u52a9\u529f\u80fd\u6743\u9650\u4ee5\u62e6\u622a\u9f20\u6807\u6309\u952e\u4e8b\u4ef6\u3002\n\n" @@ -479,9 +518,28 @@ "tray.enable_remapping": "\u555f\u7528\u6309\u9375\u91cd\u65b0\u5c0d\u6620", "tray.enable_debug": "\u555f\u7528\u9664\u932f\u6a21\u5f0f", "tray.disable_debug": "\u505c\u7528\u9664\u932f\u6a21\u5f0f", + "tray.check_for_update": "\u6aa2\u67e5\u66f4\u65b0", "tray.quit": "\u7d50\u675f Mouser", "tray.tray_message": "Mouser \u6b63\u5728\u7cfb\u7d71\u5217\u4e2d\u57f7\u884c\u3002\u9ede\u64ca\u5716\u793a\u958b\u555f\u8a2d\u5b9a\u3002", + "update.title": "\u66f4\u65b0", + "update.desc": "\u81ea\u52d5\u6aa2\u67e5\u4e26\u5b89\u88dd\u65b0\u7248\u672c\u7684 Mouser\u3002", + "update.auto_update": "\u81ea\u52d5\u66f4\u65b0", + "update.check_now": "\u6aa2\u67e5\u66f4\u65b0", + "update.checking": "\u6b63\u5728\u6aa2\u67e5\u66f4\u65b0\u2026", + "update.up_to_date": "Mouser \u5df2\u662f\u6700\u65b0\u7248\u672c\u3002", + "update.available_prefix": "\u7248\u672c ", + "update.available_suffix": " \u53ef\u7528\uff01", + "update.download_install": "\u4e0b\u8f09\u4e26\u5b89\u88dd", + "update.downloading": "\u6b63\u5728\u4e0b\u8f09\u66f4\u65b0\u2026", + "update.installing": "\u6b63\u5728\u5b89\u88dd\u2026", + "update.installed": "\u66f4\u65b0\u5b8c\u6210 \u2014 \u8acb\u91cd\u65b0\u555f\u52d5 Mouser\u3002", + "update.needs_manual_prefix": "\u4e0b\u8f09\u8def\u5f91\uff1a", + "update.needs_manual_suffix": "\n\u624b\u52d5\u5b89\u88dd\uff1asudo dpkg -i <\u6a94\u6848>", + "update.cancelled": "\u66f4\u65b0\u5df2\u53d6\u6d88\u3002", + "update.error_prefix": "\u932f\u8aa4\uff1a", + "update.restart": "\u7acb\u5373\u91cd\u555f", + "accessibility.title": "\u9700\u8981\u8f14\u52a9\u4f7f\u7528\u6b0a\u9650", "accessibility.text": ( "Mouser \u9700\u8981\u8f14\u52a9\u4f7f\u7528\u6b0a\u9650\u4ee5\u6514\u622a\u6ed1\u9f20\u6309\u9375\u4e8b\u4ef6\u3002\n\n" diff --git a/ui/qml/ScrollPage.qml b/ui/qml/ScrollPage.qml index b3fc0f9..b31da94 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -872,6 +872,237 @@ Item { Item { width: 1; height: 16 } + // ── Updates ─────────────────────────────────────────── + Rectangle { + width: parent.width - 72 + anchors.horizontalCenter: parent.horizontalCenter + height: updateContent.implicitHeight + 40 + radius: Theme.radius + color: scrollPage.theme.bgCard + border.width: 1 + border.color: scrollPage.theme.border + + Column { + id: updateContent + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: 20 + } + spacing: 12 + + Text { + text: s["update.title"] || "Updates" + font { + family: uiState.fontFamily + pixelSize: 16 + bold: true + } + color: scrollPage.theme.textPrimary + } + + Text { + text: s["update.desc"] || "Automatically check for and install new versions of Mouser." + font { family: uiState.fontFamily; pixelSize: 12 } + color: scrollPage.theme.textSecondary + wrapMode: Text.WordWrap + width: parent.width + } + + // ── Auto-update toggle ───────────────────────────── + Rectangle { + width: parent.width + height: 52 + radius: 10 + color: scrollPage.theme.bgSubtle + + RowLayout { + anchors { + fill: parent + leftMargin: 16 + rightMargin: 16 + } + + Text { + text: s["update.auto_update"] || "Auto-update" + font { family: uiState.fontFamily; pixelSize: 13 } + color: scrollPage.theme.textPrimary + Layout.fillWidth: true + } + + Switch { + id: autoUpdateSwitch + checked: backend.autoUpdate + Material.accent: scrollPage.theme.accent + Accessible.name: s["update.auto_update"] || "Auto-update" + onToggled: backend.setAutoUpdate(checked) + } + } + } + + // ── Status message + progress ────────────────────── + Column { + width: parent.width + spacing: 8 + visible: backend.updateStatus !== "idle" + + Text { + id: updateStatusText + width: parent.width + wrapMode: Text.WordWrap + font { family: uiState.fontFamily; pixelSize: 12 } + color: { + var st = backend.updateStatus + if (st === "error") return scrollPage.theme.warning + if (st === "available" || st === "installed") return scrollPage.theme.accent + return scrollPage.theme.textSecondary + } + text: { + var st = backend.updateStatus + if (st === "checking") + return s["update.checking"] || "Checking for updates…" + if (st === "up_to_date") + return s["update.up_to_date"] || "Mouser is up to date." + if (st === "available") + return (s["update.available_prefix"] || "Version ") + + backend.updateLatestVersion + + (s["update.available_suffix"] || " is available!") + if (st === "downloading" || st === "installing") + return st === "downloading" + ? (s["update.downloading"] || "Downloading update…") + : (s["update.installing"] || "Installing…") + if (st === "installed") + return s["update.installed"] || "Update installed — please restart Mouser." + if (st === "needs_manual") + return (s["update.needs_manual_prefix"] || "Download ready at: ") + + backend.updateDetail + + (s["update.needs_manual_suffix"] || "\nInstall manually with: sudo dpkg -i ") + if (st === "cancelled") + return s["update.cancelled"] || "Update cancelled." + if (st === "error") + return (s["update.error_prefix"] || "Error: ") + backend.updateDetail + return "" + } + } + + // Download progress bar + Rectangle { + width: parent.width + height: 6 + radius: 3 + color: scrollPage.theme.bgSubtle + visible: backend.updateStatus === "downloading" || backend.updateStatus === "installing" + + Rectangle { + width: parent.width * backend.updateDownloadProgress + height: parent.height + radius: parent.radius + color: scrollPage.theme.accent + Behavior on width { NumberAnimation { duration: 150 } } + } + } + } + + // ── Action buttons ───────────────────────────────── + Row { + width: parent.width + spacing: 10 + + // Check for Update button — styled like other setting buttons + Rectangle { + width: Math.max(140, checkUpdateText.implicitWidth + 32) + height: 38 + radius: 10 + visible: backend.updateStatus !== "downloading" && backend.updateStatus !== "installing" + color: checkUpdateMa.containsMouse + ? scrollPage.theme.bgCardHover + : scrollPage.theme.bgSubtle + border.width: 1 + border.color: scrollPage.theme.border + + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + id: checkUpdateText + anchors.centerIn: parent + text: s["update.check_now"] || "Check for Update" + font { family: uiState.fontFamily; pixelSize: 13 } + color: scrollPage.theme.textPrimary + } + + MouseArea { + id: checkUpdateMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: backend.checkForUpdate() + } + } + + // Download & Install button (shown only when update available) + Rectangle { + width: Math.max(140, dlText.implicitWidth + 32) + height: 38 + radius: 10 + visible: backend.updateStatus === "available" + color: dlMa.containsMouse + ? Qt.darker(scrollPage.theme.accent, 1.08) + : scrollPage.theme.accent + + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + id: dlText + anchors.centerIn: parent + text: s["update.download_install"] || "Download & Install" + font { family: uiState.fontFamily; pixelSize: 13; bold: true } + color: scrollPage.theme.bgSidebar + } + + MouseArea { + id: dlMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: backend.downloadAndInstallUpdate() + } + } + + // Restart button (shown after install) + Rectangle { + width: Math.max(140, restartText.implicitWidth + 32) + height: 38 + radius: 10 + visible: backend.updateStatus === "installed" + color: restartMa.containsMouse + ? Qt.darker(scrollPage.theme.accent, 1.08) + : scrollPage.theme.accent + + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + id: restartText + anchors.centerIn: parent + text: s["update.restart"] || "Restart Now" + font { family: uiState.fontFamily; pixelSize: 13; bold: true } + color: scrollPage.theme.bgSidebar + } + + MouseArea { + id: restartMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: backend.restartApp() + } + } + } + } + } + + Item { width: 1; height: 16 } + // ── DPI note ────────────────────────────────────────── Rectangle { width: parent.width - 72 @@ -934,6 +1165,7 @@ Item { } vscrollSwitch.checked = backend.invertVScroll hscrollSwitch.checked = backend.invertHScroll + autoUpdateSwitch.checked = backend.autoUpdate } } }