mirror of https://github.com/ospab/ostp.git
feat(gui): split tunneling — tag-chip UI, process picker with live process list
This commit is contained in:
parent
533466b63a
commit
83ba39e59a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 *.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.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 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">
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue