Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8315d49a58 | ||
|
|
7a278a162a | ||
|
|
a5751aaace | ||
|
|
8f5438fffd | ||
|
|
bfc729affd | ||
|
|
0c15fe259d | ||
|
|
f39550830e | ||
|
|
9ea4d68896 | ||
|
|
7461790934 | ||
|
|
e67a7e4ff7 | ||
|
|
d8bd1d1828 | ||
|
|
619ff97b61 | ||
|
|
af44ce2fa2 | ||
|
|
604bb459c8 | ||
|
|
cae140a348 | ||
|
|
766458d0a0 |
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# verstak-browser-extension
|
# Verstak Browser Extension
|
||||||
|
|
||||||
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.
|
||||||
@@ -7,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
|
only sends capture events through the public local receiver protocol. If the
|
||||||
receiver is offline, captures stay in the extension pending queue.
|
receiver is offline, captures stay in the extension pending queue.
|
||||||
|
|
||||||
|
> **Alpha software.** Use with a matching Verstak Desktop alpha release.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -20,12 +22,52 @@ 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 and publish it, together with `updates.json`, as the
|
||||||
|
current GitHub Release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERSTAK_BROWSER_ENV=/path/to/.env npm run publish:firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
The first invocation publishes tag `v2.0.2` and bootstraps the
|
||||||
|
`releases/latest/download/updates.json` endpoint. Re-running the command
|
||||||
|
replaces those two release assets for the same tag.
|
||||||
|
|
||||||
|
To sign and prepare release files locally without changing GitHub:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VERSTAK_BROWSER_ENV=/home/mirivlad/git/verstak/.env npm run release:firefox
|
VERSTAK_BROWSER_ENV=/home/mirivlad/git/verstak/.env npm run release:firefox
|
||||||
```
|
```
|
||||||
@@ -35,8 +77,19 @@ Release output:
|
|||||||
- `release/firefox/verstak-firefox-<version>.xpi`
|
- `release/firefox/verstak-firefox-<version>.xpi`
|
||||||
- `release/firefox/updates.json`
|
- `release/firefox/updates.json`
|
||||||
|
|
||||||
The XPI is signed as an unlisted/self-distributed Firefox extension. Build and
|
The XPI is signed as an unlisted/self-distributed Firefox extension; GitHub
|
||||||
release artifacts are local outputs and are not committed.
|
Releases distribute that signed file. Build and release artifacts are local
|
||||||
|
outputs and are not committed.
|
||||||
|
|
||||||
|
## Reproducible local package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:package -- v0.1.0-alpha.1
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the tests and build, then writes unsigned Chromium and Firefox source
|
||||||
|
packages to `release/` with a `SHA256SUMS` file. Use `release:firefox` above
|
||||||
|
when an AMO-signed XPI is required.
|
||||||
|
|
||||||
## Manual Check
|
## Manual Check
|
||||||
|
|
||||||
@@ -67,6 +120,15 @@ Headers:
|
|||||||
|
|
||||||
Rotating the token in Desktop invalidates the value stored by the extension.
|
Rotating the token in Desktop invalidates the value stored by the extension.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
The popup settings provide a persisted `System / English / Russian` language
|
||||||
|
selector. `System` follows the browser UI language: Russian browser locales use
|
||||||
|
Russian, and all other locales use English.
|
||||||
|
|
||||||
|
The extension and desktop application store their language choices
|
||||||
|
independently. Changing one does not change the other.
|
||||||
|
|
||||||
Payload:
|
Payload:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -100,3 +162,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).
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Verstak Bridge",
|
"name": "Verstak Bridge",
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"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://git.mirv.top/verstak/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"
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# GitHub Firefox Updates 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:** Publish signed Firefox XPI releases to GitHub Releases and let installed alpha extensions discover them through a GitHub-hosted update manifest.
|
||||||
|
|
||||||
|
**Architecture:** Firefox keeps one immutable `update_url` targeting the current GitHub Release's `updates.json` asset. A small Node helper owns the tag, asset URL and update-manifest formatting so it has focused unit tests. The existing local signing command remains local; an explicit publishing command uploads its two artifacts to the public GitHub release.
|
||||||
|
|
||||||
|
**Tech Stack:** Manifest V2 JSON, Node.js CommonJS tests, Bash, `web-ext`, GitHub CLI (`gh`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Publish repository: `mirivlad/verstak-browser-extension`.
|
||||||
|
- Firefox addon ID: `verstak-bridge@verstak.app`.
|
||||||
|
- Initial GitHub release: `v2.0.2`; package and both browser manifests are version `2.0.2`.
|
||||||
|
- Every alpha GitHub release is ordinary/latest, not a prerelease.
|
||||||
|
- XPI remains Mozilla-signed as an unlisted/self-distributed addon before upload.
|
||||||
|
- `npm run release:firefox` remains a local sign-and-package action.
|
||||||
|
- `npm run publish:firefox` is the only command allowed to create or modify a GitHub Release.
|
||||||
|
- Generated `release/` output remains untracked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Make GitHub update metadata testable
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/firefox-github-release.js`
|
||||||
|
- Create: `scripts/test-firefox-github-release.js`
|
||||||
|
- Modify: `package.json:8-15`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces `releaseTag(version)`, `releaseAssetURL(version, assetName)`, and `updateManifest(addonID, version, assetName)` from `scripts/firefox-github-release.js`.
|
||||||
|
- `updateManifest` returns the object written as `updates.json` and uses the fixed GitHub repository.
|
||||||
|
- Later shell scripts invoke `node scripts/firefox-github-release.js write-updates <addon-id> <version> <asset-name> <output-path>`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing metadata test**
|
||||||
|
|
||||||
|
Create `scripts/test-firefox-github-release.js` with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const assert = require('assert');
|
||||||
|
const release = require('./firefox-github-release');
|
||||||
|
|
||||||
|
const version = '2.0.2';
|
||||||
|
const asset = 'verstak-firefox-2.0.2.xpi';
|
||||||
|
assert.equal(release.releaseTag(version), 'v2.0.2');
|
||||||
|
assert.equal(
|
||||||
|
release.releaseAssetURL(version, asset),
|
||||||
|
'https://github.com/mirivlad/verstak-browser-extension/releases/download/v2.0.2/verstak-firefox-2.0.2.xpi',
|
||||||
|
);
|
||||||
|
assert.deepEqual(release.updateManifest('verstak-bridge@verstak.app', version, asset), {
|
||||||
|
addons: {
|
||||||
|
'verstak-bridge@verstak.app': {
|
||||||
|
updates: [{
|
||||||
|
version: '2.0.2',
|
||||||
|
update_link: 'https://github.com/mirivlad/verstak-browser-extension/releases/download/v2.0.2/verstak-firefox-2.0.2.xpi',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Firefox GitHub release metadata tests passed');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused test and verify it fails**
|
||||||
|
|
||||||
|
Run: `node scripts/test-firefox-github-release.js`
|
||||||
|
|
||||||
|
Expected: failure because `scripts/firefox-github-release.js` does not yet exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the metadata helper**
|
||||||
|
|
||||||
|
Create `scripts/firefox-github-release.js` with these exact exports and CLI:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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) }] } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const [, , command, addonID, version, assetName, outputPath] = process.argv;
|
||||||
|
if (command !== 'write-updates' || !addonID || !version || !assetName || !outputPath) process.exitCode = 2;
|
||||||
|
else fs.writeFileSync(outputPath, `${JSON.stringify(updateManifest(addonID, version, assetName), null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { REPOSITORY, releaseTag, releaseAssetURL, updateManifest };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register and run the test**
|
||||||
|
|
||||||
|
Append `node scripts/test-firefox-github-release.js` to the existing `test`
|
||||||
|
script in `package.json`, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all existing tests and `Firefox GitHub release metadata tests passed`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json scripts/firefox-github-release.js scripts/test-firefox-github-release.js
|
||||||
|
git commit -m "test: cover Firefox GitHub release metadata"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Point Firefox and local release artifacts at GitHub
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `firefox/manifest.json:8-17`
|
||||||
|
- Modify: `scripts/release-firefox-xpi.sh:29-70`
|
||||||
|
- Test: `scripts/test-firefox-github-release.js`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Firefox reads `https://github.com/mirivlad/verstak-browser-extension/releases/latest/download/updates.json`.
|
||||||
|
- `release-firefox-xpi.sh` invokes the Task 1 helper to write a versioned update manifest.
|
||||||
|
- It continues producing `release/firefox/verstak-firefox-<version>.xpi` and `release/firefox/updates.json` locally.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend the failing test with the stable manifest URL**
|
||||||
|
|
||||||
|
Add this assertion to `scripts/test-firefox-github-release.js` before its final
|
||||||
|
success message:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const firefoxManifest = require('../firefox/manifest.json');
|
||||||
|
assert.equal(
|
||||||
|
firefoxManifest.browser_specific_settings.gecko.update_url,
|
||||||
|
'https://github.com/mirivlad/verstak-browser-extension/releases/latest/download/updates.json',
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused test and verify it fails**
|
||||||
|
|
||||||
|
Run: `node scripts/test-firefox-github-release.js`
|
||||||
|
|
||||||
|
Expected: assertion failure because the manifest still uses `mirv.top`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the update endpoint and JSON writer**
|
||||||
|
|
||||||
|
In `firefox/manifest.json`, replace only the Gecko `update_url` value with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"update_url": "https://github.com/mirivlad/verstak-browser-extension/releases/latest/download/updates.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
In `scripts/release-firefox-xpi.sh`, remove `UPDATE_BASE_URL` and replace the
|
||||||
|
heredoc that writes `updates.json` with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/firefox-github-release.js write-updates \
|
||||||
|
"$ADDON_ID" "$VERSION" "$RELEASE_XPI" "$RELEASE_DIR/updates.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run targeted checks**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/test-firefox-github-release.js
|
||||||
|
bash -n scripts/release-firefox-xpi.sh
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands succeed; `dist/firefox/manifest.json` contains the
|
||||||
|
GitHub update URL after the build.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add firefox/manifest.json scripts/release-firefox-xpi.sh scripts/test-firefox-github-release.js
|
||||||
|
git commit -m "feat: host Firefox updates on GitHub Releases"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Add explicit GitHub publishing and public instructions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/publish-firefox-github-release.sh`
|
||||||
|
- Modify: `package.json:8-15`
|
||||||
|
- Modify: `README.md:42-68`
|
||||||
|
- Test: `scripts/publish-firefox-github-release.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `npm run publish:firefox` calls `scripts/publish-firefox-github-release.sh`.
|
||||||
|
- The script runs the existing local release command, then uploads
|
||||||
|
`verstak-firefox-<version>.xpi` and `updates.json` to release tag
|
||||||
|
`v<version>` in `mirivlad/verstak-browser-extension`.
|
||||||
|
- A pre-existing tag receives asset replacement through `gh release upload --clobber`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the shell-contract test**
|
||||||
|
|
||||||
|
Create a Node test that reads the publish script and asserts the mandatory
|
||||||
|
commands are present:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const source = fs.readFileSync('scripts/publish-firefox-github-release.sh', 'utf8');
|
||||||
|
assert.match(source, /gh auth status/);
|
||||||
|
assert.match(source, /gh release create/);
|
||||||
|
assert.match(source, /gh release upload/);
|
||||||
|
assert.match(source, /--clobber/);
|
||||||
|
assert.match(source, /--latest/);
|
||||||
|
```
|
||||||
|
|
||||||
|
Place those assertions in `scripts/test-firefox-github-release.js` and run the
|
||||||
|
test. Expected: it fails before the publish script exists.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement an explicit idempotent publisher**
|
||||||
|
|
||||||
|
Create `scripts/publish-firefox-github-release.sh` with this behavior:
|
||||||
|
|
||||||
|
1. Set `ROOT_DIR`, `cd` to it and run `gh auth status`.
|
||||||
|
2. Run `./scripts/release-firefox-xpi.sh`.
|
||||||
|
3. Read `VERSION` from `dist/firefox/manifest.json`, set `TAG="v${VERSION}"`,
|
||||||
|
`REPOSITORY="mirivlad/verstak-browser-extension"`, and locate the two local
|
||||||
|
release assets by exact names.
|
||||||
|
4. Run `gh release view "$TAG" --repo "$REPOSITORY"`; when it is absent, run
|
||||||
|
`gh release create "$TAG" "$XPI" "$UPDATES" --repo "$REPOSITORY" --title
|
||||||
|
"Verstak Browser Extension $VERSION" --generate-notes --latest --target
|
||||||
|
"$(git rev-parse HEAD)"`.
|
||||||
|
5. When the release already exists, run `gh release upload "$TAG" "$XPI"
|
||||||
|
"$UPDATES" --repo "$REPOSITORY" --clobber`.
|
||||||
|
6. Print the GitHub release URL with `gh release view "$TAG" --repo
|
||||||
|
"$REPOSITORY" --json url --jq .url`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register the command and document it**
|
||||||
|
|
||||||
|
Add to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"publish:firefox": "./scripts/publish-firefox-github-release.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rewrite the Firefox Release README section so it documents:
|
||||||
|
|
||||||
|
- the public [GitHub Releases](https://github.com/mirivlad/verstak-browser-extension/releases) page;
|
||||||
|
- `VERSTAK_BROWSER_ENV=/path/to/.env npm run publish:firefox`;
|
||||||
|
- that signing remains unlisted with Mozilla but distribution and automatic
|
||||||
|
updates use GitHub Release assets;
|
||||||
|
- that the first `v2.0.2` publish bootstraps the `latest/download/updates.json`
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run checks without publishing**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash -n scripts/publish-firefox-github-release.sh
|
||||||
|
node scripts/test-firefox-github-release.js
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands succeed. Do not invoke `npm run publish:firefox` in this
|
||||||
|
task; it creates a public GitHub Release and needs available AMO credentials.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit and push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md package.json scripts/publish-firefox-github-release.sh scripts/test-firefox-github-release.js
|
||||||
|
git commit -m "feat: publish Firefox releases to GitHub"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 4: Bootstrap and verify the first public update release
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Generated: `release/firefox/verstak-firefox-2.0.2.xpi` (untracked)
|
||||||
|
- Generated: `release/firefox/updates.json` (untracked)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes the Task 3 `npm run publish:firefox` command and valid AMO signing credentials.
|
||||||
|
- Produces GitHub Release tag `v2.0.2` and public XPI/update-manifest assets.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify release credentials before write operations**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh auth status
|
||||||
|
test -n "${VERSTAK_BROWSER_ENV:-}" || test -f .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: authenticated GitHub CLI and a configured environment file path for
|
||||||
|
the existing AMO signing command.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Publish the first release**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run publish:firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Mozilla signing succeeds, GitHub Release `v2.0.2` is latest, and it
|
||||||
|
contains `verstak-firefox-2.0.2.xpi` plus `updates.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify public delivery**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release view v2.0.2 --repo mirivlad/verstak-browser-extension --json isLatest,url,assets
|
||||||
|
curl -fsSL https://github.com/mirivlad/verstak-browser-extension/releases/latest/download/updates.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the release is latest and the JSON contains addon ID
|
||||||
|
`verstak-bridge@verstak.app`, version `2.0.2`, and the versioned GitHub XPI
|
||||||
|
asset URL.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Record release verification without committing assets**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no generated `release/` assets are staged or committed.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# GitHub Releases for Firefox Updates
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Firefox XPI releases are published as ordinary public GitHub Releases in
|
||||||
|
`mirivlad/verstak-browser-extension`. During the alpha phase every such release
|
||||||
|
is marked as the repository's latest release, including alpha versions. There
|
||||||
|
are no other Verstak users whose automatic updates need a separate stable
|
||||||
|
channel yet.
|
||||||
|
|
||||||
|
## Update protocol
|
||||||
|
|
||||||
|
The Firefox manifest uses this stable endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://github.com/mirivlad/verstak-browser-extension/releases/latest/download/updates.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Each release uploads two assets:
|
||||||
|
|
||||||
|
- `verstak-firefox-<version>.xpi`, signed by Mozilla as an unlisted addon;
|
||||||
|
- `updates.json`, whose `update_link` points to that same release tag's XPI
|
||||||
|
asset.
|
||||||
|
|
||||||
|
For version `2.0.2`, the versioned link is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://github.com/mirivlad/verstak-browser-extension/releases/download/v2.0.2/verstak-firefox-2.0.2.xpi
|
||||||
|
```
|
||||||
|
|
||||||
|
The stable `latest/download` endpoint lets already installed extensions discover
|
||||||
|
the next version without changing the manifest on each release.
|
||||||
|
|
||||||
|
## Release commands
|
||||||
|
|
||||||
|
`npm run release:firefox` remains the local sign-and-package command. A new
|
||||||
|
explicit `npm run publish:firefox` command runs it, verifies `gh`
|
||||||
|
authentication, creates or reuses the `v<manifest-version>` GitHub Release,
|
||||||
|
and uploads the XPI plus `updates.json`. Re-running the command replaces only
|
||||||
|
those two assets, so a failed network upload can be retried safely.
|
||||||
|
|
||||||
|
Publishing requires the existing AMO credentials used for signing and a
|
||||||
|
GitHub-authenticated `gh` CLI. It does not use the invalid `mirv.top` update
|
||||||
|
endpoint.
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
There is currently no GitHub Release in this repository. After the code change,
|
||||||
|
the first `v2.0.2` signed release must be published before shipping a manifest
|
||||||
|
that points to `latest/download/updates.json`; until then that URL is a 404.
|
||||||
|
The current package, Chromium manifest and Firefox manifest all use version
|
||||||
|
`2.0.2`, so the initial tag and XPI asset name are unambiguous.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
The README links users to the GitHub Releases page for downloading the signed
|
||||||
|
Firefox XPI and documents that releases are currently the auto-update channel.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- add a focused script test for versioned GitHub URLs and generated
|
||||||
|
`updates.json`;
|
||||||
|
- run the existing extension test suite and build;
|
||||||
|
- run shell syntax validation for the publish script;
|
||||||
|
- verify a published release's assets and `latest/download/updates.json` with
|
||||||
|
unauthenticated HTTPS requests.
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "Verstak Bridge",
|
"name": "Verstak Bridge",
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"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", "windows", "http://127.0.0.1/*", "http://localhost/*"],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["protocol.js", "api.js", "queue.js", "background.js"]
|
"scripts": ["hostname.js", "activity-tracker.js", "protocol.js", "api.js", "queue.js", "i18n.js", "background.js"]
|
||||||
},
|
},
|
||||||
"browser_action": {
|
"browser_action": {
|
||||||
"default_popup": "popup/popup.html",
|
"default_popup": "popup/popup.html",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "verstak-browser-extension",
|
"name": "verstak-browser-extension",
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "verstak-browser-extension",
|
"name": "verstak-browser-extension",
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"web-ext": "^8.3.0"
|
"web-ext": "^8.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "verstak-browser-extension",
|
"name": "verstak-browser-extension",
|
||||||
"version": "2.0.1",
|
"version": "2.0.2",
|
||||||
"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",
|
"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 && 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",
|
||||||
|
"release:package": "./scripts/package-release.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"web-ext": "^8.3.0"
|
"web-ext": "^8.3.0"
|
||||||
|
|||||||
@@ -38,27 +38,39 @@ function copyIcons(destRoot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyLocalization(destRoot) {
|
||||||
|
copy(path.join(shared, 'i18n.js'), path.join(destRoot, 'i18n.js'));
|
||||||
|
for (const locale of ['en', 'ru']) {
|
||||||
|
copy(path.join(shared, 'locales', `${locale}.json`), path.join(destRoot, 'locales', `${locale}.json`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rm(dist);
|
rm(dist);
|
||||||
|
|
||||||
const chromiumDist = path.join(dist, 'chromium');
|
const chromiumDist = path.join(dist, 'chromium');
|
||||||
mkdir(chromiumDist);
|
mkdir(chromiumDist);
|
||||||
copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json'));
|
copy(path.join(root, 'chromium', 'manifest.json'), path.join(chromiumDist, 'manifest.json'));
|
||||||
concat([
|
concat([
|
||||||
|
path.join(shared, 'hostname.js'),
|
||||||
|
path.join(shared, 'activity-tracker.js'),
|
||||||
path.join(shared, 'protocol.js'),
|
path.join(shared, 'protocol.js'),
|
||||||
path.join(shared, 'api.js'),
|
path.join(shared, 'api.js'),
|
||||||
path.join(shared, 'queue.js'),
|
path.join(shared, 'queue.js'),
|
||||||
|
path.join(shared, 'i18n.js'),
|
||||||
path.join(shared, 'background.js'),
|
path.join(shared, 'background.js'),
|
||||||
], path.join(chromiumDist, 'background.js'));
|
], path.join(chromiumDist, 'background.js'));
|
||||||
copyPopup(chromiumDist);
|
copyPopup(chromiumDist);
|
||||||
copyIcons(chromiumDist);
|
copyIcons(chromiumDist);
|
||||||
|
copyLocalization(chromiumDist);
|
||||||
|
|
||||||
const firefoxDist = path.join(dist, 'firefox');
|
const firefoxDist = path.join(dist, 'firefox');
|
||||||
mkdir(firefoxDist);
|
mkdir(firefoxDist);
|
||||||
copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json'));
|
copy(path.join(root, 'firefox', 'manifest.json'), path.join(firefoxDist, 'manifest.json'));
|
||||||
for (const name of ['protocol.js', 'api.js', 'queue.js', 'background.js']) {
|
for (const name of ['hostname.js', 'activity-tracker.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
|
||||||
copy(path.join(shared, name), path.join(firefoxDist, name));
|
copy(path.join(shared, name), path.join(firefoxDist, name));
|
||||||
}
|
}
|
||||||
copyPopup(firefoxDist);
|
copyPopup(firefoxDist);
|
||||||
copyIcons(firefoxDist);
|
copyIcons(firefoxDist);
|
||||||
|
copyLocalization(firefoxDist);
|
||||||
|
|
||||||
console.log('built dist/chromium and dist/firefox');
|
console.log('built dist/chromium and dist/firefox');
|
||||||
|
|||||||
@@ -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 };
|
||||||
Executable
+28
@@ -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"
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/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}"
|
||||||
|
|
||||||
|
if ! command -v gh >/dev/null; then
|
||||||
|
echo "ERROR: gh CLI is required to publish a GitHub Release" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
gh auth status
|
||||||
|
./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 gh release view "$TAG" --repo "$REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release upload "$TAG" "$XPI" "$UPDATES" --repo "$REPOSITORY" --clobber
|
||||||
|
else
|
||||||
|
gh release create "$TAG" "$XPI" "$UPDATES" \
|
||||||
|
--repo "$REPOSITORY" \
|
||||||
|
--title "Verstak Browser Extension $VERSION" \
|
||||||
|
--generate-notes \
|
||||||
|
--latest \
|
||||||
|
--target "$(git rev-parse HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "GitHub release:"
|
||||||
|
gh release view "$TAG" --repo "$REPOSITORY" --json url --jq .url
|
||||||
@@ -32,7 +32,6 @@ load_env_file "$ENV_FILE"
|
|||||||
SOURCE_DIR="${VERSTAK_FIREFOX_SOURCE_DIR:-dist/firefox}"
|
SOURCE_DIR="${VERSTAK_FIREFOX_SOURCE_DIR:-dist/firefox}"
|
||||||
ARTIFACTS_DIR="${WEB_EXT_ARTIFACTS_DIR:-web-ext-artifacts}"
|
ARTIFACTS_DIR="${WEB_EXT_ARTIFACTS_DIR:-web-ext-artifacts}"
|
||||||
RELEASE_DIR="${VERSTAK_FIREFOX_RELEASE_DIR:-release/firefox}"
|
RELEASE_DIR="${VERSTAK_FIREFOX_RELEASE_DIR:-release/firefox}"
|
||||||
UPDATE_BASE_URL="${VERSTAK_FIREFOX_UPDATE_BASE_URL:-https://mirv.top/verstak/firefox}"
|
|
||||||
|
|
||||||
./scripts/sign-firefox-xpi.sh
|
./scripts/sign-firefox-xpi.sh
|
||||||
|
|
||||||
@@ -44,9 +43,9 @@ if [[ -z "$VERSION" || -z "$ADDON_ID" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' | sort | tail -n 1 || true)"
|
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name "*-${VERSION}.xpi" | sort | tail -n 1 || true)"
|
||||||
if [[ -z "$SIGNED_XPI" ]]; then
|
if [[ -z "$SIGNED_XPI" ]]; then
|
||||||
echo "ERROR: no signed XPI found in $ARTIFACTS_DIR" >&2
|
echo "ERROR: no signed XPI for version $VERSION found in $ARTIFACTS_DIR" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -54,20 +53,8 @@ mkdir -p "$RELEASE_DIR"
|
|||||||
RELEASE_XPI="verstak-firefox-${VERSION}.xpi"
|
RELEASE_XPI="verstak-firefox-${VERSION}.xpi"
|
||||||
cp "$SIGNED_XPI" "$RELEASE_DIR/$RELEASE_XPI"
|
cp "$SIGNED_XPI" "$RELEASE_DIR/$RELEASE_XPI"
|
||||||
|
|
||||||
cat > "$RELEASE_DIR/updates.json" <<EOF
|
node scripts/firefox-github-release.js write-updates \
|
||||||
{
|
"$ADDON_ID" "$VERSION" "$RELEASE_XPI" "$RELEASE_DIR/updates.json"
|
||||||
"addons": {
|
|
||||||
"${ADDON_ID}": {
|
|
||||||
"updates": [
|
|
||||||
{
|
|
||||||
"version": "${VERSION}",
|
|
||||||
"update_link": "${UPDATE_BASE_URL}/${RELEASE_XPI}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "Firefox release artifacts:"
|
echo "Firefox release artifacts:"
|
||||||
echo "$RELEASE_DIR/$RELEASE_XPI"
|
echo "$RELEASE_DIR/$RELEASE_XPI"
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ echo "Using AMO API proxy from WEB_EXT_API_PROXY"
|
|||||||
|
|
||||||
npx web-ext "${SIGN_ARGS[@]}"
|
npx web-ext "${SIGN_ARGS[@]}"
|
||||||
|
|
||||||
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' | sort | tail -n 1 || true)"
|
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' -printf '%T@ %p\n' | sort -n | tail -n 1 | cut -d' ' -f2- || true)"
|
||||||
if [[ -z "$SIGNED_XPI" ]]; then
|
if [[ -z "$SIGNED_XPI" ]]; then
|
||||||
echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2
|
echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const catalogs = {
|
||||||
|
en: JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'en.json'), 'utf8')),
|
||||||
|
ru: JSON.parse(fs.readFileSync(path.join(root, 'shared', 'locales', 'ru.json'), 'utf8')),
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
settings: {
|
||||||
|
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
|
||||||
|
receiverToken: 'paired-token',
|
||||||
|
language: 'ru',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let menuTitles = [];
|
||||||
|
let installedListener;
|
||||||
|
let messageListener;
|
||||||
|
|
||||||
|
const browser = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get(key) {
|
||||||
|
if (typeof key === 'string') return Promise.resolve({ [key]: state[key] });
|
||||||
|
return Promise.resolve({ ...state });
|
||||||
|
},
|
||||||
|
set(patch) {
|
||||||
|
Object.assign(state, patch || {});
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contextMenus: {
|
||||||
|
removeAll() {
|
||||||
|
menuTitles = [];
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
create(item) {
|
||||||
|
menuTitles.push(item.title);
|
||||||
|
},
|
||||||
|
onClicked: { addListener() {} },
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
getURL(relativePath) { return `extension://${relativePath}`; },
|
||||||
|
onInstalled: { addListener(listener) { installedListener = listener; } },
|
||||||
|
onMessage: { addListener(listener) { messageListener = listener; } },
|
||||||
|
},
|
||||||
|
i18n: { getUILanguage() { return 'en-US'; } },
|
||||||
|
tabs: {
|
||||||
|
query() { return Promise.resolve([{ id: 7, windowId: 1, url: 'https://example.com/private-path', title: 'Private title' }]); },
|
||||||
|
get() { return Promise.resolve({ id: 7, windowId: 1, url: 'https://example.com/private-path', title: 'Private title' }); },
|
||||||
|
onActivated: { addListener() {} },
|
||||||
|
onUpdated: { addListener() {} },
|
||||||
|
onRemoved: { addListener() {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fetchCatalog(url) {
|
||||||
|
const locale = /\/ru\.json$/.test(url) ? 'ru' : 'en';
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(catalogs[locale]) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = vm.createContext({
|
||||||
|
browser,
|
||||||
|
console,
|
||||||
|
Date,
|
||||||
|
Math,
|
||||||
|
Promise,
|
||||||
|
URL,
|
||||||
|
fetch: fetchCatalog,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
});
|
||||||
|
context.globalThis = context;
|
||||||
|
for (const file of ['hostname.js', 'activity-tracker.js', 'protocol.js', 'api.js', 'queue.js', 'i18n.js', 'background.js']) {
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(root, 'shared', file), 'utf8'), context, { filename: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
for (let i = 0; i < 8; i += 1) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(message) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
messageListener(message, {}, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
installedListener();
|
||||||
|
await flush();
|
||||||
|
assert.deepStrictEqual(menuTitles, [
|
||||||
|
'Отправить страницу в Верстак',
|
||||||
|
'Отправить выделение в Верстак',
|
||||||
|
'Отправить ссылку в Верстак',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trackingState = await sendMessage({
|
||||||
|
type: 'verstak.capture',
|
||||||
|
action: 'saveSettings',
|
||||||
|
settings: {
|
||||||
|
receiverUrl: state.settings.receiverUrl,
|
||||||
|
receiverToken: state.settings.receiverToken,
|
||||||
|
language: 'en',
|
||||||
|
passiveActivityEnabled: true,
|
||||||
|
passiveActivityExcludedDomains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.strictEqual(trackingState.settings.passiveActivityEnabled, true);
|
||||||
|
assert.ok(trackingState.activityState.activeAccumulator['example.com']);
|
||||||
|
assert.equal(JSON.stringify(trackingState.activityState).includes('private-path'), false);
|
||||||
|
assert.equal(JSON.stringify(trackingState.activityState).includes('Private title'), false);
|
||||||
|
|
||||||
|
const nextState = await sendMessage({
|
||||||
|
type: 'verstak.capture',
|
||||||
|
action: 'saveSettings',
|
||||||
|
settings: {
|
||||||
|
receiverUrl: state.settings.receiverUrl,
|
||||||
|
receiverToken: state.settings.receiverToken,
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
assert.strictEqual(state.settings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures');
|
||||||
|
assert.strictEqual(state.settings.receiverToken, 'paired-token');
|
||||||
|
assert.strictEqual(state.settings.language, 'en');
|
||||||
|
assert.strictEqual(nextState.settings.language, 'en');
|
||||||
|
assert.deepStrictEqual(menuTitles, [
|
||||||
|
'Send page to Verstak',
|
||||||
|
'Send selection to Verstak',
|
||||||
|
'Send link to Verstak',
|
||||||
|
]);
|
||||||
|
console.log('browser extension background localization tests passed');
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const release = require('./firefox-github-release');
|
||||||
|
|
||||||
|
const version = '2.0.2';
|
||||||
|
const asset = 'verstak-firefox-2.0.2.xpi';
|
||||||
|
|
||||||
|
assert.equal(release.releaseTag(version), 'v2.0.2');
|
||||||
|
assert.equal(
|
||||||
|
release.releaseAssetURL(version, asset),
|
||||||
|
'https://github.com/mirivlad/verstak-browser-extension/releases/download/v2.0.2/verstak-firefox-2.0.2.xpi',
|
||||||
|
);
|
||||||
|
assert.deepEqual(release.updateManifest('verstak-bridge@verstak.app', version, asset), {
|
||||||
|
addons: {
|
||||||
|
'verstak-bridge@verstak.app': {
|
||||||
|
updates: [{
|
||||||
|
version: '2.0.2',
|
||||||
|
update_link: 'https://github.com/mirivlad/verstak-browser-extension/releases/download/v2.0.2/verstak-firefox-2.0.2.xpi',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firefoxManifest = require('../firefox/manifest.json');
|
||||||
|
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, /gh auth status/);
|
||||||
|
assert.match(publisher, /gh release create/);
|
||||||
|
assert.match(publisher, /gh release upload/);
|
||||||
|
assert.match(publisher, /--clobber/);
|
||||||
|
assert.match(publisher, /--latest/);
|
||||||
|
|
||||||
|
console.log('Firefox GitHub release metadata tests passed');
|
||||||
@@ -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');
|
||||||
@@ -33,6 +33,18 @@ assert.strictEqual(tEn('error.value', { error: 'offline' }), 'Error: offline');
|
|||||||
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');
|
||||||
|
|
||||||
|
const dist = path.join(root, 'dist');
|
||||||
|
if (fs.existsSync(dist)) {
|
||||||
|
for (const target of ['chromium', 'firefox']) {
|
||||||
|
for (const relativePath of ['i18n.js', 'locales/en.json', 'locales/ru.json']) {
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(dist, target, relativePath)),
|
||||||
|
`${target} build is missing ${relativePath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
i18n.loadCatalogs((locale) => Promise.resolve(locale === 'ru' ? ru : en))
|
i18n.loadCatalogs((locale) => Promise.resolve(locale === 'ru' ? ru : en))
|
||||||
.then((catalogs) => {
|
.then((catalogs) => {
|
||||||
assert.strictEqual(catalogs.en, en);
|
assert.strictEqual(catalogs.en, en);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor(text) {
|
||||||
|
this.value = '';
|
||||||
|
this.textContent = text || '';
|
||||||
|
this.className = '';
|
||||||
|
this.files = [];
|
||||||
|
this.listeners = {};
|
||||||
|
}
|
||||||
|
addEventListener(type, listener) { this.listeners[type] = listener; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [
|
||||||
|
'status', 'receiver-state', 'receiver-url', 'receiver-input', 'receiver-token-input',
|
||||||
|
'language-select', 'file-input', 'pending-count', 'status-dot', 'subtitle',
|
||||||
|
'receiver-label', 'pending-label', 'url-label', 'file-label', 'receiver-url-label',
|
||||||
|
'receiver-token-label', 'language-label', 'capture-page', 'capture-file', 'retry',
|
||||||
|
'save-settings', 'context-menu-hint', 'language-system-option', 'language-en-option',
|
||||||
|
'language-ru-option',
|
||||||
|
];
|
||||||
|
const elements = Object.fromEntries(ids.map((id) => [id, new Element()]));
|
||||||
|
elements['capture-page'].textContent = 'Send Page';
|
||||||
|
|
||||||
|
const browser = {
|
||||||
|
runtime: {
|
||||||
|
getURL(relativePath) { return `extension://${relativePath}`; },
|
||||||
|
sendMessage() {
|
||||||
|
return Promise.resolve({
|
||||||
|
settings: { receiverUrl: 'http://127.0.0.1/', receiverToken: '', language: 'system' },
|
||||||
|
pendingCount: 0,
|
||||||
|
status: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
i18n: { getUILanguage() { return 'en-US'; } },
|
||||||
|
};
|
||||||
|
const document = {
|
||||||
|
activeElement: null,
|
||||||
|
documentElement: { lang: '' },
|
||||||
|
getElementById(id) { return elements[id]; },
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
browser,
|
||||||
|
document,
|
||||||
|
navigator: { language: 'en-US' },
|
||||||
|
console: { warn() {}, error: console.error },
|
||||||
|
Promise,
|
||||||
|
fetch() { return Promise.reject(new Error('catalog unavailable')); },
|
||||||
|
});
|
||||||
|
context.globalThis = context;
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
for (const file of ['shared/i18n.js', 'shared/popup/popup.js']) {
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, { filename: file });
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (let i = 0; i < 16; i += 1) await Promise.resolve();
|
||||||
|
assert.strictEqual(elements['capture-page'].textContent, 'Send Page');
|
||||||
|
assert.strictEqual(elements['receiver-state'].textContent, 'Unknown');
|
||||||
|
assert.strictEqual(document.documentElement.lang, 'en');
|
||||||
|
console.log('browser extension popup catalog fallback tests passed');
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -20,6 +20,10 @@ class Element {
|
|||||||
click() {
|
click() {
|
||||||
if (this.listeners.click) this.listeners.click();
|
if (this.listeners.click) this.listeners.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
change() {
|
||||||
|
if (this.listeners.change) this.listeners.change({ target: this });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements = {};
|
const elements = {};
|
||||||
@@ -31,11 +35,31 @@ const elements = {};
|
|||||||
'receiver-token-input',
|
'receiver-token-input',
|
||||||
'file-input',
|
'file-input',
|
||||||
'pending-count',
|
'pending-count',
|
||||||
|
'activity-pending-count',
|
||||||
'status-dot',
|
'status-dot',
|
||||||
|
'subtitle',
|
||||||
|
'receiver-label',
|
||||||
|
'pending-label',
|
||||||
|
'activity-pending-label',
|
||||||
|
'url-label',
|
||||||
'capture-page',
|
'capture-page',
|
||||||
'capture-file',
|
'capture-file',
|
||||||
'retry',
|
'retry',
|
||||||
|
'file-label',
|
||||||
|
'receiver-url-label',
|
||||||
|
'receiver-token-label',
|
||||||
|
'language-label',
|
||||||
|
'language-select',
|
||||||
|
'language-system-option',
|
||||||
|
'language-en-option',
|
||||||
|
'language-ru-option',
|
||||||
'save-settings',
|
'save-settings',
|
||||||
|
'context-menu-hint',
|
||||||
|
'passive-activity-enabled',
|
||||||
|
'passive-activity-label',
|
||||||
|
'passive-activity-disclosure',
|
||||||
|
'passive-activity-exclusions-label',
|
||||||
|
'passive-activity-exclusions',
|
||||||
].forEach((id) => {
|
].forEach((id) => {
|
||||||
elements[id] = new Element();
|
elements[id] = new Element();
|
||||||
});
|
});
|
||||||
@@ -45,12 +69,16 @@ const initialState = {
|
|||||||
settings: {
|
settings: {
|
||||||
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
|
receiverUrl: 'http://127.0.0.1:47731/api/browser-inbox/v1/captures',
|
||||||
receiverToken: 'persisted-token',
|
receiverToken: 'persisted-token',
|
||||||
|
language: 'system',
|
||||||
|
passiveActivityEnabled: false,
|
||||||
|
passiveActivityExcludedDomains: ['youtube.com'],
|
||||||
},
|
},
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
status: {},
|
status: {},
|
||||||
};
|
};
|
||||||
const browser = {
|
const browser = {
|
||||||
runtime: {
|
runtime: {
|
||||||
|
getURL(relativePath) { return `extension://${relativePath}`; },
|
||||||
sendMessage(message) {
|
sendMessage(message) {
|
||||||
if (message.action === 'getState') return Promise.resolve(initialState);
|
if (message.action === 'getState') return Promise.resolve(initialState);
|
||||||
if (message.action === 'saveSettings') {
|
if (message.action === 'saveSettings') {
|
||||||
@@ -60,34 +88,75 @@ const browser = {
|
|||||||
return Promise.resolve(initialState);
|
return Promise.resolve(initialState);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
i18n: { getUILanguage() { return 'ru-RU'; } },
|
||||||
};
|
};
|
||||||
const document = {
|
const document = {
|
||||||
activeElement: null,
|
activeElement: null,
|
||||||
|
documentElement: { lang: '' },
|
||||||
getElementById(id) {
|
getElementById(id) {
|
||||||
return elements[id];
|
return elements[id];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const catalogs = {
|
||||||
|
en: JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'shared', 'locales', 'en.json'), 'utf8')),
|
||||||
|
ru: JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'shared', 'locales', 'ru.json'), 'utf8')),
|
||||||
|
};
|
||||||
|
function fetchCatalog(url) {
|
||||||
|
const locale = /\/ru\.json$/.test(url) ? 'ru' : 'en';
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(catalogs[locale]) });
|
||||||
|
}
|
||||||
|
const i18nPath = path.join(__dirname, '..', 'shared', 'i18n.js');
|
||||||
const popupPath = path.join(__dirname, '..', 'shared', 'popup', 'popup.js');
|
const popupPath = path.join(__dirname, '..', 'shared', 'popup', 'popup.js');
|
||||||
vm.runInNewContext(fs.readFileSync(popupPath, 'utf8'), { browser, console, document, Promise, btoa });
|
const 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(fs.readFileSync(popupPath, 'utf8'), context, { filename: popupPath });
|
||||||
|
|
||||||
async function flush() {
|
async function flush() {
|
||||||
for (let i = 0; i < 4; i += 1) await Promise.resolve();
|
for (let i = 0; i < 16; i += 1) await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await flush();
|
await flush();
|
||||||
assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl);
|
assert.strictEqual(elements['receiver-input'].value, initialState.settings.receiverUrl);
|
||||||
assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken);
|
assert.strictEqual(elements['receiver-token-input'].value, initialState.settings.receiverToken);
|
||||||
|
assert.strictEqual(elements['language-select'].value, 'system');
|
||||||
|
assert.strictEqual(elements['passive-activity-enabled'].checked, false);
|
||||||
|
assert.strictEqual(elements['passive-activity-exclusions'].value, 'youtube.com');
|
||||||
|
assert.strictEqual(elements['capture-page'].textContent, 'Отправить страницу');
|
||||||
|
assert.strictEqual(elements['receiver-state'].textContent, 'Неизвестно');
|
||||||
|
assert.strictEqual(document.documentElement.lang, 'ru');
|
||||||
|
elements['capture-file'].click();
|
||||||
|
assert.strictEqual(elements.status.textContent, 'Сначала выберите файл');
|
||||||
|
|
||||||
|
elements['language-select'].value = 'en';
|
||||||
|
elements['language-select'].change();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
assert.strictEqual(elements['capture-page'].textContent, 'Send Page');
|
||||||
|
assert.strictEqual(elements['receiver-state'].textContent, 'Unknown');
|
||||||
|
assert.strictEqual(document.documentElement.lang, 'en');
|
||||||
|
assert.ok(savedSettings);
|
||||||
|
assert.strictEqual(savedSettings.language, 'en');
|
||||||
|
assert.strictEqual(savedSettings.receiverUrl, initialState.settings.receiverUrl);
|
||||||
|
assert.strictEqual(savedSettings.receiverToken, initialState.settings.receiverToken);
|
||||||
|
assert.strictEqual(savedSettings.passiveActivityEnabled, false);
|
||||||
|
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com']);
|
||||||
|
|
||||||
elements['receiver-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
|
elements['receiver-input'].value = 'http://127.0.0.1:47731/api/browser-inbox/v1/captures';
|
||||||
elements['receiver-token-input'].value = 'new-token';
|
elements['receiver-token-input'].value = 'new-token';
|
||||||
|
elements['passive-activity-enabled'].checked = true;
|
||||||
|
elements['passive-activity-exclusions'].value = 'youtube.com\nx.com';
|
||||||
elements['save-settings'].click();
|
elements['save-settings'].click();
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
assert.ok(savedSettings);
|
assert.ok(savedSettings);
|
||||||
assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures');
|
assert.strictEqual(savedSettings.receiverUrl, 'http://127.0.0.1:47731/api/browser-inbox/v1/captures');
|
||||||
assert.strictEqual(savedSettings.receiverToken, 'new-token');
|
assert.strictEqual(savedSettings.receiverToken, 'new-token');
|
||||||
console.log('browser extension popup settings tests passed');
|
assert.strictEqual(savedSettings.language, 'en');
|
||||||
|
assert.strictEqual(savedSettings.passiveActivityEnabled, true);
|
||||||
|
assert.deepStrictEqual(Array.from(savedSettings.passiveActivityExcludedDomains), ['youtube.com', 'x.com']);
|
||||||
|
console.log('browser extension popup localization/settings tests passed');
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
+215
-19
@@ -4,20 +4,78 @@
|
|||||||
var ext = typeof browser !== 'undefined' ? browser : chrome;
|
var ext = typeof browser !== 'undefined' ? browser : chrome;
|
||||||
var protocol = globalThis.VerstakBrowser;
|
var protocol = globalThis.VerstakBrowser;
|
||||||
var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext));
|
var queue = new protocol.CaptureQueue(protocol.browserStorageAdapter(ext));
|
||||||
|
var activityTracker = new protocol.DomainActivityTracker(
|
||||||
|
protocol.browserActivityStorageAdapter(ext),
|
||||||
|
function (batch) {
|
||||||
|
return getSettings().then(function (settings) {
|
||||||
|
return protocol.sendActivityBatch(settings.receiverUrl, settings.receiverToken, batch);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
var i18n = globalThis.VerstakBrowserI18n;
|
||||||
var DEFAULT_SETTINGS = {
|
var DEFAULT_SETTINGS = {
|
||||||
receiverUrl: protocol.DEFAULT_RECEIVER_URL,
|
receiverUrl: protocol.DEFAULT_RECEIVER_URL,
|
||||||
receiverToken: ''
|
receiverToken: '',
|
||||||
|
language: 'system',
|
||||||
|
passiveActivityEnabled: false,
|
||||||
|
passiveActivityExcludedDomains: []
|
||||||
};
|
};
|
||||||
var STATUS_KEY = 'verstak.status';
|
var STATUS_KEY = 'verstak.status';
|
||||||
|
var ACTIVITY_FLUSH_ALARM = 'verstak-domain-activity-flush';
|
||||||
|
var localeCatalogs = null;
|
||||||
|
var focusedWindowID = null;
|
||||||
|
var activeTabID = null;
|
||||||
|
|
||||||
function getSettings() {
|
function getSettings() {
|
||||||
return ext.storage.local.get('settings').then(function (result) {
|
return ext.storage.local.get('settings').then(function (result) {
|
||||||
return Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {});
|
var settings = Object.assign({}, DEFAULT_SETTINGS, result && result.settings || {});
|
||||||
|
settings.language = i18n.normalizePreference(settings.language);
|
||||||
|
settings.passiveActivityEnabled = settings.passiveActivityEnabled === true;
|
||||||
|
settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains);
|
||||||
|
return settings;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings(settings) {
|
function saveSettings(settings) {
|
||||||
return ext.storage.local.set({ settings: Object.assign({}, DEFAULT_SETTINGS, settings || {}) });
|
settings = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||||
|
settings.language = i18n.normalizePreference(settings.language);
|
||||||
|
settings.passiveActivityEnabled = settings.passiveActivityEnabled === true;
|
||||||
|
settings.passiveActivityExcludedDomains = normalizeExcludedDomains(settings.passiveActivityExcludedDomains);
|
||||||
|
return ext.storage.local.set({ settings: settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExcludedDomains(value) {
|
||||||
|
var values = Array.isArray(value) ? value : String(value || '').split(/[\n,]/);
|
||||||
|
var seen = {};
|
||||||
|
return values.map(function (item) {
|
||||||
|
return protocol.normalizeHostnameV1(String(item || '').trim());
|
||||||
|
}).filter(function (item) {
|
||||||
|
if (!item || seen[item]) return false;
|
||||||
|
seen[item] = true;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocaleCatalogs() {
|
||||||
|
if (localeCatalogs) return localeCatalogs;
|
||||||
|
localeCatalogs = i18n.loadCatalogs(function (locale) {
|
||||||
|
return fetch(ext.runtime.getURL('locales/' + locale + '.json')).then(function (response) {
|
||||||
|
if (!response.ok) throw new Error('catalog load failed: ' + locale);
|
||||||
|
return response.json();
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.warn('[verstak] localization catalogs unavailable:', error);
|
||||||
|
return { en: {}, ru: {} };
|
||||||
|
});
|
||||||
|
return localeCatalogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserLocale() {
|
||||||
|
try {
|
||||||
|
return ext.i18n && ext.i18n.getUILanguage ? ext.i18n.getUILanguage() : 'en';
|
||||||
|
} catch (_) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(patch) {
|
function setStatus(patch) {
|
||||||
@@ -39,6 +97,58 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFocusedWindow(windowID) {
|
||||||
|
return focusedWindowID == null || focusedWindowID === windowID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackTab(tab, settings) {
|
||||||
|
if (!settings.passiveActivityEnabled || !tab || !isFocusedWindow(tab.windowId)) {
|
||||||
|
return activityTracker.setActiveHostname('', false);
|
||||||
|
}
|
||||||
|
activeTabID = tab.id == null ? activeTabID : tab.id;
|
||||||
|
var hostname = protocol.normalizeURLHostnameV1(tab.url || '');
|
||||||
|
if (!hostname || protocol.isExcludedHostname(hostname, settings.passiveActivityExcludedDomains)) {
|
||||||
|
return activityTracker.setActiveHostname('', false);
|
||||||
|
}
|
||||||
|
return activityTracker.setActiveHostname(hostname, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFocusedTab(settings) {
|
||||||
|
return activeTab().then(function (tab) {
|
||||||
|
return trackTab(tab, settings);
|
||||||
|
}).catch(function () {
|
||||||
|
return activityTracker.setActiveHostname('', false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function configurePassiveActivity(settings) {
|
||||||
|
return activityTracker.setEnabled(settings.passiveActivityEnabled).then(function () {
|
||||||
|
if (!settings.passiveActivityEnabled) return undefined;
|
||||||
|
return refreshFocusedTab(settings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPassiveActivity() {
|
||||||
|
return getSettings().then(function (settings) {
|
||||||
|
if (!settings.passiveActivityEnabled) return activityTracker.setEnabled(false);
|
||||||
|
return activityTracker.setEnabled(true).then(function () {
|
||||||
|
return refreshFocusedTab(settings);
|
||||||
|
}).then(function () {
|
||||||
|
return activityTracker.flush();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPassiveActivity() {
|
||||||
|
if (ext.alarms && ext.alarms.create) {
|
||||||
|
ext.alarms.create(ACTIVITY_FLUSH_ALARM, { periodInMinutes: 5 });
|
||||||
|
}
|
||||||
|
if (ext.idle && ext.idle.setDetectionInterval) {
|
||||||
|
ext.idle.setDetectionInterval(60);
|
||||||
|
}
|
||||||
|
return getSettings().then(configurePassiveActivity);
|
||||||
|
}
|
||||||
|
|
||||||
function captureFromInfo(kind, info, tab) {
|
function captureFromInfo(kind, info, tab) {
|
||||||
return protocol.buildCapture({
|
return protocol.buildCapture({
|
||||||
kind: kind,
|
kind: kind,
|
||||||
@@ -94,22 +204,42 @@
|
|||||||
return Promise.all([
|
return Promise.all([
|
||||||
getSettings(),
|
getSettings(),
|
||||||
queue.list(),
|
queue.list(),
|
||||||
ext.storage.local.get(STATUS_KEY)
|
ext.storage.local.get(STATUS_KEY),
|
||||||
|
activityTracker.getState()
|
||||||
]).then(function (results) {
|
]).then(function (results) {
|
||||||
return {
|
return {
|
||||||
settings: results[0],
|
settings: results[0],
|
||||||
pendingCount: results[1].length,
|
pendingCount: results[1].length,
|
||||||
|
pendingActivityCount: results[3].pendingBatches.length,
|
||||||
|
activityState: results[3],
|
||||||
status: results[2] && results[2][STATUS_KEY] || {}
|
status: results[2] && results[2][STATUS_KEY] || {}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupContextMenus() {
|
function setupContextMenus() {
|
||||||
if (!ext.contextMenus) return;
|
if (!ext.contextMenus) return Promise.resolve();
|
||||||
ext.contextMenus.removeAll(function () {
|
return Promise.all([getSettings(), loadLocaleCatalogs()]).then(function (results) {
|
||||||
ext.contextMenus.create({ id: 'verstak-capture-page', title: 'Send page to Verstak', contexts: ['page'] });
|
var locale = i18n.resolveLocale(results[0].language, browserLocale());
|
||||||
ext.contextMenus.create({ id: 'verstak-capture-selection', title: 'Send selection to Verstak', contexts: ['selection'] });
|
var t = i18n.createTranslator(results[1], locale);
|
||||||
ext.contextMenus.create({ id: 'verstak-capture-link', title: 'Send link to Verstak', contexts: ['link'] });
|
return new Promise(function (resolve) {
|
||||||
|
var created = false;
|
||||||
|
function createMenus() {
|
||||||
|
if (created) return;
|
||||||
|
created = true;
|
||||||
|
ext.contextMenus.create({ id: 'verstak-capture-page', title: t('context.sendPage', null, 'Send page to Verstak'), contexts: ['page'] });
|
||||||
|
ext.contextMenus.create({ id: 'verstak-capture-selection', title: t('context.sendSelection', null, 'Send selection to Verstak'), contexts: ['selection'] });
|
||||||
|
ext.contextMenus.create({ id: 'verstak-capture-link', title: t('context.sendLink', null, 'Send link to Verstak'), contexts: ['link'] });
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
var removal;
|
||||||
|
try {
|
||||||
|
removal = ext.contextMenus.removeAll(createMenus);
|
||||||
|
} catch (_) {
|
||||||
|
removal = ext.contextMenus.removeAll();
|
||||||
|
}
|
||||||
|
if (removal && typeof removal.then === 'function') removal.then(createMenus, createMenus);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,17 +254,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(message) {
|
function handleMessage(message) {
|
||||||
if (!message || message.type !== 'verstak.capture') return Promise.resolve(undefined);
|
return ready.then(function () {
|
||||||
if (message.action === 'getState') return getState();
|
if (!message || message.type !== 'verstak.capture') return undefined;
|
||||||
if (message.action === 'saveSettings') {
|
if (message.action === 'getState') return getState();
|
||||||
return saveSettings(message.settings).then(function () {
|
if (message.action === 'saveSettings') {
|
||||||
return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' });
|
return saveSettings(message.settings).then(function () {
|
||||||
|
return setupContextMenus();
|
||||||
|
}).then(function () {
|
||||||
|
return getSettings().then(configurePassiveActivity);
|
||||||
|
}).then(function () {
|
||||||
|
return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' });
|
||||||
|
}).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 sendOrQueue(captureFromInfo(message.kind || 'page', message, tab));
|
||||||
}).then(getState);
|
}).then(getState);
|
||||||
}
|
});
|
||||||
if (message.action === 'retryPending') return retryPending().then(getState);
|
|
||||||
return activeTab().then(function (tab) {
|
|
||||||
return sendOrQueue(captureFromInfo(message.kind || 'page', message, tab));
|
|
||||||
}).then(getState);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {
|
ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {
|
||||||
@@ -143,4 +280,63 @@
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (ext.tabs && ext.tabs.onActivated) {
|
||||||
|
ext.tabs.onActivated.addListener(function (info) {
|
||||||
|
if (!isFocusedWindow(info.windowId) || !ext.tabs.get) return;
|
||||||
|
activeTabID = info.tabId;
|
||||||
|
ready.then(function () { return getSettings(); }).then(function (settings) {
|
||||||
|
return ext.tabs.get(info.tabId).then(function (tab) { return trackTab(tab, settings); });
|
||||||
|
}).catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext.tabs && ext.tabs.onUpdated) {
|
||||||
|
ext.tabs.onUpdated.addListener(function (tabID, changeInfo, tab) {
|
||||||
|
if (tabID !== activeTabID || (!changeInfo.url && !(tab && tab.url))) return;
|
||||||
|
ready.then(function () { return getSettings(); }).then(function (settings) {
|
||||||
|
return trackTab(tab || { id: tabID, url: changeInfo.url }, settings);
|
||||||
|
}).catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext.windows && ext.windows.onFocusChanged) {
|
||||||
|
ext.windows.onFocusChanged.addListener(function (windowID) {
|
||||||
|
focusedWindowID = windowID;
|
||||||
|
if (windowID === (ext.windows.WINDOW_ID_NONE == null ? -1 : ext.windows.WINDOW_ID_NONE)) {
|
||||||
|
activityTracker.setActiveHostname('', false).catch(function () {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ready.then(function () { return getSettings(); }).then(refreshFocusedTab).catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext.idle && ext.idle.onStateChanged) {
|
||||||
|
ext.idle.onStateChanged.addListener(function (state) {
|
||||||
|
if (state === 'active') {
|
||||||
|
ready.then(function () { return getSettings(); }).then(refreshFocusedTab).catch(function () {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activityTracker.setActiveHostname('', false).catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext.alarms && ext.alarms.onAlarm) {
|
||||||
|
ext.alarms.onAlarm.addListener(function (alarm) {
|
||||||
|
if (alarm && alarm.name === ACTIVITY_FLUSH_ALARM) flushPassiveActivity().catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext.tabs && ext.tabs.onRemoved) {
|
||||||
|
ext.tabs.onRemoved.addListener(function (tabID) {
|
||||||
|
if (tabID === activeTabID) {
|
||||||
|
activeTabID = null;
|
||||||
|
activityTracker.setActiveHostname('', false).catch(function () {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var ready = activityTracker.initialize().then(setupPassiveActivity).catch(function (error) {
|
||||||
|
console.warn('[verstak] passive activity initialization failed:', error);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$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,
|
||||||
|
"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": "" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Неизвестно",
|
||||||
|
|||||||
@@ -126,11 +126,52 @@ input {
|
|||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
resize: vertical;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="file"] {
|
input[type="file"] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tracking-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: #e8ecf3 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-toggle input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-settings .hint {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
+29
-8
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -17,15 +17,19 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span>Receiver</span>
|
<span id="receiver-label">Receiver</span>
|
||||||
<strong id="receiver-state">Unknown</strong>
|
<strong id="receiver-state">Unknown</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span>Pending</span>
|
<span id="pending-label">Pending</span>
|
||||||
<strong id="pending-count">0</strong>
|
<strong id="pending-count">0</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span id="activity-pending-label">Activity batches</span>
|
||||||
|
<strong id="activity-pending-count">0</strong>
|
||||||
|
</div>
|
||||||
<div class="row url-row">
|
<div class="row url-row">
|
||||||
<span>URL</span>
|
<span id="url-label">URL</span>
|
||||||
<code id="receiver-url"></code>
|
<code id="receiver-url"></code>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -37,21 +41,38 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="file-picker">
|
<section class="file-picker">
|
||||||
<label for="file-input">File</label>
|
<label id="file-label" for="file-input">File</label>
|
||||||
<input id="file-input" type="file">
|
<input id="file-input" type="file">
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings">
|
<section class="settings">
|
||||||
<label for="receiver-input">Receiver URL</label>
|
<label id="receiver-url-label" for="receiver-input">Receiver URL</label>
|
||||||
<input id="receiver-input" type="url" spellcheck="false">
|
<input id="receiver-input" type="url" spellcheck="false">
|
||||||
<label for="receiver-token-input">Pairing token</label>
|
<label id="receiver-token-label" for="receiver-token-input">Pairing token</label>
|
||||||
<input id="receiver-token-input" type="password" spellcheck="false" autocomplete="off">
|
<input id="receiver-token-input" type="password" spellcheck="false" autocomplete="off">
|
||||||
|
<label id="language-label" for="language-select">Language</label>
|
||||||
|
<select id="language-select">
|
||||||
|
<option id="language-system-option" value="system">System</option>
|
||||||
|
<option id="language-en-option" value="en">English</option>
|
||||||
|
<option id="language-ru-option" value="ru">Русский</option>
|
||||||
|
</select>
|
||||||
<button id="save-settings" class="secondary">Save</button>
|
<button id="save-settings" class="secondary">Save</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="hint">Selection and link captures are available from the page context menu.</p>
|
<section class="settings tracking-settings">
|
||||||
|
<label class="tracking-toggle" for="passive-activity-enabled">
|
||||||
|
<input id="passive-activity-enabled" type="checkbox">
|
||||||
|
<span id="passive-activity-label">Track active domain time</span>
|
||||||
|
</label>
|
||||||
|
<p id="passive-activity-disclosure" class="hint">Optional. Collects only the active domain and time spent there. It never sends page URLs, titles, text, keystrokes, or page contents.</p>
|
||||||
|
<label id="passive-activity-exclusions-label" for="passive-activity-exclusions">Excluded domains</label>
|
||||||
|
<textarea id="passive-activity-exclusions" rows="3" spellcheck="false" placeholder="youtube.com"></textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="context-menu-hint" class="hint">Selection and link captures are available from the page context menu.</p>
|
||||||
<p id="status"></p>
|
<p id="status"></p>
|
||||||
</main>
|
</main>
|
||||||
|
<script src="../i18n.js"></script>
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+158
-56
@@ -2,16 +2,100 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var ext = typeof browser !== 'undefined' ? browser : chrome;
|
var ext = typeof browser !== 'undefined' ? browser : chrome;
|
||||||
|
var i18n = globalThis.VerstakBrowserI18n;
|
||||||
var statusEl = document.getElementById('status');
|
var statusEl = document.getElementById('status');
|
||||||
var receiverStateEl = document.getElementById('receiver-state');
|
var receiverStateEl = document.getElementById('receiver-state');
|
||||||
var receiverUrlEl = document.getElementById('receiver-url');
|
var receiverUrlEl = document.getElementById('receiver-url');
|
||||||
var receiverInputEl = document.getElementById('receiver-input');
|
var receiverInputEl = document.getElementById('receiver-input');
|
||||||
var receiverTokenInputEl = document.getElementById('receiver-token-input');
|
var receiverTokenInputEl = document.getElementById('receiver-token-input');
|
||||||
|
var languageSelectEl = document.getElementById('language-select');
|
||||||
var fileInputEl = document.getElementById('file-input');
|
var fileInputEl = document.getElementById('file-input');
|
||||||
var pendingCountEl = document.getElementById('pending-count');
|
var pendingCountEl = document.getElementById('pending-count');
|
||||||
|
var pendingActivityCountEl = document.getElementById('activity-pending-count');
|
||||||
var statusDotEl = document.getElementById('status-dot');
|
var statusDotEl = document.getElementById('status-dot');
|
||||||
|
var passiveActivityEnabledEl = document.getElementById('passive-activity-enabled');
|
||||||
|
var passiveActivityExclusionsEl = document.getElementById('passive-activity-exclusions');
|
||||||
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
|
var MAX_FILE_TEXT_LENGTH = 2 * 1024 * 1024;
|
||||||
var MAX_FILE_BYTES = 8 * 1024 * 1024;
|
var MAX_FILE_BYTES = 8 * 1024 * 1024;
|
||||||
|
var catalogs = { en: {}, ru: {} };
|
||||||
|
var currentPreference = 'system';
|
||||||
|
var currentState = {};
|
||||||
|
var t = i18n.createTranslator(catalogs, 'en');
|
||||||
|
|
||||||
|
var staticText = {
|
||||||
|
subtitle: 'popup.subtitle',
|
||||||
|
'receiver-label': 'label.receiver',
|
||||||
|
'pending-label': 'label.pending',
|
||||||
|
'activity-pending-label': 'label.activityPending',
|
||||||
|
'url-label': 'label.url',
|
||||||
|
'file-label': 'label.file',
|
||||||
|
'receiver-url-label': 'label.receiverUrl',
|
||||||
|
'receiver-token-label': 'label.pairingToken',
|
||||||
|
'language-label': 'label.language',
|
||||||
|
'capture-page': 'action.sendPage',
|
||||||
|
'capture-file': 'action.sendFile',
|
||||||
|
retry: 'action.retryPending',
|
||||||
|
'save-settings': 'action.save',
|
||||||
|
'context-menu-hint': 'hint.contextMenu',
|
||||||
|
'language-system-option': 'language.system',
|
||||||
|
'language-en-option': 'language.en',
|
||||||
|
'language-ru-option': 'language.ru',
|
||||||
|
'passive-activity-label': 'label.passiveActivity',
|
||||||
|
'passive-activity-disclosure': 'hint.passiveActivityDisclosure',
|
||||||
|
'passive-activity-exclusions-label': 'label.passiveActivityExclusions'
|
||||||
|
};
|
||||||
|
|
||||||
|
function browserLocale() {
|
||||||
|
try {
|
||||||
|
if (ext.i18n && ext.i18n.getUILanguage) return ext.i18n.getUILanguage();
|
||||||
|
} catch (_) {}
|
||||||
|
return typeof navigator !== 'undefined' ? navigator.language : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCatalogs() {
|
||||||
|
return i18n.loadCatalogs(function (locale) {
|
||||||
|
return fetch(ext.runtime.getURL('locales/' + locale + '.json')).then(function (response) {
|
||||||
|
if (!response.ok) throw new Error('catalog load failed: ' + locale);
|
||||||
|
return response.json();
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.warn('[verstak] localization catalogs unavailable:', error);
|
||||||
|
return { en: {}, ru: {} };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReceiverState(state) {
|
||||||
|
var reachable = state && state.status && state.status.receiverReachable;
|
||||||
|
if (reachable === true) {
|
||||||
|
receiverStateEl.textContent = t('receiver.online', null, 'Online');
|
||||||
|
receiverStateEl.className = 'online';
|
||||||
|
statusDotEl.className = 'dot online';
|
||||||
|
} else if (reachable === false) {
|
||||||
|
receiverStateEl.textContent = t('receiver.offline', null, 'Offline');
|
||||||
|
receiverStateEl.className = 'offline';
|
||||||
|
statusDotEl.className = 'dot offline';
|
||||||
|
} else {
|
||||||
|
receiverStateEl.textContent = t('receiver.unknown', null, 'Unknown');
|
||||||
|
receiverStateEl.className = '';
|
||||||
|
statusDotEl.className = 'dot unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLocale(preference) {
|
||||||
|
currentPreference = i18n.normalizePreference(preference);
|
||||||
|
var locale = i18n.resolveLocale(currentPreference, browserLocale());
|
||||||
|
t = i18n.createTranslator(catalogs, locale);
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
languageSelectEl.value = currentPreference;
|
||||||
|
Object.keys(staticText).forEach(function (id) {
|
||||||
|
var element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
if (element.__verstakI18nFallback == null) element.__verstakI18nFallback = element.textContent;
|
||||||
|
element.textContent = t(staticText[id], null, element.__verstakI18nFallback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
applyReceiverState(currentState);
|
||||||
|
}
|
||||||
|
|
||||||
function arrayBufferToBase64(buffer) {
|
function arrayBufferToBase64(buffer) {
|
||||||
var bytes = new Uint8Array(buffer);
|
var bytes = new Uint8Array(buffer);
|
||||||
@@ -42,48 +126,69 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(state) {
|
function render(state) {
|
||||||
state = state || {};
|
currentState = state || {};
|
||||||
var settings = state.settings || {};
|
var settings = currentState.settings || {};
|
||||||
var status = state.status || {};
|
pendingCountEl.textContent = String(currentState.pendingCount || 0);
|
||||||
var reachable = status.receiverReachable;
|
pendingActivityCountEl.textContent = String(currentState.pendingActivityCount || 0);
|
||||||
pendingCountEl.textContent = String(state.pendingCount || 0);
|
|
||||||
receiverUrlEl.textContent = settings.receiverUrl || '';
|
receiverUrlEl.textContent = settings.receiverUrl || '';
|
||||||
if (document.activeElement !== receiverInputEl) {
|
if (document.activeElement !== receiverInputEl) receiverInputEl.value = settings.receiverUrl || '';
|
||||||
receiverInputEl.value = settings.receiverUrl || '';
|
if (document.activeElement !== receiverTokenInputEl) receiverTokenInputEl.value = settings.receiverToken || '';
|
||||||
}
|
passiveActivityEnabledEl.checked = settings.passiveActivityEnabled === true;
|
||||||
if (document.activeElement !== receiverTokenInputEl) {
|
if (document.activeElement !== passiveActivityExclusionsEl) {
|
||||||
receiverTokenInputEl.value = settings.receiverToken || '';
|
passiveActivityExclusionsEl.value = Array.isArray(settings.passiveActivityExcludedDomains)
|
||||||
}
|
? settings.passiveActivityExcludedDomains.join('\n')
|
||||||
|
: '';
|
||||||
if (reachable === true) {
|
|
||||||
receiverStateEl.textContent = 'Online';
|
|
||||||
receiverStateEl.className = 'online';
|
|
||||||
statusDotEl.className = 'dot online';
|
|
||||||
} else if (reachable === false) {
|
|
||||||
receiverStateEl.textContent = 'Offline';
|
|
||||||
receiverStateEl.className = 'offline';
|
|
||||||
statusDotEl.className = 'dot offline';
|
|
||||||
} else {
|
|
||||||
receiverStateEl.textContent = 'Unknown';
|
|
||||||
receiverStateEl.className = '';
|
|
||||||
statusDotEl.className = 'dot unknown';
|
|
||||||
}
|
}
|
||||||
|
applyReceiverState(currentState);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
return request({ type: 'verstak.capture', action: 'getState' }).then(render).catch(function (err) {
|
return request({ type: 'verstak.capture', action: 'getState' }).then(function (state) {
|
||||||
setStatus(err && err.message ? err.message : String(err));
|
render(state);
|
||||||
|
applyLocale(state.settings && state.settings.language || 'system');
|
||||||
|
return state;
|
||||||
|
}).catch(function (error) {
|
||||||
|
setStatus(error && error.message ? error.message : String(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSettings() {
|
||||||
|
return {
|
||||||
|
receiverUrl: receiverInputEl.value.trim(),
|
||||||
|
receiverToken: receiverTokenInputEl.value.trim(),
|
||||||
|
language: currentPreference,
|
||||||
|
passiveActivityEnabled: passiveActivityEnabledEl.checked === true,
|
||||||
|
passiveActivityExcludedDomains: passiveActivityExclusionsEl.value.split(/[\n,]/).map(function (value) {
|
||||||
|
return value.trim();
|
||||||
|
}).filter(Boolean)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCurrentSettings(successMessage) {
|
||||||
|
return request({
|
||||||
|
type: 'verstak.capture',
|
||||||
|
action: 'saveSettings',
|
||||||
|
settings: currentSettings()
|
||||||
|
}).then(function (state) {
|
||||||
|
render(state);
|
||||||
|
if (successMessage) setStatus(t('status.saved', null, 'Saved'));
|
||||||
|
return state;
|
||||||
|
}).catch(function (error) {
|
||||||
|
setStatus(error && error.message ? error.message : String(error));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function send(message) {
|
function send(message) {
|
||||||
setStatus('Sending...');
|
setStatus(t('status.sending', null, 'Sending...'));
|
||||||
request(message).then(function (state) {
|
request(message).then(function (state) {
|
||||||
render(state);
|
render(state);
|
||||||
if (state.status && state.status.lastResult === 'queued') setStatus('Queued until Verstak is available');
|
if (state.status && state.status.lastResult === 'queued') {
|
||||||
else setStatus('Done');
|
setStatus(t('status.queued', null, 'Queued until Verstak is available'));
|
||||||
}).catch(function (err) {
|
} else {
|
||||||
setStatus(err && err.message ? err.message : String(err));
|
setStatus(t('status.done', null, 'Done'));
|
||||||
|
}
|
||||||
|
}).catch(function (error) {
|
||||||
|
setStatus(error && error.message ? error.message : String(error));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,27 +199,26 @@
|
|||||||
document.getElementById('capture-file').addEventListener('click', function () {
|
document.getElementById('capture-file').addEventListener('click', function () {
|
||||||
var file = fileInputEl.files && fileInputEl.files[0];
|
var file = fileInputEl.files && fileInputEl.files[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setStatus('Choose a text file first');
|
setStatus(t('error.chooseFile', null, 'Choose a file first'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > MAX_FILE_BYTES) {
|
if (file.size > MAX_FILE_BYTES) {
|
||||||
setStatus('File is too large for browser capture');
|
setStatus(t('error.fileTooLarge', null, 'File is too large for browser capture'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus('Reading file...');
|
setStatus(t('status.readingFile', null, 'Reading file...'));
|
||||||
Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) {
|
Promise.all([file.arrayBuffer(), readOptionalText(file)]).then(function (results) {
|
||||||
var content = results[1] || '';
|
|
||||||
send({
|
send({
|
||||||
type: 'verstak.capture',
|
type: 'verstak.capture',
|
||||||
kind: 'file',
|
kind: 'file',
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileMime: file.type || '',
|
fileMime: file.type || '',
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
fileText: content,
|
fileText: results[1] || '',
|
||||||
fileDataBase64: arrayBufferToBase64(results[0])
|
fileDataBase64: arrayBufferToBase64(results[0])
|
||||||
});
|
});
|
||||||
}).catch(function (err) {
|
}).catch(function (error) {
|
||||||
setStatus(err && err.message ? err.message : String(err));
|
setStatus(error && error.message ? error.message : String(error));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,24 +226,22 @@
|
|||||||
send({ type: 'verstak.capture', action: 'retryPending' });
|
send({ type: 'verstak.capture', action: 'retryPending' });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('save-settings').addEventListener('click', function () {
|
languageSelectEl.addEventListener('change', function () {
|
||||||
var receiverUrl = receiverInputEl.value.trim();
|
applyLocale(languageSelectEl.value);
|
||||||
var receiverToken = receiverTokenInputEl.value.trim();
|
saveCurrentSettings(true);
|
||||||
if (!/^https?:\/\//.test(receiverUrl)) {
|
|
||||||
setStatus('Receiver URL must start with http:// or https://');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
request({
|
|
||||||
type: 'verstak.capture',
|
|
||||||
action: 'saveSettings',
|
|
||||||
settings: { receiverUrl: receiverUrl, receiverToken: receiverToken }
|
|
||||||
}).then(function (state) {
|
|
||||||
render(state);
|
|
||||||
setStatus('Saved');
|
|
||||||
}).catch(function (err) {
|
|
||||||
setStatus(err && err.message ? err.message : String(err));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
refresh();
|
document.getElementById('save-settings').addEventListener('click', function () {
|
||||||
|
if (!/^https?:\/\//.test(receiverInputEl.value.trim())) {
|
||||||
|
setStatus(t('error.invalidReceiverUrl', null, 'Receiver URL must start with http:// or https://'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveCurrentSettings(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadCatalogs().then(function (loadedCatalogs) {
|
||||||
|
catalogs = loadedCatalogs;
|
||||||
|
applyLocale('system');
|
||||||
|
return refresh();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+5
-5
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user