From 3689ad5809847f10cd32e3583b8b2a1fa063e7cc Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 7 Sep 2026 20:15:24 +0000 Subject: [PATCH 1/8] feat(research): bake profile=research, port 5667 and a distinct bundle id into research builds The Research Edition profile stack (#1399) shipped, but the research build never consumed it: v0.14.0b5-research resolves profile=default, binds 5600 and shares activitywatch/ with a standard install. The profile is build identity, not a launch argument: a login-item --profile would miss double-click, Spotlight and the updater relaunch. New fail-closed patcher scripts/patch_research_edition_profile.py {qt|tauri} (same pattern as patch_research_edition_awqt.py), wired into all three build jobs for AW_RESEARCH_EDITION == 'true': - every no-flag/no-env profile fallback in aw-qt, aw-server, aw-client, aw-server-rust and aw-tauri resolves to BUILD_PROFILE = "research"; DEFAULT_PROFILE itself is left alone because it means the ordinary install in suffix/dir/lockfile/export_profile logic - built-in port 5600 -> 5667 in both servers, aw-client, aw-qt's tray and manager fallbacks, and aw-tauri's UserConfig::default - CFBundleIdentifier net.activitywatch.ActivityWatch-research (Tauri identifier net.activitywatch.tauri-research) and CFBundleName "ActivityWatch Research" in aw.spec, build_app_tauri.sh, tauri.conf.json and notarize.sh The bake runs after the module test suites (which assert the ordinary defaults) and before Package; Python modules are editable installs, the Rust binaries get an explicit rebuild step. Git-Session-Id: cb3d --- .github/workflows/release.yml | 60 +++ scripts/patch_research_edition_profile.py | 398 ++++++++++++++++++ .../test_patch_research_edition_profile.py | 206 +++++++++ 3 files changed, 664 insertions(+) create mode 100644 scripts/patch_research_edition_profile.py create mode 100644 scripts/tests/test_patch_research_edition_profile.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 044f7e8db..389712996 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -461,6 +461,23 @@ jobs: source venv/bin/activate || source venv/Scripts/activate make test-integration + # 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 @@ -735,6 +752,23 @@ jobs: source venv/bin/activate make test-integration + # 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 @@ -963,6 +997,32 @@ jobs: source venv/bin/activate || source venv/Scripts/activate make test SKIP_SERVER_RUST=${{ matrix.skip_rust }} + # 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: | diff --git a/scripts/patch_research_edition_profile.py b/scripts/patch_research_edition_profile.py new file mode 100644 index 000000000..80f6e5a61 --- /dev/null +++ b/scripts/patch_research_edition_profile.py @@ -0,0 +1,398 @@ +#!/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", so dual-run next to a + standard install is a distinct LaunchServices identity with its own login + item and TCC grant, not a shared slot. + +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" + + +@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( + "scripts/notarize.sh", + "bundleid=net.activitywatch.ActivityWatch # Match aw.spec\n", + f"bundleid={BUNDLE_ID} # Match aw.spec\n", + "notarization bundle id", + ), +] + +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( + "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", + ), +] + +TARGETS = { + "qt": PROFILE_PATCHES_QT + PORT_PATCHES_QT + BUNDLE_PATCHES_QT, + "tauri": PROFILE_PATCHES_TAURI + PORT_PATCHES_TAURI + BUNDLE_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..b87d959b8 --- /dev/null +++ b/scripts/tests/test_patch_research_edition_profile.py @@ -0,0 +1,206 @@ +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" From 7609fd39b7e1336d4d88cf2feb46a4a29dc23b5d Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 7 Sep 2026 20:30:33 +0000 Subject: [PATCH 2/8] fix(research): emit ActivityWatch-Research.app and run patcher tests in CI The research macOS bundle still installed as ActivityWatch.app, so dragging it into Applications replaced a standard install. Bake the on-disk stem (ActivityWatch-Research.app / .dmg) through aw.spec, APP_NAME, Makefile, and notarize.sh, and parameterize the dmg job on APP_BUNDLE. Also run the fail-closed patcher tests on every release/PR job before patching, so table drift fails at PR time instead of tag time. --- .github/workflows/release.yml | 57 +++++++++++++++---- Makefile | 12 ++-- scripts/patch_research_edition_profile.py | 50 +++++++++++++++- .../test_patch_research_edition_profile.py | 30 ++++++++++ 4 files changed, 132 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 389712996..19210d320 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -461,6 +461,15 @@ 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 @@ -508,21 +517,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 }} @@ -752,6 +766,15 @@ 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 @@ -997,6 +1020,15 @@ 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 @@ -1080,21 +1112,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 }} 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/scripts/patch_research_edition_profile.py b/scripts/patch_research_edition_profile.py index 80f6e5a61..4cc6ce6b7 100644 --- a/scripts/patch_research_edition_profile.py +++ b/scripts/patch_research_edition_profile.py @@ -26,9 +26,10 @@ 3. **macOS bundle identity**: ``CFBundleIdentifier`` becomes ``net.activitywatch.ActivityWatch-research`` (Tauri: ``net.activitywatch.tauri-research``) - with ``CFBundleName`` "ActivityWatch Research", so dual-run next to a - standard install is a distinct LaunchServices identity with its own login - item and TCC grant, not a shared slot. + 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``. Usage (from the repository root, after ``make test``, before ``make package``): @@ -60,6 +61,9 @@ 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" @dataclass(frozen=True) @@ -263,12 +267,32 @@ def _python_profile_patches(path: str) -> List[Patch]: ' "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] = [ @@ -284,6 +308,18 @@ def _python_profile_patches(path: str) -> List[Patch]: 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', @@ -296,6 +332,14 @@ def _python_profile_patches(path: str) -> List[Patch]: 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", + ), ] TARGETS = { diff --git a/scripts/tests/test_patch_research_edition_profile.py b/scripts/tests/test_patch_research_edition_profile.py index b87d959b8..78e402b95 100644 --- a/scripts/tests/test_patch_research_edition_profile.py +++ b/scripts/tests/test_patch_research_edition_profile.py @@ -204,3 +204,33 @@ def test_patched_python_profile_module_behaviour(tmp_path: Path, monkeypatch): 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 From 2868946262c15143db7788a4c7512f42464f520c Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 7 Sep 2026 21:03:36 +0000 Subject: [PATCH 3/8] build(deps): bump aw-watcher-window for research-edition guard skips Watcher #143 squash-merged at abd69a6. The two #138 guards now skip when AW_RESEARCH_EDITION=true, which is what killed v0.14.0b5-research run 34155142876. Pin folded into this PR so the recut does not need a separate bump round-trip. Git-Session-Id: pm-aw143 --- aw-watcher-window | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f96b8d305f6f8161d6d816f144242681d5a9f9e7 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 7 Sep 2026 21:49:56 +0000 Subject: [PATCH 4/8] feat(research): give the research edition its own Windows install identity Both Inno Setup scripts describe a single product each. A research build made from them would share the standard AppId, so Windows treats it as the *same* product: the research setup upgrades over an existing install and its uninstaller removes both. That defeats the dual-run the research edition exists for. Patch, fail-closed, at release time: - activitywatch-setup.iss (qt): AppId -> 32024B9B-..., MyAppName -> "ActivityWatch Research", OutputBaseFilename -> activitywatch-research-setup - aw-tauri.iss (tauri): AppId -> 70E2D4AB-..., MyAppName -> "ActivityWatch Research (Tauri)", DefaultDirName -> ActivityWatch-Research-Tauri, OutputBaseFilename -> activitywatch-research-tauri-setup - tauri.conf.json: pin bundle.windows.wix.upgradeCode AppName, DefaultDirName (qt), the Start-Menu / desktop / {userstartup} shortcuts and UninstallDisplayName all derive from `#define MyAppName`, so one patched token cascades to every user-visible identity. The tests assert that cascade against the *real* .iss files rather than a synthetic fixture, so upstream hardcoding a shortcut name fails the research build instead of silently shipping a colliding Start-Menu entry. WixConfig exposes no product-code field (Tauri generates one per build); the upgrade code is what defines the MSI product family, so that is the one pinned. It was already distinct via the patched identifier, but only as a side effect of an unrelated string. 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 are distinct from each other as well as from standard. Refs #1434 Git-Session-Id: 26d3 --- scripts/patch_research_edition_profile.py | 101 +++++++++++++++- .../test_patch_research_edition_profile.py | 113 ++++++++++++++++++ 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/scripts/patch_research_edition_profile.py b/scripts/patch_research_edition_profile.py index 4cc6ce6b7..90bf7390c 100644 --- a/scripts/patch_research_edition_profile.py +++ b/scripts/patch_research_edition_profile.py @@ -65,6 +65,22 @@ # 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" + @dataclass(frozen=True) class Patch: @@ -342,9 +358,90 @@ def _python_profile_patches(path: str) -> List[Patch]: ), ] +# --- 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", + ), +] + + TARGETS = { - "qt": PROFILE_PATCHES_QT + PORT_PATCHES_QT + BUNDLE_PATCHES_QT, - "tauri": PROFILE_PATCHES_TAURI + PORT_PATCHES_TAURI + BUNDLE_PATCHES_TAURI, + "qt": ( + PROFILE_PATCHES_QT + + PORT_PATCHES_QT + + BUNDLE_PATCHES_QT + + WINDOWS_PATCHES_QT + ), + "tauri": ( + PROFILE_PATCHES_TAURI + + PORT_PATCHES_TAURI + + BUNDLE_PATCHES_TAURI + + WINDOWS_PATCHES_TAURI + ), } diff --git a/scripts/tests/test_patch_research_edition_profile.py b/scripts/tests/test_patch_research_edition_profile.py index 78e402b95..9f05bf699 100644 --- a/scripts/tests/test_patch_research_edition_profile.py +++ b/scripts/tests/test_patch_research_edition_profile.py @@ -234,3 +234,116 @@ def test_macos_bundle_dir_is_distinct_from_standard(tmp_path: Path, target: str) 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 From 99ae754ad78543968ed000f85804d4fb7ac97cf8 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 7 Sep 2026 22:27:12 +0000 Subject: [PATCH 5/8] feat(research): split Linux package and first-run autostart identity The research edition already had its own profile, port, macOS bundle and Windows installer identity. Linux was still a single product: the deb is `Package: activitywatch` installing `/opt/activitywatch`, and it drops `aw-qt.desktop` into 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. Bake a distinct Linux identity at release time: package `activitywatch-research`, `/opt/activitywatch-research`, `activitywatch-research.desktop` in both shared namespaces, a rebranded `Name=`, and a matching AppImage icon id / desktop-entry filename. Also split what aw-qt registers for itself on first run (config `autostart_on_first_run`): the Linux autostart filename, the macOS LaunchAgent label, and APP_NAME (which the Windows Run value and Startup shortcut derive from). Installer identity does not cover these -- without them the two editions still overwrite each other's autostart. The Linux autostart patch targets `_linux_desktop_path()` rather than `DESKTOP_FILENAME`, which also names the shipped resource the build reads out of the bundle; flipping the constant would send `_bundled_desktop_file()` looking for a file that is not there. Tauri's Linux bundles come from Tauri's own bundler, keyed on `productName`, which is coupled to the cargo binary name -- left for a change that can be verified against a real Tauri build. Tests patch the real packaging scripts, not a synthetic fixture, so upstream adding another /opt/activitywatch reference or a fourth desktop-entry copy fails the research build instead of shipping a collision. Git-Session-Id: e26e --- scripts/patch_research_edition_profile.py | 151 ++++++++++++++++++ .../test_patch_research_edition_profile.py | 106 ++++++++++++ 2 files changed, 257 insertions(+) diff --git a/scripts/patch_research_edition_profile.py b/scripts/patch_research_edition_profile.py index 90bf7390c..835f373d9 100644 --- a/scripts/patch_research_edition_profile.py +++ b/scripts/patch_research_edition_profile.py @@ -31,6 +31,17 @@ 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 @@ -81,6 +92,24 @@ # 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: @@ -429,12 +458,134 @@ def _python_profile_patches(path: str) -> List[Patch]: ] +# --- 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", + ), +] + + TARGETS = { "qt": ( PROFILE_PATCHES_QT + PORT_PATCHES_QT + BUNDLE_PATCHES_QT + WINDOWS_PATCHES_QT + + LINUX_PATCHES_QT + + AUTOSTART_PATCHES_QT ), "tauri": ( PROFILE_PATCHES_TAURI diff --git a/scripts/tests/test_patch_research_edition_profile.py b/scripts/tests/test_patch_research_edition_profile.py index 9f05bf699..7328b417c 100644 --- a/scripts/tests/test_patch_research_edition_profile.py +++ b/scripts/tests/test_patch_research_edition_profile.py @@ -347,3 +347,109 @@ def test_tauri_wix_upgrade_code_is_pinned_and_config_stays_valid_json(tmp_path: 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}" From 706ba75f388a3f177368e1c5c9340b7fa9631a1b Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 8 Sep 2026 02:54:46 +0000 Subject: [PATCH 6/8] =?UTF-8?q?ci:=20add=20research-edition-checks=20job?= =?UTF-8?q?=20=E2=80=94=20fire=20patch=20guards=20at=20PR=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated `research-edition-checks` job that runs on every PR and push to master. It checks out with submodules and verifies that every patch target in the Qt and Tauri tables still exists exactly once in the real source files (`--check` mode). This is the fail-closed guard that previously only fired when Erik pushed the annotated release tag. The job is stdlib-only (no poetry/build step) so it completes in ~30s. It also runs the fixture-based profile patcher unit tests. Closes task step 6.2 of the research-edition-profile-wiring checklist. Git-Session-Id: f8ff --- .github/workflows/release.yml | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19210d320..003f5dded 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' From 82dc91c6360f408875fada4326ef4c782e041204 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 8 Sep 2026 03:18:22 +0000 Subject: [PATCH 7/8] =?UTF-8?q?feat(research):=205.4=20Tauri=20autostart?= =?UTF-8?q?=20=E2=80=94=20AUTOSTART=5FPATCHES=5FTAURI=20for=20distinct=20a?= =?UTF-8?q?pp=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tauri_plugin_autostart uses productName ("aw-tauri") as the OS autostart entry name by default. Standard and research Tauri builds sharing that name means enabling autostart in one edition overwrites the other's entry on Windows (registry Run key) and Linux (~/.config/autostart/). Add AUTOSTART_PATCHES_TAURI that replaces tauri_plugin_autostart::init() with a Builder::new() call. After the profile patches have also run, BUILD_PROFILE is defined in profile.rs. When BUILD_PROFILE != DEFAULT_PROFILE the Builder sets app_name("aw-tauri-research"), giving the research binary its own entry name. MacosLauncher selection (AppleScript vs LaunchAgent) is preserved. Tauri's Linux bundler derives package/desktop names from productName, so a full Linux package identity split needs a separate investigation; the collision that is fixed here is the runtime autostart entry written by the plugin itself. Add test asserting the patcher inserts BUILD_PROFILE and that the lib.rs patch produces Builder code with the conditional app_name override. Git-Session-Id: 49c9 --- scripts/patch_research_edition_profile.py | 62 +++++++++++++++++++ .../test_patch_research_edition_profile.py | 54 ++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/scripts/patch_research_edition_profile.py b/scripts/patch_research_edition_profile.py index 835f373d9..a53d607c2 100644 --- a/scripts/patch_research_edition_profile.py +++ b/scripts/patch_research_edition_profile.py @@ -578,6 +578,67 @@ def _python_profile_patches(path: str) -> List[Patch]: ] +# --- 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 @@ -592,6 +653,7 @@ def _python_profile_patches(path: str) -> List[Patch]: + PORT_PATCHES_TAURI + BUNDLE_PATCHES_TAURI + WINDOWS_PATCHES_TAURI + + AUTOSTART_PATCHES_TAURI ), } diff --git a/scripts/tests/test_patch_research_edition_profile.py b/scripts/tests/test_patch_research_edition_profile.py index 7328b417c..9e5bac3d2 100644 --- a/scripts/tests/test_patch_research_edition_profile.py +++ b/scripts/tests/test_patch_research_edition_profile.py @@ -453,3 +453,57 @@ def test_first_run_autostart_identity_is_distinct_on_every_platform(tmp_path: Pa '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 From 5d5faa8eaf5a6c7444ff1d2e48b2553f55e36e91 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 8 Sep 2026 03:31:49 +0000 Subject: [PATCH 8/8] ci: research-edition build leg on PR/branch CI (6.2) The --check smoke test (706ba75) only catches patch-target drift on the unpatched tree. A patched-tree compile/test/package failure -- the class that killed the first v0.14.0b5-research run -- still only surfaced at tag time. Add a `research` matrix dimension with one Linux include row per build job (Qt on ubuntu-22.04, Tauri on ubuntu-24.04) that sets AW_RESEARCH_EDITION=true, so every PR and branch push applies the full research patch set and builds, tests and packages the patched tree. On tag pushes and workflow dispatches the include row's `research` value resolves to false, so it merges into the existing Linux 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. Artifact names carry a -research suffix so the leg cannot collide with the standard row on the same runner. Git-Session-Id: 0283 --- .github/workflows/release.yml | 44 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 003f5dded..b39511205 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -298,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 @@ -313,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 @@ -594,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: @@ -873,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" @@ -897,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 @@ -1232,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/*