#!/usr/bin/env python3
"""Bake the Research Edition profile identity into the bundle at release time.
The research profile is *build identity*, not a launch argument. A login item
that passes ``--profile research`` misses double-click, Spotlight, and the
updater relaunch, and every one of those would silently start ``default`` and
pollute a participant's standard install. So the research build patches the
no-information fallbacks in the sources it ships, before they are compiled or
collected:
1. **Profile**: every "no ``--profile``, no ``AW_PROFILE``" fallback resolves to
``research`` instead of ``default``. Both launchers (aw-qt, aw-tauri), both
servers, and aw-client (the watchers' library) are patched, so any binary in
the bundle defaults to the research instance no matter how it was started.
The launchers then *set* ``AW_PROFILE=research`` for their children, and
aw-core/aw-tauri dirs put everything under ``activitywatch-research/``.
``DEFAULT_PROFILE`` itself is deliberately **not** flipped: it means "the
ordinary install" in suffix/dir/lockfile/``export_profile`` logic, and
flipping it would make the research build share dirs and lockfile with a
standard install — the exact collision this patch exists to prevent.
2. **Port**: the built-in 5600 default becomes 5667 in both servers, in
aw-client, and in aw-qt's tray/manager fallbacks, so a fresh research
profile binds 5667 without any config file existing yet.
3. **macOS bundle identity**: ``CFBundleIdentifier`` becomes
``net.activitywatch.ActivityWatch-research`` (Tauri: ``net.activitywatch.tauri-research``)
with ``CFBundleName`` "ActivityWatch Research" *and* on-disk bundle
``ActivityWatch-Research.app``, so dual-run next to a standard install is a
distinct LaunchServices identity that does not overwrite
``/Applications/ActivityWatch.app``.
4. **Install identity**: Windows gets its own Inno ``AppId``, install dir,
shortcuts and uninstall entry; Linux gets its own deb package name,
``/opt/activitywatch-research`` tree and desktop-entry filename. Bundle ids
split LaunchServices, not the on-disk product -- without this a research
installer *replaces* a participant's standard install.
5. **First-run autostart**: aw-qt writes its own login item / Startup shortcut /
autostart ``.desktop``, under names independent of the installer's. Those are
rebranded too, or the two editions overwrite each other's autostart even with
every other identity split.
Usage (from the repository root, after ``make test``, before ``make package``):
python3 scripts/patch_research_edition_profile.py qt # build-qt jobs
python3 scripts/patch_research_edition_profile.py tauri # build-tauri job
python3 scripts/patch_research_edition_profile.py qt --check # verify only
Every patch is fail-closed: if a target string is missing or ambiguous, the
script exits non-zero and the research build must not ship — a silent miss
here means a bundle that says "Research Edition" but runs ``profile=default``
on port 5600. Bump the submodule pin or update the table; never skip.
Run this **after** the module test suites: they assert the ordinary defaults
(``resolve_profile(None) == "default"``, port 5600) and would fail on the
patched tree. Python modules are editable installs, so PyInstaller collects
the patched source at package time; Rust binaries need a rebuild afterwards.
"""
from __future__ import annotations
import argparse
import pathlib
import sys
from dataclasses import dataclass
from typing import Iterable, List, Sequence
RESEARCH_PROFILE = "research"
RESEARCH_PORT = 5667
BUNDLE_ID = "net.activitywatch.ActivityWatch-research"
TAURI_IDENTIFIER = "net.activitywatch.tauri-research"
BUNDLE_NAME = "ActivityWatch Research"
# Hyphenated on-disk stem so Makefile / notarize / CI paths stay unquoted.
# Dock and Spotlight still show CFBundleName ("ActivityWatch Research").
BUNDLE_DIR_STEM = "ActivityWatch-Research"
# Windows install identity. Inno Setup keys off AppId, not the display name: a
# research setup sharing the standard AppId registers as the *same* product, so
# it upgrades over an existing install and its uninstaller removes both. Own
# GUIDs give the research build its own install dir, Start-Menu/desktop/startup
# shortcuts and "Apps & features" entry, which is what dual-run requires.
WINDOWS_APPID_QT = "32024B9B-352E-4E97-AA56-9EEF143E5B70"
WINDOWS_APPID_TAURI = "70E2D4AB-8DA2-4BE0-8391-AB5B48653773"
# MSI upgrade code. Tauri derives this from `identifier` when unset, so the
# research build already differs today via the patched identifier — but that
# makes installer identity a silent side effect of an unrelated string. Pinning
# it means a future identifier change cannot collapse research MSIs onto the
# standard upgrade family. WixConfig exposes no product-code field (Tauri
# generates one per build); the upgrade code is what defines the product family,
# so it is the one that matters here.
WINDOWS_WIX_UPGRADE_CODE_TAURI = "ABF0AB0C-5BA0-4C2C-BF3E-797B2E1913DA"
# Linux install identity. The Qt deb is a single product on master:
# `Package: activitywatch`, `/opt/activitywatch`, and one `aw-qt.desktop`
# filename in both /etc/xdg/autostart and /usr/share/applications. Installing a
# research deb next to a standard one therefore *replaces* it (dpkg treats a
# same-named package as an upgrade) and its autostart entry overwrites the
# standard one. Own package name, own /opt tree and own desktop-entry filename
# is what makes the two editions co-installable.
LINUX_PACKAGE = "activitywatch-research"
LINUX_OPT_DIR = f"/opt/{LINUX_PACKAGE}"
LINUX_DESKTOP_FILENAME = f"{LINUX_PACKAGE}.desktop"
# Desktop-entry `Icon=` id. Only the AppImage actually installs an icon under
# this name (`linuxdeploy --icon-filename`); the deb ships none today. Patch
# both together so they cannot drift apart.
LINUX_ICON_ID = LINUX_PACKAGE
# macOS LaunchAgent label written by aw-qt's own first-run autostart (distinct
# from the .app bundle id, which the installer/LaunchServices own).
LAUNCH_AGENT_LABEL = "net.activitywatch.aw-qt-research"
@dataclass(frozen=True)
class Patch:
"""One exact, unique substring replacement in one file."""
path: str
old: str
new: str
why: str
# --- profile fallbacks ------------------------------------------------------
_PY_FALLBACK = " return TESTING_PROFILE if testing else DEFAULT_PROFILE\n"
_PY_FALLBACK_NEW = " return TESTING_PROFILE if testing else BUILD_PROFILE\n"
def _python_profile_patches(path: str) -> List[Patch]:
"""aw-qt / aw-server / aw-client share one profile module layout.
Three no-information fallbacks return ``DEFAULT_PROFILE``: the ``None``
branch of ``resolve_profile`` and the unset/invalid branches of
``profile_from_env``. All three become ``BUILD_PROFILE``.
"""
return [
Patch(
path,
'DEFAULT_PROFILE = "default"\nTESTING_PROFILE = "testing"\n',
'DEFAULT_PROFILE = "default"\nTESTING_PROFILE = "testing"\n'
"#: Research Edition build identity (baked at release time). Used only\n"
"#: as the no-flag/no-env fallback; DEFAULT_PROFILE still means the\n"
"#: ordinary install for suffix/dir/lockfile/export logic.\n"
f'BUILD_PROFILE = "{RESEARCH_PROFILE}"\n',
"declare BUILD_PROFILE next to DEFAULT_PROFILE",
),
Patch(
path,
" if profile is None:\n" + _PY_FALLBACK,
" if profile is None:\n" + _PY_FALLBACK_NEW,
"resolve_profile(None) falls back to the build profile",
),
Patch(
path,
" if not profile:\n" + _PY_FALLBACK,
" if not profile:\n" + _PY_FALLBACK_NEW,
"profile_from_env with AW_PROFILE unset falls back to the build profile",
),
Patch(
path,
" except ValueError:\n" + _PY_FALLBACK,
" except ValueError:\n" + _PY_FALLBACK_NEW,
"profile_from_env with an invalid AW_PROFILE falls back to the build profile",
),
]
PROFILE_PATCHES_QT: List[Patch] = [
*_python_profile_patches("aw-qt/aw_qt/profile.py"),
*_python_profile_patches("aw-server/aw_server/profile.py"),
*_python_profile_patches("aw-client/aw_client/profile.py"),
Patch(
"aw-server-rust/aw-server/src/main.rs",
' } else {\n "default".to_string()\n }\n',
f' }} else {{\n "{RESEARCH_PROFILE}".to_string()\n }}\n',
"aw-server-rust with no --profile and no AW_PROFILE runs the build profile",
),
]
PROFILE_PATCHES_TAURI: List[Patch] = [
# Watchers in the Tauri bundle are the same Python modules.
*_python_profile_patches("aw-client/aw_client/profile.py"),
Patch(
"aw-tauri/src-tauri/src/profile.rs",
'pub const DEFAULT_PROFILE: &str = "default";\n',
'pub const DEFAULT_PROFILE: &str = "default";\n'
"/// Research Edition build identity (baked at release time). Only the\n"
"/// no-flag/no-env fallback; DEFAULT_PROFILE still means the ordinary install.\n"
f'pub const BUILD_PROFILE: &str = "{RESEARCH_PROFILE}";\n',
"declare BUILD_PROFILE next to DEFAULT_PROFILE",
),
Patch(
"aw-tauri/src-tauri/src/profile.rs",
" None => Ok(DEFAULT_PROFILE.to_string()),\n",
" None => Ok(BUILD_PROFILE.to_string()),\n",
"resolve_profile with no --profile and no AW_PROFILE runs the build profile",
),
Patch(
"aw-tauri/src-tauri/src/profile.rs",
" _ => DEFAULT_PROFILE.to_string(),\n",
" _ => BUILD_PROFILE.to_string(),\n",
"current_profile() before export falls back to the build profile",
),
]
# --- port -------------------------------------------------------------------
PORT_PATCHES_QT: List[Patch] = [
Patch(
"aw-server/aw_server/config.py",
'port = "5600"\n',
f'port = "{RESEARCH_PORT}"\n',
"aw-server default_config [server] port",
),
Patch(
"aw-server/aw_server/config.py",
" return 5666 if is_testing(profile) else 5600\n",
f" return 5666 if is_testing(profile) else {RESEARCH_PORT}\n",
"aw-server default_port()",
),
Patch(
"aw-server-rust/aw-server/src/config.rs",
" } else {\n 5600\n }\n",
f" }} else {{\n {RESEARCH_PORT}\n }}\n",
"aw-server-rust default_port()",
),
Patch(
"aw-client/aw_client/config.py",
'port = "5600"\n',
f'port = "{RESEARCH_PORT}"\n',
"aw-client default_config [server] port",
),
Patch(
"aw-client/aw_client/config.py",
' _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
' "aw-server-rust",\n'
' "config.toml",\n'
" ),\n"
" 5600,\n",
' _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
' "aw-server-rust",\n'
' "config.toml",\n'
" ),\n"
f" {RESEARCH_PORT},\n",
"aw-client api-key lookup default port for a named profile",
),
Patch(
"aw-qt/aw_qt/config.py",
" default_port = 5666 if is_testing(profile) else 5600\n",
f" default_port = 5666 if is_testing(profile) else {RESEARCH_PORT}\n",
"aw-qt _read_server_port fallback",
),
Patch(
"aw-qt/aw_qt/manager.py",
" default_port = 5666 if testing else 5600\n",
f" default_port = 5666 if testing else {RESEARCH_PORT}\n",
"aw-qt external-server probe fallback",
),
Patch(
"aw-qt/aw_qt/trayicon.py",
" port = 5666 if testing else 5600\n",
f" port = 5666 if testing else {RESEARCH_PORT}\n",
"aw-qt tray root_url fallback",
),
]
PORT_PATCHES_TAURI: List[Patch] = [
# aw-tauri embeds aw-server-rust in-process and passes its own config
# port, so the server crate's default_port() is irrelevant here.
Patch(
"aw-tauri/src-tauri/src/lib.rs",
" UserConfig {\n port: 5600,\n",
f" UserConfig {{\n port: {RESEARCH_PORT},\n",
"aw-tauri UserConfig::default port",
),
Patch(
"aw-client/aw_client/config.py",
'port = "5600"\n',
f'port = "{RESEARCH_PORT}"\n',
"aw-client default_config [server] port",
),
Patch(
"aw-client/aw_client/config.py",
' _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
' "aw-server-rust",\n'
' "config.toml",\n'
" ),\n"
" 5600,\n",
' _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
' "aw-server-rust",\n'
' "config.toml",\n'
" ),\n"
f" {RESEARCH_PORT},\n",
"aw-client api-key lookup default port for a named profile",
),
]
# --- macOS bundle identity ----------------------------------------------------
BUNDLE_PATCHES_QT: List[Patch] = [
Patch(
"aw.spec",
' bundle_identifier="net.activitywatch.ActivityWatch",\n',
f' bundle_identifier="{BUNDLE_ID}",\n',
"PyInstaller BUNDLE identifier",
),
Patch(
"aw.spec",
' "CFBundleExecutable": "MacOS/aw-qt",\n',
f' "CFBundleName": "{BUNDLE_NAME}",\n'
' "CFBundleExecutable": "MacOS/aw-qt",\n',
"PyInstaller BUNDLE display name",
),
Patch(
"aw.spec",
' name="ActivityWatch.app",\n',
f' name="{BUNDLE_DIR_STEM}.app",\n',
"PyInstaller BUNDLE on-disk filename",
),
Patch(
"Makefile",
"APP_BUNDLE ?= ActivityWatch\n",
f"APP_BUNDLE ?= {BUNDLE_DIR_STEM}\n",
"Makefile .app/.dmg stem",
),
Patch(
"scripts/notarize.sh",
"bundleid=net.activitywatch.ActivityWatch # Match aw.spec\n",
f"bundleid={BUNDLE_ID} # Match aw.spec\n",
"notarization bundle id",
),
Patch(
"scripts/notarize.sh",
"app=dist/ActivityWatch.app\n"
"dmg=dist/ActivityWatch.dmg\n",
f"app=dist/{BUNDLE_DIR_STEM}.app\n"
f"dmg=dist/{BUNDLE_DIR_STEM}.dmg\n",
"notarization .app/.dmg paths",
),
]
BUNDLE_PATCHES_TAURI: List[Patch] = [
Patch(
"scripts/package/build_app_tauri.sh",
'BUNDLE_ID="net.activitywatch.ActivityWatch"\n',
f'BUNDLE_ID="{BUNDLE_ID}"\n',
"Tauri .app CFBundleIdentifier",
),
Patch(
"scripts/package/build_app_tauri.sh",
" CFBundleName\n ${APP_NAME}\n",
f" CFBundleName\n {BUNDLE_NAME}\n",
"Tauri .app CFBundleName",
),
Patch(
"scripts/package/build_app_tauri.sh",
'APP_NAME="ActivityWatch"\n',
f'APP_NAME="{BUNDLE_DIR_STEM}"\n',
"Tauri .app on-disk filename",
),
Patch(
"Makefile",
"APP_BUNDLE ?= ActivityWatch\n",
f"APP_BUNDLE ?= {BUNDLE_DIR_STEM}\n",
"Makefile .app/.dmg stem",
),
Patch(
"aw-tauri/src-tauri/tauri.conf.json",
' "identifier": "net.activitywatch.tauri",\n',
f' "identifier": "{TAURI_IDENTIFIER}",\n',
"Tauri identifier (single-instance slot on macOS/Windows, installer identity)",
),
Patch(
"scripts/notarize.sh",
"bundleid=net.activitywatch.ActivityWatch # Match aw.spec\n",
f"bundleid={BUNDLE_ID} # Match aw.spec\n",
"notarization bundle id",
),
Patch(
"scripts/notarize.sh",
"app=dist/ActivityWatch.app\n"
"dmg=dist/ActivityWatch.dmg\n",
f"app=dist/{BUNDLE_DIR_STEM}.app\n"
f"dmg=dist/{BUNDLE_DIR_STEM}.dmg\n",
"notarization .app/.dmg paths",
),
]
# --- Windows install identity -------------------------------------------------
# Both .iss files derive AppName, DefaultDirName (Qt), the Start-Menu / desktop /
# {userstartup} shortcut names and UninstallDisplayName from `#define MyAppName`,
# so patching that one token cascades to every user-visible identity. AppId and
# OutputBaseFilename do not cascade and are patched explicitly.
#
# Note both .iss files ship `OutputBaseFilename=activitywatch-setup` on master,
# so the Qt and Tauri setups already overwrite each other in dist/. The research
# names below are distinct from each other as well as from standard, which also
# stops research artifacts from colliding on the release page.
WINDOWS_PATCHES_QT: List[Patch] = [
Patch(
"scripts/package/activitywatch-setup.iss",
'#define MyAppName "ActivityWatch"\n',
f'#define MyAppName "{BUNDLE_NAME}"\n',
"Inno Qt product name (cascades to dir, shortcuts, uninstall entry)",
),
Patch(
"scripts/package/activitywatch-setup.iss",
"AppId={{F226B8F4-3244-46E6-901D-0CE8035423E4}\n",
f"AppId={{{{{WINDOWS_APPID_QT}}}\n",
"Inno Qt AppId (separate product, not an upgrade of standard)",
),
Patch(
"scripts/package/activitywatch-setup.iss",
"OutputBaseFilename=activitywatch-setup\n",
"OutputBaseFilename=activitywatch-research-setup\n",
"Inno Qt setup .exe filename",
),
]
WINDOWS_PATCHES_TAURI: List[Patch] = [
Patch(
"aw-tauri/src-tauri/tauri.conf.json",
' "bundle": {\n "active": true,\n',
' "bundle": {\n "active": true,\n'
' "windows": {\n'
' "wix": {\n'
f' "upgradeCode": "{WINDOWS_WIX_UPGRADE_CODE_TAURI}"\n'
' }\n'
' },\n',
"Tauri WiX upgrade code (research MSIs are their own product family)",
),
Patch(
"scripts/package/aw-tauri.iss",
'#define MyAppName "ActivityWatch (Tauri)"\n',
f'#define MyAppName "{BUNDLE_NAME} (Tauri)"\n',
"Inno Tauri product name (cascades to shortcuts, uninstall entry)",
),
Patch(
"scripts/package/aw-tauri.iss",
"AppId={{983D0855-08C8-46BD-AEFB-3924581C6703}\n",
f"AppId={{{{{WINDOWS_APPID_TAURI}}}\n",
"Inno Tauri AppId (separate product, not an upgrade of standard Tauri)",
),
Patch(
"scripts/package/aw-tauri.iss",
"DefaultDirName={autopf}\\ActivityWatch-Tauri\n",
f"DefaultDirName={{autopf}}\\{BUNDLE_DIR_STEM}-Tauri\n",
"Inno Tauri install directory",
),
Patch(
"scripts/package/aw-tauri.iss",
"OutputBaseFilename=activitywatch-setup\n",
"OutputBaseFilename=activitywatch-research-tauri-setup\n",
"Inno Tauri setup .exe filename",
),
]
# --- Linux package identity ---------------------------------------------------
# Qt only: the Tauri Linux bundles come from Tauri's own bundler, whose package
# and desktop-entry names derive from `productName`. That is coupled to the
# cargo binary name (`mainBinaryName`), so splitting it needs a Tauri build to
# verify and is tracked separately.
LINUX_PATCHES_QT: List[Patch] = [
Patch(
"scripts/package/deb/control",
"Package: activitywatch\n",
f"Package: {LINUX_PACKAGE}\n",
"deb package name (co-installable with the standard package)",
),
Patch(
"scripts/package/deb/control",
"Description: Open source time tracker\n",
"Description: Open source time tracker (Research Edition)\n",
"deb package description",
),
Patch(
"scripts/package/package-deb.sh",
'PKGDIR="activitywatch_$VERSION_NUM"\n',
f'PKGDIR="{LINUX_PACKAGE}_$VERSION_NUM"\n',
"deb staging dir (dpkg-deb names the .deb after it)",
),
Patch(
"scripts/package/package-deb.sh",
"sudo mv activitywatch_${VERSION_NUM}.deb",
f"sudo mv {LINUX_PACKAGE}_${{VERSION_NUM}}.deb",
"deb output filename produced by dpkg-deb --build",
),
Patch(
"scripts/package/package-deb.sh",
"cp -r dist/activitywatch/ $PKGDIR/opt/\n",
f"cp -r dist/activitywatch/ $PKGDIR{LINUX_OPT_DIR}\n",
"install tree location (/opt/activitywatch-research)",
),
Patch(
"scripts/package/package-deb.sh",
"sudo sed -i 's!Exec=aw-qt!Exec=/opt/activitywatch/aw-qt!' "
"$PKGDIR/opt/activitywatch/aw-qt.desktop\n"
"sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/etc/xdg/autostart/\n"
"sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/usr/share/applications/\n",
f"sudo sed -i 's!Exec=aw-qt!Exec={LINUX_OPT_DIR}/aw-qt!' "
f"$PKGDIR{LINUX_OPT_DIR}/aw-qt.desktop\n"
f"sudo cp $PKGDIR{LINUX_OPT_DIR}/aw-qt.desktop "
f"$PKGDIR/etc/xdg/autostart/{LINUX_DESKTOP_FILENAME}\n"
f"sudo cp $PKGDIR{LINUX_OPT_DIR}/aw-qt.desktop "
f"$PKGDIR/usr/share/applications/{LINUX_DESKTOP_FILENAME}\n",
"Exec path plus distinct autostart/menu desktop-entry filename",
),
Patch(
"aw-qt/resources/aw-qt.desktop",
"Name=ActivityWatch\n",
f"Name={BUNDLE_NAME}\n",
"desktop entry display name",
),
Patch(
"aw-qt/resources/aw-qt.desktop",
"Icon=activitywatch\n",
f"Icon={LINUX_ICON_ID}\n",
"desktop entry icon id (matches the AppImage --icon-filename)",
),
Patch(
"scripts/package/package-appimage.sh",
"--desktop-file ./activitywatch/aw-qt.desktop "
"--icon-file ./activitywatch/media/logo/logo.png "
"--icon-filename activitywatch\n",
f"--desktop-file ./activitywatch/{LINUX_DESKTOP_FILENAME} "
"--icon-file ./activitywatch/media/logo/logo.png "
f"--icon-filename {LINUX_ICON_ID}\n",
"AppImage desktop-entry filename and icon id",
),
Patch(
"scripts/package/package-appimage.sh",
"# create AppRun\n",
"# Research edition: linuxdeploy installs the desktop entry under its own\n"
"# basename, so give it a distinct one - appimaged would otherwise\n"
"# overwrite a standard install's entry on desktop integration.\n"
f"cp ./activitywatch/aw-qt.desktop ./activitywatch/{LINUX_DESKTOP_FILENAME}\n"
"\n# create AppRun\n",
"AppImage: stage the research desktop entry under its own filename",
),
]
# --- first-run autostart identity ---------------------------------------------
# aw-qt writes its own autostart entry (config `autostart_on_first_run`). Those
# names are independent of the installer's: without this the two editions
# overwrite each other's login item / Startup shortcut / autostart .desktop even
# though every other identity is already split.
#
# aw-qt#133 made those names profile-aware (`aw-qt-research.desktop`,
# `net.activitywatch.aw-qt-research`, `ActivityWatch (research)`). The research
# edition *is* profile=research, so the suffix alone collides with a standard
# build launched as `--profile research`. Override the written identities to
# edition-specific names; leave DESKTOP_FILENAME / LAUNCH_AGENT_LABEL as the
# shipped-resource / base strings.
AUTOSTART_PATCHES_QT: List[Patch] = [
Patch(
"aw-qt/aw_qt/autostart.py",
'APP_NAME = "ActivityWatch"\n',
f'APP_NAME = "{BUNDLE_NAME}"\n',
"Windows Run value + Startup shortcut name",
),
Patch(
"aw-qt/aw_qt/autostart.py",
' return _linux_autostart_dir() / f"{stem}{_profile_suffix()}{extension}"\n',
f' return _linux_autostart_dir() / "{LINUX_DESKTOP_FILENAME}"\n',
"Linux autostart entry filename (DESKTOP_FILENAME still names the "
"shipped resource we copy from; do not use aw-qt-research.desktop — "
"that is the standard build's named-profile entry)",
),
Patch(
"aw-qt/aw_qt/autostart.py",
' return f"{LAUNCH_AGENT_LABEL}{_profile_suffix()}"\n',
f' return "{BUNDLE_ID}"\n',
"macOS LaunchAgent label (bundle id, not net.activitywatch.aw-qt-research, "
"which is the standard build's named-profile label)",
),
Patch(
"aw-qt/aw_qt/autostart.py",
"Name=ActivityWatch\n",
f"Name={BUNDLE_NAME}\n",
"fallback desktop-entry template display name",
),
]
# --- first-run autostart identity (Tauri) --------------------------------------
# aw-tauri#253 already gives named profiles a distinct plugin app name
# (`aw-tauri-research`). The research edition *is* profile=research, so that
# name collides with a standard build launched as `--profile research`. Override
# with an edition-specific `aw-tauri-{BUILD_PROFILE}-edition` identity.
#
# This patch applies after PROFILE_PATCHES_TAURI, so BUILD_PROFILE and
# DEFAULT_PROFILE are both defined in the compiled profile module by the time
# the research binary runs.
AUTOSTART_PATCHES_TAURI: List[Patch] = [
Patch(
"aw-tauri/src-tauri/src/lib.rs",
" if let Some(app_name) = profile::autostart_app_name(&cli_args.profile) {\n"
" builder = builder\n"
" .app_name(app_name)\n"
" .args([\"--profile\", cli_args.profile.as_str()]);\n"
" }\n",
" if let Some(app_name) = profile::autostart_app_name(&cli_args.profile) {\n"
" builder = builder\n"
" .app_name(app_name)\n"
" .args([\"--profile\", cli_args.profile.as_str()]);\n"
" }\n"
" // Non-default BUILD_PROFILE is a research-edition binary: do not\n"
" // reuse the named-profile identity (`aw-tauri-research`), which a\n"
" // standard `--profile research` login item already owns.\n"
" if profile::BUILD_PROFILE != profile::DEFAULT_PROFILE {\n"
" builder = builder.app_name(format!(\n"
" \"aw-tauri-{}-edition\",\n"
" profile::BUILD_PROFILE\n"
" ));\n"
" }\n",
"tauri autostart: edition-specific app_name so research != named profile",
),
]
TARGETS = {
"qt": (
PROFILE_PATCHES_QT
+ PORT_PATCHES_QT
+ BUNDLE_PATCHES_QT
+ WINDOWS_PATCHES_QT
+ LINUX_PATCHES_QT
+ AUTOSTART_PATCHES_QT
),
"tauri": (
PROFILE_PATCHES_TAURI
+ PORT_PATCHES_TAURI
+ BUNDLE_PATCHES_TAURI
+ WINDOWS_PATCHES_TAURI
+ AUTOSTART_PATCHES_TAURI
),
}
class PatchError(Exception):
pass
def _group_by_path(patches: Iterable[Patch]) -> "dict[str, List[Patch]]":
grouped: "dict[str, List[Patch]]" = {}
for patch in patches:
grouped.setdefault(patch.path, []).append(patch)
return grouped
def apply_patches(
root: pathlib.Path, patches: Sequence[Patch], check: bool = False
) -> List[str]:
"""Apply (or with ``check`` only verify) every patch under ``root``.
All targets in a file are validated before any of them is written, and a
file is only written once all its patches apply. Any missing or ambiguous
target raises :class:`PatchError` naming the file and the patch's purpose,
so a stale submodule pin fails the release instead of shipping a bundle
that runs the default profile.
"""
applied: List[str] = []
for rel_path, file_patches in _group_by_path(patches).items():
path = root / rel_path
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
raise PatchError(
f"{rel_path}: not found - is the submodule checked out?"
) from None
for patch in file_patches:
if patch.new in text:
# Declaration inserts keep `old` as a prefix of `new`, so a
# second run would silently double-insert without this check.
raise PatchError(
f"{rel_path}: [{patch.why}] is already applied - refusing to "
"patch a tree twice"
)
occurrences = text.count(patch.old)
if occurrences != 1:
raise PatchError(
f"{rel_path}: expected exactly one match for [{patch.why}], "
f"found {occurrences}. The source drifted from what this "
"patcher targets - refusing to ship a research build with "
"the default profile/port/bundle id."
)
text = text.replace(patch.old, patch.new, 1)
applied.append(f"{rel_path}: {patch.why}")
if not check:
path.write_text(text, encoding="utf-8")
return applied
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"target", choices=sorted(TARGETS), help="which bundle is being built"
)
parser.add_argument(
"--root",
type=pathlib.Path,
default=pathlib.Path.cwd(),
help="repository root (default: current directory)",
)
parser.add_argument(
"--check",
action="store_true",
help="verify every target string is present and unique; write nothing",
)
args = parser.parse_args(argv)
try:
applied = apply_patches(args.root, TARGETS[args.target], check=args.check)
except PatchError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
verb = "Verified" if args.check else "Patched"
for line in applied:
print(f"{verb} {line}")
print(
f"{verb} {len(applied)} research edition site(s) for {args.target}: "
f"profile={RESEARCH_PROFILE} port={RESEARCH_PORT} bundle={BUNDLE_ID}"
)
return 0
if __name__ == "__main__":
sys.exit(main())