The window used to be visible from launch, so the unprivileged instance's normal window would show, flash while pkexec/osascript asked for the admin password, and only then get replaced by the elevated relaunch's window once it killed this one ~1.2s later — the same mechanism Sabiá already uses under the hood, but with Sabiá's window visible throughout too; Guará's heavier initial UI made the unprivileged flash far more noticeable. tauri.conf.json now starts the window with "visible": false. main.rs shows it explicitly in exactly one of three cases: already elevated (or non-unix, where the OS-level UAC manifest already elevated the process before it started), the relaunch failed and the user needs to see the warning, or this is the already-elevated relaunched instance itself. Only one window is ever shown to the user now. Also fixes a latent bug this surfaced: do_relaunch_elevated only emitted "elevate-failed" from the child-process-exited-nonzero path. If it fails earlier (pkexec/osascript missing, exe path unresolved, log file unwritable) it returns Err directly without emitting anything — with the window now starting hidden, that would have left it hidden forever with no feedback. The setup() closure now emits the same event on that early-Err path too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
317 lines
12 KiB
Rust
317 lines
12 KiB
Rust
// Prevents an extra console window on Windows in release builds.
|
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
use guara_core::service::Engine;
|
|
use std::sync::Arc;
|
|
|
|
struct State(Arc<Engine>);
|
|
|
|
/// Single bridge between the UI and the core (same methods as `guara ui` mode).
|
|
#[tauri::command]
|
|
async fn rpc(
|
|
state: tauri::State<'_, State>,
|
|
method: String,
|
|
params: serde_json::Value,
|
|
) -> Result<serde_json::Value, String> {
|
|
let eng = state.0.clone();
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
eng.rpc(&method, params).map_err(|e| e.to_string())
|
|
})
|
|
.await
|
|
.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` when the process already has sufficient privileges to read raw disks.
|
|
/// On Windows the manifest already requires administrator privileges before launch,
|
|
/// so this is always `true` here.
|
|
#[tauri::command]
|
|
fn is_elevated() -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
unsafe { libc::geteuid() == 0 }
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Relaunches the app with administrator privileges (Linux/macOS). On Windows the
|
|
/// manifest already enforces elevation. The new instance terminates this one on startup; if
|
|
/// authorization fails, this window stays open and the UI displays the reason.
|
|
#[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 Guará 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 cfg = guara_core::settings::config_dir();
|
|
let log = std::env::temp_dir().join(format!("guara-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!("GUARA_CONFIG_DIR={}", cfg.display()));
|
|
c.arg(format!("GUARA_REPLACE_PID={pid}"));
|
|
c.arg(&exe);
|
|
c
|
|
};
|
|
#[cfg(target_os = "macos")]
|
|
let mut cmd = {
|
|
let shell = format!(
|
|
"GUARA_CONFIG_DIR='{}' GUARA_REPLACE_PID={} '{}' >/dev/null 2>&1 &",
|
|
cfg.display(),
|
|
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 || {
|
|
// On Linux, pkexec only exits with success when the elevated app closes (and the elevated
|
|
// app will have already terminated this process). Any failure before that becomes a warning.
|
|
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(())
|
|
}
|
|
|
|
/// Executable path accessible to root. Inside an AppImage, `current_exe` points
|
|
/// inside a user FUSE mount that root cannot access: use the
|
|
/// `.AppImage` file itself (`APPIMAGE` environment variable).
|
|
#[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())
|
|
}
|
|
|
|
/// Serves recovered files for preview (`guara://localhost/file/<job>/<id>`),
|
|
/// with Range support for video/audio/PDF, reading directly from the source.
|
|
fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http::Response<Vec<u8>> {
|
|
use tauri::http::Response;
|
|
if let Some(rest) = req.uri().path().strip_prefix("/thumb/") {
|
|
let mut it = rest.split('/');
|
|
let (job, id) = (
|
|
it.next().unwrap_or(""),
|
|
it.next().and_then(|s| s.parse::<u64>().ok()),
|
|
);
|
|
let size = req
|
|
.uri()
|
|
.query()
|
|
.and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s=")))
|
|
.and_then(|v| v.parse::<u32>().ok())
|
|
.unwrap_or(256);
|
|
let err = |code: u16| {
|
|
Response::builder()
|
|
.status(code)
|
|
.header("Access-Control-Allow-Origin", "*")
|
|
.body(Vec::new())
|
|
.unwrap()
|
|
};
|
|
let Some(id) = id else { return err(400) };
|
|
return match eng.thumb(job, id, size) {
|
|
Ok(b) => Response::builder()
|
|
.status(200)
|
|
.header("Content-Type", "image/jpeg")
|
|
.header("Cache-Control", "private, max-age=3600")
|
|
.header("Access-Control-Allow-Origin", "*")
|
|
.body(b.as_ref().clone())
|
|
.unwrap_or_else(|_| err(500)),
|
|
Err(code) => err(code),
|
|
};
|
|
}
|
|
const CAP: u64 = 8 * 1024 * 1024;
|
|
let mut it = req.uri().path().trim_start_matches("/file/").split('/');
|
|
let (job, id) = (
|
|
it.next().unwrap_or(""),
|
|
it.next().and_then(|s| s.parse::<u64>().ok()),
|
|
);
|
|
let err = |code: u16| {
|
|
Response::builder()
|
|
.status(code)
|
|
.header("Access-Control-Allow-Origin", "*")
|
|
.body(Vec::new())
|
|
.unwrap()
|
|
};
|
|
let Some(id) = id else { return err(400) };
|
|
let range = req.headers().get("range").and_then(|v| v.to_str().ok());
|
|
let slice = match eng.file_slice(job, id, range) {
|
|
Ok(s) => s,
|
|
Err(code) => return err(code),
|
|
};
|
|
let total = slice.total;
|
|
let start = slice.start;
|
|
let want = slice.len();
|
|
let mut headers = slice.headers();
|
|
let partial = slice.partial || want > CAP;
|
|
let Ok(data) = slice.read_all(CAP) else {
|
|
return err(500);
|
|
};
|
|
let mut b = Response::builder().status(if partial { 206 } else { 200 });
|
|
headers.retain(|(k, _)| *k != "Content-Range");
|
|
for (k, v) in &headers {
|
|
b = b.header(*k, v);
|
|
}
|
|
if partial && !data.is_empty() {
|
|
b = b.header(
|
|
"Content-Range",
|
|
format!(
|
|
"bytes {}-{}/{}",
|
|
start,
|
|
start + data.len() as u64 - 1,
|
|
total
|
|
),
|
|
);
|
|
}
|
|
b.body(data).unwrap_or_else(|_| err(500))
|
|
}
|
|
|
|
fn main() {
|
|
let engine = Arc::new(Engine::new());
|
|
let for_protocol = engine.clone();
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.manage(State(engine))
|
|
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
|
|
let eng = for_protocol.clone();
|
|
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
|
|
})
|
|
.setup(|app| {
|
|
use tauri::{Listener, Manager};
|
|
// When launched elevated by the unprivileged instance, terminate the old process.
|
|
#[cfg(unix)]
|
|
if let Some(pid) = std::env::var("GUARA_REPLACE_PID")
|
|
.ok()
|
|
.and_then(|p| p.parse::<i32>().ok())
|
|
{
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(std::time::Duration::from_millis(1200));
|
|
// SAFETY: only sends SIGTERM to the previous process owned by the invoking user.
|
|
unsafe {
|
|
if libc::geteuid() == 0 {
|
|
libc::kill(pid, libc::SIGTERM);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// The window starts hidden (tauri.conf.json: "visible": false) so the
|
|
// unprivileged instance never flashes on screen before being replaced.
|
|
// Reading raw disks requires root: request elevation as soon as the app
|
|
// launches, without waiting for the user to look for a button, and only
|
|
// show a window once we know which one it is — the elevated relaunch's,
|
|
// or this same one if elevation turned out not to be needed/possible.
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
{
|
|
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
|
|
use tauri::Emitter;
|
|
let handle = app.handle().clone();
|
|
let handle_for_failure = app.handle().clone();
|
|
std::thread::spawn(move || {
|
|
// do_relaunch_elevated only emits "elevate-failed" itself once the
|
|
// child process has actually spawned and later exits non-zero. If it
|
|
// fails earlier (pkexec/osascript missing, exe path unresolved, log
|
|
// file unwritable), it returns Err directly without emitting anything
|
|
// — emit it here too, or the hidden window would never reappear.
|
|
if let Err(e) = do_relaunch_elevated(handle) {
|
|
let _ = handle_for_failure.emit("elevate-failed", e);
|
|
}
|
|
});
|
|
// Whichever path emits it, make sure the window comes back so the
|
|
// user sees the warning instead of a silently stuck, invisible app.
|
|
if let Some(win) = app.get_webview_window("main") {
|
|
app.handle().listen("elevate-failed", move |_| {
|
|
let _ = win.show();
|
|
});
|
|
}
|
|
} else if let Some(win) = app.get_webview_window("main") {
|
|
let _ = win.show();
|
|
}
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
|
if let Some(win) = app.get_webview_window("main") {
|
|
let _ = win.show();
|
|
}
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
rpc,
|
|
is_elevated,
|
|
relaunch_elevated,
|
|
update_check,
|
|
update_apply
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("erro ao iniciar o Guará");
|
|
}
|