feat: Firefox-расширение Verstak Bridge
- extension-firefox/manifest.json — Manifest V3 для Firefox (browser_specific_settings.gecko, background.scripts) - extension-firefox/background.js — browser.* API с chrome.* полифиллом - Стабильный device_id через crypto.getRandomValues (6 байт hex) - Фильтрация about:, moz-extension:, resource: и пр. внутренних URL - device_id с префиксом 'firefox-' для различения в activity - extension-firefox/popup/ — общие popup HTML/CSS/JS (копия Chrome)
This commit is contained in:
parent
fc429ac26e
commit
c5505ee43c
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -19,8 +19,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-DhmtVNHh.js"></script>
|
<script type="module" crossorigin src="/assets/main-C05Td0Ko.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-N_NpVgn3.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-DuzJPSY0.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
// Verstak Bridge for Firefox — Background Service Worker
|
||||||
|
// Uses browser.* WebExtensions API with chrome.* polyfill layer.
|
||||||
|
// Functionally identical to the Chrome version.
|
||||||
|
|
||||||
|
// Firefox uses browser.* namespace; provide chrome.* aliases for shared code.
|
||||||
|
if (typeof browser !== 'undefined' && typeof chrome === 'undefined') {
|
||||||
|
globalThis.chrome = browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'verstak_queue';
|
||||||
|
const BRIDGE_KEY = 'verstak_bridge_config';
|
||||||
|
const RECENT_KEY = 'verstak_recent';
|
||||||
|
const DEFAULT_CONFIG = { port: 9786, secret: '', autoGenPort: true };
|
||||||
|
const DEFAULT_DEVICE_ID = generateDeviceId();
|
||||||
|
|
||||||
|
// Session state
|
||||||
|
let session = {
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
domain: '',
|
||||||
|
tabId: -1,
|
||||||
|
startedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
setupAlarms();
|
||||||
|
setupListeners();
|
||||||
|
// Generate a stable device ID on first install
|
||||||
|
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||||
|
const cfg = data[BRIDGE_KEY] || {};
|
||||||
|
if (!cfg.deviceId) {
|
||||||
|
chrome.storage.local.set({ [BRIDGE_KEY]: { ...DEFAULT_CONFIG, ...cfg, deviceId: DEFAULT_DEVICE_ID } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.runtime.onStartup.addListener(() => {
|
||||||
|
setupAlarms();
|
||||||
|
setupListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupAlarms() {
|
||||||
|
chrome.alarms.create('flushEvents', { periodInMinutes: 0.5 });
|
||||||
|
chrome.alarms.create('pingBridge', { periodInMinutes: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupListeners() {
|
||||||
|
chrome.tabs.onActivated.addListener(onTabActivated);
|
||||||
|
chrome.tabs.onUpdated.addListener(onTabUpdated);
|
||||||
|
chrome.windows.onFocusChanged.addListener(onWindowFocusChanged);
|
||||||
|
|
||||||
|
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||||
|
if (alarm.name === 'flushEvents') flushSession();
|
||||||
|
if (alarm.name === 'pingBridge') pingBridge();
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
|
if (msg.type === 'FORCE_FLUSH') {
|
||||||
|
flushSession();
|
||||||
|
flushQueue();
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
}
|
||||||
|
if (msg.type === 'SET_TRACKING') {
|
||||||
|
chrome.storage.local.set({ 'verstak_tracking_enabled': msg.enabled });
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
}
|
||||||
|
return true; // keep channel open for async response
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tab tracking ---
|
||||||
|
|
||||||
|
function onTabActivated(activeInfo) {
|
||||||
|
flushSession();
|
||||||
|
startTracking(activeInfo.tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabUpdated(tabId, changeInfo, tab) {
|
||||||
|
if (changeInfo.status === 'complete' && tab.active) {
|
||||||
|
flushSession();
|
||||||
|
startTracking(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowFocusChanged(windowId) {
|
||||||
|
if (windowId === chrome.windows.WINDOW_ID_NONE) {
|
||||||
|
flushSession();
|
||||||
|
session = { url: '', title: '', domain: '', tabId: -1, startedAt: null };
|
||||||
|
} else {
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
|
if (tabs && tabs.length > 0) {
|
||||||
|
startTracking(tabs[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTracking(tabId) {
|
||||||
|
chrome.tabs.get(tabId, (tab) => {
|
||||||
|
if (chrome.runtime.lastError || !tab || !tab.url) return;
|
||||||
|
if (isInternalPage(tab.url)) return;
|
||||||
|
|
||||||
|
session = {
|
||||||
|
url: tab.url,
|
||||||
|
title: tab.title || '',
|
||||||
|
domain: extractDomain(tab.url),
|
||||||
|
tabId,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session flush ---
|
||||||
|
|
||||||
|
function flushSession() {
|
||||||
|
if (!session.url || !session.startedAt) return;
|
||||||
|
|
||||||
|
const duration = Math.round((Date.now() - session.startedAt) / 1000);
|
||||||
|
if (duration < 3) {
|
||||||
|
session.startedAt = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
id: generateId(),
|
||||||
|
type: 'page_visit',
|
||||||
|
url: session.url,
|
||||||
|
title: session.title,
|
||||||
|
domain: session.domain,
|
||||||
|
active_seconds: duration,
|
||||||
|
ts_start: new Date(session.startedAt).toISOString(),
|
||||||
|
ts_end: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
queueEvent(event);
|
||||||
|
session.startedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueEvent(event) {
|
||||||
|
chrome.storage.local.get(STORAGE_KEY, (data) => {
|
||||||
|
const queue = data[STORAGE_KEY] || [];
|
||||||
|
queue.push(event);
|
||||||
|
|
||||||
|
if (queue.length > 500) {
|
||||||
|
queue.splice(0, queue.length - 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.storage.local.set({ [STORAGE_KEY]: queue }, () => {
|
||||||
|
if (queue.length >= 5) {
|
||||||
|
flushQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bridge communication ---
|
||||||
|
|
||||||
|
function flushQueue() {
|
||||||
|
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY], (data) => {
|
||||||
|
const queue = data[STORAGE_KEY] || [];
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
|
||||||
|
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
|
||||||
|
const port = config.port || 9786;
|
||||||
|
const secret = config.secret || '';
|
||||||
|
const deviceId = config.deviceId || DEFAULT_DEVICE_ID;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
version: 1,
|
||||||
|
device_id: 'firefox-' + deviceId,
|
||||||
|
events: queue,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(`http://127.0.0.1:${port}/api/events`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Verstak-Secret': secret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
for (const ev of queue) {
|
||||||
|
updateRecent(ev);
|
||||||
|
}
|
||||||
|
chrome.storage.local.set({ [STORAGE_KEY]: [] });
|
||||||
|
chrome.runtime.sendMessage({ type: 'UI_UPDATE' }).catch(() => {});
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
console.warn('[verstak] bridge auth failed, check secret');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Bridge not available — keep events in queue
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pingBridge() {
|
||||||
|
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||||
|
const config = data[BRIDGE_KEY] || DEFAULT_CONFIG;
|
||||||
|
const port = config.port || 9786;
|
||||||
|
|
||||||
|
fetch(`http://127.0.0.1:${port}/api/ping`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((info) => {
|
||||||
|
chrome.storage.local.set({
|
||||||
|
[BRIDGE_KEY]: { ...config, ...info, bridgeReachable: true, lastPing: Date.now() }
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
chrome.storage.local.set({
|
||||||
|
[BRIDGE_KEY]: { ...config, bridgeReachable: false, lastPing: Date.now() }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Recent events ---
|
||||||
|
|
||||||
|
function updateRecent(event) {
|
||||||
|
chrome.storage.local.get(RECENT_KEY, (data) => {
|
||||||
|
const recent = data[RECENT_KEY] || [];
|
||||||
|
recent.push({
|
||||||
|
domain: event.domain,
|
||||||
|
title: event.title,
|
||||||
|
url: event.url,
|
||||||
|
active_seconds: event.active_seconds,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (recent.length > 50) {
|
||||||
|
recent.splice(0, recent.length - 50);
|
||||||
|
}
|
||||||
|
chrome.storage.local.set({ [RECENT_KEY]: recent });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function extractDomain(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInternalPage(url) {
|
||||||
|
return url.startsWith('about:') ||
|
||||||
|
url.startsWith('moz-extension://') ||
|
||||||
|
url.startsWith('chrome://') ||
|
||||||
|
url.startsWith('chrome-extension://') ||
|
||||||
|
url.startsWith('edge://') ||
|
||||||
|
url.startsWith('file://') ||
|
||||||
|
url.startsWith('data:') ||
|
||||||
|
url.startsWith('blob:') ||
|
||||||
|
url.startsWith('view-source:') ||
|
||||||
|
url.startsWith('resource:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
const ts = Date.now().toString(36);
|
||||||
|
const rand = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `evt_${ts}_${rand}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDeviceId() {
|
||||||
|
const buf = new Uint8Array(6);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Verstak Bridge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Отслеживает активные вкладки и отправляет события в Verstak",
|
||||||
|
"author": "Verstak",
|
||||||
|
"homepage_url": "https://git.mirv.top/mirivlad/verstak",
|
||||||
|
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "verstak-bridge@verstak.app",
|
||||||
|
"strict_min_version": "109.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"permissions": [
|
||||||
|
"tabs",
|
||||||
|
"storage",
|
||||||
|
"alarms"
|
||||||
|
],
|
||||||
|
|
||||||
|
"host_permissions": [
|
||||||
|
"http://127.0.0.1/*",
|
||||||
|
"http://localhost/*"
|
||||||
|
],
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"scripts": ["background.js"],
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup/popup.html",
|
||||||
|
"default_title": "Verstak Bridge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
/* Verstak Bridge Popup */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #1a1a2e;
|
||||||
|
--surface: #16213e;
|
||||||
|
--surface2: #1e2d50;
|
||||||
|
--accent: #0f9b8e;
|
||||||
|
--accent-glow: rgba(15, 155, 142, 0.3);
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-dim: #8892b0;
|
||||||
|
--danger: #e74c3c;
|
||||||
|
--success: #2ecc71;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 320px;
|
||||||
|
min-height: 400px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.online {
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 6px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.offline {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 6px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.offline {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.online {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle switch */
|
||||||
|
.controls {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .slider {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .slider::after {
|
||||||
|
left: 18px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recent events */
|
||||||
|
.recent {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent h2 {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#event-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .domain {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .title {
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item .duration {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer buttons */
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #0d8a7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary:hover {
|
||||||
|
background: #253a60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Verstak Bridge</title>
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Verstak Bridge</h1>
|
||||||
|
<span id="status-dot" class="dot offline"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="info">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Сервер</span>
|
||||||
|
<span id="bridge-status" class="value offline">Недоступен</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Очередь</span>
|
||||||
|
<span id="queue-count" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">Порт</span>
|
||||||
|
<span id="bridge-port" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="track-toggle" checked>
|
||||||
|
<span class="slider"></span>
|
||||||
|
<span class="toggle-label">Отслеживать вкладки</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="recent">
|
||||||
|
<h2>Последние события</h2>
|
||||||
|
<div id="event-list">
|
||||||
|
<p class="empty">Нет событий</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button id="sync-btn" class="btn">🔄 Отправить сейчас</button>
|
||||||
|
<button id="config-btn" class="btn secondary">⚙ Настройки</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
// Verstak Bridge — Popup Script
|
||||||
|
// Shows connection status, queue count, and recent events.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'verstak_queue';
|
||||||
|
const BRIDGE_KEY = 'verstak_bridge_config';
|
||||||
|
const RECENT_KEY = 'verstak_recent';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
updateUI();
|
||||||
|
restoreToggleState();
|
||||||
|
|
||||||
|
document.getElementById('sync-btn').addEventListener('click', forceSync);
|
||||||
|
document.getElementById('track-toggle').addEventListener('change', onToggle);
|
||||||
|
document.getElementById('config-btn').addEventListener('click', () => {
|
||||||
|
// For now, just show bridge config in console
|
||||||
|
chrome.storage.local.get(BRIDGE_KEY, (data) => {
|
||||||
|
console.log('[verstak] bridge config:', data[BRIDGE_KEY]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
chrome.storage.local.get([STORAGE_KEY, BRIDGE_KEY, RECENT_KEY], (data) => {
|
||||||
|
const queue = data[STORAGE_KEY] || [];
|
||||||
|
const config = data[BRIDGE_KEY] || {};
|
||||||
|
const recent = data[RECENT_KEY] || [];
|
||||||
|
|
||||||
|
// Queue count
|
||||||
|
document.getElementById('queue-count').textContent = queue.length;
|
||||||
|
|
||||||
|
// Bridge status
|
||||||
|
const statusEl = document.getElementById('bridge-status');
|
||||||
|
const portEl = document.getElementById('bridge-port');
|
||||||
|
const dotEl = document.getElementById('status-dot');
|
||||||
|
|
||||||
|
if (config.bridgeReachable) {
|
||||||
|
statusEl.textContent = 'Доступен';
|
||||||
|
statusEl.className = 'value online';
|
||||||
|
dotEl.className = 'dot online';
|
||||||
|
portEl.textContent = config.port || '?';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Недоступен';
|
||||||
|
statusEl.className = 'value offline';
|
||||||
|
dotEl.className = 'dot offline';
|
||||||
|
portEl.textContent = config.lastPing ? '—' : 'проверка...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent events
|
||||||
|
renderRecent(recent, config.bridgeReachable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecent(events, reachable) {
|
||||||
|
const list = document.getElementById('event-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
list.innerHTML = '<p class="empty">Нет событий</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show last 5
|
||||||
|
const lastFive = events.slice(-5).reverse();
|
||||||
|
for (const ev of lastFive) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'event-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="domain">${escapeHtml(ev.domain || '?')}</div>
|
||||||
|
<div class="title">${escapeHtml(truncate(ev.title || ev.url, 60))}</div>
|
||||||
|
<div class="duration">${ev.active_seconds || 0}с${reachable ? '' : ' (ожидает)'}</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceSync() {
|
||||||
|
const btn = document.getElementById('sync-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Отправка...';
|
||||||
|
|
||||||
|
// Ask background to flush
|
||||||
|
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, (response) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
updateUI();
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🔄 Отправить сейчас';
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggle(e) {
|
||||||
|
const enabled = e.target.checked;
|
||||||
|
chrome.storage.local.set({ 'verstak_tracking_enabled': enabled });
|
||||||
|
|
||||||
|
// Notify background
|
||||||
|
chrome.runtime.sendMessage({ type: 'SET_TRACKING', enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreToggleState() {
|
||||||
|
chrome.storage.local.get('verstak_tracking_enabled', (data) => {
|
||||||
|
const enabled = data.verstak_tracking_enabled !== false;
|
||||||
|
document.getElementById('track-toggle').checked = enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for updates from background
|
||||||
|
chrome.runtime.onMessage.addListener((msg) => {
|
||||||
|
if (msg.type === 'UI_UPDATE') {
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function truncate(s, n) {
|
||||||
|
if (!s) return '';
|
||||||
|
return s.length > n ? s.substring(0, n) + '...' : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue