Compare commits
No commits in common. "main" and "codex/alpha-product-ux" have entirely different histories.
main
...
codex/alph
56
README.md
|
|
@ -1,19 +1,5 @@
|
|||
<div align="center">
|
||||
|
||||
# Verstak Browser Extension
|
||||
|
||||
### Send pages, selections, links and files from the browser into Verstak.
|
||||
|
||||
**English** · [Русский](README.ru.md)
|
||||
|
||||
[](https://github.com/mirivlad/verstak-browser-extension/releases)
|
||||

|
||||
[](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
> **Alpha software.** Use with a matching Verstak Desktop alpha release.
|
||||
|
||||
Verstak Browser Extension captures pages, selected text, links, and selected
|
||||
files and sends them to a local Verstak browser inbox receiver.
|
||||
|
||||
|
|
@ -21,6 +7,8 @@ The extension does not know Notes, Files, Activity, or Journal internals. It
|
|||
only sends capture events through the public local receiver protocol. If the
|
||||
receiver is offline, captures stay in the extension pending queue.
|
||||
|
||||
> **Alpha software.** Use with a matching Verstak Desktop alpha release.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
|
|
@ -51,45 +39,14 @@ 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 Release
|
||||
|
||||
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
|
||||
proxy.
|
||||
|
||||
Create the signed XPI locally, without publishing it:
|
||||
|
||||
```bash
|
||||
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
|
||||
VERSTAK_BROWSER_ENV=/home/mirivlad/git/verstak/.env npm run release:firefox
|
||||
```
|
||||
|
||||
Release output:
|
||||
|
|
@ -97,9 +54,8 @@ Release output:
|
|||
- `release/firefox/verstak-firefox-<version>.xpi`
|
||||
- `release/firefox/updates.json`
|
||||
|
||||
The XPI is signed as an unlisted/self-distributed Firefox extension; GitHub
|
||||
Releases distribute that signed file. Build and release artifacts are local
|
||||
outputs and are not committed.
|
||||
The XPI is signed as an unlisted/self-distributed Firefox extension. Build and
|
||||
release artifacts are local outputs and are not committed.
|
||||
|
||||
## Reproducible local package
|
||||
|
||||
|
|
|
|||
107
README.ru.md
|
|
@ -1,107 +0,0 @@
|
|||
<div align="center">
|
||||
|
||||
# Расширение Верстака для браузера
|
||||
|
||||
### Отправляйте страницы, выделенный текст, ссылки и файлы из браузера в Верстак.
|
||||
|
||||
[English](README.md) · **Русский**
|
||||
|
||||
[](https://github.com/mirivlad/verstak-browser-extension/releases)
|
||||

|
||||
[](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).
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Verstak Bridge",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.2",
|
||||
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
|
||||
"author": "Verstak",
|
||||
"homepage_url": "https://github.com/mirivlad/verstak-browser-extension",
|
||||
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
|
||||
"permissions": ["alarms", "contextMenus", "idle", "storage", "tabs", "windows"],
|
||||
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
|
||||
"background": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
# 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"
|
||||
```
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# 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.
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Verstak Bridge",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.2",
|
||||
"description": "Send pages, selections, links, and files to the local Verstak browser inbox.",
|
||||
"author": "Verstak",
|
||||
"homepage_url": "https://github.com/mirivlad/verstak-browser-extension",
|
||||
"homepage_url": "https://git.mirv.top/verstak/verstak-browser-extension",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "verstak-bridge@verstak.app",
|
||||
"strict_min_version": "115.0",
|
||||
"update_url": "https://github.com/mirivlad/verstak-browser-extension/releases/latest/download/updates.json",
|
||||
"update_url": "https://mirv.top/verstak/firefox/updates.json",
|
||||
"data_collection_permissions": {
|
||||
"required": ["none"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": ["alarms", "contextMenus", "idle", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"],
|
||||
"permissions": ["alarms", "contextMenus", "idle", "storage", "tabs", "windows", "http://127.0.0.1/*", "http://localhost/*"],
|
||||
"background": {
|
||||
"scripts": ["hostname.js", "activity-tracker.js", "protocol.js", "api.js", "queue.js", "i18n.js", "background.js"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "verstak-browser-extension",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "verstak-browser-extension",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.2",
|
||||
"devDependencies": {
|
||||
"web-ext": "^8.3.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
{
|
||||
"name": "verstak-browser-extension",
|
||||
"version": "2.0.6",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"description": "Verstak browser capture extension for Chromium and Firefox",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"build": "node scripts/build-extension.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",
|
||||
"test": "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-catalog-fallback.js && node scripts/test-background-i18n.js",
|
||||
"sign:firefox": "./scripts/sign-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": {
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
## 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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
## 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.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
## 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 теперь используют тот же исходный знак, что и
|
||||
приложение Верстак.
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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 };
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
#!/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"
|
||||
|
|
@ -32,6 +32,7 @@ load_env_file "$ENV_FILE"
|
|||
SOURCE_DIR="${VERSTAK_FIREFOX_SOURCE_DIR:-dist/firefox}"
|
||||
ARTIFACTS_DIR="${WEB_EXT_ARTIFACTS_DIR:-web-ext-artifacts}"
|
||||
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
|
||||
|
||||
|
|
@ -53,8 +54,20 @@ mkdir -p "$RELEASE_DIR"
|
|||
RELEASE_XPI="verstak-firefox-${VERSION}.xpi"
|
||||
cp "$SIGNED_XPI" "$RELEASE_DIR/$RELEASE_XPI"
|
||||
|
||||
node scripts/firefox-github-release.js write-updates \
|
||||
"$ADDON_ID" "$VERSION" "$RELEASE_XPI" "$RELEASE_DIR/updates.json"
|
||||
cat > "$RELEASE_DIR/updates.json" <<EOF
|
||||
{
|
||||
"addons": {
|
||||
"${ADDON_ID}": {
|
||||
"updates": [
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"update_link": "${UPDATE_BASE_URL}/${RELEASE_XPI}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Firefox release artifacts:"
|
||||
echo "$RELEASE_DIR/$RELEASE_XPI"
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
#!/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');
|
||||
|
|
@ -29,7 +29,7 @@ assert.ok(Object.values(ru).every((value) => typeof value === 'string'));
|
|||
const tEn = i18n.createTranslator({ en, ru }, 'en');
|
||||
const tRu = i18n.createTranslator({ en, ru }, 'ru');
|
||||
assert.strictEqual(tRu('status.queued'), 'В очереди до запуска Верстака');
|
||||
assert.strictEqual(tEn('error.sendCapture'), 'Could not send the capture. Please try again.');
|
||||
assert.strictEqual(tEn('error.value', { error: 'offline' }), 'Error: offline');
|
||||
assert.strictEqual(tRu('missing', null, 'Fallback'), 'Fallback');
|
||||
assert.strictEqual(tRu('missing.key'), 'missing.key');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
#!/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');
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
#!/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');
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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');
|
||||
|
|
@ -65,8 +65,6 @@ const elements = {};
|
|||
});
|
||||
|
||||
let savedSettings = null;
|
||||
let nextRequestError = null;
|
||||
const technicalWarnings = [];
|
||||
const initialState = {
|
||||
settings: {
|
||||
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
|
||||
|
|
@ -82,11 +80,6 @@ const browser = {
|
|||
runtime: {
|
||||
getURL(relativePath) { return `extension://${relativePath}`; },
|
||||
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 === 'saveSettings') {
|
||||
savedSettings = message.settings;
|
||||
|
|
@ -114,24 +107,10 @@ function fetchCatalog(url) {
|
|||
}
|
||||
const i18nPath = path.join(__dirname, '..', 'shared', 'i18n.js');
|
||||
const popupPath = path.join(__dirname, '..', 'shared', 'popup', 'popup.js');
|
||||
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' },
|
||||
});
|
||||
const context = vm.createContext({ browser, console, document, Promise, btoa, fetch: fetchCatalog, navigator: { language: 'en-US' } });
|
||||
context.globalThis = context;
|
||||
vm.runInContext(fs.readFileSync(i18nPath, 'utf8'), context, { filename: i18nPath });
|
||||
vm.runInContext(popupSource, context, { filename: popupPath });
|
||||
vm.runInContext(fs.readFileSync(popupPath, 'utf8'), context, { filename: popupPath });
|
||||
|
||||
async function flush() {
|
||||
for (let i = 0; i < 16; i += 1) await Promise.resolve();
|
||||
|
|
@ -177,14 +156,6 @@ async function flush() {
|
|||
assert.strictEqual(savedSettings.language, 'en');
|
||||
assert.strictEqual(savedSettings.passiveActivityEnabled, true);
|
||||
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com', 'x.com']);
|
||||
|
||||
nextRequestError = '[plugin:verstak.browser-inbox] captures.create failed: receiver unavailable';
|
||||
elements['capture-page'].click();
|
||||
await flush();
|
||||
assert.strictEqual(elements.status.textContent, 'Could not send the capture. Please try again.');
|
||||
assert.equal(elements.status.textContent.includes('[plugin:'), false);
|
||||
assert.ok(technicalWarnings.some((message) => message.includes('captures.create failed')));
|
||||
|
||||
console.log('browser extension popup localization/settings tests passed');
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://raw.githubusercontent.com/mirivlad/verstak-sdk/main/schemas/hostname-normalization-v1.json",
|
||||
"$id": "https://git.mirv.top/verstak/verstak-sdk/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,
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.4 KiB |
|
|
@ -1,5 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 290 B |
|
|
@ -30,10 +30,7 @@
|
|||
"error.chooseFile": "Choose a file first",
|
||||
"error.fileTooLarge": "File is too large for browser capture",
|
||||
"error.invalidReceiverUrl": "Receiver URL must start with http:// or https://",
|
||||
"error.loadState": "Could not load the extension state. Please try again.",
|
||||
"error.saveSettings": "Could not save settings. Please try again.",
|
||||
"error.sendCapture": "Could not send the capture. Please try again.",
|
||||
"error.readFile": "Could not read the file. Choose it again.",
|
||||
"error.value": "Error: {error}",
|
||||
"context.sendPage": "Send page to Verstak",
|
||||
"context.sendSelection": "Send selection to Verstak",
|
||||
"context.sendLink": "Send link to Verstak"
|
||||
|
|
|
|||
|
|
@ -30,10 +30,7 @@
|
|||
"error.chooseFile": "Сначала выберите файл",
|
||||
"error.fileTooLarge": "Файл слишком велик для отправки из браузера",
|
||||
"error.invalidReceiverUrl": "URL приёмника должен начинаться с http:// или https://",
|
||||
"error.loadState": "Не удалось загрузить данные расширения. Повторите попытку.",
|
||||
"error.saveSettings": "Не удалось сохранить настройки. Повторите попытку.",
|
||||
"error.sendCapture": "Не удалось отправить материал. Повторите попытку.",
|
||||
"error.readFile": "Не удалось прочитать файл. Выберите его ещё раз.",
|
||||
"error.value": "Ошибка: {error}",
|
||||
"context.sendPage": "Отправить страницу в Верстак",
|
||||
"context.sendSelection": "Отправить выделение в Верстак",
|
||||
"context.sendLink": "Отправить ссылку в Верстак"
|
||||
|
|
|
|||
|
|
@ -145,17 +145,7 @@ select {
|
|||
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 {
|
||||
padding: 7px 8px;
|
||||
background: #0f172a;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,11 +118,6 @@
|
|||
statusEl.textContent = text;
|
||||
}
|
||||
|
||||
function reportError(key, fallback, error) {
|
||||
console.warn('[verstak.popup] request failed:', error);
|
||||
setStatus(t(key, null, fallback));
|
||||
}
|
||||
|
||||
function request(message) {
|
||||
return Promise.resolve(ext.runtime.sendMessage(message)).then(function (result) {
|
||||
if (result && result.error) throw new Error(result.error);
|
||||
|
|
@ -153,7 +148,7 @@
|
|||
applyLocale(state.settings && state.settings.language || 'system');
|
||||
return state;
|
||||
}).catch(function (error) {
|
||||
reportError('error.loadState', 'Could not load the extension state. Please try again.', error);
|
||||
setStatus(error && error.message ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +174,7 @@
|
|||
if (successMessage) setStatus(t('status.saved', null, 'Saved'));
|
||||
return state;
|
||||
}).catch(function (error) {
|
||||
reportError('error.saveSettings', 'Could not save settings. Please try again.', error);
|
||||
setStatus(error && error.message ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -193,7 +188,7 @@
|
|||
setStatus(t('status.done', null, 'Done'));
|
||||
}
|
||||
}).catch(function (error) {
|
||||
reportError('error.sendCapture', 'Could not send the capture. Please try again.', error);
|
||||
setStatus(error && error.message ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +218,7 @@
|
|||
fileDataBase64: arrayBufferToBase64(results[0])
|
||||
});
|
||||
}).catch(function (error) {
|
||||
reportError('error.readFile', 'Could not read the file. Choose it again.', error);
|
||||
setStatus(error && error.message ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||