App desktop (Tauri), GitHub Actions de CI/release, elevação automática
- Move a UI de crates/sabia-cli/src/web/ para apps/gui/ui/: fonte única servida tanto pelo `sabia ui` quanto pelo app desktop. - sabia-core::engine::Engine centraliza sessões de scan/monitor/update — usado pelo servidor local da CLI e pelo app Tauri, sem duplicar lógica. - apps/gui/src-tauri: app desktop Tauri 2, mesma interface, comandos IPC espelhando a API HTTP. Pede elevação de administrador/root automaticamente ao abrir (modo monitor e captura crua exigem): manifesto requireAdministrator no Windows (UAC antes mesmo do app abrir), pkexec no Linux e osascript no macOS, com reinício automático e aviso + botão de nova tentativa na interface se for negada. - Ícone gerado a partir do logo (apps/gui/src-tauri/icons/source.png). - .github/workflows/ci.yml: fmt, clippy, testes (Linux/Windows/macOS), auditoria de dependências, compilação do app desktop. - .github/workflows/release.yml: ao empurrar uma tag vX.Y.Z, builda a CLI (Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64) e o app desktop pras mesmas plataformas, publica tudo numa release do GitHub com checksums SHA-256. - sabia-core::update: corrige o nome do asset esperado (casava pelo nome fixo, mas o release empacota com a versão no nome) e a extração (tar.gz no Linux/macOS, zip no Windows). - LICENSE-MIT, LICENSE-APACHE, CHANGELOG.md com a entrada 0.1.0. - cargo fmt + clippy (-D warnings) limpos em todo o workspace.
This commit is contained in:
1 parent
fd3ef5240e
commit
df27974359
29 files changed
+1268
-191
No files matched your search
@@ -0,0 +1,28 @@
|
||||
# App desktop (Tauri 2). Fica fora do workspace principal porque exige
|
||||
# as bibliotecas de WebView do sistema (WebKitGTK no Linux).
|
||||
[package]
|
||||
name = "sabia-gui"
|
||||
description = "Sabiá: interface gráfica de auditoria de Wi-Fi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/lucasrguerra/sabia"
|
||||
rust-version = "1.80"
|
||||
|
||||
[workspace]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde_json = "1"
|
||||
sabia-core = { path = "../../../crates/sabia-core" }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,13 @@
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Modo monitor e captura crua exigem administrador no Windows: o
|
||||
// manifesto pede elevação (UAC) assim que o app abre.
|
||||
let manifest = include_str!("windows-app-manifest.xml");
|
||||
let attrs = tauri_build::Attributes::new()
|
||||
.windows_attributes(tauri_build::WindowsAttributes::new().app_manifest(manifest));
|
||||
tauri_build::try_build(attrs).expect("falha no tauri-build");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
tauri_build::build();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,190 @@
|
||||
// Evita a janela de console extra no Windows em release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use sabia_core::engine::Engine;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct State(Arc<Engine>);
|
||||
|
||||
#[tauri::command]
|
||||
fn version(state: tauri::State<'_, State>) -> String {
|
||||
state.0.version().to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_ifaces(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
let list = state.0.list_ifaces().map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(list).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_monitor(state: tauri::State<'_, State>, iface: String, enable: bool) -> Result<(), String> {
|
||||
state.0.set_monitor(&iface, enable).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_scan(state: tauri::State<'_, State>, iface: String) -> Result<(), String> {
|
||||
state.0.start_scan(&iface).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_scan(state: tauri::State<'_, State>, iface: String) {
|
||||
state.0.stop_scan(&iface);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn scan_results(state: tauri::State<'_, State>, iface: String) -> Result<serde_json::Value, String> {
|
||||
serde_json::to_value(state.0.scan_results(&iface)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
|
||||
let status = state.0.update_check().map_err(|e| e.to_string())?;
|
||||
serde_json::to_value(status).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
|
||||
state.0.update_apply().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `true` quando o processo já tem privilégio suficiente pra tudo que o
|
||||
/// Sabiá faz (modo monitor, captura crua). No Windows o manifesto já
|
||||
/// exige administrador antes mesmo do app abrir, então aqui é sempre `true`.
|
||||
#[tauri::command]
|
||||
fn is_elevated() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Reabre o app com privilégios de administrador (Linux/macOS). O app novo
|
||||
/// encerra este quando abre; se a autorização falhar ou for cancelada, a
|
||||
/// janela continua aqui e a interface mostra o motivo.
|
||||
#[tauri::command]
|
||||
fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
do_relaunch_elevated(app)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = app;
|
||||
Err("o Sabiá já roda como administrador no Windows".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let exe = elevated_exe()?;
|
||||
let log = std::env::temp_dir().join(format!("sabia-elevate-{}.log", std::process::id()));
|
||||
let _ = std::fs::write(&log, b"");
|
||||
let out = std::fs::OpenOptions::new().append(true).open(&log).map_err(|e| e.to_string())?;
|
||||
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
let pid = std::process::id().to_string();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut cmd = {
|
||||
let mut c = std::process::Command::new("pkexec");
|
||||
c.arg("/usr/bin/env");
|
||||
for k in ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "GDK_BACKEND"] {
|
||||
if let Ok(v) = std::env::var(k) {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
}
|
||||
c.arg(format!("SABIA_REPLACE_PID={pid}"));
|
||||
c.arg(&exe);
|
||||
c
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = {
|
||||
let shell = format!("SABIA_REPLACE_PID={} '{}' >/dev/null 2>&1 &", pid, exe.display());
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
|
||||
let mut c = std::process::Command::new("osascript");
|
||||
c.args(["-e", &script]);
|
||||
c
|
||||
};
|
||||
cmd.stdout(out).stderr(err);
|
||||
let mut child = cmd.spawn().map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
|
||||
std::thread::spawn(move || {
|
||||
let status = child.wait();
|
||||
if matches!(&status, Ok(s) if s.success()) {
|
||||
let _ = std::fs::remove_file(&log);
|
||||
return;
|
||||
}
|
||||
let detail = std::fs::read_to_string(&log).unwrap_or_default();
|
||||
let msg = match status.ok().and_then(|s| s.code()) {
|
||||
Some(126) => "Autorização cancelada.".to_string(),
|
||||
Some(127) => "Não foi possível autenticar como administrador.".to_string(),
|
||||
_ => format!("Não foi possível reiniciar como administrador. {}", detail.lines().last().unwrap_or("").trim()),
|
||||
};
|
||||
let _ = app.emit("elevate-failed", msg);
|
||||
let _ = std::fs::remove_file(&log);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta
|
||||
/// pra dentro de um ponto de montagem FUSE do usuário, inacessível ao root:
|
||||
/// usa o próprio arquivo `.AppImage` (variável `APPIMAGE`).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
fn elevated_exe() -> Result<std::path::PathBuf, String> {
|
||||
if let Some(p) = std::env::var_os("APPIMAGE").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
|
||||
return Ok(p);
|
||||
}
|
||||
std::env::current_exe().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let engine = Arc::new(Engine::new());
|
||||
tauri::Builder::default()
|
||||
.manage(State(engine))
|
||||
.setup(|app| {
|
||||
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = std::env::var("SABIA_REPLACE_PID").ok().and_then(|p| p.parse::<i32>().ok()) {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1200));
|
||||
// SAFETY: só envia SIGTERM ao processo anterior do próprio usuário.
|
||||
unsafe {
|
||||
if libc::geteuid() == 0 {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Captura crua e modo monitor exigem root: pede elevação assim
|
||||
// que o app abre, sem esperar o usuário procurar um botão. Se
|
||||
// a autorização falhar, a janela continua aberta e a interface
|
||||
// mostra o aviso (com um botão pra tentar de novo).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if unsafe { libc::geteuid() != 0 } && std::env::var("SABIA_REPLACE_PID").is_err() {
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = do_relaunch_elevated(handle);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
version,
|
||||
list_ifaces,
|
||||
set_monitor,
|
||||
start_scan,
|
||||
stop_scan,
|
||||
scan_results,
|
||||
update_check,
|
||||
update_apply,
|
||||
is_elevated,
|
||||
relaunch_elevated,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("erro ao iniciar o Sabiá");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Sabiá",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.sabia.app",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Sabiá",
|
||||
"width": 1180,
|
||||
"height": 780,
|
||||
"minWidth": 420,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; frame-src 'none'; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "Utility",
|
||||
"shortDescription": "Auditoria de Wi-Fi",
|
||||
"longDescription": "Sabiá audita, monitora e testa a segurança de redes Wi-Fi — modo monitor, captura de quadros e handshakes, sempre com trilha de auditoria.",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
@@ -0,0 +1,187 @@
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fafafc;
|
||||
--side: rgba(246, 246, 248, 0.82);
|
||||
--text: #1d1d1f;
|
||||
--text-2: #6e6e73;
|
||||
--text-3: #8e8e93;
|
||||
--line: rgba(0, 0, 0, 0.09);
|
||||
|
||||
/* "canto" — cobre quente, o peito do sabiá-laranjeira */
|
||||
--accent: #b45309;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: rgba(180, 83, 9, 0.11);
|
||||
/* "sinal" — ciano, a onda que o rádio enxerga */
|
||||
--signal: #0891b2;
|
||||
--signal-soft: rgba(8, 145, 178, 0.12);
|
||||
|
||||
--ok: #1a8f3c;
|
||||
--warn: #b26a00;
|
||||
--bad: #d12b2b;
|
||||
--ok-soft: rgba(26, 143, 60, 0.12);
|
||||
--warn-soft: rgba(178, 106, 0, 0.13);
|
||||
--bad-soft: rgba(209, 43, 43, 0.11);
|
||||
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
--radius: 16px;
|
||||
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI Variable", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
--side-w: 280px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #000000;
|
||||
--surface: #1c1c1e;
|
||||
--surface-2: #232326;
|
||||
--side: rgba(28, 28, 30, 0.8);
|
||||
--text: #f5f5f7;
|
||||
--text-2: #a1a1a6;
|
||||
--text-3: #7d7d82;
|
||||
--line: rgba(255, 255, 255, 0.1);
|
||||
--accent: #f59e0b;
|
||||
--accent-ink: #1f1300;
|
||||
--accent-soft: rgba(245, 158, 11, 0.15);
|
||||
--signal: #22d3ee;
|
||||
--signal-soft: rgba(34, 211, 238, 0.14);
|
||||
--ok: #32d74b;
|
||||
--warn: #ffb340;
|
||||
--bad: #ff6961;
|
||||
--ok-soft: rgba(50, 215, 75, 0.15);
|
||||
--warn-soft: rgba(255, 179, 64, 0.15);
|
||||
--bad-soft: rgba(255, 105, 97, 0.15);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #000000; --surface: #1c1c1e; --surface-2: #232326; --side: rgba(28, 28, 30, 0.8);
|
||||
--text: #f5f5f7; --text-2: #a1a1a6; --text-3: #7d7d82; --line: rgba(255, 255, 255, 0.1);
|
||||
--accent: #f59e0b; --accent-ink: #1f1300; --accent-soft: rgba(245, 158, 11, 0.15);
|
||||
--signal: #22d3ee; --signal-soft: rgba(34, 211, 238, 0.14);
|
||||
--ok: #32d74b; --warn: #ffb340; --bad: #ff6961;
|
||||
--ok-soft: rgba(50, 215, 75, 0.15); --warn-soft: rgba(255, 179, 64, 0.15); --bad-soft: rgba(255, 105, 97, 0.15);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
:root { color-scheme: light dark; }
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 15px/1.45 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
button { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 3px solid color-mix(in srgb, var(--signal) 55%, transparent); outline-offset: 2px; border-radius: 8px; }
|
||||
h1, h2, h3 { margin: 0; letter-spacing: -0.02em; }
|
||||
code, .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ------------------------------------------------------------ layout */
|
||||
.app { display: grid; grid-template-columns: var(--side-w) 1fr; height: 100vh; height: 100dvh; }
|
||||
.sidebar {
|
||||
background: var(--side);
|
||||
backdrop-filter: saturate(180%) blur(24px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(24px);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; min-height: 0; overflow-y: auto;
|
||||
padding: 18px 12px 12px;
|
||||
}
|
||||
.brand { display: flex; gap: 10px; align-items: center; padding: 4px 10px 20px; }
|
||||
.brand img { width: 34px; height: 34px; }
|
||||
.brand strong { display: block; font-size: 17px; letter-spacing: -0.02em; }
|
||||
.brand small { color: var(--text-2); font-size: 12px; }
|
||||
|
||||
.side-title { padding: 14px 10px 6px; font-size: 11px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .07em; }
|
||||
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
|
||||
.list:empty::after { content: attr(data-empty); display: block; padding: 6px 10px; color: var(--text-3); font-size: 13px; }
|
||||
.dev {
|
||||
width: 100%; text-align: left; border: 0; background: transparent; cursor: pointer;
|
||||
display: grid; grid-template-columns: 34px 1fr; gap: 10px; align-items: center;
|
||||
padding: 9px 10px; border-radius: 12px; transition: background .15s;
|
||||
}
|
||||
.dev:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||
.dev[aria-current="true"] { background: var(--accent-soft); }
|
||||
.dev .ico { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface); box-shadow: 0 0 0 1px var(--line); color: var(--text-2); }
|
||||
.dev[aria-current="true"] .ico { color: var(--accent); }
|
||||
.dev .t { display: block; font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dev .s { display: block; font-size: 11.5px; color: var(--text-2); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 14px 10px 4px; font-size: 11.5px; color: var(--text-3); display: flex; justify-content: space-between; gap: 8px; }
|
||||
.side-foot button { background: transparent; border: 0; color: var(--text-3); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
||||
.side-foot button:hover { color: var(--text-2); }
|
||||
|
||||
.main { overflow-y: auto; min-width: 0; }
|
||||
.page { max-width: 1040px; margin: 0 auto; padding: 30px 36px 100px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* -------------------------------------------------------- topo + linha de sinal */
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.topbar h1 { font-size: 21px; }
|
||||
.topbar .sub { color: var(--text-2); font-size: 13px; margin-top: 2px; }
|
||||
.wave { width: 100%; height: 34px; margin: 18px 0 26px; }
|
||||
.wave path { fill: none; stroke: var(--line); stroke-width: 1.6; }
|
||||
.wave path.live { stroke: var(--signal); opacity: 0; transition: opacity .25s; }
|
||||
.wave.scanning path.live { opacity: 1; }
|
||||
.wave.scanning path.flat { opacity: 0; }
|
||||
|
||||
/* ------------------------------------------------------------ aviso */
|
||||
.notice { display: flex; gap: 10px; background: var(--surface); border-radius: 12px; padding: 12px 14px; box-shadow: var(--shadow); font-size: 13px; color: var(--text-2); margin-bottom: 20px; align-items: flex-start; }
|
||||
.notice svg { flex: none; margin-top: 2px; color: var(--warn); }
|
||||
|
||||
/* ------------------------------------------------------------ controles */
|
||||
.controls { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 22px; }
|
||||
.controls .group { display: flex; align-items: center; gap: 8px; }
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text-2); }
|
||||
.pill.on { background: var(--signal-soft); color: var(--signal); }
|
||||
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.pill.on .dot { animation: pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
|
||||
.btn { border: 0; border-radius: 999px; padding: 9px 18px; font-weight: 600; font-size: 13.5px; cursor: pointer; background: color-mix(in srgb, var(--text) 9%, transparent); transition: transform .12s, filter .15s, background .15s; display: inline-flex; gap: 8px; align-items: center; }
|
||||
.btn:hover { background: color-mix(in srgb, var(--text) 14%, transparent); }
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--accent); color: var(--accent-ink); }
|
||||
.btn.primary:hover { filter: brightness(1.06); }
|
||||
.btn.live { background: var(--bad); color: #fff; }
|
||||
.btn.live:hover { filter: brightness(1.06); }
|
||||
|
||||
/* ------------------------------------------------------------ tabela */
|
||||
.result-head { display: flex; align-items: baseline; justify-content: space-between; margin: 4px 2px 10px; }
|
||||
.result-head strong { font-size: 15px; }
|
||||
.result-head span { color: var(--text-3); font-size: 12.5px; }
|
||||
|
||||
.ap-card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; padding: 11px 14px; font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; border-bottom: 1px solid var(--line); }
|
||||
tbody td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: var(--surface-2); }
|
||||
td.bssid { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
|
||||
td.ssid { font-weight: 600; }
|
||||
td.ssid em { color: var(--text-3); font-weight: 400; font-style: normal; }
|
||||
.sec-tag { font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
|
||||
.sec-aberta { background: var(--bad-soft); color: var(--bad); }
|
||||
.sec-wep { background: var(--warn-soft); color: var(--warn); }
|
||||
.sec-wpa, .sec-wpa2, .sec-wpa3 { background: var(--ok-soft); color: var(--ok); }
|
||||
.signal-bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 14px; vertical-align: middle; margin-right: 6px; }
|
||||
.signal-bars i { width: 3px; background: var(--line); border-radius: 1px; display: block; }
|
||||
.signal-bars i.on { background: var(--signal); }
|
||||
|
||||
.empty-state { text-align: center; padding: 70px 20px; color: var(--text-2); }
|
||||
.empty-state svg { color: var(--text-3); margin-bottom: 14px; }
|
||||
.empty-state h2 { font-size: 17px; color: var(--text); margin-bottom: 6px; }
|
||||
.empty-state p { font-size: 13.5px; max-width: 360px; margin: 0 auto; }
|
||||
|
||||
/* ------------------------------------------------------------ responsivo */
|
||||
@media (max-width: 760px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
|
||||
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); flex-direction: row; align-items: center; overflow-x: auto; }
|
||||
.brand { padding: 4px 10px; }
|
||||
.side-title, .side-foot { display: none; }
|
||||
.list { display: flex; }
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const tauri = window.__TAURI__;
|
||||
|
||||
const els = {
|
||||
list: document.getElementById('ifaceList'),
|
||||
ifaceSub: document.getElementById('ifaceSub'),
|
||||
modePill: document.getElementById('modePill'),
|
||||
modeLabel: document.getElementById('modeLabel'),
|
||||
btnMonitor: document.getElementById('btnMonitor'),
|
||||
btnScan: document.getElementById('btnScan'),
|
||||
resultArea: document.getElementById('resultArea'),
|
||||
count: document.getElementById('count'),
|
||||
wave: document.getElementById('wave'),
|
||||
verInfo: document.getElementById('verInfo'),
|
||||
btnUpdate: document.getElementById('btnUpdate'),
|
||||
};
|
||||
|
||||
let ifaces = [];
|
||||
let current = null;
|
||||
let scanning = false;
|
||||
|
||||
const ICON_WIFI = `<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
|
||||
|
||||
const ICON_EMPTY = `<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg>`;
|
||||
|
||||
async function api(path, opts) {
|
||||
if (tauri) return tauriApi(path);
|
||||
const url = new URL(path, location.origin);
|
||||
url.searchParams.set('token', token);
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// No app desktop não existe servidor HTTP: as mesmas rotas viram chamadas
|
||||
// IPC diretas pro núcleo, pelos comandos registrados em src-tauri/src/main.rs.
|
||||
async function tauriApi(path) {
|
||||
const [p, qs] = path.split('?');
|
||||
const q = new URLSearchParams(qs || '');
|
||||
const invoke = tauri.core.invoke;
|
||||
try {
|
||||
switch (p) {
|
||||
case '/api/version':
|
||||
return { version: await invoke('version') };
|
||||
case '/api/ifaces':
|
||||
return await invoke('list_ifaces');
|
||||
case '/api/monitor':
|
||||
await invoke('set_monitor', { iface: q.get('iface'), enable: q.get('enable') === 'true' });
|
||||
return { ok: true };
|
||||
case '/api/scan/start':
|
||||
await invoke('start_scan', { iface: q.get('iface') });
|
||||
return { ok: true };
|
||||
case '/api/scan/stop':
|
||||
await invoke('stop_scan', { iface: q.get('iface') });
|
||||
return { ok: true };
|
||||
case '/api/scan':
|
||||
return await invoke('scan_results', { iface: q.get('iface') });
|
||||
case '/api/update/check':
|
||||
return await invoke('update_check');
|
||||
default:
|
||||
return { error: 'rota desconhecida' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmptyResult(message, hint) {
|
||||
els.resultArea.innerHTML = `<div class="ap-card empty-state">${ICON_EMPTY}<h2>${message}</h2><p>${hint}</p></div>`;
|
||||
}
|
||||
|
||||
function renderIfaceList() {
|
||||
els.list.innerHTML = '';
|
||||
for (const i of ifaces) {
|
||||
const li = document.createElement('li');
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'dev';
|
||||
btn.setAttribute('aria-current', String(i.name === current));
|
||||
btn.innerHTML = `<span class="ico">${ICON_WIFI}</span><span><span class="t">${i.name}</span><span class="s">${i.mac}</span></span>`;
|
||||
btn.addEventListener('click', () => selectIface(i.name));
|
||||
li.appendChild(btn);
|
||||
els.list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function selectIface(name) {
|
||||
current = name;
|
||||
scanning = false;
|
||||
renderIfaceList();
|
||||
refreshModeUi();
|
||||
renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.');
|
||||
}
|
||||
|
||||
function refreshModeUi() {
|
||||
const iface = ifaces.find((i) => i.name === current);
|
||||
const on = !!iface && iface.mode === 'Monitor';
|
||||
els.ifaceSub.textContent = current ? `${current} · ${iface?.mac ?? ''}` : 'selecione uma interface';
|
||||
els.modeLabel.textContent = on ? 'modo monitor' : 'modo gerenciado';
|
||||
els.modePill.className = 'pill' + (on ? ' on' : '');
|
||||
els.btnMonitor.disabled = !current;
|
||||
els.btnMonitor.textContent = on ? 'desativar modo monitor' : 'ativar modo monitor';
|
||||
els.btnScan.disabled = !on;
|
||||
}
|
||||
|
||||
async function loadIfaces() {
|
||||
ifaces = await api('/api/ifaces');
|
||||
if (!current && ifaces.length) current = ifaces[0].name;
|
||||
renderIfaceList();
|
||||
refreshModeUi();
|
||||
if (current) renderEmptyResult('Pronto pra capturar', 'Ative o modo monitor e inicie a captura pra ver as redes no ar.');
|
||||
else renderEmptyResult('Nenhuma interface Wi-Fi', 'Conecte um adaptador compatível com modo monitor e recarregue.');
|
||||
}
|
||||
|
||||
els.btnMonitor.addEventListener('click', async () => {
|
||||
const iface = ifaces.find((i) => i.name === current);
|
||||
const enable = !(iface && iface.mode === 'Monitor');
|
||||
els.btnMonitor.disabled = true;
|
||||
const res = await api(`/api/monitor?iface=${encodeURIComponent(current)}&enable=${enable}`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
alert('Não foi possível trocar o modo: ' + (res.error || 'erro desconhecido'));
|
||||
}
|
||||
await loadIfaces();
|
||||
});
|
||||
|
||||
els.btnScan.addEventListener('click', async () => {
|
||||
if (!scanning) {
|
||||
await api(`/api/scan/start?iface=${encodeURIComponent(current)}`, { method: 'POST' });
|
||||
scanning = true;
|
||||
els.btnScan.textContent = 'parar captura';
|
||||
els.btnScan.classList.add('live');
|
||||
els.wave.classList.add('scanning');
|
||||
} else {
|
||||
await api(`/api/scan/stop?iface=${encodeURIComponent(current)}`, { method: 'POST' });
|
||||
scanning = false;
|
||||
els.btnScan.textContent = 'iniciar captura';
|
||||
els.btnScan.classList.remove('live');
|
||||
els.wave.classList.remove('scanning');
|
||||
}
|
||||
});
|
||||
|
||||
els.btnUpdate.addEventListener('click', async () => {
|
||||
els.btnUpdate.textContent = 'checando…';
|
||||
const status = await api('/api/update/check');
|
||||
els.btnUpdate.textContent = status.update_available
|
||||
? `atualizar para ${status.latest_version}`
|
||||
: 'já atualizado';
|
||||
});
|
||||
|
||||
function secClass(sec) {
|
||||
if (!sec) return '';
|
||||
return 'sec-' + sec.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
}
|
||||
|
||||
function signalBars(dbm) {
|
||||
// dBm tipicamente entre -30 (ótimo) e -90 (péssimo)
|
||||
let level = 0;
|
||||
if (dbm != null) {
|
||||
if (dbm >= -55) level = 4;
|
||||
else if (dbm >= -65) level = 3;
|
||||
else if (dbm >= -75) level = 2;
|
||||
else level = 1;
|
||||
}
|
||||
const heights = [5, 8, 11, 14];
|
||||
let bars = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
bars += `<i class="${i < level ? 'on' : ''}" style="height:${heights[i]}px"></i>`;
|
||||
}
|
||||
return `<span class="signal-bars">${bars}</span>`;
|
||||
}
|
||||
|
||||
function renderResults(aps) {
|
||||
els.count.textContent = aps.length;
|
||||
if (!aps.length) {
|
||||
renderEmptyResult('Escutando o ar…', 'Nenhuma rede vista ainda. Isso pode levar alguns segundos enquanto os canais são varridos.');
|
||||
return;
|
||||
}
|
||||
const rows = aps.map((ap) => `
|
||||
<tr>
|
||||
<td class="ssid">${ap.ssid ? escapeHtml(ap.ssid) : '<em>oculta</em>'}</td>
|
||||
<td class="bssid">${ap.bssid}</td>
|
||||
<td>${ap.channel ?? '—'}</td>
|
||||
<td>${signalBars(ap.last_signal_dbm)}${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}</td>
|
||||
<td><span class="sec-tag ${secClass(ap.security)}">${ap.security || '—'}</span></td>
|
||||
<td>${ap.clients.length}</td>
|
||||
</tr>`).join('');
|
||||
els.resultArea.innerHTML = `
|
||||
<div class="ap-card">
|
||||
<table>
|
||||
<thead><tr><th>Rede</th><th>BSSID</th><th>Canal</th><th>Sinal</th><th>Segurança</th><th>Clientes</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
let waveT = 0;
|
||||
function animateWave() {
|
||||
waveT += 0.12;
|
||||
if (scanning) {
|
||||
let d = 'M0 17 ';
|
||||
for (let x = 0; x <= 600; x += 12) {
|
||||
const y = 17 + Math.sin(x * 0.045 + waveT) * 9 * Math.sin(waveT * 0.6 + x * 0.002);
|
||||
d += `L${x} ${y.toFixed(1)} `;
|
||||
}
|
||||
document.getElementById('waveLive').setAttribute('d', d);
|
||||
}
|
||||
requestAnimationFrame(animateWave);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (scanning && current) {
|
||||
const aps = await api(`/api/scan?iface=${encodeURIComponent(current)}`);
|
||||
renderResults(aps);
|
||||
}
|
||||
setTimeout(poll, 1000);
|
||||
}
|
||||
|
||||
async function checkElevation() {
|
||||
if (!tauri) return; // via `sabia ui` a elevação é responsabilidade do terminal (sudo)
|
||||
const notice = document.getElementById('elevateNotice');
|
||||
const msg = document.getElementById('elevateMsg');
|
||||
const retry = document.getElementById('btnElevateRetry');
|
||||
const elevated = await tauri.core.invoke('is_elevated').catch(() => true);
|
||||
if (elevated) return;
|
||||
|
||||
notice.hidden = false;
|
||||
msg.textContent = 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…';
|
||||
retry.hidden = true;
|
||||
|
||||
retry.addEventListener('click', async () => {
|
||||
msg.textContent = 'Pedindo privilégio de administrador — necessário pra modo monitor e captura…';
|
||||
retry.hidden = true;
|
||||
await tauri.core.invoke('relaunch_elevated').catch((e) => {
|
||||
msg.textContent = String(e);
|
||||
retry.hidden = false;
|
||||
});
|
||||
});
|
||||
|
||||
tauri.event?.listen?.('elevate-failed', (e) => {
|
||||
msg.textContent = String(e.payload || 'Não foi possível obter privilégio de administrador.');
|
||||
retry.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
checkElevation();
|
||||
const status = await api('/api/version');
|
||||
els.verInfo.textContent = `v${status.version}`;
|
||||
await loadIfaces();
|
||||
animateWave();
|
||||
poll();
|
||||
})();
|
||||
@@ -0,0 +1,76 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sabiá</title>
|
||||
<link rel="icon" href="/sabia-logo.svg">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/sabia-logo.svg" alt="">
|
||||
<div>
|
||||
<strong>Sabiá</strong>
|
||||
<small>auditoria de Wi-Fi</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-title">Interfaces</div>
|
||||
<ul class="list" id="ifaceList" data-empty="nenhuma interface Wi-Fi encontrada"></ul>
|
||||
|
||||
<div class="side-foot">
|
||||
<span id="verInfo" class="mono">—</span>
|
||||
<button id="btnUpdate" type="button">checar atualização</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="page">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>Painel de captura</h1>
|
||||
<div class="sub" id="ifaceSub">selecione uma interface</div>
|
||||
</div>
|
||||
<span id="modePill" class="pill"><span class="dot"></span><span id="modeLabel">—</span></span>
|
||||
</div>
|
||||
|
||||
<svg class="wave" id="wave" viewBox="0 0 600 34" preserveAspectRatio="none">
|
||||
<path class="flat" d="M0 17 L600 17"/>
|
||||
<path class="live" id="waveLive" d="M0 17 L600 17"/>
|
||||
</svg>
|
||||
|
||||
<div class="notice" id="elevateNotice" hidden>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
<span id="elevateMsg">Pedindo privilégio de administrador — necessário pra modo monitor e captura…</span>
|
||||
<button id="btnElevateRetry" class="btn" style="margin-left:auto" hidden>tentar de novo</button>
|
||||
</div>
|
||||
|
||||
<div class="notice">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
<span>Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura fica registrada na trilha de auditoria local.</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="group">
|
||||
<button id="btnMonitor" class="btn" disabled>ativar modo monitor</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<button id="btnScan" class="btn primary" disabled>iniciar captura</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-head">
|
||||
<strong>Redes vistas</strong>
|
||||
<span id="count">0</span>
|
||||
</div>
|
||||
|
||||
<div id="resultArea"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Sabiá"><style>.a{stroke:#b45309}.b{stroke:#0891b2}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#f59e0b}.b{stroke:#22d3ee}.t{fill:#f5f5f7}}</style><defs><clipPath id="s-bird"><path d="M78 14 C70 8 60 6 52 10 C46 6 38 6 32 10 C20 16 12 28 12 42 C12 54 18 64 28 70 C26 76 28 82 34 85 C33 80 35 76 39 74 C46 78 55 79 63 76 C73 72 80 62 81 50 C88 48 93 42 94 34 C87 36 81 34 77 29 C81 25 83 19 78 14 Z"/></clipPath></defs><g clip-path="url(#s-bird)" fill="none" stroke-width="3.6"><path class="a" d="M78 14A6 6 0 0 1 78 26"/><path class="b" d="M78 14A6 6 0 0 0 78 26" stroke-dasharray="3 3"/><path class="a" d="M78 7A13 13 0 0 1 78 33"/><path class="b" d="M78 7A13 13 0 0 0 78 33" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M78 0A20 20 0 0 1 78 40"/><path class="b" d="M78 0A20 20 0 0 0 78 40" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M78 -7A27 27 0 0 1 78 47"/><path class="b" d="M78 -7A27 27 0 0 0 78 47" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M78 -14A34 34 0 0 1 78 54"/><path class="b" d="M78 -14A34 34 0 0 0 78 54" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M78 -21A41 41 0 0 1 78 61"/><path class="b" d="M78 -21A41 41 0 0 0 78 61" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M78 -28A48 48 0 0 1 78 68"/><path class="b" d="M78 -28A48 48 0 0 0 78 68" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M78 -35A55 55 0 0 1 78 75"/><path class="b" d="M78 -35A55 55 0 0 0 78 75" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M78 -42A62 62 0 0 1 78 82"/><path class="b" d="M78 -42A62 62 0 0 0 78 82" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M78 -49A69 69 0 0 1 78 89"/><path class="b" d="M78 -49A69 69 0 0 0 78 89" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M78 -56A76 76 0 0 1 78 96"/><path class="b" d="M78 -56A76 76 0 0 0 78 96" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M78 -63A83 83 0 0 1 78 103"/><path class="b" d="M78 -63A83 83 0 0 0 78 103" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M78 -70A90 90 0 0 1 78 110"/><path class="b" d="M78 -70A90 90 0 0 0 78 110" stroke-dasharray="3 3" stroke-dashoffset="36"/><rect class="t" stroke="none" x="20" y="60" width="64" height="40"/></g><circle class="t" cx="40" cy="36" r="2.6"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
Reference in new issue
Block a user