23 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
44 changed files with 1530 additions and 510 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
+83 -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
@@ -109,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.2", "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"
@@ -1,328 +0,0 @@
# Browser Extension Localization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add persisted `System / English / Russian` language selection to the browser extension, localizing popup chrome and context menus in Chromium and Firefox.
**Architecture:** A framework-independent `shared/i18n.js` resolves preferences and loads extension-owned JSON catalogs. Popup and background both consume it; background owns persistence and context-menu recreation, while popup performs immediate DOM translation and sends settings updates.
**Tech Stack:** WebExtension APIs, plain ES5-compatible JavaScript, JSON catalogs, Node `assert`/`vm` tests, existing Node build script.
## Global Constraints
- Store only `system`, `en`, or `ru`; missing/invalid values normalize to `system`.
- `ru` and `ru-*` browser UI locales resolve to Russian; all other system locales resolve to English.
- Keep the extension language independent from desktop settings.
- Add no runtime dependency and no manifest-localization scope.
- Never translate receiver URLs, tokens, file names, user content, or raw receiver error text.
- Preserve receiver URL/token values when language changes.
- Both browser distributions must contain `i18n.js`, `locales/en.json`, and `locales/ru.json`.
---
### Task 1: Shared Locale Runtime and Catalog Contract
**Files:**
- Create: `shared/i18n.js`
- Create: `shared/locales/en.json`
- Create: `shared/locales/ru.json`
- Create: `scripts/test-i18n.js`
- Modify: `package.json`
**Interfaces:**
- Produces: `globalThis.VerstakBrowserI18n.normalizePreference(value)`.
- Produces: `globalThis.VerstakBrowserI18n.resolveLocale(preference, systemLocale)`.
- Produces: `globalThis.VerstakBrowserI18n.loadCatalogs(loadJSON)` returning `Promise<{en,ru}>`.
- Produces: `globalThis.VerstakBrowserI18n.createTranslator(catalogs, locale)` returning `t(key, params, fallback)`.
- [ ] **Step 1: Write the failing runtime/catalog test**
Create `scripts/test-i18n.js` that loads `shared/i18n.js` in `vm`, reads both JSON catalogs, and asserts:
```js
assert.strictEqual(i18n.normalizePreference('de'), 'system');
assert.strictEqual(i18n.resolveLocale('system', 'ru-RU'), 'ru');
assert.strictEqual(i18n.resolveLocale('system', 'uk-UA'), 'en');
assert.strictEqual(i18n.resolveLocale('en', 'ru-RU'), 'en');
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'));
assert.strictEqual(i18n.createTranslator({ en, ru }, 'ru')('status.queued'), 'В очереди до запуска Верстака');
assert.strictEqual(i18n.createTranslator({ en, ru }, 'en')('error.value', { error: 'offline' }), 'Error: offline');
```
- [ ] **Step 2: Run the test and confirm RED**
Run: `node scripts/test-i18n.js`
Expected: FAIL because `shared/i18n.js` and catalogs do not exist.
- [ ] **Step 3: Implement the minimal shared runtime**
Implement an IIFE exporting the four functions. `loadCatalogs(loadJSON)` must call `loadJSON('en')` and `loadJSON('ru')`; translation lookup order is selected catalog, English catalog, explicit fallback, key. Interpolation replaces `{name}` only when `params` owns that property.
Catalogs must define identical keys for:
```text
popup.subtitle
label.receiver
label.pending
label.url
label.file
label.receiverUrl
label.pairingToken
label.language
action.sendPage
action.sendFile
action.retryPending
action.save
hint.contextMenu
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
error.value
context.sendPage
context.sendSelection
context.sendLink
```
- [ ] **Step 4: Add the test to `npm test` and verify GREEN**
Set:
```json
"test": "node scripts/test-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js"
```
Run: `node scripts/test-i18n.js`
Expected: `browser extension localization runtime tests passed`.
- [ ] **Step 5: Commit the shared contract**
```bash
git add shared/i18n.js shared/locales scripts/test-i18n.js package.json
git commit -m "feat: add browser extension localization runtime"
```
---
### Task 2: Persisted Language and Localized Context Menus
**Files:**
- Modify: `shared/background.js`
- Modify: `chromium/manifest.json`
- Modify: `firefox/manifest.json`
- Create: `scripts/test-background-i18n.js`
**Interfaces:**
- Consumes: `VerstakBrowserI18n.loadCatalogs`, `normalizePreference`, `resolveLocale`, `createTranslator`.
- Produces: persisted `settings.language` and context-menu recreation after `saveSettings`.
- [ ] **Step 1: Write the failing background test**
Load `protocol.js`, `queue.js`, `i18n.js`, and `background.js` into `vm` with fake storage/context-menu APIs. Seed `settings.language = 'ru'`, trigger `runtime.onInstalled`, and assert:
```js
assert.deepStrictEqual(menuTitles, [
'Отправить страницу в Верстак',
'Отправить выделение в Верстак',
'Отправить ссылку в Верстак',
]);
```
Send a `saveSettings` message with `{ receiverUrl, receiverToken, language: 'en' }`, then assert stored receiver fields are unchanged and menu titles become English.
- [ ] **Step 2: Run the test and confirm RED**
Run: `node scripts/test-background-i18n.js`
Expected: FAIL because background defaults contain no language and menu titles are literal English.
- [ ] **Step 3: Implement background localization**
Add `language: 'system'` to `DEFAULT_SETTINGS`. Normalize it in `getSettings`/`saveSettings`. Load catalogs with:
```js
function loadLocaleCatalogs() {
return 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();
});
});
}
```
Make `setupContextMenus()` resolve settings plus catalogs, choose
`ext.i18n.getUILanguage()` with English fallback, and create the three menu
titles via `t('context.sendPage')`, `t('context.sendSelection')`, and
`t('context.sendLink')`. Call it after language settings are saved.
- [ ] **Step 4: Load `i18n.js` before background code in both manifests**
- Chromium service worker remains bundled by the build concatenation order.
- Firefox `background.scripts` becomes `protocol.js`, `api.js`, `queue.js`, `i18n.js`, `background.js`.
Also append `node scripts/test-background-i18n.js` to the `test` script in
`package.json` now that the test file exists.
- [ ] **Step 5: Verify and commit**
Run: `node scripts/test-background-i18n.js`
Expected: `browser extension background localization tests passed`.
```bash
git add shared/background.js chromium/manifest.json firefox/manifest.json scripts/test-background-i18n.js
git commit -m "feat: localize browser extension context menus"
```
---
### Task 3: Popup Language Selector and Live Translation
**Files:**
- Modify: `shared/popup/popup.html`
- Modify: `shared/popup/popup.js`
- Modify: `shared/popup/popup.css`
- Modify: `scripts/test-popup-settings.js`
**Interfaces:**
- Consumes: state `settings.language` from background and the shared translator.
- Produces: immediate popup translation plus a `saveSettings` message containing receiver URL, token, and language.
- [ ] **Step 1: Extend the popup test and confirm RED**
Add fake elements for every localized ID plus `language-select`, mock catalog fetches, `browser.i18n.getUILanguage()`, and `document.documentElement.lang`. Assert initial `system` with `ru-RU` renders Russian. Change select value to `en`, dispatch `change`, flush promises, then assert:
```js
assert.strictEqual(elements['capture-page'].textContent, 'Send Page');
assert.strictEqual(elements['receiver-state'].textContent, 'Unknown');
assert.strictEqual(document.documentElement.lang, 'en');
assert.strictEqual(savedSettings.language, 'en');
assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl);
assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken);
```
- [ ] **Step 2: Add popup markup**
Load `../i18n.js` before `popup.js`, give all static user-visible elements stable IDs, and add:
```html
<label id="language-label" for="language-select">Language</label>
<select id="language-select">
<option value="system">System</option>
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
```
- [ ] **Step 3: Implement asynchronous popup initialization and live switch**
Load both catalogs through `ext.runtime.getURL`, resolve the locale from saved
preference plus browser UI language, and apply all static text through one
`applyLocale(preference)` function. `render(state)` localizes
online/offline/unknown. Known statuses and validations use catalog keys; raw
error messages are wrapped only when needed by `error.value`.
The select `change` handler must call `applyLocale` before awaiting background
storage, then send all three settings fields. The existing Save button also
sends the selected language.
- [ ] **Step 4: Style the select consistently and verify GREEN**
Share the existing input box model/colors with `select` and keep the popup
minimum width unchanged.
Run: `node scripts/test-popup-settings.js`
Expected: `browser extension popup localization/settings tests passed`.
- [ ] **Step 5: Commit**
```bash
git add shared/popup scripts/test-popup-settings.js
git commit -m "feat: add browser extension language selector"
```
---
### Task 4: Distribution Packaging, Documentation, and Full Verification
**Files:**
- Modify: `scripts/build-extension.js`
- Modify: `README.md`
- Test: `scripts/test-i18n.js`
- Test: `scripts/test-background-i18n.js`
- Test: `scripts/test-popup-settings.js`
**Interfaces:**
- Consumes: shared runtime/catalogs and updated manifests.
- Produces: complete `dist/chromium` and `dist/firefox` extension directories.
- [ ] **Step 1: Add failing build-content assertions**
Extend `scripts/test-i18n.js` to run after build when `dist` exists and assert
both targets contain:
```text
i18n.js
locales/en.json
locales/ru.json
```
- [ ] **Step 2: Update the build**
For Chromium, concatenate `shared/i18n.js` before `shared/background.js` and
also copy it as `dist/chromium/i18n.js` for popup use. For Firefox, copy
`shared/i18n.js` beside background scripts. Copy both JSON catalogs to each
target's `locales/` directory.
- [ ] **Step 3: Document the selector**
Add a README section stating that popup settings provide persisted
`System / English / Russian`, that System uses browser UI language, and that
extension/desktop preferences are independent.
- [ ] **Step 4: Run complete verification**
```bash
npm test
npm run build
npm test
git diff --check
```
Expected:
- protocol, runtime, popup, and background tests pass;
- both browser distributions build;
- post-build content assertions pass;
- no whitespace errors.
- [ ] **Step 5: Commit packaging and docs**
```bash
git add scripts/build-extension.js README.md scripts/test-i18n.js
git commit -m "build: package browser extension locale catalogs"
```
@@ -1,114 +0,0 @@
# Browser Extension Localization Design
## Goal
Add English and Russian localization to the Verstak browser extension with a
persisted manual `System / English / Russian` language selector. The extension
remains independent from the desktop application's language preference.
## Scope
The change covers:
- all user-visible popup labels, hints, statuses, validation messages, and
receiver-state labels;
- the three browser context-menu titles;
- persisted language selection;
- Chromium and Firefox build packaging;
- automated tests for locale resolution, persistence, live popup updates, and
context-menu localization.
Manifest name and description localization is not part of this milestone. The
existing English manifest values remain the fallback in both browser builds.
## Locale Model
The stored setting is named `language` and accepts only:
- `system` (default);
- `en`;
- `ru`.
`system` resolves the browser UI language through
`browser.i18n.getUILanguage()` / `chrome.i18n.getUILanguage()`. Popup code may
fall back to `navigator.language` when the extension i18n API is unavailable.
`ru` and `ru-*` resolve to Russian; every other value resolves to English.
Invalid or missing stored preferences normalize to `system`.
## Catalogs and Runtime
The extension owns framework-independent JSON catalogs:
```text
shared/locales/en.json
shared/locales/ru.json
```
A small shared runtime provides:
- preference normalization;
- system-locale resolution;
- key lookup with English and literal fallback;
- `{parameter}` interpolation.
The runtime has no dependency on desktop, the plugin SDK, DOM APIs, or external
packages. It is loaded by both popup and background scripts.
Both catalogs must contain identical string-only keys. The build copies them
into `dist/chromium/locales` and `dist/firefox/locales` together with the shared
runtime.
## Popup Behavior
The existing settings section gains a labeled language `<select>` with
`system`, `en`, and `ru` options. Selecting a value:
1. updates all popup chrome immediately without reopening the popup;
2. sends the full settings patch to background storage;
3. preserves receiver URL and pairing token values;
4. shows the localized saved/error state.
The `<html lang>` attribute is updated to the resolved locale. User data,
receiver URLs, tokens, file names, and backend-provided error text are not
translated.
## Background and Context Menus
`DEFAULT_SETTINGS` gains `language: "system"`. Context-menu creation resolves
the effective locale from stored settings and creates localized page,
selection, and link titles.
After a language setting is saved, background recreates the context menus so
the new language applies without reinstalling or reloading the extension.
Failure to access the i18n API falls back to English and must not block capture
or settings operations.
## Error Handling
- Missing catalog keys fall back to English, then to the provided literal/key.
- Invalid stored language values behave as `system`.
- Catalog or browser-language detection failure falls back to English.
- Existing receiver and capture errors continue to be shown; known extension
validation/status chrome around them is localized.
## Verification
Tests are written before production changes and cover:
- `ru-*` and non-Russian system-language resolution;
- explicit language override and invalid-preference normalization;
- English/Russian catalog parity and interpolation;
- popup language persistence without losing receiver settings;
- live popup translation after selecting Russian/English;
- localized online/offline/unknown and capture status messages;
- localized context-menu creation and recreation after language changes;
- build contents for both Chromium and Firefox.
Final verification commands:
```bash
npm test
npm run build
```
Manual installation or store signing is outside this milestone.
+5 -5
View File
@@ -1,23 +1,23 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Verstak Bridge", "name": "Verstak Bridge",
"version": "2.0.2", "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", "i18n.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.2", "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.2", "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.2", "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-i18n.js && node scripts/test-popup-settings.js && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.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 теперь используют тот же исходный знак, что и
приложение Верстак.
+3 -1
View File
@@ -51,6 +51,8 @@ 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'),
@@ -64,7 +66,7 @@ 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', 'i18n.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);
+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"
+2 -15
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
@@ -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"
+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);
});
+24 -2
View File
@@ -49,7 +49,13 @@ const browser = {
onMessage: { addListener(listener) { messageListener = listener; } }, onMessage: { addListener(listener) { messageListener = listener; } },
}, },
i18n: { getUILanguage() { return 'en-US'; } }, i18n: { getUILanguage() { return 'en-US'; } },
tabs: { query() { return Promise.resolve([]); } }, 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) { function fetchCatalog(url) {
@@ -69,7 +75,7 @@ const context = vm.createContext({
clearTimeout, clearTimeout,
}); });
context.globalThis = context; context.globalThis = context;
for (const file of ['protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) { 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 }); vm.runInContext(fs.readFileSync(path.join(root, 'shared', file), 'utf8'), context, { filename: file });
} }
@@ -92,6 +98,22 @@ function sendMessage(message) {
'Отправить ссылку в Верстак', 'Отправить ссылку в Верстак',
]); ]);
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({ const nextState = await sendMessage({
type: 'verstak.capture', type: 'verstak.capture',
action: 'saveSettings', action: 'saveSettings',
+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');
+1 -1
View File
@@ -29,7 +29,7 @@ assert.ok(Object.values(ru).every((value) => typeof value === 'string'));
const tEn = i18n.createTranslator({ en, ru }, 'en'); const tEn = i18n.createTranslator({ en, ru }, 'en');
const tRu = i18n.createTranslator({ en, ru }, 'ru'); const tRu = i18n.createTranslator({ en, ru }, 'ru');
assert.strictEqual(tRu('status.queued'), 'В очереди до запуска Верстака'); assert.strictEqual(tRu('status.queued'), 'В очереди до запуска Верстака');
assert.strictEqual(tEn('error.value', { error: 'offline' }), 'Error: offline'); assert.strictEqual(tEn('error.sendCapture'), 'Could not send the capture. Please try again.');
assert.strictEqual(tRu('missing', null, 'Fallback'), 'Fallback'); assert.strictEqual(tRu('missing', null, 'Fallback'), 'Fallback');
assert.strictEqual(tRu('missing.key'), 'missing.key'); assert.strictEqual(tRu('missing.key'), 'missing.key');
+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');
+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');
+48 -2
View File
@@ -35,10 +35,12 @@ const elements = {};
'receiver-token-input', 'receiver-token-input',
'file-input', 'file-input',
'pending-count', 'pending-count',
'activity-pending-count',
'status-dot', 'status-dot',
'subtitle', 'subtitle',
'receiver-label', 'receiver-label',
'pending-label', 'pending-label',
'activity-pending-label',
'url-label', 'url-label',
'capture-page', 'capture-page',
'capture-file', 'capture-file',
@@ -53,16 +55,25 @@ const elements = {};
'language-ru-option', 'language-ru-option',
'save-settings', 'save-settings',
'context-menu-hint', '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', language: 'system',
passiveActivityEnabled: false,
passiveActivityExcludedDomains: ['youtube.com'],
}, },
pendingCount: 0, pendingCount: 0,
status: {}, status: {},
@@ -71,6 +82,11 @@ const browser = {
runtime: { runtime: {
getURL(relativePath) { return `extension://${relativePath}`; }, 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;
@@ -98,10 +114,24 @@ function fetchCatalog(url) {
} }
const i18nPath = path.join(__dirname, '..', 'shared', 'i18n.js'); 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');
const context = vm.createContext({ browser, console, document, Promise, btoa, fetch: fetchCatalog, navigator: { language: 'en-US' } }); 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; context.globalThis = context;
vm.runInContext(fs.readFileSync(i18nPath, 'utf8'), context, { filename: i18nPath }); vm.runInContext(fs.readFileSync(i18nPath, 'utf8'), context, { filename: i18nPath });
vm.runInContext(fs.readFileSync(popupPath, 'utf8'), context, { filename: popupPath }); vm.runInContext(popupSource, context, { filename: popupPath });
async function flush() { async function flush() {
for (let i = 0; i < 16; i += 1) await Promise.resolve(); for (let i = 0; i < 16; i += 1) await Promise.resolve();
@@ -112,6 +142,8 @@ async function 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['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['capture-page'].textContent, 'Отправить страницу');
assert.strictEqual(elements['receiver-state'].textContent, 'Неизвестно'); assert.strictEqual(elements['receiver-state'].textContent, 'Неизвестно');
assert.strictEqual(document.documentElement.lang, 'ru'); assert.strictEqual(document.documentElement.lang, 'ru');
@@ -129,9 +161,13 @@ async function flush() {
assert.strictEqual(savedSettings.language, 'en'); assert.strictEqual(savedSettings.language, 'en');
assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl); assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl);
assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken); 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();
@@ -139,6 +175,16 @@ async function flush() {
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');
assert.strictEqual(savedSettings.language, 'en'); 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'); console.log('browser extension popup localization/settings tests passed');
})().catch((error) => { })().catch((error) => {
console.error(error); console.error(error);
+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);
+151 -3
View File
@@ -4,19 +4,34 @@
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 i18n = globalThis.VerstakBrowserI18n;
var DEFAULT_SETTINGS = { var DEFAULT_SETTINGS = {
receiverUrl: protocol.DEFAULT_RECEIVER_URL, receiverUrl: protocol.DEFAULT_RECEIVER_URL,
receiverToken: '', receiverToken: '',
language: 'system' 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 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) {
var settings = Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {}); var settings = Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {});
settings.language = i18n.normalizePreference(settings.language); settings.language = i18n.normalizePreference(settings.language);
settings.passiveActivityEnabled = settings.passiveActivityEnabled === true;
settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains);
return settings; return settings;
}); });
} }
@@ -24,9 +39,23 @@
function saveSettings(settings) { function saveSettings(settings) {
settings = Object.assign({}, DEFAULT_SETTINGS, settings || {}); settings = Object.assign({}, DEFAULT_SETTINGS, settings || {});
settings.language = i18n.normalizePreference(settings.language); settings.language = i18n.normalizePreference(settings.language);
settings.passiveActivityEnabled = settings.passiveActivityEnabled === true;
settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains);
return ext.storage.local.set({ settings: settings }); 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() { function loadLocaleCatalogs() {
if (localeCatalogs) return localeCatalogs; if (localeCatalogs) return localeCatalogs;
localeCatalogs = i18n.loadCatalogs(function (locale) { localeCatalogs = i18n.loadCatalogs(function (locale) {
@@ -68,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,
@@ -123,11 +204,14 @@
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] || {}
}; };
}); });
@@ -170,19 +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(); return setupContextMenus();
}).then(function () {
return getSettings().then(configurePassiveActivity);
}).then(function () { }).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) {
@@ -191,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);
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

+8 -1
View File
@@ -2,16 +2,20 @@
"popup.subtitle": "Browser Inbox", "popup.subtitle": "Browser Inbox",
"label.receiver": "Receiver", "label.receiver": "Receiver",
"label.pending": "Pending", "label.pending": "Pending",
"label.activityPending": "Activity batches",
"label.url": "URL", "label.url": "URL",
"label.file": "File", "label.file": "File",
"label.receiverUrl": "Receiver URL", "label.receiverUrl": "Receiver URL",
"label.pairingToken": "Pairing token", "label.pairingToken": "Pairing token",
"label.language": "Language", "label.language": "Language",
"label.passiveActivity": "Track active domain time",
"label.passiveActivityExclusions": "Excluded domains",
"action.sendPage": "Send Page", "action.sendPage": "Send Page",
"action.sendFile": "Send File", "action.sendFile": "Send File",
"action.retryPending": "Retry Pending", "action.retryPending": "Retry Pending",
"action.save": "Save", "action.save": "Save",
"hint.contextMenu": "Selection and link captures are available from the page context menu.", "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.online": "Online",
"receiver.offline": "Offline", "receiver.offline": "Offline",
"receiver.unknown": "Unknown", "receiver.unknown": "Unknown",
@@ -26,7 +30,10 @@
"error.chooseFile": "Choose a file first", "error.chooseFile": "Choose a file first",
"error.fileTooLarge": "File is too large for browser capture", "error.fileTooLarge": "File is too large for browser capture",
"error.invalidReceiverUrl": "Receiver URL must start with http:// or https://", "error.invalidReceiverUrl": "Receiver URL must start with http:// or https://",
"error.value": "Error: {error}", "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.sendPage": "Send page to Verstak",
"context.sendSelection": "Send selection to Verstak", "context.sendSelection": "Send selection to Verstak",
"context.sendLink": "Send link to Verstak" "context.sendLink": "Send link to Verstak"
+8 -1
View File
@@ -2,16 +2,20 @@
"popup.subtitle": "Входящие из браузера", "popup.subtitle": "Входящие из браузера",
"label.receiver": "Приёмник", "label.receiver": "Приёмник",
"label.pending": "В очереди", "label.pending": "В очереди",
"label.activityPending": "Пакеты активности",
"label.url": "URL", "label.url": "URL",
"label.file": "Файл", "label.file": "Файл",
"label.receiverUrl": "URL приёмника", "label.receiverUrl": "URL приёмника",
"label.pairingToken": "Токен сопряжения", "label.pairingToken": "Токен сопряжения",
"label.language": "Язык", "label.language": "Язык",
"label.passiveActivity": "Учитывать время на активном домене",
"label.passiveActivityExclusions": "Исключённые домены",
"action.sendPage": "Отправить страницу", "action.sendPage": "Отправить страницу",
"action.sendFile": "Отправить файл", "action.sendFile": "Отправить файл",
"action.retryPending": "Повторить отправку", "action.retryPending": "Повторить отправку",
"action.save": "Сохранить", "action.save": "Сохранить",
"hint.contextMenu": "Выделение и ссылки можно отправить через контекстное меню страницы.", "hint.contextMenu": "Выделение и ссылки можно отправить через контекстное меню страницы.",
"hint.passiveActivityDisclosure": "Необязательно. Собираются только активный домен и время на нём. URL страниц, заголовки, текст, нажатия клавиш и содержимое страниц не отправляются.",
"receiver.online": "Доступен", "receiver.online": "Доступен",
"receiver.offline": "Недоступен", "receiver.offline": "Недоступен",
"receiver.unknown": "Неизвестно", "receiver.unknown": "Неизвестно",
@@ -26,7 +30,10 @@
"error.chooseFile": "Сначала выберите файл", "error.chooseFile": "Сначала выберите файл",
"error.fileTooLarge": "Файл слишком велик для отправки из браузера", "error.fileTooLarge": "Файл слишком велик для отправки из браузера",
"error.invalidReceiverUrl": "URL приёмника должен начинаться с http:// или https://", "error.invalidReceiverUrl": "URL приёмника должен начинаться с http:// или https://",
"error.value": "Ошибка: {error}", "error.loadState": "Не удалось загрузить данные расширения. Повторите попытку.",
"error.saveSettings": "Не удалось сохранить настройки. Повторите попытку.",
"error.sendCapture": "Не удалось отправить материал. Повторите попытку.",
"error.readFile": "Не удалось прочитать файл. Выберите его ещё раз.",
"context.sendPage": "Отправить страницу в Верстак", "context.sendPage": "Отправить страницу в Верстак",
"context.sendSelection": "Отправить выделение в Верстак", "context.sendSelection": "Отправить выделение в Верстак",
"context.sendLink": "Отправить ссылку в Верстак" "context.sendLink": "Отправить ссылку в Верстак"
+41 -1
View File
@@ -126,13 +126,36 @@ input {
color: #e5e7eb; color: #e5e7eb;
} }
select { textarea {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
margin-top: 5px; margin-top: 5px;
border: 1px solid #374151; border: 1px solid #374151;
border-radius: 4px; border-radius: 4px;
padding: 7px 8px; 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; background: #0f172a;
color: #e5e7eb; color: #e5e7eb;
} }
@@ -142,6 +165,23 @@ input[type="file"] {
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;
+14
View File
@@ -24,6 +24,10 @@
<span id="pending-label">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 id="url-label">URL</span> <span id="url-label">URL</span>
<code id="receiver-url"></code> <code id="receiver-url"></code>
@@ -55,6 +59,16 @@
<button id="save-settings" class="secondary">Save</button> <button id="save-settings" class="secondary">Save</button>
</section> </section>
<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="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>
+29 -6
View File
@@ -11,7 +11,10 @@
var languageSelectEl = document.getElementById('language-select'); 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 catalogs = { en: {}, ru: {} };
@@ -23,6 +26,7 @@
subtitle: 'popup.subtitle', subtitle: 'popup.subtitle',
'receiver-label': 'label.receiver', 'receiver-label': 'label.receiver',
'pending-label': 'label.pending', 'pending-label': 'label.pending',
'activity-pending-label': 'label.activityPending',
'url-label': 'label.url', 'url-label': 'label.url',
'file-label': 'label.file', 'file-label': 'label.file',
'receiver-url-label': 'label.receiverUrl', 'receiver-url-label': 'label.receiverUrl',
@@ -35,7 +39,10 @@
'context-menu-hint': 'hint.contextMenu', 'context-menu-hint': 'hint.contextMenu',
'language-system-option': 'language.system', 'language-system-option': 'language.system',
'language-en-option': 'language.en', 'language-en-option': 'language.en',
'language-ru-option': 'language.ru' 'language-ru-option': 'language.ru',
'passive-activity-label': 'label.passiveActivity',
'passive-activity-disclosure': 'hint.passiveActivityDisclosure',
'passive-activity-exclusions-label': 'label.passiveActivityExclusions'
}; };
function browserLocale() { function browserLocale() {
@@ -111,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);
@@ -122,9 +134,16 @@
currentState = state || {}; currentState = state || {};
var settings = currentState.settings || {}; var settings = currentState.settings || {};
pendingCountEl.textContent = String(currentState.pendingCount || 0); pendingCountEl.textContent = String(currentState.pendingCount || 0);
pendingActivityCountEl.textContent = String(currentState.pendingActivityCount || 0);
receiverUrlEl.textContent = settings.receiverUrl || ''; receiverUrlEl.textContent = settings.receiverUrl || '';
if (document.activeElement !== receiverInputEl) receiverInputEl.value = settings.receiverUrl || ''; if (document.activeElement !== receiverInputEl) receiverInputEl.value = settings.receiverUrl || '';
if (document.activeElement !== receiverTokenInputEl) receiverTokenInputEl.value = settings.receiverToken || ''; if (document.activeElement !== receiverTokenInputEl) receiverTokenInputEl.value = settings.receiverToken || '';
passiveActivityEnabledEl.checked = settings.passiveActivityEnabled === true;
if (document.activeElement !== passiveActivityExclusionsEl) {
passiveActivityExclusionsEl.value = Array.isArray(settings.passiveActivityExcludedDomains)
? settings.passiveActivityExcludedDomains.join('\n')
: '';
}
applyReceiverState(currentState); applyReceiverState(currentState);
} }
@@ -134,7 +153,7 @@
applyLocale(state.settings && state.settings.language || 'system'); applyLocale(state.settings && state.settings.language || 'system');
return state; return state;
}).catch(function (error) { }).catch(function (error) {
setStatus(error && error.message ? error.message : String(error)); reportError('error.loadState', 'Could not load the extension state. Please try again.', error);
}); });
} }
@@ -142,7 +161,11 @@
return { return {
receiverUrl: receiverInputEl.value.trim(), receiverUrl: receiverInputEl.value.trim(),
receiverToken: receiverTokenInputEl.value.trim(), receiverToken: receiverTokenInputEl.value.trim(),
language: currentPreference language: currentPreference,
passiveActivityEnabled: passiveActivityEnabledEl.checked === true,
passiveActivityExcludedDomains: passiveActivityExclusionsEl.value.split(/[\n,]/).map(function (value) {
return value.trim();
}).filter(Boolean)
}; };
} }
@@ -156,7 +179,7 @@
if (successMessage) setStatus(t('status.saved', null, 'Saved')); if (successMessage) setStatus(t('status.saved', null, 'Saved'));
return state; return state;
}).catch(function (error) { }).catch(function (error) {
setStatus(error && error.message ? error.message : String(error)); reportError('error.saveSettings', 'Could not save settings. Please try again.', error);
}); });
} }
@@ -170,7 +193,7 @@
setStatus(t('status.done', null, 'Done')); setStatus(t('status.done', null, 'Done'));
} }
}).catch(function (error) { }).catch(function (error) {
setStatus(error && error.message ? error.message : String(error)); reportError('error.sendCapture', 'Could not send the capture. Please try again.', error);
}); });
} }
@@ -200,7 +223,7 @@
fileDataBase64: arrayBufferToBase64(results[0]) fileDataBase64: arrayBufferToBase64(results[0])
}); });
}).catch(function (error) { }).catch(function (error) {
setStatus(error && error.message ? error.message : String(error)); reportError('error.readFile', 'Could not read the file. Choose it again.', error);
}); });
}); });
+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,