feat: настройки Browser Bridge в Verstak и extension

Verstak (GUI):
- SettingsBrowserBridge.svelte — новая секция в Settings:
  toggle вкл/выкл сервера, поле порта (default 9786),
  статус (Запущен/Остановлен), кнопки Сохранить/Перезапустить
- SettingsWindow + SettingsSidebar — подключена секция browserBridge
- BridgeConfig: добавлено поле Enabled (default true)
- RestartBridge() — новый биндинг для перезапуска сервера
- initVault: проверяет bc.Enabled перед запуском bridge

Extension (Chrome + Firefox):
- popup: панель настроек с полем порта (default 9786)
- кнопка «Проверить» — fetch /api/ping с таймаутом 3с
- кнопка «Сохранить» — сохраняет port в chrome.storage.local
- статус соединения: ✓ Сервер отвечает / ✗ Недоступен / ✗ Таймаут
- оба расширения работают только с 127.0.0.1
This commit is contained in:
mirivlad 2026-06-07 01:03:35 +08:00
parent 1cc0c407b1
commit 8cbc87cdad
12 changed files with 840 additions and 101 deletions

View File

@ -1,6 +1,7 @@
package main
import (
"fmt"
"log"
"verstak/internal/core/bridge"
@ -112,3 +113,59 @@ func bridgeToBrowser(ev bridge.Event) browser.Event {
Note: ev.Note,
}
}
// RestartBridge stops and restarts the bridge server with current config.
func (a *App) RestartBridge() error {
a.mu.Lock()
defer a.mu.Unlock()
// Stop existing server
if a.bridge != nil {
a.bridge.Stop()
a.bridge = nil
}
// Load config
appCfg, err := config.LoadAppConfig()
if err != nil || appCfg == nil {
appCfg = config.DefaultAppConfig()
}
bc := a.bridgeConfig(appCfg)
if !bc.Enabled {
return nil
}
handler := func(events []bridge.Event) {
be := make([]browser.Event, 0, len(events))
for _, ev := range events {
be = append(be, bridgeToBrowser(ev))
}
n, err := a.browser.InsertEvents(be)
if err != nil {
log.Printf("[bridge] store events: %v", err)
return
}
if n > 0 {
log.Printf("[bridge] stored %d/%d events", n, len(be))
}
}
srv := bridge.NewServer(bridge.Config{
Port: bc.Port,
Secret: bc.Secret,
}, handler)
port, err := srv.Start(bridge.Config{
Port: bc.Port,
AutoGenPort: bc.AutoGenPort,
Secret: bc.Secret,
})
if err != nil {
return fmt.Errorf("bridge restart: %w", err)
}
a.bridge = srv
log.Printf("[bridge] restarted on port %d", port)
return nil
}

View File

@ -284,8 +284,12 @@ func (a *App) initVault(vaultPath string) error {
// Start auto-sync loop
go a.autoSyncLoop()
// Start bridge server for browser extension integration.
a.startBridge(appCfg)
// Start bridge server for browser extension integration (if enabled).
if appCfg == nil || appCfg.Vault.Bridge.Enabled {
a.startBridge(appCfg)
} else {
log.Println("[bridge] disabled by config")
}
return nil
}

View File

@ -228,3 +228,94 @@ footer {
opacity: 0.5;
cursor: not-allowed;
}
/* Settings panel */
#settings-panel {
padding-top: 4px;
}
.settings-form {
background: var(--surface);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 8px;
}
.form-group {
margin-bottom: 10px;
}
.form-group label {
display: block;
color: var(--text-dim);
font-size: 11px;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.port-row {
display: flex;
align-items: center;
gap: 4px;
}
.port-prefix {
color: var(--text-dim);
font-size: 12px;
font-family: monospace;
}
#port-input {
width: 80px;
padding: 6px 8px;
background: var(--bg);
border: 1px solid var(--surface2);
border-radius: 4px;
color: var(--text);
font-size: 13px;
font-family: monospace;
}
#port-input:focus {
outline: none;
border-color: var(--accent);
}
.hint {
color: var(--text-dim);
font-size: 10px;
margin-top: 4px;
line-height: 1.3;
}
.settings-actions {
display: flex;
gap: 6px;
}
.settings-actions .btn {
flex: 1;
}
.btn.primary {
background: var(--accent);
}
.btn.primary:hover {
background: #0d8a7e;
}
.port-status {
margin-top: 8px;
font-size: 11px;
min-height: 16px;
}
.port-status.ok {
color: var(--success);
}
.port-status.error {
color: var(--danger);
}

View File

@ -8,45 +8,73 @@
</head>
<body>
<div class="container">
<header>
<h1>Verstak Bridge</h1>
<span id="status-dot" class="dot offline"></span>
</header>
<!-- Main panel -->
<div id="main-panel">
<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="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="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>
<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>
<footer>
<button id="sync-btn" class="btn">🔄 Отправить сейчас</button>
<button id="settings-btn" class="btn secondary"></button>
</footer>
</div>
<!-- Settings panel (hidden by default) -->
<div id="settings-panel" style="display: none;">
<header>
<h1>Настройки</h1>
</header>
<section class="settings-form">
<div class="form-group">
<label for="port-input">Порт сервера</label>
<div class="port-row">
<span class="port-prefix">127.0.0.1:</span>
<input type="number" id="port-input" min="1024" max="65535" value="9786" />
</div>
<p class="hint">По умолчанию: 9786. Изменения вступят в силу при следующей отправке событий.</p>
</div>
<div class="settings-actions">
<button id="test-port-btn" class="btn">Проверить</button>
<button id="save-port-btn" class="btn primary">Сохранить</button>
</div>
<div id="port-status" class="port-status"></div>
</section>
</div>
</div>
<script src="popup.js"></script>
</body>

View File

@ -1,22 +1,21 @@
// Verstak Bridge — Popup Script
// Shows connection status, queue count, and recent events.
// Shows connection status, queue count, recent events, and settings.
const STORAGE_KEY = 'verstak_queue';
const BRIDGE_KEY = 'verstak_bridge_config';
const RECENT_KEY = 'verstak_recent';
const DEFAULT_PORT = 9786;
document.addEventListener('DOMContentLoaded', () => {
updateUI();
restoreToggleState();
restoreSettingsPanel();
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]);
});
});
document.getElementById('settings-btn').addEventListener('click', toggleSettings);
document.getElementById('save-port-btn').addEventListener('click', savePort);
document.getElementById('test-port-btn').addEventListener('click', testPort);
});
function updateUI() {
@ -37,7 +36,7 @@ function updateUI() {
statusEl.textContent = 'Доступен';
statusEl.className = 'value online';
dotEl.className = 'dot online';
portEl.textContent = config.port || '?';
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
} else {
statusEl.textContent = 'Недоступен';
statusEl.className = 'value offline';
@ -45,6 +44,12 @@ function updateUI() {
portEl.textContent = config.lastPing ? '—' : 'проверка...';
}
// Update settings panel port field
const portInput = document.getElementById('port-input');
if (portInput && !portInput.dataset.userEdited) {
portInput.value = config.port || DEFAULT_PORT;
}
// Recent events
renderRecent(recent, config.bridgeReachable);
});
@ -59,7 +64,6 @@ function renderRecent(events, reachable) {
return;
}
// Show last 5
const lastFive = events.slice(-5).reverse();
for (const ev of lastFive) {
const item = document.createElement('div');
@ -78,8 +82,7 @@ function forceSync() {
btn.disabled = true;
btn.textContent = '⏳ Отправка...';
// Ask background to flush
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, (response) => {
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, () => {
setTimeout(() => {
updateUI();
btn.disabled = false;
@ -91,8 +94,6 @@ function forceSync() {
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 });
}
@ -103,6 +104,92 @@ function restoreToggleState() {
});
}
// --- Settings panel ---
function toggleSettings() {
const panel = document.getElementById('settings-panel');
const main = document.getElementById('main-panel');
const btn = document.getElementById('settings-btn');
if (panel.style.display === 'none' || !panel.style.display) {
panel.style.display = 'block';
main.style.display = 'none';
btn.textContent = '← Назад';
} else {
panel.style.display = 'none';
main.style.display = 'block';
btn.textContent = '⚙';
}
}
function restoreSettingsPanel() {
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || {};
const portInput = document.getElementById('port-input');
if (portInput) {
portInput.value = config.port || DEFAULT_PORT;
}
});
}
function savePort() {
const portInput = document.getElementById('port-input');
const port = parseInt(portInput.value, 10);
const statusEl = document.getElementById('port-status');
if (isNaN(port) || port < 1024 || port > 65535) {
statusEl.textContent = 'Порт: 102465535';
statusEl.className = 'port-status error';
return;
}
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || {};
config.port = port;
chrome.storage.local.set({ [BRIDGE_KEY]: config }, () => {
statusEl.textContent = 'Сохранено';
statusEl.className = 'port-status ok';
// Trigger immediate ping with new port
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' });
setTimeout(() => { updateUI(); statusEl.textContent = ''; }, 1500);
});
});
}
function testPort() {
const portInput = document.getElementById('port-input');
const port = parseInt(portInput.value, 10);
const statusEl = document.getElementById('port-status');
if (isNaN(port) || port < 1024 || port > 65535) {
statusEl.textContent = 'Порт: 102465535';
statusEl.className = 'port-status error';
return;
}
statusEl.textContent = 'Проверка...';
statusEl.className = 'port-status';
fetch(`http://127.0.0.1:${port}/api/ping`, { signal: AbortSignal.timeout(3000) })
.then((res) => {
if (res.ok) {
statusEl.textContent = '✓ Сервер отвечает';
statusEl.className = 'port-status ok';
} else {
statusEl.textContent = `✗ Ошибка ${res.status}`;
statusEl.className = 'port-status error';
}
})
.catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
statusEl.textContent = '✗ Нет ответа (таймаут)';
} else {
statusEl.textContent = '✗ Сервер недоступен';
}
statusEl.className = 'port-status error';
});
}
// Listen for updates from background
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'UI_UPDATE') {

View File

@ -228,3 +228,94 @@ footer {
opacity: 0.5;
cursor: not-allowed;
}
/* Settings panel */
#settings-panel {
padding-top: 4px;
}
.settings-form {
background: var(--surface);
border-radius: var(--radius);
padding: 12px;
margin-bottom: 8px;
}
.form-group {
margin-bottom: 10px;
}
.form-group label {
display: block;
color: var(--text-dim);
font-size: 11px;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.port-row {
display: flex;
align-items: center;
gap: 4px;
}
.port-prefix {
color: var(--text-dim);
font-size: 12px;
font-family: monospace;
}
#port-input {
width: 80px;
padding: 6px 8px;
background: var(--bg);
border: 1px solid var(--surface2);
border-radius: 4px;
color: var(--text);
font-size: 13px;
font-family: monospace;
}
#port-input:focus {
outline: none;
border-color: var(--accent);
}
.hint {
color: var(--text-dim);
font-size: 10px;
margin-top: 4px;
line-height: 1.3;
}
.settings-actions {
display: flex;
gap: 6px;
}
.settings-actions .btn {
flex: 1;
}
.btn.primary {
background: var(--accent);
}
.btn.primary:hover {
background: #0d8a7e;
}
.port-status {
margin-top: 8px;
font-size: 11px;
min-height: 16px;
}
.port-status.ok {
color: var(--success);
}
.port-status.error {
color: var(--danger);
}

View File

@ -8,45 +8,73 @@
</head>
<body>
<div class="container">
<header>
<h1>Verstak Bridge</h1>
<span id="status-dot" class="dot offline"></span>
</header>
<!-- Main panel -->
<div id="main-panel">
<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="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="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>
<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>
<footer>
<button id="sync-btn" class="btn">🔄 Отправить сейчас</button>
<button id="settings-btn" class="btn secondary"></button>
</footer>
</div>
<!-- Settings panel (hidden by default) -->
<div id="settings-panel" style="display: none;">
<header>
<h1>Настройки</h1>
</header>
<section class="settings-form">
<div class="form-group">
<label for="port-input">Порт сервера</label>
<div class="port-row">
<span class="port-prefix">127.0.0.1:</span>
<input type="number" id="port-input" min="1024" max="65535" value="9786" />
</div>
<p class="hint">По умолчанию: 9786. Изменения вступят в силу при следующей отправке событий.</p>
</div>
<div class="settings-actions">
<button id="test-port-btn" class="btn">Проверить</button>
<button id="save-port-btn" class="btn primary">Сохранить</button>
</div>
<div id="port-status" class="port-status"></div>
</section>
</div>
</div>
<script src="popup.js"></script>
</body>

View File

@ -1,22 +1,21 @@
// Verstak Bridge — Popup Script
// Shows connection status, queue count, and recent events.
// Shows connection status, queue count, recent events, and settings.
const STORAGE_KEY = 'verstak_queue';
const BRIDGE_KEY = 'verstak_bridge_config';
const RECENT_KEY = 'verstak_recent';
const DEFAULT_PORT = 9786;
document.addEventListener('DOMContentLoaded', () => {
updateUI();
restoreToggleState();
restoreSettingsPanel();
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]);
});
});
document.getElementById('settings-btn').addEventListener('click', toggleSettings);
document.getElementById('save-port-btn').addEventListener('click', savePort);
document.getElementById('test-port-btn').addEventListener('click', testPort);
});
function updateUI() {
@ -37,7 +36,7 @@ function updateUI() {
statusEl.textContent = 'Доступен';
statusEl.className = 'value online';
dotEl.className = 'dot online';
portEl.textContent = config.port || '?';
portEl.textContent = '127.0.0.1:' + (config.port || DEFAULT_PORT);
} else {
statusEl.textContent = 'Недоступен';
statusEl.className = 'value offline';
@ -45,6 +44,12 @@ function updateUI() {
portEl.textContent = config.lastPing ? '—' : 'проверка...';
}
// Update settings panel port field
const portInput = document.getElementById('port-input');
if (portInput && !portInput.dataset.userEdited) {
portInput.value = config.port || DEFAULT_PORT;
}
// Recent events
renderRecent(recent, config.bridgeReachable);
});
@ -59,7 +64,6 @@ function renderRecent(events, reachable) {
return;
}
// Show last 5
const lastFive = events.slice(-5).reverse();
for (const ev of lastFive) {
const item = document.createElement('div');
@ -78,8 +82,7 @@ function forceSync() {
btn.disabled = true;
btn.textContent = '⏳ Отправка...';
// Ask background to flush
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, (response) => {
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' }, () => {
setTimeout(() => {
updateUI();
btn.disabled = false;
@ -91,8 +94,6 @@ function forceSync() {
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 });
}
@ -103,6 +104,92 @@ function restoreToggleState() {
});
}
// --- Settings panel ---
function toggleSettings() {
const panel = document.getElementById('settings-panel');
const main = document.getElementById('main-panel');
const btn = document.getElementById('settings-btn');
if (panel.style.display === 'none' || !panel.style.display) {
panel.style.display = 'block';
main.style.display = 'none';
btn.textContent = '← Назад';
} else {
panel.style.display = 'none';
main.style.display = 'block';
btn.textContent = '⚙';
}
}
function restoreSettingsPanel() {
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || {};
const portInput = document.getElementById('port-input');
if (portInput) {
portInput.value = config.port || DEFAULT_PORT;
}
});
}
function savePort() {
const portInput = document.getElementById('port-input');
const port = parseInt(portInput.value, 10);
const statusEl = document.getElementById('port-status');
if (isNaN(port) || port < 1024 || port > 65535) {
statusEl.textContent = 'Порт: 102465535';
statusEl.className = 'port-status error';
return;
}
chrome.storage.local.get(BRIDGE_KEY, (data) => {
const config = data[BRIDGE_KEY] || {};
config.port = port;
chrome.storage.local.set({ [BRIDGE_KEY]: config }, () => {
statusEl.textContent = 'Сохранено';
statusEl.className = 'port-status ok';
// Trigger immediate ping with new port
chrome.runtime.sendMessage({ type: 'FORCE_FLUSH' });
setTimeout(() => { updateUI(); statusEl.textContent = ''; }, 1500);
});
});
}
function testPort() {
const portInput = document.getElementById('port-input');
const port = parseInt(portInput.value, 10);
const statusEl = document.getElementById('port-status');
if (isNaN(port) || port < 1024 || port > 65535) {
statusEl.textContent = 'Порт: 102465535';
statusEl.className = 'port-status error';
return;
}
statusEl.textContent = 'Проверка...';
statusEl.className = 'port-status';
fetch(`http://127.0.0.1:${port}/api/ping`, { signal: AbortSignal.timeout(3000) })
.then((res) => {
if (res.ok) {
statusEl.textContent = '✓ Сервер отвечает';
statusEl.className = 'port-status ok';
} else {
statusEl.textContent = `✗ Ошибка ${res.status}`;
statusEl.className = 'port-status error';
}
})
.catch((err) => {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
statusEl.textContent = '✗ Нет ответа (таймаут)';
} else {
statusEl.textContent = '✗ Сервер недоступен';
}
statusEl.className = 'port-status error';
});
}
// Listen for updates from background
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'UI_UPDATE') {

View File

@ -0,0 +1,254 @@
<script>
import { t } from './i18n'
export let appConfig = null
let bridgeEnabled = false
let bridgePort = 9786
let bridgeRunning = false
let bridgeActualPort = 0
let saving = false
let statusMsg = ''
let statusKind = '' // 'ok' | 'error' | ''
// Load current bridge state from config
function loadFromConfig() {
if (appConfig && appConfig.vault && appConfig.vault.bridge) {
const bc = appConfig.vault.bridge
bridgeEnabled = bc.enabled !== false
bridgePort = bc.port || 9786
}
}
// Also fetch live bridge status from backend
async function fetchBridgeStatus() {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
const info = await window['go']['main']['App']['BridgeInfo']()
bridgeRunning = info.running || false
bridgeActualPort = info.port || 0
}
} catch (e) {
bridgeRunning = false
bridgeActualPort = 0
}
}
loadFromConfig()
fetchBridgeStatus()
async function save() {
saving = true
statusMsg = ''
statusKind = ''
try {
// Update config
if (!appConfig) appConfig = {}
if (!appConfig.vault) appConfig.vault = {}
if (!appConfig.vault.bridge) appConfig.vault.bridge = {}
appConfig.vault.bridge.enabled = bridgeEnabled
appConfig.vault.bridge.port = bridgePort
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
await window['go']['main']['App']['SaveAppConfig'](appConfig)
}
statusMsg = 'saved'
statusKind = 'ok'
// Refresh live status
await fetchBridgeStatus()
} catch (e) {
statusMsg = String(e)
statusKind = 'error'
}
saving = false
}
async function restartBridge() {
saving = true
statusMsg = ''
statusKind = ''
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
await window['go']['main']['App']['RestartBridge']()
}
statusMsg = 'bridge restarted'
statusKind = 'ok'
await fetchBridgeStatus()
} catch (e) {
statusMsg = String(e)
statusKind = 'error'
}
saving = false
}
</script>
<div class="settings-section">
<h2>Browser Bridge</h2>
<p class="section-desc">
Локальный HTTP-сервер для приёма событий от браузерного расширения Verstak Bridge.
Работает только на 127.0.0.1.
</p>
{#if statusMsg}
<div class="status-msg" class:ok={statusKind === 'ok'} class:error={statusKind === 'error'}>
{statusMsg}
</div>
{/if}
<div class="settings-card">
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" bind:checked={bridgeEnabled} />
<span class="toggle-text">Включить Browser Bridge</span>
</label>
</div>
<div class="form-group">
<label>
<span class="label-text">Порт</span>
<input type="number" bind:value={bridgePort} min="1024" max="65535" disabled={!bridgeEnabled} />
</label>
<p class="field-hint">По умолчанию: 9786. Требуется перезапуск сервера при изменении.</p>
</div>
<div class="bridge-status">
<div class="info-row">
<span class="info-label">Статус</span>
<span class="info-value" class:status-ok={bridgeRunning} class:status-off={!bridgeRunning}>
{bridgeRunning ? 'Запущен' : 'Остановлен'}
</span>
</div>
{#if bridgeRunning && bridgeActualPort}
<div class="info-row">
<span class="info-label">Активный порт</span>
<span class="info-value mono">127.0.0.1:{bridgeActualPort}</span>
</div>
{/if}
</div>
</div>
<div class="actions">
<button class="btn btn-primary" on:click={save} disabled={saving}>
Сохранить
</button>
{#if bridgeEnabled}
<button class="btn" on:click={restartBridge} disabled={saving}>
Перезапустить сервер
</button>
{/if}
</div>
</div>
<style>
.settings-section {
padding: 1.5rem;
max-width: 600px;
}
.settings-section h2 {
margin: 0 0 0.25rem 0;
font-size: 1.2rem;
color: var(--text, #e0e0e0);
}
.section-desc {
color: var(--text-dim, #888);
font-size: 0.85rem;
margin-bottom: 1.25rem;
line-height: 1.4;
}
.settings-card {
background: var(--surface-alt, #1e1e30);
border: 1px solid var(--border, #2a2a3e);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.label-text {
display: block;
color: var(--text-dim, #888);
font-size: 0.85rem;
margin-bottom: 0.35rem;
}
input[type="number"] {
width: 120px;
padding: 0.4rem 0.6rem;
background: var(--bg, #13131f);
border: 1px solid var(--border, #2a2a3e);
border-radius: 6px;
color: var(--text, #e0e0e0);
font-size: 0.9rem;
}
input[type="number"]:disabled {
opacity: 0.5;
}
.field-hint {
color: var(--text-dim, #666);
font-size: 0.75rem;
margin-top: 0.35rem;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.toggle-text {
color: var(--text, #e0e0e0);
font-size: 0.9rem;
}
.bridge-status {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border, #2a2a3e);
}
.info-row {
display: flex;
padding: 0.3rem 0;
font-size: 0.85rem;
}
.info-label {
width: 140px;
min-width: 140px;
color: var(--text-dim, #888);
}
.info-value {
color: var(--text, #e0e0e0);
}
.info-value.mono {
font-family: monospace;
font-size: 0.85rem;
}
.status-ok {
color: #34d399;
font-weight: 600;
}
.status-off {
color: #888;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.status-msg {
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
}
.status-msg.ok {
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.3);
color: #34d399;
}
.status-msg.error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: #ff6b6b;
}
</style>

View File

@ -13,6 +13,7 @@
{ id: 'activity', label: t('settings.activity') },
{ id: 'sync', label: t('settings.sync') },
{ id: 'backup', label: t('settings.backup') },
{ id: 'browserBridge', label: 'Browser Bridge' },
]
function select(id) {
@ -57,6 +58,9 @@
<path d="M5 4h12l2 2v14H5z"/>
<path d="M8 4v6h8V4"/>
<path d="M8 20v-6h8v6"/>
{:else if sec.id === 'browserBridge'}
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
{/if}
</svg>
</span>

View File

@ -9,6 +9,7 @@
import SettingsActivity from './SettingsActivity.svelte'
import SettingsSync from './SettingsSync.svelte'
import SettingsBackup from './SettingsBackup.svelte'
import SettingsBrowserBridge from './SettingsBrowserBridge.svelte'
export let onClose = null
export let onSyncRefresh = null
@ -90,6 +91,8 @@
<SettingsSync onRefresh={handleSyncRefresh} />
{:else if activeSection === 'backup'}
<SettingsBackup />
{:else if activeSection === 'browserBridge'}
<SettingsBrowserBridge {appConfig} />
{/if}
</div>
</div>

View File

@ -65,9 +65,10 @@ type SyncSettings struct {
// BridgeConfig holds local HTTP bridge settings for browser extension.
type BridgeConfig struct {
Port int `json:"port"`
Secret string `json:"secret,omitempty"` // shared secret for extension auth
AutoGenPort bool `json:"auto_gen_port,omitempty"` // pick random port if port taken
Enabled bool `json:"enabled,omitempty"` // enable/disable bridge server
Port int `json:"port"` // listen port (default 9786)
Secret string `json:"secret,omitempty"` // shared secret for extension auth
AutoGenPort bool `json:"auto_gen_port,omitempty"` // pick random port if port taken
}
func DefaultAppConfig() *AppConfig {
@ -79,6 +80,10 @@ func DefaultAppConfig() *AppConfig {
EnabledPlugins: []string{},
Vault: VaultAppConfig{
FileWatcher: BoolPtr(true),
Bridge: BridgeConfig{
Enabled: true,
Port: 9786,
},
},
}
}