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:
Lucas committed 2026-10-23 08:56:08 -03:00
1 parent fd3ef5240e
commit df27974359
29 files changed
+1268 -191

No files matched your search

+28
View File
@@ -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
+13
View File
@@ -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

+190
View File
@@ -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á");
}
+39
View File
@@ -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>