snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
This commit is contained in:
597
scripts/build_changelog.py
Executable file
597
scripts/build_changelog.py
Executable file
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script that generates a changelog for the repository and its submodules, and outputs it in the current directory.
|
||||
|
||||
NOTE: This script can be downloaded as-is and run from your repository.
|
||||
|
||||
Repos using this script:
|
||||
- ActivityWatch/activitywatch
|
||||
- ErikBjare/gptme
|
||||
|
||||
Manual actions needed to clean up for changelog:
|
||||
- Reorder modules in a logical order (aw-webui, aw-server, aw-server-rust, aw-watcher-window, aw-watcher-afk, ...)
|
||||
- Remove duplicate aw-webui entries
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from collections import defaultdict
|
||||
from collections.abc import Collection
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE, STDOUT
|
||||
from subprocess import run as _run
|
||||
from time import sleep
|
||||
from typing import (
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
script_dir = Path(__file__).parent.resolve()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate changelog from git history")
|
||||
|
||||
# repo info
|
||||
parser.add_argument("--org", default="ActivityWatch", help="GitHub organization")
|
||||
parser.add_argument("--repo", default="activitywatch", help="GitHub repository")
|
||||
parser.add_argument(
|
||||
"--project-title", default="ActivityWatch", help="Project title"
|
||||
)
|
||||
|
||||
# settings
|
||||
last_tag = run("git describe --tags --abbrev=0").strip() # get latest tag
|
||||
branch = run("git rev-parse --abbrev-ref HEAD").strip() # get current branch name
|
||||
parser.add_argument(
|
||||
"--range", default=f"{last_tag}...{branch}", help="Git commit range"
|
||||
)
|
||||
parser.add_argument("--path", default=".", help="Path to git repo")
|
||||
|
||||
# output
|
||||
parser.add_argument(
|
||||
"--output", default="changelog.md", help="Path to output changelog"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--add-version-header",
|
||||
action="store_true",
|
||||
help="Add version header and adjust heading levels for docs",
|
||||
)
|
||||
|
||||
# parse args
|
||||
args = parser.parse_args()
|
||||
since, until = args.range.split("...", 1)
|
||||
|
||||
# preferred output order for submodules
|
||||
repo_order = [
|
||||
"activitywatch",
|
||||
"aw-server",
|
||||
"aw-server-rust",
|
||||
"aw-webui",
|
||||
"aw-watcher-afk",
|
||||
"aw-watcher-window",
|
||||
"aw-qt",
|
||||
"aw-core",
|
||||
"aw-client",
|
||||
]
|
||||
|
||||
build(
|
||||
args.org,
|
||||
args.repo,
|
||||
args.project_title,
|
||||
commit_range=(since, until),
|
||||
output_path=args.output,
|
||||
repo_order=repo_order,
|
||||
add_version_header=args.add_version_header,
|
||||
)
|
||||
|
||||
|
||||
class CommitMsg:
|
||||
type: str
|
||||
subtype: str
|
||||
msg: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Commit:
|
||||
id: str
|
||||
msg: str
|
||||
org: str
|
||||
repo: str
|
||||
|
||||
@property
|
||||
def msg_processed(self) -> str:
|
||||
"""Generates links from commit and issue references (like 0c14d77, #123) to correct repo and such"""
|
||||
s = self.msg
|
||||
s = re.sub(
|
||||
rf"[^(-]https://github.com/{self.org}/([\-\w\d]+)/(issues|pulls)/(\d+)",
|
||||
rf"[#\3](https://github.com/{self.org}/\1/issues/\3)",
|
||||
s,
|
||||
)
|
||||
s = re.sub(
|
||||
r"#(\d+)",
|
||||
rf"[#\1](https://github.com/{self.org}/{self.repo}/issues/\1)",
|
||||
s,
|
||||
)
|
||||
s = re.sub(
|
||||
r"[\s\(][0-9a-f]{7}[\s\)]",
|
||||
rf"[`\0`](https://github.com/{self.org}/{self.repo}/issues/\0)",
|
||||
s,
|
||||
)
|
||||
# wrap html elements in backticks, if not already wrapped
|
||||
s = re.sub(r"(?<!`)<([^>]+)>(?!`)", r"`<\1>`", s)
|
||||
return s
|
||||
|
||||
def parse_type(self) -> Optional[Tuple[str, str]]:
|
||||
# Needs to handle '!' indicating breaking change
|
||||
match = re.search(r"^(\w+)(\((.+)\))?[!]?:", self.msg)
|
||||
if match:
|
||||
type = match.group(1)
|
||||
subtype = match.group(3)
|
||||
if type in ["build", "ci", "fix", "feat"]:
|
||||
return type, subtype
|
||||
return None
|
||||
|
||||
@property
|
||||
def type(self) -> Optional[str]:
|
||||
_type, _ = self.parse_type() or (None, None)
|
||||
return _type
|
||||
|
||||
@property
|
||||
def subtype(self) -> Optional[str]:
|
||||
_, subtype = self.parse_type() or (None, None)
|
||||
return subtype
|
||||
|
||||
def type_str(self) -> str:
|
||||
_type, subtype = self.parse_type() or (None, None)
|
||||
return f"{_type}" + (f"({subtype})" if subtype else "")
|
||||
|
||||
def format(self) -> str:
|
||||
commit_link = commit_linkify(self.id, self.org, self.repo) if self.id else ""
|
||||
|
||||
return f"{self.msg_processed}" + (f" ({commit_link})" if commit_link else "")
|
||||
|
||||
|
||||
def run(cmd, cwd=".") -> str:
|
||||
logger.debug(f"Running in {cwd}: {cmd}")
|
||||
p = _run(shlex.split(cmd), stdout=PIPE, stderr=STDOUT, encoding="utf8", cwd=cwd)
|
||||
if p.returncode != 0:
|
||||
print(p.stdout)
|
||||
print(p.stderr)
|
||||
raise Exception
|
||||
return p.stdout
|
||||
|
||||
|
||||
def pr_linkify(prid: str, org: str, repo: str) -> str:
|
||||
return f"[#{prid}](https://github.com/{org}/{repo}/pulls/{prid})"
|
||||
|
||||
|
||||
def commit_linkify(commitid: str, org: str, repo: str) -> str:
|
||||
return f"[`{commitid}`](https://github.com/{org}/{repo}/commit/{commitid})"
|
||||
|
||||
|
||||
def wrap_details(title, body, wraplines=5):
|
||||
"""Wrap lines into a <details> element if body is longer than `wraplines`"""
|
||||
out = f"\n\n### {title}"
|
||||
wrap = body.strip().count("\n") > wraplines
|
||||
if wrap:
|
||||
out += "\n<details><summary>Click to expand</summary>\n<p>"
|
||||
out += f"\n{body.rstrip()}"
|
||||
if wrap:
|
||||
out += "\n\n</p>\n</details>"
|
||||
return out
|
||||
|
||||
|
||||
contributor_emails = set()
|
||||
|
||||
|
||||
def summary_repo(
|
||||
org: str,
|
||||
repo: str,
|
||||
path: str,
|
||||
commit_range: Tuple[str, str],
|
||||
filter_types: List[str],
|
||||
repo_order: List[str],
|
||||
) -> str:
|
||||
if commit_range[1] == "0000000":
|
||||
# Happens when a submodule has been removed
|
||||
return ""
|
||||
if commit_range[0] == "0000000":
|
||||
# Happens when a submodule has been added
|
||||
commit_range = ("", "") # no range = all commits for new submodule
|
||||
|
||||
out = f"\n## 📦 {repo}"
|
||||
|
||||
feats = ""
|
||||
fixes = ""
|
||||
misc = ""
|
||||
hidden = 0
|
||||
|
||||
# pretty format is modified version of: https://stackoverflow.com/a/1441062/965332
|
||||
summary_bundle = run(
|
||||
f"git log {'...'.join(commit_range) if any(commit_range) else ''} --no-decorate --pretty=format:'%h%x09%an%x09%ae%x09%s'",
|
||||
cwd=path,
|
||||
)
|
||||
print(f"Found {len(summary_bundle.splitlines())} commits in {repo}")
|
||||
for line in summary_bundle.split("\n"):
|
||||
if line:
|
||||
_id, _author, email, msg = line.split("\t")
|
||||
# will add author email to contributor list
|
||||
# the `contributor_emails` is global and collected later
|
||||
contributor_emails.add(email)
|
||||
commit = Commit(id=_id, msg=msg, org=org, repo=repo)
|
||||
|
||||
entry = f"\n - {commit.format()}"
|
||||
if commit.type == "feat":
|
||||
feats += entry
|
||||
elif commit.type == "fix":
|
||||
fixes += entry
|
||||
elif commit.type not in filter_types:
|
||||
misc += entry
|
||||
else:
|
||||
hidden += 1
|
||||
|
||||
for name, entries in (
|
||||
("✨ Features", feats),
|
||||
("🐛 Fixes", fixes),
|
||||
("🔨 Misc", misc),
|
||||
):
|
||||
if entries:
|
||||
_count = len(entries.strip().split("\n"))
|
||||
title = f"{name} ({_count})"
|
||||
if "Misc" in name or "Fixes" in name:
|
||||
out += wrap_details(title, entries)
|
||||
else:
|
||||
out += f"\n\n### {title}\n"
|
||||
out += entries
|
||||
if hidden > 1:
|
||||
full_history_url = f"https://github.com/{org}/{repo}/compare/{commit_range[0]}...{commit_range[1]}"
|
||||
out += f"\n\n*(excluded {hidden} less relevant [commits]({full_history_url}))*"
|
||||
|
||||
# NOTE: For now, these TODOs can be manually fixed for each changelog.
|
||||
# TODO: Fix issue where subsubmodules can appear twice (like aw-webui)
|
||||
# TODO: Use specific order (aw-webui should be one of the first, for example)
|
||||
summary_subrepos = run(
|
||||
f"git submodule summary --cached {commit_range[0]}", cwd=path
|
||||
)
|
||||
subrepos = {}
|
||||
for header, *_ in [s.split("\n") for s in summary_subrepos.split("\n\n")]:
|
||||
if header.startswith("fatal: not a git repository"):
|
||||
# Happens when a submodule has been removed
|
||||
continue
|
||||
if header.strip():
|
||||
if len(header.split(" ")) < 4:
|
||||
# Submodule may have been deleted
|
||||
continue
|
||||
|
||||
_, name, crange, count = header.split(" ")
|
||||
commit_range = tuple(crange.split("...", 1)) # type: ignore
|
||||
count = count.strip().lstrip("(").rstrip("):")
|
||||
logger.info(
|
||||
f"Found {name}, looking up range: {commit_range} ({count} commits)"
|
||||
)
|
||||
name = name.strip(".").strip("/")
|
||||
|
||||
subrepos[name] = summary_repo(
|
||||
org,
|
||||
name,
|
||||
f"{path}/{name}",
|
||||
commit_range,
|
||||
filter_types=filter_types,
|
||||
repo_order=repo_order,
|
||||
)
|
||||
|
||||
# filter out subrepos with no commits (single line after stripping whitespace)
|
||||
subrepos = {
|
||||
name: output
|
||||
for name, output in subrepos.items()
|
||||
if len(output.strip().splitlines()) > 1
|
||||
}
|
||||
|
||||
# pick subrepos in repo_order, and remove from dict
|
||||
for name in repo_order:
|
||||
if name in subrepos:
|
||||
out += "\n"
|
||||
out += subrepos[name]
|
||||
logger.info(f"{name:12} length: \t{len(subrepos[name])}")
|
||||
del subrepos[name]
|
||||
|
||||
# add remaining repos
|
||||
for output in subrepos.values():
|
||||
out += "\n"
|
||||
out += output
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# FIXME: Doesn't work, messy af, just gonna have to remove the aw-webui section by hand
|
||||
def remove_duplicates(s: List[str], minlen=10, only_sections=True) -> List[str]:
|
||||
"""
|
||||
Removes the longest sequence of repeated elements (they don't have to be adjacent), if sequence if longer than `minlen`.
|
||||
Preserves order of elements.
|
||||
"""
|
||||
if len(s) < minlen:
|
||||
return s
|
||||
out = []
|
||||
longest: List[str] = []
|
||||
for i in range(len(s)):
|
||||
if i == 0 or s[i] not in out:
|
||||
# Not matching any previous line,
|
||||
# so add longest and new line to output, and reset longest
|
||||
if len(longest) < minlen:
|
||||
out.extend(longest)
|
||||
else:
|
||||
duplicate = "\n".join(longest)
|
||||
print(f"Removing duplicate '{duplicate[:80]}...'")
|
||||
out.append(s[i])
|
||||
longest = []
|
||||
else:
|
||||
# Matches a previous line, so add to longest
|
||||
# If longest is empty and only_sections is True, check that the line is a section start
|
||||
if only_sections:
|
||||
if not longest and s[i].startswith("#"):
|
||||
longest.append(s[i])
|
||||
else:
|
||||
out.append(s[i])
|
||||
else:
|
||||
longest.append(s[i])
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def build(
|
||||
org: str,
|
||||
repo: str,
|
||||
project_name: str,
|
||||
commit_range: Tuple[str, str],
|
||||
output_path: str,
|
||||
repo_order: List[str],
|
||||
filter_types: Optional[List[str]] = None,
|
||||
add_version_header: bool = False,
|
||||
):
|
||||
# provides a commit summary for the repo and subrepos, recursively looking up subrepos
|
||||
# NOTE: this must be done *before* `get_all_contributors` is called,
|
||||
# as the latter relies on summary_repo looking up all users and storing in a global.
|
||||
if not filter_types:
|
||||
filter_types = ["build", "ci", "tests", "test"]
|
||||
|
||||
logger.info("Generating commit summary")
|
||||
since, tag = commit_range
|
||||
output_changelog = summary_repo(
|
||||
org,
|
||||
repo,
|
||||
".",
|
||||
commit_range=commit_range,
|
||||
filter_types=filter_types,
|
||||
repo_order=repo_order,
|
||||
)
|
||||
|
||||
output_changelog = f"""
|
||||
# Changelog
|
||||
|
||||
Changes since [{since}](https://github.com/{org}/{repo}/releases/tag/{since}):
|
||||
|
||||
{output_changelog}
|
||||
""".strip()
|
||||
|
||||
# Would ideally sort by number of commits or something, but that's tricky
|
||||
usernames = sorted(get_all_contributors(), key=str.casefold)
|
||||
usernames = [u for u in usernames if not u.endswith("[bot]")]
|
||||
twitter_handles = get_twitter_of_ghusers(usernames)
|
||||
print(
|
||||
"Twitter handles: "
|
||||
+ ", ".join("@" + handle for handle in twitter_handles.values() if handle),
|
||||
)
|
||||
|
||||
output_contributors = f"""# Contributors
|
||||
|
||||
Thanks to everyone who contributed to this release:
|
||||
|
||||
{', '.join(('@' + username for username in usernames))}"""
|
||||
|
||||
# Header starts here
|
||||
logger.info("Building final output")
|
||||
output = f"These are the release notes for {project_name} version {tag}.".strip()
|
||||
output += "\n\n"
|
||||
|
||||
# hardcoded for now
|
||||
if repo == "activitywatch":
|
||||
output += "**New to ActivityWatch?** Check out the [website](https://activitywatch.net) and the [README](https://github.com/ActivityWatch/activitywatch/blob/master/README.md)."
|
||||
output += "\n\n"
|
||||
output += """# Installation
|
||||
|
||||
See the [getting started guide in the documentation](https://docs.activitywatch.net/en/latest/getting-started.html).
|
||||
""".strip()
|
||||
output += "\n\n"
|
||||
output += f"""# Downloads
|
||||
|
||||
- [**Windows**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-windows-x86_64-setup.exe) (.exe, installer)
|
||||
- [**macOS**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-macos-x86_64.dmg) (.dmg)
|
||||
- [**Linux**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-linux-x86_64.zip) (.zip)
|
||||
""".strip()
|
||||
output += "\n\n"
|
||||
|
||||
output += output_contributors.strip() + "\n\n"
|
||||
output += output_changelog.strip() + "\n\n"
|
||||
output += (
|
||||
f"**Full Changelog**: https://github.com/{org}/{repo}/compare/{since}...{tag}"
|
||||
)
|
||||
|
||||
if repo == "activitywatch":
|
||||
output = output.replace("# activitywatch", "# activitywatch (bundle repo)")
|
||||
|
||||
if add_version_header:
|
||||
output = f"# {tag}\n\n" + output
|
||||
output = output.replace("\n# Contributors\n", "\n## Contributors\n")
|
||||
output = output.replace("\n# Changelog\n", "\n## Changelog\n")
|
||||
|
||||
with open(output_path, "w") as f:
|
||||
f.write(output)
|
||||
print(f"Wrote {len(output.splitlines())} lines to {output_path}")
|
||||
|
||||
|
||||
def _resolve_email(email: str) -> Optional[str]:
|
||||
if "users.noreply.github.com" in email:
|
||||
username = email.split("@")[0]
|
||||
if "+" in username:
|
||||
username = username.split("+")[1]
|
||||
# TODO: Verify username is valid using the GitHub API
|
||||
print(f"Contributor: @{username}")
|
||||
return username
|
||||
else:
|
||||
resp = None
|
||||
backoff = 0
|
||||
max_backoff = 2
|
||||
while resp is None:
|
||||
if backoff >= max_backoff:
|
||||
logger.warning(f"Backed off {max_backoff} times, giving up")
|
||||
break
|
||||
try:
|
||||
logger.info(f"Sending request for {email}")
|
||||
_resp = requests.get(
|
||||
f"https://api.github.com/search/users?q={email}+in%3Aemail"
|
||||
)
|
||||
_resp.raise_for_status()
|
||||
resp = _resp
|
||||
backoff = 0
|
||||
# if rate limit exceeded, back off
|
||||
except requests.exceptions.RequestException as e:
|
||||
if isinstance(e, requests.exceptions.HTTPError):
|
||||
if e.response.status_code == 403:
|
||||
logger.warning("Rate limit exceeded, backing off...")
|
||||
backoff += 1
|
||||
sleep(3)
|
||||
continue
|
||||
else:
|
||||
raise e
|
||||
finally:
|
||||
# Just to respect API limits...
|
||||
sleep(1)
|
||||
|
||||
if resp:
|
||||
data = resp.json()
|
||||
if data["total_count"] == 0:
|
||||
logger.info(f"No match for email: {email}")
|
||||
if data["total_count"] > 1:
|
||||
logger.warning(f"Multiple matches for email: {email}")
|
||||
if data["total_count"] >= 1:
|
||||
username = data["items"][0]["login"]
|
||||
logger.info(f"Contributor: @{username} (by email: {email})")
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
def get_all_contributors() -> set[str]:
|
||||
# TODO: Merge with contributor-stats?
|
||||
logger.info("Getting all contributors")
|
||||
|
||||
# We will commit this file, to act as a cache (preventing us from querying GitHub API every time)
|
||||
filename = script_dir / "changelog_contributors.csv"
|
||||
|
||||
# mapping from username to one or more emails
|
||||
usernames: Dict[str, set] = defaultdict(set)
|
||||
|
||||
# some hardcoded ones, some that don't resolve...
|
||||
usernames["erikbjare"] |= {"erik.bjareholt@gmail.com", "erik@bjareho.lt"}
|
||||
usernames["iloveitaly"] |= {"iloveitaly@gmail.com"}
|
||||
usernames["kewde"] |= {"kewde@particl.io"}
|
||||
usernames["victorwinberg"] |= {"victor.m.winberg@gmail.com"}
|
||||
usernames["NicoWeio"] |= {"nico.weio@gmail.com"}
|
||||
usernames["2e3s"] |= {"2e3s19@gmail.com"}
|
||||
usernames["alwinator"] |= {"accounts@alwinschuster.at"}
|
||||
|
||||
# read existing contributors, to avoid extra calls to the GitHub API
|
||||
if os.path.exists(filename):
|
||||
with open(filename, "r") as f:
|
||||
s = f.read()
|
||||
for line in s.split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
username, *emails = line.split("\t")
|
||||
for email in emails:
|
||||
usernames[username].add(email)
|
||||
logger.info(f"Read {len(usernames)} contributors from {filename}")
|
||||
|
||||
resolved_emails = set(
|
||||
email for email_set in usernames.values() for email in email_set
|
||||
)
|
||||
unresolved_emails = contributor_emails - resolved_emails
|
||||
for email in unresolved_emails:
|
||||
username_opt = _resolve_email(email)
|
||||
if username_opt:
|
||||
usernames[username_opt].add(email)
|
||||
|
||||
with open(filename, "w") as f:
|
||||
for username, email_set in sorted(usernames.items()):
|
||||
emails_str = "\t".join(sorted(email_set))
|
||||
f.write(f"{username}\t{emails_str}")
|
||||
f.write("\n")
|
||||
|
||||
logger.info(f"Wrote {len(usernames)} contributors to {filename}")
|
||||
|
||||
email_to_username = {
|
||||
email: username for username, emails in usernames.items() for email in emails
|
||||
}
|
||||
|
||||
return set(
|
||||
email_to_username[email]
|
||||
for email in contributor_emails
|
||||
if email in email_to_username
|
||||
)
|
||||
|
||||
|
||||
def get_twitter_of_ghusers(ghusers: Collection[str]):
|
||||
logger.info("Getting twitter of GitHub usernames")
|
||||
|
||||
# We will commit this file, to act as a cache (preventing us from querying GitHub API every time)
|
||||
filename = script_dir / "changelog_contributors_twitter.csv"
|
||||
|
||||
twitter = {}
|
||||
|
||||
# read existing contributors, to avoid extra calls to the GitHub API
|
||||
if os.path.exists(filename):
|
||||
with open(filename, "r") as f:
|
||||
s = f.read()
|
||||
for line in s.split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
gh_username, twitter_username = line.split("\t")
|
||||
twitter[gh_username] = twitter_username
|
||||
logger.info(f"Read {len(twitter)} Twitter handles from {filename}")
|
||||
|
||||
for username in ghusers:
|
||||
if username in twitter:
|
||||
continue
|
||||
try:
|
||||
resp = requests.get(f"https://api.github.com/users/{username}")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get twitter of {username}: {e}")
|
||||
continue
|
||||
|
||||
twitter_username = data["twitter_username"]
|
||||
if twitter_username:
|
||||
twitter[username] = twitter_username
|
||||
|
||||
with open(filename, "w") as f:
|
||||
for username, twitter_username in sorted(twitter.items()):
|
||||
f.write(f"{username}\t{twitter_username}")
|
||||
f.write("\n")
|
||||
|
||||
return twitter
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
69
scripts/changelog_contributors.csv
Normal file
69
scripts/changelog_contributors.csv
Normal file
@@ -0,0 +1,69 @@
|
||||
2e3s 2e3s19@gmail.com
|
||||
750 37119951+750@users.noreply.github.com
|
||||
Alwinator 39517491+Alwinator@users.noreply.github.com
|
||||
BasileusErwin 67933444+BasileusErwin@users.noreply.github.com
|
||||
BelKed 66956532+BelKed@users.noreply.github.com
|
||||
CrazyPython Jamtlu@gmail.com
|
||||
Drarig29 corentingirard.dev@gmail.com
|
||||
Elijah-Bodden 106613755+Elijah-Bodden@users.noreply.github.com
|
||||
Furffico 43836984+Furffico@users.noreply.github.com
|
||||
GabLeRoux lebreton.gabriel@gmail.com
|
||||
Game4Move78 83040764+Game4Move78@users.noreply.github.com
|
||||
Julianoe Julianoe@users.noreply.github.com
|
||||
LockBlock-dev 68129141+LockBlock-dev@users.noreply.github.com
|
||||
LunarWatcher zoe.i2k1@gmail.com
|
||||
NicoWeio kontakt@nicolaiweitkemper.de nico.weio@gmail.com
|
||||
Organoidus 150709464+Organoidus@users.noreply.github.com
|
||||
Shi-Soul 86898048+Shi-Soul@users.noreply.github.com
|
||||
ShootingKing-AM narnindi.raghu@gmail.com
|
||||
Shubham0324 53115519+Shubham0324@users.noreply.github.com
|
||||
StefanoChiodino StefanoChiodino@users.noreply.github.com
|
||||
TSRBerry 20988865+TSRBerry@users.noreply.github.com
|
||||
Valentin-N 1926716+Valentin-N@users.noreply.github.com
|
||||
Y7n05h Y7n05h@protonmail.com
|
||||
aaayushsingh ayush-_-singh@live.com
|
||||
alclary 9044153+alclary@users.noreply.github.com
|
||||
alialamine ali@towbe.com
|
||||
alwinator accounts@alwinschuster.at
|
||||
0xbrayo vukubrian@gmail.com
|
||||
chaoky levimanga@gmail.com
|
||||
chengyuhui chengyuhui1@gmail.com
|
||||
davidfraser davidfraser@users.noreply.github.com
|
||||
dependabot-preview[bot] 27856297+dependabot-preview[bot]@users.noreply.github.com
|
||||
dependabot[bot] 49699333+dependabot[bot]@users.noreply.github.com
|
||||
erikbjare erik.bjareholt@gmail.com erik@bjareho.lt
|
||||
hooger hooger@users.noreply.github.com
|
||||
iloveitaly iloveitaly@gmail.com
|
||||
infokiller infokiller@users.noreply.github.com
|
||||
ishitatsuyuki ishitatsuyuki@gmail.com
|
||||
jkbh 33606327+jkbh@users.noreply.github.com
|
||||
johan-bjareholt johan@bjareho.lt
|
||||
jtojnar jtojnar@gmail.com
|
||||
kewde kewde@particl.io
|
||||
lgtm-com[bot] 43144390+lgtm-com[bot]@users.noreply.github.com
|
||||
liutiming 39947942+liutiming@users.noreply.github.com
|
||||
luzpaz luzpaz@users.noreply.github.com
|
||||
maciekstosio maciekstosio@users.noreply.github.com
|
||||
michaeljelly 53475252+michaeljelly@users.noreply.github.com
|
||||
modderme123 modderme123@users.noreply.github.com
|
||||
nathanmerrill nathanmerrill@users.noreply.github.com
|
||||
noisersup patryk@kwiatek.xyz
|
||||
ochen1 o.chen1@share.epsb.ca
|
||||
omahs 73983677+omahs@users.noreply.github.com
|
||||
oscar-king oscar-king@users.noreply.github.com
|
||||
pktiuk kotiuk@zohomail.eu
|
||||
pkvach pavel.kvach@gmail.com
|
||||
rakleed 19418601+rakleed@users.noreply.github.com
|
||||
repo-visualizer repo-visualizer@users.noreply.github.com
|
||||
salahineo salahineo.personal@gmail.com
|
||||
skaparis 43264989+skaparis@users.noreply.github.com
|
||||
skewballfox joshua.ferguson.273@gmail.com
|
||||
soxofaan soxofaan@users.noreply.github.com
|
||||
sunrosa 79175772+sunrosa@users.noreply.github.com
|
||||
vedantmgoyal2009 83997633+vedantmgoyal2009@users.noreply.github.com
|
||||
victorlin 13424970+victorlin@users.noreply.github.com
|
||||
victorwinberg victor.m.winberg@gmail.com
|
||||
vieteh viet.tran@employmenthero.com
|
||||
xylix kerk.pelt@gmail.com
|
||||
yuhldr yuhldr@qq.com
|
||||
yumemio 59369226+yumemio@users.noreply.github.com
|
||||
|
6
scripts/changelog_contributors_twitter.csv
Normal file
6
scripts/changelog_contributors_twitter.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
0xbrayo subrupt
|
||||
chaoky chaokyer
|
||||
erikbjare erikbjare
|
||||
iloveitaly mike_bianco
|
||||
vedantmgoyal2009 vedantmgoyal
|
||||
victorlin victorlin_
|
||||
|
280
scripts/check_tauri_server.py
Executable file
280
scripts/check_tauri_server.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify that Qt and Tauri bundle the same aw-server-rust revision."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
FIELD_RE = re.compile(r'^\s*([a-zA-Z0-9_-]+)\s*=\s*"([^"]+)"\s*$', re.MULTILINE)
|
||||
VERSION_RE = re.compile(r"^(\d+)\.(\d+)(?:\.|-|$)")
|
||||
REVISION_RE = re.compile(r"#([0-9a-f]{40})$")
|
||||
|
||||
|
||||
def major_minor(version: str) -> str:
|
||||
match = VERSION_RE.match(version)
|
||||
if not match:
|
||||
raise ValueError(f"invalid version: {version!r}")
|
||||
return ".".join(match.groups())
|
||||
|
||||
|
||||
def read_package_version(cargo_toml: Path) -> str:
|
||||
in_package = False
|
||||
for line in cargo_toml.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip() == "[package]":
|
||||
in_package = True
|
||||
continue
|
||||
if in_package and line.startswith("["):
|
||||
break
|
||||
if in_package:
|
||||
match = re.match(r'^version\s*=\s*"([^"]+)"', line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
raise ValueError(f"no [package].version in {cargo_toml}")
|
||||
|
||||
|
||||
def read_locked_server(cargo_lock: Path) -> Tuple[str, str]:
|
||||
matches = []
|
||||
for block in re.split(
|
||||
r"(?m)^\[\[package\]\]\s*$", cargo_lock.read_text(encoding="utf-8")
|
||||
):
|
||||
fields = dict(FIELD_RE.findall(block))
|
||||
source = fields.get("source", "")
|
||||
if (
|
||||
fields.get("name") == "aw-server"
|
||||
and "ActivityWatch/aw-server-rust" in source
|
||||
):
|
||||
revision = REVISION_RE.search(source)
|
||||
if not revision:
|
||||
raise ValueError(
|
||||
f"aw-server source has no full Git revision: {source!r}"
|
||||
)
|
||||
matches.append((fields["version"], revision.group(1)))
|
||||
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"expected one Git-locked aw-server package in {cargo_lock}, found {len(matches)}"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def validation_errors(
|
||||
release_version: Optional[str],
|
||||
submodule_version: str,
|
||||
submodule_revision: str,
|
||||
tauri_version: str,
|
||||
tauri_revision: str,
|
||||
) -> List[str]:
|
||||
errors = []
|
||||
if release_version is not None:
|
||||
release_line = major_minor(release_version)
|
||||
if major_minor(submodule_version) != release_line:
|
||||
errors.append(
|
||||
f"aw-server-rust submodule version {submodule_version} does not match "
|
||||
f"release line {release_line}"
|
||||
)
|
||||
if major_minor(tauri_version) != release_line:
|
||||
errors.append(
|
||||
f"Tauri locks aw-server {tauri_version}, which does not match release line "
|
||||
f"{release_line}"
|
||||
)
|
||||
if tauri_revision != submodule_revision:
|
||||
errors.append(
|
||||
f"Tauri locks aw-server-rust {tauri_revision[:12]}, but the release "
|
||||
f"submodule is {submodule_revision[:12]}"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def is_git_checkout(path: Path) -> bool:
|
||||
"""Return whether path is the root of an initialized Git checkout."""
|
||||
try:
|
||||
git_root = Path(
|
||||
subprocess.check_output(
|
||||
["git", "-C", str(path), "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
).resolve()
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
return git_root == path.resolve()
|
||||
|
||||
|
||||
def initialize_submodule(root: Path, name: str) -> bool:
|
||||
"""Initialize a missing submodule without resetting an updated checkout."""
|
||||
path = root / name
|
||||
if is_git_checkout(path):
|
||||
expected_git_dir = Path(
|
||||
subprocess.check_output(
|
||||
["git", "-C", str(root), "rev-parse", "--git-path", f"modules/{name}"],
|
||||
text=True,
|
||||
).strip()
|
||||
)
|
||||
if not expected_git_dir.is_absolute():
|
||||
expected_git_dir = root / expected_git_dir
|
||||
expected_git_dir = expected_git_dir.resolve()
|
||||
actual_git_dir = Path(
|
||||
subprocess.check_output(
|
||||
["git", "-C", str(path), "rev-parse", "--absolute-git-dir"],
|
||||
text=True,
|
||||
).strip()
|
||||
).resolve()
|
||||
if actual_git_dir != expected_git_dir:
|
||||
hint = ""
|
||||
if (path / ".git").is_dir():
|
||||
hint = (
|
||||
f"; if this is an old-form submodule, migrate it first with "
|
||||
f"'git submodule absorbgitdirs {name}'"
|
||||
)
|
||||
raise ValueError(
|
||||
f"refusing unmanaged Git checkout at configured submodule path {path}"
|
||||
f"{hint}"
|
||||
)
|
||||
return False
|
||||
subprocess.run(
|
||||
["git", "-C", str(root), "submodule", "update", "--init", name], check=True
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def sync_submodule(server_root: Path, revision: str) -> bool:
|
||||
"""Check out the Tauri-locked revision in the top-level server submodule."""
|
||||
if not is_git_checkout(server_root):
|
||||
raise ValueError(
|
||||
f"aw-server-rust is not initialized as a Git submodule at {server_root}"
|
||||
)
|
||||
|
||||
dirty = subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(server_root),
|
||||
"status",
|
||||
"--porcelain",
|
||||
"--untracked-files=no",
|
||||
"--ignore-submodules=all",
|
||||
],
|
||||
text=True,
|
||||
).strip()
|
||||
if dirty:
|
||||
raise ValueError(
|
||||
f"refusing to replace dirty aw-server-rust checkout at {server_root}"
|
||||
)
|
||||
|
||||
current_revision = subprocess.check_output(
|
||||
["git", "-C", str(server_root), "rev-parse", "HEAD"], text=True
|
||||
).strip()
|
||||
if current_revision == revision:
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", str(server_root), "cat-file", "-e", f"{revision}^{{commit}}"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
subprocess.run(
|
||||
["git", "-C", str(server_root), "fetch", "origin", revision], check=True
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["git", "-C", str(server_root), "checkout", "--detach", revision], check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(server_root), "submodule", "update", "--init", "--recursive"],
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"release_version",
|
||||
nargs="?",
|
||||
help="ActivityWatch version without the leading v (required unless --sync)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sync",
|
||||
action="store_true",
|
||||
help="make the top-level aw-server-rust submodule follow Tauri's Cargo.lock",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-root", type=Path, default=Path(__file__).resolve().parents[1]
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.release_version is None and not args.sync:
|
||||
parser.error("release_version is required unless --sync is used")
|
||||
|
||||
root = args.repo_root.resolve()
|
||||
server_root = root / "aw-server-rust"
|
||||
try:
|
||||
if args.sync:
|
||||
initialize_submodule(root, "aw-tauri")
|
||||
initialize_submodule(root, "aw-server-rust")
|
||||
tauri_version, tauri_revision = read_locked_server(
|
||||
root / "aw-tauri/src-tauri/Cargo.lock"
|
||||
)
|
||||
if args.sync:
|
||||
changed = sync_submodule(server_root, tauri_revision)
|
||||
if changed:
|
||||
print(f"Synced aw-server-rust to Tauri lock {tauri_revision[:12]}")
|
||||
else:
|
||||
print(f"aw-server-rust already matches Tauri lock {tauri_revision[:12]}")
|
||||
submodule_version = read_package_version(server_root / "aw-server/Cargo.toml")
|
||||
submodule_revision = subprocess.check_output(
|
||||
["git", "-C", str(server_root), "rev-parse", "HEAD"], text=True
|
||||
).strip()
|
||||
errors = validation_errors(
|
||||
args.release_version,
|
||||
submodule_version,
|
||||
submodule_revision,
|
||||
tauri_version,
|
||||
tauri_revision,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError, ValueError) as exc:
|
||||
print(
|
||||
f"ERROR: could not inspect bundled aw-server versions: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.release_version is not None:
|
||||
print(f"ActivityWatch release: {args.release_version}")
|
||||
print(f"aw-server-rust: {submodule_version} @ {submodule_revision[:12]}")
|
||||
print(f"Tauri Cargo.lock: {tauri_version} @ {tauri_revision[:12]}")
|
||||
if errors:
|
||||
print("\nERROR: inconsistent aw-server build inputs:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f" - {error}", file=sys.stderr)
|
||||
if (
|
||||
args.release_version is not None
|
||||
and major_minor(tauri_version) != major_minor(args.release_version)
|
||||
):
|
||||
print(
|
||||
"\nUpdate aw-tauri's Cargo.lock to the intended server release line, "
|
||||
"then run `make sync-tauri-server`.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"\nRun `make sync-tauri-server` to make the top-level submodule "
|
||||
"follow aw-tauri's Cargo.lock.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print("OK: Qt and Tauri will bundle the same aw-server-rust revision")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
6
scripts/checkout-latest-tag.sh
vendored
Executable file
6
scripts/checkout-latest-tag.sh
vendored
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
latest_version_tag=$(git tag -l | grep "^v[0-9]\..*" | sort --version-sort | tail -n1 )
|
||||
current_version_tag=$(git describe --tags)
|
||||
echo "Latest version: $latest_version_tag"
|
||||
echo "Current version: $current_version_tag"
|
||||
8
scripts/chores/make-release.sh
vendored
Normal file
8
scripts/chores/make-release.sh
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
#
|
||||
# We should create a release checklist to ensure releases are consistent.
|
||||
#
|
||||
|
||||
# Create an annotated tag
|
||||
#git tag -a $
|
||||
3
scripts/ci/enable_long_paths.bat
Normal file
3
scripts/ci/enable_long_paths.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
:: Enable long paths on Windows (needed when building since node_modules can create deep hierarchies)
|
||||
|
||||
REG ADD "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f
|
||||
23
scripts/ci/import-macos-p12.sh
vendored
Executable file
23
scripts/ci/import-macos-p12.sh
vendored
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
export KEY_CHAIN="${KEY_CHAIN:-build.keychain-db}"
|
||||
export CERTIFICATE_P12="${CERTIFICATE_P12:-aw_certificate.p12}"
|
||||
|
||||
# Recreate the certificate from the secure environment variable.
|
||||
printf '%s' "$CERTIFICATE_MACOS_P12_BASE64" | base64 --decode > "$CERTIFICATE_P12"
|
||||
|
||||
# Recreate the temporary keychain on every run so stale state cannot leak between jobs.
|
||||
security -v delete-keychain "$KEY_CHAIN" >/dev/null 2>&1 || true
|
||||
security -v create-keychain -p travis "$KEY_CHAIN"
|
||||
security -v default-keychain -s "$KEY_CHAIN"
|
||||
security -v unlock-keychain -p travis "$KEY_CHAIN"
|
||||
security -v set-keychain-settings -lut 21600 "$KEY_CHAIN"
|
||||
security -v list-keychains -d user -s "$KEY_CHAIN" login.keychain-db login.keychain
|
||||
|
||||
security -v import "$CERTIFICATE_P12" -k "$KEY_CHAIN" -P "$CERTIFICATE_MACOS_P12_PASSWORD" -A
|
||||
security -v set-key-partition-list -S apple-tool:,apple: -s -k travis "$KEY_CHAIN"
|
||||
security -v find-identity -v -p codesigning "$KEY_CHAIN"
|
||||
|
||||
rm -f "$CERTIFICATE_P12"
|
||||
40
scripts/ci/install_node.ps1
vendored
Normal file
40
scripts/ci/install_node.ps1
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
$msipath = "$PSScriptRoot\node-installer.msi"
|
||||
|
||||
function RunCommand ($command, $command_args) {
|
||||
Write-Host $command $command_args
|
||||
Start-Process -FilePath $command -ArgumentList $command_args -Wait -Passthru
|
||||
}
|
||||
|
||||
function InstallNode () {
|
||||
DownloadNodeMSI
|
||||
InstallNodeMSI
|
||||
}
|
||||
|
||||
function DownloadNodeMSI () {
|
||||
$url = "https://nodejs.org/dist/v12.18.4/node-v12.18.4-x64.msi"
|
||||
$start_time = Get-Date
|
||||
|
||||
Write-Output "Downloading node msi"
|
||||
Invoke-WebRequest -Uri $url -OutFile $msipath
|
||||
Write-Output "Time taken: $((Get-Date).Subtract($start_time).Seconds) second(s)"
|
||||
}
|
||||
|
||||
function InstallNodeMSI () {
|
||||
$install_args = "/qn /log node_install.log /i $msipath"
|
||||
$uninstall_args = "/qn /x $msipath"
|
||||
RunCommand "msiexec.exe" $install_args
|
||||
|
||||
#if (-not(Test-Path $python_home)) {
|
||||
# Write-Host "Python seems to be installed else-where, reinstalling."
|
||||
# RunCommand "msiexec.exe" $uninstall_args
|
||||
# RunCommand "msiexec.exe" $install_args
|
||||
#}
|
||||
}
|
||||
|
||||
|
||||
function main () {
|
||||
InstallNode
|
||||
rm $msipath
|
||||
}
|
||||
|
||||
main
|
||||
13
scripts/ci/install_pyhook.ps1
vendored
Normal file
13
scripts/ci/install_pyhook.ps1
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
function main ($arch) {
|
||||
If ( $arch -eq "64" ) {
|
||||
$url="https://github.com/ActivityWatch/wheels/raw/master/pyHook-1.5.1-cp36-cp36m-win_amd64.whl"
|
||||
} ElseIf ( $arch -eq "32" ) {
|
||||
$url="https://github.com/ActivityWatch/wheels/raw/master/pyHook-1.5.1-cp36-cp36m-win32.whl"
|
||||
} Else {
|
||||
Write-Output "Invalid architecture"
|
||||
return -1
|
||||
}
|
||||
pip install --user $url
|
||||
}
|
||||
|
||||
main $env:PYTHON_ARCH
|
||||
233
scripts/ci/install_python.ps1
vendored
Normal file
233
scripts/ci/install_python.ps1
vendored
Normal file
@@ -0,0 +1,233 @@
|
||||
# Sample script to install Python and pip under Windows
|
||||
# Authors: Olivier Grisel, Jonathan Helmus, Kyle Kastner, and Alex Willmer
|
||||
# License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/
|
||||
#
|
||||
# Find the latest version of this script at:
|
||||
# https://github.com/ogrisel/python-appveyor-demo/blob/master/appveyor/install.ps1
|
||||
|
||||
|
||||
$MINICONDA_URL = "http://repo.continuum.io/miniconda/"
|
||||
$BASE_URL = "https://www.python.org/ftp/python/"
|
||||
$GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
|
||||
$GET_PIP_PATH = "C:\get-pip.py"
|
||||
|
||||
$PYTHON_PRERELEASE_REGEX = @"
|
||||
(?x)
|
||||
(?<major>\d+)
|
||||
\.
|
||||
(?<minor>\d+)
|
||||
\.
|
||||
(?<micro>\d+)
|
||||
(?<prerelease>[a-z]{1,2}\d+)
|
||||
"@
|
||||
|
||||
|
||||
function Download ($filename, $url) {
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
|
||||
$basedir = $pwd.Path + "\"
|
||||
$filepath = $basedir + $filename
|
||||
if (Test-Path $filename) {
|
||||
Write-Host "Reusing" $filepath
|
||||
return $filepath
|
||||
}
|
||||
|
||||
# Download and retry up to 3 times in case of network transient errors.
|
||||
Write-Host "Downloading" $filename "from" $url
|
||||
$retry_attempts = 2
|
||||
for ($i = 0; $i -lt $retry_attempts; $i++) {
|
||||
try {
|
||||
$webclient.DownloadFile($url, $filepath)
|
||||
break
|
||||
}
|
||||
Catch [Exception]{
|
||||
Start-Sleep 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $filepath) {
|
||||
Write-Host "File saved at" $filepath
|
||||
} else {
|
||||
# Retry once to get the error message if any at the last try
|
||||
$webclient.DownloadFile($url, $filepath)
|
||||
}
|
||||
return $filepath
|
||||
}
|
||||
|
||||
|
||||
function ParsePythonVersion ($python_version) {
|
||||
if ($python_version -match $PYTHON_PRERELEASE_REGEX) {
|
||||
return ([int]$matches.major, [int]$matches.minor, [int]$matches.micro,
|
||||
$matches.prerelease)
|
||||
}
|
||||
$version_obj = [version]$python_version
|
||||
return ($version_obj.major, $version_obj.minor, $version_obj.build, "")
|
||||
}
|
||||
|
||||
|
||||
function DownloadPython ($python_version, $platform_suffix) {
|
||||
$major, $minor, $micro, $prerelease = ParsePythonVersion $python_version
|
||||
|
||||
if (($major -le 2 -and $micro -eq 0) `
|
||||
-or ($major -eq 3 -and $minor -le 2 -and $micro -eq 0) `
|
||||
) {
|
||||
$dir = "$major.$minor"
|
||||
$python_version = "$major.$minor$prerelease"
|
||||
} else {
|
||||
$dir = "$major.$minor.$micro"
|
||||
}
|
||||
|
||||
if ($prerelease) {
|
||||
if (($major -le 2) `
|
||||
-or ($major -eq 3 -and $minor -eq 1) `
|
||||
-or ($major -eq 3 -and $minor -eq 2) `
|
||||
-or ($major -eq 3 -and $minor -eq 3) `
|
||||
) {
|
||||
$dir = "$dir/prev"
|
||||
}
|
||||
}
|
||||
|
||||
if (($major -le 2) -or ($major -le 3 -and $minor -le 4)) {
|
||||
$ext = "msi"
|
||||
if ($platform_suffix) {
|
||||
$platform_suffix = ".$platform_suffix"
|
||||
}
|
||||
} else {
|
||||
$ext = "exe"
|
||||
if ($platform_suffix) {
|
||||
$platform_suffix = "-$platform_suffix"
|
||||
}
|
||||
}
|
||||
|
||||
$filename = "python-$python_version$platform_suffix.$ext"
|
||||
$url = "$BASE_URL$dir/$filename"
|
||||
$filepath = Download $filename $url
|
||||
return $filepath
|
||||
}
|
||||
|
||||
|
||||
function InstallPython ($python_version, $architecture, $python_home) {
|
||||
Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home
|
||||
if (Test-Path $python_home) {
|
||||
Write-Host $python_home "already exists, skipping."
|
||||
return $false
|
||||
}
|
||||
if ($architecture -eq "32") {
|
||||
$platform_suffix = ""
|
||||
} else {
|
||||
$platform_suffix = "amd64"
|
||||
}
|
||||
$installer_path = DownloadPython $python_version $platform_suffix
|
||||
$installer_ext = [System.IO.Path]::GetExtension($installer_path)
|
||||
Write-Host "Installing $installer_path to $python_home"
|
||||
$install_log = $python_home + ".log"
|
||||
if ($installer_ext -eq '.msi') {
|
||||
InstallPythonMSI $installer_path $python_home $install_log
|
||||
} else {
|
||||
InstallPythonEXE $installer_path $python_home $install_log
|
||||
}
|
||||
if (Test-Path $python_home) {
|
||||
Write-Host "Python $python_version ($architecture) installation complete"
|
||||
} else {
|
||||
Write-Host "Failed to install Python in $python_home"
|
||||
Get-Content -Path $install_log
|
||||
Exit 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function InstallPythonEXE ($exepath, $python_home, $install_log) {
|
||||
$install_args = "/quiet InstallAllUsers=1 TargetDir=$python_home"
|
||||
RunCommand $exepath $install_args
|
||||
}
|
||||
|
||||
|
||||
function InstallPythonMSI ($msipath, $python_home, $install_log) {
|
||||
$install_args = "/qn /log $install_log /i $msipath TARGETDIR=$python_home"
|
||||
$uninstall_args = "/qn /x $msipath"
|
||||
RunCommand "msiexec.exe" $install_args
|
||||
if (-not(Test-Path $python_home)) {
|
||||
Write-Host "Python seems to be installed else-where, reinstalling."
|
||||
RunCommand "msiexec.exe" $uninstall_args
|
||||
RunCommand "msiexec.exe" $install_args
|
||||
}
|
||||
}
|
||||
|
||||
function RunCommand ($command, $command_args) {
|
||||
Write-Host $command $command_args
|
||||
Start-Process -FilePath $command -ArgumentList $command_args -Wait -Passthru
|
||||
}
|
||||
|
||||
|
||||
function InstallPip ($python_home) {
|
||||
$pip_path = $python_home + "\Scripts\pip.exe"
|
||||
$python_path = $python_home + "\python.exe"
|
||||
if (-not(Test-Path $pip_path)) {
|
||||
Write-Host "Installing pip..."
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
$webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH)
|
||||
Write-Host "Executing:" $python_path $GET_PIP_PATH
|
||||
& $python_path $GET_PIP_PATH
|
||||
} else {
|
||||
Write-Host "pip already installed."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function DownloadMiniconda ($python_version, $platform_suffix) {
|
||||
if ($python_version -eq "3.4") {
|
||||
$filename = "Miniconda3-3.5.5-Windows-" + $platform_suffix + ".exe"
|
||||
} else {
|
||||
$filename = "Miniconda-3.5.5-Windows-" + $platform_suffix + ".exe"
|
||||
}
|
||||
$url = $MINICONDA_URL + $filename
|
||||
$filepath = Download $filename $url
|
||||
return $filepath
|
||||
}
|
||||
|
||||
|
||||
function InstallMiniconda ($python_version, $architecture, $python_home) {
|
||||
Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home
|
||||
if (Test-Path $python_home) {
|
||||
Write-Host $python_home "already exists, skipping."
|
||||
return $false
|
||||
}
|
||||
if ($architecture -eq "32") {
|
||||
$platform_suffix = "x86"
|
||||
} else {
|
||||
$platform_suffix = "x86_64"
|
||||
}
|
||||
$filepath = DownloadMiniconda $python_version $platform_suffix
|
||||
Write-Host "Installing" $filepath "to" $python_home
|
||||
$install_log = $python_home + ".log"
|
||||
$args = "/S /D=$python_home"
|
||||
Write-Host $filepath $args
|
||||
Start-Process -FilePath $filepath -ArgumentList $args -Wait -Passthru
|
||||
if (Test-Path $python_home) {
|
||||
Write-Host "Python $python_version ($architecture) installation complete"
|
||||
} else {
|
||||
Write-Host "Failed to install Python in $python_home"
|
||||
Get-Content -Path $install_log
|
||||
Exit 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function InstallMinicondaPip ($python_home) {
|
||||
$pip_path = $python_home + "\Scripts\pip.exe"
|
||||
$conda_path = $python_home + "\Scripts\conda.exe"
|
||||
if (-not(Test-Path $pip_path)) {
|
||||
Write-Host "Installing pip..."
|
||||
$args = "install --yes pip"
|
||||
Write-Host $conda_path $args
|
||||
Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru
|
||||
} else {
|
||||
Write-Host "pip already installed."
|
||||
}
|
||||
}
|
||||
|
||||
function main () {
|
||||
InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON
|
||||
InstallPip $env:PYTHON
|
||||
}
|
||||
|
||||
main
|
||||
87
scripts/ci/run_with_env.cmd
vendored
Normal file
87
scripts/ci/run_with_env.cmd
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
:: To build extensions for 64 bit Python 3, we need to configure environment
|
||||
:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of:
|
||||
:: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1)
|
||||
::
|
||||
:: To build extensions for 64 bit Python 2, we need to configure environment
|
||||
:: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of:
|
||||
:: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0)
|
||||
::
|
||||
:: 32 bit builds, and 64-bit builds for 3.5 and beyond, do not require specific
|
||||
:: environment configurations.
|
||||
::
|
||||
:: Note: this script needs to be run with the /E:ON and /V:ON flags for the
|
||||
:: cmd interpreter, at least for (SDK v7.0)
|
||||
::
|
||||
:: More details at:
|
||||
:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows
|
||||
:: http://stackoverflow.com/a/13751649/163740
|
||||
::
|
||||
:: Author: Olivier Grisel
|
||||
:: License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/
|
||||
::
|
||||
:: Notes about batch files for Python people:
|
||||
::
|
||||
:: Quotes in values are literally part of the values:
|
||||
:: SET FOO="bar"
|
||||
:: FOO is now five characters long: " b a r "
|
||||
:: If you don't want quotes, don't include them on the right-hand side.
|
||||
::
|
||||
:: The CALL lines at the end of this file look redundant, but if you move them
|
||||
:: outside of the IF clauses, they do not run properly in the SET_SDK_64==Y
|
||||
:: case, I don't know why.
|
||||
@ECHO OFF
|
||||
SET COMMAND_TO_RUN=%*
|
||||
SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows
|
||||
SET WIN_WDK=c:\Program Files (x86)\Windows Kits\10\Include\wdf
|
||||
|
||||
:: Extract the major and minor versions, and allow for the minor version to be
|
||||
:: more than 9. This requires the version number to have two dots in it.
|
||||
SET MAJOR_PYTHON_VERSION=%PYTHON_VERSION:~0,1%
|
||||
IF "%PYTHON_VERSION:~3,1%" == "." (
|
||||
SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,1%
|
||||
) ELSE (
|
||||
SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,2%
|
||||
)
|
||||
|
||||
:: Based on the Python version, determine what SDK version to use, and whether
|
||||
:: to set the SDK for 64-bit.
|
||||
IF %MAJOR_PYTHON_VERSION% == 2 (
|
||||
SET WINDOWS_SDK_VERSION="v7.0"
|
||||
SET SET_SDK_64=Y
|
||||
) ELSE (
|
||||
IF %MAJOR_PYTHON_VERSION% == 3 (
|
||||
SET WINDOWS_SDK_VERSION="v7.1"
|
||||
IF %MINOR_PYTHON_VERSION% LEQ 4 (
|
||||
SET SET_SDK_64=Y
|
||||
) ELSE (
|
||||
SET SET_SDK_64=N
|
||||
IF EXIST "%WIN_WDK%" (
|
||||
:: See: https://connect.microsoft.com/VisualStudio/feedback/details/1610302/
|
||||
REN "%WIN_WDK%" 0wdf
|
||||
)
|
||||
)
|
||||
) ELSE (
|
||||
ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%"
|
||||
EXIT 1
|
||||
)
|
||||
)
|
||||
|
||||
IF %PYTHON_ARCH% == 64 (
|
||||
IF %SET_SDK_64% == Y (
|
||||
ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture
|
||||
SET DISTUTILS_USE_SDK=1
|
||||
SET MSSdk=1
|
||||
"%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION%
|
||||
"%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release
|
||||
ECHO Executing: %COMMAND_TO_RUN%
|
||||
call %COMMAND_TO_RUN% || EXIT 1
|
||||
) ELSE (
|
||||
ECHO Using default MSVC build environment for 64 bit architecture
|
||||
ECHO Executing: %COMMAND_TO_RUN%
|
||||
call %COMMAND_TO_RUN% || EXIT 1
|
||||
)
|
||||
) ELSE (
|
||||
ECHO Using default MSVC build environment for 32 bit architecture
|
||||
ECHO Executing: %COMMAND_TO_RUN%
|
||||
call %COMMAND_TO_RUN% || EXIT 1
|
||||
)
|
||||
32
scripts/count_lines.sh
vendored
Executable file
32
scripts/count_lines.sh
vendored
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
re_ignore='.*(build|dist|venv|old|other|scripts|node|static).*'
|
||||
|
||||
echo -n "Lines of code (excluding test): "
|
||||
files=$(find | egrep '\.(py|js|ts|rs|vue)$' | egrep -v $re_ignore | grep -v 'test')
|
||||
echo $files | xargs cat | wc -l
|
||||
|
||||
#echo "Files:"
|
||||
#for file in $files; do
|
||||
# echo " - $file"
|
||||
#done
|
||||
|
||||
echo -n " - of which Python code: "
|
||||
files=$(find | egrep '\.(py)$' | egrep -v $re_ignore | grep -v 'test')
|
||||
echo $files | xargs cat | wc -l
|
||||
|
||||
echo -n " - of which Rust code: "
|
||||
files=$(find | egrep '\.(rs)$' | egrep -v $re_ignore | grep -v 'test')
|
||||
echo $files | xargs cat | wc -l
|
||||
|
||||
echo -n " - of which JS/TS code: "
|
||||
files=$(find | egrep '\.(js|ts)$' | egrep -v $re_ignore | grep -v 'test')
|
||||
echo $files | xargs cat | wc -l
|
||||
|
||||
echo -n " - of which Vue code: "
|
||||
files=$(find | egrep '\.(vue)$' | egrep -v $re_ignore | grep -v 'test')
|
||||
echo $files | xargs cat | wc -l
|
||||
|
||||
echo -ne "\nLines of test: "
|
||||
files=$(find | egrep '\.(py|js|vue)$' | egrep -v $re_ignore | grep 'test')
|
||||
echo $files | xargs cat | wc -l
|
||||
164
scripts/emit_research_category_preset.py
Normal file
164
scripts/emit_research_category_preset.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit the Research Edition category preset consumed by aw-webui at build time.
|
||||
|
||||
The approved study contract keeps application names while the watcher replaces
|
||||
browser titles with study categories and removes every URL. aw-webui categorises
|
||||
client-side, so this preset matches both stored browser category labels and the
|
||||
known raw application-name aliases. Top Applications can therefore show Word,
|
||||
Spotify, and Teams while Top Categories still uses the study taxonomy.
|
||||
|
||||
aw-webui (ActivityWatch/aw-webui#936) reads a preset category set from the
|
||||
`AW_PRESET_CATEGORY_SETS` env var at build time. This script derives that
|
||||
preset from the same taxonomy source as the watcher build patch, so the two can
|
||||
never drift:
|
||||
|
||||
python3 scripts/emit_research_category_preset.py > preset.json
|
||||
|
||||
Rules are exact, case-insensitive matches. aw-webui applies every category rule
|
||||
to `app` and `title`, and the oldest web UI pinned by the release carriers drops
|
||||
unknown per-rule metadata, so the preset cannot rely on field or priority keys.
|
||||
Each category carries a `data.color` so the Activity view is not unstyled
|
||||
(ActivityWatch/activitywatch#1439). Explicitly excluded app aliases map to
|
||||
`Excluded`; unknown applications remain `Uncategorized` instead of overlapping
|
||||
every specific rule with a catch-all.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
# Characters that are special to BOTH Python's `re` and JavaScript's RegExp
|
||||
# outside a character class. Escaping is deliberately restricted to these.
|
||||
#
|
||||
# `re.escape()` is not usable here: it escapes space as `\ ` and `&` as `\&`,
|
||||
# which are *invalid identity escapes* in JavaScript unicode-mode regex. Names
|
||||
# like "AI Chatbots & Assistants" then throw `Invalid escape` under `new
|
||||
# RegExp(r, "u")` -- 14 of the 18 study categories do. They happen to compile
|
||||
# today only because aw-webui builds the regex without the `u` flag, and the
|
||||
# failure would present as "categories don't show", i.e. indistinguishable from
|
||||
# the bug this preset exists to fix. Escape only what both engines agree on.
|
||||
_REGEX_METACHARACTERS = set(r"\^$.|?*+()[]{}")
|
||||
|
||||
PRESET_ID = "research-study"
|
||||
PRESET_NAME = "Research Edition study categories"
|
||||
|
||||
# Qualitative palette for the study taxonomy. aw-webui only colors a category
|
||||
# when `data.color` is set — there is no name-hash fallback for categories —
|
||||
# so omitting this is what made the research-study set render grey.
|
||||
# Keys must stay in lockstep with CATEGORY_MAP ∪ APP_CATEGORY_MAP; build_preset
|
||||
# raises if a category is missing.
|
||||
CATEGORY_COLORS: dict[str, str] = {
|
||||
"AI Chatbots & Assistants": "#7B1FA2",
|
||||
"Banking & Finance": "#1B5E20",
|
||||
"Education & Learning": "#1565C0",
|
||||
"Email": "#00838F",
|
||||
"Excluded": "#BDBDBD",
|
||||
"Games": "#EF6C00",
|
||||
"Messaging": "#00897B",
|
||||
"Music & Audio": "#7CB342",
|
||||
"News & Current Affairs": "#C62828",
|
||||
"Public Services": "#455A64",
|
||||
"Search & Navigation": "#5C6BC0",
|
||||
"Sensitive / Excluded": "#757575",
|
||||
"Shopping - Goods": "#6D4C41",
|
||||
"Shopping - Groceries & Food": "#F9A825",
|
||||
"Social Networking": "#AD1457",
|
||||
"Travel & Mobility": "#0277BD",
|
||||
"Video Streaming": "#E53935",
|
||||
"Work & Productivity": "#2E7D32",
|
||||
}
|
||||
|
||||
_PATCHER = pathlib.Path(__file__).with_name("patch_research_edition_config.py")
|
||||
|
||||
|
||||
def _load_category_source():
|
||||
"""Import the patch script without executing its CLI entry point."""
|
||||
spec = importlib.util.spec_from_file_location("_re_patcher", _PATCHER)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"could not load {_PATCHER}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["_re_patcher"] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def escape_portable(value: str) -> str:
|
||||
"""Escape `value` so the result is a literal in both Python and JS regex.
|
||||
|
||||
Portable across engines *and* across JS flag modes, unlike `re.escape()`.
|
||||
"""
|
||||
return "".join(
|
||||
"\\" + char if char in _REGEX_METACHARACTERS else char for char in value
|
||||
)
|
||||
|
||||
|
||||
def exact_alternation(values: set[str]) -> str:
|
||||
"""Build a stable whole-value alternation portable across Python and JS."""
|
||||
escaped = [escape_portable(value) for value in sorted(values)]
|
||||
return f"^(?:{'|'.join(escaped)})$"
|
||||
|
||||
|
||||
def color_for(category: str) -> str:
|
||||
"""Look up the Activity-view color for a study category.
|
||||
|
||||
Missing keys fail the build rather than ship an unstyled taxonomy.
|
||||
"""
|
||||
try:
|
||||
return CATEGORY_COLORS[category]
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(
|
||||
f"study category {category!r} has no color in CATEGORY_COLORS"
|
||||
) from exc
|
||||
|
||||
|
||||
def build_preset() -> dict:
|
||||
source = _load_category_source()
|
||||
|
||||
categories = {category for _, category in source.CATEGORY_MAP}
|
||||
categories |= set(source.APP_CATEGORY_MAP.values())
|
||||
if not categories:
|
||||
raise RuntimeError("no categories found -- refusing to emit an empty preset")
|
||||
|
||||
extra_colors = set(CATEGORY_COLORS) - categories
|
||||
if extra_colors:
|
||||
raise RuntimeError(
|
||||
"CATEGORY_COLORS has entries not in the study taxonomy: "
|
||||
+ ", ".join(sorted(extra_colors))
|
||||
)
|
||||
|
||||
return {
|
||||
"id": PRESET_ID,
|
||||
"name": PRESET_NAME,
|
||||
# Sorted so the same taxonomy always produces a byte-identical preset.
|
||||
"categories": [
|
||||
{
|
||||
"name": [category],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": exact_alternation(
|
||||
{category}
|
||||
| {
|
||||
app
|
||||
for app, app_category in source.APP_CATEGORY_MAP.items()
|
||||
if app_category == category
|
||||
}
|
||||
),
|
||||
"ignore_case": True,
|
||||
},
|
||||
"data": {"color": color_for(category)},
|
||||
}
|
||||
for category in sorted(categories)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
preset = build_preset()
|
||||
# Compact and newline-free: this is written straight into $GITHUB_ENV,
|
||||
# which treats a newline as the end of the value.
|
||||
print(json.dumps([preset], separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
29
scripts/get_latest_release.sh
vendored
Executable file
29
scripts/get_latest_release.sh
vendored
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# TODO: Merge with scripts/package/getversion.sh
|
||||
|
||||
# Script that fetches the previous release (if current commit is a tag),
|
||||
# or the latest release, if current commit is not a tag.
|
||||
|
||||
# If stable only, then we return the latest stable release,
|
||||
# else, we will return the latest release, either stable or prerelease.
|
||||
RE_STABLE='(?<=[/])v[0-9\.]+$'
|
||||
RE_INCL_PRERELEASE='(?<=[/])v[0-9\.]+(a|b|rc)?[0-9]+$'
|
||||
|
||||
# Get tag for this commit, if any
|
||||
TAG=$(git describe --tags --exact-match 2>/dev/null)
|
||||
|
||||
RE=$RE_INCL_PRERELEASE
|
||||
if [ -n "$STABLE_ONLY" ]; then
|
||||
if [ "$STABLE_ONLY" = "true" ]; then
|
||||
RE=$RE_STABLE
|
||||
fi
|
||||
fi
|
||||
ALL_TAGS=`git for-each-ref --sort=creatordate --format '%(refname)' refs/tags`
|
||||
|
||||
# If current commit is a tag, we filter it out
|
||||
if [ -n "$TAG" ]; then
|
||||
ALL_TAGS=`echo "$ALL_TAGS" | grep -v "^refs/tags/$TAG$"`
|
||||
fi
|
||||
|
||||
echo "$ALL_TAGS" | grep -P "$RE" --only-matching | tail -n1
|
||||
80
scripts/logcrawler.py
Normal file
80
scripts/logcrawler.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
|
||||
import aw_core
|
||||
|
||||
logging.basicConfig()
|
||||
|
||||
log_dir = aw_core.dirs.get_log_dir("")
|
||||
|
||||
|
||||
def get_filepaths():
|
||||
filepaths = []
|
||||
for folder, dirs, files in os.walk(log_dir):
|
||||
print("Crawling folder: " + folder)
|
||||
filepaths.extend([os.path.join(folder, filename) for filename in files])
|
||||
return filepaths
|
||||
|
||||
|
||||
def collect():
|
||||
matched_lines = defaultdict(lambda: [])
|
||||
for filepath in sorted(get_filepaths()):
|
||||
with open(filepath, "r") as f:
|
||||
log = f.read()
|
||||
for line in log.split("\n"):
|
||||
s = re.search("(ERR|WARN)", line)
|
||||
ignored = re.search("(CORS|Deleted bucket)", line)
|
||||
if s and not ignored:
|
||||
matched_lines[filepath].append(line)
|
||||
return matched_lines
|
||||
|
||||
|
||||
_date_reg_exp = re.compile('\\d{4}-\\d{2}-\\d{2}')
|
||||
|
||||
|
||||
today = datetime.now()
|
||||
|
||||
|
||||
def line_age(line):
|
||||
"""Returns line age in days"""
|
||||
match = _date_reg_exp.search(line)
|
||||
if not match:
|
||||
logging.warning("Line had no date, avoid multiple line messages in logs. Line will have its age set to zero.")
|
||||
return 0
|
||||
else:
|
||||
dt = datetime.strptime(match.group(), '%Y-%m-%d')
|
||||
td = today - dt
|
||||
return td.days
|
||||
|
||||
|
||||
def main(exclude_testing: bool = False, limit_days: int = 10, limit_lines: int = 10):
|
||||
file_lines = collect()
|
||||
|
||||
if exclude_testing:
|
||||
keys = filter(lambda k: "testing" not in k, file_lines.keys())
|
||||
file_lines = {key: file_lines[key] for key in keys}
|
||||
|
||||
for filename, lines in sorted(file_lines.items()):
|
||||
lines = sorted(file_lines[filename], reverse=True)
|
||||
|
||||
# Filter lines older than x days
|
||||
if limit_days:
|
||||
lines = [line for line in lines if line_age(line) <= limit_days]
|
||||
|
||||
if lines:
|
||||
print("-" * 50)
|
||||
print("File: {}".format(filename))
|
||||
|
||||
# Print lines up to the limit
|
||||
for line in lines[:limit_lines]:
|
||||
print(" " + line)
|
||||
|
||||
if limit_lines < len(lines):
|
||||
print("Showing {} out of {} lines".format(limit_lines, len(lines)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3
scripts/nop.sh
vendored
Executable file
3
scripts/nop.sh
vendored
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "nop.bat was executed as a workaround for something"
|
||||
108
scripts/notarize.sh
vendored
Executable file
108
scripts/notarize.sh
vendored
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
|
||||
applemail=$APPLE_EMAIL # Email address used for Apple ID
|
||||
password=$APPLE_PASSWORD # See apps-specific password https://support.apple.com/en-us/HT204397
|
||||
teamid=$APPLE_TEAMID # Team idenitifer (if single developer, then set to developer identifier)
|
||||
# Name of the keychain profile to use. Must not contain spaces: APPLE_PERSONALID
|
||||
# holds the full codesign identity ("Developer ID Application: ..."), so use the
|
||||
# team ID instead.
|
||||
keychain_profile="activitywatch-$APPLE_TEAMID"
|
||||
bundleid=net.activitywatch.ActivityWatch # Match aw.spec
|
||||
app=dist/ActivityWatch.app
|
||||
dmg=dist/ActivityWatch.dmg
|
||||
|
||||
# XCode >= 13
|
||||
run_notarytool() {
|
||||
dist=$1
|
||||
# Setup the credentials for notarization
|
||||
xcrun notarytool store-credentials "$keychain_profile" --apple-id "$applemail" --team-id "$teamid" --password "$password"
|
||||
# Notarize and wait; tee to a temp file so output streams in real-time
|
||||
# while we can still inspect it afterward for failure details.
|
||||
echo "Notarization: starting for $dist"
|
||||
echo "Notarization: in progress for $dist"
|
||||
tmpfile=$(mktemp)
|
||||
xcrun notarytool submit "$dist" --keychain-profile "$keychain_profile" --wait 2>&1 | tee "$tmpfile"
|
||||
submission_exit=${PIPESTATUS[0]}
|
||||
submission_output=$(cat "$tmpfile")
|
||||
rm -f "$tmpfile"
|
||||
# On failure, retrieve the detailed rejection log from Apple's server.
|
||||
# This avoids having to run 'notarytool log' manually after the fact.
|
||||
if echo "$submission_output" | grep -q "status: Invalid"; then
|
||||
uuid=$(echo "$submission_output" | grep '^[[:space:]]*id:' | head -1 | awk '{print $NF}')
|
||||
if [ -n "$uuid" ]; then
|
||||
echo ""
|
||||
echo "=== Notarization rejected (status: Invalid) — fetching rejection log for $uuid ==="
|
||||
xcrun notarytool log "$uuid" --keychain-profile "$keychain_profile" 2>&1 || true
|
||||
echo "=== End of rejection log ==="
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
return $submission_exit
|
||||
}
|
||||
|
||||
# XCode < 13
|
||||
run_altool() {
|
||||
dist=$1
|
||||
# Setup the credentials for notarization
|
||||
xcrun altool --store-password-in-keychain-item $keychain_profile -u $applemail -p $password
|
||||
# Notarize and wait
|
||||
echo "Notarization: starting for $dist"
|
||||
upload=$(xcrun altool --notarize-app -t osx -f $dist --primary-bundle-id $bundleid -u $applemail --password "@keychain:$keychain_profile")
|
||||
uuid = $(/usr/libexec/PlistBuddy -c "Print :notarization-upload:RequestUUID" $upload)
|
||||
while true; do
|
||||
req=$(xcrun altool --notarization-info $uuid -u $applemail -p $password --output-format xml)
|
||||
status=$(/usr/libexec/PlistBuddy -c "Print :notarization-info:Status" $req)
|
||||
if [ $status != "in progress" ]; then
|
||||
break
|
||||
else
|
||||
echo "Notarization: in progress for $dist"
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
# Staples the notarization certificate to the executable/bunldle
|
||||
run_stapler() {
|
||||
dist=$1
|
||||
xcrun stapler staple $dist
|
||||
}
|
||||
|
||||
echo 'Detecting availability of notarization tools'
|
||||
notarization_method=exit
|
||||
# Detect if notarytool is available
|
||||
xcrun notarytool >/dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "+ Found notarytool"
|
||||
notarization_method=run_notarytool
|
||||
else
|
||||
# Fallback to altool
|
||||
xcrun -f altool >/dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "+ Found altool"
|
||||
notarization_method=run_altool
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $notarization_method = "exit" ]; then
|
||||
echo "- Found no tools, exiting"
|
||||
$notarization_method
|
||||
fi
|
||||
|
||||
if test -d "$app"; then
|
||||
echo "Notarizing: $app"
|
||||
zip=$app.zip
|
||||
# Turn the app into a zip file that notarization will accept
|
||||
ditto -c -k --keepParent "$app" "$zip"
|
||||
$notarization_method "$zip"
|
||||
run_stapler "$app"
|
||||
else
|
||||
echo "Skipping: $app (expected .app bundle directory)"
|
||||
fi
|
||||
|
||||
if test -f "$dmg"; then
|
||||
echo "Notarizing: $dmg"
|
||||
$notarization_method "$dmg"
|
||||
run_stapler "$dmg"
|
||||
else
|
||||
echo "Skipping: $dmg"
|
||||
fi
|
||||
10
scripts/package/README.txt
Normal file
10
scripts/package/README.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
Run move-to-aw-modules.sh to copy all modules except aw-tauri to ~/aw-modules/.
|
||||
aw-tauri (replaces aw-qt) will use this directory to discover new modules.
|
||||
You can add your own modules and scripts to this directory. The modules should
|
||||
start with the aw- prefix and should not have an extension (e.g. no .sh).
|
||||
|
||||
In the aw-tauri folder there are AppImage, RPM, and DEB binaries. Choose the
|
||||
appropriate one for your Linux distribution. If in doubt, use the AppImage as
|
||||
it works on most Linux systems. If you use the AppImage, copy it to a permanent
|
||||
folder like ~/bin or /usr/local/bin, since autostart relies on the AppImage
|
||||
being in the same location each time.
|
||||
98
scripts/package/RESEARCH_FEED.md
Normal file
98
scripts/package/RESEARCH_FEED.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Research updater feed publisher
|
||||
|
||||
`publish_research_feed.py` prepares an ordinary commit of
|
||||
`latest-research.json` on the `research-updates` branch. It preserves branch
|
||||
history and existing files. The default invocation changes only a new local
|
||||
checkout. `--publish` explicitly enables the remote update.
|
||||
|
||||
This command is manual. There is no release-published trigger. Before live
|
||||
use, the release owner must verify research signing keys, bundle contents,
|
||||
installed-app upgrades, and the disposition of older research clients that
|
||||
still contain the standard updater endpoint/key. The publisher does not prove
|
||||
those properties or enable an update channel in an installed client.
|
||||
|
||||
## Prepare and review
|
||||
|
||||
Requires Git, a configured Git author, and `uv` (the script declares its
|
||||
`semver` dependency). Run from the ActivityWatch checkout, using a new output
|
||||
directory for each attempt:
|
||||
|
||||
```sh
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
TAG=v0.14.0b6-research
|
||||
MANIFEST=/absolute/path/to/reviewed/latest-research.json
|
||||
CANDIDATE=/absolute/path/to/new-feed-candidate
|
||||
|
||||
# Read the current remote head. Use the literal 'absent' only if this
|
||||
# succeeds and returns no matching ref (initial provisioning).
|
||||
git ls-remote --refs https://github.com/ActivityWatch/activitywatch.git \
|
||||
refs/heads/research-updates
|
||||
PARENT=<full-sha-from-the-output>
|
||||
|
||||
uv run --script "$REPO_ROOT/scripts/package/publish_research_feed.py" \
|
||||
--tag "$TAG" --manifest "$MANIFEST" --expected-parent "$PARENT" \
|
||||
--work-dir "$CANDIDATE"
|
||||
|
||||
git -C "$CANDIDATE" show HEAD
|
||||
```
|
||||
|
||||
The supplied manifest must already use normalized SemVer, for example
|
||||
`0.14.0-beta.6`. Tags and asset filenames retain their release spelling
|
||||
(`v0.14.0b6-research` / `activitywatch-tauri-research-0.14.0b6-…`).
|
||||
This publisher accepts stable and a/b/rc research tags, not development builds.
|
||||
Build/app version normalization is owned by the release configuration work;
|
||||
this command validates the tag/version association without modifying builds.
|
||||
|
||||
Validation requires:
|
||||
|
||||
- An unauthenticated GitHub API read of the exact published, immutable,
|
||||
non-draft research release.
|
||||
- All five release targets: macOS arm64/x86_64, Linux arm64/x86_64, and Windows
|
||||
x86_64. A reduced platform rollout requires an explicit code review.
|
||||
- A supported updater bundle and nonempty uploaded `.sig` asset for each
|
||||
platform, under the exact selected release and research filename prefix.
|
||||
- The manifest signature must equal the publicly downloaded `.sig` contents.
|
||||
Cryptographic signature verification and embedded payload-version/edition
|
||||
verification remain release-owner gates; this command does not download
|
||||
the potentially large bundles.
|
||||
- A valid static updater manifest, with RFC3339 publication date and string
|
||||
notes. Unknown fields are refused to avoid alternate updater parsing paths.
|
||||
|
||||
## Publish after release acceptance
|
||||
|
||||
After reviewing the candidate and satisfying the release gates, rerun the
|
||||
same invocation with a **new** work directory and `--publish`. It repeats all
|
||||
release checks and compares the feed's current SemVer before committing.
|
||||
Git's configured credentials authorize the push; public release reads do not
|
||||
use those credentials. Keep the recorded parent unchanged; a stale parent is
|
||||
an error, not permission to overwrite new work.
|
||||
|
||||
An older version is refused. Equal SemVer precedence with different content
|
||||
(including metadata/build-identifier changes) is refused. Identical parsed
|
||||
manifest content is an explicit no-op. Formatting/key order differences alone
|
||||
do not mint another commit. A changed release needs a higher version.
|
||||
|
||||
The publisher checks the remote parent at fetch and in pre-push against Git's
|
||||
advertised OID, then pushes normally without force. Git's receive-side old-OID
|
||||
check closes the remaining race. The wrapper invokes any existing pre-push
|
||||
hook with the original arguments/stdin and preserves its refusal. Concurrent
|
||||
creation, advancement, deletion, or rewind requires a fresh invocation with a
|
||||
reread parent; version ordering is evaluated again, so a late b5 job cannot
|
||||
replace b6 even after retry. Failed candidates remain on disk for inspection.
|
||||
|
||||
After a successful push, verify the public feed separately:
|
||||
`https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json`.
|
||||
A Git push alone does not prove CDN freshness or a successful installed update.
|
||||
Standard release publication does not invoke this command or change this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
uv run --with pytest --with 'semver>=3,<4' python3 -m pytest \
|
||||
"$REPO_ROOT/scripts/tests/test_publish_research_feed.py" -q
|
||||
```
|
||||
|
||||
The tests use release fixtures and temporary local Git remotes. They cover
|
||||
provisioning, advancement, replay, rollback refusal, racing publishers,
|
||||
branch deletion/rewind, incomplete releases, metadata validation, and existing
|
||||
push-hook enforcement. They never publish to GitHub.
|
||||
74
scripts/package/UPDATER.md
Normal file
74
scripts/package/UPDATER.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Tauri release configuration
|
||||
|
||||
`release.yml` runs `configure_tauri_release.py` before compiling aw-tauri.
|
||||
It writes the release version to `aw-tauri/src-tauri/tauri.conf.json`, which
|
||||
Tauri embeds as its package version. `generate_latest_json.py` uses the same
|
||||
`tauri_version()` conversion for the manifest: `v0.14.0b5` becomes
|
||||
`0.14.0-beta.5`. Original AW spellings remain in asset filenames and tag URLs.
|
||||
Development builds also receive a SemVer version, with a `dev.g<hash>` suffix.
|
||||
|
||||
The standard build keeps its checked-in updater endpoint and public key.
|
||||
Research builds use only:
|
||||
|
||||
```text
|
||||
https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json
|
||||
```
|
||||
|
||||
Configure these repository settings before a research tag or manual build:
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| Variable `TAURI_UPDATER_PUBLIC_KEY_RESEARCH` | Base64-encoded minisign public-key file emitted by Tauri's signer |
|
||||
| Secret `TAURI_SIGNING_PRIVATE_KEY_RESEARCH` | Research-only signing key in Tauri's expected format |
|
||||
| Secret `TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH` | Password for that key (empty for an unencrypted key) |
|
||||
|
||||
The workflow selects secret **names** by edition. A missing research secret
|
||||
never falls back to a standard secret. The configurator rejects a research
|
||||
public key containing the standard key material, even if its comment or key
|
||||
ID differs. Research tag/manual builds fail if either key is missing.
|
||||
Secretless research PR smoke builds clear both updater endpoints and the
|
||||
public key. They cannot consume the standard channel.
|
||||
|
||||
`aw-tauri/Makefile` enables `bundle.createUpdaterArtifacts` by passing a
|
||||
`--config` override to the Tauri build when `TAURI_SIGNING_PRIVATE_KEY` is
|
||||
present. That setting is therefore absent from the checked-in JSON even
|
||||
though release builds produce signatures. Both the initial build and the
|
||||
research-profile rebuild receive the edition's selected signing key.
|
||||
|
||||
## Release acceptance still required
|
||||
|
||||
This configuration does not create the `research-updates` branch, publish
|
||||
its manifest, verify that a private key matches the configured public key,
|
||||
or change any already installed client. Before offering an update:
|
||||
|
||||
- Provision the channel and verify its public URL. Publish only a complete,
|
||||
verified manifest after its versioned release is public; use a normal
|
||||
fast-forward commit with an expected parent and a monotonic version check
|
||||
so a late release job cannot overwrite a newer feed.
|
||||
- Verify actual bundle signatures with the research key and reject standard
|
||||
signatures. Config/fixture checks alone are insufficient.
|
||||
- Verify the built app's version, equal-version no-update, one newer-version
|
||||
upgrade, and no repeated offer after restart. Both versions must preserve
|
||||
the research profile, privacy defaults, install identity, and bundled
|
||||
watchers/aw-sync. Native updater bundles and full AW packages require a
|
||||
payload-equivalence check.
|
||||
- Replace or verifiably contain any already distributed research app that
|
||||
embedded the standard endpoint/key **before publishing a valid standard
|
||||
stable manifest**. A future configuration cannot repair an installed app.
|
||||
|
||||
The pipeline creates a draft versioned release. Its `latest-research.json`
|
||||
asset is distinct from the live branch feed; uploading the asset does not
|
||||
advance that feed. Do not publish the draft until these gates are satisfied.
|
||||
|
||||
## Focused checks
|
||||
|
||||
Initialize the pinned `aw-tauri` submodule, then run:
|
||||
|
||||
```sh
|
||||
python3 -m pytest scripts/tests/test_generate_latest_json.py scripts/tests/test_configure_tauri_release.py -q
|
||||
```
|
||||
|
||||
These tests execute both CLIs against the pinned config, exercise missing and
|
||||
same-key rejection, preserve standard trust, and check both directions of
|
||||
asset partitioning. Dummy signatures in these fixtures do not prove signing
|
||||
or installation.
|
||||
135
scripts/package/abi-gate.sh
vendored
Executable file
135
scripts/package/abi-gate.sh
vendored
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# abi-gate.sh — Verify that no bundled ELF requires a GLIBC symbol above
|
||||
# GLIBC_2.28. Run this after packaging to gate on the ABI floor.
|
||||
#
|
||||
# Usage: abi-gate.sh <file.zip> [<file.AppImage>]
|
||||
# Exit 0 = all clear; exit 1 = ABI violation found; exit 2 = script error.
|
||||
#
|
||||
# For AppImage files, extraction requires either:
|
||||
# - `unsquashfs` (squashfs-tools package), or
|
||||
# - APPIMAGE_EXTRACT_AND_RUN=1 so the AppImage self-extracts without FUSE.
|
||||
# If neither works the AppImage scan is skipped with a warning (not a failure).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GLIBC_FLOOR_MAJOR=2
|
||||
GLIBC_FLOOR_MINOR=28
|
||||
|
||||
WORKDIR=$(mktemp -d)
|
||||
VIOLATION_LOG="$WORKDIR/violations.txt"
|
||||
touch "$VIOLATION_LOG"
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
# Returns 0 (true) when the version string X.Y exceeds the floor.
|
||||
glibc_exceeds_floor() {
|
||||
local ver="$1"
|
||||
local major minor
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
if [ "$major" -gt "$GLIBC_FLOOR_MAJOR" ]; then return 0; fi
|
||||
if [ "$major" -eq "$GLIBC_FLOOR_MAJOR" ] && \
|
||||
[ "$minor" -gt "$GLIBC_FLOOR_MINOR" ]; then return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
scan_elfs() {
|
||||
local dir="$1" label="$2"
|
||||
echo "=== Scanning ELFs in: $label ==="
|
||||
local total=0 scanned=0
|
||||
while IFS= read -r elf; do
|
||||
total=$((total + 1))
|
||||
local versions
|
||||
versions=$(readelf --version-info "$elf" 2>/dev/null \
|
||||
| grep -oP 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' \
|
||||
| sort -Vu) || true
|
||||
[ -z "$versions" ] && continue
|
||||
scanned=$((scanned + 1))
|
||||
while IFS= read -r sym; do
|
||||
local ver="${sym#GLIBC_}"
|
||||
if glibc_exceeds_floor "$ver"; then
|
||||
printf ' VIOLATION in %s: %s\n' "$(basename "$elf")" "$sym" \
|
||||
| tee -a "$VIOLATION_LOG"
|
||||
fi
|
||||
done <<< "$versions"
|
||||
done < <(find "$dir" -type f -exec file {} + 2>/dev/null \
|
||||
| grep -E ': ELF ' | cut -d: -f1)
|
||||
echo " ELFs with GLIBC refs: $scanned / $total files scanned"
|
||||
}
|
||||
|
||||
extract_appimage() {
|
||||
local ai="$1" dest="$2"
|
||||
# Try unsquashfs first (reliable offset detection via SquashFS magic).
|
||||
if command -v unsquashfs &>/dev/null; then
|
||||
local offset
|
||||
offset=$(python3 -c "
|
||||
import sys
|
||||
data = open(sys.argv[1], 'rb').read()
|
||||
for magic in (b'sqsh', b'hsqs'):
|
||||
idx = data.find(magic)
|
||||
if idx >= 0:
|
||||
print(idx)
|
||||
break
|
||||
" "$ai" 2>/dev/null || true)
|
||||
if [ -n "$offset" ]; then
|
||||
if unsquashfs -dest "$dest" -offset "$offset" "$ai" &>/dev/null; then
|
||||
echo " Extracted AppImage via unsquashfs (offset $offset)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Fallback: AppImage self-extract (needs APPIMAGE_EXTRACT_AND_RUN=1 in env).
|
||||
pushd "$WORKDIR" >/dev/null
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$OLDPWD/$ai" --appimage-extract &>/dev/null || true
|
||||
popd >/dev/null
|
||||
if [ -d "$WORKDIR/squashfs-root" ]; then
|
||||
mv "$WORKDIR/squashfs-root" "$dest"
|
||||
echo " Extracted AppImage via --appimage-extract"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "Usage: $0 <file.zip> [<file.AppImage>]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*.zip)
|
||||
ZIPDIR="$WORKDIR/zip"
|
||||
mkdir -p "$ZIPDIR"
|
||||
unzip -q "$arg" -d "$ZIPDIR"
|
||||
scan_elfs "$ZIPDIR" "$(basename "$arg")"
|
||||
;;
|
||||
*.AppImage)
|
||||
AI_DIR="$WORKDIR/appimage"
|
||||
mkdir -p "$AI_DIR"
|
||||
if extract_appimage "$arg" "$AI_DIR"; then
|
||||
scan_elfs "$AI_DIR" "$(basename "$arg")"
|
||||
else
|
||||
echo "ERROR: could not extract $(basename "$arg") — AppImage ABI scan is required; aborting" >&2
|
||||
exit 2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "WARNING: unrecognised file type: $arg — skipping" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== ABI Gate Summary ==="
|
||||
echo "GLIBC floor: GLIBC_${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR}"
|
||||
VIOLATION_COUNT=$(wc -l < "$VIOLATION_LOG")
|
||||
echo "Violations: $VIOLATION_COUNT"
|
||||
|
||||
if [ "$VIOLATION_COUNT" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Offending symbols:"
|
||||
cat "$VIOLATION_LOG"
|
||||
echo ""
|
||||
echo "FAIL: bundled ELFs exceed the glibc ${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR} ABI floor"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: no GLIBC symbol above GLIBC_${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR} found"
|
||||
63
scripts/package/activitywatch-setup.iss
Normal file
63
scripts/package/activitywatch-setup.iss
Normal file
@@ -0,0 +1,63 @@
|
||||
; Script generated by the Inno Setup Script Wizard.
|
||||
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||
|
||||
#define MyAppName "ActivityWatch"
|
||||
#define MyAppVersion GetEnv('AW_VERSION')
|
||||
#define MyAppPublisher "ActivityWatch Contributors"
|
||||
#define MyAppURL "https://activitywatch.net/"
|
||||
#define MyAppExeName "aw-qt.exe"
|
||||
#define RootDir "..\.."
|
||||
#define DistDir "..\..\dist"
|
||||
|
||||
#pragma verboselevel 9
|
||||
|
||||
[Setup]
|
||||
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
|
||||
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
|
||||
; NOTE: the double {{ are used to escape the { character (needed for the AppId)
|
||||
AppId={{F226B8F4-3244-46E6-901D-0CE8035423E4}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
;AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL="https://github.com/ActivityWatch/activitywatch/issues"
|
||||
AppUpdatesURL="https://github.com/ActivityWatch/activitywatch/releases"
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
; Uncomment the following line to run in non administrative install mode (install for current user only.)
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
OutputDir={#DistDir}
|
||||
OutputBaseFilename=activitywatch-setup
|
||||
SetupIconFile="{#RootDir}\aw-qt\media\logo\logo.ico"
|
||||
UninstallDisplayName={#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
Name: "StartMenuEntry" ; Description: "Start ActivityWatch when Windows starts"; GroupDescription: "Windows Startup"; MinVersion: 4,4;
|
||||
|
||||
[Files]
|
||||
Source: "{#DistDir}\activitywatch\aw-qt.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#DistDir}\activitywatch\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: StartMenuEntry;
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
; Removes the previously installed version before installing the new one
|
||||
; NOTE: Doesn't work? And also discouraged by the docs
|
||||
;[InstallDelete]
|
||||
;Type: filesandordirs; Name: "{app}\"
|
||||
14
scripts/package/app-entitlements.plist
Normal file
14
scripts/package/app-entitlements.plist
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- These are required for binaries built by PyInstaller -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- The responsible application needs this to prompt for browser Apple Events access. -->
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
57
scripts/package/aw-tauri.iss
Normal file
57
scripts/package/aw-tauri.iss
Normal file
@@ -0,0 +1,57 @@
|
||||
; Inno Setup script for ActivityWatch (Tauri edition)
|
||||
;
|
||||
; This is separate from activitywatch-setup.iss (aw-qt) to avoid
|
||||
; installation collisions. Uses a different AppId, install directory,
|
||||
; and display name.
|
||||
|
||||
#define MyAppName "ActivityWatch (Tauri)"
|
||||
#define MyAppVersion GetEnv('AW_VERSION')
|
||||
#define MyAppPublisher "ActivityWatch Contributors"
|
||||
#define MyAppURL "https://activitywatch.net/"
|
||||
#define MyAppExeName "aw-tauri.exe"
|
||||
#define RootDir "..\.."
|
||||
#define DistDir "..\..\dist"
|
||||
|
||||
#pragma verboselevel 9
|
||||
|
||||
[Setup]
|
||||
; IMPORTANT: Different AppId from aw-qt to allow side-by-side installation
|
||||
AppId={{983D0855-08C8-46BD-AEFB-3924581C6703}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL="https://github.com/ActivityWatch/activitywatch/issues"
|
||||
AppUpdatesURL="https://github.com/ActivityWatch/activitywatch/releases"
|
||||
DefaultDirName={autopf}\ActivityWatch-Tauri
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
OutputDir={#DistDir}
|
||||
OutputBaseFilename=activitywatch-setup
|
||||
SetupIconFile="{#RootDir}\aw-tauri\src-tauri\icons\icon.ico"
|
||||
UninstallDisplayName={#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
Name: "StartMenuEntry" ; Description: "Start ActivityWatch when Windows starts"; GroupDescription: "Windows Startup"; MinVersion: 4,4;
|
||||
|
||||
[Files]
|
||||
Source: "{#DistDir}\activitywatch\aw-tauri.exe"; DestDir: "{app}\aw-tauri"; Flags: ignoreversion
|
||||
Source: "{#DistDir}\activitywatch\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: StartMenuEntry;
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
277
scripts/package/build_app_tauri.sh
vendored
Executable file
277
scripts/package/build_app_tauri.sh
vendored
Executable file
@@ -0,0 +1,277 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Build a macOS .app bundle for the Tauri-based ActivityWatch.
|
||||
# This replaces the PyInstaller-based bundling used by aw-qt.
|
||||
|
||||
APP_NAME="ActivityWatch"
|
||||
BUNDLE_ID="net.activitywatch.ActivityWatch"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="$("$SCRIPT_DIR/getversion.sh" --strip-v)"
|
||||
ICON_PATH="aw-tauri/src-tauri/icons/icon.icns"
|
||||
|
||||
if [[ "$(uname)" != "Darwin" ]]; then
|
||||
echo "This script is designed to run on macOS only."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "dist/activitywatch" ]; then
|
||||
echo "Error: dist/activitywatch directory not found. Please build the project first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "dist/activitywatch/aw-tauri" ]; then
|
||||
echo "Error: aw-tauri binary not found in dist/activitywatch/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf "dist/${APP_NAME}.app"
|
||||
mkdir -p "dist"
|
||||
|
||||
echo "Creating app bundle structure..."
|
||||
mkdir -p "dist/${APP_NAME}.app/Contents/"{MacOS,Resources}
|
||||
|
||||
echo "Copying aw-tauri as main executable..."
|
||||
cp "dist/activitywatch/aw-tauri" "dist/${APP_NAME}.app/Contents/MacOS/aw-tauri"
|
||||
chmod +x "dist/${APP_NAME}.app/Contents/MacOS/aw-tauri"
|
||||
|
||||
echo "Copying components to Resources..."
|
||||
for component in dist/activitywatch/*/; do
|
||||
if [ -d "$component" ]; then
|
||||
component_name=$(basename "$component")
|
||||
echo " Copying $component_name..."
|
||||
mkdir -p "dist/${APP_NAME}.app/Contents/Resources/$component_name"
|
||||
cp -r "$component"/* "dist/${APP_NAME}.app/Contents/Resources/$component_name/"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Fixing Python.framework symlink structure..."
|
||||
# PyInstaller copies Python.framework using regular files/directories instead of
|
||||
# preserving the standard macOS symlink layout. Without symlinks, codesign rejects
|
||||
# the framework with "bundle format is ambiguous (could be app or framework)".
|
||||
# Restore the canonical layout so codesign can sign it as a proper framework bundle:
|
||||
# Versions/Current -> <version> (symlink)
|
||||
# Python -> Versions/Current/Python (symlink)
|
||||
# Resources -> Versions/Current/Resources (symlink)
|
||||
# Headers -> Versions/Current/Headers (symlink, if present)
|
||||
while IFS= read -r fw; do
|
||||
echo " Fixing: $fw"
|
||||
# Find the actual version directory (e.g., "3.9"), skipping "Current"
|
||||
version_dir=""
|
||||
for d in "$fw/Versions"/*/; do
|
||||
bname="$(basename "$d")"
|
||||
if [ "$bname" != "Current" ] && [ -d "$d" ]; then
|
||||
version_dir="$bname"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$version_dir" ]; then
|
||||
echo " Warning: No version directory found in $fw/Versions/, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Replace Versions/Current directory with a symlink to the version dir
|
||||
if [ -d "$fw/Versions/Current" ] && [ ! -L "$fw/Versions/Current" ]; then
|
||||
rm -rf "$fw/Versions/Current"
|
||||
ln -s "$version_dir" "$fw/Versions/Current"
|
||||
fi
|
||||
|
||||
# Replace root-level copies with symlinks into Versions/Current/
|
||||
for item in Python Resources Headers; do
|
||||
if [ -e "$fw/$item" ] && [ ! -L "$fw/$item" ]; then
|
||||
rm -rf "$fw/$item"
|
||||
ln -s "Versions/Current/$item" "$fw/$item"
|
||||
fi
|
||||
done
|
||||
done < <(find "dist/${APP_NAME}.app" -type d -iname "Python.framework")
|
||||
|
||||
echo "Setting executable permissions..."
|
||||
find "dist/${APP_NAME}.app/Contents/Resources" -type f -name "aw-*" -exec chmod +x {} \;
|
||||
|
||||
echo "Copying app icon..."
|
||||
if [ -f "$ICON_PATH" ]; then
|
||||
cp "$ICON_PATH" "dist/${APP_NAME}.app/Contents/Resources/icon.icns"
|
||||
else
|
||||
echo "Warning: Icon file not found at $ICON_PATH"
|
||||
fi
|
||||
|
||||
echo "Creating Info.plist..."
|
||||
cat > "dist/${APP_NAME}.app/Contents/Info.plist" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>aw-tauri</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${BUNDLE_ID}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>${APP_NAME}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>ActivityWatch needs access to monitor application usage</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${MACOSX_DEPLOYMENT_TARGET:-12.0}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
echo "Creating PkgInfo..."
|
||||
echo "APPL????" > "dist/${APP_NAME}.app/Contents/PkgInfo"
|
||||
|
||||
if [ -n "$APPLE_PERSONALID" ]; then
|
||||
echo "Signing app with identity: $APPLE_PERSONALID (inside-out, per-binary)"
|
||||
# codesign --deep is unreliable for bundles with PyInstaller helpers:
|
||||
# it doesn't reach all nested dylibs/so files and mishandles Python.framework
|
||||
# symlinks, leaving hundreds of binaries unsigned or invalidly signed.
|
||||
# The correct approach is inside-out: sign all Mach-O leaves first,
|
||||
# then .framework bundles, then the top-level .app last.
|
||||
# --timestamp is required for notarization (Apple rejects submissions without it).
|
||||
ENTITLEMENTS="scripts/package/entitlements.plist"
|
||||
APP_ENTITLEMENTS="scripts/package/app-entitlements.plist"
|
||||
|
||||
sign_binary() {
|
||||
echo " Signing: $1"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--sign "$APPLE_PERSONALID" \
|
||||
"$1"
|
||||
}
|
||||
|
||||
# Step 1: Sign all Mach-O binary files (dylibs, .so files, standalone executables).
|
||||
# Use `xargs file` to batch all type queries in O(1) subprocess calls instead of
|
||||
# one `file` invocation per binary (PyInstaller bundles can contain hundreds of files).
|
||||
# Sort by path length descending so deeper binaries are signed before shallower containers.
|
||||
# IMPORTANT: Skip the main binary of .framework bundles (e.g. Python.framework/Python).
|
||||
# codesign treats those as ambiguous ("could be app or framework") when signed as
|
||||
# standalone files. They are correctly signed in Step 2 as part of the framework bundle.
|
||||
echo " Signing Mach-O binary files..."
|
||||
while IFS= read -r f; do
|
||||
# Skip main binaries of bundle directories (.framework, .bundle, .plugin) —
|
||||
# they'll be signed as part of the bundle in Step 2. Signing them standalone
|
||||
# causes "bundle format is ambiguous" errors from codesign.
|
||||
parent_dir="$(dirname "$f")"
|
||||
if [[ "$parent_dir" == *.framework ]] || [[ "$parent_dir" == *.framework/Versions/* ]] \
|
||||
|| [[ "$parent_dir" == *.bundle ]] || [[ "$parent_dir" == *.plugin ]]; then
|
||||
echo " Skipping bundle binary (signed in Step 2): $f"
|
||||
continue
|
||||
fi
|
||||
sign_binary "$f"
|
||||
done < <(find "dist/${APP_NAME}.app" -type f \
|
||||
| xargs file \
|
||||
| grep "Mach-O" \
|
||||
| cut -d: -f1 \
|
||||
| awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
|
||||
|
||||
# Step 2: Sign bundle directories (.framework, .bundle, .plugin) after their contents.
|
||||
# Deepest bundles first (sort by path length descending) to maintain inside-out order.
|
||||
# .bundle/.plugin coverage prevents missing CodeResources catalog seals that can
|
||||
# trigger notarytool bundle-integrity warnings.
|
||||
echo " Signing bundle directories (.framework, .bundle, .plugin)..."
|
||||
while IFS= read -r fw; do
|
||||
# PyInstaller-embedded frameworks (e.g. Python.framework inside aw-watcher-window)
|
||||
# lack the standard Versions/ structure and Info.plist, so codesign rejects them
|
||||
# with "bundle format is ambiguous (could be app or framework)". Fall back to
|
||||
# signing the main binary inside the framework directly. All other signing errors
|
||||
# are still fatal.
|
||||
sign_output=$(codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--sign "$APPLE_PERSONALID" \
|
||||
"$fw" 2>&1) && echo " Signed bundle: $fw" || {
|
||||
if echo "$sign_output" | grep -q "bundle format is ambiguous"; then
|
||||
echo " Note: $fw lacks standard bundle structure; strip-signing and syncing duplicates by content hash"
|
||||
# PyInstaller copies Python.framework contents as separate files rather
|
||||
# than symlinks — Python, Versions/Current/Python, and Versions/3.9/Python
|
||||
# are distinct inodes with identical code but different embedded signatures
|
||||
# (different timestamp nonces in __LINKEDIT from PyInstaller's own
|
||||
# codesign_identity). After cp -r into the .app, all three paths carry
|
||||
# pre-existing but DIFFERENT signatures. A pre-sign SHA comparison
|
||||
# never detects them as duplicates, so all three end up signed separately,
|
||||
# producing three divergent signatures that Apple rejects as "invalid".
|
||||
#
|
||||
# Fix: strip all existing signatures first (making copies truly identical
|
||||
# at the byte level), then group by content hash, sign one canonical per
|
||||
# group via temp copy, and sync the signed result to all group members.
|
||||
# This is correct even if the framework contains genuinely different
|
||||
# binaries — only true duplicates share a signed payload.
|
||||
tmp_group_dir=$(mktemp -d)
|
||||
found_macho=false
|
||||
while IFS= read -r fw_bin; do
|
||||
found_macho=true
|
||||
existing_id=$(codesign -d "$fw_bin" 2>&1 \
|
||||
| sed -n 's/^Identifier=//p' || true)
|
||||
if [ -z "$existing_id" ]; then
|
||||
existing_id="$(basename "$fw_bin")"
|
||||
fi
|
||||
|
||||
# Strip any existing signature (from PyInstaller or prior attempts)
|
||||
# so content hashing identifies true duplicates rather than
|
||||
# nonce-only signature differences from previous signing steps.
|
||||
codesign --remove-signature "$fw_bin" 2>/dev/null || true
|
||||
content_hash=$(shasum -a 256 "$fw_bin" | awk '{print $1}')
|
||||
canonical_path_file="$tmp_group_dir/$content_hash.path"
|
||||
|
||||
if [ -f "$canonical_path_file" ]; then
|
||||
# Same binary content — sync signed payload from canonical.
|
||||
canonical="$canonical_path_file"
|
||||
echo " Syncing signed binary to duplicate: $fw_bin"
|
||||
cp "$canonical" "$fw_bin"
|
||||
else
|
||||
# First occurrence of this content — sign it as canonical.
|
||||
echo " Signing canonical binary: $fw_bin (identifier: $existing_id, hash: ${content_hash:0:12})"
|
||||
tmp_binary=$(mktemp)
|
||||
cp -p "$fw_bin" "$tmp_binary"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--identifier "$existing_id" \
|
||||
--sign "$APPLE_PERSONALID" \
|
||||
"$tmp_binary" || { rm -f "$tmp_binary"; rm -rf "$tmp_group_dir"; exit 1; }
|
||||
cp "$tmp_binary" "$fw_bin"
|
||||
# Save signed canonical as the source for duplicates.
|
||||
cp "$tmp_binary" "$canonical_path_file"
|
||||
rm -f "$tmp_binary"
|
||||
fi
|
||||
done < <(find "$fw" -type f | xargs file | grep "Mach-O" | cut -d: -f1 | sort)
|
||||
rm -rf "$tmp_group_dir"
|
||||
if ! $found_macho; then
|
||||
echo "ERROR: No Mach-O binaries found inside $fw" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Failed to sign $fw: $sign_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
done < <(find "dist/${APP_NAME}.app" -type d \
|
||||
\( -name "*.framework" -o -name "*.bundle" -o -name "*.plugin" \) \
|
||||
| awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
|
||||
|
||||
# Step 3: Sign the top-level .app bundle last.
|
||||
echo " Signing top-level .app bundle..."
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$APP_ENTITLEMENTS" \
|
||||
--sign "$APPLE_PERSONALID" \
|
||||
"dist/${APP_NAME}.app"
|
||||
|
||||
echo "App signing complete."
|
||||
else
|
||||
echo "APPLE_PERSONALID not set. Skipping code signing."
|
||||
fi
|
||||
|
||||
echo "App bundle created at: dist/${APP_NAME}.app"
|
||||
14
scripts/package/collect-setup.sh
vendored
Executable file
14
scripts/package/collect-setup.sh
vendored
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# The Research Edition patcher rewrites OutputBaseFilename in the .iss
|
||||
# (activitywatch-research[-tauri]-setup), so resolve the produced file
|
||||
# instead of hardcoding the standard name. dist/ is fresh at this point,
|
||||
# so exactly one setup exe must exist.
|
||||
setup_src=(dist/activitywatch*-setup.exe)
|
||||
if [ ${#setup_src[@]} -ne 1 ] || [ ! -f "${setup_src[0]}" ]; then
|
||||
echo "ERROR: expected exactly one dist/activitywatch*-setup.exe, got: ${setup_src[*]}"
|
||||
exit 1
|
||||
fi
|
||||
mv "${setup_src[0]}" "dist/$1"
|
||||
108
scripts/package/configure_tauri_release.py
Normal file
108
scripts/package/configure_tauri_release.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set the compiled updater version and isolate Research Edition update trust."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from generate_latest_json import tauri_version
|
||||
|
||||
RESEARCH_ENDPOINT = (
|
||||
"https://raw.githubusercontent.com/ActivityWatch/activitywatch/"
|
||||
"research-updates/latest-research.json"
|
||||
)
|
||||
STANDARD_ENDPOINT = "https://github.com/ActivityWatch/activitywatch/releases/latest/download/latest.json"
|
||||
|
||||
|
||||
def msi_rejects(version: str) -> bool:
|
||||
"""True if Tauri's msi (WiX) bundler will reject this SemVer version.
|
||||
|
||||
The msi target requires the optional pre-release identifier to be
|
||||
numeric-only (e.g. "0.14.0-5"), unlike nsis or the other bundle formats.
|
||||
AW's own pre-release scheme ("0.14.0-beta.5", "0.14.0-dev.gabc1234")
|
||||
fails that check every time.
|
||||
"""
|
||||
if "-" not in version:
|
||||
return False
|
||||
prerelease = version.split("-", 1)[1]
|
||||
return not all(part.isdigit() for part in prerelease.split("."))
|
||||
|
||||
|
||||
def public_key(encoded: str) -> bytes:
|
||||
"""Read Tauri's base64-wrapped minisign public key, ignoring its comment."""
|
||||
try:
|
||||
lines = base64.b64decode(encoded, validate=True).decode("ascii").splitlines()
|
||||
if len(lines) != 2 or not lines[0].startswith("untrusted comment: "):
|
||||
raise ValueError("invalid public key envelope")
|
||||
packet = base64.b64decode(lines[1], validate=True)
|
||||
if len(packet) != 42 or packet[:2] != b"Ed":
|
||||
raise ValueError("invalid public key packet")
|
||||
return packet[10:]
|
||||
except (ValueError, UnicodeError, binascii.Error) as exc:
|
||||
raise ValueError("Expected a base64-encoded minisign public key") from exc
|
||||
|
||||
|
||||
def configure(
|
||||
path: Path,
|
||||
version: str,
|
||||
research: bool,
|
||||
require_signing_key: bool,
|
||||
*,
|
||||
platform: str = sys.platform,
|
||||
) -> None:
|
||||
config = json.loads(path.read_text(encoding="utf-8"))
|
||||
tv = tauri_version(version)
|
||||
config["version"] = tv
|
||||
if platform == "win32" and msi_rejects(tv):
|
||||
bundle = config.setdefault("bundle", {})
|
||||
if bundle.get("targets") == "all":
|
||||
# Windows only ever produces msi+nsis from "all"; drop msi and
|
||||
# keep nsis, which accepts the full AW pre-release scheme.
|
||||
bundle["targets"] = ["nsis"]
|
||||
if research:
|
||||
updater = config["plugins"]["updater"]
|
||||
if updater["endpoints"] != [STANDARD_ENDPOINT]:
|
||||
raise ValueError(
|
||||
"Unexpected source updater endpoint; review before patching"
|
||||
)
|
||||
research_key = os.environ.get("TAURI_UPDATER_PUBLIC_KEY_RESEARCH", "").strip()
|
||||
signing_key = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "").strip()
|
||||
if require_signing_key and (not research_key or not signing_key):
|
||||
raise ValueError(
|
||||
"Research releases require their own public and signing keys"
|
||||
)
|
||||
if research_key:
|
||||
if public_key(research_key) == public_key(updater["pubkey"]):
|
||||
raise ValueError(
|
||||
"Research updater key must differ from the standard key"
|
||||
)
|
||||
updater["pubkey"] = research_key
|
||||
updater["endpoints"] = [RESEARCH_ENDPOINT]
|
||||
else:
|
||||
if signing_key:
|
||||
raise ValueError("Research signing key supplied without its public key")
|
||||
# Secretless PR smoke builds must never retain standard update trust.
|
||||
updater["pubkey"] = ""
|
||||
updater["endpoints"] = []
|
||||
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--research", action="store_true")
|
||||
parser.add_argument("--require-signing-key", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
configure(args.config, args.version, args.research, args.require_signing_key)
|
||||
except (ValueError, KeyError) as exc:
|
||||
parser.exit(1, f"ERROR: {exc}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
scripts/package/deb/control
Normal file
8
scripts/package/deb/control
Normal file
@@ -0,0 +1,8 @@
|
||||
Package: activitywatch
|
||||
Architecture: amd64
|
||||
Maintainer: Erik Bjäreholt <erik@bjareho.lt>
|
||||
Depends:
|
||||
Priority: optional
|
||||
Version: SCRIPT_VERSION_HERE
|
||||
Description: Open source time tracker
|
||||
https://github.com/ActivityWatch/activitywatch
|
||||
141
scripts/package/dmgbuild-settings.py
Normal file
141
scripts/package/dmgbuild-settings.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import plistlib
|
||||
import os.path
|
||||
|
||||
# Use like this: dmgbuild -s settings.py "Test Volume" test.dmg
|
||||
|
||||
# You can actually use this file for your own application (not just TextEdit)
|
||||
# by doing e.g.
|
||||
#
|
||||
# dmgbuild -s settings.py -D app=/path/to/My.app "My Application" MyApp.dmg
|
||||
|
||||
# .. Useful stuff ..............................................................
|
||||
|
||||
application = defines.get('app', 'dist/ActivityWatch.app')
|
||||
appname = os.path.basename(application)
|
||||
|
||||
def icon_from_app(app_path):
|
||||
plist_path = os.path.join(app_path, 'Contents', 'Info.plist')
|
||||
with open(plist_path, "rb") as f:
|
||||
plist = plistlib.load(f)
|
||||
icon_name = plist['CFBundleIconFile']
|
||||
icon_root,icon_ext = os.path.splitext(icon_name)
|
||||
if not icon_ext:
|
||||
icon_ext = '.icns'
|
||||
icon_name = icon_root + icon_ext
|
||||
return os.path.join(app_path, 'Contents', 'Resources', icon_name)
|
||||
|
||||
# .. Basics ....................................................................
|
||||
|
||||
# Uncomment to override the output filename
|
||||
# filename = 'test.dmg'
|
||||
|
||||
# Uncomment to override the output volume name
|
||||
# volume_name = 'Test'
|
||||
|
||||
# Volume format (see hdiutil create -help)
|
||||
format = defines.get('format', 'UDBZ')
|
||||
|
||||
# Volume size
|
||||
size = defines.get('size', None)
|
||||
|
||||
# Files to include
|
||||
files = [ application ]
|
||||
|
||||
# Symlinks to create
|
||||
symlinks = { 'Applications': '/Applications' }
|
||||
|
||||
# Volume icon
|
||||
#
|
||||
# You can either define icon, in which case that icon file will be copied to the
|
||||
# image, *or* you can define badge_icon, in which case the icon file you specify
|
||||
# will be used to badge the system's Removable Disk icon
|
||||
#
|
||||
#icon = '/path/to/icon.icns'
|
||||
badge_icon = icon_from_app(application)
|
||||
|
||||
# Where to put the icons
|
||||
icon_locations = {
|
||||
appname: (140, 120),
|
||||
'Applications': (500, 120)
|
||||
}
|
||||
|
||||
show_status_bar = False
|
||||
show_tab_view = False
|
||||
show_toolbar = False
|
||||
show_pathbar = False
|
||||
show_sidebar = False
|
||||
sidebar_width = 180
|
||||
|
||||
# Window position in ((x, y), (w, h)) format
|
||||
window_rect = ((100, 100), (640, 280))
|
||||
|
||||
default_view = 'icon-view'
|
||||
|
||||
show_icon_preview = False
|
||||
|
||||
# Set these to True to force inclusion of icon/list view settings (otherwise
|
||||
# we only include settings for the default view)
|
||||
include_icon_view_settings = 'auto'
|
||||
include_list_view_settings = 'auto'
|
||||
|
||||
# .. Icon view configuration ...................................................
|
||||
|
||||
arrange_by = None
|
||||
grid_offset = (0, 0)
|
||||
grid_spacing = 100
|
||||
scroll_position = (0, 0)
|
||||
label_pos = 'bottom' # or 'right'
|
||||
text_size = 16
|
||||
icon_size = 128
|
||||
|
||||
# .. List view configuration ...................................................
|
||||
|
||||
# Column names are as follows:
|
||||
#
|
||||
# name
|
||||
# date-modified
|
||||
# date-created
|
||||
# date-added
|
||||
# date-last-opened
|
||||
# size
|
||||
# kind
|
||||
# label
|
||||
# version
|
||||
# comments
|
||||
#
|
||||
list_icon_size = 16
|
||||
list_text_size = 12
|
||||
list_scroll_position = (0, 0)
|
||||
list_sort_by = 'name'
|
||||
list_use_relative_dates = True
|
||||
list_calculate_all_sizes = False,
|
||||
list_columns = ('name', 'date-modified', 'size', 'kind', 'date-added')
|
||||
list_column_widths = {
|
||||
'name': 300,
|
||||
'date-modified': 181,
|
||||
'date-created': 181,
|
||||
'date-added': 181,
|
||||
'date-last-opened': 181,
|
||||
'size': 97,
|
||||
'kind': 115,
|
||||
'label': 100,
|
||||
'version': 75,
|
||||
'comments': 300,
|
||||
}
|
||||
|
||||
list_column_sort_directions = {
|
||||
'name': 'ascending',
|
||||
'date-modified': 'descending',
|
||||
'date-created': 'descending',
|
||||
'date-added': 'descending',
|
||||
'date-last-opened': 'descending',
|
||||
'size': 'descending',
|
||||
'kind': 'ascending',
|
||||
'label': 'ascending',
|
||||
'version': 'ascending',
|
||||
'comments': 'ascending',
|
||||
}
|
||||
|
||||
12
scripts/package/entitlements.plist
Normal file
12
scripts/package/entitlements.plist
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Shared by nested binaries; app-only capabilities belong in app-entitlements.plist. -->
|
||||
<!-- These are required for binaries built by PyInstaller -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
181
scripts/package/generate_latest_json.py
Executable file
181
scripts/package/generate_latest_json.py
Executable file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble a Tauri updater manifest from per-platform .sig files.
|
||||
|
||||
Expects updater artifacts named
|
||||
`activitywatch-tauri[-research]-<version>-<platform-key>.<ext>` with a
|
||||
matching `<...>.sig` file alongside it (as produced by the "Package Tauri
|
||||
updater artifacts" step in release.yml), where <platform-key> is a Tauri
|
||||
updater platform identifier such as "darwin-aarch64" or "linux-x86_64".
|
||||
|
||||
Standard and Research Edition releases are partitioned by filename:
|
||||
|
||||
- standard: `latest.json` + `activitywatch-tauri-<ver>-...`
|
||||
- research: `latest-research.json` + `activitywatch-tauri-research-<ver>-...`
|
||||
|
||||
so the two lines cannot overwrite each other's GitHub release assets or
|
||||
share an updater endpoint.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
EDITIONS = ("standard", "research")
|
||||
|
||||
|
||||
def normalize_version(version: str) -> str:
|
||||
"""Strip a leading 'v' and a trailing '-research' edition suffix."""
|
||||
if version.startswith("v"):
|
||||
version = version[1:]
|
||||
if version.endswith("-research"):
|
||||
version = version[: -len("-research")]
|
||||
return version
|
||||
|
||||
|
||||
def tauri_version(version: str) -> str:
|
||||
"""Convert AW release/dev labels to the SemVer embedded by Tauri.
|
||||
|
||||
Asset filenames and GitHub tags keep their original AW version spelling.
|
||||
"""
|
||||
version = normalize_version(version)
|
||||
number = r"(?:0|[1-9][0-9]*)"
|
||||
match = re.fullmatch(
|
||||
rf"(?P<base>{number}\.{number}\.{number})"
|
||||
rf"(?:(?P<pre>a|b|rc)(?P<serial>{number}))?"
|
||||
r"(?:\.dev-(?P<dev>[0-9a-f]+|unknown))?",
|
||||
version,
|
||||
)
|
||||
if not match:
|
||||
raise ValueError(f"Unsupported ActivityWatch version: {version!r}")
|
||||
suffix = []
|
||||
if match["pre"]:
|
||||
suffix.extend(
|
||||
({"a": "alpha", "b": "beta", "rc": "rc"}[match["pre"]], match["serial"])
|
||||
)
|
||||
if match["dev"]:
|
||||
# Prefix hashes so an all-digit hash with a leading zero stays valid.
|
||||
suffix.extend(("dev", "g" + match["dev"]))
|
||||
return match["base"] + ("-" + ".".join(suffix) if suffix else "")
|
||||
|
||||
|
||||
def infer_edition(tag: str, edition=None) -> str:
|
||||
if edition:
|
||||
if edition not in EDITIONS:
|
||||
raise ValueError(f"unknown edition {edition!r}")
|
||||
return edition
|
||||
return "research" if tag.endswith("-research") else "standard"
|
||||
|
||||
|
||||
def asset_prefix(edition: str) -> str:
|
||||
if edition == "research":
|
||||
return "activitywatch-tauri-research"
|
||||
return "activitywatch-tauri"
|
||||
|
||||
|
||||
def manifest_filename(edition: str) -> str:
|
||||
return "latest-research.json" if edition == "research" else "latest.json"
|
||||
|
||||
|
||||
# Tauri v2 recommends NSIS for Windows updater bundles. When both NSIS and
|
||||
# MSI signatures exist for the same platform key, keep NSIS regardless of
|
||||
# os.walk order. Unlisted extensions share rank 0 (first one wins).
|
||||
WINDOWS_BUNDLE_RANK = {
|
||||
"nsis.zip": 2,
|
||||
"exe": 1,
|
||||
"msi.zip": 0,
|
||||
"msi": 0,
|
||||
}
|
||||
|
||||
|
||||
def bundle_rank(ext: str) -> int:
|
||||
return WINDOWS_BUNDLE_RANK.get(ext, 0)
|
||||
|
||||
|
||||
def collect_platforms(dist: str, version: str, repo: str, tag: str, edition: str) -> dict:
|
||||
prefix = asset_prefix(edition)
|
||||
# Non-greedy platform group: extensions can be multi-part (.app.tar.gz,
|
||||
# .AppImage.tar.gz, .nsis.zip), so stop at the first dot after the
|
||||
# platform key rather than the last.
|
||||
pattern = re.compile(
|
||||
rf"^{re.escape(prefix)}-{re.escape(version)}-(?P<platform>.+?)\.(?P<ext>.+)$"
|
||||
)
|
||||
|
||||
platforms = {}
|
||||
chosen_ext = {}
|
||||
for root, _, files in os.walk(dist):
|
||||
for name in files:
|
||||
if not name.endswith(".sig"):
|
||||
continue
|
||||
asset_name = name[: -len(".sig")]
|
||||
m = pattern.match(asset_name)
|
||||
if not m:
|
||||
continue
|
||||
platform = m.group("platform")
|
||||
ext = m.group("ext")
|
||||
prev_ext = chosen_ext.get(platform)
|
||||
if prev_ext is not None and bundle_rank(ext) <= bundle_rank(prev_ext):
|
||||
continue
|
||||
with open(os.path.join(root, name)) as f:
|
||||
signature = f.read().strip()
|
||||
chosen_ext[platform] = ext
|
||||
platforms[platform] = {
|
||||
"signature": signature,
|
||||
"url": (
|
||||
f"https://github.com/{repo}/releases/download/"
|
||||
f"{tag}/{asset_name}"
|
||||
),
|
||||
}
|
||||
return platforms
|
||||
|
||||
|
||||
def build_manifest(version: str, notes: str, platforms: dict, pub_date=None) -> dict:
|
||||
if pub_date is None:
|
||||
pub_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return {
|
||||
"version": version,
|
||||
"notes": notes,
|
||||
"pub_date": pub_date,
|
||||
"platforms": platforms,
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--notes", required=True)
|
||||
parser.add_argument(
|
||||
"--repo", required=True, help="e.g. ActivityWatch/activitywatch"
|
||||
)
|
||||
parser.add_argument("--tag", required=True, help="e.g. v0.13.3 or v0.13.3-research")
|
||||
parser.add_argument(
|
||||
"--edition",
|
||||
choices=EDITIONS,
|
||||
default=None,
|
||||
help="Release line. Inferred from --tag (*-research) if omitted.",
|
||||
)
|
||||
parser.add_argument("--dist", required=True, help="directory to search for *.sig files")
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
version = normalize_version(args.version)
|
||||
edition = infer_edition(args.tag, args.edition)
|
||||
platforms = collect_platforms(args.dist, version, args.repo, args.tag, edition)
|
||||
|
||||
if not platforms:
|
||||
raise SystemExit(
|
||||
f"No {edition} updater artifacts found - refusing to write an empty "
|
||||
f"{os.path.basename(args.output)}"
|
||||
)
|
||||
|
||||
manifest = build_manifest(tauri_version(version), args.notes, platforms)
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(f"Wrote {args.output} ({edition}) with platforms: {', '.join(sorted(platforms))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
93
scripts/package/getversion.sh
vendored
Executable file
93
scripts/package/getversion.sh
vendored
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
STRIP_V=false
|
||||
|
||||
show_usage() {
|
||||
cat << EOF
|
||||
Usage: $SCRIPT_NAME [OPTIONS]
|
||||
|
||||
Get the version of ActivityWatch from git tags or CI environment variables.
|
||||
|
||||
Options:
|
||||
--strip-v, --no-v Remove the 'v' prefix from the version (if present)
|
||||
--help, -h Show this help message
|
||||
|
||||
Environment Variables (used in CI, checked in priority order):
|
||||
GITHUB_REF_NAME GitHub Actions tag/ref (e.g., "v0.14.0")
|
||||
TRAVIS_TAG Travis CI tag
|
||||
APPVEYOR_REPO_TAG_NAME AppVeyor CI tag
|
||||
|
||||
Version Format:
|
||||
- Release tag: v0.14.0
|
||||
- Dev version: v0.14.0.dev-abc1234
|
||||
- Beta/RC: v0.14.0b1, v0.14.0rc1
|
||||
|
||||
Examples:
|
||||
$SCRIPT_NAME # v0.14.0 or v0.14.0.dev-abc1234
|
||||
$SCRIPT_NAME --strip-v # 0.14.0 or 0.14.0.dev-abc1234
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--strip-v|--no-v)
|
||||
STRIP_V=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1" >&2
|
||||
show_usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
get_version_internal() {
|
||||
local _version=""
|
||||
|
||||
if [[ -n "$GITHUB_REF" && "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
_version="$GITHUB_REF_NAME"
|
||||
elif [[ -n "$TRAVIS_TAG" ]]; then
|
||||
_version="$TRAVIS_TAG"
|
||||
elif [[ -n "$APPVEYOR_REPO_TAG_NAME" ]]; then
|
||||
_version="$APPVEYOR_REPO_TAG_NAME"
|
||||
else
|
||||
_version="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
|
||||
if [[ -z "$_version" ]]; then
|
||||
local _latest_tag
|
||||
# Exclude research-edition tags so dev versions off master don't
|
||||
# inherit a "-research" base (e.g. v0.14.0b4-research.dev-abc1234).
|
||||
# Exact-match above intentionally keeps them: building *on* a
|
||||
# research tag must report the research version.
|
||||
_latest_tag="$(git describe --tags --abbrev=0 --exclude '*-research' 2>/dev/null || echo "v0.0.0")"
|
||||
local _commit_hash
|
||||
_commit_hash="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
_version="${_latest_tag}.dev-${_commit_hash}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$_version"
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
local version
|
||||
version="$(get_version_internal)"
|
||||
|
||||
if $STRIP_V; then
|
||||
version="$(echo "$version" | sed -e 's/^v//')"
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
27
scripts/package/move-to-aw-modules.sh
vendored
Executable file
27
scripts/package/move-to-aw-modules.sh
vendored
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Copy all AW modules to ~/aw-modules/ for aw-tauri to discover.
|
||||
# aw-tauri uses this directory to find and launch AW components.
|
||||
set -e
|
||||
|
||||
mkdir -p ~/aw-modules/
|
||||
|
||||
if [[ -n "$XDG_SESSION_TYPE" && "$XDG_SESSION_TYPE" == "wayland" ]]; then
|
||||
rsync -a . ~/aw-modules/ \
|
||||
--exclude=aw-tauri \
|
||||
--exclude=aw-server-rust \
|
||||
--exclude=awatcher \
|
||||
--exclude=move-to-aw-modules.sh \
|
||||
--exclude=README.txt
|
||||
cp ./awatcher/aw-awatcher ~/aw-modules/
|
||||
cp ./aw-server-rust/aw-sync ~/aw-modules/
|
||||
else
|
||||
rsync -a . ~/aw-modules/ \
|
||||
--exclude=aw-tauri \
|
||||
--exclude=awatcher \
|
||||
--exclude=aw-server-rust \
|
||||
--exclude=move-to-aw-modules.sh \
|
||||
--exclude=README.txt
|
||||
cp ./aw-server-rust/aw-sync ~/aw-modules/
|
||||
fi
|
||||
|
||||
echo "Modules copied to ~/aw-modules/"
|
||||
123
scripts/package/package-all.sh
vendored
Executable file
123
scripts/package/package-all.sh
vendored
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echoerr() { echo "$@" 1>&2; }
|
||||
|
||||
function get_platform() {
|
||||
# Will return "linux" for GNU/Linux
|
||||
# I'd just like to interject for a moment...
|
||||
# https://wiki.installgentoo.com/index.php/Interjection
|
||||
# Will return "macos" for macOS/OS X
|
||||
# Will return "windows" for Windows/MinGW/msys
|
||||
|
||||
_platform=$(uname | tr '[:upper:]' '[:lower:]')
|
||||
if [[ $_platform == "darwin" ]]; then
|
||||
_platform="macos";
|
||||
elif [[ $_platform == "msys"* ]]; then
|
||||
_platform="windows";
|
||||
elif [[ $_platform == "mingw"* ]]; then
|
||||
_platform="windows";
|
||||
elif [[ $_platform == "linux" ]]; then
|
||||
# Nothing to do
|
||||
true;
|
||||
else
|
||||
echoerr "ERROR: $_platform is not a valid platform";
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
echo $_platform;
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
function get_version() {
|
||||
"$SCRIPT_DIR/getversion.sh";
|
||||
}
|
||||
|
||||
function get_version_no_prefix() {
|
||||
"$SCRIPT_DIR/getversion.sh" --strip-v;
|
||||
}
|
||||
|
||||
function get_arch() {
|
||||
_arch="$(uname -m)"
|
||||
echo $_arch;
|
||||
}
|
||||
|
||||
platform=$(get_platform)
|
||||
version=$(get_version)
|
||||
version_no_prefix=$(get_version_no_prefix)
|
||||
arch=$(get_arch)
|
||||
# Research tags are suffixed (e.g. v0.14.0b3-research), but the edition
|
||||
# belongs in its own filename token, not in the version part:
|
||||
# activitywatch[-tauri][-research]-<version>-<os>-<arch>[-setup].<ext>
|
||||
version="${version%-research}"
|
||||
version_no_prefix="${version_no_prefix%-research}"
|
||||
build_suffix=""
|
||||
if [[ $TAURI_BUILD == "true" ]]; then
|
||||
build_suffix="-tauri"
|
||||
fi
|
||||
if [[ $AW_RESEARCH_EDITION == "true" ]]; then
|
||||
build_suffix="${build_suffix}-research"
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo "Build Version Information"
|
||||
echo "========================================"
|
||||
echo "Platform: $platform"
|
||||
echo "Arch: $arch"
|
||||
echo "Version (with v): $version"
|
||||
echo "Version (no v): $version_no_prefix"
|
||||
echo "Tauri build: ${TAURI_BUILD:-false}"
|
||||
echo "========================================"
|
||||
echo
|
||||
|
||||
# For Tauri Linux builds, include helper scripts and README
|
||||
if [[ $platform == "linux" && $TAURI_BUILD == "true" ]]; then
|
||||
cp scripts/package/README.txt scripts/package/move-to-aw-modules.sh dist/activitywatch/
|
||||
fi
|
||||
|
||||
function build_zip() {
|
||||
echo "Zipping executables..."
|
||||
pushd dist;
|
||||
filename="activitywatch${build_suffix}-${version}-${platform}-${arch}.zip"
|
||||
echo "Name of package will be: $filename"
|
||||
|
||||
if [[ $platform == "windows"* ]]; then
|
||||
7z a $filename activitywatch;
|
||||
else
|
||||
zip -r $filename activitywatch;
|
||||
fi
|
||||
popd;
|
||||
echo "Zip built!"
|
||||
}
|
||||
|
||||
function build_setup() {
|
||||
filename="activitywatch${build_suffix}-${version}-${platform}-${arch}-setup.exe"
|
||||
echo "Name of package will be: $filename"
|
||||
|
||||
innosetupdir="/c/Program Files (x86)/Inno Setup 6"
|
||||
if [ ! -d "$innosetupdir" ]; then
|
||||
echo "ERROR: Couldn't find innosetup which is needed to build the installer. We suggest you install it using chocolatey. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $TAURI_BUILD == "true" ]]; then
|
||||
env AW_VERSION=$version_no_prefix "$innosetupdir/iscc.exe" scripts/package/aw-tauri.iss
|
||||
else
|
||||
env AW_VERSION=$version_no_prefix "$innosetupdir/iscc.exe" scripts/package/activitywatch-setup.iss
|
||||
fi
|
||||
"$SCRIPT_DIR/collect-setup.sh" "$filename"
|
||||
echo "Setup built!"
|
||||
}
|
||||
|
||||
build_zip
|
||||
if [[ $platform == "windows"* ]]; then
|
||||
build_setup
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-------------------------------------"
|
||||
echo "Contents of ./dist"
|
||||
ls -l dist
|
||||
echo "-------------------------------------"
|
||||
30
scripts/package/package-appimage.sh
vendored
Executable file
30
scripts/package/package-appimage.sh
vendored
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# pick the latest zip
|
||||
# NOTE: this assumes that the latest built zip is the only zip in the directory
|
||||
ZIP_FILE=`ls ./dist/ -1 | grep zip | sort -r | head -1`
|
||||
unzip ./dist/$ZIP_FILE
|
||||
|
||||
# fetch deps
|
||||
wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
|
||||
chmod +x linuxdeploy-x86_64.AppImage
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
|
||||
# create AppRun
|
||||
echo '#!/bin/sh
|
||||
DIR="$(dirname "$(readlink -f "${0}")")"
|
||||
"${DIR}"/aw-qt "$@"' > activitywatch/AppRun
|
||||
chmod a+x ./activitywatch/AppRun
|
||||
|
||||
# build appimage
|
||||
./linuxdeploy-x86_64.AppImage --appdir activitywatch --executable ./activitywatch/aw-qt --output appimage --desktop-file ./activitywatch/aw-qt.desktop --icon-file ./activitywatch/media/logo/logo.png --icon-filename activitywatch
|
||||
APPIMAGE_FILE=`ls -1 | grep AppImage| grep -i ActivityWatch`
|
||||
# Deliberately unversioned: the AppImage has kept this exact name across
|
||||
# releases so the stable download URL keeps working, e.g.
|
||||
# https://github.com/ActivityWatch/activitywatch/releases/latest/download/activitywatch-linux-x86_64.AppImage
|
||||
EDITION=""
|
||||
if [[ $AW_RESEARCH_EDITION == "true" ]]; then
|
||||
EDITION="-research"
|
||||
fi
|
||||
cp -v $APPIMAGE_FILE ./dist/activitywatch${EDITION}-linux-x86_64.AppImage
|
||||
61
scripts/package/package-deb.sh
vendored
Executable file
61
scripts/package/package-deb.sh
vendored
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
# Fail fast
|
||||
set -e
|
||||
# Verbose commands for CI verification
|
||||
set -x
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="$("$SCRIPT_DIR/getversion.sh")"
|
||||
# Strip the research tag suffix: the edition gets its own filename token, and
|
||||
# a bare "-research" in the Debian version field would parse as a revision.
|
||||
VERSION="${VERSION%-research}"
|
||||
VERSION_NUM="$(echo "$VERSION" | sed -e 's/^v//')"
|
||||
EDITION=""
|
||||
if [[ $AW_RESEARCH_EDITION == "true" ]]; then
|
||||
EDITION="-research"
|
||||
fi
|
||||
echo "Version (with v): $VERSION"
|
||||
echo "Version (without v): $VERSION_NUM"
|
||||
PKGDIR="activitywatch_$VERSION_NUM"
|
||||
|
||||
# Package tools
|
||||
sudo apt-get install sed jdupes wget
|
||||
|
||||
if [ -d "PKGDIR" ]; then
|
||||
sudo rm -rf $PKGDIR
|
||||
fi
|
||||
|
||||
# .deb meta files
|
||||
mkdir -p $PKGDIR/DEBIAN
|
||||
# activitywatch's install location
|
||||
mkdir -p $PKGDIR/opt
|
||||
# Allows aw-qt to autostart.
|
||||
mkdir -p $PKGDIR/etc/xdg/autostart
|
||||
# Allows users to manually start aw-qt from their start menu.
|
||||
mkdir -p $PKGDIR/usr/share/applications
|
||||
|
||||
# While storing the control file in a variable here, dumping it in a file is so unnecessarily
|
||||
# complicated that it's easier to just dump move and sed.
|
||||
cp ./scripts/package/deb/control $PKGDIR/DEBIAN/control
|
||||
sed -i "s/SCRIPT_VERSION_HERE/${VERSION_NUM}/" $PKGDIR/DEBIAN/control
|
||||
|
||||
# Verify the file content
|
||||
cat $PKGDIR/DEBIAN/control
|
||||
# The entire opt directory (should) consist of dist/activitywatch/*
|
||||
|
||||
cp -r dist/activitywatch/ $PKGDIR/opt/
|
||||
|
||||
# Hard link duplicated libraries
|
||||
# (I have no idea what this is for)
|
||||
jdupes -L -r -S -Xsize-:1K $PKGDIR/opt/
|
||||
|
||||
sudo chown -R root:root $PKGDIR
|
||||
|
||||
# Prepare the .desktop file
|
||||
sudo sed -i 's!Exec=aw-qt!Exec=/opt/activitywatch/aw-qt!' $PKGDIR/opt/activitywatch/aw-qt.desktop
|
||||
sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/etc/xdg/autostart/
|
||||
sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/usr/share/applications/
|
||||
|
||||
dpkg-deb --build $PKGDIR
|
||||
sudo mv activitywatch_${VERSION_NUM}.deb dist/activitywatch${EDITION}-${VERSION}-linux-x86_64.deb
|
||||
277
scripts/package/publish_research_feed.py
Normal file
277
scripts/package/publish_research_feed.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = ["semver>=3,<4"]
|
||||
# ///
|
||||
"""Validate and prepare a research feed commit; publish only with --publish."""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from semver import Version
|
||||
|
||||
REPOSITORY = "ActivityWatch/activitywatch"
|
||||
REF = "refs/heads/research-updates"
|
||||
MANIFEST = "latest-research.json"
|
||||
# Keep in step with build-tauri's release matrix. A reduced rollout needs review.
|
||||
TARGETS = {
|
||||
"darwin-aarch64": ("app.tar.gz",),
|
||||
"darwin-x86_64": ("app.tar.gz",),
|
||||
"linux-aarch64": ("AppImage", "AppImage.tar.gz"),
|
||||
"linux-x86_64": ("AppImage", "AppImage.tar.gz"),
|
||||
"windows-x86_64": ("exe", "nsis.zip", "msi", "msi.zip"),
|
||||
}
|
||||
|
||||
|
||||
def public_bytes(url):
|
||||
"""Unauthenticated reads prove the release/signature is publicly available."""
|
||||
request = Request(url, headers={"User-Agent": "ActivityWatch-research-publisher"})
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def validate_manifest(manifest, release, tag, fetch=None):
|
||||
if fetch is None:
|
||||
fetch = public_bytes
|
||||
if set(manifest) != {"version", "notes", "pub_date", "platforms"}:
|
||||
raise ValueError("Expected only the four static updater manifest fields")
|
||||
if not isinstance(manifest["notes"], str):
|
||||
raise ValueError("Manifest notes must be a string")
|
||||
date = manifest["pub_date"]
|
||||
if not isinstance(date, str) or not re.fullmatch(
|
||||
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])",
|
||||
date,
|
||||
):
|
||||
raise ValueError("Manifest pub_date must be an RFC3339 timestamp")
|
||||
datetime.fromisoformat(date.replace("Z", "+00:00"))
|
||||
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?-research", tag):
|
||||
raise ValueError("Expected a versioned research release tag")
|
||||
raw_version = tag[1 : -len("-research")]
|
||||
expected_version = re.sub(
|
||||
r"(a|b|rc)([0-9]+)$",
|
||||
lambda m: "-" + {"a": "alpha", "b": "beta", "rc": "rc"}[m[1]] + "." + m[2],
|
||||
raw_version,
|
||||
)
|
||||
Version.parse(expected_version)
|
||||
if manifest["version"] != expected_version:
|
||||
raise ValueError("Manifest version must match the normalized research tag")
|
||||
if (
|
||||
release["tag_name"] != tag
|
||||
or release["draft"] is not False
|
||||
or not release["published_at"]
|
||||
or release.get("immutable") is not True
|
||||
):
|
||||
raise ValueError(
|
||||
"Release must be published, immutable, non-draft, and match the tag"
|
||||
)
|
||||
if not isinstance(manifest["platforms"], dict) or set(manifest["platforms"]) != set(
|
||||
TARGETS
|
||||
):
|
||||
raise ValueError(
|
||||
"Manifest must contain exactly the complete required target matrix"
|
||||
)
|
||||
assets = {asset["name"]: asset for asset in release["assets"]}
|
||||
if len(assets) != len(release["assets"]):
|
||||
raise ValueError("Duplicate release asset names")
|
||||
base_url = f"https://github.com/{REPOSITORY}/releases/download/{tag}/"
|
||||
for target, entry in manifest["platforms"].items():
|
||||
if set(entry) != {"url", "signature"}:
|
||||
raise ValueError(f"Expected only url and signature for {target}")
|
||||
prefix = f"activitywatch-tauri-research-{raw_version}-{target}."
|
||||
names = [prefix + ext for ext in TARGETS[target]]
|
||||
matches = [name for name in names if entry["url"] == base_url + name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"Wrong edition, version, release, or bundle format for {target}"
|
||||
)
|
||||
name = matches[0]
|
||||
for asset_name in (name, name + ".sig"):
|
||||
asset = assets.get(asset_name)
|
||||
if (
|
||||
not asset
|
||||
or asset["state"] != "uploaded"
|
||||
or asset["size"] <= 0
|
||||
or asset["browser_download_url"] != base_url + asset_name
|
||||
):
|
||||
raise ValueError(
|
||||
f"Missing or invalid uploaded release asset: {asset_name}"
|
||||
)
|
||||
signature = entry["signature"]
|
||||
if (
|
||||
not isinstance(signature, str)
|
||||
or not signature.strip()
|
||||
or signature != fetch(base_url + name + ".sig").decode("utf-8").strip()
|
||||
):
|
||||
raise ValueError(f"Missing or mismatched signature for {target}")
|
||||
|
||||
|
||||
def git(repo, *args, input=None):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
input=input,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def remote_parent(repo, remote):
|
||||
result = git(repo, "ls-remote", "--refs", remote, REF)
|
||||
return result.split()[0] if result else "absent"
|
||||
|
||||
|
||||
def prepare(repo, remote, expected_parent, manifest):
|
||||
"""Create a new isolated checkout, retaining all existing feed branch files."""
|
||||
repo.mkdir(parents=True, exist_ok=False)
|
||||
git(repo, "init", "--quiet")
|
||||
if remote_parent(repo, remote) != expected_parent:
|
||||
raise ValueError("Stale expected parent; reread the feed and retry validation")
|
||||
if expected_parent != "absent":
|
||||
git(repo, "fetch", "--no-tags", remote, REF)
|
||||
if git(repo, "rev-parse", "FETCH_HEAD") != expected_parent:
|
||||
raise ValueError("Feed changed during fetch; reread and retry")
|
||||
git(repo, "checkout", "--detach", expected_parent)
|
||||
current = json.loads(git(repo, "show", f"{expected_parent}:{MANIFEST}"))
|
||||
old, new = Version.parse(current["version"]), Version.parse(manifest["version"])
|
||||
if new < old:
|
||||
raise ValueError("Refusing research feed version regression")
|
||||
if new == old:
|
||||
if manifest != current:
|
||||
raise ValueError("Conflicting content at equal SemVer precedence")
|
||||
return None
|
||||
else:
|
||||
git(repo, "checkout", "--orphan", "research-updates")
|
||||
# Replace any old symlink rather than following a path outside this checkout.
|
||||
path = repo / MANIFEST
|
||||
if path.is_symlink():
|
||||
path.unlink()
|
||||
path.write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
git(repo, "add", "--", MANIFEST)
|
||||
git(
|
||||
repo,
|
||||
"commit",
|
||||
"-m",
|
||||
f"chore(updater): offer research {manifest['version']}",
|
||||
"--",
|
||||
MANIFEST,
|
||||
)
|
||||
return git(repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def publish(repo, remote, expected_parent, commit):
|
||||
"""Assert the advertised parent in pre-push, then use Git's normal FF/CAS."""
|
||||
if git(repo, "rev-list", "--parents", "-n", "1", commit).split() != (
|
||||
[commit] if expected_parent == "absent" else [commit, expected_parent]
|
||||
):
|
||||
raise ValueError("Candidate must be one commit on the expected parent")
|
||||
original_hook = Path(git(repo, "rev-parse", "--git-path", "hooks/pre-push"))
|
||||
if not original_hook.is_absolute():
|
||||
original_hook = repo / original_hook
|
||||
expected_oid = "0" * len(commit) if expected_parent == "absent" else expected_parent
|
||||
# Git sends the same advertised old OID to receive-pack, which checks it
|
||||
# atomically. Checking only ls-remote before push would leave a race window
|
||||
# for branch deletion/rewind. Preserve the site's existing pre-push policy.
|
||||
with tempfile.TemporaryDirectory(prefix="aw-feed-push-") as directory:
|
||||
hook = Path(directory) / "pre-push"
|
||||
hook.write_text(
|
||||
"#!/bin/sh\nexec "
|
||||
+ shlex.quote(sys.executable)
|
||||
+ " -c "
|
||||
+ shlex.quote(
|
||||
"import os, subprocess, sys\n"
|
||||
"data = sys.stdin.read()\n"
|
||||
"rows = [line.split() for line in data.splitlines()]\n"
|
||||
f"expected = {expected_oid!r}\n"
|
||||
f"if len(rows) != 1 or rows[0][1:] != [{commit!r}, {REF!r}, expected]:\n"
|
||||
" sys.exit('Feed parent changed during push; reread and retry')\n"
|
||||
f"hook = {str(original_hook)!r}\n"
|
||||
"if os.access(hook, os.X_OK):\n"
|
||||
" sys.exit(subprocess.run([hook, *sys.argv[1:]], input=data, text=True).returncode)\n"
|
||||
)
|
||||
+ ' "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
hook.chmod(0o755)
|
||||
git(
|
||||
repo,
|
||||
"-c",
|
||||
f"core.hooksPath={directory}",
|
||||
"-c",
|
||||
"push.followTags=false",
|
||||
"push",
|
||||
"--porcelain",
|
||||
remote,
|
||||
f"{commit}:{REF}",
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", required=True, type=Path)
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument(
|
||||
"--expected-parent",
|
||||
required=True,
|
||||
help="Full feed SHA, or 'absent' for provisioning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--work-dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="New directory to retain the reviewable commit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--publish",
|
||||
action="store_true",
|
||||
help="Explicitly push after validation and preparation",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
remote = f"https://github.com/{REPOSITORY}.git"
|
||||
try:
|
||||
if args.expected_parent != "absent" and not re.fullmatch(
|
||||
r"[0-9a-f]{40}", args.expected_parent
|
||||
):
|
||||
raise ValueError("Expected a full parent SHA or 'absent'")
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||
release = json.loads(
|
||||
public_bytes(
|
||||
f"https://api.github.com/repos/{REPOSITORY}/releases/tags/{quote(args.tag, safe='')}"
|
||||
)
|
||||
)
|
||||
validate_manifest(manifest, release, args.tag)
|
||||
repo = args.work_dir.resolve()
|
||||
commit = prepare(repo, remote, args.expected_parent, manifest)
|
||||
if commit is None:
|
||||
print("NO-OP: identical manifest already published")
|
||||
elif args.publish:
|
||||
publish(repo, remote, args.expected_parent, commit)
|
||||
print(f"Published {commit} on {REF}")
|
||||
else:
|
||||
print(f"Prepared {commit} in {repo}; remote unchanged (no --publish)")
|
||||
except (
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
OSError,
|
||||
subprocess.CalledProcessError,
|
||||
) as exc:
|
||||
detail = (
|
||||
exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc)
|
||||
)
|
||||
parser.exit(1, f"ERROR: {detail}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
52
scripts/patch_research_edition_awqt.py
Executable file
52
scripts/patch_research_edition_awqt.py
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch aw-qt's default config for Research Edition builds.
|
||||
|
||||
Flips `autostart_on_first_run` from false to true in aw_qt/config.py's
|
||||
default_config, so participant machines register start-at-login on first
|
||||
launch (aw-qt#131) without any setup steps. The user can still disable it
|
||||
from the tray afterwards; aw-qt never re-enables once its marker is written.
|
||||
|
||||
Run as part of the CI build for research edition:
|
||||
python3 scripts/patch_research_edition_awqt.py <path/to/aw_qt/config.py>
|
||||
|
||||
Fails closed if the key is absent, which means the aw-qt submodule pin
|
||||
predates aw-qt#131 — bump the pin rather than shipping a research build
|
||||
that silently loses reboot-survival.
|
||||
"""
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
OLD = "autostart_on_first_run = false"
|
||||
NEW = "autostart_on_first_run = true"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print(f"usage: {sys.argv[0]} <path/to/aw_qt/config.py>", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
try:
|
||||
text = path.read_text()
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: {path} not found - is the aw-qt submodule checked out?", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Only patch the first occurrence: the [aw-qt] section default. The
|
||||
# [aw-qt-testing] section keeps autostart off.
|
||||
if OLD not in text:
|
||||
print(
|
||||
f"ERROR: {OLD!r} not found in {path} - aw-qt submodule pin "
|
||||
"predates aw-qt#131, refusing to build research edition without "
|
||||
"first-run autostart",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
path.write_text(text.replace(OLD, NEW, 1))
|
||||
print(f"Patched {path}: {OLD} -> {NEW} (first occurrence)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
825
scripts/patch_research_edition_config.py
Normal file
825
scripts/patch_research_edition_config.py
Normal file
@@ -0,0 +1,825 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch aw-watcher-window/config.py with Research Edition browser categories.
|
||||
|
||||
Run as part of the CI build for research edition:
|
||||
python3 scripts/patch_research_edition_config.py <path/to/config.py>
|
||||
|
||||
CATEGORY_MAP — browser URL/title substring matching:
|
||||
classify_title() in PR #130 checks each pattern against the URL first (when
|
||||
available), then the window title. Ordering is critical: first match wins.
|
||||
Sensitive exclusions must come first. More specific domains before general
|
||||
(music.youtube.com before youtube.com). Video domains before News title keywords
|
||||
(svtplay.se domain before the "svt" title keyword).
|
||||
|
||||
APP_CATEGORY_MAP is retained as the shared taxonomy for the web UI preset, but
|
||||
is deliberately not injected into the watcher. The approved study contract keeps
|
||||
application names (including browser identity) while discarding raw titles and
|
||||
URLs. An empty watcher app map selects exactly that behavior.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
CONFIG_FILE = pathlib.Path(
|
||||
sys.argv[1]
|
||||
if len(sys.argv) > 1
|
||||
else "aw-watcher-window/aw_watcher_window/config.py"
|
||||
)
|
||||
|
||||
# Combined category map derived faithfully from Matthias Lehner's classifier.
|
||||
# Structure: sensitive domains first, then music/video domains (specificity order),
|
||||
# then travel/search/other domains, then title keywords in TITLE_RULES priority.
|
||||
CATEGORY_MAP: list[tuple[str, str]] = [
|
||||
# Sensitive / Excluded (MUST be first)
|
||||
("svenskaspel.se", "Sensitive / Excluded"),
|
||||
("unibet.se", "Sensitive / Excluded"),
|
||||
("bet365.com", "Sensitive / Excluded"),
|
||||
("pokerstars.com", "Sensitive / Excluded"),
|
||||
("casino.org", "Sensitive / Excluded"),
|
||||
("tinder.com", "Sensitive / Excluded"),
|
||||
("bumble.com", "Sensitive / Excluded"),
|
||||
("grindr.com", "Sensitive / Excluded"),
|
||||
("match.com", "Sensitive / Excluded"),
|
||||
# Music & Audio domains (music.youtube.com before youtube.com; sverigesradio.se before svt.se)
|
||||
("music.youtube.com", "Music & Audio"),
|
||||
("youtube.com/music", "Music & Audio"),
|
||||
("open.spotify.com", "Music & Audio"),
|
||||
("spotify.com", "Music & Audio"),
|
||||
("music.apple.com", "Music & Audio"),
|
||||
("soundcloud.com", "Music & Audio"),
|
||||
("podme.com", "Music & Audio"),
|
||||
("bookbeat.se", "Music & Audio"),
|
||||
("storytel.com", "Music & Audio"),
|
||||
("audible.com", "Music & Audio"),
|
||||
("sverigesradio.se", "Music & Audio"),
|
||||
("sr.se", "Music & Audio"),
|
||||
("radio.se", "Music & Audio"),
|
||||
# Video Streaming domains (svtplay.se, svt.se/play before svt.se; tv4.se→Video per Matthias dict)
|
||||
("svt.se/play", "Video Streaming"),
|
||||
("svtplay.se", "Video Streaming"),
|
||||
("tv4play.se", "Video Streaming"),
|
||||
("tv4.se", "Video Streaming"),
|
||||
("youtube.com", "Video Streaming"),
|
||||
("youtu.be", "Video Streaming"),
|
||||
("netflix.com", "Video Streaming"),
|
||||
("viaplay.se", "Video Streaming"),
|
||||
("max.com", "Video Streaming"),
|
||||
("hbomax.com", "Video Streaming"),
|
||||
("primevideo.com", "Video Streaming"),
|
||||
("disneyplus.com", "Video Streaming"),
|
||||
("twitch.tv/directory/game", "Games"),
|
||||
("twitch.tv", "Video Streaming"),
|
||||
("vimeo.com", "Video Streaming"),
|
||||
("dailymotion.com", "Video Streaming"),
|
||||
("kanopy.com", "Video Streaming"),
|
||||
("dr.dk", "Video Streaming"),
|
||||
("nrk.no", "Video Streaming"),
|
||||
# Travel & Mobility (maps before google.com)
|
||||
("maps.google.com", "Travel & Mobility"),
|
||||
("google.com/maps", "Travel & Mobility"),
|
||||
("sj.se", "Travel & Mobility"),
|
||||
("sl.se", "Travel & Mobility"),
|
||||
("vasttrafik.se", "Travel & Mobility"),
|
||||
("skanetrafiken.se", "Travel & Mobility"),
|
||||
("resrobot.se", "Travel & Mobility"),
|
||||
("flixbus.se", "Travel & Mobility"),
|
||||
("sas.se", "Travel & Mobility"),
|
||||
("flysas.com", "Travel & Mobility"),
|
||||
("norwegian.com", "Travel & Mobility"),
|
||||
("ryanair.com", "Travel & Mobility"),
|
||||
("airbnb.com", "Travel & Mobility"),
|
||||
("booking.com", "Travel & Mobility"),
|
||||
("hotels.com", "Travel & Mobility"),
|
||||
("tripadvisor.com", "Travel & Mobility"),
|
||||
("uber.com", "Travel & Mobility"),
|
||||
("bolt.eu", "Travel & Mobility"),
|
||||
("parkster.com", "Travel & Mobility"),
|
||||
("easypark.com", "Travel & Mobility"),
|
||||
("circlek.se", "Travel & Mobility"),
|
||||
("okq8.se", "Travel & Mobility"),
|
||||
("preem.se", "Travel & Mobility"),
|
||||
# Search & Navigation (google.com after maps.google.com captured above)
|
||||
("google.com", "Search & Navigation"),
|
||||
("google.se", "Search & Navigation"),
|
||||
("bing.com", "Search & Navigation"),
|
||||
("duckduckgo.com", "Search & Navigation"),
|
||||
("yahoo.com", "Search & Navigation"),
|
||||
("startpage.com", "Search & Navigation"),
|
||||
("ecosia.org", "Search & Navigation"),
|
||||
("kagi.com", "Search & Navigation"),
|
||||
("brave.com/search", "Search & Navigation"),
|
||||
# News & Current Affairs (svt.se after svt.se/play and svtplay.se)
|
||||
("svt.se", "News & Current Affairs"),
|
||||
("dn.se", "News & Current Affairs"),
|
||||
("dagensnyheter.se", "News & Current Affairs"),
|
||||
("svd.se", "News & Current Affairs"),
|
||||
("aftonbladet.se", "News & Current Affairs"),
|
||||
("expressen.se", "News & Current Affairs"),
|
||||
("gp.se", "News & Current Affairs"),
|
||||
("goteborgsposten.se", "News & Current Affairs"),
|
||||
("sydsvenskan.se", "News & Current Affairs"),
|
||||
("hd.se", "News & Current Affairs"),
|
||||
("unt.se", "News & Current Affairs"),
|
||||
("vk.se", "News & Current Affairs"),
|
||||
("nwt.se", "News & Current Affairs"),
|
||||
("corren.se", "News & Current Affairs"),
|
||||
("omni.se", "News & Current Affairs"),
|
||||
("di.se", "News & Current Affairs"),
|
||||
("dagensindustri.se", "News & Current Affairs"),
|
||||
("breakit.se", "News & Current Affairs"),
|
||||
("nyteknik.se", "News & Current Affairs"),
|
||||
("theguardian.com", "News & Current Affairs"),
|
||||
("bbc.com", "News & Current Affairs"),
|
||||
("bbc.co.uk", "News & Current Affairs"),
|
||||
("cnn.com", "News & Current Affairs"),
|
||||
("edition.cnn.com", "News & Current Affairs"),
|
||||
("aljazeera.com", "News & Current Affairs"),
|
||||
("reuters.com", "News & Current Affairs"),
|
||||
("apnews.com", "News & Current Affairs"),
|
||||
("ft.com", "News & Current Affairs"),
|
||||
("economist.com", "News & Current Affairs"),
|
||||
# Social Networking
|
||||
("facebook.com", "Social Networking"),
|
||||
("instagram.com", "Social Networking"),
|
||||
("threads.net", "Social Networking"),
|
||||
("x.com", "Social Networking"),
|
||||
("twitter.com", "Social Networking"),
|
||||
("linkedin.com", "Social Networking"),
|
||||
("mastodon.social", "Social Networking"),
|
||||
("bsky.app", "Social Networking"),
|
||||
("blueskyweb.xyz", "Social Networking"),
|
||||
("bere.al", "Social Networking"),
|
||||
("snapchat.com", "Social Networking"),
|
||||
("pinterest.com", "Social Networking"),
|
||||
# Messaging (specific subdomains before parent)
|
||||
("web.whatsapp.com", "Messaging"),
|
||||
("web.telegram.org", "Messaging"),
|
||||
("chat.google.com", "Messaging"),
|
||||
("messages.google.com", "Messaging"),
|
||||
("whatsapp.com", "Messaging"),
|
||||
("messenger.com", "Messaging"),
|
||||
("telegram.org", "Messaging"),
|
||||
("signal.org", "Messaging"),
|
||||
("discord.com", "Messaging"),
|
||||
("discordapp.com", "Messaging"),
|
||||
("slack.com", "Messaging"),
|
||||
# Email (specific subdomains before parent)
|
||||
("mail.google.com", "Email"),
|
||||
("icloud.com/mail", "Email"),
|
||||
("zoho.com/mail", "Email"),
|
||||
("outlook.live.com", "Email"),
|
||||
("outlook.office.com", "Email"),
|
||||
("outlook.office365.com", "Email"),
|
||||
("gmail.com", "Email"),
|
||||
("hotmail.com", "Email"),
|
||||
("live.com", "Email"),
|
||||
("mail.yahoo.com", "Email"),
|
||||
("proton.me", "Email"),
|
||||
("protonmail.com", "Email"),
|
||||
("fastmail.com", "Email"),
|
||||
# AI Chatbots & Assistants
|
||||
("chat.openai.com", "AI Chatbots & Assistants"),
|
||||
("huggingface.co/chat", "AI Chatbots & Assistants"),
|
||||
("chat.mistral.ai", "AI Chatbots & Assistants"),
|
||||
("gemini.google.com", "AI Chatbots & Assistants"),
|
||||
("notebooklm.google.com", "AI Chatbots & Assistants"),
|
||||
("copilot.microsoft.com", "AI Chatbots & Assistants"),
|
||||
("bard.google.com", "AI Chatbots & Assistants"),
|
||||
("chatgpt.com", "AI Chatbots & Assistants"),
|
||||
("openai.com", "AI Chatbots & Assistants"),
|
||||
("claude.ai", "AI Chatbots & Assistants"),
|
||||
("anthropic.com", "AI Chatbots & Assistants"),
|
||||
("perplexity.ai", "AI Chatbots & Assistants"),
|
||||
("poe.com", "AI Chatbots & Assistants"),
|
||||
("you.com", "AI Chatbots & Assistants"),
|
||||
("mistral.ai", "AI Chatbots & Assistants"),
|
||||
("character.ai", "AI Chatbots & Assistants"),
|
||||
("meta.ai", "AI Chatbots & Assistants"),
|
||||
("grok.com", "AI Chatbots & Assistants"),
|
||||
("x.ai", "AI Chatbots & Assistants"),
|
||||
# Work & Productivity
|
||||
("teams.microsoft.com", "Work & Productivity"),
|
||||
("meet.google.com", "Work & Productivity"),
|
||||
("calendar.google.com", "Work & Productivity"),
|
||||
("docs.google.com", "Work & Productivity"),
|
||||
("drive.google.com", "Work & Productivity"),
|
||||
("translate.google.com", "Work & Productivity"),
|
||||
("acrobat.adobe.com", "Work & Productivity"),
|
||||
("onedrive.live.com", "Work & Productivity"),
|
||||
("zoom.us", "Work & Productivity"),
|
||||
("office.com", "Work & Productivity"),
|
||||
("microsoft365.com", "Work & Productivity"),
|
||||
("sharepoint.com", "Work & Productivity"),
|
||||
("dropbox.com", "Work & Productivity"),
|
||||
("notion.so", "Work & Productivity"),
|
||||
("trello.com", "Work & Productivity"),
|
||||
("asana.com", "Work & Productivity"),
|
||||
("monday.com", "Work & Productivity"),
|
||||
("miro.com", "Work & Productivity"),
|
||||
("figma.com", "Work & Productivity"),
|
||||
("canva.com", "Work & Productivity"),
|
||||
("overleaf.com", "Work & Productivity"),
|
||||
("deepl.com", "Work & Productivity"),
|
||||
("adobe.com", "Work & Productivity"),
|
||||
("github.com", "Work & Productivity"),
|
||||
("gitlab.com", "Work & Productivity"),
|
||||
("bitbucket.org", "Work & Productivity"),
|
||||
("atlassian.net", "Work & Productivity"),
|
||||
("jira.com", "Work & Productivity"),
|
||||
("confluence.com", "Work & Productivity"),
|
||||
# Education & Learning
|
||||
("stackoverflow.com", "Education & Learning"),
|
||||
("stackexchange.com", "Education & Learning"),
|
||||
("wikipedia.org", "Education & Learning"),
|
||||
("canvaslms.com", "Education & Learning"),
|
||||
("instructure.com", "Education & Learning"),
|
||||
("moodle.org", "Education & Learning"),
|
||||
("blackboard.com", "Education & Learning"),
|
||||
("ladok.se", "Education & Learning"),
|
||||
("antagning.se", "Education & Learning"),
|
||||
("studera.nu", "Education & Learning"),
|
||||
("coursera.org", "Education & Learning"),
|
||||
("edx.org", "Education & Learning"),
|
||||
("khanacademy.org", "Education & Learning"),
|
||||
("duolingo.com", "Education & Learning"),
|
||||
("quizlet.com", "Education & Learning"),
|
||||
("researchgate.net", "Education & Learning"),
|
||||
("academia.edu", "Education & Learning"),
|
||||
("sciencedirect.com", "Education & Learning"),
|
||||
("springer.com", "Education & Learning"),
|
||||
("wiley.com", "Education & Learning"),
|
||||
("tandfonline.com", "Education & Learning"),
|
||||
("jstor.org", "Education & Learning"),
|
||||
("sagepub.com", "Education & Learning"),
|
||||
("mdpi.com", "Education & Learning"),
|
||||
("frontiersin.org", "Education & Learning"),
|
||||
# Shopping - Goods
|
||||
("amazon.se", "Shopping - Goods"),
|
||||
("amazon.com", "Shopping - Goods"),
|
||||
("blocket.se", "Shopping - Goods"),
|
||||
("tradera.com", "Shopping - Goods"),
|
||||
("prisjakt.nu", "Shopping - Goods"),
|
||||
("pricerunner.se", "Shopping - Goods"),
|
||||
("elgiganten.se", "Shopping - Goods"),
|
||||
("netonnet.se", "Shopping - Goods"),
|
||||
("mediamarkt.se", "Shopping - Goods"),
|
||||
("inet.se", "Shopping - Goods"),
|
||||
("webhallen.com", "Shopping - Goods"),
|
||||
("komplett.se", "Shopping - Goods"),
|
||||
("zalando.se", "Shopping - Goods"),
|
||||
("hm.com", "Shopping - Goods"),
|
||||
("arket.com", "Shopping - Goods"),
|
||||
("cos.com", "Shopping - Goods"),
|
||||
("ellos.se", "Shopping - Goods"),
|
||||
("boozt.com", "Shopping - Goods"),
|
||||
("ikea.com", "Shopping - Goods"),
|
||||
("clasohlson.com", "Shopping - Goods"),
|
||||
("jula.se", "Shopping - Goods"),
|
||||
("biltema.se", "Shopping - Goods"),
|
||||
("apotea.se", "Shopping - Goods"),
|
||||
("apoteket.se", "Shopping - Goods"),
|
||||
("kronansapotek.se", "Shopping - Goods"),
|
||||
("lyko.com", "Shopping - Goods"),
|
||||
("cdon.se", "Shopping - Goods"),
|
||||
("adlibris.com", "Shopping - Goods"),
|
||||
("bokus.com", "Shopping - Goods"),
|
||||
("wish.com", "Shopping - Goods"),
|
||||
("temu.com", "Shopping - Goods"),
|
||||
("aliexpress.com", "Shopping - Goods"),
|
||||
# Shopping - Groceries & Food
|
||||
("ica.se", "Shopping - Groceries & Food"),
|
||||
("coop.se", "Shopping - Groceries & Food"),
|
||||
("willys.se", "Shopping - Groceries & Food"),
|
||||
("hemkop.se", "Shopping - Groceries & Food"),
|
||||
("citygross.se", "Shopping - Groceries & Food"),
|
||||
("mathem.se", "Shopping - Groceries & Food"),
|
||||
("foodora.se", "Shopping - Groceries & Food"),
|
||||
("wolt.com", "Shopping - Groceries & Food"),
|
||||
("ubereats.com", "Shopping - Groceries & Food"),
|
||||
("hellofresh.se", "Shopping - Groceries & Food"),
|
||||
("linasmatkasse.se", "Shopping - Groceries & Food"),
|
||||
("simplefeast.com", "Shopping - Groceries & Food"),
|
||||
("kavall.co", "Shopping - Groceries & Food"),
|
||||
# Banking & Finance
|
||||
("swedbank.se", "Banking & Finance"),
|
||||
("seb.se", "Banking & Finance"),
|
||||
("handelsbanken.se", "Banking & Finance"),
|
||||
("nordea.se", "Banking & Finance"),
|
||||
("lansforsakringar.se", "Banking & Finance"),
|
||||
("skandia.se", "Banking & Finance"),
|
||||
("ica-banken.se", "Banking & Finance"),
|
||||
("danskebank.se", "Banking & Finance"),
|
||||
("avanza.se", "Banking & Finance"),
|
||||
("nordnet.se", "Banking & Finance"),
|
||||
("klarna.com", "Banking & Finance"),
|
||||
("paypal.com", "Banking & Finance"),
|
||||
("revolut.com", "Banking & Finance"),
|
||||
("wise.com", "Banking & Finance"),
|
||||
("zettle.com", "Banking & Finance"),
|
||||
("fortnox.se", "Banking & Finance"),
|
||||
("vismaspcs.se", "Banking & Finance"),
|
||||
# Public Services
|
||||
("skatteverket.se", "Public Services"),
|
||||
("forsakringskassan.se", "Public Services"),
|
||||
("pensionsmyndigheten.se", "Public Services"),
|
||||
("arbetsformedlingen.se", "Public Services"),
|
||||
("1177.se", "Public Services"),
|
||||
("bankid.com", "Public Services"),
|
||||
("minmyndighetspost.se", "Public Services"),
|
||||
("kivra.se", "Public Services"),
|
||||
("polisen.se", "Public Services"),
|
||||
("migrationsverket.se", "Public Services"),
|
||||
("csn.se", "Public Services"),
|
||||
("trafikverket.se", "Public Services"),
|
||||
("transportstyrelsen.se", "Public Services"),
|
||||
("lantmateriet.se", "Public Services"),
|
||||
("bolagsverket.se", "Public Services"),
|
||||
("verksamt.se", "Public Services"),
|
||||
("stockholm.se", "Public Services"),
|
||||
("goteborg.se", "Public Services"),
|
||||
("malmo.se", "Public Services"),
|
||||
("uppsala.se", "Public Services"),
|
||||
("umea.se", "Public Services"),
|
||||
("lund.se", "Public Services"),
|
||||
# Games
|
||||
("steampowered.com", "Games"),
|
||||
("steamcommunity.com", "Games"),
|
||||
("epicgames.com", "Games"),
|
||||
("battle.net", "Games"),
|
||||
("xbox.com", "Games"),
|
||||
("playstation.com", "Games"),
|
||||
("roblox.com", "Games"),
|
||||
("minecraft.net", "Games"),
|
||||
("chess.com", "Games"),
|
||||
("lichess.org", "Games"),
|
||||
("poki.com", "Games"),
|
||||
("miniclip.com", "Games"),
|
||||
("agar.io", "Games"),
|
||||
("forgeofempires.com", "Games"),
|
||||
("catanuniverse.com", "Games"),
|
||||
# --- TITLE KEYWORDS (ordered by TITLE_RULES priority) ---
|
||||
# Sensitive / Excluded title keywords
|
||||
("svenska spel", "Sensitive / Excluded"),
|
||||
("unibet", "Sensitive / Excluded"),
|
||||
("bet365", "Sensitive / Excluded"),
|
||||
("pokerstars", "Sensitive / Excluded"),
|
||||
("casino", "Sensitive / Excluded"),
|
||||
("tinder", "Sensitive / Excluded"),
|
||||
("bumble", "Sensitive / Excluded"),
|
||||
("grindr", "Sensitive / Excluded"),
|
||||
# Travel & Mobility title keywords
|
||||
("google maps", "Travel & Mobility"),
|
||||
("sl reseplanerare", "Travel & Mobility"),
|
||||
("vasttraffik", "Travel & Mobility"),
|
||||
("vasttrafik", "Travel & Mobility"),
|
||||
("skanetrafiken", "Travel & Mobility"),
|
||||
("resrobot", "Travel & Mobility"),
|
||||
("ryanair", "Travel & Mobility"),
|
||||
("booking.com", "Travel & Mobility"),
|
||||
("airbnb", "Travel & Mobility"),
|
||||
("hotels.com", "Travel & Mobility"),
|
||||
("tripadvisor", "Travel & Mobility"),
|
||||
("easypark", "Travel & Mobility"),
|
||||
("parkster", "Travel & Mobility"),
|
||||
("norwegian", "Travel & Mobility"),
|
||||
("flixbus", "Travel & Mobility"),
|
||||
("uber eats", "Shopping - Groceries & Food"),
|
||||
("uber", "Travel & Mobility"),
|
||||
("bolt", "Travel & Mobility"),
|
||||
("maps", "Travel & Mobility"),
|
||||
("sas", "Travel & Mobility"),
|
||||
("sj", "Travel & Mobility"),
|
||||
# Shopping - Groceries title keywords
|
||||
("ica", "Shopping - Groceries & Food"),
|
||||
("coop", "Shopping - Groceries & Food"),
|
||||
("willys", "Shopping - Groceries & Food"),
|
||||
("hemköp", "Shopping - Groceries & Food"),
|
||||
("hemkop", "Shopping - Groceries & Food"),
|
||||
("city gross", "Shopping - Groceries & Food"),
|
||||
("mathem", "Shopping - Groceries & Food"),
|
||||
("foodora", "Shopping - Groceries & Food"),
|
||||
("wolt", "Shopping - Groceries & Food"),
|
||||
("hellofresh", "Shopping - Groceries & Food"),
|
||||
("linas matkasse", "Shopping - Groceries & Food"),
|
||||
# Shopping - Goods title keywords
|
||||
("amazon", "Shopping - Goods"),
|
||||
("blocket", "Shopping - Goods"),
|
||||
("tradera", "Shopping - Goods"),
|
||||
("prisjakt", "Shopping - Goods"),
|
||||
("pricerunner", "Shopping - Goods"),
|
||||
("elgiganten", "Shopping - Goods"),
|
||||
("netonnet", "Shopping - Goods"),
|
||||
("webhallen", "Shopping - Goods"),
|
||||
("komplett", "Shopping - Goods"),
|
||||
("zalando", "Shopping - Goods"),
|
||||
("h&m", "Shopping - Goods"),
|
||||
("ikea", "Shopping - Goods"),
|
||||
("clas ohlson", "Shopping - Goods"),
|
||||
("jula", "Shopping - Goods"),
|
||||
("biltema", "Shopping - Goods"),
|
||||
("apotea", "Shopping - Goods"),
|
||||
("apoteket", "Shopping - Goods"),
|
||||
("kronans apotek", "Shopping - Goods"),
|
||||
("lyko", "Shopping - Goods"),
|
||||
("cdon", "Shopping - Goods"),
|
||||
("adlibris", "Shopping - Goods"),
|
||||
("bokus", "Shopping - Goods"),
|
||||
("temu", "Shopping - Goods"),
|
||||
("aliexpress", "Shopping - Goods"),
|
||||
("inet", "Shopping - Goods"),
|
||||
# Banking & Finance title keywords
|
||||
("swedbank", "Banking & Finance"),
|
||||
("handelsbanken", "Banking & Finance"),
|
||||
("länsförsäkringar", "Banking & Finance"),
|
||||
("lansforsakringar", "Banking & Finance"),
|
||||
("skandia", "Banking & Finance"),
|
||||
("ica banken", "Banking & Finance"),
|
||||
("avanza", "Banking & Finance"),
|
||||
("nordnet", "Banking & Finance"),
|
||||
("klarna", "Banking & Finance"),
|
||||
("paypal", "Banking & Finance"),
|
||||
("revolut", "Banking & Finance"),
|
||||
("wise", "Banking & Finance"),
|
||||
("fortnox", "Banking & Finance"),
|
||||
("visma", "Banking & Finance"),
|
||||
("nordea", "Banking & Finance"),
|
||||
("seb", "Banking & Finance"),
|
||||
# Public Services title keywords
|
||||
("skatteverket", "Public Services"),
|
||||
("försäkringskassan", "Public Services"),
|
||||
("forsakringskassan", "Public Services"),
|
||||
("pensionsmyndigheten", "Public Services"),
|
||||
("arbetsförmedlingen", "Public Services"),
|
||||
("arbetsformedlingen", "Public Services"),
|
||||
("1177", "Public Services"),
|
||||
("bankid", "Public Services"),
|
||||
("min myndighetspost", "Public Services"),
|
||||
("kivra", "Public Services"),
|
||||
("polisen", "Public Services"),
|
||||
("migrationsverket", "Public Services"),
|
||||
("csn", "Public Services"),
|
||||
("trafikverket", "Public Services"),
|
||||
("transportstyrelsen", "Public Services"),
|
||||
("lantmäteriet", "Public Services"),
|
||||
("lantmateriet", "Public Services"),
|
||||
("bolagsverket", "Public Services"),
|
||||
("verksamt", "Public Services"),
|
||||
# AI Chatbots & Assistants title keywords
|
||||
("chatgpt", "AI Chatbots & Assistants"),
|
||||
("chat.openai", "AI Chatbots & Assistants"),
|
||||
("openai", "AI Chatbots & Assistants"),
|
||||
("claude", "AI Chatbots & Assistants"),
|
||||
("anthropic", "AI Chatbots & Assistants"),
|
||||
("gemini", "AI Chatbots & Assistants"),
|
||||
("google gemini", "AI Chatbots & Assistants"),
|
||||
("bard", "AI Chatbots & Assistants"),
|
||||
("microsoft copilot", "AI Chatbots & Assistants"),
|
||||
("copilot", "AI Chatbots & Assistants"),
|
||||
("perplexity", "AI Chatbots & Assistants"),
|
||||
("poe", "AI Chatbots & Assistants"),
|
||||
("you.com", "AI Chatbots & Assistants"),
|
||||
("mistral", "AI Chatbots & Assistants"),
|
||||
("le chat", "AI Chatbots & Assistants"),
|
||||
("huggingchat", "AI Chatbots & Assistants"),
|
||||
("hugging face chat", "AI Chatbots & Assistants"),
|
||||
("character.ai", "AI Chatbots & Assistants"),
|
||||
("meta ai", "AI Chatbots & Assistants"),
|
||||
("grok", "AI Chatbots & Assistants"),
|
||||
("notebooklm", "AI Chatbots & Assistants"),
|
||||
("notebook lm", "AI Chatbots & Assistants"),
|
||||
# Work & Productivity title keywords
|
||||
("microsoft teams", "Work & Productivity"),
|
||||
("teams", "Work & Productivity"),
|
||||
("zoom", "Work & Productivity"),
|
||||
("google meet", "Work & Productivity"),
|
||||
("google calendar", "Work & Productivity"),
|
||||
("google kalender", "Work & Productivity"),
|
||||
("google docs", "Work & Productivity"),
|
||||
("google dokument", "Work & Productivity"),
|
||||
("google sheets", "Work & Productivity"),
|
||||
("google kalkylark", "Work & Productivity"),
|
||||
("google slides", "Work & Productivity"),
|
||||
("google presentationer", "Work & Productivity"),
|
||||
("google drive", "Work & Productivity"),
|
||||
("office", "Work & Productivity"),
|
||||
("microsoft 365", "Work & Productivity"),
|
||||
("onedrive", "Work & Productivity"),
|
||||
("sharepoint", "Work & Productivity"),
|
||||
("dropbox", "Work & Productivity"),
|
||||
("notion", "Work & Productivity"),
|
||||
("trello", "Work & Productivity"),
|
||||
("asana", "Work & Productivity"),
|
||||
("miro", "Work & Productivity"),
|
||||
("figma", "Work & Productivity"),
|
||||
("canva", "Work & Productivity"),
|
||||
("overleaf", "Work & Productivity"),
|
||||
("deepl", "Work & Productivity"),
|
||||
("google translate", "Work & Productivity"),
|
||||
("adobe", "Work & Productivity"),
|
||||
("acrobat", "Work & Productivity"),
|
||||
("github", "Work & Productivity"),
|
||||
("gitlab", "Work & Productivity"),
|
||||
("jira", "Work & Productivity"),
|
||||
("confluence", "Work & Productivity"),
|
||||
# Education & Learning title keywords
|
||||
("wikipedia", "Education & Learning"),
|
||||
("canvas", "Education & Learning"),
|
||||
("moodle", "Education & Learning"),
|
||||
("blackboard", "Education & Learning"),
|
||||
("ladok", "Education & Learning"),
|
||||
("antagning", "Education & Learning"),
|
||||
("coursera", "Education & Learning"),
|
||||
("edx", "Education & Learning"),
|
||||
("khan academy", "Education & Learning"),
|
||||
("duolingo", "Education & Learning"),
|
||||
("quizlet", "Education & Learning"),
|
||||
("stack overflow", "Education & Learning"),
|
||||
("stackexchange", "Education & Learning"),
|
||||
("researchgate", "Education & Learning"),
|
||||
("sciencedirect", "Education & Learning"),
|
||||
("springer", "Education & Learning"),
|
||||
("wiley", "Education & Learning"),
|
||||
("jstor", "Education & Learning"),
|
||||
("sage journals", "Education & Learning"),
|
||||
("taylor & francis", "Education & Learning"),
|
||||
# Email title keywords
|
||||
("gmail", "Email"),
|
||||
("outlook", "Email"),
|
||||
("hotmail", "Email"),
|
||||
("yahoo mail", "Email"),
|
||||
("proton mail", "Email"),
|
||||
("protonmail", "Email"),
|
||||
("icloud mail", "Email"),
|
||||
("zoho mail", "Email"),
|
||||
("fastmail", "Email"),
|
||||
# Messaging title keywords
|
||||
("whatsapp", "Messaging"),
|
||||
("messenger", "Messaging"),
|
||||
("telegram", "Messaging"),
|
||||
("signal", "Messaging"),
|
||||
("discord", "Messaging"),
|
||||
("slack", "Messaging"),
|
||||
("google chat", "Messaging"),
|
||||
("messages", "Messaging"),
|
||||
# Social Networking title keywords
|
||||
("facebook", "Social Networking"),
|
||||
("instagram", "Social Networking"),
|
||||
("threads", "Social Networking"),
|
||||
("twitter", "Social Networking"),
|
||||
("x.com", "Social Networking"),
|
||||
("linkedin", "Social Networking"),
|
||||
("mastodon", "Social Networking"),
|
||||
("bluesky", "Social Networking"),
|
||||
("bsky", "Social Networking"),
|
||||
("bereal", "Social Networking"),
|
||||
("snapchat", "Social Networking"),
|
||||
("pinterest", "Social Networking"),
|
||||
# Video Streaming title keywords
|
||||
("youtube", "Video Streaming"),
|
||||
("netflix", "Video Streaming"),
|
||||
("svt play", "Video Streaming"),
|
||||
("tv4 play", "Video Streaming"),
|
||||
("viaplay", "Video Streaming"),
|
||||
("hbo max", "Video Streaming"),
|
||||
("hbomax", "Video Streaming"),
|
||||
("prime video", "Video Streaming"),
|
||||
("disney+", "Video Streaming"),
|
||||
("disney plus", "Video Streaming"),
|
||||
("twitch", "Video Streaming"),
|
||||
("vimeo", "Video Streaming"),
|
||||
("dailymotion", "Video Streaming"),
|
||||
# Music & Audio title keywords
|
||||
("spotify", "Music & Audio"),
|
||||
("apple music", "Music & Audio"),
|
||||
("youtube music", "Music & Audio"),
|
||||
("soundcloud", "Music & Audio"),
|
||||
("podme", "Music & Audio"),
|
||||
("bookbeat", "Music & Audio"),
|
||||
("storytel", "Music & Audio"),
|
||||
("audible", "Music & Audio"),
|
||||
("sveriges radio", "Music & Audio"),
|
||||
("sr play", "Music & Audio"),
|
||||
# Games title keywords
|
||||
("steam", "Games"),
|
||||
("epic games", "Games"),
|
||||
("battle.net", "Games"),
|
||||
("xbox", "Games"),
|
||||
("playstation", "Games"),
|
||||
("roblox", "Games"),
|
||||
("minecraft", "Games"),
|
||||
("chess.com", "Games"),
|
||||
("lichess", "Games"),
|
||||
("poki", "Games"),
|
||||
("miniclip", "Games"),
|
||||
("agar.io", "Games"),
|
||||
("forge of empires", "Games"),
|
||||
("catan universe", "Games"),
|
||||
# News & Current Affairs title keywords (after Video keywords — "svt" is safe here)
|
||||
("svt nyheter", "News & Current Affairs"),
|
||||
("svt", "News & Current Affairs"),
|
||||
("dn.se", "News & Current Affairs"),
|
||||
("dagens nyheter", "News & Current Affairs"),
|
||||
("svd", "News & Current Affairs"),
|
||||
("svenska dagbladet", "News & Current Affairs"),
|
||||
("aftonbladet", "News & Current Affairs"),
|
||||
("expressen", "News & Current Affairs"),
|
||||
("göteborgs-posten", "News & Current Affairs"),
|
||||
("goteborgs-posten", "News & Current Affairs"),
|
||||
("gp.se", "News & Current Affairs"),
|
||||
("sydsvenskan", "News & Current Affairs"),
|
||||
("omni", "News & Current Affairs"),
|
||||
("dagens industri", "News & Current Affairs"),
|
||||
("di.se", "News & Current Affairs"),
|
||||
("breakit", "News & Current Affairs"),
|
||||
("ny teknik", "News & Current Affairs"),
|
||||
("the guardian", "News & Current Affairs"),
|
||||
("bbc", "News & Current Affairs"),
|
||||
("cnn", "News & Current Affairs"),
|
||||
("al jazeera", "News & Current Affairs"),
|
||||
("reuters", "News & Current Affairs"),
|
||||
# Search & Navigation title keywords (last — very broad)
|
||||
("google search", "Search & Navigation"),
|
||||
("google", "Search & Navigation"),
|
||||
("bing", "Search & Navigation"),
|
||||
("duckduckgo", "Search & Navigation"),
|
||||
("yahoo search", "Search & Navigation"),
|
||||
("startpage", "Search & Navigation"),
|
||||
("ecosia", "Search & Navigation"),
|
||||
("kagi", "Search & Navigation"),
|
||||
]
|
||||
|
||||
# App-name → study category aliases used by the web UI preset.
|
||||
# Faithfully derived from Matthias Lehner's APP_TO_CATEGORY dict (classifier 2026-07-06).
|
||||
# These aliases are not injected into the watcher: Research Edition storage keeps
|
||||
# the approved raw app name and removes the non-browser title and every URL.
|
||||
APP_CATEGORY_MAP: dict[str, str] = {
|
||||
# AI chatbots & assistants
|
||||
"chatgpt": "AI Chatbots & Assistants",
|
||||
"chatgpt.exe": "AI Chatbots & Assistants",
|
||||
"claude": "AI Chatbots & Assistants",
|
||||
"claude.exe": "AI Chatbots & Assistants",
|
||||
"gemini": "AI Chatbots & Assistants",
|
||||
"microsoft copilot": "AI Chatbots & Assistants",
|
||||
"copilot": "AI Chatbots & Assistants",
|
||||
"perplexity": "AI Chatbots & Assistants",
|
||||
"poe": "AI Chatbots & Assistants",
|
||||
# Work & Productivity — Microsoft Office
|
||||
"microsoft word": "Work & Productivity",
|
||||
"word": "Work & Productivity",
|
||||
"winword.exe": "Work & Productivity",
|
||||
"microsoft excel": "Work & Productivity",
|
||||
"excel": "Work & Productivity",
|
||||
"excel.exe": "Work & Productivity",
|
||||
"microsoft powerpoint": "Work & Productivity",
|
||||
"powerpoint": "Work & Productivity",
|
||||
"powerpnt.exe": "Work & Productivity",
|
||||
# Email — Outlook is email, not productivity
|
||||
"microsoft outlook": "Email",
|
||||
"outlook": "Email",
|
||||
"outlook.exe": "Email",
|
||||
# Work & Productivity — collaboration/notes
|
||||
"microsoft teams": "Work & Productivity",
|
||||
"teams": "Work & Productivity",
|
||||
"zoom": "Work & Productivity",
|
||||
"zoom.us": "Work & Productivity",
|
||||
"notion": "Work & Productivity",
|
||||
"onenote": "Work & Productivity",
|
||||
"adobe acrobat": "Work & Productivity",
|
||||
"acrobat": "Work & Productivity",
|
||||
"preview": "Work & Productivity",
|
||||
"pages": "Work & Productivity",
|
||||
"numbers": "Work & Productivity",
|
||||
"keynote": "Work & Productivity",
|
||||
"libreoffice": "Work & Productivity",
|
||||
# Email — native clients
|
||||
"mail": "Email",
|
||||
"thunderbird": "Email",
|
||||
# Messaging
|
||||
"slack": "Messaging",
|
||||
"signal": "Messaging",
|
||||
"telegram": "Messaging",
|
||||
"whatsapp": "Messaging",
|
||||
"messenger": "Messaging",
|
||||
"discord": "Messaging",
|
||||
# Music & Audio
|
||||
"spotify": "Music & Audio",
|
||||
"music": "Music & Audio",
|
||||
"apple music": "Music & Audio",
|
||||
# Video Streaming — media players
|
||||
"vlc": "Video Streaming",
|
||||
"quicktime player": "Video Streaming",
|
||||
"netflix": "Video Streaming",
|
||||
# Games
|
||||
"steam": "Games",
|
||||
"epic games launcher": "Games",
|
||||
"battle.net": "Games",
|
||||
"roblox": "Games",
|
||||
"minecraft": "Games",
|
||||
"xbox": "Games",
|
||||
# Work & Productivity — creative/professional software
|
||||
"photoshop": "Work & Productivity",
|
||||
"illustrator": "Work & Productivity",
|
||||
"indesign": "Work & Productivity",
|
||||
"lightroom": "Work & Productivity",
|
||||
"premiere pro": "Work & Productivity",
|
||||
"final cut pro": "Work & Productivity",
|
||||
"figma": "Work & Productivity",
|
||||
"blender": "Work & Productivity",
|
||||
"autocad": "Work & Productivity",
|
||||
# System utilities — explicitly excluded (not a lookup miss)
|
||||
"finder": "Excluded",
|
||||
"explorer": "Excluded",
|
||||
"explorer.exe": "Excluded",
|
||||
"system settings": "Excluded",
|
||||
"settings": "Excluded",
|
||||
"system preferences": "Excluded",
|
||||
"terminal": "Excluded",
|
||||
"cmd.exe": "Excluded",
|
||||
"powershell.exe": "Excluded",
|
||||
}
|
||||
|
||||
|
||||
def build_toml_table(entries: list[tuple[str, str]]) -> str:
|
||||
seen: dict[str, str] = {}
|
||||
for pattern, category in entries:
|
||||
if pattern not in seen:
|
||||
seen[pattern] = category
|
||||
items = []
|
||||
for pattern, category in seen.items():
|
||||
k = pattern.replace("\\", "\\\\").replace('"', '\\"')
|
||||
v = category.replace("\\", "\\\\").replace('"', '\\"')
|
||||
items.append(f'"{k}" = "{v}"')
|
||||
return "\n".join(items)
|
||||
|
||||
|
||||
# The flag must be matched anchored to line start. Since aw-watcher-window #137,
|
||||
# config.py also documents the rewrite in a comment containing the literal text
|
||||
# `sed -i 's/^research_enabled = false$/research_enabled = true/'`, and that
|
||||
# comment appears *before* the real flag. An unanchored replace would patch the
|
||||
# comment and leave research_enabled = false -- a green build with the Research
|
||||
# Edition silently disabled.
|
||||
ENABLED_FLAG_RE = re.compile(r"^research_enabled = false$", re.MULTILINE)
|
||||
|
||||
# Anchor for the post-#137 layout: research knobs moved out of `default_config`
|
||||
# into a separate template so they are not persisted into every fresh install's
|
||||
# config file.
|
||||
RESEARCH_DEFAULTS_ANCHOR = 'research_defaults = """'
|
||||
|
||||
|
||||
def patch_config(text: str) -> str:
|
||||
"""Enable browser categorization while leaving the watcher app map empty."""
|
||||
category_header = "[aw-watcher-window.research_category_map]"
|
||||
|
||||
enabled_matches = len(ENABLED_FLAG_RE.findall(text))
|
||||
if enabled_matches != 1:
|
||||
raise ValueError(
|
||||
f"expected exactly one line-anchored 'research_enabled = false', found {enabled_matches}"
|
||||
)
|
||||
|
||||
entries = build_toml_table(CATEGORY_MAP)
|
||||
|
||||
if RESEARCH_DEFAULTS_ANCHOR in text:
|
||||
# Post-#137: the browser map belongs inside `research_defaults`.
|
||||
# That template is parsed standalone and merged into the
|
||||
# [aw-watcher-window] section key-by-key, so its table headers must NOT
|
||||
# carry the section prefix.
|
||||
# Include an empty [research_app_category_map] so that an upgrade from
|
||||
# an earlier Research Edition (which may have had this map populated)
|
||||
# explicitly clears it via the key-by-key merge. Without this, a prior
|
||||
# non-empty app map survives the upgrade and continues replacing app
|
||||
# names with categories, defeating the primary behavior change.
|
||||
block = f"research_enabled = true\n\n[research_category_map]\n{entries}\n\n[research_app_category_map]"
|
||||
patched = ENABLED_FLAG_RE.sub(lambda _: block, text, count=1)
|
||||
else:
|
||||
# Pre-#137: the section-prefixed browser header is already present in
|
||||
# `default_config`; leave the app-map section empty.
|
||||
if text.count(category_header) != 1:
|
||||
raise ValueError(f"expected exactly one '{category_header}' section")
|
||||
patched = ENABLED_FLAG_RE.sub("research_enabled = true", text, count=1)
|
||||
patched = patched.replace(category_header, f"{category_header}\n{entries}", 1)
|
||||
|
||||
return patched
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not CONFIG_FILE.exists():
|
||||
print(f"Error: {CONFIG_FILE} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
text = CONFIG_FILE.read_text(encoding="utf-8")
|
||||
try:
|
||||
patched = patch_config(text)
|
||||
except ValueError as error:
|
||||
print(f"Error: {error} in {CONFIG_FILE}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
CONFIG_FILE.write_text(patched, encoding="utf-8")
|
||||
unique = len({p for p, _ in CATEGORY_MAP})
|
||||
cats = len({c for _, c in CATEGORY_MAP})
|
||||
print(
|
||||
f"Injected {unique} unique patterns across {cats} categories into {CONFIG_FILE}"
|
||||
)
|
||||
print("Preserving application names; watcher app-category map left empty")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
112
scripts/patch_research_edition_export.py
Normal file
112
scripts/patch_research_edition_export.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch aw-server-rust export endpoints for the Research Edition build.
|
||||
|
||||
Run as part of the CI build for research edition:
|
||||
|
||||
python3 scripts/patch_research_edition_export.py [repo-root]
|
||||
|
||||
Standard builds never run this script, so `/api/0/export` stays byte-for-byte
|
||||
unchanged outside Research Edition. The patch is fail-closed: missing markers
|
||||
abort the build rather than shipping an unsanitized artifact.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
MARKER = "RESEARCH_EDITION_EXPORT_SANITIZE"
|
||||
|
||||
EXPORT_INSERT_NEEDLE = """ export.buckets.insert(bid, bucket);
|
||||
}
|
||||
|
||||
Ok(export.into())
|
||||
"""
|
||||
|
||||
EXPORT_INSERT_REPLACEMENT = f""" export.buckets.insert(bid, bucket);
|
||||
}}
|
||||
|
||||
// {MARKER}
|
||||
let export = match super::export_sanitize::sanitize_buckets_export(export) {{
|
||||
Ok(export) => export,
|
||||
Err(err) => {{
|
||||
return Err(HttpErrorJson::new(rocket::http::Status::Conflict, err))
|
||||
}}
|
||||
}};
|
||||
Ok(export.into())
|
||||
"""
|
||||
|
||||
BUCKET_INSERT_NEEDLE = """ export.buckets.insert(bucket_id.into(), bucket);
|
||||
|
||||
Ok(export.into())
|
||||
"""
|
||||
|
||||
BUCKET_INSERT_REPLACEMENT = f""" export.buckets.insert(bucket_id.into(), bucket);
|
||||
|
||||
// {MARKER}
|
||||
let export = match super::export_sanitize::sanitize_buckets_export(export) {{
|
||||
Ok(export) => export,
|
||||
Err(err) => return Err(HttpErrorJson::new(Status::Conflict, err)),
|
||||
}};
|
||||
Ok(export.into())
|
||||
"""
|
||||
|
||||
MOD_NEEDLE = "mod export;\n"
|
||||
MOD_REPLACEMENT = "mod export;\nmod export_sanitize;\n"
|
||||
|
||||
|
||||
def repo_root_from_args(argv: list[str]) -> pathlib.Path:
|
||||
if len(argv) > 1:
|
||||
return pathlib.Path(argv[1]).resolve()
|
||||
return pathlib.Path.cwd().resolve()
|
||||
|
||||
|
||||
def _replace_once(path: pathlib.Path, needle: str, replacement: str, already_ok: str) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if already_ok in text:
|
||||
return
|
||||
count = text.count(needle)
|
||||
if count != 1:
|
||||
raise ValueError(
|
||||
f"{path}: expected exactly one export-sanitizer insertion point, found {count}"
|
||||
)
|
||||
path.write_text(text.replace(needle, replacement, 1), encoding="utf-8")
|
||||
|
||||
|
||||
def patch_tree(repo_root: pathlib.Path) -> None:
|
||||
script_dir = pathlib.Path(__file__).resolve().parent
|
||||
source = script_dir / "research_edition" / "export_sanitize.rs"
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"missing sanitizer module: {source}")
|
||||
|
||||
endpoints = (
|
||||
repo_root / "aw-server-rust" / "aw-server" / "src" / "endpoints"
|
||||
)
|
||||
export_rs = endpoints / "export.rs"
|
||||
bucket_rs = endpoints / "bucket.rs"
|
||||
mod_rs = endpoints / "mod.rs"
|
||||
dest = endpoints / "export_sanitize.rs"
|
||||
|
||||
for required in (export_rs, bucket_rs, mod_rs):
|
||||
if not required.is_file():
|
||||
raise FileNotFoundError(f"expected Rust export source at {required}")
|
||||
|
||||
shutil.copyfile(source, dest)
|
||||
|
||||
_replace_once(mod_rs, MOD_NEEDLE, MOD_REPLACEMENT, "mod export_sanitize;")
|
||||
_replace_once(export_rs, EXPORT_INSERT_NEEDLE, EXPORT_INSERT_REPLACEMENT, MARKER)
|
||||
_replace_once(bucket_rs, BUCKET_INSERT_NEEDLE, BUCKET_INSERT_REPLACEMENT, MARKER)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
repo_root = repo_root_from_args(sys.argv)
|
||||
try:
|
||||
patch_tree(repo_root)
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"Patched Research Edition export sanitizer under {repo_root / 'aw-server-rust'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
737
scripts/patch_research_edition_profile.py
Normal file
737
scripts/patch_research_edition_profile.py
Normal file
@@ -0,0 +1,737 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bake the Research Edition profile identity into the bundle at release time.
|
||||
|
||||
The research profile is *build identity*, not a launch argument. A login item
|
||||
that passes ``--profile research`` misses double-click, Spotlight, and the
|
||||
updater relaunch, and every one of those would silently start ``default`` and
|
||||
pollute a participant's standard install. So the research build patches the
|
||||
no-information fallbacks in the sources it ships, before they are compiled or
|
||||
collected:
|
||||
|
||||
1. **Profile**: every "no ``--profile``, no ``AW_PROFILE``" fallback resolves to
|
||||
``research`` instead of ``default``. Both launchers (aw-qt, aw-tauri), both
|
||||
servers, and aw-client (the watchers' library) are patched, so any binary in
|
||||
the bundle defaults to the research instance no matter how it was started.
|
||||
The launchers then *set* ``AW_PROFILE=research`` for their children, and
|
||||
aw-core/aw-tauri dirs put everything under ``activitywatch-research/``.
|
||||
|
||||
``DEFAULT_PROFILE`` itself is deliberately **not** flipped: it means "the
|
||||
ordinary install" in suffix/dir/lockfile/``export_profile`` logic, and
|
||||
flipping it would make the research build share dirs and lockfile with a
|
||||
standard install — the exact collision this patch exists to prevent.
|
||||
|
||||
2. **Port**: the built-in 5600 default becomes 5667 in both servers, in
|
||||
aw-client, and in aw-qt's tray/manager fallbacks, so a fresh research
|
||||
profile binds 5667 without any config file existing yet.
|
||||
|
||||
3. **macOS bundle identity**: ``CFBundleIdentifier`` becomes
|
||||
``net.activitywatch.ActivityWatch-research`` (Tauri: ``net.activitywatch.tauri-research``)
|
||||
with ``CFBundleName`` "ActivityWatch Research" *and* on-disk bundle
|
||||
``ActivityWatch-Research.app``, so dual-run next to a standard install is a
|
||||
distinct LaunchServices identity that does not overwrite
|
||||
``/Applications/ActivityWatch.app``.
|
||||
|
||||
4. **Install identity**: Windows gets its own Inno ``AppId``, install dir,
|
||||
shortcuts and uninstall entry; Linux gets its own deb package name,
|
||||
``/opt/activitywatch-research`` tree and desktop-entry filename. Bundle ids
|
||||
split LaunchServices, not the on-disk product -- without this a research
|
||||
installer *replaces* a participant's standard install.
|
||||
|
||||
5. **First-run autostart**: aw-qt writes its own login item / Startup shortcut /
|
||||
autostart ``.desktop``, under names independent of the installer's. Those are
|
||||
rebranded too, or the two editions overwrite each other's autostart even with
|
||||
every other identity split.
|
||||
|
||||
Usage (from the repository root, after ``make test``, before ``make package``):
|
||||
|
||||
python3 scripts/patch_research_edition_profile.py qt # build-qt jobs
|
||||
python3 scripts/patch_research_edition_profile.py tauri # build-tauri job
|
||||
python3 scripts/patch_research_edition_profile.py qt --check # verify only
|
||||
|
||||
Every patch is fail-closed: if a target string is missing or ambiguous, the
|
||||
script exits non-zero and the research build must not ship — a silent miss
|
||||
here means a bundle that says "Research Edition" but runs ``profile=default``
|
||||
on port 5600. Bump the submodule pin or update the table; never skip.
|
||||
|
||||
Run this **after** the module test suites: they assert the ordinary defaults
|
||||
(``resolve_profile(None) == "default"``, port 5600) and would fail on the
|
||||
patched tree. Python modules are editable installs, so PyInstaller collects
|
||||
the patched source at package time; Rust binaries need a rebuild afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, List, Sequence
|
||||
|
||||
RESEARCH_PROFILE = "research"
|
||||
RESEARCH_PORT = 5667
|
||||
BUNDLE_ID = "net.activitywatch.ActivityWatch-research"
|
||||
TAURI_IDENTIFIER = "net.activitywatch.tauri-research"
|
||||
BUNDLE_NAME = "ActivityWatch Research"
|
||||
# Hyphenated on-disk stem so Makefile / notarize / CI paths stay unquoted.
|
||||
# Dock and Spotlight still show CFBundleName ("ActivityWatch Research").
|
||||
BUNDLE_DIR_STEM = "ActivityWatch-Research"
|
||||
|
||||
# Windows install identity. Inno Setup keys off AppId, not the display name: a
|
||||
# research setup sharing the standard AppId registers as the *same* product, so
|
||||
# it upgrades over an existing install and its uninstaller removes both. Own
|
||||
# GUIDs give the research build its own install dir, Start-Menu/desktop/startup
|
||||
# shortcuts and "Apps & features" entry, which is what dual-run requires.
|
||||
WINDOWS_APPID_QT = "32024B9B-352E-4E97-AA56-9EEF143E5B70"
|
||||
WINDOWS_APPID_TAURI = "70E2D4AB-8DA2-4BE0-8391-AB5B48653773"
|
||||
# MSI upgrade code. Tauri derives this from `identifier` when unset, so the
|
||||
# research build already differs today via the patched identifier — but that
|
||||
# makes installer identity a silent side effect of an unrelated string. Pinning
|
||||
# it means a future identifier change cannot collapse research MSIs onto the
|
||||
# standard upgrade family. WixConfig exposes no product-code field (Tauri
|
||||
# generates one per build); the upgrade code is what defines the product family,
|
||||
# so it is the one that matters here.
|
||||
WINDOWS_WIX_UPGRADE_CODE_TAURI = "ABF0AB0C-5BA0-4C2C-BF3E-797B2E1913DA"
|
||||
|
||||
# Linux install identity. The Qt deb is a single product on master:
|
||||
# `Package: activitywatch`, `/opt/activitywatch`, and one `aw-qt.desktop`
|
||||
# filename in both /etc/xdg/autostart and /usr/share/applications. Installing a
|
||||
# research deb next to a standard one therefore *replaces* it (dpkg treats a
|
||||
# same-named package as an upgrade) and its autostart entry overwrites the
|
||||
# standard one. Own package name, own /opt tree and own desktop-entry filename
|
||||
# is what makes the two editions co-installable.
|
||||
LINUX_PACKAGE = "activitywatch-research"
|
||||
LINUX_OPT_DIR = f"/opt/{LINUX_PACKAGE}"
|
||||
LINUX_DESKTOP_FILENAME = f"{LINUX_PACKAGE}.desktop"
|
||||
# Desktop-entry `Icon=` id. Only the AppImage actually installs an icon under
|
||||
# this name (`linuxdeploy --icon-filename`); the deb ships none today. Patch
|
||||
# both together so they cannot drift apart.
|
||||
LINUX_ICON_ID = LINUX_PACKAGE
|
||||
# macOS LaunchAgent label written by aw-qt's own first-run autostart (distinct
|
||||
# from the .app bundle id, which the installer/LaunchServices own).
|
||||
LAUNCH_AGENT_LABEL = "net.activitywatch.aw-qt-research"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Patch:
|
||||
"""One exact, unique substring replacement in one file."""
|
||||
|
||||
path: str
|
||||
old: str
|
||||
new: str
|
||||
why: str
|
||||
|
||||
|
||||
# --- profile fallbacks ------------------------------------------------------
|
||||
|
||||
_PY_FALLBACK = " return TESTING_PROFILE if testing else DEFAULT_PROFILE\n"
|
||||
_PY_FALLBACK_NEW = " return TESTING_PROFILE if testing else BUILD_PROFILE\n"
|
||||
|
||||
|
||||
def _python_profile_patches(path: str) -> List[Patch]:
|
||||
"""aw-qt / aw-server / aw-client share one profile module layout.
|
||||
|
||||
Three no-information fallbacks return ``DEFAULT_PROFILE``: the ``None``
|
||||
branch of ``resolve_profile`` and the unset/invalid branches of
|
||||
``profile_from_env``. All three become ``BUILD_PROFILE``.
|
||||
"""
|
||||
return [
|
||||
Patch(
|
||||
path,
|
||||
'DEFAULT_PROFILE = "default"\nTESTING_PROFILE = "testing"\n',
|
||||
'DEFAULT_PROFILE = "default"\nTESTING_PROFILE = "testing"\n'
|
||||
"#: Research Edition build identity (baked at release time). Used only\n"
|
||||
"#: as the no-flag/no-env fallback; DEFAULT_PROFILE still means the\n"
|
||||
"#: ordinary install for suffix/dir/lockfile/export logic.\n"
|
||||
f'BUILD_PROFILE = "{RESEARCH_PROFILE}"\n',
|
||||
"declare BUILD_PROFILE next to DEFAULT_PROFILE",
|
||||
),
|
||||
Patch(
|
||||
path,
|
||||
" if profile is None:\n" + _PY_FALLBACK,
|
||||
" if profile is None:\n" + _PY_FALLBACK_NEW,
|
||||
"resolve_profile(None) falls back to the build profile",
|
||||
),
|
||||
Patch(
|
||||
path,
|
||||
" if not profile:\n" + _PY_FALLBACK,
|
||||
" if not profile:\n" + _PY_FALLBACK_NEW,
|
||||
"profile_from_env with AW_PROFILE unset falls back to the build profile",
|
||||
),
|
||||
Patch(
|
||||
path,
|
||||
" except ValueError:\n" + _PY_FALLBACK,
|
||||
" except ValueError:\n" + _PY_FALLBACK_NEW,
|
||||
"profile_from_env with an invalid AW_PROFILE falls back to the build profile",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
PROFILE_PATCHES_QT: List[Patch] = [
|
||||
*_python_profile_patches("aw-qt/aw_qt/profile.py"),
|
||||
*_python_profile_patches("aw-server/aw_server/profile.py"),
|
||||
*_python_profile_patches("aw-client/aw_client/profile.py"),
|
||||
Patch(
|
||||
"aw-server-rust/aw-server/src/main.rs",
|
||||
' } else {\n "default".to_string()\n }\n',
|
||||
f' }} else {{\n "{RESEARCH_PROFILE}".to_string()\n }}\n',
|
||||
"aw-server-rust with no --profile and no AW_PROFILE runs the build profile",
|
||||
),
|
||||
]
|
||||
|
||||
PROFILE_PATCHES_TAURI: List[Patch] = [
|
||||
# Watchers in the Tauri bundle are the same Python modules.
|
||||
*_python_profile_patches("aw-client/aw_client/profile.py"),
|
||||
Patch(
|
||||
"aw-tauri/src-tauri/src/profile.rs",
|
||||
'pub const DEFAULT_PROFILE: &str = "default";\n',
|
||||
'pub const DEFAULT_PROFILE: &str = "default";\n'
|
||||
"/// Research Edition build identity (baked at release time). Only the\n"
|
||||
"/// no-flag/no-env fallback; DEFAULT_PROFILE still means the ordinary install.\n"
|
||||
f'pub const BUILD_PROFILE: &str = "{RESEARCH_PROFILE}";\n',
|
||||
"declare BUILD_PROFILE next to DEFAULT_PROFILE",
|
||||
),
|
||||
Patch(
|
||||
"aw-tauri/src-tauri/src/profile.rs",
|
||||
" None => Ok(DEFAULT_PROFILE.to_string()),\n",
|
||||
" None => Ok(BUILD_PROFILE.to_string()),\n",
|
||||
"resolve_profile with no --profile and no AW_PROFILE runs the build profile",
|
||||
),
|
||||
Patch(
|
||||
"aw-tauri/src-tauri/src/profile.rs",
|
||||
" _ => DEFAULT_PROFILE.to_string(),\n",
|
||||
" _ => BUILD_PROFILE.to_string(),\n",
|
||||
"current_profile() before export falls back to the build profile",
|
||||
),
|
||||
]
|
||||
|
||||
# --- port -------------------------------------------------------------------
|
||||
|
||||
PORT_PATCHES_QT: List[Patch] = [
|
||||
Patch(
|
||||
"aw-server/aw_server/config.py",
|
||||
'port = "5600"\n',
|
||||
f'port = "{RESEARCH_PORT}"\n',
|
||||
"aw-server default_config [server] port",
|
||||
),
|
||||
Patch(
|
||||
"aw-server/aw_server/config.py",
|
||||
" return 5666 if is_testing(profile) else 5600\n",
|
||||
f" return 5666 if is_testing(profile) else {RESEARCH_PORT}\n",
|
||||
"aw-server default_port()",
|
||||
),
|
||||
Patch(
|
||||
"aw-server-rust/aw-server/src/config.rs",
|
||||
" } else {\n 5600\n }\n",
|
||||
f" }} else {{\n {RESEARCH_PORT}\n }}\n",
|
||||
"aw-server-rust default_port()",
|
||||
),
|
||||
Patch(
|
||||
"aw-client/aw_client/config.py",
|
||||
'port = "5600"\n',
|
||||
f'port = "{RESEARCH_PORT}"\n',
|
||||
"aw-client default_config [server] port",
|
||||
),
|
||||
Patch(
|
||||
"aw-client/aw_client/config.py",
|
||||
' _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
|
||||
' "aw-server-rust",\n'
|
||||
' "config.toml",\n'
|
||||
" ),\n"
|
||||
" 5600,\n",
|
||||
' _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
|
||||
' "aw-server-rust",\n'
|
||||
' "config.toml",\n'
|
||||
" ),\n"
|
||||
f" {RESEARCH_PORT},\n",
|
||||
"aw-client api-key lookup default port for a named profile",
|
||||
),
|
||||
Patch(
|
||||
"aw-qt/aw_qt/config.py",
|
||||
" default_port = 5666 if is_testing(profile) else 5600\n",
|
||||
f" default_port = 5666 if is_testing(profile) else {RESEARCH_PORT}\n",
|
||||
"aw-qt _read_server_port fallback",
|
||||
),
|
||||
Patch(
|
||||
"aw-qt/aw_qt/manager.py",
|
||||
" default_port = 5666 if testing else 5600\n",
|
||||
f" default_port = 5666 if testing else {RESEARCH_PORT}\n",
|
||||
"aw-qt external-server probe fallback",
|
||||
),
|
||||
Patch(
|
||||
"aw-qt/aw_qt/trayicon.py",
|
||||
" port = 5666 if testing else 5600\n",
|
||||
f" port = 5666 if testing else {RESEARCH_PORT}\n",
|
||||
"aw-qt tray root_url fallback",
|
||||
),
|
||||
]
|
||||
|
||||
PORT_PATCHES_TAURI: List[Patch] = [
|
||||
# aw-tauri embeds aw-server-rust in-process and passes its own config
|
||||
# port, so the server crate's default_port() is irrelevant here.
|
||||
Patch(
|
||||
"aw-tauri/src-tauri/src/lib.rs",
|
||||
" UserConfig {\n port: 5600,\n",
|
||||
f" UserConfig {{\n port: {RESEARCH_PORT},\n",
|
||||
"aw-tauri UserConfig::default port",
|
||||
),
|
||||
Patch(
|
||||
"aw-client/aw_client/config.py",
|
||||
'port = "5600"\n',
|
||||
f'port = "{RESEARCH_PORT}"\n',
|
||||
"aw-client default_config [server] port",
|
||||
),
|
||||
Patch(
|
||||
"aw-client/aw_client/config.py",
|
||||
' _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
|
||||
' "aw-server-rust",\n'
|
||||
' "config.toml",\n'
|
||||
" ),\n"
|
||||
" 5600,\n",
|
||||
' _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),\n'
|
||||
' "aw-server-rust",\n'
|
||||
' "config.toml",\n'
|
||||
" ),\n"
|
||||
f" {RESEARCH_PORT},\n",
|
||||
"aw-client api-key lookup default port for a named profile",
|
||||
),
|
||||
]
|
||||
|
||||
# --- macOS bundle identity ----------------------------------------------------
|
||||
|
||||
BUNDLE_PATCHES_QT: List[Patch] = [
|
||||
Patch(
|
||||
"aw.spec",
|
||||
' bundle_identifier="net.activitywatch.ActivityWatch",\n',
|
||||
f' bundle_identifier="{BUNDLE_ID}",\n',
|
||||
"PyInstaller BUNDLE identifier",
|
||||
),
|
||||
Patch(
|
||||
"aw.spec",
|
||||
' "CFBundleExecutable": "MacOS/aw-qt",\n',
|
||||
f' "CFBundleName": "{BUNDLE_NAME}",\n'
|
||||
' "CFBundleExecutable": "MacOS/aw-qt",\n',
|
||||
"PyInstaller BUNDLE display name",
|
||||
),
|
||||
Patch(
|
||||
"aw.spec",
|
||||
' name="ActivityWatch.app",\n',
|
||||
f' name="{BUNDLE_DIR_STEM}.app",\n',
|
||||
"PyInstaller BUNDLE on-disk filename",
|
||||
),
|
||||
Patch(
|
||||
"Makefile",
|
||||
"APP_BUNDLE ?= ActivityWatch\n",
|
||||
f"APP_BUNDLE ?= {BUNDLE_DIR_STEM}\n",
|
||||
"Makefile .app/.dmg stem",
|
||||
),
|
||||
Patch(
|
||||
"scripts/notarize.sh",
|
||||
"bundleid=net.activitywatch.ActivityWatch # Match aw.spec\n",
|
||||
f"bundleid={BUNDLE_ID} # Match aw.spec\n",
|
||||
"notarization bundle id",
|
||||
),
|
||||
Patch(
|
||||
"scripts/notarize.sh",
|
||||
"app=dist/ActivityWatch.app\n"
|
||||
"dmg=dist/ActivityWatch.dmg\n",
|
||||
f"app=dist/{BUNDLE_DIR_STEM}.app\n"
|
||||
f"dmg=dist/{BUNDLE_DIR_STEM}.dmg\n",
|
||||
"notarization .app/.dmg paths",
|
||||
),
|
||||
]
|
||||
|
||||
BUNDLE_PATCHES_TAURI: List[Patch] = [
|
||||
Patch(
|
||||
"scripts/package/build_app_tauri.sh",
|
||||
'BUNDLE_ID="net.activitywatch.ActivityWatch"\n',
|
||||
f'BUNDLE_ID="{BUNDLE_ID}"\n',
|
||||
"Tauri .app CFBundleIdentifier",
|
||||
),
|
||||
Patch(
|
||||
"scripts/package/build_app_tauri.sh",
|
||||
" <key>CFBundleName</key>\n <string>${APP_NAME}</string>\n",
|
||||
f" <key>CFBundleName</key>\n <string>{BUNDLE_NAME}</string>\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())
|
||||
375
scripts/research_edition/export_sanitize.rs
Normal file
375
scripts/research_edition/export_sanitize.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
//! Research Edition `/api/0/export` sanitizer.
|
||||
//!
|
||||
//! Copied into `aw-server` only by `scripts/patch_research_edition_export.py`
|
||||
//! when `AW_RESEARCH_EDITION=true`. Standard builds never see this module.
|
||||
//!
|
||||
//! Two jobs:
|
||||
//! 1. Fail closed if currentwindow events still carry raw titles or any event
|
||||
//! carries a URL. Application names are approved study variables.
|
||||
//! 2. Rewrite each exported bucket's map key, embedded `id`, and `hostname`
|
||||
//! so the real machine name never leaves the device. Colliding sanitized
|
||||
//! IDs fail the export rather than silently merging two machines.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use aw_models::{Bucket, BucketsExport, TryVec};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const SANITIZED_HOSTNAME: &str = "research-participant";
|
||||
|
||||
/// Study categories plus the two "Excluded" spellings the watcher writes.
|
||||
/// Keep in sync with `scripts/patch_research_edition_config.py`.
|
||||
const STUDY_CATEGORIES: &[&str] = &[
|
||||
"Sensitive / Excluded",
|
||||
"Music & Audio",
|
||||
"Video Streaming",
|
||||
"Games",
|
||||
"Travel & Mobility",
|
||||
"Search & Navigation",
|
||||
"News & Current Affairs",
|
||||
"Social Networking",
|
||||
"Messaging",
|
||||
"Email",
|
||||
"AI Chatbots & Assistants",
|
||||
"Work & Productivity",
|
||||
"Education & Learning",
|
||||
"Shopping - Goods",
|
||||
"Shopping - Groceries & Food",
|
||||
"Banking & Finance",
|
||||
"Public Services",
|
||||
"Excluded",
|
||||
"excluded",
|
||||
];
|
||||
|
||||
pub fn sanitize_buckets_export(export: BucketsExport) -> Result<BucketsExport, String> {
|
||||
let mut checked = HashMap::new();
|
||||
for (key, mut bucket) in export.buckets {
|
||||
reject_unfiltered(&mut bucket)?;
|
||||
checked.insert(key, bucket);
|
||||
}
|
||||
rewrite_identities(checked)
|
||||
}
|
||||
|
||||
fn reject_unfiltered(bucket: &mut Bucket) -> Result<(), String> {
|
||||
let Some(events) = bucket.events.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let inner = events.take_inner();
|
||||
for event in &inner {
|
||||
if let Some(reason) = unfiltered_reason(&bucket._type, &event.data) {
|
||||
bucket.events = Some(TryVec::new(inner));
|
||||
return Err(format!(
|
||||
"Research Edition export refused: bucket type '{}' client '{}' contains unfiltered event data ({reason}). \
|
||||
Research filtering only applies to newly captured events. Uninstall ActivityWatch, \
|
||||
delete the existing database, and install the Research Edition on a clean profile.",
|
||||
bucket._type, bucket.client
|
||||
));
|
||||
}
|
||||
}
|
||||
bucket.events = Some(TryVec::new(inner));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unfiltered_reason(
|
||||
bucket_type: &str,
|
||||
data: &serde_json::Map<String, Value>,
|
||||
) -> Option<&'static str> {
|
||||
if data.contains_key("url") {
|
||||
return Some("url field");
|
||||
}
|
||||
if bucket_type != "currentwindow" {
|
||||
return None;
|
||||
}
|
||||
if let Some(title) = data.get("title").and_then(Value::as_str) {
|
||||
if !is_study_category(title) {
|
||||
return Some("non-category window title");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_study_category(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
STUDY_CATEGORIES
|
||||
.iter()
|
||||
.any(|category| category.eq_ignore_ascii_case(trimmed))
|
||||
}
|
||||
|
||||
fn rewrite_identities(buckets: HashMap<String, Bucket>) -> Result<BucketsExport, String> {
|
||||
let mut out: HashMap<String, Bucket> = HashMap::new();
|
||||
for (key, mut bucket) in buckets {
|
||||
let original_id = if bucket.id.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
bucket.id.clone()
|
||||
};
|
||||
let sanitized_id = sanitize_id(&original_id, &bucket.hostname);
|
||||
let sanitized_key = sanitize_id(&key, &bucket.hostname);
|
||||
if sanitized_id != sanitized_key {
|
||||
return Err(
|
||||
"Research Edition export refused: bucket key and embedded id sanitize to different identities. \
|
||||
Start from a clean ActivityWatch profile."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if out.contains_key(&sanitized_key) {
|
||||
return Err(format!(
|
||||
"Research Edition export refused: two buckets map to the same sanitized identity '{sanitized_key}'. \
|
||||
This usually means data from more than one machine is in the same database. Start from a clean ActivityWatch profile."
|
||||
));
|
||||
}
|
||||
bucket.id = sanitized_key.clone();
|
||||
bucket.hostname = SANITIZED_HOSTNAME.to_string();
|
||||
out.insert(sanitized_key, bucket);
|
||||
}
|
||||
Ok(BucketsExport { buckets: out })
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_id(id: &str, hostname: &str) -> String {
|
||||
if hostname.is_empty() {
|
||||
return id.to_string();
|
||||
}
|
||||
if id == hostname {
|
||||
return SANITIZED_HOSTNAME.to_string();
|
||||
}
|
||||
let suffix = format!("_{hostname}");
|
||||
if let Some(prefix) = id.strip_suffix(suffix.as_str()) {
|
||||
return format!("{prefix}_{SANITIZED_HOSTNAME}");
|
||||
}
|
||||
if id.contains(hostname) {
|
||||
return id.replace(hostname, SANITIZED_HOSTNAME);
|
||||
}
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aw_models::{BucketMetadata, Event, TryVec};
|
||||
use chrono::{Duration, TimeZone, Utc};
|
||||
use serde_json::{json, Map};
|
||||
|
||||
fn ts() -> chrono::DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(2026, 8, 24, 12, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
fn event(data: serde_json::Value) -> Event {
|
||||
Event {
|
||||
id: None,
|
||||
timestamp: ts(),
|
||||
duration: Duration::seconds(1),
|
||||
data: data.as_object().cloned().unwrap_or_else(Map::new),
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket(
|
||||
id: &str,
|
||||
hostname: &str,
|
||||
bucket_type: &str,
|
||||
client: &str,
|
||||
events: Vec<Event>,
|
||||
) -> Bucket {
|
||||
Bucket {
|
||||
bid: None,
|
||||
id: id.to_string(),
|
||||
_type: bucket_type.to_string(),
|
||||
client: client.to_string(),
|
||||
hostname: hostname.to_string(),
|
||||
created: None,
|
||||
data: Map::new(),
|
||||
metadata: BucketMetadata::default(),
|
||||
events: Some(TryVec::new(events)),
|
||||
last_updated: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn export_of(buckets: Vec<Bucket>) -> BucketsExport {
|
||||
BucketsExport {
|
||||
buckets: buckets.into_iter().map(|b| (b.id.clone(), b)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_key_id_and_hostname_together() {
|
||||
let host = "Participant-Alice-MacBook";
|
||||
let original = export_of(vec![
|
||||
bucket(
|
||||
&format!("aw-watcher-window_{host}"),
|
||||
host,
|
||||
"currentwindow",
|
||||
"aw-watcher-window",
|
||||
vec![event(json!({"app": "Excluded"}))],
|
||||
),
|
||||
bucket(
|
||||
&format!("aw-watcher-afk_{host}"),
|
||||
host,
|
||||
"afkstatus",
|
||||
"aw-watcher-afk",
|
||||
vec![event(json!({"status": "afk"}))],
|
||||
),
|
||||
]);
|
||||
|
||||
let sanitized = sanitize_buckets_export(original).unwrap();
|
||||
let window_id = format!("aw-watcher-window_{SANITIZED_HOSTNAME}");
|
||||
let afk_id = format!("aw-watcher-afk_{SANITIZED_HOSTNAME}");
|
||||
|
||||
assert_eq!(sanitized.buckets.len(), 2);
|
||||
let window = sanitized.buckets.get(&window_id).unwrap();
|
||||
assert_eq!(window.id, window_id);
|
||||
assert_eq!(window.hostname, SANITIZED_HOSTNAME);
|
||||
assert_eq!(
|
||||
window.events.as_ref().unwrap().clone().take_inner()[0].data,
|
||||
json!({"app": "Excluded"}).as_object().unwrap().clone()
|
||||
);
|
||||
|
||||
let afk = sanitized.buckets.get(&afk_id).unwrap();
|
||||
assert_eq!(afk.id, afk_id);
|
||||
assert_eq!(afk.hostname, SANITIZED_HOSTNAME);
|
||||
assert_eq!(
|
||||
afk.events.as_ref().unwrap().clone().take_inner()[0].data,
|
||||
json!({"status": "afk"}).as_object().unwrap().clone()
|
||||
);
|
||||
|
||||
let dump = serde_json::to_string(&sanitized).unwrap();
|
||||
assert!(
|
||||
!dump.contains(host),
|
||||
"real hostname must not appear in export JSON"
|
||||
);
|
||||
assert_eq!(dump.matches(SANITIZED_HOSTNAME).count(), 6); // key + id + hostname, twice
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_of_two_hostnames_fails_closed() {
|
||||
let original = export_of(vec![
|
||||
bucket(
|
||||
"aw-watcher-window_host-a",
|
||||
"host-a",
|
||||
"currentwindow",
|
||||
"aw-watcher-window",
|
||||
vec![event(json!({"app": "Excluded"}))],
|
||||
),
|
||||
bucket(
|
||||
"aw-watcher-window_host-b",
|
||||
"host-b",
|
||||
"currentwindow",
|
||||
"aw-watcher-window",
|
||||
vec![event(json!({"app": "Excluded"}))],
|
||||
),
|
||||
]);
|
||||
|
||||
let err = match sanitize_buckets_export(original) {
|
||||
Err(err) => err,
|
||||
Ok(_) => panic!("expected hostname collision to fail closed"),
|
||||
};
|
||||
assert!(err.contains("same sanitized identity"));
|
||||
assert!(!err.contains("host-a"));
|
||||
assert!(!err.contains("host-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_title_fails_closed() {
|
||||
let original = export_of(vec![bucket(
|
||||
"aw-watcher-window_host",
|
||||
"host",
|
||||
"currentwindow",
|
||||
"aw-watcher-window",
|
||||
vec![event(json!({"app": "Code", "title": "secret.rs"}))],
|
||||
)]);
|
||||
|
||||
let err = match sanitize_buckets_export(original) {
|
||||
Err(err) => err,
|
||||
Ok(_) => panic!("expected unfiltered title to fail closed"),
|
||||
};
|
||||
assert!(err.contains("unfiltered"));
|
||||
assert!(err.contains("clean profile"));
|
||||
assert!(!err.contains("secret.rs"));
|
||||
assert!(!err.contains("Code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_url_fails_closed_even_outside_window_buckets() {
|
||||
let original = export_of(vec![bucket(
|
||||
"aw-watcher-web_host",
|
||||
"host",
|
||||
"web.tab.current",
|
||||
"aw-watcher-web",
|
||||
vec![event(
|
||||
json!({"url": "https://mail.example/inbox", "title": "Inbox"}),
|
||||
)],
|
||||
)]);
|
||||
|
||||
let err = match sanitize_buckets_export(original) {
|
||||
Err(err) => err,
|
||||
Ok(_) => panic!("expected url field to fail closed"),
|
||||
};
|
||||
assert!(err.contains("url field"));
|
||||
assert!(!err.contains("mail.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_non_browser_app_name_is_allowed() {
|
||||
let original = export_of(vec![bucket(
|
||||
"aw-watcher-window_host",
|
||||
"host",
|
||||
"currentwindow",
|
||||
"aw-watcher-window",
|
||||
vec![event(json!({"app": "Microsoft Word"}))],
|
||||
)]);
|
||||
|
||||
let sanitized = sanitize_buckets_export(original).unwrap();
|
||||
let bucket = sanitized
|
||||
.buckets
|
||||
.get(&format!("aw-watcher-window_{SANITIZED_HOSTNAME}"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bucket.events.as_ref().unwrap().clone().take_inner()[0].data,
|
||||
json!({"app": "Microsoft Word"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_event_with_classified_title_is_allowed() {
|
||||
let original = export_of(vec![bucket(
|
||||
"aw-watcher-window_host",
|
||||
"host",
|
||||
"currentwindow",
|
||||
"aw-watcher-window",
|
||||
vec![event(
|
||||
json!({"app": "Firefox", "title": "Work & Productivity"}),
|
||||
)],
|
||||
)]);
|
||||
|
||||
let sanitized = sanitize_buckets_export(original).unwrap();
|
||||
let bucket = sanitized
|
||||
.buckets
|
||||
.get(&format!("aw-watcher-window_{SANITIZED_HOSTNAME}"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bucket.events.as_ref().unwrap().clone().take_inner()[0].data,
|
||||
json!({"app": "Firefox", "title": "Work & Productivity"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_id_replaces_suffix_and_embedded_hostname() {
|
||||
assert_eq!(
|
||||
sanitize_id(
|
||||
"aw-watcher-window_Participant-Alice-MacBook",
|
||||
"Participant-Alice-MacBook"
|
||||
),
|
||||
format!("aw-watcher-window_{SANITIZED_HOSTNAME}")
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_id("Participant-Alice-MacBook", "Participant-Alice-MacBook"),
|
||||
SANITIZED_HOSTNAME
|
||||
);
|
||||
assert_eq!(sanitize_id("id1", "hostname"), "id1");
|
||||
assert_eq!(sanitize_id("keep_me", ""), "keep_me");
|
||||
}
|
||||
}
|
||||
12
scripts/submodule-branch.sh
vendored
Executable file
12
scripts/submodule-branch.sh
vendored
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get current branch
|
||||
# git rev-parse --abbrev-ref HEAD
|
||||
# Get branch for each submodule
|
||||
# git submodule foreach "git rev-parse --abbrev-ref HEAD"
|
||||
|
||||
SUBMODULES=$(git submodule | sed -r -e 's/^[ \+][a-z0-9]+ //g' -e 's/ \(.*\)//g')
|
||||
for module in $SUBMODULES; do
|
||||
branch=$(git --git-dir=$module/.git rev-parse --abbrev-ref HEAD)
|
||||
printf "%-20s %-30s\n" "$module" "$branch"
|
||||
done
|
||||
4
scripts/symlink-systemd.sh
vendored
Executable file
4
scripts/symlink-systemd.sh
vendored
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
for module in "aw-server" "aw-watcher-afk" "aw-watcher-x11"; do
|
||||
ln -s $(pwd)/$module/misc/${module}.service ~/.config/systemd/user/${module}.service
|
||||
done
|
||||
84
scripts/tests/integration_tests.py
Normal file
84
scripts/tests/integration_tests.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import tempfile
|
||||
from time import sleep
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _windows_kill_process(pid):
|
||||
import ctypes
|
||||
|
||||
PROCESS_TERMINATE = 1
|
||||
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
|
||||
ctypes.windll.kernel32.TerminateProcess(handle, -1)
|
||||
ctypes.windll.kernel32.CloseHandle(handle)
|
||||
|
||||
|
||||
# NOTE: to run tests with a specific server binary,
|
||||
# set the PATH such that it is the "aw-server" binary.
|
||||
@pytest.fixture(scope="session")
|
||||
def server_process():
|
||||
logfile_stdout = tempfile.NamedTemporaryFile(delete=False)
|
||||
logfile_stderr = tempfile.NamedTemporaryFile(delete=False)
|
||||
|
||||
# find the path of the "aw-server" binary and log it
|
||||
which_server = subprocess.check_output(["which", "aw-server"], text=True)
|
||||
print(f"aw-server path: {which_server}")
|
||||
|
||||
# if aw-server-rust in PATH, assert that we're picking up the aw-server-rust binary
|
||||
if "aw-server-rust" in os.environ["PATH"]:
|
||||
assert "aw-server-rust" in which_server
|
||||
|
||||
server_proc = subprocess.Popen(
|
||||
["aw-server", "--testing"], stdout=logfile_stdout, stderr=logfile_stderr
|
||||
)
|
||||
|
||||
# Wait for server to start up properly
|
||||
# TODO: Ping the server until it's alive to remove this sleep
|
||||
sleep(5)
|
||||
|
||||
yield server_proc
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# On Windows, for whatever reason, server_proc.kill() doesn't do the job.
|
||||
_windows_kill_process(server_proc.pid)
|
||||
else:
|
||||
server_proc.kill()
|
||||
server_proc.wait(5)
|
||||
server_proc.communicate()
|
||||
|
||||
error_indicators = ["ERROR"]
|
||||
|
||||
with open(logfile_stdout.name, "r+b") as f:
|
||||
stdout = str(f.read(), "utf8")
|
||||
if any(e in stdout for e in error_indicators):
|
||||
pytest.fail(f"Found ERROR indicator in stdout from server: {stdout}")
|
||||
|
||||
with open(logfile_stderr.name, "r+b") as f:
|
||||
stderr = str(f.read(), "utf8")
|
||||
# For some reason, this fails aw-server-rust, but not aw-server-python
|
||||
# if not stderr:
|
||||
# pytest.fail("No output to stderr from server")
|
||||
|
||||
# Will show in case pytest fails
|
||||
print(stderr)
|
||||
|
||||
for s in error_indicators:
|
||||
if s in stderr:
|
||||
pytest.fail(f"Found ERROR indicator in stderr from server: {s}")
|
||||
|
||||
# NOTE: returncode was -9 for whatever reason
|
||||
# if server_proc.returncode != 0:
|
||||
# pytest.fail("Exit code was non-zero ({})".format(server_proc.returncode))
|
||||
|
||||
|
||||
# TODO: Use the fixture in the tests instead of this thing here
|
||||
def test_integration(server_process):
|
||||
# This is just here so that the server_process fixture is initialized
|
||||
pass
|
||||
|
||||
# exit_code = pytest.main(["./aw-server/tests", "-v"])
|
||||
# if exit_code != 0:
|
||||
# pytest.fail("Tests exited with non-zero code: " + str(exit_code))
|
||||
175
scripts/tests/test_check_tauri_server.py
Normal file
175
scripts/tests/test_check_tauri_server.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import importlib.util
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "check_tauri_server.py"
|
||||
SPEC = importlib.util.spec_from_file_location("check_tauri_server", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
checker = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(checker)
|
||||
|
||||
major_minor = checker.major_minor
|
||||
read_locked_server = checker.read_locked_server
|
||||
read_package_version = checker.read_package_version
|
||||
validation_errors = checker.validation_errors
|
||||
is_git_checkout = checker.is_git_checkout
|
||||
initialize_submodule = checker.initialize_submodule
|
||||
sync_submodule = checker.sync_submodule
|
||||
|
||||
|
||||
REVISION = "a" * 40
|
||||
|
||||
|
||||
def test_major_minor_accepts_beta_release():
|
||||
assert major_minor("0.14.0b5") == "0.14"
|
||||
|
||||
|
||||
def test_read_package_version(tmp_path: Path):
|
||||
cargo_toml = tmp_path / "Cargo.toml"
|
||||
cargo_toml.write_text(
|
||||
'[workspace]\n\n[package]\nname = "aw-server"\nversion = "0.14.0"\n'
|
||||
)
|
||||
|
||||
assert read_package_version(cargo_toml) == "0.14.0"
|
||||
|
||||
|
||||
def test_read_locked_server(tmp_path: Path):
|
||||
cargo_lock = tmp_path / "Cargo.lock"
|
||||
cargo_lock.write_text(
|
||||
'[[package]]\nname = "other"\nversion = "1.0.0"\n\n'
|
||||
'[[package]]\nname = "aw-server"\nversion = "0.14.0"\n'
|
||||
'source = "git+https://github.com/ActivityWatch/aw-server-rust.git?branch=master#'
|
||||
f'{REVISION}"\n'
|
||||
)
|
||||
|
||||
assert read_locked_server(cargo_lock) == ("0.14.0", REVISION)
|
||||
|
||||
|
||||
def test_valid_when_release_line_and_revisions_match():
|
||||
assert validation_errors("0.14.0b5", "0.14.0", REVISION, "0.14.0", REVISION) == []
|
||||
|
||||
|
||||
def test_rejects_old_tauri_release_line():
|
||||
errors = validation_errors("0.14.0b5", "0.14.0", REVISION, "0.13.1", REVISION)
|
||||
|
||||
assert errors == [
|
||||
"Tauri locks aw-server 0.13.1, which does not match release line 0.14"
|
||||
]
|
||||
|
||||
|
||||
def test_rejects_different_revision_even_on_same_release_line():
|
||||
errors = validation_errors("0.14.0b5", "0.14.0", REVISION, "0.14.0", "b" * 40)
|
||||
|
||||
assert errors == [
|
||||
"Tauri locks aw-server-rust bbbbbbbbbbbb, but the release submodule is aaaaaaaaaaaa"
|
||||
]
|
||||
|
||||
|
||||
def test_rejects_invalid_version():
|
||||
with pytest.raises(ValueError, match="invalid version"):
|
||||
major_minor("dev")
|
||||
|
||||
|
||||
def git(repo: Path, *args: str) -> str:
|
||||
return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip()
|
||||
|
||||
|
||||
def make_git_repo(path: Path) -> tuple[str, str]:
|
||||
path.mkdir()
|
||||
subprocess.run(["git", "-C", str(path), "init"], check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(path), "config", "user.email", "test@example.com"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-C", str(path), "config", "user.name", "Test"], check=True
|
||||
)
|
||||
tracked = path / "tracked.txt"
|
||||
tracked.write_text("first\n")
|
||||
subprocess.run(["git", "-C", str(path), "add", "tracked.txt"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(path), "commit", "-m", "first"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
first = git(path, "rev-parse", "HEAD")
|
||||
tracked.write_text("second\n")
|
||||
subprocess.run(
|
||||
["git", "-C", str(path), "commit", "-am", "second"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return first, git(path, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def test_sync_submodule_checks_out_locked_revision(tmp_path: Path):
|
||||
repo = tmp_path / "aw-server-rust"
|
||||
first, second = make_git_repo(repo)
|
||||
assert git(repo, "rev-parse", "HEAD") == second
|
||||
|
||||
assert sync_submodule(repo, first)
|
||||
assert git(repo, "rev-parse", "HEAD") == first
|
||||
assert not sync_submodule(repo, first)
|
||||
|
||||
|
||||
def test_sync_submodule_refuses_dirty_checkout(tmp_path: Path):
|
||||
repo = tmp_path / "aw-server-rust"
|
||||
_, current = make_git_repo(repo)
|
||||
(repo / "tracked.txt").write_text("dirty\n")
|
||||
|
||||
with pytest.raises(ValueError, match="refusing to replace dirty"):
|
||||
sync_submodule(repo, current)
|
||||
|
||||
|
||||
def test_sync_submodule_rejects_uninitialized_nested_directory(tmp_path: Path):
|
||||
parent = tmp_path / "activitywatch"
|
||||
_, _ = make_git_repo(parent)
|
||||
server = parent / "aw-server-rust"
|
||||
server.mkdir()
|
||||
|
||||
with pytest.raises(ValueError, match="not initialized as a Git submodule"):
|
||||
sync_submodule(server, REVISION)
|
||||
|
||||
|
||||
def test_is_git_checkout_requires_the_exact_repository_root(tmp_path: Path):
|
||||
repo = tmp_path / "activitywatch"
|
||||
_, _ = make_git_repo(repo)
|
||||
nested = repo / "aw-server-rust"
|
||||
nested.mkdir()
|
||||
|
||||
assert is_git_checkout(repo)
|
||||
assert not is_git_checkout(nested)
|
||||
|
||||
|
||||
def test_initialize_submodule_rejects_unrelated_checkout(tmp_path: Path):
|
||||
parent = tmp_path / "activitywatch"
|
||||
_, _ = make_git_repo(parent)
|
||||
unrelated = parent / "aw-server-rust"
|
||||
_, _ = make_git_repo(unrelated)
|
||||
|
||||
with pytest.raises(ValueError, match="refusing unmanaged Git checkout"):
|
||||
initialize_submodule(parent, "aw-server-rust")
|
||||
|
||||
|
||||
def test_initialize_submodule_rejects_old_form_checkout_with_migration_hint(
|
||||
tmp_path: Path,
|
||||
):
|
||||
parent = tmp_path / "activitywatch"
|
||||
_, _ = make_git_repo(parent)
|
||||
server = parent / "aw-server-rust"
|
||||
_, _ = make_git_repo(server)
|
||||
url = "https://github.com/ActivityWatch/aw-server-rust.git"
|
||||
subprocess.run(
|
||||
["git", "-C", str(server), "remote", "add", "origin", url], check=True
|
||||
)
|
||||
(parent / ".gitmodules").write_text(
|
||||
f'[submodule "aw-server-rust"]\n'
|
||||
"\tpath = aw-server-rust\n"
|
||||
f"\turl = {url}\n"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="git submodule absorbgitdirs aw-server-rust"):
|
||||
initialize_submodule(parent, "aw-server-rust")
|
||||
290
scripts/tests/test_configure_tauri_release.py
Normal file
290
scripts/tests/test_configure_tauri_release.py
Normal file
@@ -0,0 +1,290 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
CONFIGURE = ROOT / "scripts/package/configure_tauri_release.py"
|
||||
GENERATOR = ROOT / "scripts/package/generate_latest_json.py"
|
||||
RESEARCH_ENDPOINT = (
|
||||
"https://raw.githubusercontent.com/ActivityWatch/activitywatch/"
|
||||
"research-updates/latest-research.json"
|
||||
)
|
||||
|
||||
sys.path.insert(0, str(ROOT / "scripts/package"))
|
||||
from configure_tauri_release import configure, msi_rejects # noqa: E402
|
||||
|
||||
|
||||
def key(material=b"r" * 32, comment="fixture", key_id=b"i" * 8):
|
||||
packet = base64.b64encode(b"Ed" + key_id + material).decode()
|
||||
return base64.b64encode(
|
||||
f"untrusted comment: {comment}\n{packet}\n".encode()
|
||||
).decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(tmp_path):
|
||||
# Exercise the real pinned Tauri config, including its standard public key.
|
||||
source = ROOT / "aw-tauri/src-tauri/tauri.conf.json"
|
||||
assert source.exists(), "Initialize the aw-tauri submodule before these tests"
|
||||
path = tmp_path / "tauri.conf.json"
|
||||
path.write_bytes(source.read_bytes())
|
||||
return path
|
||||
|
||||
|
||||
def run_configure(config, *args, public="", private="", version="v0.14.0b5-research"):
|
||||
env = dict(
|
||||
os.environ,
|
||||
TAURI_UPDATER_PUBLIC_KEY_RESEARCH=public,
|
||||
TAURI_SIGNING_PRIVATE_KEY=private,
|
||||
)
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(CONFIGURE),
|
||||
"--config",
|
||||
str(config),
|
||||
"--version",
|
||||
version,
|
||||
*args,
|
||||
],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,rejects",
|
||||
[
|
||||
("0.14.0", False),
|
||||
("0.14.0-5", False),
|
||||
("0.14.0-beta.5", True),
|
||||
("0.14.0-dev.gabc1234", True),
|
||||
("0.14.0-rc.1", True),
|
||||
],
|
||||
)
|
||||
def test_msi_rejects(version, rejects):
|
||||
assert msi_rejects(version) is rejects
|
||||
|
||||
|
||||
def test_windows_drops_msi_for_non_numeric_prerelease(config):
|
||||
# v0.14.0b5 -> "0.14.0-beta.5", which the msi (WiX) bundler rejects.
|
||||
configure(
|
||||
config, "v0.14.0b5", research=False, require_signing_key=False, platform="win32"
|
||||
)
|
||||
after = json.loads(config.read_text())
|
||||
assert after["version"] == "0.14.0-beta.5"
|
||||
assert after["bundle"]["targets"] == ["nsis"]
|
||||
|
||||
|
||||
def test_non_windows_keeps_all_targets_for_non_numeric_prerelease(config):
|
||||
configure(
|
||||
config,
|
||||
"v0.14.0b5",
|
||||
research=False,
|
||||
require_signing_key=False,
|
||||
platform="darwin",
|
||||
)
|
||||
after = json.loads(config.read_text())
|
||||
assert after["bundle"]["targets"] == "all"
|
||||
|
||||
|
||||
def test_windows_keeps_all_targets_for_msi_safe_version(config):
|
||||
configure(
|
||||
config, "v0.14.0", research=False, require_signing_key=False, platform="win32"
|
||||
)
|
||||
after = json.loads(config.read_text())
|
||||
assert after["version"] == "0.14.0"
|
||||
assert after["bundle"]["targets"] == "all"
|
||||
|
||||
|
||||
def test_standard_version_changes_without_changing_update_trust(config):
|
||||
before = json.loads(config.read_text())
|
||||
result = run_configure(config, version="v0.14.0rc2")
|
||||
assert result.returncode == 0, result.stderr
|
||||
after = json.loads(config.read_text())
|
||||
assert after["version"] == "0.14.0-rc.2"
|
||||
before["version"] = after["version"]
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_research_release_replaces_endpoint_and_key(config):
|
||||
result = run_configure(
|
||||
config,
|
||||
"--research",
|
||||
"--require-signing-key",
|
||||
public=key(),
|
||||
private="fixture-secret-presence-only",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
after = json.loads(config.read_text())
|
||||
assert after["version"] == "0.14.0-beta.5"
|
||||
assert after["plugins"]["updater"]["endpoints"] == [RESEARCH_ENDPOINT]
|
||||
assert after["plugins"]["updater"]["pubkey"] == key()
|
||||
|
||||
|
||||
def test_secretless_research_smoke_build_has_no_update_trust(config):
|
||||
result = run_configure(config, "--research")
|
||||
assert result.returncode == 0, result.stderr
|
||||
updater = json.loads(config.read_text())["plugins"]["updater"]
|
||||
assert updater["endpoints"] == []
|
||||
assert updater["pubkey"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("public,private", [("", ""), (key(), ""), ("", "secret")])
|
||||
def test_release_missing_either_key_fails_without_modifying_config(
|
||||
config, public, private
|
||||
):
|
||||
before = config.read_bytes()
|
||||
result = run_configure(
|
||||
config, "--research", "--require-signing-key", public=public, private=private
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "require their own public and signing keys" in result.stderr
|
||||
assert config.read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change_envelope", [False, True])
|
||||
def test_same_standard_key_rejected_even_with_changed_comment_and_id(
|
||||
config, change_envelope
|
||||
):
|
||||
before = config.read_bytes()
|
||||
standard = json.loads(before)["plugins"]["updater"]["pubkey"]
|
||||
if change_envelope:
|
||||
packet = base64.b64decode(base64.b64decode(standard).decode().splitlines()[1])
|
||||
standard = key(packet[10:], comment="research", key_id=b"j" * 8)
|
||||
result = run_configure(config, "--research", public=standard)
|
||||
assert result.returncode != 0
|
||||
assert "must differ" in result.stderr
|
||||
assert config.read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_key", ["bad-base64", "dGVzdA==", key(b"short")])
|
||||
def test_malformed_public_key_rejected(config, bad_key):
|
||||
before = config.read_bytes()
|
||||
result = run_configure(config, "--research", public=bad_key)
|
||||
assert result.returncode != 0
|
||||
assert config.read_bytes() == before
|
||||
|
||||
|
||||
def test_endpoint_drift_fails_closed(config):
|
||||
data = json.loads(config.read_text())
|
||||
data["plugins"]["updater"]["endpoints"].append("https://example.com/latest.json")
|
||||
config.write_text(json.dumps(data))
|
||||
before = config.read_bytes()
|
||||
result = run_configure(config, "--research", public=key())
|
||||
assert result.returncode != 0
|
||||
assert "Unexpected source updater endpoint" in result.stderr
|
||||
assert config.read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event,ref,research,success",
|
||||
[
|
||||
("push", "refs/tags/v0.14.0b5-research", "true", False),
|
||||
("workflow_dispatch", "refs/heads/master", "true", False),
|
||||
("pull_request", "refs/pull/1/merge", "true", True),
|
||||
("push", "refs/heads/master", "true", True),
|
||||
("push", "refs/tags/v0.14.0", "false", True),
|
||||
],
|
||||
)
|
||||
def test_workflow_requires_keys_only_for_publishable_research_builds(
|
||||
config,
|
||||
event,
|
||||
ref,
|
||||
research,
|
||||
success,
|
||||
):
|
||||
workflow = (ROOT / ".github/workflows/release.yml").read_text()
|
||||
step = workflow.split(
|
||||
" - name: Configure Tauri release version and update channel\n"
|
||||
)[1]
|
||||
script = textwrap.dedent(
|
||||
step.split(" run: |\n")[1].split(" env:\n")[0]
|
||||
)
|
||||
work = config.parent / "build"
|
||||
shutil.copytree(ROOT / "scripts/package", work / "scripts/package")
|
||||
target = work / "aw-tauri/src-tauri/tauri.conf.json"
|
||||
target.parent.mkdir(parents=True)
|
||||
shutil.copyfile(config, target)
|
||||
env = dict(
|
||||
os.environ,
|
||||
AW_RESEARCH_EDITION=research,
|
||||
GITHUB_REF=ref,
|
||||
GITHUB_EVENT_NAME=event,
|
||||
VERSION_NO_V="0.14.0b5",
|
||||
TAURI_UPDATER_PUBLIC_KEY_RESEARCH="",
|
||||
TAURI_SIGNING_PRIVATE_KEY="",
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", "-e", "-c", script], cwd=work, env=env, text=True, capture_output=True
|
||||
)
|
||||
assert (result.returncode == 0) == success, result.stderr
|
||||
if success and research == "true":
|
||||
assert json.loads(target.read_text())["plugins"]["updater"]["endpoints"] == []
|
||||
if not success:
|
||||
assert target.read_bytes() == config.read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edition", ["standard", "research"])
|
||||
def test_manifest_and_compiled_config_share_version_and_partition_assets(
|
||||
config, edition
|
||||
):
|
||||
tag = "v0.14.0b5" + ("-research" if edition == "research" else "")
|
||||
result = run_configure(
|
||||
config,
|
||||
*(["--research"] if edition == "research" else []),
|
||||
version=tag,
|
||||
public=key(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for token in ("", "-research"):
|
||||
bundle = (
|
||||
config.parent / f"activitywatch-tauri{token}-0.14.0b5-linux-x86_64.AppImage"
|
||||
)
|
||||
bundle.write_bytes(b"fixture, not a cryptographic verification")
|
||||
bundle.with_suffix(".AppImage.sig").write_text("sig" + token)
|
||||
output = config.parent / (
|
||||
"latest-research.json" if edition == "research" else "latest.json"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(GENERATOR),
|
||||
"--version",
|
||||
tag,
|
||||
"--tag",
|
||||
tag,
|
||||
"--edition",
|
||||
edition,
|
||||
"--repo",
|
||||
"ActivityWatch/activitywatch",
|
||||
"--notes",
|
||||
"fixture",
|
||||
"--dist",
|
||||
str(config.parent),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
manifest = json.loads(output.read_text())
|
||||
assert (
|
||||
manifest["version"]
|
||||
== json.loads(config.read_text())["version"]
|
||||
== "0.14.0-beta.5"
|
||||
)
|
||||
assert list(manifest["platforms"]) == ["linux-x86_64"]
|
||||
target = manifest["platforms"]["linux-x86_64"]
|
||||
assert ("-research" in target["url"]) == (edition == "research")
|
||||
assert f"/releases/download/{tag}/" in target["url"]
|
||||
assert target["signature"] == "sig" + ("-research" if edition == "research" else "")
|
||||
162
scripts/tests/test_emit_research_category_preset.py
Normal file
162
scripts/tests/test_emit_research_category_preset.py
Normal file
@@ -0,0 +1,162 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
path = Path(__file__).parents[1] / f"{name}.py"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
emitter = _load("emit_research_category_preset")
|
||||
patcher = _load("patch_research_edition_config")
|
||||
|
||||
|
||||
def test_preset_covers_every_category_in_the_watcher_map():
|
||||
"""Preset and watcher map must share one source, or the UI drifts from the data."""
|
||||
expected = {c for _, c in patcher.CATEGORY_MAP} | set(
|
||||
patcher.APP_CATEGORY_MAP.values()
|
||||
)
|
||||
|
||||
names = {c["name"][0] for c in emitter.build_preset()["categories"]}
|
||||
|
||||
assert names == expected
|
||||
|
||||
|
||||
def _classify_event(data: dict[str, str]) -> str | None:
|
||||
"""Mirror aw-webui's parsed-rule contract and equal-depth matching."""
|
||||
matches: list[str] = []
|
||||
for category in emitter.build_preset()["categories"]:
|
||||
rule = category["rule"]
|
||||
flags = re.IGNORECASE if rule["ignore_case"] else 0
|
||||
if any(
|
||||
key in data and re.search(rule["regex"], data[key], flags)
|
||||
for key in ("app", "title")
|
||||
):
|
||||
matches.append(category["name"][0])
|
||||
assert len(matches) <= 1, (
|
||||
f"aw-webui cannot prioritize equal-depth matches: {matches}"
|
||||
)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def test_rules_match_browser_categories_and_raw_app_aliases():
|
||||
"""Browser titles and retained app names resolve to the same taxonomy."""
|
||||
assert _classify_event({"app": "Safari", "title": "Work & Productivity"}) == (
|
||||
"Work & Productivity"
|
||||
)
|
||||
assert _classify_event({"app": "Microsoft Word"}) == "Work & Productivity"
|
||||
assert _classify_event({"app": "SPOTIFY"}) == "Music & Audio"
|
||||
assert _classify_event({"app": "Terminal"}) == "Excluded"
|
||||
assert _classify_event({"app": "Some Unmapped Program"}) is None
|
||||
|
||||
|
||||
def test_rules_are_exact_and_scoped_to_approved_fields():
|
||||
categories = {
|
||||
category["name"][0]: category
|
||||
for category in emitter.build_preset()["categories"]
|
||||
}
|
||||
work_rule = categories["Work & Productivity"]["rule"]
|
||||
|
||||
assert re.search(work_rule["regex"], "Microsoft Word", re.IGNORECASE)
|
||||
assert not re.search(work_rule["regex"], "Microsoft Word extra", re.IGNORECASE)
|
||||
assert _classify_event({"hostname": "Microsoft Word"}) is None
|
||||
|
||||
|
||||
def test_rules_use_only_fields_preserved_by_the_aw_webui_preset_parser():
|
||||
"""The oldest pinned carrier strips unknown keys; behavior must survive that."""
|
||||
for category in emitter.build_preset()["categories"]:
|
||||
assert set(category["rule"]) <= {"type", "regex", "ignore_case"}
|
||||
|
||||
|
||||
def test_excluded_rule_only_matches_explicitly_excluded_apps():
|
||||
categories = {
|
||||
category["name"][0]: category
|
||||
for category in emitter.build_preset()["categories"]
|
||||
}
|
||||
|
||||
excluded = categories["Excluded"]["rule"]["regex"]
|
||||
assert re.search(excluded, "Terminal", re.IGNORECASE)
|
||||
assert not re.search(excluded, "Outlook", re.IGNORECASE)
|
||||
assert not re.search(excluded, "Some Unmapped Program", re.IGNORECASE)
|
||||
|
||||
|
||||
def test_every_known_value_matches_exactly_one_rule_after_parser_projection():
|
||||
"""Catch overlaps the production classifier cannot resolve at equal depth."""
|
||||
values = {category["name"][0] for category in emitter.build_preset()["categories"]}
|
||||
values |= set(patcher.APP_CATEGORY_MAP)
|
||||
|
||||
for value in values:
|
||||
assert _classify_event({"app": value}) is not None
|
||||
|
||||
|
||||
def test_escaping_is_portable_to_javascript_unicode_mode():
|
||||
"""`re.escape` is unusable here: it emits `\\ ` and `\\&`.
|
||||
|
||||
Those are valid in Python but are *invalid identity escapes* in JavaScript
|
||||
unicode-mode regex, so `new RegExp(r, "u")` throws on 14 of the 18 study
|
||||
categories. aw-webui compiles without the `u` flag today, which is the only
|
||||
reason `re.escape` would appear to work -- and the failure would present as
|
||||
"categories don't show", indistinguishable from the bug this preset fixes.
|
||||
"""
|
||||
for category in emitter.build_preset()["categories"]:
|
||||
pattern = category["rule"]["regex"]
|
||||
escaped = {pattern[i + 1] for i, ch in enumerate(pattern[:-1]) if ch == "\\"}
|
||||
|
||||
assert escaped <= emitter._REGEX_METACHARACTERS, (
|
||||
f"{pattern!r} escapes characters JavaScript rejects under the u flag"
|
||||
)
|
||||
|
||||
|
||||
def test_escape_portable_still_escapes_real_metacharacters():
|
||||
"""A future category name containing a metacharacter must not become a wildcard."""
|
||||
assert emitter.escape_portable("a.b") == r"a\.b"
|
||||
assert emitter.escape_portable("x(y)") == r"x\(y\)"
|
||||
assert emitter.escape_portable("Shopping - Goods") == "Shopping - Goods"
|
||||
assert re.fullmatch(emitter.escape_portable("a.b"), "a.b")
|
||||
assert not re.fullmatch(emitter.escape_portable("a.b"), "axb")
|
||||
|
||||
|
||||
def test_exact_alternation_is_stable_and_whole_value():
|
||||
pattern = emitter.exact_alternation({"zoom.us", "Zoom"})
|
||||
|
||||
assert pattern == r"^(?:Zoom|zoom\.us)$"
|
||||
assert re.fullmatch(pattern, "Zoom")
|
||||
assert re.fullmatch(pattern, "zoom.us")
|
||||
assert not re.fullmatch(pattern, "zoom.us meeting")
|
||||
|
||||
|
||||
def test_output_is_stable_across_runs():
|
||||
"""CI reruns must not produce a different preset from the same map."""
|
||||
assert emitter.build_preset() == emitter.build_preset()
|
||||
|
||||
|
||||
def test_serialises_without_newlines_for_github_env():
|
||||
"""The value is written straight into $GITHUB_ENV, which ends at a newline."""
|
||||
payload = json.dumps([emitter.build_preset()], separators=(",", ":"))
|
||||
|
||||
assert "\n" not in payload
|
||||
assert json.loads(payload)[0]["id"] == "research-study"
|
||||
|
||||
|
||||
def test_every_category_has_a_distinct_hex_color():
|
||||
"""Activity view has no name-hash fallback; missing data.color renders grey."""
|
||||
categories = emitter.build_preset()["categories"]
|
||||
colors = [category["data"]["color"] for category in categories]
|
||||
|
||||
assert len(colors) == len(categories)
|
||||
assert len(set(colors)) == len(colors)
|
||||
for color in colors:
|
||||
assert re.fullmatch(r"#[0-9A-F]{6}", color)
|
||||
|
||||
|
||||
def test_color_table_matches_the_study_taxonomy():
|
||||
"""A new map entry without a color must fail the build, not ship unstyled."""
|
||||
names = {category["name"][0] for category in emitter.build_preset()["categories"]}
|
||||
|
||||
assert set(emitter.CATEGORY_COLORS) == names
|
||||
191
scripts/tests/test_generate_latest_json.py
Normal file
191
scripts/tests/test_generate_latest_json.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
GENERATOR = Path(__file__).parents[1] / "package" / "generate_latest_json.py"
|
||||
|
||||
|
||||
def _load():
|
||||
spec = importlib.util.spec_from_file_location("generate_latest_json", GENERATOR)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
gen = _load()
|
||||
|
||||
|
||||
def _write_sig(root: Path, name: str, signature: str = "sig-bytes") -> None:
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(signature + "\n")
|
||||
# Matching binary next to the .sig, as the packaging step copies both.
|
||||
path.with_name(name[: -len(".sig")]).write_bytes(b"bundle")
|
||||
|
||||
|
||||
def test_normalize_version_strips_v_and_research_suffix():
|
||||
assert gen.normalize_version("v0.14.0b4") == "0.14.0b4"
|
||||
assert gen.normalize_version("0.14.0b4") == "0.14.0b4"
|
||||
assert gen.normalize_version("v0.14.0b4-research") == "0.14.0b4"
|
||||
assert gen.normalize_version("0.14.0b4-research") == "0.14.0b4"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
("v0.14.0", "0.14.0"),
|
||||
("v0.14.0b5-research", "0.14.0-beta.5"),
|
||||
("0.14.0a1", "0.14.0-alpha.1"),
|
||||
("0.14.0rc2", "0.14.0-rc.2"),
|
||||
("v0.14.0.dev-0123456", "0.14.0-dev.g0123456"),
|
||||
("v0.14.0b5.dev-abcdef0", "0.14.0-beta.5.dev.gabcdef0"),
|
||||
],
|
||||
)
|
||||
def test_tauri_version(version, expected):
|
||||
assert gen.tauri_version(version) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["0.14", "0.14.0b01", "00.14.0", "0.14.0oops", ""])
|
||||
def test_tauri_version_refuses_unrecognized_labels(version):
|
||||
with pytest.raises(ValueError):
|
||||
gen.tauri_version(version)
|
||||
|
||||
|
||||
def test_infer_edition_from_tag_or_explicit_flag():
|
||||
assert gen.infer_edition("v0.14.0") == "standard"
|
||||
assert gen.infer_edition("v0.14.0b4") == "standard"
|
||||
assert gen.infer_edition("v0.14.0b4-research") == "research"
|
||||
assert gen.infer_edition("v0.14.0b4-research", "standard") == "standard"
|
||||
assert gen.infer_edition("v0.14.0", "research") == "research"
|
||||
|
||||
|
||||
def test_asset_prefix_and_manifest_filename_partition_editions():
|
||||
assert gen.asset_prefix("standard") == "activitywatch-tauri"
|
||||
assert gen.asset_prefix("research") == "activitywatch-tauri-research"
|
||||
assert gen.manifest_filename("standard") == "latest.json"
|
||||
assert gen.manifest_filename("research") == "latest-research.json"
|
||||
|
||||
|
||||
def test_standard_collect_ignores_research_artifacts(tmp_path):
|
||||
_write_sig(
|
||||
tmp_path,
|
||||
"activitywatch-tauri-0.14.0b4-darwin-aarch64.app.tar.gz.sig",
|
||||
"std-sig",
|
||||
)
|
||||
_write_sig(
|
||||
tmp_path,
|
||||
"activitywatch-tauri-research-0.14.0b4-darwin-aarch64.app.tar.gz.sig",
|
||||
"research-sig",
|
||||
)
|
||||
|
||||
platforms = gen.collect_platforms(
|
||||
str(tmp_path),
|
||||
"0.14.0b4",
|
||||
"ActivityWatch/activitywatch",
|
||||
"v0.14.0b4",
|
||||
"standard",
|
||||
)
|
||||
|
||||
assert list(platforms) == ["darwin-aarch64"]
|
||||
assert platforms["darwin-aarch64"]["signature"] == "std-sig"
|
||||
assert platforms["darwin-aarch64"]["url"].endswith(
|
||||
"/v0.14.0b4/activitywatch-tauri-0.14.0b4-darwin-aarch64.app.tar.gz"
|
||||
)
|
||||
|
||||
|
||||
def test_research_collect_ignores_standard_artifacts(tmp_path):
|
||||
_write_sig(
|
||||
tmp_path,
|
||||
"activitywatch-tauri-0.14.0b4-linux-x86_64.AppImage.tar.gz.sig",
|
||||
"std-sig",
|
||||
)
|
||||
_write_sig(
|
||||
tmp_path,
|
||||
"activitywatch-tauri-research-0.14.0b4-linux-x86_64.AppImage.tar.gz.sig",
|
||||
"research-sig",
|
||||
)
|
||||
|
||||
platforms = gen.collect_platforms(
|
||||
str(tmp_path),
|
||||
"0.14.0b4",
|
||||
"ActivityWatch/activitywatch",
|
||||
"v0.14.0b4-research",
|
||||
"research",
|
||||
)
|
||||
|
||||
assert list(platforms) == ["linux-x86_64"]
|
||||
assert platforms["linux-x86_64"]["signature"] == "research-sig"
|
||||
assert platforms["linux-x86_64"]["url"].endswith(
|
||||
"/v0.14.0b4-research/"
|
||||
"activitywatch-tauri-research-0.14.0b4-linux-x86_64.AppImage.tar.gz"
|
||||
)
|
||||
|
||||
|
||||
def test_windows_prefers_nsis_over_msi_regardless_of_walk_order(tmp_path):
|
||||
_write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.msi.zip.sig", "msi-sig")
|
||||
_write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig", "nsis-sig")
|
||||
|
||||
platforms = gen.collect_platforms(
|
||||
str(tmp_path),
|
||||
"0.14.0",
|
||||
"ActivityWatch/activitywatch",
|
||||
"v0.14.0",
|
||||
"standard",
|
||||
)
|
||||
|
||||
assert list(platforms) == ["windows-x86_64"]
|
||||
assert platforms["windows-x86_64"]["signature"] == "nsis-sig"
|
||||
assert platforms["windows-x86_64"]["url"].endswith(
|
||||
"/v0.14.0/activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip"
|
||||
)
|
||||
|
||||
|
||||
def test_main_writes_manifest_and_refuses_empty(tmp_path):
|
||||
_write_sig(tmp_path, "activitywatch-tauri-0.14.0-windows-x86_64.nsis.zip.sig")
|
||||
out = tmp_path / "latest.json"
|
||||
|
||||
gen.main(
|
||||
[
|
||||
"--version",
|
||||
"v0.14.0",
|
||||
"--notes",
|
||||
"ActivityWatch v0.14.0",
|
||||
"--repo",
|
||||
"ActivityWatch/activitywatch",
|
||||
"--tag",
|
||||
"v0.14.0",
|
||||
"--edition",
|
||||
"standard",
|
||||
"--dist",
|
||||
str(tmp_path),
|
||||
"--output",
|
||||
str(out),
|
||||
]
|
||||
)
|
||||
|
||||
manifest = json.loads(out.read_text())
|
||||
assert manifest["version"] == "0.14.0"
|
||||
assert "windows-x86_64" in manifest["platforms"]
|
||||
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
with pytest.raises(SystemExit, match="No standard updater artifacts"):
|
||||
gen.main(
|
||||
[
|
||||
"--version",
|
||||
"0.14.0",
|
||||
"--notes",
|
||||
"none",
|
||||
"--repo",
|
||||
"ActivityWatch/activitywatch",
|
||||
"--tag",
|
||||
"v0.14.0",
|
||||
"--dist",
|
||||
str(empty),
|
||||
"--output",
|
||||
str(tmp_path / "latest.json"),
|
||||
]
|
||||
)
|
||||
223
scripts/tests/test_patch_research_edition_config.py
Normal file
223
scripts/tests/test_patch_research_edition_config.py
Normal file
@@ -0,0 +1,223 @@
|
||||
import ast
|
||||
import importlib.util
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "patch_research_edition_config.py"
|
||||
SPEC = importlib.util.spec_from_file_location("patch_research_edition_config", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
patcher = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(patcher)
|
||||
|
||||
|
||||
# Current config.py reads both maps. The Research Edition patch deliberately
|
||||
# leaves the app map empty so approved application names survive capture.
|
||||
RUNTIME_LOOKUPS = """
|
||||
|
||||
def parse_args():
|
||||
parsed_args.research_category_map = dict(config.get("research_category_map", {}))
|
||||
parsed_args.research_app_category_map = dict(config.get("research_app_category_map", {}))
|
||||
"""
|
||||
|
||||
# Layout before aw-watcher-window#137: the research tables live in
|
||||
# default_config, section-prefixed.
|
||||
PRE_137 = (
|
||||
'''default_config = """
|
||||
[aw-watcher-window]
|
||||
poll_time = 1.0
|
||||
research_enabled = false
|
||||
|
||||
[aw-watcher-window.research_category_map]
|
||||
|
||||
[aw-watcher-window.research_app_category_map]
|
||||
""".strip()
|
||||
'''
|
||||
+ RUNTIME_LOOKUPS
|
||||
)
|
||||
|
||||
# Layout since aw-watcher-window#137: research knobs moved to their own template
|
||||
# so they are not persisted into every fresh install's config, and a comment
|
||||
# documents the release-time rewrite -- including the literal flag text.
|
||||
POST_137 = (
|
||||
'''default_config = """
|
||||
[aw-watcher-window]
|
||||
poll_time = 1.0
|
||||
""".strip()
|
||||
|
||||
# The Research Edition release build rewrites the flag below with
|
||||
# sed -i 's/^research_enabled = false$/research_enabled = true/'
|
||||
# Keep that line at column 0 and byte-identical.
|
||||
research_defaults = """
|
||||
research_enabled = false
|
||||
""".strip()
|
||||
'''
|
||||
+ RUNTIME_LOOKUPS
|
||||
)
|
||||
|
||||
|
||||
def _string_constant(source: str, name: str) -> str:
|
||||
"""Return the value of a module-level string assignment, unwrapping .strip()."""
|
||||
for node in ast.parse(source).body:
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and getattr(node.targets[0], "id", None) == name
|
||||
):
|
||||
value = node.value
|
||||
if isinstance(value, ast.Call): # `""" ... """.strip()`
|
||||
value = value.func.value
|
||||
return ast.literal_eval(value)
|
||||
raise AssertionError(f"{name} not found")
|
||||
|
||||
|
||||
def test_pre_137_layout_injects_browser_map_and_preserves_app_names():
|
||||
result = patcher.patch_config(PRE_137)
|
||||
|
||||
ast.parse(result)
|
||||
|
||||
section = tomllib.loads(_string_constant(result, "default_config"))[
|
||||
"aw-watcher-window"
|
||||
]
|
||||
assert section["research_enabled"] is True
|
||||
assert section["research_category_map"]["svenskaspel.se"] == "Sensitive / Excluded"
|
||||
assert section["research_app_category_map"] == {}
|
||||
|
||||
|
||||
def test_post_137_layout_injects_only_browser_map_into_research_defaults():
|
||||
"""The tables must land in research_defaults, with unprefixed headers.
|
||||
|
||||
research_defaults is parsed standalone and merged into the
|
||||
[aw-watcher-window] section key by key, so a section-prefixed header there
|
||||
would produce a nested `aw-watcher-window` key that nothing reads.
|
||||
"""
|
||||
result = patcher.patch_config(POST_137)
|
||||
|
||||
ast.parse(result)
|
||||
|
||||
defaults = tomllib.loads(_string_constant(result, "research_defaults"))
|
||||
assert "aw-watcher-window" not in defaults
|
||||
assert defaults["research_enabled"] is True
|
||||
assert defaults["research_category_map"]["svenskaspel.se"] == "Sensitive / Excluded"
|
||||
# An empty table is emitted so upgrades from an earlier Research Edition
|
||||
# (which may have had this map populated) explicitly clear it.
|
||||
assert defaults["research_app_category_map"] == {}
|
||||
|
||||
|
||||
def test_post_137_emits_explicit_empty_app_map_to_clear_legacy_entries():
|
||||
"""The patched research_defaults must include an explicit empty app-category table.
|
||||
|
||||
An upgrade from an earlier Research Edition may have a populated
|
||||
research_app_category_map in the user's saved config. Because research_defaults
|
||||
is merged into [aw-watcher-window] key-by-key, omitting the table would leave
|
||||
legacy entries intact, so application names would continue to be replaced with
|
||||
categories, defeating the primary behavior change. The explicit empty section
|
||||
header ensures the watcher's merge logic resets the key.
|
||||
"""
|
||||
result = patcher.patch_config(POST_137)
|
||||
|
||||
defaults_str = _string_constant(result, "research_defaults")
|
||||
# The literal section header must appear so older installs see an explicit reset.
|
||||
assert "[research_app_category_map]" in defaults_str
|
||||
assert tomllib.loads(defaults_str)["research_app_category_map"] == {}
|
||||
|
||||
|
||||
def test_patched_config_matches_the_pinned_watcher_storage_contract():
|
||||
"""Approved app names survive, while browser content and every URL do not."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
config_path = root / "aw-watcher-window/aw_watcher_window/config.py"
|
||||
filter_path = root / "aw-watcher-window/aw_watcher_window/research_filter.py"
|
||||
if not config_path.is_file() or not filter_path.is_file():
|
||||
pytest.skip("aw-watcher-window not checked out")
|
||||
|
||||
patched = patcher.patch_config(config_path.read_text(encoding="utf-8"))
|
||||
defaults = tomllib.loads(_string_constant(patched, "research_defaults"))
|
||||
|
||||
spec = importlib.util.spec_from_file_location("research_filter", filter_path)
|
||||
assert spec and spec.loader
|
||||
research_filter = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(research_filter)
|
||||
|
||||
word = research_filter.transform(
|
||||
{
|
||||
"app": "Microsoft Word",
|
||||
"title": "confidential-draft.docx",
|
||||
"url": "file:///private/confidential-draft.docx",
|
||||
},
|
||||
defaults["research_category_map"],
|
||||
defaults.get("research_app_category_map"),
|
||||
)
|
||||
safari = research_filter.transform(
|
||||
{
|
||||
"app": "Safari",
|
||||
"title": "Confidential draft - Google Docs",
|
||||
"url": "https://docs.google.com/document/private-id",
|
||||
},
|
||||
defaults["research_category_map"],
|
||||
defaults.get("research_app_category_map"),
|
||||
)
|
||||
|
||||
assert word == {"app": "Microsoft Word"}
|
||||
assert safari == {"app": "Safari", "title": "Work & Productivity"}
|
||||
|
||||
|
||||
def test_flag_rewrite_is_line_anchored_and_spares_the_sed_comment():
|
||||
"""The #137 comment contains the literal flag text, and precedes the real flag.
|
||||
|
||||
An unanchored replace patches the comment and leaves research_enabled = false
|
||||
-- a green build shipping a Research Edition with research silently disabled.
|
||||
"""
|
||||
result = patcher.patch_config(POST_137)
|
||||
|
||||
assert "s/^research_enabled = false$/research_enabled = true/" in result
|
||||
assert (
|
||||
tomllib.loads(_string_constant(result, "research_defaults"))["research_enabled"]
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_app_map_runtime_support_is_not_required_for_name_preserving_build():
|
||||
without_lookup = POST_137.replace(
|
||||
'config.get("research_app_category_map"', 'config.get("something_else"'
|
||||
)
|
||||
|
||||
result = patcher.patch_config(without_lookup)
|
||||
|
||||
defaults = tomllib.loads(_string_constant(result, "research_defaults"))
|
||||
# An empty app-map table is always emitted to clear legacy populated maps;
|
||||
# the watcher ignores it if it does not read research_app_category_map.
|
||||
assert defaults["research_app_category_map"] == {}
|
||||
|
||||
|
||||
def test_fails_closed_when_flag_is_missing():
|
||||
with pytest.raises(ValueError, match="research_enabled = false"):
|
||||
patcher.patch_config(
|
||||
f'default_config = """\n[aw-watcher-window]\n"""{RUNTIME_LOOKUPS}'
|
||||
)
|
||||
|
||||
|
||||
def test_fails_closed_on_ambiguous_flag():
|
||||
"""Two line-anchored flags mean an unknown layout -- refuse rather than guess."""
|
||||
ambiguous = POST_137.replace(
|
||||
"research_enabled = false\n",
|
||||
"research_enabled = false\nresearch_enabled = false\n",
|
||||
1,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="found 2"):
|
||||
patcher.patch_config(ambiguous)
|
||||
|
||||
|
||||
def test_pre_137_layout_does_not_require_app_section():
|
||||
missing_app_table = PRE_137.replace(
|
||||
"\n[aw-watcher-window.research_app_category_map]\n", "\n"
|
||||
)
|
||||
|
||||
result = patcher.patch_config(missing_app_table)
|
||||
|
||||
section = tomllib.loads(_string_constant(result, "default_config"))[
|
||||
"aw-watcher-window"
|
||||
]
|
||||
assert section["research_enabled"] is True
|
||||
assert "research_app_category_map" not in section
|
||||
139
scripts/tests/test_patch_research_edition_export.py
Normal file
139
scripts/tests/test_patch_research_edition_export.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "patch_research_edition_export.py"
|
||||
SPEC = importlib.util.spec_from_file_location("patch_research_edition_export", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
patcher = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(patcher)
|
||||
|
||||
CONFIG_PATCHER_PATH = Path(__file__).parents[1] / "patch_research_edition_config.py"
|
||||
CONFIG_SPEC = importlib.util.spec_from_file_location(
|
||||
"patch_research_edition_config", CONFIG_PATCHER_PATH
|
||||
)
|
||||
assert CONFIG_SPEC and CONFIG_SPEC.loader
|
||||
config_patcher = importlib.util.module_from_spec(CONFIG_SPEC)
|
||||
CONFIG_SPEC.loader.exec_module(config_patcher)
|
||||
|
||||
|
||||
def _write_tree(tmp_path: Path, export: str, bucket: str, mod: str) -> Path:
|
||||
endpoints = tmp_path / "aw-server-rust" / "aw-server" / "src" / "endpoints"
|
||||
endpoints.mkdir(parents=True)
|
||||
(endpoints / "export.rs").write_text(export, encoding="utf-8")
|
||||
(endpoints / "bucket.rs").write_text(bucket, encoding="utf-8")
|
||||
(endpoints / "mod.rs").write_text(mod, encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
|
||||
EXPORT_SRC = """use std::collections::HashMap;
|
||||
|
||||
pub fn buckets_export() {
|
||||
for (bid, mut bucket) in buckets.drain() {
|
||||
export.buckets.insert(bid, bucket);
|
||||
}
|
||||
|
||||
Ok(export.into())
|
||||
}
|
||||
"""
|
||||
|
||||
BUCKET_SRC = """pub fn bucket_export() {
|
||||
export.buckets.insert(bucket_id.into(), bucket);
|
||||
|
||||
Ok(export.into())
|
||||
}
|
||||
"""
|
||||
|
||||
MOD_SRC = """mod util;
|
||||
mod export;
|
||||
mod hostcheck;
|
||||
"""
|
||||
|
||||
|
||||
def test_patch_inserts_module_and_both_call_sites(tmp_path: Path):
|
||||
root = _write_tree(tmp_path, EXPORT_SRC, BUCKET_SRC, MOD_SRC)
|
||||
|
||||
patcher.patch_tree(root)
|
||||
|
||||
export = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
bucket = (root / "aw-server-rust/aw-server/src/endpoints/bucket.rs").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
copied = root / "aw-server-rust/aw-server/src/endpoints/export_sanitize.rs"
|
||||
|
||||
assert copied.is_file()
|
||||
assert "mod export_sanitize;" in mod
|
||||
assert patcher.MARKER in export
|
||||
assert patcher.MARKER in bucket
|
||||
assert "sanitize_buckets_export" in export
|
||||
assert "sanitize_buckets_export" in bucket
|
||||
assert "Status::Conflict" in export
|
||||
assert "Status::Conflict" in bucket
|
||||
|
||||
|
||||
def test_patch_is_idempotent(tmp_path: Path):
|
||||
root = _write_tree(tmp_path, EXPORT_SRC, BUCKET_SRC, MOD_SRC)
|
||||
patcher.patch_tree(root)
|
||||
first = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
patcher.patch_tree(root)
|
||||
second = (root / "aw-server-rust/aw-server/src/endpoints/export.rs").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert first == second
|
||||
mod = (root / "aw-server-rust/aw-server/src/endpoints/mod.rs").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert mod.count("mod export_sanitize;") == 1
|
||||
|
||||
|
||||
def test_patch_fails_closed_without_export_marker(tmp_path: Path):
|
||||
root = _write_tree(tmp_path, "fn buckets_export() {}\n", BUCKET_SRC, MOD_SRC)
|
||||
with pytest.raises(ValueError, match="insertion point"):
|
||||
patcher.patch_tree(root)
|
||||
|
||||
|
||||
def test_live_tree_is_patchable_or_already_patched():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
export = root / "aw-server-rust/aw-server/src/endpoints/export.rs"
|
||||
bucket = root / "aw-server-rust/aw-server/src/endpoints/bucket.rs"
|
||||
if not export.is_file() or not bucket.is_file():
|
||||
pytest.skip("aw-server-rust not checked out")
|
||||
export_text = export.read_text(encoding="utf-8")
|
||||
bucket_text = bucket.read_text(encoding="utf-8")
|
||||
assert (
|
||||
patcher.MARKER in export_text
|
||||
or export_text.count(patcher.EXPORT_INSERT_NEEDLE) == 1
|
||||
)
|
||||
assert (
|
||||
patcher.MARKER in bucket_text
|
||||
or bucket_text.count(patcher.BUCKET_INSERT_NEEDLE) == 1
|
||||
)
|
||||
|
||||
|
||||
def test_sanitizer_allowlist_covers_config_categories():
|
||||
rust = (
|
||||
Path(__file__).parents[1] / "research_edition" / "export_sanitize.rs"
|
||||
).read_text(encoding="utf-8")
|
||||
expected = {c for _, c in config_patcher.CATEGORY_MAP} | set(
|
||||
config_patcher.APP_CATEGORY_MAP.values()
|
||||
)
|
||||
expected.update({"Excluded", "excluded"})
|
||||
missing = [category for category in expected if f'"{category}"' not in rust]
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_every_research_build_patches_the_export_sanitizer():
|
||||
workflow = (
|
||||
Path(__file__).resolve().parents[2] / ".github" / "workflows" / "release.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert workflow.count("python3 scripts/patch_research_edition_config.py") == 3
|
||||
assert workflow.count("python3 scripts/patch_research_edition_export.py") == 3
|
||||
646
scripts/tests/test_patch_research_edition_profile.py
Normal file
646
scripts/tests/test_patch_research_edition_profile.py
Normal file
@@ -0,0 +1,646 @@
|
||||
import fnmatch
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "patch_research_edition_profile.py"
|
||||
SPEC = importlib.util.spec_from_file_location("patch_research_edition_profile", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
patcher = importlib.util.module_from_spec(SPEC)
|
||||
# dataclasses.dataclass() looks the defining module up in sys.modules (to
|
||||
# resolve string type hints), so it must be registered before exec_module
|
||||
# runs the class body -- otherwise the `Patch` dataclass fails to import.
|
||||
sys.modules[SPEC.name] = patcher
|
||||
SPEC.loader.exec_module(patcher)
|
||||
|
||||
|
||||
def make_fixture_root(tmp_path: Path, target: str) -> Path:
|
||||
"""Build a synthetic tree containing exactly one occurrence of every ``old``.
|
||||
|
||||
Patches for the same path are concatenated in table order, each preceded
|
||||
by a filler line, so every target string appears exactly once per file --
|
||||
the precondition ``apply_patches`` requires to succeed.
|
||||
"""
|
||||
by_path: "dict[str, list[patcher.Patch]]" = {}
|
||||
for patch in patcher.TARGETS[target]:
|
||||
by_path.setdefault(patch.path, []).append(patch)
|
||||
|
||||
for rel_path, patches in by_path.items():
|
||||
file_path = tmp_path / rel_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = "".join(f"# filler\n{p.old}" for p in patches)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
||||
def test_applies_every_site(tmp_path: Path, target: str):
|
||||
root = make_fixture_root(tmp_path, target)
|
||||
patches = patcher.TARGETS[target]
|
||||
|
||||
applied = patcher.apply_patches(root, patches, check=False)
|
||||
|
||||
assert len(applied) == len(patches)
|
||||
|
||||
by_path: "dict[str, list[patcher.Patch]]" = {}
|
||||
for patch in patches:
|
||||
by_path.setdefault(patch.path, []).append(patch)
|
||||
|
||||
for rel_path, file_patches in by_path.items():
|
||||
text = (root / rel_path).read_text(encoding="utf-8")
|
||||
for patch in file_patches:
|
||||
assert patch.new in text
|
||||
if patch.old not in patch.new:
|
||||
assert patch.old not in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
||||
def test_check_mode_writes_nothing(tmp_path: Path, target: str):
|
||||
root = make_fixture_root(tmp_path, target)
|
||||
patches = patcher.TARGETS[target]
|
||||
|
||||
before = {
|
||||
rel_path: (root / rel_path).read_bytes()
|
||||
for rel_path in {p.path for p in patches}
|
||||
}
|
||||
|
||||
applied = patcher.apply_patches(root, patches, check=True)
|
||||
|
||||
assert len(applied) == len(patches)
|
||||
for rel_path, contents in before.items():
|
||||
assert (root / rel_path).read_bytes() == contents
|
||||
|
||||
|
||||
def test_missing_file_fails_closed(tmp_path: Path):
|
||||
root = make_fixture_root(tmp_path, "qt")
|
||||
patches = patcher.TARGETS["qt"]
|
||||
victim = patches[0].path
|
||||
(root / victim).unlink()
|
||||
|
||||
with pytest.raises(patcher.PatchError, match="submodule") as exc_info:
|
||||
patcher.apply_patches(root, patches, check=False)
|
||||
assert victim in str(exc_info.value)
|
||||
|
||||
|
||||
def test_missing_token_fails_closed(tmp_path: Path):
|
||||
root = make_fixture_root(tmp_path, "qt")
|
||||
patches = patcher.TARGETS["qt"]
|
||||
victim = patches[0]
|
||||
file_path = root / victim.path
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
file_path.write_text(text.replace(victim.old, "", 1), encoding="utf-8")
|
||||
|
||||
with pytest.raises(patcher.PatchError) as exc_info:
|
||||
patcher.apply_patches(root, patches, check=False)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert victim.path in message
|
||||
assert victim.why in message
|
||||
assert "found 0" in message
|
||||
|
||||
|
||||
def test_ambiguous_token_fails_closed(tmp_path: Path):
|
||||
root = make_fixture_root(tmp_path, "qt")
|
||||
patches = patcher.TARGETS["qt"]
|
||||
victim = patches[0]
|
||||
file_path = root / victim.path
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
file_path.write_text(text + victim.old, encoding="utf-8")
|
||||
|
||||
with pytest.raises(patcher.PatchError, match="found 2"):
|
||||
patcher.apply_patches(root, patches, check=False)
|
||||
|
||||
|
||||
def test_nothing_written_when_one_site_fails(tmp_path: Path):
|
||||
root = make_fixture_root(tmp_path, "qt")
|
||||
patches = patcher.TARGETS["qt"]
|
||||
|
||||
# Pick a path with multiple patches so breaking one leaves the others
|
||||
# unable to complete the file's all-or-nothing write.
|
||||
by_path: "dict[str, list[patcher.Patch]]" = {}
|
||||
for patch in patches:
|
||||
by_path.setdefault(patch.path, []).append(patch)
|
||||
multi_patch_path = next(p for p, ps in by_path.items() if len(ps) > 1)
|
||||
|
||||
file_path = root / multi_patch_path
|
||||
broken_patch = by_path[multi_patch_path][-1]
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
file_path.write_text(text.replace(broken_patch.old, "", 1), encoding="utf-8")
|
||||
before = file_path.read_bytes()
|
||||
|
||||
with pytest.raises(patcher.PatchError):
|
||||
patcher.apply_patches(root, patches, check=False)
|
||||
|
||||
assert file_path.read_bytes() == before
|
||||
|
||||
|
||||
def test_reapply_is_refused(tmp_path: Path):
|
||||
root = make_fixture_root(tmp_path, "qt")
|
||||
patches = patcher.TARGETS["qt"]
|
||||
|
||||
patcher.apply_patches(root, patches, check=False)
|
||||
|
||||
with pytest.raises(patcher.PatchError, match="already applied"):
|
||||
patcher.apply_patches(root, patches, check=False)
|
||||
|
||||
|
||||
def test_main_exit_codes(tmp_path: Path, capsys):
|
||||
good_root = make_fixture_root(tmp_path / "good", "qt")
|
||||
assert patcher.main(["qt", "--root", str(good_root), "--check"]) == 0
|
||||
|
||||
bad_root = tmp_path / "bad"
|
||||
bad_root.mkdir()
|
||||
code = patcher.main(["qt", "--root", str(bad_root), "--check"])
|
||||
assert code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "ERROR" in captured.err
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
||||
def test_real_tree_check_passes_if_submodules_present(target: str):
|
||||
root = _repo_root()
|
||||
marker = root / "aw-qt" / "aw_qt" / "profile.py"
|
||||
if not marker.is_file():
|
||||
pytest.skip("submodules not checked out")
|
||||
|
||||
applied = patcher.apply_patches(root, patcher.TARGETS[target], check=True)
|
||||
|
||||
assert len(applied) == len(patcher.TARGETS[target])
|
||||
|
||||
|
||||
def test_patched_python_profile_module_behaviour(tmp_path: Path, monkeypatch):
|
||||
repo_root = _repo_root()
|
||||
real_profile = repo_root / "aw-qt" / "aw_qt" / "profile.py"
|
||||
if not real_profile.is_file():
|
||||
pytest.skip("submodules not checked out")
|
||||
|
||||
dest = tmp_path / "aw-qt" / "aw_qt" / "profile.py"
|
||||
dest.parent.mkdir(parents=True)
|
||||
dest.write_text(real_profile.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
qt_python_patches = [
|
||||
p for p in patcher.TARGETS["qt"] if p.path == "aw-qt/aw_qt/profile.py"
|
||||
]
|
||||
patcher.apply_patches(tmp_path, qt_python_patches, check=False)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("patched_profile", dest)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
|
||||
assert module.resolve_profile(None, False) == "research"
|
||||
assert module.resolve_profile(None, True) == "testing"
|
||||
assert module.DEFAULT_PROFILE == "default"
|
||||
assert module.profile_suffix("research") == "-research"
|
||||
|
||||
module.export_profile(module.resolve_profile(None, False))
|
||||
assert os.environ["AW_PROFILE"] == "research"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
||||
def test_macos_bundle_dir_is_distinct_from_standard(tmp_path: Path, target: str):
|
||||
"""Research builds must not emit ActivityWatch.app next to a standard install."""
|
||||
root = make_fixture_root(tmp_path, target)
|
||||
patcher.apply_patches(root, patcher.TARGETS[target], check=False)
|
||||
|
||||
stem = patcher.BUNDLE_DIR_STEM
|
||||
expected_app = f"{stem}.app"
|
||||
colliding = "ActivityWatch.app"
|
||||
|
||||
if target == "qt":
|
||||
spec = (root / "aw.spec").read_text(encoding="utf-8")
|
||||
assert f'name="{expected_app}"' in spec
|
||||
assert f'name="{colliding}"' not in spec
|
||||
else:
|
||||
tauri = (root / "scripts/package/build_app_tauri.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert f'APP_NAME="{stem}"' in tauri
|
||||
assert 'APP_NAME="ActivityWatch"\n' not in tauri
|
||||
|
||||
makefile = (root / "Makefile").read_text(encoding="utf-8")
|
||||
assert f"APP_BUNDLE ?= {stem}\n" in makefile
|
||||
assert "APP_BUNDLE ?= ActivityWatch\n" not in makefile
|
||||
|
||||
notarize = (root / "scripts/notarize.sh").read_text(encoding="utf-8")
|
||||
assert f"app=dist/{expected_app}" in notarize
|
||||
assert "app=dist/ActivityWatch.app" not in notarize
|
||||
|
||||
|
||||
# --- Windows install identity -------------------------------------------------
|
||||
# These use the *real* .iss / tauri.conf.json rather than the synthetic fixture,
|
||||
# because the thing under test is largely what the patch does NOT touch: the
|
||||
# shortcut, uninstall and install-dir lines that derive from `#define MyAppName`.
|
||||
# A synthetic file built from the `old` strings alone cannot catch upstream
|
||||
# hardcoding a name that would then collide with a standard install.
|
||||
|
||||
WINDOWS_REAL_FILES = {
|
||||
"qt": "scripts/package/activitywatch-setup.iss",
|
||||
"tauri": "scripts/package/aw-tauri.iss",
|
||||
}
|
||||
STANDARD_APPID_QT = "F226B8F4-3244-46E6-901D-0CE8035423E4"
|
||||
STANDARD_APPID_TAURI = "983D0855-08C8-46BD-AEFB-3924581C6703"
|
||||
|
||||
|
||||
def _patch_real_file(tmp_path: Path, rel_path: str, patches) -> str:
|
||||
"""Copy one real repo file into ``tmp_path``, patch it, return the result."""
|
||||
src = _repo_root() / rel_path
|
||||
if not src.is_file():
|
||||
pytest.skip(f"{rel_path} not present")
|
||||
dst = tmp_path / rel_path
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
relevant = [p for p in patches if p.path == rel_path]
|
||||
patcher.apply_patches(tmp_path, relevant, check=False)
|
||||
return dst.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["qt", "tauri"])
|
||||
def test_windows_installer_is_a_separate_product(tmp_path: Path, target: str):
|
||||
"""Research setup must register its own product, not upgrade over standard."""
|
||||
rel = WINDOWS_REAL_FILES[target]
|
||||
patches = (
|
||||
patcher.WINDOWS_PATCHES_QT
|
||||
if target == "qt"
|
||||
else patcher.WINDOWS_PATCHES_TAURI
|
||||
)
|
||||
text = _patch_real_file(tmp_path, rel, patches)
|
||||
|
||||
research_appid = (
|
||||
patcher.WINDOWS_APPID_QT if target == "qt" else patcher.WINDOWS_APPID_TAURI
|
||||
)
|
||||
standard_appid = STANDARD_APPID_QT if target == "qt" else STANDARD_APPID_TAURI
|
||||
|
||||
# AppId is what Inno keys "same product" on — it must have changed.
|
||||
assert f"AppId={{{{{research_appid}}}" in text
|
||||
assert standard_appid not in text
|
||||
|
||||
# Display name drives shortcuts, uninstall entry and (Qt) the install dir.
|
||||
assert patcher.BUNDLE_NAME in text
|
||||
assert '#define MyAppName "ActivityWatch"\n' not in text
|
||||
assert '#define MyAppName "ActivityWatch (Tauri)"\n' not in text
|
||||
|
||||
# Setup .exe no longer collides with the standard artifact.
|
||||
assert "OutputBaseFilename=activitywatch-setup\n" not in text
|
||||
assert "OutputBaseFilename=activitywatch-research" in text
|
||||
|
||||
# Shortcut / uninstall identity must still *derive* from MyAppName. If
|
||||
# upstream ever hardcodes the name here, the research build would install a
|
||||
# shortcut and uninstall entry indistinguishable from a standard install.
|
||||
for line in ("{autoprograms}", "{autodesktop}", "{userstartup}"):
|
||||
assert f'Name: "{line}\\{{#MyAppName}}"' in text
|
||||
assert "UninstallDisplayName={#MyAppName}\n" in text
|
||||
|
||||
|
||||
def test_windows_research_installers_do_not_collide_with_each_other(tmp_path: Path):
|
||||
"""Qt-research and Tauri-research are also distinct products from each other."""
|
||||
qt = _patch_real_file(
|
||||
tmp_path / "qt", WINDOWS_REAL_FILES["qt"], patcher.WINDOWS_PATCHES_QT
|
||||
)
|
||||
tauri = _patch_real_file(
|
||||
tmp_path / "tauri", WINDOWS_REAL_FILES["tauri"], patcher.WINDOWS_PATCHES_TAURI
|
||||
)
|
||||
|
||||
appids = {
|
||||
patcher.WINDOWS_APPID_QT,
|
||||
patcher.WINDOWS_APPID_TAURI,
|
||||
STANDARD_APPID_QT,
|
||||
STANDARD_APPID_TAURI,
|
||||
}
|
||||
assert len(appids) == 4, "research AppIds must be unique GUIDs"
|
||||
|
||||
def _output_name(text: str) -> str:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("OutputBaseFilename="):
|
||||
return line.split("=", 1)[1]
|
||||
raise AssertionError("no OutputBaseFilename")
|
||||
|
||||
# Both .iss files ship `activitywatch-setup` on master, so the research
|
||||
# names must be distinct from each other as well as from standard.
|
||||
assert _output_name(qt) != _output_name(tauri)
|
||||
|
||||
# Install directories must differ (Qt derives its dir from MyAppName).
|
||||
assert "DefaultDirName={autopf}\\{#MyAppName}\n" in qt
|
||||
assert f"DefaultDirName={{autopf}}\\{patcher.BUNDLE_DIR_STEM}-Tauri\n" in tauri
|
||||
assert "DefaultDirName={autopf}\\ActivityWatch-Tauri\n" not in tauri
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
params=[(target, research) for target in ("qt", "tauri") for research in (False, True)]
|
||||
)
|
||||
def windows_installer_names(tmp_path: Path, request):
|
||||
target, research = request.param
|
||||
rel = WINDOWS_REAL_FILES[target]
|
||||
# Packaging inputs are tracked root files, so a missing producer must fail.
|
||||
assert (_repo_root() / rel).is_file()
|
||||
patches = (
|
||||
patcher.WINDOWS_PATCHES_QT if target == "qt" else patcher.WINDOWS_PATCHES_TAURI
|
||||
)
|
||||
text = _patch_real_file(tmp_path, rel, patches if research else [])
|
||||
outputs = re.findall(r"^OutputBaseFilename=(\S+)$", text, re.MULTILINE)
|
||||
assert len(outputs) == 1, "expected one executable OutputBaseFilename assignment"
|
||||
|
||||
package = (_repo_root() / "scripts/package/package-all.sh").read_text()
|
||||
calls = re.findall(
|
||||
r'^\s*"\$SCRIPT_DIR/collect-setup\.sh" "\$filename"\s*$', package, re.MULTILINE
|
||||
)
|
||||
assert len(calls) == 1, "build_setup must invoke the tested installer collector"
|
||||
templates = re.findall(
|
||||
r'^\s*filename="([^"\n]+-setup\.exe)"$', package, re.MULTILINE
|
||||
)
|
||||
assert len(templates) == 1, "expected one versioned installer filename assignment"
|
||||
suffix = "-tauri" if target == "tauri" else ""
|
||||
if research:
|
||||
suffix += "-research"
|
||||
final = Template(templates[0]).substitute(
|
||||
build_suffix=suffix, version="v0.14.0b5", platform="windows", arch="x86_64"
|
||||
)
|
||||
return target, outputs[0] + ".exe", final
|
||||
|
||||
|
||||
def test_windows_installer_producer_matches_packaging_and_release(windows_installer_names):
|
||||
target, produced, final = windows_installer_names
|
||||
collector = (_repo_root() / "scripts/package/collect-setup.sh").read_text()
|
||||
inputs = re.findall(r"^\s*setup_src=\(([^)\n]+)\)$", collector, re.MULTILINE)
|
||||
assert len(inputs) == 1, "expected one executable setup_src assignment"
|
||||
assert fnmatch.fnmatchcase(f"dist/{produced}", inputs[0].strip())
|
||||
|
||||
workflow = (_repo_root() / ".github/workflows/release.yml").read_text()
|
||||
jobs = dict(re.findall(
|
||||
r"^ ([\w-]+):\n(.*?)(?=^ [\w-]+:|\Z)", workflow, re.MULTILINE | re.DOTALL
|
||||
))
|
||||
upload = re.search(
|
||||
r"^ - name: Upload packages\n(.*?)(?=^ - |\Z)",
|
||||
jobs[f"build-{target}"], re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
assert upload, f"missing {target} package upload step"
|
||||
assert "uses: actions/upload-artifact@" in upload[1]
|
||||
path = re.search(r"^ path: (.+)$", upload[1], re.MULTILINE)
|
||||
assert path, f"missing {target} package upload path"
|
||||
globs = (
|
||||
re.findall(r"^ (\S+)$", upload[1], re.MULTILINE)
|
||||
if path[1] == "|" else [path[1]]
|
||||
)
|
||||
assert globs and any(fnmatch.fnmatchcase(f"dist/{final}", glob) for glob in globs)
|
||||
release = re.search(
|
||||
r"^ - name: Release\n(.*?)(?=^ - |\Z)",
|
||||
jobs["release"], re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
assert release and "uses: softprops/action-gh-release@" in release[1]
|
||||
files = re.search(
|
||||
r"^ files: \|\n((?:^ \S.*\n)+)", release[1], re.MULTILINE
|
||||
)
|
||||
assert files, "missing release action's asset files block"
|
||||
release_globs = [line.strip() for line in files[1].splitlines()]
|
||||
assert release_globs and any(
|
||||
fnmatch.fnmatchcase(f"dist/builds-windows/{final}", glob) for glob in release_globs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != "linux", reason="Bash fixture runs in the Ubuntu packaging smoke job"
|
||||
)
|
||||
def test_windows_setup_collection(tmp_path: Path, windows_installer_names):
|
||||
_, produced, final = windows_installer_names
|
||||
dist = tmp_path / "dist"
|
||||
dist.mkdir()
|
||||
(dist / produced).write_bytes(b"fixture installer")
|
||||
(dist / "activitywatch-portable.zip").write_bytes(b"portable archive")
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(_repo_root() / "scripts/package/collect-setup.sh"), final],
|
||||
cwd=tmp_path, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert {path.name for path in dist.iterdir()} == {final, "activitywatch-portable.zip"}
|
||||
assert (dist / final).read_bytes() == b"fixture installer"
|
||||
assert (dist / "activitywatch-portable.zip").read_bytes() == b"portable archive"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform != "linux", reason="Bash fixture runs in the Ubuntu packaging smoke job"
|
||||
)
|
||||
@pytest.mark.parametrize("case", ["missing", "multiple", "directory"])
|
||||
def test_windows_setup_collection_refuses_invalid_inputs(tmp_path: Path, case: str):
|
||||
dist = tmp_path / "dist"
|
||||
dist.mkdir()
|
||||
(dist / "activitywatch-portable.zip").write_bytes(b"portable archive")
|
||||
if case == "multiple":
|
||||
for name in ("activitywatch-setup.exe", "activitywatch-research-setup.exe"):
|
||||
(dist / name).write_bytes(name.encode())
|
||||
elif case == "directory":
|
||||
(dist / "activitywatch-research-setup.exe").mkdir()
|
||||
|
||||
def snapshot():
|
||||
return {
|
||||
path.name: path.read_bytes() if path.is_file() else None
|
||||
for path in dist.iterdir()
|
||||
}
|
||||
|
||||
before = snapshot()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash", str(_repo_root() / "scripts/package/collect-setup.sh"),
|
||||
"activitywatch-research-v0.14.0b5-windows-x86_64-setup.exe",
|
||||
],
|
||||
cwd=tmp_path, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "expected exactly one" in result.stdout + result.stderr
|
||||
assert snapshot() == before
|
||||
|
||||
|
||||
def test_tauri_wix_upgrade_code_is_pinned_and_config_stays_valid_json(tmp_path: Path):
|
||||
"""The MSI upgrade code defines the product family; pin it, don't derive it."""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
rel = "aw-tauri/src-tauri/tauri.conf.json"
|
||||
text = _patch_real_file(tmp_path, rel, patcher.WINDOWS_PATCHES_TAURI)
|
||||
|
||||
config = json.loads(text) # deny_unknown_fields upstream: must stay valid
|
||||
wix = config["bundle"]["windows"]["wix"]
|
||||
assert wix["upgradeCode"] == patcher.WINDOWS_WIX_UPGRADE_CODE_TAURI
|
||||
uuid.UUID(wix["upgradeCode"]) # tauri parses this as a uuid::Uuid
|
||||
|
||||
|
||||
# --- Linux package identity ---------------------------------------------------
|
||||
# Like the Windows tests above, these patch the *real* packaging scripts: the
|
||||
# thing under test is mostly what the patch does NOT touch. A synthetic fixture
|
||||
# built from the `old` strings alone would still pass if upstream added a fourth
|
||||
# reference to /opt/activitywatch or copied the desktop entry somewhere else.
|
||||
|
||||
|
||||
def test_linux_deb_is_a_separate_package(tmp_path: Path):
|
||||
"""A research deb must be co-installable, not an upgrade of the standard one."""
|
||||
control = _patch_real_file(
|
||||
tmp_path, "scripts/package/deb/control", patcher.LINUX_PATCHES_QT
|
||||
)
|
||||
assert f"Package: {patcher.LINUX_PACKAGE}\n" in control
|
||||
# dpkg keys off the package name: same name == upgrade == standard removed.
|
||||
assert "Package: activitywatch\n" not in control
|
||||
|
||||
|
||||
def test_linux_deb_installs_beside_a_standard_install(tmp_path: Path):
|
||||
"""No /opt/activitywatch or shared desktop-entry filename may survive."""
|
||||
script = _patch_real_file(
|
||||
tmp_path, "scripts/package/package-deb.sh", patcher.LINUX_PATCHES_QT
|
||||
)
|
||||
|
||||
# The staged install tree, the Exec= line and the .deb filename must all
|
||||
# move together; a leftover bare "/opt/activitywatch" would overwrite the
|
||||
# standard install's files even with a distinct package name.
|
||||
assert "/opt/activitywatch/" not in script
|
||||
assert f"{patcher.LINUX_OPT_DIR}/aw-qt" in script
|
||||
assert "activitywatch_${VERSION_NUM}.deb" not in script
|
||||
|
||||
# Both copies out of the install tree must land under the research
|
||||
# filename: /etc/xdg/autostart and /usr/share/applications are shared
|
||||
# namespaces, so a same-named entry silently replaces the standard one.
|
||||
for dest in ("etc/xdg/autostart", "usr/share/applications"):
|
||||
assert f"$PKGDIR/{dest}/{patcher.LINUX_DESKTOP_FILENAME}\n" in script
|
||||
assert f"$PKGDIR/{dest}/\n" not in script
|
||||
|
||||
|
||||
def test_linux_desktop_entry_is_rebranded(tmp_path: Path):
|
||||
"""Menu/dock name and icon id must not read as a standard install."""
|
||||
entry = _patch_real_file(
|
||||
tmp_path, "aw-qt/resources/aw-qt.desktop", patcher.LINUX_PATCHES_QT
|
||||
)
|
||||
assert f"Name={patcher.BUNDLE_NAME}\n" in entry
|
||||
assert "Name=ActivityWatch\n" not in entry
|
||||
assert f"Icon={patcher.LINUX_ICON_ID}\n" in entry
|
||||
|
||||
|
||||
def test_appimage_desktop_and_icon_ids_agree(tmp_path: Path):
|
||||
"""linuxdeploy's --icon-filename must match the entry's Icon= key.
|
||||
|
||||
Desktop integration resolves the icon by that id; a mismatch ships an
|
||||
AppImage with no icon, and a shared id would overwrite the standard
|
||||
install's icon in the user's hicolor theme.
|
||||
"""
|
||||
script = _patch_real_file(
|
||||
tmp_path, "scripts/package/package-appimage.sh", patcher.LINUX_PATCHES_QT
|
||||
)
|
||||
entry = _patch_real_file(
|
||||
tmp_path, "aw-qt/resources/aw-qt.desktop", patcher.LINUX_PATCHES_QT
|
||||
)
|
||||
|
||||
assert f"--icon-filename {patcher.LINUX_ICON_ID}\n" in script
|
||||
assert f"Icon={patcher.LINUX_ICON_ID}\n" in entry
|
||||
# The entry handed to linuxdeploy is the research-named copy, and the copy
|
||||
# is staged before it is used.
|
||||
assert f"--desktop-file ./activitywatch/{patcher.LINUX_DESKTOP_FILENAME} " in script
|
||||
stage = script.index(f"cp ./activitywatch/aw-qt.desktop ./activitywatch/{patcher.LINUX_DESKTOP_FILENAME}")
|
||||
assert stage < script.index("linuxdeploy-x86_64.AppImage --appdir")
|
||||
|
||||
|
||||
def test_first_run_autostart_identity_is_distinct_on_every_platform(tmp_path: Path):
|
||||
"""aw-qt writes its own autostart entry; those names are the installer's.
|
||||
|
||||
Bundle id, package name and profile are all already split at this point --
|
||||
but if the login item / Startup shortcut / autostart .desktop keep their
|
||||
standard names, the two editions still overwrite each other's autostart.
|
||||
"""
|
||||
rel = "aw-qt/aw_qt/autostart.py"
|
||||
src = _repo_root() / rel
|
||||
if not src.is_file():
|
||||
pytest.skip(f"{rel} not present")
|
||||
before = src.read_text(encoding="utf-8")
|
||||
after = _patch_real_file(tmp_path, rel, patcher.AUTOSTART_PATCHES_QT)
|
||||
|
||||
# Linux: the written filename changes; the *shipped resource* name does not
|
||||
# (the patched build still reads resources/aw-qt.desktop out of the bundle).
|
||||
# Must not be aw-qt-research.desktop — that is the standard named-profile entry.
|
||||
assert f'_linux_autostart_dir() / "{patcher.LINUX_DESKTOP_FILENAME}"' in after
|
||||
assert "aw-qt-research.desktop" not in after
|
||||
assert 'DESKTOP_FILENAME = "aw-qt.desktop"' in after
|
||||
# macOS: plist path follows _macos_launch_agent_label(), which would otherwise
|
||||
# become net.activitywatch.aw-qt-research and collide with --profile research.
|
||||
assert f'return "{patcher.BUNDLE_ID}"' in after
|
||||
assert 'return f"{LAUNCH_AGENT_LABEL}{_profile_suffix()}"' not in after
|
||||
assert f'return "{patcher.LAUNCH_AGENT_LABEL}"' not in after
|
||||
# Windows: both the Run value name and the Startup .lnk derive from APP_NAME.
|
||||
# Named-profile suffixing then yields "ActivityWatch Research (research)".
|
||||
assert f'APP_NAME = "{patcher.BUNDLE_NAME}"' in after
|
||||
assert 'APP_NAME = "ActivityWatch"\n' not in after
|
||||
|
||||
# Guard the runtime identity sources: if upstream stops suffixing these, the
|
||||
# research overrides above would be patching dead code.
|
||||
for derived in (
|
||||
'return _linux_autostart_dir() / f"{stem}{_profile_suffix()}{extension}"',
|
||||
'return f"{LAUNCH_AGENT_LABEL}{_profile_suffix()}"',
|
||||
'return APP_NAME if not suffix else f"{APP_NAME} ({_profile()})"',
|
||||
):
|
||||
assert derived in before, f"upstream no longer derives: {derived}"
|
||||
|
||||
|
||||
def test_tauri_autostart_uses_distinct_app_name_for_research_build(tmp_path: Path):
|
||||
"""tauri_plugin_autostart derives its entry name from productName by default.
|
||||
|
||||
Both standard and research Tauri builds share productName "aw-tauri", so
|
||||
enabling autostart in one edition would overwrite the other's entry on
|
||||
Windows (registry key) and Linux (~/.config/autostart/*.desktop).
|
||||
|
||||
The fix is entirely patcher-side: PROFILE_PATCHES_TAURI inserts BUILD_PROFILE
|
||||
into profile.rs; AUTOSTART_PATCHES_TAURI replaces the tauri_plugin_autostart::init()
|
||||
call in lib.rs with a Builder that sets an explicit app_name derived from
|
||||
BUILD_PROFILE when it differs from DEFAULT_PROFILE.
|
||||
|
||||
This test asserts both patch sets produce the expected output on the real sources.
|
||||
"""
|
||||
# --- half 1: PROFILE_PATCHES_TAURI inserts BUILD_PROFILE = "research" ---
|
||||
profile_rs_rel = "aw-tauri/src-tauri/src/profile.rs"
|
||||
profile_rs_src = _repo_root() / profile_rs_rel
|
||||
if not profile_rs_src.is_file():
|
||||
pytest.skip(f"{profile_rs_rel} not present")
|
||||
before_profile = profile_rs_src.read_text(encoding="utf-8")
|
||||
# Standard source has DEFAULT_PROFILE but NOT BUILD_PROFILE
|
||||
assert 'pub const DEFAULT_PROFILE: &str = "default";' in before_profile
|
||||
assert "BUILD_PROFILE" not in before_profile, (
|
||||
"BUILD_PROFILE must NOT exist in the unpatched source; the patcher inserts it"
|
||||
)
|
||||
after_profile = _patch_real_file(
|
||||
tmp_path,
|
||||
profile_rs_rel,
|
||||
patcher.PROFILE_PATCHES_TAURI,
|
||||
)
|
||||
assert f'pub const BUILD_PROFILE: &str = "{patcher.RESEARCH_PROFILE}";' in after_profile
|
||||
|
||||
# --- half 2: AUTOSTART_PATCHES_TAURI overrides named-profile app_name ---
|
||||
lib_rs_rel = "aw-tauri/src-tauri/src/lib.rs"
|
||||
lib_rs_src = _repo_root() / lib_rs_rel
|
||||
if not lib_rs_src.is_file():
|
||||
pytest.skip(f"{lib_rs_rel} not present")
|
||||
before_lib = lib_rs_src.read_text(encoding="utf-8")
|
||||
# Standard source already uses Builder + named-profile identities.
|
||||
assert "tauri_plugin_autostart::Builder::new()" in before_lib
|
||||
assert "profile::autostart_app_name" in before_lib
|
||||
assert "BUILD_PROFILE" not in before_lib
|
||||
after_lib = _patch_real_file(
|
||||
tmp_path,
|
||||
lib_rs_rel,
|
||||
patcher.AUTOSTART_PATCHES_TAURI,
|
||||
)
|
||||
# Research build keeps named-profile wiring, then overrides the OS identity
|
||||
# so it does not collide with a standard `--profile research` login item.
|
||||
assert "profile::autostart_app_name" in after_lib
|
||||
assert "profile::BUILD_PROFILE != profile::DEFAULT_PROFILE" in after_lib
|
||||
assert '"aw-tauri-{}-edition"' in after_lib
|
||||
assert 'format!("aw-tauri-{}", profile::BUILD_PROFILE)' not in after_lib
|
||||
401
scripts/tests/test_publish_research_feed.py
Normal file
401
scripts/tests/test_publish_research_feed.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""Release fixtures and real local Git remotes; never contact the public feed."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "package" / "publish_research_feed.py"
|
||||
spec = importlib.util.spec_from_file_location("publish_research_feed", SCRIPT)
|
||||
publisher = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(publisher)
|
||||
|
||||
|
||||
def fixture(beta=5):
|
||||
tag = f"v0.14.0b{beta}-research"
|
||||
base = f"https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/"
|
||||
manifest = {
|
||||
"version": f"0.14.0-beta.{beta}",
|
||||
"notes": tag,
|
||||
"pub_date": "2026-09-09T00:00:00Z",
|
||||
"platforms": {},
|
||||
}
|
||||
release = {
|
||||
"tag_name": tag,
|
||||
"draft": False,
|
||||
"immutable": True,
|
||||
"published_at": "2026-09-09T00:00:00Z",
|
||||
"assets": [],
|
||||
}
|
||||
for target, extensions in publisher.TARGETS.items():
|
||||
name = f"activitywatch-tauri-research-0.14.0b{beta}-{target}.{extensions[0]}"
|
||||
manifest["platforms"][target] = {
|
||||
"url": base + name,
|
||||
"signature": "test-signature",
|
||||
}
|
||||
for asset in (name, name + ".sig"):
|
||||
release["assets"].append(
|
||||
{
|
||||
"name": asset,
|
||||
"state": "uploaded",
|
||||
"size": 42,
|
||||
"browser_download_url": base + asset,
|
||||
}
|
||||
)
|
||||
return manifest, release, tag
|
||||
|
||||
|
||||
def validate(manifest, release, tag):
|
||||
publisher.validate_manifest(manifest, release, tag, lambda _: b"test-signature\n")
|
||||
|
||||
|
||||
def test_valid_research_release():
|
||||
validate(*fixture())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
lambda m, r: m.update(pub_date="not a date"),
|
||||
lambda m, r: m.update(pub_date="2026-09-09T00:00:00+00:99"),
|
||||
lambda m, r: m.update(pub_date="2026-02-30T00:00:00Z"),
|
||||
lambda m, r: m.update(pub_date="2026-09-09T00:00:00"),
|
||||
lambda m, r: m.update(notes=123),
|
||||
lambda m, r: m.update(name="0.14.0-beta.6"),
|
||||
lambda m, r: m.update(url=123),
|
||||
lambda m, r: m["platforms"]["darwin-aarch64"].update(extra="unexpected"),
|
||||
lambda m, r: r.update(immutable=False),
|
||||
lambda m, r: r.pop("immutable"),
|
||||
lambda m, r: r.update(draft=True),
|
||||
lambda m, r: r.update(draft="false"),
|
||||
lambda m, r: r.update(published_at=None),
|
||||
lambda m, r: r.update(tag_name="v0.14.0b4-research"),
|
||||
lambda m, r: r["assets"].pop(0), # bundle absent
|
||||
lambda m, r: r["assets"].pop(1), # signature absent
|
||||
lambda m, r: r["assets"][0].update(size=0),
|
||||
lambda m, r: r["assets"][0].update(state="new"),
|
||||
lambda m, r: r["assets"][1].update(
|
||||
browser_download_url="https://example.com/signature"
|
||||
),
|
||||
lambda m, r: r["assets"].append(r["assets"][0]),
|
||||
lambda m, r: m.update(version="0.14.0b5"),
|
||||
lambda m, r: m.update(version="0.14.0-beta.6"),
|
||||
lambda m, r: m["platforms"].pop("darwin-x86_64"),
|
||||
lambda m, r: m["platforms"].update({"unsupported-platform": {}}),
|
||||
lambda m, r: m["platforms"]["darwin-aarch64"].update(signature=""),
|
||||
lambda m, r: m["platforms"]["darwin-aarch64"].update(signature="wrong"),
|
||||
lambda m, r: m.update(platforms=list(m["platforms"])),
|
||||
],
|
||||
)
|
||||
def test_invalid_release_or_manifest(mutation):
|
||||
manifest, release, tag = fixture()
|
||||
mutation(manifest, release)
|
||||
with pytest.raises(ValueError):
|
||||
validate(manifest, release, tag)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"replacement",
|
||||
[
|
||||
("v0.14.0b5-research/", "v0.14.0b4-research/"),
|
||||
("v0.14.0b5-research/", "v0.14.0b5/"),
|
||||
("activitywatch-tauri-research-", "activitywatch-tauri-"),
|
||||
("research-0.14.0b5-", "research-0.14.0b4-"),
|
||||
(".app.tar.gz", ".dmg"),
|
||||
("https://github.com/", "https://example.com/"),
|
||||
],
|
||||
)
|
||||
def test_wrong_edition_release_version_or_extension(replacement):
|
||||
manifest, release, tag = fixture()
|
||||
entry = manifest["platforms"]["darwin-aarch64"]
|
||||
entry["url"] = entry["url"].replace(*replacement)
|
||||
# Even if metadata claims this asset exists, it is not an allowed URL.
|
||||
release["assets"][0]["browser_download_url"] = entry["url"]
|
||||
with pytest.raises(ValueError):
|
||||
validate(manifest, release, tag)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag",
|
||||
[
|
||||
"v0.14.0b5",
|
||||
"latest-research",
|
||||
"v0.14.0b05-research",
|
||||
"v0.14.0b5.dev-abcdef-research",
|
||||
"v0.14.0b5-research/other",
|
||||
],
|
||||
)
|
||||
def test_invalid_release_tag(tag):
|
||||
manifest, release, _ = fixture()
|
||||
with pytest.raises(ValueError):
|
||||
validate(manifest, release, tag)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote(tmp_path, monkeypatch):
|
||||
config = tmp_path / "gitconfig"
|
||||
config.write_text("[user]\nname = Publisher test\nemail = test@example.invalid\n")
|
||||
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config))
|
||||
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
# Isolate test repositories from any enclosing harness Git invocation.
|
||||
for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
remote = tmp_path / "remote.git"
|
||||
remote.mkdir()
|
||||
publisher.git(remote, "init", "--bare", "--quiet")
|
||||
return str(remote)
|
||||
|
||||
|
||||
def candidate(tmp_path, remote, name, parent="absent", beta=5):
|
||||
repo = tmp_path / name
|
||||
manifest = fixture(beta)[0]
|
||||
commit = publisher.prepare(repo, remote, parent, manifest)
|
||||
return repo, commit
|
||||
|
||||
|
||||
def test_initial_preparation_does_not_publish(tmp_path, remote):
|
||||
repo, commit = candidate(tmp_path, remote, "candidate")
|
||||
assert publisher.remote_parent(repo, remote) == "absent"
|
||||
assert publisher.git(repo, "rev-list", "--parents", "-n", "1", commit) == commit
|
||||
assert json.loads((repo / publisher.MANIFEST).read_text()) == fixture()[0]
|
||||
publisher.publish(repo, remote, "absent", commit)
|
||||
assert publisher.remote_parent(repo, remote) == commit
|
||||
|
||||
|
||||
def test_advance_preserves_history_and_unrelated_files(tmp_path, remote):
|
||||
repo, first = candidate(tmp_path, remote, "first")
|
||||
(repo / "README.md").write_text("Feed history\n")
|
||||
publisher.git(repo, "add", "README.md")
|
||||
publisher.git(repo, "commit", "-m", "Document feed")
|
||||
parent = publisher.git(repo, "rev-parse", "HEAD")
|
||||
publisher.git(repo, "push", remote, f"{parent}:{publisher.REF}")
|
||||
new_repo, second = candidate(tmp_path, remote, "second", parent, 6)
|
||||
publisher.publish(new_repo, remote, parent, second)
|
||||
assert publisher.git(new_repo, "rev-parse", f"{second}^") == parent
|
||||
assert publisher.git(new_repo, "rev-parse", f"{second}^^") == first
|
||||
assert (new_repo / "README.md").read_text() == "Feed history\n"
|
||||
|
||||
|
||||
def test_replay_is_noop_and_conflicting_equal_version_rejected(tmp_path, remote):
|
||||
repo, first = candidate(tmp_path, remote, "first")
|
||||
publisher.publish(repo, remote, "absent", first)
|
||||
_, repeated = candidate(tmp_path, remote, "replay", first)
|
||||
assert repeated is None
|
||||
different = fixture()[0]
|
||||
different["notes"] = "changed"
|
||||
with pytest.raises(ValueError, match="Conflicting"):
|
||||
publisher.prepare(tmp_path / "conflict", remote, first, different)
|
||||
assert publisher.remote_parent(repo, remote) == first
|
||||
|
||||
|
||||
def test_late_b5_cannot_replace_b6(tmp_path, remote):
|
||||
repo, first = candidate(tmp_path, remote, "b6", beta=6)
|
||||
publisher.publish(repo, remote, "absent", first)
|
||||
with pytest.raises(ValueError, match="regression"):
|
||||
candidate(tmp_path, remote, "late-b5", first, 5)
|
||||
|
||||
|
||||
def test_numeric_semver_order(tmp_path, remote):
|
||||
repo, first = candidate(tmp_path, remote, "b9", beta=9)
|
||||
publisher.publish(repo, remote, "absent", first)
|
||||
new_repo, second = candidate(tmp_path, remote, "b10", first, 10)
|
||||
publisher.publish(new_repo, remote, first, second)
|
||||
assert publisher.remote_parent(repo, remote) == second
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provision", [False, True])
|
||||
def test_same_parent_race_and_retry_revalidates_order(tmp_path, remote, provision):
|
||||
parent = "absent"
|
||||
if not provision:
|
||||
repo, parent = candidate(tmp_path, remote, "seed", beta=4)
|
||||
publisher.publish(repo, remote, "absent", parent)
|
||||
slow_repo, slow = candidate(tmp_path, remote, "slow", parent, 5)
|
||||
fast_repo, fast = candidate(tmp_path, remote, "fast", parent, 6)
|
||||
publisher.publish(fast_repo, remote, parent, fast)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
publisher.publish(slow_repo, remote, parent, slow)
|
||||
assert publisher.remote_parent(fast_repo, remote) == fast
|
||||
with pytest.raises(ValueError, match="Stale"):
|
||||
candidate(tmp_path, remote, "stale", parent, 5)
|
||||
with pytest.raises(ValueError, match="regression"):
|
||||
candidate(tmp_path, remote, "retry", fast, 5)
|
||||
|
||||
|
||||
def test_deleted_parent_cannot_be_silently_recreated(tmp_path, remote):
|
||||
repo, parent = candidate(tmp_path, remote, "seed")
|
||||
publisher.publish(repo, remote, "absent", parent)
|
||||
new_repo, second = candidate(tmp_path, remote, "second", parent, 6)
|
||||
# Simulate an administrator's intervening ref deletion, not publisher behavior.
|
||||
publisher.git(Path(remote), "update-ref", "-d", publisher.REF)
|
||||
with pytest.raises(subprocess.CalledProcessError, match="returned non-zero"):
|
||||
publisher.publish(new_repo, remote, parent, second)
|
||||
assert publisher.remote_parent(repo, remote) == "absent"
|
||||
|
||||
|
||||
def test_rewound_parent_is_rejected_even_when_push_would_fast_forward(tmp_path, remote):
|
||||
repo, first = candidate(tmp_path, remote, "first", beta=4)
|
||||
publisher.publish(repo, remote, "absent", first)
|
||||
second_repo, second = candidate(tmp_path, remote, "second", first, 5)
|
||||
publisher.publish(second_repo, remote, first, second)
|
||||
third_repo, third = candidate(tmp_path, remote, "third", second, 6)
|
||||
publisher.git(Path(remote), "update-ref", publisher.REF, first)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
publisher.publish(third_repo, remote, second, third)
|
||||
assert publisher.remote_parent(repo, remote) == first
|
||||
|
||||
|
||||
def test_existing_pre_push_hook_is_preserved(tmp_path, remote):
|
||||
repo, commit = candidate(tmp_path, remote, "candidate")
|
||||
hooks = tmp_path / "hooks"
|
||||
hooks.mkdir()
|
||||
sentinel = tmp_path / "hook-ran"
|
||||
hook = hooks / "pre-push"
|
||||
hook.write_text(f'#!/bin/sh\ncat > "{sentinel}"\nexit 1\n')
|
||||
hook.chmod(0o755)
|
||||
publisher.git(repo, "config", "core.hooksPath", str(hooks))
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
publisher.publish(repo, remote, "absent", commit)
|
||||
assert commit in sentinel.read_text()
|
||||
assert publisher.remote_parent(repo, remote) == "absent"
|
||||
|
||||
|
||||
def test_prepare_rejects_existing_workdir(tmp_path, remote):
|
||||
repo = tmp_path / "owned"
|
||||
repo.mkdir()
|
||||
(repo / "precious").write_text("keep")
|
||||
with pytest.raises(FileExistsError):
|
||||
publisher.prepare(repo, remote, "absent", fixture()[0])
|
||||
assert (repo / "precious").read_text() == "keep"
|
||||
|
||||
|
||||
def test_cli_validates_before_prepare(tmp_path, monkeypatch):
|
||||
manifest, release, tag = fixture()
|
||||
manifest["platforms"].pop("linux-aarch64")
|
||||
path = tmp_path / "manifest.json"
|
||||
path.write_text(json.dumps(manifest))
|
||||
monkeypatch.setattr(
|
||||
publisher, "public_bytes", lambda _: json.dumps(release).encode()
|
||||
)
|
||||
work = tmp_path / "candidate"
|
||||
with pytest.raises(SystemExit) as error:
|
||||
publisher.main(
|
||||
[
|
||||
"--manifest",
|
||||
str(path),
|
||||
"--tag",
|
||||
tag,
|
||||
"--expected-parent",
|
||||
"absent",
|
||||
"--work-dir",
|
||||
str(work),
|
||||
]
|
||||
)
|
||||
assert error.value.code == 1
|
||||
assert not work.exists()
|
||||
|
||||
|
||||
def test_receive_side_rejects_race_after_parent_was_advertised(tmp_path, remote):
|
||||
repo, first = candidate(tmp_path, remote, "seed", beta=4)
|
||||
publisher.publish(repo, remote, "absent", first)
|
||||
slow_repo, slow = candidate(tmp_path, remote, "slow", first, 5)
|
||||
fast_repo, fast = candidate(tmp_path, remote, "fast", first, 6)
|
||||
# Put the competing commit object on the remote without moving the feed yet.
|
||||
publisher.git(fast_repo, "push", remote, f"{fast}:refs/heads/competing")
|
||||
hooks = tmp_path / "race-hooks"
|
||||
hooks.mkdir()
|
||||
hook = hooks / "pre-push"
|
||||
hook.write_text(
|
||||
f'#!/bin/sh\ngit -C "{remote}" update-ref {publisher.REF} {fast} {first}\n'
|
||||
)
|
||||
hook.chmod(0o755)
|
||||
publisher.git(slow_repo, "config", "core.hooksPath", str(hooks))
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
publisher.publish(slow_repo, remote, first, slow)
|
||||
assert publisher.remote_parent(repo, remote) == fast
|
||||
|
||||
|
||||
@pytest.mark.parametrize("do_publish", [False, True])
|
||||
def test_cli_public_checks_and_explicit_publish_flag(
|
||||
tmp_path, remote, monkeypatch, capsys, do_publish
|
||||
):
|
||||
manifest, release, tag = fixture()
|
||||
path = tmp_path / "manifest.json"
|
||||
path.write_text(json.dumps(manifest))
|
||||
urls = []
|
||||
|
||||
def public(url):
|
||||
urls.append(url)
|
||||
return (
|
||||
json.dumps(release).encode()
|
||||
if url.startswith("https://api.github.com/")
|
||||
else b"test-signature\n"
|
||||
)
|
||||
|
||||
real_git = publisher.git
|
||||
|
||||
def local_git(repo, *args, **kwargs):
|
||||
return real_git(
|
||||
repo,
|
||||
*(
|
||||
remote
|
||||
if arg == "https://github.com/ActivityWatch/activitywatch.git"
|
||||
else arg
|
||||
for arg in args
|
||||
),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(publisher, "public_bytes", public)
|
||||
monkeypatch.setattr(publisher, "git", local_git)
|
||||
work = tmp_path / "candidate"
|
||||
args = [
|
||||
"--manifest",
|
||||
str(path),
|
||||
"--tag",
|
||||
tag,
|
||||
"--expected-parent",
|
||||
"absent",
|
||||
"--work-dir",
|
||||
str(work),
|
||||
]
|
||||
if do_publish:
|
||||
args.append("--publish")
|
||||
publisher.main(args)
|
||||
result = capsys.readouterr().out
|
||||
assert result.startswith("Published" if do_publish else "Prepared")
|
||||
assert len(urls) == 6 # public release metadata and all five public signatures
|
||||
assert (publisher.remote_parent(work, remote) != "absent") == do_publish
|
||||
|
||||
|
||||
def test_failed_public_signature_read_leaves_no_candidate(
|
||||
tmp_path, remote, monkeypatch
|
||||
):
|
||||
manifest, release, tag = fixture()
|
||||
path = tmp_path / "manifest.json"
|
||||
path.write_text(json.dumps(manifest))
|
||||
|
||||
def public(url):
|
||||
if url.startswith("https://api.github.com/"):
|
||||
return json.dumps(release).encode()
|
||||
raise OSError("signature HTTP 404")
|
||||
|
||||
monkeypatch.setattr(publisher, "public_bytes", public)
|
||||
work = tmp_path / "candidate"
|
||||
with pytest.raises(SystemExit) as error:
|
||||
publisher.main(
|
||||
[
|
||||
"--manifest",
|
||||
str(path),
|
||||
"--tag",
|
||||
tag,
|
||||
"--expected-parent",
|
||||
"absent",
|
||||
"--work-dir",
|
||||
str(work),
|
||||
"--publish",
|
||||
]
|
||||
)
|
||||
assert error.value.code == 1
|
||||
assert not work.exists()
|
||||
8
scripts/uninstall.sh
vendored
Executable file
8
scripts/uninstall.sh
vendored
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
modules=$(pip3 list --format=freeze | grep '^aw-' | cut -d'=' -f1)
|
||||
|
||||
for module in $modules; do
|
||||
pip3 uninstall -y $module
|
||||
done
|
||||
|
||||
30
scripts/update-deps.sh
vendored
Executable file
30
scripts/update-deps.sh
vendored
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update dependency locks for each submodule in the activitywatch repo
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
# For submodule in submodules:
|
||||
for submodule in $(git submodule | sed 's/^[+ ]//' | cut -d' ' -f2); do
|
||||
# Go to submodule
|
||||
cd $submodule
|
||||
|
||||
# Check that we're on the master branch and latest commit
|
||||
if [ $(git rev-parse --abbrev-ref HEAD) != "master" ]; then
|
||||
echo "Submodule $submodule is not on master branch, aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update dependency locks
|
||||
# Use poetry if poetry.lock exists, or cargo if Cargo.toml exists
|
||||
if [ -f "poetry.lock" ]; then
|
||||
poetry update
|
||||
elif [ -f "Cargo.toml" ]; then
|
||||
cargo update
|
||||
fi
|
||||
|
||||
# Go back to root
|
||||
cd ..
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user