feat: pré-visualização completa, tema, nova identidade e correções (0.2.0)
Release / Cria a release (push) Canceled after 0s
Release / CLI (linux-aarch64) (push) Canceled after 0s
Release / CLI (linux-x86_64) (push) Canceled after 0s
Release / CLI (macos-aarch64) (push) Canceled after 0s
Release / CLI (macos-x86_64) (push) Canceled after 0s
Release / CLI (windows-x86_64) (push) Canceled after 0s
Release / App desktop (linux-x86_64) (push) Canceled after 0s
Release / App desktop (macos-aarch64) (push) Canceled after 0s
Release / App desktop (macos-x86_64) (push) Canceled after 0s
Release / App desktop (windows-x86_64) (push) Canceled after 0s
Release / Publica a release (push) Canceled after 0s

- Preview de imagem, vídeo/MP4, áudio, PDF (pdf.js), texto, Office/ODF, ZIP e hex,
  lido sob demanda com Range; tela cheia com navegação por teclado.
- Seletor de tema Auto/Claro/Escuro persistido (vale também como administrador).
- Identidade verde-azulada com logo de pixels que formam um lobo.
- Corrige reinício como administrador no AppImage (usa $APPIMAGE) e mostra o motivo.
- Corrige barra inferior piscando (render idempotente, barra criada uma vez).
- Corrige FAT32 em mídias pequenas não reconhecido.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-18 15:34:01 -03:00
1 parent 510f1bc48e
commit c5c94a9956
32 files changed
+1666 -227

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ pub mod partition;
pub mod recover;
pub mod scan;
pub mod service;
pub mod settings;
pub mod signatures;
pub mod source;
pub mod types;
+8 -9
View File
@@ -92,17 +92,16 @@ fn detect_fat(b: &[u8]) -> Option<(FsKind, Option<u64>)> {
let root_secs = (root_entries * 32).div_ceil(bps);
let data = total.saturating_sub(reserved + nfats * fatsz + root_secs);
let clusters = data / spc;
let kind = if clusters < 4085 {
FsKind::Fat12
} else if clusters < 65525 {
FsKind::Fat16
} else {
// O layout do BPB manda: raiz fixa zerada + FAT de 32 bits = FAT32, mesmo com poucos
// clusters (mkfs.vfat -F 32 em mídias pequenas gera isso e o Linux monta).
let is32 = root_entries == 0 && fat16 == 0 && fat32 != 0;
let kind = if is32 {
FsKind::Fat32
} else if clusters < 4085 {
FsKind::Fat12
} else {
FsKind::Fat16
};
// Coerência: FAT32 tem root_entries == 0 e fat16 == 0.
if (kind == FsKind::Fat32) != (root_entries == 0 && fat16 == 0) {
return None;
}
Some((kind, Some(total * bps)))
}
+39
View File
@@ -210,3 +210,42 @@ pub fn write_file(src: &dyn Source, f: &Found, path: &Path) -> Result<String> {
.map(|b| format!("{b:02x}"))
.collect())
}
/// Lê `buf.len()` bytes do conteúdo lógico de um achado a partir de `offset`
/// (usado para streaming/pré-visualização sem gravar em disco).
pub fn read_found_at(
src: &dyn Source,
f: &Found,
offset: u64,
buf: &mut [u8],
) -> std::io::Result<usize> {
if offset >= f.size || buf.is_empty() {
return Ok(0);
}
let want = buf.len().min((f.size - offset) as usize);
match &f.data {
DataLoc::Resident { bytes } => {
let o = offset as usize;
let n = want.min(bytes.len().saturating_sub(o));
buf[..n].copy_from_slice(&bytes[o..o + n]);
Ok(n)
}
DataLoc::Extents { extents } => {
let mut pos = 0u64;
for e in extents {
let elen = e.len.min(f.size.saturating_sub(pos));
if offset < pos + elen {
let within = offset - pos;
let n = want.min((elen - within) as usize);
if e.sparse {
buf[..n].fill(0);
return Ok(n);
}
return src.read_at(e.offset + within, &mut buf[..n]);
}
pos += elen;
}
Ok(0)
}
}
}
+189 -36
View File
@@ -278,37 +278,18 @@ impl Engine {
Ok(json!({ "total": total, "items": page }))
}
pub fn preview(&self, id: &str, file: u64) -> Result<Value> {
/// Achado + origem para leitura posicional (streaming de pré-visualização).
pub fn open_file(&self, id: &str, file: u64) -> Result<(Found, Arc<dyn Source>)> {
let j = self.job(id)?;
let (f, src) = {
let s = j.st.lock().unwrap();
let i = *s
.index
.get(&file)
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
(
s.found[i].clone(),
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
)
};
let mime = match f.ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
_ => return Ok(json!({ "mime": null })),
};
if f.size > 8 * 1024 * 1024 {
return Ok(json!({ "mime": null, "reason": "grande demais para pré-visualizar" }));
}
let tmp =
std::env::temp_dir().join(format!("guara-preview-{}-{}", std::process::id(), f.id));
let r = recover::write_file(src.as_ref(), &f, &tmp);
let bytes = std::fs::read(&tmp);
let _ = std::fs::remove_file(&tmp);
r?;
Ok(json!({ "mime": mime, "base64": base64(&bytes?) }))
let s = j.st.lock().unwrap();
let i = *s
.index
.get(&file)
.ok_or_else(|| Error::msg("arquivo não encontrado"))?;
Ok((
s.found[i].clone(),
j.src.clone().ok_or_else(|| Error::msg("sem origem"))?,
))
}
// ---------------------------------------------------------------- recuperação
@@ -534,12 +515,12 @@ impl Engine {
.unwrap_or_default();
self.results(&s("job")?, &q)?
}
"preview" => self.preview(
&s("job")?,
p.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| Error::msg("id ausente"))?,
)?,
"settings_get" => json!(crate::settings::load()),
"settings_set" => {
let st: crate::settings::Settings = serde_json::from_value(p.clone())?;
crate::settings::save(&st)?;
json!({})
}
"recover_start" => {
let ids: Option<Vec<u64>> = p
.get("ids")
@@ -697,3 +678,175 @@ pub fn base64(data: &[u8]) -> String {
}
out
}
/// Tipo MIME para servir a pré-visualização. Formatos que poderiam executar
/// script (HTML/SVG/XML) saem como texto puro.
pub fn mime_for(ext: &str) -> &'static str {
match ext.to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"webm" => "video/webm",
"mkv" => "video/x-matroska",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" | "opus" => "audio/ogg",
"m4a" => "audio/mp4",
"aac" => "audio/aac",
"flac" => "audio/flac",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
/// Trecho de um arquivo achado, pronto para responder a uma requisição HTTP
/// (com suporte a `Range`) tanto no modo `guara ui` quanto no app Tauri.
pub struct FileSlice {
found: Found,
src: Arc<dyn Source>,
pub start: u64,
/// Inclusivo.
pub end: u64,
pub total: u64,
pub partial: bool,
}
impl FileSlice {
pub fn len(&self) -> u64 {
if self.total == 0 {
0
} else {
self.end - self.start + 1
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn status(&self) -> u16 {
if self.partial {
206
} else {
200
}
}
pub fn headers(&self) -> Vec<(&'static str, String)> {
let mut h = vec![
("Content-Type", mime_for(&self.found.ext).to_string()),
("Accept-Ranges", "bytes".into()),
("Cache-Control", "no-store".into()),
("X-Content-Type-Options", "nosniff".into()),
// Conteúdo recuperado é não confiável: nunca executa script nem carrega nada.
(
"Content-Security-Policy",
"sandbox; default-src 'none'".into(),
),
("Access-Control-Allow-Origin", "*".into()),
("Content-Disposition", "inline".into()),
];
if self.partial {
h.push((
"Content-Range",
format!("bytes {}-{}/{}", self.start, self.end, self.total),
));
}
h
}
/// Lê o trecho inteiro em memória (limitado a `cap` bytes).
pub fn read_all(&self, cap: u64) -> std::io::Result<Vec<u8>> {
let n = self.len().min(cap) as usize;
let mut out = vec![0u8; n];
let mut got = 0;
while got < n {
let r = crate::recover::read_found_at(
self.src.as_ref(),
&self.found,
self.start + got as u64,
&mut out[got..],
)?;
if r == 0 {
break;
}
got += r;
}
out.truncate(got);
Ok(out)
}
pub fn reader(self) -> FileSliceReader {
FileSliceReader {
pos: self.start,
slice: self,
}
}
}
pub struct FileSliceReader {
slice: FileSlice,
pos: u64,
}
impl std::io::Read for FileSliceReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pos > self.slice.end || self.slice.total == 0 {
return Ok(0);
}
let left = (self.slice.end - self.pos + 1) as usize;
let n = buf.len().min(left).min(1 << 20);
let r = crate::recover::read_found_at(
self.slice.src.as_ref(),
&self.slice.found,
self.pos,
&mut buf[..n],
)?;
self.pos += r as u64;
Ok(r)
}
}
impl Engine {
/// Resolve `/file/<job>/<id>` + cabeçalho Range. `Err(status)` em caso de falha.
pub fn file_slice(
&self,
job: &str,
id: u64,
range: Option<&str>,
) -> std::result::Result<FileSlice, u16> {
let (found, src) = self.open_file(job, id).map_err(|_| 404u16)?;
let total = found.size;
let (mut start, mut end, mut partial) = (0u64, total.saturating_sub(1), false);
if let Some(spec) = range.and_then(|r| r.strip_prefix("bytes=")) {
let spec = spec.split(',').next().unwrap_or("");
if let Some((a, b)) = spec.split_once('-') {
let parsed = match (a.trim().parse::<u64>(), b.trim().parse::<u64>()) {
(Ok(a), Ok(b)) => Some((a, b.min(total.saturating_sub(1)))),
(Ok(a), Err(_)) if b.trim().is_empty() => Some((a, total.saturating_sub(1))),
(Err(_), Ok(n)) if a.trim().is_empty() && n > 0 => {
Some((total.saturating_sub(n), total.saturating_sub(1)))
}
_ => None,
};
if let Some((a, b)) = parsed {
if a >= total || a > b {
return Err(416);
}
start = a;
end = b;
partial = true;
}
}
}
Ok(FileSlice {
found,
src,
start,
end,
total,
partial,
})
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Preferências do usuário (tema etc.), compartilhadas entre o app normal e o
//! reiniciado como administrador.
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// "auto", "light" ou "dark".
pub theme: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: "auto".into(),
}
}
}
/// Pasta de configuração do usuário *real*, mesmo quando rodando como root
/// via sudo/pkexec (para o tema não "mudar" ao elevar privilégios).
pub fn config_dir() -> PathBuf {
if let Ok(d) = std::env::var("GUARA_CONFIG_DIR") {
if !d.is_empty() {
return PathBuf::from(d);
}
}
#[cfg(unix)]
{
if let Some(home) = invoking_user_home() {
return default_dir(home);
}
}
default_dir(PathBuf::from(
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default(),
))
}
fn default_dir(home: PathBuf) -> PathBuf {
#[cfg(windows)]
{
if let Ok(a) = std::env::var("APPDATA") {
return PathBuf::from(a).join("Guara");
}
home.join("AppData").join("Roaming").join("Guara")
}
#[cfg(target_os = "macos")]
{
home.join("Library")
.join("Application Support")
.join("Guara")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
match std::env::var("XDG_CONFIG_HOME") {
Ok(x)
if !x.is_empty()
&& std::env::var("SUDO_USER").is_err()
&& std::env::var("PKEXEC_UID").is_err() =>
{
PathBuf::from(x).join("guara")
}
_ => home.join(".config").join("guara"),
}
}
}
/// Se somos root por sudo/pkexec, a home de quem invocou.
#[cfg(unix)]
fn invoking_user_home() -> Option<PathBuf> {
// SAFETY: geteuid não tem pré-condições.
if unsafe { libc::geteuid() } != 0 {
return None;
}
let user = std::env::var("SUDO_USER").ok().filter(|u| u != "root");
let uid = std::env::var("PKEXEC_UID").ok();
let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
for l in passwd.lines() {
let f: Vec<&str> = l.split(':').collect();
if f.len() >= 6 && (user.as_deref() == Some(f[0]) || uid.as_deref() == Some(f[2])) {
return Some(PathBuf::from(f[5]));
}
}
None
}
fn file() -> PathBuf {
config_dir().join("settings.json")
}
pub fn load() -> Settings {
std::fs::read(file())
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
pub fn save(s: &Settings) -> Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir)?;
let path = file();
std::fs::write(&path, serde_json::to_vec_pretty(s)?)?;
#[cfg(unix)]
{
// Como root, devolve a posse ao dono da pasta para o app normal conseguir regravar.
use std::os::unix::fs::MetadataExt;
if unsafe { libc::geteuid() } == 0 {
if let Ok(m) = std::fs::metadata(&dir) {
let _ = std::os::unix::fs::chown(&path, Some(m.uid()), Some(m.gid()));
}
}
}
Ok(())
}