feat(gui): split tunneling — tag-chip UI, process picker with live process list

This commit is contained in:
ospab 2026-06-13 02:55:28 +03:00
parent 533466b63a
commit 83ba39e59a
7 changed files with 490 additions and 29 deletions

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.2.92+7
version: 0.2.95+10
environment:
sdk: ^3.11.4

View File

@ -2665,7 +2665,7 @@ dependencies = [
[[package]]
name = "ostp-client"
version = "0.2.87"
version = "0.2.95"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2699,7 +2699,7 @@ dependencies = [
[[package]]
name = "ostp-core"
version = "0.2.87"
version = "0.2.95"
dependencies = [
"anyhow",
"bytes",
@ -2734,7 +2734,7 @@ dependencies = [
[[package]]
name = "ostp-tun"
version = "0.2.87"
version = "0.2.95"
dependencies = [
"anyhow",
"libc",

View File

@ -242,6 +242,67 @@ fn get_autostart() -> bool {
false
}
/// Returns a sorted, deduplicated list of currently running process names.
#[tauri::command]
fn list_running_processes() -> Vec<String> {
#[cfg(target_os = "windows")]
{
use std::process::Command;
if let Ok(out) = Command::new("tasklist")
.args(["/FO", "CSV", "/NH"])
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for line in text.lines() {
// CSV format: "chrome.exe","1234","Console","1","123,456 K"
let name = line.trim_matches('"').split('"').next().unwrap_or("");
if !name.is_empty() && name.ends_with(".exe") {
names.insert(name.to_string());
}
}
return names.into_iter().collect();
}
}
#[cfg(target_os = "linux")]
{
use std::process::Command;
if let Ok(out) = Command::new("ps")
.args(["-e", "-o", "comm="])
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for line in text.lines() {
let name = line.trim();
if !name.is_empty() {
names.insert(name.to_string());
}
}
return names.into_iter().collect();
}
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
if let Ok(out) = Command::new("ps")
.args(["-e", "-o", "comm="])
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for line in text.lines() {
let name = line.trim().split('/').last().unwrap_or("");
if !name.is_empty() {
names.insert(name.to_string());
}
}
return names.into_iter().collect();
}
}
vec![]
}
#[tauri::command]
async fn get_config() -> Result<String, String> {
let path = get_config_path();
@ -785,7 +846,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])
.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])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui",
"version": "0.2.92",
"version": "0.2.95",
"identifier": "com.ospab.ostp",
"build": {
"frontendDist": "../src"

View File

@ -314,25 +314,49 @@
</div>
<!-- Exclusions -->
<!-- Split Tunneling / Exclusions -->
<div class="section-head">
<span data-i18n="excl_title">Exclusions</span>
<span class="section-hint" data-i18n="excl_hint">one per line</span>
<span class="section-hint" data-i18n="excl_hint">traffic that bypasses the tunnel</span>
</div>
<div class="field-group">
<label class="field-label" for="in-ex-domains" data-i18n="excl_domains">Bypass Domains</label>
<textarea id="in-ex-domains" class="field-input mono" placeholder="example.com&#10;*.google.com" rows="2"></textarea>
<label class="field-label" for="tag-input-domains" data-i18n="excl_domains">Bypass Domains</label>
<div class="tag-input-wrap" id="tag-wrap-domains">
<div class="tag-list" id="tag-list-domains"></div>
<input id="tag-input-domains" class="tag-input-field" type="text"
placeholder="example.com" spellcheck="false" autocomplete="off" />
</div>
<span class="field-hint">Enter domain suffix and press Enter. Example: google.com, *.local</span>
</div>
<div class="field-group">
<label class="field-label" for="in-ex-ips" data-i18n="excl_ips">Bypass IPs / CIDR</label>
<textarea id="in-ex-ips" class="field-input mono" placeholder="192.168.1.0/24&#10;10.0.0.1" rows="2"></textarea>
<label class="field-label" for="tag-input-ips" data-i18n="excl_ips">Bypass IPs / CIDR</label>
<div class="tag-input-wrap" id="tag-wrap-ips">
<div class="tag-list" id="tag-list-ips"></div>
<input id="tag-input-ips" class="tag-input-field" type="text"
placeholder="192.168.1.0/24" spellcheck="false" autocomplete="off" />
</div>
<span class="field-hint">Local network ranges bypass the tunnel automatically</span>
</div>
<div class="field-group">
<label class="field-label" for="in-ex-processes" data-i18n="excl_processes">Bypass Processes</label>
<textarea id="in-ex-processes" class="field-input mono" placeholder="chrome.exe&#10;firefox.exe" rows="2"></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<label class="field-label" style="margin-bottom:0" for="tag-input-processes" data-i18n="excl_processes">Bypass Processes</label>
<div style="display:flex;gap:6px;align-items:center">
<span class="tun-badge" id="proc-tun-badge">TUN only</span>
<button id="btn-pick-process" class="pick-proc-btn" type="button">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
Pick
</button>
</div>
</div>
<div class="tag-input-wrap" id="tag-wrap-processes">
<div class="tag-list" id="tag-list-processes"></div>
<input id="tag-input-processes" class="tag-input-field" type="text"
placeholder="chrome.exe" spellcheck="false" autocomplete="off" />
</div>
<span class="field-hint" id="proc-hint">Only works in TUN mode. Excluded apps bypass the VPN.</span>
</div>
</div>
@ -344,6 +368,24 @@
<!-- Toast -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<!-- Process Picker Modal -->
<div id="proc-picker-modal" class="modal-overlay hidden">
<div class="modal-content proc-picker-content">
<div class="proc-picker-header">
<h3 class="modal-title">Running Processes</h3>
<div class="proc-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input id="proc-search" class="proc-search-input" type="text" placeholder="Search..." autocomplete="off" />
</div>
</div>
<div id="proc-list" class="proc-list"></div>
<div class="modal-actions">
<button id="btn-proc-cancel" class="btn secondary">Cancel</button>
<button id="btn-proc-add" class="btn primary">Add Selected</button>
</div>
</div>
</div>
<!-- Wintun Modal -->
<div id="wintun-modal" class="modal-overlay hidden">
<div class="modal-content">

View File

@ -47,8 +47,6 @@ const groupCustomDns = $('group-custom-dns');
const inTransport = $('in-transport');
const inSni = $('in-stealth-sni');
const inWss = $('in-wss');
const inPbk = $('in-pbk');
const inSid = $('in-sid');
const inMtu = $('in-mtu');
const inTun = $('in-tun-mode');
const inKillSwitch = $('in-kill-switch');
@ -57,15 +55,70 @@ const inMuxSessions = $('in-mux-sessions');
const inDebug = $('in-debug');
const inAutoconnect = $('in-autoconnect');
const inLaunchStartup = $('in-launch-startup');
const inDomains = $('in-ex-domains');
const inIps = $('in-ex-ips');
const inProcesses = $('in-ex-processes');
const wintunModal = $('wintun-modal');
const btnWintunCancel = $('btn-wintun-cancel');
const btnWintunOpen = $('btn-wintun-open');
const wintunInstallPath = $('wintun-install-path');
// ── Tag-input state ───────────────────────────────────────────────────────────
// Map of tagId -> Set<string>
const tagState = {
domains: new Set(),
ips: new Set(),
processes: new Set(),
};
function renderTagList(key) {
const list = $('tag-list-' + key);
if (!list) return;
list.innerHTML = '';
for (const val of tagState[key]) {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `${val}<button class="tag-chip-remove" title="Remove" tabindex="-1"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>`;
chip.querySelector('.tag-chip-remove').addEventListener('click', () => {
tagState[key].delete(val);
renderTagList(key);
scheduleAutoSave();
});
list.appendChild(chip);
}
}
function addTag(key, raw) {
const vals = raw.split(/[\s,;]+/).map(v => v.trim()).filter(Boolean);
let added = false;
for (const v of vals) {
if (!tagState[key].has(v)) { tagState[key].add(v); added = true; }
}
if (added) { renderTagList(key); scheduleAutoSave(); }
}
function wireTagInput(key) {
const input = $('tag-input-' + key);
const wrap = $('tag-wrap-' + key);
if (!input) return;
input.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const v = input.value.trim();
if (v) { addTag(key, v); input.value = ''; }
} else if (e.key === 'Backspace' && !input.value) {
const arr = [...tagState[key]];
if (arr.length) { tagState[key].delete(arr[arr.length - 1]); renderTagList(key); scheduleAutoSave(); }
}
});
input.addEventListener('paste', e => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text');
addTag(key, text);
input.value = '';
});
// click on wrap focuses input
if (wrap) wrap.addEventListener('click', () => input.focus());
}
// ── Utilities ────────────────────────────────────────────────────────────────
function fmtBytes(b) {
if (!b || b === 0) return '0 B';
@ -300,9 +353,12 @@ async function loadConfigIntoForm() {
const ex = c.exclude || {};
inDomains.value = (ex.domains || []).join('\n');
inIps.value = (ex.ips || []).join('\n');
inProcesses.value = (ex.processes || []).join('\n');
tagState.domains = new Set(ex.domains || []);
tagState.ips = new Set(ex.ips || []);
tagState.processes = new Set(ex.processes || []);
renderTagList('domains');
renderTagList('ips');
renderTagList('processes');
} catch (err) {
showToast(String(err), 'error');
}
@ -366,9 +422,9 @@ async function handleSave(silent = false) {
rawConfig.tun.dns = inDns.value.trim() || null;
rawConfig.exclude = {
domains: splitLines(inDomains.value),
ips: splitLines(inIps.value),
processes: splitLines(inProcesses.value),
domains: [...tagState.domains],
ips: [...tagState.ips],
processes: [...tagState.processes],
};
try {
@ -394,8 +450,6 @@ function handleImport() {
inServer.value = host;
inKey.value = key;
inSni.value = url.searchParams.get('sni') || '';
inPbk.value = url.searchParams.get('pbk') || '';
inSid.value = url.searchParams.get('sid') || '';
const type = url.searchParams.get('type');
if (type === 'tcp' || type === 'http') inTransport.value = 'uot';
@ -541,8 +595,8 @@ window.addEventListener('DOMContentLoaded', async () => {
});
importInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleImport(); });
// Auto-save wiring
const formInputs = document.querySelectorAll('#settings-screen input:not(#in-import-url), #settings-screen textarea, #settings-screen select');
// Auto-save wiring for standard form elements (excluding tag-inputs which wire themselves)
const formInputs = document.querySelectorAll('#settings-screen input:not(#in-import-url):not(.tag-input-field), #settings-screen select');
formInputs.forEach(el => {
el.addEventListener('input', () => {
scheduleAutoSave();
@ -555,6 +609,85 @@ window.addEventListener('DOMContentLoaded', async () => {
el.addEventListener('change', scheduleAutoSave);
});
// Wire tag inputs
wireTagInput('domains');
wireTagInput('ips');
wireTagInput('processes');
// Process picker
const procPickerModal = $('proc-picker-modal');
const btnPickProcess = $('btn-pick-process');
const btnProcCancel = $('btn-proc-cancel');
const btnProcAdd = $('btn-proc-add');
const procList = $('proc-list');
const procSearch = $('proc-search');
let allProcs = [];
let selectedProcs = new Set();
function renderProcList(filter) {
if (!procList) return;
const q = (filter || '').toLowerCase();
const filtered = q ? allProcs.filter(p => p.toLowerCase().includes(q)) : allProcs;
if (!filtered.length) {
procList.innerHTML = `<div class="proc-empty">${q ? 'No matches' : 'No processes found'}</div>`;
return;
}
procList.innerHTML = filtered.map(p => {
const sel = selectedProcs.has(p) ? 'selected' : '';
return `<div class="proc-item ${sel}" data-name="${p}"><div class="proc-item-check"></div><span class="proc-item-name">${p}</span></div>`;
}).join('');
procList.querySelectorAll('.proc-item').forEach(el => {
el.addEventListener('click', () => {
const name = el.dataset.name;
if (selectedProcs.has(name)) selectedProcs.delete(name);
else selectedProcs.add(name);
el.classList.toggle('selected');
el.querySelector('.proc-item-check') // rerender
renderProcList(procSearch ? procSearch.value : '');
});
});
}
if (btnPickProcess && procPickerModal) {
btnPickProcess.addEventListener('click', async () => {
selectedProcs = new Set([...tagState.processes]);
procPickerModal.classList.remove('hidden');
procList.innerHTML = '<div class="proc-loading"><span>Loading...</span></div>';
if (procSearch) procSearch.value = '';
try {
allProcs = await invoke('list_running_processes');
} catch {
allProcs = [];
}
renderProcList('');
if (procSearch) procSearch.focus();
});
}
if (procSearch) {
procSearch.addEventListener('input', () => renderProcList(procSearch.value));
}
if (btnProcCancel) {
btnProcCancel.addEventListener('click', () => procPickerModal.classList.add('hidden'));
}
if (btnProcAdd) {
btnProcAdd.addEventListener('click', () => {
for (const p of selectedProcs) tagState.processes.add(p);
renderTagList('processes');
scheduleAutoSave();
procPickerModal.classList.add('hidden');
});
}
// Close picker on backdrop click
if (procPickerModal) {
procPickerModal.addEventListener('click', e => {
if (e.target === procPickerModal) procPickerModal.classList.add('hidden');
});
}
btnTestPing.addEventListener('click', runPingTest);
btnWintunCancel.addEventListener('click', () => {

View File

@ -1005,3 +1005,228 @@ html[data-theme="light"] .modal-actions .btn.secondary:hover { background: rgba(
color: #fff;
}
.modal-actions .btn.primary:hover { background: var(--c-accent-2); }
/* ── Tag-chip Input ──────────────────────────────────────────────────────── */
.tag-input-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 8px 10px;
min-height: 42px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--c-card-border);
border-radius: var(--r-sm);
cursor: text;
transition: border-color var(--t-fast);
}
.tag-input-wrap:focus-within {
border-color: var(--c-accent);
box-shadow: 0 0 0 2px var(--c-accent-dim);
}
html[data-theme="light"] .tag-input-wrap {
background: rgba(0,0,0,0.04);
border-color: rgba(0,0,0,0.10);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 10px;
background: var(--c-accent-dim);
border: 1px solid rgba(108,114,255,0.3);
border-radius: var(--r-full);
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--c-accent);
white-space: nowrap;
animation: chipIn 180ms var(--t-med) both;
}
@keyframes chipIn {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
.tag-chip-remove {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: none;
background: none;
color: var(--c-accent);
cursor: pointer;
padding: 0;
border-radius: 50%;
transition: background var(--t-fast), color var(--t-fast);
flex-shrink: 0;
}
.tag-chip-remove:hover {
background: rgba(108,114,255,0.25);
color: #fff;
}
.tag-chip-remove svg { display: block; }
.tag-input-field {
flex: 1;
min-width: 120px;
border: none;
outline: none;
background: transparent;
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
color: var(--c-txt-1);
padding: 0;
}
.tag-input-field::placeholder { color: var(--c-txt-2); }
.field-hint {
display: block;
font-size: 11px;
color: var(--c-txt-2);
margin-top: 5px;
line-height: 1.4;
}
/* ── TUN badge ───────────────────────────────────────────────────────────── */
.tun-badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: var(--r-full);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(245,158,11,0.12);
border: 1px solid rgba(245,158,11,0.3);
color: var(--c-amber);
}
/* ── Pick-process button ─────────────────────────────────────────────────── */
.pick-proc-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: var(--r-xs);
border: 1px solid var(--c-card-border);
background: rgba(255,255,255,0.06);
color: var(--c-txt-1);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.pick-proc-btn:hover {
background: rgba(255,255,255,0.12);
border-color: var(--c-accent);
color: var(--c-accent);
}
html[data-theme="light"] .pick-proc-btn { background: rgba(0,0,0,0.04); }
html[data-theme="light"] .pick-proc-btn:hover { background: rgba(0,0,0,0.10); }
/* ── Process Picker Modal ────────────────────────────────────────────────── */
.proc-picker-content {
width: 360px;
max-width: 96vw;
padding: 0;
overflow: hidden;
}
.proc-picker-header {
padding: 18px 20px 14px;
border-bottom: 1px solid var(--c-card-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.proc-picker-header .modal-title { margin: 0; font-size: 15px; }
.proc-search-wrap {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
background: rgba(255,255,255,0.06);
border: 1px solid var(--c-card-border);
border-radius: var(--r-xs);
flex: 1;
max-width: 160px;
}
html[data-theme="light"] .proc-search-wrap { background: rgba(0,0,0,0.05); }
.proc-search-wrap svg { color: var(--c-txt-2); flex-shrink: 0; }
.proc-search-input {
border: none; outline: none; background: transparent;
font-size: 12px; color: var(--c-txt-1); width: 100%;
}
.proc-list {
max-height: 280px;
overflow-y: auto;
padding: 8px 0;
}
.proc-list::-webkit-scrollbar { width: 4px; }
.proc-list::-webkit-scrollbar-track { background: transparent; }
.proc-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
.proc-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 20px;
cursor: pointer;
transition: background var(--t-fast);
user-select: none;
}
.proc-item:hover { background: rgba(255,255,255,0.05); }
html[data-theme="light"] .proc-item:hover { background: rgba(0,0,0,0.05); }
.proc-item.selected { background: var(--c-accent-dim); }
.proc-item.selected .proc-item-name { color: var(--c-accent); }
.proc-item-check {
width: 16px; height: 16px;
border: 1.5px solid var(--c-txt-3);
border-radius: 4px;
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: border-color var(--t-fast), background var(--t-fast);
}
.proc-item.selected .proc-item-check {
border-color: var(--c-accent);
background: var(--c-accent);
}
.proc-item.selected .proc-item-check::after {
content: '';
display: block;
width: 8px; height: 5px;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(-45deg) translate(1px, -1px);
}
.proc-item-name {
font-size: 12.5px;
font-family: 'JetBrains Mono', monospace;
color: var(--c-txt-1);
}
.proc-empty {
padding: 24px 20px;
text-align: center;
font-size: 13px;
color: var(--c-txt-2);
}
.proc-loading {
padding: 24px 20px;
text-align: center;
font-size: 13px;
color: var(--c-txt-2);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.proc-picker-content .modal-actions {
border-top: 1px solid var(--c-card-border);
padding: 12px 20px;
}