6 Commits
16 changed files with 510 additions and 87 deletions
+9
View File
@@ -67,6 +67,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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"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",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"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://git.mirv.top/verstak/verstak-browser-extension",
@@ -17,7 +17,7 @@
}, },
"permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"], "permissions": ["contextMenus", "storage", "tabs", "http://127.0.0.1/*", "http://localhost/*"],
"background": { "background": {
"scripts": ["protocol.js", "api.js", "queue.js", "background.js"] "scripts": ["protocol.js", "api.js", "queue.js", "i18n.js", "background.js"]
}, },
"browser_action": { "browser_action": {
"default_popup": "popup/popup.html", "default_popup": "popup/popup.html",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "verstak-browser-extension", "name": "verstak-browser-extension",
"version": "2.0.1", "version": "2.0.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"
} }
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"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",
"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-protocol.js && node scripts/test-i18n.js && node scripts/test-popup-settings.js && node scripts/test-popup-catalog-fallback.js && node scripts/test-background-i18n.js",
"sign:firefox": "./scripts/sign-firefox-xpi.sh", "sign:firefox": "./scripts/sign-firefox-xpi.sh",
"release:firefox": "./scripts/release-firefox-xpi.sh" "release:firefox": "./scripts/release-firefox-xpi.sh"
}, },
+11 -1
View File
@@ -38,6 +38,13 @@ 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');
@@ -47,18 +54,21 @@ concat([
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 ['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');
+2 -2
View File
@@ -44,9 +44,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
+1 -1
View File
@@ -84,7 +84,7 @@ echo "Using AMO API proxy from WEB_EXT_API_PROXY"
npx web-ext "${SIGN_ARGS[@]}" npx web-ext "${SIGN_ARGS[@]}"
SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' | sort | tail -n 1 || true)" SIGNED_XPI="$(find "$ARTIFACTS_DIR" -maxdepth 1 -type f -name '*.xpi' -printf '%T@ %p\n' | sort -n | tail -n 1 | cut -d' ' -f2- || true)"
if [[ -z "$SIGNED_XPI" ]]; then if [[ -z "$SIGNED_XPI" ]]; then
echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2 echo "ERROR: signed XPI was not created in $ARTIFACTS_DIR" >&2
exit 1 exit 1
+119
View File
@@ -0,0 +1,119 @@
#!/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([]); } },
};
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 ['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 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);
});
+12
View File
@@ -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);
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
class Element {
constructor(text) {
this.value = '';
this.textContent = text || '';
this.className = '';
this.files = [];
this.listeners = {};
}
addEventListener(type, listener) { this.listeners[type] = listener; }
}
const ids = [
'status', 'receiver-state', 'receiver-url', 'receiver-input', 'receiver-token-input',
'language-select', 'file-input', 'pending-count', 'status-dot', 'subtitle',
'receiver-label', 'pending-label', 'url-label', 'file-label', 'receiver-url-label',
'receiver-token-label', 'language-label', 'capture-page', 'capture-file', 'retry',
'save-settings', 'context-menu-hint', 'language-system-option', 'language-en-option',
'language-ru-option',
];
const elements = Object.fromEntries(ids.map((id) => [id, new Element()]));
elements['capture-page'].textContent = 'Send Page';
const browser = {
runtime: {
getURL(relativePath) { return `extension://${relativePath}`; },
sendMessage() {
return Promise.resolve({
settings: { receiverUrl: 'http://127.0.0.1/', receiverToken: '', language: 'system' },
pendingCount: 0,
status: {},
});
},
},
i18n: { getUILanguage() { return 'en-US'; } },
};
const document = {
activeElement: null,
documentElement: { lang: '' },
getElementById(id) { return elements[id]; },
};
const context = vm.createContext({
browser,
document,
navigator: { language: 'en-US' },
console: { warn() {}, error: console.error },
Promise,
fetch() { return Promise.reject(new Error('catalog unavailable')); },
});
context.globalThis = context;
const root = path.resolve(__dirname, '..');
for (const file of ['shared/i18n.js', 'shared/popup/popup.js']) {
vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, { filename: file });
}
(async () => {
for (let i = 0; i < 16; i += 1) await Promise.resolve();
assert.strictEqual(elements['capture-page'].textContent, 'Send Page');
assert.strictEqual(elements['receiver-state'].textContent, 'Unknown');
assert.strictEqual(document.documentElement.lang, 'en');
console.log('browser extension popup catalog fallback tests passed');
})().catch((error) => {
console.error(error);
process.exit(1);
});
+55 -3
View File
@@ -20,6 +20,10 @@ class Element {
click() { click() {
if (this.listeners.click) this.listeners.click(); if (this.listeners.click) this.listeners.click();
} }
change() {
if (this.listeners.change) this.listeners.change({ target: this });
}
} }
const elements = {}; const elements = {};
@@ -32,10 +36,23 @@ const elements = {};
'file-input', 'file-input',
'pending-count', 'pending-count',
'status-dot', 'status-dot',
'subtitle',
'receiver-label',
'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',
].forEach((id) => { ].forEach((id) => {
elements[id] = new Element(); elements[id] = new Element();
}); });
@@ -45,12 +62,14 @@ 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',
}, },
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,24 +79,56 @@ 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['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);
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';
@@ -87,7 +138,8 @@ async function 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');
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);
+56 -8
View File
@@ -4,20 +4,49 @@
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 i18n = globalThis.VerstakBrowserI18n;
var DEFAULT_SETTINGS = { var DEFAULT_SETTINGS = {
receiverUrl: protocol.DEFAULT_RECEIVER_URL, receiverUrl: protocol.DEFAULT_RECEIVER_URL,
receiverToken: '' receiverToken: '',
language: 'system'
}; };
var STATUS_KEY = 'verstak.status'; var STATUS_KEY = 'verstak.status';
var localeCatalogs = 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);
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);
return ext.storage.local.set({ settings: settings });
}
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) {
@@ -105,11 +134,28 @@
} }
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);
});
}); });
} }
@@ -128,6 +174,8 @@
if (message.action === 'getState') return getState(); if (message.action === 'getState') return getState();
if (message.action === 'saveSettings') { if (message.action === 'saveSettings') {
return saveSettings(message.settings).then(function () { return saveSettings(message.settings).then(function () {
return setupContextMenus();
}).then(function () {
return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' }); return setStatus({ receiverReachable: null, lastResult: 'settings-saved', lastError: '' });
}).then(getState); }).then(getState);
} }
+11
View File
@@ -126,6 +126,17 @@ input {
color: #e5e7eb; color: #e5e7eb;
} }
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;
+15 -8
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -17,15 +17,15 @@
<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 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 +37,28 @@
</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> <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>
+141 -57
View File
@@ -2,16 +2,93 @@
'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 statusDotEl = document.getElementById('status-dot'); var statusDotEl = document.getElementById('status-dot');
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',
'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'
};
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 +119,58 @@
} }
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;
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 || '';
} applyReceiverState(currentState);
if (document.activeElement !== receiverTokenInputEl) {
receiverTokenInputEl.value = settings.receiverToken || '';
}
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';
}
} }
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
};
}
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 +181,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 +208,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();
});
})(); })();