§F: desktop Share — local QR + copyable ostp:// link

The plan's share feature for the (single-config) desktop GUI. The QR is
rendered locally so the access key never leaves the device.

- src-tauri: add `qrcode = "0.14"` (features=["svg"]) + `generate_qr`
  command (string -> SVG), registered in the invoke handler. Ported from
  the current ostp-gui. (cargo check on src-tauri passes.)
- Frontend: "Share" button next to Import builds `ostp://KEY@HOST?sni&type`
  from the current config fields, calls generate_qr, and shows a modal with
  the QR + a read-only link + Copy. Added i18n keys (en/ru) so the new
  data-i18n labels resolve (missing keys would render as the raw key).

Note: Rust side verified via cargo check; the frontend is syntax-checked
(node --check) but not runtime-verified — needs a Tauri build to confirm
visually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ospab 2026-06-27 21:38:21 +03:00
parent 2a9b099b24
commit 4f7f1ca838
7 changed files with 118 additions and 7 deletions

View File

@ -2665,16 +2665,14 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.2.97"
version = "0.4.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"bytes",
"chacha20poly1305",
"chrono",
"futures",
"futures-util",
"hex",
"hmac",
"json_comments",
"libc",
@ -2694,12 +2692,11 @@ dependencies = [
"tun",
"webpki-roots 0.26.11",
"winapi",
"x25519-dalek",
]
[[package]]
name = "ostp-core"
version = "0.2.97"
version = "0.4.0"
dependencies = [
"anyhow",
"bytes",
@ -2722,6 +2719,7 @@ dependencies = [
"json_comments",
"ostp-client",
"portable-atomic",
"qrcode",
"rand",
"serde",
"serde_json",
@ -2734,7 +2732,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.2.97"
version = "0.4.0"
dependencies = [
"anyhow",
"libc",
@ -3092,6 +3090,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quick-xml"
version = "0.39.4"

View File

@ -29,4 +29,5 @@ ostp-client = { path = "../../ostp-client" }
portable-atomic = "1"
json_comments = "0.2"
rand = "0.8"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }

View File

@ -483,6 +483,20 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
Ok(true)
}
/// Render a share link to an SVG QR code locally. The access key never leaves
/// the device — unlike an online QR service. (Ported from the current ostp-gui.)
#[tauri::command]
fn generate_qr(text: String) -> Result<String, String> {
let code = qrcode::QrCode::new(text.as_bytes()).map_err(|e| e.to_string())?;
let svg = code
.render::<qrcode::render::svg::Color>()
.min_dimensions(220, 220)
.dark_color(qrcode::render::svg::Color("#000000"))
.light_color(qrcode::render::svg::Color("#ffffff"))
.build();
Ok(svg)
}
#[tauri::command]
async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<bool, String> {
let mut guard = state.0.lock().await;
@ -859,7 +873,7 @@ pub fn run() {
}
_ => {}
})
.invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, reload_tunnel, get_tunnel_status, get_metrics, get_config, save_config, get_wintun_install_path, set_autostart, get_autostart, list_running_processes])
.invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, reload_tunnel, get_tunnel_status, get_metrics, get_config, save_config, get_wintun_install_path, set_autostart, get_autostart, list_running_processes, generate_qr])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -54,6 +54,11 @@ const translations = {
label_launch_startup: 'Launch at Startup',
launch_startup_hint: 'Start OSTP with Windows',
cancel_btn: 'Cancel',
share_btn: 'Share',
share_title: 'Share configuration',
share_desc: 'Scan the QR or copy the link. The QR is generated locally — the access key never leaves this device.',
copy_btn: 'Copy link',
close_btn: 'Close',
wintun_missing_title: 'Wintun Driver Missing',
wintun_missing_desc: 'TUN mode requires the Wintun network driver (wintun.dll).',
wintun_step1: 'Download wintun.zip from the official site',
@ -114,6 +119,11 @@ const translations = {
label_launch_startup: 'Запуск вместе с Windows',
launch_startup_hint: 'Автозапуск OSTP при входе в систему',
cancel_btn: 'Отмена',
share_btn: 'Поделиться',
share_title: 'Поделиться конфигурацией',
share_desc: 'Отсканируйте QR или скопируйте ссылку. QR генерируется локально — ключ доступа не покидает устройство.',
copy_btn: 'Копировать ссылку',
close_btn: 'Закрыть',
wintun_missing_title: 'Отсутствует драйвер Wintun',
wintun_missing_desc: 'Режим TUN требует сетевой драйвер Wintun (wintun.dll).',
wintun_step1: 'Скачайте wintun.zip с официального сайта',

View File

@ -165,6 +165,7 @@
data-i18n-placeholder="import_placeholder"
placeholder="Paste ostp:// share link..." />
<button id="btn-import-url" class="accent-btn" data-i18n="import_btn">Import</button>
<button id="btn-share-url" class="btn secondary" data-i18n="share_btn" title="Share this config as a QR code / ostp:// link">Share</button>
</div>
<!-- Form card -->
@ -366,6 +367,20 @@
</div>
</div>
<!-- Share Modal -->
<div id="share-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3 class="modal-title" data-i18n="share_title">Share configuration</h3>
<p class="modal-text" data-i18n="share_desc">Scan the QR or copy the link. The QR is generated locally — the access key never leaves this device.</p>
<div id="share-qr" class="share-qr"></div>
<input id="share-link" class="field-input" type="text" readonly />
<div class="modal-actions">
<button id="btn-share-close" class="btn secondary" data-i18n="close_btn">Close</button>
<button id="btn-share-copy" class="btn primary" data-i18n="copy_btn">Copy link</button>
</div>
</div>
</div>
</div>
<script type="module" src="main.js"></script>
</body>

View File

@ -36,8 +36,14 @@ const btnGoSettings = $('btn-go-settings');
const btnAutoConnect = $('btn-auto-connect');
const btnBack = $('btn-back');
const btnImport = $('btn-import-url');
const btnShare = $('btn-share-url');
const btnPeekKey = $('btn-peek-key');
const importInput = $('in-import-url');
const shareModal = $('share-modal');
const shareQr = $('share-qr');
const shareLink = $('share-link');
const btnShareClose = $('btn-share-close');
const btnShareCopy = $('btn-share-copy');
const inServer = $('in-server');
const inKey = $('in-key');
const inSocks = $('in-socks');
@ -463,6 +469,44 @@ function handleImport() {
}
}
// ── Share config (QR + ostp:// link) ──────────────────────────────────────────
function buildShareLink() {
const host = inServer.value.trim();
const key = inKey.value.trim();
if (!host || !key) return '';
const params = [];
const sni = inSni.value.trim();
if (sni) params.push(`sni=${encodeURIComponent(sni)}`);
if (inTransport.value && inTransport.value !== 'udp') params.push(`type=${inTransport.value}`);
const qs = params.length ? `?${params.join('&')}` : '';
return `ostp://${encodeURIComponent(key)}@${host}${qs}`;
}
async function handleShare() {
const link = buildShareLink();
if (!link) { showToast('Set server and key first', 'error'); return; }
shareLink.value = link;
shareQr.innerHTML = '';
try {
const svg = await invoke('generate_qr', { text: link });
if (svg) shareQr.innerHTML = svg;
} catch (err) {
console.error('generate_qr failed', err);
}
shareModal.classList.remove('hidden');
}
async function copyShareLink() {
try {
await navigator.clipboard.writeText(shareLink.value);
showToast('Link copied', 'ok');
} catch {
shareLink.select();
document.execCommand('copy');
showToast('Link copied', 'ok');
}
}
// ── Peek key ──────────────────────────────────────────────────────────────────
let peeking = false;
function togglePeek() {
@ -578,6 +622,10 @@ window.addEventListener('DOMContentLoaded', async () => {
btnGoSettings.addEventListener('click', () => showScreen('settings'));
btnBack.addEventListener('click', () => showScreen('home'));
btnImport.addEventListener('click', handleImport);
if (btnShare) btnShare.addEventListener('click', handleShare);
if (btnShareClose) btnShareClose.addEventListener('click', () => shareModal.classList.add('hidden'));
if (btnShareCopy) btnShareCopy.addEventListener('click', copyShareLink);
if (shareModal) shareModal.addEventListener('click', e => { if (e.target === shareModal) shareModal.classList.add('hidden'); });
btnPeekKey.addEventListener('click', togglePeek);
// Theme toggle

View File

@ -263,6 +263,25 @@ html[data-theme="light"] .watermark {
filter: invert(1);
}
/* ── Share modal (QR) ─────────────────────────────────────────────────────── */
.share-qr {
display: flex;
justify-content: center;
margin: 8px 0 14px;
}
.share-qr svg {
width: 220px;
height: 220px;
background: #fff;
border-radius: var(--r-sm);
padding: 10px;
}
#share-link {
margin-bottom: 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
/* ── Screen system ────────────────────────────────────────────────────────── */
.screen {
position: absolute;