snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
This commit is contained in:
10
scripts/package/README.txt
Normal file
10
scripts/package/README.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
Run move-to-aw-modules.sh to copy all modules except aw-tauri to ~/aw-modules/.
|
||||
aw-tauri (replaces aw-qt) will use this directory to discover new modules.
|
||||
You can add your own modules and scripts to this directory. The modules should
|
||||
start with the aw- prefix and should not have an extension (e.g. no .sh).
|
||||
|
||||
In the aw-tauri folder there are AppImage, RPM, and DEB binaries. Choose the
|
||||
appropriate one for your Linux distribution. If in doubt, use the AppImage as
|
||||
it works on most Linux systems. If you use the AppImage, copy it to a permanent
|
||||
folder like ~/bin or /usr/local/bin, since autostart relies on the AppImage
|
||||
being in the same location each time.
|
||||
98
scripts/package/RESEARCH_FEED.md
Normal file
98
scripts/package/RESEARCH_FEED.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Research updater feed publisher
|
||||
|
||||
`publish_research_feed.py` prepares an ordinary commit of
|
||||
`latest-research.json` on the `research-updates` branch. It preserves branch
|
||||
history and existing files. The default invocation changes only a new local
|
||||
checkout. `--publish` explicitly enables the remote update.
|
||||
|
||||
This command is manual. There is no release-published trigger. Before live
|
||||
use, the release owner must verify research signing keys, bundle contents,
|
||||
installed-app upgrades, and the disposition of older research clients that
|
||||
still contain the standard updater endpoint/key. The publisher does not prove
|
||||
those properties or enable an update channel in an installed client.
|
||||
|
||||
## Prepare and review
|
||||
|
||||
Requires Git, a configured Git author, and `uv` (the script declares its
|
||||
`semver` dependency). Run from the ActivityWatch checkout, using a new output
|
||||
directory for each attempt:
|
||||
|
||||
```sh
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
TAG=v0.14.0b6-research
|
||||
MANIFEST=/absolute/path/to/reviewed/latest-research.json
|
||||
CANDIDATE=/absolute/path/to/new-feed-candidate
|
||||
|
||||
# Read the current remote head. Use the literal 'absent' only if this
|
||||
# succeeds and returns no matching ref (initial provisioning).
|
||||
git ls-remote --refs https://github.com/ActivityWatch/activitywatch.git \
|
||||
refs/heads/research-updates
|
||||
PARENT=<full-sha-from-the-output>
|
||||
|
||||
uv run --script "$REPO_ROOT/scripts/package/publish_research_feed.py" \
|
||||
--tag "$TAG" --manifest "$MANIFEST" --expected-parent "$PARENT" \
|
||||
--work-dir "$CANDIDATE"
|
||||
|
||||
git -C "$CANDIDATE" show HEAD
|
||||
```
|
||||
|
||||
The supplied manifest must already use normalized SemVer, for example
|
||||
`0.14.0-beta.6`. Tags and asset filenames retain their release spelling
|
||||
(`v0.14.0b6-research` / `activitywatch-tauri-research-0.14.0b6-…`).
|
||||
This publisher accepts stable and a/b/rc research tags, not development builds.
|
||||
Build/app version normalization is owned by the release configuration work;
|
||||
this command validates the tag/version association without modifying builds.
|
||||
|
||||
Validation requires:
|
||||
|
||||
- An unauthenticated GitHub API read of the exact published, immutable,
|
||||
non-draft research release.
|
||||
- All five release targets: macOS arm64/x86_64, Linux arm64/x86_64, and Windows
|
||||
x86_64. A reduced platform rollout requires an explicit code review.
|
||||
- A supported updater bundle and nonempty uploaded `.sig` asset for each
|
||||
platform, under the exact selected release and research filename prefix.
|
||||
- The manifest signature must equal the publicly downloaded `.sig` contents.
|
||||
Cryptographic signature verification and embedded payload-version/edition
|
||||
verification remain release-owner gates; this command does not download
|
||||
the potentially large bundles.
|
||||
- A valid static updater manifest, with RFC3339 publication date and string
|
||||
notes. Unknown fields are refused to avoid alternate updater parsing paths.
|
||||
|
||||
## Publish after release acceptance
|
||||
|
||||
After reviewing the candidate and satisfying the release gates, rerun the
|
||||
same invocation with a **new** work directory and `--publish`. It repeats all
|
||||
release checks and compares the feed's current SemVer before committing.
|
||||
Git's configured credentials authorize the push; public release reads do not
|
||||
use those credentials. Keep the recorded parent unchanged; a stale parent is
|
||||
an error, not permission to overwrite new work.
|
||||
|
||||
An older version is refused. Equal SemVer precedence with different content
|
||||
(including metadata/build-identifier changes) is refused. Identical parsed
|
||||
manifest content is an explicit no-op. Formatting/key order differences alone
|
||||
do not mint another commit. A changed release needs a higher version.
|
||||
|
||||
The publisher checks the remote parent at fetch and in pre-push against Git's
|
||||
advertised OID, then pushes normally without force. Git's receive-side old-OID
|
||||
check closes the remaining race. The wrapper invokes any existing pre-push
|
||||
hook with the original arguments/stdin and preserves its refusal. Concurrent
|
||||
creation, advancement, deletion, or rewind requires a fresh invocation with a
|
||||
reread parent; version ordering is evaluated again, so a late b5 job cannot
|
||||
replace b6 even after retry. Failed candidates remain on disk for inspection.
|
||||
|
||||
After a successful push, verify the public feed separately:
|
||||
`https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json`.
|
||||
A Git push alone does not prove CDN freshness or a successful installed update.
|
||||
Standard release publication does not invoke this command or change this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
uv run --with pytest --with 'semver>=3,<4' python3 -m pytest \
|
||||
"$REPO_ROOT/scripts/tests/test_publish_research_feed.py" -q
|
||||
```
|
||||
|
||||
The tests use release fixtures and temporary local Git remotes. They cover
|
||||
provisioning, advancement, replay, rollback refusal, racing publishers,
|
||||
branch deletion/rewind, incomplete releases, metadata validation, and existing
|
||||
push-hook enforcement. They never publish to GitHub.
|
||||
74
scripts/package/UPDATER.md
Normal file
74
scripts/package/UPDATER.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Tauri release configuration
|
||||
|
||||
`release.yml` runs `configure_tauri_release.py` before compiling aw-tauri.
|
||||
It writes the release version to `aw-tauri/src-tauri/tauri.conf.json`, which
|
||||
Tauri embeds as its package version. `generate_latest_json.py` uses the same
|
||||
`tauri_version()` conversion for the manifest: `v0.14.0b5` becomes
|
||||
`0.14.0-beta.5`. Original AW spellings remain in asset filenames and tag URLs.
|
||||
Development builds also receive a SemVer version, with a `dev.g<hash>` suffix.
|
||||
|
||||
The standard build keeps its checked-in updater endpoint and public key.
|
||||
Research builds use only:
|
||||
|
||||
```text
|
||||
https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json
|
||||
```
|
||||
|
||||
Configure these repository settings before a research tag or manual build:
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| Variable `TAURI_UPDATER_PUBLIC_KEY_RESEARCH` | Base64-encoded minisign public-key file emitted by Tauri's signer |
|
||||
| Secret `TAURI_SIGNING_PRIVATE_KEY_RESEARCH` | Research-only signing key in Tauri's expected format |
|
||||
| Secret `TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RESEARCH` | Password for that key (empty for an unencrypted key) |
|
||||
|
||||
The workflow selects secret **names** by edition. A missing research secret
|
||||
never falls back to a standard secret. The configurator rejects a research
|
||||
public key containing the standard key material, even if its comment or key
|
||||
ID differs. Research tag/manual builds fail if either key is missing.
|
||||
Secretless research PR smoke builds clear both updater endpoints and the
|
||||
public key. They cannot consume the standard channel.
|
||||
|
||||
`aw-tauri/Makefile` enables `bundle.createUpdaterArtifacts` by passing a
|
||||
`--config` override to the Tauri build when `TAURI_SIGNING_PRIVATE_KEY` is
|
||||
present. That setting is therefore absent from the checked-in JSON even
|
||||
though release builds produce signatures. Both the initial build and the
|
||||
research-profile rebuild receive the edition's selected signing key.
|
||||
|
||||
## Release acceptance still required
|
||||
|
||||
This configuration does not create the `research-updates` branch, publish
|
||||
its manifest, verify that a private key matches the configured public key,
|
||||
or change any already installed client. Before offering an update:
|
||||
|
||||
- Provision the channel and verify its public URL. Publish only a complete,
|
||||
verified manifest after its versioned release is public; use a normal
|
||||
fast-forward commit with an expected parent and a monotonic version check
|
||||
so a late release job cannot overwrite a newer feed.
|
||||
- Verify actual bundle signatures with the research key and reject standard
|
||||
signatures. Config/fixture checks alone are insufficient.
|
||||
- Verify the built app's version, equal-version no-update, one newer-version
|
||||
upgrade, and no repeated offer after restart. Both versions must preserve
|
||||
the research profile, privacy defaults, install identity, and bundled
|
||||
watchers/aw-sync. Native updater bundles and full AW packages require a
|
||||
payload-equivalence check.
|
||||
- Replace or verifiably contain any already distributed research app that
|
||||
embedded the standard endpoint/key **before publishing a valid standard
|
||||
stable manifest**. A future configuration cannot repair an installed app.
|
||||
|
||||
The pipeline creates a draft versioned release. Its `latest-research.json`
|
||||
asset is distinct from the live branch feed; uploading the asset does not
|
||||
advance that feed. Do not publish the draft until these gates are satisfied.
|
||||
|
||||
## Focused checks
|
||||
|
||||
Initialize the pinned `aw-tauri` submodule, then run:
|
||||
|
||||
```sh
|
||||
python3 -m pytest scripts/tests/test_generate_latest_json.py scripts/tests/test_configure_tauri_release.py -q
|
||||
```
|
||||
|
||||
These tests execute both CLIs against the pinned config, exercise missing and
|
||||
same-key rejection, preserve standard trust, and check both directions of
|
||||
asset partitioning. Dummy signatures in these fixtures do not prove signing
|
||||
or installation.
|
||||
135
scripts/package/abi-gate.sh
vendored
Executable file
135
scripts/package/abi-gate.sh
vendored
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# abi-gate.sh — Verify that no bundled ELF requires a GLIBC symbol above
|
||||
# GLIBC_2.28. Run this after packaging to gate on the ABI floor.
|
||||
#
|
||||
# Usage: abi-gate.sh <file.zip> [<file.AppImage>]
|
||||
# Exit 0 = all clear; exit 1 = ABI violation found; exit 2 = script error.
|
||||
#
|
||||
# For AppImage files, extraction requires either:
|
||||
# - `unsquashfs` (squashfs-tools package), or
|
||||
# - APPIMAGE_EXTRACT_AND_RUN=1 so the AppImage self-extracts without FUSE.
|
||||
# If neither works the AppImage scan is skipped with a warning (not a failure).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GLIBC_FLOOR_MAJOR=2
|
||||
GLIBC_FLOOR_MINOR=28
|
||||
|
||||
WORKDIR=$(mktemp -d)
|
||||
VIOLATION_LOG="$WORKDIR/violations.txt"
|
||||
touch "$VIOLATION_LOG"
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
# Returns 0 (true) when the version string X.Y exceeds the floor.
|
||||
glibc_exceeds_floor() {
|
||||
local ver="$1"
|
||||
local major minor
|
||||
major=$(echo "$ver" | cut -d. -f1)
|
||||
minor=$(echo "$ver" | cut -d. -f2)
|
||||
if [ "$major" -gt "$GLIBC_FLOOR_MAJOR" ]; then return 0; fi
|
||||
if [ "$major" -eq "$GLIBC_FLOOR_MAJOR" ] && \
|
||||
[ "$minor" -gt "$GLIBC_FLOOR_MINOR" ]; then return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
scan_elfs() {
|
||||
local dir="$1" label="$2"
|
||||
echo "=== Scanning ELFs in: $label ==="
|
||||
local total=0 scanned=0
|
||||
while IFS= read -r elf; do
|
||||
total=$((total + 1))
|
||||
local versions
|
||||
versions=$(readelf --version-info "$elf" 2>/dev/null \
|
||||
| grep -oP 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' \
|
||||
| sort -Vu) || true
|
||||
[ -z "$versions" ] && continue
|
||||
scanned=$((scanned + 1))
|
||||
while IFS= read -r sym; do
|
||||
local ver="${sym#GLIBC_}"
|
||||
if glibc_exceeds_floor "$ver"; then
|
||||
printf ' VIOLATION in %s: %s\n' "$(basename "$elf")" "$sym" \
|
||||
| tee -a "$VIOLATION_LOG"
|
||||
fi
|
||||
done <<< "$versions"
|
||||
done < <(find "$dir" -type f -exec file {} + 2>/dev/null \
|
||||
| grep -E ': ELF ' | cut -d: -f1)
|
||||
echo " ELFs with GLIBC refs: $scanned / $total files scanned"
|
||||
}
|
||||
|
||||
extract_appimage() {
|
||||
local ai="$1" dest="$2"
|
||||
# Try unsquashfs first (reliable offset detection via SquashFS magic).
|
||||
if command -v unsquashfs &>/dev/null; then
|
||||
local offset
|
||||
offset=$(python3 -c "
|
||||
import sys
|
||||
data = open(sys.argv[1], 'rb').read()
|
||||
for magic in (b'sqsh', b'hsqs'):
|
||||
idx = data.find(magic)
|
||||
if idx >= 0:
|
||||
print(idx)
|
||||
break
|
||||
" "$ai" 2>/dev/null || true)
|
||||
if [ -n "$offset" ]; then
|
||||
if unsquashfs -dest "$dest" -offset "$offset" "$ai" &>/dev/null; then
|
||||
echo " Extracted AppImage via unsquashfs (offset $offset)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# Fallback: AppImage self-extract (needs APPIMAGE_EXTRACT_AND_RUN=1 in env).
|
||||
pushd "$WORKDIR" >/dev/null
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$OLDPWD/$ai" --appimage-extract &>/dev/null || true
|
||||
popd >/dev/null
|
||||
if [ -d "$WORKDIR/squashfs-root" ]; then
|
||||
mv "$WORKDIR/squashfs-root" "$dest"
|
||||
echo " Extracted AppImage via --appimage-extract"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "Usage: $0 <file.zip> [<file.AppImage>]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
*.zip)
|
||||
ZIPDIR="$WORKDIR/zip"
|
||||
mkdir -p "$ZIPDIR"
|
||||
unzip -q "$arg" -d "$ZIPDIR"
|
||||
scan_elfs "$ZIPDIR" "$(basename "$arg")"
|
||||
;;
|
||||
*.AppImage)
|
||||
AI_DIR="$WORKDIR/appimage"
|
||||
mkdir -p "$AI_DIR"
|
||||
if extract_appimage "$arg" "$AI_DIR"; then
|
||||
scan_elfs "$AI_DIR" "$(basename "$arg")"
|
||||
else
|
||||
echo "ERROR: could not extract $(basename "$arg") — AppImage ABI scan is required; aborting" >&2
|
||||
exit 2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "WARNING: unrecognised file type: $arg — skipping" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== ABI Gate Summary ==="
|
||||
echo "GLIBC floor: GLIBC_${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR}"
|
||||
VIOLATION_COUNT=$(wc -l < "$VIOLATION_LOG")
|
||||
echo "Violations: $VIOLATION_COUNT"
|
||||
|
||||
if [ "$VIOLATION_COUNT" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Offending symbols:"
|
||||
cat "$VIOLATION_LOG"
|
||||
echo ""
|
||||
echo "FAIL: bundled ELFs exceed the glibc ${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR} ABI floor"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: no GLIBC symbol above GLIBC_${GLIBC_FLOOR_MAJOR}.${GLIBC_FLOOR_MINOR} found"
|
||||
63
scripts/package/activitywatch-setup.iss
Normal file
63
scripts/package/activitywatch-setup.iss
Normal file
@@ -0,0 +1,63 @@
|
||||
; Script generated by the Inno Setup Script Wizard.
|
||||
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||
|
||||
#define MyAppName "ActivityWatch"
|
||||
#define MyAppVersion GetEnv('AW_VERSION')
|
||||
#define MyAppPublisher "ActivityWatch Contributors"
|
||||
#define MyAppURL "https://activitywatch.net/"
|
||||
#define MyAppExeName "aw-qt.exe"
|
||||
#define RootDir "..\.."
|
||||
#define DistDir "..\..\dist"
|
||||
|
||||
#pragma verboselevel 9
|
||||
|
||||
[Setup]
|
||||
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
|
||||
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
|
||||
; NOTE: the double {{ are used to escape the { character (needed for the AppId)
|
||||
AppId={{F226B8F4-3244-46E6-901D-0CE8035423E4}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
;AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL="https://github.com/ActivityWatch/activitywatch/issues"
|
||||
AppUpdatesURL="https://github.com/ActivityWatch/activitywatch/releases"
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
; Uncomment the following line to run in non administrative install mode (install for current user only.)
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
OutputDir={#DistDir}
|
||||
OutputBaseFilename=activitywatch-setup
|
||||
SetupIconFile="{#RootDir}\aw-qt\media\logo\logo.ico"
|
||||
UninstallDisplayName={#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
Name: "StartMenuEntry" ; Description: "Start ActivityWatch when Windows starts"; GroupDescription: "Windows Startup"; MinVersion: 4,4;
|
||||
|
||||
[Files]
|
||||
Source: "{#DistDir}\activitywatch\aw-qt.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#DistDir}\activitywatch\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: StartMenuEntry;
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
; Removes the previously installed version before installing the new one
|
||||
; NOTE: Doesn't work? And also discouraged by the docs
|
||||
;[InstallDelete]
|
||||
;Type: filesandordirs; Name: "{app}\"
|
||||
14
scripts/package/app-entitlements.plist
Normal file
14
scripts/package/app-entitlements.plist
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- These are required for binaries built by PyInstaller -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- The responsible application needs this to prompt for browser Apple Events access. -->
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
57
scripts/package/aw-tauri.iss
Normal file
57
scripts/package/aw-tauri.iss
Normal file
@@ -0,0 +1,57 @@
|
||||
; Inno Setup script for ActivityWatch (Tauri edition)
|
||||
;
|
||||
; This is separate from activitywatch-setup.iss (aw-qt) to avoid
|
||||
; installation collisions. Uses a different AppId, install directory,
|
||||
; and display name.
|
||||
|
||||
#define MyAppName "ActivityWatch (Tauri)"
|
||||
#define MyAppVersion GetEnv('AW_VERSION')
|
||||
#define MyAppPublisher "ActivityWatch Contributors"
|
||||
#define MyAppURL "https://activitywatch.net/"
|
||||
#define MyAppExeName "aw-tauri.exe"
|
||||
#define RootDir "..\.."
|
||||
#define DistDir "..\..\dist"
|
||||
|
||||
#pragma verboselevel 9
|
||||
|
||||
[Setup]
|
||||
; IMPORTANT: Different AppId from aw-qt to allow side-by-side installation
|
||||
AppId={{983D0855-08C8-46BD-AEFB-3924581C6703}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL="https://github.com/ActivityWatch/activitywatch/issues"
|
||||
AppUpdatesURL="https://github.com/ActivityWatch/activitywatch/releases"
|
||||
DefaultDirName={autopf}\ActivityWatch-Tauri
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
OutputDir={#DistDir}
|
||||
OutputBaseFilename=activitywatch-setup
|
||||
SetupIconFile="{#RootDir}\aw-tauri\src-tauri\icons\icon.ico"
|
||||
UninstallDisplayName={#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
Name: "StartMenuEntry" ; Description: "Start ActivityWatch when Windows starts"; GroupDescription: "Windows Startup"; MinVersion: 4,4;
|
||||
|
||||
[Files]
|
||||
Source: "{#DistDir}\activitywatch\aw-tauri.exe"; DestDir: "{app}\aw-tauri"; Flags: ignoreversion
|
||||
Source: "{#DistDir}\activitywatch\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: StartMenuEntry;
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
277
scripts/package/build_app_tauri.sh
vendored
Executable file
277
scripts/package/build_app_tauri.sh
vendored
Executable file
@@ -0,0 +1,277 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Build a macOS .app bundle for the Tauri-based ActivityWatch.
|
||||
# This replaces the PyInstaller-based bundling used by aw-qt.
|
||||
|
||||
APP_NAME="ActivityWatch"
|
||||
BUNDLE_ID="net.activitywatch.ActivityWatch"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="$("$SCRIPT_DIR/getversion.sh" --strip-v)"
|
||||
ICON_PATH="aw-tauri/src-tauri/icons/icon.icns"
|
||||
|
||||
if [[ "$(uname)" != "Darwin" ]]; then
|
||||
echo "This script is designed to run on macOS only."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "dist/activitywatch" ]; then
|
||||
echo "Error: dist/activitywatch directory not found. Please build the project first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "dist/activitywatch/aw-tauri" ]; then
|
||||
echo "Error: aw-tauri binary not found in dist/activitywatch/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf "dist/${APP_NAME}.app"
|
||||
mkdir -p "dist"
|
||||
|
||||
echo "Creating app bundle structure..."
|
||||
mkdir -p "dist/${APP_NAME}.app/Contents/"{MacOS,Resources}
|
||||
|
||||
echo "Copying aw-tauri as main executable..."
|
||||
cp "dist/activitywatch/aw-tauri" "dist/${APP_NAME}.app/Contents/MacOS/aw-tauri"
|
||||
chmod +x "dist/${APP_NAME}.app/Contents/MacOS/aw-tauri"
|
||||
|
||||
echo "Copying components to Resources..."
|
||||
for component in dist/activitywatch/*/; do
|
||||
if [ -d "$component" ]; then
|
||||
component_name=$(basename "$component")
|
||||
echo " Copying $component_name..."
|
||||
mkdir -p "dist/${APP_NAME}.app/Contents/Resources/$component_name"
|
||||
cp -r "$component"/* "dist/${APP_NAME}.app/Contents/Resources/$component_name/"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Fixing Python.framework symlink structure..."
|
||||
# PyInstaller copies Python.framework using regular files/directories instead of
|
||||
# preserving the standard macOS symlink layout. Without symlinks, codesign rejects
|
||||
# the framework with "bundle format is ambiguous (could be app or framework)".
|
||||
# Restore the canonical layout so codesign can sign it as a proper framework bundle:
|
||||
# Versions/Current -> <version> (symlink)
|
||||
# Python -> Versions/Current/Python (symlink)
|
||||
# Resources -> Versions/Current/Resources (symlink)
|
||||
# Headers -> Versions/Current/Headers (symlink, if present)
|
||||
while IFS= read -r fw; do
|
||||
echo " Fixing: $fw"
|
||||
# Find the actual version directory (e.g., "3.9"), skipping "Current"
|
||||
version_dir=""
|
||||
for d in "$fw/Versions"/*/; do
|
||||
bname="$(basename "$d")"
|
||||
if [ "$bname" != "Current" ] && [ -d "$d" ]; then
|
||||
version_dir="$bname"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$version_dir" ]; then
|
||||
echo " Warning: No version directory found in $fw/Versions/, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Replace Versions/Current directory with a symlink to the version dir
|
||||
if [ -d "$fw/Versions/Current" ] && [ ! -L "$fw/Versions/Current" ]; then
|
||||
rm -rf "$fw/Versions/Current"
|
||||
ln -s "$version_dir" "$fw/Versions/Current"
|
||||
fi
|
||||
|
||||
# Replace root-level copies with symlinks into Versions/Current/
|
||||
for item in Python Resources Headers; do
|
||||
if [ -e "$fw/$item" ] && [ ! -L "$fw/$item" ]; then
|
||||
rm -rf "$fw/$item"
|
||||
ln -s "Versions/Current/$item" "$fw/$item"
|
||||
fi
|
||||
done
|
||||
done < <(find "dist/${APP_NAME}.app" -type d -iname "Python.framework")
|
||||
|
||||
echo "Setting executable permissions..."
|
||||
find "dist/${APP_NAME}.app/Contents/Resources" -type f -name "aw-*" -exec chmod +x {} \;
|
||||
|
||||
echo "Copying app icon..."
|
||||
if [ -f "$ICON_PATH" ]; then
|
||||
cp "$ICON_PATH" "dist/${APP_NAME}.app/Contents/Resources/icon.icns"
|
||||
else
|
||||
echo "Warning: Icon file not found at $ICON_PATH"
|
||||
fi
|
||||
|
||||
echo "Creating Info.plist..."
|
||||
cat > "dist/${APP_NAME}.app/Contents/Info.plist" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>aw-tauri</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${BUNDLE_ID}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>${APP_NAME}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>ActivityWatch needs access to monitor application usage</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${MACOSX_DEPLOYMENT_TARGET:-12.0}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
echo "Creating PkgInfo..."
|
||||
echo "APPL????" > "dist/${APP_NAME}.app/Contents/PkgInfo"
|
||||
|
||||
if [ -n "$APPLE_PERSONALID" ]; then
|
||||
echo "Signing app with identity: $APPLE_PERSONALID (inside-out, per-binary)"
|
||||
# codesign --deep is unreliable for bundles with PyInstaller helpers:
|
||||
# it doesn't reach all nested dylibs/so files and mishandles Python.framework
|
||||
# symlinks, leaving hundreds of binaries unsigned or invalidly signed.
|
||||
# The correct approach is inside-out: sign all Mach-O leaves first,
|
||||
# then .framework bundles, then the top-level .app last.
|
||||
# --timestamp is required for notarization (Apple rejects submissions without it).
|
||||
ENTITLEMENTS="scripts/package/entitlements.plist"
|
||||
APP_ENTITLEMENTS="scripts/package/app-entitlements.plist"
|
||||
|
||||
sign_binary() {
|
||||
echo " Signing: $1"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--sign "$APPLE_PERSONALID" \
|
||||
"$1"
|
||||
}
|
||||
|
||||
# Step 1: Sign all Mach-O binary files (dylibs, .so files, standalone executables).
|
||||
# Use `xargs file` to batch all type queries in O(1) subprocess calls instead of
|
||||
# one `file` invocation per binary (PyInstaller bundles can contain hundreds of files).
|
||||
# Sort by path length descending so deeper binaries are signed before shallower containers.
|
||||
# IMPORTANT: Skip the main binary of .framework bundles (e.g. Python.framework/Python).
|
||||
# codesign treats those as ambiguous ("could be app or framework") when signed as
|
||||
# standalone files. They are correctly signed in Step 2 as part of the framework bundle.
|
||||
echo " Signing Mach-O binary files..."
|
||||
while IFS= read -r f; do
|
||||
# Skip main binaries of bundle directories (.framework, .bundle, .plugin) —
|
||||
# they'll be signed as part of the bundle in Step 2. Signing them standalone
|
||||
# causes "bundle format is ambiguous" errors from codesign.
|
||||
parent_dir="$(dirname "$f")"
|
||||
if [[ "$parent_dir" == *.framework ]] || [[ "$parent_dir" == *.framework/Versions/* ]] \
|
||||
|| [[ "$parent_dir" == *.bundle ]] || [[ "$parent_dir" == *.plugin ]]; then
|
||||
echo " Skipping bundle binary (signed in Step 2): $f"
|
||||
continue
|
||||
fi
|
||||
sign_binary "$f"
|
||||
done < <(find "dist/${APP_NAME}.app" -type f \
|
||||
| xargs file \
|
||||
| grep "Mach-O" \
|
||||
| cut -d: -f1 \
|
||||
| awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
|
||||
|
||||
# Step 2: Sign bundle directories (.framework, .bundle, .plugin) after their contents.
|
||||
# Deepest bundles first (sort by path length descending) to maintain inside-out order.
|
||||
# .bundle/.plugin coverage prevents missing CodeResources catalog seals that can
|
||||
# trigger notarytool bundle-integrity warnings.
|
||||
echo " Signing bundle directories (.framework, .bundle, .plugin)..."
|
||||
while IFS= read -r fw; do
|
||||
# PyInstaller-embedded frameworks (e.g. Python.framework inside aw-watcher-window)
|
||||
# lack the standard Versions/ structure and Info.plist, so codesign rejects them
|
||||
# with "bundle format is ambiguous (could be app or framework)". Fall back to
|
||||
# signing the main binary inside the framework directly. All other signing errors
|
||||
# are still fatal.
|
||||
sign_output=$(codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--sign "$APPLE_PERSONALID" \
|
||||
"$fw" 2>&1) && echo " Signed bundle: $fw" || {
|
||||
if echo "$sign_output" | grep -q "bundle format is ambiguous"; then
|
||||
echo " Note: $fw lacks standard bundle structure; strip-signing and syncing duplicates by content hash"
|
||||
# PyInstaller copies Python.framework contents as separate files rather
|
||||
# than symlinks — Python, Versions/Current/Python, and Versions/3.9/Python
|
||||
# are distinct inodes with identical code but different embedded signatures
|
||||
# (different timestamp nonces in __LINKEDIT from PyInstaller's own
|
||||
# codesign_identity). After cp -r into the .app, all three paths carry
|
||||
# pre-existing but DIFFERENT signatures. A pre-sign SHA comparison
|
||||
# never detects them as duplicates, so all three end up signed separately,
|
||||
# producing three divergent signatures that Apple rejects as "invalid".
|
||||
#
|
||||
# Fix: strip all existing signatures first (making copies truly identical
|
||||
# at the byte level), then group by content hash, sign one canonical per
|
||||
# group via temp copy, and sync the signed result to all group members.
|
||||
# This is correct even if the framework contains genuinely different
|
||||
# binaries — only true duplicates share a signed payload.
|
||||
tmp_group_dir=$(mktemp -d)
|
||||
found_macho=false
|
||||
while IFS= read -r fw_bin; do
|
||||
found_macho=true
|
||||
existing_id=$(codesign -d "$fw_bin" 2>&1 \
|
||||
| sed -n 's/^Identifier=//p' || true)
|
||||
if [ -z "$existing_id" ]; then
|
||||
existing_id="$(basename "$fw_bin")"
|
||||
fi
|
||||
|
||||
# Strip any existing signature (from PyInstaller or prior attempts)
|
||||
# so content hashing identifies true duplicates rather than
|
||||
# nonce-only signature differences from previous signing steps.
|
||||
codesign --remove-signature "$fw_bin" 2>/dev/null || true
|
||||
content_hash=$(shasum -a 256 "$fw_bin" | awk '{print $1}')
|
||||
canonical_path_file="$tmp_group_dir/$content_hash.path"
|
||||
|
||||
if [ -f "$canonical_path_file" ]; then
|
||||
# Same binary content — sync signed payload from canonical.
|
||||
canonical="$canonical_path_file"
|
||||
echo " Syncing signed binary to duplicate: $fw_bin"
|
||||
cp "$canonical" "$fw_bin"
|
||||
else
|
||||
# First occurrence of this content — sign it as canonical.
|
||||
echo " Signing canonical binary: $fw_bin (identifier: $existing_id, hash: ${content_hash:0:12})"
|
||||
tmp_binary=$(mktemp)
|
||||
cp -p "$fw_bin" "$tmp_binary"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--identifier "$existing_id" \
|
||||
--sign "$APPLE_PERSONALID" \
|
||||
"$tmp_binary" || { rm -f "$tmp_binary"; rm -rf "$tmp_group_dir"; exit 1; }
|
||||
cp "$tmp_binary" "$fw_bin"
|
||||
# Save signed canonical as the source for duplicates.
|
||||
cp "$tmp_binary" "$canonical_path_file"
|
||||
rm -f "$tmp_binary"
|
||||
fi
|
||||
done < <(find "$fw" -type f | xargs file | grep "Mach-O" | cut -d: -f1 | sort)
|
||||
rm -rf "$tmp_group_dir"
|
||||
if ! $found_macho; then
|
||||
echo "ERROR: No Mach-O binaries found inside $fw" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: Failed to sign $fw: $sign_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
done < <(find "dist/${APP_NAME}.app" -type d \
|
||||
\( -name "*.framework" -o -name "*.bundle" -o -name "*.plugin" \) \
|
||||
| awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
|
||||
|
||||
# Step 3: Sign the top-level .app bundle last.
|
||||
echo " Signing top-level .app bundle..."
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements "$APP_ENTITLEMENTS" \
|
||||
--sign "$APPLE_PERSONALID" \
|
||||
"dist/${APP_NAME}.app"
|
||||
|
||||
echo "App signing complete."
|
||||
else
|
||||
echo "APPLE_PERSONALID not set. Skipping code signing."
|
||||
fi
|
||||
|
||||
echo "App bundle created at: dist/${APP_NAME}.app"
|
||||
14
scripts/package/collect-setup.sh
vendored
Executable file
14
scripts/package/collect-setup.sh
vendored
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# The Research Edition patcher rewrites OutputBaseFilename in the .iss
|
||||
# (activitywatch-research[-tauri]-setup), so resolve the produced file
|
||||
# instead of hardcoding the standard name. dist/ is fresh at this point,
|
||||
# so exactly one setup exe must exist.
|
||||
setup_src=(dist/activitywatch*-setup.exe)
|
||||
if [ ${#setup_src[@]} -ne 1 ] || [ ! -f "${setup_src[0]}" ]; then
|
||||
echo "ERROR: expected exactly one dist/activitywatch*-setup.exe, got: ${setup_src[*]}"
|
||||
exit 1
|
||||
fi
|
||||
mv "${setup_src[0]}" "dist/$1"
|
||||
108
scripts/package/configure_tauri_release.py
Normal file
108
scripts/package/configure_tauri_release.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set the compiled updater version and isolate Research Edition update trust."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from generate_latest_json import tauri_version
|
||||
|
||||
RESEARCH_ENDPOINT = (
|
||||
"https://raw.githubusercontent.com/ActivityWatch/activitywatch/"
|
||||
"research-updates/latest-research.json"
|
||||
)
|
||||
STANDARD_ENDPOINT = "https://github.com/ActivityWatch/activitywatch/releases/latest/download/latest.json"
|
||||
|
||||
|
||||
def msi_rejects(version: str) -> bool:
|
||||
"""True if Tauri's msi (WiX) bundler will reject this SemVer version.
|
||||
|
||||
The msi target requires the optional pre-release identifier to be
|
||||
numeric-only (e.g. "0.14.0-5"), unlike nsis or the other bundle formats.
|
||||
AW's own pre-release scheme ("0.14.0-beta.5", "0.14.0-dev.gabc1234")
|
||||
fails that check every time.
|
||||
"""
|
||||
if "-" not in version:
|
||||
return False
|
||||
prerelease = version.split("-", 1)[1]
|
||||
return not all(part.isdigit() for part in prerelease.split("."))
|
||||
|
||||
|
||||
def public_key(encoded: str) -> bytes:
|
||||
"""Read Tauri's base64-wrapped minisign public key, ignoring its comment."""
|
||||
try:
|
||||
lines = base64.b64decode(encoded, validate=True).decode("ascii").splitlines()
|
||||
if len(lines) != 2 or not lines[0].startswith("untrusted comment: "):
|
||||
raise ValueError("invalid public key envelope")
|
||||
packet = base64.b64decode(lines[1], validate=True)
|
||||
if len(packet) != 42 or packet[:2] != b"Ed":
|
||||
raise ValueError("invalid public key packet")
|
||||
return packet[10:]
|
||||
except (ValueError, UnicodeError, binascii.Error) as exc:
|
||||
raise ValueError("Expected a base64-encoded minisign public key") from exc
|
||||
|
||||
|
||||
def configure(
|
||||
path: Path,
|
||||
version: str,
|
||||
research: bool,
|
||||
require_signing_key: bool,
|
||||
*,
|
||||
platform: str = sys.platform,
|
||||
) -> None:
|
||||
config = json.loads(path.read_text(encoding="utf-8"))
|
||||
tv = tauri_version(version)
|
||||
config["version"] = tv
|
||||
if platform == "win32" and msi_rejects(tv):
|
||||
bundle = config.setdefault("bundle", {})
|
||||
if bundle.get("targets") == "all":
|
||||
# Windows only ever produces msi+nsis from "all"; drop msi and
|
||||
# keep nsis, which accepts the full AW pre-release scheme.
|
||||
bundle["targets"] = ["nsis"]
|
||||
if research:
|
||||
updater = config["plugins"]["updater"]
|
||||
if updater["endpoints"] != [STANDARD_ENDPOINT]:
|
||||
raise ValueError(
|
||||
"Unexpected source updater endpoint; review before patching"
|
||||
)
|
||||
research_key = os.environ.get("TAURI_UPDATER_PUBLIC_KEY_RESEARCH", "").strip()
|
||||
signing_key = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "").strip()
|
||||
if require_signing_key and (not research_key or not signing_key):
|
||||
raise ValueError(
|
||||
"Research releases require their own public and signing keys"
|
||||
)
|
||||
if research_key:
|
||||
if public_key(research_key) == public_key(updater["pubkey"]):
|
||||
raise ValueError(
|
||||
"Research updater key must differ from the standard key"
|
||||
)
|
||||
updater["pubkey"] = research_key
|
||||
updater["endpoints"] = [RESEARCH_ENDPOINT]
|
||||
else:
|
||||
if signing_key:
|
||||
raise ValueError("Research signing key supplied without its public key")
|
||||
# Secretless PR smoke builds must never retain standard update trust.
|
||||
updater["pubkey"] = ""
|
||||
updater["endpoints"] = []
|
||||
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--research", action="store_true")
|
||||
parser.add_argument("--require-signing-key", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
configure(args.config, args.version, args.research, args.require_signing_key)
|
||||
except (ValueError, KeyError) as exc:
|
||||
parser.exit(1, f"ERROR: {exc}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
scripts/package/deb/control
Normal file
8
scripts/package/deb/control
Normal file
@@ -0,0 +1,8 @@
|
||||
Package: activitywatch
|
||||
Architecture: amd64
|
||||
Maintainer: Erik Bjäreholt <erik@bjareho.lt>
|
||||
Depends:
|
||||
Priority: optional
|
||||
Version: SCRIPT_VERSION_HERE
|
||||
Description: Open source time tracker
|
||||
https://github.com/ActivityWatch/activitywatch
|
||||
141
scripts/package/dmgbuild-settings.py
Normal file
141
scripts/package/dmgbuild-settings.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import plistlib
|
||||
import os.path
|
||||
|
||||
# Use like this: dmgbuild -s settings.py "Test Volume" test.dmg
|
||||
|
||||
# You can actually use this file for your own application (not just TextEdit)
|
||||
# by doing e.g.
|
||||
#
|
||||
# dmgbuild -s settings.py -D app=/path/to/My.app "My Application" MyApp.dmg
|
||||
|
||||
# .. Useful stuff ..............................................................
|
||||
|
||||
application = defines.get('app', 'dist/ActivityWatch.app')
|
||||
appname = os.path.basename(application)
|
||||
|
||||
def icon_from_app(app_path):
|
||||
plist_path = os.path.join(app_path, 'Contents', 'Info.plist')
|
||||
with open(plist_path, "rb") as f:
|
||||
plist = plistlib.load(f)
|
||||
icon_name = plist['CFBundleIconFile']
|
||||
icon_root,icon_ext = os.path.splitext(icon_name)
|
||||
if not icon_ext:
|
||||
icon_ext = '.icns'
|
||||
icon_name = icon_root + icon_ext
|
||||
return os.path.join(app_path, 'Contents', 'Resources', icon_name)
|
||||
|
||||
# .. Basics ....................................................................
|
||||
|
||||
# Uncomment to override the output filename
|
||||
# filename = 'test.dmg'
|
||||
|
||||
# Uncomment to override the output volume name
|
||||
# volume_name = 'Test'
|
||||
|
||||
# Volume format (see hdiutil create -help)
|
||||
format = defines.get('format', 'UDBZ')
|
||||
|
||||
# Volume size
|
||||
size = defines.get('size', None)
|
||||
|
||||
# Files to include
|
||||
files = [ application ]
|
||||
|
||||
# Symlinks to create
|
||||
symlinks = { 'Applications': '/Applications' }
|
||||
|
||||
# Volume icon
|
||||
#
|
||||
# You can either define icon, in which case that icon file will be copied to the
|
||||
# image, *or* you can define badge_icon, in which case the icon file you specify
|
||||
# will be used to badge the system's Removable Disk icon
|
||||
#
|
||||
#icon = '/path/to/icon.icns'
|
||||
badge_icon = icon_from_app(application)
|
||||
|
||||
# Where to put the icons
|
||||
icon_locations = {
|
||||
appname: (140, 120),
|
||||
'Applications': (500, 120)
|
||||
}
|
||||
|
||||
show_status_bar = False
|
||||
show_tab_view = False
|
||||
show_toolbar = False
|
||||
show_pathbar = False
|
||||
show_sidebar = False
|
||||
sidebar_width = 180
|
||||
|
||||
# Window position in ((x, y), (w, h)) format
|
||||
window_rect = ((100, 100), (640, 280))
|
||||
|
||||
default_view = 'icon-view'
|
||||
|
||||
show_icon_preview = False
|
||||
|
||||
# Set these to True to force inclusion of icon/list view settings (otherwise
|
||||
# we only include settings for the default view)
|
||||
include_icon_view_settings = 'auto'
|
||||
include_list_view_settings = 'auto'
|
||||
|
||||
# .. Icon view configuration ...................................................
|
||||
|
||||
arrange_by = None
|
||||
grid_offset = (0, 0)
|
||||
grid_spacing = 100
|
||||
scroll_position = (0, 0)
|
||||
label_pos = 'bottom' # or 'right'
|
||||
text_size = 16
|
||||
icon_size = 128
|
||||
|
||||
# .. List view configuration ...................................................
|
||||
|
||||
# Column names are as follows:
|
||||
#
|
||||
# name
|
||||
# date-modified
|
||||
# date-created
|
||||
# date-added
|
||||
# date-last-opened
|
||||
# size
|
||||
# kind
|
||||
# label
|
||||
# version
|
||||
# comments
|
||||
#
|
||||
list_icon_size = 16
|
||||
list_text_size = 12
|
||||
list_scroll_position = (0, 0)
|
||||
list_sort_by = 'name'
|
||||
list_use_relative_dates = True
|
||||
list_calculate_all_sizes = False,
|
||||
list_columns = ('name', 'date-modified', 'size', 'kind', 'date-added')
|
||||
list_column_widths = {
|
||||
'name': 300,
|
||||
'date-modified': 181,
|
||||
'date-created': 181,
|
||||
'date-added': 181,
|
||||
'date-last-opened': 181,
|
||||
'size': 97,
|
||||
'kind': 115,
|
||||
'label': 100,
|
||||
'version': 75,
|
||||
'comments': 300,
|
||||
}
|
||||
|
||||
list_column_sort_directions = {
|
||||
'name': 'ascending',
|
||||
'date-modified': 'descending',
|
||||
'date-created': 'descending',
|
||||
'date-added': 'descending',
|
||||
'date-last-opened': 'descending',
|
||||
'size': 'descending',
|
||||
'kind': 'ascending',
|
||||
'label': 'ascending',
|
||||
'version': 'ascending',
|
||||
'comments': 'ascending',
|
||||
}
|
||||
|
||||
12
scripts/package/entitlements.plist
Normal file
12
scripts/package/entitlements.plist
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Shared by nested binaries; app-only capabilities belong in app-entitlements.plist. -->
|
||||
<!-- These are required for binaries built by PyInstaller -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
181
scripts/package/generate_latest_json.py
Executable file
181
scripts/package/generate_latest_json.py
Executable file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble a Tauri updater manifest from per-platform .sig files.
|
||||
|
||||
Expects updater artifacts named
|
||||
`activitywatch-tauri[-research]-<version>-<platform-key>.<ext>` with a
|
||||
matching `<...>.sig` file alongside it (as produced by the "Package Tauri
|
||||
updater artifacts" step in release.yml), where <platform-key> is a Tauri
|
||||
updater platform identifier such as "darwin-aarch64" or "linux-x86_64".
|
||||
|
||||
Standard and Research Edition releases are partitioned by filename:
|
||||
|
||||
- standard: `latest.json` + `activitywatch-tauri-<ver>-...`
|
||||
- research: `latest-research.json` + `activitywatch-tauri-research-<ver>-...`
|
||||
|
||||
so the two lines cannot overwrite each other's GitHub release assets or
|
||||
share an updater endpoint.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
EDITIONS = ("standard", "research")
|
||||
|
||||
|
||||
def normalize_version(version: str) -> str:
|
||||
"""Strip a leading 'v' and a trailing '-research' edition suffix."""
|
||||
if version.startswith("v"):
|
||||
version = version[1:]
|
||||
if version.endswith("-research"):
|
||||
version = version[: -len("-research")]
|
||||
return version
|
||||
|
||||
|
||||
def tauri_version(version: str) -> str:
|
||||
"""Convert AW release/dev labels to the SemVer embedded by Tauri.
|
||||
|
||||
Asset filenames and GitHub tags keep their original AW version spelling.
|
||||
"""
|
||||
version = normalize_version(version)
|
||||
number = r"(?:0|[1-9][0-9]*)"
|
||||
match = re.fullmatch(
|
||||
rf"(?P<base>{number}\.{number}\.{number})"
|
||||
rf"(?:(?P<pre>a|b|rc)(?P<serial>{number}))?"
|
||||
r"(?:\.dev-(?P<dev>[0-9a-f]+|unknown))?",
|
||||
version,
|
||||
)
|
||||
if not match:
|
||||
raise ValueError(f"Unsupported ActivityWatch version: {version!r}")
|
||||
suffix = []
|
||||
if match["pre"]:
|
||||
suffix.extend(
|
||||
({"a": "alpha", "b": "beta", "rc": "rc"}[match["pre"]], match["serial"])
|
||||
)
|
||||
if match["dev"]:
|
||||
# Prefix hashes so an all-digit hash with a leading zero stays valid.
|
||||
suffix.extend(("dev", "g" + match["dev"]))
|
||||
return match["base"] + ("-" + ".".join(suffix) if suffix else "")
|
||||
|
||||
|
||||
def infer_edition(tag: str, edition=None) -> str:
|
||||
if edition:
|
||||
if edition not in EDITIONS:
|
||||
raise ValueError(f"unknown edition {edition!r}")
|
||||
return edition
|
||||
return "research" if tag.endswith("-research") else "standard"
|
||||
|
||||
|
||||
def asset_prefix(edition: str) -> str:
|
||||
if edition == "research":
|
||||
return "activitywatch-tauri-research"
|
||||
return "activitywatch-tauri"
|
||||
|
||||
|
||||
def manifest_filename(edition: str) -> str:
|
||||
return "latest-research.json" if edition == "research" else "latest.json"
|
||||
|
||||
|
||||
# Tauri v2 recommends NSIS for Windows updater bundles. When both NSIS and
|
||||
# MSI signatures exist for the same platform key, keep NSIS regardless of
|
||||
# os.walk order. Unlisted extensions share rank 0 (first one wins).
|
||||
WINDOWS_BUNDLE_RANK = {
|
||||
"nsis.zip": 2,
|
||||
"exe": 1,
|
||||
"msi.zip": 0,
|
||||
"msi": 0,
|
||||
}
|
||||
|
||||
|
||||
def bundle_rank(ext: str) -> int:
|
||||
return WINDOWS_BUNDLE_RANK.get(ext, 0)
|
||||
|
||||
|
||||
def collect_platforms(dist: str, version: str, repo: str, tag: str, edition: str) -> dict:
|
||||
prefix = asset_prefix(edition)
|
||||
# Non-greedy platform group: extensions can be multi-part (.app.tar.gz,
|
||||
# .AppImage.tar.gz, .nsis.zip), so stop at the first dot after the
|
||||
# platform key rather than the last.
|
||||
pattern = re.compile(
|
||||
rf"^{re.escape(prefix)}-{re.escape(version)}-(?P<platform>.+?)\.(?P<ext>.+)$"
|
||||
)
|
||||
|
||||
platforms = {}
|
||||
chosen_ext = {}
|
||||
for root, _, files in os.walk(dist):
|
||||
for name in files:
|
||||
if not name.endswith(".sig"):
|
||||
continue
|
||||
asset_name = name[: -len(".sig")]
|
||||
m = pattern.match(asset_name)
|
||||
if not m:
|
||||
continue
|
||||
platform = m.group("platform")
|
||||
ext = m.group("ext")
|
||||
prev_ext = chosen_ext.get(platform)
|
||||
if prev_ext is not None and bundle_rank(ext) <= bundle_rank(prev_ext):
|
||||
continue
|
||||
with open(os.path.join(root, name)) as f:
|
||||
signature = f.read().strip()
|
||||
chosen_ext[platform] = ext
|
||||
platforms[platform] = {
|
||||
"signature": signature,
|
||||
"url": (
|
||||
f"https://github.com/{repo}/releases/download/"
|
||||
f"{tag}/{asset_name}"
|
||||
),
|
||||
}
|
||||
return platforms
|
||||
|
||||
|
||||
def build_manifest(version: str, notes: str, platforms: dict, pub_date=None) -> dict:
|
||||
if pub_date is None:
|
||||
pub_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return {
|
||||
"version": version,
|
||||
"notes": notes,
|
||||
"pub_date": pub_date,
|
||||
"platforms": platforms,
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--notes", required=True)
|
||||
parser.add_argument(
|
||||
"--repo", required=True, help="e.g. ActivityWatch/activitywatch"
|
||||
)
|
||||
parser.add_argument("--tag", required=True, help="e.g. v0.13.3 or v0.13.3-research")
|
||||
parser.add_argument(
|
||||
"--edition",
|
||||
choices=EDITIONS,
|
||||
default=None,
|
||||
help="Release line. Inferred from --tag (*-research) if omitted.",
|
||||
)
|
||||
parser.add_argument("--dist", required=True, help="directory to search for *.sig files")
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
version = normalize_version(args.version)
|
||||
edition = infer_edition(args.tag, args.edition)
|
||||
platforms = collect_platforms(args.dist, version, args.repo, args.tag, edition)
|
||||
|
||||
if not platforms:
|
||||
raise SystemExit(
|
||||
f"No {edition} updater artifacts found - refusing to write an empty "
|
||||
f"{os.path.basename(args.output)}"
|
||||
)
|
||||
|
||||
manifest = build_manifest(tauri_version(version), args.notes, platforms)
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(f"Wrote {args.output} ({edition}) with platforms: {', '.join(sorted(platforms))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
93
scripts/package/getversion.sh
vendored
Executable file
93
scripts/package/getversion.sh
vendored
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
STRIP_V=false
|
||||
|
||||
show_usage() {
|
||||
cat << EOF
|
||||
Usage: $SCRIPT_NAME [OPTIONS]
|
||||
|
||||
Get the version of ActivityWatch from git tags or CI environment variables.
|
||||
|
||||
Options:
|
||||
--strip-v, --no-v Remove the 'v' prefix from the version (if present)
|
||||
--help, -h Show this help message
|
||||
|
||||
Environment Variables (used in CI, checked in priority order):
|
||||
GITHUB_REF_NAME GitHub Actions tag/ref (e.g., "v0.14.0")
|
||||
TRAVIS_TAG Travis CI tag
|
||||
APPVEYOR_REPO_TAG_NAME AppVeyor CI tag
|
||||
|
||||
Version Format:
|
||||
- Release tag: v0.14.0
|
||||
- Dev version: v0.14.0.dev-abc1234
|
||||
- Beta/RC: v0.14.0b1, v0.14.0rc1
|
||||
|
||||
Examples:
|
||||
$SCRIPT_NAME # v0.14.0 or v0.14.0.dev-abc1234
|
||||
$SCRIPT_NAME --strip-v # 0.14.0 or 0.14.0.dev-abc1234
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--strip-v|--no-v)
|
||||
STRIP_V=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1" >&2
|
||||
show_usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
get_version_internal() {
|
||||
local _version=""
|
||||
|
||||
if [[ -n "$GITHUB_REF" && "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
_version="$GITHUB_REF_NAME"
|
||||
elif [[ -n "$TRAVIS_TAG" ]]; then
|
||||
_version="$TRAVIS_TAG"
|
||||
elif [[ -n "$APPVEYOR_REPO_TAG_NAME" ]]; then
|
||||
_version="$APPVEYOR_REPO_TAG_NAME"
|
||||
else
|
||||
_version="$(git describe --tags --abbrev=0 --exact-match 2>/dev/null || true)"
|
||||
if [[ -z "$_version" ]]; then
|
||||
local _latest_tag
|
||||
# Exclude research-edition tags so dev versions off master don't
|
||||
# inherit a "-research" base (e.g. v0.14.0b4-research.dev-abc1234).
|
||||
# Exact-match above intentionally keeps them: building *on* a
|
||||
# research tag must report the research version.
|
||||
_latest_tag="$(git describe --tags --abbrev=0 --exclude '*-research' 2>/dev/null || echo "v0.0.0")"
|
||||
local _commit_hash
|
||||
_commit_hash="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
|
||||
_version="${_latest_tag}.dev-${_commit_hash}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$_version"
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
local version
|
||||
version="$(get_version_internal)"
|
||||
|
||||
if $STRIP_V; then
|
||||
version="$(echo "$version" | sed -e 's/^v//')"
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
27
scripts/package/move-to-aw-modules.sh
vendored
Executable file
27
scripts/package/move-to-aw-modules.sh
vendored
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Copy all AW modules to ~/aw-modules/ for aw-tauri to discover.
|
||||
# aw-tauri uses this directory to find and launch AW components.
|
||||
set -e
|
||||
|
||||
mkdir -p ~/aw-modules/
|
||||
|
||||
if [[ -n "$XDG_SESSION_TYPE" && "$XDG_SESSION_TYPE" == "wayland" ]]; then
|
||||
rsync -a . ~/aw-modules/ \
|
||||
--exclude=aw-tauri \
|
||||
--exclude=aw-server-rust \
|
||||
--exclude=awatcher \
|
||||
--exclude=move-to-aw-modules.sh \
|
||||
--exclude=README.txt
|
||||
cp ./awatcher/aw-awatcher ~/aw-modules/
|
||||
cp ./aw-server-rust/aw-sync ~/aw-modules/
|
||||
else
|
||||
rsync -a . ~/aw-modules/ \
|
||||
--exclude=aw-tauri \
|
||||
--exclude=awatcher \
|
||||
--exclude=aw-server-rust \
|
||||
--exclude=move-to-aw-modules.sh \
|
||||
--exclude=README.txt
|
||||
cp ./aw-server-rust/aw-sync ~/aw-modules/
|
||||
fi
|
||||
|
||||
echo "Modules copied to ~/aw-modules/"
|
||||
123
scripts/package/package-all.sh
vendored
Executable file
123
scripts/package/package-all.sh
vendored
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echoerr() { echo "$@" 1>&2; }
|
||||
|
||||
function get_platform() {
|
||||
# Will return "linux" for GNU/Linux
|
||||
# I'd just like to interject for a moment...
|
||||
# https://wiki.installgentoo.com/index.php/Interjection
|
||||
# Will return "macos" for macOS/OS X
|
||||
# Will return "windows" for Windows/MinGW/msys
|
||||
|
||||
_platform=$(uname | tr '[:upper:]' '[:lower:]')
|
||||
if [[ $_platform == "darwin" ]]; then
|
||||
_platform="macos";
|
||||
elif [[ $_platform == "msys"* ]]; then
|
||||
_platform="windows";
|
||||
elif [[ $_platform == "mingw"* ]]; then
|
||||
_platform="windows";
|
||||
elif [[ $_platform == "linux" ]]; then
|
||||
# Nothing to do
|
||||
true;
|
||||
else
|
||||
echoerr "ERROR: $_platform is not a valid platform";
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
echo $_platform;
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
function get_version() {
|
||||
"$SCRIPT_DIR/getversion.sh";
|
||||
}
|
||||
|
||||
function get_version_no_prefix() {
|
||||
"$SCRIPT_DIR/getversion.sh" --strip-v;
|
||||
}
|
||||
|
||||
function get_arch() {
|
||||
_arch="$(uname -m)"
|
||||
echo $_arch;
|
||||
}
|
||||
|
||||
platform=$(get_platform)
|
||||
version=$(get_version)
|
||||
version_no_prefix=$(get_version_no_prefix)
|
||||
arch=$(get_arch)
|
||||
# Research tags are suffixed (e.g. v0.14.0b3-research), but the edition
|
||||
# belongs in its own filename token, not in the version part:
|
||||
# activitywatch[-tauri][-research]-<version>-<os>-<arch>[-setup].<ext>
|
||||
version="${version%-research}"
|
||||
version_no_prefix="${version_no_prefix%-research}"
|
||||
build_suffix=""
|
||||
if [[ $TAURI_BUILD == "true" ]]; then
|
||||
build_suffix="-tauri"
|
||||
fi
|
||||
if [[ $AW_RESEARCH_EDITION == "true" ]]; then
|
||||
build_suffix="${build_suffix}-research"
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo "Build Version Information"
|
||||
echo "========================================"
|
||||
echo "Platform: $platform"
|
||||
echo "Arch: $arch"
|
||||
echo "Version (with v): $version"
|
||||
echo "Version (no v): $version_no_prefix"
|
||||
echo "Tauri build: ${TAURI_BUILD:-false}"
|
||||
echo "========================================"
|
||||
echo
|
||||
|
||||
# For Tauri Linux builds, include helper scripts and README
|
||||
if [[ $platform == "linux" && $TAURI_BUILD == "true" ]]; then
|
||||
cp scripts/package/README.txt scripts/package/move-to-aw-modules.sh dist/activitywatch/
|
||||
fi
|
||||
|
||||
function build_zip() {
|
||||
echo "Zipping executables..."
|
||||
pushd dist;
|
||||
filename="activitywatch${build_suffix}-${version}-${platform}-${arch}.zip"
|
||||
echo "Name of package will be: $filename"
|
||||
|
||||
if [[ $platform == "windows"* ]]; then
|
||||
7z a $filename activitywatch;
|
||||
else
|
||||
zip -r $filename activitywatch;
|
||||
fi
|
||||
popd;
|
||||
echo "Zip built!"
|
||||
}
|
||||
|
||||
function build_setup() {
|
||||
filename="activitywatch${build_suffix}-${version}-${platform}-${arch}-setup.exe"
|
||||
echo "Name of package will be: $filename"
|
||||
|
||||
innosetupdir="/c/Program Files (x86)/Inno Setup 6"
|
||||
if [ ! -d "$innosetupdir" ]; then
|
||||
echo "ERROR: Couldn't find innosetup which is needed to build the installer. We suggest you install it using chocolatey. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $TAURI_BUILD == "true" ]]; then
|
||||
env AW_VERSION=$version_no_prefix "$innosetupdir/iscc.exe" scripts/package/aw-tauri.iss
|
||||
else
|
||||
env AW_VERSION=$version_no_prefix "$innosetupdir/iscc.exe" scripts/package/activitywatch-setup.iss
|
||||
fi
|
||||
"$SCRIPT_DIR/collect-setup.sh" "$filename"
|
||||
echo "Setup built!"
|
||||
}
|
||||
|
||||
build_zip
|
||||
if [[ $platform == "windows"* ]]; then
|
||||
build_setup
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-------------------------------------"
|
||||
echo "Contents of ./dist"
|
||||
ls -l dist
|
||||
echo "-------------------------------------"
|
||||
30
scripts/package/package-appimage.sh
vendored
Executable file
30
scripts/package/package-appimage.sh
vendored
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# pick the latest zip
|
||||
# NOTE: this assumes that the latest built zip is the only zip in the directory
|
||||
ZIP_FILE=`ls ./dist/ -1 | grep zip | sort -r | head -1`
|
||||
unzip ./dist/$ZIP_FILE
|
||||
|
||||
# fetch deps
|
||||
wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
|
||||
chmod +x linuxdeploy-x86_64.AppImage
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
|
||||
# create AppRun
|
||||
echo '#!/bin/sh
|
||||
DIR="$(dirname "$(readlink -f "${0}")")"
|
||||
"${DIR}"/aw-qt "$@"' > activitywatch/AppRun
|
||||
chmod a+x ./activitywatch/AppRun
|
||||
|
||||
# build appimage
|
||||
./linuxdeploy-x86_64.AppImage --appdir activitywatch --executable ./activitywatch/aw-qt --output appimage --desktop-file ./activitywatch/aw-qt.desktop --icon-file ./activitywatch/media/logo/logo.png --icon-filename activitywatch
|
||||
APPIMAGE_FILE=`ls -1 | grep AppImage| grep -i ActivityWatch`
|
||||
# Deliberately unversioned: the AppImage has kept this exact name across
|
||||
# releases so the stable download URL keeps working, e.g.
|
||||
# https://github.com/ActivityWatch/activitywatch/releases/latest/download/activitywatch-linux-x86_64.AppImage
|
||||
EDITION=""
|
||||
if [[ $AW_RESEARCH_EDITION == "true" ]]; then
|
||||
EDITION="-research"
|
||||
fi
|
||||
cp -v $APPIMAGE_FILE ./dist/activitywatch${EDITION}-linux-x86_64.AppImage
|
||||
61
scripts/package/package-deb.sh
vendored
Executable file
61
scripts/package/package-deb.sh
vendored
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
# Fail fast
|
||||
set -e
|
||||
# Verbose commands for CI verification
|
||||
set -x
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="$("$SCRIPT_DIR/getversion.sh")"
|
||||
# Strip the research tag suffix: the edition gets its own filename token, and
|
||||
# a bare "-research" in the Debian version field would parse as a revision.
|
||||
VERSION="${VERSION%-research}"
|
||||
VERSION_NUM="$(echo "$VERSION" | sed -e 's/^v//')"
|
||||
EDITION=""
|
||||
if [[ $AW_RESEARCH_EDITION == "true" ]]; then
|
||||
EDITION="-research"
|
||||
fi
|
||||
echo "Version (with v): $VERSION"
|
||||
echo "Version (without v): $VERSION_NUM"
|
||||
PKGDIR="activitywatch_$VERSION_NUM"
|
||||
|
||||
# Package tools
|
||||
sudo apt-get install sed jdupes wget
|
||||
|
||||
if [ -d "PKGDIR" ]; then
|
||||
sudo rm -rf $PKGDIR
|
||||
fi
|
||||
|
||||
# .deb meta files
|
||||
mkdir -p $PKGDIR/DEBIAN
|
||||
# activitywatch's install location
|
||||
mkdir -p $PKGDIR/opt
|
||||
# Allows aw-qt to autostart.
|
||||
mkdir -p $PKGDIR/etc/xdg/autostart
|
||||
# Allows users to manually start aw-qt from their start menu.
|
||||
mkdir -p $PKGDIR/usr/share/applications
|
||||
|
||||
# While storing the control file in a variable here, dumping it in a file is so unnecessarily
|
||||
# complicated that it's easier to just dump move and sed.
|
||||
cp ./scripts/package/deb/control $PKGDIR/DEBIAN/control
|
||||
sed -i "s/SCRIPT_VERSION_HERE/${VERSION_NUM}/" $PKGDIR/DEBIAN/control
|
||||
|
||||
# Verify the file content
|
||||
cat $PKGDIR/DEBIAN/control
|
||||
# The entire opt directory (should) consist of dist/activitywatch/*
|
||||
|
||||
cp -r dist/activitywatch/ $PKGDIR/opt/
|
||||
|
||||
# Hard link duplicated libraries
|
||||
# (I have no idea what this is for)
|
||||
jdupes -L -r -S -Xsize-:1K $PKGDIR/opt/
|
||||
|
||||
sudo chown -R root:root $PKGDIR
|
||||
|
||||
# Prepare the .desktop file
|
||||
sudo sed -i 's!Exec=aw-qt!Exec=/opt/activitywatch/aw-qt!' $PKGDIR/opt/activitywatch/aw-qt.desktop
|
||||
sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/etc/xdg/autostart/
|
||||
sudo cp $PKGDIR/opt/activitywatch/aw-qt.desktop $PKGDIR/usr/share/applications/
|
||||
|
||||
dpkg-deb --build $PKGDIR
|
||||
sudo mv activitywatch_${VERSION_NUM}.deb dist/activitywatch${EDITION}-${VERSION}-linux-x86_64.deb
|
||||
277
scripts/package/publish_research_feed.py
Normal file
277
scripts/package/publish_research_feed.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = ["semver>=3,<4"]
|
||||
# ///
|
||||
"""Validate and prepare a research feed commit; publish only with --publish."""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from semver import Version
|
||||
|
||||
REPOSITORY = "ActivityWatch/activitywatch"
|
||||
REF = "refs/heads/research-updates"
|
||||
MANIFEST = "latest-research.json"
|
||||
# Keep in step with build-tauri's release matrix. A reduced rollout needs review.
|
||||
TARGETS = {
|
||||
"darwin-aarch64": ("app.tar.gz",),
|
||||
"darwin-x86_64": ("app.tar.gz",),
|
||||
"linux-aarch64": ("AppImage", "AppImage.tar.gz"),
|
||||
"linux-x86_64": ("AppImage", "AppImage.tar.gz"),
|
||||
"windows-x86_64": ("exe", "nsis.zip", "msi", "msi.zip"),
|
||||
}
|
||||
|
||||
|
||||
def public_bytes(url):
|
||||
"""Unauthenticated reads prove the release/signature is publicly available."""
|
||||
request = Request(url, headers={"User-Agent": "ActivityWatch-research-publisher"})
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def validate_manifest(manifest, release, tag, fetch=None):
|
||||
if fetch is None:
|
||||
fetch = public_bytes
|
||||
if set(manifest) != {"version", "notes", "pub_date", "platforms"}:
|
||||
raise ValueError("Expected only the four static updater manifest fields")
|
||||
if not isinstance(manifest["notes"], str):
|
||||
raise ValueError("Manifest notes must be a string")
|
||||
date = manifest["pub_date"]
|
||||
if not isinstance(date, str) or not re.fullmatch(
|
||||
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])",
|
||||
date,
|
||||
):
|
||||
raise ValueError("Manifest pub_date must be an RFC3339 timestamp")
|
||||
datetime.fromisoformat(date.replace("Z", "+00:00"))
|
||||
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?-research", tag):
|
||||
raise ValueError("Expected a versioned research release tag")
|
||||
raw_version = tag[1 : -len("-research")]
|
||||
expected_version = re.sub(
|
||||
r"(a|b|rc)([0-9]+)$",
|
||||
lambda m: "-" + {"a": "alpha", "b": "beta", "rc": "rc"}[m[1]] + "." + m[2],
|
||||
raw_version,
|
||||
)
|
||||
Version.parse(expected_version)
|
||||
if manifest["version"] != expected_version:
|
||||
raise ValueError("Manifest version must match the normalized research tag")
|
||||
if (
|
||||
release["tag_name"] != tag
|
||||
or release["draft"] is not False
|
||||
or not release["published_at"]
|
||||
or release.get("immutable") is not True
|
||||
):
|
||||
raise ValueError(
|
||||
"Release must be published, immutable, non-draft, and match the tag"
|
||||
)
|
||||
if not isinstance(manifest["platforms"], dict) or set(manifest["platforms"]) != set(
|
||||
TARGETS
|
||||
):
|
||||
raise ValueError(
|
||||
"Manifest must contain exactly the complete required target matrix"
|
||||
)
|
||||
assets = {asset["name"]: asset for asset in release["assets"]}
|
||||
if len(assets) != len(release["assets"]):
|
||||
raise ValueError("Duplicate release asset names")
|
||||
base_url = f"https://github.com/{REPOSITORY}/releases/download/{tag}/"
|
||||
for target, entry in manifest["platforms"].items():
|
||||
if set(entry) != {"url", "signature"}:
|
||||
raise ValueError(f"Expected only url and signature for {target}")
|
||||
prefix = f"activitywatch-tauri-research-{raw_version}-{target}."
|
||||
names = [prefix + ext for ext in TARGETS[target]]
|
||||
matches = [name for name in names if entry["url"] == base_url + name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"Wrong edition, version, release, or bundle format for {target}"
|
||||
)
|
||||
name = matches[0]
|
||||
for asset_name in (name, name + ".sig"):
|
||||
asset = assets.get(asset_name)
|
||||
if (
|
||||
not asset
|
||||
or asset["state"] != "uploaded"
|
||||
or asset["size"] <= 0
|
||||
or asset["browser_download_url"] != base_url + asset_name
|
||||
):
|
||||
raise ValueError(
|
||||
f"Missing or invalid uploaded release asset: {asset_name}"
|
||||
)
|
||||
signature = entry["signature"]
|
||||
if (
|
||||
not isinstance(signature, str)
|
||||
or not signature.strip()
|
||||
or signature != fetch(base_url + name + ".sig").decode("utf-8").strip()
|
||||
):
|
||||
raise ValueError(f"Missing or mismatched signature for {target}")
|
||||
|
||||
|
||||
def git(repo, *args, input=None):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
input=input,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def remote_parent(repo, remote):
|
||||
result = git(repo, "ls-remote", "--refs", remote, REF)
|
||||
return result.split()[0] if result else "absent"
|
||||
|
||||
|
||||
def prepare(repo, remote, expected_parent, manifest):
|
||||
"""Create a new isolated checkout, retaining all existing feed branch files."""
|
||||
repo.mkdir(parents=True, exist_ok=False)
|
||||
git(repo, "init", "--quiet")
|
||||
if remote_parent(repo, remote) != expected_parent:
|
||||
raise ValueError("Stale expected parent; reread the feed and retry validation")
|
||||
if expected_parent != "absent":
|
||||
git(repo, "fetch", "--no-tags", remote, REF)
|
||||
if git(repo, "rev-parse", "FETCH_HEAD") != expected_parent:
|
||||
raise ValueError("Feed changed during fetch; reread and retry")
|
||||
git(repo, "checkout", "--detach", expected_parent)
|
||||
current = json.loads(git(repo, "show", f"{expected_parent}:{MANIFEST}"))
|
||||
old, new = Version.parse(current["version"]), Version.parse(manifest["version"])
|
||||
if new < old:
|
||||
raise ValueError("Refusing research feed version regression")
|
||||
if new == old:
|
||||
if manifest != current:
|
||||
raise ValueError("Conflicting content at equal SemVer precedence")
|
||||
return None
|
||||
else:
|
||||
git(repo, "checkout", "--orphan", "research-updates")
|
||||
# Replace any old symlink rather than following a path outside this checkout.
|
||||
path = repo / MANIFEST
|
||||
if path.is_symlink():
|
||||
path.unlink()
|
||||
path.write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
git(repo, "add", "--", MANIFEST)
|
||||
git(
|
||||
repo,
|
||||
"commit",
|
||||
"-m",
|
||||
f"chore(updater): offer research {manifest['version']}",
|
||||
"--",
|
||||
MANIFEST,
|
||||
)
|
||||
return git(repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def publish(repo, remote, expected_parent, commit):
|
||||
"""Assert the advertised parent in pre-push, then use Git's normal FF/CAS."""
|
||||
if git(repo, "rev-list", "--parents", "-n", "1", commit).split() != (
|
||||
[commit] if expected_parent == "absent" else [commit, expected_parent]
|
||||
):
|
||||
raise ValueError("Candidate must be one commit on the expected parent")
|
||||
original_hook = Path(git(repo, "rev-parse", "--git-path", "hooks/pre-push"))
|
||||
if not original_hook.is_absolute():
|
||||
original_hook = repo / original_hook
|
||||
expected_oid = "0" * len(commit) if expected_parent == "absent" else expected_parent
|
||||
# Git sends the same advertised old OID to receive-pack, which checks it
|
||||
# atomically. Checking only ls-remote before push would leave a race window
|
||||
# for branch deletion/rewind. Preserve the site's existing pre-push policy.
|
||||
with tempfile.TemporaryDirectory(prefix="aw-feed-push-") as directory:
|
||||
hook = Path(directory) / "pre-push"
|
||||
hook.write_text(
|
||||
"#!/bin/sh\nexec "
|
||||
+ shlex.quote(sys.executable)
|
||||
+ " -c "
|
||||
+ shlex.quote(
|
||||
"import os, subprocess, sys\n"
|
||||
"data = sys.stdin.read()\n"
|
||||
"rows = [line.split() for line in data.splitlines()]\n"
|
||||
f"expected = {expected_oid!r}\n"
|
||||
f"if len(rows) != 1 or rows[0][1:] != [{commit!r}, {REF!r}, expected]:\n"
|
||||
" sys.exit('Feed parent changed during push; reread and retry')\n"
|
||||
f"hook = {str(original_hook)!r}\n"
|
||||
"if os.access(hook, os.X_OK):\n"
|
||||
" sys.exit(subprocess.run([hook, *sys.argv[1:]], input=data, text=True).returncode)\n"
|
||||
)
|
||||
+ ' "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
hook.chmod(0o755)
|
||||
git(
|
||||
repo,
|
||||
"-c",
|
||||
f"core.hooksPath={directory}",
|
||||
"-c",
|
||||
"push.followTags=false",
|
||||
"push",
|
||||
"--porcelain",
|
||||
remote,
|
||||
f"{commit}:{REF}",
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", required=True, type=Path)
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument(
|
||||
"--expected-parent",
|
||||
required=True,
|
||||
help="Full feed SHA, or 'absent' for provisioning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--work-dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="New directory to retain the reviewable commit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--publish",
|
||||
action="store_true",
|
||||
help="Explicitly push after validation and preparation",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
remote = f"https://github.com/{REPOSITORY}.git"
|
||||
try:
|
||||
if args.expected_parent != "absent" and not re.fullmatch(
|
||||
r"[0-9a-f]{40}", args.expected_parent
|
||||
):
|
||||
raise ValueError("Expected a full parent SHA or 'absent'")
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||
release = json.loads(
|
||||
public_bytes(
|
||||
f"https://api.github.com/repos/{REPOSITORY}/releases/tags/{quote(args.tag, safe='')}"
|
||||
)
|
||||
)
|
||||
validate_manifest(manifest, release, args.tag)
|
||||
repo = args.work_dir.resolve()
|
||||
commit = prepare(repo, remote, args.expected_parent, manifest)
|
||||
if commit is None:
|
||||
print("NO-OP: identical manifest already published")
|
||||
elif args.publish:
|
||||
publish(repo, remote, args.expected_parent, commit)
|
||||
print(f"Published {commit} on {REF}")
|
||||
else:
|
||||
print(f"Prepared {commit} in {repo}; remote unchanged (no --publish)")
|
||||
except (
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
OSError,
|
||||
subprocess.CalledProcessError,
|
||||
) as exc:
|
||||
detail = (
|
||||
exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc)
|
||||
)
|
||||
parser.exit(1, f"ERROR: {detail}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user