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

This commit is contained in:
upstream-mirror
2026-09-15 22:29:06 +05:00
commit c5c5af8cde
85 changed files with 12052 additions and 0 deletions

7
.gitattributes vendored Normal file
View File

@@ -0,0 +1,7 @@
# See https://github.com/github/linguist for details
# Trick to remove some build tools from language overview
Makefile linguist-vendored
*.sh linguist-vendored
*.cmd linguist-vendored
*.ps1 linguist-vendored

8
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
# Docs for this file can be found here:
# https://docs.github.com/en/github/administering-a-repository/displaying-a-sponsor-button-in-your-repository
github: ["ActivityWatch", "ErikBjare", "johan-bjareholt"]
patreon: "erikbjare"
open_collective: "activitywatch"
liberapay: "ActivityWatch"
custom: ["https://activitywatch.net/donate/"]

58
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

@@ -0,0 +1,58 @@
---
name: "\U0001F41E Bug report"
about: Did you find a bug?
title: ''
labels: 'type: bug'
assignees: ''
---
<!--
Hi there! Thank you for discovering and submitting an issue.
Before you submit this; let's make sure of a few things.
Please make sure the following boxes are ticked if they are correct.
If not, please try and complete them first.
-->
<!-- Checked checkbox should look like this: [x] -->
- [ ] I am on the [latest](https://github.com/ActivityWatch/activitywatch/releases/latest) ActivityWatch version.
- [ ] I have searched the issues of this repo and believe that this is not a duplicate.
<!--
Once those are done, if you're able to fill in the following list with your information,
it'd be very helpful to whoever handles the issue.
-->
- **OS name and version**: <!-- Replace this comment with OS name + version -->
- **ActivityWatch version**: <!-- Replace this comment with the ActivityWatch version (found at the bottom of the Web UI) -->
## Describe the bug
<!-- A clear and concise description of what the bug is. -->
## To Reproduce
<!--
Steps to reproduce the behavior, for example:
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
-->
## Expected behavior
<!-- A clear and concise description of what you expected to happen. -->
## Documentation
<!--
If applicable, add screenshots or logs to help explain your problem.
Logs can be found in different places depending on platform:
- Windows: `C:\Users\<USER>\AppData\Local\ActivityWatch\Logs`
- macOS: `/Users/<USER>/Library/Logs/activitywatch`
- Linux: `/home/<USER>/.cache/activitywatch/log`
They can be opened with any plain text editor.
-->
## Additional context
<!-- Add any other context about the problem here. -->

19
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
# Issue templates are based on templates for poetry:
# https://github.com/python-poetry/poetry/tree/master/.github/ISSUE_TEMPLATE
blank_issues_enabled: false
contact_links:
- name: "\U0001F381 Feature requests"
url: https://forum.activitywatch.net/c/features
about: Request and vote on features on the forum.
- name: "\u2753 Support"
url: https://forum.activitywatch.net/
about: Need help with something? Ask for help on the forum!
- name: "\U0001F4AD Discussion (on our forum)"
url: https://forum.activitywatch.net/
about: The preferred place for general discussion about ActivityWatch
- name: "\U0001F4AD Discussion (on GitHub Discussions)"
url: https://github.com/ActivityWatch/activitywatch/discussions
about: We're testing it out (but the forum is still the preferred place).
- name: "\U0001F4AC Chat with us on Discord"
url: https://discord.gg/dctJK6USjK
about: We love to see people who are active in issues on our Discord, come join us!

View File

@@ -0,0 +1,18 @@
---
name: "\U0001F5C3 Everything Else"
about: For questions and issues that do not fall in any of the other categories.
title: ''
labels: ''
assignees: ''
---
<!-- Describe your question and issue here. This space is meant to be used for general questions that are neither bugs nor feature requests. If you're looking for help or support, please post on the forum instead: https://forum.activitywatch.net/ -->
<!-- Checked checkbox should look like this: [x] -->
- [ ] I have searched the [issues](https://github.com/ActivityWatch/activitywatch/issues) of this repo and believe that this is not a duplicate.
- [ ] I have searched the [documentation](https://docs.activitywatch.net/en/latest/) and believe that my question is not covered.
## Issue
<!-- Now feel free to write your issue, but please be descriptive! Thanks again 🙌 ❤️ -->

27
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# Set update schedule for GitHub Actions
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
# Maintain submodule versions so module releases propagate into the meta-repo
- package-ecosystem: "gitsubmodule"
directory: "/"
schedule:
interval: "monthly"
# Maintain dependencies for pip/poetry
# NOTE: too noisy, easier to update by hand
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 0
ignore:
# pytest 9.0.3 is the first non-vulnerable release, but it requires
# Python >=3.10 while ActivityWatch still builds/releases on Python 3.9.
- dependency-name: "pytest"
versions: [">=9"]

19
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 365
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 14
# Issues with these labels will never be considered stale
exemptLabels:
- '!pinned'
- 'priority: high'
- 'improves: security'
- 'improves: sustainability'
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

43
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: "CodeQL"
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: "57 14 * * 4"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ python, javascript ]
steps:
- name: Checkout
uses: actions/checkout@v7
with:
submodules: recursive
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"

View File

@@ -0,0 +1,27 @@
name: Dependabot Auto-merge
# NOTE: This workflow relies on a Personal Access Token from the @ActivityWatchBot user
# See this issue for details: https://github.com/ridedott/merge-me-action/issues/1581
on:
workflow_run:
types:
- completed
workflows:
# List all required workflow names here.
- Build
permissions:
contents: write
pull-requests: read
jobs:
auto_merge:
name: Auto-merge
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success' && github.actor == 'dependabot[bot]'
steps:
- uses: ridedott/merge-me-action@e45e8ab1dd3d3dc1d27b8c0a3f465e40e8c9fb8e # v2
with:
GITHUB_TOKEN: ${{ secrets.AWBOT_GH_TOKEN }}

28
.github/workflows/diagram.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Diagram
on:
workflow_dispatch: {}
push:
branches:
- diagram
#- master # protected branch, can't push updated diagram to
jobs:
update-diagram:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: recursive
- name: Also checkout docs & website
run: |
git clone https://github.com/ActivityWatch/docs
git clone https://github.com/ActivityWatch/activitywatch.github.io
- name: Update diagram
uses: githubocto/repo-visualizer@a999615bdab757559bf94bda1fe6eef232765f85 # main
with:
commit_message: 'chore: update diagram [skip ci]'
file_colors: '{"rs": "#b7410e", "py": "#229922", "rst": "pink", "txt": "pink", "md": "pink", "css": "purple", "scss": "purple"}'
excluded_globs: "**/.github;**/.git;**/*.builds;**/*.bat;**/*.iss;**/*.ps1;**/*.pyi;**/*.plist;**/*.cmd"
#excluded_paths: '.github'

25
.github/workflows/greetings.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Greetings
on: [issues, pull_request]
jobs:
greeting:
runs-on: ubuntu-latest
if: github.repository_owner == 'ActivityWatch' # don't run on forks
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: >
Hi there!
As you're new to this repo, please make sure you've used an appropriate [issue template](https://github.com/ActivityWatch/activitywatch/issues/new/choose) and searched for duplicates (it helps us focus on actual development!).
We'd also like to suggest that you read our [contribution guidelines](https://github.com/ActivityWatch/activitywatch/blob/master/CONTRIBUTING.md) and our [code of conduct](https://github.com/ActivityWatch/activitywatch/blob/master/CODE_OF_CONDUCT.md).
Thanks a bunch for opening your first issue! 🙏
pr-message: >
Congratulations on opening your first pull request to this repo!
We'll get back to you as soon as possible. In the meantime, please make sure you've read our [contribution guidelines](https://github.com/ActivityWatch/activitywatch/blob/master/CONTRIBUTING.md).
Thanks for contributing!

1471
.github/workflows/release.yml vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
name: Research feed publisher tests
on:
pull_request:
paths:
- 'scripts/package/publish_research_feed.py'
- 'scripts/tests/test_publish_research_feed.py'
- '.github/workflows/research-feed-publisher-tests.yml'
push:
branches: [master]
paths:
- 'scripts/package/publish_research_feed.py'
- 'scripts/tests/test_publish_research_feed.py'
- '.github/workflows/research-feed-publisher-tests.yml'
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.9'
- run: pip install pytest 'semver>=3,<4'
- run: python3 -m pytest scripts/tests/test_publish_research_feed.py -q

175
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,175 @@
name: Test
on:
#push:
# branches: [ master ]
#pull_request:
# branches: [ master ]
workflow_dispatch:
jobs:
# an integration test designed to catch bugs triggered by updating (database migrations and such)
upgrades:
name: upgrade from ${{ matrix.aw_server_old }} ${{ matrix.aw_server_old_args }} to ${{ matrix.aw_server_new }} ${{ matrix.aw_server_new_args }}
# needs: [build]
#if: startsWith(github.ref, 'refs/tags/v') # only run on tag
runs-on: ubuntu-latest
env:
old_version: 'v0.12.2'
new_version: 'v0.13.1'
strategy:
fail-fast: false
matrix:
aw_server_old: ['aw-server', 'aw-server-rust']
aw_server_new: ['aw-server', 'aw-server-rust']
aw_server_old_args: ['']
aw_server_new_args: ['']
include:
# python, peewee (default)
- aw_server_old: 'aw-server'
aw_server_new: 'aw-server'
# python, sqlite
# FIXME: sqlite broken since aw-server enabled flask multithreading (new default)
- aw_server_old: "aw-server"
aw_server_new: "aw-server"
aw_server_old_args: "--storage sqlite"
aw_server_new_args: "--storage sqlite"
old_version: 'v0.12.2'
new_version: 'v0.13.1'
# python, peewee to sqlite
# FIXME: broken, same thing with sqlite as above
- aw_server_old: "aw-server"
aw_server_new: "aw-server"
aw_server_old_args: "--storage peewee"
aw_server_new_args: "--storage sqlite"
old_version: 'v0.12.2'
new_version: 'v0.13.1'
exclude:
# rust to python, not supported
- aw_server_old: 'aw-server-rust'
aw_server_new: 'aw-server'
steps:
# Will download all artifacts to path
- name: Download build artifacts
if: ${{ env.new_version == 'this' }}
uses: actions/download-artifact@v8
with:
name: builds-Linux-py3.9
path: dist
# Only used during testing, so we don't have to wait for the main build job
- name: Download new ActivityWatch
if: ${{ env.new_version != 'this' }}
run: |
mkdir dist
pushd dist
wget -q https://github.com/ActivityWatch/activitywatch/releases/download/${{ env.new_version }}/activitywatch-${{ env.new_version }}-linux-x86_64.zip
- name: Install new & old ActivityWatch
run: |
pushd dist
# New version
unzip activitywatch-*-linux-x86_64.zip
mv activitywatch/ aw-new
# Old version
wget -q -O aw-old.zip https://github.com/ActivityWatch/activitywatch/releases/download/${{ env.old_version }}/activitywatch-${{ env.old_version }}-linux-x86_64.zip
unzip aw-old.zip
mv activitywatch/ aw-old
- name: Display structure of downloaded files
run: ls -R
working-directory: dist
- name: Run and test old server
run: |
bin=dist/aw-old/${{ matrix.aw_server_old }}/${{ matrix.aw_server_old }}
url="http://localhost:5600"
# Check version
$bin --version || true # due to bug in old aw-server
# Run server and log output
$bin ${{ matrix.aw_server_old_args }} >> log-old.txt 2>&1 &
sleep 5 # wait for startup
# Set server URL
# Get server info
curl "$url/api/0/info" --fail-with-body
# Create bucket
curl -X 'POST' --fail-with-body \
"$url/api/0/buckets/aw-test" \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"client": "test",
"type": "test",
"hostname": "test"
}'
# Get buckets
curl "$url/api/0/buckets/" -H 'accept: application/json'
# Send a heartbeat
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
curl -X 'POST' \
"$url/api/0/buckets/aw-test/heartbeat?pulsetime=0" \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"timestamp": "'$timestamp'",
"duration": 0,
"data": {"key": "test value"}
}'
# Give a sec, then kill server process
sleep 1
kill $!
- name: Run and test new server
run: |
bin=dist/aw-new/${{ matrix.aw_server_new }}/${{ matrix.aw_server_new }}
url="http://localhost:5600"
# Check version
$bin --version
# Run server and log output
$bin ${{ matrix.aw_server_new_args }} >> log-new.txt 2>&1 &
sleep 5 # wait for startup
# Get server info
curl "$url/api/0/info"
# Get buckets
curl "$url/api/0/buckets/" -H 'accept: application/json'
# Send a heartbeat
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
curl -X 'POST' --fail-with-body \
"$url/api/0/buckets/aw-test/heartbeat?pulsetime=60" \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"timestamp": "'$timestamp'",
"duration": 0,
"data": {"key": "test value"}
}'
# Give a sec, then kill server process
sleep 1
kill $!
- name: Output logs
if: always()
run: |
cat log-old.txt || true
echo "\n---\n"
cat log-new.txt || true

13
.github/workflows/winget.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Publish to WinGet
on:
release:
types: [released]
jobs:
publish:
runs-on: windows-latest # action can only be run on windows
steps:
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: ActivityWatch.ActivityWatch
token: ${{ secrets.GH_TOKEN_WINGET_AUTOUPDATE }}
fork-user: ActivityWatchBot

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
build
dist
docs
other
old
# Coverage
*coverage*
htmlcov
# Editor/IDEs
.idea
*.swp
# Python
*venv*
__pycache__
.python-version
# Misc
.*cache
.DS_Store

33
.gitmodules vendored Normal file
View File

@@ -0,0 +1,33 @@
[submodule "aw-core"]
path = aw-core
url = https://github.com/ActivityWatch/aw-core.git
[submodule "aw-client"]
path = aw-client
url = https://github.com/ActivityWatch/aw-client.git
[submodule "aw-server"]
path = aw-server
url = https://github.com/ActivityWatch/aw-server.git
[submodule "aw-watcher-afk"]
path = aw-watcher-afk
url = https://github.com/ActivityWatch/aw-watcher-afk.git
[submodule "aw-qt"]
path = aw-qt
url = https://github.com/ActivityWatch/aw-qt.git
[submodule "aw-watcher-window"]
path = aw-watcher-window
url = https://github.com/ActivityWatch/aw-watcher-window.git
[submodule "aw-server-rust"]
path = aw-server-rust
url = https://github.com/ActivityWatch/aw-server-rust.git
[submodule "aw-watcher-input"]
path = aw-watcher-input
url = https://github.com/ActivityWatch/aw-watcher-input.git
[submodule "aw-tauri"]
path = aw-tauri
url = https://github.com/activitywatch/aw-tauri
[submodule "awatcher"]
path = awatcher
url = https://github.com/2e3s/awatcher
[submodule "aw-notify"]
path = aw-notify
url = https://github.com/ActivityWatch/aw-notify-rs.git

4
.tool-versions Normal file
View File

@@ -0,0 +1,4 @@
poetry 1.5.1
nodejs 16.20.2
rust nightly
python 3.9.13

14
CITATION.cff Normal file
View File

@@ -0,0 +1,14 @@
cff-version: 1.2.0
message: "If you use or refer to this software in your research, please cite it."
authors:
- family-names: "Bjäreholt"
given-names: "Erik"
orcid: "https://orcid.org/0000-0003-1350-9677"
- family-names: "Bjäreholt"
given-names: "Johan"
orcid: "https://orcid.org/0000-0003-4789-3160"
title: "ActivityWatch"
version: 0.13.1
doi: 10.5281/zenodo.4957165
date-released: 2024-06-10
url: "https://github.com/ActivityWatch/activitywatch"

46
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at erik@bjareho.lt. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

124
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,124 @@
How to Contribute
=================
<!-- This guide could be improved by following the advice at https://mozillascience.github.io/working-open-workshop/contributing/ -->
**Table of Contents**
- [Getting started](#getting-started)
- [How you can help](#how-you-can-help)
- [Filing an issue](#filing-an-issue)
- [Code of Conduct](#code-of-conduct)
- [Commit message guidelines](#commit-message-guidelines)
- [Getting paid](#getting-paid)
- [Claiming GitPOAP](#claiming-gitpoap)
- [Questions?](#questions)
## Getting started
To develop on ActivityWatch you'll first want to install from source. To do so, follow [the guide in the documentation](https://activitywatch.readthedocs.io/en/latest/installing-from-source.html).
You might then want to read about the [architecture](https://activitywatch.readthedocs.io/en/latest/architecture.html) and the [data model](https://activitywatch.readthedocs.io/en/latest/buckets-and-events.html).
If you want some code examples for how to write watchers or other types of clients, see the [documentation for writing watchers](https://docs.activitywatch.net/en/latest/examples/writing-watchers.html).
## How you can help
There are many ways to contribute to ActivityWatch:
- Work on issues labeled [`good first issue`][good first issue] or [`help wanted`][help wanted], these are especially suited for new contributors.
- Fix [`bugs`][bugs].
- Implement new features.
- Look among the [requested features][requested features] on the forum.
- Talk to us in the issues or on [our Discord server][discord] to get help on how to proceed.
- Write [documentation](https://github.com/ActivityWatch/docs).
- Build the ecosystem.
- Examples: New watchers, tools to analyze data, tools to import data from other sources, etc.
If you're interested in what's next for ActivityWatch, have a look at our [roadmap][roadmap] and [milestones][milestones].
Most of the above will get you up on our [contributor stats page][contributors] as thanks!
[good first issue]: https://github.com/ActivityWatch/activitywatch/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22
[help wanted]: https://github.com/ActivityWatch/activitywatch/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22
[bugs]: https://github.com/ActivityWatch/activitywatch/issues?q=is%3Aissue+is%3Aopen+label%3A%22type%3A+bug%22
[milestones]: https://github.com/ActivityWatch/activitywatch/milestones
[roadmap]: https://github.com/orgs/ActivityWatch/projects/2
[requested features]: https://forum.activitywatch.net/c/features
[contributors]: http://activitywatch.net/contributors/
## Filing an issue
Thanks for wanting to help out with squashing bugs and more by filing an issue.
When filing an issue, it's important to use an [issue template](https://github.com/ActivityWatch/activitywatch/issues/new/choose). This ensures that we have the information we need to understand the issue, so we don't have to ask for tons of follow-up questions, so we can fix the issue faster!
## Code of Conduct
We have a Code of Conduct that we expect all contributors to follow, you can find it in [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md).
## Commit message guidelines
When writing commit messages try to follow [Conventional Commits](https://www.conventionalcommits.org/). It is not a strict requirement (to minimize overhead for new contributors) but it is encouraged.
The format is:
```
<type>[optional scope]: <description>
[optional body]
[optional footer]
```
Where `type` can be one of: `feat, fix, chore, ci, docs, style, refactor, perf, test`
Examples:
```
- feat: added ability to sort by duration
- fix: fixes incorrect week number (#407)
- docs: improved query documentation
```
This guideline was adopted in [issue #391](https://github.com/ActivityWatch/activitywatch/issues/391).
## Getting paid
We're experimenting with paying our contributors using funds we've raised from donations and grants.
The idea is you track your work with ActivityWatch (and ensure it gets categorized correctly), then you modify the [working_hours.py](https://github.com/ActivityWatch/aw-client/blob/master/examples/working_hours.py) script to use your category rule and generate a report of time worked per day and the matching events.
If you've contributed to ActivityWatch (for a minimum of 10h) and want to get paid for your time, contact us!
You can read more about this experiment on [the forum](https://forum.activitywatch.net/t/getting-paid-with-activitywatch/986) and in [the issues](https://github.com/ActivityWatch/activitywatch/issues/458).
## Claiming GitPOAP
If you've contributed a commit to ActivityWatch, you are eligible to claim a GitPOAP on Ethereum. You can read about it here: https://twitter.com/ActivityWatchIt/status/1584454595467612160
The one for 2022 looks like this:
<a href="https://www.gitpoap.io/gh/ActivityWatch/activitywatch">
<img src="https://assets.poap.xyz/gitpoap-2022-activitywatch-contributor-2022-logo-1663695908409.png" width="256px">
</a>
## Questions?
If you have any questions, you can:
- Talk to us on our [Discord server][discord]
- Post on [the forum][forum] or [GitHub Discussions][github discussions].
- (as a last resort/if needed) Email one of the maintainers at: [erik@bjareho.lt](mailto:erik@bjareho.lt)
[forum]: https://forum.activitywatch.net
[github discussions]: https://github.com/ActivityWatch/activitywatch/discussions
[discord]: https://discord.gg/vDskV9q

373
LICENSE.txt Normal file
View File

@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

265
Makefile vendored Normal file
View File

@@ -0,0 +1,265 @@
# =====================================
# Makefile for the ActivityWatch bundle
# =====================================
#
# [GUIDE] How to install from source:
# - https://activitywatch.readthedocs.io/en/latest/installing-from-source.html
#
# We recommend creating and activating a Python virtualenv before building.
# Instructions on how to do this can be found in the guide linked above.
.PHONY: build install test clean clean_all update-submodules sync-tauri-server
SHELL := /usr/bin/env bash
OS := $(shell uname -s)
ifeq ($(TAURI_BUILD),true)
SUBMODULES := aw-core aw-client aw-server aw-server-rust aw-watcher-afk aw-watcher-window aw-tauri
# Include awatcher on Linux (Wayland-compatible window watcher)
ifeq ($(OS),Linux)
SUBMODULES := $(SUBMODULES) awatcher
endif
else
SUBMODULES := aw-core aw-client aw-qt aw-server aw-server-rust aw-watcher-afk aw-watcher-window
endif
# Exclude aw-server-rust if SKIP_SERVER_RUST is true
ifeq ($(SKIP_SERVER_RUST),true)
SUBMODULES := $(filter-out aw-server-rust,$(SUBMODULES))
endif
# Include extras if AW_EXTRAS is true
ifeq ($(AW_EXTRAS),true)
SUBMODULES := $(SUBMODULES) aw-notify aw-watcher-input
endif
# A function that checks if a target exists in a Makefile
# Usage: $(call has_target,<dir>,<target>)
define has_target
$(shell make -q -C $1 $2 >/dev/null 2>&1; if [ $$? -eq 0 -o $$? -eq 1 ]; then echo $1; fi)
endef
# Submodules with test/package/lint/typecheck targets
TESTABLES := $(foreach dir,$(SUBMODULES),$(call has_target,$(dir),test))
PACKAGEABLES := $(foreach dir,$(SUBMODULES),$(call has_target,$(dir),package))
LINTABLES := $(foreach dir,$(SUBMODULES),$(call has_target,$(dir),lint))
TYPECHECKABLES := $(foreach dir,$(SUBMODULES),$(call has_target,$(dir),typecheck))
# When building with Tauri, aw-server-rust is built as aw-sync only (not full server),
# so exclude it from the standard package target
ifeq ($(TAURI_BUILD),true)
PACKAGEABLES := $(filter-out aw-server-rust aw-server, $(PACKAGEABLES))
endif
# Build mode: release vs debug
ifeq ($(RELEASE), false)
targetdir := debug
else
targetdir := release
endif
# The `build` target
# ------------------
#
# What it does:
# - Installs all the Python modules
# - Builds the web UI and bundles it with aw-server
build: aw-core/.git
# needed due to https://github.com/pypa/setuptools/issues/1963
# would ordinarily be specified in pyproject.toml, but is not respected due to https://github.com/pypa/setuptools/issues/1963
pip install 'setuptools>49.1.1'
for module in $(SUBMODULES); do \
echo "Building $$module"; \
if [ "$$module" = "aw-server-rust" ] && [ "$(TAURI_BUILD)" = "true" ]; then \
make --directory=$$module aw-sync SKIP_WEBUI=$(SKIP_WEBUI) || { echo "Error in $$module aw-sync"; exit 2; }; \
else \
make --directory=$$module build SKIP_WEBUI=$(SKIP_WEBUI) || { echo "Error in $$module build"; exit 2; }; \
fi; \
done
# The below is needed due to: https://github.com/ActivityWatch/activitywatch/issues/173
make --directory=aw-client build
make --directory=aw-core build
# Needed to ensure that the server has the correct version set
python -c "import aw_server; print(aw_server.__version__)"
# Install
# -------
#
# Installs things like desktop/menu shortcuts.
# Might in the future configure autostart on the system.
ifneq ($(TAURI_BUILD),true)
install:
make --directory=aw-qt install
# Installation is already happening in the `make build` step currently.
# We might want to change this.
# We should also add some option to install as user (pip3 install --user)
endif
# Update
# ------
#
# Pulls the latest version, updates all the submodules, then runs `make build`.
update:
git pull
git submodule update --init --recursive
make build
# Move direct submodules to their latest upstream commits. aw-tauri's lockfile
# is authoritative for the server revision, so align the top-level server
# submodule with it after updating everything else.
update-submodules:
git submodule update --init --remote
git submodule foreach 'git submodule update --init --recursive'
$(MAKE) sync-tauri-server
sync-tauri-server:
python3 scripts/check_tauri_server.py --sync
lint:
@for module in $(LINTABLES); do \
echo "Linting $$module"; \
make --directory=$$module lint || { echo "Error in $$module lint"; exit 2; }; \
done
typecheck:
@for module in $(TYPECHECKABLES); do \
echo "Typechecking $$module"; \
make --directory=$$module typecheck || { echo "Error in $$module typecheck"; exit 2; }; \
done
# Uninstall
# ---------
#
# Uninstalls all the Python modules.
uninstall:
modules=$$(pip3 list --format=legacy | grep 'aw-' | grep -o '^aw-[^ ]*'); \
for module in $$modules; do \
echo "Uninstalling $$module"; \
pip3 uninstall -y $$module; \
done
test:
@for module in $(TESTABLES); do \
echo "Running tests for $$module"; \
if [ -f "$$module/pyproject.toml" ]; then \
(cd $$module && poetry run make test) || { echo "Error in $$module tests"; exit 2; }; \
else \
make -C $$module test || { echo "Error in $$module tests"; exit 2; }; \
fi; \
done
test-integration:
# TODO: Move "integration tests" to aw-client
# FIXME: For whatever reason the script stalls on Appveyor
# Example: https://ci.appveyor.com/project/ErikBjare/activitywatch/build/1.0.167/job/k1ulexsc5ar5uv4v
# aw-server-python
@echo "== Integration testing aw-server =="
@pytest ./scripts/tests/integration_tests.py ./aw-server/tests/ -v
%/.git:
git submodule update --init --recursive
ifeq ($(TAURI_BUILD),true)
ICON := "aw-tauri/src-tauri/icons/icon.png"
else
ICON := "aw-qt/media/logo/logo.png"
endif
aw-qt/media/logo/logo.icns:
mkdir -p build/MyIcon.iconset
sips -z 16 16 $(ICON) --out build/MyIcon.iconset/icon_16x16.png
sips -z 32 32 $(ICON) --out build/MyIcon.iconset/icon_16x16@2x.png
sips -z 32 32 $(ICON) --out build/MyIcon.iconset/icon_32x32.png
sips -z 64 64 $(ICON) --out build/MyIcon.iconset/icon_32x32@2x.png
sips -z 128 128 $(ICON) --out build/MyIcon.iconset/icon_128x128.png
sips -z 256 256 $(ICON) --out build/MyIcon.iconset/icon_128x128@2x.png
sips -z 256 256 $(ICON) --out build/MyIcon.iconset/icon_256x256.png
sips -z 512 512 $(ICON) --out build/MyIcon.iconset/icon_256x256@2x.png
sips -z 512 512 $(ICON) --out build/MyIcon.iconset/icon_512x512.png
cp $(ICON) build/MyIcon.iconset/icon_512x512@2x.png
iconutil -c icns build/MyIcon.iconset
rm -R build/MyIcon.iconset
mv build/MyIcon.icns aw-qt/media/logo/logo.icns
# Stem of the macOS .app / .dmg. Research Edition patches APP_BUNDLE so the
# on-disk bundle does not collide with /Applications/ActivityWatch.app.
APP_BUNDLE ?= ActivityWatch
dist/$(APP_BUNDLE).app: aw-qt/media/logo/logo.icns
ifeq ($(TAURI_BUILD),true)
scripts/package/build_app_tauri.sh
else
pyinstaller --clean --noconfirm aw.spec
endif
dist/$(APP_BUNDLE).dmg: dist/$(APP_BUNDLE).app
# NOTE: This does not codesign the dmg, that is done in the CI config
pip install dmgbuild
@for attempt in 1 2 3; do \
rm -f dist/$(APP_BUNDLE).dmg; \
if dmgbuild -s scripts/package/dmgbuild-settings.py -D app=dist/$(APP_BUNDLE).app "$(APP_BUNDLE)" dist/$(APP_BUNDLE).dmg; then \
exit 0; \
fi; \
if [ $$attempt -eq 3 ]; then \
exit 1; \
fi; \
echo "dmgbuild attempt $$attempt failed; retrying after macOS releases the disk image" >&2; \
sleep $$((5 * attempt)); \
done
dist/notarize:
./scripts/notarize.sh
package:
rm -rf dist
mkdir -p dist/activitywatch
for dir in $(PACKAGEABLES); do \
make --directory=$$dir package; \
cp -r $$dir/dist/$$dir dist/activitywatch; \
done
ifeq ($(TAURI_BUILD),true)
# Copy aw-sync binary for Tauri builds
mkdir -p dist/activitywatch/aw-server-rust
cp aw-server-rust/target/$(targetdir)/aw-sync dist/activitywatch/aw-server-rust/aw-sync
else
# Move aw-qt to the root of the dist folder
# Rename first to avoid cp conflict: the aw-qt binary inside the dir has the
# same name as the source directory, so cp -a src/. dest/ would fail trying
# to overwrite the directory with the binary of the same name.
mv dist/activitywatch/aw-qt dist/aw-qt-tmp
cp -a dist/aw-qt-tmp/. dist/activitywatch/
rm -rf dist/aw-qt-tmp
endif
# Remove problem-causing binaries
rm -f dist/activitywatch/libdrm.so.2 # see: https://github.com/ActivityWatch/activitywatch/issues/161
rm -f dist/activitywatch/libharfbuzz.so.0 # see: https://github.com/ActivityWatch/activitywatch/issues/660#issuecomment-959889230
ifeq ($(shell uname),Linux)
# PyInstaller's Qt bundle may include an older Wayland client than Qt requires.
# All portable Linux packages are built from this directory, so remove it here
# and use the distro library instead. See: https://github.com/ActivityWatch/activitywatch/issues/939
find dist/activitywatch -name 'libwayland-client.so*' -delete
endif
# These should be provided by the distro itself
# Had to be removed due to otherwise causing the error:
# aw-qt: symbol lookup error: /opt/activitywatch/libQt5XcbQpa.so.5: undefined symbol: FT_Get_Font_Format
rm -f dist/activitywatch/libfontconfig.so.1
rm -f dist/activitywatch/libfreetype.so.6
# Remove unnecessary files
rm -rf dist/activitywatch/pytz
# Builds zips and setups
bash scripts/package/package-all.sh
clean:
rm -rf build dist
# Clean all subprojects
clean_all: clean
for dir in $(SUBMODULES); do \
make --directory=$$dir clean; \
done
clean-auto:
rm -rIv **/aw-server-rust/target
rm -rIv **/aw-android/mobile/build
rm -rIfv **/node_modules

276
README.md Normal file
View File

@@ -0,0 +1,276 @@
<img title="ActivityWatch" src="https://activitywatch.net/img/banner.png" align="center">
<p align="center">
<b>Records what you do</b> so that you can <i>know how you've spent your time</i>.
<br>
All in a secure way where <i>you control the data</i>.
</p>
<p align="center">
<a href="https://twitter.com/ActivityWatchIt">
<img title="Twitter follow" src="https://img.shields.io/twitter/follow/ActivityWatchIt.svg?style=social&label=Follow"/>
</a>
<a href="https://github.com/ActivityWatch/activitywatch">
<img title="Star on GitHub" src="https://img.shields.io/github/stars/ActivityWatch/activitywatch.svg?style=social&label=Star">
</a>
<br>
<b>
<a href="https://activitywatch.net/">Website</a>
— <a href="https://forum.activitywatch.net/">Forum</a>
— <a href="https://docs.activitywatch.net">Documentation</a>
— <a href="https://github.com/ActivityWatch/activitywatch/releases">Releases</a>
</b>
<br>
<b>
<a href="https://activitywatch.net/contributors/">Contributor stats</a>
— <a href="https://activitywatch.net/ci/">CI overview</a>
</b>
</p>
<p align="center">
<a href="https://github.com/ActivityWatch/activitywatch/actions?query=branch%3Amaster">
<img title="Build Status GitHub" src="https://github.com/ActivityWatch/activitywatch/workflows/Build/badge.svg?branch=master" />
</a>
<a href="https://ci.appveyor.com/project/ErikBjare/activitywatch">
<img title="Build Status Appveyor" src="https://ci.appveyor.com/api/projects/status/vm7g9sdfi2vgix6n?svg=true" />
</a>
<a href="https://docs.activitywatch.net">
<img title="Documentation" src="https://readthedocs.org/projects/activitywatch/badge/?version=latest" />
</a>
<br>
<a href="https://github.com/ActivityWatch/activitywatch/releases">
<img title="Latest release" src="https://img.shields.io/github/release-pre/ActivityWatch/activitywatch.svg">
</a>
<a href="https://github.com/ActivityWatch/activitywatch/releases">
<img title="Total downloads (GitHub Releases)" src="https://img.shields.io/github/downloads/ActivityWatch/activitywatch/total.svg" />
</a>
<a href="https://discord.gg/vDskV9q">
<img title="Discord" src="https://img.shields.io/discord/755040852727955476" />
</a>
<br>
<a href="https://activitywatch.net/donate/">
<img title="Donated" src="https://img.shields.io/badge/budget-~%24275%2Fmo%20from%2044%20supporters-orange.svg" />
</a>
<a href="https://doi.org/10.5281/zenodo.4957165">
<img src="https://zenodo.org/badge/DOI/10.5281/zenodo.4957165.svg" />
</a>
</p>
<!--
# TODO: Best practices badge that we should work towards, see issue #42.
[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/873/badge)](https://bestpractices.coreinfrastructure.org/projects/873)
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FActivityWatch%2Factivitywatch.svg?type=shield)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2FActivityWatch%2Factivitywatch?ref=badge_shield)
-->
*Do you want to receive email updates on major announcements?*<br>
***[Signup for the newsletter](http://eepurl.com/cTU6QX)!***
<details>
<summary>Table of Contents</summary>
* [About](#about)
* [Screenshots](#screenshots)
* [Is this yet another time tracker?](#is-this-yet-another-time-tracker)
* [Feature comparison](#feature-comparison)
* [Installation &amp; Usage](#installation--usage)
* [About this repository](#about-this-repository)
* [Server](#server)
* [Watchers](#watchers)
* [Libraries](#libraries)
* [Contributing](#contributing)
</details>
## About
The goal of ActivityWatch is simple: *Enable the collection of as much valuable lifedata as possible without compromising user privacy.*
We've worked towards this goal by creating an application for safe storage of the data on the user's local machine and as well as a set of watchers which record data such as:
- Currently active application and the title of its window
- Currently active browser tab and its title and URL
- Keyboard and mouse activity, to detect if you are AFK ("away from keyboard") or not
It is up to you as user to collect as much as you want, or as little as you want (and we hope some of you will help write watchers so we can collect more).
### Screenshots
<span><img src="https://activitywatch.net/img/screenshots/screenshot-v0.9.3-activity.png" width="45%"></span>
<span><img src="https://activitywatch.net/img/screenshots/screenshot-v0.8.0b9-timeline.png" width="50%"></span>
You can find more (and newer) screenshots on [the website](https://activitywatch.net/screenshots/).
## Installation & Usage
Downloads are available on the [releases page](https://github.com/ActivityWatch/activitywatch/releases).
For instructions on how to get started, please see the [guide in the documentation](https://docs.activitywatch.net/en/latest/getting-started.html).
Interested in building from source? [There's a guide for that too](https://docs.activitywatch.net/en/latest/installing-from-source.html).
## Is this yet another time tracker?
Yes, but we found that most time trackers lack one or more important features.
**Common dealbreakers:**
- Not open source
- The user does not own the data (common with non-open source options)
- Lack of synchronization (and when available: it's centralized and the sync server knows everything)
- Difficult to setup/use (most open source options tend to target programmers)
- Low data resolution (low level of detail, does not store raw data, long intervals between entries)
- Hard or impossible to extend (collecting more data is not as simple as it could be)
**To sum it up:**
- Closed source solutions suffer from privacy issues and limited features.
- Open source solutions aren't developed with end-users in mind and are usually not written to be easily extended (they lack a proper API). They also lack synchronization.
We have a plan to address all of these and we're well on our way. See the table below for our progress.
### Feature comparison
##### Basics
| | User owns data | GUI | Sync | Open Source |
| ------------- |:------------------:|:------------------:|:--------------------------:|:------------------:|
| ActivityWatch | :white_check_mark: | :white_check_mark: | [WIP][sync], decentralized | :white_check_mark: |
| [Selfspy] | :white_check_mark: | :x: | :x: | :white_check_mark: |
| [ulogme] | :white_check_mark: | :white_check_mark: | :x: | :white_check_mark: |
| [RescueTime] | :x: | :white_check_mark: | Centralized | :x: |
| [WakaTime] | :x: | :white_check_mark: | Centralized | Clients |
[sync]: https://github.com/ActivityWatch/activitywatch/issues/35
[Selfspy]: https://github.com/selfspy/selfspy
[ulogme]: https://github.com/karpathy/ulogme
[RescueTime]: https://www.rescuetime.com/
[WakaTime]: https://wakatime.com/
##### Platforms
<!-- TODO: Replace Platform names with icons -->
| | Windows | macOS | Linux | Android | iOS |
| ------------- |:------------------:|:------------------:|:------------------:|:------------------:|:-------------------:|
| ActivityWatch | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |:x: |
| Selfspy | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: |:x: |
| ulogme | :x: | :white_check_mark: | :white_check_mark: | :x: |:x: |
| RescueTime | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |Limited functionality|
##### Tracking
| | App & Window Title | AFK | Browser Extensions | Editor Plugins | Extensible |
| ------------- |:------------------:|:------------------:|:------------------:|:------------------:|:---------------------:|
| ActivityWatch | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Selfspy | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: |
| ulogme | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: |
| RescueTime | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: |
| WakaTime | :x: | :white_check_mark: | :white_check_mark: | :white_check_mark: | Only for text editors |
For a complete list of the things ActivityWatch can track, [see the page on *watchers* in the documentation](https://docs.activitywatch.net/en/latest/watchers.html).
## Architecture
```mermaid
graph TD;
aw-qt[<a href='https://github.com/ActivityWatch/aw-qt'>aw-qt</a>];
aw-notify[<a href='https://github.com/ActivityWatch/aw-notify-rs'>aw-notify</a>];
aw-server[<a href='https://github.com/ActivityWatch/aw-server'>aw-server</a>];
aw-webui[<a href='https://github.com/ActivityWatch/aw-webui'>aw-webui</a>];
aw-watcher-window[<a href='https://github.com/ActivityWatch/aw-watcher-window'>aw-watcher-window</a>];
aw-watcher-afk[<a href='https://github.com/ActivityWatch/aw-watcher-afk'>aw-watcher-afk</a>];
aw-watcher-web[<a href='https://github.com/ActivityWatch/aw-watcher-web'>aw-watcher-web</a>];
aw-sync[<a href='https://github.com/ActivityWatch/aw-server-rust/tree/master/aw-sync'>aw-sync</a>];
aw-qt -- Manages --> aw-server;
aw-qt -- Manages --> aw-notify -- Queries --> aw-server;
aw-qt -- Manages --> aw-watcher-window -- Watches --> S1[Active window] -- Heartbeats --> aw-server;
aw-qt -- Manages --> aw-watcher-afk -- Watches --> S2[AFK status] -- Heartbeats --> aw-server;
Browser -- Manages --> aw-watcher-web -- Watches --> S3[Active tab] -- Heartbeats --> aw-server;
SF -- Dropbox/Syncthing/etc --> SF;
aw-server <-- Push/Pull --> aw-sync <-- Read/Write --> SF[Sync folder];
aw-server -- Serves --> aw-webui -- Queries --> aw-server;
%% User -- Interacts --> aw-webui;
%% User -- Observes --> aw-notify;
%% User -- Interacts --> aw-qt;
classDef lightMode fill:#FFFFFF, stroke:#333333, color:#333333;
classDef darkMode fill:#333333, stroke:#FFFFFF, color:#FFFFFF;
classDef lightModeLinks stroke:#333333;
classDef darkModeLinks stroke:#FFFFFF;
class A,B,C,D,E,G lightMode;
class A,B,C,D,E,G darkMode;
%% linkStyle 0 stroke:#FF4136, stroke-width:2px;
%% linkStyle 1 stroke:#1ABC9C, stroke-width:2px;
```
## About this repository
This repo is a bundle of the core components and official modules of ActivityWatch (managed with `git submodule`). Its primary use is as a meta-package providing all the components in one repo; enabling easier packaging and installation. It is also where releases of the full suite are published (see [releases](https://github.com/ActivityWatch/activitywatch/releases)).
### Server
ActivityWatch has two server implementations:
- `aw-server` (Python) - The current default implementation
- `aw-server-rust` - A Rust implementation that is the planned future default
Both provide a REST API to a datastore and query engine, and serve the web interface developed in the `aw-webui` project (which provides the frontend).
The REST API includes:
- Access to a datastore suitable for timeseries/timeperiod-data organized in "buckets" (containers grouping related activity data by metadata like client type or hostname)
- **Buckets API:** Create, retrieve, and delete data buckets
- **Events API:** Read and write timestamped events within buckets
- **Heartbeat API:** Watchers use heartbeat signals to update the current state of activity (e.g., active application, AFK status)
- **Query API:** simple query scripting language for filtering, merging, grouping, and transforming events
- **Client libraries:** Language-specific libraries like `aw-client` (Python), `aw-client-js`, and `aw-client-rust` that wrap REST endpoints for programmatic access
The frontend (`aw-webui`) includes:
- **Data visualization:** Dashboard and timeline views showing activity summaries with detailed breakdowns of app usage, web browsing, and user-defined categories
- **Query explorer:** Browser-based interface for writing, executing, and debugging queries with real-time results
- **Activity browser:** Navigate through historical data with filtering by date ranges, applications, websites, and custom categories
- **Raw data access:** View and browse individual events from all tracking buckets with detailed metadata
- **Export functionality:** Export activity data in JSON format (individual buckets or complete datasets) via web interface or REST API
### Watchers
ActivityWatch comes pre-installed with two watchers:
- `aw-watcher-afk` tracks the user active/inactive state from keyboard and mouse input
- `aw-watcher-window` tracks the currently active application and its window title.
There are lots of other watchers for ActivityWatch which can track more types of activity. Like `aw-watcher-web` which tracks time spent on websites, multiple editor watchers which track spent time coding, and many more! A full list of watchers can be found in [the documentation](https://docs.activitywatch.net/en/latest/watchers.html).
### Libraries
- `aw-core` - core library, provides no runnable modules
- `aw-client` - client library, useful when writing watchers
### Folder structure
<span><img src="https://raw.githubusercontent.com/ActivityWatch/activitywatch/master/diagram.svg" width="60%"></span>
## Contributing
Want to help? Great! Check out the [CONTRIBUTING.md file](./CONTRIBUTING.md)!
## Questions and support
Have a question, suggestion, problem, or just want to say hi? Post on [the forum](https://forum.activitywatch.net/)!

25
SECURITY.md Normal file
View File

@@ -0,0 +1,25 @@
# Security Policy
<!--
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ---------- | ------------------ |
| 0.11.0 | :white_check_mark: |
| <= 0.10.0 | :x: |
-->
## Reporting a Vulnerability
<!--
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
-->
If you discover a vulnerability, please send a PGP encrypted email with details to [erik@bjareho.lt](mailto:erik@bjareho.lt) (preferably PGP encrypted using [this key](https://erik.bjareholt.com/erikbjare.asc)).

255
aw.spec Normal file
View File

@@ -0,0 +1,255 @@
# -*- mode: python -*-
# vi: set ft=python :
import os
import platform
import shlex
import subprocess
from pathlib import Path
import aw_core
import flask_restx
def build_analysis(name, location, binaries=[], datas=[], hiddenimports=[]):
name_py = name.replace("-", "_")
location_candidates = [
location / f"{name_py}/__main__.py",
location / f"src/{name_py}/__main__.py",
]
try:
location = next(p for p in location_candidates if p.exists())
except StopIteration:
raise Exception(f"Could not find {name} location from {location_candidates}")
return Analysis(
[location],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
)
def build_collect(analysis, name, console=True, entitlements=None):
"""Used to build the COLLECT statements for each module"""
pyz = PYZ(analysis.pure, analysis.zipped_data)
exe = EXE(
pyz,
analysis.scripts,
exclude_binaries=True,
name=name,
debug=False,
strip=False,
upx=True,
console=console,
contents_directory=".",
entitlements_file=entitlements or entitlements_file,
codesign_identity=codesign_identity,
)
return COLLECT(
exe,
analysis.binaries,
analysis.zipfiles,
analysis.datas,
strip=False,
upx=True,
name=name,
)
# Get the current release version
current_release = subprocess.run(
shlex.split("git describe --tags --abbrev=0"),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding="utf8",
).stdout.strip()
print("bundling activitywatch version " + current_release)
# Get entitlements and codesign identity
entitlements_file = Path(".") / "scripts" / "package" / "entitlements.plist"
app_entitlements_file = Path(".") / "scripts" / "package" / "app-entitlements.plist"
codesign_identity = os.environ.get("APPLE_PERSONALID", "").strip()
if not codesign_identity:
print("Environment variable APPLE_PERSONALID not set. Releases won't be signed.")
aw_core_path = Path(os.path.dirname(aw_core.__file__))
restx_path = Path(os.path.dirname(flask_restx.__file__))
aws_location = Path("aw-server")
aw_server_rust_location = Path("aw-server-rust")
aw_server_rust_bin = aw_server_rust_location / "target/package/aw-server-rust"
aw_sync_bin = aw_server_rust_location / "target/package/aw-sync"
aw_qt_location = Path("aw-qt")
awa_location = Path("aw-watcher-afk")
aww_location = Path("aw-watcher-window")
awi_location = Path("aw-watcher-input")
aw_notify_location = Path("aw-notify")
if platform.system() == "Darwin":
from PyInstaller.utils import osx as osxutils
icon = aw_qt_location / "media/logo/logo.icns"
else:
icon = aw_qt_location / "media/logo/logo.ico"
skip_rust = False
if not aw_server_rust_bin.exists():
skip_rust = True
print("Skipping Rust build because aw-server-rust binary not found.")
aw_qt_a = build_analysis(
"aw-qt",
aw_qt_location,
binaries=[(aw_server_rust_bin, "."), (aw_sync_bin, ".")] if not skip_rust else [],
datas=[
(aw_qt_location / "resources/aw-qt.desktop", "aw_qt/resources"),
(aw_qt_location / "media", "aw_qt/media"),
],
)
aw_server_a = build_analysis(
"aw-server",
aws_location,
datas=[
(aws_location / "aw_server/static", "aw_server/static"),
(restx_path / "templates", "flask_restx/templates"),
(restx_path / "static", "flask_restx/static"),
(aw_core_path / "schemas", "aw_core/schemas"),
],
)
aw_watcher_afk_a = build_analysis(
"aw_watcher_afk",
awa_location,
hiddenimports=[
"Xlib.keysymdef.miscellany",
"Xlib.keysymdef.latin1",
"Xlib.keysymdef.latin2",
"Xlib.keysymdef.latin3",
"Xlib.keysymdef.latin4",
"Xlib.keysymdef.greek",
"Xlib.support.unix_connect",
"Xlib.ext.shape",
"Xlib.ext.xinerama",
"Xlib.ext.composite",
"Xlib.ext.randr",
"Xlib.ext.xfixes",
"Xlib.ext.security",
"Xlib.ext.xinput",
"pynput.keyboard._xorg",
"pynput.mouse._xorg",
"pynput.keyboard._win32",
"pynput.mouse._win32",
"pynput.keyboard._darwin",
"pynput.mouse._darwin",
],
)
aw_watcher_input_a = build_analysis("aw_watcher_input", awi_location)
aw_watcher_window_a = build_analysis(
"aw_watcher_window",
aww_location,
binaries=(
[
(
aww_location / "aw_watcher_window/aw-watcher-window-macos",
"aw_watcher_window",
)
]
if platform.system() == "Darwin"
else []
),
datas=[
(aww_location / "aw_watcher_window/printAppStatus.jxa", "aw_watcher_window")
],
)
# Check if aw-notify is a Python package
_notify_candidates = [
aw_notify_location / "aw_notify/__main__.py",
aw_notify_location / "src/aw_notify/__main__.py",
]
skip_aw_notify = not any(p.exists() for p in _notify_candidates)
if skip_aw_notify:
print("Skipping aw-notify Python packaging (Rust-based implementation detected)")
aw_notify_a = None if skip_aw_notify else build_analysis(
"aw_notify", aw_notify_location, hiddenimports=["desktop_notifier.resources"]
)
# https://pythonhosted.org/PyInstaller/spec-files.html#multipackage-bundles
# MERGE takes a bit weird arguments, it wants tuples which consists of
# the analysis paired with the script name and the bin name
merge_args = [
(aw_server_a, "aw-server", "aw-server"),
(aw_qt_a, "aw-qt", "aw-qt"),
(aw_watcher_afk_a, "aw-watcher-afk", "aw-watcher-afk"),
(aw_watcher_window_a, "aw-watcher-window", "aw-watcher-window"),
(aw_watcher_input_a, "aw-watcher-input", "aw-watcher-input"),
]
if aw_notify_a is not None:
merge_args.append((aw_notify_a, "aw-notify", "aw-notify"))
MERGE(*merge_args)
# aw-server
aws_coll = build_collect(aw_server_a, "aw-server")
# aw-watcher-window
aww_coll = build_collect(aw_watcher_window_a, "aw-watcher-window")
# aw-watcher-afk
awa_coll = build_collect(aw_watcher_afk_a, "aw-watcher-afk")
# aw-qt
awq_coll = build_collect(
aw_qt_a,
"aw-qt",
console=False if platform.system() == "Windows" else True,
entitlements=app_entitlements_file,
)
# aw-watcher-input
awi_coll = build_collect(aw_watcher_input_a, "aw-watcher-input")
# aw-notify (only if Python package exists)
aw_notify_coll = build_collect(aw_notify_a, "aw-notify") if aw_notify_a is not None else None
if platform.system() == "Darwin":
bundle_args = [
awq_coll,
aws_coll,
aww_coll,
awa_coll,
awi_coll,
]
if aw_notify_coll is not None:
bundle_args.append(aw_notify_coll)
app = BUNDLE(
*bundle_args,
name="ActivityWatch.app",
icon=icon,
bundle_identifier="net.activitywatch.ActivityWatch",
version=current_release.lstrip("v"),
info_plist={
"NSPrincipalClass": "NSApplication",
"CFBundleExecutable": "MacOS/aw-qt",
"CFBundleIconFile": "logo.icns",
"NSAppleEventsUsageDescription": "Please grant access to use Apple Events",
# This could be set to a more specific version string (including the commit id, for example)
"CFBundleVersion": current_release.lstrip("v"),
# Replaced by the 'version' kwarg above
# "CFBundleShortVersionString": current_release.lstrip('v'),
},
)
# BUNDLE inherits signing settings from the last COLLECT and deep-signs the
# assembled bundle with them. Re-sign the application layer so its responsible
# executable retains app-only capabilities without propagating them to helpers.
osxutils.sign_binary(app.name, codesign_identity, app_entitlements_file)

10
gptme.toml Normal file
View File

@@ -0,0 +1,10 @@
files = [
"README.md",
"Makefile",
"aw-server/README.md",
"aw-server/aw-webui/README.md",
"aw-server-rust/README.md",
"aw-server-rust/aw-sync/README.md",
"aw-client/README.md",
# ideally we'd also include some of the docs here, but they are not a submodule
]

554
poetry.lock generated Normal file
View File

@@ -0,0 +1,554 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "altgraph"
version = "0.17.4"
description = "Python graph (network) package"
optional = false
python-versions = "*"
groups = ["dev"]
markers = "python_version < \"3.14\""
files = [
{file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"},
{file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"},
]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.6.1"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"},
{file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"},
{file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"},
{file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"},
{file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"},
{file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"},
{file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"},
{file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"},
{file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"},
{file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"},
{file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"},
{file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"},
{file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"},
{file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"},
{file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"},
{file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"},
{file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"},
{file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"},
{file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"},
{file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"},
{file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"},
{file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"},
{file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"},
{file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"},
{file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"},
{file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"},
{file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"},
{file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"},
{file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"},
{file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"},
{file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"},
{file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"},
{file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"},
{file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"},
{file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"},
{file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"},
{file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"},
{file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"},
{file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"},
{file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"},
{file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"},
{file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"},
{file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"},
{file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"},
{file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"},
{file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"},
{file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"},
{file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"},
{file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"},
{file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"},
{file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"},
{file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"},
{file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"},
{file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"},
{file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"},
{file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"},
{file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"},
{file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"},
{file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"},
{file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"},
{file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"},
{file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"},
{file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"},
{file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"},
{file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"},
{file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"},
{file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"},
{file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"},
{file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"},
{file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"},
{file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"},
{file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "exceptiongroup"
version = "1.2.2"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
markers = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
]
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "importlib-metadata"
version = "8.5.0"
description = "Read metadata from Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
markers = "python_version == \"3.9\""
files = [
{file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"},
{file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"},
]
[package.dependencies]
zipp = ">=3.20"
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
enabler = ["pytest-enabler (>=2.2)"]
perf = ["ipython"]
test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
type = ["pytest-mypy"]
[[package]]
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "macholib"
version = "1.16.3"
description = "Mach-O header analysis and editing"
optional = false
python-versions = "*"
groups = ["dev"]
markers = "python_version < \"3.14\" and sys_platform == \"darwin\""
files = [
{file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"},
{file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"},
]
[package.dependencies]
altgraph = ">=0.17"
[[package]]
name = "mypy"
version = "1.12.0"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "mypy-1.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4397081e620dc4dc18e2f124d5e1d2c288194c2c08df6bdb1db31c38cd1fe1ed"},
{file = "mypy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:684a9c508a283f324804fea3f0effeb7858eb03f85c4402a967d187f64562469"},
{file = "mypy-1.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cabe4cda2fa5eca7ac94854c6c37039324baaa428ecbf4de4567279e9810f9e"},
{file = "mypy-1.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:060a07b10e999ac9e7fa249ce2bdcfa9183ca2b70756f3bce9df7a92f78a3c0a"},
{file = "mypy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:0eff042d7257f39ba4ca06641d110ca7d2ad98c9c1fb52200fe6b1c865d360ff"},
{file = "mypy-1.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b86de37a0da945f6d48cf110d5206c5ed514b1ca2614d7ad652d4bf099c7de7"},
{file = "mypy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c7c5ce0c1be0b0aea628374e6cf68b420bcc772d85c3c974f675b88e3e6e57"},
{file = "mypy-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a64ee25f05fc2d3d8474985c58042b6759100a475f8237da1f4faf7fcd7e6309"},
{file = "mypy-1.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:faca7ab947c9f457a08dcb8d9a8664fd438080e002b0fa3e41b0535335edcf7f"},
{file = "mypy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:5bc81701d52cc8767005fdd2a08c19980de9ec61a25dbd2a937dfb1338a826f9"},
{file = "mypy-1.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8462655b6694feb1c99e433ea905d46c478041a8b8f0c33f1dab00ae881b2164"},
{file = "mypy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:923ea66d282d8af9e0f9c21ffc6653643abb95b658c3a8a32dca1eff09c06475"},
{file = "mypy-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ebf9e796521f99d61864ed89d1fb2926d9ab6a5fab421e457cd9c7e4dd65aa9"},
{file = "mypy-1.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e478601cc3e3fa9d6734d255a59c7a2e5c2934da4378f3dd1e3411ea8a248642"},
{file = "mypy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:c72861b7139a4f738344faa0e150834467521a3fba42dc98264e5aa9507dd601"},
{file = "mypy-1.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52b9e1492e47e1790360a43755fa04101a7ac72287b1a53ce817f35899ba0521"},
{file = "mypy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48d3e37dd7d9403e38fa86c46191de72705166d40b8c9f91a3de77350daa0893"},
{file = "mypy-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f106db5ccb60681b622ac768455743ee0e6a857724d648c9629a9bd2ac3f721"},
{file = "mypy-1.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:233e11b3f73ee1f10efada2e6da0f555b2f3a5316e9d8a4a1224acc10e7181d3"},
{file = "mypy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ae8959c21abcf9d73aa6c74a313c45c0b5a188752bf37dace564e29f06e9c1b"},
{file = "mypy-1.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eafc1b7319b40ddabdc3db8d7d48e76cfc65bbeeafaa525a4e0fa6b76175467f"},
{file = "mypy-1.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9b9ce1ad8daeb049c0b55fdb753d7414260bad8952645367e70ac91aec90e07e"},
{file = "mypy-1.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfe012b50e1491d439172c43ccb50db66d23fab714d500b57ed52526a1020bb7"},
{file = "mypy-1.12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c40658d4fa1ab27cb53d9e2f1066345596af2f8fe4827defc398a09c7c9519b"},
{file = "mypy-1.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:dee78a8b9746c30c1e617ccb1307b351ded57f0de0d287ca6276378d770006c0"},
{file = "mypy-1.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b5df6c8a8224f6b86746bda716bbe4dbe0ce89fd67b1fa4661e11bfe38e8ec8"},
{file = "mypy-1.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5feee5c74eb9749e91b77f60b30771563327329e29218d95bedbe1257e2fe4b0"},
{file = "mypy-1.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77278e8c6ffe2abfba6db4125de55f1024de9a323be13d20e4f73b8ed3402bd1"},
{file = "mypy-1.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:dcfb754dea911039ac12434d1950d69a2f05acd4d56f7935ed402be09fad145e"},
{file = "mypy-1.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:06de0498798527451ffb60f68db0d368bd2bae2bbfb5237eae616d4330cc87aa"},
{file = "mypy-1.12.0-py3-none-any.whl", hash = "sha256:fd313226af375d52e1e36c383f39bf3836e1f192801116b31b090dfcd3ec5266"},
{file = "mypy-1.12.0.tar.gz", hash = "sha256:65a22d87e757ccd95cbbf6f7e181e6caa87128255eb2b6be901bb71b26d8a99d"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = ">=4.6.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.5"
groups = ["dev"]
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "packaging"
version = "24.1"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
]
[[package]]
name = "pefile"
version = "2023.2.7"
description = "Python PE parsing module"
optional = false
python-versions = ">=3.6.0"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"},
{file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"},
]
[[package]]
name = "pluggy"
version = "1.5.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "psutil"
version = "6.0.0"
description = "Cross-platform lib for process and system monitoring in Python."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
groups = ["dev"]
files = [
{file = "psutil-6.0.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a021da3e881cd935e64a3d0a20983bda0bb4cf80e4f74fa9bfcb1bc5785360c6"},
{file = "psutil-6.0.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:1287c2b95f1c0a364d23bc6f2ea2365a8d4d9b726a3be7294296ff7ba97c17f0"},
{file = "psutil-6.0.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a9a3dbfb4de4f18174528d87cc352d1f788b7496991cca33c6996f40c9e3c92c"},
{file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:6ec7588fb3ddaec7344a825afe298db83fe01bfaaab39155fa84cf1c0d6b13c3"},
{file = "psutil-6.0.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e7c870afcb7d91fdea2b37c24aeb08f98b6d67257a5cb0a8bc3ac68d0f1a68c"},
{file = "psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35"},
{file = "psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1"},
{file = "psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0"},
{file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0"},
{file = "psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd"},
{file = "psutil-6.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e8d0054fc88153ca0544f5c4d554d42e33df2e009c4ff42284ac9ebdef4132"},
{file = "psutil-6.0.0-cp36-cp36m-win32.whl", hash = "sha256:fc8c9510cde0146432bbdb433322861ee8c3efbf8589865c8bf8d21cb30c4d14"},
{file = "psutil-6.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:34859b8d8f423b86e4385ff3665d3f4d94be3cdf48221fbe476e883514fdb71c"},
{file = "psutil-6.0.0-cp37-abi3-win32.whl", hash = "sha256:a495580d6bae27291324fe60cea0b5a7c23fa36a7cd35035a16d93bdcf076b9d"},
{file = "psutil-6.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:33ea5e1c975250a720b3a6609c490db40dae5d83a4eb315170c4fe0d8b1f34b3"},
{file = "psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0"},
{file = "psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2"},
]
[package.extras]
test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""]
[[package]]
name = "py-cpuinfo"
version = "9.0.0"
description = "Get CPU info with pure Python"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"},
{file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"},
]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyinstaller"
version = "6.11.0"
description = "PyInstaller bundles a Python application and all its dependencies into a single package."
optional = false
python-versions = "<3.14,>=3.8"
groups = ["dev"]
markers = "python_version < \"3.14\""
files = [
{file = "pyinstaller-6.11.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:6fd68a3c1207635c49326c54881b89d5c3bd9ba061bbc9daa58c0902db1be39e"},
{file = "pyinstaller-6.11.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:eddd53f231e51adc65088eac4f40057ca803a990239828d4a9229407fb866239"},
{file = "pyinstaller-6.11.0-py3-none-manylinux2014_i686.whl", hash = "sha256:e6d229009e815542833fe00332b589aa6984a06f794dc16f2ce1acab1c567590"},
{file = "pyinstaller-6.11.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d2cd2ebdcd6860f8a4abe2977264a7b6d260a7147047008971c7cfc66a656a4"},
{file = "pyinstaller-6.11.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:d9ec6d4398b4eebc1d4c00437716264ba8406bc2746f594e253070a82378a584"},
{file = "pyinstaller-6.11.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:04f71828aa9531ab18c9656985c1f09b83d10332c73a1f4a113a48b491906955"},
{file = "pyinstaller-6.11.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:a843d470768d68b05684ccf4860c45b2eb13727f41667c0b2cd8f57ae231bd18"},
{file = "pyinstaller-6.11.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:963dedc1f37144a4385f58f7f65f1c69c004a67faae522a2085b5ddb230c908b"},
{file = "pyinstaller-6.11.0-py3-none-win32.whl", hash = "sha256:c71024c8a19c7b221b9152b2baff4c3ba849cada68dcdd34382ba09f0107451f"},
{file = "pyinstaller-6.11.0-py3-none-win_amd64.whl", hash = "sha256:0e229610c22b96d741d905706f9496af472c1a9216a118988f393c98ecc3f51f"},
{file = "pyinstaller-6.11.0-py3-none-win_arm64.whl", hash = "sha256:a5f716bb507517912fda39d109dead91fc0dd2e7b2859562522b63c61aa21676"},
{file = "pyinstaller-6.11.0.tar.gz", hash = "sha256:cb4d433a3db30d9d17cf5f2cf7bb4df80a788d493c1d67dd822dc5791d9864af"},
]
[package.dependencies]
altgraph = "*"
importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""}
macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
packaging = ">=22.0"
pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""}
pyinstaller-hooks-contrib = ">=2024.8"
pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
setuptools = ">=42.0.0"
[package.extras]
completion = ["argcomplete"]
hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2024.9"
description = "Community maintained hooks for PyInstaller"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = []
develop = false
[package.dependencies]
importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""}
packaging = ">=22.0"
setuptools = ">=42.0.0"
[package.source]
type = "git"
url = "https://github.com/pyinstaller/pyinstaller-hooks-contrib.git"
reference = "master"
resolved_reference = "5bcaf52e302d2adeaf4457c435ffa07f5b4b1f53"
[[package]]
name = "pytest"
version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-benchmark"
version = "4.0.0"
description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "pytest-benchmark-4.0.0.tar.gz", hash = "sha256:fb0785b83efe599a6a956361c0691ae1dbb5318018561af10f3e915caa0048d1"},
{file = "pytest_benchmark-4.0.0-py3-none-any.whl", hash = "sha256:fdb7db64e31c8b277dff9850d2a2556d8b60bcb0ea6524e36e28ffd7c87f71d6"},
]
[package.dependencies]
py-cpuinfo = "*"
pytest = ">=3.8"
[package.extras]
aspect = ["aspectlib"]
elasticsearch = ["elasticsearch"]
histogram = ["pygal", "pygaljs"]
[[package]]
name = "pytest-cov"
version = "5.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
{file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
]
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
optional = false
python-versions = ">=3.6"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
{file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
]
[[package]]
name = "setuptools"
version = "80.10.2"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173"},
{file = "setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70"},
]
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
enabler = ["pytest-enabler (>=2.2)"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
[[package]]
name = "tomli"
version = "2.0.2"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
markers = "python_full_version <= \"3.11.0a6\""
files = [
{file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"},
{file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "zipp"
version = "3.20.2"
description = "Backport of pathlib-compatible object wrapper for zip files"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
markers = "python_version == \"3.9\""
files = [
{file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"},
{file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"},
]
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
enabler = ["pytest-enabler (>=2.2)"]
test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
type = ["pytest-mypy"]
[metadata]
lock-version = "2.1"
python-versions = "^3.9"
content-hash = "7a636d2a4560801509f660b530070b2fdae02f6dae003819eae3df3cba319a18"

38
pyproject.toml Normal file
View File

@@ -0,0 +1,38 @@
[tool.poetry]
name = "activitywatch"
version = "0.14.0"
description = "The free and open-source automated time tracker. Cross-platform, extensible, privacy-focused."
authors = ["Erik Bjäreholt <erik@bjareho.lt>", "Johan Bjäreholt <johan@bjareho.lt>"]
license = "MPL-2.0"
package-mode = false
[tool.poetry.dependencies]
python = "^3.9"
# Installing them from here won't work
#aw-core = {path = "aw-core"}
#aw-client = {path = "aw-client"}
#aw-watcher-afk = {path = "aw-watcher-afk"}
#aw-watcher-window = {path = "aw-watcher-window"}
#aw-server = {path = "aw-server"}
#aw-qt = {path = "aw-qt"}
[tool.poetry.group.dev.dependencies]
mypy = "*"
pytest = "*"
pytest-cov = "*"
pytest-benchmark = "*"
psutil = "*"
pywin32-ctypes = {version = "*", platform = "win32"}
pefile = {version = "*", platform = "win32"}
pyinstaller = {version = "*", python = "^3.9,<3.14"}
# releases are very infrequent, so good idea to use the master branch
# we need this unreleased commit: https://github.com/pyinstaller/pyinstaller-hooks-contrib/commit/0f40dc6e74086e5472aee75070b9077b4c17ab18
pyinstaller-hooks-contrib = {git = "https://github.com/pyinstaller/pyinstaller-hooks-contrib.git", branch="master"}
# PyInstaller still imports pkg_resources, which setuptools removed in 81+.
setuptools = ">=78.1.1,<81"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

597
scripts/build_changelog.py Executable file
View 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()

View 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
1 2e3s 2e3s19@gmail.com
2 750 37119951+750@users.noreply.github.com
3 Alwinator 39517491+Alwinator@users.noreply.github.com
4 BasileusErwin 67933444+BasileusErwin@users.noreply.github.com
5 BelKed 66956532+BelKed@users.noreply.github.com
6 CrazyPython Jamtlu@gmail.com
7 Drarig29 corentingirard.dev@gmail.com
8 Elijah-Bodden 106613755+Elijah-Bodden@users.noreply.github.com
9 Furffico 43836984+Furffico@users.noreply.github.com
10 GabLeRoux lebreton.gabriel@gmail.com
11 Game4Move78 83040764+Game4Move78@users.noreply.github.com
12 Julianoe Julianoe@users.noreply.github.com
13 LockBlock-dev 68129141+LockBlock-dev@users.noreply.github.com
14 LunarWatcher zoe.i2k1@gmail.com
15 NicoWeio kontakt@nicolaiweitkemper.de nico.weio@gmail.com
16 Organoidus 150709464+Organoidus@users.noreply.github.com
17 Shi-Soul 86898048+Shi-Soul@users.noreply.github.com
18 ShootingKing-AM narnindi.raghu@gmail.com
19 Shubham0324 53115519+Shubham0324@users.noreply.github.com
20 StefanoChiodino StefanoChiodino@users.noreply.github.com
21 TSRBerry 20988865+TSRBerry@users.noreply.github.com
22 Valentin-N 1926716+Valentin-N@users.noreply.github.com
23 Y7n05h Y7n05h@protonmail.com
24 aaayushsingh ayush-_-singh@live.com
25 alclary 9044153+alclary@users.noreply.github.com
26 alialamine ali@towbe.com
27 alwinator accounts@alwinschuster.at
28 0xbrayo vukubrian@gmail.com
29 chaoky levimanga@gmail.com
30 chengyuhui chengyuhui1@gmail.com
31 davidfraser davidfraser@users.noreply.github.com
32 dependabot-preview[bot] 27856297+dependabot-preview[bot]@users.noreply.github.com
33 dependabot[bot] 49699333+dependabot[bot]@users.noreply.github.com
34 erikbjare erik.bjareholt@gmail.com erik@bjareho.lt
35 hooger hooger@users.noreply.github.com
36 iloveitaly iloveitaly@gmail.com
37 infokiller infokiller@users.noreply.github.com
38 ishitatsuyuki ishitatsuyuki@gmail.com
39 jkbh 33606327+jkbh@users.noreply.github.com
40 johan-bjareholt johan@bjareho.lt
41 jtojnar jtojnar@gmail.com
42 kewde kewde@particl.io
43 lgtm-com[bot] 43144390+lgtm-com[bot]@users.noreply.github.com
44 liutiming 39947942+liutiming@users.noreply.github.com
45 luzpaz luzpaz@users.noreply.github.com
46 maciekstosio maciekstosio@users.noreply.github.com
47 michaeljelly 53475252+michaeljelly@users.noreply.github.com
48 modderme123 modderme123@users.noreply.github.com
49 nathanmerrill nathanmerrill@users.noreply.github.com
50 noisersup patryk@kwiatek.xyz
51 ochen1 o.chen1@share.epsb.ca
52 omahs 73983677+omahs@users.noreply.github.com
53 oscar-king oscar-king@users.noreply.github.com
54 pktiuk kotiuk@zohomail.eu
55 pkvach pavel.kvach@gmail.com
56 rakleed 19418601+rakleed@users.noreply.github.com
57 repo-visualizer repo-visualizer@users.noreply.github.com
58 salahineo salahineo.personal@gmail.com
59 skaparis 43264989+skaparis@users.noreply.github.com
60 skewballfox joshua.ferguson.273@gmail.com
61 soxofaan soxofaan@users.noreply.github.com
62 sunrosa 79175772+sunrosa@users.noreply.github.com
63 vedantmgoyal2009 83997633+vedantmgoyal2009@users.noreply.github.com
64 victorlin 13424970+victorlin@users.noreply.github.com
65 victorwinberg victor.m.winberg@gmail.com
66 vieteh viet.tran@employmenthero.com
67 xylix kerk.pelt@gmail.com
68 yuhldr yuhldr@qq.com
69 yumemio 59369226+yumemio@users.noreply.github.com

View File

@@ -0,0 +1,6 @@
0xbrayo subrupt
chaoky chaokyer
erikbjare erikbjare
iloveitaly mike_bianco
vedantmgoyal2009 vedantmgoyal
victorlin victorlin_
1 0xbrayo subrupt
2 chaoky chaokyer
3 erikbjare erikbjare
4 iloveitaly mike_bianco
5 vedantmgoyal2009 vedantmgoyal
6 victorlin victorlin_

280
scripts/check_tauri_server.py Executable file
View 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
View 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
View 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 $

View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
#!/bin/bash
echo "nop.bat was executed as a workaround for something"

108
scripts/notarize.sh vendored Executable file
View 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

View 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.

View 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.

View 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
View 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"

View 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}\"

View 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>

View 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
View 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
View 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"

View 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()

View 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

View 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',
}

View 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>

View 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
View 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
View 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
View 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
View 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
View 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

View 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()

View 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())

View 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()

View 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()

View 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())

View 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
View 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
View 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

View 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))

View 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")

View 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 "")

View 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

View 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"),
]
)

View 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

View 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

View 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

View 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
View 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
View 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