32 Commits
Author SHA1 Message Date
mirivlad 06eff98538 Документация: удалены superpowers-планы, добавлен README.ru.md с шапкой 2026-07-18 18:06:44 +08:00
mirivlad a8c58ff584 fix: update browser extension brand icon 2026-07-15 19:17:18 +08:00
mirivlad 7ff31dd308 docs: add historical release notes 2026-07-15 07:50:17 +08:00
mirivlad ac268e5c57 docs: require human release notes 2026-07-15 07:42:02 +08:00
mirivlad f00deb3d2a release: prepare browser extension v2.0.5 2026-07-15 02:57:11 +08:00
mirivlad 8597e3b12f fix: remove unsupported Firefox permission 2026-07-15 02:53:49 +08:00
mirivlad af629e9864 release: prepare browser extension v2.0.4 2026-07-15 00:01:15 +08:00
mirivlad d6c6ca2cc5 fix: localize popup operation errors 2026-07-14 22:00:24 +08:00
mirivlad 9a96883c87 fix: style extension language selector 2026-07-14 08:00:45 +08:00
mirivlad a80a2b55e6 docs: clarify browser release tagging 2026-07-13 05:03:14 +08:00
mirivlad 4dd87dc8cc build: standardize browser GitHub releases 2026-07-13 05:02:04 +08:00
mirivlad 057b63a946 docs: point Chromium extension to GitHub 2026-07-13 03:58:15 +08:00
mirivlad 39e93e0987 chore: prepare Firefox release v2.0.3 2026-07-12 22:33:47 +08:00
mirivlad 8315d49a58 feat: publish Firefox releases to GitHub 2026-07-12 22:30:04 +08:00
mirivlad 7a278a162a feat: host Firefox updates on GitHub Releases 2026-07-12 22:28:37 +08:00
mirivlad a5751aaace test: cover Firefox GitHub release metadata 2026-07-12 22:27:45 +08:00
mirivlad 8f5438fffd docs: plan GitHub Firefox update releases 2026-07-12 22:25:16 +08:00
mirivlad bfc729affd docs: add Firefox release bootstrap requirement 2026-07-12 22:22:36 +08:00
mirivlad 0c15fe259d docs: design GitHub Firefox update releases 2026-07-12 22:18:35 +08:00
mirivlad f39550830e build: declare browser extension AGPL license 2026-07-12 21:45:44 +08:00
mirivlad 9ea4d68896 docs: prepare browser extension alpha release 2026-07-12 21:23:01 +08:00
mirivlad 7461790934 feat: add opt-in browser domain activity tracking 2026-07-12 17:37:25 +08:00
mirivlad e67a7e4ff7 feat: normalize browser hostnames consistently 2026-07-12 17:15:35 +08:00
mirivlad d8bd1d1828 fix: select Firefox artifact by release version 2026-07-11 22:33:08 +08:00
mirivlad 619ff97b61 chore: prepare Firefox release 2.0.2 2026-07-11 22:32:03 +08:00
mirivlad af44ce2fa2 fix: preserve popup copy when catalogs fail 2026-07-11 22:24:32 +08:00
mirivlad 604bb459c8 build: package browser extension locale catalogs 2026-07-11 22:22:20 +08:00
mirivlad cae140a348 feat: add browser extension language selector 2026-07-11 22:20:05 +08:00
mirivlad 766458d0a0 feat: localize browser extension context menus 2026-07-11 22:17:19 +08:00
mirivlad 18bff9f352 feat: add browser extension localization runtime 2026-07-11 22:15:26 +08:00
mirivlad fb4b41d367 docs: plan browser extension localization 2026-07-11 22:08:20 +08:00
mirivlad 00831d3bf6 docs: design browser extension localization 2026-07-11 22:03:30 +08:00
45 changed files with 2179 additions and 129 deletions
+17
View File
@@ -0,0 +1,17 @@
Verstak Browser Extension
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later
+92 -5
View File
@@ -1,4 +1,18 @@
# verstak-browser-extension <div align="center">
# Verstak Browser Extension
### Send pages, selections, links and files from the browser into Verstak.
**English** · [Русский](README.ru.md)
[![Release](https://img.shields.io/github/v/release/mirivlad/verstak-browser-extension?include_prereleases\&label=release)](https://github.com/mirivlad/verstak-browser-extension/releases)
![Status](https://img.shields.io/badge/status-alpha-orange)
[![License](https://img.shields.io/github/license/mirivlad/verstak-browser-extension)](LICENSE)
</div>
> **Alpha software.** Use with a matching Verstak Desktop alpha release.
Verstak Browser Extension captures pages, selected text, links, and selected Verstak Browser Extension captures pages, selected text, links, and selected
files and sends them to a local Verstak browser inbox receiver. files and sends them to a local Verstak browser inbox receiver.
@@ -20,14 +34,62 @@ Build output:
- `dist/chromium` - `dist/chromium`
- `dist/firefox` - `dist/firefox`
## Firefox Release Load `dist/chromium` as an unpacked extension in Chromium-based browsers, or
load `dist/firefox` temporarily in Firefox during development.
## Passive domain activity
Passive tracking is **off by default**. On first use the extension explains
what it records; the user must explicitly enable it in extension settings.
When enabled, it observes only the focused browser's active tab and sends
bounded aggregate intervals as a canonical domain name plus duration. It never
sends URL paths, page titles, page text, selected text, keystrokes, navigation
history or inactive-tab time. The settings screen has an exclusion list for
domains such as `youtube.com` or `x.com`.
Manual “Send page”, selection, link and file actions are separate. They create
Browser Inbox captures; they do not create a workspace or a journal entry.
## Firefox download and updates
The signed XPI is published on the [GitHub Releases page](https://github.com/mirivlad/verstak-browser-extension/releases).
Download the Firefox asset named `verstak-firefox-<version>.xpi` from the latest
release and open it in Firefox to install it.
After the first public release, installed copies check GitHub Releases for
updates through the release's `updates.json` asset. During the alpha phase each
published release is the current update channel.
## Firefox release publishing
Firefox signing uses `web-ext` and AMO credentials from an env file. The script Firefox signing uses `web-ext` and AMO credentials from an env file. The script
requires `WEB_EXT_API_PROXY`; AMO upload and approval polling run through that requires `WEB_EXT_API_PROXY`; AMO upload and approval polling run through that
proxy. proxy.
Create the signed XPI locally, without publishing it:
```bash ```bash
VERSTAK_BROWSER_ENV=/home/mirivlad/git/verstak/.env npm run release:firefox VERSTAK_BROWSER_ENV=/path/to/.env npm run release:firefox
```
Publish the signed XPI and `updates.json` as the current GitHub Release:
```bash
VERSTAK_BROWSER_ENV=/path/to/.env npm run publish:github
```
The publisher reads the version from `package.json` and requires an
authenticated GitHub CLI plus a clean local `main` equal to `origin/main`. It
creates and pushes the version tag if needed, then creates or updates the
GitHub Release. Re-running it for the same tag replaces the XPI and
`updates.json` assets. `npm run publish:firefox` remains as a compatible alias
for the same Firefox publishing flow.
For an explicit version check, pass the current tag after `--`:
```bash
VERSTAK_BROWSER_ENV=/path/to/.env npm run publish:github -- v2.0.6
``` ```
Release output: Release output:
@@ -35,8 +97,19 @@ Release output:
- `release/firefox/verstak-firefox-<version>.xpi` - `release/firefox/verstak-firefox-<version>.xpi`
- `release/firefox/updates.json` - `release/firefox/updates.json`
The XPI is signed as an unlisted/self-distributed Firefox extension. Build and The XPI is signed as an unlisted/self-distributed Firefox extension; GitHub
release artifacts are local outputs and are not committed. Releases distribute that signed file. Build and release artifacts are local
outputs and are not committed.
## Reproducible local package
```bash
npm run release:package -- v0.1.0-alpha.1
```
This runs the tests and build, then writes unsigned Chromium and Firefox source
packages to `release/` with a `SHA256SUMS` file. Use `release:firefox` above
when an AMO-signed XPI is required.
## Manual Check ## Manual Check
@@ -67,6 +140,15 @@ Headers:
Rotating the token in Desktop invalidates the value stored by the extension. Rotating the token in Desktop invalidates the value stored by the extension.
## Language
The popup settings provide a persisted `System / English / Russian` language
selector. `System` follows the browser UI language: Russian browser locales use
Russian, and all other locales use English.
The extension and desktop application store their language choices
independently. Changing one does not change the other.
Payload: Payload:
```json ```json
@@ -100,3 +182,8 @@ Expected success response:
```json ```json
{ "status": "accepted", "captureId": "uuid-or-generated-id" } { "status": "accepted", "captureId": "uuid-or-generated-id" }
``` ```
## License
Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).
+107
View File
@@ -0,0 +1,107 @@
<div align="center">
# Расширение Верстака для браузера
### Отправляйте страницы, выделенный текст, ссылки и файлы из браузера в Верстак.
[English](README.md) · **Русский**
[![Релиз](https://img.shields.io/github/v/release/mirivlad/verstak-browser-extension?include_prereleases\&label=release)](https://github.com/mirivlad/verstak-browser-extension/releases)
![Статус](https://img.shields.io/badge/status-alpha-orange)
[![Лицензия](https://img.shields.io/github/license/mirivlad/verstak-browser-extension)](LICENSE)
</div>
> **Alpha-версия.** Используйте с совместимой alpha-сборкой Verstak Desktop.
Расширение Верстака захватывает страницы, выделенный текст, ссылки и выбранные
файлы и отправляет их в локальный приёмник «Входящие из браузера».
Расширение не знает внутреннего устройства Заметок, Файлов, Активности или
Журнала. Оно только отправляет события захвата через публичный протокол
локального приёмника. Если приёмник недоступен, захваты остаются в очереди
расширения.
## Сборка
```bash
npm ci
npm test
npm run build
```
Результат сборки:
- `dist/chromium`
- `dist/firefox`
Загрузите `dist/chromium` как распакованное расширение в браузеры на базе
Chromium или временно загрузите `dist/firefox` в Firefox при разработке.
## Пассивный учёт активности по доменам
Пассивный учёт **выключен по умолчанию**. При первом использовании расширение
объясняет, что оно записывает; пользователь должен явно включить его в
настройках.
Когда учёт включён, расширение наблюдает только за активной вкладкой и
отправляет ограниченные интервалы как каноническое доменное имя плюс
длительность. Оно никогда не отправляет URL-пути, заголовки страниц, текст
страниц, выделенный текст, нажатия клавиш, историю посещений или время
неактивных вкладок.
Ручные действия «Отправить страницу», «Отправить выделенный текст», «Отправить
ссылку» и «Отправить файл» отделены от пассивного учёта. Они создают записи во
«Входящих из браузера», а не дела или записи журнала.
## Загрузка и обновления в Firefox
Подписанный XPI публикуется на странице
[GitHub Releases](https://github.com/mirivlad/verstak-browser-extension/releases).
Скачайте Firefox-ассет `verstak-firefox-<версия>.xpi` из последнего релиза и
откройте его в Firefox для установки.
После первого публичного релиза установленные копии проверяют GitHub Releases
на наличие обновлений через ассет `updates.json` релиза.
## Сопряжение
1. В Verstak Desktop откройте настройки «Входящих из браузера».
2. Скопируйте адрес приёмника и токен сопряжения.
3. Вставьте оба значения в настройки расширения и сохраните.
Смена токена в Desktop делает недействительным значение, сохранённое в расширении.
## Язык
Настройки расширения предлагают выбор языка: `System / English / Russian`.
`System` следует языку интерфейса браузера: русская локаль выбирает русский,
остальные — английский.
Расширение и desktop-приложение хранят выбор языка независимо. Изменение одного
не меняет другой.
## Протокол локального приёмника
Эндпоинт по умолчанию:
```text
POST http://127.0.0.1:47731/api/browser-inbox/v1/captures
```
Заголовки:
- `Content-Type: application/json`
- `X-Verstak-Receiver-Token: <токен>` — обязателен, когда приёмник в paired mode
Поддерживаемые виды захвата:
- `page` — страница (url, title, domain)
- `selection` — выделенный текст
- `link` — ссылка (url, опционально текст)
- `file` — файл (имя, base64 или текст до 8 МБ)
## Лицензия
Copyright © 2026 Verstak contributors. Распространяется на условиях
[GNU AGPLv3 или новее](LICENSE).
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.1", "version": "2.0.6",
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.", "description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://github.com/mirivlad/verstak-browser-extension",
"permissions": ["contextMenus", "storage", "tabs"], "permissions": ["alarms", "contextMenus", "idle", "storage", "tabs", "windows"],
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
+5 -5
View File
@@ -1,23 +1,23 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.1", "version": "2.0.6",
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.", "description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
"author": "Verstak", "author": "Verstak",
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension", "homepage_url": "https://github.com/mirivlad/verstak-browser-extension",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "verstak-bridge@verstak.app", "id": "verstak-bridge@verstak.app",
"strict_min_version": "115.0", "strict_min_version": "115.0",
"update_url": "https://mirv.top/verstak/firefox/updates.json", "update_url": "https://github.com/mirivlad/verstak-browser-extension/releases/latest/download/updates.json",
"data_collection_permissions": { "data_collection_permissions": {
"required": ["none"] "required": ["none"]
} }
} }
}, },
"permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"], "permissions": ["alarms", "contextMenus", "idle", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"],
"background": { "background": {
"scripts": ["protocol.js", "api.js", "queue.js", "background.js"] "scripts": ["hostname.js", "activity-tracker.js", "protocol.js", "api.js", "queue.js", "i18n.js", "background.js"]
}, },
"browser_action": { "browser_action": {
"default_popup": "popup/popup.html", "default_popup": "popup/popup.html",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "verstak-browser-extension", "name": "verstak-browser-extension",
"version": "2.0.1", "version": "2.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "verstak-browser-extension", "name": "verstak-browser-extension",
"version": "2.0.1", "version": "2.0.6",
"devDependencies": { "devDependencies": {
"web-ext": "^8.3.0" "web-ext": "^8.3.0"
} }
+7 -3
View File
@@ -1,13 +1,17 @@
{ {
"name": "verstak-browser-extension", "name": "verstak-browser-extension",
"version": "2.0.1", "version": "2.0.6",
"private": true, "private": true,
"description": "Verstak browser capture extension for Chromium and Firefox", "description": "Verstak browser capture extension for Chromium and Firefox",
"license": "AGPL-3.0-or-later",
"scripts": { "scripts": {
"build": "node scripts/build-extension.js", "build": "node scripts/build-extension.js",
"test": "node scripts/test-protocol.js && node scripts/test-popup-settings.js", "test": "node scripts/test-manifests.js && node scripts/test-icons.js && node scripts/test-hostname.js && node scripts/test-activity-tracker.js && node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js && node scripts/test-popup-select-style.js && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.js && node scripts/test-firefox-github-release.js",
"sign:firefox": "./scripts/sign-firefox-xpi.sh", "sign:firefox": "./scripts/sign-firefox-xpi.sh",
"release:firefox": "./scripts/release-firefox-xpi.sh" "release:firefox": "./scripts/release-firefox-xpi.sh",
"publish:firefox": "./scripts/publish-firefox-github-release.sh",
"publish:github": "./scripts/publish-github-release.sh",
"release:package": "./scripts/package-release.sh"
}, },
"devDependencies": { "devDependencies": {
"web-ext": "^8.3.0" "web-ext": "^8.3.0"
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- First public Firefox release distributed and updated through GitHub Releases.
- Captures links, selected text, files, and images only when the user explicitly sends them to Verstak.
- Adds opt-in domain activity tracking, Russian localization, and secure receiver-token pairing.
**Full Changelog**: https://github.com/mirivlad/verstak-browser-extension/compare/v2.0.2...v2.0.3
+5
View File
@@ -0,0 +1,5 @@
## Highlights
- The Firefox package no longer requests a permission unsupported by Firefox.
- Popup operation errors and the language selector are localized and use the extension's visual style.
- The release includes the signed XPI and `updates.json` for GitHub-hosted Firefox updates.
+13
View File
@@ -0,0 +1,13 @@
## Highlights
- Replaced the legacy orange extension symbol with the current Verstak workbench
icon in every browser size: 16, 48, and 128 pixels.
- Chromium and Firefox packages now use the same icon source as the Verstak
desktop application.
## Главное
- Старый оранжевый знак расширения заменён на актуальный фирменный значок
Верстака во всех размерах: 16, 48 и 128 пикселей.
- Пакеты Chromium и Firefox теперь используют тот же исходный знак, что и
приложение Верстак.
+13 -1
View File
@@ -38,27 +38,39 @@ function copyIcons(destRoot) {
} }
} }
function copyLocalization(destRoot) {
copy(path.join(shared, 'i18n.js'), path.join(destRoot, 'i18n.js'));
for (const locale of ['en', 'ru']) {
copy(path.join(shared, 'locales', `${locale}.json`), path.join(destRoot, 'locales', `${locale}.json`));
}
}
rm(dist); rm(dist);
const chromiumDist = path.join(dist, 'chromium'); const chromiumDist = path.join(dist, 'chromium');
mkdir(chromiumDist); mkdir(chromiumDist);
copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json')); copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json'));
concat([ concat([
path.join(shared, 'hostname.js'),
path.join(shared, 'activity-tracker.js'),
path.join(shared, 'protocol.js'), path.join(shared, 'protocol.js'),
path.join(shared, 'api.js'), path.join(shared, 'api.js'),
path.join(shared, 'queue.js'), path.join(shared, 'queue.js'),
path.join(shared, 'i18n.js'),
path.join(shared, 'background.js'), path.join(shared, 'background.js'),
], path.join(chromiumDist, 'background.js')); ], path.join(chromiumDist, 'background.js'));
copyPopup(chromiumDist); copyPopup(chromiumDist);
copyIcons(chromiumDist); copyIcons(chromiumDist);
copyLocalization(chromiumDist);
const firefoxDist = path.join(dist, 'firefox'); const firefoxDist = path.join(dist, 'firefox');
mkdir(firefoxDist); mkdir(firefoxDist);
copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json')); copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json'));
for (const name of ['protocol.js', 'api.js', 'queue.js', 'background.js']) { for (const name of ['hostname.js', 'activity-tracker.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
copy(path.join(shared, name), path.join(firefoxDist, name)); copy(path.join(shared, name), path.join(firefoxDist, name));
} }
copyPopup(firefoxDist); copyPopup(firefoxDist);
copyIcons(firefoxDist); copyIcons(firefoxDist);
copyLocalization(firefoxDist);
console.log('built dist/chromium and dist/firefox'); console.log('built dist/chromium and dist/firefox');
+40
View File
@@ -0,0 +1,40 @@
const fs = require('fs');
const REPOSITORY = 'mirivlad/verstak-browser-extension';
function releaseTag(version) {
return `v${version}`;
}
function releaseAssetURL(version, assetName) {
return `https://github.com/${REPOSITORY}/releases/download/${releaseTag(version)}/${assetName}`;
}
function updateManifest(addonID, version, assetName) {
return {
addons: {
[addonID]: {
updates: [{
version,
update_link: releaseAssetURL(version, assetName),
}],
},
},
};
}
function writeUpdates(addonID, version, assetName, outputPath) {
fs.writeFileSync(outputPath, `${JSON.stringify(updateManifest(addonID, version, assetName), null, 2)}\n`);
}
if (require.main === module) {
const [, , command, addonID, version, assetName, outputPath] = process.argv;
if (command !== 'write-updates' || !addonID || !version || !assetName || !outputPath) {
console.error('usage: node scripts/firefox-github-release.js write-updates <addon-id> <version> <asset-name> <output-path>');
process.exitCode = 2;
} else {
writeUpdates(addonID, version, assetName, outputPath);
}
}
module.exports = { REPOSITORY, releaseTag, releaseAssetURL, updateManifest, writeUpdates };
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MAGICK="${MAGICK_BIN:-magick}"
SOURCE="$ROOT/shared/icons/verstak.svg"
if ! command -v "$MAGICK" >/dev/null; then
echo "ImageMagick is required to generate browser extension icons: $MAGICK not found" >&2
exit 1
fi
for size in 16 48 128; do
"$MAGICK" -background none "$SOURCE" -resize "${size}x${size}" \
"PNG32:$ROOT/shared/icons/icon${size}.png"
done
echo "generated browser extension icons from $SOURCE"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
echo "=== verstak browser extension release $VERSION ==="
(cd "$ROOT" && npm ci --no-audit --no-fund && npm test && npm run build)
RELEASE_ROOT="$ROOT/release"
STAGING="$RELEASE_ROOT/verstak-browser-extension-$VERSION"
ARCHIVE="$RELEASE_ROOT/verstak-browser-extension-$VERSION.tar.gz"
rm -rf "$STAGING" "$ARCHIVE"
mkdir -p "$STAGING"
cp "$ROOT/README.md" "$ROOT/LICENSE" "$STAGING/"
cp -R "$ROOT/dist" "$STAGING/dist"
tar -C "$RELEASE_ROOT" -czf "$ARCHIVE" "$(basename "$STAGING")"
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$ARCHIVE")" > SHA256SUMS)
echo "release archive: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
REPOSITORY="mirivlad/verstak-browser-extension"
SOURCE_DIR="${VERSTAK_FIREFOX_SOURCE_DIR:-dist/firefox}"
RELEASE_DIR="${VERSTAK_FIREFOX_RELEASE_DIR:-release/firefox}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-release-notes}"
GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}"
if ! command -v "$GH_BIN" >/dev/null; then
echo "ERROR: gh CLI is required to publish a GitHub Release" >&2
exit 1
fi
if [[ "$("$GIT_BIN" branch --show-current)" != "main" ]]; then
echo "ERROR: GitHub releases must be published from main" >&2
exit 1
fi
if [[ -n "$("$GIT_BIN" status --porcelain)" ]]; then
echo "ERROR: working tree must be clean before publishing a release" >&2
exit 1
fi
"$GH_BIN" auth status
"$GIT_BIN" fetch origin main --tags
HEAD="$("$GIT_BIN" rev-parse HEAD)"
if [[ "$HEAD" != "$("$GIT_BIN" rev-parse origin/main)" ]]; then
echo "ERROR: local main must match origin/main before publishing a release" >&2
exit 1
fi
./scripts/release-firefox-xpi.sh
VERSION="$(node -e "console.log(require('./${SOURCE_DIR}/manifest.json').version)")"
TAG="v${VERSION}"
XPI="$RELEASE_DIR/verstak-firefox-${VERSION}.xpi"
UPDATES="$RELEASE_DIR/updates.json"
for artifact in "$XPI" "$UPDATES"; do
if [[ ! -f "$artifact" ]]; then
echo "ERROR: release artifact not found: $artifact" >&2
exit 1
fi
done
if "$GIT_BIN" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
if [[ "$("$GIT_BIN" rev-parse "${TAG}^{commit}")" != "$HEAD" ]]; then
echo "ERROR: existing tag $TAG does not point at HEAD" >&2
exit 1
fi
else
"$GIT_BIN" tag -a "$TAG" -m "Release $TAG"
"$GIT_BIN" push origin "refs/tags/$TAG"
fi
if "$GH_BIN" release view "$TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$TAG" "$XPI" "$UPDATES" --repo "$REPOSITORY" --clobber
else
NOTES_FILE="$RELEASE_NOTES_DIR/$TAG.md"
if [[ ! -s "$NOTES_FILE" ]]; then
echo "ERROR: human-readable release notes are required: $NOTES_FILE" >&2
exit 1
fi
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --latest --verify-tag)
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
fi
"$GH_BIN" release create "$TAG" "$XPI" "$UPDATES" \
--repo "$REPOSITORY" \
--title "Verstak Browser Extension $VERSION" \
"${RELEASE_OPTIONS[@]}"
fi
echo "GitHub release:"
"$GH_BIN" release view "$TAG" --repo "$REPOSITORY" --json url --jq .url
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="$(node -p "require('$ROOT/package.json').version")"
if [[ $# -gt 1 || ( $# -eq 1 && "$1" != "v$VERSION" && "$1" != "$VERSION" ) ]]; then
echo "usage: $0 [v$VERSION]" >&2
exit 2
fi
exec "$ROOT/scripts/publish-firefox-github-release.sh"
+4 -17
View File
@@ -32,7 +32,6 @@ load_env_file "$ENV_FILE"
SOURCE_DIR="${VERSTAK_FIREFOX_SOURCE_DIR:-dist/firefox}" SOURCE_DIR="${VERSTAK_FIREFOX_SOURCE_DIR:-dist/firefox}"
ARTIFACTS_DIR="${WEB_EXT_ARTIFACTS_DIR:-web-ext-artifacts}" ARTIFACTS_DIR="${WEB_EXT_ARTIFACTS_DIR:-web-ext-artifacts}"
RELEASE_DIR="${VERSTAK_FIREFOX_RELEASE_DIR:-release/firefox}" RELEASE_DIR="${VERSTAK_FIREFOX_RELEASE_DIR:-release/firefox}"
UPDATE_BASE_URL="${VERSTAK_FIREFOX_UPDATE_BASE_URL:-https://mirv.top/verstak/firefox}"
./scripts/sign-firefox-xpi.sh ./scripts/sign-firefox-xpi.sh
@@ -44,9 +43,9 @@ if [[ -z "$VERSION" || -z "$ADDON_ID" ]]; then
exit 1 exit 1
fi fi
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' | sort | tail -n 1 || true)" SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name "*-${VERSION}.xpi" | sort | tail -n 1 || true)"
if [[ -z "$SIGNED_XPI" ]]; then if [[ -z "$SIGNED_XPI" ]]; then
echo "ERROR: no signed XPI found in $ARTIFACTS_DIR" >&2 echo "ERROR: no signed XPI for version $VERSION found in $ARTIFACTS_DIR" >&2
exit 1 exit 1
fi fi
@@ -54,20 +53,8 @@ mkdir -p "$RELEASE_DIR"
RELEASE_XPI="verstak-firefox-${VERSION}.xpi" RELEASE_XPI="verstak-firefox-${VERSION}.xpi"
cp "$SIGNED_XPI" "$RELEASE_DIR/$RELEASE_XPI" cp "$SIGNED_XPI" "$RELEASE_DIR/$RELEASE_XPI"
cat > "$RELEASE_DIR/updates.json" <<EOF node scripts/firefox-github-release.js write-updates \
{ "$ADDON_ID" "$VERSION" "$RELEASE_XPI" "$RELEASE_DIR/updates.json"
"addons": {
"${ADDON_ID}": {
"updates": [
{
"version": "${VERSION}",
"update_link": "${UPDATE_BASE_URL}/${RELEASE_XPI}"
}
]
}
}
}
EOF
echo "Firefox release artifacts:" echo "Firefox release artifacts:"
echo "$RELEASE_DIR/$RELEASE_XPI" echo "$RELEASE_DIR/$RELEASE_XPI"
+1 -1
View File
@@ -84,7 +84,7 @@ echo "Using AMO API proxy from WEB_EXT_API_PROXY"
npx web-ext "${SIGN_ARGS[@]}" npx web-ext "${SIGN_ARGS[@]}"
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' | sort | tail -n 1 || true)" SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' -printf '%T@ %p\n' | sort -n | tail -n 1 | cut -d' ' -f2- || true)"
if [[ -z "$SIGNED_XPI" ]]; then if [[ -z "$SIGNED_XPI" ]]; then
echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2 echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2
exit 1 exit 1
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
const assert = require('assert');
const hostname = require('../shared/hostname');
const trackerApi = require('../shared/activity-tracker');
async function main() {
const storage = trackerApi.createMemoryActivityStorage();
const sent = [];
let failSends = true;
const tracker = new trackerApi.DomainActivityTracker(storage, async (batch) => {
sent.push(JSON.parse(JSON.stringify(batch)));
if (failSends) throw new Error('offline');
return { status: 'accepted', batchId: batch.batchId };
});
await tracker.initialize();
await tracker.setEnabled(true);
await tracker.setActiveHostname('example.com', true, 0);
await tracker.checkpoint(600000);
await tracker.flush(600000);
let state = await tracker.getState();
assert.equal(state.pendingBatches.length, 1);
assert.equal(state.pendingBatches[0].entries[0].durationSeconds, 600);
const immutableFirstBatch = JSON.stringify(sent[0]);
await tracker.setActiveHostname('example.com', true, 900000);
await tracker.flush(900000);
state = await tracker.getState();
assert.equal(state.pendingBatches.length, 2, 'new activity must not mutate sent batch A');
assert.equal(state.pendingBatches[1].entries[0].durationSeconds, 300);
failSends = false;
await tracker.retryPending();
state = await tracker.getState();
assert.equal(state.pendingBatches.length, 0);
assert.ok(state.acknowledgedIds.length >= 2);
assert.equal(JSON.stringify(sent[1]), immutableFirstBatch, 'retry must resend an immutable batch A payload');
const clockTracker = new trackerApi.DomainActivityTracker(trackerApi.createMemoryActivityStorage(), async () => ({ status: 'accepted' }));
await clockTracker.initialize();
await clockTracker.setEnabled(true);
await clockTracker.setActiveHostname('example.com', true, 0);
await clockTracker.checkpoint(8 * 60 * 60 * 1000);
state = await clockTracker.getState();
assert.equal(state.activeAccumulator['example.com'].durationMs, 0, 'sleep-sized gap must be discarded');
await clockTracker.checkpoint(8 * 60 * 60 * 1000 + 5 * 60 * 1000);
state = await clockTracker.getState();
assert.equal(state.activeAccumulator['example.com'].durationMs, 300000);
await clockTracker.checkpoint(1000000);
state = await clockTracker.getState();
assert.equal(state.activeAccumulator['example.com'].durationMs, 0, 'clock rollback must reset the ambiguous interval');
assert.equal(trackerApi.isExcludedHostname('video.youtube.com', ['youtube.com']), true);
assert.equal(trackerApi.isExcludedHostname('youtube.com.evil.test', ['youtube.com']), false);
assert.equal(trackerApi.isExcludedHostname(hostname.normalizeHostnameV1('пример.рф'), ['пример.рф']), true);
await tracker.setEnabled(false);
state = await tracker.getState();
assert.deepEqual(state.activeAccumulator, {});
assert.deepEqual(state.pendingBatches, []);
assert.equal(JSON.stringify(state).includes('https://'), false);
console.log('browser activity tracker tests passed');
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = path.resolve(__dirname, '..');
const catalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'ru.json'), 'utf8')),
};
const state = {
settings: {
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
receiverToken: 'paired-token',
language: 'ru',
},
};
let menuTitles = [];
let installedListener;
let messageListener;
const browser = {
storage: {
local: {
get(key) {
if (typeof key === 'string') return Promise.resolve({ [key]: state[key] });
return Promise.resolve({ ...state });
},
set(patch) {
Object.assign(state, patch || {});
return Promise.resolve();
},
},
},
contextMenus: {
removeAll() {
menuTitles = [];
return Promise.resolve();
},
create(item) {
menuTitles.push(item.title);
},
onClicked: { addListener() {} },
},
runtime: {
getURL(relativePath) { return `extension://${relativePath}`; },
onInstalled: { addListener(listener) { installedListener = listener; } },
onMessage: { addListener(listener) { messageListener = listener; } },
},
i18n: { getUILanguage() { return 'en-US'; } },
tabs: {
query() { return Promise.resolve([{ id: 7, windowId: 1, url: 'https://example.com/private-path', title: 'Private title' }]); },
get() { return Promise.resolve({ id: 7, windowId: 1, url: 'https://example.com/private-path', title: 'Private title' }); },
onActivated: { addListener() {} },
onUpdated: { addListener() {} },
onRemoved: { addListener() {} },
},
};
function fetchCatalog(url) {
const locale = /\/ru\.json$/.test(url) ? 'ru' : 'en';
return Promise.resolve({ ok: true, json: () => Promise.resolve(catalogs[locale]) });
}
const context = vm.createContext({
browser,
console,
Date,
Math,
Promise,
URL,
fetch: fetchCatalog,
setTimeout,
clearTimeout,
});
context.globalThis = context;
for (const file of ['hostname.js', 'activity-tracker.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
vm.runInContext(fs.readFileSync(path.join(root, 'shared', file), 'utf8'), context, { filename: file });
}
async function flush() {
for (let i = 0; i < 8; i += 1) await Promise.resolve();
}
function sendMessage(message) {
return new Promise((resolve) => {
messageListener(message, {}, resolve);
});
}
(async () => {
installedListener();
await flush();
assert.deepStrictEqual(menuTitles, [
'Отправить страницу в Верстак',
'Отправить выделение в Верстак',
'Отправить ссылку в Верстак',
]);
const trackingState = await sendMessage({
type: 'verstak.capture',
action: 'saveSettings',
settings: {
receiverUrl: state.settings.receiverUrl,
receiverToken: state.settings.receiverToken,
language: 'en',
passiveActivityEnabled: true,
passiveActivityExcludedDomains: [],
},
});
assert.strictEqual(trackingState.settings.passiveActivityEnabled, true);
assert.ok(trackingState.activityState.activeAccumulator['example.com']);
assert.equal(JSON.stringify(trackingState.activityState).includes('private-path'), false);
assert.equal(JSON.stringify(trackingState.activityState).includes('Private title'), false);
const nextState = await sendMessage({
type: 'verstak.capture',
action: 'saveSettings',
settings: {
receiverUrl: state.settings.receiverUrl,
receiverToken: state.settings.receiverToken,
language: 'en',
},
});
await flush();
assert.strictEqual(state.settings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures');
assert.strictEqual(state.settings.receiverToken, 'paired-token');
assert.strictEqual(state.settings.language, 'en');
assert.strictEqual(nextState.settings.language, 'en');
assert.deepStrictEqual(menuTitles, [
'Send page to Verstak',
'Send selection to Verstak',
'Send link to Verstak',
]);
console.log('browser extension background localization tests passed');
})().catch((error) => {
console.error(error);
process.exit(1);
});
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const release = require('./firefox-github-release');
const packageManifest = require('../package.json');
const chromiumManifest = require('../chromium/manifest.json');
const firefoxManifest = require('../firefox/manifest.json');
const version = '2.0.6';
const asset = 'verstak-firefox-2.0.6.xpi';
assert.equal(packageManifest.version, version);
assert.equal(chromiumManifest.version, version);
assert.equal(firefoxManifest.version, version);
assert.equal(release.releaseTag(version), 'v2.0.6');
assert.equal(
release.releaseAssetURL(version, asset),
'https://github.com/mirivlad/verstak-browser-extension/releases/download/v2.0.6/verstak-firefox-2.0.6.xpi',
);
assert.deepEqual(release.updateManifest('verstak-bridge@verstak.app', version, asset), {
addons: {
'verstak-bridge@verstak.app': {
updates: [{
version: '2.0.6',
update_link: 'https://github.com/mirivlad/verstak-browser-extension/releases/download/v2.0.6/verstak-firefox-2.0.6.xpi',
}],
},
},
});
assert.equal(firefoxManifest.homepage_url, 'https://github.com/mirivlad/verstak-browser-extension');
assert.equal(
firefoxManifest.browser_specific_settings.gecko.update_url,
'https://github.com/mirivlad/verstak-browser-extension/releases/latest/download/updates.json',
);
const publisher = fs.readFileSync('scripts/publish-firefox-github-release.sh', 'utf8');
assert.match(publisher, /auth status/);
assert.match(publisher, /branch --show-current/);
assert.match(publisher, /tag -a/);
assert.match(publisher, /push origin/);
assert.match(publisher, /release create/);
assert.match(publisher, /release upload/);
assert.match(publisher, /--clobber/);
assert.match(publisher, /--latest/);
assert.match(publisher, /--notes-file/);
assert.match(publisher, /--generate-notes/);
assert.match(publisher, /--notes-start-tag/);
const genericPublisher = fs.readFileSync('scripts/publish-github-release.sh', 'utf8');
assert.match(genericPublisher, /publish-firefox-github-release\.sh/);
console.log('Firefox GitHub release metadata tests passed');
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const hostname = require('../shared/hostname');
const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, '../shared/hostname-normalization-v1.json'), 'utf8'));
for (const vector of vectors.bare) {
assert.equal(hostname.normalizeHostnameV1(vector.input), vector.output, vector.input);
}
for (const vector of vectors.url) {
assert.equal(hostname.normalizeURLHostnameV1(vector.input), vector.output, vector.input);
}
console.log('browser hostname normalization tests passed');
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const i18n = require(path.join(root, 'shared', 'i18n.js'));
const en = JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'en.json'), 'utf8'));
const ru = JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'ru.json'), 'utf8'));
assert.strictEqual(i18n.normalizePreference(undefined), 'system');
assert.strictEqual(i18n.normalizePreference('de'), 'system');
assert.strictEqual(i18n.normalizePreference('system'), 'system');
assert.strictEqual(i18n.normalizePreference('en'), 'en');
assert.strictEqual(i18n.normalizePreference('ru'), 'ru');
assert.strictEqual(i18n.resolveLocale('system', 'ru'), 'ru');
assert.strictEqual(i18n.resolveLocale('system', 'ru-RU'), 'ru');
assert.strictEqual(i18n.resolveLocale('system', 'RU-ru'), 'ru');
assert.strictEqual(i18n.resolveLocale('system', 'uk-UA'), 'en');
assert.strictEqual(i18n.resolveLocale('system', 'en-US'), 'en');
assert.strictEqual(i18n.resolveLocale('en', 'ru-RU'), 'en');
assert.strictEqual(i18n.resolveLocale('ru', 'en-US'), 'ru');
assert.deepStrictEqual(Object.keys(ru).sort(), Object.keys(en).sort());
assert.ok(Object.values(en).every((value) => typeof value === 'string'));
assert.ok(Object.values(ru).every((value) => typeof value === 'string'));
const tEn = i18n.createTranslator({ en, ru }, 'en');
const tRu = i18n.createTranslator({ en, ru }, 'ru');
assert.strictEqual(tRu('status.queued'), 'В очереди до запуска Верстака');
assert.strictEqual(tEn('error.sendCapture'), 'Could not send the capture. Please try again.');
assert.strictEqual(tRu('missing', null, 'Fallback'), 'Fallback');
assert.strictEqual(tRu('missing.key'), 'missing.key');
const dist = path.join(root, 'dist');
if (fs.existsSync(dist)) {
for (const target of ['chromium', 'firefox']) {
for (const relativePath of ['i18n.js', 'locales/en.json', 'locales/ru.json']) {
assert.ok(
fs.existsSync(path.join(dist, target, relativePath)),
`${target} build is missing ${relativePath}`,
);
}
}
}
i18n.loadCatalogs((locale) => Promise.resolve(locale === 'ru' ? ru : en))
.then((catalogs) => {
assert.strictEqual(catalogs.en, en);
assert.strictEqual(catalogs.ru, ru);
console.log('browser extension localization runtime tests passed');
})
.catch((error) => {
console.error(error);
process.exit(1);
});
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const root = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(root, 'shared', 'icons', 'verstak.svg'), 'utf8');
assert.match(source, /fill="#1c2f4a"/i, 'icon source must define the Verstak navy');
assert.match(source, /fill="#72d2b3"/i, 'icon source must define the Verstak mint');
function paeth(left, up, upperLeft) {
const estimate = left + up - upperLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upperLeftDistance = Math.abs(estimate - upperLeft);
if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left;
if (upDistance <= upperLeftDistance) return up;
return upperLeft;
}
function pngColors(file) {
const png = fs.readFileSync(file);
assert.deepStrictEqual(png.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), `${file} must be PNG`);
let offset = 8;
let width;
let height;
let bitDepth;
let colorType;
let palette;
const idat = [];
while (offset < png.length) {
const length = png.readUInt32BE(offset);
const type = png.toString('ascii', offset + 4, offset + 8);
const data = png.subarray(offset + 8, offset + 8 + length);
offset += length + 12;
if (type === 'IHDR') {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
} else if (type === 'PLTE') {
palette = data;
} else if (type === 'IDAT') {
idat.push(data);
}
}
assert.equal(bitDepth, 8, `${file} must use 8-bit samples`);
assert.ok([2, 3, 6].includes(colorType), `${file} must use RGB, palette, or RGBA pixels`);
const bytesPerPixel = colorType === 6 ? 4 : colorType === 2 ? 3 : 1;
const rowSize = width * bytesPerPixel;
const decoded = zlib.inflateSync(Buffer.concat(idat));
const colors = new Set();
let readOffset = 0;
let previous = Buffer.alloc(rowSize);
for (let y = 0; y < height; y += 1) {
const filter = decoded[readOffset++];
const encoded = decoded.subarray(readOffset, readOffset + rowSize);
readOffset += rowSize;
const row = Buffer.alloc(rowSize);
for (let x = 0; x < rowSize; x += 1) {
const left = x >= bytesPerPixel ? row[x - bytesPerPixel] : 0;
const up = previous[x];
const upperLeft = x >= bytesPerPixel ? previous[x - bytesPerPixel] : 0;
if (filter === 0) row[x] = encoded[x];
else if (filter === 1) row[x] = (encoded[x] + left) & 255;
else if (filter === 2) row[x] = (encoded[x] + up) & 255;
else if (filter === 3) row[x] = (encoded[x] + Math.floor((left + up) / 2)) & 255;
else if (filter === 4) row[x] = (encoded[x] + paeth(left, up, upperLeft)) & 255;
else assert.fail(`${file} has unsupported PNG filter ${filter}`);
}
for (let x = 0; x < width; x += 1) {
if (colorType === 3) {
const paletteOffset = row[x] * 3;
colors.add(palette.subarray(paletteOffset, paletteOffset + 3).toString('hex'));
} else {
const pixelOffset = x * bytesPerPixel;
colors.add(row.subarray(pixelOffset, pixelOffset + 3).toString('hex'));
}
}
previous = row;
}
return { width, height, colors };
}
for (const [name, size, needsMint] of [
['icon16.png', 16, false],
['icon48.png', 48, true],
['icon128.png', 128, true],
]) {
const icon = pngColors(path.join(root, 'shared', 'icons', name));
assert.equal(icon.width, size, `${name} width`);
assert.equal(icon.height, size, `${name} height`);
assert.ok(icon.colors.has('1c2f4a'), `${name} must contain the Verstak navy`);
if (needsMint) assert.ok(icon.colors.has('72d2b3'), `${name} must contain the Verstak mint`);
}
console.log('browser extension icon tests passed');
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env node
const assert = require('assert');
const chromium = require('../chromium/manifest.json');
const firefox = require('../firefox/manifest.json');
assert.equal(chromium.version, firefox.version, 'browser packages must use the same version');
assert.equal(
firefox.permissions.includes('windows'),
false,
'Firefox does not accept the Chromium-only windows permission'
);
assert.equal(chromium.permissions.includes('windows'), true, 'Chromium still needs the windows permission');
console.log('browser manifest compatibility tests passed');
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
class Element {
constructor(text) {
this.value = '';
this.textContent = text || '';
this.className = '';
this.files = [];
this.listeners = {};
}
addEventListener(type, listener) { this.listeners[type] = listener; }
}
const ids = [
'status', 'receiver-state', 'receiver-url', 'receiver-input', 'receiver-token-input',
'language-select', 'file-input', 'pending-count', 'status-dot', 'subtitle',
'receiver-label', 'pending-label', 'url-label', 'file-label', 'receiver-url-label',
'receiver-token-label', 'language-label', 'capture-page', 'capture-file', 'retry',
'save-settings', 'context-menu-hint', 'language-system-option', 'language-en-option',
'language-ru-option',
];
const elements = Object.fromEntries(ids.map((id) => [id, new Element()]));
elements['capture-page'].textContent = 'Send Page';
const browser = {
runtime: {
getURL(relativePath) { return `extension://${relativePath}`; },
sendMessage() {
return Promise.resolve({
settings: { receiverUrl: 'http://127.0.0.1/', receiverToken: '', language: 'system' },
pendingCount: 0,
status: {},
});
},
},
i18n: { getUILanguage() { return 'en-US'; } },
};
const document = {
activeElement: null,
documentElement: { lang: '' },
getElementById(id) { return elements[id]; },
};
const context = vm.createContext({
browser,
document,
navigator: { language: 'en-US' },
console: { warn() {}, error: console.error },
Promise,
fetch() { return Promise.reject(new Error('catalog unavailable')); },
});
context.globalThis = context;
const root = path.resolve(__dirname, '..');
for (const file of ['shared/i18n.js', 'shared/popup/popup.js']) {
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, { filename: file });
}
(async () => {
for (let i = 0; i < 16; i += 1) await Promise.resolve();
assert.strictEqual(elements['capture-page'].textContent, 'Send Page');
assert.strictEqual(elements['receiver-state'].textContent, 'Unknown');
assert.strictEqual(document.documentElement.lang, 'en');
console.log('browser extension popup catalog fallback tests passed');
})().catch((error) => {
console.error(error);
process.exit(1);
});
+10
View File
@@ -0,0 +1,10 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const css = fs.readFileSync(path.join(__dirname, '..', 'shared', 'popup', 'popup.css'), 'utf8');
assert.match(css, /select\s*\{[^}]*appearance:\s*none/, 'popup select must hide the native arrow');
assert.match(css, /select option\s*\{[^}]*background/, 'popup options must use the extension surface');
console.log('browser extension popup select style test passed');
+101 -3
View File
@@ -20,6 +20,10 @@ class Element {
click() { click() {
if (this.listeners.click) this.listeners.click(); if (this.listeners.click) this.listeners.click();
} }
change() {
if (this.listeners.change) this.listeners.change({ target: this });
}
} }
const elements = {}; const elements = {};
@@ -31,27 +35,58 @@ const elements = {};
'receiver-token-input', 'receiver-token-input',
'file-input', 'file-input',
'pending-count', 'pending-count',
'activity-pending-count',
'status-dot', 'status-dot',
'subtitle',
'receiver-label',
'pending-label',
'activity-pending-label',
'url-label',
'capture-page', 'capture-page',
'capture-file', 'capture-file',
'retry', 'retry',
'file-label',
'receiver-url-label',
'receiver-token-label',
'language-label',
'language-select',
'language-system-option',
'language-en-option',
'language-ru-option',
'save-settings', 'save-settings',
'context-menu-hint',
'passive-activity-enabled',
'passive-activity-label',
'passive-activity-disclosure',
'passive-activity-exclusions-label',
'passive-activity-exclusions',
].forEach((id) => { ].forEach((id) => {
elements[id] = new Element(); elements[id] = new Element();
}); });
let savedSettings = null; let savedSettings = null;
let nextRequestError = null;
const technicalWarnings = [];
const initialState = { const initialState = {
settings: { settings: {
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures', receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
receiverToken: 'persisted-token', receiverToken: 'persisted-token',
language: 'system',
passiveActivityEnabled: false,
passiveActivityExcludedDomains: ['youtube.com'],
}, },
pendingCount: 0, pendingCount: 0,
status: {}, status: {},
}; };
const browser = { const browser = {
runtime: { runtime: {
getURL(relativePath) { return `extension://${relativePath}`; },
sendMessage(message) { sendMessage(message) {
if (nextRequestError) {
const error = nextRequestError;
nextRequestError = null;
return Promise.reject(new Error(error));
}
if (message.action === 'getState') return Promise.resolve(initialState); if (message.action === 'getState') return Promise.resolve(initialState);
if (message.action === 'saveSettings') { if (message.action === 'saveSettings') {
savedSettings = message.settings; savedSettings = message.settings;
@@ -60,34 +95,97 @@ const browser = {
return Promise.resolve(initialState); return Promise.resolve(initialState);
}, },
}, },
i18n: { getUILanguage() { return 'ru-RU'; } },
}; };
const document = { const document = {
activeElement: null, activeElement: null,
documentElement: { lang: '' },
getElementById(id) { getElementById(id) {
return elements[id]; return elements[id];
}, },
}; };
const catalogs = {
en: JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'shared', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'shared', 'locales', 'ru.json'), 'utf8')),
};
function fetchCatalog(url) {
const locale = /\/ru\.json$/.test(url) ? 'ru' : 'en';
return Promise.resolve({ ok: true, json: () => Promise.resolve(catalogs[locale]) });
}
const i18nPath = path.join(__dirname, '..', 'shared', 'i18n.js');
const popupPath = path.join(__dirname, '..', 'shared', 'popup', 'popup.js'); const popupPath = path.join(__dirname, '..', 'shared', 'popup', 'popup.js');
vm.runInNewContext(fs.readFileSync(popupPath, 'utf8'), { browser, console, document, Promise, btoa }); const popupSource = fs.readFileSync(popupPath, 'utf8');
assert.equal(/setStatus\(\s*(?:error\b|err\b|String\()/.test(popupSource), false);
const context = vm.createContext({
browser,
console: {
error: console.error,
log: console.log,
warn(...args) { technicalWarnings.push(args.map(String).join(' ')); },
},
document,
Promise,
btoa,
fetch: fetchCatalog,
navigator: { language: 'en-US' },
});
context.globalThis = context;
vm.runInContext(fs.readFileSync(i18nPath, 'utf8'), context, { filename: i18nPath });
vm.runInContext(popupSource, context, { filename: popupPath });
async function flush() { async function flush() {
for (let i = 0; i < 4; i += 1) await Promise.resolve(); for (let i = 0; i < 16; i += 1) await Promise.resolve();
} }
(async () => { (async () => {
await flush(); await flush();
assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl); assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl);
assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken); assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken);
assert.strictEqual(elements['language-select'].value, 'system');
assert.strictEqual(elements['passive-activity-enabled'].checked, false);
assert.strictEqual(elements['passive-activity-exclusions'].value, 'youtube.com');
assert.strictEqual(elements['capture-page'].textContent, 'Отправить страницу');
assert.strictEqual(elements['receiver-state'].textContent, 'Неизвестно');
assert.strictEqual(document.documentElement.lang, 'ru');
elements['capture-file'].click();
assert.strictEqual(elements.status.textContent, 'Сначала выберите файл');
elements['language-select'].value = 'en';
elements['language-select'].change();
await flush();
assert.strictEqual(elements['capture-page'].textContent, 'Send Page');
assert.strictEqual(elements['receiver-state'].textContent, 'Unknown');
assert.strictEqual(document.documentElement.lang, 'en');
assert.ok(savedSettings);
assert.strictEqual(savedSettings.language, 'en');
assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl);
assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken);
assert.strictEqual(savedSettings.passiveActivityEnabled, false);
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com']);
elements['receiver-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'; elements['receiver-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
elements['receiver-token-input'].value = 'new-token'; elements['receiver-token-input'].value = 'new-token';
elements['passive-activity-enabled'].checked = true;
elements['passive-activity-exclusions'].value = 'youtube.com\nx.com';
elements['save-settings'].click(); elements['save-settings'].click();
await flush(); await flush();
assert.ok(savedSettings); assert.ok(savedSettings);
assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'); assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures');
assert.strictEqual(savedSettings.receiverToken, 'new-token'); assert.strictEqual(savedSettings.receiverToken, 'new-token');
console.log('browser extension popup settings tests passed'); assert.strictEqual(savedSettings.language, 'en');
assert.strictEqual(savedSettings.passiveActivityEnabled, true);
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com', 'x.com']);
nextRequestError = '[plugin:verstak.browser-inbox] captures.create failed: receiver unavailable';
elements['capture-page'].click();
await flush();
assert.strictEqual(elements.status.textContent, 'Could not send the capture. Please try again.');
assert.equal(elements.status.textContent.includes('[plugin:'), false);
assert.ok(technicalWarnings.some((message) => message.includes('captures.create failed')));
console.log('browser extension popup localization/settings tests passed');
})().catch((error) => { })().catch((error) => {
console.error(error); console.error(error);
process.exit(1); process.exit(1);
+26
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
const assert = require('assert'); const assert = require('assert');
require('../shared/hostname');
const protocol = require('../shared/protocol'); const protocol = require('../shared/protocol');
require('../shared/api'); require('../shared/api');
const queueApi = require('../shared/queue'); const queueApi = require('../shared/queue');
@@ -51,6 +52,20 @@ const fetchOk = (url, options) => {
return Promise.resolve({ status: 202, json: () => Promise.resolve({ status: 'accepted' }) }); return Promise.resolve({ status: 202, json: () => Promise.resolve({ status: 'accepted' }) });
}; };
const activityBatch = {
schemaVersion: 1,
batchId: 'activity-batch-id',
createdAt: '2026-07-12T10:05:00.000Z',
source: 'verstak-browser-extension',
entries: [{
hostname: 'example.com',
startedAt: '2026-07-12T10:00:00.000Z',
endedAt: '2026-07-12T10:05:00.000Z',
durationSeconds: 300,
url: 'https://must-not-be-sent.example'
}]
};
globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', page, fetchOk) globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', page, fetchOk)
.then((result) => { .then((result) => {
assert.equal(result.status, 'accepted'); assert.equal(result.status, 'accepted');
@@ -58,6 +73,17 @@ globalThis.VerstakBrowser.sendCapture('http://127.0.0.1:47731/api/browser-inbox/
assert.equal(request.options.headers['X-Verstak-Receiver-Token'], 'token'); assert.equal(request.options.headers['X-Verstak-Receiver-Token'], 'token');
assert.equal(JSON.parse(request.options.body).captureId, 'test-capture-id'); assert.equal(JSON.parse(request.options.body).captureId, 'test-capture-id');
}) })
.then(() => {
return globalThis.VerstakBrowser.sendActivityBatch('http://127.0.0.1:47731/api/browser-inbox/v1/captures', 'token', activityBatch, fetchOk)
.then((result) => {
assert.equal(result.status, 'accepted');
assert.equal(request.url, 'http://127.0.0.1:47731/api/browser-activity/v1/batches');
const body = JSON.parse(request.options.body);
assert.equal(body.batchId, 'activity-batch-id');
assert.equal(body.entries[0].hostname, 'example.com');
assert.equal(Object.prototype.hasOwnProperty.call(body.entries[0], 'url'), false);
});
})
.then(() => { .then(() => {
const queue = new queueApi.CaptureQueue(queueApi.createMemoryStorage()); const queue = new queueApi.CaptureQueue(queueApi.createMemoryStorage());
return queue.enqueue(page) return queue.enqueue(page)
+283
View File
@@ -0,0 +1,283 @@
(function (root) {
'use strict';
var ACTIVITY_STATE_KEY = 'verstak.domainActivity.v1';
var MAX_CHECKPOINT_GAP_MS = 10 * 60 * 1000;
var ACK_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
var MAX_ACKNOWLEDGED_IDS = 1000;
function createMemoryActivityStorage(seed) {
var state = Object.assign({}, seed || {});
return {
get: function (key) {
return Promise.resolve(state[key]);
},
set: function (key, value) {
state[key] = value;
return Promise.resolve();
}
};
}
function browserActivityStorageAdapter(browserApi) {
var storage = browserApi && browserApi.storage && browserApi.storage.local;
if (!storage) return createMemoryActivityStorage();
return {
get: function (key) {
return storage.get(key).then(function (result) { return result && result[key]; });
},
set: function (key, value) {
var patch = {};
patch[key] = value;
return storage.set(patch);
}
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function isFiniteNumber(value) {
return typeof value === 'number' && Number.isFinite(value);
}
function initialState() {
return {
activeAccumulator: {},
pendingBatches: [],
acknowledgedIds: []
};
}
function normalizedState(value) {
var state = value && typeof value === 'object' ? value : {};
return {
activeAccumulator: state.activeAccumulator && typeof state.activeAccumulator === 'object' ? state.activeAccumulator : {},
pendingBatches: Array.isArray(state.pendingBatches) ? state.pendingBatches : [],
acknowledgedIds: Array.isArray(state.acknowledgedIds) ? state.acknowledgedIds : []
};
}
function batchId(now) {
var cryptoObj = root.crypto;
if (cryptoObj && typeof cryptoObj.randomUUID === 'function') return cryptoObj.randomUUID();
return 'activity-' + String(now) + '-' + Math.random().toString(36).slice(2);
}
function toISOString(time) {
return new Date(time).toISOString();
}
function isExcludedHostname(hostname, excludedDomains) {
var normalizer = root.VerstakBrowser && root.VerstakBrowser.normalizeHostnameV1;
var canonicalHostname = typeof normalizer === 'function' ? normalizer(hostname) : '';
if (!canonicalHostname) return true;
return (Array.isArray(excludedDomains) ? excludedDomains : []).some(function (value) {
var excluded = typeof normalizer === 'function' ? normalizer(value) : '';
return excluded && (canonicalHostname === excluded || canonicalHostname.slice(-(excluded.length + 1)) === '.' + excluded);
});
}
function DomainActivityTracker(storage, sender, options) {
options = options || {};
this.storage = storage || createMemoryActivityStorage();
this.sender = typeof sender === 'function' ? sender : function () { return Promise.reject(new Error('activity sender unavailable')); };
this.maxCheckpointGapMs = isFiniteNumber(options.maxCheckpointGapMs) ? options.maxCheckpointGapMs : MAX_CHECKPOINT_GAP_MS;
this.now = typeof options.now === 'function' ? options.now : function () { return Date.now(); };
this.state = initialState();
this.enabled = false;
this.activeHostname = '';
this.serial = Promise.resolve();
}
DomainActivityTracker.prototype.enqueue = function (operation) {
var next = this.serial.then(operation, operation);
this.serial = next.catch(function () {});
return next;
};
DomainActivityTracker.prototype.initialize = function () {
var self = this;
return this.enqueue(function () {
return self.storage.get(ACTIVITY_STATE_KEY).then(function (value) {
self.state = normalizedState(value);
self.pruneAcknowledged(self.now());
return self.persist().then(function () { return self.getStateUnsafe(); });
});
});
};
DomainActivityTracker.prototype.getState = function () {
var self = this;
return this.enqueue(function () { return self.getStateUnsafe(); });
};
DomainActivityTracker.prototype.getStateUnsafe = function () {
return clone(this.state);
};
DomainActivityTracker.prototype.persist = function () {
return this.storage.set(ACTIVITY_STATE_KEY, clone(this.state));
};
DomainActivityTracker.prototype.setEnabled = function (enabled) {
var self = this;
return this.enqueue(function () {
self.enabled = enabled === true;
if (!self.enabled) {
self.activeHostname = '';
self.state.activeAccumulator = {};
self.state.pendingBatches = [];
}
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.setActiveHostname = function (hostname, active, observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
if (!self.enabled || !active || !hostname) {
self.checkpointUnsafe(now);
self.activeHostname = '';
return self.persist().then(function () { return self.getStateUnsafe(); });
}
self.checkpointUnsafe(now);
self.activeHostname = hostname;
self.ensureAccumulator(hostname, now);
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.checkpoint = function (observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
self.checkpointUnsafe(now);
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.ensureAccumulator = function (hostname, now) {
if (!this.state.activeAccumulator[hostname]) {
this.state.activeAccumulator[hostname] = {
hostname: hostname,
startedAt: now,
lastCheckpointAt: now,
durationMs: 0
};
}
return this.state.activeAccumulator[hostname];
};
DomainActivityTracker.prototype.checkpointUnsafe = function (now) {
if (!this.enabled || !this.activeHostname) return;
var accumulator = this.ensureAccumulator(this.activeHostname, now);
var previous = accumulator.lastCheckpointAt;
var delta = now - previous;
if (!isFiniteNumber(previous) || delta < 0 || delta > this.maxCheckpointGapMs) {
accumulator.startedAt = now;
accumulator.lastCheckpointAt = now;
accumulator.durationMs = 0;
return;
}
if (delta === 0) return;
accumulator.durationMs += delta;
accumulator.lastCheckpointAt = now;
};
DomainActivityTracker.prototype.flush = function (observedAt) {
var self = this;
var now = isFiniteNumber(observedAt) ? observedAt : this.now();
return this.enqueue(function () {
if (!self.enabled) return self.getStateUnsafe();
self.checkpointUnsafe(now);
var entries = [];
Object.keys(self.state.activeAccumulator).forEach(function (hostname) {
var accumulator = self.state.activeAccumulator[hostname];
var durationSeconds = Math.floor(Number(accumulator.durationMs || 0) / 1000);
if (durationSeconds < 1) return;
entries.push({
hostname: accumulator.hostname,
startedAt: toISOString(accumulator.startedAt),
endedAt: toISOString(accumulator.lastCheckpointAt),
durationSeconds: durationSeconds
});
});
if (entries.length) {
self.state.pendingBatches.push({
schemaVersion: 1,
batchId: batchId(now),
createdAt: toISOString(now),
source: 'verstak-browser-extension',
entries: entries
});
self.state.activeAccumulator = {};
if (self.activeHostname) self.ensureAccumulator(self.activeHostname, now);
}
return self.persist().then(function () {
return self.deliverPendingUnsafe(now);
});
});
};
DomainActivityTracker.prototype.retryPending = function () {
var self = this;
return this.enqueue(function () {
if (!self.enabled) return self.getStateUnsafe();
return self.deliverPendingUnsafe(self.now());
});
};
DomainActivityTracker.prototype.deliverPendingUnsafe = function (now) {
var self = this;
var pending = this.state.pendingBatches.slice();
return pending.reduce(function (chain, batch) {
return chain.then(function () {
if (!self.hasPendingBatch(batch.batchId)) return undefined;
var immutableBatch = clone(batch);
return self.sender(immutableBatch).then(function (result) {
if (result && result.batchId && result.batchId !== batch.batchId) {
throw new Error('receiver acknowledged a different activity batch');
}
self.state.pendingBatches = self.state.pendingBatches.filter(function (item) { return item.batchId !== batch.batchId; });
self.state.acknowledgedIds.push({ id: batch.batchId, acknowledgedAt: toISOString(now) });
self.pruneAcknowledged(now);
return self.persist();
});
});
}, Promise.resolve()).then(function () {
return self.getStateUnsafe();
}).catch(function () {
return self.persist().then(function () { return self.getStateUnsafe(); });
});
};
DomainActivityTracker.prototype.hasPendingBatch = function (batchID) {
return this.state.pendingBatches.some(function (batch) { return batch.batchId === batchID; });
};
DomainActivityTracker.prototype.pruneAcknowledged = function (now) {
var cutoff = now - ACK_RETENTION_MS;
var seen = {};
this.state.acknowledgedIds = this.state.acknowledgedIds.filter(function (entry) {
var timestamp = Date.parse(entry && entry.acknowledgedAt);
if (!entry || !entry.id || !Number.isFinite(timestamp) || timestamp < cutoff || seen[entry.id]) return false;
seen[entry.id] = true;
return true;
}).slice(-MAX_ACKNOWLEDGED_IDS);
};
var api = {
ACTIVITY_STATE_KEY: ACTIVITY_STATE_KEY,
MAX_CHECKPOINT_GAP_MS: MAX_CHECKPOINT_GAP_MS,
createMemoryActivityStorage: createMemoryActivityStorage,
browserActivityStorageAdapter: browserActivityStorageAdapter,
DomainActivityTracker: DomainActivityTracker,
isExcludedHostname: isExcludedHostname
};
root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api);
if (typeof module !== 'undefined') module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);
+57 -1
View File
@@ -20,7 +20,63 @@
}); });
} }
var api = { sendCapture: sendCapture }; function activityReceiverURL(captureReceiverURL) {
var protocol = root.VerstakBrowser || {};
var receiverURL = String(captureReceiverURL || protocol.DEFAULT_RECEIVER_URL || '').trim();
var capturePath = '/api/browser-inbox/v1/captures';
if (receiverURL.slice(-capturePath.length) === capturePath) {
return receiverURL.slice(0, -capturePath.length) + '/api/browser-activity/v1/batches';
}
return protocol.DEFAULT_ACTIVITY_URL;
}
function safeActivityBatch(payload) {
if (!payload || payload.schemaVersion !== 1 || !payload.batchId || !payload.createdAt || !payload.source || !Array.isArray(payload.entries) || payload.entries.length === 0) {
throw new Error('invalid activity batch');
}
var entries = payload.entries.map(function (entry) {
if (!entry || !entry.hostname || !entry.startedAt || !entry.endedAt || !Number.isFinite(Number(entry.durationSeconds)) || Number(entry.durationSeconds) <= 0) {
throw new Error('invalid activity batch entry');
}
return {
hostname: String(entry.hostname),
startedAt: String(entry.startedAt),
endedAt: String(entry.endedAt),
durationSeconds: Math.floor(Number(entry.durationSeconds))
};
});
return {
schemaVersion: 1,
batchId: String(payload.batchId),
createdAt: String(payload.createdAt),
source: String(payload.source),
entries: entries
};
}
function sendActivityBatch(captureReceiverURL, token, payload, fetchImpl) {
var batch = safeActivityBatch(payload);
var fetchFn = fetchImpl || root.fetch;
if (typeof fetchFn !== 'function') return Promise.reject(new Error('fetch unavailable'));
return fetchFn(activityReceiverURL(captureReceiverURL), {
method: 'POST',
headers: Object.assign({
'Content-Type': 'application/json'
}, token ? { 'X-Verstak-Receiver-Token': token } : {}),
body: JSON.stringify(batch)
}).then(function (response) {
if (!response || response.status < 200 || response.status >= 300) {
throw new Error('receiver rejected activity batch: HTTP ' + (response && response.status));
}
return response.json ? response.json() : { status: 'accepted', batchId: batch.batchId };
});
}
var api = {
sendCapture: sendCapture,
sendActivityBatch: sendActivityBatch,
activityReceiverURL: activityReceiverURL
};
root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api); root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api);
if (typeof module !== 'undefined') module.exports = api; if (typeof module !== 'undefined') module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this); })(typeof globalThis !== 'undefined' ? globalThis : this);
+206 -10
View File
@@ -4,20 +4,78 @@
var ext = typeof browser !== 'undefined' ? browser : chrome; var ext = typeof browser !== 'undefined' ? browser : chrome;
var protocol = globalThis.VerstakBrowser; var protocol = globalThis.VerstakBrowser;
var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext)); var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext));
var activityTracker = new protocol.DomainActivityTracker(
protocol.browserActivityStorageAdapter(ext),
function (batch) {
return getSettings().then(function (settings) {
return protocol.sendActivityBatch(settings.receiverUrl, settings.receiverToken, batch);
});
}
);
var i18n = globalThis.VerstakBrowserI18n;
var DEFAULT_SETTINGS = { var DEFAULT_SETTINGS = {
receiverUrl: protocol.DEFAULT_RECEIVER_URL, receiverUrl: protocol.DEFAULT_RECEIVER_URL,
receiverToken: '' receiverToken: '',
language: 'system',
passiveActivityEnabled: false,
passiveActivityExcludedDomains: []
}; };
var STATUS_KEY = 'verstak.status'; var STATUS_KEY = 'verstak.status';
var ACTIVITY_FLUSH_ALARM = 'verstak-domain-activity-flush';
var localeCatalogs = null;
var focusedWindowID = null;
var activeTabID = null;
function getSettings() { function getSettings() {
return ext.storage.local.get('settings').then(function (result) { return ext.storage.local.get('settings').then(function (result) {
return Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {}); var settings = Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {});
settings.language = i18n.normalizePreference(settings.language);
settings.passiveActivityEnabled = settings.passiveActivityEnabled === true;
settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains);
return settings;
}); });
} }
function saveSettings(settings) { function saveSettings(settings) {
return ext.storage.local.set({ settings: Object.assign({}, DEFAULT_SETTINGS, settings || {}) }); settings = Object.assign({}, DEFAULT_SETTINGS, settings || {});
settings.language = i18n.normalizePreference(settings.language);
settings.passiveActivityEnabled = settings.passiveActivityEnabled === true;
settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains);
return ext.storage.local.set({ settings: settings });
}
function normalizeExcludedDomains(value) {
var values = Array.isArray(value) ? value : String(value || '').split(/[\n,]/);
var seen = {};
return values.map(function (item) {
return protocol.normalizeHostnameV1(String(item || '').trim());
}).filter(function (item) {
if (!item || seen[item]) return false;
seen[item] = true;
return true;
});
}
function loadLocaleCatalogs() {
if (localeCatalogs) return localeCatalogs;
localeCatalogs = i18n.loadCatalogs(function (locale) {
return fetch(ext.runtime.getURL('locales/' + locale + '.json')).then(function (response) {
if (!response.ok) throw new Error('catalog load failed: ' + locale);
return response.json();
});
}).catch(function (error) {
console.warn('[verstak] localization catalogs unavailable:', error);
return { en: {}, ru: {} };
});
return localeCatalogs;
}
function browserLocale() {
try {
return ext.i18n && ext.i18n.getUILanguage ? ext.i18n.getUILanguage() : 'en';
} catch (_) {
return 'en';
}
} }
function setStatus(patch) { function setStatus(patch) {
@@ -39,6 +97,58 @@
}); });
} }
function isFocusedWindow(windowID) {
return focusedWindowID == null || focusedWindowID === windowID;
}
function trackTab(tab, settings) {
if (!settings.passiveActivityEnabled || !tab || !isFocusedWindow(tab.windowId)) {
return activityTracker.setActiveHostname('', false);
}
activeTabID = tab.id == null ? activeTabID : tab.id;
var hostname = protocol.normalizeURLHostnameV1(tab.url || '');
if (!hostname || protocol.isExcludedHostname(hostname, settings.passiveActivityExcludedDomains)) {
return activityTracker.setActiveHostname('', false);
}
return activityTracker.setActiveHostname(hostname, true);
}
function refreshFocusedTab(settings) {
return activeTab().then(function (tab) {
return trackTab(tab, settings);
}).catch(function () {
return activityTracker.setActiveHostname('', false);
});
}
function configurePassiveActivity(settings) {
return activityTracker.setEnabled(settings.passiveActivityEnabled).then(function () {
if (!settings.passiveActivityEnabled) return undefined;
return refreshFocusedTab(settings);
});
}
function flushPassiveActivity() {
return getSettings().then(function (settings) {
if (!settings.passiveActivityEnabled) return activityTracker.setEnabled(false);
return activityTracker.setEnabled(true).then(function () {
return refreshFocusedTab(settings);
}).then(function () {
return activityTracker.flush();
});
});
}
function setupPassiveActivity() {
if (ext.alarms && ext.alarms.create) {
ext.alarms.create(ACTIVITY_FLUSH_ALARM, { periodInMinutes: 5 });
}
if (ext.idle && ext.idle.setDetectionInterval) {
ext.idle.setDetectionInterval(60);
}
return getSettings().then(configurePassiveActivity);
}
function captureFromInfo(kind, info, tab) { function captureFromInfo(kind, info, tab) {
return protocol.buildCapture({ return protocol.buildCapture({
kind: kind, kind: kind,
@@ -94,22 +204,42 @@
return Promise.all([ return Promise.all([
getSettings(), getSettings(),
queue.list(), queue.list(),
ext.storage.local.get(STATUS_KEY) ext.storage.local.get(STATUS_KEY),
activityTracker.getState()
]).then(function (results) { ]).then(function (results) {
return { return {
settings: results[0], settings: results[0],
pendingCount: results[1].length, pendingCount: results[1].length,
pendingActivityCount: results[3].pendingBatches.length,
activityState: results[3],
status: results[2] && results[2][STATUS_KEY] || {} status: results[2] && results[2][STATUS_KEY] || {}
}; };
}); });
} }
function setupContextMenus() { function setupContextMenus() {
if (!ext.contextMenus) return; if (!ext.contextMenus) return Promise.resolve();
ext.contextMenus.removeAll(function () { return Promise.all([getSettings(), loadLocaleCatalogs()]).then(function (results) {
ext.contextMenus.create({ id: 'verstak-capture-page', title: 'Send page to Verstak', contexts: ['page'] }); var locale = i18n.resolveLocale(results[0].language, browserLocale());
ext.contextMenus.create({ id: 'verstak-capture-selection', title: 'Send selection to Verstak', contexts: ['selection'] }); var t = i18n.createTranslator(results[1], locale);
ext.contextMenus.create({ id: 'verstak-capture-link', title: 'Send link to Verstak', contexts: ['link'] }); return new Promise(function (resolve) {
var created = false;
function createMenus() {
if (created) return;
created = true;
ext.contextMenus.create({ id: 'verstak-capture-page', title: t('context.sendPage', null, 'Send page to Verstak'), contexts: ['page'] });
ext.contextMenus.create({ id: 'verstak-capture-selection', title: t('context.sendSelection', null, 'Send selection to Verstak'), contexts: ['selection'] });
ext.contextMenus.create({ id: 'verstak-capture-link', title: t('context.sendLink', null, 'Send link to Verstak'), contexts: ['link'] });
resolve();
}
var removal;
try {
removal = ext.contextMenus.removeAll(createMenus);
} catch (_) {
removal = ext.contextMenus.removeAll();
}
if (removal && typeof removal.then === 'function') removal.then(createMenus, createMenus);
});
}); });
} }
@@ -124,17 +254,24 @@
} }
function handleMessage(message) { function handleMessage(message) {
if (!message || message.type !== 'verstak.capture') return Promise.resolve(undefined); return ready.then(function () {
if (!message || message.type !== 'verstak.capture') return undefined;
if (message.action === 'getState') return getState(); if (message.action === 'getState') return getState();
if (message.action === 'saveSettings') { if (message.action === 'saveSettings') {
return saveSettings(message.settings).then(function () { return saveSettings(message.settings).then(function () {
return setupContextMenus();
}).then(function () {
return getSettings().then(configurePassiveActivity);
}).then(function () {
return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' }); return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' });
}).then(getState); }).then(getState);
} }
if (message.action === 'retryPending') return retryPending().then(getState); if (message.action === 'retryPending') return retryPending().then(getState);
if (message.action === 'retryPendingActivity') return activityTracker.retryPending().then(getState);
return activeTab().then(function (tab) { return activeTab().then(function (tab) {
return sendOrQueue(captureFromInfo(message.kind || 'page', message, tab)); return sendOrQueue(captureFromInfo(message.kind || 'page', message, tab));
}).then(getState); }).then(getState);
});
} }
ext.runtime.onMessage.addListener(function (message, sender, sendResponse) { ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {
@@ -143,4 +280,63 @@
}); });
return true; return true;
}); });
if (ext.tabs && ext.tabs.onActivated) {
ext.tabs.onActivated.addListener(function (info) {
if (!isFocusedWindow(info.windowId) || !ext.tabs.get) return;
activeTabID = info.tabId;
ready.then(function () { return getSettings(); }).then(function (settings) {
return ext.tabs.get(info.tabId).then(function (tab) { return trackTab(tab, settings); });
}).catch(function () {});
});
}
if (ext.tabs && ext.tabs.onUpdated) {
ext.tabs.onUpdated.addListener(function (tabID, changeInfo, tab) {
if (tabID !== activeTabID || (!changeInfo.url && !(tab && tab.url))) return;
ready.then(function () { return getSettings(); }).then(function (settings) {
return trackTab(tab || { id: tabID, url: changeInfo.url }, settings);
}).catch(function () {});
});
}
if (ext.windows && ext.windows.onFocusChanged) {
ext.windows.onFocusChanged.addListener(function (windowID) {
focusedWindowID = windowID;
if (windowID === (ext.windows.WINDOW_ID_NONE == null ? -1 : ext.windows.WINDOW_ID_NONE)) {
activityTracker.setActiveHostname('', false).catch(function () {});
return;
}
ready.then(function () { return getSettings(); }).then(refreshFocusedTab).catch(function () {});
});
}
if (ext.idle && ext.idle.onStateChanged) {
ext.idle.onStateChanged.addListener(function (state) {
if (state === 'active') {
ready.then(function () { return getSettings(); }).then(refreshFocusedTab).catch(function () {});
return;
}
activityTracker.setActiveHostname('', false).catch(function () {});
});
}
if (ext.alarms && ext.alarms.onAlarm) {
ext.alarms.onAlarm.addListener(function (alarm) {
if (alarm && alarm.name === ACTIVITY_FLUSH_ALARM) flushPassiveActivity().catch(function () {});
});
}
if (ext.tabs && ext.tabs.onRemoved) {
ext.tabs.onRemoved.addListener(function (tabID) {
if (tabID === activeTabID) {
activeTabID = null;
activityTracker.setActiveHostname('', false).catch(function () {});
}
});
}
var ready = activityTracker.initialize().then(setupPassiveActivity).catch(function (error) {
console.warn('[verstak] passive activity initialization failed:', error);
});
})(); })();
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/mirivlad/verstak-sdk/main/schemas/hostname-normalization-v1.json",
"title": "Verstak canonical hostname normalization v1 test vectors",
"description": "Canonical hostnames are lowercase ASCII A-labels without a port or trailing DNS dot. Bare hostnames accept DNS names, IPv4, bracketed IPv6, localhost, and internal single-label names. URL inputs accept only HTTP(S). Invalid or excessively long input normalizes to an empty string.",
"version": 1,
"bare": [
{ "input": "example.com", "output": "example.com" },
{ "input": " Example.COM. ", "output": "example.com" },
{ "input": "пример.рф", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "bücher.example", "output": "xn--bcher-kva.example" },
{ "input": "127.0.0.1", "output": "127.0.0.1" },
{ "input": "[2001:db8::1]", "output": "2001:db8::1" },
{ "input": "localhost", "output": "localhost" },
{ "input": "intranet", "output": "intranet" },
{ "input": "", "output": "" },
{ "input": "https://example.com", "output": "" },
{ "input": "example.com:443", "output": "" },
{ "input": "user@example.com", "output": "" },
{ "input": "bad host", "output": "" },
{ "input": "example..com", "output": "" },
{ "input": "example.com..", "output": "" },
{ "input": "127.000.000.001", "output": "" },
{ "input": "[2001:db8::1]:443", "output": "" },
{ "input": "a...............................................................example", "output": "" }
],
"url": [
{ "input": "https://пример.рф/path", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "http://Example.COM.:8080/path", "output": "example.com" },
{ "input": "https://[2001:db8::1]/", "output": "2001:db8::1" },
{ "input": "ftp://example.com/path", "output": "" },
{ "input": "not a URL", "output": "" }
]
}
+86
View File
@@ -0,0 +1,86 @@
(function (root) {
'use strict';
var MAX_DNS_HOSTNAME_LENGTH = 253;
var MAX_DNS_LABEL_LENGTH = 63;
function trimmedString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeIPv4(value) {
var parts = value.split('.');
if (parts.length !== 4) return '';
for (var i = 0; i < parts.length; i += 1) {
if (!/^(0|[1-9][0-9]{0,2})$/.test(parts[i])) return '';
if (Number(parts[i]) > 255) return '';
}
return parts.join('.');
}
function normalizeIPv6(value) {
try {
var parsed = new URL('http://[' + value + ']');
var hostname = parsed.hostname;
if (hostname[0] !== '[' || hostname[hostname.length - 1] !== ']') return '';
return hostname.slice(1, -1).toLowerCase();
} catch (_) {
return '';
}
}
function normalizeDNS(value) {
var ascii;
try {
ascii = new URL('http://' + value).hostname.toLowerCase();
} catch (_) {
return '';
}
if (!ascii || ascii[0] === '[' || ascii.indexOf(':') !== -1) return '';
if (ascii[ascii.length - 1] === '.') ascii = ascii.slice(0, -1);
if (!ascii || ascii[ascii.length - 1] === '.' || ascii.length > MAX_DNS_HOSTNAME_LENGTH) return '';
var labels = ascii.split('.');
for (var i = 0; i < labels.length; i += 1) {
if (labels[i].length === 0 || labels[i].length > MAX_DNS_LABEL_LENGTH) return '';
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(labels[i])) return '';
}
return ascii;
}
function normalizeHostnameV1(input) {
var value = trimmedString(input);
if (!value || /[\s\\/?#@]/.test(value)) return '';
if (value[0] === '[' || value[value.length - 1] === ']') {
if (value[0] !== '[' || value[value.length - 1] !== ']') return '';
return normalizeIPv6(value.slice(1, -1));
}
if (value.indexOf(':') !== -1) return '';
if (value[value.length - 1] === '.') value = value.slice(0, -1);
if (!value || value[value.length - 1] === '.') return '';
if (/^[0-9.]+$/.test(value)) return normalizeIPv4(value);
return normalizeDNS(value);
}
function normalizeURLHostnameV1(input) {
var value = trimmedString(input);
if (!value) return '';
try {
var url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
return normalizeHostnameV1(url.hostname);
} catch (_) {
return '';
}
}
var api = {
normalizeHostnameV1: normalizeHostnameV1,
normalizeURLHostnameV1: normalizeURLHostnameV1
};
root.VerstakBrowser = Object.assign(root.VerstakBrowser || {}, api);
if (typeof module !== 'undefined') module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);
+54
View File
@@ -0,0 +1,54 @@
(function (root, factory) {
'use strict';
var api = factory();
root.VerstakBrowserI18n = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
var PREFERENCES = { system: true, en: true, ru: true };
function normalizePreference(value) {
value = String(value || '').trim().toLowerCase();
return PREFERENCES[value] ? value : 'system';
}
function resolveLocale(preference, systemLocale) {
preference = normalizePreference(preference);
if (preference === 'en' || preference === 'ru') return preference;
systemLocale = String(systemLocale || '').trim().toLowerCase();
return systemLocale === 'ru' || systemLocale.indexOf('ru-') === 0 ? 'ru' : 'en';
}
function interpolate(message, params) {
if (!params) return message;
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, function (placeholder, name) {
return Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder;
});
}
function loadCatalogs(loadJSON) {
return Promise.all([loadJSON('en'), loadJSON('ru')]).then(function (catalogs) {
return { en: catalogs[0] || {}, ru: catalogs[1] || {} };
});
}
function createTranslator(catalogs, locale) {
catalogs = catalogs || {};
locale = locale === 'ru' ? 'ru' : 'en';
return function translate(key, params, fallback) {
var message = catalogs[locale] && catalogs[locale][key];
if (message == null && catalogs.en) message = catalogs.en[key];
if (message == null) message = fallback == null ? key : fallback;
return interpolate(String(message), params);
};
}
return {
normalizePreference: normalizePreference,
resolveLocale: resolveLocale,
loadCatalogs: loadCatalogs,
createTranslator: createTranslator
};
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="26" fill="#1c2f4a"/>
<path d="M28 34h72v18H28zm0 31h72v29H28z" fill="#72d2b3"/>
<path d="M41 52v13m23-13v13m23-13v13" stroke="#1c2f4a" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

+40
View File
@@ -0,0 +1,40 @@
{
"popup.subtitle": "Browser Inbox",
"label.receiver": "Receiver",
"label.pending": "Pending",
"label.activityPending": "Activity batches",
"label.url": "URL",
"label.file": "File",
"label.receiverUrl": "Receiver URL",
"label.pairingToken": "Pairing token",
"label.language": "Language",
"label.passiveActivity": "Track active domain time",
"label.passiveActivityExclusions": "Excluded domains",
"action.sendPage": "Send Page",
"action.sendFile": "Send File",
"action.retryPending": "Retry Pending",
"action.save": "Save",
"hint.contextMenu": "Selection and link captures are available from the page context menu.",
"hint.passiveActivityDisclosure": "Optional. Collects only the active domain and time spent there. It never sends page URLs, titles, text, keystrokes, or page contents.",
"receiver.online": "Online",
"receiver.offline": "Offline",
"receiver.unknown": "Unknown",
"language.system": "System",
"language.en": "English",
"language.ru": "Russian",
"status.sending": "Sending...",
"status.queued": "Queued until Verstak is available",
"status.done": "Done",
"status.readingFile": "Reading file...",
"status.saved": "Saved",
"error.chooseFile": "Choose a file first",
"error.fileTooLarge": "File is too large for browser capture",
"error.invalidReceiverUrl": "Receiver URL must start with http:// or https://",
"error.loadState": "Could not load the extension state. Please try again.",
"error.saveSettings": "Could not save settings. Please try again.",
"error.sendCapture": "Could not send the capture. Please try again.",
"error.readFile": "Could not read the file. Choose it again.",
"context.sendPage": "Send page to Verstak",
"context.sendSelection": "Send selection to Verstak",
"context.sendLink": "Send link to Verstak"
}
+40
View File
@@ -0,0 +1,40 @@
{
"popup.subtitle": "Входящие из браузера",
"label.receiver": "Приёмник",
"label.pending": "В очереди",
"label.activityPending": "Пакеты активности",
"label.url": "URL",
"label.file": "Файл",
"label.receiverUrl": "URL приёмника",
"label.pairingToken": "Токен сопряжения",
"label.language": "Язык",
"label.passiveActivity": "Учитывать время на активном домене",
"label.passiveActivityExclusions": "Исключённые домены",
"action.sendPage": "Отправить страницу",
"action.sendFile": "Отправить файл",
"action.retryPending": "Повторить отправку",
"action.save": "Сохранить",
"hint.contextMenu": "Выделение и ссылки можно отправить через контекстное меню страницы.",
"hint.passiveActivityDisclosure": "Необязательно. Собираются только активный домен и время на нём. URL страниц, заголовки, текст, нажатия клавиш и содержимое страниц не отправляются.",
"receiver.online": "Доступен",
"receiver.offline": "Недоступен",
"receiver.unknown": "Неизвестно",
"language.system": "Системный",
"language.en": "Английский",
"language.ru": "Русский",
"status.sending": "Отправка...",
"status.queued": "В очереди до запуска Верстака",
"status.done": "Готово",
"status.readingFile": "Чтение файла...",
"status.saved": "Сохранено",
"error.chooseFile": "Сначала выберите файл",
"error.fileTooLarge": "Файл слишком велик для отправки из браузера",
"error.invalidReceiverUrl": "URL приёмника должен начинаться с http:// или https://",
"error.loadState": "Не удалось загрузить данные расширения. Повторите попытку.",
"error.saveSettings": "Не удалось сохранить настройки. Повторите попытку.",
"error.sendCapture": "Не удалось отправить материал. Повторите попытку.",
"error.readFile": "Не удалось прочитать файл. Выберите его ещё раз.",
"context.sendPage": "Отправить страницу в Верстак",
"context.sendSelection": "Отправить выделение в Верстак",
"context.sendLink": "Отправить ссылку в Верстак"
}
+51
View File
@@ -126,11 +126,62 @@ input {
color: #e5e7eb; color: #e5e7eb;
} }
textarea {
box-sizing: border-box;
width: 100%;
margin-top: 5px;
border: 1px solid #374151;
border-radius: 4px;
padding: 7px 8px;
resize: vertical;
background: #0f172a;
color: #e5e7eb;
font: inherit;
}
select {
box-sizing: border-box;
width: 100%;
margin-top: 5px;
border: 1px solid #374151;
border-radius: 4px;
padding: 7px 30px 7px 8px;
appearance: none;
background-color: #0f172a;
background-image: linear-gradient(45deg, transparent 50%, #94a3b8 50%), linear-gradient(135deg, #94a3b8 50%, transparent 50%);
background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
color: #e5e7eb;
}
select option {
background: #0f172a;
color: #e5e7eb;
}
input[type="file"] { input[type="file"] {
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
} }
.tracking-toggle {
display: flex;
align-items: center;
gap: 7px;
color: #e8ecf3 !important;
cursor: pointer;
}
.tracking-toggle input {
width: auto;
margin: 0;
}
.tracking-settings .hint {
margin: 8px 0 4px;
}
.hint { .hint {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
+29 -8
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -17,15 +17,19 @@
<section class="panel"> <section class="panel">
<div class="row"> <div class="row">
<span>Receiver</span> <span id="receiver-label">Receiver</span>
<strong id="receiver-state">Unknown</strong> <strong id="receiver-state">Unknown</strong>
</div> </div>
<div class="row"> <div class="row">
<span>Pending</span> <span id="pending-label">Pending</span>
<strong id="pending-count">0</strong> <strong id="pending-count">0</strong>
</div> </div>
<div class="row">
<span id="activity-pending-label">Activity batches</span>
<strong id="activity-pending-count">0</strong>
</div>
<div class="row url-row"> <div class="row url-row">
<span>URL</span> <span id="url-label">URL</span>
<code id="receiver-url"></code> <code id="receiver-url"></code>
</div> </div>
</section> </section>
@@ -37,21 +41,38 @@
</section> </section>
<section class="file-picker"> <section class="file-picker">
<label for="file-input">File</label> <label id="file-label" for="file-input">File</label>
<input id="file-input" type="file"> <input id="file-input" type="file">
</section> </section>
<section class="settings"> <section class="settings">
<label for="receiver-input">Receiver URL</label> <label id="receiver-url-label" for="receiver-input">Receiver URL</label>
<input id="receiver-input" type="url" spellcheck="false"> <input id="receiver-input" type="url" spellcheck="false">
<label for="receiver-token-input">Pairing token</label> <label id="receiver-token-label" for="receiver-token-input">Pairing token</label>
<input id="receiver-token-input" type="password" spellcheck="false" autocomplete="off"> <input id="receiver-token-input" type="password" spellcheck="false" autocomplete="off">
<label id="language-label" for="language-select">Language</label>
<select id="language-select">
<option id="language-system-option" value="system">System</option>
<option id="language-en-option" value="en">English</option>
<option id="language-ru-option" value="ru">Русский</option>
</select>
<button id="save-settings" class="secondary">Save</button> <button id="save-settings" class="secondary">Save</button>
</section> </section>
<p class="hint">Selection and link captures are available from the page context menu.</p> <section class="settings tracking-settings">
<label class="tracking-toggle" for="passive-activity-enabled">
<input id="passive-activity-enabled" type="checkbox">
<span id="passive-activity-label">Track active domain time</span>
</label>
<p id="passive-activity-disclosure" class="hint">Optional. Collects only the active domain and time spent there. It never sends page URLs, titles, text, keystrokes, or page contents.</p>
<label id="passive-activity-exclusions-label" for="passive-activity-exclusions">Excluded domains</label>
<textarea id="passive-activity-exclusions" rows="3" spellcheck="false" placeholder="youtube.com"></textarea>
</section>
<p id="context-menu-hint" class="hint">Selection and link captures are available from the page context menu.</p>
<p id="status"></p> <p id="status"></p>
</main> </main>
<script src="../i18n.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </html>
+163 -56
View File
@@ -2,16 +2,100 @@
'use strict'; 'use strict';
var ext = typeof browser !== 'undefined' ? browser : chrome; var ext = typeof browser !== 'undefined' ? browser : chrome;
var i18n = globalThis.VerstakBrowserI18n;
var statusEl = document.getElementById('status'); var statusEl = document.getElementById('status');
var receiverStateEl = document.getElementById('receiver-state'); var receiverStateEl = document.getElementById('receiver-state');
var receiverUrlEl = document.getElementById('receiver-url'); var receiverUrlEl = document.getElementById('receiver-url');
var receiverInputEl = document.getElementById('receiver-input'); var receiverInputEl = document.getElementById('receiver-input');
var receiverTokenInputEl = document.getElementById('receiver-token-input'); var receiverTokenInputEl = document.getElementById('receiver-token-input');
var languageSelectEl = document.getElementById('language-select');
var fileInputEl = document.getElementById('file-input'); var fileInputEl = document.getElementById('file-input');
var pendingCountEl = document.getElementById('pending-count'); var pendingCountEl = document.getElementById('pending-count');
var pendingActivityCountEl = document.getElementById('activity-pending-count');
var statusDotEl = document.getElementById('status-dot'); var statusDotEl = document.getElementById('status-dot');
var passiveActivityEnabledEl = document.getElementById('passive-activity-enabled');
var passiveActivityExclusionsEl = document.getElementById('passive-activity-exclusions');
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
var MAX_FILE_BYTES = 8 * 1024 * 1024; var MAX_FILE_BYTES = 8 * 1024 * 1024;
var catalogs = { en: {}, ru: {} };
var currentPreference = 'system';
var currentState = {};
var t = i18n.createTranslator(catalogs, 'en');
var staticText = {
subtitle: 'popup.subtitle',
'receiver-label': 'label.receiver',
'pending-label': 'label.pending',
'activity-pending-label': 'label.activityPending',
'url-label': 'label.url',
'file-label': 'label.file',
'receiver-url-label': 'label.receiverUrl',
'receiver-token-label': 'label.pairingToken',
'language-label': 'label.language',
'capture-page': 'action.sendPage',
'capture-file': 'action.sendFile',
retry: 'action.retryPending',
'save-settings': 'action.save',
'context-menu-hint': 'hint.contextMenu',
'language-system-option': 'language.system',
'language-en-option': 'language.en',
'language-ru-option': 'language.ru',
'passive-activity-label': 'label.passiveActivity',
'passive-activity-disclosure': 'hint.passiveActivityDisclosure',
'passive-activity-exclusions-label': 'label.passiveActivityExclusions'
};
function browserLocale() {
try {
if (ext.i18n && ext.i18n.getUILanguage) return ext.i18n.getUILanguage();
} catch (_) {}
return typeof navigator !== 'undefined' ? navigator.language : 'en';
}
function loadCatalogs() {
return i18n.loadCatalogs(function (locale) {
return fetch(ext.runtime.getURL('locales/' + locale + '.json')).then(function (response) {
if (!response.ok) throw new Error('catalog load failed: ' + locale);
return response.json();
});
}).catch(function (error) {
console.warn('[verstak] localization catalogs unavailable:', error);
return { en: {}, ru: {} };
});
}
function applyReceiverState(state) {
var reachable = state && state.status && state.status.receiverReachable;
if (reachable === true) {
receiverStateEl.textContent = t('receiver.online', null, 'Online');
receiverStateEl.className = 'online';
statusDotEl.className = 'dot online';
} else if (reachable === false) {
receiverStateEl.textContent = t('receiver.offline', null, 'Offline');
receiverStateEl.className = 'offline';
statusDotEl.className = 'dot offline';
} else {
receiverStateEl.textContent = t('receiver.unknown', null, 'Unknown');
receiverStateEl.className = '';
statusDotEl.className = 'dot unknown';
}
}
function applyLocale(preference) {
currentPreference = i18n.normalizePreference(preference);
var locale = i18n.resolveLocale(currentPreference, browserLocale());
t = i18n.createTranslator(catalogs, locale);
document.documentElement.lang = locale;
languageSelectEl.value = currentPreference;
Object.keys(staticText).forEach(function (id) {
var element = document.getElementById(id);
if (element) {
if (element.__verstakI18nFallback == null) element.__verstakI18nFallback = element.textContent;
element.textContent = t(staticText[id], null, element.__verstakI18nFallback);
}
});
applyReceiverState(currentState);
}
function arrayBufferToBase64(buffer) { function arrayBufferToBase64(buffer) {
var bytes = new Uint8Array(buffer); var bytes = new Uint8Array(buffer);
@@ -34,6 +118,11 @@
statusEl.textContent = text; statusEl.textContent = text;
} }
function reportError(key, fallback, error) {
console.warn('[verstak.popup] request failed:', error);
setStatus(t(key, null, fallback));
}
function request(message) { function request(message) {
return Promise.resolve(ext.runtime.sendMessage(message)).then(function (result) { return Promise.resolve(ext.runtime.sendMessage(message)).then(function (result) {
if (result && result.error) throw new Error(result.error); if (result && result.error) throw new Error(result.error);
@@ -42,48 +131,69 @@
} }
function render(state) { function render(state) {
state = state || {}; currentState = state || {};
var settings = state.settings || {}; var settings = currentState.settings || {};
var status = state.status || {}; pendingCountEl.textContent = String(currentState.pendingCount || 0);
var reachable = status.receiverReachable; pendingActivityCountEl.textContent = String(currentState.pendingActivityCount || 0);
pendingCountEl.textContent = String(state.pendingCount || 0);
receiverUrlEl.textContent = settings.receiverUrl || ''; receiverUrlEl.textContent = settings.receiverUrl || '';
if (document.activeElement !== receiverInputEl) { if (document.activeElement !== receiverInputEl) receiverInputEl.value = settings.receiverUrl || '';
receiverInputEl.value = settings.receiverUrl || ''; if (document.activeElement !== receiverTokenInputEl) receiverTokenInputEl.value = settings.receiverToken || '';
} passiveActivityEnabledEl.checked = settings.passiveActivityEnabled === true;
if (document.activeElement !== receiverTokenInputEl) { if (document.activeElement !== passiveActivityExclusionsEl) {
receiverTokenInputEl.value = settings.receiverToken || ''; passiveActivityExclusionsEl.value = Array.isArray(settings.passiveActivityExcludedDomains)
} ? settings.passiveActivityExcludedDomains.join('\n')
: '';
if (reachable === true) {
receiverStateEl.textContent = 'Online';
receiverStateEl.className = 'online';
statusDotEl.className = 'dot online';
} else if (reachable === false) {
receiverStateEl.textContent = 'Offline';
receiverStateEl.className = 'offline';
statusDotEl.className = 'dot offline';
} else {
receiverStateEl.textContent = 'Unknown';
receiverStateEl.className = '';
statusDotEl.className = 'dot unknown';
} }
applyReceiverState(currentState);
} }
function refresh() { function refresh() {
return request({ type: 'verstak.capture', action: 'getState' }).then(render).catch(function (err) { return request({ type: 'verstak.capture', action: 'getState' }).then(function (state) {
setStatus(err && err.message ? err.message : String(err)); render(state);
applyLocale(state.settings && state.settings.language || 'system');
return state;
}).catch(function (error) {
reportError('error.loadState', 'Could not load the extension state. Please try again.', error);
});
}
function currentSettings() {
return {
receiverUrl: receiverInputEl.value.trim(),
receiverToken: receiverTokenInputEl.value.trim(),
language: currentPreference,
passiveActivityEnabled: passiveActivityEnabledEl.checked === true,
passiveActivityExcludedDomains: passiveActivityExclusionsEl.value.split(/[\n,]/).map(function (value) {
return value.trim();
}).filter(Boolean)
};
}
function saveCurrentSettings(successMessage) {
return request({
type: 'verstak.capture',
action: 'saveSettings',
settings: currentSettings()
}).then(function (state) {
render(state);
if (successMessage) setStatus(t('status.saved', null, 'Saved'));
return state;
}).catch(function (error) {
reportError('error.saveSettings', 'Could not save settings. Please try again.', error);
}); });
} }
function send(message) { function send(message) {
setStatus('Sending...'); setStatus(t('status.sending', null, 'Sending...'));
request(message).then(function (state) { request(message).then(function (state) {
render(state); render(state);
if (state.status && state.status.lastResult === 'queued') setStatus('Queued until Verstak is available'); if (state.status && state.status.lastResult === 'queued') {
else setStatus('Done'); setStatus(t('status.queued', null, 'Queued until Verstak is available'));
}).catch(function (err) { } else {
setStatus(err && err.message ? err.message : String(err)); setStatus(t('status.done', null, 'Done'));
}
}).catch(function (error) {
reportError('error.sendCapture', 'Could not send the capture. Please try again.', error);
}); });
} }
@@ -94,27 +204,26 @@
document.getElementById('capture-file').addEventListener('click', function () { document.getElementById('capture-file').addEventListener('click', function () {
var file = fileInputEl.files && fileInputEl.files[0]; var file = fileInputEl.files && fileInputEl.files[0];
if (!file) { if (!file) {
setStatus('Choose a text file first'); setStatus(t('error.chooseFile', null, 'Choose a file first'));
return; return;
} }
if (file.size > MAX_FILE_BYTES) { if (file.size > MAX_FILE_BYTES) {
setStatus('File is too large for browser capture'); setStatus(t('error.fileTooLarge', null, 'File is too large for browser capture'));
return; return;
} }
setStatus('Reading file...'); setStatus(t('status.readingFile', null, 'Reading file...'));
Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) { Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) {
var content = results[1] || '';
send({ send({
type: 'verstak.capture', type: 'verstak.capture',
kind: 'file', kind: 'file',
fileName: file.name, fileName: file.name,
fileMime: file.type || '', fileMime: file.type || '',
fileSize: file.size, fileSize: file.size,
fileText: content, fileText: results[1] || '',
fileDataBase64: arrayBufferToBase64(results[0]) fileDataBase64: arrayBufferToBase64(results[0])
}); });
}).catch(function (err) { }).catch(function (error) {
setStatus(err && err.message ? err.message : String(err)); reportError('error.readFile', 'Could not read the file. Choose it again.', error);
}); });
}); });
@@ -122,24 +231,22 @@
send({ type: 'verstak.capture', action: 'retryPending' }); send({ type: 'verstak.capture', action: 'retryPending' });
}); });
document.getElementById('save-settings').addEventListener('click', function () { languageSelectEl.addEventListener('change', function () {
var receiverUrl = receiverInputEl.value.trim(); applyLocale(languageSelectEl.value);
var receiverToken = receiverTokenInputEl.value.trim(); saveCurrentSettings(true);
if (!/^https?:\/\//.test(receiverUrl)) {
setStatus('Receiver URL must start with http:// or https://');
return;
}
request({
type: 'verstak.capture',
action: 'saveSettings',
settings: { receiverUrl: receiverUrl, receiverToken: receiverToken }
}).then(function (state) {
render(state);
setStatus('Saved');
}).catch(function (err) {
setStatus(err && err.message ? err.message : String(err));
});
}); });
refresh(); document.getElementById('save-settings').addEventListener('click', function () {
if (!/^https?:\/\//.test(receiverInputEl.value.trim())) {
setStatus(t('error.invalidReceiverUrl', null, 'Receiver URL must start with http:// or https://'));
return;
}
saveCurrentSettings(true);
});
loadCatalogs().then(function (loadedCatalogs) {
catalogs = loadedCatalogs;
applyLocale('system');
return refresh();
});
})(); })();
+5 -5
View File
@@ -3,6 +3,7 @@
var CAPTURE_SCHEMA_VERSION = 1; var CAPTURE_SCHEMA_VERSION = 1;
var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures'; var DEFAULT_RECEIVER_URL = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
var DEFAULT_ACTIVITY_URL = 'http://127.0.0.1:47731/api/browser-activity/v1/batches';
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024; var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
var MAX_FILE_BYTES = 8 * 1024 * 1024; var MAX_FILE_BYTES = 8 * 1024 * 1024;
@@ -29,11 +30,9 @@
} }
function hostname(url) { function hostname(url) {
try { return root.VerstakBrowser.normalizeURLHostnameV1
return new URL(url).hostname; ? root.VerstakBrowser.normalizeURLHostnameV1(url)
} catch (_) { : '';
return '';
}
} }
function buildCapture(input) { function buildCapture(input) {
@@ -100,6 +99,7 @@
var api = { var api = {
CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION, CAPTURE_SCHEMA_VERSION: CAPTURE_SCHEMA_VERSION,
DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL, DEFAULT_RECEIVER_URL: DEFAULT_RECEIVER_URL,
DEFAULT_ACTIVITY_URL: DEFAULT_ACTIVITY_URL,
MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH, MAX_FILE_TEXT_LENGTH: MAX_FILE_TEXT_LENGTH,
MAX_FILE_BYTES: MAX_FILE_BYTES, MAX_FILE_BYTES: MAX_FILE_BYTES,
buildCapture: buildCapture, buildCapture: buildCapture,