chore: initial private ActivityWatch Russian deployment project

This commit is contained in:
igor04091968
2026-04-25 15:24:04 +03:00
commit 0821de0964
26 changed files with 2915 additions and 0 deletions
Executable
+56
View File
@@ -0,0 +1,56 @@
# ActivityWatch-Russian
Практический каркас проекта для повторного развёртывания ActivityWatch Server в новом окружении с LXC-контейнером на Proxmox, русифицированным Web UI, systemd-юнитами, шаблонными скриптами деплоя и эксплуатационной документацией.
## Что входит
- `docs/preparation.md` — подготовка инфраструктуры и входных параметров.
- `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server.
- `docs/runbook.md` — быстрый runbook для оператора.
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
## Базовый сценарий
1. Подготовить параметры окружения по `docs/preparation.md`.
2. Заполнить единый файл секретов `secrets/deploy.secrets.env` (автоподключение).
3. На узле Proxmox создать контейнер через `proxmox/create-ct.sh`.
4. Загрузить артефакты и серверный env в CT через `proxmox/push-aw-artifacts.sh`.
5. Внутри контейнера выполнить `aw-server/install_aw_server.sh`.
6. Применить русификацию Web UI через `aw-server/apply_webui_ru_patch.sh`.
7. Проверить API, Web UI и состояние systemd по `docs/runbook.md`.
Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают:
- `secrets/deploy.secrets.env`
## Принципы
- Никаких реальных секретов, токенов и боевых IP в репозитории.
- Все переменные вынесены в `.example` / `.env` шаблоны.
- Документация ориентирована на повторяемое развёртывание, а не на одноразовую ручную установку.
- Rollback и backup описаны как обязательная часть каждой операции.
## Минимальная структура
- CT/LXC на Debian 12
- ActivityWatch Server Rust release
- Web UI override в `/opt/activitywatch/webui-ru`
- systemd unit `activitywatch-server.service`
- bind/listen через переменные окружения
## Ограничения
- Windows-клиенты и watcher-автоматизация в этой части каркаса не описываются.
- Интеграции с InfluxDB/Grafana/LDAP оставлены как следующий слой, не как обязательная база.
## Быстрые ссылки
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/docs/FULL_DEPLOYMENT_MANUAL_RU.md`
- `docs/preparation.md`
- `docs/deployment.md`
- `docs/runbook.md`
- `docs/operations.md`
- `proxmox/create-ct.sh`
- `aw-server/install_aw_server.sh`
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=ActivityWatch Server (Rust)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/activitywatch/aw-server.env
User=__AW_SERVER_USER__
Group=__AW_SERVER_GROUP__
WorkingDirectory=__AW_SERVER_DATA_DIR__
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"'
Restart=on-failure
RestartSec=5s
StateDirectory=activitywatch
LogsDirectory=activitywatch
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
set -euo pipefail
ENV_FILE="/etc/activitywatch/aw-server.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "missing env file: $ENV_FILE" >&2
exit 1
fi
source "$ENV_FILE"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-/opt/activitywatch/webui-ru}"
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
INDEX_HTML="$WEBUI_DIR/index.html"
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
TS=$(date +%Y%m%d%H%M%S)
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
install -d "$WEBUI_DIR/js"
install -m 0644 "$PATCH_JS_SRC" "$WEBUI_DIR/js/aw-ru-patch.js"
install -m 0644 "$SW_CLEANUP_SRC" "$WEBUI_DIR/js/aw-sw-cleanup.js"
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
sed -i '/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
sed -i 's#</head>#<script src="/js/aw-sw-cleanup.js"></script></head>#' "$INDEX_HTML"
sed -i 's#</body>#<script defer="defer" src="/js/aw-ru-patch.js"></script></body>#' "$INDEX_HTML"
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
echo "RU patch applied to $WEBUI_DIR"
+122
View File
@@ -0,0 +1,122 @@
(function () {
window.__awRuPatchVersion = "template-v1";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v1");
const exact = new Map([
["ActivityWatch", "АктивВотч"],
["Home", "Главная"],
["Activity", "Активность"],
["Timeline", "Таймлайн"],
["Trends", "Тренды"],
["Report", "Отчеты"],
["Settings", "Настройки"],
["Search", "Поиск"],
["Buckets", "Бакеты"],
["Stopwatch", "Секундомер"],
["Tools", "Инструменты"],
["Raw Data", "Сырые данные"],
["Summary", "Сводка"],
["All", "Все"],
["None", "Нет"],
["Date", "Дата"],
["Time", "Время"],
["Start", "Начало"],
["End", "Конец"],
["Download", "Скачать"],
["Upload", "Загрузить"],
["Show", "Показать"],
["Hide", "Скрыть"],
["Close", "Закрыть"],
["Week", "Неделя"],
["Month", "Месяц"],
["Year", "Год"],
["Today", "Сегодня"],
["Yesterday", "Вчера"],
["No data", "Нет данных"],
["Category", "Категория"],
["Categories", "Категории"],
["Duration", "Длительность"],
["Applications", "Приложения"],
["Hosts", "Хосты"],
["Date Range", "Диапазон дат"],
["Generate", "Сформировать"],
["Loading", "Загрузка"],
["Loading...", "Загрузка..."],
["Dark", "Темная"],
["Light", "Светлая"],
["Save", "Сохранить"],
["Cancel", "Отмена"],
["Delete", "Удалить"],
["API Browser", "API-браузер"],
["Documentation", "Документация"]
]);
const partial = [
["Top apps", "Топ приложений"],
["Top titles", "Топ заголовков"],
["Top URLs", "Топ URL"],
["Top domains", "Топ доменов"],
["Top Browser Domains", "Топ доменов браузера"],
["Top Browser URLs", "Топ URL браузера"],
["Top Browser Titles", "Топ заголовков браузера"],
["Top Categories", "Топ категорий"],
["Category Tree", "Дерево категорий"],
["Timeline (barchart)", "Таймлайн (гистограмма)"],
["Calculate Work Time", "Рассчитать рабочее время"],
["Export CSV", "Экспорт CSV"],
["Export JSON", "Экспорт JSON"],
["No duplicate events found.", "Дубликаты событий не найдены."],
["No overlapping events found.", "Пересекающиеся события не найдены."],
["No zero-duration events found.", "События нулевой длительности не найдены."]
];
function replaceText(text) {
if (!text) return text;
if (exact.has(text.trim())) {
return text.replace(text.trim(), exact.get(text.trim()));
}
let result = text;
for (const [en, ru] of partial) {
result = result.split(en).join(ru);
}
return result;
}
function walk(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
const nextValue = replaceText(node.nodeValue);
if (nextValue !== node.nodeValue) {
node.nodeValue = nextValue;
}
}
}
function translateAttributes(root) {
const elements = root.querySelectorAll("[title],[placeholder],[aria-label]");
for (const element of elements) {
["title", "placeholder", "aria-label"].forEach(function (attr) {
const value = element.getAttribute(attr);
if (value) {
element.setAttribute(attr, replaceText(value));
}
});
}
}
function applyPatch() {
walk(document.body);
translateAttributes(document.body);
}
const observer = new MutationObserver(function () {
applyPatch();
});
window.addEventListener("load", function () {
applyPatch();
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
});
})();
+11
View File
@@ -0,0 +1,11 @@
# Copy to /etc/activitywatch/aw-server.env and fill with real values.
AW_SERVER_VERSION=0.13.2
AW_SERVER_DOWNLOAD_URL=https://github.com/ActivityWatch/aw-server-rust/releases/download/v0.13.2/aw-server-rust-linux-x86_64.zip
AW_SERVER_BIND_HOST=0.0.0.0
AW_SERVER_PORT=5600
AW_SERVER_WEBUI_DIR=/opt/activitywatch/webui-ru
AW_SERVER_DATA_DIR=/var/lib/activitywatch
AW_SERVER_LOG_DIR=/var/log/activitywatch
AW_SERVER_USER=activitywatch
AW_SERVER_GROUP=activitywatch
+18
View File
@@ -0,0 +1,18 @@
self.addEventListener("install", function (event) {
self.skipWaiting();
event.waitUntil((async function () {
const keys = await caches.keys();
await Promise.all(keys.map(function (key) { return caches.delete(key); }));
})());
});
self.addEventListener("activate", function (event) {
event.waitUntil((async function () {
const keys = await caches.keys();
await Promise.all(keys.map(function (key) { return caches.delete(key); }));
await self.clients.claim();
await self.registration.unregister();
})());
});
self.addEventListener("fetch", function () {});
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
set -euo pipefail
ENV_FILE="/etc/activitywatch/aw-server.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "missing env file: $ENV_FILE" >&2
exit 1
fi
source "$ENV_FILE"
required_vars=(
AW_SERVER_VERSION
AW_SERVER_DOWNLOAD_URL
AW_SERVER_BIND_HOST
AW_SERVER_PORT
AW_SERVER_WEBUI_DIR
AW_SERVER_DATA_DIR
AW_SERVER_LOG_DIR
AW_SERVER_USER
AW_SERVER_GROUP
)
for var_name in "${required_vars[@]}"; do
if [[ -z "${!var_name:-}" ]]; then
echo "missing required variable: $var_name" >&2
exit 1
fi
done
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates unzip jq
if ! getent group "$AW_SERVER_GROUP" >/dev/null; then
groupadd --system "$AW_SERVER_GROUP"
fi
if ! id "$AW_SERVER_USER" >/dev/null 2>&1; then
useradd --system --gid "$AW_SERVER_GROUP" --home-dir "$AW_SERVER_DATA_DIR" --shell /usr/sbin/nologin "$AW_SERVER_USER"
fi
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/bin
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/releases
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_LOG_DIR"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
curl -fL "$AW_SERVER_DOWNLOAD_URL" -o "$tmp_dir/aw-server.zip"
unzip -q "$tmp_dir/aw-server.zip" -d "$tmp_dir/unpacked"
server_bin=$(find "$tmp_dir/unpacked" -type f \( -name 'aw-server-rust' -o -name 'aw-server' \) | head -n 1)
webui_dir=$(find "$tmp_dir/unpacked" -type d \( -name 'webui' -o -name 'aw-webui' \) | head -n 1 || true)
if [[ -z "$server_bin" || ! -f "$server_bin" ]]; then
echo "aw-server binary not found in archive" >&2
exit 1
fi
release_dir="/opt/activitywatch/releases/aw-server-rust-v${AW_SERVER_VERSION}"
rm -rf "$release_dir"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$release_dir"
install -m 0755 -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$server_bin" "$release_dir/aw-server-rust"
ln -sfn "$release_dir/aw-server-rust" /opt/activitywatch/bin/aw-server-rust
if [[ -n "$webui_dir" && -d "$webui_dir" ]]; then
rm -rf "$AW_SERVER_WEBUI_DIR"
mkdir -p "$AW_SERVER_WEBUI_DIR"
cp -a "$webui_dir"/. "$AW_SERVER_WEBUI_DIR"/
chown -R "$AW_SERVER_USER:$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
fi
sed \
-e "s#__AW_SERVER_USER__#$AW_SERVER_USER#g" \
-e "s#__AW_SERVER_GROUP__#$AW_SERVER_GROUP#g" \
-e "s#__AW_SERVER_DATA_DIR__#$AW_SERVER_DATA_DIR#g" \
/root/bootstrap/activitywatch-server.service > /etc/systemd/system/activitywatch-server.service
chmod 0644 /etc/systemd/system/activitywatch-server.service
systemctl daemon-reload
systemctl enable activitywatch-server.service
systemctl restart activitywatch-server.service
systemctl --no-pager --full status activitywatch-server.service || true
+308
View File
@@ -0,0 +1,308 @@
# Полная инструкция по развёртыванию и поддержке ActivityWatch-Russian
Документ описывает полный цикл: Proxmox/LXC сервер, установка ActivityWatch Server, RU Web UI patch, развёртывание Windows-клиентов в другом AD-домене, валидация, сопровождение и rollback.
---
## 0) Структура проекта (полные пути)
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/aw-server/install_aw_server.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/aw-server/apply_webui_ru_patch.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/deploy-single-user.ps1`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/deploy-domain-users.ps1`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/hardening-recovery.ps1`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/browser-domains-native-collector.ps1`
---
## 1) Подготовка
### 1.1 Требования
- Proxmox VE 8/9, доступ root (или sudo с правами на `pct`).
- Шаблон Debian 12 LXC на хосте Proxmox.
- Windows хост(ы) с PowerShell 5.1+ и правами локального администратора.
- Сетевой доступ Windows-клиентов до ActivityWatch Server (`5600/tcp`).
### 1.2 Подготовка единого файла секретов
Скопируйте шаблон:
```bash
cp /mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env.example \
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env
```
Заполните в файле `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`:
- все `CT_*` параметры контейнера;
- все `AW_SERVER_*` параметры сервера;
- `CT_PASSWORD` (реальный пароль).
Важно: этот файл подхватывается автоматически скриптами Proxmox.
---
## 2) Развёртывание сервера в Proxmox
### 2.1 Создать LXC контейнер
На узле Proxmox:
```bash
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh
```
По умолчанию читается:
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`
При необходимости можно передать другой путь:
```bash
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh /absolute/path/to/deploy.secrets.env
```
### 2.2 Загрузить bootstrap-артефакты и env внутрь CT
```bash
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh
```
Скрипт загружает в CT:
- `/root/bootstrap/install_aw_server.sh`
- `/root/bootstrap/apply_webui_ru_patch.sh`
- `/root/bootstrap/activitywatch-server.service`
- `/root/bootstrap/aw-ru-patch.js`
- `/root/bootstrap/aw-sw-cleanup.js`
- `/etc/activitywatch/aw-server.env` (из `AW_SERVER_*`)
### 2.3 Установить ActivityWatch Server внутри CT
```bash
pct enter <CT_ID>
bash /root/bootstrap/install_aw_server.sh
```
### 2.4 Применить RU patch Web UI
```bash
bash /root/bootstrap/apply_webui_ru_patch.sh
systemctl restart activitywatch-server.service
```
### 2.5 Проверка сервера
В CT:
```bash
systemctl status activitywatch-server.service --no-pager
curl -fsS http://127.0.0.1:5600/api/0/info
ss -ltnp | grep 5600
grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html
```
Ожидается:
- сервис `active (running)`;
- API отвечает JSON;
- порт 5600 слушается;
- в `index.html` присутствуют оба скрипта.
---
## 3) Развёртывание Windows-клиентов (другой AD-домен)
### 3.1 Подготовка на Windows-хосте
Скопируйте каталог:
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows`
например в:
- `C:\Deploy\ActivityWatch-Russian\windows`
Откройте **elevated PowerShell**:
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
```
### 3.2 Массовое доменное развёртывание (рекомендуется)
Пример со списком пользователей:
```powershell
C:\Deploy\ActivityWatch-Russian\windows\deploy-domain-users.ps1 `
-ServerHost aw.example.local `
-ServerPort 5600 `
-Domain CONTOSO `
-UserListPath C:\Deploy\aw-users.txt `
-CustomRulesPath C:\Deploy\ActivityWatch-Russian\windows\web-category-rules.example.json
```
Поддерживаемые варианты:
- `-Users user01,user02`
- `-Users 'CONTOSO\user01','CONTOSO\user02'`
- `-UserListPath <txt|csv>`
### 3.3 Single-user развёртывание
```powershell
C:\Deploy\ActivityWatch-Russian\windows\deploy-single-user.ps1 `
-ServerHost aw.example.local `
-ServerPort 5600 `
-TargetUser 'CONTOSO\user01' `
-CustomRulesPath C:\Deploy\ActivityWatch-Russian\windows\web-category-rules.example.json
```
### 3.4 Recovery / hardening
```powershell
C:\Deploy\ActivityWatch-Russian\windows\hardening-recovery.ps1 `
-ConfigPath C:\ProgramData\ActivityWatch\deployment-config.json
```
---
## 4) Что должно появиться на Windows после установки
- `C:\Program Files\ActivityWatch`
- `C:\ProgramData\ActivityWatch\deployment-config.json`
- `C:\ProgramData\ActivityWatch\launch-watchers.ps1`
- `C:\ProgramData\ActivityWatch\recovery-loop.ps1`
- `C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1`
- `C:\ProgramData\ActivityWatch\web-category-rules.json`
- `C:\ProgramData\ActivityWatch\logs\`
Задачи планировщика:
- `ActivityWatch Launch [<user>]` (per-user, при логоне)
- `ActivityWatch Recovery` (system-level recovery)
---
## 5) Полная валидация потока данных
### 5.1 На Windows-хосте
Проверить процессы:
```powershell
Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue
Get-CimInstance Win32_Process | ? { $_.CommandLine -like '*browser-domains-native-collector.ps1*' } | select ProcessId,SessionId,CommandLine
```
Проверить задачи:
```powershell
Get-ScheduledTask | ? { $_.TaskName -like 'ActivityWatch*' } | select TaskName,State
```
### 5.2 На сервере ActivityWatch API
```bash
curl -sS http://127.0.0.1:5600/api/0/buckets | jq 'keys'
```
Ожидаемые bucket'ы:
- `aw-watcher-afk_<HOST>`
- `aw-watcher-window_<HOST>`
- `aw-watcher-web-<browser>_<HOST>`
- `aw-detmir-web-category_<HOST>` (категоризованный поток)
Проверка событий браузера:
```bash
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-watcher-web-edge_<HOST>/events?limit=5" | jq
```
Проверка категоризации:
```bash
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-detmir-web-category_<HOST>/events?limit=5" | jq
```
---
## 6) Сопровождение (обязательно)
### 6.1 Backup перед любыми изменениями
На Proxmox:
```bash
vzdump <CT_ID> --mode snapshot --compress zstd --storage <BACKUP_STORAGE>
```
Конфиги внутри CT:
```bash
pct exec <CT_ID> -- tar -C / -czf /root/activitywatch-config-backup.tgz \
etc/activitywatch \
etc/systemd/system/activitywatch-server.service \
opt/activitywatch/webui-ru \
opt/activitywatch/releases
```
### 6.2 Обновление сервера
1. Обновить `AW_SERVER_VERSION` и `AW_SERVER_DOWNLOAD_URL` в
`/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`
2. Выполнить:
```bash
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh
pct enter <CT_ID>
bash /root/bootstrap/install_aw_server.sh
bash /root/bootstrap/apply_webui_ru_patch.sh
systemctl restart activitywatch-server.service
```
3. Повторить валидацию API/UI.
### 6.3 Rollback
RU patch rollback:
```bash
cp /opt/activitywatch/webui-ru/index.html.bak.<timestamp> /opt/activitywatch/webui-ru/index.html
systemctl restart activitywatch-server.service
```
Полный rollback:
- восстановить CT из snapshot/backup;
- проверить API и Web UI;
- проверить доступность для Windows-клиентов.
---
## 7) Безопасность
- Не хранить реальные секреты вне `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`.
- Не открывать `5600/tcp` в интернет напрямую.
- Публиковать через VPN или reverse proxy с ограничением доступа.
- Перед изменениями всегда делать backup.
---
## 8) Короткий чек-лист ввода в эксплуатацию
1. Заполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`.
2. Выполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh`.
3. Выполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh`.
4. В CT выполнены `/root/bootstrap/install_aw_server.sh` и `/root/bootstrap/apply_webui_ru_patch.sh`.
5. Сервер API/порт/UI проверены.
6. На Windows выполнен `deploy-domain-users.ps1`.
7. Проверены процессы, задачи и bucket'ы.
8. Зафиксированы параметры и дата ввода.
+116
View File
@@ -0,0 +1,116 @@
# Deployment
## 1. Подготовить env-файлы
На рабочей машине оператора:
```sh
cp proxmox/ct-vars.example.env /root/activitywatch-ct.env
cp aw-server/aw-server.env.example /root/activitywatch-aw.env
```
Заполнить оба файла реальными значениями вне git.
## 2. Создать CT на Proxmox
На узле Proxmox:
```sh
cd /path/to/ActivityWatch-Russian
./proxmox/create-ct.sh /root/activitywatch-ct.env
```
Скрипт:
- валидирует обязательные переменные;
- создаёт Debian 12 CT;
- запускает контейнер;
- выполняет минимальный bootstrap пакетов;
- готовит `/root/bootstrap` для дальнейшей загрузки артефактов.
## 3. Загрузить артефакты в CT
На узле Proxmox:
```sh
./proxmox/push-aw-artifacts.sh /root/activitywatch-ct.env
pct push <CT_ID> /root/activitywatch-aw.env /etc/activitywatch/aw-server.env
```
В CT будут загружены:
- `install_aw_server.sh`
- `apply_webui_ru_patch.sh`
- `activitywatch-server.service`
- `aw-ru-patch.js`
- `aw-sw-cleanup.js`
## 4. Установить ActivityWatch Server
Внутри CT:
```sh
pct enter <CT_ID>
bash /root/bootstrap/install_aw_server.sh
```
Скрипт установки:
- ставит `curl`, `unzip`, `jq`, `ca-certificates`;
- создаёт пользователя `activitywatch`;
- скачивает release `aw-server-rust`;
- раскладывает бинарник по версиям;
- создаёт активный symlink;
- копирует systemd unit;
- включает и запускает сервис.
## 5. Применить RU patch для Web UI
Внутри CT:
```sh
bash /root/bootstrap/apply_webui_ru_patch.sh
systemctl restart activitywatch-server.service
```
Патч:
- копирует `aw-ru-patch.js` и `aw-sw-cleanup.js`;
- делает backup `index.html`;
- добавляет include в `index.html`;
- заменяет `service-worker.js` cleanup-версией для сброса старого cache.
## 6. Проверить сервис
```sh
systemctl status activitywatch-server.service --no-pager
curl -fsS http://127.0.0.1:5600/api/0/info
ss -ltnp | grep 5600
grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html
```
Ожидаемо:
- сервис `active (running)`;
- API отвечает;
- порт слушается;
- в `index.html` есть оба include.
## 7. После публикации
Проверить извне:
- открывается `/`;
- API доступен по ожидаемому URL;
- после hard refresh видна русификация;
- reverse proxy не кэширует старую статику.
## 8. Что зафиксировать после ввода
- дата ввода;
- узел Proxmox;
- `CT_ID`;
- IP/FQDN;
- версия `aw-server-rust`;
- место хранения backup;
- фактическая схема публикации.
+113
View File
@@ -0,0 +1,113 @@
# Operations
## Эксплуатационная модель
Система состоит из:
- узла Proxmox VE;
- LXC-контейнера с Debian 12;
- `ActivityWatch Server` на Rust;
- Web UI override с RU patch;
- systemd unit `activitywatch-server.service`.
Базовые правила:
- все изменения только через backup-first workflow;
- секреты не хранить в git;
- каждое изменение фиксировать в ticket/run log;
- публичную публикацию делать через отдельный proxy/security layer.
## Регулярные проверки
### Ежедневно или перед работами
- `pct status <CT_ID>`;
- `systemctl is-active activitywatch-server.service`;
- локальный `curl /api/0/info`;
- `df -h`;
- `journalctl -p err -b`.
### Еженедельно
- проверить свежесть backup/snapshot;
- проверить publish URL извне;
- проверить срок TLS, если есть reverse proxy;
- перепроверить firewall/ACL для `5600/tcp`.
### Ежемесячно
- проверить новые релизы ActivityWatch;
- сделать dry-run rollback;
- обновить runbook при изменениях инфраструктуры.
## Backup
Минимум сохранять:
- backup CT через `vzdump`;
- `/etc/activitywatch/aw-server.env`;
- `/etc/systemd/system/activitywatch-server.service`;
- `/opt/activitywatch/webui-ru/`;
- `/opt/activitywatch/releases/`;
- конфиг reverse proxy, если он есть.
Пример:
```sh
vzdump <CT_ID> --mode snapshot --compress zstd --storage <BACKUP_STORAGE>
pct exec <CT_ID> -- tar -C / -czf /root/activitywatch-config-backup.tgz \
etc/activitywatch etc/systemd/system/activitywatch-server.service opt/activitywatch/webui-ru
```
## Rollback
### Быстрый rollback RU patch
```sh
cp /opt/activitywatch/webui-ru/index.html.bak.<timestamp> /opt/activitywatch/webui-ru/index.html
systemctl restart activitywatch-server.service
```
### Rollback server release
1. Остановить сервис.
2. Переключить symlink на предыдущий release.
3. Проверить права.
4. Запустить сервис.
5. Проверить API/UI.
### Полный rollback CT
- остановить CT;
- восстановить snapshot или `vzdump`;
- поднять CT;
- проверить API и publish path.
## Обновление
Порядок:
1. Сделать backup.
2. Скачать новый release в отдельную директорию.
3. Не затирать прошлую версию до успешной проверки.
4. Проверить совместимость RU patch.
5. Перезапустить сервис.
6. Проверить API/UI.
7. Зафиксировать результат.
## Эскалация
Эскалировать сразу, если:
- потерян доступ к Proxmox или CT;
- backup chain повреждён;
- UI/API не вернулись после rollback;
- изменились маршруты, bridge, VLAN или firewall policy;
- нужен новый публичный endpoint.
## Что не делать
- не вшивать реальные IP, пароли и токены;
- не обновлять поверх рабочего бинарника без backup;
- не открывать `5600/tcp` наружу без отдельной защиты;
- не править `index.html` вручную без backup.
+92
View File
@@ -0,0 +1,92 @@
# Preparation
## Цель
Подготовить чистое окружение для повторяемого развёртывания отдельного `ActivityWatch Server` в LXC-контейнере на Proxmox без зависимости от старой инсталляции.
## Что определить заранее
### Инфраструктурные параметры
- имя узла Proxmox;
- `CT_ID`;
- `CT_HOSTNAME`;
- `CT_BRIDGE`;
- `CT_VLAN`, если нужен;
- статический IP/маска/шлюз;
- список DNS;
- storage для `rootfs`;
- размер диска;
- лимиты CPU/RAM/SWAP;
- timezone;
- внутренний или внешний FQDN для публикации.
### Доступы
Хранить вне репозитория:
- SSH/API-доступ к Proxmox;
- пароль или ключ для CT;
- TLS-сертификаты reverse proxy;
- токены внешнего мониторинга или backup target.
## Рекомендуемая базовая схема
- Proxmox VE 8/9
- Debian 12 LXC
- `unprivileged=1`
- `nesting=1,keyctl=1`
- внутренний listen ActivityWatch: `0.0.0.0:5600`
- внешняя публикация только через VPN или reverse proxy
## Проверки на узле Proxmox
```sh
pveversion
pveam update
pveam available | grep debian-12
pvesm status
pct list
```
Проверить:
- есть место под CT и backup;
- `CT_ID` не занят;
- выбранный bridge/VLAN реально маршрутизируется;
- Debian template доступен;
- есть маршрут до будущего адреса контейнера.
## Что подготовить до деплоя
- заполненный `secrets/deploy.secrets.env` (единый файл для CT + AW server);
- согласованный URL релиза `aw-server-rust`;
- решение по публикации: VPN или reverse proxy;
- решение по backup: `vzdump`, snapshot, rsync, NAS или object storage.
## Единый файл секретов
Файл `secrets/deploy.secrets.env` автоматически подхватывается:
- `proxmox/create-ct.sh`
- `proxmox/push-aw-artifacts.sh`
В нём хранятся:
- `CT_*` параметры контейнера;
- `AW_SERVER_*` параметры сервера.
## Базовые каталоги внутри CT
- `/opt/activitywatch/bin`
- `/opt/activitywatch/releases`
- `/opt/activitywatch/webui-ru`
- `/etc/activitywatch`
- `/var/lib/activitywatch`
- `/var/log/activitywatch`
## Security-заметки
- не публиковать `5600/tcp` напрямую в интернет;
- не хранить реальные значения в `.example` файлах;
- перед вводом в эксплуатацию зафиксировать firewall, DNS и publish path в `docs/operations.md`.
+91
View File
@@ -0,0 +1,91 @@
# Runbook
## Быстрый health-check
### На Proxmox
```sh
pct status <CT_ID>
pct config <CT_ID>
pct exec <CT_ID> -- systemctl is-active activitywatch-server.service
pct exec <CT_ID> -- curl -fsS http://127.0.0.1:5600/api/0/info
```
### Внутри CT
```sh
systemctl status activitywatch-server.service --no-pager
journalctl -u activitywatch-server.service -n 100 --no-pager
curl -fsS http://127.0.0.1:5600/api/0/info
ss -ltnp | grep 5600
```
## Проверка RU patch
```sh
grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html
ls -l /opt/activitywatch/webui-ru/js/
```
Проверить:
- есть `aw-ru-patch.js`;
- есть `aw-sw-cleanup.js`;
- `index.html` содержит оба include;
- `service-worker.js` заменён cleanup-версией.
## Типовые инциденты
### Сервис не стартует
```sh
systemctl cat activitywatch-server.service
cat /etc/activitywatch/aw-server.env
journalctl -xeu activitywatch-server.service --no-pager
```
Частые причины:
- битый URL релиза;
- неполная распаковка архива;
- занят порт;
- не созданы каталоги или пользователь;
- ошибка в env-файле.
### API отвечает, но UI без русификации
Проверить:
- патч реально вставлен в `index.html`;
- browser cache/service worker очищен;
- reverse proxy не отдаёт старую статику;
- сервис был перезапущен после правок.
Повторное применение:
```sh
bash /root/bootstrap/apply_webui_ru_patch.sh
systemctl restart activitywatch-server.service
```
### После обновления UI сломался патч
- сравнить `index.html` с backup;
- заново применить patch script;
- проверить словарь в `aw-ru-patch.js`;
- при необходимости откатить только Web UI override.
## Перед любыми изменениями
1. Сделать snapshot или `vzdump`.
2. Сохранить текущий `/etc/activitywatch/aw-server.env`.
3. Сохранить текущий `index.html`.
4. Зафиксировать текущую версию `aw-server-rust`.
## Критерии готовности
- systemd unit стартует без ручного вмешательства;
- API `/api/0/info` отвечает локально;
- UI открывается;
- русификация присутствует;
- rollback-путь понятен оператору.
+111
View File
@@ -0,0 +1,111 @@
# Windows deployment
## Состав пакета
- `windows/deploy-single-user.ps1` — развёртывание для одного пользователя.
- `windows/deploy-domain-users.ps1` — массовое развёртывание по списку пользователей.
- `windows/hardening-recovery.ps1` — повторная регистрация задач, ACL и recovery-loop.
- `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией.
- `windows/web-category-rules.example.json` — пример кастомных правил категоризации.
## Что делает пакет
- Ставит `aw-watcher-afk` и `aw-watcher-window` из официального Windows ZIP ActivityWatch.
- Копирует browser-domain collector в `C:\ProgramData\ActivityWatch`.
- Создаёт per-user задачи `ActivityWatch Launch [...]` с запуском при логоне.
- Создаёт системную задачу `ActivityWatch Recovery`, которая циклически перезапускает per-user launch tasks.
- Применяет ACL к `C:\Program Files\ActivityWatch`, `C:\ProgramData\ActivityWatch` и каталогу логов.
- Не содержит хардкодов инфраструктуры: сервер, домен, список пользователей и правила передаются параметрами.
## Предпосылки
- Windows 10/11 или Windows Server с PowerShell 5.1+.
- Запуск из elevated PowerShell.
- Доступ до ActivityWatch Server по `host:port`.
- Пользователи домена должны реально входить на хост интерактивно.
- Если интернет недоступен, заранее скачайте ActivityWatch ZIP и передайте `-PackageZipPath`.
## Single-user deploy
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
.\windows\deploy-single-user.ps1 `
-ServerHost aw.example.local `
-ServerPort 5600 `
-TargetUser 'CONTOSO\svc.activity.user01' `
-CustomRulesPath .\windows\web-category-rules.example.json
```
Локальный пользователь:
```powershell
.\windows\deploy-single-user.ps1 `
-ServerHost aw-gateway.internal `
-ServerPort 5600 `
-TargetUser '.\operator01'
```
Оффлайн-установка из локального ZIP:
```powershell
.\windows\deploy-single-user.ps1 `
-ServerHost aw.example.local `
-TargetUser 'CONTOSO\user01' `
-PackageZipPath C:\Temp\activitywatch-v0.13.2-windows-x86_64.zip
```
## Multi-user / domain deploy
Поддерживаются:
- `-Users user1,user2`
- `-Users 'CONTOSO\user1','CONTOSO\user2'`
- `-UserListPath .\users.txt`
- `-UserListPath .\users.csv`
TXT-формат:
```text
# comments are ignored
user01
user02
user03
```
CSV-формат: колонка `User`, `Username`, `SamAccountName` или `Login`.
Пример:
```powershell
.\windows\deploy-domain-users.ps1 `
-ServerHost aw.example.local `
-ServerPort 5600 `
-Domain CONTOSO `
-UserListPath C:\Temp\aw-users.txt `
-CustomRulesPath C:\Temp\web-category-rules.json
```
Если список уже содержит `DOMAIN\user`, параметр `-Domain` не нужен.
## Категоризация доменов
- Встроенные категории покрывают базовые рабочие, нейтральные и личные домены.
- Для кастомизации скопируйте `windows/web-category-rules.example.json` и отредактируйте домены.
- Передайте файл через `-CustomRulesPath`; он будет сохранён как `C:\ProgramData\ActivityWatch\web-category-rules.json`.
- Пользовательские правила имеют приоритет над встроенными.
## Структура после установки
- `C:\Program Files\ActivityWatch` — бинарники watcher'ов.
- `C:\ProgramData\ActivityWatch\deployment-config.json` — итоговая конфигурация.
- `C:\ProgramData\ActivityWatch\launch-watchers.ps1` — per-user launcher.
- `C:\ProgramData\ActivityWatch\recovery-loop.ps1` — system recovery loop.
- `C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1` — runtime collector.
- `C:\ProgramData\ActivityWatch\logs\` — логи collector'а.
## Повторный прогон
- Скрипты идемпотентны: переустанавливают задачи и обновляют runtime-файлы.
- Предыдущая установка ActivityWatch бэкапится в `C:\ProgramData\ActivityWatch\backups\install-YYYYMMDD-HHMMSS`.
- Для жёсткого восстановления запускайте `windows/hardening-recovery.ps1`.
+102
View File
@@ -0,0 +1,102 @@
# Windows troubleshooting
## Частые проблемы
### `Run this script from an elevated PowerShell session`
- Откройте PowerShell через `Run as administrator`.
- Проверьте, что UAC не понизил токен.
### Задачи созданы, но watcher'ы не стартуют
- Убедитесь, что пользователь реально вошёл в интерактивную сессию.
- Проверьте задачу `ActivityWatch Launch [...]` в Task Scheduler.
- Запустите вручную:
```powershell
Start-ScheduledTask -TaskName 'ActivityWatch Launch [CONTOSO_user01]'
```
- Если доменный логон ещё ни разу не происходил на хосте, сначала выполните вход этим пользователем.
### Collector не видит URL
- Скрипт работает через UI Automation и foreground window.
- Некоторые браузеры/страницы могут скрывать адресную строку или блокировать UIA.
- Проверьте лог `C:\ProgramData\ActivityWatch\logs\browser-domains-<user>.log`.
- Убедитесь, что активное окно — поддерживаемый браузер: Edge, Chrome, Brave, Vivaldi, Opera, Firefox.
### Сервер недоступен
- Проверьте TCP-доступ:
```powershell
Test-NetConnection aw.example.local -Port 5600
```
- Проверьте локально API:
```powershell
Invoke-WebRequest http://aw.example.local:5600/api/0/info
```
- Если нужен HTTPS reverse proxy, задайте `-ServerScheme https`.
### Неправильная категоризация домена
- Проверьте содержимое `C:\ProgramData\ActivityWatch\web-category-rules.json`.
- Пользовательские правила должны быть валидным JSON.
- Один и тот же домен лучше определять только в одной категории.
- После изменения правил достаточно перезапустить collector или задачу пользователя:
```powershell
Stop-Process -Name powershell -ErrorAction SilentlyContinue
Start-ScheduledTask -TaskName 'ActivityWatch Launch [CONTOSO_user01]'
```
### ACL сломаны или пользователи удалили runtime-файлы
- Запустите:
```powershell
.\windows\hardening-recovery.ps1 -RepairPackage
```
- Если сервер и пользователи уже есть в `deployment-config.json`, дополнительные параметры не нужны.
## Диагностика
Проверить задачи:
```powershell
Get-ScheduledTask -TaskName 'ActivityWatch*' | Select-Object TaskName, State
```
Проверить процессы в пользовательской сессии:
```powershell
Get-Process aw-watcher-afk, aw-watcher-window -ErrorAction SilentlyContinue |
Select-Object ProcessName, Id, SessionId, StartTime
```
Проверить collector:
```powershell
Get-CimInstance Win32_Process |
Where-Object { $_.Name -in 'powershell.exe', 'pwsh.exe' -and $_.CommandLine -match 'browser-domains-native-collector.ps1' } |
Select-Object ProcessId, SessionId, CommandLine
```
Проверить конфиг:
```powershell
Get-Content C:\ProgramData\ActivityWatch\deployment-config.json -Raw
```
## Когда запускать hardening/recovery
- После ручной чистки задач.
- После неудачного обновления ActivityWatch.
- После переноса сервера на другой host/port.
- После обновления списка пользователей.
- После изменения кастомных правил, если нужен централизованный repair pass.
+109
View File
@@ -0,0 +1,109 @@
# Windows validation
## Базовая проверка после развёртывания
### 1. Проверить установленные файлы
```powershell
Test-Path 'C:\Program Files\ActivityWatch\aw-watcher-afk\aw-watcher-afk.exe'
Test-Path 'C:\Program Files\ActivityWatch\aw-watcher-window\aw-watcher-window.exe'
Test-Path 'C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1'
Test-Path 'C:\ProgramData\ActivityWatch\deployment-config.json'
```
Ожидаемый результат — везде `True`.
### 2. Проверить задачи
```powershell
Get-ScheduledTask -TaskName 'ActivityWatch*' |
Select-Object TaskName, Author, State
```
Ожидаемо:
- по одной задаче `ActivityWatch Launch [...]` на пользователя;
- одна задача `ActivityWatch Recovery`.
### 3. Проверить процессы после логина пользователя
```powershell
Get-Process aw-watcher-afk, aw-watcher-window -ErrorAction SilentlyContinue |
Select-Object ProcessName, SessionId, StartTime
```
И collector:
```powershell
Get-CimInstance Win32_Process |
Where-Object { $_.Name -in 'powershell.exe', 'pwsh.exe' -and $_.CommandLine -match 'browser-domains-native-collector.ps1' } |
Select-Object ProcessId, SessionId, CommandLine
```
### 4. Проверить сетевую связность
```powershell
Test-NetConnection aw.example.local -Port 5600
Invoke-WebRequest http://aw.example.local:5600/api/0/info
```
Если используется HTTPS:
```powershell
Invoke-WebRequest https://aw.example.local/api/0/info
```
### 5. Проверить buckets на сервере
После открытия нескольких сайтов в браузере на сервере должны появиться:
- `aw-watcher-window_<hostname>`
- `aw-watcher-web-edge_<hostname>` или другой browser bucket
- `aw-watcher-web-category_<hostname>`
Проверка через API:
```powershell
Invoke-WebRequest http://aw.example.local:5600/api/0/buckets | Select-Object -ExpandProperty Content
```
## Проверка категоризации
1. Откройте сайт из кастомного правила.
2. Подождите `PollSeconds + PulseSeconds`.
3. Проверьте category bucket на сервере.
4. Убедитесь, что поля `domain`, `rootDomain`, `category`, `categoryGroup`, `categoryRule` заполнены.
## Проверка восстановления
1. Завершите `aw-watcher-afk.exe` и `aw-watcher-window.exe` у тестового пользователя.
2. Подождите до `RecoveryIntervalSeconds`.
3. Убедитесь, что per-user launch task стартовала процессы заново.
Ручной запуск recovery:
```powershell
Start-ScheduledTask -TaskName 'ActivityWatch Recovery'
```
## Проверка ACL
```powershell
icacls 'C:\Program Files\ActivityWatch'
icacls 'C:\ProgramData\ActivityWatch'
icacls 'C:\ProgramData\ActivityWatch\logs'
```
Ожидаемо:
- `SYSTEM` и `Administrators` имеют `F`;
- `Users` имеет `RX` на install/state;
- `Users` имеет `M` на `logs`.
## Критерий готовности к массовому развёртыванию
- Установка проходит без ручного редактирования скриптов.
- Все параметры инфраструктуры передаются снаружи.
- Повторный запуск не ломает текущую установку.
- Recovery восстанавливает запуск watcher'ов после остановки.
- Collector пишет domain/category события без расширений браузера.
+66
View File
@@ -0,0 +1,66 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DEFAULT_ENV_FILE="$SCRIPT_DIR/../secrets/deploy.secrets.env"
if [ "$#" -gt 1 ]; then
echo "usage: $0 [ /path/to/deploy.secrets.env ]" >&2
exit 1
fi
ENV_FILE="${1:-$DEFAULT_ENV_FILE}"
if [ ! -f "$ENV_FILE" ]; then
echo "env file not found: $ENV_FILE" >&2
exit 1
fi
# shellcheck disable=SC1090
. "$ENV_FILE"
required_vars="CT_ID CT_HOSTNAME CT_STORAGE CT_TEMPLATE CT_ROOTFS_SIZE CT_CORES CT_MEMORY CT_SWAP CT_BRIDGE CT_IP CT_GW CT_PASSWORD CT_UNPRIVILEGED CT_ONBOOT CT_FEATURES"
for var_name in $required_vars; do
eval "var_value=\${$var_name:-}"
if [ -z "$var_value" ]; then
echo "missing required variable: $var_name" >&2
exit 1
fi
done
if pct status "$CT_ID" >/dev/null 2>&1; then
echo "CT $CT_ID already exists" >&2
exit 1
fi
NET0="name=eth0,bridge=$CT_BRIDGE,ip=$CT_IP,gw=$CT_GW"
if [ -n "${CT_VLAN:-}" ]; then
NET0="$NET0,tag=$CT_VLAN"
fi
pct create "$CT_ID" "$CT_TEMPLATE" \
--hostname "$CT_HOSTNAME" \
--cores "$CT_CORES" \
--memory "$CT_MEMORY" \
--swap "$CT_SWAP" \
--rootfs "$CT_STORAGE:$CT_ROOTFS_SIZE" \
--password "$CT_PASSWORD" \
--unprivileged "$CT_UNPRIVILEGED" \
--onboot "$CT_ONBOOT" \
--features "$CT_FEATURES" \
--net0 "$NET0" \
--nameserver "${CT_NAMESERVER:-}" \
--searchdomain "${CT_SEARCHDOMAIN:-}" \
--ostype debian
pct start "$CT_ID"
sleep 5
pct exec "$CT_ID" -- bash -lc '
set -eu
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates bash unzip xz-utils jq
mkdir -p /root/bootstrap /etc/activitywatch
'
echo "CT $CT_ID created and bootstrapped"
+21
View File
@@ -0,0 +1,21 @@
# Copy this file outside git and fill with real values.
PVE_NODE=pve-example
CT_ID=203
CT_HOSTNAME=activitywatch-server
CT_STORAGE=local-lvm
CT_TEMPLATE=local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst
CT_ROOTFS_SIZE=8G
CT_CORES=2
CT_MEMORY=2048
CT_SWAP=512
CT_BRIDGE=vmbr10
CT_IP=10.20.30.13/24
CT_GW=10.20.30.1
CT_VLAN=
CT_NAMESERVER=1.1.1.1 8.8.8.8
CT_SEARCHDOMAIN=example.internal
CT_PASSWORD=CHANGE_ME
CT_UNPRIVILEGED=1
CT_ONBOOT=1
CT_FEATURES=nesting=1,keyctl=1
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
DEFAULT_ENV_FILE="$PROJECT_ROOT/secrets/deploy.secrets.env"
if [ "$#" -gt 1 ]; then
echo "usage: $0 [ /path/to/deploy.secrets.env ]" >&2
exit 1
fi
ENV_FILE="${1:-$DEFAULT_ENV_FILE}"
# shellcheck disable=SC1090
. "$ENV_FILE"
: "${CT_ID:?missing CT_ID}"
pct exec "$CT_ID" -- mkdir -p /root/bootstrap
for file_name in \
install_aw_server.sh \
apply_webui_ru_patch.sh \
activitywatch-server.service \
aw-server.env.example \
aw-ru-patch.js \
aw-sw-cleanup.js
do
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/$file_name" "/root/bootstrap/$file_name"
done
if [ -n "${AW_SERVER_VERSION:-}" ] &&
[ -n "${AW_SERVER_DOWNLOAD_URL:-}" ] &&
[ -n "${AW_SERVER_BIND_HOST:-}" ] &&
[ -n "${AW_SERVER_PORT:-}" ] &&
[ -n "${AW_SERVER_WEBUI_DIR:-}" ] &&
[ -n "${AW_SERVER_DATA_DIR:-}" ] &&
[ -n "${AW_SERVER_LOG_DIR:-}" ] &&
[ -n "${AW_SERVER_USER:-}" ] &&
[ -n "${AW_SERVER_GROUP:-}" ]; then
tmp_env=$(mktemp)
trap 'rm -f "$tmp_env"' EXIT
{
echo "AW_SERVER_VERSION=$AW_SERVER_VERSION"
echo "AW_SERVER_DOWNLOAD_URL=$AW_SERVER_DOWNLOAD_URL"
echo "AW_SERVER_BIND_HOST=$AW_SERVER_BIND_HOST"
echo "AW_SERVER_PORT=$AW_SERVER_PORT"
echo "AW_SERVER_WEBUI_DIR=$AW_SERVER_WEBUI_DIR"
echo "AW_SERVER_DATA_DIR=$AW_SERVER_DATA_DIR"
echo "AW_SERVER_LOG_DIR=$AW_SERVER_LOG_DIR"
echo "AW_SERVER_USER=$AW_SERVER_USER"
echo "AW_SERVER_GROUP=$AW_SERVER_GROUP"
} > "$tmp_env"
pct push "$CT_ID" "$tmp_env" "/etc/activitywatch/aw-server.env"
pct exec "$CT_ID" -- chmod 0600 /etc/activitywatch/aw-server.env
echo "Server env pushed to CT $CT_ID:/etc/activitywatch/aw-server.env"
else
echo "WARN: AW_SERVER_* variables are incomplete in $ENV_FILE; /etc/activitywatch/aw-server.env was not updated" >&2
fi
echo "Bootstrap artifacts pushed to CT $CT_ID:/root/bootstrap"
+1
View File
@@ -0,0 +1 @@
deploy.secrets.env
+31
View File
@@ -0,0 +1,31 @@
# Copy to deploy.secrets.env and fill real values.
PVE_NODE=pve-example
CT_ID=203
CT_HOSTNAME=activitywatch-server
CT_STORAGE=local-lvm
CT_TEMPLATE=local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst
CT_ROOTFS_SIZE=8G
CT_CORES=2
CT_MEMORY=2048
CT_SWAP=512
CT_BRIDGE=vmbr10
CT_IP=10.20.30.13/24
CT_GW=10.20.30.1
CT_VLAN=
CT_NAMESERVER=1.1.1.1 8.8.8.8
CT_SEARCHDOMAIN=example.internal
CT_PASSWORD=CHANGE_ME
CT_UNPRIVILEGED=1
CT_ONBOOT=1
CT_FEATURES=nesting=1,keyctl=1
AW_SERVER_VERSION=0.13.2
AW_SERVER_DOWNLOAD_URL=https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-linux-x86_64.zip
AW_SERVER_BIND_HOST=0.0.0.0
AW_SERVER_PORT=5600
AW_SERVER_WEBUI_DIR=/opt/activitywatch/webui-ru
AW_SERVER_DATA_DIR=/var/lib/activitywatch
AW_SERVER_LOG_DIR=/var/log/activitywatch
AW_SERVER_USER=activitywatch
AW_SERVER_GROUP=activitywatch
+597
View File
@@ -0,0 +1,597 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
}
function New-ActivityWatchDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
}
function Get-ActivityWatchPackageUrl {
param(
[string]$Version = 'v0.13.2'
)
return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip"
}
function Get-ActivityWatchArchive {
param(
[string]$PackageZipPath,
[string]$PackageUrl,
[string]$Version = 'v0.13.2',
[Parameter(Mandatory = $true)]
[string]$WorkingRoot
)
New-ActivityWatchDirectory -Path $WorkingRoot
if ($PackageZipPath) {
$resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop
return $resolved.Path
}
if (-not $PackageUrl) {
$PackageUrl = Get-ActivityWatchPackageUrl -Version $Version
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}.zip" -f $Version.TrimStart('v'))
Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath
return $archivePath
}
function Get-ActivityWatchPackageRoot {
param(
[Parameter(Mandatory = $true)]
[string]$ExpandedRoot
)
$afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse |
Select-Object -First 1
if (-not $afkBinary) {
throw "Cannot find aw-watcher-afk.exe under $ExpandedRoot."
}
return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent)
}
function Install-ActivityWatchPackage {
param(
[Parameter(Mandatory = $true)]
[string]$ArchivePath,
[Parameter(Mandatory = $true)]
[string]$InstallRoot,
[Parameter(Mandatory = $true)]
[string]$WorkingRoot,
[Parameter(Mandatory = $true)]
[string]$BackupRoot
)
New-ActivityWatchDirectory -Path $WorkingRoot
New-ActivityWatchDirectory -Path $BackupRoot
$extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid)
if (Test-Path -LiteralPath $extractRoot) {
Remove-Item -LiteralPath $extractRoot -Recurse -Force
}
New-ActivityWatchDirectory -Path $extractRoot
Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force
$packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot
if (Test-Path -LiteralPath $InstallRoot) {
$existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue
if ($existingItems) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$backupPath = Join-Path $BackupRoot ("install-$stamp")
New-ActivityWatchDirectory -Path $backupPath
Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force
Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force
}
}
else {
New-ActivityWatchDirectory -Path $InstallRoot
}
Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force
return [pscustomobject]@{
PackageRoot = $packageRoot
ExtractRoot = $extractRoot
BackupRoot = $BackupRoot
}
}
function Get-ActivityWatchExecutableMap {
param(
[Parameter(Mandatory = $true)]
[string]$InstallRoot
)
$map = [ordered]@{
Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe'
Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe'
}
foreach ($entry in $map.GetEnumerator()) {
if (-not (Test-Path -LiteralPath $entry.Value)) {
throw "Missing required ActivityWatch binary: $($entry.Value)"
}
}
return [pscustomobject]$map
}
function Normalize-ActivityWatchUsers {
param(
[string[]]$Users,
[string]$UserListPath,
[string]$Domain
)
$collected = New-Object System.Collections.Generic.List[string]
if ($Users) {
foreach ($user in $Users) {
if (-not [string]::IsNullOrWhiteSpace($user)) {
$collected.Add($user.Trim())
}
}
}
if ($UserListPath) {
$resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop
$extension = [IO.Path]::GetExtension($resolved.Path)
if ($extension -ieq '.csv') {
$rows = Import-Csv -LiteralPath $resolved.Path
foreach ($row in $rows) {
foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') {
if ($row.PSObject.Properties.Name -contains $column) {
$value = [string]$row.$column
if (-not [string]::IsNullOrWhiteSpace($value)) {
$collected.Add($value.Trim())
break
}
}
}
}
}
else {
Get-Content -LiteralPath $resolved.Path | ForEach-Object {
$line = $_.Trim()
if ($line -and -not $line.StartsWith('#')) {
$collected.Add($line)
}
}
}
}
$normalized = $collected |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object {
if ($Domain -and ($_ -notmatch '[\\@]')) {
'{0}\{1}' -f $Domain, $_
}
else {
$_
}
} |
Sort-Object -Unique
if (-not $normalized -or $normalized.Count -eq 0) {
throw 'No target users resolved. Provide -Users or -UserListPath.'
}
return @($normalized)
}
function Get-ActivityWatchTaskNameToken {
param(
[Parameter(Mandatory = $true)]
[string]$UserId
)
$buffer = [Text.StringBuilder]::new()
foreach ($character in $UserId.ToCharArray()) {
if ([char]::IsLetterOrDigit($character)) {
[void]$buffer.Append($character)
}
else {
[void]$buffer.Append('_')
}
}
return $buffer.ToString().Trim('_')
}
function New-ActivityWatchUserTaskDefinitions {
param(
[Parameter(Mandatory = $true)]
[string[]]$Users
)
$result = foreach ($user in $Users) {
$token = Get-ActivityWatchTaskNameToken -UserId $user
[pscustomobject]@{
UserId = $user
LaunchTaskName = "ActivityWatch Launch [$token]"
}
}
return @($result)
}
function Copy-ActivityWatchCollectorAssets {
param(
[Parameter(Mandatory = $true)]
[string]$CollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$ExampleRulesSource,
[Parameter(Mandatory = $true)]
[string]$StateRoot,
[string]$CustomRulesSource
)
New-ActivityWatchDirectory -Path $StateRoot
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json'
$rulesTarget = Join-Path $StateRoot 'web-category-rules.json'
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
if ($CustomRulesSource) {
$resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop
Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force
}
return [pscustomobject]@{
CollectorScript = $collectorTarget
ExampleRules = $exampleRulesTarget
ActiveRules = $rulesTarget
}
}
function New-ActivityWatchDeploymentConfig {
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
[Parameter(Mandatory = $true)]
[int]$ServerPort,
[Parameter(Mandatory = $true)]
[string]$ServerScheme,
[Parameter(Mandatory = $true)]
[string]$InstallRoot,
[Parameter(Mandatory = $true)]
[string]$StateRoot,
[Parameter(Mandatory = $true)]
[string]$LogsRoot,
[Parameter(Mandatory = $true)]
[string]$CollectorScript,
[Parameter(Mandatory = $true)]
[string]$RulesPath,
[Parameter(Mandatory = $true)]
[int]$PollSeconds,
[Parameter(Mandatory = $true)]
[int]$PulseSeconds,
[Parameter(Mandatory = $true)]
[int]$RecoveryIntervalSeconds,
[Parameter(Mandatory = $true)]
[string]$LaunchScriptPath,
[Parameter(Mandatory = $true)]
[string]$RecoveryScriptPath,
[Parameter(Mandatory = $true)]
[pscustomobject[]]$UserTasks,
[string]$PackageVersion = 'v0.13.2'
)
return [pscustomobject]@{
version = 1
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
server = [pscustomobject]@{
host = $ServerHost
port = $ServerPort
scheme = $ServerScheme
}
paths = [pscustomobject]@{
installRoot = $InstallRoot
stateRoot = $StateRoot
logsRoot = $LogsRoot
collectorScript = $CollectorScript
rulesPath = $RulesPath
launchScript = $LaunchScriptPath
recoveryScript = $RecoveryScriptPath
}
collector = [pscustomobject]@{
pollSeconds = $PollSeconds
pulseSeconds = $PulseSeconds
}
recovery = [pscustomobject]@{
intervalSeconds = $RecoveryIntervalSeconds
taskName = 'ActivityWatch Recovery'
}
package = [pscustomobject]@{
version = $PackageVersion
}
userTasks = @($UserTasks)
}
}
function Write-ActivityWatchDeploymentConfig {
param(
[Parameter(Mandatory = $true)]
[pscustomobject]$Config,
[Parameter(Mandatory = $true)]
[string]$Path
)
$directory = Split-Path -Path $Path -Parent
if ($directory) {
New-ActivityWatchDirectory -Path $directory
}
$json = $Config | ConvertTo-Json -Depth 8
Set-Content -LiteralPath $Path -Value $json -Encoding UTF8
}
function Read-ActivityWatchDeploymentConfig {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Deployment config not found: $Path"
}
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Write-ActivityWatchLaunchScript {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
$content = @"
param(
[string]`$ConfigPath = '$ConfigPath'
)
Set-StrictMode -Version Latest
`$ErrorActionPreference = 'Stop'
function Get-DeploymentConfig {
param([string]`$Path)
return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json
}
function Test-ProcessInSession {
param(
[string]`$Name,
[int]`$SessionId
)
return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1)
}
function Test-CollectorRunning {
param(
[string]`$CollectorScript,
[int]`$SessionId
)
`$escapedCollector = [Regex]::Escape(`$CollectorScript)
`$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
(`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and
`$_.SessionId -eq `$SessionId -and
`$_.CommandLine -match `$escapedCollector
}
return [bool](`$processes | Select-Object -First 1)
}
`$config = Get-DeploymentConfig -Path `$ConfigPath
`$sessionId = (Get-Process -Id `$PID).SessionId
`$installRoot = [string]`$config.paths.installRoot
`$collectorScript = [string]`$config.paths.collectorScript
`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe'
`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe'
`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port)
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
if (-not (Test-Path -LiteralPath `$afkExe)) {
throw "Missing aw-watcher-afk.exe: `$afkExe"
}
if (-not (Test-Path -LiteralPath `$windowExe)) {
throw "Missing aw-watcher-window.exe: `$windowExe"
}
if (-not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) {
Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden
}
if (-not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) {
Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden
}
if ((Test-Path -LiteralPath `$collectorScript) -and -not (Test-CollectorRunning -CollectorScript `$collectorScript -SessionId `$sessionId)) {
Start-Process -FilePath `$powershellExe -ArgumentList @(
'-NoProfile',
'-WindowStyle', 'Hidden',
'-ExecutionPolicy', 'Bypass',
'-File', `$collectorScript,
'-ConfigPath', `$ConfigPath
) -WindowStyle Hidden
}
"@
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
}
function Write-ActivityWatchRecoveryScript {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
$content = @"
param(
[string]`$ConfigPath = '$ConfigPath'
)
Set-StrictMode -Version Latest
`$ErrorActionPreference = 'Continue'
function Get-DeploymentConfig {
param([string]`$Path)
return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json
}
while (`$true) {
try {
`$config = Get-DeploymentConfig -Path `$ConfigPath
foreach (`$task in @(`$config.userTasks)) {
Start-ScheduledTask -TaskName ([string]`$task.launchTaskName) -ErrorAction SilentlyContinue
}
}
catch {
}
`$config = Get-DeploymentConfig -Path `$ConfigPath
Start-Sleep -Seconds ([int]`$config.recovery.intervalSeconds)
}
"@
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
}
function Remove-LegacyActivityWatchEntries {
$legacyTaskNames = @(
'ActivityWatch Watchers',
'ActivityWatch Guard',
'ActivityWatch Heal'
)
foreach ($taskName in $legacyTaskNames) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
$runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run'
foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') {
Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue
}
}
function Register-ActivityWatchUserTasks {
param(
[Parameter(Mandatory = $true)]
[pscustomobject[]]$TaskDefinitions,
[Parameter(Mandatory = $true)]
[string]$LaunchScriptPath,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
$powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
foreach ($definition in $TaskDefinitions) {
Unregister-ScheduledTask -TaskName $definition.LaunchTaskName -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute $powershellExe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$LaunchScriptPath`" -ConfigPath `"$ConfigPath`""
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId
$principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType InteractiveToken -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0)
Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
}
}
function Register-ActivityWatchRecoveryTask {
param(
[Parameter(Mandatory = $true)]
[string]$TaskName,
[Parameter(Mandatory = $true)]
[string]$RecoveryScriptPath,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
$powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$action = New-ScheduledTaskAction -Execute $powershellExe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$RecoveryScriptPath`" -ConfigPath `"$ConfigPath`""
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
}
function Set-ActivityWatchAcl {
param(
[Parameter(Mandatory = $true)]
[string]$InstallRoot,
[Parameter(Mandatory = $true)]
[string]$StateRoot,
[Parameter(Mandatory = $true)]
[string]$LogsRoot
)
foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) {
New-ActivityWatchDirectory -Path $path
}
& icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $InstallRoot"
}
& icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $StateRoot"
}
& icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $LogsRoot"
}
}
function Start-ActivityWatchTasks {
param(
[Parameter(Mandatory = $true)]
[pscustomobject[]]$TaskDefinitions,
[string]$RecoveryTaskName = 'ActivityWatch Recovery'
)
foreach ($definition in $TaskDefinitions) {
Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue
}
Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue
}
Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries
+434
View File
@@ -0,0 +1,434 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
[string]$ServerScheme,
[string]$RulesPath,
[string]$LogPath,
[int]$PollSeconds,
[int]$PulseSeconds
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class NativeAwMethods {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")]
public static extern int GetWindowTextLength(IntPtr hWnd);
}
"@
function Get-DeploymentConfig {
param([string]$Path)
if ($Path -and (Test-Path -LiteralPath $Path)) {
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
return $null
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\ActivityWatch\web-category-rules.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) }
if (-not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:LogPath = $resolvedLogPath
$script:BrowserMap = @{
msedge = 'edge'
chrome = 'chrome'
brave = 'brave'
vivaldi = 'vivaldi'
opera = 'opera'
firefox = 'firefox'
}
$script:CategoryRules = @(
@{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') }
@{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') }
@{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') }
@{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') }
@{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') }
@{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') }
@{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') }
@{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') }
@{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') }
@{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') }
)
function Write-CollectorLog {
param([string]$Message)
try {
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
}
catch {
}
}
function Test-DomainMatch {
param(
[string]$Host,
[string]$RuleDomain
)
if ([string]::IsNullOrWhiteSpace($Host) -or [string]::IsNullOrWhiteSpace($RuleDomain)) {
return $false
}
$left = $Host.ToLowerInvariant()
$right = $RuleDomain.ToLowerInvariant()
return $left -eq $right -or $left.EndsWith('.' + $right)
}
function Get-HostFromUrl {
param([string]$Url)
if ([string]::IsNullOrWhiteSpace($Url)) {
return $null
}
try {
$uri = [Uri]$Url
$host = $uri.Host.ToLowerInvariant()
if ($host.StartsWith('www.')) {
return $host.Substring(4)
}
return $host
}
catch {
return $null
}
}
function Get-RootDomain {
param([string]$Host)
if ([string]::IsNullOrWhiteSpace($Host)) {
return $null
}
$parts = $Host.Split('.')
if ($parts.Count -le 2) {
return $Host
}
$suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant()
$compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn')
if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) {
return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant()
}
return $suffix
}
function ConvertTo-NormalizedUrl {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return $null
}
$candidate = $Value.Trim()
if ($candidate.Length -lt 4) {
return $null
}
if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') {
return $null
}
if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') {
return $candidate
}
if ($candidate -match '^(?i)localhost([/:]|$)') {
return "http://$candidate"
}
if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') {
return "https://$candidate"
}
return $null
}
function Load-CustomCategoryRules {
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
return
}
try {
$parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$rules = @()
if ($parsed.rules) {
$sourceRules = @($parsed.rules)
}
elseif ($parsed -is [System.Collections.IEnumerable]) {
$sourceRules = @($parsed)
}
else {
$sourceRules = @()
}
foreach ($rule in $sourceRules) {
if (-not $rule) {
continue
}
$name = [string]$rule.name
$group = [string]$rule.group
$domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
if ($name -and $group -and $domains.Count -gt 0) {
$rules += @{
Name = $name
Group = $group
Domains = $domains
}
}
}
if ($rules.Count -gt 0) {
$script:CategoryRules = @($rules) + @($script:CategoryRules)
Write-CollectorLog ("custom rules loaded: {0}" -f $rules.Count)
}
}
catch {
Write-CollectorLog ("custom rules load failed: {0}" -f $_.Exception.Message)
}
}
function Get-WebCategory {
param([string]$Host)
foreach ($rule in $script:CategoryRules) {
foreach ($domain in $rule.Domains) {
if (Test-DomainMatch -Host $Host -RuleDomain $domain) {
return [pscustomobject]@{
Name = [string]$rule.Name
Group = [string]$rule.Group
Rule = [string]$domain
}
}
}
}
return [pscustomobject]@{
Name = 'uncategorized'
Group = 'neutral'
Rule = 'none'
}
}
function Get-ForegroundWindowContext {
$handle = [NativeAwMethods]::GetForegroundWindow()
if ($handle -eq [IntPtr]::Zero) {
return $null
}
$processId = [uint32]0
[void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId)
if (-not $processId) {
return $null
}
$process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue
if (-not $process) {
return $null
}
$textLength = [NativeAwMethods]::GetWindowTextLength($handle)
$builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260))
[void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity)
return [pscustomobject]@{
Handle = $handle
ProcessName = $process.ProcessName.ToLowerInvariant()
Title = $builder.ToString()
}
}
function Get-BrowserUrlFromWindow {
param([IntPtr]$Handle)
$root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle)
if (-not $root) {
return $null
}
$editCondition = [System.Windows.Automation.PropertyCondition]::new(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Edit
)
$edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition)
foreach ($edit in $edits) {
$valuePattern = $null
if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) {
$candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value
if ($candidate) {
return $candidate
}
}
$candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name
if ($candidateFromName) {
return $candidateFromName
}
}
return $null
}
function Ensure-Bucket {
param(
[string]$BucketId,
[string]$ClientName,
[string]$BucketType = 'web.tab.current'
)
if ($script:KnownBuckets.ContainsKey($BucketId)) {
return
}
$body = @{
client = $ClientName
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json' -Body $body | Out-Null
$script:KnownBuckets[$BucketId] = $true
}
function Send-Heartbeat {
param(
[string]$BucketId,
[string]$Url,
[string]$Title,
[string]$BrowserKey,
[string]$ProcessName
)
$event = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
url = $Url
title = $Title
browser = $BrowserKey
app = "$ProcessName.exe"
source = 'uia-native'
sessionId = $script:SessionId
}
} | ConvertTo-Json -Depth 4 -Compress
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
}
function Send-CategoryHeartbeat {
param(
[string]$Url,
[string]$Title,
[string]$BrowserKey,
[string]$ProcessName,
[string]$Domain,
[string]$RootDomain,
[string]$Category,
[string]$CategoryGroup,
[string]$CategoryRule
)
$bucketId = 'aw-watcher-web-category_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-watcher-web-category' -BucketType 'aw.web.category'
$event = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
url = $Url
title = $Title
browser = $BrowserKey
app = "$ProcessName.exe"
domain = $Domain
rootDomain = $RootDomain
category = $Category
categoryGroup = $CategoryGroup
categoryRule = $CategoryRule
source = 'uia-native'
sessionId = $script:SessionId
}
} | ConvertTo-Json -Depth 4 -Compress
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
}
Load-CustomCategoryRules -Path $resolvedRulesPath
Write-CollectorLog ("collector started against {0}" -f $script:ApiBase)
while ($true) {
try {
$context = Get-ForegroundWindowContext
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
if ($url) {
$browserKey = $script:BrowserMap[$context.ProcessName]
$domain = Get-HostFromUrl -Url $url
if (-not $domain) {
$domain = 'unknown'
}
$rootDomain = Get-RootDomain -Host $domain
if (-not $rootDomain) {
$rootDomain = $domain
}
$category = Get-WebCategory -Host $domain
$bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey)
Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName
Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule
}
}
}
catch {
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
+80
View File
@@ -0,0 +1,80 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
[string[]]$Users,
[string]$UserListPath,
[string]$Domain,
[int]$ServerPort = 5600,
[ValidateSet('http', 'https')]
[string]$ServerScheme = 'http',
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
[string]$CustomRulesPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
$targetUsers = Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
$backupRoot = Join-Path $StateRoot 'backups'
$logsRoot = Join-Path $StateRoot 'logs'
$configPath = Join-Path $StateRoot 'deployment-config.json'
$launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
New-ActivityWatchDirectory -Path $StateRoot
New-ActivityWatchDirectory -Path $logsRoot
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets -CollectorScriptSource $collectorSource -ExampleRulesSource $exampleRulesSource -StateRoot $StateRoot -CustomRulesSource $CustomRulesPath
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $targetUsers
Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $configPath
Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $configPath
$config = New-ActivityWatchDeploymentConfig `
-ServerHost $ServerHost `
-ServerPort $ServerPort `
-ServerScheme $ServerScheme `
-InstallRoot $InstallRoot `
-StateRoot $StateRoot `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-RulesPath $assetResult.ActiveRules `
-PollSeconds $PollSeconds `
-PulseSeconds $PulseSeconds `
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions `
-PackageVersion $Version
Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath
Remove-LegacyActivityWatchEntries
Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host 'ActivityWatch deployed for users:'
$targetUsers | ForEach-Object { Write-Host " - $_" }
Write-Host "Server: $ServerScheme://$ServerHost`:$ServerPort"
Write-Host "State root: $StateRoot"
+79
View File
@@ -0,0 +1,79 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
[Parameter(Mandatory = $true)]
[string]$TargetUser,
[int]$ServerPort = 5600,
[ValidateSet('http', 'https')]
[string]$ServerScheme = 'http',
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
[string]$CustomRulesPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
$backupRoot = Join-Path $StateRoot 'backups'
$logsRoot = Join-Path $StateRoot 'logs'
$configPath = Join-Path $StateRoot 'deployment-config.json'
$launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
New-ActivityWatchDirectory -Path $StateRoot
New-ActivityWatchDirectory -Path $logsRoot
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets -CollectorScriptSource $collectorSource -ExampleRulesSource $exampleRulesSource -StateRoot $StateRoot -CustomRulesSource $CustomRulesPath
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users @($TargetUser)
Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $configPath
Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $configPath
$config = New-ActivityWatchDeploymentConfig `
-ServerHost $ServerHost `
-ServerPort $ServerPort `
-ServerScheme $ServerScheme `
-InstallRoot $InstallRoot `
-StateRoot $StateRoot `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-RulesPath $assetResult.ActiveRules `
-PollSeconds $PollSeconds `
-PulseSeconds $PulseSeconds `
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions `
-PackageVersion $Version
Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath
Remove-LegacyActivityWatchEntries
Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host "ActivityWatch deployed for $TargetUser"
Write-Host "Server: $ServerScheme://$ServerHost`:$ServerPort"
Write-Host "Install root: $InstallRoot"
Write-Host "State root: $StateRoot"
Write-Host "Rules file: $($assetResult.ActiveRules)"
+115
View File
@@ -0,0 +1,115 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
[string]$ServerScheme,
[string[]]$Users,
[string]$UserListPath,
[string]$Domain,
[string]$InstallRoot,
[string]$StateRoot,
[int]$PollSeconds,
[int]$PulseSeconds,
[int]$RecoveryIntervalSeconds,
[string]$CustomRulesPath,
[switch]$RepairPackage,
[string]$Version,
[string]$PackageUrl,
[string]$PackageZipPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
$existingConfig = $null
if (Test-Path -LiteralPath $ConfigPath) {
$existingConfig = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
}
if (-not $existingConfig -and (-not $ServerHost)) {
throw 'deployment-config.json is missing. Provide -ServerHost and user parameters, or run a deploy script first.'
}
$effectiveStateRoot = if ($StateRoot) { $StateRoot } elseif ($existingConfig) { [string]$existingConfig.paths.stateRoot } else { 'C:\ProgramData\ActivityWatch' }
$effectiveInstallRoot = if ($InstallRoot) { $InstallRoot } elseif ($existingConfig) { [string]$existingConfig.paths.installRoot } else { 'C:\Program Files\ActivityWatch' }
$effectiveLogsRoot = if ($existingConfig) { [string]$existingConfig.paths.logsRoot } else { Join-Path $effectiveStateRoot 'logs' }
$effectiveConfigPath = if ($ConfigPath) { $ConfigPath } else { Join-Path $effectiveStateRoot 'deployment-config.json' }
$effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1'
$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1'
$effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json'
$effectiveServerHost = if ($ServerHost) { $ServerHost } elseif ($existingConfig) { [string]$existingConfig.server.host } else { $null }
$effectiveServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($existingConfig) { [int]$existingConfig.server.port } else { 5600 }
$effectiveServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($existingConfig) { [string]$existingConfig.server.scheme } else { 'http' }
$effectivePollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($existingConfig) { [int]$existingConfig.collector.pollSeconds } else { 5 }
$effectivePulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($existingConfig) { [int]$existingConfig.collector.pulseSeconds } else { 30 }
$effectiveRecoveryInterval = if ($PSBoundParameters.ContainsKey('RecoveryIntervalSeconds')) { $RecoveryIntervalSeconds } elseif ($existingConfig) { [int]$existingConfig.recovery.intervalSeconds } else { 180 }
$effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' }
$effectiveUsers = if ($Users -or $UserListPath) {
Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
}
elseif ($existingConfig) {
@($existingConfig.userTasks | ForEach-Object { [string]$_.userId })
}
else {
throw 'Target users are missing.'
}
New-ActivityWatchDirectory -Path $effectiveStateRoot
New-ActivityWatchDirectory -Path $effectiveLogsRoot
if ($RepairPackage) {
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
$backupRoot = Join-Path $effectiveStateRoot 'backups'
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $effectiveVersion -WorkingRoot $workingRoot
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $effectiveInstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
}
Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') `
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
-StateRoot $effectiveStateRoot `
-CustomRulesSource $CustomRulesPath
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $effectiveUsers
Write-ActivityWatchLaunchScript -Path $effectiveLaunchScript -ConfigPath $effectiveConfigPath
Write-ActivityWatchRecoveryScript -Path $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
$config = New-ActivityWatchDeploymentConfig `
-ServerHost $effectiveServerHost `
-ServerPort $effectiveServerPort `
-ServerScheme $effectiveServerScheme `
-InstallRoot $effectiveInstallRoot `
-StateRoot $effectiveStateRoot `
-LogsRoot $effectiveLogsRoot `
-CollectorScript $effectiveCollector `
-RulesPath $effectiveRules `
-PollSeconds $effectivePollSeconds `
-PulseSeconds $effectivePulseSeconds `
-RecoveryIntervalSeconds $effectiveRecoveryInterval `
-LaunchScriptPath $effectiveLaunchScript `
-RecoveryScriptPath $effectiveRecoveryScript `
-UserTasks $taskDefinitions `
-PackageVersion $effectiveVersion
Write-ActivityWatchDeploymentConfig -Config $config -Path $effectiveConfigPath
Remove-LegacyActivityWatchEntries
Set-ActivityWatchAcl -InstallRoot $effectiveInstallRoot -StateRoot $effectiveStateRoot -LogsRoot $effectiveLogsRoot
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $effectiveLaunchScript -ConfigPath $effectiveConfigPath
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host 'ActivityWatch hardening/recovery completed.'
Write-Host "Config: $effectiveConfigPath"
Write-Host "Users repaired: $($effectiveUsers -join ', ')"
+37
View File
@@ -0,0 +1,37 @@
{
"version": 1,
"description": "Override or extend built-in ActivityWatch web categorization rules.",
"rules": [
{
"name": "work_crm",
"group": "work",
"domains": [
"crm.example.com",
"portal.example.org"
]
},
{
"name": "work_erp",
"group": "work",
"domains": [
"erp.example.com",
"bi.example.com"
]
},
{
"name": "neutral_training",
"group": "neutral",
"domains": [
"wiki.example.net",
"kb.example.net"
]
},
{
"name": "personal_social",
"group": "personal",
"domains": [
"social.example.net"
]
}
]
}