mirror of https://github.com/ospab/ostp.git
Fix settings GUI scrolling: force screen height and block layout for scrollable
This commit is contained in:
parent
83f14ec209
commit
80b4ad8d54
|
|
@ -89,6 +89,7 @@ pub async fn run_tun_inbound(
|
|||
let async_fd_shared = std::sync::Arc::new(async_fd);
|
||||
|
||||
let afd1 = async_fd_shared.clone();
|
||||
let m_sent = metrics.clone();
|
||||
let tun_to_stack = tokio::spawn(async move {
|
||||
let mut frame = vec![0u8; 65535];
|
||||
loop {
|
||||
|
|
@ -104,6 +105,8 @@ pub async fn run_tun_inbound(
|
|||
} else { Ok(res as isize) }
|
||||
}) {
|
||||
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; }
|
||||
}
|
||||
Ok(Ok(_)) => break,
|
||||
|
|
@ -114,8 +117,11 @@ pub async fn run_tun_inbound(
|
|||
});
|
||||
|
||||
let afd2 = async_fd_shared.clone();
|
||||
let m_recv = metrics.clone();
|
||||
let stack_to_tun = tokio::spawn(async move {
|
||||
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;
|
||||
while written < frame.len() {
|
||||
let mut guard = match afd2.writable().await {
|
||||
|
|
|
|||
|
|
@ -289,6 +289,7 @@ pub async fn handle_udp(
|
|||
}
|
||||
|
||||
// Send handshake first
|
||||
let hs_start = std::time::Instant::now();
|
||||
if let Ok(action) = machine.on_event(OstpEvent::Start) {
|
||||
handle_udp_action(action, &transport).await;
|
||||
}
|
||||
|
|
@ -300,6 +301,14 @@ pub async fn handle_udp(
|
|||
transport.recv(&mut buf),
|
||||
).await {
|
||||
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])));
|
||||
}
|
||||
_ => {
|
||||
|
|
|
|||
|
|
@ -183,7 +183,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
outbounds.add({"type": "direct", "tag": "direct"});
|
||||
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 = {
|
||||
"mode": "client",
|
||||
"version": "0.3.20",
|
||||
"log": {
|
||||
"level": debugMode ? "debug" : "info"
|
||||
|
|
@ -191,14 +205,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||
"inbounds": inbounds,
|
||||
"outbounds": outbounds,
|
||||
"routing": {
|
||||
"rules": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
"rules": routingRules,
|
||||
"default_outbound": "proxy"
|
||||
},
|
||||
"app_rules": {
|
||||
"mode": appRoutingMode,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
|
@ -395,9 +451,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
),
|
||||
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20, color: Colors.white54),
|
||||
onPressed: () => _showEditProfileDialog(p),
|
||||
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),
|
||||
onPressed: () => _showEditProfileDialog(p),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -2665,7 +2665,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-client"
|
||||
version = "0.3.18"
|
||||
version = "0.3.21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
|
@ -2700,7 +2700,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-core"
|
||||
version = "0.3.18"
|
||||
version = "0.3.21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"byteorder",
|
||||
|
|
@ -2729,6 +2729,7 @@ dependencies = [
|
|||
"ostp-client",
|
||||
"ostp-core",
|
||||
"portable-atomic",
|
||||
"qrcode",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2742,7 +2743,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ostp-tun"
|
||||
version = "0.3.18"
|
||||
version = "0.3.21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
|
|
@ -3101,6 +3102,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"
|
||||
|
|
|
|||
|
|
@ -33,4 +33,5 @@ rand = "0.8"
|
|||
chacha20poly1305 = "0.10"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4.3"
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -416,6 +416,20 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String>
|
|||
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]
|
||||
async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result<bool, String> {
|
||||
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!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -432,7 +432,16 @@ async function handleToggle() {
|
|||
serverAddr = cfg.server || '';
|
||||
} 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');
|
||||
// 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 {
|
||||
console.log('[OSTP] invoking start_tunnel...');
|
||||
|
|
@ -482,82 +491,40 @@ async function loadConfigIntoForm() {
|
|||
const c = rawConfig.mode === 'client' ? rawConfig : null;
|
||||
if (!c) return;
|
||||
|
||||
if (c.version === '0.3.1' || c.outbounds !== undefined) {
|
||||
// NEW FORMAT
|
||||
const ostpOut = (c.outbounds || []).find(o => o.type === 'ostp');
|
||||
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');
|
||||
if (tunIn) {
|
||||
inTun.checked = true;
|
||||
inMtu.value = tunIn.mtu || '';
|
||||
} else {
|
||||
inTun.checked = false;
|
||||
}
|
||||
|
||||
const socksIn = (c.inbounds || []).find(i => i.type === 'local_proxy');
|
||||
if (socksIn) {
|
||||
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;
|
||||
inDebug.checked = c.log?.level === 'debug';
|
||||
|
||||
const ex = c.routing?.rules || [];
|
||||
const doms = new Set();
|
||||
const ips = new Set();
|
||||
const procs = new Set();
|
||||
ex.forEach(r => {
|
||||
if (r.outbound === 'direct') {
|
||||
if (r.domain_suffix) r.domain_suffix.forEach(d => doms.add(d));
|
||||
if (r.ip_cidr) r.ip_cidr.forEach(ip => ips.add(ip));
|
||||
if (r.process_name) r.process_name.forEach(p => procs.add(p));
|
||||
}
|
||||
});
|
||||
tagState.domains = doms;
|
||||
tagState.ips = ips;
|
||||
tagState.processes = procs;
|
||||
// Restore the profile list from persisted GUI state.
|
||||
profiles = rawConfig.gui?.profiles || [];
|
||||
renderProfiles();
|
||||
|
||||
const tunIn = (c.inbounds || []).find(i => i.type === 'tun');
|
||||
if (tunIn) {
|
||||
inTun.checked = true;
|
||||
inMtu.value = tunIn.mtu || '';
|
||||
} 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 || []);
|
||||
inTun.checked = false;
|
||||
}
|
||||
|
||||
const socksIn = (c.inbounds || []).find(i => i.type === 'local_proxy');
|
||||
if (socksIn) {
|
||||
inSocks.value = `${socksIn.listen || '127.0.0.1'}:${socksIn.port || 1088}`;
|
||||
}
|
||||
|
||||
if (inKillSwitch) inKillSwitch.checked = !!c.gui?.kill_switch;
|
||||
inDebug.checked = c.log?.level === 'debug';
|
||||
|
||||
const ex = c.routing?.rules || [];
|
||||
const doms = new Set();
|
||||
const ips = new Set();
|
||||
const procs = new Set();
|
||||
ex.forEach(r => {
|
||||
if (r.outbound === 'direct') {
|
||||
if (r.domain_suffix) r.domain_suffix.forEach(d => doms.add(d));
|
||||
if (r.ip_cidr) r.ip_cidr.forEach(ip => ips.add(ip));
|
||||
if (r.process_name) r.process_name.forEach(p => procs.add(p));
|
||||
}
|
||||
});
|
||||
tagState.domains = doms;
|
||||
tagState.ips = ips;
|
||||
tagState.processes = procs;
|
||||
|
||||
if (inAutoconnect) inAutoconnect.checked = !!c.gui?.autoconnect;
|
||||
if (inLaunchStartup) inLaunchStartup.checked = !!c.gui?.launch_startup;
|
||||
|
|
@ -581,18 +548,15 @@ function scheduleAutoSave() {
|
|||
async function handleSave(silent = false) {
|
||||
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) {
|
||||
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 sPort = server.includes(':') ? parseInt(server.substring(server.lastIndexOf(':') + 1), 10) : 50000;
|
||||
const activeProfiles = profiles.filter(p => p.active);
|
||||
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 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",
|
||||
tag: "proxy",
|
||||
server: sHost,
|
||||
port: sPort,
|
||||
access_key: key,
|
||||
transport: {
|
||||
type: inTransport.value,
|
||||
domain: inTransport.value === 'dns' ? inDnsDomain.value.trim() : undefined,
|
||||
resolver: inTransport.value === 'dns' ? inDnsRegion.value : undefined
|
||||
},
|
||||
tag: tag,
|
||||
server: host,
|
||||
port: port,
|
||||
access_key: p.accessKey,
|
||||
transport: { type: p.transportMode },
|
||||
multiplex: inMux.checked ? {
|
||||
enabled: true,
|
||||
sessions: parseInt(inMuxSessions.value, 10) || 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 = [];
|
||||
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 (inLaunchStartup) rawConfig.gui.launch_startup = inLaunchStartup.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 {
|
||||
const ok = await invoke('save_config', { jsonContent: JSON.stringify(rawConfig, null, 2) });
|
||||
if (!ok && !silent) {
|
||||
showToast(t('toast_error'), 'error');
|
||||
} else if (ok && appState === 'connected') {
|
||||
// Hot-reload exclusions into the running tunnel (no reconnect needed)
|
||||
try { await invoke('reload_tunnel'); } catch { /* ignore */ }
|
||||
try { await invoke('reload_tunnel'); } catch { }
|
||||
}
|
||||
} catch (err) {
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
function handleImport() {
|
||||
const raw = importInput.value.trim();
|
||||
if (!raw) return;
|
||||
try {
|
||||
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 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';
|
||||
|
||||
createProfileFromLink(raw);
|
||||
importInput.value = '';
|
||||
showToast(t('toast_imported'), 'ok');
|
||||
handleSave(false);
|
||||
showToast(t('toast_imported') || 'Profile added', 'ok');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
|
|
@ -736,10 +710,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||
} 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 {
|
||||
const raw = await invoke('get_config');
|
||||
rawConfig = parseJsonc(raw);
|
||||
await loadConfigIntoForm();
|
||||
if (rawConfig?.gui?.autoconnect) {
|
||||
setTimeout(() => {
|
||||
if (appState === 'disconnected') handleToggle();
|
||||
|
|
@ -829,8 +803,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
btnGoSettings.addEventListener('click', () => showScreen('settings'));
|
||||
btnBack.addEventListener('click', () => showScreen('home'));
|
||||
btnImport.addEventListener('click', handleImport);
|
||||
btnPeekKey.addEventListener('click', togglePeek);
|
||||
if (btnImport) btnImport.addEventListener('click', handleImport);
|
||||
// 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
|
||||
const btnThemeToggle = $('btn-theme-toggle');
|
||||
|
|
@ -928,23 +904,29 @@ window.addEventListener('DOMContentLoaded', async () => {
|
|||
});
|
||||
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
if (profiles.length === 0) {
|
||||
profilesList.innerHTML = '';
|
||||
profilesEmpty.style.display = 'block';
|
||||
} else {
|
||||
profilesEmpty.style.display = 'none';
|
||||
profilesList.innerHTML = profiles.map(p => `
|
||||
const hasProfiles = profiles.length > 0;
|
||||
const rowsHtml = profiles.map(p => `
|
||||
<div class="profile-item">
|
||||
<input type="checkbox" ${p.active ? 'checked' : ''} onchange="toggleProfile('${p.id}')">
|
||||
<div class="profile-info">
|
||||
<div class="profile-name">${p.name}</div>
|
||||
<div class="profile-addr">${p.serverAddr}</div>
|
||||
<div class="profile-name">${escapeHtml(p.name)}</div>
|
||||
<div class="profile-addr">${escapeHtml(p.serverAddr)}</div>
|
||||
</div>
|
||||
<button class="icon-btn" onclick="editProfile('${p.id}')" style="width:24px;height:24px;">✎</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
<button class="icon-btn" onclick="shareProfile('${p.id}')" title="Share" style="width:26px;height:26px;">⤴</button>
|
||||
<button class="icon-btn" onclick="editProfile('${p.id}')" title="Edit" style="width:26px;height:26px;">✎</button>
|
||||
</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) {
|
||||
|
|
@ -964,18 +946,99 @@ window.editProfile = function(id) {
|
|||
profileModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
if (btnAddProfile) {
|
||||
btnAddProfile.addEventListener('click', () => {
|
||||
editingProfileId = null;
|
||||
$('profile-modal-title').innerText = 'New Profile';
|
||||
inProfName.value = '';
|
||||
inProfServer.value = '';
|
||||
inProfKey.value = '';
|
||||
inProfTransport.value = 'udp';
|
||||
btnProfDelete.style.display = 'none';
|
||||
profileModal.classList.remove('hidden');
|
||||
});
|
||||
function openProfileModalNew() {
|
||||
editingProfileId = null;
|
||||
$('profile-modal-title').innerText = 'New Profile';
|
||||
inProfName.value = '';
|
||||
inProfServer.value = '';
|
||||
inProfKey.value = '';
|
||||
inProfTransport.value = 'udp';
|
||||
if (btnProfDelete) btnProfDelete.style.display = 'none';
|
||||
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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,8 @@ html[data-theme="light"] .watermark {
|
|||
.screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -697,15 +699,21 @@ html[data-theme="light"] .watermark {
|
|||
|
||||
.card.scrollable {
|
||||
flex: 1;
|
||||
min-height: 0; /* required so overflow-y:auto actually scrolls inside the flex column */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||
display: block; /* Force block layout to avoid flexbox nested scrolling issues */
|
||||
}
|
||||
|
||||
.card.scrollable > * {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 14px; /* Emulate gap from .card */
|
||||
}
|
||||
|
||||
.card.scrollable > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card.scrollable::-webkit-scrollbar { width: 3px; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue