2 Commits
Author SHA1 Message Date
Lucas RayanandClaude Opus 5.5 c3b5f79009 Versão 0.6.0: saúde do disco no estilo do CrystalDiskInfo e nova logo
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
SMART completo (estado com % de vida útil, temperatura, horas e vezes
ligado, total lido/gravado, ficha do dispositivo e tabela de atributos
ATA e NVMe), leitura pelo disco que contém a partição e comando
`guara health`. Nova logo: a cabeça do guará recortada das trilhas de um
disco, nas cores do app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:05:48 -03:00
Lucas RayanandClaude Opus 5.5 f65e7ae615 Nova logo em estilo geométrico facetado
Cabeça do lobo-guará contornada sobre foto de referência, com as regiões de
cor do animal real em tons de verde-azulado e o pescoço se desfazendo em
estilhaços.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:51:44 -03:00
22 changed files with 630 additions and 254 deletions

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [0.6.0] - 2026-10-18
### Adicionado
- **Saúde do disco no estilo do CrystalDiskInfo:** a tela do dispositivo mostra o estado (Bom, Atenção, Ruim) com a % de vida útil restante em SSD, temperatura, horas ligado, vezes ligado, total lido e total gravado. Também traz a ficha do dispositivo (modelo, firmware, nº de série, interface, modo de transferência, capacidade, rotação, formato e recursos como TRIM) e a tabela completa de atributos SMART, com ID, valor atual, pior, limite e bruto, e um indicador por atributo. Funciona com discos ATA/SATA e NVMe (nos NVMe, os campos do log de saúde viram a tabela).
- **Partições:** ao abrir uma partição (`/dev/sda1`, `/dev/nvme0n1p2`, `/dev/disk2s1`), o SMART é lido do disco que a contém, e a tela avisa isso.
- **Comando `guara health`** no terminal, com saída legível ou `--json`.
### Alterado
- Um atributo SMART com valor normalizado no limite de falha (ou abaixo dele) agora marca o disco como "Ruim", mesmo que a autoavaliação geral ainda passe.
- **Nova logo:** a cabeça do lobo-guará recortada das trilhas de um disco, inteiras de um lado (recuperado) e quebradas do outro (perdido), nas cores do app. Na interface ela acompanha o tema escolhido (claro ou escuro).
## [0.5.0] - 2026-10-18
### Adicionado
Generated
+2 -2
View File
@@ -317,7 +317,7 @@ dependencies = [
[[package]]
name = "guara-cli"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"clap",
"ctrlc",
@@ -329,7 +329,7 @@ dependencies = [
[[package]]
name = "guara-core"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"flate2",
"image",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "0.5.0"
version = "0.6.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
+7
View File
@@ -41,6 +41,7 @@ Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux
```bash
sudo guara devices # discos e pendrives (avisa TRIM e volumes montados)
sudo guara inspect /dev/sdb # partições, espaço não particionado, cifragem
sudo guara health /dev/sdb # saúde do disco (SMART), requer smartmontools
sudo guara scan /dev/sdb --session s.json --list # varredura profunda
sudo guara scan /dev/sdb --quick # só metadados (segundos)
guara scan disco.img --category image,document --min-confidence medium
@@ -117,6 +118,12 @@ Veja [CONTRIBUTING.md](CONTRIBUTING.md). Versões seguem [SemVer](https://semver
[MIT](LICENSE-MIT) ou [Apache-2.0](LICENSE-APACHE), à sua escolha.
## Novidades da 0.6.0
- Saúde do disco completa, no estilo do CrystalDiskInfo: estado com % de vida útil, temperatura, horas e vezes ligado, total lido e gravado, ficha do dispositivo (modelo, firmware, interface, modo de transferência, rotação, recursos) e a tabela inteira de atributos SMART (ATA e NVMe).
- Novo comando `guara health` no terminal.
- Nova logo: a cabeça do guará recortada das trilhas de um disco.
## Novidades da 0.5.0
- Visualizador de bancos SQLite com recuperação de linhas apagadas.
+1 -1
View File
@@ -3,7 +3,7 @@
[package]
name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos"
version = "0.5.0"
version = "0.6.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guara",
"version": "0.5.0",
"version": "0.6.0",
"identifier": "dev.guara.app",
"build": {
"frontendDist": "../ui"
+22
View File
@@ -494,3 +494,25 @@ dialog.lightbox[open] { display: grid; }
.xp-tiles { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; height: 100%; }
.xp-tiles img { width: 100%; height: 100%; display: block; }
.xp-pin { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #0f8f8f; border: 3px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); }
/* saúde do disco (SMART) */
.h-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; }
.h-tile { background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; }
.h-tile small { color: var(--text-3); font-size: 12px; }
.h-tile b { font-size: 18px; font-variant-numeric: tabular-nums; }
.h-tile.warning b { color: var(--warn); } .h-tile.failing b { color: var(--bad); }
.h-status.ok { background: var(--ok-soft); } .h-status.ok b { color: var(--ok); }
.h-status.warning { background: var(--warn-soft); } .h-status.failing { background: var(--bad-soft); }
.h-status b span { font-weight: 500; opacity: .8; }
.h-info { margin-top: 14px; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); padding: 14px 16px; }
.h-scroll { overflow-x: auto; background: var(--surface); border-radius: 14px; box-shadow: var(--shadow); }
.h-table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }
.h-table th { text-align: left; color: var(--text-3); font-weight: 500; padding: 8px 10px; border-bottom: 1px solid var(--line, rgba(127,127,127,.2)); white-space: nowrap; }
.h-table td { padding: 6px 10px; border-bottom: 1px solid var(--line, rgba(127,127,127,.12)); white-space: nowrap; }
.h-table tr:last-child td { border-bottom: 0; }
.h-table .dim { color: var(--text-3); }
.h-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ok); }
.h-dot.warning { background: var(--warn); } .h-dot.failing { background: var(--bad); }
/* logo inline: segue o tema escolhido no app, não o do sistema */
.logo .a { stroke: var(--accent); } .logo .b { stroke: var(--accent-2); } .logo .t { fill: var(--text); }
+36 -5
View File
@@ -287,15 +287,46 @@ async function openDevice(d) {
rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {});
}
const HEALTH_WORD = { ok: 'Bom', warning: 'Atenção', failing: 'Ruim' };
function paintHealth() {
const el = $('#healthArea');
const h = S.health;
if (!el || !h || !h.available) { if (el) el.innerHTML = ''; return; }
if (!el || !h) { if (el) el.innerHTML = ''; return; }
if (!h.available) {
el.innerHTML = `<div class="section"><h2>Saúde do disco</h2><p class="sub">${esc(h.summary)}</p></div>`;
return;
}
const bad = h.status === 'failing' || h.status === 'warning';
const rows = (h.details || []).filter(([k]) => bad || ['Modelo', 'Horas ligado', 'Temperatura'].includes(k)).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
el.innerHTML = `<div class="notice ${h.status === 'failing' ? 'bad' : h.status === 'warning' ? '' : 'info'}">${warnSvg}<div><b>${esc(h.summary)}</b>
${rows ? `<dl class="kv" style="margin-top:6px">${rows}</dl>` : ''}${(h.advice || []).map((a) => `<div style="margin-top:6px">${esc(a)}</div>`).join('')}
${bad ? '<div class="row" style="margin-top:8px"><button class="btn sm primary" id="healthImg">Criar a imagem primeiro</button></div>' : ''}</div></div>`;
const fmtN = (n) => Number(n).toLocaleString(LOC());
const tile = (label, value, cls = '') => value == null ? '' : `<div class="h-tile ${cls}"><small>${label}</small><b>${value}</b></div>`;
const temp = h.temperature_c;
const tiles = [
`<div class="h-tile h-status ${esc(h.status)}"><small>Estado</small><b>${HEALTH_WORD[h.status] || '?'}${h.health_pct != null ? ` <span>${h.health_pct}%</span>` : ''}</b></div>`,
tile('Temperatura', temp == null ? null : `${temp} °C`, temp >= 60 ? 'failing' : temp >= 50 ? 'warning' : ''),
tile('Horas ligado', h.power_on_hours == null ? null : `${fmtN(h.power_on_hours)} h`),
tile('Vezes ligado', h.power_cycles == null ? null : fmtN(h.power_cycles)),
tile('Total lido', h.host_reads == null ? null : human(h.host_reads)),
tile('Total gravado', h.host_writes == null ? null : human(h.host_writes)),
].join('');
const info = (h.info || []).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
const dash = (x) => x == null ? '<span class="dim">–</span>' : x;
const attrs = h.attributes || [];
const table = attrs.length ? `<details class="adv h-attrs"${bad ? ' open' : ''}><summary>Atributos SMART (${attrs.length})</summary>
<div class="h-scroll"><table class="h-table"><thead><tr><th></th><th>ID</th><th>Atributo</th><th>Atual</th><th>Pior</th><th>Limite</th><th>Bruto</th></tr></thead><tbody>
${attrs.map((a) => `<tr><td><span class="h-dot ${esc(a.status)}" title="${HEALTH_WORD[a.status] || ''}"></span></td><td class="mono">${esc(a.id)}</td><td class="mono">${esc(a.name)}</td><td>${dash(a.value)}</td><td>${dash(a.worst)}</td><td>${dash(a.thresh)}</td><td class="mono">${esc(a.raw)}</td></tr>`).join('')}
</tbody></table></div></details>` : '';
const scope = h.partition ? `<p class="sub">O SMART é do disco inteiro: os dados abaixo são de <span class="mono">${esc(h.disk)}</span>, que contém <span class="mono">${esc(h.partition)}</span>.</p>` : '';
// o que motivou o alerta (os campos da ficha já aparecem abaixo)
const known = new Set(['Modelo', 'Nº de série', 'Horas ligado', 'Temperatura', 'Autoavaliação SMART', 'Vida útil usada']);
const why = (h.details || []).filter(([k]) => !known.has(k)).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
el.innerHTML = `${bad ? `<div class="notice ${h.status === 'failing' ? 'bad' : ''}">${warnSvg}<div><b>${esc(h.summary)}</b>
${why ? `<dl class="kv" style="margin-top:6px">${why}</dl>` : ''}
${(h.advice || []).map((a) => `<div style="margin-top:6px">${esc(a)}</div>`).join('')}
<div class="row" style="margin-top:8px"><button class="btn sm primary" id="healthImg">Criar a imagem primeiro</button></div></div></div>` : ''}
<div class="section"><h2>Saúde do disco</h2>${scope}
<div class="h-tiles">${tiles}</div>
${info ? `<dl class="kv h-info">${info}</dl>` : ''}${table}</div>`;
$('#healthImg')?.addEventListener('click', startImage);
}
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100" role="img" aria-label="Guará"><defs><linearGradient id="wh" gradientUnits="userSpaceOnUse" x1="58" y1="0" x2="160" y2="100"><stop offset="0" stop-color="#3BE3CC"/> <stop offset="0.45" stop-color="#12B5A8"/> <stop offset="1" stop-color="#0B6A86"/></linearGradient><mask id="wm" maskUnits="userSpaceOnUse" x="0" y="0" width="160" height="100"><g shape-rendering="crispEdges"><rect x="80" y="0" width="81" height="100" fill="#fff"/><rect x="72.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="52.38" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="62.38" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="47.38" width="5.24" height="5.24" fill="#fff"/><rect x="57.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="87.38" width="5.24" height="5.24" fill="#fff"/><rect x="74.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="54.88" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="69.88" width="5.24" height="5.24" fill="#fff"/><rect x="77.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="Line truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Guará"><style>.a{stroke:#0f766e}.b{stroke:#14b8a6}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#2dd4bf}.b{stroke:#5eead4}.t{fill:#f5f5f7}}</style><defs><clipPath id="g-head"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#083344"/></linearGradient><clipPath id="ic"><rect width="128" height="128" rx="29"/></clipPath></defs><rect width="128" height="128" rx="29" fill="url(#bg)"/><g clip-path="url(#ic)"><g transform="translate(-13 18) scale(0.86)"><defs><linearGradient id="ih" gradientUnits="userSpaceOnUse" x1="58" y1="0" x2="160" y2="100"><stop offset="0" stop-color="#d9fff7"/> <stop offset="0.5" stop-color="#8df1e0"/> <stop offset="1" stop-color="#3fc8cf"/></linearGradient><mask id="im" maskUnits="userSpaceOnUse" x="0" y="0" width="160" height="100"><g shape-rendering="crispEdges"><rect x="80" y="0" width="81" height="100" fill="#fff"/><rect x="67.38" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="77.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="62.38" width="5.24" height="5.24" fill="#fff"/><rect x="67.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="57.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="79.88" width="5.24" height="5.24" fill="#fff"/><rect x="74.88" y="64.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="52.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="84.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="87.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="67.38" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="89.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="72.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="57.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="47.38" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="57.38" width="5.24" height="5.24" fill="#fff"/><rect x="62.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="82.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="72.38" y="37.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="34.88" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="79.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="74.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="69.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="32.38" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="39.88" width="2.74" height="2.74" fill="#fff"/><rect x="62.38" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="67.38" y="54.88" width="2.74" height="2.74" fill="#fff"/><rect x="59.88" y="44.88" width="2.74" height="2.74" fill="#fff"/><rect x="69.88" y="42.38" width="2.74" height="2.74" fill="#fff"/><rect x="64.88" y="49.88" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="59.88" width="2.74" height="2.74" fill="#fff"/><rect x="77.38" y="62.38" width="2.74" height="2.74" fill="#fff"/><rect x="74.88" y="32.38" width="2.74" height="2.74" fill="#ffLine truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Guará"><style>.a{stroke:#0f766e}.b{stroke:#14b8a6}.t{fill:#1d1d1f}@media (prefers-color-scheme:dark){.a{stroke:#2dd4bf}.b{stroke:#5eead4}.t{fill:#f5f5f7}}</style><defs><clipPath id="g-head"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+43
View File
@@ -1,5 +1,48 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
export const EN = {
// saúde do disco (SMART)
'Saúde do disco': 'Disk health',
'O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco.': 'SMART could not be read. Install the smartmontools package (smartctl command) and run as administrator to see the disk health.',
'Setores realocados': 'Reallocated sectors',
'Setores pendentes de realocação': 'Pending sectors',
'Setores incorrigíveis offline': 'Offline uncorrectable sectors',
'Erros incorrigíveis reportados': 'Reported uncorrectable errors',
'Erros de CRC na interface (cabo ou porta)': 'Interface CRC errors (cable or port)',
'Erros registrados no log do disco': 'Errors in the disk log',
'Aviso crítico NVMe': 'NVMe critical warning',
'Erros de mídia': 'Media errors',
'Reserva de blocos abaixo do limite': 'Spare blocks below threshold',
'Erros de leitura/gravação vistos pelo sistema': 'Read/write errors seen by the system',
'O sistema já registrou erros de E/S neste disco.': 'The system has already logged I/O errors on this disk.',
'Estado': 'Status',
'Bom': 'Good',
'Atenção': 'Caution',
'Ruim': 'Bad',
'Temperatura': 'Temperature',
'Horas ligado': 'Power on hours',
'Vezes ligado': 'Power on count',
'Total lido': 'Host reads',
'Total gravado': 'Host writes',
'Atributos SMART ({n})': 'SMART attributes ({n})',
'Atributo': 'Attribute',
'Atual': 'Current',
'Pior': 'Worst',
'Limite': 'Threshold',
'Bruto': 'Raw',
'Modelo': 'Model',
'Nº de série': 'Serial number',
'Família': 'Family',
'Modo de transferência': 'Transfer mode',
'Capacidade': 'Capacity',
'Rotação': 'Rotation rate',
'Formato': 'Form factor',
'Recursos': 'Features',
'O SMART é do disco inteiro: os dados abaixo são de': 'SMART belongs to the whole disk: the data below is for',
', que contém': ', which contains',
'Imagem de disco: não há SMART para avaliar.': 'Disk image: there is no SMART to check.',
'Nenhum sinal de defeito encontrado pelo SMART.': 'SMART found no signs of failure.',
'Este disco mostra sinais sérios de defeito.': 'This disk shows serious signs of failure.',
'Este disco mostra sinais de desgaste ou erros.': 'This disk shows signs of wear or errors.',
// barra lateral e estrutura
'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices',
+2 -2
View File
@@ -14,10 +14,10 @@
<button class="icon-btn" id="menuBtn" aria-label="Abrir menu" aria-expanded="false">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
</button>
<img src="guara.svg" alt="" width="24" height="24"><strong>Guará</strong>
<svg class="logo" width="24" height="24" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-top"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-top)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><strong>Guará</strong>
</header>
<aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões">
<div class="brand"><img src="guara.svg" alt="" width="30" height="30"><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
<div class="brand"><svg class="logo" width="30" height="30" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><clipPath id="g-head-side"><path d="M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 C75 64 67 70 60 74 C57 80 54 88 50 95 Z"/></clipPath></defs><g clip-path="url(#g-head-side)" fill="none" stroke-width="4.2"><path class="a" d="M50 93A7 7 0 0 1 50 107"/><path class="b" d="M50 93A7 7 0 0 0 50 107" stroke-dasharray="3 3" stroke-dashoffset="0"/><path class="a" d="M50 86A14 14 0 0 1 50 114"/><path class="b" d="M50 86A14 14 0 0 0 50 114" stroke-dasharray="6 4" stroke-dashoffset="3"/><path class="a" d="M50 79A21 21 0 0 1 50 121"/><path class="b" d="M50 79A21 21 0 0 0 50 121" stroke-dasharray="9 5" stroke-dashoffset="6"/><path class="a" d="M50 72A28 28 0 0 1 50 128"/><path class="b" d="M50 72A28 28 0 0 0 50 128" stroke-dasharray="3 6" stroke-dashoffset="9"/><path class="a" d="M50 65A35 35 0 0 1 50 135"/><path class="b" d="M50 65A35 35 0 0 0 50 135" stroke-dasharray="6 3" stroke-dashoffset="12"/><path class="a" d="M50 58A42 42 0 0 1 50 142"/><path class="b" d="M50 58A42 42 0 0 0 50 142" stroke-dasharray="9 4" stroke-dashoffset="15"/><path class="a" d="M50 51A49 49 0 0 1 50 149"/><path class="b" d="M50 51A49 49 0 0 0 50 149" stroke-dasharray="3 5" stroke-dashoffset="18"/><path class="a" d="M50 44A56 56 0 0 1 50 156"/><path class="b" d="M50 44A56 56 0 0 0 50 156" stroke-dasharray="6 6" stroke-dashoffset="21"/><path class="a" d="M50 37A63 63 0 0 1 50 163"/><path class="b" d="M50 37A63 63 0 0 0 50 163" stroke-dasharray="9 3" stroke-dashoffset="24"/><path class="a" d="M50 30A70 70 0 0 1 50 170"/><path class="b" d="M50 30A70 70 0 0 0 50 170" stroke-dasharray="3 4" stroke-dashoffset="27"/><path class="a" d="M50 23A77 77 0 0 1 50 177"/><path class="b" d="M50 23A77 77 0 0 0 50 177" stroke-dasharray="6 5" stroke-dashoffset="30"/><path class="a" d="M50 16A84 84 0 0 1 50 184"/><path class="b" d="M50 16A84 84 0 0 0 50 184" stroke-dasharray="9 6" stroke-dashoffset="33"/><path class="a" d="M50 9A91 91 0 0 1 50 191"/><path class="b" d="M50 9A91 91 0 0 0 50 191" stroke-dasharray="3 3" stroke-dashoffset="36"/><path class="a" d="M50 2A98 98 0 0 1 50 198"/><path class="b" d="M50 2A98 98 0 0 0 50 198" stroke-dasharray="6 4" stroke-dashoffset="39"/><rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g><path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/></svg><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
<nav>
<div class="side-title">Dispositivos <button class="icon-btn small" id="refreshBtn" title="Atualizar lista" aria-label="Atualizar lista"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M20 12a8 8 0 1 1-2.5-5.8M20 4v5h-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
<ul id="deviceList" class="list"></ul>
+111
View File
@@ -32,6 +32,13 @@ enum Cmd {
#[arg(long)]
json: bool,
},
/// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos
Health {
/// Dispositivo ou partição (o SMART lido é o do disco que a contém)
path: String,
#[arg(long)]
json: bool,
},
/// Procura arquivos apagados/perdidos (metadados + assinatura)
Scan(ScanArgs),
/// Continua uma varredura pausada (Ctrl+C) a partir do arquivo de sessão
@@ -206,6 +213,15 @@ fn run(cli: Cli) -> Result<(), String> {
}
Ok(())
}
Cmd::Health { path, json } => {
let v = eng.rpc("health", json!({ "path": path })).map_err(s)?;
if json {
println!("{}", serde_json::to_string_pretty(&v).unwrap());
} else {
print_health(&v);
}
Ok(())
}
Cmd::Scan(a) => {
let opts = ScanOptions {
mode: if a.quick {
@@ -702,3 +718,98 @@ fn paint(code: &str, s: &str) -> String {
fn s<E: std::fmt::Display>(e: E) -> String {
e.to_string()
}
fn print_health(v: &Value) {
let st = v["status"].as_str().unwrap_or("unknown");
if !v["available"].as_bool().unwrap_or(false) {
println!("{}", v["summary"].as_str().unwrap_or(""));
return;
}
let word = match st {
"ok" => paint("32", "Bom"),
"warning" => paint("33", "Atenção"),
"failing" => paint("31", "Ruim"),
_ => "?".into(),
};
let pct = v["health_pct"]
.as_u64()
.map(|p| format!(" ({p}%)"))
.unwrap_or_default();
println!("Disco: {}", v["disk"].as_str().unwrap_or(""));
if let Some(p) = v["partition"].as_str() {
println!(" (o SMART é do disco inteiro que contém {p})");
}
println!(
"Estado: {word}{pct} {}",
v["summary"].as_str().unwrap_or("")
);
let num = |k: &str| v[k].as_i64();
let bytes = |k: &str| v[k].as_u64().map(human);
let mut m = Vec::new();
if let Some(t) = num("temperature_c") {
m.push(format!("Temperatura: {t} °C"));
}
if let Some(h) = num("power_on_hours") {
m.push(format!("Horas ligado: {h}"));
}
if let Some(c) = num("power_cycles") {
m.push(format!("Vezes ligado: {c}"));
}
if let Some(r) = bytes("host_reads") {
m.push(format!("Total lido: {r}"));
}
if let Some(w) = bytes("host_writes") {
m.push(format!("Total gravado: {w}"));
}
if !m.is_empty() {
println!("{}", m.join(" · "));
}
if st != "ok" {
for kv in v["details"].as_array().into_iter().flatten() {
println!(
" ! {}: {}",
kv[0].as_str().unwrap_or(""),
kv[1].as_str().unwrap_or("")
);
}
}
println!();
for kv in v["info"].as_array().into_iter().flatten() {
println!(
" {:<24}{}",
kv[0].as_str().unwrap_or(""),
kv[1].as_str().unwrap_or("")
);
}
let attrs = v["attributes"].as_array().cloned().unwrap_or_default();
if !attrs.is_empty() {
let o = |x: &Value| {
x.as_i64()
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into())
};
println!(
"\n ID {:<34}{:>6}{:>6}{:>7} Bruto",
"Atributo", "Atual", "Pior", "Limite"
);
for a in attrs {
let dot = match a["status"].as_str() {
Some("failing") => paint("31", "●"),
Some("warning") => paint("33", "●"),
_ => paint("32", "●"),
};
println!(
" {dot} {:<3} {:<34}{:>6}{:>6}{:>7} {}",
a["id"].as_str().unwrap_or(""),
a["name"].as_str().unwrap_or(""),
o(&a["value"]),
o(&a["worst"]),
o(&a["thresh"]),
a["raw"].as_str().unwrap_or("")
);
}
}
for adv in v["advice"].as_array().into_iter().flatten() {
println!("\n{}", adv.as_str().unwrap_or(""));
}
}
+290 -2
View File
@@ -15,6 +15,37 @@ pub struct Health {
pub summary: String,
pub details: Vec<(String, String)>,
pub advice: Vec<String>,
/// Ficha do dispositivo, no estilo do CrystalDiskInfo (modelo, firmware, interface...).
pub info: Vec<(String, String)>,
/// Saúde estimada em % (SSD: vida útil restante). `None` em HD, que não informa isso.
pub health_pct: Option<u8>,
pub temperature_c: Option<i64>,
pub power_on_hours: Option<i64>,
pub power_cycles: Option<i64>,
/// Total lido e gravado pelo computador, em bytes, quando o disco informa.
pub host_reads: Option<u64>,
pub host_writes: Option<u64>,
/// Tabela completa de atributos SMART.
pub attributes: Vec<Attr>,
/// Caminho avaliado de fato (o disco inteiro, quando `path` é uma partição).
pub disk: String,
/// Partição pedida, quando a avaliação foi feita no disco que a contém.
pub partition: Option<String>,
}
/// Uma linha da tabela de atributos SMART.
#[derive(Debug, Clone, Serialize, Default)]
pub struct Attr {
/// Identificador em hexadecimal, como o CrystalDiskInfo mostra ("05", "C5").
pub id: String,
pub name: String,
/// Valores normalizados (ATA): atual, pior já visto e limite de falha.
pub value: Option<i64>,
pub worst: Option<i64>,
pub thresh: Option<i64>,
pub raw: String,
/// "ok", "warning" ou "failing".
pub status: String,
}
impl Health {
@@ -44,14 +75,47 @@ pub fn check(path: &str) -> Health {
if !is_dev {
return Health::unknown("Imagem de disco: não há SMART para avaliar.");
}
let mut h = match run_smartctl(path) {
let disk = parent_disk(path);
let mut h = match run_smartctl(&disk) {
Some(v) => parse_smartctl(&v),
None => Health::unknown("O SMART não pôde ser lido. Instale o pacote smartmontools (comando smartctl) e rode como administrador para ver a saúde do disco."),
};
kernel_errors(path, &mut h);
if disk != path {
h.partition = Some(path.to_string());
}
h.disk = disk;
h
}
/// O SMART é do disco, não da partição: `/dev/sda1` vira `/dev/sda`,
/// `/dev/nvme0n1p2` vira `/dev/nvme0n1` e `/dev/disk2s1` vira `/dev/disk2`.
pub fn parent_disk(path: &str) -> String {
#[cfg(target_os = "linux")]
{
let name = path.trim_start_matches("/dev/");
let sys = format!("/sys/class/block/{name}");
if std::path::Path::new(&format!("{sys}/partition")).exists() {
if let Ok(real) = std::fs::canonicalize(&sys) {
if let Some(p) = real.parent().and_then(|p| p.file_name()) {
return format!("/dev/{}", p.to_string_lossy());
}
}
}
}
#[cfg(target_os = "macos")]
{
for pre in ["/dev/rdisk", "/dev/disk"] {
if let Some(rest) = path.strip_prefix(pre) {
if let Some(i) = rest.find('s') {
return format!("{pre}{}", &rest[..i]);
}
}
}
}
path.to_string()
}
fn smartctl_name(path: &str) -> String {
// Windows: \\.\PhysicalDriveN -> /dev/sdX (convenção do smartctl)
if let Some(n) = path
@@ -70,7 +134,7 @@ fn smartctl_name(path: &str) -> String {
fn run_smartctl(path: &str) -> Option<Value> {
let dev = smartctl_name(path);
let out = Command::new("smartctl")
.args(["--json=o", "-H", "-A", "-i", "-l", "error", &dev])
.args(["--json=o", "-a", &dev])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
@@ -111,6 +175,8 @@ pub fn parse_smartctl(v: &Value) -> Health {
bad = bad.max(sev);
h.details.push((k.to_string(), val));
};
fill_info(v, &mut h);
fill_attributes(v, &mut h);
if let Some(m) = v["model_name"].as_str() {
h.details.push(("Modelo".into(), m.to_string()));
}
@@ -225,6 +291,9 @@ pub fn parse_smartctl(v: &Value) -> Health {
}
}
}
if h.attributes.iter().any(|a| a.status == "failing") {
bad = 2;
}
match bad {
2 => {
h.status = "failing".into();
@@ -241,6 +310,225 @@ pub fn parse_smartctl(v: &Value) -> Health {
h
}
fn s(v: &Value, p: &str) -> Option<String> {
v.pointer(p)
.and_then(|x| x.as_str())
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
}
fn n(v: &Value, p: &str) -> Option<i64> {
v.pointer(p).and_then(|x| x.as_i64())
}
/// Ficha do dispositivo e métricas gerais (temperatura, horas, total lido/gravado, saúde %).
fn fill_info(v: &Value, h: &mut Health) {
let mut put = |k: &str, val: Option<String>| {
if let Some(val) = val {
h.info.push((k.to_string(), val));
}
};
put(
"Modelo",
s(v, "/model_name").or_else(|| {
let a = s(v, "/scsi_vendor")?;
Some(format!("{a} {}", s(v, "/scsi_product").unwrap_or_default()))
}),
);
put("Firmware", s(v, "/firmware_version"));
put("Nº de série", s(v, "/serial_number"));
put("Família", s(v, "/model_family"));
let proto = s(v, "/device/protocol");
let iface = s(v, "/sata_version/string").or_else(|| {
let sp = v
.pointer("/nvme_pci_vendor")
.map(|_| "NVM Express".to_string());
sp.or_else(|| proto.clone())
});
put("Interface", iface);
let speed = |p: &str| s(v, &format!("/interface_speed/{p}/string"));
put(
"Modo de transferência",
match (speed("current"), speed("max")) {
(Some(c), Some(m)) if c != m => Some(format!("{c} (máx. {m})")),
(Some(c), _) => Some(c),
(None, Some(m)) => Some(m),
_ => None,
},
);
put(
"Capacidade",
n(v, "/user_capacity/bytes")
.or_else(|| n(v, "/nvme_total_capacity"))
.map(|b| decimal_size(b as u64)),
);
put(
"Rotação",
n(v, "/rotation_rate").map(|r| {
if r == 0 {
"SSD (sem rotação)".into()
} else {
format!("{r} RPM")
}
}),
);
put("Formato", s(v, "/form_factor/name"));
let mut feats = Vec::new();
if v.pointer("/smart_support/enabled") == Some(&Value::Bool(true))
|| v.get("nvme_smart_health_information_log").is_some()
{
feats.push("S.M.A.R.T.");
}
if v.pointer("/trim/supported") == Some(&Value::Bool(true)) {
feats.push("TRIM");
}
if v.pointer("/write_cache/enabled") == Some(&Value::Bool(true)) {
feats.push("Cache de gravação");
}
if v.get("ata_security").and_then(|x| x.get("enabled")) == Some(&Value::Bool(true)) {
feats.push("Segurança ATA");
}
if !feats.is_empty() {
put("Recursos", Some(feats.join(", ")));
}
h.temperature_c = n(v, "/temperature/current");
h.power_on_hours = n(v, "/power_on_time/hours");
h.power_cycles = n(v, "/power_cycle_count");
if let Some(l) = v.get("nvme_smart_health_information_log") {
// a unidade NVMe vale 1000 setores de 512 bytes
h.host_reads = l["data_units_read"].as_u64().map(|u| u * 512_000);
h.host_writes = l["data_units_written"].as_u64().map(|u| u * 512_000);
h.health_pct = l["percentage_used"]
.as_i64()
.map(|u| (100 - u.clamp(0, 100)) as u8);
h.power_cycles = h.power_cycles.or(l["power_cycles"].as_i64());
} else {
let lba = n(v, "/logical_block_size").unwrap_or(512) as u64;
h.host_reads = attr(v, 242).map(|x| x as u64 * lba);
h.host_writes = attr(v, 241).map(|x| x as u64 * lba);
// SSD SATA: vida útil restante no valor normalizado de um destes atributos
// (231 SSD Life Left, 233 Media Wearout, 177 Wear Leveling, 202 Percent Lifetime Remain)
h.health_pct = [231u64, 233, 177, 202, 169]
.iter()
.find_map(|&id| attr_field(v, id, "value"))
.filter(|_| n(v, "/rotation_rate") == Some(0))
.map(|x| x.clamp(0, 100) as u8);
}
}
/// Capacidade como o fabricante anuncia (base 1000): "500,1 GB".
fn decimal_size(b: u64) -> String {
let units = ["B", "kB", "MB", "GB", "TB", "PB"];
let mut x = b as f64;
let mut i = 0;
while x >= 1000.0 && i < units.len() - 1 {
x /= 1000.0;
i += 1;
}
format!("{x:.1} {}", units[i]).replace('.', ",")
}
fn attr_field(v: &Value, id: u64, f: &str) -> Option<i64> {
v.pointer("/ata_smart_attributes/table")?
.as_array()?
.iter()
.find(|a| a["id"] == id)?
.get(f)?
.as_i64()
}
/// Atributos cujo valor bruto acima de zero já é motivo de atenção.
const RAW_WARN: [u64; 5] = [5, 196, 197, 198, 187];
fn fill_attributes(v: &Value, h: &mut Health) {
if let Some(t) = v
.pointer("/ata_smart_attributes/table")
.and_then(|t| t.as_array())
{
for a in t {
let id = a["id"].as_u64().unwrap_or(0);
let value = a["value"].as_i64();
let thresh = a["thresh"].as_i64();
let raw_n = a
.pointer("/raw/value")
.and_then(|x| x.as_i64())
.unwrap_or(0);
let failing = a
.pointer("/when_failed")
.and_then(|x| x.as_str())
.is_some_and(|w| w == "now")
|| matches!((value, thresh), (Some(v), Some(t)) if t > 0 && v <= t);
let status = if failing {
"failing"
} else if RAW_WARN.contains(&id) && raw_n > 0 {
"warning"
} else {
"ok"
};
h.attributes.push(Attr {
id: format!("{id:02X}"),
name: a["name"]
.as_str()
.unwrap_or("Unknown_Attribute")
.replace('_', " "),
value,
worst: a["worst"].as_i64(),
thresh,
raw: a
.pointer("/raw/string")
.and_then(|x| x.as_str())
.map(str::to_string)
.unwrap_or_else(|| raw_n.to_string()),
status: status.into(),
});
}
}
if let Some(l) = v.get("nvme_smart_health_information_log") {
// mesma ordem e nomes que o CrystalDiskInfo usa para NVMe
let rows: [(u8, &str, &str); 15] = [
(1, "Critical Warning", "critical_warning"),
(2, "Composite Temperature", "temperature"),
(3, "Available Spare", "available_spare"),
(4, "Available Spare Threshold", "available_spare_threshold"),
(5, "Percentage Used", "percentage_used"),
(6, "Data Units Read", "data_units_read"),
(7, "Data Units Written", "data_units_written"),
(8, "Host Read Commands", "host_reads"),
(9, "Host Write Commands", "host_writes"),
(10, "Controller Busy Time", "controller_busy_time"),
(11, "Power Cycles", "power_cycles"),
(12, "Power On Hours", "power_on_hours"),
(13, "Unsafe Shutdowns", "unsafe_shutdowns"),
(14, "Media and Data Integrity Errors", "media_errors"),
(
15,
"Number of Error Information Log Entries",
"num_err_log_entries",
),
];
let spare_low = matches!((l["available_spare"].as_i64(), l["available_spare_threshold"].as_i64()), (Some(s), Some(t)) if s < t);
for (id, name, key) in rows {
let Some(x) = l.get(key).and_then(|x| x.as_i64()) else {
continue;
};
let status = match key {
"critical_warning" | "media_errors" if x > 0 => "failing",
"available_spare" if spare_low => "failing",
"percentage_used" if x >= 100 => "failing",
"percentage_used" if x >= 90 => "warning",
_ => "ok",
};
h.attributes.push(Attr {
id: format!("{id:02X}"),
name: name.into(),
raw: x.to_string(),
status: status.into(),
..Default::default()
});
}
}
}
/// Contadores de erro de E/S do kernel (Linux): reforçam o aviso do SMART.
fn kernel_errors(path: &str, h: &mut Health) {
#[cfg(target_os = "linux")]
+60
View File
@@ -51,6 +51,66 @@ fn smart_saudavel_nvme_desgastado_e_nvme_critico() {
assert_eq!(health::parse_smartctl(&media).status, "failing");
}
#[test]
fn smart_ssd_sata_traz_ficha_metricas_e_tabela() {
let v = json!({
"device": {"protocol": "ATA"},
"model_name": "Samsung SSD 860 EVO 500GB", "firmware_version": "RVT04B6Q",
"serial_number": "S3Z1NB0K", "sata_version": {"string": "SATA 3.2"},
"interface_speed": {"max": {"string": "6.0 Gb/s"}, "current": {"string": "6.0 Gb/s"}},
"user_capacity": {"bytes": 500107862016u64}, "logical_block_size": 512, "rotation_rate": 0,
"smart_support": {"enabled": true}, "trim": {"supported": true},
"smart_status": {"passed": true}, "temperature": {"current": 31},
"power_on_time": {"hours": 12034}, "power_cycle_count": 811,
"ata_smart_attributes": {"table": [
{"id": 5, "name": "Reallocated_Sector_Ct", "value": 100, "worst": 100, "thresh": 10, "raw": {"value": 0, "string": "0"}},
{"id": 177, "name": "Wear_Leveling_Count", "value": 92, "worst": 92, "thresh": 0, "raw": {"value": 41, "string": "41"}},
{"id": 241, "name": "Total_LBAs_Written", "value": 99, "worst": 99, "thresh": 0, "raw": {"value": 2000000000u64, "string": "2000000000"}}
]}
});
let h = health::parse_smartctl(&v);
assert_eq!(h.status, "ok");
assert_eq!(h.health_pct, Some(92));
assert_eq!(h.temperature_c, Some(31));
assert_eq!(h.power_cycles, Some(811));
assert_eq!(h.host_writes, Some(2_000_000_000 * 512));
assert!(h
.info
.iter()
.any(|(k, v)| k == "Capacidade" && v == "500,1 GB"));
assert!(h
.info
.iter()
.any(|(k, v)| k == "Recursos" && v.contains("TRIM")));
assert_eq!(h.attributes.len(), 3);
assert_eq!(h.attributes[0].id, "05");
assert_eq!(h.attributes[0].name, "Reallocated Sector Ct");
assert_eq!(h.attributes[1].id, "B1");
}
#[test]
fn smart_atributo_abaixo_do_limite_reprova_e_nvme_vira_tabela() {
let v = json!({"smart_status": {"passed": true}, "ata_smart_attributes": {"table": [
{"id": 1, "name": "Raw_Read_Error_Rate", "value": 40, "worst": 40, "thresh": 51, "raw": {"value": 9}}
]}});
let h = health::parse_smartctl(&v);
assert_eq!(h.attributes[0].status, "failing");
assert_eq!(h.status, "failing");
let nv = json!({"smart_status": {"passed": true}, "nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 40, "available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 3, "data_units_read": 1000, "data_units_written": 2000,
"power_cycles": 50, "power_on_hours": 900, "unsafe_shutdowns": 4, "media_errors": 0}});
let h = health::parse_smartctl(&nv);
assert_eq!(h.health_pct, Some(97));
assert_eq!(h.host_writes, Some(2000 * 512_000));
assert_eq!(h.power_cycles, Some(50));
assert!(h
.attributes
.iter()
.any(|a| a.id == "0D" && a.name == "Unsafe Shutdowns" && a.raw == "4"));
}
#[test]
fn imagem_de_disco_nao_tem_smart() {
let f = tempfile::NamedTempFile::new().unwrap();
+41 -238
View File
@@ -1,251 +1,54 @@
#!/usr/bin/env python3
"""Gera a identidade visual do Guará.
"""Gera a logo do Guará: a cabeça do lobo-guará, de frente, recortada das
trilhas de um disco.
Conceito: pixels de tamanhos distintos, soltos à esquerda, que se juntam numa
grade cada vez mais densa até formar a cabeça de um lobo-guará à direita.
As trilhas são arcos concêntricos com centro abaixo do focinho. Na metade
direita estão inteiras (dados recuperados) e na esquerda quebradas em
fragmentos (dados perdidos). Focinho e olhos usam a cor do texto. As cores
são as do app (apps/gui/ui/app.css): --accent e --accent-2 no tema claro e
seus equivalentes no escuro, trocados por prefers-color-scheme.
- O lobo é uma silhueta VETORIAL suave (curvas), sutil, sem serrilhado.
- À esquerda a silhueta é recortada por uma máscara de quadrados de tamanhos
distintos que ficam cada vez mais densos até se fundirem no lobo inteiro.
- Partículas soltas (quadrados) completam a dissolução fora da silhueta.
Uso: python3 scripts/make-logo.py -> apps/gui/ui/guara-logo.svg e guara.svg
Uso: python3 scripts/make-logo.py [pasta] (padrão: apps/gui/ui)
Grava guara-logo.svg e guara.svg (mesma arte; o segundo é o ícone).
O PNG para os ícones do app (src-tauri/icons/source.png) sai desta arte
renderizada em 1024x1024.
"""
import math
import random
from pathlib import Path
import os, sys
OUT = Path(__file__).resolve().parent.parent / "apps/gui/ui"
W, H = 160.0, 100.0
CELL = 2.5 # módulo da grade de pixels
COLS, ROWS = int(W / CELL), int(H / CELL)
# --- silhueta -------------------------------------------------------------
# Cabeça de lobo-guará (3/4, olhando para a direita): orelhas enormes, focinho
# longo e fino, e a juba escorrendo para a esquerda (é ela que se desmancha em
# pixels). Coordenadas 0..100 na altura; x pode ser negativo (juba).
WOLF = [
(100, 57), (97.5, 54), (91, 51), (83, 48), (75, 45), (69, 41), (65, 36), (62, 29), (59, 20), (56, 11),
(52.5, 4), (49, 0.5), (46, 4), (43, 13), (41, 23), (40, 32), (38, 31), (35, 22), (31, 11), (27.5, 3),
(24.5, 0.5), (22, 5), (20, 15), (19, 27), (9, 35), (-6, 45), (-20, 58), (-30, 76), (-34, 100), (-10, 100), (20, 100), (44, 100),
(60, 100), (59.2, 95), (59.6, 89), (62, 82), (65, 78), (72, 74), (82, 71.5), (92, 69), (98.5, 66.5), (100, 63),
]
# Interior das orelhas (escuro, como no animal real).
EAR_IN = [
[(53, 9), (50, 3), (47, 10), (45, 20), (47, 27), (51, 24), (54, 15)],
[(27.5, 8), (25, 4), (23, 14), (22, 24), (25, 27), (28, 20)],
]
WX0, WSCALE = 64.0, 88.0 # nariz em x=152 (margem de 8 à direita)
Y0, YS = 6.0, 88.0 # topo das orelhas em y=6, base em y=94 # x do desenho onde wx=0 e largura de 100 unidades do lobo
HEAD = ("M50 95 C46 88 43 80 40 74 C33 70 25 64 22 56 L14 6 L36 30 "
"C40 28 45 27 50 27 C55 27 60 28 64 30 L86 6 L78 56 "
"C75 64 67 70 60 74 C57 80 54 88 50 95 Z")
STYLE = (".a{stroke:#0f766e}.b{stroke:#14b8a6}.t{fill:#1d1d1f}"
"@media (prefers-color-scheme:dark){.a{stroke:#2dd4bf}.b{stroke:#5eead4}.t{fill:#f5f5f7}}")
def to_wolf(x, y):
return (x - WX0) * 100.0 / WSCALE, y * 100.0 / H
def tracks():
out = []
for i, r in enumerate(range(7, 104, 7)):
top, bot = 100 - r, 100 + r
out.append(f'<path class="a" d="M50 {top}A{r} {r} 0 0 1 50 {bot}"/>')
dash = f"{3 + i % 3 * 3} {3 + (i * 5) % 4}"
out.append(f'<path class="b" d="M50 {top}A{r} {r} 0 0 0 50 {bot}" '
f'stroke-dasharray="{dash}" stroke-dashoffset="{i * 3}"/>')
return "".join(out)
def wolf_at(x, y):
wx, wy = to_wolf(x, y)
if not _inside(WOLF, wx, wy):
return False
return math.hypot(wx - 71.0, wy - 49.0) >= 1.7 # olho
def ear_in(x, y):
wx, wy = to_wolf(x, y)
return any(_inside(p, wx, wy) for p in EAR_IN)
def _inside(poly, x, y):
c = False
j = len(poly) - 1
for i in range(len(poly)):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
c = not c
j = i
return c
def smooth(t):
t = max(0.0, min(1.0, t))
return t * t * (3 - 2 * t)
def mix(c1, c2, t):
t = max(0.0, min(1.0, t))
a = [int(c1[i:i + 2], 16) for i in (1, 3, 5)]
b = [int(c2[i:i + 2], 16) for i in (1, 3, 5)]
return "#" + "".join(f"{round(p + (q - p) * t):02x}" for p, q in zip(a, b))
def smooth_path(pts, dup=()):
"""Curva fechada (Catmull-Rom -> Bézier); `dup` = índices de quinas vivas."""
P = []
for i, p in enumerate(pts):
P.append(p)
if i in dup:
P.append(p)
n = len(P)
d = f"M{P[0][0]:.2f},{P[0][1]:.2f}"
for i in range(n):
p0, p1, p2, p3 = P[(i - 1) % n], P[i], P[(i + 1) % n], P[(i + 2) % n]
c1 = (p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6)
c2 = (p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6)
d += f"C{c1[0]:.2f},{c1[1]:.2f} {c2[0]:.2f},{c2[1]:.2f} {p2[0]:.2f},{p2[1]:.2f}"
return d + "Z"
def tx(pts):
return [(WX0 + x * WSCALE / 100.0, Y0 + y * YS / 100.0) for x, y in pts]
def lerp(a, b, t):
return a + (b - a) * t
def build(pal, scatter=1.0, seed=5, uid="a", x_min=8.0):
rnd = random.Random(seed)
wolf = tx(WOLF)
corners = {i for i, (_, y) in enumerate(WOLF) if y >= 99.5 or y <= 0.6} # pontas e cantos vivos
body = smooth_path(wolf, dup=corners)
ears = [smooth_path(tx(e)) for e in EAR_IN]
cols, rows = int(W / CELL), int(H / CELL)
# gradiente da figura (o mesmo para a silhueta e para cada pixel => fusão sem emenda)
gx0, gy0, gx1, gy1 = 58.0, 0.0, 160.0, 100.0
stops = pal["stops"]
def grad(x, y):
dx, dy = gx1 - gx0, gy1 - gy0
t = max(0.0, min(1.0, ((x - gx0) * dx + (y - gy0) * dy) / (dx * dx + dy * dy)))
for (t0, c0), (t1, c1) in zip(stops, stops[1:]):
if t <= t1:
return mix(c0, c1, (t - t0) / (t1 - t0))
return stops[-1][1]
X_SOLID = 80.0 # múltiplo do módulo: a partir daqui a figura é inteira e lisa
def ramp(x):
return min(1.0, max(0.0, x / X_SOLID)) ** 2.0
# envelope da "cauda": nasce na largura do pescoço e se afunila para a esquerda
def envelope(x, y):
k = smooth((x + 6) / 80.0)
yc = lerp(70, 62, k)
hh = lerp(5, 31, k ** 1.15)
return math.exp(-(abs(y - yc) / hh) ** 3.0)
occ = set()
def free(cx, cy, n, m=0):
return all((cx + i, cy + j) not in occ for i in range(-m, n + m) for j in range(-m, n + m))
def take(cx, cy, n):
for i in range(n):
for j in range(n):
occ.add((cx + i, cy + j))
mask_sq, free_sq = [], []
def emit(cx, cy, n, sz_scale, op, gap):
x0, y0 = cx * CELL, cy * CELL
size = n * CELL
sz = size * sz_scale
off = (size - sz) / 2
cxm, cym = x0 + size / 2, y0 + size / 2
inside = _inside(wolf, cxm, cym)
if inside and x0 >= 56:
# faz parte da figura lisa: vira janela da máscara
e = 0.12
mask_sq.append(f'<rect x="{x0 - e:.2f}" y="{y0 - e:.2f}" width="{size + 2 * e:.2f}" height="{size + 2 * e:.2f}" fill="#fff"/>')
else:
free_sq.append(f'<rect x="{x0 + off:.2f}" y="{y0 + off:.2f}" width="{sz:.2f}" height="{sz:.2f}" fill="{grad(cxm, cym)}" fill-opacity="{op:.2f}"/>')
# 1) campo de pixels da esquerda até a figura: tamanhos em múltiplos do módulo
order = [(cx, cy) for cy in range(rows) for cx in range(int(x_min / CELL), int(X_SOLID / CELL))]
rnd.shuffle(order)
for cx, cy in order:
x, y = (cx + 0.5) * CELL, (cy + 0.5) * CELL
r, env = ramp(x), envelope(x, y)
p = r * env
if p < 0.01 or y > 94:
continue
# blocos grandes na faixa média; sempre 1×1 colados perto da figura
n = 1
if r < 0.7:
big = rnd.random() < 0.9 * (1 - r) * env ** 0.5 * min(1.0, r * 3.2)
if big:
n = rnd.choice([2, 2, 3]) if r < 0.45 else 2
if cx + n > cols or cy + n > rows or not free(cx, cy, n, 0):
n = 1
if not free(cx, cy, 1, 0):
continue
acc = p if n == 1 else min(1.0, p * 1.6)
if r > 0.88:
acc = 1.0 if env > 0.45 else acc
if rnd.random() > acc:
continue
take(cx, cy, n)
gap_scale = 1.0 if r > 0.86 else (0.9 - 0.45 * (1 - r)) if n > 1 else rnd.choice([0.5, 0.65, 0.8, 0.9]) * (0.7 + 0.3 * r)
op = 0.38 + 0.62 * smooth(r * 1.25)
emit(cx, cy, n, gap_scale if r <= 0.86 else 1.02, op, 0)
# 2) partículas esparsas bem à esquerda e acima/abaixo da cauda (profundidade)
placed, tries, target = 0, 0, int(34 * scatter)
while placed < target and tries < 6000:
tries += 1
x = x_min + rnd.random() ** 1.3 * (70 - x_min)
cy = rnd.randrange(3, rows - 4)
cx = int(x / CELL)
y = (cy + 0.5) * CELL
env = envelope(x, y)
if env > 0.5 or not free(cx, cy, 1, 1):
continue
r = ramp(x)
# sempre perto da cauda, nunca longe demais
near = math.exp(-((abs(y - 62) - 26) / 22.0) ** 2)
if rnd.random() > 0.55 * near * (0.35 + 0.65 * r):
continue
take(cx, cy, 1)
emit(cx, cy, 1, rnd.choice([0.45, 0.6, 0.75]), 0.22 + 0.38 * r, 0)
placed += 1
ex, ey = tx([(71.0, 49.0)])[0]
hx = " ".join(f'<stop offset="{t:g}" stop-color="{c}"/>' for t, c in stops)
svg = (
"<defs>"
f'<linearGradient id="{uid}h" gradientUnits="userSpaceOnUse" x1="{gx0:g}" y1="{gy0:g}" x2="{gx1:g}" y2="{gy1:g}">{hx}</linearGradient>'
f'<mask id="{uid}m" maskUnits="userSpaceOnUse" x="0" y="0" width="{W:g}" height="{H:g}"><g shape-rendering="crispEdges">'
f'<rect x="{X_SOLID:g}" y="0" width="{W - X_SOLID + 1:g}" height="{H:g}" fill="#fff"/>'
+ "".join(mask_sq)
+ "</g></mask></defs>"
f'<g mask="url(#{uid}m)"><path d="{body}" fill="url(#{uid}h)"/>'
+ "".join(f'<path d="{e}" fill="{pal["ear"]}" fill-opacity="{pal["ear_op"]}"/>' for e in ears)
+ "</g>"
+ '<g shape-rendering="crispEdges">' + "".join(free_sq) + "</g>"
+ f'<circle cx="{ex:.2f}" cy="{ey:.2f}" r="1.35" fill="{pal["eye"]}"/>'
)
return svg
def main():
# Marca larga: funciona em fundo claro e escuro. Verde-azulado -> azul petróleo.
pal = dict(stops=[(0.0, "#3BE3CC"), (0.45, "#12B5A8"), (1.0, "#0B6A86")], ear="#05323a", ear_op=0.5, eye="#ecfeff")
(OUT / "guara-logo.svg").write_text(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W:g} {H:g}" role="img" aria-label="Guará">' + build(pal, uid="w") + "</svg>\n"
)
# Ícone: fundo petróleo profundo, figura clara.
ipal = dict(stops=[(0.0, "#d9fff7"), (0.5, "#8df1e0"), (1.0, "#3fc8cf")], ear="#0b4a55", ear_op=0.45, eye="#083344")
(OUT / "guara.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Guará">'
'<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#083344"/></linearGradient>'
'<clipPath id="ic"><rect width="128" height="128" rx="29"/></clipPath></defs>'
'<rect width="128" height="128" rx="29" fill="url(#bg)"/>'
'<g clip-path="url(#ic)"><g transform="translate(-13 18) scale(0.86)">' + build(ipal, scatter=0.5, seed=9, uid="i", x_min=30.0) + "</g></g></svg>\n"
def svg():
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Guará">'
f'<style>{STYLE}</style>'
f'<defs><clipPath id="g-head"><path d="{HEAD}"/></clipPath></defs>'
f'<g clip-path="url(#g-head)" fill="none" stroke-width="4.2">{tracks()}'
# o focinho é a ponta da própria cabeça (recortada pelo mesmo contorno),
# então nenhuma trilha escapa pelas laterais
'<rect class="t" stroke="none" x="0" y="74" width="100" height="30"/></g>'
'<path class="t" d="M37 50Q42 47 46 52Q41 53 37 50ZM63 50Q58 47 54 52Q59 53 63 50Z"/>'
'</svg>\n'
)
if __name__ == "__main__":
main()
out = sys.argv[1] if len(sys.argv) > 1 else "apps/gui/ui"
for name in ("guara-logo.svg", "guara.svg"):
with open(os.path.join(out, name), "w") as f:
f.write(svg())