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

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