commit c5c5af8cdedcb95435120aacf1fa51d3933cb388 Author: upstream-mirror Date: Tue Sep 15 22:29:06 2026 +0500 snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8694c0a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# See https://github.com/github/linguist for details + +# Trick to remove some build tools from language overview +Makefile linguist-vendored +*.sh linguist-vendored +*.cmd linguist-vendored +*.ps1 linguist-vendored diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3e9342b --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,8 @@ +# Docs for this file can be found here: +# https://docs.github.com/en/github/administering-a-repository/displaying-a-sponsor-button-in-your-repository + +github: ["ActivityWatch", "ErikBjare", "johan-bjareholt"] +patreon: "erikbjare" +open_collective: "activitywatch" +liberapay: "ActivityWatch" +custom: ["https://activitywatch.net/donate/"] diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..310fec5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,58 @@ +--- +name: "\U0001F41E Bug report" +about: Did you find a bug? +title: '' +labels: 'type: bug' +assignees: '' + +--- + + + + + - [ ] I am on the [latest](https://github.com/ActivityWatch/activitywatch/releases/latest) ActivityWatch version. + - [ ] I have searched the issues of this repo and believe that this is not a duplicate. + + + + +- **OS name and version**: +- **ActivityWatch version**: + +## Describe the bug + + +## To Reproduce + + +## Expected behavior + + +## Documentation + + +## Additional context + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e291831 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,19 @@ +# Issue templates are based on templates for poetry: +# https://github.com/python-poetry/poetry/tree/master/.github/ISSUE_TEMPLATE +blank_issues_enabled: false +contact_links: + - name: "\U0001F381 Feature requests" + url: https://forum.activitywatch.net/c/features + about: Request and vote on features on the forum. + - name: "\u2753 Support" + url: https://forum.activitywatch.net/ + about: Need help with something? Ask for help on the forum! + - name: "\U0001F4AD Discussion (on our forum)" + url: https://forum.activitywatch.net/ + about: The preferred place for general discussion about ActivityWatch + - name: "\U0001F4AD Discussion (on GitHub Discussions)" + url: https://github.com/ActivityWatch/activitywatch/discussions + about: We're testing it out (but the forum is still the preferred place). + - name: "\U0001F4AC Chat with us on Discord" + url: https://discord.gg/dctJK6USjK + about: We love to see people who are active in issues on our Discord, come join us! diff --git a/.github/ISSUE_TEMPLATE/everything-else.md b/.github/ISSUE_TEMPLATE/everything-else.md new file mode 100644 index 0000000..d3921d8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/everything-else.md @@ -0,0 +1,18 @@ +--- +name: "\U0001F5C3 Everything Else" +about: For questions and issues that do not fall in any of the other categories. +title: '' +labels: '' +assignees: '' + +--- + + + + + +- [ ] I have searched the [issues](https://github.com/ActivityWatch/activitywatch/issues) of this repo and believe that this is not a duplicate. +- [ ] I have searched the [documentation](https://docs.activitywatch.net/en/latest/) and believe that my question is not covered. + +## Issue + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..65c5d09 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +# Set update schedule for GitHub Actions +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + + # Maintain submodule versions so module releases propagate into the meta-repo + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "monthly" + + # Maintain dependencies for pip/poetry + # NOTE: too noisy, easier to update by hand + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 0 + ignore: + # pytest 9.0.3 is the first non-vulnerable release, but it requires + # Python >=3.10 while ActivityWatch still builds/releases on Python 3.9. + - dependency-name: "pytest" + versions: [">=9"] diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..561ebe4 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,19 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 365 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 14 +# Issues with these labels will never be considered stale +exemptLabels: + - '!pinned' + - 'priority: high' + - 'improves: security' + - 'improves: sustainability' +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9be0d81 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "57 14 * * 4" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python, javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..f527a2d --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,27 @@ +name: Dependabot Auto-merge + +# NOTE: This workflow relies on a Personal Access Token from the @ActivityWatchBot user +# See this issue for details: https://github.com/ridedott/merge-me-action/issues/1581 + +on: + workflow_run: + types: + - completed + workflows: + # List all required workflow names here. + - Build + +permissions: + contents: write + pull-requests: read + +jobs: + auto_merge: + name: Auto-merge + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' && github.actor == 'dependabot[bot]' + + steps: + - uses: ridedott/merge-me-action@e45e8ab1dd3d3dc1d27b8c0a3f465e40e8c9fb8e # v2 + with: + GITHUB_TOKEN: ${{ secrets.AWBOT_GH_TOKEN }} diff --git a/.github/workflows/diagram.yml b/.github/workflows/diagram.yml new file mode 100644 index 0000000..1735164 --- /dev/null +++ b/.github/workflows/diagram.yml @@ -0,0 +1,28 @@ +name: Diagram + +on: + workflow_dispatch: {} + push: + branches: + - diagram + #- master # protected branch, can't push updated diagram to + +jobs: + update-diagram: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + submodules: recursive + - name: Also checkout docs & website + run: | + git clone https://github.com/ActivityWatch/docs + git clone https://github.com/ActivityWatch/activitywatch.github.io + - name: Update diagram + uses: githubocto/repo-visualizer@a999615bdab757559bf94bda1fe6eef232765f85 # main + with: + commit_message: 'chore: update diagram [skip ci]' + file_colors: '{"rs": "#b7410e", "py": "#229922", "rst": "pink", "txt": "pink", "md": "pink", "css": "purple", "scss": "purple"}' + excluded_globs: "**/.github;**/.git;**/*.builds;**/*.bat;**/*.iss;**/*.ps1;**/*.pyi;**/*.plist;**/*.cmd" + #excluded_paths: '.github' diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml new file mode 100644 index 0000000..0686419 --- /dev/null +++ b/.github/workflows/greetings.yml @@ -0,0 +1,25 @@ +name: Greetings + +on: [issues, pull_request] + +jobs: + greeting: + runs-on: ubuntu-latest + if: github.repository_owner == 'ActivityWatch' # don't run on forks + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: > + Hi there! + + As you're new to this repo, please make sure you've used an appropriate [issue template](https://github.com/ActivityWatch/activitywatch/issues/new/choose) and searched for duplicates (it helps us focus on actual development!). + We'd also like to suggest that you read our [contribution guidelines](https://github.com/ActivityWatch/activitywatch/blob/master/CONTRIBUTING.md) and our [code of conduct](https://github.com/ActivityWatch/activitywatch/blob/master/CODE_OF_CONDUCT.md). + + Thanks a bunch for opening your first issue! 🙏 + pr-message: > + Congratulations on opening your first pull request to this repo! + + We'll get back to you as soon as possible. In the meantime, please make sure you've read our [contribution guidelines](https://github.com/ActivityWatch/activitywatch/blob/master/CONTRIBUTING.md). + + Thanks for contributing! diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0065930 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,1471 @@ +name: Release + +# Unified release/build pipeline for ActivityWatch. +# +# Triggers: +# - schedule / workflow_dispatch: decide whether to create a new dev prerelease tag +# - push / pull_request: run the normal build matrices +# - tag push: run both build matrices, generate release notes, and publish one draft release + +on: + schedule: + - cron: '0 12 * * 4' + workflow_dispatch: + inputs: + release_line: + description: 'Release line to prerelease from' + required: true + default: patch + type: choice + options: + - patch + - minor + edition: + description: 'Build edition: research enables privacy filter in aw-watcher-window by default' + required: false + type: choice + options: + - standard + - research + default: standard + push: + branches: [master] + tags: + - v* + pull_request: + branches: [master] + +permissions: + contents: write + actions: read + checks: read + +jobs: + preflight: + name: Pre-flight checks + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + concurrency: + group: dev-release + cancel-in-progress: false + outputs: + should_release: ${{ steps.preflight.outputs.should_release }} + next_tag: ${{ steps.preflight.outputs.next_tag }} + since_ref: ${{ steps.preflight.outputs.since_ref }} + commits_since_ref: ${{ steps.preflight.outputs.commits_since_ref }} + head_sha: ${{ steps.preflight.outputs.head_sha }} + steps: + - uses: actions/checkout@v7 + with: + ref: master + fetch-depth: 0 + + - name: Decide whether to create a dev release + id: preflight + env: + GH_TOKEN: ${{ github.token }} + RELEASE_LINE: ${{ github.event.inputs.release_line || 'patch' }} + EDITION: ${{ github.event.inputs.edition || 'standard' }} + run: | + set -euo pipefail + + if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then + ref_epoch=$(date -d "2024-01-04" +%s) + now_epoch=$(date -u +%s) + weeks_since=$(( (now_epoch - ref_epoch) / 604800 )) + if [ $((weeks_since % 2)) -eq 1 ]; then + echo "Skipping this week to keep the cadence biweekly." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + bump_version() { + local version="$1" release_line="$2" + IFS='.' read -r major minor patch <<< "$version" + if [ "$release_line" = "minor" ]; then + minor=$((minor + 1)) + patch=0 + else + patch=$((patch + 1)) + fi + echo "${major}.${minor}.${patch}" + } + + latest_stable=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true) + if [ -z "$latest_stable" ]; then + echo "No stable tag found, refusing to create prerelease tags." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + next_base=$(bump_version "${latest_stable#v}" "$RELEASE_LINE") + + # If a prerelease for a version ahead of next_base already exists, + # continue that release line instead of starting a new one from the + # stable bump. Example: latest_stable=v0.13.2 + patch -> v0.13.3, + # but if v0.14.0b1 exists, produce v0.14.0b2 instead. + highest_prerelease=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$' | head -1 || true) + if [ -n "$highest_prerelease" ]; then + hp_base="${highest_prerelease%%b*}" + hp_base="${hp_base#v}" + if [ "$(printf '%s\n%s\n' "$hp_base" "$next_base" | sort -V | tail -1)" = "$hp_base" ] && \ + [ "$hp_base" != "$next_base" ]; then + echo "Existing prerelease $highest_prerelease is ahead of computed v${next_base}; continuing that release line." + next_base="$hp_base" + fi + fi + + prerelease_pattern="^v${next_base//./\\.}b[0-9]+$" + last_prerelease=$(git tag --sort=-version:refname | grep -E "$prerelease_pattern" | head -1 || true) + + if [ -n "$last_prerelease" ]; then + since_ref="$last_prerelease" + last_prerelease_num=${last_prerelease##*b} + next_tag="v${next_base}b$((last_prerelease_num + 1))" + else + since_ref="$latest_stable" + next_tag="v${next_base}b1" + fi + + if [ "$EDITION" = "research" ]; then + # A research release is the current standard prerelease lineage + # with an edition suffix. It must not consume the next standard bN. + if [ -n "$last_prerelease" ]; then + next_tag="${last_prerelease}-research" + else + next_tag="${next_tag}-research" + fi + if git rev-parse -q --verify "refs/tags/${next_tag}" >/dev/null; then + echo "Research tag $next_tag already exists; skipping duplicate release." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + + commits_since_ref=$(git rev-list "${since_ref}..HEAD" --count) + echo "latest_stable=$latest_stable" + echo "last_prerelease=${last_prerelease:-}" + echo "since_ref=$since_ref" + echo "next_tag=$next_tag" + echo "commits_since_ref=$commits_since_ref" + + if [ "$commits_since_ref" -eq 0 ]; then + echo "No new commits since $since_ref, skipping dev release." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + head_sha=$(git rev-parse HEAD) + + current_suite_id=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --jq '.check_suite_id' 2>/dev/null || echo "0") + + # Auxiliary workflow jobs are not CI signals; exclude them so their + # skipped or failed conclusions do not block the release. + conclusions=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${head_sha}/check-runs" \ + --paginate \ + --slurp 2>/dev/null | jq -r --arg suite "$current_suite_id" ' + [.[].check_runs[]? + | select( + .app.slug == "github-actions" and + ((.check_suite.id | tostring) != $suite) and + (.name | test("^(Dependabot|Auto-merge|greeting|Pre-flight checks|Create dev release tag)$") | not) + ) + | .conclusion] + | unique + | .[] + ' 2>/dev/null || echo unknown) + + echo "CI conclusions: $conclusions" + + if echo "$conclusions" | grep -qE 'failure|action_required|timed_out|cancelled|startup_failure'; then + echo "CI has failures on HEAD, skipping dev release." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if echo "$conclusions" | grep -qE 'null|pending'; then + echo "CI is still running on HEAD, skipping dev release." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -z "$conclusions" ] || [ "$conclusions" = "unknown" ]; then + echo "CI status unavailable on HEAD, skipping dev release." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! echo "$conclusions" | grep -q 'success'; then + echo "No successful CI checks found on HEAD, skipping dev release." + echo "should_release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "should_release=true" >> "$GITHUB_OUTPUT" + echo "next_tag=$next_tag" >> "$GITHUB_OUTPUT" + echo "since_ref=$since_ref" >> "$GITHUB_OUTPUT" + echo "commits_since_ref=$commits_since_ref" >> "$GITHUB_OUTPUT" + echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" + + create-tag: + name: Create dev release tag + needs: preflight + if: needs.preflight.outputs.should_release == 'true' + runs-on: ubuntu-latest + concurrency: + group: dev-release + cancel-in-progress: false + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.preflight.outputs.head_sha }} + fetch-depth: 1 + token: ${{ secrets.AWBOT_GH_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create and push prerelease tag + run: | + set -euo pipefail + tag="${{ needs.preflight.outputs.next_tag }}" + msg="Development prerelease $tag" + if [[ "$tag" == *-research ]]; then + msg="Development prerelease $tag (research edition)" + fi + git tag -a "$tag" -m "$msg" + git push origin "$tag" + { + echo "## Dev release created" + echo "" + echo "- Tag: \`$tag\`" + if [[ "$tag" == *-research ]]; then + echo "- Edition: research" + fi + echo "- Changes since: \`${{ needs.preflight.outputs.since_ref }}\`" + echo "- Commits: \`${{ needs.preflight.outputs.commits_since_ref }}\`" + echo "" + echo "The tag-triggered build jobs in this workflow will now build artifacts and create/update the draft prerelease." + } >> "$GITHUB_STEP_SUMMARY" + + research-edition-checks: + name: Research Edition — patch and packaging smoke tests + # Run on every PR and push so drifted submodule pins fail at review time, + # not when Erik pushes the annotated release tag. The patcher is stdlib-only + # so no poetry/build step is needed; only submodules (real source files) and + # pytest (for the unit-test fixture suite). + if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + with: + submodules: 'recursive' + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.9' + + - name: Install pytest + run: pip install pytest + + # Verify every patch target string exists exactly once in the real + # submodule sources for both build flavours. Fails immediately if a + # target is missing or ambiguous — the same guard that would abort a tag + # build, now running at PR time. + - name: Verify Qt patch targets (--check) + run: python3 scripts/patch_research_edition_profile.py qt --check + + - name: Verify Tauri patch targets (--check) + run: python3 scripts/patch_research_edition_profile.py tauri --check + + # Fixture-based unit tests: patchers apply correctly on synthetic trees, + # fail closed on missing/ambiguous tokens, and leave ordinary defaults + # untouched on the unpatched tree. Also exercise the Windows installer + # collection step with fixture .exe files named by the real patched .iss + # sources; this catches naming drift without Inno Setup or signing. + - name: Run profile patcher and installer collection tests + run: python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q + + - name: Test updater channel isolation and release versions + run: python3 -m pytest scripts/tests/test_generate_latest_json.py scripts/tests/test_configure_tauri_release.py -q + + build-qt: + name: Build Qt artifacts + if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} + env: + AW_EXTRAS: true + MACOSX_DEPLOYMENT_TARGET: "12.0" + AW_RESEARCH_EDITION: ${{ matrix.research || (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }} + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + # Build Linux Qt/AppImage artifacts on the oldest supported runner to + # keep the bundled glibc baseline compatible with older distributions. + os: [ubuntu-22.04, windows-latest, macos-latest, macos-15-intel] + python_version: [3.9] + node_version: [22] + skip_rust: [false] + skip_webui: [false] + experimental: [false] + # Research Edition build leg: apply the research patch set and + # compile/test/package the *patched* tree on every PR and branch push, + # so a drifted patch target or a patched-source compile failure dies at + # PR time instead of at tag time. Linux only: cheapest full build and no + # signing secrets. On tag pushes and research dispatches `research` + # resolves to false, so this row merges into the existing ubuntu-22.04 row + # instead of adding a duplicate leg -- research tags already build the + # research edition in every row, and standard tags must not upload + # research artifacts to the standard release. + research: [false] + include: + - os: ubuntu-22.04 + python_version: 3.9 + node_version: 22 + skip_rust: false + skip_webui: false + experimental: false + research: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + + steps: + - uses: actions/checkout@v7 + with: + submodules: 'recursive' + fetch-depth: 0 + + - name: Set RELEASE + run: | + echo "RELEASE=${{ startsWith(github.ref_name, 'v') || github.ref_name == 'master' }}" >> "$GITHUB_ENV" + + - name: Set tag metadata + if: startsWith(github.ref, 'refs/tags/v') + run: | + echo "VERSION_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV" + + - name: Determine and output version + run: | + VERSION_WITH_V=$(bash scripts/package/getversion.sh) + # Strip the research tag suffix for filenames: the edition gets its + # own token (activitywatch[-tauri][-research]--...), so the + # version part stays a plain version. + VERSION_WITH_V="${VERSION_WITH_V%-research}" + VERSION_NO_V="${VERSION_WITH_V#v}" + echo "VERSION_WITH_V=${VERSION_WITH_V}" >> "$GITHUB_ENV" + echo "VERSION_NO_V=${VERSION_NO_V}" >> "$GITHUB_ENV" + echo "========================================" + echo "Build Version Information" + echo "========================================" + echo "GitHub ref: ${{ github.ref }}" + echo "GitHub ref_name: ${{ github.ref_name }}" + echo "Version (with v): ${VERSION_WITH_V}" + echo "Version (no v): ${VERSION_NO_V}" + echo "========================================" + + - name: Verify aw-server-rust submodule version matches release tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + # Fail fast if the aw-server-rust submodule is pinned to an older + # release line than the one we're tagging. The Windows 0.14 Tauri + # bundle shipped with aw-server-rust v0.13.1 (activitywatch#1380); + # catch the same class of mismatch here before spending ~30 min on a + # full Tauri build. + # + # The bundled binary's version lives in + # aw-server-rust/aw-server/Cargo.toml (the workspace-level + # aw-server-rust/Cargo.toml has no [package].version field). + BUNDLED_VERSION=$(grep -m1 '^version = ' aw-server-rust/aw-server/Cargo.toml | sed -E 's/^version = "(.*)".*/\1/') + if [ -z "$BUNDLED_VERSION" ]; then + echo "ERROR: could not read aw-server version from aw-server-rust/aw-server/Cargo.toml" >&2 + exit 1 + fi + + AW_VERSION="${VERSION_NO_V}" # e.g. "0.14.0b3" + if [ -z "$AW_VERSION" ]; then + echo "ERROR: VERSION_NO_V is empty — the 'Determine and output version' step must export it to GITHUB_ENV" >&2 + exit 1 + fi + AW_MAJOR_MINOR=$(echo "$AW_VERSION" | cut -d'.' -f1-2) # "0.14" + AWS_MAJOR_MINOR=$(echo "$BUNDLED_VERSION" | cut -d'.' -f1-2) # "0.14" + + echo "AW release tag: ${AW_VERSION} (major.minor: ${AW_MAJOR_MINOR})" + echo "Bundled aw-server: ${BUNDLED_VERSION} (major.minor: ${AWS_MAJOR_MINOR})" + + if [ "$AW_MAJOR_MINOR" != "$AWS_MAJOR_MINOR" ]; then + echo "" + echo "ERROR: aw-server-rust major.minor (${AWS_MAJOR_MINOR}) does not match" + echo " AW release major.minor (${AW_MAJOR_MINOR})." + echo " The aw-server-rust submodule is stale for this tag." + echo " Update the submodule (cd aw-server-rust && git pull) and re-tag." + exit 1 + fi + echo "OK: aw-server version ${BUNDLED_VERSION} is consistent with AW release ${AW_VERSION}" + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python_version }} + + - name: Set up Node + if: ${{ !matrix.skip_webui }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node_version }} + + - name: Set up Rust + if: ${{ !matrix.skip_rust }} + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + id: toolchain + with: + toolchain: stable + + - name: Cache node_modules + uses: actions/cache@v6 + if: ${{ !matrix.skip_webui }} + with: + path: aw-server-rust/aw-webui/node_modules + key: ${{ matrix.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ matrix.os }}-node_modules- + + - name: Cache cargo build + uses: actions/cache@v6 + env: + cache-name: cargo-build-target + with: + path: aw-server-rust/target + key: ${{ matrix.os }}-${{ env.cache-name }}-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ matrix.os }}-${{ env.cache-name }}-${{ steps.toolchain.outputs.rustc_hash }}- + + - name: Install APT dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + appstream \ + qt5-qmake \ + qtbase5-dev \ + qtwayland5 \ + libqt5x11extras5 \ + libfontconfig1 \ + libxcb1 \ + libfontconfig1-dev \ + libfreetype6-dev \ + libx11-dev \ + libxcursor-dev \ + libxext-dev \ + libxfixes-dev \ + libxft-dev \ + libxi-dev \ + libxrandr-dev \ + libxrender-dev + + - name: Install dependencies + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + choco install innosetup + fi + pip3 install poetry==2.2.1 + + - name: Patch research edition defaults + if: env.AW_RESEARCH_EDITION == 'true' + run: | + python3 scripts/patch_research_edition_config.py aw-watcher-window/aw_watcher_window/config.py + # Participants' machines must resume tracking after reboots without + # setup steps: enable start-at-login on first launch (aw-qt#131). + python3 scripts/patch_research_edition_awqt.py aw-qt/aw_qt/config.py + + # Watchers store buckets as `{client}_{hostname}`. That hostname is a + # participant identifier on named machines, and /api/0/export copies it + # into the map key, embedded id, and hostname field. Standard builds + # must not change; this patch is Research-only and fail-closed. + - name: Patch research edition export hostname sanitizer + if: env.AW_RESEARCH_EDITION == 'true' + run: python3 scripts/patch_research_edition_export.py + + # The watcher keeps approved app names and replaces browser titles with a + # study category. This preset maps both representations into the study + # taxonomy so Top Applications and Top Categories agree. + - name: Emit research edition category preset for the web UI + if: env.AW_RESEARCH_EDITION == 'true' + shell: bash + run: | + preset="$(python3 scripts/emit_research_category_preset.py)" + echo "AW_PRESET_CATEGORY_SETS=${preset}" >> "$GITHUB_ENV" + + - name: Build + run: | + python3 -m venv venv + source venv/bin/activate || source venv/Scripts/activate + poetry install + make build SKIP_WEBUI=${{ matrix.skip_webui }} SKIP_SERVER_RUST=${{ matrix.skip_rust }} + pip freeze + + - name: Run tests + run: | + source venv/bin/activate || source venv/Scripts/activate + make test SKIP_SERVER_RUST=${{ matrix.skip_rust }} + + - name: Run integration tests + if: runner.os != 'Windows' + run: | + source venv/bin/activate || source venv/Scripts/activate + make test-integration + + # The patcher's exact-match table is release-critical. Run it on every + # PR/release job (not only research tags) so a drifted submodule pin + # fails before packaging. Must stay on the unpatched tree: --check + # and the fail-closed tests assert the ordinary defaults. + - name: Test research edition profile patcher + run: | + source venv/bin/activate || source venv/Scripts/activate + python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q + + # Research Edition profile identity is baked AFTER the module test + # suites: they assert the ordinary defaults (profile=default, port 5600) + # and would fail on the patched tree. Python modules are editable + # installs, so PyInstaller collects the patched source at package time; + # the Rust binaries are rebuilt below. Fail-closed: a stale submodule pin + # aborts the build rather than shipping a bundle that runs the default + # profile next to a participant's standard install. + - name: Patch research edition profile identity + if: env.AW_RESEARCH_EDITION == 'true' + run: python3 scripts/patch_research_edition_profile.py qt + + - name: Rebuild aw-server-rust with research defaults + if: env.AW_RESEARCH_EDITION == 'true' && matrix.skip_rust != true + run: | + source venv/bin/activate || source venv/Scripts/activate + make --directory=aw-server-rust aw-server SKIP_WEBUI=true + + - name: Package + run: | + source venv/bin/activate || source venv/Scripts/activate + poetry install + make package SKIP_SERVER_RUST=${{ matrix.skip_rust }} + + - name: Package dmg + if: runner.os == 'macOS' + run: | + SIGN_MACOS=false + if [[ "${GITHUB_REF:-}" == refs/tags/v* || "$AW_RESEARCH_EDITION" == "true" ]]; then + SIGN_MACOS=true + fi + + if [ "$SIGN_MACOS" = true ]; then + for var in APPLE_EMAIL APPLE_PASSWORD APPLE_TEAMID CERTIFICATE_MACOS_P12_BASE64 CERTIFICATE_MACOS_P12_PASSWORD; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is required to sign macOS release artifacts" >&2 + exit 1 + fi + done + ./scripts/ci/import-macos-p12.sh + APPLE_PERSONALID=$(security find-identity -v -p codesigning "${KEY_CHAIN:-build.keychain-db}" | sed -n 's/.*"\(.*\)"/\1/p' | head -1) + if [ -z "$APPLE_PERSONALID" ]; then + echo "ERROR: no codesigning identity found after importing the macOS certificate" >&2 + exit 1 + fi + export APPLE_PERSONALID + fi + + BUNDLE="ActivityWatch" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then + BUNDLE="ActivityWatch-Research" + fi + + source venv/bin/activate + make "dist/${BUNDLE}.dmg" APP_BUNDLE="${BUNDLE}" + + if [ "$SIGN_MACOS" = true ]; then + codesign --force --verbose --timestamp -s "${APPLE_PERSONALID}" "dist/${BUNDLE}.dmg" + + brew install akeru-inc/tap/xcnotary + xcnotary precheck "dist/${BUNDLE}.app" + xcnotary precheck "dist/${BUNDLE}.dmg" + + make dist/notarize + fi + EDITION="" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi + mv "dist/${BUNDLE}.dmg" dist/activitywatch${EDITION}-${VERSION_WITH_V}-macos-$(uname -m).dmg + env: + APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_PERSONALID: "" + APPLE_TEAMID: ${{ secrets.APPLE_TEAMID }} + CERTIFICATE_MACOS_P12_BASE64: ${{ secrets.CERTIFICATE_MACOS_P12_BASE64 }} + CERTIFICATE_MACOS_P12_PASSWORD: ${{ secrets.CERTIFICATE_MACOS_P12_PASSWORD }} + + - name: Package AppImage + if: startsWith(runner.os, 'linux') + run: ./scripts/package/package-appimage.sh + + - name: Package deb + if: startsWith(runner.os, 'linux') + run: ./scripts/package/package-deb.sh + + - name: Upload packages + uses: actions/upload-artifact@v7 + with: + name: builds-${{ matrix.os }}-py${{ matrix.python_version }}${{ matrix.research && '-research' || '' }} + path: dist/activitywatch-*.* + + build-qt-manylinux-2-28: + name: Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) + if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-22.04 + container: + image: quay.io/pypa/manylinux_2_28_x86_64 + env: + AW_EXTRAS: true + AW_RESEARCH_EDITION: ${{ (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }} + # AppImage tools (linuxdeploy/appimagetool) are themselves AppImages; + # FUSE is unavailable inside Docker containers, so use extract-and-run. + APPIMAGE_EXTRACT_AND_RUN: 1 + # aw-sync enables openssl/vendored on Linux and tries to build OpenSSL + # from source; use the container's system openssl-devel instead. + OPENSSL_NO_VENDOR: 1 + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + python_version: [3.9] + node_version: [22] + + steps: + - uses: actions/checkout@v7 + with: + submodules: 'recursive' + fetch-depth: 0 + + # Configure git safe.directory immediately after checkout so that every + # subsequent step — including the version probe below — can read the full + # history. Deferring this step past the first `git describe` call causes + # the version probe to fall back to v0.0.0.dev-unknown. + - name: Configure git safe directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Set RELEASE + run: | + echo "RELEASE=${{ startsWith(github.ref_name, 'v') || github.ref_name == 'master' }}" >> "$GITHUB_ENV" + + - name: Set tag metadata + if: startsWith(github.ref, 'refs/tags/v') + run: | + echo "VERSION_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV" + + - name: Determine and output version + run: | + VERSION_WITH_V=$(bash scripts/package/getversion.sh) + # Strip the research tag suffix for filenames: the edition gets its + # own token (activitywatch[-research]--...), so the version + # part stays a plain version string. + VERSION_WITH_V="${VERSION_WITH_V%-research}" + VERSION_NO_V="${VERSION_WITH_V#v}" + echo "VERSION_WITH_V=${VERSION_WITH_V}" >> "$GITHUB_ENV" + echo "VERSION_NO_V=${VERSION_NO_V}" >> "$GITHUB_ENV" + echo "========================================" + echo "Build Version Information" + echo "========================================" + echo "GitHub ref: ${{ github.ref }}" + echo "GitHub ref_name: ${{ github.ref_name }}" + echo "Version (with v): ${VERSION_WITH_V}" + echo "Version (no v): ${VERSION_NO_V}" + echo "========================================" + + - name: Verify aw-server-rust submodule version matches release tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + # Fail fast if the aw-server-rust submodule is pinned to an older + # release line than the one we're tagging. Same check as in + # build-qt and build-qt-tauri — prevents shipping a manylinux + # bundle with a mismatched aw-server-rust binary. + # + # The bundled binary's version lives in + # aw-server-rust/aw-server/Cargo.toml (the workspace-level + # aw-server-rust/Cargo.toml has no [package].version field). + BUNDLED_VERSION=$(grep -m1 '^version = ' aw-server-rust/aw-server/Cargo.toml | sed -E 's/^version = "(.*)".*/\1/') + if [ -z "$BUNDLED_VERSION" ]; then + echo "ERROR: could not read aw-server version from aw-server-rust/aw-server/Cargo.toml" >&2 + exit 1 + fi + + AW_VERSION="${VERSION_NO_V}" # e.g. "0.14.0b3" + if [ -z "$AW_VERSION" ]; then + echo "ERROR: VERSION_NO_V is empty — the 'Determine and output version' step must export it to GITHUB_ENV" >&2 + exit 1 + fi + AW_MAJOR_MINOR=$(echo "$AW_VERSION" | cut -d'.' -f1-2) # "0.14" + AWS_MAJOR_MINOR=$(echo "$BUNDLED_VERSION" | cut -d'.' -f1-2) # "0.14" + + echo "AW release tag: ${AW_VERSION} (major.minor: ${AW_MAJOR_MINOR})" + echo "Bundled aw-server: ${BUNDLED_VERSION} (major.minor: ${AWS_MAJOR_MINOR})" + + if [ "$AW_MAJOR_MINOR" != "$AWS_MAJOR_MINOR" ]; then + echo "" + echo "ERROR: aw-server-rust major.minor (${AWS_MAJOR_MINOR}) does not match" + echo " AW release major.minor (${AW_MAJOR_MINOR})." + echo " The aw-server-rust submodule is stale for this tag." + echo " Update the submodule (cd aw-server-rust && git pull) and re-tag." + exit 1 + fi + echo "OK: aw-server version ${BUNDLED_VERSION} is consistent with AW release ${AW_VERSION}" + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node_version }} + + - name: Set up Rust + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + id: toolchain + with: + toolchain: stable + + - name: Cache node_modules + uses: actions/cache@v6 + with: + path: aw-server-rust/aw-webui/node_modules + key: manylinux_2_28-node_modules-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + manylinux_2_28-node_modules- + + - name: Cache cargo build + uses: actions/cache@v6 + env: + cache-name: cargo-build-target + with: + path: aw-server-rust/target + key: manylinux_2_28-${{ env.cache-name }}-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + manylinux_2_28-${{ env.cache-name }}-${{ steps.toolchain.outputs.rustc_hash }}- + + - name: Install Linux dependencies + run: | + dnf install -y epel-release + dnf install -y \ + qt5-qtbase-devel \ + qt5-qtx11extras \ + qt5-qtwayland \ + fontconfig-devel \ + freetype-devel \ + libX11-devel \ + libXcursor-devel \ + libXext-devel \ + libXfixes-devel \ + libXft-devel \ + libXi-devel \ + libXrandr-devel \ + libXrender-devel \ + libxcb-devel \ + openssl-devel \ + kernel-headers \ + gcc \ + gcc-c++ \ + python39-devel \ + python39-libs \ + squashfs-tools \ + zip \ + wget + + - name: Set up Python PATH + run: | + # Prepend the manylinux CPython 3.9 so `python3` resolves to the + # container's build Python, which ships headers and a shared + # libpython needed by PyInstaller. + echo "/opt/python/cp39-cp39/bin" >> "$GITHUB_PATH" + echo "LD_LIBRARY_PATH=/usr/lib64:${LD_LIBRARY_PATH:-}" >> "$GITHUB_ENV" + + - name: Install Python tooling + run: python3 -m pip install poetry==2.2.1 + + - name: Patch research edition defaults + if: env.AW_RESEARCH_EDITION == 'true' + run: | + python3 scripts/patch_research_edition_config.py aw-watcher-window/aw_watcher_window/config.py + # Participants' machines must resume tracking after reboots without + # setup steps: enable start-at-login on first launch (aw-qt#131). + python3 scripts/patch_research_edition_awqt.py aw-qt/aw_qt/config.py + + - name: Patch research edition export hostname sanitizer + if: env.AW_RESEARCH_EDITION == 'true' + run: python3 scripts/patch_research_edition_export.py + + - name: Emit research edition category preset for the web UI + if: env.AW_RESEARCH_EDITION == 'true' + run: | + preset="$(python3 scripts/emit_research_category_preset.py)" + echo "AW_PRESET_CATEGORY_SETS=${preset}" >> "$GITHUB_ENV" + + - name: Build + run: | + python3 -m venv venv + source venv/bin/activate + poetry install + make build + pip freeze + + - name: Run tests + run: | + source venv/bin/activate + make test + + - name: Run integration tests + run: | + source venv/bin/activate + make test-integration + + # The patcher's exact-match table is release-critical. Run it on every + # PR/release job (not only research tags) so a drifted submodule pin + # fails before packaging. Must stay on the unpatched tree: --check + # and the fail-closed tests assert the ordinary defaults. + - name: Test research edition profile patcher + run: | + source venv/bin/activate + python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q + + # Research Edition profile identity is baked AFTER the module test + # suites: they assert the ordinary defaults (profile=default, port 5600) + # and would fail on the patched tree. Python modules are editable + # installs, so PyInstaller collects the patched source at package time; + # the Rust binaries are rebuilt below. Fail-closed: a stale submodule pin + # aborts the build rather than shipping a bundle that runs the default + # profile next to a participant's standard install. + - name: Patch research edition profile identity + if: env.AW_RESEARCH_EDITION == 'true' + run: python3 scripts/patch_research_edition_profile.py qt + + - name: Rebuild aw-server-rust with research defaults + if: env.AW_RESEARCH_EDITION == 'true' + run: | + source venv/bin/activate + make --directory=aw-server-rust aw-server SKIP_WEBUI=true + + - name: Package + run: | + source venv/bin/activate + poetry install + make package + + - name: Package AppImage + run: ./scripts/package/package-appimage.sh + + - name: Rename artifacts with manylinux_2_28 suffix + run: | + EDITION="" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi + # ZIP produced by `make package` + ZIP_SRC="dist/activitywatch${EDITION}-${VERSION_WITH_V}-linux-x86_64.zip" + ZIP_DST="dist/activitywatch${EDITION}-${VERSION_WITH_V}-linux-x86_64-manylinux_2_28.zip" + [ -f "$ZIP_SRC" ] && mv -v "$ZIP_SRC" "$ZIP_DST" + # AppImage produced by package-appimage.sh (intentionally unversioned) + AI_SRC="dist/activitywatch${EDITION}-linux-x86_64.AppImage" + AI_DST="dist/activitywatch${EDITION}-linux-x86_64-manylinux_2_28.AppImage" + [ -f "$AI_SRC" ] && mv -v "$AI_SRC" "$AI_DST" + + - name: ABI gate — verify glibc 2.28 floor + run: | + bash scripts/package/abi-gate.sh \ + dist/activitywatch*-manylinux_2_28.zip \ + dist/activitywatch*-manylinux_2_28.AppImage + + - name: Upload packages + uses: actions/upload-artifact@v7 + with: + name: builds-manylinux_2_28-qt-py${{ matrix.python_version }} + path: dist/activitywatch-*.* + + build-tauri: + name: Build Tauri artifacts + if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} + env: + AW_EXTRAS: true + TAURI_BUILD: true + MACOSX_DEPLOYMENT_TARGET: "12.0" + AW_RESEARCH_EDITION: ${{ matrix.research || (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }} + # All subprojects share one virtualenv. Poetry's parallel installer can + # race while replacing the same dependency from different lock files. + POETRY_INSTALLER_PARALLEL: "false" + defaults: + run: + shell: bash + strategy: + fail-fast: false + max-parallel: 5 + matrix: + os: + [ + ubuntu-24.04, + ubuntu-24.04-arm, + windows-latest, + macos-latest, + macos-15-intel, + ] + python_version: [3.9] + node_version: [22] + skip_rust: [false] + skip_webui: [false] + experimental: [false] + # Research Edition build leg: apply the research patch set and + # compile/test/package the *patched* tree on every PR and branch push, + # so a drifted patch target or a patched-source compile failure dies at + # PR time instead of at tag time. Linux only: cheapest full build and no + # signing secrets. On tag pushes and research dispatches `research` + # resolves to false, so this row merges into the existing ubuntu-24.04 row + # instead of adding a duplicate leg -- research tags already build the + # research edition in every row, and standard tags must not upload + # research artifacts to the standard release. + research: [false] + include: + - os: ubuntu-24.04 + python_version: 3.9 + node_version: 22 + skip_rust: false + skip_webui: false + experimental: false + research: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }} + + steps: + - uses: actions/checkout@v7 + with: + submodules: "recursive" + fetch-depth: 0 + + - name: Set environment variables + run: | + echo "RELEASE=${{ startsWith(github.ref_name, 'v') || github.ref_name == 'master' }}" >> "$GITHUB_ENV" + echo "TAURI_BUILD=true" >> "$GITHUB_ENV" + + - name: Set tag metadata + if: startsWith(github.ref, 'refs/tags/v') + run: | + echo "VERSION_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV" + + - name: Determine and output version + run: | + VERSION_WITH_V=$(bash scripts/package/getversion.sh) + # Strip the research tag suffix for filenames: the edition gets its + # own token (activitywatch[-tauri][-research]--...), so the + # version part stays a plain version. + VERSION_WITH_V="${VERSION_WITH_V%-research}" + VERSION_NO_V="${VERSION_WITH_V#v}" + echo "VERSION_WITH_V=${VERSION_WITH_V}" >> "$GITHUB_ENV" + echo "VERSION_NO_V=${VERSION_NO_V}" >> "$GITHUB_ENV" + echo "========================================" + echo "Build Version Information (Tauri)" + echo "========================================" + echo "GitHub ref: ${{ github.ref }}" + echo "GitHub ref_name: ${{ github.ref_name }}" + echo "Version (with v): ${VERSION_WITH_V}" + echo "Version (no v): ${VERSION_NO_V}" + echo "========================================" + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python_version }} + + - name: Verify Tauri and Qt embed the same aw-server-rust revision + # Tauri embeds the Git revision in its Cargo.lock, independently of + # the top-level submodule used by Qt. Check the actual build inputs; + # checking only the submodule allowed v0.14.0b4 Tauri to ship 0.13.1. + run: python3 scripts/check_tauri_server.py "$VERSION_NO_V" + + - name: Set up Node + if: ${{ !matrix.skip_webui }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node_version }} + + - name: Set up Rust + if: ${{ !matrix.skip_rust }} + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master + id: toolchain + with: + toolchain: stable + + - name: Cache node_modules + uses: actions/cache@v6 + if: ${{ !matrix.skip_webui }} + with: + path: | + aw-server-rust/aw-webui/node_modules + aw-tauri/node_modules + key: ${{ matrix.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ matrix.os }}-node_modules- + + - name: Cache cargo build + uses: actions/cache@v6 + env: + cache-name: cargo-build-target + with: + path: | + aw-server-rust/target + aw-tauri/src-tauri/target + key: ${{ matrix.os }}-${{ env.cache-name }}-${{ steps.toolchain.outputs.cachekey }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ matrix.os }}-${{ env.cache-name }}-${{ steps.toolchain.outputs.rustc_hash }}- + + - name: Install APT dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libsoup-3.0-dev \ + xdg-utils + # Pin WebKit to avoid blank-webview regression on newer ubuntu-24.04 packages. + # See: ActivityWatch/aw-tauri#99 + sudo apt-get install -y \ + libwebkit2gtk-4.1-0=2.44.0-2 \ + libwebkit2gtk-4.1-dev=2.44.0-2 \ + libjavascriptcoregtk-4.1-0=2.44.0-2 \ + libjavascriptcoregtk-4.1-dev=2.44.0-2 \ + gir1.2-javascriptcoregtk-4.1=2.44.0-2 \ + gir1.2-webkit2-4.1=2.44.0-2 + + - name: Install dependencies + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + choco install innosetup + fi + pip3 install poetry==2.2.1 + + - name: Patch research edition defaults + if: env.AW_RESEARCH_EDITION == 'true' + run: python3 scripts/patch_research_edition_config.py aw-watcher-window/aw_watcher_window/config.py + + # Watchers store buckets as `{client}_{hostname}`. That hostname is a + # participant identifier on named machines, and /api/0/export copies it + # into the map key, embedded id, and hostname field. Standard builds + # must not change; this patch is Research-only and fail-closed. + - name: Patch research edition export hostname sanitizer + if: env.AW_RESEARCH_EDITION == 'true' + run: python3 scripts/patch_research_edition_export.py + + # The watcher keeps approved app names and replaces browser titles with a + # study category. This preset maps both representations into the study + # taxonomy so Top Applications and Top Categories agree. + - name: Emit research edition category preset for the web UI + if: env.AW_RESEARCH_EDITION == 'true' + shell: bash + run: | + preset="$(python3 scripts/emit_research_category_preset.py)" + echo "AW_PRESET_CATEGORY_SETS=${preset}" >> "$GITHUB_ENV" + + # aw-tauri/Makefile enables bundle.createUpdaterArtifacts through a CLI + # --config override when TAURI_SIGNING_PRIVATE_KEY is set. The JSON file + # alone therefore does not describe whether signed bundles are produced. + - name: Configure Tauri release version and update channel + run: | + args=() + if [ "$AW_RESEARCH_EDITION" = true ]; then + args+=(--research) + if [[ "$GITHUB_REF" == refs/tags/v* || "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + args+=(--require-signing-key) + fi + fi + python3 scripts/package/configure_tauri_release.py \ + --config aw-tauri/src-tauri/tauri.conf.json \ + --version "$VERSION_NO_V" "${args[@]}" + env: + TAURI_UPDATER_PUBLIC_KEY_RESEARCH: ${{ vars.TAURI_UPDATER_PUBLIC_KEY_RESEARCH }} + # Select the secret NAME, never `research_secret || standard_secret`: + # an absent research secret must not fall back to standard signing. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }} + + - name: Build + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 + with: + timeout_minutes: 60 + max_attempts: 3 + shell: bash + command: | + set -e + python3 -m venv venv + source venv/bin/activate || source venv/Scripts/activate + poetry install + make build SKIP_WEBUI=${{ matrix.skip_webui }} SKIP_SERVER_RUST=${{ matrix.skip_rust }} + pip freeze + env: + # aw-tauri/Makefile enables createUpdaterArtifacts when this key is set. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'] }} + + - name: Run tests + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 + with: + timeout_minutes: 60 + max_attempts: 3 + shell: bash + command: | + source venv/bin/activate || source venv/Scripts/activate + make test SKIP_SERVER_RUST=${{ matrix.skip_rust }} + + # The patcher's exact-match table is release-critical. Run it on every + # PR/release job (not only research tags) so a drifted submodule pin + # fails before packaging. Must stay on the unpatched tree: --check + # and the fail-closed tests assert the ordinary defaults. + - name: Test research edition profile patcher + run: | + source venv/bin/activate || source venv/Scripts/activate + python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q + + # Research Edition profile identity is baked AFTER the module test + # suites: they assert the ordinary defaults (profile=default, port 5600) + # and would fail on the patched tree. Python modules are editable + # installs, so PyInstaller collects the patched source at package time; + # the Rust binaries are rebuilt below. Fail-closed: a stale submodule pin + # aborts the build rather than shipping a bundle that runs the default + # profile next to a participant's standard install. + - name: Patch research edition profile identity + if: env.AW_RESEARCH_EDITION == 'true' + run: python3 scripts/patch_research_edition_profile.py tauri + + - name: Rebuild aw-tauri with research defaults + if: env.AW_RESEARCH_EDITION == 'true' + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 + with: + timeout_minutes: 60 + max_attempts: 3 + shell: bash + command: | + set -e + source venv/bin/activate || source venv/Scripts/activate + make --directory=aw-tauri build + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY'] }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets[env.AW_RESEARCH_EDITION == 'true' && 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH' || 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'] }} + + - name: Import macOS signing certificate + if: runner.os == 'macOS' && (startsWith(github.ref, 'refs/tags/v') || env.AW_RESEARCH_EDITION == 'true') + run: | + for var in APPLE_EMAIL APPLE_PASSWORD APPLE_TEAMID CERTIFICATE_MACOS_P12_BASE64 CERTIFICATE_MACOS_P12_PASSWORD; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is required to sign macOS release artifacts" >&2 + exit 1 + fi + done + ./scripts/ci/import-macos-p12.sh + APPLE_PERSONALID=$(security find-identity -v -p codesigning "${KEY_CHAIN:-build.keychain-db}" | sed -n 's/.*"\(.*\)"/\1/p' | head -1) + if [ -z "$APPLE_PERSONALID" ]; then + echo "ERROR: no codesigning identity found after importing the macOS certificate" >&2 + exit 1 + fi + echo "APPLE_PERSONALID=$APPLE_PERSONALID" >> "$GITHUB_ENV" + env: + APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAMID: ${{ secrets.APPLE_TEAMID }} + CERTIFICATE_MACOS_P12_BASE64: ${{ secrets.CERTIFICATE_MACOS_P12_BASE64 }} + CERTIFICATE_MACOS_P12_PASSWORD: ${{ secrets.CERTIFICATE_MACOS_P12_PASSWORD }} + + - name: Package + run: | + source venv/bin/activate || source venv/Scripts/activate + poetry install + make package SKIP_SERVER_RUST=${{ matrix.skip_rust }} + + - name: Package Linux (Tauri bundles) + if: runner.os == 'Linux' + run: | + # aw-tauri's bundler produces .AppImage/.deb/.rpm under + # dist/activitywatch/aw-tauri/ via `make package`. Copy them out with + # versioned, arch-suffixed filenames so they match the + # `dist/activitywatch-*.*` upload pattern below. + ARCH=$(uname -m) + shopt -s nullglob + EDITION="" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi + for ext in AppImage deb rpm; do + files=( dist/activitywatch/aw-tauri/*.${ext} ) + case ${#files[@]} in + 0) continue ;; + 1) cp -v "${files[0]}" "dist/activitywatch-tauri${EDITION}-${VERSION_WITH_V}-linux-${ARCH}.${ext}" ;; + *) echo "ERROR: expected at most 1 .${ext} bundle, found ${#files[@]}" >&2; exit 1 ;; + esac + done + + - name: Package dmg + if: runner.os == 'macOS' + run: | + SIGN_MACOS=false + if [[ "${GITHUB_REF:-}" == refs/tags/v* || "$AW_RESEARCH_EDITION" == "true" ]]; then + SIGN_MACOS=true + fi + + BUNDLE="ActivityWatch" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then + BUNDLE="ActivityWatch-Research" + fi + + source venv/bin/activate + make "dist/${BUNDLE}.dmg" APP_BUNDLE="${BUNDLE}" + + if [ "$SIGN_MACOS" = true ]; then + codesign --force --verbose --timestamp -s "${APPLE_PERSONALID}" "dist/${BUNDLE}.dmg" + + brew install akeru-inc/tap/xcnotary + xcnotary precheck "dist/${BUNDLE}.app" + xcnotary precheck "dist/${BUNDLE}.dmg" + + make dist/notarize + fi + EDITION="" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then EDITION="-research"; fi + mv "dist/${BUNDLE}.dmg" dist/activitywatch-tauri${EDITION}-${VERSION_WITH_V}-macos-$(uname -m).dmg + env: + APPLE_EMAIL: ${{ secrets.APPLE_EMAIL }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAMID: ${{ secrets.APPLE_TEAMID }} + + - name: Package Tauri updater artifacts + run: | + set -euo pipefail + mkdir -p dist/updater + VERSION="${VERSION_WITH_V#v}" + + case "$RUNNER_OS" in + macOS) OS_NAME="darwin" ;; + Linux) OS_NAME="linux" ;; + Windows) OS_NAME="windows" ;; + esac + case "$(uname -m)" in + arm64|aarch64) ARCH_NAME="aarch64" ;; + x86_64|amd64) ARCH_NAME="x86_64" ;; + *) ARCH_NAME="$(uname -m)" ;; + esac + PLATFORM_KEY="${OS_NAME}-${ARCH_NAME}" + EDITION="" + if [[ "${AW_RESEARCH_EDITION:-}" == "true" ]]; then EDITION="-research"; fi + + # Only the bundle formats Tauri's updater consumes — avoids picking up + # .sig files for .deb/.rpm/.dmg if those ever get signed too. + found=0 + while IFS= read -r -d '' sig; do + artifact="${sig%.sig}" + if [ -f "$artifact" ]; then + base="${artifact##*/}" + # Bundle names embed the crate version (e.g. aw-tauri_0.1.0_amd64.AppImage), + # so "everything after the first dot" would yield "1.0_amd64.AppImage". + case "$base" in + *.app.tar.gz) ext="app.tar.gz" ;; + *.AppImage.tar.gz) ext="AppImage.tar.gz" ;; + *.AppImage) ext="AppImage" ;; + *.nsis.zip) ext="nsis.zip" ;; + *.msi.zip) ext="msi.zip" ;; + *.msi) ext="msi" ;; + *.exe) ext="exe" ;; + *) echo "unknown updater bundle: $base" >&2; exit 1 ;; + esac + out="dist/updater/activitywatch-tauri${EDITION}-${VERSION}-${PLATFORM_KEY}.${ext}" + cp "$artifact" "$out" + cp "$sig" "$out.sig" + found=$((found + 1)) + fi + done < <(find aw-tauri/src-tauri/target -type f \( \ + -path "*/release/bundle/macos/*.sig" -o \ + -path "*/release/bundle/appimage/*.sig" -o \ + -path "*/release/bundle/nsis/*.sig" -o \ + -path "*/release/bundle/msi/*.sig" \ + \) -print0) + + echo "Found $found Tauri updater artifact(s) for $PLATFORM_KEY" + ls -la dist/updater/ || true + + - name: Upload packages + uses: actions/upload-artifact@v7 + with: + name: builds-tauri-${{ matrix.os }}-py${{ matrix.python_version }}${{ matrix.research && '-research' || '' }} + path: | + dist/activitywatch-*.* + dist/updater/* + + release-notes: + name: Generate release notes + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + submodules: 'recursive' + fetch-depth: 0 + + - uses: ActivityWatch/check-version-format-action@5b223bd718c4c00466359532cf944474f069625a # v2.0.1 + id: version + with: + prefix: 'v' + + - name: Echo version + run: | + echo "${{ steps.version.outputs.full }} (stable: ${{ steps.version.outputs.is_stable }})" + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Install deps + run: | + pip install requests + + - name: Generate release notes + if: ${{ !endsWith(github.ref_name, '-research') }} + run: | + LAST_RELEASE=`STABLE_ONLY=${{ steps.version.outputs.is_stable }} ./scripts/get_latest_release.sh` + ./scripts/build_changelog.py --range "$LAST_RELEASE...${{ steps.version.outputs.full }}" + + - name: Generate release notes (research edition) + if: endsWith(github.ref_name, '-research') + run: | + # check-version-format-action cannot parse research-suffixed tags, + # so derive the range end from the tag name directly. Research + # releases are never stable. + LAST_RELEASE=$(STABLE_ONLY=false ./scripts/get_latest_release.sh) + # get_latest_release.sh only matches standard tags; use git describe + # to find the topologically nearest ancestral research release. + # Excluding the current tag also ignores newer or unrelated lineages. + prev_research=$(git describe --tags --abbrev=0 \ + --match '*-research' --exclude "${GITHUB_REF_NAME}" \ + "${GITHUB_REF_NAME}" 2>/dev/null || true) + if [ -n "$LAST_RELEASE" ] && [ -n "$prev_research" ] && \ + git merge-base --is-ancestor "$LAST_RELEASE" "$prev_research"; then + LAST_RELEASE="$prev_research" + fi + ./scripts/build_changelog.py --range "$LAST_RELEASE...${GITHUB_REF_NAME}" + + - name: Rename + run: | + mv changelog.md release_notes.md + + - name: Adjust release notes for research edition + if: endsWith(github.ref_name, '-research') + run: | + { + echo "> [!IMPORTANT]" + echo "> **ActivityWatch Research Edition** (\`${GITHUB_REF_NAME}\`) — prerelease build for research-study participants, with the privacy filter and study category map enabled by default (\`research_enabled = true\` in aw-watcher-window)." + echo "> Not intended for general use — regular users should install the latest [standard release](https://github.com/ActivityWatch/activitywatch/releases) instead." + echo "" + cat release_notes.md + } > release_notes_research.md + mv release_notes_research.md release_notes.md + # The generated Downloads section hardcodes standard-edition asset + # filenames, which do not exist on research releases (research + # artifacts carry a -research token). Point at the asset + # list instead of linking to 404s. + python3 - <<'PY' + import re + + path = "release_notes.md" + notes = open(path).read() + notes = re.sub( + r"(?m)^ - \[\*\*(Windows|macOS|Linux)\*\*\]\([^)]*\)[^\n]*\n?", + "", + notes, + ) + notes = notes.replace( + "# Downloads\n", + "# Downloads\n\nResearch Edition installers are attached to this" + " release as assets below (filenames contain `research`)." + " Standard-release download links do not apply to this build.\n", + ) + open(path, "w").write(notes) + PY + + - name: Upload release notes + uses: actions/upload-artifact@v7 + with: + name: release_notes + path: release_notes.md + + release: + name: Publish draft release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: [build-qt, build-qt-manylinux-2-28, build-tauri, release-notes] + runs-on: ubuntu-latest + steps: + # Pin checkout in this contents-write job so a moved v7 tag cannot + # change the release automation. Build jobs keep actions/checkout@v7. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + path: dist + + - name: Display structure of downloaded files + run: ls -R + working-directory: dist + + - uses: ActivityWatch/check-version-format-action@5b223bd718c4c00466359532cf944474f069625a # v2.0.1 + id: version + with: + prefix: 'v' + + # Tag/repo values are passed via env (GITHUB_REF_NAME / GITHUB_REPOSITORY + # are set by Actions) rather than interpolated into the shell script, so + # a crafted v* tag cannot inject commands into this contents-write job. + # + # Keep versioned release assets separate. Research clients use the + # independent research-updates branch; these assets do not publish that feed. + - name: Generate updater manifest + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + VERSION="${VERSION%-research}" + if [[ "$TAG" == *-research ]]; then + EDITION="research" + MANIFEST="latest-research.json" + NOTES="ActivityWatch ${TAG} (Research Edition)" + else + EDITION="standard" + MANIFEST="latest.json" + NOTES="ActivityWatch ${TAG}" + fi + python3 scripts/package/generate_latest_json.py \ + --version "$VERSION" \ + --edition "$EDITION" \ + --notes "$NOTES" \ + --repo "${GITHUB_REPOSITORY}" \ + --tag "$TAG" \ + --dist dist \ + --output "dist/${MANIFEST}" + cat "dist/${MANIFEST}" + + - name: Release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 + with: + draft: true + # Empty name falls back to the tag name (standard releases unchanged). + name: ${{ endsWith(github.ref_name, '-research') && format('{0} (Research Edition)', github.ref_name) || '' }} + files: | + dist/*/activitywatch-*.* + dist/*/updater/* + dist/latest.json + dist/latest-research.json + body_path: dist/release_notes/release_notes.md + # check-version-format-action leaves is_stable unset/false for + # research-suffixed tags, so this stays prerelease for them too. + prerelease: ${{ !(steps.version.outputs.is_stable == 'true') }} + +# Retry CI after transient upload-artifact timeout. diff --git a/.github/workflows/research-feed-publisher-tests.yml b/.github/workflows/research-feed-publisher-tests.yml new file mode 100644 index 0000000..7d71c2f --- /dev/null +++ b/.github/workflows/research-feed-publisher-tests.yml @@ -0,0 +1,25 @@ +name: Research feed publisher tests +on: + pull_request: + paths: + - 'scripts/package/publish_research_feed.py' + - 'scripts/tests/test_publish_research_feed.py' + - '.github/workflows/research-feed-publisher-tests.yml' + push: + branches: [master] + paths: + - 'scripts/package/publish_research_feed.py' + - 'scripts/tests/test_publish_research_feed.py' + - '.github/workflows/research-feed-publisher-tests.yml' +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.9' + - run: pip install pytest 'semver>=3,<4' + - run: python3 -m pytest scripts/tests/test_publish_research_feed.py -q diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8d771ad --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,175 @@ +name: Test + +on: + #push: + # branches: [ master ] + #pull_request: + # branches: [ master ] + workflow_dispatch: + + + +jobs: + # an integration test designed to catch bugs triggered by updating (database migrations and such) + upgrades: + name: upgrade from ${{ matrix.aw_server_old }} ${{ matrix.aw_server_old_args }} to ${{ matrix.aw_server_new }} ${{ matrix.aw_server_new_args }} + # needs: [build] + #if: startsWith(github.ref, 'refs/tags/v') # only run on tag + runs-on: ubuntu-latest + env: + old_version: 'v0.12.2' + new_version: 'v0.13.1' + strategy: + fail-fast: false + matrix: + aw_server_old: ['aw-server', 'aw-server-rust'] + aw_server_new: ['aw-server', 'aw-server-rust'] + aw_server_old_args: [''] + aw_server_new_args: [''] + include: + # python, peewee (default) + - aw_server_old: 'aw-server' + aw_server_new: 'aw-server' + # python, sqlite + # FIXME: sqlite broken since aw-server enabled flask multithreading (new default) + - aw_server_old: "aw-server" + aw_server_new: "aw-server" + aw_server_old_args: "--storage sqlite" + aw_server_new_args: "--storage sqlite" + old_version: 'v0.12.2' + new_version: 'v0.13.1' + # python, peewee to sqlite + # FIXME: broken, same thing with sqlite as above + - aw_server_old: "aw-server" + aw_server_new: "aw-server" + aw_server_old_args: "--storage peewee" + aw_server_new_args: "--storage sqlite" + old_version: 'v0.12.2' + new_version: 'v0.13.1' + exclude: + # rust to python, not supported + - aw_server_old: 'aw-server-rust' + aw_server_new: 'aw-server' + + steps: + # Will download all artifacts to path + - name: Download build artifacts + if: ${{ env.new_version == 'this' }} + uses: actions/download-artifact@v8 + with: + name: builds-Linux-py3.9 + path: dist + + # Only used during testing, so we don't have to wait for the main build job + - name: Download new ActivityWatch + if: ${{ env.new_version != 'this' }} + run: | + mkdir dist + pushd dist + wget -q https://github.com/ActivityWatch/activitywatch/releases/download/${{ env.new_version }}/activitywatch-${{ env.new_version }}-linux-x86_64.zip + + - name: Install new & old ActivityWatch + run: | + pushd dist + + # New version + unzip activitywatch-*-linux-x86_64.zip + mv activitywatch/ aw-new + + # Old version + wget -q -O aw-old.zip https://github.com/ActivityWatch/activitywatch/releases/download/${{ env.old_version }}/activitywatch-${{ env.old_version }}-linux-x86_64.zip + unzip aw-old.zip + mv activitywatch/ aw-old + + - name: Display structure of downloaded files + run: ls -R + working-directory: dist + + - name: Run and test old server + run: | + bin=dist/aw-old/${{ matrix.aw_server_old }}/${{ matrix.aw_server_old }} + url="http://localhost:5600" + + # Check version + $bin --version || true # due to bug in old aw-server + + # Run server and log output + $bin ${{ matrix.aw_server_old_args }} >> log-old.txt 2>&1 & + sleep 5 # wait for startup + + # Set server URL + + # Get server info + curl "$url/api/0/info" --fail-with-body + + # Create bucket + curl -X 'POST' --fail-with-body \ + "$url/api/0/buckets/aw-test" \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "client": "test", + "type": "test", + "hostname": "test" + }' + + # Get buckets + curl "$url/api/0/buckets/" -H 'accept: application/json' + + # Send a heartbeat + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + curl -X 'POST' \ + "$url/api/0/buckets/aw-test/heartbeat?pulsetime=0" \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "timestamp": "'$timestamp'", + "duration": 0, + "data": {"key": "test value"} + }' + + # Give a sec, then kill server process + sleep 1 + kill $! + + - name: Run and test new server + run: | + bin=dist/aw-new/${{ matrix.aw_server_new }}/${{ matrix.aw_server_new }} + url="http://localhost:5600" + + # Check version + $bin --version + + # Run server and log output + $bin ${{ matrix.aw_server_new_args }} >> log-new.txt 2>&1 & + sleep 5 # wait for startup + + # Get server info + curl "$url/api/0/info" + + # Get buckets + curl "$url/api/0/buckets/" -H 'accept: application/json' + + # Send a heartbeat + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + curl -X 'POST' --fail-with-body \ + "$url/api/0/buckets/aw-test/heartbeat?pulsetime=60" \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "timestamp": "'$timestamp'", + "duration": 0, + "data": {"key": "test value"} + }' + + # Give a sec, then kill server process + sleep 1 + kill $! + + - name: Output logs + if: always() + run: | + cat log-old.txt || true + echo "\n---\n" + cat log-new.txt || true + diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml new file mode 100644 index 0000000..25e1d9f --- /dev/null +++ b/.github/workflows/winget.yml @@ -0,0 +1,13 @@ +name: Publish to WinGet +on: + release: + types: [released] +jobs: + publish: + runs-on: windows-latest # action can only be run on windows + steps: + - uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 + with: + identifier: ActivityWatch.ActivityWatch + token: ${{ secrets.GH_TOKEN_WINGET_AUTOUPDATE }} + fork-user: ActivityWatchBot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09338d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +build +dist +docs +other +old + +# Coverage +*coverage* +htmlcov + +# Editor/IDEs +.idea +*.swp + +# Python +*venv* +__pycache__ +.python-version + +# Misc +.*cache +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..18d9689 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,33 @@ +[submodule "aw-core"] + path = aw-core + url = https://github.com/ActivityWatch/aw-core.git +[submodule "aw-client"] + path = aw-client + url = https://github.com/ActivityWatch/aw-client.git +[submodule "aw-server"] + path = aw-server + url = https://github.com/ActivityWatch/aw-server.git +[submodule "aw-watcher-afk"] + path = aw-watcher-afk + url = https://github.com/ActivityWatch/aw-watcher-afk.git +[submodule "aw-qt"] + path = aw-qt + url = https://github.com/ActivityWatch/aw-qt.git +[submodule "aw-watcher-window"] + path = aw-watcher-window + url = https://github.com/ActivityWatch/aw-watcher-window.git +[submodule "aw-server-rust"] + path = aw-server-rust + url = https://github.com/ActivityWatch/aw-server-rust.git +[submodule "aw-watcher-input"] + path = aw-watcher-input + url = https://github.com/ActivityWatch/aw-watcher-input.git +[submodule "aw-tauri"] + path = aw-tauri + url = https://github.com/activitywatch/aw-tauri +[submodule "awatcher"] + path = awatcher + url = https://github.com/2e3s/awatcher +[submodule "aw-notify"] + path = aw-notify + url = https://github.com/ActivityWatch/aw-notify-rs.git diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..59417e9 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,4 @@ +poetry 1.5.1 +nodejs 16.20.2 +rust nightly +python 3.9.13 diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..bc386e0 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,14 @@ +cff-version: 1.2.0 +message: "If you use or refer to this software in your research, please cite it." +authors: +- family-names: "Bjäreholt" + given-names: "Erik" + orcid: "https://orcid.org/0000-0003-1350-9677" +- family-names: "Bjäreholt" + given-names: "Johan" + orcid: "https://orcid.org/0000-0003-4789-3160" +title: "ActivityWatch" +version: 0.13.1 +doi: 10.5281/zenodo.4957165 +date-released: 2024-06-10 +url: "https://github.com/ActivityWatch/activitywatch" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..de6fe76 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at erik@bjareho.lt. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..22e5e3e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +How to Contribute +================= + + + +**Table of Contents** + + - [Getting started](#getting-started) + - [How you can help](#how-you-can-help) + - [Filing an issue](#filing-an-issue) + - [Code of Conduct](#code-of-conduct) + - [Commit message guidelines](#commit-message-guidelines) + - [Getting paid](#getting-paid) + - [Claiming GitPOAP](#claiming-gitpoap) + - [Questions?](#questions) + + +## Getting started + +To develop on ActivityWatch you'll first want to install from source. To do so, follow [the guide in the documentation](https://activitywatch.readthedocs.io/en/latest/installing-from-source.html). + +You might then want to read about the [architecture](https://activitywatch.readthedocs.io/en/latest/architecture.html) and the [data model](https://activitywatch.readthedocs.io/en/latest/buckets-and-events.html). + +If you want some code examples for how to write watchers or other types of clients, see the [documentation for writing watchers](https://docs.activitywatch.net/en/latest/examples/writing-watchers.html). + + +## How you can help + +There are many ways to contribute to ActivityWatch: + + - Work on issues labeled [`good first issue`][good first issue] or [`help wanted`][help wanted], these are especially suited for new contributors. + - Fix [`bugs`][bugs]. + - Implement new features. + - Look among the [requested features][requested features] on the forum. + - Talk to us in the issues or on [our Discord server][discord] to get help on how to proceed. + - Write [documentation](https://github.com/ActivityWatch/docs). + - Build the ecosystem. + - Examples: New watchers, tools to analyze data, tools to import data from other sources, etc. + +If you're interested in what's next for ActivityWatch, have a look at our [roadmap][roadmap] and [milestones][milestones]. + +Most of the above will get you up on our [contributor stats page][contributors] as thanks! + +[good first issue]: https://github.com/ActivityWatch/activitywatch/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 +[help wanted]: https://github.com/ActivityWatch/activitywatch/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22 +[bugs]: https://github.com/ActivityWatch/activitywatch/issues?q=is%3Aissue+is%3Aopen+label%3A%22type%3A+bug%22 +[milestones]: https://github.com/ActivityWatch/activitywatch/milestones +[roadmap]: https://github.com/orgs/ActivityWatch/projects/2 +[requested features]: https://forum.activitywatch.net/c/features +[contributors]: http://activitywatch.net/contributors/ + + +## Filing an issue + +Thanks for wanting to help out with squashing bugs and more by filing an issue. + +When filing an issue, it's important to use an [issue template](https://github.com/ActivityWatch/activitywatch/issues/new/choose). This ensures that we have the information we need to understand the issue, so we don't have to ask for tons of follow-up questions, so we can fix the issue faster! + + +## Code of Conduct + +We have a Code of Conduct that we expect all contributors to follow, you can find it in [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md). + + +## Commit message guidelines + +When writing commit messages try to follow [Conventional Commits](https://www.conventionalcommits.org/). It is not a strict requirement (to minimize overhead for new contributors) but it is encouraged. + +The format is: + +``` +[optional scope]: + +[optional body] + +[optional footer] +``` + +Where `type` can be one of: `feat, fix, chore, ci, docs, style, refactor, perf, test` + +Examples: + +``` + - feat: added ability to sort by duration + - fix: fixes incorrect week number (#407) + - docs: improved query documentation +``` + +This guideline was adopted in [issue #391](https://github.com/ActivityWatch/activitywatch/issues/391). + + +## Getting paid + +We're experimenting with paying our contributors using funds we've raised from donations and grants. + +The idea is you track your work with ActivityWatch (and ensure it gets categorized correctly), then you modify the [working_hours.py](https://github.com/ActivityWatch/aw-client/blob/master/examples/working_hours.py) script to use your category rule and generate a report of time worked per day and the matching events. + +If you've contributed to ActivityWatch (for a minimum of 10h) and want to get paid for your time, contact us! + +You can read more about this experiment on [the forum](https://forum.activitywatch.net/t/getting-paid-with-activitywatch/986) and in [the issues](https://github.com/ActivityWatch/activitywatch/issues/458). + + +## Claiming GitPOAP + +If you've contributed a commit to ActivityWatch, you are eligible to claim a GitPOAP on Ethereum. You can read about it here: https://twitter.com/ActivityWatchIt/status/1584454595467612160 + +The one for 2022 looks like this: + + + + + + +## Questions? + +If you have any questions, you can: + + - Talk to us on our [Discord server][discord] + - Post on [the forum][forum] or [GitHub Discussions][github discussions]. + - (as a last resort/if needed) Email one of the maintainers at: [erik@bjareho.lt](mailto:erik@bjareho.lt) + +[forum]: https://forum.activitywatch.net +[github discussions]: https://github.com/ActivityWatch/activitywatch/discussions +[discord]: https://discord.gg/vDskV9q diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4202a3a --- /dev/null +++ b/Makefile @@ -0,0 +1,265 @@ +# ===================================== +# Makefile for the ActivityWatch bundle +# ===================================== +# +# [GUIDE] How to install from source: +# - https://activitywatch.readthedocs.io/en/latest/installing-from-source.html +# +# We recommend creating and activating a Python virtualenv before building. +# Instructions on how to do this can be found in the guide linked above. +.PHONY: build install test clean clean_all update-submodules sync-tauri-server + +SHELL := /usr/bin/env bash + +OS := $(shell uname -s) + +ifeq ($(TAURI_BUILD),true) + SUBMODULES := aw-core aw-client aw-server aw-server-rust aw-watcher-afk aw-watcher-window aw-tauri + # Include awatcher on Linux (Wayland-compatible window watcher) + ifeq ($(OS),Linux) + SUBMODULES := $(SUBMODULES) awatcher + endif +else + SUBMODULES := aw-core aw-client aw-qt aw-server aw-server-rust aw-watcher-afk aw-watcher-window +endif + +# Exclude aw-server-rust if SKIP_SERVER_RUST is true +ifeq ($(SKIP_SERVER_RUST),true) + SUBMODULES := $(filter-out aw-server-rust,$(SUBMODULES)) +endif +# Include extras if AW_EXTRAS is true +ifeq ($(AW_EXTRAS),true) + SUBMODULES := $(SUBMODULES) aw-notify aw-watcher-input +endif + +# A function that checks if a target exists in a Makefile +# Usage: $(call has_target,,) +define has_target +$(shell make -q -C $1 $2 >/dev/null 2>&1; if [ $$? -eq 0 -o $$? -eq 1 ]; then echo $1; fi) +endef + +# Submodules with test/package/lint/typecheck targets +TESTABLES := $(foreach dir,$(SUBMODULES),$(call has_target,$(dir),test)) +PACKAGEABLES := $(foreach dir,$(SUBMODULES),$(call has_target,$(dir),package)) +LINTABLES := $(foreach dir,$(SUBMODULES),$(call has_target,$(dir),lint)) +TYPECHECKABLES := $(foreach dir,$(SUBMODULES),$(call has_target,$(dir),typecheck)) + +# When building with Tauri, aw-server-rust is built as aw-sync only (not full server), +# so exclude it from the standard package target +ifeq ($(TAURI_BUILD),true) + PACKAGEABLES := $(filter-out aw-server-rust aw-server, $(PACKAGEABLES)) +endif + +# Build mode: release vs debug +ifeq ($(RELEASE), false) + targetdir := debug +else + targetdir := release +endif + +# The `build` target +# ------------------ +# +# What it does: +# - Installs all the Python modules +# - Builds the web UI and bundles it with aw-server +build: aw-core/.git +# needed due to https://github.com/pypa/setuptools/issues/1963 +# would ordinarily be specified in pyproject.toml, but is not respected due to https://github.com/pypa/setuptools/issues/1963 + pip install 'setuptools>49.1.1' + for module in $(SUBMODULES); do \ + echo "Building $$module"; \ + if [ "$$module" = "aw-server-rust" ] && [ "$(TAURI_BUILD)" = "true" ]; then \ + make --directory=$$module aw-sync SKIP_WEBUI=$(SKIP_WEBUI) || { echo "Error in $$module aw-sync"; exit 2; }; \ + else \ + make --directory=$$module build SKIP_WEBUI=$(SKIP_WEBUI) || { echo "Error in $$module build"; exit 2; }; \ + fi; \ + done +# The below is needed due to: https://github.com/ActivityWatch/activitywatch/issues/173 + make --directory=aw-client build + make --directory=aw-core build +# Needed to ensure that the server has the correct version set + python -c "import aw_server; print(aw_server.__version__)" + + +# Install +# ------- +# +# Installs things like desktop/menu shortcuts. +# Might in the future configure autostart on the system. +ifneq ($(TAURI_BUILD),true) +install: + make --directory=aw-qt install +# Installation is already happening in the `make build` step currently. +# We might want to change this. +# We should also add some option to install as user (pip3 install --user) +endif + +# Update +# ------ +# +# Pulls the latest version, updates all the submodules, then runs `make build`. +update: + git pull + git submodule update --init --recursive + make build + +# Move direct submodules to their latest upstream commits. aw-tauri's lockfile +# is authoritative for the server revision, so align the top-level server +# submodule with it after updating everything else. +update-submodules: + git submodule update --init --remote + git submodule foreach 'git submodule update --init --recursive' + $(MAKE) sync-tauri-server + +sync-tauri-server: + python3 scripts/check_tauri_server.py --sync + + +lint: + @for module in $(LINTABLES); do \ + echo "Linting $$module"; \ + make --directory=$$module lint || { echo "Error in $$module lint"; exit 2; }; \ + done + +typecheck: + @for module in $(TYPECHECKABLES); do \ + echo "Typechecking $$module"; \ + make --directory=$$module typecheck || { echo "Error in $$module typecheck"; exit 2; }; \ + done + +# Uninstall +# --------- +# +# Uninstalls all the Python modules. +uninstall: + modules=$$(pip3 list --format=legacy | grep 'aw-' | grep -o '^aw-[^ ]*'); \ + for module in $$modules; do \ + echo "Uninstalling $$module"; \ + pip3 uninstall -y $$module; \ + done + +test: + @for module in $(TESTABLES); do \ + echo "Running tests for $$module"; \ + if [ -f "$$module/pyproject.toml" ]; then \ + (cd $$module && poetry run make test) || { echo "Error in $$module tests"; exit 2; }; \ + else \ + make -C $$module test || { echo "Error in $$module tests"; exit 2; }; \ + fi; \ + done + +test-integration: + # TODO: Move "integration tests" to aw-client + # FIXME: For whatever reason the script stalls on Appveyor + # Example: https://ci.appveyor.com/project/ErikBjare/activitywatch/build/1.0.167/job/k1ulexsc5ar5uv4v + # aw-server-python + @echo "== Integration testing aw-server ==" + @pytest ./scripts/tests/integration_tests.py ./aw-server/tests/ -v + +%/.git: + git submodule update --init --recursive + +ifeq ($(TAURI_BUILD),true) + ICON := "aw-tauri/src-tauri/icons/icon.png" +else + ICON := "aw-qt/media/logo/logo.png" +endif + +aw-qt/media/logo/logo.icns: + mkdir -p build/MyIcon.iconset + sips -z 16 16 $(ICON) --out build/MyIcon.iconset/icon_16x16.png + sips -z 32 32 $(ICON) --out build/MyIcon.iconset/icon_16x16@2x.png + sips -z 32 32 $(ICON) --out build/MyIcon.iconset/icon_32x32.png + sips -z 64 64 $(ICON) --out build/MyIcon.iconset/icon_32x32@2x.png + sips -z 128 128 $(ICON) --out build/MyIcon.iconset/icon_128x128.png + sips -z 256 256 $(ICON) --out build/MyIcon.iconset/icon_128x128@2x.png + sips -z 256 256 $(ICON) --out build/MyIcon.iconset/icon_256x256.png + sips -z 512 512 $(ICON) --out build/MyIcon.iconset/icon_256x256@2x.png + sips -z 512 512 $(ICON) --out build/MyIcon.iconset/icon_512x512.png + cp $(ICON) build/MyIcon.iconset/icon_512x512@2x.png + iconutil -c icns build/MyIcon.iconset + rm -R build/MyIcon.iconset + mv build/MyIcon.icns aw-qt/media/logo/logo.icns + +# Stem of the macOS .app / .dmg. Research Edition patches APP_BUNDLE so the +# on-disk bundle does not collide with /Applications/ActivityWatch.app. +APP_BUNDLE ?= ActivityWatch + +dist/$(APP_BUNDLE).app: aw-qt/media/logo/logo.icns +ifeq ($(TAURI_BUILD),true) + scripts/package/build_app_tauri.sh +else + pyinstaller --clean --noconfirm aw.spec +endif + +dist/$(APP_BUNDLE).dmg: dist/$(APP_BUNDLE).app + # NOTE: This does not codesign the dmg, that is done in the CI config + pip install dmgbuild + @for attempt in 1 2 3; do \ + rm -f dist/$(APP_BUNDLE).dmg; \ + if dmgbuild -s scripts/package/dmgbuild-settings.py -D app=dist/$(APP_BUNDLE).app "$(APP_BUNDLE)" dist/$(APP_BUNDLE).dmg; then \ + exit 0; \ + fi; \ + if [ $$attempt -eq 3 ]; then \ + exit 1; \ + fi; \ + echo "dmgbuild attempt $$attempt failed; retrying after macOS releases the disk image" >&2; \ + sleep $$((5 * attempt)); \ + done + +dist/notarize: + ./scripts/notarize.sh + +package: + rm -rf dist + mkdir -p dist/activitywatch + for dir in $(PACKAGEABLES); do \ + make --directory=$$dir package; \ + cp -r $$dir/dist/$$dir dist/activitywatch; \ + done +ifeq ($(TAURI_BUILD),true) +# Copy aw-sync binary for Tauri builds + mkdir -p dist/activitywatch/aw-server-rust + cp aw-server-rust/target/$(targetdir)/aw-sync dist/activitywatch/aw-server-rust/aw-sync +else +# Move aw-qt to the root of the dist folder +# Rename first to avoid cp conflict: the aw-qt binary inside the dir has the +# same name as the source directory, so cp -a src/. dest/ would fail trying +# to overwrite the directory with the binary of the same name. + mv dist/activitywatch/aw-qt dist/aw-qt-tmp + cp -a dist/aw-qt-tmp/. dist/activitywatch/ + rm -rf dist/aw-qt-tmp +endif +# Remove problem-causing binaries + rm -f dist/activitywatch/libdrm.so.2 # see: https://github.com/ActivityWatch/activitywatch/issues/161 + rm -f dist/activitywatch/libharfbuzz.so.0 # see: https://github.com/ActivityWatch/activitywatch/issues/660#issuecomment-959889230 +ifeq ($(shell uname),Linux) +# PyInstaller's Qt bundle may include an older Wayland client than Qt requires. +# All portable Linux packages are built from this directory, so remove it here +# and use the distro library instead. See: https://github.com/ActivityWatch/activitywatch/issues/939 + find dist/activitywatch -name 'libwayland-client.so*' -delete +endif +# These should be provided by the distro itself +# Had to be removed due to otherwise causing the error: +# aw-qt: symbol lookup error: /opt/activitywatch/libQt5XcbQpa.so.5: undefined symbol: FT_Get_Font_Format + rm -f dist/activitywatch/libfontconfig.so.1 + rm -f dist/activitywatch/libfreetype.so.6 +# Remove unnecessary files + rm -rf dist/activitywatch/pytz +# Builds zips and setups + bash scripts/package/package-all.sh + +clean: + rm -rf build dist + +# Clean all subprojects +clean_all: clean + for dir in $(SUBMODULES); do \ + make --directory=$$dir clean; \ + done + +clean-auto: + rm -rIv **/aw-server-rust/target + rm -rIv **/aw-android/mobile/build + rm -rIfv **/node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..958c004 --- /dev/null +++ b/README.md @@ -0,0 +1,276 @@ + + +

+ Records what you do so that you can know how you've spent your time. +
+ All in a secure way where you control the data. +

+ +

+ + + + + + + +
+ + + Website + — Forum + — Documentation + — Releases + + +
+ + + Contributor stats + — CI overview + +

+ +

+ + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + +

+ + + + +*Do you want to receive email updates on major announcements?*
+***[Signup for the newsletter](http://eepurl.com/cTU6QX)!*** + +
+ Table of Contents + + * [About](#about) + * [Screenshots](#screenshots) + * [Is this yet another time tracker?](#is-this-yet-another-time-tracker) + * [Feature comparison](#feature-comparison) + * [Installation & Usage](#installation--usage) + * [About this repository](#about-this-repository) + * [Server](#server) + * [Watchers](#watchers) + * [Libraries](#libraries) + * [Contributing](#contributing) +
+ +## About + +The goal of ActivityWatch is simple: *Enable the collection of as much valuable lifedata as possible without compromising user privacy.* + +We've worked towards this goal by creating an application for safe storage of the data on the user's local machine and as well as a set of watchers which record data such as: + + - Currently active application and the title of its window + - Currently active browser tab and its title and URL + - Keyboard and mouse activity, to detect if you are AFK ("away from keyboard") or not + +It is up to you as user to collect as much as you want, or as little as you want (and we hope some of you will help write watchers so we can collect more). + +### Screenshots + + + + +You can find more (and newer) screenshots on [the website](https://activitywatch.net/screenshots/). + + +## Installation & Usage + +Downloads are available on the [releases page](https://github.com/ActivityWatch/activitywatch/releases). + +For instructions on how to get started, please see the [guide in the documentation](https://docs.activitywatch.net/en/latest/getting-started.html). + +Interested in building from source? [There's a guide for that too](https://docs.activitywatch.net/en/latest/installing-from-source.html). + +## Is this yet another time tracker? + +Yes, but we found that most time trackers lack one or more important features. + +**Common dealbreakers:** + + - Not open source + - The user does not own the data (common with non-open source options) + - Lack of synchronization (and when available: it's centralized and the sync server knows everything) + - Difficult to setup/use (most open source options tend to target programmers) + - Low data resolution (low level of detail, does not store raw data, long intervals between entries) + - Hard or impossible to extend (collecting more data is not as simple as it could be) + +**To sum it up:** + + - Closed source solutions suffer from privacy issues and limited features. + - Open source solutions aren't developed with end-users in mind and are usually not written to be easily extended (they lack a proper API). They also lack synchronization. + +We have a plan to address all of these and we're well on our way. See the table below for our progress. + + +### Feature comparison + +##### Basics + +| | User owns data | GUI | Sync | Open Source | +| ------------- |:------------------:|:------------------:|:--------------------------:|:------------------:| +| ActivityWatch | :white_check_mark: | :white_check_mark: | [WIP][sync], decentralized | :white_check_mark: | +| [Selfspy] | :white_check_mark: | :x: | :x: | :white_check_mark: | +| [ulogme] | :white_check_mark: | :white_check_mark: | :x: | :white_check_mark: | +| [RescueTime] | :x: | :white_check_mark: | Centralized | :x: | +| [WakaTime] | :x: | :white_check_mark: | Centralized | Clients | + +[sync]: https://github.com/ActivityWatch/activitywatch/issues/35 +[Selfspy]: https://github.com/selfspy/selfspy +[ulogme]: https://github.com/karpathy/ulogme +[RescueTime]: https://www.rescuetime.com/ +[WakaTime]: https://wakatime.com/ + +##### Platforms + + +| | Windows | macOS | Linux | Android | iOS | +| ------------- |:------------------:|:------------------:|:------------------:|:------------------:|:-------------------:| +| ActivityWatch | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |:x: | +| Selfspy | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |:x: | +| ulogme | :x: | :white_check_mark: | :white_check_mark: | :x: |:x: | +| RescueTime | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |Limited functionality| + +##### Tracking + +| | App & Window Title | AFK | Browser Extensions | Editor Plugins | Extensible | +| ------------- |:------------------:|:------------------:|:------------------:|:------------------:|:---------------------:| +| ActivityWatch | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Selfspy | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | +| ulogme | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | +| RescueTime | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | +| WakaTime | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | Only for text editors | + +For a complete list of the things ActivityWatch can track, [see the page on *watchers* in the documentation](https://docs.activitywatch.net/en/latest/watchers.html). + + +## Architecture + +```mermaid +graph TD; + aw-qt[aw-qt]; + aw-notify[aw-notify]; + aw-server[aw-server]; + aw-webui[aw-webui]; + aw-watcher-window[aw-watcher-window]; + aw-watcher-afk[aw-watcher-afk]; + aw-watcher-web[aw-watcher-web]; + aw-sync[aw-sync]; + + aw-qt -- Manages --> aw-server; + aw-qt -- Manages --> aw-notify -- Queries --> aw-server; + aw-qt -- Manages --> aw-watcher-window -- Watches --> S1[Active window] -- Heartbeats --> aw-server; + aw-qt -- Manages --> aw-watcher-afk -- Watches --> S2[AFK status] -- Heartbeats --> aw-server; + Browser -- Manages --> aw-watcher-web -- Watches --> S3[Active tab] -- Heartbeats --> aw-server; + SF -- Dropbox/Syncthing/etc --> SF; + aw-server <-- Push/Pull --> aw-sync <-- Read/Write --> SF[Sync folder]; + aw-server -- Serves --> aw-webui -- Queries --> aw-server; + + %% User -- Interacts --> aw-webui; + %% User -- Observes --> aw-notify; + %% User -- Interacts --> aw-qt; + +classDef lightMode fill:#FFFFFF, stroke:#333333, color:#333333; +classDef darkMode fill:#333333, stroke:#FFFFFF, color:#FFFFFF; + +classDef lightModeLinks stroke:#333333; +classDef darkModeLinks stroke:#FFFFFF; + +class A,B,C,D,E,G lightMode; +class A,B,C,D,E,G darkMode; + +%% linkStyle 0 stroke:#FF4136, stroke-width:2px; +%% linkStyle 1 stroke:#1ABC9C, stroke-width:2px; +``` + +## About this repository + +This repo is a bundle of the core components and official modules of ActivityWatch (managed with `git submodule`). Its primary use is as a meta-package providing all the components in one repo; enabling easier packaging and installation. It is also where releases of the full suite are published (see [releases](https://github.com/ActivityWatch/activitywatch/releases)). + +### Server + +ActivityWatch has two server implementations: + +- `aw-server` (Python) - The current default implementation +- `aw-server-rust` - A Rust implementation that is the planned future default + +Both provide a REST API to a datastore and query engine, and serve the web interface developed in the `aw-webui` project (which provides the frontend). + +The REST API includes: + + - Access to a datastore suitable for timeseries/timeperiod-data organized in "buckets" (containers grouping related activity data by metadata like client type or hostname) + - **Buckets API:** Create, retrieve, and delete data buckets + - **Events API:** Read and write timestamped events within buckets + - **Heartbeat API:** Watchers use heartbeat signals to update the current state of activity (e.g., active application, AFK status) + - **Query API:** simple query scripting language for filtering, merging, grouping, and transforming events + - **Client libraries:** Language-specific libraries like `aw-client` (Python), `aw-client-js`, and `aw-client-rust` that wrap REST endpoints for programmatic access + +The frontend (`aw-webui`) includes: + + - **Data visualization:** Dashboard and timeline views showing activity summaries with detailed breakdowns of app usage, web browsing, and user-defined categories + - **Query explorer:** Browser-based interface for writing, executing, and debugging queries with real-time results + - **Activity browser:** Navigate through historical data with filtering by date ranges, applications, websites, and custom categories + - **Raw data access:** View and browse individual events from all tracking buckets with detailed metadata + - **Export functionality:** Export activity data in JSON format (individual buckets or complete datasets) via web interface or REST API + +### Watchers + +ActivityWatch comes pre-installed with two watchers: + + - `aw-watcher-afk` tracks the user active/inactive state from keyboard and mouse input + - `aw-watcher-window` tracks the currently active application and its window title. + +There are lots of other watchers for ActivityWatch which can track more types of activity. Like `aw-watcher-web` which tracks time spent on websites, multiple editor watchers which track spent time coding, and many more! A full list of watchers can be found in [the documentation](https://docs.activitywatch.net/en/latest/watchers.html). + +### Libraries + + - `aw-core` - core library, provides no runnable modules + - `aw-client` - client library, useful when writing watchers + +### Folder structure + + + +## Contributing + +Want to help? Great! Check out the [CONTRIBUTING.md file](./CONTRIBUTING.md)! + +## Questions and support + +Have a question, suggestion, problem, or just want to say hi? Post on [the forum](https://forum.activitywatch.net/)! + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7590e88 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + + + +## Reporting a Vulnerability + + + +If you discover a vulnerability, please send a PGP encrypted email with details to [erik@bjareho.lt](mailto:erik@bjareho.lt) (preferably PGP encrypted using [this key](https://erik.bjareholt.com/erikbjare.asc)). diff --git a/aw.spec b/aw.spec new file mode 100644 index 0000000..5c26aec --- /dev/null +++ b/aw.spec @@ -0,0 +1,255 @@ +# -*- mode: python -*- +# vi: set ft=python : + +import os +import platform +import shlex +import subprocess +from pathlib import Path + +import aw_core +import flask_restx + + +def build_analysis(name, location, binaries=[], datas=[], hiddenimports=[]): + name_py = name.replace("-", "_") + location_candidates = [ + location / f"{name_py}/__main__.py", + location / f"src/{name_py}/__main__.py", + ] + try: + location = next(p for p in location_candidates if p.exists()) + except StopIteration: + raise Exception(f"Could not find {name} location from {location_candidates}") + + return Analysis( + [location], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + ) + + +def build_collect(analysis, name, console=True, entitlements=None): + """Used to build the COLLECT statements for each module""" + pyz = PYZ(analysis.pure, analysis.zipped_data) + exe = EXE( + pyz, + analysis.scripts, + exclude_binaries=True, + name=name, + debug=False, + strip=False, + upx=True, + console=console, + contents_directory=".", + entitlements_file=entitlements or entitlements_file, + codesign_identity=codesign_identity, + ) + return COLLECT( + exe, + analysis.binaries, + analysis.zipfiles, + analysis.datas, + strip=False, + upx=True, + name=name, + ) + + +# Get the current release version +current_release = subprocess.run( + shlex.split("git describe --tags --abbrev=0"), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + encoding="utf8", +).stdout.strip() +print("bundling activitywatch version " + current_release) + +# Get entitlements and codesign identity +entitlements_file = Path(".") / "scripts" / "package" / "entitlements.plist" +app_entitlements_file = Path(".") / "scripts" / "package" / "app-entitlements.plist" +codesign_identity = os.environ.get("APPLE_PERSONALID", "").strip() +if not codesign_identity: + print("Environment variable APPLE_PERSONALID not set. Releases won't be signed.") + +aw_core_path = Path(os.path.dirname(aw_core.__file__)) +restx_path = Path(os.path.dirname(flask_restx.__file__)) + +aws_location = Path("aw-server") +aw_server_rust_location = Path("aw-server-rust") +aw_server_rust_bin = aw_server_rust_location / "target/package/aw-server-rust" +aw_sync_bin = aw_server_rust_location / "target/package/aw-sync" +aw_qt_location = Path("aw-qt") +awa_location = Path("aw-watcher-afk") +aww_location = Path("aw-watcher-window") +awi_location = Path("aw-watcher-input") +aw_notify_location = Path("aw-notify") + +if platform.system() == "Darwin": + from PyInstaller.utils import osx as osxutils + + icon = aw_qt_location / "media/logo/logo.icns" +else: + icon = aw_qt_location / "media/logo/logo.ico" + +skip_rust = False +if not aw_server_rust_bin.exists(): + skip_rust = True + print("Skipping Rust build because aw-server-rust binary not found.") + + +aw_qt_a = build_analysis( + "aw-qt", + aw_qt_location, + binaries=[(aw_server_rust_bin, "."), (aw_sync_bin, ".")] if not skip_rust else [], + datas=[ + (aw_qt_location / "resources/aw-qt.desktop", "aw_qt/resources"), + (aw_qt_location / "media", "aw_qt/media"), + ], +) +aw_server_a = build_analysis( + "aw-server", + aws_location, + datas=[ + (aws_location / "aw_server/static", "aw_server/static"), + (restx_path / "templates", "flask_restx/templates"), + (restx_path / "static", "flask_restx/static"), + (aw_core_path / "schemas", "aw_core/schemas"), + ], +) +aw_watcher_afk_a = build_analysis( + "aw_watcher_afk", + awa_location, + hiddenimports=[ + "Xlib.keysymdef.miscellany", + "Xlib.keysymdef.latin1", + "Xlib.keysymdef.latin2", + "Xlib.keysymdef.latin3", + "Xlib.keysymdef.latin4", + "Xlib.keysymdef.greek", + "Xlib.support.unix_connect", + "Xlib.ext.shape", + "Xlib.ext.xinerama", + "Xlib.ext.composite", + "Xlib.ext.randr", + "Xlib.ext.xfixes", + "Xlib.ext.security", + "Xlib.ext.xinput", + "pynput.keyboard._xorg", + "pynput.mouse._xorg", + "pynput.keyboard._win32", + "pynput.mouse._win32", + "pynput.keyboard._darwin", + "pynput.mouse._darwin", + ], +) +aw_watcher_input_a = build_analysis("aw_watcher_input", awi_location) +aw_watcher_window_a = build_analysis( + "aw_watcher_window", + aww_location, + binaries=( + [ + ( + aww_location / "aw_watcher_window/aw-watcher-window-macos", + "aw_watcher_window", + ) + ] + if platform.system() == "Darwin" + else [] + ), + datas=[ + (aww_location / "aw_watcher_window/printAppStatus.jxa", "aw_watcher_window") + ], +) +# Check if aw-notify is a Python package +_notify_candidates = [ + aw_notify_location / "aw_notify/__main__.py", + aw_notify_location / "src/aw_notify/__main__.py", +] +skip_aw_notify = not any(p.exists() for p in _notify_candidates) +if skip_aw_notify: + print("Skipping aw-notify Python packaging (Rust-based implementation detected)") + +aw_notify_a = None if skip_aw_notify else build_analysis( + "aw_notify", aw_notify_location, hiddenimports=["desktop_notifier.resources"] +) + +# https://pythonhosted.org/PyInstaller/spec-files.html#multipackage-bundles +# MERGE takes a bit weird arguments, it wants tuples which consists of +# the analysis paired with the script name and the bin name +merge_args = [ + (aw_server_a, "aw-server", "aw-server"), + (aw_qt_a, "aw-qt", "aw-qt"), + (aw_watcher_afk_a, "aw-watcher-afk", "aw-watcher-afk"), + (aw_watcher_window_a, "aw-watcher-window", "aw-watcher-window"), + (aw_watcher_input_a, "aw-watcher-input", "aw-watcher-input"), +] +if aw_notify_a is not None: + merge_args.append((aw_notify_a, "aw-notify", "aw-notify")) + +MERGE(*merge_args) + + +# aw-server +aws_coll = build_collect(aw_server_a, "aw-server") + +# aw-watcher-window +aww_coll = build_collect(aw_watcher_window_a, "aw-watcher-window") + +# aw-watcher-afk +awa_coll = build_collect(aw_watcher_afk_a, "aw-watcher-afk") + +# aw-qt +awq_coll = build_collect( + aw_qt_a, + "aw-qt", + console=False if platform.system() == "Windows" else True, + entitlements=app_entitlements_file, +) + +# aw-watcher-input +awi_coll = build_collect(aw_watcher_input_a, "aw-watcher-input") + +# aw-notify (only if Python package exists) +aw_notify_coll = build_collect(aw_notify_a, "aw-notify") if aw_notify_a is not None else None + +if platform.system() == "Darwin": + bundle_args = [ + awq_coll, + aws_coll, + aww_coll, + awa_coll, + awi_coll, + ] + if aw_notify_coll is not None: + bundle_args.append(aw_notify_coll) + + app = BUNDLE( + *bundle_args, + name="ActivityWatch.app", + icon=icon, + bundle_identifier="net.activitywatch.ActivityWatch", + version=current_release.lstrip("v"), + info_plist={ + "NSPrincipalClass": "NSApplication", + "CFBundleExecutable": "MacOS/aw-qt", + "CFBundleIconFile": "logo.icns", + "NSAppleEventsUsageDescription": "Please grant access to use Apple Events", + # This could be set to a more specific version string (including the commit id, for example) + "CFBundleVersion": current_release.lstrip("v"), + # Replaced by the 'version' kwarg above + # "CFBundleShortVersionString": current_release.lstrip('v'), + }, + ) + + # BUNDLE inherits signing settings from the last COLLECT and deep-signs the + # assembled bundle with them. Re-sign the application layer so its responsible + # executable retains app-only capabilities without propagating them to helpers. + osxutils.sign_binary(app.name, codesign_identity, app_entitlements_file) diff --git a/gptme.toml b/gptme.toml new file mode 100644 index 0000000..96ddb72 --- /dev/null +++ b/gptme.toml @@ -0,0 +1,10 @@ +files = [ + "README.md", + "Makefile", + "aw-server/README.md", + "aw-server/aw-webui/README.md", + "aw-server-rust/README.md", + "aw-server-rust/aw-sync/README.md", + "aw-client/README.md", + # ideally we'd also include some of the docs here, but they are not a submodule +] diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..e2f5fef --- /dev/null +++ b/poetry.lock @@ -0,0 +1,554 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "altgraph" +version = "0.17.4" +description = "Python graph (network) package" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "python_version < \"3.14\"" +files = [ + {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, + {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.6.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "importlib-metadata" +version = "8.5.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.9\"" +files = [ + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "macholib" +version = "1.16.3" +description = "Mach-O header analysis and editing" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "python_version < \"3.14\" and sys_platform == \"darwin\"" +files = [ + {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, + {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, +] + +[package.dependencies] +altgraph = ">=0.17" + +[[package]] +name = "mypy" +version = "1.12.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy-1.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4397081e620dc4dc18e2f124d5e1d2c288194c2c08df6bdb1db31c38cd1fe1ed"}, + {file = "mypy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:684a9c508a283f324804fea3f0effeb7858eb03f85c4402a967d187f64562469"}, + {file = "mypy-1.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cabe4cda2fa5eca7ac94854c6c37039324baaa428ecbf4de4567279e9810f9e"}, + {file = "mypy-1.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:060a07b10e999ac9e7fa249ce2bdcfa9183ca2b70756f3bce9df7a92f78a3c0a"}, + {file = "mypy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:0eff042d7257f39ba4ca06641d110ca7d2ad98c9c1fb52200fe6b1c865d360ff"}, + {file = "mypy-1.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b86de37a0da945f6d48cf110d5206c5ed514b1ca2614d7ad652d4bf099c7de7"}, + {file = "mypy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c7c5ce0c1be0b0aea628374e6cf68b420bcc772d85c3c974f675b88e3e6e57"}, + {file = "mypy-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a64ee25f05fc2d3d8474985c58042b6759100a475f8237da1f4faf7fcd7e6309"}, + {file = "mypy-1.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:faca7ab947c9f457a08dcb8d9a8664fd438080e002b0fa3e41b0535335edcf7f"}, + {file = "mypy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:5bc81701d52cc8767005fdd2a08c19980de9ec61a25dbd2a937dfb1338a826f9"}, + {file = "mypy-1.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8462655b6694feb1c99e433ea905d46c478041a8b8f0c33f1dab00ae881b2164"}, + {file = "mypy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:923ea66d282d8af9e0f9c21ffc6653643abb95b658c3a8a32dca1eff09c06475"}, + {file = "mypy-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ebf9e796521f99d61864ed89d1fb2926d9ab6a5fab421e457cd9c7e4dd65aa9"}, + {file = "mypy-1.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e478601cc3e3fa9d6734d255a59c7a2e5c2934da4378f3dd1e3411ea8a248642"}, + {file = "mypy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:c72861b7139a4f738344faa0e150834467521a3fba42dc98264e5aa9507dd601"}, + {file = "mypy-1.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52b9e1492e47e1790360a43755fa04101a7ac72287b1a53ce817f35899ba0521"}, + {file = "mypy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48d3e37dd7d9403e38fa86c46191de72705166d40b8c9f91a3de77350daa0893"}, + {file = "mypy-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f106db5ccb60681b622ac768455743ee0e6a857724d648c9629a9bd2ac3f721"}, + {file = "mypy-1.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:233e11b3f73ee1f10efada2e6da0f555b2f3a5316e9d8a4a1224acc10e7181d3"}, + {file = "mypy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ae8959c21abcf9d73aa6c74a313c45c0b5a188752bf37dace564e29f06e9c1b"}, + {file = "mypy-1.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eafc1b7319b40ddabdc3db8d7d48e76cfc65bbeeafaa525a4e0fa6b76175467f"}, + {file = "mypy-1.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9b9ce1ad8daeb049c0b55fdb753d7414260bad8952645367e70ac91aec90e07e"}, + {file = "mypy-1.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfe012b50e1491d439172c43ccb50db66d23fab714d500b57ed52526a1020bb7"}, + {file = "mypy-1.12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c40658d4fa1ab27cb53d9e2f1066345596af2f8fe4827defc398a09c7c9519b"}, + {file = "mypy-1.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:dee78a8b9746c30c1e617ccb1307b351ded57f0de0d287ca6276378d770006c0"}, + {file = "mypy-1.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b5df6c8a8224f6b86746bda716bbe4dbe0ce89fd67b1fa4661e11bfe38e8ec8"}, + {file = "mypy-1.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5feee5c74eb9749e91b77f60b30771563327329e29218d95bedbe1257e2fe4b0"}, + {file = "mypy-1.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77278e8c6ffe2abfba6db4125de55f1024de9a323be13d20e4f73b8ed3402bd1"}, + {file = "mypy-1.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:dcfb754dea911039ac12434d1950d69a2f05acd4d56f7935ed402be09fad145e"}, + {file = "mypy-1.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:06de0498798527451ffb60f68db0d368bd2bae2bbfb5237eae616d4330cc87aa"}, + {file = "mypy-1.12.0-py3-none-any.whl", hash = "sha256:fd313226af375d52e1e36c383f39bf3836e1f192801116b31b090dfcd3ec5266"}, + {file = "mypy-1.12.0.tar.gz", hash = "sha256:65a22d87e757ccd95cbbf6f7e181e6caa87128255eb2b6be901bb71b26d8a99d"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pefile" +version = "2023.2.7" +description = "Python PE parsing module" +optional = false +python-versions = ">=3.6.0" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, + {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "psutil" +version = "6.0.0" +description = "Cross-platform lib for process and system monitoring in Python." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["dev"] +files = [ + {file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"}, + {file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"}, + {file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"}, + {file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"}, + {file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"}, + {file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"}, + {file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"}, + {file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"}, + {file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"}, + {file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"}, + {file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"}, + {file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"}, + {file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"}, +] + +[package.extras] +test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +description = "Get CPU info with pure Python" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"}, + {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"}, +] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyinstaller" +version = "6.11.0" +description = "PyInstaller bundles a Python application and all its dependencies into a single package." +optional = false +python-versions = "<3.14,>=3.8" +groups = ["dev"] +markers = "python_version < \"3.14\"" +files = [ + {file = "pyinstaller-6.11.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:6fd68a3c1207635c49326c54881b89d5c3bd9ba061bbc9daa58c0902db1be39e"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:eddd53f231e51adc65088eac4f40057ca803a990239828d4a9229407fb866239"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_i686.whl", hash = "sha256:e6d229009e815542833fe00332b589aa6984a06f794dc16f2ce1acab1c567590"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d2cd2ebdcd6860f8a4abe2977264a7b6d260a7147047008971c7cfc66a656a4"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:d9ec6d4398b4eebc1d4c00437716264ba8406bc2746f594e253070a82378a584"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:04f71828aa9531ab18c9656985c1f09b83d10332c73a1f4a113a48b491906955"}, + {file = "pyinstaller-6.11.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:a843d470768d68b05684ccf4860c45b2eb13727f41667c0b2cd8f57ae231bd18"}, + {file = "pyinstaller-6.11.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:963dedc1f37144a4385f58f7f65f1c69c004a67faae522a2085b5ddb230c908b"}, + {file = "pyinstaller-6.11.0-py3-none-win32.whl", hash = "sha256:c71024c8a19c7b221b9152b2baff4c3ba849cada68dcdd34382ba09f0107451f"}, + {file = "pyinstaller-6.11.0-py3-none-win_amd64.whl", hash = "sha256:0e229610c22b96d741d905706f9496af472c1a9216a118988f393c98ecc3f51f"}, + {file = "pyinstaller-6.11.0-py3-none-win_arm64.whl", hash = "sha256:a5f716bb507517912fda39d109dead91fc0dd2e7b2859562522b63c61aa21676"}, + {file = "pyinstaller-6.11.0.tar.gz", hash = "sha256:cb4d433a3db30d9d17cf5f2cf7bb4df80a788d493c1d67dd822dc5791d9864af"}, +] + +[package.dependencies] +altgraph = "*" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +packaging = ">=22.0" +pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2024.8" +pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} +setuptools = ">=42.0.0" + +[package.extras] +completion = ["argcomplete"] +hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2024.9" +description = "Community maintained hooks for PyInstaller" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [] +develop = false + +[package.dependencies] +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +packaging = ">=22.0" +setuptools = ">=42.0.0" + +[package.source] +type = "git" +url = "https://github.com/pyinstaller/pyinstaller-hooks-contrib.git" +reference = "master" +resolved_reference = "5bcaf52e302d2adeaf4457c435ffa07f5b4b1f53" + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-benchmark" +version = "4.0.0" +description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-benchmark-4.0.0.tar.gz", hash = "sha256:fb0785b83efe599a6a956361c0691ae1dbb5318018561af10f3e915caa0048d1"}, + {file = "pytest_benchmark-4.0.0-py3-none-any.whl", hash = "sha256:fdb7db64e31c8b277dff9850d2a2556d8b60bcb0ea6524e36e28ffd7c87f71d6"}, +] + +[package.dependencies] +py-cpuinfo = "*" +pytest = ">=3.8" + +[package.extras] +aspect = ["aspectlib"] +elasticsearch = ["elasticsearch"] +histogram = ["pygal", "pygaljs"] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + +[[package]] +name = "setuptools" +version = "80.10.2" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173"}, + {file = "setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "tomli" +version = "2.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "zipp" +version = "3.20.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.9\"" +files = [ + {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, + {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.9" +content-hash = "7a636d2a4560801509f660b530070b2fdae02f6dae003819eae3df3cba319a18" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3dfac14 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[tool.poetry] +name = "activitywatch" +version = "0.14.0" +description = "The free and open-source automated time tracker. Cross-platform, extensible, privacy-focused." +authors = ["Erik Bjäreholt ", "Johan Bjäreholt "] +license = "MPL-2.0" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +# Installing them from here won't work +#aw-core = {path = "aw-core"} +#aw-client = {path = "aw-client"} +#aw-watcher-afk = {path = "aw-watcher-afk"} +#aw-watcher-window = {path = "aw-watcher-window"} +#aw-server = {path = "aw-server"} +#aw-qt = {path = "aw-qt"} + +[tool.poetry.group.dev.dependencies] +mypy = "*" +pytest = "*" +pytest-cov = "*" +pytest-benchmark = "*" +psutil = "*" +pywin32-ctypes = {version = "*", platform = "win32"} +pefile = {version = "*", platform = "win32"} + +pyinstaller = {version = "*", python = "^3.9,<3.14"} +# releases are very infrequent, so good idea to use the master branch +# we need this unreleased commit: https://github.com/pyinstaller/pyinstaller-hooks-contrib/commit/0f40dc6e74086e5472aee75070b9077b4c17ab18 +pyinstaller-hooks-contrib = {git = "https://github.com/pyinstaller/pyinstaller-hooks-contrib.git", branch="master"} + +# PyInstaller still imports pkg_resources, which setuptools removed in 81+. +setuptools = ">=78.1.1,<81" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/scripts/build_changelog.py b/scripts/build_changelog.py new file mode 100755 index 0000000..d2cda8d --- /dev/null +++ b/scripts/build_changelog.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +""" +Script that generates a changelog for the repository and its submodules, and outputs it in the current directory. + +NOTE: This script can be downloaded as-is and run from your repository. + +Repos using this script: + - ActivityWatch/activitywatch + - ErikBjare/gptme + +Manual actions needed to clean up for changelog: + - Reorder modules in a logical order (aw-webui, aw-server, aw-server-rust, aw-watcher-window, aw-watcher-afk, ...) + - Remove duplicate aw-webui entries +""" + +import argparse +import logging +import os +import re +import shlex +from collections import defaultdict +from collections.abc import Collection +from dataclasses import dataclass +from pathlib import Path +from subprocess import PIPE, STDOUT +from subprocess import run as _run +from time import sleep +from typing import ( + Dict, + List, + Optional, + Tuple, +) + +import requests + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +script_dir = Path(__file__).parent.resolve() + + +def main(): + parser = argparse.ArgumentParser(description="Generate changelog from git history") + + # repo info + parser.add_argument("--org", default="ActivityWatch", help="GitHub organization") + parser.add_argument("--repo", default="activitywatch", help="GitHub repository") + parser.add_argument( + "--project-title", default="ActivityWatch", help="Project title" + ) + + # settings + last_tag = run("git describe --tags --abbrev=0").strip() # get latest tag + branch = run("git rev-parse --abbrev-ref HEAD").strip() # get current branch name + parser.add_argument( + "--range", default=f"{last_tag}...{branch}", help="Git commit range" + ) + parser.add_argument("--path", default=".", help="Path to git repo") + + # output + parser.add_argument( + "--output", default="changelog.md", help="Path to output changelog" + ) + parser.add_argument( + "--add-version-header", + action="store_true", + help="Add version header and adjust heading levels for docs", + ) + + # parse args + args = parser.parse_args() + since, until = args.range.split("...", 1) + + # preferred output order for submodules + repo_order = [ + "activitywatch", + "aw-server", + "aw-server-rust", + "aw-webui", + "aw-watcher-afk", + "aw-watcher-window", + "aw-qt", + "aw-core", + "aw-client", + ] + + build( + args.org, + args.repo, + args.project_title, + commit_range=(since, until), + output_path=args.output, + repo_order=repo_order, + add_version_header=args.add_version_header, + ) + + +class CommitMsg: + type: str + subtype: str + msg: str + + +@dataclass +class Commit: + id: str + msg: str + org: str + repo: str + + @property + def msg_processed(self) -> str: + """Generates links from commit and issue references (like 0c14d77, #123) to correct repo and such""" + s = self.msg + s = re.sub( + rf"[^(-]https://github.com/{self.org}/([\-\w\d]+)/(issues|pulls)/(\d+)", + rf"[#\3](https://github.com/{self.org}/\1/issues/\3)", + s, + ) + s = re.sub( + r"#(\d+)", + rf"[#\1](https://github.com/{self.org}/{self.repo}/issues/\1)", + s, + ) + s = re.sub( + r"[\s\(][0-9a-f]{7}[\s\)]", + rf"[`\0`](https://github.com/{self.org}/{self.repo}/issues/\0)", + s, + ) + # wrap html elements in backticks, if not already wrapped + s = re.sub(r"(?]+)>(?!`)", r"`<\1>`", s) + return s + + def parse_type(self) -> Optional[Tuple[str, str]]: + # Needs to handle '!' indicating breaking change + match = re.search(r"^(\w+)(\((.+)\))?[!]?:", self.msg) + if match: + type = match.group(1) + subtype = match.group(3) + if type in ["build", "ci", "fix", "feat"]: + return type, subtype + return None + + @property + def type(self) -> Optional[str]: + _type, _ = self.parse_type() or (None, None) + return _type + + @property + def subtype(self) -> Optional[str]: + _, subtype = self.parse_type() or (None, None) + return subtype + + def type_str(self) -> str: + _type, subtype = self.parse_type() or (None, None) + return f"{_type}" + (f"({subtype})" if subtype else "") + + def format(self) -> str: + commit_link = commit_linkify(self.id, self.org, self.repo) if self.id else "" + + return f"{self.msg_processed}" + (f" ({commit_link})" if commit_link else "") + + +def run(cmd, cwd=".") -> str: + logger.debug(f"Running in {cwd}: {cmd}") + p = _run(shlex.split(cmd), stdout=PIPE, stderr=STDOUT, encoding="utf8", cwd=cwd) + if p.returncode != 0: + print(p.stdout) + print(p.stderr) + raise Exception + return p.stdout + + +def pr_linkify(prid: str, org: str, repo: str) -> str: + return f"[#{prid}](https://github.com/{org}/{repo}/pulls/{prid})" + + +def commit_linkify(commitid: str, org: str, repo: str) -> str: + return f"[`{commitid}`](https://github.com/{org}/{repo}/commit/{commitid})" + + +def wrap_details(title, body, wraplines=5): + """Wrap lines into a
element if body is longer than `wraplines`""" + out = f"\n\n### {title}" + wrap = body.strip().count("\n") > wraplines + if wrap: + out += "\n
Click to expand\n

" + out += f"\n{body.rstrip()}" + if wrap: + out += "\n\n

\n
" + return out + + +contributor_emails = set() + + +def summary_repo( + org: str, + repo: str, + path: str, + commit_range: Tuple[str, str], + filter_types: List[str], + repo_order: List[str], +) -> str: + if commit_range[1] == "0000000": + # Happens when a submodule has been removed + return "" + if commit_range[0] == "0000000": + # Happens when a submodule has been added + commit_range = ("", "") # no range = all commits for new submodule + + out = f"\n## 📦 {repo}" + + feats = "" + fixes = "" + misc = "" + hidden = 0 + + # pretty format is modified version of: https://stackoverflow.com/a/1441062/965332 + summary_bundle = run( + f"git log {'...'.join(commit_range) if any(commit_range) else ''} --no-decorate --pretty=format:'%h%x09%an%x09%ae%x09%s'", + cwd=path, + ) + print(f"Found {len(summary_bundle.splitlines())} commits in {repo}") + for line in summary_bundle.split("\n"): + if line: + _id, _author, email, msg = line.split("\t") + # will add author email to contributor list + # the `contributor_emails` is global and collected later + contributor_emails.add(email) + commit = Commit(id=_id, msg=msg, org=org, repo=repo) + + entry = f"\n - {commit.format()}" + if commit.type == "feat": + feats += entry + elif commit.type == "fix": + fixes += entry + elif commit.type not in filter_types: + misc += entry + else: + hidden += 1 + + for name, entries in ( + ("✨ Features", feats), + ("🐛 Fixes", fixes), + ("🔨 Misc", misc), + ): + if entries: + _count = len(entries.strip().split("\n")) + title = f"{name} ({_count})" + if "Misc" in name or "Fixes" in name: + out += wrap_details(title, entries) + else: + out += f"\n\n### {title}\n" + out += entries + if hidden > 1: + full_history_url = f"https://github.com/{org}/{repo}/compare/{commit_range[0]}...{commit_range[1]}" + out += f"\n\n*(excluded {hidden} less relevant [commits]({full_history_url}))*" + + # NOTE: For now, these TODOs can be manually fixed for each changelog. + # TODO: Fix issue where subsubmodules can appear twice (like aw-webui) + # TODO: Use specific order (aw-webui should be one of the first, for example) + summary_subrepos = run( + f"git submodule summary --cached {commit_range[0]}", cwd=path + ) + subrepos = {} + for header, *_ in [s.split("\n") for s in summary_subrepos.split("\n\n")]: + if header.startswith("fatal: not a git repository"): + # Happens when a submodule has been removed + continue + if header.strip(): + if len(header.split(" ")) < 4: + # Submodule may have been deleted + continue + + _, name, crange, count = header.split(" ") + commit_range = tuple(crange.split("...", 1)) # type: ignore + count = count.strip().lstrip("(").rstrip("):") + logger.info( + f"Found {name}, looking up range: {commit_range} ({count} commits)" + ) + name = name.strip(".").strip("/") + + subrepos[name] = summary_repo( + org, + name, + f"{path}/{name}", + commit_range, + filter_types=filter_types, + repo_order=repo_order, + ) + + # filter out subrepos with no commits (single line after stripping whitespace) + subrepos = { + name: output + for name, output in subrepos.items() + if len(output.strip().splitlines()) > 1 + } + + # pick subrepos in repo_order, and remove from dict + for name in repo_order: + if name in subrepos: + out += "\n" + out += subrepos[name] + logger.info(f"{name:12} length: \t{len(subrepos[name])}") + del subrepos[name] + + # add remaining repos + for output in subrepos.values(): + out += "\n" + out += output + + return out + + +# FIXME: Doesn't work, messy af, just gonna have to remove the aw-webui section by hand +def remove_duplicates(s: List[str], minlen=10, only_sections=True) -> List[str]: + """ + Removes the longest sequence of repeated elements (they don't have to be adjacent), if sequence if longer than `minlen`. + Preserves order of elements. + """ + if len(s) < minlen: + return s + out = [] + longest: List[str] = [] + for i in range(len(s)): + if i == 0 or s[i] not in out: + # Not matching any previous line, + # so add longest and new line to output, and reset longest + if len(longest) < minlen: + out.extend(longest) + else: + duplicate = "\n".join(longest) + print(f"Removing duplicate '{duplicate[:80]}...'") + out.append(s[i]) + longest = [] + else: + # Matches a previous line, so add to longest + # If longest is empty and only_sections is True, check that the line is a section start + if only_sections: + if not longest and s[i].startswith("#"): + longest.append(s[i]) + else: + out.append(s[i]) + else: + longest.append(s[i]) + + return out + + +def build( + org: str, + repo: str, + project_name: str, + commit_range: Tuple[str, str], + output_path: str, + repo_order: List[str], + filter_types: Optional[List[str]] = None, + add_version_header: bool = False, +): + # provides a commit summary for the repo and subrepos, recursively looking up subrepos + # NOTE: this must be done *before* `get_all_contributors` is called, + # as the latter relies on summary_repo looking up all users and storing in a global. + if not filter_types: + filter_types = ["build", "ci", "tests", "test"] + + logger.info("Generating commit summary") + since, tag = commit_range + output_changelog = summary_repo( + org, + repo, + ".", + commit_range=commit_range, + filter_types=filter_types, + repo_order=repo_order, + ) + + output_changelog = f""" +# Changelog + +Changes since [{since}](https://github.com/{org}/{repo}/releases/tag/{since}): + +{output_changelog} + """.strip() + + # Would ideally sort by number of commits or something, but that's tricky + usernames = sorted(get_all_contributors(), key=str.casefold) + usernames = [u for u in usernames if not u.endswith("[bot]")] + twitter_handles = get_twitter_of_ghusers(usernames) + print( + "Twitter handles: " + + ", ".join("@" + handle for handle in twitter_handles.values() if handle), + ) + + output_contributors = f"""# Contributors + +Thanks to everyone who contributed to this release: + +{', '.join(('@' + username for username in usernames))}""" + + # Header starts here + logger.info("Building final output") + output = f"These are the release notes for {project_name} version {tag}.".strip() + output += "\n\n" + + # hardcoded for now + if repo == "activitywatch": + output += "**New to ActivityWatch?** Check out the [website](https://activitywatch.net) and the [README](https://github.com/ActivityWatch/activitywatch/blob/master/README.md)." + output += "\n\n" + output += """# Installation + +See the [getting started guide in the documentation](https://docs.activitywatch.net/en/latest/getting-started.html). + """.strip() + output += "\n\n" + output += f"""# Downloads + + - [**Windows**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-windows-x86_64-setup.exe) (.exe, installer) + - [**macOS**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-macos-x86_64.dmg) (.dmg) + - [**Linux**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-linux-x86_64.zip) (.zip) + """.strip() + output += "\n\n" + + output += output_contributors.strip() + "\n\n" + output += output_changelog.strip() + "\n\n" + output += ( + f"**Full Changelog**: https://github.com/{org}/{repo}/compare/{since}...{tag}" + ) + + if repo == "activitywatch": + output = output.replace("# activitywatch", "# activitywatch (bundle repo)") + + if add_version_header: + output = f"# {tag}\n\n" + output + output = output.replace("\n# Contributors\n", "\n## Contributors\n") + output = output.replace("\n# Changelog\n", "\n## Changelog\n") + + with open(output_path, "w") as f: + f.write(output) + print(f"Wrote {len(output.splitlines())} lines to {output_path}") + + +def _resolve_email(email: str) -> Optional[str]: + if "users.noreply.github.com" in email: + username = email.split("@")[0] + if "+" in username: + username = username.split("+")[1] + # TODO: Verify username is valid using the GitHub API + print(f"Contributor: @{username}") + return username + else: + resp = None + backoff = 0 + max_backoff = 2 + while resp is None: + if backoff >= max_backoff: + logger.warning(f"Backed off {max_backoff} times, giving up") + break + try: + logger.info(f"Sending request for {email}") + _resp = requests.get( + f"https://api.github.com/search/users?q={email}+in%3Aemail" + ) + _resp.raise_for_status() + resp = _resp + backoff = 0 + # if rate limit exceeded, back off + except requests.exceptions.RequestException as e: + if isinstance(e, requests.exceptions.HTTPError): + if e.response.status_code == 403: + logger.warning("Rate limit exceeded, backing off...") + backoff += 1 + sleep(3) + continue + else: + raise e + finally: + # Just to respect API limits... + sleep(1) + + if resp: + data = resp.json() + if data["total_count"] == 0: + logger.info(f"No match for email: {email}") + if data["total_count"] > 1: + logger.warning(f"Multiple matches for email: {email}") + if data["total_count"] >= 1: + username = data["items"][0]["login"] + logger.info(f"Contributor: @{username} (by email: {email})") + return username + return None + + +def get_all_contributors() -> set[str]: + # TODO: Merge with contributor-stats? + logger.info("Getting all contributors") + + # We will commit this file, to act as a cache (preventing us from querying GitHub API every time) + filename = script_dir / "changelog_contributors.csv" + + # mapping from username to one or more emails + usernames: Dict[str, set] = defaultdict(set) + + # some hardcoded ones, some that don't resolve... + usernames["erikbjare"] |= {"erik.bjareholt@gmail.com", "erik@bjareho.lt"} + usernames["iloveitaly"] |= {"iloveitaly@gmail.com"} + usernames["kewde"] |= {"kewde@particl.io"} + usernames["victorwinberg"] |= {"victor.m.winberg@gmail.com"} + usernames["NicoWeio"] |= {"nico.weio@gmail.com"} + usernames["2e3s"] |= {"2e3s19@gmail.com"} + usernames["alwinator"] |= {"accounts@alwinschuster.at"} + + # read existing contributors, to avoid extra calls to the GitHub API + if os.path.exists(filename): + with open(filename, "r") as f: + s = f.read() + for line in s.split("\n"): + if not line: + continue + username, *emails = line.split("\t") + for email in emails: + usernames[username].add(email) + logger.info(f"Read {len(usernames)} contributors from {filename}") + + resolved_emails = set( + email for email_set in usernames.values() for email in email_set + ) + unresolved_emails = contributor_emails - resolved_emails + for email in unresolved_emails: + username_opt = _resolve_email(email) + if username_opt: + usernames[username_opt].add(email) + + with open(filename, "w") as f: + for username, email_set in sorted(usernames.items()): + emails_str = "\t".join(sorted(email_set)) + f.write(f"{username}\t{emails_str}") + f.write("\n") + + logger.info(f"Wrote {len(usernames)} contributors to {filename}") + + email_to_username = { + email: username for username, emails in usernames.items() for email in emails + } + + return set( + email_to_username[email] + for email in contributor_emails + if email in email_to_username + ) + + +def get_twitter_of_ghusers(ghusers: Collection[str]): + logger.info("Getting twitter of GitHub usernames") + + # We will commit this file, to act as a cache (preventing us from querying GitHub API every time) + filename = script_dir / "changelog_contributors_twitter.csv" + + twitter = {} + + # read existing contributors, to avoid extra calls to the GitHub API + if os.path.exists(filename): + with open(filename, "r") as f: + s = f.read() + for line in s.split("\n"): + if not line: + continue + gh_username, twitter_username = line.split("\t") + twitter[gh_username] = twitter_username + logger.info(f"Read {len(twitter)} Twitter handles from {filename}") + + for username in ghusers: + if username in twitter: + continue + try: + resp = requests.get(f"https://api.github.com/users/{username}") + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.warning(f"Failed to get twitter of {username}: {e}") + continue + + twitter_username = data["twitter_username"] + if twitter_username: + twitter[username] = twitter_username + + with open(filename, "w") as f: + for username, twitter_username in sorted(twitter.items()): + f.write(f"{username}\t{twitter_username}") + f.write("\n") + + return twitter + + +if __name__ == "__main__": + main() diff --git a/scripts/changelog_contributors.csv b/scripts/changelog_contributors.csv new file mode 100644 index 0000000..f3d7532 --- /dev/null +++ b/scripts/changelog_contributors.csv @@ -0,0 +1,69 @@ +2e3s 2e3s19@gmail.com +750 37119951+750@users.noreply.github.com +Alwinator 39517491+Alwinator@users.noreply.github.com +BasileusErwin 67933444+BasileusErwin@users.noreply.github.com +BelKed 66956532+BelKed@users.noreply.github.com +CrazyPython Jamtlu@gmail.com +Drarig29 corentingirard.dev@gmail.com +Elijah-Bodden 106613755+Elijah-Bodden@users.noreply.github.com +Furffico 43836984+Furffico@users.noreply.github.com +GabLeRoux lebreton.gabriel@gmail.com +Game4Move78 83040764+Game4Move78@users.noreply.github.com +Julianoe Julianoe@users.noreply.github.com +LockBlock-dev 68129141+LockBlock-dev@users.noreply.github.com +LunarWatcher zoe.i2k1@gmail.com +NicoWeio kontakt@nicolaiweitkemper.de nico.weio@gmail.com +Organoidus 150709464+Organoidus@users.noreply.github.com +Shi-Soul 86898048+Shi-Soul@users.noreply.github.com +ShootingKing-AM narnindi.raghu@gmail.com +Shubham0324 53115519+Shubham0324@users.noreply.github.com +StefanoChiodino StefanoChiodino@users.noreply.github.com +TSRBerry 20988865+TSRBerry@users.noreply.github.com +Valentin-N 1926716+Valentin-N@users.noreply.github.com +Y7n05h Y7n05h@protonmail.com +aaayushsingh ayush-_-singh@live.com +alclary 9044153+alclary@users.noreply.github.com +alialamine ali@towbe.com +alwinator accounts@alwinschuster.at +0xbrayo vukubrian@gmail.com +chaoky levimanga@gmail.com +chengyuhui chengyuhui1@gmail.com +davidfraser davidfraser@users.noreply.github.com +dependabot-preview[bot] 27856297+dependabot-preview[bot]@users.noreply.github.com +dependabot[bot] 49699333+dependabot[bot]@users.noreply.github.com +erikbjare erik.bjareholt@gmail.com erik@bjareho.lt +hooger hooger@users.noreply.github.com +iloveitaly iloveitaly@gmail.com +infokiller infokiller@users.noreply.github.com +ishitatsuyuki ishitatsuyuki@gmail.com +jkbh 33606327+jkbh@users.noreply.github.com +johan-bjareholt johan@bjareho.lt +jtojnar jtojnar@gmail.com +kewde kewde@particl.io +lgtm-com[bot] 43144390+lgtm-com[bot]@users.noreply.github.com +liutiming 39947942+liutiming@users.noreply.github.com +luzpaz luzpaz@users.noreply.github.com +maciekstosio maciekstosio@users.noreply.github.com +michaeljelly 53475252+michaeljelly@users.noreply.github.com +modderme123 modderme123@users.noreply.github.com +nathanmerrill nathanmerrill@users.noreply.github.com +noisersup patryk@kwiatek.xyz +ochen1 o.chen1@share.epsb.ca +omahs 73983677+omahs@users.noreply.github.com +oscar-king oscar-king@users.noreply.github.com +pktiuk kotiuk@zohomail.eu +pkvach pavel.kvach@gmail.com +rakleed 19418601+rakleed@users.noreply.github.com +repo-visualizer repo-visualizer@users.noreply.github.com +salahineo salahineo.personal@gmail.com +skaparis 43264989+skaparis@users.noreply.github.com +skewballfox joshua.ferguson.273@gmail.com +soxofaan soxofaan@users.noreply.github.com +sunrosa 79175772+sunrosa@users.noreply.github.com +vedantmgoyal2009 83997633+vedantmgoyal2009@users.noreply.github.com +victorlin 13424970+victorlin@users.noreply.github.com +victorwinberg victor.m.winberg@gmail.com +vieteh viet.tran@employmenthero.com +xylix kerk.pelt@gmail.com +yuhldr yuhldr@qq.com +yumemio 59369226+yumemio@users.noreply.github.com diff --git a/scripts/changelog_contributors_twitter.csv b/scripts/changelog_contributors_twitter.csv new file mode 100644 index 0000000..d0560c1 --- /dev/null +++ b/scripts/changelog_contributors_twitter.csv @@ -0,0 +1,6 @@ +0xbrayo subrupt +chaoky chaokyer +erikbjare erikbjare +iloveitaly mike_bianco +vedantmgoyal2009 vedantmgoyal +victorlin victorlin_ diff --git a/scripts/check_tauri_server.py b/scripts/check_tauri_server.py new file mode 100755 index 0000000..011d48c --- /dev/null +++ b/scripts/check_tauri_server.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Verify that Qt and Tauri bundle the same aw-server-rust revision.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path +from typing import List, Optional, Tuple + + +FIELD_RE = re.compile(r'^\s*([a-zA-Z0-9_-]+)\s*=\s*"([^"]+)"\s*$', re.MULTILINE) +VERSION_RE = re.compile(r"^(\d+)\.(\d+)(?:\.|-|$)") +REVISION_RE = re.compile(r"#([0-9a-f]{40})$") + + +def major_minor(version: str) -> str: + match = VERSION_RE.match(version) + if not match: + raise ValueError(f"invalid version: {version!r}") + return ".".join(match.groups()) + + +def read_package_version(cargo_toml: Path) -> str: + in_package = False + for line in cargo_toml.read_text(encoding="utf-8").splitlines(): + if line.strip() == "[package]": + in_package = True + continue + if in_package and line.startswith("["): + break + if in_package: + match = re.match(r'^version\s*=\s*"([^"]+)"', line) + if match: + return match.group(1) + raise ValueError(f"no [package].version in {cargo_toml}") + + +def read_locked_server(cargo_lock: Path) -> Tuple[str, str]: + matches = [] + for block in re.split( + r"(?m)^\[\[package\]\]\s*$", cargo_lock.read_text(encoding="utf-8") + ): + fields = dict(FIELD_RE.findall(block)) + source = fields.get("source", "") + if ( + fields.get("name") == "aw-server" + and "ActivityWatch/aw-server-rust" in source + ): + revision = REVISION_RE.search(source) + if not revision: + raise ValueError( + f"aw-server source has no full Git revision: {source!r}" + ) + matches.append((fields["version"], revision.group(1))) + + if len(matches) != 1: + raise ValueError( + f"expected one Git-locked aw-server package in {cargo_lock}, found {len(matches)}" + ) + return matches[0] + + +def validation_errors( + release_version: Optional[str], + submodule_version: str, + submodule_revision: str, + tauri_version: str, + tauri_revision: str, +) -> List[str]: + errors = [] + if release_version is not None: + release_line = major_minor(release_version) + if major_minor(submodule_version) != release_line: + errors.append( + f"aw-server-rust submodule version {submodule_version} does not match " + f"release line {release_line}" + ) + if major_minor(tauri_version) != release_line: + errors.append( + f"Tauri locks aw-server {tauri_version}, which does not match release line " + f"{release_line}" + ) + if tauri_revision != submodule_revision: + errors.append( + f"Tauri locks aw-server-rust {tauri_revision[:12]}, but the release " + f"submodule is {submodule_revision[:12]}" + ) + return errors + + +def is_git_checkout(path: Path) -> bool: + """Return whether path is the root of an initialized Git checkout.""" + try: + git_root = Path( + subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "--show-toplevel"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + ).resolve() + except subprocess.CalledProcessError: + return False + return git_root == path.resolve() + + +def initialize_submodule(root: Path, name: str) -> bool: + """Initialize a missing submodule without resetting an updated checkout.""" + path = root / name + if is_git_checkout(path): + expected_git_dir = Path( + subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "--git-path", f"modules/{name}"], + text=True, + ).strip() + ) + if not expected_git_dir.is_absolute(): + expected_git_dir = root / expected_git_dir + expected_git_dir = expected_git_dir.resolve() + actual_git_dir = Path( + subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "--absolute-git-dir"], + text=True, + ).strip() + ).resolve() + if actual_git_dir != expected_git_dir: + hint = "" + if (path / ".git").is_dir(): + hint = ( + f"; if this is an old-form submodule, migrate it first with " + f"'git submodule absorbgitdirs {name}'" + ) + raise ValueError( + f"refusing unmanaged Git checkout at configured submodule path {path}" + f"{hint}" + ) + return False + subprocess.run( + ["git", "-C", str(root), "submodule", "update", "--init", name], check=True + ) + return True + + +def sync_submodule(server_root: Path, revision: str) -> bool: + """Check out the Tauri-locked revision in the top-level server submodule.""" + if not is_git_checkout(server_root): + raise ValueError( + f"aw-server-rust is not initialized as a Git submodule at {server_root}" + ) + + dirty = subprocess.check_output( + [ + "git", + "-C", + str(server_root), + "status", + "--porcelain", + "--untracked-files=no", + "--ignore-submodules=all", + ], + text=True, + ).strip() + if dirty: + raise ValueError( + f"refusing to replace dirty aw-server-rust checkout at {server_root}" + ) + + current_revision = subprocess.check_output( + ["git", "-C", str(server_root), "rev-parse", "HEAD"], text=True + ).strip() + if current_revision == revision: + return False + + try: + subprocess.run( + ["git", "-C", str(server_root), "cat-file", "-e", f"{revision}^{{commit}}"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + subprocess.run( + ["git", "-C", str(server_root), "fetch", "origin", revision], check=True + ) + + subprocess.run( + ["git", "-C", str(server_root), "checkout", "--detach", revision], check=True + ) + subprocess.run( + ["git", "-C", str(server_root), "submodule", "update", "--init", "--recursive"], + check=True, + ) + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "release_version", + nargs="?", + help="ActivityWatch version without the leading v (required unless --sync)", + ) + parser.add_argument( + "--sync", + action="store_true", + help="make the top-level aw-server-rust submodule follow Tauri's Cargo.lock", + ) + parser.add_argument( + "--repo-root", type=Path, default=Path(__file__).resolve().parents[1] + ) + args = parser.parse_args() + if args.release_version is None and not args.sync: + parser.error("release_version is required unless --sync is used") + + root = args.repo_root.resolve() + server_root = root / "aw-server-rust" + try: + if args.sync: + initialize_submodule(root, "aw-tauri") + initialize_submodule(root, "aw-server-rust") + tauri_version, tauri_revision = read_locked_server( + root / "aw-tauri/src-tauri/Cargo.lock" + ) + if args.sync: + changed = sync_submodule(server_root, tauri_revision) + if changed: + print(f"Synced aw-server-rust to Tauri lock {tauri_revision[:12]}") + else: + print(f"aw-server-rust already matches Tauri lock {tauri_revision[:12]}") + submodule_version = read_package_version(server_root / "aw-server/Cargo.toml") + submodule_revision = subprocess.check_output( + ["git", "-C", str(server_root), "rev-parse", "HEAD"], text=True + ).strip() + errors = validation_errors( + args.release_version, + submodule_version, + submodule_revision, + tauri_version, + tauri_revision, + ) + except (OSError, subprocess.CalledProcessError, ValueError) as exc: + print( + f"ERROR: could not inspect bundled aw-server versions: {exc}", + file=sys.stderr, + ) + return 1 + + if args.release_version is not None: + print(f"ActivityWatch release: {args.release_version}") + print(f"aw-server-rust: {submodule_version} @ {submodule_revision[:12]}") + print(f"Tauri Cargo.lock: {tauri_version} @ {tauri_revision[:12]}") + if errors: + print("\nERROR: inconsistent aw-server build inputs:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + if ( + args.release_version is not None + and major_minor(tauri_version) != major_minor(args.release_version) + ): + print( + "\nUpdate aw-tauri's Cargo.lock to the intended server release line, " + "then run `make sync-tauri-server`.", + file=sys.stderr, + ) + else: + print( + "\nRun `make sync-tauri-server` to make the top-level submodule " + "follow aw-tauri's Cargo.lock.", + file=sys.stderr, + ) + return 1 + + print("OK: Qt and Tauri will bundle the same aw-server-rust revision") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checkout-latest-tag.sh b/scripts/checkout-latest-tag.sh new file mode 100755 index 0000000..68cae54 --- /dev/null +++ b/scripts/checkout-latest-tag.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +latest_version_tag=$(git tag -l | grep "^v[0-9]\..*" | sort --version-sort | tail -n1 ) +current_version_tag=$(git describe --tags) +echo "Latest version: $latest_version_tag" +echo "Current version: $current_version_tag" diff --git a/scripts/chores/make-release.sh b/scripts/chores/make-release.sh new file mode 100644 index 0000000..cf919c9 --- /dev/null +++ b/scripts/chores/make-release.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# +# We should create a release checklist to ensure releases are consistent. +# + +# Create an annotated tag +#git tag -a $ diff --git a/scripts/ci/enable_long_paths.bat b/scripts/ci/enable_long_paths.bat new file mode 100644 index 0000000..0bd15ac --- /dev/null +++ b/scripts/ci/enable_long_paths.bat @@ -0,0 +1,3 @@ +:: Enable long paths on Windows (needed when building since node_modules can create deep hierarchies) + +REG ADD "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f diff --git a/scripts/ci/import-macos-p12.sh b/scripts/ci/import-macos-p12.sh new file mode 100755 index 0000000..e792889 --- /dev/null +++ b/scripts/ci/import-macos-p12.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +set -eu + +export KEY_CHAIN="${KEY_CHAIN:-build.keychain-db}" +export CERTIFICATE_P12="${CERTIFICATE_P12:-aw_certificate.p12}" + +# Recreate the certificate from the secure environment variable. +printf '%s' "$CERTIFICATE_MACOS_P12_BASE64" | base64 --decode > "$CERTIFICATE_P12" + +# Recreate the temporary keychain on every run so stale state cannot leak between jobs. +security -v delete-keychain "$KEY_CHAIN" >/dev/null 2>&1 || true +security -v create-keychain -p travis "$KEY_CHAIN" +security -v default-keychain -s "$KEY_CHAIN" +security -v unlock-keychain -p travis "$KEY_CHAIN" +security -v set-keychain-settings -lut 21600 "$KEY_CHAIN" +security -v list-keychains -d user -s "$KEY_CHAIN" login.keychain-db login.keychain + +security -v import "$CERTIFICATE_P12" -k "$KEY_CHAIN" -P "$CERTIFICATE_MACOS_P12_PASSWORD" -A +security -v set-key-partition-list -S apple-tool:,apple: -s -k travis "$KEY_CHAIN" +security -v find-identity -v -p codesigning "$KEY_CHAIN" + +rm -f "$CERTIFICATE_P12" diff --git a/scripts/ci/install_node.ps1 b/scripts/ci/install_node.ps1 new file mode 100644 index 0000000..874607f --- /dev/null +++ b/scripts/ci/install_node.ps1 @@ -0,0 +1,40 @@ +$msipath = "$PSScriptRoot\node-installer.msi" + +function RunCommand ($command, $command_args) { + Write-Host $command $command_args + Start-Process -FilePath $command -ArgumentList $command_args -Wait -Passthru +} + +function InstallNode () { + DownloadNodeMSI + InstallNodeMSI +} + +function DownloadNodeMSI () { + $url = "https://nodejs.org/dist/v12.18.4/node-v12.18.4-x64.msi" + $start_time = Get-Date + + Write-Output "Downloading node msi" + Invoke-WebRequest -Uri $url -OutFile $msipath + Write-Output "Time taken: $((Get-Date).Subtract($start_time).Seconds) second(s)" +} + +function InstallNodeMSI () { + $install_args = "/qn /log node_install.log /i $msipath" + $uninstall_args = "/qn /x $msipath" + RunCommand "msiexec.exe" $install_args + + #if (-not(Test-Path $python_home)) { + # Write-Host "Python seems to be installed else-where, reinstalling." + # RunCommand "msiexec.exe" $uninstall_args + # RunCommand "msiexec.exe" $install_args + #} +} + + +function main () { + InstallNode + rm $msipath +} + +main diff --git a/scripts/ci/install_pyhook.ps1 b/scripts/ci/install_pyhook.ps1 new file mode 100644 index 0000000..396e92f --- /dev/null +++ b/scripts/ci/install_pyhook.ps1 @@ -0,0 +1,13 @@ +function main ($arch) { + If ( $arch -eq "64" ) { + $url="https://github.com/ActivityWatch/wheels/raw/master/pyHook-1.5.1-cp36-cp36m-win_amd64.whl" + } ElseIf ( $arch -eq "32" ) { + $url="https://github.com/ActivityWatch/wheels/raw/master/pyHook-1.5.1-cp36-cp36m-win32.whl" + } Else { + Write-Output "Invalid architecture" + return -1 + } + pip install --user $url +} + +main $env:PYTHON_ARCH diff --git a/scripts/ci/install_python.ps1 b/scripts/ci/install_python.ps1 new file mode 100644 index 0000000..a17235f --- /dev/null +++ b/scripts/ci/install_python.ps1 @@ -0,0 +1,233 @@ +# Sample script to install Python and pip under Windows +# Authors: Olivier Grisel, Jonathan Helmus, Kyle Kastner, and Alex Willmer +# License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ +# +# Find the latest version of this script at: +# https://github.com/ogrisel/python-appveyor-demo/blob/master/appveyor/install.ps1 + + +$MINICONDA_URL = "http://repo.continuum.io/miniconda/" +$BASE_URL = "https://www.python.org/ftp/python/" +$GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" +$GET_PIP_PATH = "C:\get-pip.py" + +$PYTHON_PRERELEASE_REGEX = @" +(?x) +(?\d+) +\. +(?\d+) +\. +(?\d+) +(?[a-z]{1,2}\d+) +"@ + + +function Download ($filename, $url) { + $webclient = New-Object System.Net.WebClient + + $basedir = $pwd.Path + "\" + $filepath = $basedir + $filename + if (Test-Path $filename) { + Write-Host "Reusing" $filepath + return $filepath + } + + # Download and retry up to 3 times in case of network transient errors. + Write-Host "Downloading" $filename "from" $url + $retry_attempts = 2 + for ($i = 0; $i -lt $retry_attempts; $i++) { + try { + $webclient.DownloadFile($url, $filepath) + break + } + Catch [Exception]{ + Start-Sleep 1 + } + } + if (Test-Path $filepath) { + Write-Host "File saved at" $filepath + } else { + # Retry once to get the error message if any at the last try + $webclient.DownloadFile($url, $filepath) + } + return $filepath +} + + +function ParsePythonVersion ($python_version) { + if ($python_version -match $PYTHON_PRERELEASE_REGEX) { + return ([int]$matches.major, [int]$matches.minor, [int]$matches.micro, + $matches.prerelease) + } + $version_obj = [version]$python_version + return ($version_obj.major, $version_obj.minor, $version_obj.build, "") +} + + +function DownloadPython ($python_version, $platform_suffix) { + $major, $minor, $micro, $prerelease = ParsePythonVersion $python_version + + if (($major -le 2 -and $micro -eq 0) ` + -or ($major -eq 3 -and $minor -le 2 -and $micro -eq 0) ` + ) { + $dir = "$major.$minor" + $python_version = "$major.$minor$prerelease" + } else { + $dir = "$major.$minor.$micro" + } + + if ($prerelease) { + if (($major -le 2) ` + -or ($major -eq 3 -and $minor -eq 1) ` + -or ($major -eq 3 -and $minor -eq 2) ` + -or ($major -eq 3 -and $minor -eq 3) ` + ) { + $dir = "$dir/prev" + } + } + + if (($major -le 2) -or ($major -le 3 -and $minor -le 4)) { + $ext = "msi" + if ($platform_suffix) { + $platform_suffix = ".$platform_suffix" + } + } else { + $ext = "exe" + if ($platform_suffix) { + $platform_suffix = "-$platform_suffix" + } + } + + $filename = "python-$python_version$platform_suffix.$ext" + $url = "$BASE_URL$dir/$filename" + $filepath = Download $filename $url + return $filepath +} + + +function InstallPython ($python_version, $architecture, $python_home) { + Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home + if (Test-Path $python_home) { + Write-Host $python_home "already exists, skipping." + return $false + } + if ($architecture -eq "32") { + $platform_suffix = "" + } else { + $platform_suffix = "amd64" + } + $installer_path = DownloadPython $python_version $platform_suffix + $installer_ext = [System.IO.Path]::GetExtension($installer_path) + Write-Host "Installing $installer_path to $python_home" + $install_log = $python_home + ".log" + if ($installer_ext -eq '.msi') { + InstallPythonMSI $installer_path $python_home $install_log + } else { + InstallPythonEXE $installer_path $python_home $install_log + } + if (Test-Path $python_home) { + Write-Host "Python $python_version ($architecture) installation complete" + } else { + Write-Host "Failed to install Python in $python_home" + Get-Content -Path $install_log + Exit 1 + } +} + + +function InstallPythonEXE ($exepath, $python_home, $install_log) { + $install_args = "/quiet InstallAllUsers=1 TargetDir=$python_home" + RunCommand $exepath $install_args +} + + +function InstallPythonMSI ($msipath, $python_home, $install_log) { + $install_args = "/qn /log $install_log /i $msipath TARGETDIR=$python_home" + $uninstall_args = "/qn /x $msipath" + RunCommand "msiexec.exe" $install_args + if (-not(Test-Path $python_home)) { + Write-Host "Python seems to be installed else-where, reinstalling." + RunCommand "msiexec.exe" $uninstall_args + RunCommand "msiexec.exe" $install_args + } +} + +function RunCommand ($command, $command_args) { + Write-Host $command $command_args + Start-Process -FilePath $command -ArgumentList $command_args -Wait -Passthru +} + + +function InstallPip ($python_home) { + $pip_path = $python_home + "\Scripts\pip.exe" + $python_path = $python_home + "\python.exe" + if (-not(Test-Path $pip_path)) { + Write-Host "Installing pip..." + $webclient = New-Object System.Net.WebClient + $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) + Write-Host "Executing:" $python_path $GET_PIP_PATH + & $python_path $GET_PIP_PATH + } else { + Write-Host "pip already installed." + } +} + + +function DownloadMiniconda ($python_version, $platform_suffix) { + if ($python_version -eq "3.4") { + $filename = "Miniconda3-3.5.5-Windows-" + $platform_suffix + ".exe" + } else { + $filename = "Miniconda-3.5.5-Windows-" + $platform_suffix + ".exe" + } + $url = $MINICONDA_URL + $filename + $filepath = Download $filename $url + return $filepath +} + + +function InstallMiniconda ($python_version, $architecture, $python_home) { + Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home + if (Test-Path $python_home) { + Write-Host $python_home "already exists, skipping." + return $false + } + if ($architecture -eq "32") { + $platform_suffix = "x86" + } else { + $platform_suffix = "x86_64" + } + $filepath = DownloadMiniconda $python_version $platform_suffix + Write-Host "Installing" $filepath "to" $python_home + $install_log = $python_home + ".log" + $args = "/S /D=$python_home" + Write-Host $filepath $args + Start-Process -FilePath $filepath -ArgumentList $args -Wait -Passthru + if (Test-Path $python_home) { + Write-Host "Python $python_version ($architecture) installation complete" + } else { + Write-Host "Failed to install Python in $python_home" + Get-Content -Path $install_log + Exit 1 + } +} + + +function InstallMinicondaPip ($python_home) { + $pip_path = $python_home + "\Scripts\pip.exe" + $conda_path = $python_home + "\Scripts\conda.exe" + if (-not(Test-Path $pip_path)) { + Write-Host "Installing pip..." + $args = "install --yes pip" + Write-Host $conda_path $args + Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru + } else { + Write-Host "pip already installed." + } +} + +function main () { + InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON + InstallPip $env:PYTHON +} + +main diff --git a/scripts/ci/run_with_env.cmd b/scripts/ci/run_with_env.cmd new file mode 100644 index 0000000..3a25095 --- /dev/null +++ b/scripts/ci/run_with_env.cmd @@ -0,0 +1,87 @@ +:: To build extensions for 64 bit Python 3, we need to configure environment +:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) +:: +:: To build extensions for 64 bit Python 2, we need to configure environment +:: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) +:: +:: 32 bit builds, and 64-bit builds for 3.5 and beyond, do not require specific +:: environment configurations. +:: +:: Note: this script needs to be run with the /E:ON and /V:ON flags for the +:: cmd interpreter, at least for (SDK v7.0) +:: +:: More details at: +:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows +:: http://stackoverflow.com/a/13751649/163740 +:: +:: Author: Olivier Grisel +:: License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ +:: +:: Notes about batch files for Python people: +:: +:: Quotes in values are literally part of the values: +:: SET FOO="bar" +:: FOO is now five characters long: " b a r " +:: If you don't want quotes, don't include them on the right-hand side. +:: +:: The CALL lines at the end of this file look redundant, but if you move them +:: outside of the IF clauses, they do not run properly in the SET_SDK_64==Y +:: case, I don't know why. +@ECHO OFF +SET COMMAND_TO_RUN=%* +SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows +SET WIN_WDK=c:\Program Files (x86)\Windows Kits\10\Include\wdf + +:: Extract the major and minor versions, and allow for the minor version to be +:: more than 9. This requires the version number to have two dots in it. +SET MAJOR_PYTHON_VERSION=%PYTHON_VERSION:~0,1% +IF "%PYTHON_VERSION:~3,1%" == "." ( + SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,1% +) ELSE ( + SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,2% +) + +:: Based on the Python version, determine what SDK version to use, and whether +:: to set the SDK for 64-bit. +IF %MAJOR_PYTHON_VERSION% == 2 ( + SET WINDOWS_SDK_VERSION="v7.0" + SET SET_SDK_64=Y +) ELSE ( + IF %MAJOR_PYTHON_VERSION% == 3 ( + SET WINDOWS_SDK_VERSION="v7.1" + IF %MINOR_PYTHON_VERSION% LEQ 4 ( + SET SET_SDK_64=Y + ) ELSE ( + SET SET_SDK_64=N + IF EXIST "%WIN_WDK%" ( + :: See: https://connect.microsoft.com/VisualStudio/feedback/details/1610302/ + REN "%WIN_WDK%" 0wdf + ) + ) + ) ELSE ( + ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%" + EXIT 1 + ) +) + +IF %PYTHON_ARCH% == 64 ( + IF %SET_SDK_64% == Y ( + ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture + SET DISTUTILS_USE_SDK=1 + SET MSSdk=1 + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 + ) ELSE ( + ECHO Using default MSVC build environment for 64 bit architecture + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 + ) +) ELSE ( + ECHO Using default MSVC build environment for 32 bit architecture + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 +) diff --git a/scripts/count_lines.sh b/scripts/count_lines.sh new file mode 100755 index 0000000..3f0963b --- /dev/null +++ b/scripts/count_lines.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +re_ignore='.*(build|dist|venv|old|other|scripts|node|static).*' + +echo -n "Lines of code (excluding test): " +files=$(find | egrep '\.(py|js|ts|rs|vue)$' | egrep -v $re_ignore | grep -v 'test') +echo $files | xargs cat | wc -l + +#echo "Files:" +#for file in $files; do +# echo " - $file" +#done + +echo -n " - of which Python code: " +files=$(find | egrep '\.(py)$' | egrep -v $re_ignore | grep -v 'test') +echo $files | xargs cat | wc -l + +echo -n " - of which Rust code: " +files=$(find | egrep '\.(rs)$' | egrep -v $re_ignore | grep -v 'test') +echo $files | xargs cat | wc -l + +echo -n " - of which JS/TS code: " +files=$(find | egrep '\.(js|ts)$' | egrep -v $re_ignore | grep -v 'test') +echo $files | xargs cat | wc -l + +echo -n " - of which Vue code: " +files=$(find | egrep '\.(vue)$' | egrep -v $re_ignore | grep -v 'test') +echo $files | xargs cat | wc -l + +echo -ne "\nLines of test: " +files=$(find | egrep '\.(py|js|vue)$' | egrep -v $re_ignore | grep 'test') +echo $files | xargs cat | wc -l diff --git a/scripts/emit_research_category_preset.py b/scripts/emit_research_category_preset.py new file mode 100644 index 0000000..06da62c --- /dev/null +++ b/scripts/emit_research_category_preset.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Emit the Research Edition category preset consumed by aw-webui at build time. + +The approved study contract keeps application names while the watcher replaces +browser titles with study categories and removes every URL. aw-webui categorises +client-side, so this preset matches both stored browser category labels and the +known raw application-name aliases. Top Applications can therefore show Word, +Spotify, and Teams while Top Categories still uses the study taxonomy. + +aw-webui (ActivityWatch/aw-webui#936) reads a preset category set from the +`AW_PRESET_CATEGORY_SETS` env var at build time. This script derives that +preset from the same taxonomy source as the watcher build patch, so the two can +never drift: + + python3 scripts/emit_research_category_preset.py > preset.json + +Rules are exact, case-insensitive matches. aw-webui applies every category rule +to `app` and `title`, and the oldest web UI pinned by the release carriers drops +unknown per-rule metadata, so the preset cannot rely on field or priority keys. +Each category carries a `data.color` so the Activity view is not unstyled +(ActivityWatch/activitywatch#1439). Explicitly excluded app aliases map to +`Excluded`; unknown applications remain `Uncategorized` instead of overlapping +every specific rule with a catch-all. +""" + +import importlib.util +import json +import pathlib +import sys + +# Characters that are special to BOTH Python's `re` and JavaScript's RegExp +# outside a character class. Escaping is deliberately restricted to these. +# +# `re.escape()` is not usable here: it escapes space as `\ ` and `&` as `\&`, +# which are *invalid identity escapes* in JavaScript unicode-mode regex. Names +# like "AI Chatbots & Assistants" then throw `Invalid escape` under `new +# RegExp(r, "u")` -- 14 of the 18 study categories do. They happen to compile +# today only because aw-webui builds the regex without the `u` flag, and the +# failure would present as "categories don't show", i.e. indistinguishable from +# the bug this preset exists to fix. Escape only what both engines agree on. +_REGEX_METACHARACTERS = set(r"\^$.|?*+()[]{}") + +PRESET_ID = "research-study" +PRESET_NAME = "Research Edition study categories" + +# Qualitative palette for the study taxonomy. aw-webui only colors a category +# when `data.color` is set — there is no name-hash fallback for categories — +# so omitting this is what made the research-study set render grey. +# Keys must stay in lockstep with CATEGORY_MAP ∪ APP_CATEGORY_MAP; build_preset +# raises if a category is missing. +CATEGORY_COLORS: dict[str, str] = { + "AI Chatbots & Assistants": "#7B1FA2", + "Banking & Finance": "#1B5E20", + "Education & Learning": "#1565C0", + "Email": "#00838F", + "Excluded": "#BDBDBD", + "Games": "#EF6C00", + "Messaging": "#00897B", + "Music & Audio": "#7CB342", + "News & Current Affairs": "#C62828", + "Public Services": "#455A64", + "Search & Navigation": "#5C6BC0", + "Sensitive / Excluded": "#757575", + "Shopping - Goods": "#6D4C41", + "Shopping - Groceries & Food": "#F9A825", + "Social Networking": "#AD1457", + "Travel & Mobility": "#0277BD", + "Video Streaming": "#E53935", + "Work & Productivity": "#2E7D32", +} + +_PATCHER = pathlib.Path(__file__).with_name("patch_research_edition_config.py") + + +def _load_category_source(): + """Import the patch script without executing its CLI entry point.""" + spec = importlib.util.spec_from_file_location("_re_patcher", _PATCHER) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load {_PATCHER}") + module = importlib.util.module_from_spec(spec) + sys.modules["_re_patcher"] = module + spec.loader.exec_module(module) + return module + + +def escape_portable(value: str) -> str: + """Escape `value` so the result is a literal in both Python and JS regex. + + Portable across engines *and* across JS flag modes, unlike `re.escape()`. + """ + return "".join( + "\\" + char if char in _REGEX_METACHARACTERS else char for char in value + ) + + +def exact_alternation(values: set[str]) -> str: + """Build a stable whole-value alternation portable across Python and JS.""" + escaped = [escape_portable(value) for value in sorted(values)] + return f"^(?:{'|'.join(escaped)})$" + + +def color_for(category: str) -> str: + """Look up the Activity-view color for a study category. + + Missing keys fail the build rather than ship an unstyled taxonomy. + """ + try: + return CATEGORY_COLORS[category] + except KeyError as exc: + raise RuntimeError( + f"study category {category!r} has no color in CATEGORY_COLORS" + ) from exc + + +def build_preset() -> dict: + source = _load_category_source() + + categories = {category for _, category in source.CATEGORY_MAP} + categories |= set(source.APP_CATEGORY_MAP.values()) + if not categories: + raise RuntimeError("no categories found -- refusing to emit an empty preset") + + extra_colors = set(CATEGORY_COLORS) - categories + if extra_colors: + raise RuntimeError( + "CATEGORY_COLORS has entries not in the study taxonomy: " + + ", ".join(sorted(extra_colors)) + ) + + return { + "id": PRESET_ID, + "name": PRESET_NAME, + # Sorted so the same taxonomy always produces a byte-identical preset. + "categories": [ + { + "name": [category], + "rule": { + "type": "regex", + "regex": exact_alternation( + {category} + | { + app + for app, app_category in source.APP_CATEGORY_MAP.items() + if app_category == category + } + ), + "ignore_case": True, + }, + "data": {"color": color_for(category)}, + } + for category in sorted(categories) + ], + } + + +def main() -> None: + preset = build_preset() + # Compact and newline-free: this is written straight into $GITHUB_ENV, + # which treats a newline as the end of the value. + print(json.dumps([preset], separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/scripts/get_latest_release.sh b/scripts/get_latest_release.sh new file mode 100755 index 0000000..219eb8f --- /dev/null +++ b/scripts/get_latest_release.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# TODO: Merge with scripts/package/getversion.sh + +# Script that fetches the previous release (if current commit is a tag), +# or the latest release, if current commit is not a tag. + +# If stable only, then we return the latest stable release, +# else, we will return the latest release, either stable or prerelease. +RE_STABLE='(?<=[/])v[0-9\.]+$' +RE_INCL_PRERELEASE='(?<=[/])v[0-9\.]+(a|b|rc)?[0-9]+$' + +# Get tag for this commit, if any +TAG=$(git describe --tags --exact-match 2>/dev/null) + +RE=$RE_INCL_PRERELEASE +if [ -n "$STABLE_ONLY" ]; then + if [ "$STABLE_ONLY" = "true" ]; then + RE=$RE_STABLE + fi +fi +ALL_TAGS=`git for-each-ref --sort=creatordate --format '%(refname)' refs/tags` + +# If current commit is a tag, we filter it out +if [ -n "$TAG" ]; then + ALL_TAGS=`echo "$ALL_TAGS" | grep -v "^refs/tags/$TAG$"` +fi + +echo "$ALL_TAGS" | grep -P "$RE" --only-matching | tail -n1 diff --git a/scripts/logcrawler.py b/scripts/logcrawler.py new file mode 100644 index 0000000..0ef8ab4 --- /dev/null +++ b/scripts/logcrawler.py @@ -0,0 +1,80 @@ +import os +import re +from datetime import datetime +from collections import defaultdict +import logging + +import aw_core + +logging.basicConfig() + +log_dir = aw_core.dirs.get_log_dir("") + + +def get_filepaths(): + filepaths = [] + for folder, dirs, files in os.walk(log_dir): + print("Crawling folder: " + folder) + filepaths.extend([os.path.join(folder, filename) for filename in files]) + return filepaths + + +def collect(): + matched_lines = defaultdict(lambda: []) + for filepath in sorted(get_filepaths()): + with open(filepath, "r") as f: + log = f.read() + for line in log.split("\n"): + s = re.search("(ERR|WARN)", line) + ignored = re.search("(CORS|Deleted bucket)", line) + if s and not ignored: + matched_lines[filepath].append(line) + return matched_lines + + +_date_reg_exp = re.compile('\\d{4}-\\d{2}-\\d{2}') + + +today = datetime.now() + + +def line_age(line): + """Returns line age in days""" + match = _date_reg_exp.search(line) + if not match: + logging.warning("Line had no date, avoid multiple line messages in logs. Line will have its age set to zero.") + return 0 + else: + dt = datetime.strptime(match.group(), '%Y-%m-%d') + td = today - dt + return td.days + + +def main(exclude_testing: bool = False, limit_days: int = 10, limit_lines: int = 10): + file_lines = collect() + + if exclude_testing: + keys = filter(lambda k: "testing" not in k, file_lines.keys()) + file_lines = {key: file_lines[key] for key in keys} + + for filename, lines in sorted(file_lines.items()): + lines = sorted(file_lines[filename], reverse=True) + + # Filter lines older than x days + if limit_days: + lines = [line for line in lines if line_age(line) <= limit_days] + + if lines: + print("-" * 50) + print("File: {}".format(filename)) + + # Print lines up to the limit + for line in lines[:limit_lines]: + print(" " + line) + + if limit_lines < len(lines): + print("Showing {} out of {} lines".format(limit_lines, len(lines))) + + +if __name__ == "__main__": + main() diff --git a/scripts/nop.sh b/scripts/nop.sh new file mode 100755 index 0000000..5628b01 --- /dev/null +++ b/scripts/nop.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +echo "nop.bat was executed as a workaround for something" diff --git a/scripts/notarize.sh b/scripts/notarize.sh new file mode 100755 index 0000000..bf70bf3 --- /dev/null +++ b/scripts/notarize.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +applemail=$APPLE_EMAIL # Email address used for Apple ID +password=$APPLE_PASSWORD # See apps-specific password https://support.apple.com/en-us/HT204397 +teamid=$APPLE_TEAMID # Team idenitifer (if single developer, then set to developer identifier) +# Name of the keychain profile to use. Must not contain spaces: APPLE_PERSONALID +# holds the full codesign identity ("Developer ID Application: ..."), so use the +# team ID instead. +keychain_profile="activitywatch-$APPLE_TEAMID" +bundleid=net.activitywatch.ActivityWatch # Match aw.spec +app=dist/ActivityWatch.app +dmg=dist/ActivityWatch.dmg + +# XCode >= 13 +run_notarytool() { + dist=$1 + # Setup the credentials for notarization + xcrun notarytool store-credentials "$keychain_profile" --apple-id "$applemail" --team-id "$teamid" --password "$password" + # Notarize and wait; tee to a temp file so output streams in real-time + # while we can still inspect it afterward for failure details. + echo "Notarization: starting for $dist" + echo "Notarization: in progress for $dist" + tmpfile=$(mktemp) + xcrun notarytool submit "$dist" --keychain-profile "$keychain_profile" --wait 2>&1 | tee "$tmpfile" + submission_exit=${PIPESTATUS[0]} + submission_output=$(cat "$tmpfile") + rm -f "$tmpfile" + # On failure, retrieve the detailed rejection log from Apple's server. + # This avoids having to run 'notarytool log' manually after the fact. + if echo "$submission_output" | grep -q "status: Invalid"; then + uuid=$(echo "$submission_output" | grep '^[[:space:]]*id:' | head -1 | awk '{print $NF}') + if [ -n "$uuid" ]; then + echo "" + echo "=== Notarization rejected (status: Invalid) — fetching rejection log for $uuid ===" + xcrun notarytool log "$uuid" --keychain-profile "$keychain_profile" 2>&1 || true + echo "=== End of rejection log ===" + fi + return 1 + fi + return $submission_exit +} + +# XCode < 13 +run_altool() { + dist=$1 + # Setup the credentials for notarization + xcrun altool --store-password-in-keychain-item $keychain_profile -u $applemail -p $password + # Notarize and wait + echo "Notarization: starting for $dist" + upload=$(xcrun altool --notarize-app -t osx -f $dist --primary-bundle-id $bundleid -u $applemail --password "@keychain:$keychain_profile") + uuid = $(/usr/libexec/PlistBuddy -c "Print :notarization-upload:RequestUUID" $upload) + while true; do + req=$(xcrun altool --notarization-info $uuid -u $applemail -p $password --output-format xml) + status=$(/usr/libexec/PlistBuddy -c "Print :notarization-info:Status" $req) + if [ $status != "in progress" ]; then + break + else + echo "Notarization: in progress for $dist" + fi + sleep 10 + done +} + +# Staples the notarization certificate to the executable/bunldle +run_stapler() { + dist=$1 + xcrun stapler staple $dist +} + +echo 'Detecting availability of notarization tools' +notarization_method=exit +# Detect if notarytool is available +xcrun notarytool >/dev/null 2>&1 +if [ $? -eq 0 ]; then + echo "+ Found notarytool" + notarization_method=run_notarytool +else + # Fallback to altool + xcrun -f altool >/dev/null 2>&1 + if [ $? -eq 0 ]; then + echo "+ Found altool" + notarization_method=run_altool + fi +fi + +if [ $notarization_method = "exit" ]; then + echo "- Found no tools, exiting" + $notarization_method +fi + +if test -d "$app"; then + echo "Notarizing: $app" + zip=$app.zip + # Turn the app into a zip file that notarization will accept + ditto -c -k --keepParent "$app" "$zip" + $notarization_method "$zip" + run_stapler "$app" +else + echo "Skipping: $app (expected .app bundle directory)" +fi + +if test -f "$dmg"; then + echo "Notarizing: $dmg" + $notarization_method "$dmg" + run_stapler "$dmg" +else + echo "Skipping: $dmg" +fi diff --git a/scripts/package/README.txt b/scripts/package/README.txt new file mode 100644 index 0000000..085add7 --- /dev/null +++ b/scripts/package/README.txt @@ -0,0 +1,10 @@ +Run move-to-aw-modules.sh to copy all modules except aw-tauri to ~/aw-modules/. +aw-tauri (replaces aw-qt) will use this directory to discover new modules. +You can add your own modules and scripts to this directory. The modules should +start with the aw- prefix and should not have an extension (e.g. no .sh). + +In the aw-tauri folder there are AppImage, RPM, and DEB binaries. Choose the +appropriate one for your Linux distribution. If in doubt, use the AppImage as +it works on most Linux systems. If you use the AppImage, copy it to a permanent +folder like ~/bin or /usr/local/bin, since autostart relies on the AppImage +being in the same location each time. diff --git a/scripts/package/RESEARCH_FEED.md b/scripts/package/RESEARCH_FEED.md new file mode 100644 index 0000000..1e2e941 --- /dev/null +++ b/scripts/package/RESEARCH_FEED.md @@ -0,0 +1,98 @@ +# Research updater feed publisher + +`publish_research_feed.py` prepares an ordinary commit of +`latest-research.json` on the `research-updates` branch. It preserves branch +history and existing files. The default invocation changes only a new local +checkout. `--publish` explicitly enables the remote update. + +This command is manual. There is no release-published trigger. Before live +use, the release owner must verify research signing keys, bundle contents, +installed-app upgrades, and the disposition of older research clients that +still contain the standard updater endpoint/key. The publisher does not prove +those properties or enable an update channel in an installed client. + +## Prepare and review + +Requires Git, a configured Git author, and `uv` (the script declares its +`semver` dependency). Run from the ActivityWatch checkout, using a new output +directory for each attempt: + +```sh +REPO_ROOT=$(git rev-parse --show-toplevel) +TAG=v0.14.0b6-research +MANIFEST=/absolute/path/to/reviewed/latest-research.json +CANDIDATE=/absolute/path/to/new-feed-candidate + +# Read the current remote head. Use the literal 'absent' only if this +# succeeds and returns no matching ref (initial provisioning). +git ls-remote --refs https://github.com/ActivityWatch/activitywatch.git \ + refs/heads/research-updates +PARENT= + +uv run --script "$REPO_ROOT/scripts/package/publish_research_feed.py" \ + --tag "$TAG" --manifest "$MANIFEST" --expected-parent "$PARENT" \ + --work-dir "$CANDIDATE" + +git -C "$CANDIDATE" show HEAD +``` + +The supplied manifest must already use normalized SemVer, for example +`0.14.0-beta.6`. Tags and asset filenames retain their release spelling +(`v0.14.0b6-research` / `activitywatch-tauri-research-0.14.0b6-…`). +This publisher accepts stable and a/b/rc research tags, not development builds. +Build/app version normalization is owned by the release configuration work; +this command validates the tag/version association without modifying builds. + +Validation requires: + +- An unauthenticated GitHub API read of the exact published, immutable, + non-draft research release. +- All five release targets: macOS arm64/x86_64, Linux arm64/x86_64, and Windows + x86_64. A reduced platform rollout requires an explicit code review. +- A supported updater bundle and nonempty uploaded `.sig` asset for each + platform, under the exact selected release and research filename prefix. +- The manifest signature must equal the publicly downloaded `.sig` contents. + Cryptographic signature verification and embedded payload-version/edition + verification remain release-owner gates; this command does not download + the potentially large bundles. +- A valid static updater manifest, with RFC3339 publication date and string + notes. Unknown fields are refused to avoid alternate updater parsing paths. + +## Publish after release acceptance + +After reviewing the candidate and satisfying the release gates, rerun the +same invocation with a **new** work directory and `--publish`. It repeats all +release checks and compares the feed's current SemVer before committing. +Git's configured credentials authorize the push; public release reads do not +use those credentials. Keep the recorded parent unchanged; a stale parent is +an error, not permission to overwrite new work. + +An older version is refused. Equal SemVer precedence with different content +(including metadata/build-identifier changes) is refused. Identical parsed +manifest content is an explicit no-op. Formatting/key order differences alone +do not mint another commit. A changed release needs a higher version. + +The publisher checks the remote parent at fetch and in pre-push against Git's +advertised OID, then pushes normally without force. Git's receive-side old-OID +check closes the remaining race. The wrapper invokes any existing pre-push +hook with the original arguments/stdin and preserves its refusal. Concurrent +creation, advancement, deletion, or rewind requires a fresh invocation with a +reread parent; version ordering is evaluated again, so a late b5 job cannot +replace b6 even after retry. Failed candidates remain on disk for inspection. + +After a successful push, verify the public feed separately: +`https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json`. +A Git push alone does not prove CDN freshness or a successful installed update. +Standard release publication does not invoke this command or change this branch. + +## Tests + +```sh +uv run --with pytest --with 'semver>=3,<4' python3 -m pytest \ + "$REPO_ROOT/scripts/tests/test_publish_research_feed.py" -q +``` + +The tests use release fixtures and temporary local Git remotes. They cover +provisioning, advancement, replay, rollback refusal, racing publishers, +branch deletion/rewind, incomplete releases, metadata validation, and existing +push-hook enforcement. They never publish to GitHub. diff --git a/scripts/package/UPDATER.md b/scripts/package/UPDATER.md new file mode 100644 index 0000000..c075a56 --- /dev/null +++ b/scripts/package/UPDATER.md @@ -0,0 +1,74 @@ +# Tauri release configuration + +`release.yml` runs `configure_tauri_release.py` before compiling aw-tauri. +It writes the release version to `aw-tauri/src-tauri/tauri.conf.json`, which +Tauri embeds as its package version. `generate_latest_json.py` uses the same +`tauri_version()` conversion for the manifest: `v0.14.0b5` becomes +`0.14.0-beta.5`. Original AW spellings remain in asset filenames and tag URLs. +Development builds also receive a SemVer version, with a `dev.g` suffix. + +The standard build keeps its checked-in updater endpoint and public key. +Research builds use only: + +```text +https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json +``` + +Configure these repository settings before a research tag or manual build: + +| Setting | Purpose | +| --- | --- | +| Variable `TAURI_UPDATER_PUBLIC_KEY_RESEARCH` | Base64-encoded minisign public-key file emitted by Tauri's signer | +| Secret `TAURI_SIGNING_PRIVATE_KEY_RESEARCH` | Research-only signing key in Tauri's expected format | +| Secret `TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH` | Password for that key (empty for an unencrypted key) | + +The workflow selects secret **names** by edition. A missing research secret +never falls back to a standard secret. The configurator rejects a research +public key containing the standard key material, even if its comment or key +ID differs. Research tag/manual builds fail if either key is missing. +Secretless research PR smoke builds clear both updater endpoints and the +public key. They cannot consume the standard channel. + +`aw-tauri/Makefile` enables `bundle.createUpdaterArtifacts` by passing a +`--config` override to the Tauri build when `TAURI_SIGNING_PRIVATE_KEY` is +present. That setting is therefore absent from the checked-in JSON even +though release builds produce signatures. Both the initial build and the +research-profile rebuild receive the edition's selected signing key. + +## Release acceptance still required + +This configuration does not create the `research-updates` branch, publish +its manifest, verify that a private key matches the configured public key, +or change any already installed client. Before offering an update: + +- Provision the channel and verify its public URL. Publish only a complete, + verified manifest after its versioned release is public; use a normal + fast-forward commit with an expected parent and a monotonic version check + so a late release job cannot overwrite a newer feed. +- Verify actual bundle signatures with the research key and reject standard + signatures. Config/fixture checks alone are insufficient. +- Verify the built app's version, equal-version no-update, one newer-version + upgrade, and no repeated offer after restart. Both versions must preserve + the research profile, privacy defaults, install identity, and bundled + watchers/aw-sync. Native updater bundles and full AW packages require a + payload-equivalence check. +- Replace or verifiably contain any already distributed research app that + embedded the standard endpoint/key **before publishing a valid standard + stable manifest**. A future configuration cannot repair an installed app. + +The pipeline creates a draft versioned release. Its `latest-research.json` +asset is distinct from the live branch feed; uploading the asset does not +advance that feed. Do not publish the draft until these gates are satisfied. + +## Focused checks + +Initialize the pinned `aw-tauri` submodule, then run: + +```sh +python3 -m pytest scripts/tests/test_generate_latest_json.py scripts/tests/test_configure_tauri_release.py -q +``` + +These tests execute both CLIs against the pinned config, exercise missing and +same-key rejection, preserve standard trust, and check both directions of +asset partitioning. Dummy signatures in these fixtures do not prove signing +or installation. diff --git a/scripts/package/abi-gate.sh b/scripts/package/abi-gate.sh new file mode 100755 index 0000000..3705fd9 --- /dev/null +++ b/scripts/package/abi-gate.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# abi-gate.sh — Verify that no bundled ELF requires a GLIBC symbol above +# GLIBC_2.28. Run this after packaging to gate on the ABI floor. +# +# Usage: abi-gate.sh [] +# Exit 0 = all clear; exit 1 = ABI violation found; exit 2 = script error. +# +# For AppImage files, extraction requires either: +# - `unsquashfs` (squashfs-tools package), or +# - APPIMAGE_EXTRACT_AND_RUN=1 so the AppImage self-extracts without FUSE. +# If neither works the AppImage scan is skipped with a warning (not a failure). + +set -euo pipefail + +GLIBC_FLOOR_MAJOR=2 +GLIBC_FLOOR_MINOR=28 + +WORKDIR=$(mktemp -d) +VIOLATION_LOG="$WORKDIR/violations.txt" +touch "$VIOLATION_LOG" +trap 'rm -rf "$WORKDIR"' EXIT + +# Returns 0 (true) when the version string X.Y exceeds the floor. +glibc_exceeds_floor() { + local ver="$1" + local major minor + major=$(echo "$ver" | cut -d. -f1) + minor=$(echo "$ver" | cut -d. -f2) + if [ "$major" -gt "$GLIBC_FLOOR_MAJOR" ]; then return 0; fi + if [ "$major" -eq "$GLIBC_FLOOR_MAJOR" ] && \ + [ "$minor" -gt "$GLIBC_FLOOR_MINOR" ]; then return 0; fi + return 1 +} + +scan_elfs() { + local dir="$1" label="$2" + echo "=== Scanning ELFs in: $label ===" + local total=0 scanned=0 + while IFS= read -r elf; do + total=$((total + 1)) + local versions + versions=$(readelf --version-info "$elf" 2>/dev/null \ + | grep -oP 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' \ + | sort -Vu) || true + [ -z "$versions" ] && continue + scanned=$((scanned + 1)) + while IFS= read -r sym; do + local ver="${sym#GLIBC_}" + if glibc_exceeds_floor "$ver"; then + printf ' VIOLATION in %s: %s\n' "$(basename "$elf")" "$sym" \ + | tee -a "$VIOLATION_LOG" + fi + done <<< "$versions" + done < <(find "$dir" -type f -exec file {} + 2>/dev/null \ + | grep -E ': ELF ' | cut -d: -f1) + echo " ELFs with GLIBC refs: $scanned / $total files scanned" +} + +extract_appimage() { + local ai="$1" dest="$2" + # Try unsquashfs first (reliable offset detection via SquashFS magic). + if command -v unsquashfs &>/dev/null; then + local offset + offset=$(python3 -c " +import sys +data = open(sys.argv[1], 'rb').read() +for magic in (b'sqsh', b'hsqs'): + idx = data.find(magic) + if idx >= 0: + print(idx) + break +" "$ai" 2>/dev/null || true) + if [ -n "$offset" ]; then + if unsquashfs -dest "$dest" -offset "$offset" "$ai" &>/dev/null; then + echo " Extracted AppImage via unsquashfs (offset $offset)" + return 0 + fi + fi + fi + # Fallback: AppImage self-extract (needs APPIMAGE_EXTRACT_AND_RUN=1 in env). + pushd "$WORKDIR" >/dev/null + APPIMAGE_EXTRACT_AND_RUN=1 "$OLDPWD/$ai" --appimage-extract &>/dev/null || true + popd >/dev/null + if [ -d "$WORKDIR/squashfs-root" ]; then + mv "$WORKDIR/squashfs-root" "$dest" + echo " Extracted AppImage via --appimage-extract" + return 0 + fi + return 1 +} + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 []" >&2 + exit 2 +fi + +for arg in "$@"; do + case "$arg" in + *.zip) + ZIPDIR="$WORKDIR/zip" + mkdir -p "$ZIPDIR" + unzip -q "$arg" -d "$ZIPDIR" + scan_elfs "$ZIPDIR" "$(basename "$arg")" + ;; + *.AppImage) + AI_DIR="$WORKDIR/appimage" + mkdir -p "$AI_DIR" + if extract_appimage "$arg" "$AI_DIR"; then + scan_elfs "$AI_DIR" "$(basename "$arg")" + else + echo "ERROR: could not extract $(basename "$arg") — AppImage ABI scan is required; aborting" >&2 + exit 2 + fi + ;; + *) + echo "WARNING: unrecognised file type: $arg — skipping" >&2 + ;; + esac +done + +echo "" +echo "=== ABI Gate Summary ===" +echo "GLIBC floor: GLIBC_${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR}" +VIOLATION_COUNT=$(wc -l < "$VIOLATION_LOG") +echo "Violations: $VIOLATION_COUNT" + +if [ "$VIOLATION_COUNT" -gt 0 ]; then + echo "" + echo "Offending symbols:" + cat "$VIOLATION_LOG" + echo "" + echo "FAIL: bundled ELFs exceed the glibc ${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR} ABI floor" + exit 1 +fi +echo "PASS: no GLIBC symbol above GLIBC_${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR} found" diff --git a/scripts/package/activitywatch-setup.iss b/scripts/package/activitywatch-setup.iss new file mode 100644 index 0000000..dc582e5 --- /dev/null +++ b/scripts/package/activitywatch-setup.iss @@ -0,0 +1,63 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "ActivityWatch" +#define MyAppVersion GetEnv('AW_VERSION') +#define MyAppPublisher "ActivityWatch Contributors" +#define MyAppURL "https://activitywatch.net/" +#define MyAppExeName "aw-qt.exe" +#define RootDir "..\.." +#define DistDir "..\..\dist" + +#pragma verboselevel 9 + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +; NOTE: the double {{ are used to escape the { character (needed for the AppId) +AppId={{F226B8F4-3244-46E6-901D-0CE8035423E4} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL="https://github.com/ActivityWatch/activitywatch/issues" +AppUpdatesURL="https://github.com/ActivityWatch/activitywatch/releases" +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +; Uncomment the following line to run in non administrative install mode (install for current user only.) +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir={#DistDir} +OutputBaseFilename=activitywatch-setup +SetupIconFile="{#RootDir}\aw-qt\media\logo\logo.ico" +UninstallDisplayName={#MyAppName} +UninstallDisplayIcon={app}\{#MyAppExeName} +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "StartMenuEntry" ; Description: "Start ActivityWatch when Windows starts"; GroupDescription: "Windows Startup"; MinVersion: 4,4; + +[Files] +Source: "{#DistDir}\activitywatch\aw-qt.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#DistDir}\activitywatch\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: StartMenuEntry; + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +; Removes the previously installed version before installing the new one +; NOTE: Doesn't work? And also discouraged by the docs +;[InstallDelete] +;Type: filesandordirs; Name: "{app}\" diff --git a/scripts/package/app-entitlements.plist b/scripts/package/app-entitlements.plist new file mode 100644 index 0000000..8736a97 --- /dev/null +++ b/scripts/package/app-entitlements.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.automation.apple-events + + + diff --git a/scripts/package/aw-tauri.iss b/scripts/package/aw-tauri.iss new file mode 100644 index 0000000..0cbd9f4 --- /dev/null +++ b/scripts/package/aw-tauri.iss @@ -0,0 +1,57 @@ +; Inno Setup script for ActivityWatch (Tauri edition) +; +; This is separate from activitywatch-setup.iss (aw-qt) to avoid +; installation collisions. Uses a different AppId, install directory, +; and display name. + +#define MyAppName "ActivityWatch (Tauri)" +#define MyAppVersion GetEnv('AW_VERSION') +#define MyAppPublisher "ActivityWatch Contributors" +#define MyAppURL "https://activitywatch.net/" +#define MyAppExeName "aw-tauri.exe" +#define RootDir "..\.." +#define DistDir "..\..\dist" + +#pragma verboselevel 9 + +[Setup] +; IMPORTANT: Different AppId from aw-qt to allow side-by-side installation +AppId={{983D0855-08C8-46BD-AEFB-3924581C6703} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL="https://github.com/ActivityWatch/activitywatch/issues" +AppUpdatesURL="https://github.com/ActivityWatch/activitywatch/releases" +DefaultDirName={autopf}\ActivityWatch-Tauri +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir={#DistDir} +OutputBaseFilename=activitywatch-setup +SetupIconFile="{#RootDir}\aw-tauri\src-tauri\icons\icon.ico" +UninstallDisplayName={#MyAppName} +UninstallDisplayIcon={app}\{#MyAppExeName} +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "StartMenuEntry" ; Description: "Start ActivityWatch when Windows starts"; GroupDescription: "Windows Startup"; MinVersion: 4,4; + +[Files] +Source: "{#DistDir}\activitywatch\aw-tauri.exe"; DestDir: "{app}\aw-tauri"; Flags: ignoreversion +Source: "{#DistDir}\activitywatch\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: StartMenuEntry; + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/scripts/package/build_app_tauri.sh b/scripts/package/build_app_tauri.sh new file mode 100755 index 0000000..740803f --- /dev/null +++ b/scripts/package/build_app_tauri.sh @@ -0,0 +1,277 @@ +#!/bin/bash +set -e + +# Build a macOS .app bundle for the Tauri-based ActivityWatch. +# This replaces the PyInstaller-based bundling used by aw-qt. + +APP_NAME="ActivityWatch" +BUNDLE_ID="net.activitywatch.ActivityWatch" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERSION="$("$SCRIPT_DIR/getversion.sh" --strip-v)" +ICON_PATH="aw-tauri/src-tauri/icons/icon.icns" + +if [[ "$(uname)" != "Darwin" ]]; then + echo "This script is designed to run on macOS only." + exit 1 +fi + +if [ ! -d "dist/activitywatch" ]; then + echo "Error: dist/activitywatch directory not found. Please build the project first." + exit 1 +fi + +if [ ! -f "dist/activitywatch/aw-tauri" ]; then + echo "Error: aw-tauri binary not found in dist/activitywatch/" + exit 1 +fi + +echo "Cleaning previous builds..." +rm -rf "dist/${APP_NAME}.app" +mkdir -p "dist" + +echo "Creating app bundle structure..." +mkdir -p "dist/${APP_NAME}.app/Contents/"{MacOS,Resources} + +echo "Copying aw-tauri as main executable..." +cp "dist/activitywatch/aw-tauri" "dist/${APP_NAME}.app/Contents/MacOS/aw-tauri" +chmod +x "dist/${APP_NAME}.app/Contents/MacOS/aw-tauri" + +echo "Copying components to Resources..." +for component in dist/activitywatch/*/; do + if [ -d "$component" ]; then + component_name=$(basename "$component") + echo " Copying $component_name..." + mkdir -p "dist/${APP_NAME}.app/Contents/Resources/$component_name" + cp -r "$component"/* "dist/${APP_NAME}.app/Contents/Resources/$component_name/" + fi +done + +echo "Fixing Python.framework symlink structure..." +# PyInstaller copies Python.framework using regular files/directories instead of +# preserving the standard macOS symlink layout. Without symlinks, codesign rejects +# the framework with "bundle format is ambiguous (could be app or framework)". +# Restore the canonical layout so codesign can sign it as a proper framework bundle: +# Versions/Current -> (symlink) +# Python -> Versions/Current/Python (symlink) +# Resources -> Versions/Current/Resources (symlink) +# Headers -> Versions/Current/Headers (symlink, if present) +while IFS= read -r fw; do + echo " Fixing: $fw" + # Find the actual version directory (e.g., "3.9"), skipping "Current" + version_dir="" + for d in "$fw/Versions"/*/; do + bname="$(basename "$d")" + if [ "$bname" != "Current" ] && [ -d "$d" ]; then + version_dir="$bname" + break + fi + done + if [ -z "$version_dir" ]; then + echo " Warning: No version directory found in $fw/Versions/, skipping" + continue + fi + + # Replace Versions/Current directory with a symlink to the version dir + if [ -d "$fw/Versions/Current" ] && [ ! -L "$fw/Versions/Current" ]; then + rm -rf "$fw/Versions/Current" + ln -s "$version_dir" "$fw/Versions/Current" + fi + + # Replace root-level copies with symlinks into Versions/Current/ + for item in Python Resources Headers; do + if [ -e "$fw/$item" ] && [ ! -L "$fw/$item" ]; then + rm -rf "$fw/$item" + ln -s "Versions/Current/$item" "$fw/$item" + fi + done +done < <(find "dist/${APP_NAME}.app" -type d -iname "Python.framework") + +echo "Setting executable permissions..." +find "dist/${APP_NAME}.app/Contents/Resources" -type f -name "aw-*" -exec chmod +x {} \; + +echo "Copying app icon..." +if [ -f "$ICON_PATH" ]; then + cp "$ICON_PATH" "dist/${APP_NAME}.app/Contents/Resources/icon.icns" +else + echo "Warning: Icon file not found at $ICON_PATH" +fi + +echo "Creating Info.plist..." +cat > "dist/${APP_NAME}.app/Contents/Info.plist" << EOF + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + aw-tauri + CFBundleIconFile + icon.icns + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${APP_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${VERSION} + NSAppleEventsUsageDescription + ActivityWatch needs access to monitor application usage + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + LSMinimumSystemVersion + ${MACOSX_DEPLOYMENT_TARGET:-12.0} + + +EOF + +echo "Creating PkgInfo..." +echo "APPL????" > "dist/${APP_NAME}.app/Contents/PkgInfo" + +if [ -n "$APPLE_PERSONALID" ]; then + echo "Signing app with identity: $APPLE_PERSONALID (inside-out, per-binary)" + # codesign --deep is unreliable for bundles with PyInstaller helpers: + # it doesn't reach all nested dylibs/so files and mishandles Python.framework + # symlinks, leaving hundreds of binaries unsigned or invalidly signed. + # The correct approach is inside-out: sign all Mach-O leaves first, + # then .framework bundles, then the top-level .app last. + # --timestamp is required for notarization (Apple rejects submissions without it). + ENTITLEMENTS="scripts/package/entitlements.plist" + APP_ENTITLEMENTS="scripts/package/app-entitlements.plist" + + sign_binary() { + echo " Signing: $1" + codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" \ + --sign "$APPLE_PERSONALID" \ + "$1" + } + + # Step 1: Sign all Mach-O binary files (dylibs, .so files, standalone executables). + # Use `xargs file` to batch all type queries in O(1) subprocess calls instead of + # one `file` invocation per binary (PyInstaller bundles can contain hundreds of files). + # Sort by path length descending so deeper binaries are signed before shallower containers. + # IMPORTANT: Skip the main binary of .framework bundles (e.g. Python.framework/Python). + # codesign treats those as ambiguous ("could be app or framework") when signed as + # standalone files. They are correctly signed in Step 2 as part of the framework bundle. + echo " Signing Mach-O binary files..." + while IFS= read -r f; do + # Skip main binaries of bundle directories (.framework, .bundle, .plugin) — + # they'll be signed as part of the bundle in Step 2. Signing them standalone + # causes "bundle format is ambiguous" errors from codesign. + parent_dir="$(dirname "$f")" + if [[ "$parent_dir" == *.framework ]] || [[ "$parent_dir" == *.framework/Versions/* ]] \ + || [[ "$parent_dir" == *.bundle ]] || [[ "$parent_dir" == *.plugin ]]; then + echo " Skipping bundle binary (signed in Step 2): $f" + continue + fi + sign_binary "$f" + done < <(find "dist/${APP_NAME}.app" -type f \ + | xargs file \ + | grep "Mach-O" \ + | cut -d: -f1 \ + | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-) + + # Step 2: Sign bundle directories (.framework, .bundle, .plugin) after their contents. + # Deepest bundles first (sort by path length descending) to maintain inside-out order. + # .bundle/.plugin coverage prevents missing CodeResources catalog seals that can + # trigger notarytool bundle-integrity warnings. + echo " Signing bundle directories (.framework, .bundle, .plugin)..." + while IFS= read -r fw; do + # PyInstaller-embedded frameworks (e.g. Python.framework inside aw-watcher-window) + # lack the standard Versions/ structure and Info.plist, so codesign rejects them + # with "bundle format is ambiguous (could be app or framework)". Fall back to + # signing the main binary inside the framework directly. All other signing errors + # are still fatal. + sign_output=$(codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" \ + --sign "$APPLE_PERSONALID" \ + "$fw" 2>&1) && echo " Signed bundle: $fw" || { + if echo "$sign_output" | grep -q "bundle format is ambiguous"; then + echo " Note: $fw lacks standard bundle structure; strip-signing and syncing duplicates by content hash" + # PyInstaller copies Python.framework contents as separate files rather + # than symlinks — Python, Versions/Current/Python, and Versions/3.9/Python + # are distinct inodes with identical code but different embedded signatures + # (different timestamp nonces in __LINKEDIT from PyInstaller's own + # codesign_identity). After cp -r into the .app, all three paths carry + # pre-existing but DIFFERENT signatures. A pre-sign SHA comparison + # never detects them as duplicates, so all three end up signed separately, + # producing three divergent signatures that Apple rejects as "invalid". + # + # Fix: strip all existing signatures first (making copies truly identical + # at the byte level), then group by content hash, sign one canonical per + # group via temp copy, and sync the signed result to all group members. + # This is correct even if the framework contains genuinely different + # binaries — only true duplicates share a signed payload. + tmp_group_dir=$(mktemp -d) + found_macho=false + while IFS= read -r fw_bin; do + found_macho=true + existing_id=$(codesign -d "$fw_bin" 2>&1 \ + | sed -n 's/^Identifier=//p' || true) + if [ -z "$existing_id" ]; then + existing_id="$(basename "$fw_bin")" + fi + + # Strip any existing signature (from PyInstaller or prior attempts) + # so content hashing identifies true duplicates rather than + # nonce-only signature differences from previous signing steps. + codesign --remove-signature "$fw_bin" 2>/dev/null || true + content_hash=$(shasum -a 256 "$fw_bin" | awk '{print $1}') + canonical_path_file="$tmp_group_dir/$content_hash.path" + + if [ -f "$canonical_path_file" ]; then + # Same binary content — sync signed payload from canonical. + canonical="$canonical_path_file" + echo " Syncing signed binary to duplicate: $fw_bin" + cp "$canonical" "$fw_bin" + else + # First occurrence of this content — sign it as canonical. + echo " Signing canonical binary: $fw_bin (identifier: $existing_id, hash: ${content_hash:0:12})" + tmp_binary=$(mktemp) + cp -p "$fw_bin" "$tmp_binary" + codesign --force --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" \ + --identifier "$existing_id" \ + --sign "$APPLE_PERSONALID" \ + "$tmp_binary" || { rm -f "$tmp_binary"; rm -rf "$tmp_group_dir"; exit 1; } + cp "$tmp_binary" "$fw_bin" + # Save signed canonical as the source for duplicates. + cp "$tmp_binary" "$canonical_path_file" + rm -f "$tmp_binary" + fi + done < <(find "$fw" -type f | xargs file | grep "Mach-O" | cut -d: -f1 | sort) + rm -rf "$tmp_group_dir" + if ! $found_macho; then + echo "ERROR: No Mach-O binaries found inside $fw" >&2 + exit 1 + fi + else + echo "ERROR: Failed to sign $fw: $sign_output" >&2 + exit 1 + fi + } + done < <(find "dist/${APP_NAME}.app" -type d \ + \( -name "*.framework" -o -name "*.bundle" -o -name "*.plugin" \) \ + | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-) + + # Step 3: Sign the top-level .app bundle last. + echo " Signing top-level .app bundle..." + codesign --force --options runtime --timestamp \ + --entitlements "$APP_ENTITLEMENTS" \ + --sign "$APPLE_PERSONALID" \ + "dist/${APP_NAME}.app" + + echo "App signing complete." +else + echo "APPLE_PERSONALID not set. Skipping code signing." +fi + +echo "App bundle created at: dist/${APP_NAME}.app" diff --git a/scripts/package/collect-setup.sh b/scripts/package/collect-setup.sh new file mode 100755 index 0000000..282522e --- /dev/null +++ b/scripts/package/collect-setup.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# The Research Edition patcher rewrites OutputBaseFilename in the .iss +# (activitywatch-research[-tauri]-setup), so resolve the produced file +# instead of hardcoding the standard name. dist/ is fresh at this point, +# so exactly one setup exe must exist. +setup_src=(dist/activitywatch*-setup.exe) +if [ ${#setup_src[@]} -ne 1 ] || [ ! -f "${setup_src[0]}" ]; then + echo "ERROR: expected exactly one dist/activitywatch*-setup.exe, got: ${setup_src[*]}" + exit 1 +fi +mv "${setup_src[0]}" "dist/$1" diff --git a/scripts/package/configure_tauri_release.py b/scripts/package/configure_tauri_release.py new file mode 100644 index 0000000..ab24550 --- /dev/null +++ b/scripts/package/configure_tauri_release.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Set the compiled updater version and isolate Research Edition update trust.""" + +import argparse +import base64 +import binascii +import json +import os +import sys +from pathlib import Path + +from generate_latest_json import tauri_version + +RESEARCH_ENDPOINT = ( + "https://raw.githubusercontent.com/ActivityWatch/activitywatch/" + "research-updates/latest-research.json" +) +STANDARD_ENDPOINT = "https://github.com/ActivityWatch/activitywatch/releases/latest/download/latest.json" + + +def msi_rejects(version: str) -> bool: + """True if Tauri's msi (WiX) bundler will reject this SemVer version. + + The msi target requires the optional pre-release identifier to be + numeric-only (e.g. "0.14.0-5"), unlike nsis or the other bundle formats. + AW's own pre-release scheme ("0.14.0-beta.5", "0.14.0-dev.gabc1234") + fails that check every time. + """ + if "-" not in version: + return False + prerelease = version.split("-", 1)[1] + return not all(part.isdigit() for part in prerelease.split(".")) + + +def public_key(encoded: str) -> bytes: + """Read Tauri's base64-wrapped minisign public key, ignoring its comment.""" + try: + lines = base64.b64decode(encoded, validate=True).decode("ascii").splitlines() + if len(lines) != 2 or not lines[0].startswith("untrusted comment: "): + raise ValueError("invalid public key envelope") + packet = base64.b64decode(lines[1], validate=True) + if len(packet) != 42 or packet[:2] != b"Ed": + raise ValueError("invalid public key packet") + return packet[10:] + except (ValueError, UnicodeError, binascii.Error) as exc: + raise ValueError("Expected a base64-encoded minisign public key") from exc + + +def configure( + path: Path, + version: str, + research: bool, + require_signing_key: bool, + *, + platform: str = sys.platform, +) -> None: + config = json.loads(path.read_text(encoding="utf-8")) + tv = tauri_version(version) + config["version"] = tv + if platform == "win32" and msi_rejects(tv): + bundle = config.setdefault("bundle", {}) + if bundle.get("targets") == "all": + # Windows only ever produces msi+nsis from "all"; drop msi and + # keep nsis, which accepts the full AW pre-release scheme. + bundle["targets"] = ["nsis"] + if research: + updater = config["plugins"]["updater"] + if updater["endpoints"] != [STANDARD_ENDPOINT]: + raise ValueError( + "Unexpected source updater endpoint; review before patching" + ) + research_key = os.environ.get("TAURI_UPDATER_PUBLIC_KEY_RESEARCH", "").strip() + signing_key = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "").strip() + if require_signing_key and (not research_key or not signing_key): + raise ValueError( + "Research releases require their own public and signing keys" + ) + if research_key: + if public_key(research_key) == public_key(updater["pubkey"]): + raise ValueError( + "Research updater key must differ from the standard key" + ) + updater["pubkey"] = research_key + updater["endpoints"] = [RESEARCH_ENDPOINT] + else: + if signing_key: + raise ValueError("Research signing key supplied without its public key") + # Secretless PR smoke builds must never retain standard update trust. + updater["pubkey"] = "" + updater["endpoints"] = [] + path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--research", action="store_true") + parser.add_argument("--require-signing-key", action="store_true") + args = parser.parse_args() + try: + configure(args.config, args.version, args.research, args.require_signing_key) + except (ValueError, KeyError) as exc: + parser.exit(1, f"ERROR: {exc}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/package/deb/control b/scripts/package/deb/control new file mode 100644 index 0000000..57e0501 --- /dev/null +++ b/scripts/package/deb/control @@ -0,0 +1,8 @@ +Package: activitywatch +Architecture: amd64 +Maintainer: Erik Bjäreholt +Depends: +Priority: optional +Version: SCRIPT_VERSION_HERE +Description: Open source time tracker + https://github.com/ActivityWatch/activitywatch diff --git a/scripts/package/dmgbuild-settings.py b/scripts/package/dmgbuild-settings.py new file mode 100644 index 0000000..c2c7696 --- /dev/null +++ b/scripts/package/dmgbuild-settings.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import plistlib +import os.path + +# Use like this: dmgbuild -s settings.py "Test Volume" test.dmg + +# You can actually use this file for your own application (not just TextEdit) +# by doing e.g. +# +# dmgbuild -s settings.py -D app=/path/to/My.app "My Application" MyApp.dmg + +# .. Useful stuff .............................................................. + +application = defines.get('app', 'dist/ActivityWatch.app') +appname = os.path.basename(application) + +def icon_from_app(app_path): + plist_path = os.path.join(app_path, 'Contents', 'Info.plist') + with open(plist_path, "rb") as f: + plist = plistlib.load(f) + icon_name = plist['CFBundleIconFile'] + icon_root,icon_ext = os.path.splitext(icon_name) + if not icon_ext: + icon_ext = '.icns' + icon_name = icon_root + icon_ext + return os.path.join(app_path, 'Contents', 'Resources', icon_name) + +# .. Basics .................................................................... + +# Uncomment to override the output filename +# filename = 'test.dmg' + +# Uncomment to override the output volume name +# volume_name = 'Test' + +# Volume format (see hdiutil create -help) +format = defines.get('format', 'UDBZ') + +# Volume size +size = defines.get('size', None) + +# Files to include +files = [ application ] + +# Symlinks to create +symlinks = { 'Applications': '/Applications' } + +# Volume icon +# +# You can either define icon, in which case that icon file will be copied to the +# image, *or* you can define badge_icon, in which case the icon file you specify +# will be used to badge the system's Removable Disk icon +# +#icon = '/path/to/icon.icns' +badge_icon = icon_from_app(application) + +# Where to put the icons +icon_locations = { + appname: (140, 120), + 'Applications': (500, 120) +} + +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +sidebar_width = 180 + +# Window position in ((x, y), (w, h)) format +window_rect = ((100, 100), (640, 280)) + +default_view = 'icon-view' + +show_icon_preview = False + +# Set these to True to force inclusion of icon/list view settings (otherwise +# we only include settings for the default view) +include_icon_view_settings = 'auto' +include_list_view_settings = 'auto' + +# .. Icon view configuration ................................................... + +arrange_by = None +grid_offset = (0, 0) +grid_spacing = 100 +scroll_position = (0, 0) +label_pos = 'bottom' # or 'right' +text_size = 16 +icon_size = 128 + +# .. List view configuration ................................................... + +# Column names are as follows: +# +# name +# date-modified +# date-created +# date-added +# date-last-opened +# size +# kind +# label +# version +# comments +# +list_icon_size = 16 +list_text_size = 12 +list_scroll_position = (0, 0) +list_sort_by = 'name' +list_use_relative_dates = True +list_calculate_all_sizes = False, +list_columns = ('name', 'date-modified', 'size', 'kind', 'date-added') +list_column_widths = { + 'name': 300, + 'date-modified': 181, + 'date-created': 181, + 'date-added': 181, + 'date-last-opened': 181, + 'size': 97, + 'kind': 115, + 'label': 100, + 'version': 75, + 'comments': 300, +} + +list_column_sort_directions = { + 'name': 'ascending', + 'date-modified': 'descending', + 'date-created': 'descending', + 'date-added': 'descending', + 'date-last-opened': 'descending', + 'size': 'descending', + 'kind': 'ascending', + 'label': 'ascending', + 'version': 'ascending', + 'comments': 'ascending', +} + diff --git a/scripts/package/entitlements.plist b/scripts/package/entitlements.plist new file mode 100644 index 0000000..37ed7bd --- /dev/null +++ b/scripts/package/entitlements.plist @@ -0,0 +1,12 @@ + + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/scripts/package/generate_latest_json.py b/scripts/package/generate_latest_json.py new file mode 100755 index 0000000..b953248 --- /dev/null +++ b/scripts/package/generate_latest_json.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Assemble a Tauri updater manifest from per-platform .sig files. + +Expects updater artifacts named +`activitywatch-tauri[-research]--.` with a +matching `<...>.sig` file alongside it (as produced by the "Package Tauri +updater artifacts" step in release.yml), where is a Tauri +updater platform identifier such as "darwin-aarch64" or "linux-x86_64". + +Standard and Research Edition releases are partitioned by filename: + +- standard: `latest.json` + `activitywatch-tauri--...` +- research: `latest-research.json` + `activitywatch-tauri-research--...` + +so the two lines cannot overwrite each other's GitHub release assets or +share an updater endpoint. +""" +import argparse +import json +import os +import re +from datetime import datetime, timezone + +EDITIONS = ("standard", "research") + + +def normalize_version(version: str) -> str: + """Strip a leading 'v' and a trailing '-research' edition suffix.""" + if version.startswith("v"): + version = version[1:] + if version.endswith("-research"): + version = version[: -len("-research")] + return version + + +def tauri_version(version: str) -> str: + """Convert AW release/dev labels to the SemVer embedded by Tauri. + + Asset filenames and GitHub tags keep their original AW version spelling. + """ + version = normalize_version(version) + number = r"(?:0|[1-9][0-9]*)" + match = re.fullmatch( + rf"(?P{number}\.{number}\.{number})" + rf"(?:(?P
a|b|rc)(?P{number}))?"
+        r"(?:\.dev-(?P[0-9a-f]+|unknown))?",
+        version,
+    )
+    if not match:
+        raise ValueError(f"Unsupported ActivityWatch version: {version!r}")
+    suffix = []
+    if match["pre"]:
+        suffix.extend(
+            ({"a": "alpha", "b": "beta", "rc": "rc"}[match["pre"]], match["serial"])
+        )
+    if match["dev"]:
+        # Prefix hashes so an all-digit hash with a leading zero stays valid.
+        suffix.extend(("dev", "g" + match["dev"]))
+    return match["base"] + ("-" + ".".join(suffix) if suffix else "")
+
+
+def infer_edition(tag: str, edition=None) -> str:
+    if edition:
+        if edition not in EDITIONS:
+            raise ValueError(f"unknown edition {edition!r}")
+        return edition
+    return "research" if tag.endswith("-research") else "standard"
+
+
+def asset_prefix(edition: str) -> str:
+    if edition == "research":
+        return "activitywatch-tauri-research"
+    return "activitywatch-tauri"
+
+
+def manifest_filename(edition: str) -> str:
+    return "latest-research.json" if edition == "research" else "latest.json"
+
+
+# Tauri v2 recommends NSIS for Windows updater bundles. When both NSIS and
+# MSI signatures exist for the same platform key, keep NSIS regardless of
+# os.walk order. Unlisted extensions share rank 0 (first one wins).
+WINDOWS_BUNDLE_RANK = {
+    "nsis.zip": 2,
+    "exe": 1,
+    "msi.zip": 0,
+    "msi": 0,
+}
+
+
+def bundle_rank(ext: str) -> int:
+    return WINDOWS_BUNDLE_RANK.get(ext, 0)
+
+
+def collect_platforms(dist: str, version: str, repo: str, tag: str, edition: str) -> dict:
+    prefix = asset_prefix(edition)
+    # Non-greedy platform group: extensions can be multi-part (.app.tar.gz,
+    # .AppImage.tar.gz, .nsis.zip), so stop at the first dot after the
+    # platform key rather than the last.
+    pattern = re.compile(
+        rf"^{re.escape(prefix)}-{re.escape(version)}-(?P.+?)\.(?P.+)$"
+    )
+
+    platforms = {}
+    chosen_ext = {}
+    for root, _, files in os.walk(dist):
+        for name in files:
+            if not name.endswith(".sig"):
+                continue
+            asset_name = name[: -len(".sig")]
+            m = pattern.match(asset_name)
+            if not m:
+                continue
+            platform = m.group("platform")
+            ext = m.group("ext")
+            prev_ext = chosen_ext.get(platform)
+            if prev_ext is not None and bundle_rank(ext) <= bundle_rank(prev_ext):
+                continue
+            with open(os.path.join(root, name)) as f:
+                signature = f.read().strip()
+            chosen_ext[platform] = ext
+            platforms[platform] = {
+                "signature": signature,
+                "url": (
+                    f"https://github.com/{repo}/releases/download/"
+                    f"{tag}/{asset_name}"
+                ),
+            }
+    return platforms
+
+
+def build_manifest(version: str, notes: str, platforms: dict, pub_date=None) -> dict:
+    if pub_date is None:
+        pub_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+    return {
+        "version": version,
+        "notes": notes,
+        "pub_date": pub_date,
+        "platforms": platforms,
+    }
+
+
+def main(argv=None):
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--version", required=True)
+    parser.add_argument("--notes", required=True)
+    parser.add_argument(
+        "--repo", required=True, help="e.g. ActivityWatch/activitywatch"
+    )
+    parser.add_argument("--tag", required=True, help="e.g. v0.13.3 or v0.13.3-research")
+    parser.add_argument(
+        "--edition",
+        choices=EDITIONS,
+        default=None,
+        help="Release line. Inferred from --tag (*-research) if omitted.",
+    )
+    parser.add_argument("--dist", required=True, help="directory to search for *.sig files")
+    parser.add_argument("--output", required=True)
+    args = parser.parse_args(argv)
+
+    version = normalize_version(args.version)
+    edition = infer_edition(args.tag, args.edition)
+    platforms = collect_platforms(args.dist, version, args.repo, args.tag, edition)
+
+    if not platforms:
+        raise SystemExit(
+            f"No {edition} updater artifacts found - refusing to write an empty "
+            f"{os.path.basename(args.output)}"
+        )
+
+    manifest = build_manifest(tauri_version(version), args.notes, platforms)
+
+    with open(args.output, "w") as f:
+        json.dump(manifest, f, indent=2)
+        f.write("\n")
+
+    print(f"Wrote {args.output} ({edition}) with platforms: {', '.join(sorted(platforms))}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/package/getversion.sh b/scripts/package/getversion.sh
new file mode 100755
index 0000000..ef725c8
--- /dev/null
+++ b/scripts/package/getversion.sh
@@ -0,0 +1,93 @@
+#!/bin/bash
+set -e
+
+SCRIPT_NAME="$(basename "$0")"
+STRIP_V=false
+
+show_usage() {
+    cat << EOF
+Usage: $SCRIPT_NAME [OPTIONS]
+
+Get the version of ActivityWatch from git tags or CI environment variables.
+
+Options:
+    --strip-v, --no-v    Remove the 'v' prefix from the version (if present)
+    --help, -h           Show this help message
+
+Environment Variables (used in CI, checked in priority order):
+    GITHUB_REF_NAME      GitHub Actions tag/ref (e.g., "v0.14.0")
+    TRAVIS_TAG           Travis CI tag
+    APPVEYOR_REPO_TAG_NAME AppVeyor CI tag
+
+Version Format:
+    - Release tag: v0.14.0
+    - Dev version: v0.14.0.dev-abc1234
+    - Beta/RC: v0.14.0b1, v0.14.0rc1
+
+Examples:
+    $SCRIPT_NAME                    # v0.14.0 or v0.14.0.dev-abc1234
+    $SCRIPT_NAME --strip-v          # 0.14.0 or 0.14.0.dev-abc1234
+EOF
+}
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --strip-v|--no-v)
+                STRIP_V=true
+                shift
+                ;;
+            --help|-h)
+                show_usage
+                exit 0
+                ;;
+            *)
+                echo "ERROR: Unknown argument: $1" >&2
+                show_usage >&2
+                exit 1
+                ;;
+        esac
+    done
+}
+
+get_version_internal() {
+    local _version=""
+    
+    if [[ -n "$GITHUB_REF" && "$GITHUB_REF" == refs/tags/v* ]]; then
+        _version="$GITHUB_REF_NAME"
+    elif [[ -n "$TRAVIS_TAG" ]]; then
+        _version="$TRAVIS_TAG"
+    elif [[ -n "$APPVEYOR_REPO_TAG_NAME" ]]; then
+        _version="$APPVEYOR_REPO_TAG_NAME"
+    else
+        _version="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
+        if [[ -z "$_version" ]]; then
+            local _latest_tag
+            # Exclude research-edition tags so dev versions off master don't
+            # inherit a "-research" base (e.g. v0.14.0b4-research.dev-abc1234).
+            # Exact-match above intentionally keeps them: building *on* a
+            # research tag must report the research version.
+            _latest_tag="$(git describe --tags --abbrev=0 --exclude '*-research' 2>/dev/null || echo "v0.0.0")"
+            local _commit_hash
+            _commit_hash="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
+            _version="${_latest_tag}.dev-${_commit_hash}"
+        fi
+    fi
+    
+    echo "$_version"
+}
+
+main() {
+    parse_args "$@"
+    
+    local version
+    version="$(get_version_internal)"
+    
+    if $STRIP_V; then
+        version="$(echo "$version" | sed -e 's/^v//')"
+    fi
+    
+    echo "$version"
+}
+
+main "$@"
diff --git a/scripts/package/move-to-aw-modules.sh b/scripts/package/move-to-aw-modules.sh
new file mode 100755
index 0000000..f3f79ca
--- /dev/null
+++ b/scripts/package/move-to-aw-modules.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+# Copy all AW modules to ~/aw-modules/ for aw-tauri to discover.
+# aw-tauri uses this directory to find and launch AW components.
+set -e
+
+mkdir -p ~/aw-modules/
+
+if [[ -n "$XDG_SESSION_TYPE" && "$XDG_SESSION_TYPE" == "wayland" ]]; then
+    rsync -a . ~/aw-modules/ \
+        --exclude=aw-tauri \
+        --exclude=aw-server-rust \
+        --exclude=awatcher \
+        --exclude=move-to-aw-modules.sh \
+        --exclude=README.txt
+    cp ./awatcher/aw-awatcher ~/aw-modules/
+    cp ./aw-server-rust/aw-sync ~/aw-modules/
+else
+    rsync -a . ~/aw-modules/ \
+        --exclude=aw-tauri \
+        --exclude=awatcher \
+        --exclude=aw-server-rust \
+        --exclude=move-to-aw-modules.sh \
+        --exclude=README.txt
+    cp ./aw-server-rust/aw-sync ~/aw-modules/
+fi
+
+echo "Modules copied to ~/aw-modules/"
diff --git a/scripts/package/package-all.sh b/scripts/package/package-all.sh
new file mode 100755
index 0000000..f6a2ca8
--- /dev/null
+++ b/scripts/package/package-all.sh
@@ -0,0 +1,123 @@
+#!/bin/bash
+
+set -e
+
+echoerr() { echo "$@" 1>&2; }
+
+function get_platform() {
+    # Will return "linux" for GNU/Linux
+    #   I'd just like to interject for a moment...
+    #   https://wiki.installgentoo.com/index.php/Interjection
+    # Will return "macos" for macOS/OS X
+    # Will return "windows" for Windows/MinGW/msys
+
+    _platform=$(uname | tr '[:upper:]' '[:lower:]')
+    if [[ $_platform == "darwin" ]]; then
+        _platform="macos";
+    elif [[ $_platform == "msys"* ]]; then
+        _platform="windows";
+    elif [[ $_platform == "mingw"* ]]; then
+        _platform="windows";
+    elif [[ $_platform == "linux" ]]; then
+        # Nothing to do
+        true;
+    else
+        echoerr "ERROR: $_platform is not a valid platform";
+        exit 1;
+    fi
+
+    echo $_platform;
+}
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+function get_version() {
+    "$SCRIPT_DIR/getversion.sh";
+}
+
+function get_version_no_prefix() {
+    "$SCRIPT_DIR/getversion.sh" --strip-v;
+}
+
+function get_arch() {
+    _arch="$(uname -m)"
+    echo $_arch;
+}
+
+platform=$(get_platform)
+version=$(get_version)
+version_no_prefix=$(get_version_no_prefix)
+arch=$(get_arch)
+# Research tags are suffixed (e.g. v0.14.0b3-research), but the edition
+# belongs in its own filename token, not in the version part:
+#   activitywatch[-tauri][-research]---[-setup].
+version="${version%-research}"
+version_no_prefix="${version_no_prefix%-research}"
+build_suffix=""
+if [[ $TAURI_BUILD == "true" ]]; then
+    build_suffix="-tauri"
+fi
+if [[ $AW_RESEARCH_EDITION == "true" ]]; then
+    build_suffix="${build_suffix}-research"
+fi
+
+echo "========================================"
+echo "Build Version Information"
+echo "========================================"
+echo "Platform:       $platform"
+echo "Arch:           $arch"
+echo "Version (with v):  $version"
+echo "Version (no v):     $version_no_prefix"
+echo "Tauri build:    ${TAURI_BUILD:-false}"
+echo "========================================"
+echo
+
+# For Tauri Linux builds, include helper scripts and README
+if [[ $platform == "linux" && $TAURI_BUILD == "true" ]]; then
+    cp scripts/package/README.txt scripts/package/move-to-aw-modules.sh dist/activitywatch/
+fi
+
+function build_zip() {
+    echo "Zipping executables..."
+    pushd dist;
+    filename="activitywatch${build_suffix}-${version}-${platform}-${arch}.zip"
+    echo "Name of package will be: $filename"
+
+    if [[ $platform == "windows"* ]]; then
+        7z a $filename activitywatch;
+    else
+        zip -r $filename activitywatch;
+    fi
+    popd;
+    echo "Zip built!"
+}
+
+function build_setup() {
+    filename="activitywatch${build_suffix}-${version}-${platform}-${arch}-setup.exe"
+    echo "Name of package will be: $filename"
+
+    innosetupdir="/c/Program Files (x86)/Inno Setup 6"
+    if [ ! -d "$innosetupdir" ]; then
+        echo "ERROR: Couldn't find innosetup which is needed to build the installer. We suggest you install it using chocolatey. Exiting."
+        exit 1
+    fi
+
+    if [[ $TAURI_BUILD == "true" ]]; then
+        env AW_VERSION=$version_no_prefix "$innosetupdir/iscc.exe" scripts/package/aw-tauri.iss
+    else
+        env AW_VERSION=$version_no_prefix "$innosetupdir/iscc.exe" scripts/package/activitywatch-setup.iss
+    fi
+    "$SCRIPT_DIR/collect-setup.sh" "$filename"
+    echo "Setup built!"
+}
+
+build_zip
+if [[ $platform == "windows"* ]]; then
+    build_setup
+fi
+
+echo
+echo "-------------------------------------"
+echo "Contents of ./dist"
+ls -l dist
+echo "-------------------------------------"
diff --git a/scripts/package/package-appimage.sh b/scripts/package/package-appimage.sh
new file mode 100755
index 0000000..b046c65
--- /dev/null
+++ b/scripts/package/package-appimage.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+# pick the latest zip
+# NOTE: this assumes that the latest built zip is the only zip in the directory
+ZIP_FILE=`ls ./dist/ -1 | grep zip | sort -r | head -1`
+unzip ./dist/$ZIP_FILE
+
+# fetch deps
+wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
+chmod +x linuxdeploy-x86_64.AppImage
+wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
+chmod +x appimagetool-x86_64.AppImage
+
+# create AppRun
+echo '#!/bin/sh
+DIR="$(dirname "$(readlink -f "${0}")")"
+"${DIR}"/aw-qt "$@"' > activitywatch/AppRun
+chmod a+x ./activitywatch/AppRun
+
+# build appimage
+./linuxdeploy-x86_64.AppImage --appdir activitywatch --executable ./activitywatch/aw-qt --output appimage --desktop-file ./activitywatch/aw-qt.desktop --icon-file ./activitywatch/media/logo/logo.png --icon-filename activitywatch
+APPIMAGE_FILE=`ls -1 | grep AppImage| grep -i ActivityWatch`
+# Deliberately unversioned: the AppImage has kept this exact name across
+# releases so the stable download URL keeps working, e.g.
+# https://github.com/ActivityWatch/activitywatch/releases/latest/download/activitywatch-linux-x86_64.AppImage
+EDITION=""
+if [[ $AW_RESEARCH_EDITION == "true" ]]; then
+    EDITION="-research"
+fi
+cp -v $APPIMAGE_FILE ./dist/activitywatch${EDITION}-linux-x86_64.AppImage
diff --git a/scripts/package/package-deb.sh b/scripts/package/package-deb.sh
new file mode 100755
index 0000000..4e91bdb
--- /dev/null
+++ b/scripts/package/package-deb.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/bash
+
+# Fail fast
+set -e
+# Verbose commands for CI verification
+set -x
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+VERSION="$("$SCRIPT_DIR/getversion.sh")"
+# Strip the research tag suffix: the edition gets its own filename token, and
+# a bare "-research" in the Debian version field would parse as a revision.
+VERSION="${VERSION%-research}"
+VERSION_NUM="$(echo "$VERSION" | sed -e 's/^v//')"
+EDITION=""
+if [[ $AW_RESEARCH_EDITION == "true" ]]; then
+    EDITION="-research"
+fi
+echo "Version (with v): $VERSION"
+echo "Version (without v): $VERSION_NUM"
+PKGDIR="activitywatch_$VERSION_NUM"
+
+# Package tools
+sudo apt-get install sed jdupes wget
+
+if [ -d "PKGDIR" ]; then
+    sudo rm -rf $PKGDIR
+fi
+
+# .deb meta files
+mkdir -p $PKGDIR/DEBIAN
+# activitywatch's install location
+mkdir -p $PKGDIR/opt
+# Allows aw-qt to autostart.
+mkdir -p $PKGDIR/etc/xdg/autostart
+# Allows users to manually start aw-qt from their start menu.
+mkdir -p $PKGDIR/usr/share/applications
+
+# While storing the control file in a variable here, dumping it in a file is so unnecessarily
+# complicated that it's easier to just dump move and sed.
+cp ./scripts/package/deb/control $PKGDIR/DEBIAN/control
+sed -i "s/SCRIPT_VERSION_HERE/${VERSION_NUM}/" $PKGDIR/DEBIAN/control
+
+# Verify the file content
+cat $PKGDIR/DEBIAN/control
+# The entire opt directory (should) consist of dist/activitywatch/*
+
+cp -r dist/activitywatch/ $PKGDIR/opt/
+
+# Hard link duplicated libraries
+# (I have no idea what this is for)
+jdupes -L -r -S -Xsize-:1K $PKGDIR/opt/
+
+sudo chown -R root:root $PKGDIR
+
+# Prepare the .desktop file
+sudo sed -i 's!Exec=aw-qt!Exec=/opt/activitywatch/aw-qt!' $PKGDIR/opt/activitywatch/aw-qt.desktop
+sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/etc/xdg/autostart/
+sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/usr/share/applications/
+
+dpkg-deb --build $PKGDIR
+sudo mv activitywatch_${VERSION_NUM}.deb dist/activitywatch${EDITION}-${VERSION}-linux-x86_64.deb
diff --git a/scripts/package/publish_research_feed.py b/scripts/package/publish_research_feed.py
new file mode 100644
index 0000000..42226f2
--- /dev/null
+++ b/scripts/package/publish_research_feed.py
@@ -0,0 +1,277 @@
+#!/usr/bin/env python3
+# /// script
+# requires-python = ">=3.9"
+# dependencies = ["semver>=3,<4"]
+# ///
+"""Validate and prepare a research feed commit; publish only with --publish."""
+
+import argparse
+from datetime import datetime
+import json
+from pathlib import Path
+import re
+import shlex
+import subprocess
+import sys
+import tempfile
+from urllib.parse import quote
+from urllib.request import Request, urlopen
+
+from semver import Version
+
+REPOSITORY = "ActivityWatch/activitywatch"
+REF = "refs/heads/research-updates"
+MANIFEST = "latest-research.json"
+# Keep in step with build-tauri's release matrix. A reduced rollout needs review.
+TARGETS = {
+    "darwin-aarch64": ("app.tar.gz",),
+    "darwin-x86_64": ("app.tar.gz",),
+    "linux-aarch64": ("AppImage", "AppImage.tar.gz"),
+    "linux-x86_64": ("AppImage", "AppImage.tar.gz"),
+    "windows-x86_64": ("exe", "nsis.zip", "msi", "msi.zip"),
+}
+
+
+def public_bytes(url):
+    """Unauthenticated reads prove the release/signature is publicly available."""
+    request = Request(url, headers={"User-Agent": "ActivityWatch-research-publisher"})
+    with urlopen(request, timeout=30) as response:
+        return response.read()
+
+
+def validate_manifest(manifest, release, tag, fetch=None):
+    if fetch is None:
+        fetch = public_bytes
+    if set(manifest) != {"version", "notes", "pub_date", "platforms"}:
+        raise ValueError("Expected only the four static updater manifest fields")
+    if not isinstance(manifest["notes"], str):
+        raise ValueError("Manifest notes must be a string")
+    date = manifest["pub_date"]
+    if not isinstance(date, str) or not re.fullmatch(
+        r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])",
+        date,
+    ):
+        raise ValueError("Manifest pub_date must be an RFC3339 timestamp")
+    datetime.fromisoformat(date.replace("Z", "+00:00"))
+    if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?-research", tag):
+        raise ValueError("Expected a versioned research release tag")
+    raw_version = tag[1 : -len("-research")]
+    expected_version = re.sub(
+        r"(a|b|rc)([0-9]+)$",
+        lambda m: "-" + {"a": "alpha", "b": "beta", "rc": "rc"}[m[1]] + "." + m[2],
+        raw_version,
+    )
+    Version.parse(expected_version)
+    if manifest["version"] != expected_version:
+        raise ValueError("Manifest version must match the normalized research tag")
+    if (
+        release["tag_name"] != tag
+        or release["draft"] is not False
+        or not release["published_at"]
+        or release.get("immutable") is not True
+    ):
+        raise ValueError(
+            "Release must be published, immutable, non-draft, and match the tag"
+        )
+    if not isinstance(manifest["platforms"], dict) or set(manifest["platforms"]) != set(
+        TARGETS
+    ):
+        raise ValueError(
+            "Manifest must contain exactly the complete required target matrix"
+        )
+    assets = {asset["name"]: asset for asset in release["assets"]}
+    if len(assets) != len(release["assets"]):
+        raise ValueError("Duplicate release asset names")
+    base_url = f"https://github.com/{REPOSITORY}/releases/download/{tag}/"
+    for target, entry in manifest["platforms"].items():
+        if set(entry) != {"url", "signature"}:
+            raise ValueError(f"Expected only url and signature for {target}")
+        prefix = f"activitywatch-tauri-research-{raw_version}-{target}."
+        names = [prefix + ext for ext in TARGETS[target]]
+        matches = [name for name in names if entry["url"] == base_url + name]
+        if len(matches) != 1:
+            raise ValueError(
+                f"Wrong edition, version, release, or bundle format for {target}"
+            )
+        name = matches[0]
+        for asset_name in (name, name + ".sig"):
+            asset = assets.get(asset_name)
+            if (
+                not asset
+                or asset["state"] != "uploaded"
+                or asset["size"] <= 0
+                or asset["browser_download_url"] != base_url + asset_name
+            ):
+                raise ValueError(
+                    f"Missing or invalid uploaded release asset: {asset_name}"
+                )
+        signature = entry["signature"]
+        if (
+            not isinstance(signature, str)
+            or not signature.strip()
+            or signature != fetch(base_url + name + ".sig").decode("utf-8").strip()
+        ):
+            raise ValueError(f"Missing or mismatched signature for {target}")
+
+
+def git(repo, *args, input=None):
+    return subprocess.run(
+        ["git", "-C", str(repo), *args],
+        input=input,
+        text=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        check=True,
+    ).stdout.strip()
+
+
+def remote_parent(repo, remote):
+    result = git(repo, "ls-remote", "--refs", remote, REF)
+    return result.split()[0] if result else "absent"
+
+
+def prepare(repo, remote, expected_parent, manifest):
+    """Create a new isolated checkout, retaining all existing feed branch files."""
+    repo.mkdir(parents=True, exist_ok=False)
+    git(repo, "init", "--quiet")
+    if remote_parent(repo, remote) != expected_parent:
+        raise ValueError("Stale expected parent; reread the feed and retry validation")
+    if expected_parent != "absent":
+        git(repo, "fetch", "--no-tags", remote, REF)
+        if git(repo, "rev-parse", "FETCH_HEAD") != expected_parent:
+            raise ValueError("Feed changed during fetch; reread and retry")
+        git(repo, "checkout", "--detach", expected_parent)
+        current = json.loads(git(repo, "show", f"{expected_parent}:{MANIFEST}"))
+        old, new = Version.parse(current["version"]), Version.parse(manifest["version"])
+        if new < old:
+            raise ValueError("Refusing research feed version regression")
+        if new == old:
+            if manifest != current:
+                raise ValueError("Conflicting content at equal SemVer precedence")
+            return None
+    else:
+        git(repo, "checkout", "--orphan", "research-updates")
+    # Replace any old symlink rather than following a path outside this checkout.
+    path = repo / MANIFEST
+    if path.is_symlink():
+        path.unlink()
+    path.write_text(
+        json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
+    )
+    git(repo, "add", "--", MANIFEST)
+    git(
+        repo,
+        "commit",
+        "-m",
+        f"chore(updater): offer research {manifest['version']}",
+        "--",
+        MANIFEST,
+    )
+    return git(repo, "rev-parse", "HEAD")
+
+
+def publish(repo, remote, expected_parent, commit):
+    """Assert the advertised parent in pre-push, then use Git's normal FF/CAS."""
+    if git(repo, "rev-list", "--parents", "-n", "1", commit).split() != (
+        [commit] if expected_parent == "absent" else [commit, expected_parent]
+    ):
+        raise ValueError("Candidate must be one commit on the expected parent")
+    original_hook = Path(git(repo, "rev-parse", "--git-path", "hooks/pre-push"))
+    if not original_hook.is_absolute():
+        original_hook = repo / original_hook
+    expected_oid = "0" * len(commit) if expected_parent == "absent" else expected_parent
+    # Git sends the same advertised old OID to receive-pack, which checks it
+    # atomically. Checking only ls-remote before push would leave a race window
+    # for branch deletion/rewind. Preserve the site's existing pre-push policy.
+    with tempfile.TemporaryDirectory(prefix="aw-feed-push-") as directory:
+        hook = Path(directory) / "pre-push"
+        hook.write_text(
+            "#!/bin/sh\nexec "
+            + shlex.quote(sys.executable)
+            + " -c "
+            + shlex.quote(
+                "import os, subprocess, sys\n"
+                "data = sys.stdin.read()\n"
+                "rows = [line.split() for line in data.splitlines()]\n"
+                f"expected = {expected_oid!r}\n"
+                f"if len(rows) != 1 or rows[0][1:] != [{commit!r}, {REF!r}, expected]:\n"
+                "    sys.exit('Feed parent changed during push; reread and retry')\n"
+                f"hook = {str(original_hook)!r}\n"
+                "if os.access(hook, os.X_OK):\n"
+                "    sys.exit(subprocess.run([hook, *sys.argv[1:]], input=data, text=True).returncode)\n"
+            )
+            + ' "$@"\n',
+            encoding="utf-8",
+        )
+        hook.chmod(0o755)
+        git(
+            repo,
+            "-c",
+            f"core.hooksPath={directory}",
+            "-c",
+            "push.followTags=false",
+            "push",
+            "--porcelain",
+            remote,
+            f"{commit}:{REF}",
+        )
+
+
+def main(argv=None):
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--manifest", required=True, type=Path)
+    parser.add_argument("--tag", required=True)
+    parser.add_argument(
+        "--expected-parent",
+        required=True,
+        help="Full feed SHA, or 'absent' for provisioning",
+    )
+    parser.add_argument(
+        "--work-dir",
+        required=True,
+        type=Path,
+        help="New directory to retain the reviewable commit",
+    )
+    parser.add_argument(
+        "--publish",
+        action="store_true",
+        help="Explicitly push after validation and preparation",
+    )
+    args = parser.parse_args(argv)
+    remote = f"https://github.com/{REPOSITORY}.git"
+    try:
+        if args.expected_parent != "absent" and not re.fullmatch(
+            r"[0-9a-f]{40}", args.expected_parent
+        ):
+            raise ValueError("Expected a full parent SHA or 'absent'")
+        manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
+        release = json.loads(
+            public_bytes(
+                f"https://api.github.com/repos/{REPOSITORY}/releases/tags/{quote(args.tag, safe='')}"
+            )
+        )
+        validate_manifest(manifest, release, args.tag)
+        repo = args.work_dir.resolve()
+        commit = prepare(repo, remote, args.expected_parent, manifest)
+        if commit is None:
+            print("NO-OP: identical manifest already published")
+        elif args.publish:
+            publish(repo, remote, args.expected_parent, commit)
+            print(f"Published {commit} on {REF}")
+        else:
+            print(f"Prepared {commit} in {repo}; remote unchanged (no --publish)")
+    except (
+        ValueError,
+        KeyError,
+        TypeError,
+        OSError,
+        subprocess.CalledProcessError,
+    ) as exc:
+        detail = (
+            exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc)
+        )
+        parser.exit(1, f"ERROR: {detail}\n")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/patch_research_edition_awqt.py b/scripts/patch_research_edition_awqt.py
new file mode 100755
index 0000000..431407f
--- /dev/null
+++ b/scripts/patch_research_edition_awqt.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+"""Patch aw-qt's default config for Research Edition builds.
+
+Flips `autostart_on_first_run` from false to true in aw_qt/config.py's
+default_config, so participant machines register start-at-login on first
+launch (aw-qt#131) without any setup steps. The user can still disable it
+from the tray afterwards; aw-qt never re-enables once its marker is written.
+
+Run as part of the CI build for research edition:
+    python3 scripts/patch_research_edition_awqt.py 
+
+Fails closed if the key is absent, which means the aw-qt submodule pin
+predates aw-qt#131 — bump the pin rather than shipping a research build
+that silently loses reboot-survival.
+"""
+import pathlib
+import sys
+
+OLD = "autostart_on_first_run = false"
+NEW = "autostart_on_first_run = true"
+
+
+def main() -> int:
+    if len(sys.argv) != 2:
+        print(f"usage: {sys.argv[0]} ", file=sys.stderr)
+        return 2
+
+    path = pathlib.Path(sys.argv[1])
+    try:
+        text = path.read_text()
+    except FileNotFoundError:
+        print(f"ERROR: {path} not found - is the aw-qt submodule checked out?", file=sys.stderr)
+        return 1
+
+    # Only patch the first occurrence: the [aw-qt] section default. The
+    # [aw-qt-testing] section keeps autostart off.
+    if OLD not in text:
+        print(
+            f"ERROR: {OLD!r} not found in {path} - aw-qt submodule pin "
+            "predates aw-qt#131, refusing to build research edition without "
+            "first-run autostart",
+            file=sys.stderr,
+        )
+        return 1
+
+    path.write_text(text.replace(OLD, NEW, 1))
+    print(f"Patched {path}: {OLD} -> {NEW} (first occurrence)")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/patch_research_edition_config.py b/scripts/patch_research_edition_config.py
new file mode 100644
index 0000000..0a59cd1
--- /dev/null
+++ b/scripts/patch_research_edition_config.py
@@ -0,0 +1,825 @@
+#!/usr/bin/env python3
+"""Patch aw-watcher-window/config.py with Research Edition browser categories.
+
+Run as part of the CI build for research edition:
+    python3 scripts/patch_research_edition_config.py 
+
+CATEGORY_MAP — browser URL/title substring matching:
+    classify_title() in PR #130 checks each pattern against the URL first (when
+    available), then the window title. Ordering is critical: first match wins.
+    Sensitive exclusions must come first. More specific domains before general
+    (music.youtube.com before youtube.com). Video domains before News title keywords
+    (svtplay.se domain before the "svt" title keyword).
+
+APP_CATEGORY_MAP is retained as the shared taxonomy for the web UI preset, but
+is deliberately not injected into the watcher. The approved study contract keeps
+application names (including browser identity) while discarding raw titles and
+URLs. An empty watcher app map selects exactly that behavior.
+"""
+
+import pathlib
+import re
+import sys
+
+CONFIG_FILE = pathlib.Path(
+    sys.argv[1]
+    if len(sys.argv) > 1
+    else "aw-watcher-window/aw_watcher_window/config.py"
+)
+
+# Combined category map derived faithfully from Matthias Lehner's classifier.
+# Structure: sensitive domains first, then music/video domains (specificity order),
+# then travel/search/other domains, then title keywords in TITLE_RULES priority.
+CATEGORY_MAP: list[tuple[str, str]] = [
+    # Sensitive / Excluded (MUST be first)
+    ("svenskaspel.se", "Sensitive / Excluded"),
+    ("unibet.se", "Sensitive / Excluded"),
+    ("bet365.com", "Sensitive / Excluded"),
+    ("pokerstars.com", "Sensitive / Excluded"),
+    ("casino.org", "Sensitive / Excluded"),
+    ("tinder.com", "Sensitive / Excluded"),
+    ("bumble.com", "Sensitive / Excluded"),
+    ("grindr.com", "Sensitive / Excluded"),
+    ("match.com", "Sensitive / Excluded"),
+    # Music & Audio domains (music.youtube.com before youtube.com; sverigesradio.se before svt.se)
+    ("music.youtube.com", "Music & Audio"),
+    ("youtube.com/music", "Music & Audio"),
+    ("open.spotify.com", "Music & Audio"),
+    ("spotify.com", "Music & Audio"),
+    ("music.apple.com", "Music & Audio"),
+    ("soundcloud.com", "Music & Audio"),
+    ("podme.com", "Music & Audio"),
+    ("bookbeat.se", "Music & Audio"),
+    ("storytel.com", "Music & Audio"),
+    ("audible.com", "Music & Audio"),
+    ("sverigesradio.se", "Music & Audio"),
+    ("sr.se", "Music & Audio"),
+    ("radio.se", "Music & Audio"),
+    # Video Streaming domains (svtplay.se, svt.se/play before svt.se; tv4.se→Video per Matthias dict)
+    ("svt.se/play", "Video Streaming"),
+    ("svtplay.se", "Video Streaming"),
+    ("tv4play.se", "Video Streaming"),
+    ("tv4.se", "Video Streaming"),
+    ("youtube.com", "Video Streaming"),
+    ("youtu.be", "Video Streaming"),
+    ("netflix.com", "Video Streaming"),
+    ("viaplay.se", "Video Streaming"),
+    ("max.com", "Video Streaming"),
+    ("hbomax.com", "Video Streaming"),
+    ("primevideo.com", "Video Streaming"),
+    ("disneyplus.com", "Video Streaming"),
+    ("twitch.tv/directory/game", "Games"),
+    ("twitch.tv", "Video Streaming"),
+    ("vimeo.com", "Video Streaming"),
+    ("dailymotion.com", "Video Streaming"),
+    ("kanopy.com", "Video Streaming"),
+    ("dr.dk", "Video Streaming"),
+    ("nrk.no", "Video Streaming"),
+    # Travel & Mobility (maps before google.com)
+    ("maps.google.com", "Travel & Mobility"),
+    ("google.com/maps", "Travel & Mobility"),
+    ("sj.se", "Travel & Mobility"),
+    ("sl.se", "Travel & Mobility"),
+    ("vasttrafik.se", "Travel & Mobility"),
+    ("skanetrafiken.se", "Travel & Mobility"),
+    ("resrobot.se", "Travel & Mobility"),
+    ("flixbus.se", "Travel & Mobility"),
+    ("sas.se", "Travel & Mobility"),
+    ("flysas.com", "Travel & Mobility"),
+    ("norwegian.com", "Travel & Mobility"),
+    ("ryanair.com", "Travel & Mobility"),
+    ("airbnb.com", "Travel & Mobility"),
+    ("booking.com", "Travel & Mobility"),
+    ("hotels.com", "Travel & Mobility"),
+    ("tripadvisor.com", "Travel & Mobility"),
+    ("uber.com", "Travel & Mobility"),
+    ("bolt.eu", "Travel & Mobility"),
+    ("parkster.com", "Travel & Mobility"),
+    ("easypark.com", "Travel & Mobility"),
+    ("circlek.se", "Travel & Mobility"),
+    ("okq8.se", "Travel & Mobility"),
+    ("preem.se", "Travel & Mobility"),
+    # Search & Navigation (google.com after maps.google.com captured above)
+    ("google.com", "Search & Navigation"),
+    ("google.se", "Search & Navigation"),
+    ("bing.com", "Search & Navigation"),
+    ("duckduckgo.com", "Search & Navigation"),
+    ("yahoo.com", "Search & Navigation"),
+    ("startpage.com", "Search & Navigation"),
+    ("ecosia.org", "Search & Navigation"),
+    ("kagi.com", "Search & Navigation"),
+    ("brave.com/search", "Search & Navigation"),
+    # News & Current Affairs (svt.se after svt.se/play and svtplay.se)
+    ("svt.se", "News & Current Affairs"),
+    ("dn.se", "News & Current Affairs"),
+    ("dagensnyheter.se", "News & Current Affairs"),
+    ("svd.se", "News & Current Affairs"),
+    ("aftonbladet.se", "News & Current Affairs"),
+    ("expressen.se", "News & Current Affairs"),
+    ("gp.se", "News & Current Affairs"),
+    ("goteborgsposten.se", "News & Current Affairs"),
+    ("sydsvenskan.se", "News & Current Affairs"),
+    ("hd.se", "News & Current Affairs"),
+    ("unt.se", "News & Current Affairs"),
+    ("vk.se", "News & Current Affairs"),
+    ("nwt.se", "News & Current Affairs"),
+    ("corren.se", "News & Current Affairs"),
+    ("omni.se", "News & Current Affairs"),
+    ("di.se", "News & Current Affairs"),
+    ("dagensindustri.se", "News & Current Affairs"),
+    ("breakit.se", "News & Current Affairs"),
+    ("nyteknik.se", "News & Current Affairs"),
+    ("theguardian.com", "News & Current Affairs"),
+    ("bbc.com", "News & Current Affairs"),
+    ("bbc.co.uk", "News & Current Affairs"),
+    ("cnn.com", "News & Current Affairs"),
+    ("edition.cnn.com", "News & Current Affairs"),
+    ("aljazeera.com", "News & Current Affairs"),
+    ("reuters.com", "News & Current Affairs"),
+    ("apnews.com", "News & Current Affairs"),
+    ("ft.com", "News & Current Affairs"),
+    ("economist.com", "News & Current Affairs"),
+    # Social Networking
+    ("facebook.com", "Social Networking"),
+    ("instagram.com", "Social Networking"),
+    ("threads.net", "Social Networking"),
+    ("x.com", "Social Networking"),
+    ("twitter.com", "Social Networking"),
+    ("linkedin.com", "Social Networking"),
+    ("mastodon.social", "Social Networking"),
+    ("bsky.app", "Social Networking"),
+    ("blueskyweb.xyz", "Social Networking"),
+    ("bere.al", "Social Networking"),
+    ("snapchat.com", "Social Networking"),
+    ("pinterest.com", "Social Networking"),
+    # Messaging (specific subdomains before parent)
+    ("web.whatsapp.com", "Messaging"),
+    ("web.telegram.org", "Messaging"),
+    ("chat.google.com", "Messaging"),
+    ("messages.google.com", "Messaging"),
+    ("whatsapp.com", "Messaging"),
+    ("messenger.com", "Messaging"),
+    ("telegram.org", "Messaging"),
+    ("signal.org", "Messaging"),
+    ("discord.com", "Messaging"),
+    ("discordapp.com", "Messaging"),
+    ("slack.com", "Messaging"),
+    # Email (specific subdomains before parent)
+    ("mail.google.com", "Email"),
+    ("icloud.com/mail", "Email"),
+    ("zoho.com/mail", "Email"),
+    ("outlook.live.com", "Email"),
+    ("outlook.office.com", "Email"),
+    ("outlook.office365.com", "Email"),
+    ("gmail.com", "Email"),
+    ("hotmail.com", "Email"),
+    ("live.com", "Email"),
+    ("mail.yahoo.com", "Email"),
+    ("proton.me", "Email"),
+    ("protonmail.com", "Email"),
+    ("fastmail.com", "Email"),
+    # AI Chatbots & Assistants
+    ("chat.openai.com", "AI Chatbots & Assistants"),
+    ("huggingface.co/chat", "AI Chatbots & Assistants"),
+    ("chat.mistral.ai", "AI Chatbots & Assistants"),
+    ("gemini.google.com", "AI Chatbots & Assistants"),
+    ("notebooklm.google.com", "AI Chatbots & Assistants"),
+    ("copilot.microsoft.com", "AI Chatbots & Assistants"),
+    ("bard.google.com", "AI Chatbots & Assistants"),
+    ("chatgpt.com", "AI Chatbots & Assistants"),
+    ("openai.com", "AI Chatbots & Assistants"),
+    ("claude.ai", "AI Chatbots & Assistants"),
+    ("anthropic.com", "AI Chatbots & Assistants"),
+    ("perplexity.ai", "AI Chatbots & Assistants"),
+    ("poe.com", "AI Chatbots & Assistants"),
+    ("you.com", "AI Chatbots & Assistants"),
+    ("mistral.ai", "AI Chatbots & Assistants"),
+    ("character.ai", "AI Chatbots & Assistants"),
+    ("meta.ai", "AI Chatbots & Assistants"),
+    ("grok.com", "AI Chatbots & Assistants"),
+    ("x.ai", "AI Chatbots & Assistants"),
+    # Work & Productivity
+    ("teams.microsoft.com", "Work & Productivity"),
+    ("meet.google.com", "Work & Productivity"),
+    ("calendar.google.com", "Work & Productivity"),
+    ("docs.google.com", "Work & Productivity"),
+    ("drive.google.com", "Work & Productivity"),
+    ("translate.google.com", "Work & Productivity"),
+    ("acrobat.adobe.com", "Work & Productivity"),
+    ("onedrive.live.com", "Work & Productivity"),
+    ("zoom.us", "Work & Productivity"),
+    ("office.com", "Work & Productivity"),
+    ("microsoft365.com", "Work & Productivity"),
+    ("sharepoint.com", "Work & Productivity"),
+    ("dropbox.com", "Work & Productivity"),
+    ("notion.so", "Work & Productivity"),
+    ("trello.com", "Work & Productivity"),
+    ("asana.com", "Work & Productivity"),
+    ("monday.com", "Work & Productivity"),
+    ("miro.com", "Work & Productivity"),
+    ("figma.com", "Work & Productivity"),
+    ("canva.com", "Work & Productivity"),
+    ("overleaf.com", "Work & Productivity"),
+    ("deepl.com", "Work & Productivity"),
+    ("adobe.com", "Work & Productivity"),
+    ("github.com", "Work & Productivity"),
+    ("gitlab.com", "Work & Productivity"),
+    ("bitbucket.org", "Work & Productivity"),
+    ("atlassian.net", "Work & Productivity"),
+    ("jira.com", "Work & Productivity"),
+    ("confluence.com", "Work & Productivity"),
+    # Education & Learning
+    ("stackoverflow.com", "Education & Learning"),
+    ("stackexchange.com", "Education & Learning"),
+    ("wikipedia.org", "Education & Learning"),
+    ("canvaslms.com", "Education & Learning"),
+    ("instructure.com", "Education & Learning"),
+    ("moodle.org", "Education & Learning"),
+    ("blackboard.com", "Education & Learning"),
+    ("ladok.se", "Education & Learning"),
+    ("antagning.se", "Education & Learning"),
+    ("studera.nu", "Education & Learning"),
+    ("coursera.org", "Education & Learning"),
+    ("edx.org", "Education & Learning"),
+    ("khanacademy.org", "Education & Learning"),
+    ("duolingo.com", "Education & Learning"),
+    ("quizlet.com", "Education & Learning"),
+    ("researchgate.net", "Education & Learning"),
+    ("academia.edu", "Education & Learning"),
+    ("sciencedirect.com", "Education & Learning"),
+    ("springer.com", "Education & Learning"),
+    ("wiley.com", "Education & Learning"),
+    ("tandfonline.com", "Education & Learning"),
+    ("jstor.org", "Education & Learning"),
+    ("sagepub.com", "Education & Learning"),
+    ("mdpi.com", "Education & Learning"),
+    ("frontiersin.org", "Education & Learning"),
+    # Shopping - Goods
+    ("amazon.se", "Shopping - Goods"),
+    ("amazon.com", "Shopping - Goods"),
+    ("blocket.se", "Shopping - Goods"),
+    ("tradera.com", "Shopping - Goods"),
+    ("prisjakt.nu", "Shopping - Goods"),
+    ("pricerunner.se", "Shopping - Goods"),
+    ("elgiganten.se", "Shopping - Goods"),
+    ("netonnet.se", "Shopping - Goods"),
+    ("mediamarkt.se", "Shopping - Goods"),
+    ("inet.se", "Shopping - Goods"),
+    ("webhallen.com", "Shopping - Goods"),
+    ("komplett.se", "Shopping - Goods"),
+    ("zalando.se", "Shopping - Goods"),
+    ("hm.com", "Shopping - Goods"),
+    ("arket.com", "Shopping - Goods"),
+    ("cos.com", "Shopping - Goods"),
+    ("ellos.se", "Shopping - Goods"),
+    ("boozt.com", "Shopping - Goods"),
+    ("ikea.com", "Shopping - Goods"),
+    ("clasohlson.com", "Shopping - Goods"),
+    ("jula.se", "Shopping - Goods"),
+    ("biltema.se", "Shopping - Goods"),
+    ("apotea.se", "Shopping - Goods"),
+    ("apoteket.se", "Shopping - Goods"),
+    ("kronansapotek.se", "Shopping - Goods"),
+    ("lyko.com", "Shopping - Goods"),
+    ("cdon.se", "Shopping - Goods"),
+    ("adlibris.com", "Shopping - Goods"),
+    ("bokus.com", "Shopping - Goods"),
+    ("wish.com", "Shopping - Goods"),
+    ("temu.com", "Shopping - Goods"),
+    ("aliexpress.com", "Shopping - Goods"),
+    # Shopping - Groceries & Food
+    ("ica.se", "Shopping - Groceries & Food"),
+    ("coop.se", "Shopping - Groceries & Food"),
+    ("willys.se", "Shopping - Groceries & Food"),
+    ("hemkop.se", "Shopping - Groceries & Food"),
+    ("citygross.se", "Shopping - Groceries & Food"),
+    ("mathem.se", "Shopping - Groceries & Food"),
+    ("foodora.se", "Shopping - Groceries & Food"),
+    ("wolt.com", "Shopping - Groceries & Food"),
+    ("ubereats.com", "Shopping - Groceries & Food"),
+    ("hellofresh.se", "Shopping - Groceries & Food"),
+    ("linasmatkasse.se", "Shopping - Groceries & Food"),
+    ("simplefeast.com", "Shopping - Groceries & Food"),
+    ("kavall.co", "Shopping - Groceries & Food"),
+    # Banking & Finance
+    ("swedbank.se", "Banking & Finance"),
+    ("seb.se", "Banking & Finance"),
+    ("handelsbanken.se", "Banking & Finance"),
+    ("nordea.se", "Banking & Finance"),
+    ("lansforsakringar.se", "Banking & Finance"),
+    ("skandia.se", "Banking & Finance"),
+    ("ica-banken.se", "Banking & Finance"),
+    ("danskebank.se", "Banking & Finance"),
+    ("avanza.se", "Banking & Finance"),
+    ("nordnet.se", "Banking & Finance"),
+    ("klarna.com", "Banking & Finance"),
+    ("paypal.com", "Banking & Finance"),
+    ("revolut.com", "Banking & Finance"),
+    ("wise.com", "Banking & Finance"),
+    ("zettle.com", "Banking & Finance"),
+    ("fortnox.se", "Banking & Finance"),
+    ("vismaspcs.se", "Banking & Finance"),
+    # Public Services
+    ("skatteverket.se", "Public Services"),
+    ("forsakringskassan.se", "Public Services"),
+    ("pensionsmyndigheten.se", "Public Services"),
+    ("arbetsformedlingen.se", "Public Services"),
+    ("1177.se", "Public Services"),
+    ("bankid.com", "Public Services"),
+    ("minmyndighetspost.se", "Public Services"),
+    ("kivra.se", "Public Services"),
+    ("polisen.se", "Public Services"),
+    ("migrationsverket.se", "Public Services"),
+    ("csn.se", "Public Services"),
+    ("trafikverket.se", "Public Services"),
+    ("transportstyrelsen.se", "Public Services"),
+    ("lantmateriet.se", "Public Services"),
+    ("bolagsverket.se", "Public Services"),
+    ("verksamt.se", "Public Services"),
+    ("stockholm.se", "Public Services"),
+    ("goteborg.se", "Public Services"),
+    ("malmo.se", "Public Services"),
+    ("uppsala.se", "Public Services"),
+    ("umea.se", "Public Services"),
+    ("lund.se", "Public Services"),
+    # Games
+    ("steampowered.com", "Games"),
+    ("steamcommunity.com", "Games"),
+    ("epicgames.com", "Games"),
+    ("battle.net", "Games"),
+    ("xbox.com", "Games"),
+    ("playstation.com", "Games"),
+    ("roblox.com", "Games"),
+    ("minecraft.net", "Games"),
+    ("chess.com", "Games"),
+    ("lichess.org", "Games"),
+    ("poki.com", "Games"),
+    ("miniclip.com", "Games"),
+    ("agar.io", "Games"),
+    ("forgeofempires.com", "Games"),
+    ("catanuniverse.com", "Games"),
+    # --- TITLE KEYWORDS (ordered by TITLE_RULES priority) ---
+    # Sensitive / Excluded title keywords
+    ("svenska spel", "Sensitive / Excluded"),
+    ("unibet", "Sensitive / Excluded"),
+    ("bet365", "Sensitive / Excluded"),
+    ("pokerstars", "Sensitive / Excluded"),
+    ("casino", "Sensitive / Excluded"),
+    ("tinder", "Sensitive / Excluded"),
+    ("bumble", "Sensitive / Excluded"),
+    ("grindr", "Sensitive / Excluded"),
+    # Travel & Mobility title keywords
+    ("google maps", "Travel & Mobility"),
+    ("sl reseplanerare", "Travel & Mobility"),
+    ("vasttraffik", "Travel & Mobility"),
+    ("vasttrafik", "Travel & Mobility"),
+    ("skanetrafiken", "Travel & Mobility"),
+    ("resrobot", "Travel & Mobility"),
+    ("ryanair", "Travel & Mobility"),
+    ("booking.com", "Travel & Mobility"),
+    ("airbnb", "Travel & Mobility"),
+    ("hotels.com", "Travel & Mobility"),
+    ("tripadvisor", "Travel & Mobility"),
+    ("easypark", "Travel & Mobility"),
+    ("parkster", "Travel & Mobility"),
+    ("norwegian", "Travel & Mobility"),
+    ("flixbus", "Travel & Mobility"),
+    ("uber eats", "Shopping - Groceries & Food"),
+    ("uber", "Travel & Mobility"),
+    ("bolt", "Travel & Mobility"),
+    ("maps", "Travel & Mobility"),
+    ("sas", "Travel & Mobility"),
+    ("sj", "Travel & Mobility"),
+    # Shopping - Groceries title keywords
+    ("ica", "Shopping - Groceries & Food"),
+    ("coop", "Shopping - Groceries & Food"),
+    ("willys", "Shopping - Groceries & Food"),
+    ("hemköp", "Shopping - Groceries & Food"),
+    ("hemkop", "Shopping - Groceries & Food"),
+    ("city gross", "Shopping - Groceries & Food"),
+    ("mathem", "Shopping - Groceries & Food"),
+    ("foodora", "Shopping - Groceries & Food"),
+    ("wolt", "Shopping - Groceries & Food"),
+    ("hellofresh", "Shopping - Groceries & Food"),
+    ("linas matkasse", "Shopping - Groceries & Food"),
+    # Shopping - Goods title keywords
+    ("amazon", "Shopping - Goods"),
+    ("blocket", "Shopping - Goods"),
+    ("tradera", "Shopping - Goods"),
+    ("prisjakt", "Shopping - Goods"),
+    ("pricerunner", "Shopping - Goods"),
+    ("elgiganten", "Shopping - Goods"),
+    ("netonnet", "Shopping - Goods"),
+    ("webhallen", "Shopping - Goods"),
+    ("komplett", "Shopping - Goods"),
+    ("zalando", "Shopping - Goods"),
+    ("h&m", "Shopping - Goods"),
+    ("ikea", "Shopping - Goods"),
+    ("clas ohlson", "Shopping - Goods"),
+    ("jula", "Shopping - Goods"),
+    ("biltema", "Shopping - Goods"),
+    ("apotea", "Shopping - Goods"),
+    ("apoteket", "Shopping - Goods"),
+    ("kronans apotek", "Shopping - Goods"),
+    ("lyko", "Shopping - Goods"),
+    ("cdon", "Shopping - Goods"),
+    ("adlibris", "Shopping - Goods"),
+    ("bokus", "Shopping - Goods"),
+    ("temu", "Shopping - Goods"),
+    ("aliexpress", "Shopping - Goods"),
+    ("inet", "Shopping - Goods"),
+    # Banking & Finance title keywords
+    ("swedbank", "Banking & Finance"),
+    ("handelsbanken", "Banking & Finance"),
+    ("länsförsäkringar", "Banking & Finance"),
+    ("lansforsakringar", "Banking & Finance"),
+    ("skandia", "Banking & Finance"),
+    ("ica banken", "Banking & Finance"),
+    ("avanza", "Banking & Finance"),
+    ("nordnet", "Banking & Finance"),
+    ("klarna", "Banking & Finance"),
+    ("paypal", "Banking & Finance"),
+    ("revolut", "Banking & Finance"),
+    ("wise", "Banking & Finance"),
+    ("fortnox", "Banking & Finance"),
+    ("visma", "Banking & Finance"),
+    ("nordea", "Banking & Finance"),
+    ("seb", "Banking & Finance"),
+    # Public Services title keywords
+    ("skatteverket", "Public Services"),
+    ("försäkringskassan", "Public Services"),
+    ("forsakringskassan", "Public Services"),
+    ("pensionsmyndigheten", "Public Services"),
+    ("arbetsförmedlingen", "Public Services"),
+    ("arbetsformedlingen", "Public Services"),
+    ("1177", "Public Services"),
+    ("bankid", "Public Services"),
+    ("min myndighetspost", "Public Services"),
+    ("kivra", "Public Services"),
+    ("polisen", "Public Services"),
+    ("migrationsverket", "Public Services"),
+    ("csn", "Public Services"),
+    ("trafikverket", "Public Services"),
+    ("transportstyrelsen", "Public Services"),
+    ("lantmäteriet", "Public Services"),
+    ("lantmateriet", "Public Services"),
+    ("bolagsverket", "Public Services"),
+    ("verksamt", "Public Services"),
+    # AI Chatbots & Assistants title keywords
+    ("chatgpt", "AI Chatbots & Assistants"),
+    ("chat.openai", "AI Chatbots & Assistants"),
+    ("openai", "AI Chatbots & Assistants"),
+    ("claude", "AI Chatbots & Assistants"),
+    ("anthropic", "AI Chatbots & Assistants"),
+    ("gemini", "AI Chatbots & Assistants"),
+    ("google gemini", "AI Chatbots & Assistants"),
+    ("bard", "AI Chatbots & Assistants"),
+    ("microsoft copilot", "AI Chatbots & Assistants"),
+    ("copilot", "AI Chatbots & Assistants"),
+    ("perplexity", "AI Chatbots & Assistants"),
+    ("poe", "AI Chatbots & Assistants"),
+    ("you.com", "AI Chatbots & Assistants"),
+    ("mistral", "AI Chatbots & Assistants"),
+    ("le chat", "AI Chatbots & Assistants"),
+    ("huggingchat", "AI Chatbots & Assistants"),
+    ("hugging face chat", "AI Chatbots & Assistants"),
+    ("character.ai", "AI Chatbots & Assistants"),
+    ("meta ai", "AI Chatbots & Assistants"),
+    ("grok", "AI Chatbots & Assistants"),
+    ("notebooklm", "AI Chatbots & Assistants"),
+    ("notebook lm", "AI Chatbots & Assistants"),
+    # Work & Productivity title keywords
+    ("microsoft teams", "Work & Productivity"),
+    ("teams", "Work & Productivity"),
+    ("zoom", "Work & Productivity"),
+    ("google meet", "Work & Productivity"),
+    ("google calendar", "Work & Productivity"),
+    ("google kalender", "Work & Productivity"),
+    ("google docs", "Work & Productivity"),
+    ("google dokument", "Work & Productivity"),
+    ("google sheets", "Work & Productivity"),
+    ("google kalkylark", "Work & Productivity"),
+    ("google slides", "Work & Productivity"),
+    ("google presentationer", "Work & Productivity"),
+    ("google drive", "Work & Productivity"),
+    ("office", "Work & Productivity"),
+    ("microsoft 365", "Work & Productivity"),
+    ("onedrive", "Work & Productivity"),
+    ("sharepoint", "Work & Productivity"),
+    ("dropbox", "Work & Productivity"),
+    ("notion", "Work & Productivity"),
+    ("trello", "Work & Productivity"),
+    ("asana", "Work & Productivity"),
+    ("miro", "Work & Productivity"),
+    ("figma", "Work & Productivity"),
+    ("canva", "Work & Productivity"),
+    ("overleaf", "Work & Productivity"),
+    ("deepl", "Work & Productivity"),
+    ("google translate", "Work & Productivity"),
+    ("adobe", "Work & Productivity"),
+    ("acrobat", "Work & Productivity"),
+    ("github", "Work & Productivity"),
+    ("gitlab", "Work & Productivity"),
+    ("jira", "Work & Productivity"),
+    ("confluence", "Work & Productivity"),
+    # Education & Learning title keywords
+    ("wikipedia", "Education & Learning"),
+    ("canvas", "Education & Learning"),
+    ("moodle", "Education & Learning"),
+    ("blackboard", "Education & Learning"),
+    ("ladok", "Education & Learning"),
+    ("antagning", "Education & Learning"),
+    ("coursera", "Education & Learning"),
+    ("edx", "Education & Learning"),
+    ("khan academy", "Education & Learning"),
+    ("duolingo", "Education & Learning"),
+    ("quizlet", "Education & Learning"),
+    ("stack overflow", "Education & Learning"),
+    ("stackexchange", "Education & Learning"),
+    ("researchgate", "Education & Learning"),
+    ("sciencedirect", "Education & Learning"),
+    ("springer", "Education & Learning"),
+    ("wiley", "Education & Learning"),
+    ("jstor", "Education & Learning"),
+    ("sage journals", "Education & Learning"),
+    ("taylor & francis", "Education & Learning"),
+    # Email title keywords
+    ("gmail", "Email"),
+    ("outlook", "Email"),
+    ("hotmail", "Email"),
+    ("yahoo mail", "Email"),
+    ("proton mail", "Email"),
+    ("protonmail", "Email"),
+    ("icloud mail", "Email"),
+    ("zoho mail", "Email"),
+    ("fastmail", "Email"),
+    # Messaging title keywords
+    ("whatsapp", "Messaging"),
+    ("messenger", "Messaging"),
+    ("telegram", "Messaging"),
+    ("signal", "Messaging"),
+    ("discord", "Messaging"),
+    ("slack", "Messaging"),
+    ("google chat", "Messaging"),
+    ("messages", "Messaging"),
+    # Social Networking title keywords
+    ("facebook", "Social Networking"),
+    ("instagram", "Social Networking"),
+    ("threads", "Social Networking"),
+    ("twitter", "Social Networking"),
+    ("x.com", "Social Networking"),
+    ("linkedin", "Social Networking"),
+    ("mastodon", "Social Networking"),
+    ("bluesky", "Social Networking"),
+    ("bsky", "Social Networking"),
+    ("bereal", "Social Networking"),
+    ("snapchat", "Social Networking"),
+    ("pinterest", "Social Networking"),
+    # Video Streaming title keywords
+    ("youtube", "Video Streaming"),
+    ("netflix", "Video Streaming"),
+    ("svt play", "Video Streaming"),
+    ("tv4 play", "Video Streaming"),
+    ("viaplay", "Video Streaming"),
+    ("hbo max", "Video Streaming"),
+    ("hbomax", "Video Streaming"),
+    ("prime video", "Video Streaming"),
+    ("disney+", "Video Streaming"),
+    ("disney plus", "Video Streaming"),
+    ("twitch", "Video Streaming"),
+    ("vimeo", "Video Streaming"),
+    ("dailymotion", "Video Streaming"),
+    # Music & Audio title keywords
+    ("spotify", "Music & Audio"),
+    ("apple music", "Music & Audio"),
+    ("youtube music", "Music & Audio"),
+    ("soundcloud", "Music & Audio"),
+    ("podme", "Music & Audio"),
+    ("bookbeat", "Music & Audio"),
+    ("storytel", "Music & Audio"),
+    ("audible", "Music & Audio"),
+    ("sveriges radio", "Music & Audio"),
+    ("sr play", "Music & Audio"),
+    # Games title keywords
+    ("steam", "Games"),
+    ("epic games", "Games"),
+    ("battle.net", "Games"),
+    ("xbox", "Games"),
+    ("playstation", "Games"),
+    ("roblox", "Games"),
+    ("minecraft", "Games"),
+    ("chess.com", "Games"),
+    ("lichess", "Games"),
+    ("poki", "Games"),
+    ("miniclip", "Games"),
+    ("agar.io", "Games"),
+    ("forge of empires", "Games"),
+    ("catan universe", "Games"),
+    # News & Current Affairs title keywords (after Video keywords — "svt" is safe here)
+    ("svt nyheter", "News & Current Affairs"),
+    ("svt", "News & Current Affairs"),
+    ("dn.se", "News & Current Affairs"),
+    ("dagens nyheter", "News & Current Affairs"),
+    ("svd", "News & Current Affairs"),
+    ("svenska dagbladet", "News & Current Affairs"),
+    ("aftonbladet", "News & Current Affairs"),
+    ("expressen", "News & Current Affairs"),
+    ("göteborgs-posten", "News & Current Affairs"),
+    ("goteborgs-posten", "News & Current Affairs"),
+    ("gp.se", "News & Current Affairs"),
+    ("sydsvenskan", "News & Current Affairs"),
+    ("omni", "News & Current Affairs"),
+    ("dagens industri", "News & Current Affairs"),
+    ("di.se", "News & Current Affairs"),
+    ("breakit", "News & Current Affairs"),
+    ("ny teknik", "News & Current Affairs"),
+    ("the guardian", "News & Current Affairs"),
+    ("bbc", "News & Current Affairs"),
+    ("cnn", "News & Current Affairs"),
+    ("al jazeera", "News & Current Affairs"),
+    ("reuters", "News & Current Affairs"),
+    # Search & Navigation title keywords (last — very broad)
+    ("google search", "Search & Navigation"),
+    ("google", "Search & Navigation"),
+    ("bing", "Search & Navigation"),
+    ("duckduckgo", "Search & Navigation"),
+    ("yahoo search", "Search & Navigation"),
+    ("startpage", "Search & Navigation"),
+    ("ecosia", "Search & Navigation"),
+    ("kagi", "Search & Navigation"),
+]
+
+# App-name → study category aliases used by the web UI preset.
+# Faithfully derived from Matthias Lehner's APP_TO_CATEGORY dict (classifier 2026-07-06).
+# These aliases are not injected into the watcher: Research Edition storage keeps
+# the approved raw app name and removes the non-browser title and every URL.
+APP_CATEGORY_MAP: dict[str, str] = {
+    # AI chatbots & assistants
+    "chatgpt": "AI Chatbots & Assistants",
+    "chatgpt.exe": "AI Chatbots & Assistants",
+    "claude": "AI Chatbots & Assistants",
+    "claude.exe": "AI Chatbots & Assistants",
+    "gemini": "AI Chatbots & Assistants",
+    "microsoft copilot": "AI Chatbots & Assistants",
+    "copilot": "AI Chatbots & Assistants",
+    "perplexity": "AI Chatbots & Assistants",
+    "poe": "AI Chatbots & Assistants",
+    # Work & Productivity — Microsoft Office
+    "microsoft word": "Work & Productivity",
+    "word": "Work & Productivity",
+    "winword.exe": "Work & Productivity",
+    "microsoft excel": "Work & Productivity",
+    "excel": "Work & Productivity",
+    "excel.exe": "Work & Productivity",
+    "microsoft powerpoint": "Work & Productivity",
+    "powerpoint": "Work & Productivity",
+    "powerpnt.exe": "Work & Productivity",
+    # Email — Outlook is email, not productivity
+    "microsoft outlook": "Email",
+    "outlook": "Email",
+    "outlook.exe": "Email",
+    # Work & Productivity — collaboration/notes
+    "microsoft teams": "Work & Productivity",
+    "teams": "Work & Productivity",
+    "zoom": "Work & Productivity",
+    "zoom.us": "Work & Productivity",
+    "notion": "Work & Productivity",
+    "onenote": "Work & Productivity",
+    "adobe acrobat": "Work & Productivity",
+    "acrobat": "Work & Productivity",
+    "preview": "Work & Productivity",
+    "pages": "Work & Productivity",
+    "numbers": "Work & Productivity",
+    "keynote": "Work & Productivity",
+    "libreoffice": "Work & Productivity",
+    # Email — native clients
+    "mail": "Email",
+    "thunderbird": "Email",
+    # Messaging
+    "slack": "Messaging",
+    "signal": "Messaging",
+    "telegram": "Messaging",
+    "whatsapp": "Messaging",
+    "messenger": "Messaging",
+    "discord": "Messaging",
+    # Music & Audio
+    "spotify": "Music & Audio",
+    "music": "Music & Audio",
+    "apple music": "Music & Audio",
+    # Video Streaming — media players
+    "vlc": "Video Streaming",
+    "quicktime player": "Video Streaming",
+    "netflix": "Video Streaming",
+    # Games
+    "steam": "Games",
+    "epic games launcher": "Games",
+    "battle.net": "Games",
+    "roblox": "Games",
+    "minecraft": "Games",
+    "xbox": "Games",
+    # Work & Productivity — creative/professional software
+    "photoshop": "Work & Productivity",
+    "illustrator": "Work & Productivity",
+    "indesign": "Work & Productivity",
+    "lightroom": "Work & Productivity",
+    "premiere pro": "Work & Productivity",
+    "final cut pro": "Work & Productivity",
+    "figma": "Work & Productivity",
+    "blender": "Work & Productivity",
+    "autocad": "Work & Productivity",
+    # System utilities — explicitly excluded (not a lookup miss)
+    "finder": "Excluded",
+    "explorer": "Excluded",
+    "explorer.exe": "Excluded",
+    "system settings": "Excluded",
+    "settings": "Excluded",
+    "system preferences": "Excluded",
+    "terminal": "Excluded",
+    "cmd.exe": "Excluded",
+    "powershell.exe": "Excluded",
+}
+
+
+def build_toml_table(entries: list[tuple[str, str]]) -> str:
+    seen: dict[str, str] = {}
+    for pattern, category in entries:
+        if pattern not in seen:
+            seen[pattern] = category
+    items = []
+    for pattern, category in seen.items():
+        k = pattern.replace("\\", "\\\\").replace('"', '\\"')
+        v = category.replace("\\", "\\\\").replace('"', '\\"')
+        items.append(f'"{k}" = "{v}"')
+    return "\n".join(items)
+
+
+# The flag must be matched anchored to line start. Since aw-watcher-window #137,
+# config.py also documents the rewrite in a comment containing the literal text
+# `sed -i 's/^research_enabled = false$/research_enabled = true/'`, and that
+# comment appears *before* the real flag. An unanchored replace would patch the
+# comment and leave research_enabled = false -- a green build with the Research
+# Edition silently disabled.
+ENABLED_FLAG_RE = re.compile(r"^research_enabled = false$", re.MULTILINE)
+
+# Anchor for the post-#137 layout: research knobs moved out of `default_config`
+# into a separate template so they are not persisted into every fresh install's
+# config file.
+RESEARCH_DEFAULTS_ANCHOR = 'research_defaults = """'
+
+
+def patch_config(text: str) -> str:
+    """Enable browser categorization while leaving the watcher app map empty."""
+    category_header = "[aw-watcher-window.research_category_map]"
+
+    enabled_matches = len(ENABLED_FLAG_RE.findall(text))
+    if enabled_matches != 1:
+        raise ValueError(
+            f"expected exactly one line-anchored 'research_enabled = false', found {enabled_matches}"
+        )
+
+    entries = build_toml_table(CATEGORY_MAP)
+
+    if RESEARCH_DEFAULTS_ANCHOR in text:
+        # Post-#137: the browser map belongs inside `research_defaults`.
+        # That template is parsed standalone and merged into the
+        # [aw-watcher-window] section key-by-key, so its table headers must NOT
+        # carry the section prefix.
+        # Include an empty [research_app_category_map] so that an upgrade from
+        # an earlier Research Edition (which may have had this map populated)
+        # explicitly clears it via the key-by-key merge. Without this, a prior
+        # non-empty app map survives the upgrade and continues replacing app
+        # names with categories, defeating the primary behavior change.
+        block = f"research_enabled = true\n\n[research_category_map]\n{entries}\n\n[research_app_category_map]"
+        patched = ENABLED_FLAG_RE.sub(lambda _: block, text, count=1)
+    else:
+        # Pre-#137: the section-prefixed browser header is already present in
+        # `default_config`; leave the app-map section empty.
+        if text.count(category_header) != 1:
+            raise ValueError(f"expected exactly one '{category_header}' section")
+        patched = ENABLED_FLAG_RE.sub("research_enabled = true", text, count=1)
+        patched = patched.replace(category_header, f"{category_header}\n{entries}", 1)
+
+    return patched
+
+
+def main() -> None:
+    if not CONFIG_FILE.exists():
+        print(f"Error: {CONFIG_FILE} not found", file=sys.stderr)
+        sys.exit(1)
+    text = CONFIG_FILE.read_text(encoding="utf-8")
+    try:
+        patched = patch_config(text)
+    except ValueError as error:
+        print(f"Error: {error} in {CONFIG_FILE}", file=sys.stderr)
+        sys.exit(1)
+    CONFIG_FILE.write_text(patched, encoding="utf-8")
+    unique = len({p for p, _ in CATEGORY_MAP})
+    cats = len({c for _, c in CATEGORY_MAP})
+    print(
+        f"Injected {unique} unique patterns across {cats} categories into {CONFIG_FILE}"
+    )
+    print("Preserving application names; watcher app-category map left empty")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/patch_research_edition_export.py b/scripts/patch_research_edition_export.py
new file mode 100644
index 0000000..2f5cb35
--- /dev/null
+++ b/scripts/patch_research_edition_export.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+"""Patch aw-server-rust export endpoints for the Research Edition build.
+
+Run as part of the CI build for research edition:
+
+    python3 scripts/patch_research_edition_export.py [repo-root]
+
+Standard builds never run this script, so `/api/0/export` stays byte-for-byte
+unchanged outside Research Edition. The patch is fail-closed: missing markers
+abort the build rather than shipping an unsanitized artifact.
+"""
+from __future__ import annotations
+
+import pathlib
+import shutil
+import sys
+
+MARKER = "RESEARCH_EDITION_EXPORT_SANITIZE"
+
+EXPORT_INSERT_NEEDLE = """        export.buckets.insert(bid, bucket);
+    }
+
+    Ok(export.into())
+"""
+
+EXPORT_INSERT_REPLACEMENT = f"""        export.buckets.insert(bid, bucket);
+    }}
+
+    // {MARKER}
+    let export = match super::export_sanitize::sanitize_buckets_export(export) {{
+        Ok(export) => export,
+        Err(err) => {{
+            return Err(HttpErrorJson::new(rocket::http::Status::Conflict, err))
+        }}
+    }};
+    Ok(export.into())
+"""
+
+BUCKET_INSERT_NEEDLE = """    export.buckets.insert(bucket_id.into(), bucket);
+
+    Ok(export.into())
+"""
+
+BUCKET_INSERT_REPLACEMENT = f"""    export.buckets.insert(bucket_id.into(), bucket);
+
+    // {MARKER}
+    let export = match super::export_sanitize::sanitize_buckets_export(export) {{
+        Ok(export) => export,
+        Err(err) => return Err(HttpErrorJson::new(Status::Conflict, err)),
+    }};
+    Ok(export.into())
+"""
+
+MOD_NEEDLE = "mod export;\n"
+MOD_REPLACEMENT = "mod export;\nmod export_sanitize;\n"
+
+
+def repo_root_from_args(argv: list[str]) -> pathlib.Path:
+    if len(argv) > 1:
+        return pathlib.Path(argv[1]).resolve()
+    return pathlib.Path.cwd().resolve()
+
+
+def _replace_once(path: pathlib.Path, needle: str, replacement: str, already_ok: str) -> None:
+    text = path.read_text(encoding="utf-8")
+    if already_ok in text:
+        return
+    count = text.count(needle)
+    if count != 1:
+        raise ValueError(
+            f"{path}: expected exactly one export-sanitizer insertion point, found {count}"
+        )
+    path.write_text(text.replace(needle, replacement, 1), encoding="utf-8")
+
+
+def patch_tree(repo_root: pathlib.Path) -> None:
+    script_dir = pathlib.Path(__file__).resolve().parent
+    source = script_dir / "research_edition" / "export_sanitize.rs"
+    if not source.is_file():
+        raise FileNotFoundError(f"missing sanitizer module: {source}")
+
+    endpoints = (
+        repo_root / "aw-server-rust" / "aw-server" / "src" / "endpoints"
+    )
+    export_rs = endpoints / "export.rs"
+    bucket_rs = endpoints / "bucket.rs"
+    mod_rs = endpoints / "mod.rs"
+    dest = endpoints / "export_sanitize.rs"
+
+    for required in (export_rs, bucket_rs, mod_rs):
+        if not required.is_file():
+            raise FileNotFoundError(f"expected Rust export source at {required}")
+
+    shutil.copyfile(source, dest)
+
+    _replace_once(mod_rs, MOD_NEEDLE, MOD_REPLACEMENT, "mod export_sanitize;")
+    _replace_once(export_rs, EXPORT_INSERT_NEEDLE, EXPORT_INSERT_REPLACEMENT, MARKER)
+    _replace_once(bucket_rs, BUCKET_INSERT_NEEDLE, BUCKET_INSERT_REPLACEMENT, MARKER)
+
+
+def main() -> None:
+    repo_root = repo_root_from_args(sys.argv)
+    try:
+        patch_tree(repo_root)
+    except (OSError, ValueError) as error:
+        print(f"Error: {error}", file=sys.stderr)
+        sys.exit(1)
+    print(f"Patched Research Edition export sanitizer under {repo_root / 'aw-server-rust'}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/patch_research_edition_profile.py b/scripts/patch_research_edition_profile.py
new file mode 100644
index 0000000..cb38bf5
--- /dev/null
+++ b/scripts/patch_research_edition_profile.py
@@ -0,0 +1,737 @@
+#!/usr/bin/env python3
+"""Bake the Research Edition profile identity into the bundle at release time.
+
+The research profile is *build identity*, not a launch argument. A login item
+that passes ``--profile research`` misses double-click, Spotlight, and the
+updater relaunch, and every one of those would silently start ``default`` and
+pollute a participant's standard install. So the research build patches the
+no-information fallbacks in the sources it ships, before they are compiled or
+collected:
+
+1. **Profile**: every "no ``--profile``, no ``AW_PROFILE``" fallback resolves to
+   ``research`` instead of ``default``. Both launchers (aw-qt, aw-tauri), both
+   servers, and aw-client (the watchers' library) are patched, so any binary in
+   the bundle defaults to the research instance no matter how it was started.
+   The launchers then *set* ``AW_PROFILE=research`` for their children, and
+   aw-core/aw-tauri dirs put everything under ``activitywatch-research/``.
+
+   ``DEFAULT_PROFILE`` itself is deliberately **not** flipped: it means "the
+   ordinary install" in suffix/dir/lockfile/``export_profile`` logic, and
+   flipping it would make the research build share dirs and lockfile with a
+   standard install — the exact collision this patch exists to prevent.
+
+2. **Port**: the built-in 5600 default becomes 5667 in both servers, in
+   aw-client, and in aw-qt's tray/manager fallbacks, so a fresh research
+   profile binds 5667 without any config file existing yet.
+
+3. **macOS bundle identity**: ``CFBundleIdentifier`` becomes
+   ``net.activitywatch.ActivityWatch-research`` (Tauri: ``net.activitywatch.tauri-research``)
+   with ``CFBundleName`` "ActivityWatch Research" *and* on-disk bundle
+   ``ActivityWatch-Research.app``, so dual-run next to a standard install is a
+   distinct LaunchServices identity that does not overwrite
+   ``/Applications/ActivityWatch.app``.
+
+4. **Install identity**: Windows gets its own Inno ``AppId``, install dir,
+   shortcuts and uninstall entry; Linux gets its own deb package name,
+   ``/opt/activitywatch-research`` tree and desktop-entry filename. Bundle ids
+   split LaunchServices, not the on-disk product -- without this a research
+   installer *replaces* a participant's standard install.
+
+5. **First-run autostart**: aw-qt writes its own login item / Startup shortcut /
+   autostart ``.desktop``, under names independent of the installer's. Those are
+   rebranded too, or the two editions overwrite each other's autostart even with
+   every other identity split.
+
+Usage (from the repository root, after ``make test``, before ``make package``):
+
+    python3 scripts/patch_research_edition_profile.py qt      # build-qt jobs
+    python3 scripts/patch_research_edition_profile.py tauri   # build-tauri job
+    python3 scripts/patch_research_edition_profile.py qt --check   # verify only
+
+Every patch is fail-closed: if a target string is missing or ambiguous, the
+script exits non-zero and the research build must not ship — a silent miss
+here means a bundle that says "Research Edition" but runs ``profile=default``
+on port 5600. Bump the submodule pin or update the table; never skip.
+
+Run this **after** the module test suites: they assert the ordinary defaults
+(``resolve_profile(None) == "default"``, port 5600) and would fail on the
+patched tree. Python modules are editable installs, so PyInstaller collects
+the patched source at package time; Rust binaries need a rebuild afterwards.
+"""
+
+from __future__ import annotations
+
+import argparse
+import pathlib
+import sys
+from dataclasses import dataclass
+from typing import Iterable, List, Sequence
+
+RESEARCH_PROFILE = "research"
+RESEARCH_PORT = 5667
+BUNDLE_ID = "net.activitywatch.ActivityWatch-research"
+TAURI_IDENTIFIER = "net.activitywatch.tauri-research"
+BUNDLE_NAME = "ActivityWatch Research"
+# Hyphenated on-disk stem so Makefile / notarize / CI paths stay unquoted.
+# Dock and Spotlight still show CFBundleName ("ActivityWatch Research").
+BUNDLE_DIR_STEM = "ActivityWatch-Research"
+
+# Windows install identity. Inno Setup keys off AppId, not the display name: a
+# research setup sharing the standard AppId registers as the *same* product, so
+# it upgrades over an existing install and its uninstaller removes both. Own
+# GUIDs give the research build its own install dir, Start-Menu/desktop/startup
+# shortcuts and "Apps & features" entry, which is what dual-run requires.
+WINDOWS_APPID_QT = "32024B9B-352E-4E97-AA56-9EEF143E5B70"
+WINDOWS_APPID_TAURI = "70E2D4AB-8DA2-4BE0-8391-AB5B48653773"
+# MSI upgrade code. Tauri derives this from `identifier` when unset, so the
+# research build already differs today via the patched identifier — but that
+# makes installer identity a silent side effect of an unrelated string. Pinning
+# it means a future identifier change cannot collapse research MSIs onto the
+# standard upgrade family. WixConfig exposes no product-code field (Tauri
+# generates one per build); the upgrade code is what defines the product family,
+# so it is the one that matters here.
+WINDOWS_WIX_UPGRADE_CODE_TAURI = "ABF0AB0C-5BA0-4C2C-BF3E-797B2E1913DA"
+
+# Linux install identity. The Qt deb is a single product on master:
+# `Package: activitywatch`, `/opt/activitywatch`, and one `aw-qt.desktop`
+# filename in both /etc/xdg/autostart and /usr/share/applications. Installing a
+# research deb next to a standard one therefore *replaces* it (dpkg treats a
+# same-named package as an upgrade) and its autostart entry overwrites the
+# standard one. Own package name, own /opt tree and own desktop-entry filename
+# is what makes the two editions co-installable.
+LINUX_PACKAGE = "activitywatch-research"
+LINUX_OPT_DIR = f"/opt/{LINUX_PACKAGE}"
+LINUX_DESKTOP_FILENAME = f"{LINUX_PACKAGE}.desktop"
+# Desktop-entry `Icon=` id. Only the AppImage actually installs an icon under
+# this name (`linuxdeploy --icon-filename`); the deb ships none today. Patch
+# both together so they cannot drift apart.
+LINUX_ICON_ID = LINUX_PACKAGE
+# macOS LaunchAgent label written by aw-qt's own first-run autostart (distinct
+# from the .app bundle id, which the installer/LaunchServices own).
+LAUNCH_AGENT_LABEL = "net.activitywatch.aw-qt-research"
+
+
+@dataclass(frozen=True)
+class Patch:
+    """One exact, unique substring replacement in one file."""
+
+    path: str
+    old: str
+    new: str
+    why: str
+
+
+# --- profile fallbacks ------------------------------------------------------
+
+_PY_FALLBACK = "        return TESTING_PROFILE if testing else DEFAULT_PROFILE\n"
+_PY_FALLBACK_NEW = "        return TESTING_PROFILE if testing else BUILD_PROFILE\n"
+
+
+def _python_profile_patches(path: str) -> List[Patch]:
+    """aw-qt / aw-server / aw-client share one profile module layout.
+
+    Three no-information fallbacks return ``DEFAULT_PROFILE``: the ``None``
+    branch of ``resolve_profile`` and the unset/invalid branches of
+    ``profile_from_env``. All three become ``BUILD_PROFILE``.
+    """
+    return [
+        Patch(
+            path,
+            'DEFAULT_PROFILE = "default"\nTESTING_PROFILE = "testing"\n',
+            'DEFAULT_PROFILE = "default"\nTESTING_PROFILE = "testing"\n'
+            "#: Research Edition build identity (baked at release time). Used only\n"
+            "#: as the no-flag/no-env fallback; DEFAULT_PROFILE still means the\n"
+            "#: ordinary install for suffix/dir/lockfile/export logic.\n"
+            f'BUILD_PROFILE = "{RESEARCH_PROFILE}"\n',
+            "declare BUILD_PROFILE next to DEFAULT_PROFILE",
+        ),
+        Patch(
+            path,
+            "    if profile is None:\n" + _PY_FALLBACK,
+            "    if profile is None:\n" + _PY_FALLBACK_NEW,
+            "resolve_profile(None) falls back to the build profile",
+        ),
+        Patch(
+            path,
+            "    if not profile:\n" + _PY_FALLBACK,
+            "    if not profile:\n" + _PY_FALLBACK_NEW,
+            "profile_from_env with AW_PROFILE unset falls back to the build profile",
+        ),
+        Patch(
+            path,
+            "    except ValueError:\n" + _PY_FALLBACK,
+            "    except ValueError:\n" + _PY_FALLBACK_NEW,
+            "profile_from_env with an invalid AW_PROFILE falls back to the build profile",
+        ),
+    ]
+
+
+PROFILE_PATCHES_QT: List[Patch] = [
+    *_python_profile_patches("aw-qt/aw_qt/profile.py"),
+    *_python_profile_patches("aw-server/aw_server/profile.py"),
+    *_python_profile_patches("aw-client/aw_client/profile.py"),
+    Patch(
+        "aw-server-rust/aw-server/src/main.rs",
+        '                } else {\n                    "default".to_string()\n                }\n',
+        f'                }} else {{\n                    "{RESEARCH_PROFILE}".to_string()\n                }}\n',
+        "aw-server-rust with no --profile and no AW_PROFILE runs the build profile",
+    ),
+]
+
+PROFILE_PATCHES_TAURI: List[Patch] = [
+    # Watchers in the Tauri bundle are the same Python modules.
+    *_python_profile_patches("aw-client/aw_client/profile.py"),
+    Patch(
+        "aw-tauri/src-tauri/src/profile.rs",
+        'pub const DEFAULT_PROFILE: &str = "default";\n',
+        'pub const DEFAULT_PROFILE: &str = "default";\n'
+        "/// Research Edition build identity (baked at release time). Only the\n"
+        "/// no-flag/no-env fallback; DEFAULT_PROFILE still means the ordinary install.\n"
+        f'pub const BUILD_PROFILE: &str = "{RESEARCH_PROFILE}";\n',
+        "declare BUILD_PROFILE next to DEFAULT_PROFILE",
+    ),
+    Patch(
+        "aw-tauri/src-tauri/src/profile.rs",
+        "        None => Ok(DEFAULT_PROFILE.to_string()),\n",
+        "        None => Ok(BUILD_PROFILE.to_string()),\n",
+        "resolve_profile with no --profile and no AW_PROFILE runs the build profile",
+    ),
+    Patch(
+        "aw-tauri/src-tauri/src/profile.rs",
+        "        _ => DEFAULT_PROFILE.to_string(),\n",
+        "        _ => BUILD_PROFILE.to_string(),\n",
+        "current_profile() before export falls back to the build profile",
+    ),
+]
+
+# --- port -------------------------------------------------------------------
+
+PORT_PATCHES_QT: List[Patch] = [
+    Patch(
+        "aw-server/aw_server/config.py",
+        'port = "5600"\n',
+        f'port = "{RESEARCH_PORT}"\n',
+        "aw-server default_config [server] port",
+    ),
+    Patch(
+        "aw-server/aw_server/config.py",
+        "    return 5666 if is_testing(profile) else 5600\n",
+        f"    return 5666 if is_testing(profile) else {RESEARCH_PORT}\n",
+        "aw-server default_port()",
+    ),
+    Patch(
+        "aw-server-rust/aw-server/src/config.rs",
+        "    } else {\n        5600\n    }\n",
+        f"    }} else {{\n        {RESEARCH_PORT}\n    }}\n",
+        "aw-server-rust default_port()",
+    ),
+    Patch(
+        "aw-client/aw_client/config.py",
+        'port = "5600"\n',
+        f'port = "{RESEARCH_PORT}"\n',
+        "aw-client default_config [server] port",
+    ),
+    Patch(
+        "aw-client/aw_client/config.py",
+        '                _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
+        '                "aw-server-rust",\n'
+        '                "config.toml",\n'
+        "            ),\n"
+        "            5600,\n",
+        '                _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
+        '                "aw-server-rust",\n'
+        '                "config.toml",\n'
+        "            ),\n"
+        f"            {RESEARCH_PORT},\n",
+        "aw-client api-key lookup default port for a named profile",
+    ),
+    Patch(
+        "aw-qt/aw_qt/config.py",
+        "    default_port = 5666 if is_testing(profile) else 5600\n",
+        f"    default_port = 5666 if is_testing(profile) else {RESEARCH_PORT}\n",
+        "aw-qt _read_server_port fallback",
+    ),
+    Patch(
+        "aw-qt/aw_qt/manager.py",
+        "        default_port = 5666 if testing else 5600\n",
+        f"        default_port = 5666 if testing else {RESEARCH_PORT}\n",
+        "aw-qt external-server probe fallback",
+    ),
+    Patch(
+        "aw-qt/aw_qt/trayicon.py",
+        "            port = 5666 if testing else 5600\n",
+        f"            port = 5666 if testing else {RESEARCH_PORT}\n",
+        "aw-qt tray root_url fallback",
+    ),
+]
+
+PORT_PATCHES_TAURI: List[Patch] = [
+    # aw-tauri embeds aw-server-rust in-process and passes its own config
+    # port, so the server crate's default_port() is irrelevant here.
+    Patch(
+        "aw-tauri/src-tauri/src/lib.rs",
+        "        UserConfig {\n            port: 5600,\n",
+        f"        UserConfig {{\n            port: {RESEARCH_PORT},\n",
+        "aw-tauri UserConfig::default port",
+    ),
+    Patch(
+        "aw-client/aw_client/config.py",
+        'port = "5600"\n',
+        f'port = "{RESEARCH_PORT}"\n',
+        "aw-client default_config [server] port",
+    ),
+    Patch(
+        "aw-client/aw_client/config.py",
+        '                _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
+        '                "aw-server-rust",\n'
+        '                "config.toml",\n'
+        "            ),\n"
+        "            5600,\n",
+        '                _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
+        '                "aw-server-rust",\n'
+        '                "config.toml",\n'
+        "            ),\n"
+        f"            {RESEARCH_PORT},\n",
+        "aw-client api-key lookup default port for a named profile",
+    ),
+]
+
+# --- macOS bundle identity ----------------------------------------------------
+
+BUNDLE_PATCHES_QT: List[Patch] = [
+    Patch(
+        "aw.spec",
+        '        bundle_identifier="net.activitywatch.ActivityWatch",\n',
+        f'        bundle_identifier="{BUNDLE_ID}",\n',
+        "PyInstaller BUNDLE identifier",
+    ),
+    Patch(
+        "aw.spec",
+        '            "CFBundleExecutable": "MacOS/aw-qt",\n',
+        f'            "CFBundleName": "{BUNDLE_NAME}",\n'
+        '            "CFBundleExecutable": "MacOS/aw-qt",\n',
+        "PyInstaller BUNDLE display name",
+    ),
+    Patch(
+        "aw.spec",
+        '        name="ActivityWatch.app",\n',
+        f'        name="{BUNDLE_DIR_STEM}.app",\n',
+        "PyInstaller BUNDLE on-disk filename",
+    ),
+    Patch(
+        "Makefile",
+        "APP_BUNDLE ?= ActivityWatch\n",
+        f"APP_BUNDLE ?= {BUNDLE_DIR_STEM}\n",
+        "Makefile .app/.dmg stem",
+    ),
+    Patch(
+        "scripts/notarize.sh",
+        "bundleid=net.activitywatch.ActivityWatch # Match aw.spec\n",
+        f"bundleid={BUNDLE_ID} # Match aw.spec\n",
+        "notarization bundle id",
+    ),
+    Patch(
+        "scripts/notarize.sh",
+        "app=dist/ActivityWatch.app\n"
+        "dmg=dist/ActivityWatch.dmg\n",
+        f"app=dist/{BUNDLE_DIR_STEM}.app\n"
+        f"dmg=dist/{BUNDLE_DIR_STEM}.dmg\n",
+        "notarization .app/.dmg paths",
+    ),
+]
+
+BUNDLE_PATCHES_TAURI: List[Patch] = [
+    Patch(
+        "scripts/package/build_app_tauri.sh",
+        'BUNDLE_ID="net.activitywatch.ActivityWatch"\n',
+        f'BUNDLE_ID="{BUNDLE_ID}"\n',
+        "Tauri .app CFBundleIdentifier",
+    ),
+    Patch(
+        "scripts/package/build_app_tauri.sh",
+        "    CFBundleName\n    ${APP_NAME}\n",
+        f"    CFBundleName\n    {BUNDLE_NAME}\n",
+        "Tauri .app CFBundleName",
+    ),
+    Patch(
+        "scripts/package/build_app_tauri.sh",
+        'APP_NAME="ActivityWatch"\n',
+        f'APP_NAME="{BUNDLE_DIR_STEM}"\n',
+        "Tauri .app on-disk filename",
+    ),
+    Patch(
+        "Makefile",
+        "APP_BUNDLE ?= ActivityWatch\n",
+        f"APP_BUNDLE ?= {BUNDLE_DIR_STEM}\n",
+        "Makefile .app/.dmg stem",
+    ),
+    Patch(
+        "aw-tauri/src-tauri/tauri.conf.json",
+        '  "identifier": "net.activitywatch.tauri",\n',
+        f'  "identifier": "{TAURI_IDENTIFIER}",\n',
+        "Tauri identifier (single-instance slot on macOS/Windows, installer identity)",
+    ),
+    Patch(
+        "scripts/notarize.sh",
+        "bundleid=net.activitywatch.ActivityWatch # Match aw.spec\n",
+        f"bundleid={BUNDLE_ID} # Match aw.spec\n",
+        "notarization bundle id",
+    ),
+    Patch(
+        "scripts/notarize.sh",
+        "app=dist/ActivityWatch.app\n"
+        "dmg=dist/ActivityWatch.dmg\n",
+        f"app=dist/{BUNDLE_DIR_STEM}.app\n"
+        f"dmg=dist/{BUNDLE_DIR_STEM}.dmg\n",
+        "notarization .app/.dmg paths",
+    ),
+]
+
+# --- Windows install identity -------------------------------------------------
+# Both .iss files derive AppName, DefaultDirName (Qt), the Start-Menu / desktop /
+# {userstartup} shortcut names and UninstallDisplayName from `#define MyAppName`,
+# so patching that one token cascades to every user-visible identity. AppId and
+# OutputBaseFilename do not cascade and are patched explicitly.
+#
+# Note both .iss files ship `OutputBaseFilename=activitywatch-setup` on master,
+# so the Qt and Tauri setups already overwrite each other in dist/. The research
+# names below are distinct from each other as well as from standard, which also
+# stops research artifacts from colliding on the release page.
+
+WINDOWS_PATCHES_QT: List[Patch] = [
+    Patch(
+        "scripts/package/activitywatch-setup.iss",
+        '#define MyAppName "ActivityWatch"\n',
+        f'#define MyAppName "{BUNDLE_NAME}"\n',
+        "Inno Qt product name (cascades to dir, shortcuts, uninstall entry)",
+    ),
+    Patch(
+        "scripts/package/activitywatch-setup.iss",
+        "AppId={{F226B8F4-3244-46E6-901D-0CE8035423E4}\n",
+        f"AppId={{{{{WINDOWS_APPID_QT}}}\n",
+        "Inno Qt AppId (separate product, not an upgrade of standard)",
+    ),
+    Patch(
+        "scripts/package/activitywatch-setup.iss",
+        "OutputBaseFilename=activitywatch-setup\n",
+        "OutputBaseFilename=activitywatch-research-setup\n",
+        "Inno Qt setup .exe filename",
+    ),
+]
+
+WINDOWS_PATCHES_TAURI: List[Patch] = [
+    Patch(
+        "aw-tauri/src-tauri/tauri.conf.json",
+        '  "bundle": {\n    "active": true,\n',
+        '  "bundle": {\n    "active": true,\n'
+        '    "windows": {\n'
+        '      "wix": {\n'
+        f'        "upgradeCode": "{WINDOWS_WIX_UPGRADE_CODE_TAURI}"\n'
+        '      }\n'
+        '    },\n',
+        "Tauri WiX upgrade code (research MSIs are their own product family)",
+    ),
+    Patch(
+        "scripts/package/aw-tauri.iss",
+        '#define MyAppName "ActivityWatch (Tauri)"\n',
+        f'#define MyAppName "{BUNDLE_NAME} (Tauri)"\n',
+        "Inno Tauri product name (cascades to shortcuts, uninstall entry)",
+    ),
+    Patch(
+        "scripts/package/aw-tauri.iss",
+        "AppId={{983D0855-08C8-46BD-AEFB-3924581C6703}\n",
+        f"AppId={{{{{WINDOWS_APPID_TAURI}}}\n",
+        "Inno Tauri AppId (separate product, not an upgrade of standard Tauri)",
+    ),
+    Patch(
+        "scripts/package/aw-tauri.iss",
+        "DefaultDirName={autopf}\\ActivityWatch-Tauri\n",
+        f"DefaultDirName={{autopf}}\\{BUNDLE_DIR_STEM}-Tauri\n",
+        "Inno Tauri install directory",
+    ),
+    Patch(
+        "scripts/package/aw-tauri.iss",
+        "OutputBaseFilename=activitywatch-setup\n",
+        "OutputBaseFilename=activitywatch-research-tauri-setup\n",
+        "Inno Tauri setup .exe filename",
+    ),
+]
+
+
+# --- Linux package identity ---------------------------------------------------
+# Qt only: the Tauri Linux bundles come from Tauri's own bundler, whose package
+# and desktop-entry names derive from `productName`. That is coupled to the
+# cargo binary name (`mainBinaryName`), so splitting it needs a Tauri build to
+# verify and is tracked separately.
+
+LINUX_PATCHES_QT: List[Patch] = [
+    Patch(
+        "scripts/package/deb/control",
+        "Package: activitywatch\n",
+        f"Package: {LINUX_PACKAGE}\n",
+        "deb package name (co-installable with the standard package)",
+    ),
+    Patch(
+        "scripts/package/deb/control",
+        "Description: Open source time tracker\n",
+        "Description: Open source time tracker (Research Edition)\n",
+        "deb package description",
+    ),
+    Patch(
+        "scripts/package/package-deb.sh",
+        'PKGDIR="activitywatch_$VERSION_NUM"\n',
+        f'PKGDIR="{LINUX_PACKAGE}_$VERSION_NUM"\n',
+        "deb staging dir (dpkg-deb names the .deb after it)",
+    ),
+    Patch(
+        "scripts/package/package-deb.sh",
+        "sudo mv activitywatch_${VERSION_NUM}.deb",
+        f"sudo mv {LINUX_PACKAGE}_${{VERSION_NUM}}.deb",
+        "deb output filename produced by dpkg-deb --build",
+    ),
+    Patch(
+        "scripts/package/package-deb.sh",
+        "cp -r dist/activitywatch/ $PKGDIR/opt/\n",
+        f"cp -r dist/activitywatch/ $PKGDIR{LINUX_OPT_DIR}\n",
+        "install tree location (/opt/activitywatch-research)",
+    ),
+    Patch(
+        "scripts/package/package-deb.sh",
+        "sudo sed -i 's!Exec=aw-qt!Exec=/opt/activitywatch/aw-qt!' "
+        "$PKGDIR/opt/activitywatch/aw-qt.desktop\n"
+        "sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/etc/xdg/autostart/\n"
+        "sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/usr/share/applications/\n",
+        f"sudo sed -i 's!Exec=aw-qt!Exec={LINUX_OPT_DIR}/aw-qt!' "
+        f"$PKGDIR{LINUX_OPT_DIR}/aw-qt.desktop\n"
+        f"sudo cp $PKGDIR{LINUX_OPT_DIR}/aw-qt.desktop "
+        f"$PKGDIR/etc/xdg/autostart/{LINUX_DESKTOP_FILENAME}\n"
+        f"sudo cp $PKGDIR{LINUX_OPT_DIR}/aw-qt.desktop "
+        f"$PKGDIR/usr/share/applications/{LINUX_DESKTOP_FILENAME}\n",
+        "Exec path plus distinct autostart/menu desktop-entry filename",
+    ),
+    Patch(
+        "aw-qt/resources/aw-qt.desktop",
+        "Name=ActivityWatch\n",
+        f"Name={BUNDLE_NAME}\n",
+        "desktop entry display name",
+    ),
+    Patch(
+        "aw-qt/resources/aw-qt.desktop",
+        "Icon=activitywatch\n",
+        f"Icon={LINUX_ICON_ID}\n",
+        "desktop entry icon id (matches the AppImage --icon-filename)",
+    ),
+    Patch(
+        "scripts/package/package-appimage.sh",
+        "--desktop-file ./activitywatch/aw-qt.desktop "
+        "--icon-file ./activitywatch/media/logo/logo.png "
+        "--icon-filename activitywatch\n",
+        f"--desktop-file ./activitywatch/{LINUX_DESKTOP_FILENAME} "
+        "--icon-file ./activitywatch/media/logo/logo.png "
+        f"--icon-filename {LINUX_ICON_ID}\n",
+        "AppImage desktop-entry filename and icon id",
+    ),
+    Patch(
+        "scripts/package/package-appimage.sh",
+        "# create AppRun\n",
+        "# Research edition: linuxdeploy installs the desktop entry under its own\n"
+        "# basename, so give it a distinct one - appimaged would otherwise\n"
+        "# overwrite a standard install's entry on desktop integration.\n"
+        f"cp ./activitywatch/aw-qt.desktop ./activitywatch/{LINUX_DESKTOP_FILENAME}\n"
+        "\n# create AppRun\n",
+        "AppImage: stage the research desktop entry under its own filename",
+    ),
+]
+
+# --- first-run autostart identity ---------------------------------------------
+# aw-qt writes its own autostart entry (config `autostart_on_first_run`). Those
+# names are independent of the installer's: without this the two editions
+# overwrite each other's login item / Startup shortcut / autostart .desktop even
+# though every other identity is already split.
+#
+# aw-qt#133 made those names profile-aware (`aw-qt-research.desktop`,
+# `net.activitywatch.aw-qt-research`, `ActivityWatch (research)`). The research
+# edition *is* profile=research, so the suffix alone collides with a standard
+# build launched as `--profile research`. Override the written identities to
+# edition-specific names; leave DESKTOP_FILENAME / LAUNCH_AGENT_LABEL as the
+# shipped-resource / base strings.
+
+AUTOSTART_PATCHES_QT: List[Patch] = [
+    Patch(
+        "aw-qt/aw_qt/autostart.py",
+        'APP_NAME = "ActivityWatch"\n',
+        f'APP_NAME = "{BUNDLE_NAME}"\n',
+        "Windows Run value + Startup shortcut name",
+    ),
+    Patch(
+        "aw-qt/aw_qt/autostart.py",
+        '    return _linux_autostart_dir() / f"{stem}{_profile_suffix()}{extension}"\n',
+        f'    return _linux_autostart_dir() / "{LINUX_DESKTOP_FILENAME}"\n',
+        "Linux autostart entry filename (DESKTOP_FILENAME still names the "
+        "shipped resource we copy from; do not use aw-qt-research.desktop — "
+        "that is the standard build's named-profile entry)",
+    ),
+    Patch(
+        "aw-qt/aw_qt/autostart.py",
+        '    return f"{LAUNCH_AGENT_LABEL}{_profile_suffix()}"\n',
+        f'    return "{BUNDLE_ID}"\n',
+        "macOS LaunchAgent label (bundle id, not net.activitywatch.aw-qt-research, "
+        "which is the standard build's named-profile label)",
+    ),
+    Patch(
+        "aw-qt/aw_qt/autostart.py",
+        "Name=ActivityWatch\n",
+        f"Name={BUNDLE_NAME}\n",
+        "fallback desktop-entry template display name",
+    ),
+]
+
+
+# --- first-run autostart identity (Tauri) --------------------------------------
+# aw-tauri#253 already gives named profiles a distinct plugin app name
+# (`aw-tauri-research`). The research edition *is* profile=research, so that
+# name collides with a standard build launched as `--profile research`. Override
+# with an edition-specific `aw-tauri-{BUILD_PROFILE}-edition` identity.
+#
+# This patch applies after PROFILE_PATCHES_TAURI, so BUILD_PROFILE and
+# DEFAULT_PROFILE are both defined in the compiled profile module by the time
+# the research binary runs.
+
+AUTOSTART_PATCHES_TAURI: List[Patch] = [
+    Patch(
+        "aw-tauri/src-tauri/src/lib.rs",
+        "            if let Some(app_name) = profile::autostart_app_name(&cli_args.profile) {\n"
+        "                builder = builder\n"
+        "                    .app_name(app_name)\n"
+        "                    .args([\"--profile\", cli_args.profile.as_str()]);\n"
+        "            }\n",
+        "            if let Some(app_name) = profile::autostart_app_name(&cli_args.profile) {\n"
+        "                builder = builder\n"
+        "                    .app_name(app_name)\n"
+        "                    .args([\"--profile\", cli_args.profile.as_str()]);\n"
+        "            }\n"
+        "            // Non-default BUILD_PROFILE is a research-edition binary: do not\n"
+        "            // reuse the named-profile identity (`aw-tauri-research`), which a\n"
+        "            // standard `--profile research` login item already owns.\n"
+        "            if profile::BUILD_PROFILE != profile::DEFAULT_PROFILE {\n"
+        "                builder = builder.app_name(format!(\n"
+        "                    \"aw-tauri-{}-edition\",\n"
+        "                    profile::BUILD_PROFILE\n"
+        "                ));\n"
+        "            }\n",
+        "tauri autostart: edition-specific app_name so research != named profile",
+    ),
+]
+
+
+TARGETS = {
+    "qt": (
+        PROFILE_PATCHES_QT
+        + PORT_PATCHES_QT
+        + BUNDLE_PATCHES_QT
+        + WINDOWS_PATCHES_QT
+        + LINUX_PATCHES_QT
+        + AUTOSTART_PATCHES_QT
+    ),
+    "tauri": (
+        PROFILE_PATCHES_TAURI
+        + PORT_PATCHES_TAURI
+        + BUNDLE_PATCHES_TAURI
+        + WINDOWS_PATCHES_TAURI
+        + AUTOSTART_PATCHES_TAURI
+    ),
+}
+
+
+class PatchError(Exception):
+    pass
+
+
+def _group_by_path(patches: Iterable[Patch]) -> "dict[str, List[Patch]]":
+    grouped: "dict[str, List[Patch]]" = {}
+    for patch in patches:
+        grouped.setdefault(patch.path, []).append(patch)
+    return grouped
+
+
+def apply_patches(
+    root: pathlib.Path, patches: Sequence[Patch], check: bool = False
+) -> List[str]:
+    """Apply (or with ``check`` only verify) every patch under ``root``.
+
+    All targets in a file are validated before any of them is written, and a
+    file is only written once all its patches apply. Any missing or ambiguous
+    target raises :class:`PatchError` naming the file and the patch's purpose,
+    so a stale submodule pin fails the release instead of shipping a bundle
+    that runs the default profile.
+    """
+    applied: List[str] = []
+    for rel_path, file_patches in _group_by_path(patches).items():
+        path = root / rel_path
+        try:
+            text = path.read_text(encoding="utf-8")
+        except FileNotFoundError:
+            raise PatchError(
+                f"{rel_path}: not found - is the submodule checked out?"
+            ) from None
+
+        for patch in file_patches:
+            if patch.new in text:
+                # Declaration inserts keep `old` as a prefix of `new`, so a
+                # second run would silently double-insert without this check.
+                raise PatchError(
+                    f"{rel_path}: [{patch.why}] is already applied - refusing to "
+                    "patch a tree twice"
+                )
+            occurrences = text.count(patch.old)
+            if occurrences != 1:
+                raise PatchError(
+                    f"{rel_path}: expected exactly one match for [{patch.why}], "
+                    f"found {occurrences}. The source drifted from what this "
+                    "patcher targets - refusing to ship a research build with "
+                    "the default profile/port/bundle id."
+                )
+            text = text.replace(patch.old, patch.new, 1)
+            applied.append(f"{rel_path}: {patch.why}")
+
+        if not check:
+            path.write_text(text, encoding="utf-8")
+    return applied
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+    parser.add_argument(
+        "target", choices=sorted(TARGETS), help="which bundle is being built"
+    )
+    parser.add_argument(
+        "--root",
+        type=pathlib.Path,
+        default=pathlib.Path.cwd(),
+        help="repository root (default: current directory)",
+    )
+    parser.add_argument(
+        "--check",
+        action="store_true",
+        help="verify every target string is present and unique; write nothing",
+    )
+    args = parser.parse_args(argv)
+
+    try:
+        applied = apply_patches(args.root, TARGETS[args.target], check=args.check)
+    except PatchError as e:
+        print(f"ERROR: {e}", file=sys.stderr)
+        return 1
+
+    verb = "Verified" if args.check else "Patched"
+    for line in applied:
+        print(f"{verb} {line}")
+    print(
+        f"{verb} {len(applied)} research edition site(s) for {args.target}: "
+        f"profile={RESEARCH_PROFILE} port={RESEARCH_PORT} bundle={BUNDLE_ID}"
+    )
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/research_edition/export_sanitize.rs b/scripts/research_edition/export_sanitize.rs
new file mode 100644
index 0000000..803025a
--- /dev/null
+++ b/scripts/research_edition/export_sanitize.rs
@@ -0,0 +1,375 @@
+//! Research Edition `/api/0/export` sanitizer.
+//!
+//! Copied into `aw-server` only by `scripts/patch_research_edition_export.py`
+//! when `AW_RESEARCH_EDITION=true`. Standard builds never see this module.
+//!
+//! Two jobs:
+//! 1. Fail closed if currentwindow events still carry raw titles or any event
+//!    carries a URL. Application names are approved study variables.
+//! 2. Rewrite each exported bucket's map key, embedded `id`, and `hostname`
+//!    so the real machine name never leaves the device. Colliding sanitized
+//!    IDs fail the export rather than silently merging two machines.
+
+use std::collections::HashMap;
+
+use aw_models::{Bucket, BucketsExport, TryVec};
+use serde_json::Value;
+
+pub const SANITIZED_HOSTNAME: &str = "research-participant";
+
+/// Study categories plus the two "Excluded" spellings the watcher writes.
+/// Keep in sync with `scripts/patch_research_edition_config.py`.
+const STUDY_CATEGORIES: &[&str] = &[
+    "Sensitive / Excluded",
+    "Music & Audio",
+    "Video Streaming",
+    "Games",
+    "Travel & Mobility",
+    "Search & Navigation",
+    "News & Current Affairs",
+    "Social Networking",
+    "Messaging",
+    "Email",
+    "AI Chatbots & Assistants",
+    "Work & Productivity",
+    "Education & Learning",
+    "Shopping - Goods",
+    "Shopping - Groceries & Food",
+    "Banking & Finance",
+    "Public Services",
+    "Excluded",
+    "excluded",
+];
+
+pub fn sanitize_buckets_export(export: BucketsExport) -> Result {
+    let mut checked = HashMap::new();
+    for (key, mut bucket) in export.buckets {
+        reject_unfiltered(&mut bucket)?;
+        checked.insert(key, bucket);
+    }
+    rewrite_identities(checked)
+}
+
+fn reject_unfiltered(bucket: &mut Bucket) -> Result<(), String> {
+    let Some(events) = bucket.events.take() else {
+        return Ok(());
+    };
+    let inner = events.take_inner();
+    for event in &inner {
+        if let Some(reason) = unfiltered_reason(&bucket._type, &event.data) {
+            bucket.events = Some(TryVec::new(inner));
+            return Err(format!(
+                "Research Edition export refused: bucket type '{}' client '{}' contains unfiltered event data ({reason}). \
+                 Research filtering only applies to newly captured events. Uninstall ActivityWatch, \
+                 delete the existing database, and install the Research Edition on a clean profile.",
+                bucket._type, bucket.client
+            ));
+        }
+    }
+    bucket.events = Some(TryVec::new(inner));
+    Ok(())
+}
+
+fn unfiltered_reason(
+    bucket_type: &str,
+    data: &serde_json::Map,
+) -> Option<&'static str> {
+    if data.contains_key("url") {
+        return Some("url field");
+    }
+    if bucket_type != "currentwindow" {
+        return None;
+    }
+    if let Some(title) = data.get("title").and_then(Value::as_str) {
+        if !is_study_category(title) {
+            return Some("non-category window title");
+        }
+    }
+    None
+}
+
+fn is_study_category(value: &str) -> bool {
+    let trimmed = value.trim();
+    STUDY_CATEGORIES
+        .iter()
+        .any(|category| category.eq_ignore_ascii_case(trimmed))
+}
+
+fn rewrite_identities(buckets: HashMap) -> Result {
+    let mut out: HashMap = HashMap::new();
+    for (key, mut bucket) in buckets {
+        let original_id = if bucket.id.is_empty() {
+            key.clone()
+        } else {
+            bucket.id.clone()
+        };
+        let sanitized_id = sanitize_id(&original_id, &bucket.hostname);
+        let sanitized_key = sanitize_id(&key, &bucket.hostname);
+        if sanitized_id != sanitized_key {
+            return Err(
+                "Research Edition export refused: bucket key and embedded id sanitize to different identities. \
+                 Start from a clean ActivityWatch profile."
+                    .to_string(),
+            );
+        }
+        if out.contains_key(&sanitized_key) {
+            return Err(format!(
+                "Research Edition export refused: two buckets map to the same sanitized identity '{sanitized_key}'. \
+                 This usually means data from more than one machine is in the same database. Start from a clean ActivityWatch profile."
+            ));
+        }
+        bucket.id = sanitized_key.clone();
+        bucket.hostname = SANITIZED_HOSTNAME.to_string();
+        out.insert(sanitized_key, bucket);
+    }
+    Ok(BucketsExport { buckets: out })
+}
+
+pub(crate) fn sanitize_id(id: &str, hostname: &str) -> String {
+    if hostname.is_empty() {
+        return id.to_string();
+    }
+    if id == hostname {
+        return SANITIZED_HOSTNAME.to_string();
+    }
+    let suffix = format!("_{hostname}");
+    if let Some(prefix) = id.strip_suffix(suffix.as_str()) {
+        return format!("{prefix}_{SANITIZED_HOSTNAME}");
+    }
+    if id.contains(hostname) {
+        return id.replace(hostname, SANITIZED_HOSTNAME);
+    }
+    id.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use aw_models::{BucketMetadata, Event, TryVec};
+    use chrono::{Duration, TimeZone, Utc};
+    use serde_json::{json, Map};
+
+    fn ts() -> chrono::DateTime {
+        Utc.with_ymd_and_hms(2026, 8, 24, 12, 0, 0).unwrap()
+    }
+
+    fn event(data: serde_json::Value) -> Event {
+        Event {
+            id: None,
+            timestamp: ts(),
+            duration: Duration::seconds(1),
+            data: data.as_object().cloned().unwrap_or_else(Map::new),
+        }
+    }
+
+    fn bucket(
+        id: &str,
+        hostname: &str,
+        bucket_type: &str,
+        client: &str,
+        events: Vec,
+    ) -> Bucket {
+        Bucket {
+            bid: None,
+            id: id.to_string(),
+            _type: bucket_type.to_string(),
+            client: client.to_string(),
+            hostname: hostname.to_string(),
+            created: None,
+            data: Map::new(),
+            metadata: BucketMetadata::default(),
+            events: Some(TryVec::new(events)),
+            last_updated: None,
+        }
+    }
+
+    fn export_of(buckets: Vec) -> BucketsExport {
+        BucketsExport {
+            buckets: buckets.into_iter().map(|b| (b.id.clone(), b)).collect(),
+        }
+    }
+
+    #[test]
+    fn rewrites_key_id_and_hostname_together() {
+        let host = "Participant-Alice-MacBook";
+        let original = export_of(vec![
+            bucket(
+                &format!("aw-watcher-window_{host}"),
+                host,
+                "currentwindow",
+                "aw-watcher-window",
+                vec![event(json!({"app": "Excluded"}))],
+            ),
+            bucket(
+                &format!("aw-watcher-afk_{host}"),
+                host,
+                "afkstatus",
+                "aw-watcher-afk",
+                vec![event(json!({"status": "afk"}))],
+            ),
+        ]);
+
+        let sanitized = sanitize_buckets_export(original).unwrap();
+        let window_id = format!("aw-watcher-window_{SANITIZED_HOSTNAME}");
+        let afk_id = format!("aw-watcher-afk_{SANITIZED_HOSTNAME}");
+
+        assert_eq!(sanitized.buckets.len(), 2);
+        let window = sanitized.buckets.get(&window_id).unwrap();
+        assert_eq!(window.id, window_id);
+        assert_eq!(window.hostname, SANITIZED_HOSTNAME);
+        assert_eq!(
+            window.events.as_ref().unwrap().clone().take_inner()[0].data,
+            json!({"app": "Excluded"}).as_object().unwrap().clone()
+        );
+
+        let afk = sanitized.buckets.get(&afk_id).unwrap();
+        assert_eq!(afk.id, afk_id);
+        assert_eq!(afk.hostname, SANITIZED_HOSTNAME);
+        assert_eq!(
+            afk.events.as_ref().unwrap().clone().take_inner()[0].data,
+            json!({"status": "afk"}).as_object().unwrap().clone()
+        );
+
+        let dump = serde_json::to_string(&sanitized).unwrap();
+        assert!(
+            !dump.contains(host),
+            "real hostname must not appear in export JSON"
+        );
+        assert_eq!(dump.matches(SANITIZED_HOSTNAME).count(), 6); // key + id + hostname, twice
+    }
+
+    #[test]
+    fn collision_of_two_hostnames_fails_closed() {
+        let original = export_of(vec![
+            bucket(
+                "aw-watcher-window_host-a",
+                "host-a",
+                "currentwindow",
+                "aw-watcher-window",
+                vec![event(json!({"app": "Excluded"}))],
+            ),
+            bucket(
+                "aw-watcher-window_host-b",
+                "host-b",
+                "currentwindow",
+                "aw-watcher-window",
+                vec![event(json!({"app": "Excluded"}))],
+            ),
+        ]);
+
+        let err = match sanitize_buckets_export(original) {
+            Err(err) => err,
+            Ok(_) => panic!("expected hostname collision to fail closed"),
+        };
+        assert!(err.contains("same sanitized identity"));
+        assert!(!err.contains("host-a"));
+        assert!(!err.contains("host-b"));
+    }
+
+    #[test]
+    fn unfiltered_title_fails_closed() {
+        let original = export_of(vec![bucket(
+            "aw-watcher-window_host",
+            "host",
+            "currentwindow",
+            "aw-watcher-window",
+            vec![event(json!({"app": "Code", "title": "secret.rs"}))],
+        )]);
+
+        let err = match sanitize_buckets_export(original) {
+            Err(err) => err,
+            Ok(_) => panic!("expected unfiltered title to fail closed"),
+        };
+        assert!(err.contains("unfiltered"));
+        assert!(err.contains("clean profile"));
+        assert!(!err.contains("secret.rs"));
+        assert!(!err.contains("Code"));
+    }
+
+    #[test]
+    fn unfiltered_url_fails_closed_even_outside_window_buckets() {
+        let original = export_of(vec![bucket(
+            "aw-watcher-web_host",
+            "host",
+            "web.tab.current",
+            "aw-watcher-web",
+            vec![event(
+                json!({"url": "https://mail.example/inbox", "title": "Inbox"}),
+            )],
+        )]);
+
+        let err = match sanitize_buckets_export(original) {
+            Err(err) => err,
+            Ok(_) => panic!("expected url field to fail closed"),
+        };
+        assert!(err.contains("url field"));
+        assert!(!err.contains("mail.example"));
+    }
+
+    #[test]
+    fn retained_non_browser_app_name_is_allowed() {
+        let original = export_of(vec![bucket(
+            "aw-watcher-window_host",
+            "host",
+            "currentwindow",
+            "aw-watcher-window",
+            vec![event(json!({"app": "Microsoft Word"}))],
+        )]);
+
+        let sanitized = sanitize_buckets_export(original).unwrap();
+        let bucket = sanitized
+            .buckets
+            .get(&format!("aw-watcher-window_{SANITIZED_HOSTNAME}"))
+            .unwrap();
+        assert_eq!(
+            bucket.events.as_ref().unwrap().clone().take_inner()[0].data,
+            json!({"app": "Microsoft Word"})
+                .as_object()
+                .unwrap()
+                .clone()
+        );
+    }
+
+    #[test]
+    fn browser_event_with_classified_title_is_allowed() {
+        let original = export_of(vec![bucket(
+            "aw-watcher-window_host",
+            "host",
+            "currentwindow",
+            "aw-watcher-window",
+            vec![event(
+                json!({"app": "Firefox", "title": "Work & Productivity"}),
+            )],
+        )]);
+
+        let sanitized = sanitize_buckets_export(original).unwrap();
+        let bucket = sanitized
+            .buckets
+            .get(&format!("aw-watcher-window_{SANITIZED_HOSTNAME}"))
+            .unwrap();
+        assert_eq!(
+            bucket.events.as_ref().unwrap().clone().take_inner()[0].data,
+            json!({"app": "Firefox", "title": "Work & Productivity"})
+                .as_object()
+                .unwrap()
+                .clone()
+        );
+    }
+
+    #[test]
+    fn sanitize_id_replaces_suffix_and_embedded_hostname() {
+        assert_eq!(
+            sanitize_id(
+                "aw-watcher-window_Participant-Alice-MacBook",
+                "Participant-Alice-MacBook"
+            ),
+            format!("aw-watcher-window_{SANITIZED_HOSTNAME}")
+        );
+        assert_eq!(
+            sanitize_id("Participant-Alice-MacBook", "Participant-Alice-MacBook"),
+            SANITIZED_HOSTNAME
+        );
+        assert_eq!(sanitize_id("id1", "hostname"), "id1");
+        assert_eq!(sanitize_id("keep_me", ""), "keep_me");
+    }
+}
diff --git a/scripts/submodule-branch.sh b/scripts/submodule-branch.sh
new file mode 100755
index 0000000..91ab9d8
--- /dev/null
+++ b/scripts/submodule-branch.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+# Get current branch
+#   git rev-parse --abbrev-ref HEAD
+# Get branch for each submodule
+#   git submodule foreach "git rev-parse --abbrev-ref HEAD"
+
+SUBMODULES=$(git submodule | sed -r -e 's/^[ \+][a-z0-9]+ //g' -e 's/ \(.*\)//g')
+for module in $SUBMODULES; do
+    branch=$(git --git-dir=$module/.git rev-parse --abbrev-ref HEAD)
+    printf "%-20s %-30s\n" "$module" "$branch"
+done
diff --git a/scripts/symlink-systemd.sh b/scripts/symlink-systemd.sh
new file mode 100755
index 0000000..61a55b2
--- /dev/null
+++ b/scripts/symlink-systemd.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+for module in "aw-server" "aw-watcher-afk" "aw-watcher-x11"; do
+    ln -s $(pwd)/$module/misc/${module}.service ~/.config/systemd/user/${module}.service
+done
diff --git a/scripts/tests/integration_tests.py b/scripts/tests/integration_tests.py
new file mode 100644
index 0000000..335ac67
--- /dev/null
+++ b/scripts/tests/integration_tests.py
@@ -0,0 +1,84 @@
+import os
+import platform
+import subprocess
+import tempfile
+from time import sleep
+
+import pytest
+
+
+def _windows_kill_process(pid):
+    import ctypes
+
+    PROCESS_TERMINATE = 1
+    handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
+    ctypes.windll.kernel32.TerminateProcess(handle, -1)
+    ctypes.windll.kernel32.CloseHandle(handle)
+
+
+# NOTE: to run tests with a specific server binary,
+#       set the PATH such that it is the "aw-server" binary.
+@pytest.fixture(scope="session")
+def server_process():
+    logfile_stdout = tempfile.NamedTemporaryFile(delete=False)
+    logfile_stderr = tempfile.NamedTemporaryFile(delete=False)
+
+    # find the path of the "aw-server" binary and log it
+    which_server = subprocess.check_output(["which", "aw-server"], text=True)
+    print(f"aw-server path: {which_server}")
+
+    # if aw-server-rust in PATH, assert that we're picking up the aw-server-rust binary
+    if "aw-server-rust" in os.environ["PATH"]:
+        assert "aw-server-rust" in which_server
+
+    server_proc = subprocess.Popen(
+        ["aw-server", "--testing"], stdout=logfile_stdout, stderr=logfile_stderr
+    )
+
+    # Wait for server to start up properly
+    # TODO: Ping the server until it's alive to remove this sleep
+    sleep(5)
+
+    yield server_proc
+
+    if platform.system() == "Windows":
+        # On Windows, for whatever reason, server_proc.kill() doesn't do the job.
+        _windows_kill_process(server_proc.pid)
+    else:
+        server_proc.kill()
+    server_proc.wait(5)
+    server_proc.communicate()
+
+    error_indicators = ["ERROR"]
+
+    with open(logfile_stdout.name, "r+b") as f:
+        stdout = str(f.read(), "utf8")
+        if any(e in stdout for e in error_indicators):
+            pytest.fail(f"Found ERROR indicator in stdout from server: {stdout}")
+
+    with open(logfile_stderr.name, "r+b") as f:
+        stderr = str(f.read(), "utf8")
+        # For some reason, this fails aw-server-rust, but not aw-server-python
+        # if not stderr:
+        #    pytest.fail("No output to stderr from server")
+
+        # Will show in case pytest fails
+        print(stderr)
+
+        for s in error_indicators:
+            if s in stderr:
+                pytest.fail(f"Found ERROR indicator in stderr from server: {s}")
+
+    # NOTE: returncode was -9 for whatever reason
+    # if server_proc.returncode != 0:
+    #     pytest.fail("Exit code was non-zero ({})".format(server_proc.returncode))
+
+
+# TODO: Use the fixture in the tests instead of this thing here
+def test_integration(server_process):
+    # This is just here so that the server_process fixture is initialized
+    pass
+
+    # exit_code = pytest.main(["./aw-server/tests", "-v"])
+    # if exit_code != 0:
+    #     pytest.fail("Tests exited with non-zero code: " + str(exit_code))
diff --git a/scripts/tests/test_check_tauri_server.py b/scripts/tests/test_check_tauri_server.py
new file mode 100644
index 0000000..71f5d0e
--- /dev/null
+++ b/scripts/tests/test_check_tauri_server.py
@@ -0,0 +1,175 @@
+import importlib.util
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+SCRIPT = Path(__file__).parents[1] / "check_tauri_server.py"
+SPEC = importlib.util.spec_from_file_location("check_tauri_server", SCRIPT)
+assert SPEC and SPEC.loader
+checker = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(checker)
+
+major_minor = checker.major_minor
+read_locked_server = checker.read_locked_server
+read_package_version = checker.read_package_version
+validation_errors = checker.validation_errors
+is_git_checkout = checker.is_git_checkout
+initialize_submodule = checker.initialize_submodule
+sync_submodule = checker.sync_submodule
+
+
+REVISION = "a" * 40
+
+
+def test_major_minor_accepts_beta_release():
+    assert major_minor("0.14.0b5") == "0.14"
+
+
+def test_read_package_version(tmp_path: Path):
+    cargo_toml = tmp_path / "Cargo.toml"
+    cargo_toml.write_text(
+        '[workspace]\n\n[package]\nname = "aw-server"\nversion = "0.14.0"\n'
+    )
+
+    assert read_package_version(cargo_toml) == "0.14.0"
+
+
+def test_read_locked_server(tmp_path: Path):
+    cargo_lock = tmp_path / "Cargo.lock"
+    cargo_lock.write_text(
+        '[[package]]\nname = "other"\nversion = "1.0.0"\n\n'
+        '[[package]]\nname = "aw-server"\nversion = "0.14.0"\n'
+        'source = "git+https://github.com/ActivityWatch/aw-server-rust.git?branch=master#'
+        f'{REVISION}"\n'
+    )
+
+    assert read_locked_server(cargo_lock) == ("0.14.0", REVISION)
+
+
+def test_valid_when_release_line_and_revisions_match():
+    assert validation_errors("0.14.0b5", "0.14.0", REVISION, "0.14.0", REVISION) == []
+
+
+def test_rejects_old_tauri_release_line():
+    errors = validation_errors("0.14.0b5", "0.14.0", REVISION, "0.13.1", REVISION)
+
+    assert errors == [
+        "Tauri locks aw-server 0.13.1, which does not match release line 0.14"
+    ]
+
+
+def test_rejects_different_revision_even_on_same_release_line():
+    errors = validation_errors("0.14.0b5", "0.14.0", REVISION, "0.14.0", "b" * 40)
+
+    assert errors == [
+        "Tauri locks aw-server-rust bbbbbbbbbbbb, but the release submodule is aaaaaaaaaaaa"
+    ]
+
+
+def test_rejects_invalid_version():
+    with pytest.raises(ValueError, match="invalid version"):
+        major_minor("dev")
+
+
+def git(repo: Path, *args: str) -> str:
+    return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip()
+
+
+def make_git_repo(path: Path) -> tuple[str, str]:
+    path.mkdir()
+    subprocess.run(["git", "-C", str(path), "init"], check=True, capture_output=True)
+    subprocess.run(
+        ["git", "-C", str(path), "config", "user.email", "test@example.com"],
+        check=True,
+    )
+    subprocess.run(
+        ["git", "-C", str(path), "config", "user.name", "Test"], check=True
+    )
+    tracked = path / "tracked.txt"
+    tracked.write_text("first\n")
+    subprocess.run(["git", "-C", str(path), "add", "tracked.txt"], check=True)
+    subprocess.run(
+        ["git", "-C", str(path), "commit", "-m", "first"],
+        check=True,
+        capture_output=True,
+    )
+    first = git(path, "rev-parse", "HEAD")
+    tracked.write_text("second\n")
+    subprocess.run(
+        ["git", "-C", str(path), "commit", "-am", "second"],
+        check=True,
+        capture_output=True,
+    )
+    return first, git(path, "rev-parse", "HEAD")
+
+
+def test_sync_submodule_checks_out_locked_revision(tmp_path: Path):
+    repo = tmp_path / "aw-server-rust"
+    first, second = make_git_repo(repo)
+    assert git(repo, "rev-parse", "HEAD") == second
+
+    assert sync_submodule(repo, first)
+    assert git(repo, "rev-parse", "HEAD") == first
+    assert not sync_submodule(repo, first)
+
+
+def test_sync_submodule_refuses_dirty_checkout(tmp_path: Path):
+    repo = tmp_path / "aw-server-rust"
+    _, current = make_git_repo(repo)
+    (repo / "tracked.txt").write_text("dirty\n")
+
+    with pytest.raises(ValueError, match="refusing to replace dirty"):
+        sync_submodule(repo, current)
+
+
+def test_sync_submodule_rejects_uninitialized_nested_directory(tmp_path: Path):
+    parent = tmp_path / "activitywatch"
+    _, _ = make_git_repo(parent)
+    server = parent / "aw-server-rust"
+    server.mkdir()
+
+    with pytest.raises(ValueError, match="not initialized as a Git submodule"):
+        sync_submodule(server, REVISION)
+
+
+def test_is_git_checkout_requires_the_exact_repository_root(tmp_path: Path):
+    repo = tmp_path / "activitywatch"
+    _, _ = make_git_repo(repo)
+    nested = repo / "aw-server-rust"
+    nested.mkdir()
+
+    assert is_git_checkout(repo)
+    assert not is_git_checkout(nested)
+
+
+def test_initialize_submodule_rejects_unrelated_checkout(tmp_path: Path):
+    parent = tmp_path / "activitywatch"
+    _, _ = make_git_repo(parent)
+    unrelated = parent / "aw-server-rust"
+    _, _ = make_git_repo(unrelated)
+
+    with pytest.raises(ValueError, match="refusing unmanaged Git checkout"):
+        initialize_submodule(parent, "aw-server-rust")
+
+
+def test_initialize_submodule_rejects_old_form_checkout_with_migration_hint(
+    tmp_path: Path,
+):
+    parent = tmp_path / "activitywatch"
+    _, _ = make_git_repo(parent)
+    server = parent / "aw-server-rust"
+    _, _ = make_git_repo(server)
+    url = "https://github.com/ActivityWatch/aw-server-rust.git"
+    subprocess.run(
+        ["git", "-C", str(server), "remote", "add", "origin", url], check=True
+    )
+    (parent / ".gitmodules").write_text(
+        f'[submodule "aw-server-rust"]\n'
+        "\tpath = aw-server-rust\n"
+        f"\turl = {url}\n"
+    )
+
+    with pytest.raises(ValueError, match="git submodule absorbgitdirs aw-server-rust"):
+        initialize_submodule(parent, "aw-server-rust")
diff --git a/scripts/tests/test_configure_tauri_release.py b/scripts/tests/test_configure_tauri_release.py
new file mode 100644
index 0000000..7f2b9f8
--- /dev/null
+++ b/scripts/tests/test_configure_tauri_release.py
@@ -0,0 +1,290 @@
+import base64
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import textwrap
+
+import pytest
+
+ROOT = Path(__file__).parents[2]
+CONFIGURE = ROOT / "scripts/package/configure_tauri_release.py"
+GENERATOR = ROOT / "scripts/package/generate_latest_json.py"
+RESEARCH_ENDPOINT = (
+    "https://raw.githubusercontent.com/ActivityWatch/activitywatch/"
+    "research-updates/latest-research.json"
+)
+
+sys.path.insert(0, str(ROOT / "scripts/package"))
+from configure_tauri_release import configure, msi_rejects  # noqa: E402
+
+
+def key(material=b"r" * 32, comment="fixture", key_id=b"i" * 8):
+    packet = base64.b64encode(b"Ed" + key_id + material).decode()
+    return base64.b64encode(
+        f"untrusted comment: {comment}\n{packet}\n".encode()
+    ).decode()
+
+
+@pytest.fixture
+def config(tmp_path):
+    # Exercise the real pinned Tauri config, including its standard public key.
+    source = ROOT / "aw-tauri/src-tauri/tauri.conf.json"
+    assert source.exists(), "Initialize the aw-tauri submodule before these tests"
+    path = tmp_path / "tauri.conf.json"
+    path.write_bytes(source.read_bytes())
+    return path
+
+
+def run_configure(config, *args, public="", private="", version="v0.14.0b5-research"):
+    env = dict(
+        os.environ,
+        TAURI_UPDATER_PUBLIC_KEY_RESEARCH=public,
+        TAURI_SIGNING_PRIVATE_KEY=private,
+    )
+    return subprocess.run(
+        [
+            sys.executable,
+            str(CONFIGURE),
+            "--config",
+            str(config),
+            "--version",
+            version,
+            *args,
+        ],
+        env=env,
+        text=True,
+        capture_output=True,
+    )
+
+
+@pytest.mark.parametrize(
+    "version,rejects",
+    [
+        ("0.14.0", False),
+        ("0.14.0-5", False),
+        ("0.14.0-beta.5", True),
+        ("0.14.0-dev.gabc1234", True),
+        ("0.14.0-rc.1", True),
+    ],
+)
+def test_msi_rejects(version, rejects):
+    assert msi_rejects(version) is rejects
+
+
+def test_windows_drops_msi_for_non_numeric_prerelease(config):
+    # v0.14.0b5 -> "0.14.0-beta.5", which the msi (WiX) bundler rejects.
+    configure(
+        config, "v0.14.0b5", research=False, require_signing_key=False, platform="win32"
+    )
+    after = json.loads(config.read_text())
+    assert after["version"] == "0.14.0-beta.5"
+    assert after["bundle"]["targets"] == ["nsis"]
+
+
+def test_non_windows_keeps_all_targets_for_non_numeric_prerelease(config):
+    configure(
+        config,
+        "v0.14.0b5",
+        research=False,
+        require_signing_key=False,
+        platform="darwin",
+    )
+    after = json.loads(config.read_text())
+    assert after["bundle"]["targets"] == "all"
+
+
+def test_windows_keeps_all_targets_for_msi_safe_version(config):
+    configure(
+        config, "v0.14.0", research=False, require_signing_key=False, platform="win32"
+    )
+    after = json.loads(config.read_text())
+    assert after["version"] == "0.14.0"
+    assert after["bundle"]["targets"] == "all"
+
+
+def test_standard_version_changes_without_changing_update_trust(config):
+    before = json.loads(config.read_text())
+    result = run_configure(config, version="v0.14.0rc2")
+    assert result.returncode == 0, result.stderr
+    after = json.loads(config.read_text())
+    assert after["version"] == "0.14.0-rc.2"
+    before["version"] = after["version"]
+    assert after == before
+
+
+def test_research_release_replaces_endpoint_and_key(config):
+    result = run_configure(
+        config,
+        "--research",
+        "--require-signing-key",
+        public=key(),
+        private="fixture-secret-presence-only",
+    )
+    assert result.returncode == 0, result.stderr
+    after = json.loads(config.read_text())
+    assert after["version"] == "0.14.0-beta.5"
+    assert after["plugins"]["updater"]["endpoints"] == [RESEARCH_ENDPOINT]
+    assert after["plugins"]["updater"]["pubkey"] == key()
+
+
+def test_secretless_research_smoke_build_has_no_update_trust(config):
+    result = run_configure(config, "--research")
+    assert result.returncode == 0, result.stderr
+    updater = json.loads(config.read_text())["plugins"]["updater"]
+    assert updater["endpoints"] == []
+    assert updater["pubkey"] == ""
+
+
+@pytest.mark.parametrize("public,private", [("", ""), (key(), ""), ("", "secret")])
+def test_release_missing_either_key_fails_without_modifying_config(
+    config, public, private
+):
+    before = config.read_bytes()
+    result = run_configure(
+        config, "--research", "--require-signing-key", public=public, private=private
+    )
+    assert result.returncode != 0
+    assert "require their own public and signing keys" in result.stderr
+    assert config.read_bytes() == before
+
+
+@pytest.mark.parametrize("change_envelope", [False, True])
+def test_same_standard_key_rejected_even_with_changed_comment_and_id(
+    config, change_envelope
+):
+    before = config.read_bytes()
+    standard = json.loads(before)["plugins"]["updater"]["pubkey"]
+    if change_envelope:
+        packet = base64.b64decode(base64.b64decode(standard).decode().splitlines()[1])
+        standard = key(packet[10:], comment="research", key_id=b"j" * 8)
+    result = run_configure(config, "--research", public=standard)
+    assert result.returncode != 0
+    assert "must differ" in result.stderr
+    assert config.read_bytes() == before
+
+
+@pytest.mark.parametrize("bad_key", ["bad-base64", "dGVzdA==", key(b"short")])
+def test_malformed_public_key_rejected(config, bad_key):
+    before = config.read_bytes()
+    result = run_configure(config, "--research", public=bad_key)
+    assert result.returncode != 0
+    assert config.read_bytes() == before
+
+
+def test_endpoint_drift_fails_closed(config):
+    data = json.loads(config.read_text())
+    data["plugins"]["updater"]["endpoints"].append("https://example.com/latest.json")
+    config.write_text(json.dumps(data))
+    before = config.read_bytes()
+    result = run_configure(config, "--research", public=key())
+    assert result.returncode != 0
+    assert "Unexpected source updater endpoint" in result.stderr
+    assert config.read_bytes() == before
+
+
+@pytest.mark.parametrize(
+    "event,ref,research,success",
+    [
+        ("push", "refs/tags/v0.14.0b5-research", "true", False),
+        ("workflow_dispatch", "refs/heads/master", "true", False),
+        ("pull_request", "refs/pull/1/merge", "true", True),
+        ("push", "refs/heads/master", "true", True),
+        ("push", "refs/tags/v0.14.0", "false", True),
+    ],
+)
+def test_workflow_requires_keys_only_for_publishable_research_builds(
+    config,
+    event,
+    ref,
+    research,
+    success,
+):
+    workflow = (ROOT / ".github/workflows/release.yml").read_text()
+    step = workflow.split(
+        "      - name: Configure Tauri release version and update channel\n"
+    )[1]
+    script = textwrap.dedent(
+        step.split("        run: |\n")[1].split("        env:\n")[0]
+    )
+    work = config.parent / "build"
+    shutil.copytree(ROOT / "scripts/package", work / "scripts/package")
+    target = work / "aw-tauri/src-tauri/tauri.conf.json"
+    target.parent.mkdir(parents=True)
+    shutil.copyfile(config, target)
+    env = dict(
+        os.environ,
+        AW_RESEARCH_EDITION=research,
+        GITHUB_REF=ref,
+        GITHUB_EVENT_NAME=event,
+        VERSION_NO_V="0.14.0b5",
+        TAURI_UPDATER_PUBLIC_KEY_RESEARCH="",
+        TAURI_SIGNING_PRIVATE_KEY="",
+    )
+    result = subprocess.run(
+        ["bash", "-e", "-c", script], cwd=work, env=env, text=True, capture_output=True
+    )
+    assert (result.returncode == 0) == success, result.stderr
+    if success and research == "true":
+        assert json.loads(target.read_text())["plugins"]["updater"]["endpoints"] == []
+    if not success:
+        assert target.read_bytes() == config.read_bytes()
+
+
+@pytest.mark.parametrize("edition", ["standard", "research"])
+def test_manifest_and_compiled_config_share_version_and_partition_assets(
+    config, edition
+):
+    tag = "v0.14.0b5" + ("-research" if edition == "research" else "")
+    result = run_configure(
+        config,
+        *(["--research"] if edition == "research" else []),
+        version=tag,
+        public=key(),
+    )
+    assert result.returncode == 0, result.stderr
+    for token in ("", "-research"):
+        bundle = (
+            config.parent / f"activitywatch-tauri{token}-0.14.0b5-linux-x86_64.AppImage"
+        )
+        bundle.write_bytes(b"fixture, not a cryptographic verification")
+        bundle.with_suffix(".AppImage.sig").write_text("sig" + token)
+    output = config.parent / (
+        "latest-research.json" if edition == "research" else "latest.json"
+    )
+    result = subprocess.run(
+        [
+            sys.executable,
+            str(GENERATOR),
+            "--version",
+            tag,
+            "--tag",
+            tag,
+            "--edition",
+            edition,
+            "--repo",
+            "ActivityWatch/activitywatch",
+            "--notes",
+            "fixture",
+            "--dist",
+            str(config.parent),
+            "--output",
+            str(output),
+        ],
+        text=True,
+        capture_output=True,
+    )
+    assert result.returncode == 0, result.stderr
+    manifest = json.loads(output.read_text())
+    assert (
+        manifest["version"]
+        == json.loads(config.read_text())["version"]
+        == "0.14.0-beta.5"
+    )
+    assert list(manifest["platforms"]) == ["linux-x86_64"]
+    target = manifest["platforms"]["linux-x86_64"]
+    assert ("-research" in target["url"]) == (edition == "research")
+    assert f"/releases/download/{tag}/" in target["url"]
+    assert target["signature"] == "sig" + ("-research" if edition == "research" else "")
diff --git a/scripts/tests/test_emit_research_category_preset.py b/scripts/tests/test_emit_research_category_preset.py
new file mode 100644
index 0000000..b42a305
--- /dev/null
+++ b/scripts/tests/test_emit_research_category_preset.py
@@ -0,0 +1,162 @@
+import importlib.util
+import json
+import re
+from pathlib import Path
+
+
+def _load(name: str):
+    path = Path(__file__).parents[1] / f"{name}.py"
+    spec = importlib.util.spec_from_file_location(name, path)
+    assert spec and spec.loader
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+emitter = _load("emit_research_category_preset")
+patcher = _load("patch_research_edition_config")
+
+
+def test_preset_covers_every_category_in_the_watcher_map():
+    """Preset and watcher map must share one source, or the UI drifts from the data."""
+    expected = {c for _, c in patcher.CATEGORY_MAP} | set(
+        patcher.APP_CATEGORY_MAP.values()
+    )
+
+    names = {c["name"][0] for c in emitter.build_preset()["categories"]}
+
+    assert names == expected
+
+
+def _classify_event(data: dict[str, str]) -> str | None:
+    """Mirror aw-webui's parsed-rule contract and equal-depth matching."""
+    matches: list[str] = []
+    for category in emitter.build_preset()["categories"]:
+        rule = category["rule"]
+        flags = re.IGNORECASE if rule["ignore_case"] else 0
+        if any(
+            key in data and re.search(rule["regex"], data[key], flags)
+            for key in ("app", "title")
+        ):
+            matches.append(category["name"][0])
+    assert len(matches) <= 1, (
+        f"aw-webui cannot prioritize equal-depth matches: {matches}"
+    )
+    return matches[0] if matches else None
+
+
+def test_rules_match_browser_categories_and_raw_app_aliases():
+    """Browser titles and retained app names resolve to the same taxonomy."""
+    assert _classify_event({"app": "Safari", "title": "Work & Productivity"}) == (
+        "Work & Productivity"
+    )
+    assert _classify_event({"app": "Microsoft Word"}) == "Work & Productivity"
+    assert _classify_event({"app": "SPOTIFY"}) == "Music & Audio"
+    assert _classify_event({"app": "Terminal"}) == "Excluded"
+    assert _classify_event({"app": "Some Unmapped Program"}) is None
+
+
+def test_rules_are_exact_and_scoped_to_approved_fields():
+    categories = {
+        category["name"][0]: category
+        for category in emitter.build_preset()["categories"]
+    }
+    work_rule = categories["Work & Productivity"]["rule"]
+
+    assert re.search(work_rule["regex"], "Microsoft Word", re.IGNORECASE)
+    assert not re.search(work_rule["regex"], "Microsoft Word extra", re.IGNORECASE)
+    assert _classify_event({"hostname": "Microsoft Word"}) is None
+
+
+def test_rules_use_only_fields_preserved_by_the_aw_webui_preset_parser():
+    """The oldest pinned carrier strips unknown keys; behavior must survive that."""
+    for category in emitter.build_preset()["categories"]:
+        assert set(category["rule"]) <= {"type", "regex", "ignore_case"}
+
+
+def test_excluded_rule_only_matches_explicitly_excluded_apps():
+    categories = {
+        category["name"][0]: category
+        for category in emitter.build_preset()["categories"]
+    }
+
+    excluded = categories["Excluded"]["rule"]["regex"]
+    assert re.search(excluded, "Terminal", re.IGNORECASE)
+    assert not re.search(excluded, "Outlook", re.IGNORECASE)
+    assert not re.search(excluded, "Some Unmapped Program", re.IGNORECASE)
+
+
+def test_every_known_value_matches_exactly_one_rule_after_parser_projection():
+    """Catch overlaps the production classifier cannot resolve at equal depth."""
+    values = {category["name"][0] for category in emitter.build_preset()["categories"]}
+    values |= set(patcher.APP_CATEGORY_MAP)
+
+    for value in values:
+        assert _classify_event({"app": value}) is not None
+
+
+def test_escaping_is_portable_to_javascript_unicode_mode():
+    """`re.escape` is unusable here: it emits `\\ ` and `\\&`.
+
+    Those are valid in Python but are *invalid identity escapes* in JavaScript
+    unicode-mode regex, so `new RegExp(r, "u")` throws on 14 of the 18 study
+    categories. aw-webui compiles without the `u` flag today, which is the only
+    reason `re.escape` would appear to work -- and the failure would present as
+    "categories don't show", indistinguishable from the bug this preset fixes.
+    """
+    for category in emitter.build_preset()["categories"]:
+        pattern = category["rule"]["regex"]
+        escaped = {pattern[i + 1] for i, ch in enumerate(pattern[:-1]) if ch == "\\"}
+
+        assert escaped <= emitter._REGEX_METACHARACTERS, (
+            f"{pattern!r} escapes characters JavaScript rejects under the u flag"
+        )
+
+
+def test_escape_portable_still_escapes_real_metacharacters():
+    """A future category name containing a metacharacter must not become a wildcard."""
+    assert emitter.escape_portable("a.b") == r"a\.b"
+    assert emitter.escape_portable("x(y)") == r"x\(y\)"
+    assert emitter.escape_portable("Shopping - Goods") == "Shopping - Goods"
+    assert re.fullmatch(emitter.escape_portable("a.b"), "a.b")
+    assert not re.fullmatch(emitter.escape_portable("a.b"), "axb")
+
+
+def test_exact_alternation_is_stable_and_whole_value():
+    pattern = emitter.exact_alternation({"zoom.us", "Zoom"})
+
+    assert pattern == r"^(?:Zoom|zoom\.us)$"
+    assert re.fullmatch(pattern, "Zoom")
+    assert re.fullmatch(pattern, "zoom.us")
+    assert not re.fullmatch(pattern, "zoom.us meeting")
+
+
+def test_output_is_stable_across_runs():
+    """CI reruns must not produce a different preset from the same map."""
+    assert emitter.build_preset() == emitter.build_preset()
+
+
+def test_serialises_without_newlines_for_github_env():
+    """The value is written straight into $GITHUB_ENV, which ends at a newline."""
+    payload = json.dumps([emitter.build_preset()], separators=(",", ":"))
+
+    assert "\n" not in payload
+    assert json.loads(payload)[0]["id"] == "research-study"
+
+
+def test_every_category_has_a_distinct_hex_color():
+    """Activity view has no name-hash fallback; missing data.color renders grey."""
+    categories = emitter.build_preset()["categories"]
+    colors = [category["data"]["color"] for category in categories]
+
+    assert len(colors) == len(categories)
+    assert len(set(colors)) == len(colors)
+    for color in colors:
+        assert re.fullmatch(r"#[0-9A-F]{6}", color)
+
+
+def test_color_table_matches_the_study_taxonomy():
+    """A new map entry without a color must fail the build, not ship unstyled."""
+    names = {category["name"][0] for category in emitter.build_preset()["categories"]}
+
+    assert set(emitter.CATEGORY_COLORS) == names
diff --git a/scripts/tests/test_generate_latest_json.py b/scripts/tests/test_generate_latest_json.py
new file mode 100644
index 0000000..e878a2a
--- /dev/null
+++ b/scripts/tests/test_generate_latest_json.py
@@ -0,0 +1,191 @@
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+GENERATOR = Path(__file__).parents[1] / "package" / "generate_latest_json.py"
+
+
+def _load():
+    spec = importlib.util.spec_from_file_location("generate_latest_json", GENERATOR)
+    assert spec and spec.loader
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+gen = _load()
+
+
+def _write_sig(root: Path, name: str, signature: str = "sig-bytes") -> None:
+    path = root / name
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(signature + "\n")
+    # Matching binary next to the .sig, as the packaging step copies both.
+    path.with_name(name[: -len(".sig")]).write_bytes(b"bundle")
+
+
+def test_normalize_version_strips_v_and_research_suffix():
+    assert gen.normalize_version("v0.14.0b4") == "0.14.0b4"
+    assert gen.normalize_version("0.14.0b4") == "0.14.0b4"
+    assert gen.normalize_version("v0.14.0b4-research") == "0.14.0b4"
+    assert gen.normalize_version("0.14.0b4-research") == "0.14.0b4"
+
+
+@pytest.mark.parametrize(
+    "version,expected",
+    [
+        ("v0.14.0", "0.14.0"),
+        ("v0.14.0b5-research", "0.14.0-beta.5"),
+        ("0.14.0a1", "0.14.0-alpha.1"),
+        ("0.14.0rc2", "0.14.0-rc.2"),
+        ("v0.14.0.dev-0123456", "0.14.0-dev.g0123456"),
+        ("v0.14.0b5.dev-abcdef0", "0.14.0-beta.5.dev.gabcdef0"),
+    ],
+)
+def test_tauri_version(version, expected):
+    assert gen.tauri_version(version) == expected
+
+
+@pytest.mark.parametrize("version", ["0.14", "0.14.0b01", "00.14.0", "0.14.0oops", ""])
+def test_tauri_version_refuses_unrecognized_labels(version):
+    with pytest.raises(ValueError):
+        gen.tauri_version(version)
+
+
+def test_infer_edition_from_tag_or_explicit_flag():
+    assert gen.infer_edition("v0.14.0") == "standard"
+    assert gen.infer_edition("v0.14.0b4") == "standard"
+    assert gen.infer_edition("v0.14.0b4-research") == "research"
+    assert gen.infer_edition("v0.14.0b4-research", "standard") == "standard"
+    assert gen.infer_edition("v0.14.0", "research") == "research"
+
+
+def test_asset_prefix_and_manifest_filename_partition_editions():
+    assert gen.asset_prefix("standard") == "activitywatch-tauri"
+    assert gen.asset_prefix("research") == "activitywatch-tauri-research"
+    assert gen.manifest_filename("standard") == "latest.json"
+    assert gen.manifest_filename("research") == "latest-research.json"
+
+
+def test_standard_collect_ignores_research_artifacts(tmp_path):
+    _write_sig(
+        tmp_path,
+        "activitywatch-tauri-0.14.0b4-darwin-aarch64.app.tar.gz.sig",
+        "std-sig",
+    )
+    _write_sig(
+        tmp_path,
+        "activitywatch-tauri-research-0.14.0b4-darwin-aarch64.app.tar.gz.sig",
+        "research-sig",
+    )
+
+    platforms = gen.collect_platforms(
+        str(tmp_path),
+        "0.14.0b4",
+        "ActivityWatch/activitywatch",
+        "v0.14.0b4",
+        "standard",
+    )
+
+    assert list(platforms) == ["darwin-aarch64"]
+    assert platforms["darwin-aarch64"]["signature"] == "std-sig"
+    assert platforms["darwin-aarch64"]["url"].endswith(
+        "/v0.14.0b4/activitywatch-tauri-0.14.0b4-darwin-aarch64.app.tar.gz"
+    )
+
+
+def test_research_collect_ignores_standard_artifacts(tmp_path):
+    _write_sig(
+        tmp_path,
+        "activitywatch-tauri-0.14.0b4-linux-x86_64.AppImage.tar.gz.sig",
+        "std-sig",
+    )
+    _write_sig(
+        tmp_path,
+        "activitywatch-tauri-research-0.14.0b4-linux-x86_64.AppImage.tar.gz.sig",
+        "research-sig",
+    )
+
+    platforms = gen.collect_platforms(
+        str(tmp_path),
+        "0.14.0b4",
+        "ActivityWatch/activitywatch",
+        "v0.14.0b4-research",
+        "research",
+    )
+
+    assert list(platforms) == ["linux-x86_64"]
+    assert platforms["linux-x86_64"]["signature"] == "research-sig"
+    assert platforms["linux-x86_64"]["url"].endswith(
+        "/v0.14.0b4-research/"
+        "activitywatch-tauri-research-0.14.0b4-linux-x86_64.AppImage.tar.gz"
+    )
+
+
+def test_windows_prefers_nsis_over_msi_regardless_of_walk_order(tmp_path):
+    _write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.msi.zip.sig", "msi-sig")
+    _write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig", "nsis-sig")
+
+    platforms = gen.collect_platforms(
+        str(tmp_path),
+        "0.14.0",
+        "ActivityWatch/activitywatch",
+        "v0.14.0",
+        "standard",
+    )
+
+    assert list(platforms) == ["windows-x86_64"]
+    assert platforms["windows-x86_64"]["signature"] == "nsis-sig"
+    assert platforms["windows-x86_64"]["url"].endswith(
+        "/v0.14.0/activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip"
+    )
+
+
+def test_main_writes_manifest_and_refuses_empty(tmp_path):
+    _write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig")
+    out = tmp_path / "latest.json"
+
+    gen.main(
+        [
+            "--version",
+            "v0.14.0",
+            "--notes",
+            "ActivityWatch v0.14.0",
+            "--repo",
+            "ActivityWatch/activitywatch",
+            "--tag",
+            "v0.14.0",
+            "--edition",
+            "standard",
+            "--dist",
+            str(tmp_path),
+            "--output",
+            str(out),
+        ]
+    )
+
+    manifest = json.loads(out.read_text())
+    assert manifest["version"] == "0.14.0"
+    assert "windows-x86_64" in manifest["platforms"]
+
+    empty = tmp_path / "empty"
+    empty.mkdir()
+    with pytest.raises(SystemExit, match="No standard updater artifacts"):
+        gen.main(
+            [
+                "--version",
+                "0.14.0",
+                "--notes",
+                "none",
+                "--repo",
+                "ActivityWatch/activitywatch",
+                "--tag",
+                "v0.14.0",
+                "--dist",
+                str(empty),
+                "--output",
+                str(tmp_path / "latest.json"),
+            ]
+        )
diff --git a/scripts/tests/test_patch_research_edition_config.py b/scripts/tests/test_patch_research_edition_config.py
new file mode 100644
index 0000000..2be20d1
--- /dev/null
+++ b/scripts/tests/test_patch_research_edition_config.py
@@ -0,0 +1,223 @@
+import ast
+import importlib.util
+import tomllib
+from pathlib import Path
+
+import pytest
+
+
+SCRIPT = Path(__file__).parents[1] / "patch_research_edition_config.py"
+SPEC = importlib.util.spec_from_file_location("patch_research_edition_config", SCRIPT)
+assert SPEC and SPEC.loader
+patcher = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(patcher)
+
+
+# Current config.py reads both maps. The Research Edition patch deliberately
+# leaves the app map empty so approved application names survive capture.
+RUNTIME_LOOKUPS = """
+
+def parse_args():
+    parsed_args.research_category_map = dict(config.get("research_category_map", {}))
+    parsed_args.research_app_category_map = dict(config.get("research_app_category_map", {}))
+"""
+
+# Layout before aw-watcher-window#137: the research tables live in
+# default_config, section-prefixed.
+PRE_137 = (
+    '''default_config = """
+[aw-watcher-window]
+poll_time = 1.0
+research_enabled = false
+
+[aw-watcher-window.research_category_map]
+
+[aw-watcher-window.research_app_category_map]
+""".strip()
+'''
+    + RUNTIME_LOOKUPS
+)
+
+# Layout since aw-watcher-window#137: research knobs moved to their own template
+# so they are not persisted into every fresh install's config, and a comment
+# documents the release-time rewrite -- including the literal flag text.
+POST_137 = (
+    '''default_config = """
+[aw-watcher-window]
+poll_time = 1.0
+""".strip()
+
+# The Research Edition release build rewrites the flag below with
+#   sed -i 's/^research_enabled = false$/research_enabled = true/'
+# Keep that line at column 0 and byte-identical.
+research_defaults = """
+research_enabled = false
+""".strip()
+'''
+    + RUNTIME_LOOKUPS
+)
+
+
+def _string_constant(source: str, name: str) -> str:
+    """Return the value of a module-level string assignment, unwrapping .strip()."""
+    for node in ast.parse(source).body:
+        if (
+            isinstance(node, ast.Assign)
+            and getattr(node.targets[0], "id", None) == name
+        ):
+            value = node.value
+            if isinstance(value, ast.Call):  # `""" ... """.strip()`
+                value = value.func.value
+            return ast.literal_eval(value)
+    raise AssertionError(f"{name} not found")
+
+
+def test_pre_137_layout_injects_browser_map_and_preserves_app_names():
+    result = patcher.patch_config(PRE_137)
+
+    ast.parse(result)
+
+    section = tomllib.loads(_string_constant(result, "default_config"))[
+        "aw-watcher-window"
+    ]
+    assert section["research_enabled"] is True
+    assert section["research_category_map"]["svenskaspel.se"] == "Sensitive / Excluded"
+    assert section["research_app_category_map"] == {}
+
+
+def test_post_137_layout_injects_only_browser_map_into_research_defaults():
+    """The tables must land in research_defaults, with unprefixed headers.
+
+    research_defaults is parsed standalone and merged into the
+    [aw-watcher-window] section key by key, so a section-prefixed header there
+    would produce a nested `aw-watcher-window` key that nothing reads.
+    """
+    result = patcher.patch_config(POST_137)
+
+    ast.parse(result)
+
+    defaults = tomllib.loads(_string_constant(result, "research_defaults"))
+    assert "aw-watcher-window" not in defaults
+    assert defaults["research_enabled"] is True
+    assert defaults["research_category_map"]["svenskaspel.se"] == "Sensitive / Excluded"
+    # An empty table is emitted so upgrades from an earlier Research Edition
+    # (which may have had this map populated) explicitly clear it.
+    assert defaults["research_app_category_map"] == {}
+
+
+def test_post_137_emits_explicit_empty_app_map_to_clear_legacy_entries():
+    """The patched research_defaults must include an explicit empty app-category table.
+
+    An upgrade from an earlier Research Edition may have a populated
+    research_app_category_map in the user's saved config. Because research_defaults
+    is merged into [aw-watcher-window] key-by-key, omitting the table would leave
+    legacy entries intact, so application names would continue to be replaced with
+    categories, defeating the primary behavior change. The explicit empty section
+    header ensures the watcher's merge logic resets the key.
+    """
+    result = patcher.patch_config(POST_137)
+
+    defaults_str = _string_constant(result, "research_defaults")
+    # The literal section header must appear so older installs see an explicit reset.
+    assert "[research_app_category_map]" in defaults_str
+    assert tomllib.loads(defaults_str)["research_app_category_map"] == {}
+
+
+def test_patched_config_matches_the_pinned_watcher_storage_contract():
+    """Approved app names survive, while browser content and every URL do not."""
+    root = Path(__file__).resolve().parents[2]
+    config_path = root / "aw-watcher-window/aw_watcher_window/config.py"
+    filter_path = root / "aw-watcher-window/aw_watcher_window/research_filter.py"
+    if not config_path.is_file() or not filter_path.is_file():
+        pytest.skip("aw-watcher-window not checked out")
+
+    patched = patcher.patch_config(config_path.read_text(encoding="utf-8"))
+    defaults = tomllib.loads(_string_constant(patched, "research_defaults"))
+
+    spec = importlib.util.spec_from_file_location("research_filter", filter_path)
+    assert spec and spec.loader
+    research_filter = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(research_filter)
+
+    word = research_filter.transform(
+        {
+            "app": "Microsoft Word",
+            "title": "confidential-draft.docx",
+            "url": "file:///private/confidential-draft.docx",
+        },
+        defaults["research_category_map"],
+        defaults.get("research_app_category_map"),
+    )
+    safari = research_filter.transform(
+        {
+            "app": "Safari",
+            "title": "Confidential draft - Google Docs",
+            "url": "https://docs.google.com/document/private-id",
+        },
+        defaults["research_category_map"],
+        defaults.get("research_app_category_map"),
+    )
+
+    assert word == {"app": "Microsoft Word"}
+    assert safari == {"app": "Safari", "title": "Work & Productivity"}
+
+
+def test_flag_rewrite_is_line_anchored_and_spares_the_sed_comment():
+    """The #137 comment contains the literal flag text, and precedes the real flag.
+
+    An unanchored replace patches the comment and leaves research_enabled = false
+    -- a green build shipping a Research Edition with research silently disabled.
+    """
+    result = patcher.patch_config(POST_137)
+
+    assert "s/^research_enabled = false$/research_enabled = true/" in result
+    assert (
+        tomllib.loads(_string_constant(result, "research_defaults"))["research_enabled"]
+        is True
+    )
+
+
+def test_app_map_runtime_support_is_not_required_for_name_preserving_build():
+    without_lookup = POST_137.replace(
+        'config.get("research_app_category_map"', 'config.get("something_else"'
+    )
+
+    result = patcher.patch_config(without_lookup)
+
+    defaults = tomllib.loads(_string_constant(result, "research_defaults"))
+    # An empty app-map table is always emitted to clear legacy populated maps;
+    # the watcher ignores it if it does not read research_app_category_map.
+    assert defaults["research_app_category_map"] == {}
+
+
+def test_fails_closed_when_flag_is_missing():
+    with pytest.raises(ValueError, match="research_enabled = false"):
+        patcher.patch_config(
+            f'default_config = """\n[aw-watcher-window]\n"""{RUNTIME_LOOKUPS}'
+        )
+
+
+def test_fails_closed_on_ambiguous_flag():
+    """Two line-anchored flags mean an unknown layout -- refuse rather than guess."""
+    ambiguous = POST_137.replace(
+        "research_enabled = false\n",
+        "research_enabled = false\nresearch_enabled = false\n",
+        1,
+    )
+
+    with pytest.raises(ValueError, match="found 2"):
+        patcher.patch_config(ambiguous)
+
+
+def test_pre_137_layout_does_not_require_app_section():
+    missing_app_table = PRE_137.replace(
+        "\n[aw-watcher-window.research_app_category_map]\n", "\n"
+    )
+
+    result = patcher.patch_config(missing_app_table)
+
+    section = tomllib.loads(_string_constant(result, "default_config"))[
+        "aw-watcher-window"
+    ]
+    assert section["research_enabled"] is True
+    assert "research_app_category_map" not in section
diff --git a/scripts/tests/test_patch_research_edition_export.py b/scripts/tests/test_patch_research_edition_export.py
new file mode 100644
index 0000000..4d82002
--- /dev/null
+++ b/scripts/tests/test_patch_research_edition_export.py
@@ -0,0 +1,139 @@
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parents[1] / "patch_research_edition_export.py"
+SPEC = importlib.util.spec_from_file_location("patch_research_edition_export", SCRIPT)
+assert SPEC and SPEC.loader
+patcher = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(patcher)
+
+CONFIG_PATCHER_PATH = Path(__file__).parents[1] / "patch_research_edition_config.py"
+CONFIG_SPEC = importlib.util.spec_from_file_location(
+    "patch_research_edition_config", CONFIG_PATCHER_PATH
+)
+assert CONFIG_SPEC and CONFIG_SPEC.loader
+config_patcher = importlib.util.module_from_spec(CONFIG_SPEC)
+CONFIG_SPEC.loader.exec_module(config_patcher)
+
+
+def _write_tree(tmp_path: Path, export: str, bucket: str, mod: str) -> Path:
+    endpoints = tmp_path / "aw-server-rust" / "aw-server" / "src" / "endpoints"
+    endpoints.mkdir(parents=True)
+    (endpoints / "export.rs").write_text(export, encoding="utf-8")
+    (endpoints / "bucket.rs").write_text(bucket, encoding="utf-8")
+    (endpoints / "mod.rs").write_text(mod, encoding="utf-8")
+    return tmp_path
+
+
+EXPORT_SRC = """use std::collections::HashMap;
+
+pub fn buckets_export() {
+    for (bid, mut bucket) in buckets.drain() {
+        export.buckets.insert(bid, bucket);
+    }
+
+    Ok(export.into())
+}
+"""
+
+BUCKET_SRC = """pub fn bucket_export() {
+    export.buckets.insert(bucket_id.into(), bucket);
+
+    Ok(export.into())
+}
+"""
+
+MOD_SRC = """mod util;
+mod export;
+mod hostcheck;
+"""
+
+
+def test_patch_inserts_module_and_both_call_sites(tmp_path: Path):
+    root = _write_tree(tmp_path, EXPORT_SRC, BUCKET_SRC, MOD_SRC)
+
+    patcher.patch_tree(root)
+
+    export = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
+        encoding="utf-8"
+    )
+    bucket = (root / "aw-server-rust/aw-server/src/endpoints/bucket.rs").read_text(
+        encoding="utf-8"
+    )
+    mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text(
+        encoding="utf-8"
+    )
+    copied = root / "aw-server-rust/aw-server/src/endpoints/export_sanitize.rs"
+
+    assert copied.is_file()
+    assert "mod export_sanitize;" in mod
+    assert patcher.MARKER in export
+    assert patcher.MARKER in bucket
+    assert "sanitize_buckets_export" in export
+    assert "sanitize_buckets_export" in bucket
+    assert "Status::Conflict" in export
+    assert "Status::Conflict" in bucket
+
+
+def test_patch_is_idempotent(tmp_path: Path):
+    root = _write_tree(tmp_path, EXPORT_SRC, BUCKET_SRC, MOD_SRC)
+    patcher.patch_tree(root)
+    first = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
+        encoding="utf-8"
+    )
+    patcher.patch_tree(root)
+    second = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
+        encoding="utf-8"
+    )
+    assert first == second
+    mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text(
+        encoding="utf-8"
+    )
+    assert mod.count("mod export_sanitize;") == 1
+
+
+def test_patch_fails_closed_without_export_marker(tmp_path: Path):
+    root = _write_tree(tmp_path, "fn buckets_export() {}\n", BUCKET_SRC, MOD_SRC)
+    with pytest.raises(ValueError, match="insertion point"):
+        patcher.patch_tree(root)
+
+
+def test_live_tree_is_patchable_or_already_patched():
+    root = Path(__file__).resolve().parents[2]
+    export = root / "aw-server-rust/aw-server/src/endpoints/export.rs"
+    bucket = root / "aw-server-rust/aw-server/src/endpoints/bucket.rs"
+    if not export.is_file() or not bucket.is_file():
+        pytest.skip("aw-server-rust not checked out")
+    export_text = export.read_text(encoding="utf-8")
+    bucket_text = bucket.read_text(encoding="utf-8")
+    assert (
+        patcher.MARKER in export_text
+        or export_text.count(patcher.EXPORT_INSERT_NEEDLE) == 1
+    )
+    assert (
+        patcher.MARKER in bucket_text
+        or bucket_text.count(patcher.BUCKET_INSERT_NEEDLE) == 1
+    )
+
+
+def test_sanitizer_allowlist_covers_config_categories():
+    rust = (
+        Path(__file__).parents[1] / "research_edition" / "export_sanitize.rs"
+    ).read_text(encoding="utf-8")
+    expected = {c for _, c in config_patcher.CATEGORY_MAP} | set(
+        config_patcher.APP_CATEGORY_MAP.values()
+    )
+    expected.update({"Excluded", "excluded"})
+    missing = [category for category in expected if f'"{category}"' not in rust]
+    assert missing == []
+
+
+def test_every_research_build_patches_the_export_sanitizer():
+    workflow = (
+        Path(__file__).resolve().parents[2] / ".github" / "workflows" / "release.yml"
+    ).read_text(encoding="utf-8")
+
+    assert workflow.count("python3 scripts/patch_research_edition_config.py") == 3
+    assert workflow.count("python3 scripts/patch_research_edition_export.py") == 3
diff --git a/scripts/tests/test_patch_research_edition_profile.py b/scripts/tests/test_patch_research_edition_profile.py
new file mode 100644
index 0000000..1160bab
--- /dev/null
+++ b/scripts/tests/test_patch_research_edition_profile.py
@@ -0,0 +1,646 @@
+import fnmatch
+import importlib.util
+import os
+import re
+import subprocess
+import sys
+from pathlib import Path
+from string import Template
+
+import pytest
+
+SCRIPT = Path(__file__).parents[1] / "patch_research_edition_profile.py"
+SPEC = importlib.util.spec_from_file_location("patch_research_edition_profile", SCRIPT)
+assert SPEC and SPEC.loader
+patcher = importlib.util.module_from_spec(SPEC)
+# dataclasses.dataclass() looks the defining module up in sys.modules (to
+# resolve string type hints), so it must be registered before exec_module
+# runs the class body -- otherwise the `Patch` dataclass fails to import.
+sys.modules[SPEC.name] = patcher
+SPEC.loader.exec_module(patcher)
+
+
+def make_fixture_root(tmp_path: Path, target: str) -> Path:
+    """Build a synthetic tree containing exactly one occurrence of every ``old``.
+
+    Patches for the same path are concatenated in table order, each preceded
+    by a filler line, so every target string appears exactly once per file --
+    the precondition ``apply_patches`` requires to succeed.
+    """
+    by_path: "dict[str, list[patcher.Patch]]" = {}
+    for patch in patcher.TARGETS[target]:
+        by_path.setdefault(patch.path, []).append(patch)
+
+    for rel_path, patches in by_path.items():
+        file_path = tmp_path / rel_path
+        file_path.parent.mkdir(parents=True, exist_ok=True)
+        content = "".join(f"# filler\n{p.old}" for p in patches)
+        file_path.write_text(content, encoding="utf-8")
+
+    return tmp_path
+
+
+@pytest.mark.parametrize("target", ["qt", "tauri"])
+def test_applies_every_site(tmp_path: Path, target: str):
+    root = make_fixture_root(tmp_path, target)
+    patches = patcher.TARGETS[target]
+
+    applied = patcher.apply_patches(root, patches, check=False)
+
+    assert len(applied) == len(patches)
+
+    by_path: "dict[str, list[patcher.Patch]]" = {}
+    for patch in patches:
+        by_path.setdefault(patch.path, []).append(patch)
+
+    for rel_path, file_patches in by_path.items():
+        text = (root / rel_path).read_text(encoding="utf-8")
+        for patch in file_patches:
+            assert patch.new in text
+            if patch.old not in patch.new:
+                assert patch.old not in text
+
+
+@pytest.mark.parametrize("target", ["qt", "tauri"])
+def test_check_mode_writes_nothing(tmp_path: Path, target: str):
+    root = make_fixture_root(tmp_path, target)
+    patches = patcher.TARGETS[target]
+
+    before = {
+        rel_path: (root / rel_path).read_bytes()
+        for rel_path in {p.path for p in patches}
+    }
+
+    applied = patcher.apply_patches(root, patches, check=True)
+
+    assert len(applied) == len(patches)
+    for rel_path, contents in before.items():
+        assert (root / rel_path).read_bytes() == contents
+
+
+def test_missing_file_fails_closed(tmp_path: Path):
+    root = make_fixture_root(tmp_path, "qt")
+    patches = patcher.TARGETS["qt"]
+    victim = patches[0].path
+    (root / victim).unlink()
+
+    with pytest.raises(patcher.PatchError, match="submodule") as exc_info:
+        patcher.apply_patches(root, patches, check=False)
+    assert victim in str(exc_info.value)
+
+
+def test_missing_token_fails_closed(tmp_path: Path):
+    root = make_fixture_root(tmp_path, "qt")
+    patches = patcher.TARGETS["qt"]
+    victim = patches[0]
+    file_path = root / victim.path
+    text = file_path.read_text(encoding="utf-8")
+    file_path.write_text(text.replace(victim.old, "", 1), encoding="utf-8")
+
+    with pytest.raises(patcher.PatchError) as exc_info:
+        patcher.apply_patches(root, patches, check=False)
+
+    message = str(exc_info.value)
+    assert victim.path in message
+    assert victim.why in message
+    assert "found 0" in message
+
+
+def test_ambiguous_token_fails_closed(tmp_path: Path):
+    root = make_fixture_root(tmp_path, "qt")
+    patches = patcher.TARGETS["qt"]
+    victim = patches[0]
+    file_path = root / victim.path
+    text = file_path.read_text(encoding="utf-8")
+    file_path.write_text(text + victim.old, encoding="utf-8")
+
+    with pytest.raises(patcher.PatchError, match="found 2"):
+        patcher.apply_patches(root, patches, check=False)
+
+
+def test_nothing_written_when_one_site_fails(tmp_path: Path):
+    root = make_fixture_root(tmp_path, "qt")
+    patches = patcher.TARGETS["qt"]
+
+    # Pick a path with multiple patches so breaking one leaves the others
+    # unable to complete the file's all-or-nothing write.
+    by_path: "dict[str, list[patcher.Patch]]" = {}
+    for patch in patches:
+        by_path.setdefault(patch.path, []).append(patch)
+    multi_patch_path = next(p for p, ps in by_path.items() if len(ps) > 1)
+
+    file_path = root / multi_patch_path
+    broken_patch = by_path[multi_patch_path][-1]
+    text = file_path.read_text(encoding="utf-8")
+    file_path.write_text(text.replace(broken_patch.old, "", 1), encoding="utf-8")
+    before = file_path.read_bytes()
+
+    with pytest.raises(patcher.PatchError):
+        patcher.apply_patches(root, patches, check=False)
+
+    assert file_path.read_bytes() == before
+
+
+def test_reapply_is_refused(tmp_path: Path):
+    root = make_fixture_root(tmp_path, "qt")
+    patches = patcher.TARGETS["qt"]
+
+    patcher.apply_patches(root, patches, check=False)
+
+    with pytest.raises(patcher.PatchError, match="already applied"):
+        patcher.apply_patches(root, patches, check=False)
+
+
+def test_main_exit_codes(tmp_path: Path, capsys):
+    good_root = make_fixture_root(tmp_path / "good", "qt")
+    assert patcher.main(["qt", "--root", str(good_root), "--check"]) == 0
+
+    bad_root = tmp_path / "bad"
+    bad_root.mkdir()
+    code = patcher.main(["qt", "--root", str(bad_root), "--check"])
+    assert code == 1
+    captured = capsys.readouterr()
+    assert "ERROR" in captured.err
+
+
+def _repo_root() -> Path:
+    return Path(__file__).resolve().parents[2]
+
+
+@pytest.mark.parametrize("target", ["qt", "tauri"])
+def test_real_tree_check_passes_if_submodules_present(target: str):
+    root = _repo_root()
+    marker = root / "aw-qt" / "aw_qt" / "profile.py"
+    if not marker.is_file():
+        pytest.skip("submodules not checked out")
+
+    applied = patcher.apply_patches(root, patcher.TARGETS[target], check=True)
+
+    assert len(applied) == len(patcher.TARGETS[target])
+
+
+def test_patched_python_profile_module_behaviour(tmp_path: Path, monkeypatch):
+    repo_root = _repo_root()
+    real_profile = repo_root / "aw-qt" / "aw_qt" / "profile.py"
+    if not real_profile.is_file():
+        pytest.skip("submodules not checked out")
+
+    dest = tmp_path / "aw-qt" / "aw_qt" / "profile.py"
+    dest.parent.mkdir(parents=True)
+    dest.write_text(real_profile.read_text(encoding="utf-8"), encoding="utf-8")
+
+    qt_python_patches = [
+        p for p in patcher.TARGETS["qt"] if p.path == "aw-qt/aw_qt/profile.py"
+    ]
+    patcher.apply_patches(tmp_path, qt_python_patches, check=False)
+
+    spec = importlib.util.spec_from_file_location("patched_profile", dest)
+    assert spec and spec.loader
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+
+    monkeypatch.delenv("AW_PROFILE", raising=False)
+
+    assert module.resolve_profile(None, False) == "research"
+    assert module.resolve_profile(None, True) == "testing"
+    assert module.DEFAULT_PROFILE == "default"
+    assert module.profile_suffix("research") == "-research"
+
+    module.export_profile(module.resolve_profile(None, False))
+    assert os.environ["AW_PROFILE"] == "research"
+
+
+@pytest.mark.parametrize("target", ["qt", "tauri"])
+def test_macos_bundle_dir_is_distinct_from_standard(tmp_path: Path, target: str):
+    """Research builds must not emit ActivityWatch.app next to a standard install."""
+    root = make_fixture_root(tmp_path, target)
+    patcher.apply_patches(root, patcher.TARGETS[target], check=False)
+
+    stem = patcher.BUNDLE_DIR_STEM
+    expected_app = f"{stem}.app"
+    colliding = "ActivityWatch.app"
+
+    if target == "qt":
+        spec = (root / "aw.spec").read_text(encoding="utf-8")
+        assert f'name="{expected_app}"' in spec
+        assert f'name="{colliding}"' not in spec
+    else:
+        tauri = (root / "scripts/package/build_app_tauri.sh").read_text(
+            encoding="utf-8"
+        )
+        assert f'APP_NAME="{stem}"' in tauri
+        assert 'APP_NAME="ActivityWatch"\n' not in tauri
+
+    makefile = (root / "Makefile").read_text(encoding="utf-8")
+    assert f"APP_BUNDLE ?= {stem}\n" in makefile
+    assert "APP_BUNDLE ?= ActivityWatch\n" not in makefile
+
+    notarize = (root / "scripts/notarize.sh").read_text(encoding="utf-8")
+    assert f"app=dist/{expected_app}" in notarize
+    assert "app=dist/ActivityWatch.app" not in notarize
+
+
+# --- Windows install identity -------------------------------------------------
+# These use the *real* .iss / tauri.conf.json rather than the synthetic fixture,
+# because the thing under test is largely what the patch does NOT touch: the
+# shortcut, uninstall and install-dir lines that derive from `#define MyAppName`.
+# A synthetic file built from the `old` strings alone cannot catch upstream
+# hardcoding a name that would then collide with a standard install.
+
+WINDOWS_REAL_FILES = {
+    "qt": "scripts/package/activitywatch-setup.iss",
+    "tauri": "scripts/package/aw-tauri.iss",
+}
+STANDARD_APPID_QT = "F226B8F4-3244-46E6-901D-0CE8035423E4"
+STANDARD_APPID_TAURI = "983D0855-08C8-46BD-AEFB-3924581C6703"
+
+
+def _patch_real_file(tmp_path: Path, rel_path: str, patches) -> str:
+    """Copy one real repo file into ``tmp_path``, patch it, return the result."""
+    src = _repo_root() / rel_path
+    if not src.is_file():
+        pytest.skip(f"{rel_path} not present")
+    dst = tmp_path / rel_path
+    dst.parent.mkdir(parents=True, exist_ok=True)
+    dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
+
+    relevant = [p for p in patches if p.path == rel_path]
+    patcher.apply_patches(tmp_path, relevant, check=False)
+    return dst.read_text(encoding="utf-8")
+
+
+@pytest.mark.parametrize("target", ["qt", "tauri"])
+def test_windows_installer_is_a_separate_product(tmp_path: Path, target: str):
+    """Research setup must register its own product, not upgrade over standard."""
+    rel = WINDOWS_REAL_FILES[target]
+    patches = (
+        patcher.WINDOWS_PATCHES_QT
+        if target == "qt"
+        else patcher.WINDOWS_PATCHES_TAURI
+    )
+    text = _patch_real_file(tmp_path, rel, patches)
+
+    research_appid = (
+        patcher.WINDOWS_APPID_QT if target == "qt" else patcher.WINDOWS_APPID_TAURI
+    )
+    standard_appid = STANDARD_APPID_QT if target == "qt" else STANDARD_APPID_TAURI
+
+    # AppId is what Inno keys "same product" on — it must have changed.
+    assert f"AppId={{{{{research_appid}}}" in text
+    assert standard_appid not in text
+
+    # Display name drives shortcuts, uninstall entry and (Qt) the install dir.
+    assert patcher.BUNDLE_NAME in text
+    assert '#define MyAppName "ActivityWatch"\n' not in text
+    assert '#define MyAppName "ActivityWatch (Tauri)"\n' not in text
+
+    # Setup .exe no longer collides with the standard artifact.
+    assert "OutputBaseFilename=activitywatch-setup\n" not in text
+    assert "OutputBaseFilename=activitywatch-research" in text
+
+    # Shortcut / uninstall identity must still *derive* from MyAppName. If
+    # upstream ever hardcodes the name here, the research build would install a
+    # shortcut and uninstall entry indistinguishable from a standard install.
+    for line in ("{autoprograms}", "{autodesktop}", "{userstartup}"):
+        assert f'Name: "{line}\\{{#MyAppName}}"' in text
+    assert "UninstallDisplayName={#MyAppName}\n" in text
+
+
+def test_windows_research_installers_do_not_collide_with_each_other(tmp_path: Path):
+    """Qt-research and Tauri-research are also distinct products from each other."""
+    qt = _patch_real_file(
+        tmp_path / "qt", WINDOWS_REAL_FILES["qt"], patcher.WINDOWS_PATCHES_QT
+    )
+    tauri = _patch_real_file(
+        tmp_path / "tauri", WINDOWS_REAL_FILES["tauri"], patcher.WINDOWS_PATCHES_TAURI
+    )
+
+    appids = {
+        patcher.WINDOWS_APPID_QT,
+        patcher.WINDOWS_APPID_TAURI,
+        STANDARD_APPID_QT,
+        STANDARD_APPID_TAURI,
+    }
+    assert len(appids) == 4, "research AppIds must be unique GUIDs"
+
+    def _output_name(text: str) -> str:
+        for line in text.splitlines():
+            if line.startswith("OutputBaseFilename="):
+                return line.split("=", 1)[1]
+        raise AssertionError("no OutputBaseFilename")
+
+    # Both .iss files ship `activitywatch-setup` on master, so the research
+    # names must be distinct from each other as well as from standard.
+    assert _output_name(qt) != _output_name(tauri)
+
+    # Install directories must differ (Qt derives its dir from MyAppName).
+    assert "DefaultDirName={autopf}\\{#MyAppName}\n" in qt
+    assert f"DefaultDirName={{autopf}}\\{patcher.BUNDLE_DIR_STEM}-Tauri\n" in tauri
+    assert "DefaultDirName={autopf}\\ActivityWatch-Tauri\n" not in tauri
+
+
+@pytest.fixture(
+    params=[(target, research) for target in ("qt", "tauri") for research in (False, True)]
+)
+def windows_installer_names(tmp_path: Path, request):
+    target, research = request.param
+    rel = WINDOWS_REAL_FILES[target]
+    # Packaging inputs are tracked root files, so a missing producer must fail.
+    assert (_repo_root() / rel).is_file()
+    patches = (
+        patcher.WINDOWS_PATCHES_QT if target == "qt" else patcher.WINDOWS_PATCHES_TAURI
+    )
+    text = _patch_real_file(tmp_path, rel, patches if research else [])
+    outputs = re.findall(r"^OutputBaseFilename=(\S+)$", text, re.MULTILINE)
+    assert len(outputs) == 1, "expected one executable OutputBaseFilename assignment"
+
+    package = (_repo_root() / "scripts/package/package-all.sh").read_text()
+    calls = re.findall(
+        r'^\s*"\$SCRIPT_DIR/collect-setup\.sh" "\$filename"\s*$', package, re.MULTILINE
+    )
+    assert len(calls) == 1, "build_setup must invoke the tested installer collector"
+    templates = re.findall(
+        r'^\s*filename="([^"\n]+-setup\.exe)"$', package, re.MULTILINE
+    )
+    assert len(templates) == 1, "expected one versioned installer filename assignment"
+    suffix = "-tauri" if target == "tauri" else ""
+    if research:
+        suffix += "-research"
+    final = Template(templates[0]).substitute(
+        build_suffix=suffix, version="v0.14.0b5", platform="windows", arch="x86_64"
+    )
+    return target, outputs[0] + ".exe", final
+
+
+def test_windows_installer_producer_matches_packaging_and_release(windows_installer_names):
+    target, produced, final = windows_installer_names
+    collector = (_repo_root() / "scripts/package/collect-setup.sh").read_text()
+    inputs = re.findall(r"^\s*setup_src=\(([^)\n]+)\)$", collector, re.MULTILINE)
+    assert len(inputs) == 1, "expected one executable setup_src assignment"
+    assert fnmatch.fnmatchcase(f"dist/{produced}", inputs[0].strip())
+
+    workflow = (_repo_root() / ".github/workflows/release.yml").read_text()
+    jobs = dict(re.findall(
+        r"^  ([\w-]+):\n(.*?)(?=^  [\w-]+:|\Z)", workflow, re.MULTILINE | re.DOTALL
+    ))
+    upload = re.search(
+        r"^      - name: Upload packages\n(.*?)(?=^      - |\Z)",
+        jobs[f"build-{target}"], re.MULTILINE | re.DOTALL,
+    )
+    assert upload, f"missing {target} package upload step"
+    assert "uses: actions/upload-artifact@" in upload[1]
+    path = re.search(r"^          path: (.+)$", upload[1], re.MULTILINE)
+    assert path, f"missing {target} package upload path"
+    globs = (
+        re.findall(r"^            (\S+)$", upload[1], re.MULTILINE)
+        if path[1] == "|" else [path[1]]
+    )
+    assert globs and any(fnmatch.fnmatchcase(f"dist/{final}", glob) for glob in globs)
+    release = re.search(
+        r"^      - name: Release\n(.*?)(?=^      - |\Z)",
+        jobs["release"], re.MULTILINE | re.DOTALL,
+    )
+    assert release and "uses: softprops/action-gh-release@" in release[1]
+    files = re.search(
+        r"^          files: \|\n((?:^            \S.*\n)+)", release[1], re.MULTILINE
+    )
+    assert files, "missing release action's asset files block"
+    release_globs = [line.strip() for line in files[1].splitlines()]
+    assert release_globs and any(
+        fnmatch.fnmatchcase(f"dist/builds-windows/{final}", glob) for glob in release_globs
+    )
+
+
+@pytest.mark.skipif(
+    sys.platform != "linux", reason="Bash fixture runs in the Ubuntu packaging smoke job"
+)
+def test_windows_setup_collection(tmp_path: Path, windows_installer_names):
+    _, produced, final = windows_installer_names
+    dist = tmp_path / "dist"
+    dist.mkdir()
+    (dist / produced).write_bytes(b"fixture installer")
+    (dist / "activitywatch-portable.zip").write_bytes(b"portable archive")
+
+    result = subprocess.run(
+        ["bash", str(_repo_root() / "scripts/package/collect-setup.sh"), final],
+        cwd=tmp_path, capture_output=True, text=True,
+    )
+
+    assert result.returncode == 0, result.stdout + result.stderr
+    assert {path.name for path in dist.iterdir()} == {final, "activitywatch-portable.zip"}
+    assert (dist / final).read_bytes() == b"fixture installer"
+    assert (dist / "activitywatch-portable.zip").read_bytes() == b"portable archive"
+
+
+@pytest.mark.skipif(
+    sys.platform != "linux", reason="Bash fixture runs in the Ubuntu packaging smoke job"
+)
+@pytest.mark.parametrize("case", ["missing", "multiple", "directory"])
+def test_windows_setup_collection_refuses_invalid_inputs(tmp_path: Path, case: str):
+    dist = tmp_path / "dist"
+    dist.mkdir()
+    (dist / "activitywatch-portable.zip").write_bytes(b"portable archive")
+    if case == "multiple":
+        for name in ("activitywatch-setup.exe", "activitywatch-research-setup.exe"):
+            (dist / name).write_bytes(name.encode())
+    elif case == "directory":
+        (dist / "activitywatch-research-setup.exe").mkdir()
+
+    def snapshot():
+        return {
+            path.name: path.read_bytes() if path.is_file() else None
+            for path in dist.iterdir()
+        }
+
+    before = snapshot()
+    result = subprocess.run(
+        [
+            "bash", str(_repo_root() / "scripts/package/collect-setup.sh"),
+            "activitywatch-research-v0.14.0b5-windows-x86_64-setup.exe",
+        ],
+        cwd=tmp_path, capture_output=True, text=True,
+    )
+
+    assert result.returncode != 0
+    assert "expected exactly one" in result.stdout + result.stderr
+    assert snapshot() == before
+
+
+def test_tauri_wix_upgrade_code_is_pinned_and_config_stays_valid_json(tmp_path: Path):
+    """The MSI upgrade code defines the product family; pin it, don't derive it."""
+    import json
+    import uuid
+
+    rel = "aw-tauri/src-tauri/tauri.conf.json"
+    text = _patch_real_file(tmp_path, rel, patcher.WINDOWS_PATCHES_TAURI)
+
+    config = json.loads(text)  # deny_unknown_fields upstream: must stay valid
+    wix = config["bundle"]["windows"]["wix"]
+    assert wix["upgradeCode"] == patcher.WINDOWS_WIX_UPGRADE_CODE_TAURI
+    uuid.UUID(wix["upgradeCode"])  # tauri parses this as a uuid::Uuid
+
+
+# --- Linux package identity ---------------------------------------------------
+# Like the Windows tests above, these patch the *real* packaging scripts: the
+# thing under test is mostly what the patch does NOT touch. A synthetic fixture
+# built from the `old` strings alone would still pass if upstream added a fourth
+# reference to /opt/activitywatch or copied the desktop entry somewhere else.
+
+
+def test_linux_deb_is_a_separate_package(tmp_path: Path):
+    """A research deb must be co-installable, not an upgrade of the standard one."""
+    control = _patch_real_file(
+        tmp_path, "scripts/package/deb/control", patcher.LINUX_PATCHES_QT
+    )
+    assert f"Package: {patcher.LINUX_PACKAGE}\n" in control
+    # dpkg keys off the package name: same name == upgrade == standard removed.
+    assert "Package: activitywatch\n" not in control
+
+
+def test_linux_deb_installs_beside_a_standard_install(tmp_path: Path):
+    """No /opt/activitywatch or shared desktop-entry filename may survive."""
+    script = _patch_real_file(
+        tmp_path, "scripts/package/package-deb.sh", patcher.LINUX_PATCHES_QT
+    )
+
+    # The staged install tree, the Exec= line and the .deb filename must all
+    # move together; a leftover bare "/opt/activitywatch" would overwrite the
+    # standard install's files even with a distinct package name.
+    assert "/opt/activitywatch/" not in script
+    assert f"{patcher.LINUX_OPT_DIR}/aw-qt" in script
+    assert "activitywatch_${VERSION_NUM}.deb" not in script
+
+    # Both copies out of the install tree must land under the research
+    # filename: /etc/xdg/autostart and /usr/share/applications are shared
+    # namespaces, so a same-named entry silently replaces the standard one.
+    for dest in ("etc/xdg/autostart", "usr/share/applications"):
+        assert f"$PKGDIR/{dest}/{patcher.LINUX_DESKTOP_FILENAME}\n" in script
+        assert f"$PKGDIR/{dest}/\n" not in script
+
+
+def test_linux_desktop_entry_is_rebranded(tmp_path: Path):
+    """Menu/dock name and icon id must not read as a standard install."""
+    entry = _patch_real_file(
+        tmp_path, "aw-qt/resources/aw-qt.desktop", patcher.LINUX_PATCHES_QT
+    )
+    assert f"Name={patcher.BUNDLE_NAME}\n" in entry
+    assert "Name=ActivityWatch\n" not in entry
+    assert f"Icon={patcher.LINUX_ICON_ID}\n" in entry
+
+
+def test_appimage_desktop_and_icon_ids_agree(tmp_path: Path):
+    """linuxdeploy's --icon-filename must match the entry's Icon= key.
+
+    Desktop integration resolves the icon by that id; a mismatch ships an
+    AppImage with no icon, and a shared id would overwrite the standard
+    install's icon in the user's hicolor theme.
+    """
+    script = _patch_real_file(
+        tmp_path, "scripts/package/package-appimage.sh", patcher.LINUX_PATCHES_QT
+    )
+    entry = _patch_real_file(
+        tmp_path, "aw-qt/resources/aw-qt.desktop", patcher.LINUX_PATCHES_QT
+    )
+
+    assert f"--icon-filename {patcher.LINUX_ICON_ID}\n" in script
+    assert f"Icon={patcher.LINUX_ICON_ID}\n" in entry
+    # The entry handed to linuxdeploy is the research-named copy, and the copy
+    # is staged before it is used.
+    assert f"--desktop-file ./activitywatch/{patcher.LINUX_DESKTOP_FILENAME} " in script
+    stage = script.index(f"cp ./activitywatch/aw-qt.desktop ./activitywatch/{patcher.LINUX_DESKTOP_FILENAME}")
+    assert stage < script.index("linuxdeploy-x86_64.AppImage --appdir")
+
+
+def test_first_run_autostart_identity_is_distinct_on_every_platform(tmp_path: Path):
+    """aw-qt writes its own autostart entry; those names are the installer's.
+
+    Bundle id, package name and profile are all already split at this point --
+    but if the login item / Startup shortcut / autostart .desktop keep their
+    standard names, the two editions still overwrite each other's autostart.
+    """
+    rel = "aw-qt/aw_qt/autostart.py"
+    src = _repo_root() / rel
+    if not src.is_file():
+        pytest.skip(f"{rel} not present")
+    before = src.read_text(encoding="utf-8")
+    after = _patch_real_file(tmp_path, rel, patcher.AUTOSTART_PATCHES_QT)
+
+    # Linux: the written filename changes; the *shipped resource* name does not
+    # (the patched build still reads resources/aw-qt.desktop out of the bundle).
+    # Must not be aw-qt-research.desktop — that is the standard named-profile entry.
+    assert f'_linux_autostart_dir() / "{patcher.LINUX_DESKTOP_FILENAME}"' in after
+    assert "aw-qt-research.desktop" not in after
+    assert 'DESKTOP_FILENAME = "aw-qt.desktop"' in after
+    # macOS: plist path follows _macos_launch_agent_label(), which would otherwise
+    # become net.activitywatch.aw-qt-research and collide with --profile research.
+    assert f'return "{patcher.BUNDLE_ID}"' in after
+    assert 'return f"{LAUNCH_AGENT_LABEL}{_profile_suffix()}"' not in after
+    assert f'return "{patcher.LAUNCH_AGENT_LABEL}"' not in after
+    # Windows: both the Run value name and the Startup .lnk derive from APP_NAME.
+    # Named-profile suffixing then yields "ActivityWatch Research (research)".
+    assert f'APP_NAME = "{patcher.BUNDLE_NAME}"' in after
+    assert 'APP_NAME = "ActivityWatch"\n' not in after
+
+    # Guard the runtime identity sources: if upstream stops suffixing these, the
+    # research overrides above would be patching dead code.
+    for derived in (
+        'return _linux_autostart_dir() / f"{stem}{_profile_suffix()}{extension}"',
+        'return f"{LAUNCH_AGENT_LABEL}{_profile_suffix()}"',
+        'return APP_NAME if not suffix else f"{APP_NAME} ({_profile()})"',
+    ):
+        assert derived in before, f"upstream no longer derives: {derived}"
+
+
+def test_tauri_autostart_uses_distinct_app_name_for_research_build(tmp_path: Path):
+    """tauri_plugin_autostart derives its entry name from productName by default.
+
+    Both standard and research Tauri builds share productName "aw-tauri", so
+    enabling autostart in one edition would overwrite the other's entry on
+    Windows (registry key) and Linux (~/.config/autostart/*.desktop).
+
+    The fix is entirely patcher-side: PROFILE_PATCHES_TAURI inserts BUILD_PROFILE
+    into profile.rs; AUTOSTART_PATCHES_TAURI replaces the tauri_plugin_autostart::init()
+    call in lib.rs with a Builder that sets an explicit app_name derived from
+    BUILD_PROFILE when it differs from DEFAULT_PROFILE.
+
+    This test asserts both patch sets produce the expected output on the real sources.
+    """
+    # --- half 1: PROFILE_PATCHES_TAURI inserts BUILD_PROFILE = "research" ---
+    profile_rs_rel = "aw-tauri/src-tauri/src/profile.rs"
+    profile_rs_src = _repo_root() / profile_rs_rel
+    if not profile_rs_src.is_file():
+        pytest.skip(f"{profile_rs_rel} not present")
+    before_profile = profile_rs_src.read_text(encoding="utf-8")
+    # Standard source has DEFAULT_PROFILE but NOT BUILD_PROFILE
+    assert 'pub const DEFAULT_PROFILE: &str = "default";' in before_profile
+    assert "BUILD_PROFILE" not in before_profile, (
+        "BUILD_PROFILE must NOT exist in the unpatched source; the patcher inserts it"
+    )
+    after_profile = _patch_real_file(
+        tmp_path,
+        profile_rs_rel,
+        patcher.PROFILE_PATCHES_TAURI,
+    )
+    assert f'pub const BUILD_PROFILE: &str = "{patcher.RESEARCH_PROFILE}";' in after_profile
+
+    # --- half 2: AUTOSTART_PATCHES_TAURI overrides named-profile app_name ---
+    lib_rs_rel = "aw-tauri/src-tauri/src/lib.rs"
+    lib_rs_src = _repo_root() / lib_rs_rel
+    if not lib_rs_src.is_file():
+        pytest.skip(f"{lib_rs_rel} not present")
+    before_lib = lib_rs_src.read_text(encoding="utf-8")
+    # Standard source already uses Builder + named-profile identities.
+    assert "tauri_plugin_autostart::Builder::new()" in before_lib
+    assert "profile::autostart_app_name" in before_lib
+    assert "BUILD_PROFILE" not in before_lib
+    after_lib = _patch_real_file(
+        tmp_path,
+        lib_rs_rel,
+        patcher.AUTOSTART_PATCHES_TAURI,
+    )
+    # Research build keeps named-profile wiring, then overrides the OS identity
+    # so it does not collide with a standard `--profile research` login item.
+    assert "profile::autostart_app_name" in after_lib
+    assert "profile::BUILD_PROFILE != profile::DEFAULT_PROFILE" in after_lib
+    assert '"aw-tauri-{}-edition"' in after_lib
+    assert 'format!("aw-tauri-{}", profile::BUILD_PROFILE)' not in after_lib
diff --git a/scripts/tests/test_publish_research_feed.py b/scripts/tests/test_publish_research_feed.py
new file mode 100644
index 0000000..d6f2d58
--- /dev/null
+++ b/scripts/tests/test_publish_research_feed.py
@@ -0,0 +1,401 @@
+"""Release fixtures and real local Git remotes; never contact the public feed."""
+
+import importlib.util
+import json
+from pathlib import Path
+import subprocess
+
+import pytest
+
+SCRIPT = Path(__file__).parents[1] / "package" / "publish_research_feed.py"
+spec = importlib.util.spec_from_file_location("publish_research_feed", SCRIPT)
+publisher = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(publisher)
+
+
+def fixture(beta=5):
+    tag = f"v0.14.0b{beta}-research"
+    base = f"https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/"
+    manifest = {
+        "version": f"0.14.0-beta.{beta}",
+        "notes": tag,
+        "pub_date": "2026-09-09T00:00:00Z",
+        "platforms": {},
+    }
+    release = {
+        "tag_name": tag,
+        "draft": False,
+        "immutable": True,
+        "published_at": "2026-09-09T00:00:00Z",
+        "assets": [],
+    }
+    for target, extensions in publisher.TARGETS.items():
+        name = f"activitywatch-tauri-research-0.14.0b{beta}-{target}.{extensions[0]}"
+        manifest["platforms"][target] = {
+            "url": base + name,
+            "signature": "test-signature",
+        }
+        for asset in (name, name + ".sig"):
+            release["assets"].append(
+                {
+                    "name": asset,
+                    "state": "uploaded",
+                    "size": 42,
+                    "browser_download_url": base + asset,
+                }
+            )
+    return manifest, release, tag
+
+
+def validate(manifest, release, tag):
+    publisher.validate_manifest(manifest, release, tag, lambda _: b"test-signature\n")
+
+
+def test_valid_research_release():
+    validate(*fixture())
+
+
+@pytest.mark.parametrize(
+    "mutation",
+    [
+        lambda m, r: m.update(pub_date="not a date"),
+        lambda m, r: m.update(pub_date="2026-09-09T00:00:00+00:99"),
+        lambda m, r: m.update(pub_date="2026-02-30T00:00:00Z"),
+        lambda m, r: m.update(pub_date="2026-09-09T00:00:00"),
+        lambda m, r: m.update(notes=123),
+        lambda m, r: m.update(name="0.14.0-beta.6"),
+        lambda m, r: m.update(url=123),
+        lambda m, r: m["platforms"]["darwin-aarch64"].update(extra="unexpected"),
+        lambda m, r: r.update(immutable=False),
+        lambda m, r: r.pop("immutable"),
+        lambda m, r: r.update(draft=True),
+        lambda m, r: r.update(draft="false"),
+        lambda m, r: r.update(published_at=None),
+        lambda m, r: r.update(tag_name="v0.14.0b4-research"),
+        lambda m, r: r["assets"].pop(0),  # bundle absent
+        lambda m, r: r["assets"].pop(1),  # signature absent
+        lambda m, r: r["assets"][0].update(size=0),
+        lambda m, r: r["assets"][0].update(state="new"),
+        lambda m, r: r["assets"][1].update(
+            browser_download_url="https://example.com/signature"
+        ),
+        lambda m, r: r["assets"].append(r["assets"][0]),
+        lambda m, r: m.update(version="0.14.0b5"),
+        lambda m, r: m.update(version="0.14.0-beta.6"),
+        lambda m, r: m["platforms"].pop("darwin-x86_64"),
+        lambda m, r: m["platforms"].update({"unsupported-platform": {}}),
+        lambda m, r: m["platforms"]["darwin-aarch64"].update(signature=""),
+        lambda m, r: m["platforms"]["darwin-aarch64"].update(signature="wrong"),
+        lambda m, r: m.update(platforms=list(m["platforms"])),
+    ],
+)
+def test_invalid_release_or_manifest(mutation):
+    manifest, release, tag = fixture()
+    mutation(manifest, release)
+    with pytest.raises(ValueError):
+        validate(manifest, release, tag)
+
+
+@pytest.mark.parametrize(
+    "replacement",
+    [
+        ("v0.14.0b5-research/", "v0.14.0b4-research/"),
+        ("v0.14.0b5-research/", "v0.14.0b5/"),
+        ("activitywatch-tauri-research-", "activitywatch-tauri-"),
+        ("research-0.14.0b5-", "research-0.14.0b4-"),
+        (".app.tar.gz", ".dmg"),
+        ("https://github.com/", "https://example.com/"),
+    ],
+)
+def test_wrong_edition_release_version_or_extension(replacement):
+    manifest, release, tag = fixture()
+    entry = manifest["platforms"]["darwin-aarch64"]
+    entry["url"] = entry["url"].replace(*replacement)
+    # Even if metadata claims this asset exists, it is not an allowed URL.
+    release["assets"][0]["browser_download_url"] = entry["url"]
+    with pytest.raises(ValueError):
+        validate(manifest, release, tag)
+
+
+@pytest.mark.parametrize(
+    "tag",
+    [
+        "v0.14.0b5",
+        "latest-research",
+        "v0.14.0b05-research",
+        "v0.14.0b5.dev-abcdef-research",
+        "v0.14.0b5-research/other",
+    ],
+)
+def test_invalid_release_tag(tag):
+    manifest, release, _ = fixture()
+    with pytest.raises(ValueError):
+        validate(manifest, release, tag)
+
+
+@pytest.fixture
+def remote(tmp_path, monkeypatch):
+    config = tmp_path / "gitconfig"
+    config.write_text("[user]\nname = Publisher test\nemail = test@example.invalid\n")
+    monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config))
+    monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
+    # Isolate test repositories from any enclosing harness Git invocation.
+    for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
+        monkeypatch.delenv(name, raising=False)
+    remote = tmp_path / "remote.git"
+    remote.mkdir()
+    publisher.git(remote, "init", "--bare", "--quiet")
+    return str(remote)
+
+
+def candidate(tmp_path, remote, name, parent="absent", beta=5):
+    repo = tmp_path / name
+    manifest = fixture(beta)[0]
+    commit = publisher.prepare(repo, remote, parent, manifest)
+    return repo, commit
+
+
+def test_initial_preparation_does_not_publish(tmp_path, remote):
+    repo, commit = candidate(tmp_path, remote, "candidate")
+    assert publisher.remote_parent(repo, remote) == "absent"
+    assert publisher.git(repo, "rev-list", "--parents", "-n", "1", commit) == commit
+    assert json.loads((repo / publisher.MANIFEST).read_text()) == fixture()[0]
+    publisher.publish(repo, remote, "absent", commit)
+    assert publisher.remote_parent(repo, remote) == commit
+
+
+def test_advance_preserves_history_and_unrelated_files(tmp_path, remote):
+    repo, first = candidate(tmp_path, remote, "first")
+    (repo / "README.md").write_text("Feed history\n")
+    publisher.git(repo, "add", "README.md")
+    publisher.git(repo, "commit", "-m", "Document feed")
+    parent = publisher.git(repo, "rev-parse", "HEAD")
+    publisher.git(repo, "push", remote, f"{parent}:{publisher.REF}")
+    new_repo, second = candidate(tmp_path, remote, "second", parent, 6)
+    publisher.publish(new_repo, remote, parent, second)
+    assert publisher.git(new_repo, "rev-parse", f"{second}^") == parent
+    assert publisher.git(new_repo, "rev-parse", f"{second}^^") == first
+    assert (new_repo / "README.md").read_text() == "Feed history\n"
+
+
+def test_replay_is_noop_and_conflicting_equal_version_rejected(tmp_path, remote):
+    repo, first = candidate(tmp_path, remote, "first")
+    publisher.publish(repo, remote, "absent", first)
+    _, repeated = candidate(tmp_path, remote, "replay", first)
+    assert repeated is None
+    different = fixture()[0]
+    different["notes"] = "changed"
+    with pytest.raises(ValueError, match="Conflicting"):
+        publisher.prepare(tmp_path / "conflict", remote, first, different)
+    assert publisher.remote_parent(repo, remote) == first
+
+
+def test_late_b5_cannot_replace_b6(tmp_path, remote):
+    repo, first = candidate(tmp_path, remote, "b6", beta=6)
+    publisher.publish(repo, remote, "absent", first)
+    with pytest.raises(ValueError, match="regression"):
+        candidate(tmp_path, remote, "late-b5", first, 5)
+
+
+def test_numeric_semver_order(tmp_path, remote):
+    repo, first = candidate(tmp_path, remote, "b9", beta=9)
+    publisher.publish(repo, remote, "absent", first)
+    new_repo, second = candidate(tmp_path, remote, "b10", first, 10)
+    publisher.publish(new_repo, remote, first, second)
+    assert publisher.remote_parent(repo, remote) == second
+
+
+@pytest.mark.parametrize("provision", [False, True])
+def test_same_parent_race_and_retry_revalidates_order(tmp_path, remote, provision):
+    parent = "absent"
+    if not provision:
+        repo, parent = candidate(tmp_path, remote, "seed", beta=4)
+        publisher.publish(repo, remote, "absent", parent)
+    slow_repo, slow = candidate(tmp_path, remote, "slow", parent, 5)
+    fast_repo, fast = candidate(tmp_path, remote, "fast", parent, 6)
+    publisher.publish(fast_repo, remote, parent, fast)
+    with pytest.raises(subprocess.CalledProcessError):
+        publisher.publish(slow_repo, remote, parent, slow)
+    assert publisher.remote_parent(fast_repo, remote) == fast
+    with pytest.raises(ValueError, match="Stale"):
+        candidate(tmp_path, remote, "stale", parent, 5)
+    with pytest.raises(ValueError, match="regression"):
+        candidate(tmp_path, remote, "retry", fast, 5)
+
+
+def test_deleted_parent_cannot_be_silently_recreated(tmp_path, remote):
+    repo, parent = candidate(tmp_path, remote, "seed")
+    publisher.publish(repo, remote, "absent", parent)
+    new_repo, second = candidate(tmp_path, remote, "second", parent, 6)
+    # Simulate an administrator's intervening ref deletion, not publisher behavior.
+    publisher.git(Path(remote), "update-ref", "-d", publisher.REF)
+    with pytest.raises(subprocess.CalledProcessError, match="returned non-zero"):
+        publisher.publish(new_repo, remote, parent, second)
+    assert publisher.remote_parent(repo, remote) == "absent"
+
+
+def test_rewound_parent_is_rejected_even_when_push_would_fast_forward(tmp_path, remote):
+    repo, first = candidate(tmp_path, remote, "first", beta=4)
+    publisher.publish(repo, remote, "absent", first)
+    second_repo, second = candidate(tmp_path, remote, "second", first, 5)
+    publisher.publish(second_repo, remote, first, second)
+    third_repo, third = candidate(tmp_path, remote, "third", second, 6)
+    publisher.git(Path(remote), "update-ref", publisher.REF, first)
+    with pytest.raises(subprocess.CalledProcessError):
+        publisher.publish(third_repo, remote, second, third)
+    assert publisher.remote_parent(repo, remote) == first
+
+
+def test_existing_pre_push_hook_is_preserved(tmp_path, remote):
+    repo, commit = candidate(tmp_path, remote, "candidate")
+    hooks = tmp_path / "hooks"
+    hooks.mkdir()
+    sentinel = tmp_path / "hook-ran"
+    hook = hooks / "pre-push"
+    hook.write_text(f'#!/bin/sh\ncat > "{sentinel}"\nexit 1\n')
+    hook.chmod(0o755)
+    publisher.git(repo, "config", "core.hooksPath", str(hooks))
+    with pytest.raises(subprocess.CalledProcessError):
+        publisher.publish(repo, remote, "absent", commit)
+    assert commit in sentinel.read_text()
+    assert publisher.remote_parent(repo, remote) == "absent"
+
+
+def test_prepare_rejects_existing_workdir(tmp_path, remote):
+    repo = tmp_path / "owned"
+    repo.mkdir()
+    (repo / "precious").write_text("keep")
+    with pytest.raises(FileExistsError):
+        publisher.prepare(repo, remote, "absent", fixture()[0])
+    assert (repo / "precious").read_text() == "keep"
+
+
+def test_cli_validates_before_prepare(tmp_path, monkeypatch):
+    manifest, release, tag = fixture()
+    manifest["platforms"].pop("linux-aarch64")
+    path = tmp_path / "manifest.json"
+    path.write_text(json.dumps(manifest))
+    monkeypatch.setattr(
+        publisher, "public_bytes", lambda _: json.dumps(release).encode()
+    )
+    work = tmp_path / "candidate"
+    with pytest.raises(SystemExit) as error:
+        publisher.main(
+            [
+                "--manifest",
+                str(path),
+                "--tag",
+                tag,
+                "--expected-parent",
+                "absent",
+                "--work-dir",
+                str(work),
+            ]
+        )
+    assert error.value.code == 1
+    assert not work.exists()
+
+
+def test_receive_side_rejects_race_after_parent_was_advertised(tmp_path, remote):
+    repo, first = candidate(tmp_path, remote, "seed", beta=4)
+    publisher.publish(repo, remote, "absent", first)
+    slow_repo, slow = candidate(tmp_path, remote, "slow", first, 5)
+    fast_repo, fast = candidate(tmp_path, remote, "fast", first, 6)
+    # Put the competing commit object on the remote without moving the feed yet.
+    publisher.git(fast_repo, "push", remote, f"{fast}:refs/heads/competing")
+    hooks = tmp_path / "race-hooks"
+    hooks.mkdir()
+    hook = hooks / "pre-push"
+    hook.write_text(
+        f'#!/bin/sh\ngit -C "{remote}" update-ref {publisher.REF} {fast} {first}\n'
+    )
+    hook.chmod(0o755)
+    publisher.git(slow_repo, "config", "core.hooksPath", str(hooks))
+    with pytest.raises(subprocess.CalledProcessError):
+        publisher.publish(slow_repo, remote, first, slow)
+    assert publisher.remote_parent(repo, remote) == fast
+
+
+@pytest.mark.parametrize("do_publish", [False, True])
+def test_cli_public_checks_and_explicit_publish_flag(
+    tmp_path, remote, monkeypatch, capsys, do_publish
+):
+    manifest, release, tag = fixture()
+    path = tmp_path / "manifest.json"
+    path.write_text(json.dumps(manifest))
+    urls = []
+
+    def public(url):
+        urls.append(url)
+        return (
+            json.dumps(release).encode()
+            if url.startswith("https://api.github.com/")
+            else b"test-signature\n"
+        )
+
+    real_git = publisher.git
+
+    def local_git(repo, *args, **kwargs):
+        return real_git(
+            repo,
+            *(
+                remote
+                if arg == "https://github.com/ActivityWatch/activitywatch.git"
+                else arg
+                for arg in args
+            ),
+            **kwargs,
+        )
+
+    monkeypatch.setattr(publisher, "public_bytes", public)
+    monkeypatch.setattr(publisher, "git", local_git)
+    work = tmp_path / "candidate"
+    args = [
+        "--manifest",
+        str(path),
+        "--tag",
+        tag,
+        "--expected-parent",
+        "absent",
+        "--work-dir",
+        str(work),
+    ]
+    if do_publish:
+        args.append("--publish")
+    publisher.main(args)
+    result = capsys.readouterr().out
+    assert result.startswith("Published" if do_publish else "Prepared")
+    assert len(urls) == 6  # public release metadata and all five public signatures
+    assert (publisher.remote_parent(work, remote) != "absent") == do_publish
+
+
+def test_failed_public_signature_read_leaves_no_candidate(
+    tmp_path, remote, monkeypatch
+):
+    manifest, release, tag = fixture()
+    path = tmp_path / "manifest.json"
+    path.write_text(json.dumps(manifest))
+
+    def public(url):
+        if url.startswith("https://api.github.com/"):
+            return json.dumps(release).encode()
+        raise OSError("signature HTTP 404")
+
+    monkeypatch.setattr(publisher, "public_bytes", public)
+    work = tmp_path / "candidate"
+    with pytest.raises(SystemExit) as error:
+        publisher.main(
+            [
+                "--manifest",
+                str(path),
+                "--tag",
+                tag,
+                "--expected-parent",
+                "absent",
+                "--work-dir",
+                str(work),
+                "--publish",
+            ]
+        )
+    assert error.value.code == 1
+    assert not work.exists()
diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh
new file mode 100755
index 0000000..e8ba653
--- /dev/null
+++ b/scripts/uninstall.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+modules=$(pip3 list --format=freeze | grep '^aw-' | cut -d'=' -f1)
+
+for module in $modules; do
+    pip3 uninstall -y $module
+done
+
diff --git a/scripts/update-deps.sh b/scripts/update-deps.sh
new file mode 100755
index 0000000..5c4ce48
--- /dev/null
+++ b/scripts/update-deps.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+# Update dependency locks for each submodule in the activitywatch repo
+
+set -e
+set -x
+
+# For submodule in submodules:
+for submodule in $(git submodule | sed 's/^[+ ]//' | cut -d' ' -f2); do
+    # Go to submodule
+    cd $submodule
+
+    # Check that we're on the master branch and latest commit
+    if [ $(git rev-parse --abbrev-ref HEAD) != "master" ]; then
+        echo "Submodule $submodule is not on master branch, aborting"
+        exit 1
+    fi
+
+    # Update dependency locks
+    # Use poetry if poetry.lock exists, or cargo if Cargo.toml exists
+    if [ -f "poetry.lock" ]; then
+        poetry update
+    elif [ -f "Cargo.toml" ]; then
+        cargo update
+    fi
+
+    # Go back to root
+    cd ..
+done
+