diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 044f7e8db..b39511205 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,6 +252,44 @@ jobs: 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 table smoke test + # 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. + - name: Run profile patcher unit tests + run: python3 -m pytest scripts/tests/test_patch_research_edition_profile.py -q + build-qt: name: Build Qt artifacts if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' @@ -260,7 +298,7 @@ jobs: env: AW_EXTRAS: true MACOSX_DEPLOYMENT_TARGET: "12.0" - AW_RESEARCH_EDITION: ${{ (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }} + AW_RESEARCH_EDITION: ${{ matrix.research || (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }} defaults: run: shell: bash @@ -275,6 +313,24 @@ jobs: 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 @@ -461,6 +517,32 @@ jobs: 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 @@ -491,21 +573,26 @@ jobs: export APPLE_PERSONALID fi + BUNDLE="ActivityWatch" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then + BUNDLE="ActivityWatch-Research" + fi + source venv/bin/activate - make dist/ActivityWatch.dmg + make "dist/${BUNDLE}.dmg" APP_BUNDLE="${BUNDLE}" if [ "$SIGN_MACOS" = true ]; then - codesign --force --verbose --timestamp -s "${APPLE_PERSONALID}" dist/ActivityWatch.dmg + codesign --force --verbose --timestamp -s "${APPLE_PERSONALID}" "dist/${BUNDLE}.dmg" brew install akeru-inc/tap/xcnotary - xcnotary precheck dist/ActivityWatch.app - xcnotary precheck dist/ActivityWatch.dmg + 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/ActivityWatch.dmg dist/activitywatch${EDITION}-${VERSION_WITH_V}-macos-$(uname -m).dmg + 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 }} @@ -525,7 +612,7 @@ jobs: - name: Upload packages uses: actions/upload-artifact@v7 with: - name: builds-${{ matrix.os }}-py${{ matrix.python_version }} + name: builds-${{ matrix.os }}-py${{ matrix.python_version }}${{ matrix.research && '-research' || '' }} path: dist/activitywatch-*.* build-qt-manylinux-2-28: @@ -735,6 +822,32 @@ jobs: 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 @@ -778,7 +891,7 @@ jobs: AW_EXTRAS: true TAURI_BUILD: true MACOSX_DEPLOYMENT_TARGET: "12.0" - AW_RESEARCH_EDITION: ${{ (github.event_name == 'workflow_dispatch' && inputs.edition == 'research') || endsWith(github.ref_name, '-research') }} + 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" @@ -802,6 +915,24 @@ jobs: 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 @@ -963,6 +1094,41 @@ jobs: 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.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.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: | @@ -1020,21 +1186,26 @@ jobs: SIGN_MACOS=true fi + BUNDLE="ActivityWatch" + if [[ "$AW_RESEARCH_EDITION" == "true" ]]; then + BUNDLE="ActivityWatch-Research" + fi + source venv/bin/activate - make dist/ActivityWatch.dmg + make "dist/${BUNDLE}.dmg" APP_BUNDLE="${BUNDLE}" if [ "$SIGN_MACOS" = true ]; then - codesign --force --verbose --timestamp -s "${APPLE_PERSONALID}" dist/ActivityWatch.dmg + codesign --force --verbose --timestamp -s "${APPLE_PERSONALID}" "dist/${BUNDLE}.dmg" brew install akeru-inc/tap/xcnotary - xcnotary precheck dist/ActivityWatch.app - xcnotary precheck dist/ActivityWatch.dmg + 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/ActivityWatch.dmg dist/activitywatch-tauri${EDITION}-${VERSION_WITH_V}-macos-$(uname -m).dmg + 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 }} @@ -1097,7 +1268,7 @@ jobs: - name: Upload packages uses: actions/upload-artifact@v7 with: - name: builds-tauri-${{ matrix.os }}-py${{ matrix.python_version }} + name: builds-tauri-${{ matrix.os }}-py${{ matrix.python_version }}${{ matrix.research && '-research' || '' }} path: | dist/activitywatch-*.* dist/updater/* diff --git a/Makefile b/Makefile index 8f809ffc8..4202a3a2d 100644 --- a/Makefile +++ b/Makefile @@ -182,19 +182,23 @@ aw-qt/media/logo/logo.icns: rm -R build/MyIcon.iconset mv build/MyIcon.icns aw-qt/media/logo/logo.icns -dist/ActivityWatch.app: 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/ActivityWatch.dmg: dist/ActivityWatch.app +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/ActivityWatch.dmg; \ - if dmgbuild -s scripts/package/dmgbuild-settings.py -D app=dist/ActivityWatch.app "ActivityWatch" dist/ActivityWatch.dmg; then \ + 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 \ diff --git a/aw-watcher-window b/aw-watcher-window index a101165b0..abd69a6b6 160000 --- a/aw-watcher-window +++ b/aw-watcher-window @@ -1 +1 @@ -Subproject commit a101165b037b1a734ff8424da0e4b65578bbadf9 +Subproject commit abd69a6b6a56a80df3d9971ca152f84fc23e7340 diff --git a/scripts/patch_research_edition_profile.py b/scripts/patch_research_edition_profile.py new file mode 100644 index 000000000..a53d607c2 --- /dev/null +++ b/scripts/patch_research_edition_profile.py @@ -0,0 +1,752 @@ +#!/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. + +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() / DESKTOP_FILENAME\n", + f' return _linux_autostart_dir() / "{LINUX_DESKTOP_FILENAME}"\n', + "Linux autostart entry filename (DESKTOP_FILENAME still names the " + "shipped resource we copy from)", + ), + Patch( + "aw-qt/aw_qt/autostart.py", + 'LAUNCH_AGENT_LABEL = "net.activitywatch.aw-qt"\n', + f'LAUNCH_AGENT_LABEL = "{LAUNCH_AGENT_LABEL}"\n', + "macOS LaunchAgent label and plist filename", + ), + 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) -------------------------------------- +# tauri_plugin_autostart derives its OS entry name from productName by default. +# Standard and research Tauri builds share productName="aw-tauri", so their +# autostart entries (Windows registry Run key, Linux ~/.config/autostart/ file) +# overwrite each other. Give the research build a distinct name by switching to +# the Builder API and setting app_name when BUILD_PROFILE is not the default. +# macOS uses different OS mechanisms (AppleScript vs LaunchAgent) so it doesn't +# collide, but the macos_launcher selection is included for completeness. +# +# 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", + " .plugin(tauri_plugin_autostart::init(\n" + " // AppleScript login items silently drop extra arguments; LaunchAgent\n" + " // writes a plist with ProgramArguments so --profile survives relogin.\n" + " if profile::is_default(&cli_args.profile) {\n" + " MacosLauncher::AppleScript\n" + " } else {\n" + " MacosLauncher::LaunchAgent\n" + " },\n" + " if profile::is_default(&cli_args.profile) {\n" + " Some(vec![])\n" + " } else {\n" + " Some(vec![\"--profile\", cli_args.profile.as_str()])\n" + " },\n" + " ))\n", + " .plugin({\n" + " // AppleScript login items silently drop extra arguments; LaunchAgent\n" + " // writes a plist with ProgramArguments so --profile survives relogin.\n" + " // Non-default BUILD_PROFILE means a research-edition binary: give it a\n" + " // distinct autostart entry name so editions don't overwrite each other.\n" + " let is_default_profile = profile::is_default(&cli_args.profile);\n" + " let args: Vec<&str> = if is_default_profile {\n" + " vec![]\n" + " } else {\n" + " vec![\"--profile\", cli_args.profile.as_str()]\n" + " };\n" + " #[allow(unused_mut)]\n" + " let mut b = tauri_plugin_autostart::Builder::new().args(args);\n" + " if profile::BUILD_PROFILE != profile::DEFAULT_PROFILE {\n" + " b = b.app_name(format!(\"aw-tauri-{}\", profile::BUILD_PROFILE));\n" + " }\n" + " #[cfg(target_os = \"macos\")]\n" + " {\n" + " b = b.macos_launcher(if is_default_profile {\n" + " MacosLauncher::AppleScript\n" + " } else {\n" + " MacosLauncher::LaunchAgent\n" + " });\n" + " }\n" + " b.build()\n" + " })\n", + "tauri autostart: Builder with distinct app_name for research edition", + ), +] + + +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/tests/test_patch_research_edition_profile.py b/scripts/tests/test_patch_research_edition_profile.py new file mode 100644 index 000000000..9e5bac3d2 --- /dev/null +++ b/scripts/tests/test_patch_research_edition_profile.py @@ -0,0 +1,509 @@ +import importlib.util +import os +import sys +from pathlib import Path + +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 + + +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). + assert f'_linux_autostart_dir() / "{patcher.LINUX_DESKTOP_FILENAME}"' in after + assert 'DESKTOP_FILENAME = "aw-qt.desktop"' in after + # macOS: LAUNCH_AGENT_FILENAME is derived, so the plist follows the label. + assert f'LAUNCH_AGENT_LABEL = "{patcher.LAUNCH_AGENT_LABEL}"' in after + assert 'LAUNCH_AGENT_LABEL = "net.activitywatch.aw-qt"\n' not in after + # Windows: both the Run value name and the Startup .lnk derive from APP_NAME. + assert f'APP_NAME = "{patcher.BUNDLE_NAME}"' in after + assert 'APP_NAME = "ActivityWatch"\n' not in after + + # Guard the derivations the assertions above rely on: if upstream stops + # deriving these, the research build silently keeps a colliding name. + for derived in ( + 'LAUNCH_AGENT_FILENAME = f"{LAUNCH_AGENT_LABEL}.plist"', + "WINDOWS_RUN_VALUE_NAME = APP_NAME", + 'WINDOWS_STARTUP_SHORTCUT_NAME = f"{APP_NAME}.lnk"', + ): + 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 replaces init() with Builder + 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 uses init(), not Builder + assert "tauri_plugin_autostart::init(" in before_lib + assert "tauri_plugin_autostart::Builder::new()" not in before_lib + after_lib = _patch_real_file( + tmp_path, + lib_rs_rel, + patcher.AUTOSTART_PATCHES_TAURI, + ) + # Research build uses Builder with conditional app_name + assert "tauri_plugin_autostart::Builder::new().args(args)" in after_lib + assert "profile::BUILD_PROFILE != profile::DEFAULT_PROFILE" in after_lib + assert 'b.app_name(format!("aw-tauri-{}", profile::BUILD_PROFILE))' in after_lib + # Standard autostart call is gone + assert "tauri_plugin_autostart::init(" not in after_lib