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