Fix settings GUI scrolling: force screen height and block layout for scrollable

This commit is contained in:
ospab 2026-07-07 17:35:08 +03:00
parent 83f14ec209
commit 80b4ad8d54
9 changed files with 340 additions and 158 deletions

View File

@ -89,6 +89,7 @@ pub async fn run_tun_inbound(
let async_fd_shared = std::sync::Arc::new(async_fd); let async_fd_shared = std::sync::Arc::new(async_fd);
let afd1 = async_fd_shared.clone(); let afd1 = async_fd_shared.clone();
let m_sent = metrics.clone();
let tun_to_stack = tokio::spawn(async move { let tun_to_stack = tokio::spawn(async move {
let mut frame = vec![0u8; 65535]; let mut frame = vec![0u8; 65535];
loop { loop {
@ -104,6 +105,8 @@ pub async fn run_tun_inbound(
} else { Ok(res as isize) } } else { Ok(res as isize) }
}) { }) {
Ok(Ok(n)) if n > 0 => { Ok(Ok(n)) if n > 0 => {
// Bytes leaving the device toward the tunnel = upload.
m_sent.bytes_sent.fetch_add(n as u64, Ordering::Relaxed);
if let Err(_) = stack_sink.send(frame[..n as usize].to_vec()).await { break; } if let Err(_) = stack_sink.send(frame[..n as usize].to_vec()).await { break; }
} }
Ok(Ok(_)) => break, Ok(Ok(_)) => break,
@ -114,8 +117,11 @@ pub async fn run_tun_inbound(
}); });
let afd2 = async_fd_shared.clone(); let afd2 = async_fd_shared.clone();
let m_recv = metrics.clone();
let stack_to_tun = tokio::spawn(async move { let stack_to_tun = tokio::spawn(async move {
while let Some(Ok(frame)) = stack_stream.next().await { while let Some(Ok(frame)) = stack_stream.next().await {
// Bytes arriving from the tunnel toward the device = download.
m_recv.bytes_recv.fetch_add(frame.len() as u64, Ordering::Relaxed);
let mut written = 0; let mut written = 0;
while written < frame.len() { while written < frame.len() {
let mut guard = match afd2.writable().await { let mut guard = match afd2.writable().await {

View File

@ -289,6 +289,7 @@ pub async fn handle_udp(
} }
// Send handshake first // Send handshake first
let hs_start = std::time::Instant::now();
if let Ok(action) = machine.on_event(OstpEvent::Start) { if let Ok(action) = machine.on_event(OstpEvent::Start) {
handle_udp_action(action, &transport).await; handle_udp_action(action, &transport).await;
} }
@ -300,6 +301,14 @@ pub async fn handle_udp(
transport.recv(&mut buf), transport.recv(&mut buf),
).await { ).await {
Ok(Ok(n)) => { Ok(Ok(n)) => {
// Real round-trip to the server over the already-protected transport.
// On Android the separate health-probe socket is NOT VPN-protected
// (it would route into the tunnel and never reach the server), so this
// handshake timing is the only reliable RTT source on mobile. We only
// set rtt here; connection_state stays owned by the health probe.
if let Some(m) = &metrics {
m.rtt_ms.store(hs_start.elapsed().as_millis() as u32, std::sync::atomic::Ordering::Relaxed);
}
let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n]))); let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n])));
} }
_ => { _ => {

View File

@ -183,7 +183,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
outbounds.add({"type": "direct", "tag": "direct"}); outbounds.add({"type": "direct", "tag": "direct"});
outbounds.add({"type": "block", "tag": "block"}); outbounds.add({"type": "block", "tag": "block"});
// Exclusions "direct". Keys MUST match the Rust RoutingRule fields
// (domain_suffix / ip_cidr / process_name). The old "domains/ips/processes"
// keys did not match the struct and were silently dropped, so exclusions
// never applied. Only push a rule that actually has entries a rule with an
// empty list never matches anyway.
final exDomainsList = exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList();
final exIpsList = exIps.split('\n').where((s) => s.trim().isNotEmpty).toList();
final exProcessesList = exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList();
final List<Map<String, dynamic>> routingRules = [];
if (exDomainsList.isNotEmpty) routingRules.add({"domain_suffix": exDomainsList, "outbound": "direct"});
if (exIpsList.isNotEmpty) routingRules.add({"ip_cidr": exIpsList, "outbound": "direct"});
if (exProcessesList.isNotEmpty) routingRules.add({"process_name": exProcessesList, "outbound": "direct"});
final configMap = { final configMap = {
"mode": "client",
"version": "0.3.20", "version": "0.3.20",
"log": { "log": {
"level": debugMode ? "debug" : "info" "level": debugMode ? "debug" : "info"
@ -191,14 +205,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
"inbounds": inbounds, "inbounds": inbounds,
"outbounds": outbounds, "outbounds": outbounds,
"routing": { "routing": {
"rules": [ "rules": routingRules,
{ "default_outbound": "proxy"
"domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
"outbound": "direct"
}
]
}, },
"app_rules": { "app_rules": {
"mode": appRoutingMode, "mode": appRoutingMode,

View File

@ -141,6 +141,62 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
String _buildShareLink(OstpProfile p) {
final type = p.transportMode == 'uot' ? 'tcp' : 'udp';
final key = Uri.encodeComponent(p.accessKey);
final name = Uri.encodeComponent(p.name);
return 'ostp://$key@${p.serverAddr}?type=$type#$name';
}
void _shareProfile(OstpProfile p) {
final link = _buildShareLink(p);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(ctx).colorScheme.surface,
title: const Text('Share profile'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: QrImageView(
data: link,
size: 200,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 16),
SelectableText(
link,
textAlign: TextAlign.center,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
],
),
actions: [
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: link));
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(content: Text('Copied')),
);
},
child: const Text('Copy link'),
),
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
],
),
);
}
void _showAddProfileMenu() { void _showAddProfileMenu() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -395,10 +451,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)), title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)), subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)),
trailing: IconButton( trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.qr_code_2, size: 20, color: Colors.white54),
tooltip: 'Share',
onPressed: () => _shareProfile(p),
),
IconButton(
icon: const Icon(Icons.edit, size: 20, color: Colors.white54), icon: const Icon(Icons.edit, size: 20, color: Colors.white54),
onPressed: () => _showEditProfileDialog(p), onPressed: () => _showEditProfileDialog(p),
), ),
],
),
onTap: () { onTap: () {
setState(() { setState(() {
p.active = !p.active; p.active = !p.active;

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-client" name = "ostp-client"
version = "0.3.18" version = "0.3.21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@ -2700,7 +2700,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-core" name = "ostp-core"
version = "0.3.18" version = "0.3.21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"byteorder", "byteorder",
@ -2729,6 +2729,7 @@ dependencies = [
"ostp-client", "ostp-client",
"ostp-core", "ostp-core",
"portable-atomic", "portable-atomic",
"qrcode",
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
@ -2742,7 +2743,7 @@ dependencies = [
[[package]] [[package]]
name = "ostp-tun" name = "ostp-tun"
version = "0.3.18" version = "0.3.21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@ -3101,6 +3102,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.4" version = "0.39.4"

View File

@ -33,4 +33,5 @@ rand = "0.8"
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4.3" hex = "0.4.3"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }

View File

@ -416,6 +416,20 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
Ok(true) Ok(true)
} }
#[tauri::command]
fn generate_qr(text: String) -> Result<String, String> {
// Render the share link to an SVG QR locally — the access key never leaves
// the device (unlike an online QR service).
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] #[tauri::command]
async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<bool, String> { async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<bool, String> {
let mut guard = state.0.lock().await; let mut guard = state.0.lock().await;
@ -888,7 +902,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, dns_prober::run_dns_prober]) .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, dns_prober::run_dns_prober, generate_qr])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@ -432,7 +432,16 @@ async function handleToggle() {
serverAddr = cfg.server || ''; serverAddr = cfg.server || '';
} catch { serverAddr = ''; } } catch { serverAddr = ''; }
const activeProfiles = profiles.filter(p => p.active);
if (activeProfiles.length === 0) {
showToast('Select at least one profile in Settings', 'error');
return;
}
setState('connecting'); setState('connecting');
// Rebuild config.json from the current active profiles so a freshly added,
// edited or toggled profile actually takes effect on connect.
await handleSave(true);
try { try {
console.log('[OSTP] invoking start_tunnel...'); console.log('[OSTP] invoking start_tunnel...');
@ -482,23 +491,9 @@ async function loadConfigIntoForm() {
const c = rawConfig.mode === 'client' ? rawConfig : null; const c = rawConfig.mode === 'client' ? rawConfig : null;
if (!c) return; if (!c) return;
if (c.version === '0.3.1' || c.outbounds !== undefined) { // Restore the profile list from persisted GUI state.
// NEW FORMAT profiles = rawConfig.gui?.profiles || [];
const ostpOut = (c.outbounds || []).find(o => o.type === 'ostp'); renderProfiles();
if (ostpOut) {
inServer.value = ostpOut.server ? `${ostpOut.server}:${ostpOut.port || 50000}` : '';
inKey.value = ostpOut.access_key || '';
inTransport.value = ostpOut.transport?.type || 'udp';
if (inTransport.value === 'dns') {
groupDnsProxy.style.display = 'flex';
inDnsDomain.value = ostpOut.transport?.domain || '';
inDnsRegion.value = ostpOut.transport?.resolver || 'Global';
} else {
groupDnsProxy.style.display = 'none';
}
inMux.checked = !!ostpOut.multiplex?.enabled;
inMuxSessions.value = ostpOut.multiplex?.sessions || '';
}
const tunIn = (c.inbounds || []).find(i => i.type === 'tun'); const tunIn = (c.inbounds || []).find(i => i.type === 'tun');
if (tunIn) { if (tunIn) {
@ -513,7 +508,6 @@ async function loadConfigIntoForm() {
inSocks.value = `${socksIn.listen || '127.0.0.1'}:${socksIn.port || 1088}`; inSocks.value = `${socksIn.listen || '127.0.0.1'}:${socksIn.port || 1088}`;
} }
inDns.value = ''; // DNS handling is manual in routing now, ignore here
if (inKillSwitch) inKillSwitch.checked = !!c.gui?.kill_switch; if (inKillSwitch) inKillSwitch.checked = !!c.gui?.kill_switch;
inDebug.checked = c.log?.level === 'debug'; inDebug.checked = c.log?.level === 'debug';
@ -532,33 +526,6 @@ async function loadConfigIntoForm() {
tagState.ips = ips; tagState.ips = ips;
tagState.processes = procs; tagState.processes = procs;
} else {
// OLD FORMAT
inServer.value = c.server || '';
inKey.value = c.access_key || '';
inSocks.value = c.socks5_bind || '127.0.0.1:1088';
inTransport.value = c.transport?.mode || 'udp';
if (inTransport.value === 'dns') {
groupDnsProxy.style.display = 'block';
} else {
groupDnsProxy.style.display = 'none';
}
inMtu.value = c.mtu || '';
inTun.checked = !!c.tun?.enable;
if (inKillSwitch) inKillSwitch.checked = !!c.tun?.kill_switch;
inMux.checked = !!c.mux?.enabled;
inMuxSessions.value = c.mux?.sessions || '';
inDns.value = c.tun?.dns || '';
inDebug.checked = !!c.debug;
const ex = c.exclude || {};
tagState.domains = new Set(ex.domains || []);
tagState.ips = new Set(ex.ips || []);
tagState.processes = new Set(ex.processes || []);
}
if (inAutoconnect) inAutoconnect.checked = !!c.gui?.autoconnect; if (inAutoconnect) inAutoconnect.checked = !!c.gui?.autoconnect;
if (inLaunchStartup) inLaunchStartup.checked = !!c.gui?.launch_startup; if (inLaunchStartup) inLaunchStartup.checked = !!c.gui?.launch_startup;
@ -581,18 +548,15 @@ function scheduleAutoSave() {
async function handleSave(silent = false) { async function handleSave(silent = false) {
if (!rawConfig) rawConfig = { mode: 'client', log_level: 'info' }; if (!rawConfig) rawConfig = { mode: 'client', log_level: 'info' };
const server = inServer.value.trim();
const key = inKey.value.trim();
if (!server) { if (!silent) showToast(t('err_server_req') || 'Server address required', 'error'); return; }
if (!key) { if (!silent) showToast(t('err_key_req') || 'Access key required', 'error'); return; }
if (inLaunchStartup) { if (inLaunchStartup) {
try { await invoke('set_autostart', { enable: inLaunchStartup.checked }); } catch (err) { console.error('autostart error', err); } try { await invoke('set_autostart', { enable: inLaunchStartup.checked }); } catch (err) { console.error('autostart error', err); }
} }
const sHost = server.includes(':') ? server.substring(0, server.lastIndexOf(':')) : server; const activeProfiles = profiles.filter(p => p.active);
const sPort = server.includes(':') ? parseInt(server.substring(server.lastIndexOf(':') + 1), 10) : 50000; if (activeProfiles.length === 0) {
if (!silent) showToast('Please select at least one profile in Settings', 'error');
return;
}
const socksStr = inSocks.value.trim() || '127.0.0.1:1088'; const socksStr = inSocks.value.trim() || '127.0.0.1:1088';
const socksHost = socksStr.includes(':') ? socksStr.substring(0, socksStr.lastIndexOf(':')) : '127.0.0.1'; const socksHost = socksStr.includes(':') ? socksStr.substring(0, socksStr.lastIndexOf(':')) : '127.0.0.1';
@ -616,26 +580,42 @@ async function handleSave(silent = false) {
}); });
} }
const outbounds = [ const outbounds = [];
{ const ostpTags = [];
activeProfiles.forEach((p, i) => {
const tag = activeProfiles.length > 1 ? `proxy-${i}` : 'proxy';
ostpTags.push(tag);
const parts = p.serverAddr.split(':');
const host = parts[0] || '127.0.0.1';
const port = parseInt(parts[1]) || 50000;
outbounds.push({
type: "ostp", type: "ostp",
tag: "proxy", tag: tag,
server: sHost, server: host,
port: sPort, port: port,
access_key: key, access_key: p.accessKey,
transport: { transport: { type: p.transportMode },
type: inTransport.value,
domain: inTransport.value === 'dns' ? inDnsDomain.value.trim() : undefined,
resolver: inTransport.value === 'dns' ? inDnsRegion.value : undefined
},
multiplex: inMux.checked ? { multiplex: inMux.checked ? {
enabled: true, enabled: true,
sessions: parseInt(inMuxSessions.value, 10) || 1 sessions: parseInt(inMuxSessions.value, 10) || 1
} : { enabled: false, sessions: 1 } } : { enabled: false, sessions: 1 }
}, });
{ type: "direct", tag: "direct" }, });
{ type: "block", tag: "block" }
]; if (activeProfiles.length > 1) {
outbounds.push({
type: "urltest",
tag: "proxy",
outbounds: ostpTags,
url: "http://cp.cloudflare.com",
interval: "3m"
});
}
outbounds.push({ type: "direct", tag: "direct" }, { type: "block", tag: "block" });
const rules = []; const rules = [];
if (tagState.domains.size > 0) rules.push({ domain_suffix: Array.from(tagState.domains), outbound: "direct" }); if (tagState.domains.size > 0) rules.push({ domain_suffix: Array.from(tagState.domains), outbound: "direct" });
@ -660,42 +640,36 @@ async function handleSave(silent = false) {
if (inAutoconnect) rawConfig.gui.autoconnect = inAutoconnect.checked; if (inAutoconnect) rawConfig.gui.autoconnect = inAutoconnect.checked;
if (inLaunchStartup) rawConfig.gui.launch_startup = inLaunchStartup.checked; if (inLaunchStartup) rawConfig.gui.launch_startup = inLaunchStartup.checked;
if (inKillSwitch) rawConfig.gui.kill_switch = inKillSwitch.checked; if (inKillSwitch) rawConfig.gui.kill_switch = inKillSwitch.checked;
// Persist the full profile list (including inactive ones) so it survives restarts.
rawConfig.gui.profiles = profiles;
try { try {
const ok = await invoke('save_config', { jsonContent: JSON.stringify(rawConfig, null, 2) }); const ok = await invoke('save_config', { jsonContent: JSON.stringify(rawConfig, null, 2) });
if (!ok && !silent) { if (!ok && !silent) {
showToast(t('toast_error'), 'error'); showToast(t('toast_error'), 'error');
} else if (ok && appState === 'connected') { } else if (ok && appState === 'connected') {
// Hot-reload exclusions into the running tunnel (no reconnect needed) try { await invoke('reload_tunnel'); } catch { }
try { await invoke('reload_tunnel'); } catch { /* ignore */ }
} }
} catch (err) { } catch (err) {
if (!silent) showToast(String(err), 'error'); if (!silent) showToast(String(err), 'error');
} }
} }
// Called by profile add/edit/toggle/delete. saveSettings() was referenced but
// never defined — every profile mutation threw ReferenceError, so nothing
// persisted and the list never re-rendered. It rebuilds config.json (outbounds
// from active profiles) and persists the profile list via handleSave.
function saveSettings() { handleSave(true); }
// ── Import share link ───────────────────────────────────────────────────────── // ── Import share link ─────────────────────────────────────────────────────────
function handleImport() { function handleImport() {
const raw = importInput.value.trim(); const raw = importInput.value.trim();
if (!raw) return; if (!raw) return;
try { try {
if (!raw.startsWith('ostp://')) throw new Error('Link must start with ostp://'); createProfileFromLink(raw);
const url = new URL(raw);
const key = decodeURIComponent(url.username);
const host = url.host;
if (!key || !host) throw new Error('Incomplete link parameters');
inServer.value = host;
inKey.value = key;
inTransport.value = 'udp';
groupDnsProxy.style.display = 'none';
const type = url.searchParams.get('type');
if (type === 'tcp' || type === 'http') inTransport.value = 'uot';
else inTransport.value = 'udp';
importInput.value = ''; importInput.value = '';
showToast(t('toast_imported'), 'ok'); showToast(t('toast_imported') || 'Profile added', 'ok');
handleSave(false);
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -736,10 +710,10 @@ window.addEventListener('DOMContentLoaded', async () => {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
// Auto-connect on startup // Load persisted config (form fields + profiles) so connect has the right
// inbounds/outbounds even if the user never opens Settings, then auto-connect.
try { try {
const raw = await invoke('get_config'); await loadConfigIntoForm();
rawConfig = parseJsonc(raw);
if (rawConfig?.gui?.autoconnect) { if (rawConfig?.gui?.autoconnect) {
setTimeout(() => { setTimeout(() => {
if (appState === 'disconnected') handleToggle(); if (appState === 'disconnected') handleToggle();
@ -829,8 +803,10 @@ window.addEventListener('DOMContentLoaded', async () => {
btnGoSettings.addEventListener('click', () => showScreen('settings')); btnGoSettings.addEventListener('click', () => showScreen('settings'));
btnBack.addEventListener('click', () => showScreen('home')); btnBack.addEventListener('click', () => showScreen('home'));
btnImport.addEventListener('click', handleImport); if (btnImport) btnImport.addEventListener('click', handleImport);
btnPeekKey.addEventListener('click', togglePeek); // btn-peek-key / in-key etc. were removed with the old single-server UI.
// Guard so a missing element can't throw and abort the rest of init wiring.
if (btnPeekKey) btnPeekKey.addEventListener('click', togglePeek);
// Theme toggle // Theme toggle
const btnThemeToggle = $('btn-theme-toggle'); const btnThemeToggle = $('btn-theme-toggle');
@ -928,23 +904,29 @@ window.addEventListener('DOMContentLoaded', async () => {
}); });
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function renderProfiles() { function renderProfiles() {
if (profiles.length === 0) { const hasProfiles = profiles.length > 0;
profilesList.innerHTML = ''; const rowsHtml = profiles.map(p => `
profilesEmpty.style.display = 'block';
} else {
profilesEmpty.style.display = 'none';
profilesList.innerHTML = profiles.map(p => `
<div class="profile-item"> <div class="profile-item">
<input type="checkbox" ${p.active ? 'checked' : ''} onchange="toggleProfile('${p.id}')"> <input type="checkbox" ${p.active ? 'checked' : ''} onchange="toggleProfile('${p.id}')">
<div class="profile-info"> <div class="profile-info">
<div class="profile-name">${p.name}</div> <div class="profile-name">${escapeHtml(p.name)}</div>
<div class="profile-addr">${p.serverAddr}</div> <div class="profile-addr">${escapeHtml(p.serverAddr)}</div>
</div> </div>
<button class="icon-btn" onclick="editProfile('${p.id}')" style="width:24px;height:24px;"></button> <button class="icon-btn" onclick="shareProfile('${p.id}')" title="Share" style="width:26px;height:26px;"></button>
</div> <button class="icon-btn" onclick="editProfile('${p.id}')" title="Edit" style="width:26px;height:26px;"></button>
`).join(''); </div>`).join('');
}
// Profiles live on the Settings page.
if (profilesList) profilesList.innerHTML = rowsHtml;
if (profilesEmpty) profilesEmpty.style.display = hasProfiles ? 'none' : 'block';
// Settings page shows only "Create a new profile" + "+" until a profile exists.
const csb = $('client-settings-block');
if (csb) csb.style.display = hasProfiles ? '' : 'none';
} }
window.toggleProfile = function(id) { window.toggleProfile = function(id) {
@ -964,18 +946,99 @@ window.editProfile = function(id) {
profileModal.classList.remove('hidden'); profileModal.classList.remove('hidden');
}; };
if (btnAddProfile) { function openProfileModalNew() {
btnAddProfile.addEventListener('click', () => {
editingProfileId = null; editingProfileId = null;
$('profile-modal-title').innerText = 'New Profile'; $('profile-modal-title').innerText = 'New Profile';
inProfName.value = ''; inProfName.value = '';
inProfServer.value = ''; inProfServer.value = '';
inProfKey.value = ''; inProfKey.value = '';
inProfTransport.value = 'udp'; inProfTransport.value = 'udp';
btnProfDelete.style.display = 'none'; if (btnProfDelete) btnProfDelete.style.display = 'none';
profileModal.classList.remove('hidden'); profileModal.classList.remove('hidden');
});
} }
if (btnAddProfile) btnAddProfile.addEventListener('click', () => { const m = $('add-menu'); if (m) m.classList.remove('hidden'); });
// Build / parse the ostp:// share link (key in userinfo, host:port authority,
// transport in ?type, name in #fragment).
function buildShareLink(p) {
const type = p.transportMode === 'uot' ? 'tcp' : 'udp';
return `ostp://${encodeURIComponent(p.accessKey)}@${p.serverAddr}?type=${type}#${encodeURIComponent(p.name || '')}`;
}
function createProfileFromLink(raw) {
if (!raw.startsWith('ostp://')) throw new Error('Link must start with ostp://');
const url = new URL(raw);
const key = decodeURIComponent(url.username || '');
const host = url.host;
if (!key || !host) throw new Error('Incomplete link');
const type = url.searchParams.get('type');
const transportMode = (type === 'tcp' || type === 'http') ? 'uot' : 'udp';
let name = url.searchParams.get('name');
if (!name && url.hash) { try { name = decodeURIComponent(url.hash.slice(1)); } catch {} }
profiles.push({
id: Date.now().toString(),
name: name || host,
serverAddr: host,
accessKey: key,
transportMode,
active: profiles.length === 0,
});
saveSettings();
renderProfiles();
}
// Share a profile: show its link + a scannable QR (QR rendered by the Rust
// `generate_qr` command so the access key never leaves the device).
window.shareProfile = async function(id) {
const p = profiles.find(x => x.id === id);
if (!p) return;
const link = buildShareLink(p);
const linkInput = $('share-link');
if (linkInput) linkInput.value = link;
const qrBox = $('share-qr');
if (qrBox) {
qrBox.innerHTML = '<div style="color:var(--c-txt-3);font-size:0.8rem;">Generating…</div>';
try {
qrBox.innerHTML = await invoke('generate_qr', { text: link });
} catch {
qrBox.innerHTML = '<div style="color:var(--c-red);font-size:0.8rem;">QR unavailable — rebuild app</div>';
}
}
const m = $('share-modal');
if (m) m.classList.remove('hidden');
};
// ── Add-profile menu + link/share modal wiring ────────────────────
(function wireProfileMenus() {
const addMenu = $('add-menu');
const closeAddMenu = () => { if (addMenu) addMenu.classList.add('hidden'); };
// Clicking the overlay (outside the card) closes the menu.
if (addMenu) addMenu.addEventListener('click', (e) => { if (e.target === addMenu) closeAddMenu(); });
if ($('btn-add-cancel')) $('btn-add-cancel').addEventListener('click', closeAddMenu);
const linkModal = $('link-modal');
if ($('btn-add-from-link')) $('btn-add-from-link').addEventListener('click', () => {
closeAddMenu();
if ($('in-link')) $('in-link').value = '';
if (linkModal) linkModal.classList.remove('hidden');
});
if ($('btn-add-manual')) $('btn-add-manual').addEventListener('click', () => {
closeAddMenu();
openProfileModalNew();
});
if ($('btn-link-cancel')) $('btn-link-cancel').addEventListener('click', () => { if (linkModal) linkModal.classList.add('hidden'); });
if ($('btn-link-import')) $('btn-link-import').addEventListener('click', () => {
const raw = ($('in-link')?.value || '').trim();
if (!raw) return;
try { createProfileFromLink(raw); if (linkModal) linkModal.classList.add('hidden'); showToast(t('toast_imported') || 'Profile added', 'ok'); }
catch (err) { showToast(err.message, 'error'); }
});
// Share modal
if ($('btn-share-close')) $('btn-share-close').addEventListener('click', () => { const m = $('share-modal'); if (m) m.classList.add('hidden'); });
if ($('btn-share-copy')) $('btn-share-copy').addEventListener('click', () => {
const v = $('share-link')?.value || '';
if (v) { navigator.clipboard?.writeText(v); showToast('Copied', 'ok'); }
});
})();
if (btnProfCancel) btnProfCancel.addEventListener('click', () => profileModal.classList.add('hidden')); if (btnProfCancel) btnProfCancel.addEventListener('click', () => profileModal.classList.add('hidden'));

View File

@ -227,6 +227,8 @@ html[data-theme="light"] .watermark {
.screen { .screen {
position: absolute; position: absolute;
inset: 0; inset: 0;
height: 100%;
overflow: hidden;
z-index: 2; z-index: 2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -697,15 +699,21 @@ html[data-theme="light"] .watermark {
.card.scrollable { .card.scrollable {
flex: 1; flex: 1;
min-height: 0; /* required so overflow-y:auto actually scrolls inside the flex column */ min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.08) transparent; scrollbar-color: rgba(255,255,255,0.08) transparent;
display: block; /* Force block layout to avoid flexbox nested scrolling issues */
} }
.card.scrollable > * { .card.scrollable > * {
flex-shrink: 0; flex-shrink: 0;
margin-bottom: 14px; /* Emulate gap from .card */
}
.card.scrollable > *:last-child {
margin-bottom: 0;
} }
.card.scrollable::-webkit-scrollbar { width: 3px; } .card.scrollable::-webkit-scrollbar { width: 3px; }