27 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5.5 0b9a16e8a0 feat: responsive pause/stop, non-blocking update with progress bar, delete saved sessions (1.5.0)
Release / Create release (push) Successful in 13s
CI / Format and clippy (push) Successful in 25s
CI / Dependency audit (push) Successful in 11s
Release / CLI (linux-x86_64) (push) Successful in 2m31s
CI / Tests (Linux) (push) Successful in 4m28s
CI / Check desktop app (Linux) (push) Successful in 1m35s
Release / CLI (linux-aarch64) (push) Successful in 1m46s
Release / Desktop app (linux-x86_64) (push) Successful in 6m9s
Release / Desktop app (windows) (push) Successful in 4m13s
Release / Desktop app (linux-aarch64) (push) Successful in 9m51s
- carve: check cancellation inside chunks and per hit; reassembly stage is cancellable
- status exposes `stopping`; UI shows "Pausing…" immediately
- Tauri update commands run off the UI thread; download percentage reported
- delete autosaved sessions manually (one/all) and by retention setting
- chore(release): 1.5.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 16:36:34 -03:00
lucasrguerra db5b972650 Merge pull request 'Browse and recover by folder (1.4.0)' (#1) from feat/navegacao-pastas into main
Release / Desktop app (linux-x86_64) (push) Successful in 5m54s
Release / Desktop app (windows) (push) Successful in 4m19s
Release / Desktop app (linux-aarch64) (push) Successful in 12m4s
CI / Format and clippy (push) Successful in 30s
CI / Tests (Linux) (push) Successful in 4m42s
Release / Create release (push) Successful in 8s
CI / Dependency audit (push) Successful in 15s
CI / Check desktop app (Linux) (push) Successful in 1m24s
Release / CLI (linux-x86_64) (push) Successful in 2m2s
Release / CLI (linux-aarch64) (push) Successful in 1m55s
2026-11-03 17:10:45 +00:00
Lucas RayanandClaude Opus 5.5 c5f21f749d docs: folder browsing screenshots and docs; translate folder counts
CI / Format and clippy (pull_request) Successful in 31s
CI / Dependency audit (pull_request) Successful in 22s
CI / Check desktop app (Linux) (pull_request) Successful in 1m12s
CI / Tests (Linux) (pull_request) Successful in 4m59s
Retakes the explorer screenshots on 1.4.0 (they still showed 0.4.0) and adds
the Folders view in English and Portuguese. The folder rows' file count sat
inside .xp-lnm, which the DOM translator skips, so it stayed in Portuguese
in the English UI; it is now translated explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:59:05 -03:00
Lucas RayanandClaude Opus 5.5 bf93ca01b4 chore(release): 1.4.0
CI / Format and clippy (pull_request) Successful in 2m25s
CI / Dependency audit (pull_request) Successful in 11s
CI / Check desktop app (Linux) (pull_request) Successful in 1m22s
CI / Tests (Linux) (pull_request) Successful in 8m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:39:16 -03:00
Lucas RayanandClaude Opus 5.5 00aceecf70 feat(ui): browse and recover by folder
A Folders mode next to grid and list shows one level at a time: subfolders
first with their file count and size, then the files, under a clickable
breadcrumb. Folders and files are marked with tri-state boxes that persist
while navigating; the bar shows the exact total from the core, and the
selection is recovered relative to each marked folder. The sidebar folder
tree navigates in this mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:38:33 -03:00
Lucas RayanandClaude Opus 5.5 ca677a8bc1 feat(recover): recover a selection of folders relative to each marked folder
A marked folder a/b/Projeto is recreated as Projeto/... with everything
inside it; single marked files go to the destination root and unmarked
files inside a marked folder are left out. recover_start accepts
{selection: {folders, ids, exclude}}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:31:45 -03:00
Lucas RayanandClaude Opus 5.5 b274f0b7ef feat(explorer): list one folder level with recursive counts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:26:12 -03:00
Lucas GuerraandClaude Sonnet 5 7683967650 feat(report): add logo and TLP classification badge to the recovery report
CI / Format and clippy (push) Successful in 2m13s
CI / Dependency audit (push) Successful in 12s
CI / Check desktop app (Linux) (push) Successful in 4m21s
CI / Tests (Linux) (push) Successful in 8m23s
The HTML/PDF report had no visual identity (no logo) and no sharing
classification. Embeds the Guará logo as a base64 data URI (so the
standalone export still renders offline, same approach as Sabiá's report)
and adds a TLP:AMBER badge next to the existing "Uso autorizado" one —
forensic recovery findings are sensitive and worth marking for limited
disclosure, same as any other Ciberfauna report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-02 11:31:40 -03:00
Lucas RayanandClaude Sonnet 5 43e9860b41 chore(release): bump version to 1.3.5, move repository to the ciberfauna org
CI / Format and clippy (push) Successful in 19s
CI / Dependency audit (push) Successful in 9s
Release / Create release (push) Successful in 8s
CI / Check desktop app (Linux) (push) Successful in 1m24s
Release / CLI (linux-x86_64) (push) Successful in 2m13s
CI / Tests (Linux) (push) Successful in 4m10s
Release / CLI (linux-aarch64) (push) Successful in 1m48s
Release / Desktop app (linux-x86_64) (push) Successful in 5m45s
Release / Desktop app (linux-aarch64) (push) Successful in 5m18s
Release / Desktop app (windows) (push) Successful in 4m19s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 10:19:25 -03:00
Lucas RayanandClaude Sonnet 5 81f067abdb chore(release): bump version to 1.3.5
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 10:15:17 -03:00
Lucas RayanandClaude Sonnet 5 cdda2bc45f feat(update): one-click desktop self-update (AppImage, .deb, Windows)
update_apply used to flatly refuse to run for the GUI binary and point
the user to a manual download instead. Since both desktop apps already
elevate to root/administrator before showing their window, the
process applying the update already has everything it needs — no
second authentication round is required.

- New guara_core::update::apply_desktop[_with_progress], alongside the
  existing CLI-only apply() (left untouched): detects the install kind
  at runtime (AppImage via the APPIMAGE env var — current_exe() is
  unreliable there, same FUSE-mount issue elevated_exe() already works
  around and this reuses; Deb otherwise on Linux; WindowsInstaller on
  Windows), matches the right release asset by the GUI bundle naming
  (_amd64/_aarch64.AppImage, _amd64/_arm64.deb, _x64-setup.exe — a
  different suffix convention than the CLI tarballs), verifies SHA-256
  when a sidecar is published, and installs:
  - AppImage: downloads to a temp file in the same directory (same
    filesystem, atomic rename), renames over the running file, execs
    it in place (same PID, same already-root privileges — no elevation
    dance needed).
  - .deb: runs `dpkg -i` directly (already root), then execs
    current_exe() again now that dpkg replaced it on disk.
  - Windows: runs the downloaded installer with /S (silent) detached,
    then exits immediately to release the file lock on our own .exe.
    Does NOT claim to relaunch the app afterwards — there is no
    documented NSIS or Tauri flag for that (verified against NSIS's
    and Tauri's own docs: Tauri's own updater restarts the app from its
    own Rust code after the installer exits, not via a command-line
    switch), and blocking here on the installer's exit status risks a
    self-deadlock if its script waits for this process to close before
    it can overwrite the running .exe. Needs a real Windows machine to
    confirm the actual post-install behavior.
- Engine::update_apply_desktop wires it into the Tauri `update_apply`
  command, which now also takes AppHandle and emits `update-progress`
  events (downloading/verifying/installing/restarting) for the UI.
- Sabiá: the existing #btnUpdate handler changed the button's label to
  "update to X" but never actually called apply — wired it for real,
  with the same progress feedback.
- Guará: restored the "Update now" action in the update card (removed
  during the updater-unification migration), wired to the new command.
- scripts/build-gui.sh: generates a .sha256 next to every file in
  dist-gui/, matching the existing convention for CLI release
  archives, so apply_desktop can verify downloads the same way CLI
  update --apply already does.
- 3 new unit tests per repo: install-kind detection, GUI asset suffix
  matching against real release naming, and the existing CLI-refusal
  test still passes unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 10:13:56 -03:00
Lucas RayanandClaude Sonnet 5 21eb4eafae chore(release): bump version to 1.3.4
Release / Create release (push) Successful in 6s
CI / Check desktop app (Linux) (push) Successful in 1m22s
Release / CLI (linux-x86_64) (push) Successful in 1m45s
CI / Tests (Linux) (push) Successful in 4m7s
Release / CLI (linux-aarch64) (push) Successful in 2m36s
Release / Desktop app (linux-x86_64) (push) Successful in 4m58s
Release / Desktop app (windows) (push) Successful in 3m44s
CI / Format and clippy (push) Successful in 22s
CI / Dependency audit (push) Successful in 10s
Release / Desktop app (linux-aarch64) (push) Successful in 8m31s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 08:08:55 -03:00
Lucas RayanandClaude Sonnet 5 5ecf40056f fix(gui): hide the window until elevation is resolved, not after
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
2026-10-31 08:07:57 -03:00
Lucas RayanandClaude Sonnet 5 803346469b ci: regenerate the XFS fixture when expected.tsv is also missing
CI / Format and clippy (push) Successful in 2m46s
CI / Dependency audit (push) Successful in 11s
CI / Check desktop app (Linux) (push) Successful in 4m9s
CI / Tests (Linux) (push) Successful in 8m6s
Release / Create release (push) Successful in 8s
Release / CLI (linux-aarch64) (push) Successful in 2m35s
Release / CLI (linux-x86_64) (push) Successful in 3m21s
Release / Desktop app (linux-aarch64) (push) Successful in 6m37s
Release / Desktop app (linux-x86_64) (push) Successful in 12m16s
Release / Desktop app (windows) (push) Successful in 9m40s
The 'Real XFS deletion fixture (Linux)' step only checked for
disk.img before deciding to skip scripts/make-xfs-real-deletion.sh,
but crates/guara-core/tests/xfs.rs requires both disk.img AND
expected.tsv to exist (img.exists() && tsv.exists()) before it will
run the real-deletion test, panicking with GUARA_REQUIRE_FIXTURES=1
otherwise.

A stale disk.img from an earlier run that failed partway through (for
example the container.privileged: false bug just fixed on the Gitea
runners, where sudo mount -o loop had no capability to succeed)
lingers in the persistent /opt/hostedtoolcache/guara-testdata/ cache
without its matching expected.tsv, so the workflow step kept deciding
there was nothing to do while the test kept failing for the opposite
reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 23:14:38 -03:00
Lucas RayanandClaude Sonnet 5 82895581e9 ci: pin CI jobs to the arm64 runners, not the shared ubuntu-latest alias
CI / Format and clippy (push) Successful in 1m20s
CI / Dependency audit (push) Successful in 15s
CI / Check desktop app (Linux) (push) Successful in 1m4s
CI / Tests (Linux) (push) Failing after 4m0s
ci.yml's four jobs (lint, tests, audit, gui check) still used
runs-on: ubuntu-latest. Since the Gitea runners were relabeled (see
the previous ci(release) commit), that alias now resolves only to
hostinger-runner — the single native amd64 machine release.yml routes
the x86_64 build legs to. Every CI push was piling onto that one
runner alongside the release jobs, and it could not keep up: run 73
(triggered by the previous commit) shows 'Set up job' taking 15m22s
and then every following step failing in 0s, including deterministic
ones like cargo fmt --check that have nothing to do with runner
architecture.

Pins all four ci.yml jobs to linux-arm64 (the two oracle-runner
replicas), matching the explicit-label convention release.yml already
uses, and freeing hostinger-runner to do only what it was provisioned
for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 22:23:32 -03:00
Lucas RayanandClaude Sonnet 5 5e508bbd14 chore(release): bump version to 1.3.3
CI / Tests (Linux) (push) Failing after 10m8s
CI / Dependency audit (push) Canceled after 7m7s
CI / Check desktop app (Linux) (push) Canceled after 0s
CI / Format and clippy (push) Failing after 15m22s
Release / Create release (push) Successful in 5s
Release / CLI (linux-x86_64) (push) Successful in 3m15s
Release / CLI (linux-aarch64) (push) Successful in 1m39s
Release / Desktop app (linux-aarch64) (push) Successful in 6m23s
Release / Desktop app (linux-x86_64) (push) Successful in 13m45s
Release / Desktop app (windows) (push) Successful in 17m27s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 22:05:25 -03:00
Lucas RayanandClaude Sonnet 5 40ebf705ed ci(release): drop the updater job, build linux-x86_64 on the native amd64 runner
Two changes to release.yml, kept together since they touch the same
jobs:

1. Drops the 'updater' job ('Build latest.json'): the desktop app no
   longer reads a Tauri-updater manifest (see the previous commit), so
   nothing consumes it any more. Also drops the now-unused
   GUARA_TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) secrets from the 'gui'
   job's env and the header comment documenting them.

2. Routes the linux-x86_64 leg of the cli and gui matrices to the new
   native x86_64 Gitea Actions runner (hostinger-runner, label
   linux-amd64) instead of cross-compiling it from an aarch64 host
   (oracle-runner/-b, label linux-arm64):
   - matrix.include gets an explicit runner field per platform;
     runs-on: ${{ matrix.runner }} picks it per job. windows also
     pinned to linux-amd64 explicitly rather than relying on the
     ubuntu-latest alias, since that alias now points at whichever
     runner is tagged for it and is no longer a safe stand-in for
     "the aarch64 host" (it briefly meant exactly that, then got
     relabeled to the x86_64 runner — pinning explicit labels avoids
     depending on that alias at all).
   - The "Platform cross-linker" step (gcc-x86-64-linux-gnu) only
     runs when NOT on the native amd64 runner, since a native build
     needs no cross-linker.
   - scripts/build-gui.sh and scripts/package-cli.sh are intentionally
     left untouched: both already branch on `uname -m` and take the
     simple native path when run on x86_64. The existing cross-compile
     path (qemu wrappers, the ELF launcher that survives
     tauri-bundler's dd patch) stays in place as a dormant fallback in
     case the single amd64 runner is ever unavailable — deliberately
     kept, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 22:04:15 -03:00
Lucas RayanandClaude Sonnet 5 76e4466652 feat(update): unify the updater with Sabiá's Gitea-based mechanism
Replaces the signed tauri-plugin-updater (latest.json manifest, minisign
key pair) with the same simple mechanism Sabiá already uses: query the
Gitea Releases API directly, download the matching platform archive,
verify its SHA-256 checksum, and swap the binary in place. The CI job
that built the old latest.json manifest is removed in the next commit,
alongside an unrelated runner-routing change to the same workflow file.

- New guara-core::update module (check/apply), ported from
  sabia-core::update and adapted to Guará's own release asset naming
  (guara-<tag>-<platform>.tar.gz, matching scripts/package-cli.sh).
- New Engine::update_check / Engine::update_apply, wired into the RPC
  service for the browser UI.
- New CLI subcommand: "guara update" (check) and "guara update --apply"
  (download, verify, replace), mirroring "sabia update".
- GUI: replaced the Tauri plugin-updater flow (download_and_install,
  update-progress events, in-app restart) with a call to the new
  update_check/update_apply commands. The desktop app can no longer
  self-install in one click — it now points the user to the Releases
  page, same as Sabiá's desktop app already does.
- Removed tauri-plugin-updater, createUpdaterArtifacts and the
  updater.pubkey config, the signing-key loading in build-gui.sh, the
  CI step testing the old latest.json generator, and the now-unused
  make-latest-json.py/test_make_latest_json.py scripts.
- Documented the break for existing installs in CHANGELOG.md /
  CHANGELOG.pt-BR.md: versions up to 1.3.2 won't find this release
  automatically and need one manual download.

Trade-off accepted knowingly: SHA-256 verifies integrity against
Gitea's own API but, unlike the retired signed mechanism, does not
protect against a compromised Gitea server independently of it. This
is Lucas's own self-hosted instance, not a third-party mirror.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 22:03:28 -03:00
Lucas RayanandClaude Sonnet 5 6328220c60 chore(i18n): finish translating code comments and scripts to English
CI / Format and clippy (push) Successful in 1m57s
CI / Dependency audit (push) Successful in 13s
CI / Check desktop app (Linux) (push) Successful in 3m54s
CI / Tests (Linux) (push) Failing after 7m25s
Release / Create release (push) Successful in 6s
Release / CLI (linux-aarch64) (push) Successful in 1m48s
Release / CLI (linux-x86_64) (push) Successful in 2m39s
Release / Desktop app (linux-aarch64) (push) Successful in 10m9s
Release / Desktop app (linux-x86_64) (push) Successful in 12m20s
Release / Desktop app (windows) (push) Successful in 7m32s
Release / Build latest.json (push) Successful in 5s
Completes the English-only standardization for code, comments, scripts
and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en,
untouched). Covers the remaining crates/, apps/gui/ui/ and
apps/gui/src-tauri/ comments, .gitea/ workflow and template comments,
.githooks/ hooks, and the scripts/ source comments for the files
already renamed in a prior commit (check.sh, generate-logo.py,
install-hooks.sh).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:43:02 -03:00
Lucas RayanandClaude Sonnet 5 da212e6926 docs: refresh desktop screenshots
Re-captured the explorer (light/dark, en/pt) and Settings language
screenshots from the real UI against a synthetic test image, replacing
the outdated set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:42:49 -03:00
Lucas RayanandClaude Sonnet 5 341a5a2cb5 fix(ci): stop dd from corrupting the x86_64 linuxdeploy launcher
On the aarch64 Gitea runner, tauri-bundler's prepare_tools step runs
'dd ... seek=8 conv=notrunc' on the cached linuxdeploy-x86_64 binary to
hide it from AppImage integration tools. That zeroed bytes 8-10 of the
file; our cached binary there was a bash wrapper script, so the dd wrote
into the middle of its shebang/interpreter line, corrupting it, and the
bundler's subsequent spawn failed with ENOENT ('failed to bundle
project: No such file or directory').

Replace the shell wrapper with a small native ELF launcher compiled with
cc: bytes 8-10 of an ELF header are EI_PAD padding, unused by the
loader, so the same dd leaves it executable. The launcher sets the same
environment the old wrapper did and execs the real aarch64 linuxdeploy
binary. Also cache AppRun-x86_64 explicitly so the bundler does not need
to fetch it itself mid-build.

Verified locally: compiled a minimal ELF binary, ran the same dd
command against it, confirmed it still executes (the shebang-script
equivalent does not).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-30 20:42:23 -03:00
Lucas Rayan 2786d28e7e chore: padroniza arquitetura Ciberfauna, elevação root na abertura e CI multi-arch no runner ARM
CI / Auditoria de dependências (push) Successful in 9s
CI / Compila o app desktop (Linux) (push) Successful in 1m55s
Release / Cria a release (push) Successful in 5s
CI / Formatação e clippy (push) Successful in 20s
CI / Testes (Linux) (push) Successful in 4m25s
Release / CLI (linux-aarch64) (push) Successful in 2m18s
Release / App desktop (linux-aarch64) (push) Successful in 9m19s
Release / App desktop (windows) (push) Successful in 10m27s
Release / Monta o latest.json (push) Skipped
Release / CLI (linux-x86_64) (push) Successful in 2m51s
Release / App desktop (linux-x86_64) (push) Failing after 11m11s
2026-10-30 17:38:54 -03:00
Lucas RayanandClaude Sonnet 5.5 009c3d61de ci: find the desktop bundles in CARGO_TARGET_DIR, accept Linux aarch64 in latest.json
CI / Formatação e clippy (push) Successful in 23s
CI / Auditoria de dependências (push) Successful in 25s
CI / Compila o app desktop (Linux) (push) Successful in 1m12s
Release / Cria a release (push) Successful in 5s
Release / App desktop (linux) (push) Successful in 11m25s
CI / Testes (Linux) (push) Successful in 4m20s
Release / CLI (linux-aarch64) (push) Successful in 2m28s
Release / CLI (linux-x86_64) (push) Successful in 2m38s
Release / App desktop (windows) (push) Successful in 11m8s
Release / Monta o latest.json (push) Successful in 6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:02:36 -03:00
Lucas RayanandClaude Sonnet 5.5 b47708126e release 1.3.2: update from Gitea, Linux-only CLI, desktop app for Linux and Windows
CI / Testes (Linux) (push) Successful in 4m36s
CI / Compila o app desktop (Linux) (push) Successful in 1m14s
CI / Formatação e clippy (push) Successful in 2m5s
CI / Auditoria de dependências (push) Successful in 10s
The updater reads latest.json from Gitea, built by scripts/make-latest-json.py
against the Gitea API (no gh CLI). Release workflow builds the Linux CLI and
the Linux+Windows desktop app, signs the updater artifacts and attaches
everything to the release.

Needs the repo secrets TAURI_SIGNING_PRIVATE_KEY and
TAURI_SIGNING_PRIVATE_KEY_PASSWORD, which I could not set (secret-store writes
are reserved for the user) - set them from ~/.tauri/guara-updater.key and
.pass before pushing a tag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 13:41:21 -03:00
Lucas RayanandClaude Sonnet 5 8ba3151512 docs: Windows and Linux only, no macOS build
The Gitea CI has one Linux runner; the GUI's Windows package is cross-compiled
from it, but there is no path to a macOS build without Apple hardware and a
developer account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 13:37:23 -03:00
Lucas RayanandClaude Sonnet 5.5 089ffe0920 ci: create a Gitea release when a v* tag is pushed
CI / Compila o app desktop (Linux) (push) Successful in 4m52s
CI / Testes (Linux) (push) Successful in 7m37s
CI / Formatação e clippy (push) Successful in 27s
CI / Auditoria de dependências (push) Successful in 8s
Notes come from the matching CHANGELOG.md section. The macOS and Windows
binaries the old release workflow built need runners this Gitea does not have.
The CI also ignores .wolf, .claude and the release files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 12:37:01 -03:00
Lucas RayanandClaude Sonnet 5.5 fdf7489ea2 ci: move the CI to Gitea Actions and cache what is rebuilt
CI / Formatação e clippy (push) Successful in 1m32s
CI / Auditoria de dependências (push) Successful in 11s
CI / Compila o app desktop (Linux) (push) Successful in 5m0s
CI / Testes (Linux) (push) Successful in 7m7s
Linux-only runner, so the Windows and macOS legs and GitHub's audit action are
gone. The Rust toolchain, cargo registry and target directories live on the
runner's persistent toolcache volume. release.yml stays under .github and is
not run by Gitea.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 10:45:46 -03:00
224 changed files with 14305 additions and 2389 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: Report a bug
description: Something did not work as expected
labels: [bug]
body:
- type: textarea
id: what-happened
attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." }
validations: { required: true }
- type: input
id: system
attributes: { label: "Operating system and Guará version", placeholder: "Ubuntu 24.04 / guara 1.3.2" }
- type: input
id: media
attributes: { label: "Media type and filesystem", placeholder: "32 GB USB drive, FAT32" }
- type: textarea
id: log
attributes: { label: "Terminal output (guara inspect / scan)", render: shell }
+8
View File
@@ -0,0 +1,8 @@
## What changes and why
## Checklist
- [ ] `cargo fmt --all` and `cargo clippy --workspace --all-targets` pass without warnings
- [ ] `cargo test --workspace` passes
- [ ] Nothing in this PR writes to the source device
- [ ] Commits follow Conventional Commits in English and code/comments are in English
- [ ] CHANGELOG updated (if there is a user-visible change)
+111
View File
@@ -0,0 +1,111 @@
name: CI
on:
# Documentation-only or local agent state changes do not trigger CI jobs.
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
pull_request:
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**', '.wolf/**', '.claude/**', '.opencode/**', '.codex/**', '.cursor/**', '.superpowers/**', '.superset/**', '.gitea/ISSUE_TEMPLATE/**', 'scripts/release-notes.sh', 'scripts/release-upload.sh', '.gitea/workflows/release.yml']
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
# The runner persists /opt/hostedtoolcache across jobs: rustup toolchains,
# cargo registry, npm/apt caches, and target directories live there so nothing
# is re-downloaded or rebuilt from scratch on every run. Each job uses its own
# target directory so differing RUSTFLAGS do not invalidate another job's cache.
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
jobs:
lint:
name: Format and clippy
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: actions/setup-node@v7
with:
node-version: 22
- name: UI translation coverage
run: node scripts/check-ui-i18n.mjs
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
# Linux-only runner (ARM64).
test:
name: Tests (Linux)
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Test image tools (Linux)
if: runner.os == 'Linux'
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/guara-tools mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip
- name: Reference disk images (BitLocker, FileVault 2, dfvfs, AFF4)
shell: bash
run: |
mkdir -p /opt/hostedtoolcache/guara-testdata target
ln -sfn /opt/hostedtoolcache/guara-testdata target/testdata
scripts/fetch-testdata.sh
# Mounts an image via loop device (mount -o loop), which requires a
# privileged container. Unprivileged runners skip this step cleanly.
- name: Real XFS deletion fixture (Linux)
if: runner.os == 'Linux'
continue-on-error: true
run: |
if [ ! -f target/testdata/xfs-real-delete/disk.img ] || [ ! -f target/testdata/xfs-real-delete/expected.tsv ]; then
sudo scripts/make-xfs-real-deletion.sh
fi
- name: Run tests
shell: bash
run: |
if [ "${RUNNER_OS}" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
cargo test --workspace
audit:
name: Dependency audit
runs-on: linux-arm64
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
# rustsec/audit-check depends on GitHub's checks API. cargo-audit itself
# needs nothing but the advisory database, and installing it once into the
# persistent CARGO_HOME makes every later run skip compilation.
- name: cargo audit
run: |
command -v cargo-audit >/dev/null || cargo install cargo-audit --locked
cargo audit
gui:
name: Check desktop app (Linux)
runs-on: linux-arm64
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Tauri system dependencies
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Generate icons
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
env:
RUSTFLAGS: ""
+128
View File
@@ -0,0 +1,128 @@
name: Release
# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes
# from CHANGELOG.md and uploads the compiled CLI (scripts/package-cli.sh) with
# a .sha256 checksum alongside each archive, plus the desktop bundles (Linux
# aarch64, Linux x86_64, and Windows via cargo-xwin).
#
# No macOS: Tauri requires Apple's SDK and code-signing hardware/account, which
# this Linux ARM64 runner does not have.
on:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
CARGO_HOME: /opt/hostedtoolcache/cargo
RUSTUP_HOME: /opt/hostedtoolcache/rustup
NPM_CONFIG_CACHE: /opt/hostedtoolcache/npm-cache
XWIN_CACHE_DIR: /opt/hostedtoolcache/xwin-cache
jobs:
release:
name: Create release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Release
env:
TAG: ${{ gitea.ref_name }}
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: |
# Re-running the workflow does not recreate an existing release.
if curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" >/dev/null 2>&1; then
echo "release $TAG already exists"; exit 0
fi
scripts/release-notes.sh "$TAG" > notes.md
pre=false; case "$TAG" in *-*) pre=true ;; esac
jq -n --arg tag "$TAG" --arg name "Guará ${TAG#v}" --rawfile body notes.md --argjson pre "$pre" \
'{tag_name:$tag, name:$name, body:$body, prerelease:$pre}' > release.json
curl -fsS -X POST "$API/releases" -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' -d @release.json >/dev/null
cli:
name: CLI (${{ matrix.platform }})
needs: release
runs-on: ${{ matrix.runner }}
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-cli-${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "", runner: linux-arm64 }
- { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu", runner: linux-amd64 }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Platform cross-linker
if: ${{ matrix.apt != '' && matrix.runner != 'linux-amd64' }}
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
- name: Build and package
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
- name: Upload to release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" $(cat packages.txt)
gui:
name: Desktop app (${{ matrix.platform }})
needs: release
runs-on: ${{ matrix.runner }}
env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-gui-${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- { platform: linux-aarch64, runner: linux-arm64 }
- { platform: linux-x86_64, runner: linux-amd64 }
- { platform: windows, runner: linux-amd64 }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Tauri dependencies (Linux aarch64)
if: matrix.platform == 'linux-aarch64'
timeout-minutes: 25
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Tauri dependencies (Linux x86_64)
if: matrix.platform == 'linux-x86_64'
timeout-minutes: 25
run: |
if [ "$(uname -m)" = aarch64 ]; then
dpkg --add-architecture amd64
. /etc/os-release
if [ "${ID:-}" = ubuntu ]; then
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
grep -q '^Architectures:' /etc/apt/sources.list.d/ubuntu.sources || sed -i '/^Types: deb/a Architectures: arm64' /etc/apt/sources.list.d/ubuntu.sources
printf 'Types: deb\nURIs: http://archive.ubuntu.com/ubuntu/\nSuites: %s %s-updates %s-security\nComponents: main universe\nArchitectures: amd64\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.sources
elif [ -f /etc/apt/sources.list ]; then
sed -i 's/^deb http/deb [arch=arm64] http/' /etc/apt/sources.list
printf 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-updates main universe\ndeb [arch=amd64] http://archive.ubuntu.com/ubuntu/ %s-security main universe\n' "$VERSION_CODENAME" "$VERSION_CODENAME" "$VERSION_CODENAME" >/etc/apt/sources.list.d/amd64.list
fi
fi
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-amd64 gcc-x86-64-linux-gnu g++-x86-64-linux-gnu pkg-config patchelf file squashfs-tools qemu-user libglib2.0-bin libwebkit2gtk-4.1-dev:amd64 libayatana-appindicator3-dev:amd64 librsvg2-dev:amd64
else
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
fi
- name: Cross-compilation tools (Windows)
if: matrix.platform == 'windows'
run: |
scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-win nsis lld llvm clang
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
- name: Build and package
run: scripts/build-gui.sh "${{ matrix.platform }}"
- name: Upload to release
env:
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Enforces Conventional Commits 1.0.0 and English-only commit messages.
set -euo pipefail
msg_file="${1:?usage: commit-msg <file>}"
python3 - "$msg_file" <<'PY'
import re, sys
from pathlib import Path
raw = Path(sys.argv[1]).read_text(encoding="utf-8")
lines = [l for l in raw.splitlines() if not l.startswith("#")]
while lines and not lines[0].strip():
lines.pop(0)
if not lines:
sys.exit("commit-msg: empty commit message")
subject = lines[0].strip()
if subject.startswith(("Merge ", "Revert \"")):
sys.exit(0)
conv_re = re.compile(
r"^(feat|fix|chore|ci|docs|refactor|test|perf|build|style|revert)(\([a-z0-9_./-]+\))?!?: .+$"
)
if not conv_re.match(subject):
sys.exit(
f"commit-msg: invalid Conventional Commit header: {subject!r}\n"
"Expected: <type>[optional scope]: <description in English>\n"
"Allowed types: feat, fix, chore, ci, docs, refactor, test, perf, build, style, revert"
)
body = "\n".join(lines)
cleaned = re.sub(r"\b(Guará|Sabiá|Acauã|Ciberfauna)\b", "", body)
pt_accents = re.search(r"[áàâãéêíóôõúçÁÀÂÃÉÊÍÓÔÕÚÇ]", cleaned)
pt_words = re.search(
r"\b(não|para|como|quando|então|pelos|pelas|pelo|pela|estão|está|também|versão|"
r"corrige|adiciona|atualiza|remove|padroniza|implementa|ajusta|arquivo|arquivos)\b",
cleaned,
re.I,
)
if pt_accents or pt_words:
sys.exit("commit-msg: commit messages must be written 100% in English.")
PY
+2 -2
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env bash
# Antes de cada push: a verificação rápida. Ignore com `git push --no-verify` (use com parcimônia).
exec "$(git rev-parse --show-toplevel)/scripts/verificar.sh"
set -euo pipefail
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
-17
View File
@@ -1,17 +0,0 @@
name: Relatar um problema
description: Algo não funcionou como esperado
labels: [bug]
body:
- type: textarea
id: o-que
attributes: { label: O que aconteceu?, description: "Passos, o que esperava e o que viu." }
validations: { required: true }
- type: input
id: sistema
attributes: { label: "Sistema operacional e versão do Guará", placeholder: "Windows 11 / guara 0.1.0" }
- type: input
id: midia
attributes: { label: "Tipo de mídia e sistema de arquivos", placeholder: "pendrive 32 GB, FAT32" }
- type: textarea
id: log
attributes: { label: "Saída do terminal (guara inspect / scan)", render: shell }
-7
View File
@@ -1,7 +0,0 @@
## O que muda e por quê
## Checklist
- [ ] `cargo fmt --all` e `cargo clippy --workspace --all-targets` sem avisos
- [ ] `cargo test --workspace` passa
- [ ] Nada neste PR escreve no dispositivo de origem
- [ ] CHANGELOG atualizado (se houver mudança visível)
-110
View File
@@ -1,110 +0,0 @@
name: CI
on:
# Mudança só de documentação não precisa de 6 jobs em 3 sistemas.
push:
branches: [main]
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
pull_request:
paths-ignore: ['**.md', 'docs/**', 'LICENSE*', '.gitignore', '.githooks/**']
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
jobs:
lint:
name: Formatação e clippy
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cobertura da tradução da interface
run: node scripts/check-ui-i18n.mjs
- name: Script do latest.json do atualizador
run: python3 scripts/test_make_latest_json.py
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
test:
name: Testes (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Cache dos pacotes das ferramentas de teste (.deb)
if: runner.os == 'Linux'
uses: actions/cache@v6
with:
path: ~/apt-cache-tools
key: apt-tools-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Ferramentas para imagens de teste (Linux)
if: runner.os == 'Linux'
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache-tools mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip
- name: Cache das imagens de referência
uses: actions/cache@v6
with:
path: target/testdata
key: testdata-${{ runner.os }}-${{ hashFiles('scripts/fetch-testdata.sh') }}
- name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
shell: bash
run: scripts/fetch-testdata.sh
- name: Imagem XFS com exclusão feita pelo kernel (Linux)
if: runner.os == 'Linux'
run: sudo scripts/make-xfs-real-deletion.sh
- name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux)
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
cargo test --workspace
audit:
name: Auditoria de dependências
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
gui:
name: Compila o app desktop (Linux)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/gui/src-tauri
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cache dos pacotes do Tauri (.deb)
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Dependências do Tauri
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
- run: cargo check --manifest-path apps/gui/src-tauri/Cargo.toml
env:
RUSTFLAGS: ""
-167
View File
@@ -1,167 +0,0 @@
name: Release
# Publica uma release pública ao empurrar uma tag vX.Y.Z (ex.: git tag v0.1.0 && git push --tags)
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
create-release:
name: Cria a release
runs-on: ubuntu-24.04
outputs:
release_id: ${{ steps.rel.outputs.result }}
steps:
- uses: actions/checkout@v7
- name: Notas da versão (do CHANGELOG)
id: notes
run: |
v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v9
id: rel
with:
script: |
const fs = require('fs');
const tag = context.ref.replace('refs/tags/', '');
const r = await github.rest.repos.createRelease({
owner: context.repo.owner, repo: context.repo.repo, tag_name: tag,
name: `Guará ${tag}`, body: fs.readFileSync('notes.md', 'utf8'),
draft: true, prerelease: tag.includes('-'),
});
return r.data.id;
cli:
name: CLI (${{ matrix.name }})
needs: create-release
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { name: linux-x86_64, os: ubuntu-24.04, target: x86_64-unknown-linux-gnu }
- { name: linux-aarch64, os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
- { name: macos-aarch64, os: macos-latest, target: aarch64-apple-darwin }
- { name: macos-x86_64, os: macos-latest, target: x86_64-apple-darwin }
- { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --release --locked -p guara-cli --target ${{ matrix.target }}
- name: Empacota
shell: bash
run: |
name="guara-${GITHUB_REF_NAME}-${{ matrix.name }}"
mkdir "$name"
if [ "$RUNNER_OS" = "Windows" ]; then
cp target/${{ matrix.target }}/release/guara.exe "$name/"
else
cp target/${{ matrix.target }}/release/guara "$name/"
fi
cp README.md LICENSE-MIT LICENSE-APACHE "$name/"
if [ "$RUNNER_OS" = "Windows" ]; then
7z a "$name.zip" "$name" >/dev/null; asset="$name.zip"
else
tar czf "$name.tar.gz" "$name"; asset="$name.tar.gz"
fi
if command -v sha256sum >/dev/null; then sha256sum "$asset" > "$asset.sha256"; else shasum -a 256 "$asset" > "$asset.sha256"; fi
echo "ASSET=$asset" >> "$GITHUB_ENV"
- name: Envia para a release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$GITHUB_REF_NAME" "$ASSET" "$ASSET.sha256" --clobber
gui:
name: App desktop (${{ matrix.name }})
needs: create-release
runs-on: ${{ matrix.os }}
# Os segredos de assinatura do atualizador ficam no ambiente "release", que só aceita tags v*.
environment: release
strategy:
fail-fast: false
matrix:
include:
- { name: linux-x86_64, os: ubuntu-24.04, args: "" }
- { name: macos-aarch64, os: macos-latest, args: "--target aarch64-apple-darwin" }
- { name: macos-x86_64, os: macos-latest, args: "--target x86_64-apple-darwin" }
- { name: windows-x86_64, os: windows-latest, args: "" }
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ contains(matrix.args, 'apple') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/gui/src-tauri
- uses: actions/setup-node@v7
with:
node-version: 22
- name: Cache dos pacotes do Tauri (.deb)
if: runner.os == 'Linux'
uses: actions/cache@v6
with:
path: ~/apt-cache
key: apt-tauri-${{ runner.os }}-${{ runner.arch }}-${{ env.ImageVersion }}-${{ hashFiles('scripts/apt-install.sh') }}
- name: Dependências do Tauri (Linux)
if: runner.os == 'Linux'
timeout-minutes: 25
run: scripts/apt-install.sh ~/apt-cache libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Gera os ícones
run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons
# A chave privada de atualização fica só nos segredos do repositório. Ela assina os pacotes
# que o app baixa sozinho; a chave pública correspondente está em tauri.conf.json.
- uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: apps/gui/src-tauri
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.args }}
# o latest.json é montado no job de publicação, de uma vez só (scripts/make-latest-json.py)
uploadUpdaterJson: false
publish:
name: Publica a release
needs: [create-release, cli, gui]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Notas da versão (do CHANGELOG)
run: |
v="${GITHUB_REF_NAME#v}"
v="${v%%-*}"
awk -v v="$v" '$0 ~ "^## \\[" v "\\]" {f=1; next} /^## \[/ {f=0} f' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Veja o CHANGELOG." > notes.md
- uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
await github.rest.repos.updateRelease({
owner: context.repo.owner, repo: context.repo.repo,
release_id: ${{ needs.create-release.outputs.release_id }},
name: `Guará ${context.ref.replace('refs/tags/', '')}`,
body: fs.readFileSync('notes.md', 'utf8'),
draft: false,
});
# Depois de publicada (rascunho não é visível por tag), monta e sobe o latest.json.
- name: latest.json do atualizador do app
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
python3 scripts/make-latest-json.py "$GITHUB_REF_NAME" notes.md latest.json
gh release upload "$GITHUB_REF_NAME" latest.json --clobber
+37 -14
View File
@@ -1,19 +1,42 @@
# Material de trabalho local, não versionado
.superpowers/
# Estado de ferramentas locais
.wolf/
.claude/
.superset/
CLAUDE.md
# Variáveis de ambiente locais (o setup do Superset copia da raiz)
.env
.env.*
# Build
# Build artifacts
target/
apps/gui/src-tauri/target/
node_modules/
*.img
*.guara-session.json
.DS_Store
**/*.rs.bk
# Sabiá local captures and audit log
*.pcap
*.cap
.sabia-audit.log
# Logo copied to the UI directory by build.rs
/apps/gui/ui/logo.png
# Local AI agent configuration and state — not part of the product
.claude/
.wolf/
.opencode/
.codex/
.cursor/
.superpowers/
.superset/
CLAUDE.md
AGENTS.md
GEMINI.md
# Local environment files
.env
.env.*
# Local scratch files
*.eml
__pycache__/
*.pyc
# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh
/guara-v*
/sabia-v*
/dist-gui
+57
View File
@@ -4,6 +4,63 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [1.5.0] - 2026-11-03
### Added
- **Update progress bar.** The in-app update shows a real download percentage bar followed by the verify/install/restart phases.
- **Delete saved sessions.** Each autosaved session in the sidebar has a delete button, plus a "delete all" button. Settings gained "Delete autosaved sessions after" (never, 7, 30 or 90 days), applied on startup. Sessions open in the app are never removed automatically.
### Fixed
- **Updating no longer freezes the app.** The update check and install ran on the UI thread; they now run in the background.
- **Pause and Stop respond right away.** The signature scan only checked for the request once per 8 MiB block; it now checks continuously, and the UI shows "Pausing…" immediately.
## [1.4.0] - 2026-11-03
### Added
- **Browse by folder.** A Folders view next to grid and list shows what was found one folder at a time, like a file manager: subfolders first with their file count and size, then the files, under a clickable breadcrumb. The sidebar folder tree navigates in this view, and filters still apply.
- **Recover whole folders or parts of them.** Folders and files are ticked with tri-state boxes that persist while navigating; a ticked folder takes everything inside it, and unticked subfolders or files stay out. Each ticked folder is recreated in the destination from itself down (`Documents/Clients/Project` → `Project/...`), with the usual hash manifest.
## [1.3.5] - 2026-10-31
### Added
- **One-click desktop update.** The "Update now" button actually downloads, verifies (SHA-256) and installs the new version now, instead of only linking to the Releases page. Works for all three desktop packages: AppImage (atomic self-replace), `.deb` (`dpkg -i`, no new password prompt — the app is already running as root/administrator by the time you click it), and the Windows installer (silent `/S` install). On Linux, the app restarts itself automatically; on Windows, the installer runs silently but the app does not reopen on its own yet — start it again once the install finishes.
### Changed
- **The repository moved to the Ciberfauna organization** (`ciberfauna/guara`, was `lucasrguerra/guara`). **Important notice for existing installations:** versions up to 1.3.4 have the old location baked into their update check; they will not find this release automatically. Download 1.3.5 once manually from the new Releases page, and future update checks will work through the new location.
## [1.3.4] - 2026-10-31
### Fixed
- **Desktop app: only one window on startup.** The main window used to be visible from launch, so the unprivileged instance's normal window would show, flash while the system asked for the administrator password, and only then get replaced by the elevated relaunch's window a moment later. The window now stays hidden until elevation is resolved — either already elevated, elevation failed (the window then shows the warning), or this is the already-elevated relaunched instance — so only one window ever appears, matching Sabiá.
## [1.3.3] - 2026-10-30
Unified updater architecture with Sabiá: migrated from the signed `tauri-plugin-updater` to direct Gitea Releases API checking and self-updating with SHA-256 verification.
### Changed
- **New update mechanism:** The updater was rewritten to match Sabiá's model. Updates now query the Gitea Releases API directly, verify package checksums via `.sha256`, and no longer rely on `latest.json` or minisign updater keys.
- **CLI self-update (`guara update`):** The CLI now supports `guara update` (checks for new versions) and `guara update --apply` (downloads, verifies SHA-256 checksum, and atomically replaces the binary).
- **Desktop app notice:** The desktop app checks Gitea on startup and links directly to the new release packages.
- **Important notice for existing installations:** Versions up to 1.3.2 used the previous signed `tauri-plugin-updater` mechanism looking for `latest.json`. Because that mechanism has been retired, existing installs will not automatically find version 1.3.3: download version 1.3.3 once manually from Gitea Releases, and future update checks will work through the new unified mechanism.
- Removed `tauri-plugin-updater` dependency, `createUpdaterArtifacts` configuration, and the `updater` (`Build latest.json`) CI job.
## [1.3.2] - 2026-10-30
The project moved from GitHub to Gitea, and the update check followed it.
### Changed
- **Updates now come from Gitea.** The desktop app's update card reads
`latest.json` from `git.lucasrguerra.dev.br` instead of GitHub Releases.
Versions up to 1.3.1 still look at GitHub, which is no longer reachable, so
they cannot find this release: download 1.3.2 once, and later updates are
automatic.
- The CLI is published for Linux x86_64 and aarch64 only, built by the Gitea CI
when a `v*` tag is pushed, with a `.sha256` next to each package. **There is
no Windows CLI any more.** The desktop app still ships for Linux (AppImage,
`.deb`) and Windows (NSIS `.exe`, cross-compiled from Linux). There is no
macOS build: it needs Apple's SDK and code signing, which this project's CI
(one Linux runner) cannot provide.
## [1.3.1] - 2026-10-23
### Changed
+57
View File
@@ -4,6 +4,63 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [1.5.0] - 2026-11-03
### Adicionado
- **Barra de progresso da atualização.** A atualização dentro do app mostra uma barra com a porcentagem real do download, seguida das fases de verificação, instalação e reinício.
- **Apagar sessões salvas.** Cada sessão salva sozinha na lateral tem um botão de apagar, além de um botão "apagar todas". As configurações ganharam "Apagar sessões salvas sozinhas após" (nunca, 7, 30 ou 90 dias), aplicado ao abrir. Sessões abertas no app nunca são removidas automaticamente.
### Corrigido
- **Atualizar não trava mais o app.** A verificação e a instalação rodavam na thread da interface; agora rodam em segundo plano.
- **Pausar e Parar respondem na hora.** A varredura por assinatura só checava o pedido a cada bloco de 8 MiB; agora checa continuamente, e a interface mostra "Pausando…" imediatamente.
## [1.4.0] - 2026-11-03
### Adicionado
- **Navegação por pastas.** Uma visualização Pastas, ao lado de grade e lista, mostra o que foi encontrado uma pasta por vez, como num gerenciador de arquivos: primeiro as subpastas, com número de arquivos e tamanho, depois os arquivos, sob um caminho clicável. A árvore de pastas da barra lateral navega nessa visualização, e os filtros continuam valendo.
- **Recuperar pastas inteiras ou partes delas.** Pastas e arquivos são marcados por caixas de três estados que continuam ao navegar; uma pasta marcada leva tudo o que está dentro dela, e subpastas ou arquivos desmarcados ficam de fora. Cada pasta marcada é recriada no destino a partir dela mesma (`Documentos/Clientes/Projeto` → `Projeto/...`), com o manifesto de hashes de sempre.
## [1.3.5] - 2026-10-31
### Adicionado
- **Atualização do app desktop em um clique.** O botão "Atualizar agora" agora baixa, confere (SHA-256) e instala a versão nova de verdade, em vez de só linkar pra página de Releases. Funciona nos três pacotes do desktop: AppImage (substituição atômica), `.deb` (`dpkg -i`, sem pedir senha de novo — o app já roda como root/administrador quando você clica), e o instalador do Windows (instalação silenciosa `/S`). No Linux o app reabre sozinho; no Windows o instalador roda silencioso mas o app ainda não reabre por conta própria — abra de novo depois que a instalação terminar.
### Alterado
- **O repositório se mudou pra organização Ciberfauna** (`ciberfauna/guara`, era `lucasrguerra/guara`). **Aviso importante pra instalações existentes:** versões até a 1.3.4 têm o endereço antigo fixo na checagem de atualização e não vão encontrar esta versão sozinhas. Baixe a 1.3.5 manualmente uma vez na nova página de Releases, e as próximas verificações voltam a funcionar pelo novo endereço.
## [1.3.4] - 2026-10-31
### Corrigido
- **App desktop: só uma janela na abertura.** A janela principal ficava visível desde o lançamento, então a instância sem privilégio mostrava a janela normal, piscava enquanto o sistema pedia a senha de administrador, e só depois era substituída pela janela da instância já elevada. Agora a janela fica escondida até a elevação ser resolvida — já elevada, falhou (a janela aí mostra o aviso), ou é a própria instância já relançada como administrador — então só uma janela aparece, igual ao Sabiá.
## [1.3.3] - 2026-10-30
Arquitetura de atualizador unificada com o Sabiá: migração do `tauri-plugin-updater` assinado para consulta direta à API de Releases do Gitea e autoatualização com verificação de SHA-256.
### Alterado
- **Novo mecanismo de atualização:** O atualizador foi reescrito para seguir o modelo do Sabiá. As atualizações agora consultam diretamente a API de Releases do Gitea, conferem a integridade via `.sha256` e não dependem mais de `latest.json` nem de chaves minisign.
- **Autoatualização do CLI (`guara update`):** O CLI agora conta com `guara update` (para verificar novas versões) e `guara update --apply` (que baixa, confere o SHA-256 e substitui o binário atomicamente).
- **Aviso no app desktop:** O app desktop consulta o Gitea ao abrir e direciona para os novos pacotes da release.
- **Aviso importante para instalações existentes:** Versões até a 1.3.2 usavam o mecanismo assinado do `tauri-plugin-updater` baseado em `latest.json`. Como esse mecanismo foi descontinuado, instalações anteriores não encontrarão a versão 1.3.3 sozinhas: baixe a versão 1.3.3 manualmente uma vez nas Releases do Gitea, e as próximas verificações funcionarão pelo novo mecanismo unificado.
- Removidos a dependência `tauri-plugin-updater`, a configuração `createUpdaterArtifacts` e o job de CI `updater` (`Build latest.json`).
## [1.3.2] - 2026-10-30
O projeto saiu do GitHub para o Gitea, e a verificação de atualização foi junto.
### Alterado
- **As atualizações agora vêm do Gitea.** O cartão de atualização do app
desktop lê o `latest.json` em `git.lucasrguerra.dev.br`, e não mais as
Releases do GitHub. As versões até a 1.3.1 ainda olham o GitHub, que não
responde mais, e por isso não enxergam esta release: baixe a 1.3.2 uma vez, e
as próximas atualizações são automáticas.
- O CLI é publicado só para Linux x86_64 e aarch64, compilado pelo CI do Gitea
quando uma tag `v*` é enviada, com um `.sha256` ao lado de cada pacote. **Não
há mais CLI para Windows.** O app desktop continua saindo para Linux
(AppImage, `.deb`) e Windows (`.exe` NSIS, compilado a partir do Linux). Não
há build para macOS: exige o SDK e a assinatura da Apple, que o CI deste
projeto (um único runner Linux) não tem.
## [1.3.1] - 2026-10-23
### Alterado
+13 -6
View File
@@ -7,22 +7,29 @@ Thanks for helping! Golden rules:
1. **Never write to the source.** Any code that opens a device must use `source::open_path` (read-only). PRs that write to the source disk are rejected.
2. **Test with real data.** New detectors/readers need a test that recovers a known file bit for bit (see `crates/guara-core/tests/` and `scripts/make-fixtures.sh`).
3. **Be honest about confidence.** If the content cannot be validated, confidence is at most medium.
4. **100% English codebase and comments.** All source code, comments, scripts, CI workflows, and commit messages must be written in English, while the GUI/CLI remains bilingual (PT-BR and EN via `i18n-en.js`).
## Workflow
```bash
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
scripts/install-hooks.sh # once: enables commit-msg and pre-push Git hooks
scripts/check.sh # fast (seconds): fmt, clippy as in CI, i18n, scripts, workflows, semver
scripts/check.sh --full # includes cargo test --workspace and cargo audit
```
The CI takes 5 to 30 minutes on three systems, so run the check before pushing: almost everything that makes the CI fail shows up locally in seconds. Documentation-only changes (`*.md`, `docs/`) skip the CI.
The CI takes 5 to 30 minutes on Linux, so run the check before pushing: almost everything that makes the CI fail shows up locally in seconds. Documentation-only changes (`*.md`, `docs/`) skip the CI.
On Linux the tests build images with `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` and `python3`. Without these tools the image tests are skipped (set `GUARA_REQUIRE_FIXTURES=1` to make them fail).
For the external reference images (BitLocker, FileVault 2, dfvfs, AFF4), run `scripts/fetch-testdata.sh`; without them those tests are skipped. The BitLocker test takes about 75 s.
The desktop app signs its update packages: `tauri.conf.json` has `createUpdaterArtifacts` on, so a local `tauri build` needs `TAURI_SIGNING_PRIVATE_KEY` (any key made with `tauri signer generate`; the release key lives only in the repository secrets). For a quick local bundle, pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'`.
## Releases and Versioning
Versions strictly follow **Semantic Versioning 2.0.0** (`MAJOR.MINOR.PATCH`), synchronized across `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml`, and `apps/gui/src-tauri/tauri.conf.json` (enforced by `scripts/check.sh`).
Pushing a `v*` tag makes the Gitea CI create the release (notes come from `CHANGELOG.md`, so update it first) and attach the Linux CLI (x86_64 and aarch64) and the desktop app for Linux (x86_64 and aarch64) and Windows.
There is no macOS build: it needs Apple's SDK and code signing, which a Linux runner cannot provide.
## Where to make changes
@@ -42,4 +49,4 @@ The main version is in English and every file has a Portuguese translation (`*.p
## Commits
Short messages in the imperative (`fix reading of sparse runs in NTFS`). One logical change per PR.
Commits must follow **Conventional Commits 1.0.0** in English (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), e.g. `fix(ntfs): handle sparse data runs correctly`. One logical change per PR. Enforced automatically by `.githooks/commit-msg`.
+13 -6
View File
@@ -7,22 +7,29 @@ Obrigado por ajudar! Regras de ouro:
1. **Nunca escreva na origem.** Qualquer código que abra um dispositivo deve usar `source::open_path` (somente leitura). PRs que escrevem no disco de origem são recusados.
2. **Teste com dados reais.** Novos detectores/leitores precisam de teste que recupera um arquivo conhecido bit a bit (veja `crates/guara-core/tests/` e `scripts/make-fixtures.sh`).
3. **Seja honesto sobre confiança.** Se o conteúdo não puder ser validado, a confiança é no máximo média.
4. **Código, comentários e commits 100% em inglês.** Todo código-fonte, comentários, scripts, workflows de CI e mensagens de commit devem ser escritos em inglês, enquanto a interface gráfica/CLI permanece bilíngue (PT-BR e EN via `i18n-en.js`).
## Fluxo
```bash
scripts/instalar-hooks.sh # uma vez: roda a verificação rápida antes de cada push
scripts/verificar.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows
scripts/verificar.sh --completo # inclui cargo test --workspace e cargo audit
scripts/install-hooks.sh # uma vez: ativa os hooks commit-msg e pre-push
scripts/check.sh # rápido (segundos): fmt, clippy como no CI, tradução, scripts, workflows, semver
scripts/check.sh --full # inclui cargo test --workspace e cargo audit
```
O CI leva de 5 a 30 minutos em três sistemas, então rode a verificação antes do push: quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) pulam o CI.
O CI leva de 5 a 30 minutos em Linux, então rode a verificação antes do push: quase tudo que faz o CI falhar aparece aqui em segundos. Mudanças só de documentação (`*.md`, `docs/`) pulam o CI.
No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatprogs`, `imagemagick`, `zip` e `python3`. Sem essas ferramentas os testes com imagem são ignorados (defina `GUARA_REQUIRE_FIXTURES=1` para falhar).
Para as imagens de referência externas (BitLocker, FileVault 2, dfvfs, AFF4), rode `scripts/fetch-testdata.sh`; sem elas esses testes são pulados. O teste de BitLocker leva cerca de 75 s.
O app desktop assina os pacotes de atualização: o `tauri.conf.json` tem `createUpdaterArtifacts` ligado, então um `tauri build` local precisa de `TAURI_SIGNING_PRIVATE_KEY` (qualquer chave feita com `tauri signer generate`; a chave da release fica só nos segredos do repositório). Para um pacote local rápido, passe `--config '{"bundle":{"createUpdaterArtifacts":false}}'`.
## Releases e Versionamento
As versões seguem estritamente o **Versionamento Semântico 2.0.0** (`MAJOR.MINOR.PATCH`), sincronizadas entre `Cargo.toml`, `apps/gui/src-tauri/Cargo.toml` e `apps/gui/src-tauri/tauri.conf.json` (validado por `scripts/check.sh`).
Enviar uma tag `v*` faz o CI do Gitea criar a release (as notas vêm do `CHANGELOG.md`, então atualize-o antes) e anexar o CLI para Linux (x86_64 e aarch64) e o app desktop para Linux (x86_64 e aarch64) e Windows.
Não há build para macOS: ele exige o SDK e a assinatura da Apple, que um runner Linux não tem.
## Onde mexer
@@ -42,4 +49,4 @@ A versão principal é em inglês e cada arquivo tem uma tradução em portuguê
## Commits
Mensagens curtas no imperativo (`corrige leitura de runs esparsos no NTFS`). Uma mudança lógica por PR.
Os commits devem seguir **Conventional Commits 1.0.0** em inglês (`feat:`, `fix:`, `chore:`, `ci:`, `docs:`, `refactor:`, `test:`, `perf:`, `build:`), ex.: `fix(ntfs): handle sparse data runs correctly`. Uma mudança lógica por PR. Validado automaticamente por `.githooks/commit-msg`.
Generated
+2 -2
View File
@@ -416,7 +416,7 @@ dependencies = [
[[package]]
name = "guara-cli"
version = "1.3.1"
version = "1.5.0"
dependencies = [
"clap",
"ctrlc",
@@ -429,7 +429,7 @@ dependencies = [
[[package]]
name = "guara-core"
version = "1.3.1"
version = "1.5.0"
dependencies = [
"aes",
"argon2",
+3 -3
View File
@@ -3,10 +3,10 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "1.3.1"
version = "1.5.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
authors = ["Guará contributors"]
rust-version = "1.80"
@@ -17,6 +17,6 @@ opt-level = 3
strip = true
panic = "abort"
# Dependências otimizadas também em debug: os testes de cifra fazem milhões de SHA-256.
# Optimize dependencies in debug builds as well: cipher tests run millions of SHA-256 rounds.
[profile.dev.package."*"]
opt-level = 3
+1 -1
View File
@@ -18,7 +18,7 @@ RUN apt-get update \
COPY --from=build /src/target/release/guara /usr/local/bin/guara
LABEL org.opencontainers.image.title="Guará" \
org.opencontainers.image.description="File recovery for hard drives, SSDs and USB sticks (CLI)" \
org.opencontainers.image.source="https://github.com/lucasrguerra/guara" \
org.opencontainers.image.source="https://git.lucasrguerra.dev.br/lucasrguerra/guara" \
org.opencontainers.image.licenses="MIT OR Apache-2.0"
WORKDIR /data
ENTRYPOINT ["guara"]
+15 -9
View File
@@ -1,6 +1,6 @@
<p align="center"><img src="apps/gui/ui/guara.png" width="300" alt="Guará"></p>
<p align="center"><img src="logo.png" width="300" alt="Guará"></p>
<h1 align="center">Guará</h1>
<p align="center">Free file recovery for hard drives, SSDs and USB sticks.<br>Terminal and graphical interface · Windows, Linux and macOS · read-only on the source.</p>
<p align="center">Free file recovery for hard drives, SSDs and USB sticks.<br>Terminal and graphical interface · Windows and Linux · read-only on the source.</p>
<p align="center"><a href="docs/support.md">What it supports</a> · <a href="docs/interface.md">Interface</a> · <a href="docs/cli.md">Terminal</a> · <a href="docs/architecture.md">Architecture</a> · <a href="ROADMAP.md">Roadmap</a> · <a href="CHANGELOG.md">Changelog</a></p>
@@ -38,15 +38,16 @@ Download the package for your system from **[Releases](../../releases)**:
| | CLI | Graphical app |
|---|---|---|
| Windows | `guara-*-windows-x86_64.zip` | `.msi` / `.exe` installer |
| macOS | `guara-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
| Windows | — | `.msi` / `.exe` installer |
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
Every file has a `.sha256` next to it. To build from source: `cargo build --release -p guara-cli` (Rust 1.80+).
The CLI is Linux-only; the desktop app ships for Linux and Windows. Every CLI
package has a `.sha256` next to it. To build from source: `cargo build
--release -p guara-cli` (Rust 1.80+, Linux).
**Updates:** the desktop app (AppImage, Windows and macOS) checks GitHub for a newer version when it opens; if there is one, a card offers **Update now**: it downloads, verifies the package signature, installs and reopens, so you no longer swap the AppImage by hand. The check is a single request for the release's `latest.json` and nothing of yours is sent; turn it off in **Settings**. Installs from `.deb`/`.rpm` only show the notice, because the package manager owns those files.
**Updates:** the desktop app checks Gitea on startup; when a new version exists, a notice offers the release link. The CLI updates the same way (`guara update`); it downloads the right binary for your platform, verifies the **SHA-256** and swaps the running executable — no manual download. The check is a single request to the releases API and sends nothing of yours.
> The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn you. Check the SHA-256.
> The binaries are **not signed yet**: Windows SmartScreen will warn you. Check the SHA-256.
Reading raw disks requires privileges: **administrator** (Windows), `sudo` (Linux and macOS). Even as root, Guará writes sessions, reports and recovered files with the owner and permissions of the user who launched it.
@@ -76,6 +77,10 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f
<p align="center"><img src="docs/img/explorer-dark-en.png" width="640" alt="The explorer in the dark theme"> <img src="docs/img/settings-language-en.png" width="250" alt="Settings with the language selector"></p>
- **Thumbnail grid** or **list**, grouping by day or month and virtual scrolling for tens of thousands of files.
- **Folder browsing:** open what was found one folder at a time, like a file manager, and recover whole folders or only some subfolders and files, recreated from the ticked folder down.
<p align="center"><img src="docs/img/folders-light-en.png" width="720" alt="The Folders view: the root of the recovered files with DCIM ticked, Viagens partly ticked and the folder details on the right"></p>
- **Sidebar navigation** by type, real photo date (EXIF), folder, camera, confidence and GPS, with an option to hide duplicates.
- **Finder or Explorer style selection** and full-screen **quick look** (Space, Enter or double click).
- **Details panel** with preview, dates and metadata.
@@ -104,7 +109,7 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f
- **NTFS:** compressed files, EFS and heavily fragmented records are skipped or marked as incomplete.
- **exFAT:** validated with a synthetic image; real media are still missing.
- **Phones:** Guará works with storage media and does **not** unlock devices or bypass passwords.
- **Windows and macOS:** the code builds and is tested in CI, but the tests with real images run only on Linux. Reports from real media are very welcome.
- **Windows:** the code builds and is tested in CI, but the tests with real images run only on Linux. There is no macOS build: Tauri needs the macOS SDK and code signing, both tied to Apple hardware and an Apple Developer account, which this project's CI (a single Linux runner) does not have. The source still compiles for macOS; it is just not built or tested.
- **GUI:** on Linux and macOS the app restarts entirely as administrator (`pkexec` or `osascript`) instead of using a helper process.
## Where it is going
@@ -114,7 +119,8 @@ Summary of the [ROADMAP](ROADMAP.md):
| | |
|---|---|
| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, Ed25519 signatures, APFS free map |
| **Next** | Trusted timestamps (RFC 3161), tests on Windows and macOS |
| **Next** | Trusted timestamps (RFC 3161), more tests on Windows |
| **Not planned** | A macOS build — see [Known limits](#known-limits) |
| **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes |
| **No reference image** | ReFS and Storage Spaces: they only land with validation |
| **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits |
+15 -9
View File
@@ -1,6 +1,6 @@
<p align="center"><img src="apps/gui/ui/guara.png" width="300" alt="Guará"></p>
<p align="center"><img src="logo.png" width="300" alt="Guará"></p>
<h1 align="center">Guará</h1>
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows, Linux e macOS · somente leitura na origem.</p>
<p align="center">Recuperação de arquivos livre e gratuita para HD, SSD e pendrive.<br>Terminal e interface gráfica · Windows e Linux · somente leitura na origem.</p>
<p align="center"><a href="docs/pt-BR/suporte.md">O que suporta</a> · <a href="docs/pt-BR/interface.md">Interface</a> · <a href="docs/pt-BR/cli.md">Terminal</a> · <a href="docs/pt-BR/arquitetura.md">Arquitetura</a> · <a href="ROADMAP.pt-BR.md">Roadmap</a> · <a href="CHANGELOG.pt-BR.md">Changelog</a></p>
@@ -38,15 +38,16 @@ Baixe o pacote do seu sistema em **[Releases](../../releases)**:
| | CLI | App gráfico |
|---|---|---|
| Windows | `guara-*-windows-x86_64.zip` | instalador `.msi` / `.exe` |
| macOS | `guara-*-macos-{aarch64,x86_64}.tar.gz` | `.dmg` |
| Windows | — | instalador `.msi` / `.exe` |
| Linux | `guara-*-linux-{x86_64,aarch64}.tar.gz` | `.AppImage` / `.deb` |
Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+).
O CLI é só para Linux; o app desktop sai para Linux e Windows. Cada pacote do
CLI tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p
guara-cli` (Rust 1.80+, Linux).
**Atualizações:** o app desktop (AppImage, Windows e macOS) consulta o GitHub ao abrir; havendo versão nova, um cartão oferece **Atualizar agora**: ele baixa, confere a assinatura do pacote, instala e reabre, e você deixa de trocar o AppImage na mão. A consulta é uma única requisição ao `latest.json` da release e nada seu é enviado; desligue em **Configurações**. Instalações por `.deb`/`.rpm` só mostram o aviso, porque quem manda nesses arquivos é o gerenciador de pacotes.
**Atualizações:** o app desktop consulta o Gitea ao abrir; havendo versão nova, um aviso oferece o link da release. O CLI atualiza do mesmo jeito (`guara update`); ele baixa o binário certo para a plataforma, confere o **SHA-256** e troca o executável em uso — você não precisa baixar nada na mão. A consulta é uma única requisição à API de releases e nada seu é enviado.
> Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256.
> Os binários ainda **não são assinados**: o Windows SmartScreen vai avisar. Confira o SHA-256.
Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux e macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu.
@@ -76,6 +77,10 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando
<p align="center"><img src="docs/img/explorer-dark-pt.png" width="640" alt="O explorador no tema escuro"> <img src="docs/img/settings-language-pt.png" width="250" alt="Configurações com o seletor de idioma"></p>
- **Grade de miniaturas** ou **lista**, agrupamento por dia ou mês e rolagem virtual para dezenas de milhares de arquivos.
- **Navegação por pastas:** abra o que foi encontrado uma pasta por vez, como num gerenciador de arquivos, e recupere pastas inteiras ou só algumas subpastas e arquivos, recriados a partir da pasta marcada.
<p align="center"><img src="docs/img/folders-light-pt.png" width="720" alt="A visualização Pastas: a raiz dos arquivos encontrados com DCIM marcada, Viagens parcialmente marcada e os detalhes da pasta à direita"></p>
- **Navegação lateral** por tipo, data real da foto (EXIF), pasta, câmera, confiança e GPS, com opção de ocultar duplicadas.
- **Seleção como no Finder ou Explorer** e **visualização rápida** em tela cheia (Espaço, Enter ou duplo clique).
- **Painel de detalhes** com pré-visualização, datas e metadados.
@@ -104,7 +109,7 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando
- **NTFS:** arquivos comprimidos, EFS e registros muito fragmentados são ignorados ou marcados como incompletos.
- **exFAT:** validado com imagem sintética; faltam mídias reais.
- **Celulares:** o Guará trabalha com mídias de armazenamento e **não** desbloqueia aparelhos nem contorna senhas.
- **Windows e macOS:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Relatos de mídias reais são muito bem-vindos.
- **Windows:** o código compila e é testado em CI, mas os testes com imagens reais rodam só no Linux. Não há build para macOS: o Tauri precisa do SDK do macOS e de assinatura, os dois presos a hardware Apple e a uma conta Apple Developer, que o CI deste projeto (um único runner Linux) não tem. O código-fonte continua compilando para macOS; só não é compilado nem testado.
- **GUI:** no Linux e macOS o app reinicia inteiro como administrador (`pkexec` ou `osascript`) em vez de usar um processo auxiliar.
## Para onde vai
@@ -114,7 +119,8 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md):
| | |
|---|---|
| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, assinaturas Ed25519, mapa livre do APFS |
| **Próximo** | Carimbo de tempo de terceiros (RFC 3161), testes em Windows e macOS |
| **Próximo** | Carimbo de tempo de terceiros (RFC 3161), mais testes em Windows |
| **Fora de plano** | Um build para macOS — veja [Limites conhecidos](#limites-conhecidos) |
| **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados |
| **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação |
| **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip |
+9 -4
View File
@@ -6,6 +6,11 @@ Where Guará has been, where it is and where it is going. What is marked as done
Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ out of scope
> Items marked done for macOS reflect what the GitHub CI used to build and test,
> before the move to Gitea. The Gitea CI has one Linux runner; it builds and
> tests Linux and Windows. macOS is not built or tested any more — see the
> README's [Known limits](README.md#known-limits).
## Principles that do not change
1. **The source is read-only.** Nothing in the core writes to the device being recovered.
@@ -138,7 +143,7 @@ Physical access is the differentiator of commercial tools like Cellebrite. It de
| **Reproducibility:** replay the recorded procedure and get the same result | 🔜 |
| PDF report (A4, built in) | ✅ |
| **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 |
| Signed binaries (Windows and macOS) | 🔜 |
| Signed binaries (Windows) | 🔜 |
External validation is what makes a report accepted. Without it, Guará is good for triage, recovery and support, and the examiner must check the results by other means.
@@ -158,9 +163,9 @@ External validation is what makes a report accepted. Without it, Guará is good
| Item | Status |
|---|---|
| CLI and interface for Windows, Linux and macOS | ✅ |
| CI on three systems, automatic release by tag | ✅ |
| Tests with real reference images on Linux, macOS and Windows | ✅ |
| CLI (Linux) and interface for Windows and Linux | ✅ |
| CI on Linux and Windows, automatic release by tag | ✅ |
| Tests with real reference images on Linux and Windows | ✅ |
| Update from inside the desktop app (signed packages, one click) | ✅ |
| GUI: privileged helper process instead of restarting the whole app as administrator | 🔜 |
| Signed and notarized binaries | 🔜 |
+9 -4
View File
@@ -6,6 +6,11 @@ Onde o Guará esteve, onde está e para onde vai. O que está marcado como feito
Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo · ⛔ fora do escopo
> Itens marcados como feitos para macOS refletem o que o CI do GitHub compilava e
> testava, antes da mudança para o Gitea. O CI do Gitea tem um único runner Linux;
> ele compila e testa Linux e Windows. O macOS deixou de ser compilado e testado —
> veja os [Limites conhecidos](README.pt-BR.md#limites-conhecidos) do README.
## Princípios que não mudam
1. **A origem é somente leitura.** Nada no núcleo escreve no dispositivo recuperado.
@@ -138,7 +143,7 @@ O acesso físico é o diferencial de ferramentas comerciais como a Cellebrite. D
| **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 |
| Relatório em PDF (A4, próprio) | ✅ |
| **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 |
| Binários assinados (Windows e macOS) | 🔜 |
| Binários assinados (Windows) | 🔜 |
A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve para triagem, recuperação e apoio, e o perito deve conferir os resultados por outro meio.
@@ -158,9 +163,9 @@ A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve
| Item | Estado |
|---|---|
| CLI e interface para Windows, Linux e macOS | ✅ |
| CI em três sistemas, release automática por tag | ✅ |
| Testes com imagens de referência reais em Linux, macOS e Windows | ✅ |
| CLI (Linux) e interface para Windows e Linux | ✅ |
| CI em Linux e Windows, release automática por tag | ✅ |
| Testes com imagens de referência reais em Linux e Windows | ✅ |
| Atualização dentro do app desktop (pacotes assinados, um clique) | ✅ |
| GUI: processo auxiliar privilegiado em vez de reiniciar o app inteiro como administrador | 🔜 |
| Binários assinados e notarizados | 🔜 |
+1 -1
View File
@@ -2,4 +2,4 @@
[English](SECURITY.md) · [Português](SECURITY.pt-BR.md)
Guará opens devices in read-only mode and processes potentially corrupted or malicious data (forged file systems and files). Vulnerabilities (memory corruption in parsers, improper writes to the source, path escape during recovery) must be reported privately through the **Security → Report a vulnerability** tab of the repository on GitHub. Do not open a public issue before the fix.
Guará opens devices in read-only mode and processes potentially corrupted or malicious data (forged file systems and files). Vulnerabilities (memory corruption in parsers, improper writes to the source, path escape during recovery) must be reported privately through the private vulnerability reporting feature of the repository on Gitea. Do not open a public issue before the fix.
+1 -1
View File
@@ -2,4 +2,4 @@
[English](SECURITY.md) · **Português**
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades (corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação) devem ser reportadas em privado pela aba **Security → Report a vulnerability** do repositório no GitHub. Não abra issue pública antes da correção.
Guará abre dispositivos em modo somente leitura e processa dados potencialmente corrompidos ou maliciosos (sistemas de arquivos e arquivos forjados). Vulnerabilidades (corrupção de memória em parsers, escrita indevida na origem, escape de caminho na recuperação) devem ser reportadas em privado pelo recurso de relato privado de vulnerabilidade do repositório no Gitea. Não abra issue pública antes da correção.
+4865
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,12 +1,13 @@
# Aplicativo desktop (Tauri 2). Fica fora do workspace principal porque exige
# as bibliotecas de WebView do sistema (WebKitGTK no Linux).
# Desktop application (Tauri 2). Kept outside the main workspace because it
# requires system WebView libraries (WebKitGTK on Linux).
[package]
name = "guara-gui"
description = "Guará: interface gráfica de recuperação de arquivos"
version = "1.3.1"
description = "Guará: graphical file recovery interface"
version = "1.5.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/guara"
authors = ["Guará contributors"]
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
rust-version = "1.80"
[workspace]
@@ -17,7 +18,6 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
guara-core = { path = "../../../crates/guara-core" }
+4 -2
View File
@@ -1,11 +1,13 @@
fn main() {
println!("cargo:rerun-if-changed=../../../logo.png");
let _ = std::fs::copy("../../../logo.png", "../ui/logo.png");
#[cfg(windows)]
{
// Ler discos brutos no Windows exige administrador: o manifesto pede elevação (UAC).
// Reading raw disks on Windows requires Administrator privileges: embed UAC elevation manifest.
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");
tauri_build::try_build(attrs).expect("tauri-build failed");
}
#[cfg(not(windows))]
tauri_build::build();
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schema/desktop-schema.json",
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissões da janela principal",
"description": "Main window permissions",
"windows": ["main"],
"permissions": ["core:default", "core:window:allow-destroy", "core:window:allow-close", "dialog:default"]
}
@@ -0,0 +1 @@
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"DenLine truncated
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Main window permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close","dialog:default"]}}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 416 KiB

+189 -96
View File
@@ -1,4 +1,4 @@
// Evita a janela de console extra no Windows em release.
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use guara_core::service::Engine;
@@ -6,95 +6,101 @@ use std::sync::Arc;
struct State(Arc<Engine>);
/// Ponte única entre a interface e o núcleo (mesmos métodos do modo `guara ui`).
/// 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> {
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()))
tauri::async_runtime::spawn_blocking(move || {
eng.rpc(&method, params).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
/// Versão nova encontrada na release do GitHub.
#[derive(serde::Serialize)]
struct UpdateInfo {
current: String,
version: String,
notes: String,
/// O app consegue se atualizar sozinho (AppImage, Windows e macOS). Instalação por pacote
/// (.deb/.rpm) só avisa e leva à página da release.
auto: bool,
url: String,
}
fn self_update_possible() -> bool {
!cfg!(target_os = "linux") || std::env::var_os("APPIMAGE").is_some()
}
/// Procura uma versão mais nova. `None` = já está na última.
#[tauri::command]
async fn update_check(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> {
use tauri_plugin_updater::UpdaterExt;
let found = app.updater().map_err(|e| e.to_string())?.check().await.map_err(|e| e.to_string())?;
Ok(found.map(|u| UpdateInfo {
current: u.current_version.clone(),
notes: u.body.clone().unwrap_or_default(),
url: format!("https://github.com/lucasrguerra/guara/releases/tag/v{}", u.version),
version: u.version,
auto: self_update_possible(),
}))
async fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
let eng = state.0.clone();
tauri::async_runtime::spawn_blocking(move || {
let status = eng.update_check().map_err(|e| e.to_string())?;
serde_json::to_value(status).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
/// Baixa, confere a assinatura (chave pública embutida no app), instala e reabre o app.
/// O progresso sai no evento `update-progress`.
#[tauri::command]
async fn update_install(app: tauri::AppHandle) -> Result<(), String> {
async fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> {
use tauri::Emitter;
use tauri_plugin_updater::UpdaterExt;
if !self_update_possible() {
return Err("Esta instalação não se atualiza sozinha (pacote do sistema). Baixe a versão nova na página da release.".into());
}
let update = app
.updater()
.map_err(|e| e.to_string())?
.check()
#[cfg(any(target_os = "linux", target_os = "macos"))]
let exe = elevated_exe().unwrap_or_else(|_| std::env::current_exe().unwrap_or_default());
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let exe = std::env::current_exe().unwrap_or_default();
// Blocking download/install must not run on the UI thread, or the window freezes.
let eng = state.0.clone();
tauri::async_runtime::spawn_blocking(move || {
eng.update_apply_desktop(&exe, move |phase| {
let (name, pct) = match phase.split_once(':') {
Some((n, p)) => (n, p.parse::<u8>().ok()),
None => (phase, None),
};
let _ = app.emit(
"update-progress",
serde_json::json!({ "phase": name, "percent": pct }),
);
})
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
.ok_or("Já está na versão mais recente.")?;
let (a, b) = (app.clone(), app.clone());
let mut got = 0u64;
update
.download_and_install(
move |chunk, total| {
got += chunk as u64;
let _ = a.emit("update-progress", serde_json::json!({ "downloaded": got, "total": total }));
},
move || {
let _ = b.emit("update-progress", serde_json::json!({ "installing": true }));
},
)
.await
.map_err(|e| format!("Não foi possível atualizar: {e}"))?;
// Rodando como administrador, o arquivo novo nasce do root: devolve ao usuário que abriu o app.
if let Some(p) = std::env::var_os("APPIMAGE") {
guara_core::owner::give_to_user(std::path::Path::new(&p));
}
app.restart()
}
/// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o
/// manifesto já exige elevação. O app novo encerra este quando abre; se a
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
/// `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 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();
@@ -102,7 +108,14 @@ fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
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"] {
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}"));
}
@@ -120,16 +133,21 @@ fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
pid,
exe.display()
);
let script = format!("do shell script \"{}\" with administrator privileges", shell.replace('\\', "\\\\").replace('"', "\\\""));
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}"))?;
let mut child = cmd
.spawn()
.map_err(|e| format!("não foi possível pedir privilégios: {e}"))?;
std::thread::spawn(move || {
// No Linux o pkexec só termina com sucesso quando o app elevado é fechado (e o app
// elevado já terá encerrado este processo). Qualquer falha antes disso vira aviso.
// 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);
@@ -139,45 +157,54 @@ fn relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> {
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()),
_ => 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(())
}
#[cfg(windows)]
{
let _ = app;
Err("o Guará já roda como administrador no Windows".into())
}
}
/// Caminho que o root consegue executar. No AppImage, `current_exe` aponta para
/// dentro de um ponto de montagem FUSE do usuário, inacessível ao root: usa o
/// próprio arquivo `.AppImage` (variável `APPIMAGE`).
/// 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()) {
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())
}
/// Serve os arquivos recuperados para pré-visualização (`guara://localhost/file/<job>/<id>`),
/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem.
/// 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 (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 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()
@@ -192,8 +219,17 @@ fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http:
}
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 (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) {
@@ -205,14 +241,24 @@ fn serve_file(eng: &Engine, req: &tauri::http::Request<Vec<u8>>) -> tauri::http:
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 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 = b.header(
"Content-Range",
format!(
"bytes {}-{}/{}",
start,
start + data.len() as u64 - 1,
total
),
);
}
b.body(data).unwrap_or_else(|_| err(500))
}
@@ -222,19 +268,22 @@ fn main() {
let for_protocol = engine.clone();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.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| {
// Quando abrimos elevados a pedido do app normal, ele fecha o app antigo.
.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()) {
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: só envia SIGTERM ao processo anterior do próprio usuário.
// SAFETY: only sends SIGTERM to the previous process owned by the invoking user.
unsafe {
if libc::geteuid() == 0 {
libc::kill(pid, libc::SIGTERM);
@@ -242,9 +291,53 @@ fn main() {
}
});
}
// 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, relaunch_elevated, update_check, update_install])
.invoke_handler(tauri::generate_handler![
rpc,
is_elevated,
relaunch_elevated,
update_check,
update_apply
])
.run(tauri::generate_context!())
.expect("erro ao iniciar o Guará");
}
+5 -16
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guara",
"version": "1.3.1",
"productName": "Guará",
"version": "1.5.0",
"identifier": "dev.guara.app",
"build": {
"frontendDist": "../ui"
@@ -15,7 +15,8 @@
"height": 780,
"minWidth": 420,
"minHeight": 560,
"resizable": true
"resizable": true,
"visible": false
}
],
"security": {
@@ -34,18 +35,6 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhENzRCRUU5NEI3QTAxQzgKUldUSUFYcEw2YjUwamFzMnpXM3ZKTEZPN1JIeXdLL3Q5SEF1M1FxQ284WFM0UWNsdlAyNUloWTEK",
"endpoints": [
"https://github.com/lucasrguerra/guara/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
]
}
}
+32 -12
View File
@@ -129,7 +129,7 @@ h1, h2, h3 { margin: 0; letter-spacing: -0.02em; }
.page { max-width: 1120px; margin: 0 auto; padding: 36px 40px 120px; animation: rise .35s cubic-bezier(.2, .8, .2, 1); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* ------------------------------------------------------------ componentes */
/* ------------------------------------------------------------ components */
.hero { padding: 56px 0 28px; max-width: 640px; }
.hero h1 { font-size: clamp(32px, 5vw, 48px); line-height: 1.05; font-weight: 700; }
.hero p { color: var(--text-2); font-size: 18px; margin: 14px 0 0; }
@@ -194,7 +194,7 @@ details.adv[open] summary { margin-bottom: 10px; }
.check { display: flex; gap: 10px; align-items: flex-start; padding: 6px 0; font-size: 14px; }
.check input { margin-top: 3px; accent-color: var(--accent); width: 16px; height: 16px; }
/* progresso */
/* progress */
.progress-card { background: var(--surface); border-radius: 22px; box-shadow: var(--shadow); padding: 26px; display: grid; gap: 14px; }
.bar { height: 10px; border-radius: 99px; background: color-mix(in srgb, var(--text) 9%, transparent); overflow: hidden; }
.bar > i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .3s ease; }
@@ -204,7 +204,7 @@ details.adv[open] summary { margin-bottom: 10px; }
.stat b { display: block; font-size: 28px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stat span { color: var(--text-2); font-size: 13px; }
/* resultados */
/* results */
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin: 22px 0 12px; }
.chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.chips::-webkit-scrollbar { display: none; }
@@ -212,7 +212,7 @@ details.adv[open] summary { margin-bottom: 10px; }
.chip[aria-pressed="true"] { background: var(--text); color: var(--bg); }
.chip small { opacity: .6; margin-left: 4px; font-variant-numeric: tabular-nums; }
.input, .select { border: 0; border-radius: 12px; padding: 9px 14px; background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); font: inherit; min-width: 0; }
/* O fundo do select é translúcido; a lista aberta não herda isso e vinha branca com texto claro no tema escuro. */
/* The select background is translucent; the open dropdown does not inherit it and rendered white with light text in dark mode. */
.select option, .select optgroup { background-color: var(--surface); color: var(--text); }
.input { flex: 1 1 200px; }
.input::placeholder { color: var(--text-3); }
@@ -255,7 +255,7 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
.scrim { display: none; }
.back { color: var(--accent); background: none; border: 0; cursor: pointer; font-weight: 500; padding: 0; margin-bottom: 8px; }
/* ------------------------------------------------------------ responsivo */
/* ------------------------------------------------------------ responsive */
@media (max-width: 1100px) {
.split { grid-template-columns: 1fr; }
.preview { position: static; order: -1; grid-template-columns: 120px 1fr; align-items: start; }
@@ -285,12 +285,12 @@ code, .mono { overflow-wrap: anywhere; font-family: var(--mono); font-size: .92e
* { animation: none !important; transition: none !important; }
}
/* ------------------------------------------------------------ tema */
/* ------------------------------------------------------------ theme */
.theme-switch { display: inline-flex; padding: 2px; border-radius: 999px; background: color-mix(in srgb, var(--text) 8%, transparent); }
.theme-switch button { border: 0; background: transparent; cursor: pointer; padding: 4px 10px; border-radius: 999px; font-size: 12px; color: var(--text-2); }
.theme-switch button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, .18); font-weight: 600; }
/* ------------------------------------------------------------ pré-visualização */
/* ------------------------------------------------------------ preview */
.viewer { position: relative; min-height: 160px; max-height: 52vh; overflow: auto; border-radius: 12px; background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--line); display: flex; flex-direction: column; align-items: center; }
.viewer.large { max-height: none; height: 100%; min-height: 0; }
.viewer > * { max-width: 100%; }
@@ -335,7 +335,7 @@ dialog.lightbox[open] { display: grid; }
.hero-logo { width: min(340px, 70%); height: auto; margin: 0 0 14px -6px; }
.pill.accent { background: var(--accent-soft); color: var(--accent); }
/* ------------------------------------------------------------ explorador de arquivos */
/* ------------------------------------------------------------ file explorer */
.main.fixed { overflow: hidden; }
.page.wide { max-width: none; padding: 14px 18px 0; }
.page.fill { height: 100%; display: flex; flex-direction: column; }
@@ -422,6 +422,21 @@ dialog.lightbox[open] { display: grid; }
.xp-insp .viewer { max-height: 38vh; min-height: 150px; }
.xp-ititle { display: flex; gap: 8px; align-items: flex-start; justify-content: space-between; } .xp-ititle b { overflow-wrap: anywhere; font-size: 15px; }
.xp-meta h5 { margin: 6px 0 6px; font-size: 12px; color: var(--text-3); font-weight: 600; }
/* folder browsing */
.xp-path { display: flex; align-items: center; gap: 2px; min-width: 0; overflow: hidden; }
.xp-cpart { border: 0; background: none; color: var(--text-2); font: inherit; font-size: 14px; padding: 4px 6px; border-radius: 6px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px; }
.xp-cpart:hover { background: var(--surface-2); color: var(--text); }
.xp-cpart:last-child { color: var(--text); font-weight: 600; }
.xp-csep { color: var(--text-3); }
.xp-lth .xp-fico { color: var(--accent); background: var(--accent-soft); border-radius: 8px; }
.xp-dir .xp-lnm b { font-weight: 600; }
.xp-dir[data-state="mixed"] .xp-chk { opacity: 1; background: var(--accent) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.2' stroke-linecap='round'%3E%3Cpath d='M7 12h10'/%3E%3C/svg%3E") center / 14px no-repeat; }
.xp-lrow[data-cur="1"] { box-shadow: inset 0 0 0 2px var(--accent); }
.xp-folder-insp { display: grid; gap: 14px; padding: 20px 4px; }
.xp-folder-insp h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
.xp-fbig { width: 64px; height: 64px; display: grid; place-items: center; border-radius: 16px; color: var(--accent); background: var(--accent-soft); }
.xp-fbig svg { width: 34px; height: 34px; }
.xp-folder-insp .row { display: flex; gap: 8px; flex-wrap: wrap; }
.xp-multi { display: grid; gap: 12px; padding: 20px 4px; text-align: center; } .xp-multi b { font-size: 18px; }
.xp-insp-x { display: none; align-self: flex-end; }
.xp-veil { display: none; }
@@ -467,7 +482,7 @@ dialog.lightbox[open] { display: grid; }
.cand .dev[aria-current="true"] { outline: 2px solid var(--accent); }
.cand .s { white-space: normal; }
/* ------------------------------------------------------------ visualizador de bancos de dados */
/* ------------------------------------------------------------ database viewer */
.db-sum { width: 100%; padding: 16px; display: grid; gap: 8px; text-align: left; }
.db-sum ul { list-style: none; margin: 4px 0; padding: 0; display: grid; gap: 2px; font-size: 13px; }
.db-sum li { display: flex; justify-content: space-between; gap: 10px; } .db-sum li small { color: var(--text-3); }
@@ -497,7 +512,7 @@ dialog.lightbox[open] { display: grid; }
.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) */
/* disk health (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; }
@@ -516,10 +531,10 @@ dialog.lightbox[open] { display: grid; }
.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: uma arte só (laranja sobre transparente) serve aos temas claro e escuro */
/* logo: a single artwork (orange on transparent) works for both light and dark themes */
.logo-img { display: block; flex: none; }
/* atualização do app */
/* app update */
.update-card { position: fixed; right: 18px; bottom: 18px; z-index: 60; width: min(400px, calc(100vw - 36px)); display: grid; gap: 10px; padding: 16px 18px; border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.update-card .upd-head { display: grid; gap: 2px; }
.update-card .upd-ver { font-size: 12.5px; color: var(--text-2); }
@@ -527,3 +542,8 @@ dialog.lightbox[open] { display: grid; }
.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.update-card .sub { margin: 0; }
pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; }
.update-card .upd-bar { margin-top: 10px; }
.list li.has-del { position: relative; }
.list li.has-del .del { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); opacity: 0; }
.list li.has-del:hover .del, .list li.has-del .del:focus-visible { opacity: 1; }
+158 -80
View File
@@ -1,5 +1,5 @@
// Guará: interface. Fala com o núcleo por `rpc(método, parâmetros)`:
// • app Tauri: invoke('rpc') • modo `guara ui`: POST /rpc/<método> com token.
// Guará: UI. Communicates with the core via `rpc(method, params)`:
// • Tauri app: invoke('rpc') • `guara ui` mode: POST /rpc/<method> with token.
import { createExplorer } from './explorer.js';
import { setLang, LOC, currentLang } from './i18n.js';
@@ -10,7 +10,7 @@ let token = params.get('token');
try {
if (token) sessionStorage.setItem('guara-token', token);
else token = sessionStorage.getItem('guara-token');
} catch { /* armazenamento indisponível */ }
} catch { /* storage unavailable */ }
async function rpc(method, p = {}) {
if (tauri?.core?.invoke) return tauri.core.invoke('rpc', { method, params: p });
@@ -29,7 +29,7 @@ const fileBase = tauri ? (isWin ? 'http://guara.localhost' : 'guara://localhost'
const thumbUrl = (job, id, s = 256) => `${fileBase}/thumb/${job}/${id}?s=${s}` + (tauri ? '' : `&t=${encodeURIComponent(token || '')}`);
const fileUrl = (job, id) => `${fileBase}/file/${job}/${id}` + (tauri ? '' : `?t=${encodeURIComponent(token || '')}`);
// Atualiza o HTML só quando mudou (evita piscar e perder o foco/hover).
// Update HTML only when changed (prevents flickering and losing focus/hover).
function setHTML(el, html) {
if (!el || el._h === html) return false;
el._h = html;
@@ -37,7 +37,7 @@ function setHTML(el, html) {
return true;
}
// ------------------------------------------------------------------ tema
// ------------------------------------------------------------------ theme
function applyTheme(t) {
const root = document.documentElement;
if (t === 'light' || t === 'dark') root.dataset.theme = t; else delete root.dataset.theme;
@@ -47,19 +47,19 @@ try { applyTheme(localStorage.getItem('guara-theme') || 'auto'); } catch { apply
document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async () => {
const t = b.dataset.themePick;
applyTheme(t);
try { localStorage.setItem('guara-theme', t); } catch { /* sem armazenamento */ }
try { localStorage.setItem('guara-theme', t); } catch { /* no storage */ }
saveSettings({ theme: t });
}));
// ------------------------------------------------------------------ configurações
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true };
// ------------------------------------------------------------------ settings
// (declared before use: `S` is defined further below, so the object lives here)
const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true, session_retention_days: 0 };
async function saveSettings(patch) {
Object.assign(SETTINGS, patch);
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
try { await rpc('settings_set', SETTINGS); } catch { /* continue with local storage only */ }
}
// ------------------------------------------------------------------ utilidades
// ------------------------------------------------------------------ utilities
const $ = (s, el = document) => el.querySelector(s);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const human = (n) => {
@@ -147,19 +147,20 @@ function info({ title, html }) {
// ------------------------------------------------------------------ estado
const S = {
elevated: true,
devices: [],
defaults: {},
sessions: [], // {job, target, label}
device: null, // {path,name,...}
layout: null,
job: null, // id da varredura atual
job: null, // active scan job id
status: null,
filters: { category: null, minConfidence: null, query: '', sort: 'confidence' },
items: [],
total: 0,
selected: new Set(),
allFiltered: false,
current: null, // arquivo em foco (pré-visualização)
current: null, // focused file (preview)
poll: null,
recJob: null,
};
@@ -200,11 +201,36 @@ function renderSide() {
const rl = $('#recentList');
rl.dataset.empty = 'Nada salvo ainda';
rl.innerHTML = (S.recent || []).map((r, i) => `
<li><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button></li>`).join('');
<li class="has-del"><button class="dev" data-r="${i}" title="${esc(r.path)}"><span class="ico">${icon('default')}</span>
<span><span class="t">${esc((r.target || '').split(/[\\/]/).pop() || 'Sessão')}</span><span class="s">${r.found.toLocaleString(LOC())} arquivos · ${esc(r.status)} · ${new Date(r.saved_at * 1000).toLocaleString(LOC(), { dateStyle: 'short', timeStyle: 'short' })}</span></span></button><button class="icon-btn small del" data-del="${i}" title="Apagar esta sessão" aria-label="Apagar esta sessão"><svg viewBox="0 0 24 24" width="14" height="14"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg></button></li>`).join('');
rl.querySelectorAll('.dev').forEach((b) => (b.onclick = () => openRecent(S.recent[+b.dataset.r])));
rl.querySelectorAll('.del').forEach((b) => (b.onclick = (e) => { e.stopPropagation(); deleteRecent(S.recent[+b.dataset.del]); }));
}
function confirmDlg({ title, text, ok }) {
return new Promise((resolve) => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>${esc(title)}</h2><p>${text}</p><menu><button class="btn" value="cancel">Cancelar</button><button class="btn danger" value="ok">${esc(ok)}</button></menu></form>`;
d.onclose = () => resolve(d.returnValue === 'ok');
d.showModal();
});
}
async function deleteRecent(r) {
if (!r) return;
const name = (r.target || '').split(/[\\/]/).pop() || 'Sessão';
if (!await confirmDlg({ title: 'Apagar sessão salva', text: `A sessão de <code>${esc(name)}</code> será apagada de vez. Os arquivos do disco não são afetados.`, ok: 'Apagar' })) return;
try { await rpc('session_delete', { path: r.path }); toast('Sessão apagada'); } catch (e) { toast(e.message); }
loadRecent();
}
$('#clearRecentBtn')?.addEventListener('click', async () => {
if (!(S.recent || []).length) { toast('Nada para apagar'); return; }
if (!await confirmDlg({ title: 'Apagar todas as sessões salvas', text: 'Todas as sessões salvas sozinhas serão apagadas de vez. Sessões abertas agora são mantidas.', ok: 'Apagar tudo' })) return;
try { const r = await rpc('sessions_prune', { all: true }); toast(`Sessões apagadas: ${r.removed}`); } catch (e) { toast(e.message); }
loadRecent();
});
async function loadRecent() {
try { S.recent = await rpc('sessions_list'); } catch { S.recent = []; }
renderSide();
@@ -258,25 +284,26 @@ $('#loadSessionBtn').onclick = async () => {
} catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `<p>${esc(e.message)}</p>` }); }
};
// ------------------------------------------------------------------ início
// ------------------------------------------------------------------ home
function renderHome() {
main.classList.remove('fixed');
stopPoll();
S.device = null; S.job = null;
renderSide();
main.innerHTML = `<div class="page">
<section class="hero"><img class="logo-img hero-logo" src="guara.png" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1>
<section class="hero"><img class="logo-img hero-logo" src="logo.png" alt="Guará"><h1>Encontre o que você achou que tinha perdido.</h1>
<p>Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.</p></section>
<div class="cards">
<div class="card"><h3>Uma varredura, tudo junto</h3><p>Metadados do sistema de arquivos e busca por assinatura na mesma passada, sem duplicatas.</p></div>
<div class="card"><h3>Confiança por arquivo</h3><p>Cada achado mostra a chance de abrir corretamente: alta, média ou baixa.</p></div>
<div class="card"><h3>Origem protegida</h3><p>Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.</p></div>
</div>
${S.devices.length ? '' : `<div class="notice info" style="margin-top:22px">${warnSvg}<div><b>Nenhum disco apareceu.</b> Discos brutos exigem administrador. Rode <code>sudo guara ui</code> (Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.</div></div>`}
${!S.elevated ? `<div class="notice bad" style="margin-top:22px">${warnSvg}<div>Este disco exige permissão de administrador. <button class="btn sm" id="elevateHome" style="margin-left:6px">Reiniciar como administrador</button></div></div>` : S.devices.length ? '' : `<div class="notice info" style="margin-top:22px">${warnSvg}<div><b>Nenhum disco apareceu.</b> Discos brutos exigem administrador. Rode <code>sudo guara ui</code> (Linux/macOS) ou abra o Guará como administrador (Windows). Você ainda pode abrir um arquivo de imagem pelo menu.</div></div>`}
</div>`;
$('#elevateHome')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e))));
}
// ------------------------------------------------------------------ dispositivo
// ------------------------------------------------------------------ device
async function openDevice(d) {
main.classList.remove('fixed');
stopPoll();
@@ -289,7 +316,7 @@ async function openDevice(d) {
try { layout = await rpc('inspect', { path: d.path }); } catch (e) { err = e.message; }
S.layout = layout;
renderDevice(err);
// saúde (SMART) em segundo plano: não atrasa a tela
// Query SMART health in the background without blocking the screen.
rpc('health', { path: d.path }).then((h) => { if (S.device === d) { S.health = h; paintHealth(); } }).catch(() => {});
}
@@ -323,7 +350,7 @@ function paintHealth() {
${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)
// Fields that triggered the alert (standard summary fields appear below).
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>
@@ -336,7 +363,7 @@ function paintHealth() {
$('#healthImg')?.addEventListener('click', startImage);
}
/** Disco com sinais de defeito: oferece criar a imagem antes de varrer. */
/** Disk showing signs of failure: offer to create a disk image before scanning. */
function confirmRisky() {
return new Promise((resolve) => {
const h = S.health;
@@ -530,8 +557,8 @@ function openSearch(job, terms) {
(async () => { while (await tick2()) await new Promise((r) => setTimeout(r, 700)); })();
}
// ------------------------------------------------------------------ varredura
let EX = null; // explorador de arquivos (grade, navegação, detalhes)
// ------------------------------------------------------------------ scan
let EX = null; // file explorer (grid, navigation, details)
function openJob(s) {
stopPoll();
@@ -554,7 +581,7 @@ async function tick() {
S.status = st;
paintStatus();
EX?.onStatus(st);
// continua consultando enquanto a varredura roda ou as datas/EXIF ainda estão sendo lidas
// Keep polling while the scan is running or dates/EXIF are still being enriched.
if (st.status !== 'running' && !st.enriching) {
stopPoll();
loadRecent();
@@ -588,7 +615,9 @@ function paintStatus() {
const rate = p.total && st.elapsed > 1 && p.done ? p.done / st.elapsed : 0;
const eta = rate && running ? dur((p.total - p.done) / rate) : null;
const enr = st.enriching ? `<span class="sub">Lendo datas e metadados das fotos… ${st.enriching.done.toLocaleString(LOC())}/${st.enriching.total.toLocaleString(LOC())}</span>` : '';
const buttons = running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
const stopping = running && st.stopping;
const buttons = stopping ? '<button class="btn sm" disabled>Pausando…</button>'
: running ? '<button class="btn sm" data-act="pause">Pausar</button><button class="btn sm danger" data-act="stop">Parar</button>'
: st.status === 'paused' ? `<button class="btn sm primary" data-act="${st.source === false ? 'reconnect' : 'resume'}">${st.source === false ? 'Reconectar o disco' : 'Continuar'}</button><button class="btn sm" data-act="relink">Escolher a origem…</button>` : '';
const relink = st.source === false ? '<button class="btn sm primary" data-act="relink">Escolher a origem…</button>' : '';
const tools = relink + (!running && st.found ? '<button class="btn sm" data-act="csv">Exportar CSV</button><button class="btn sm" data-act="report">Relatório</button><button class="btn sm" data-act="save">Salvar sessão…</button>' : '');
@@ -613,7 +642,7 @@ function paintStatus() {
async function chooseSource() {
let cands = [];
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* segue só com o campo manual */ }
try { cands = await rpc('session_candidates', { job: S.job }); } catch { /* fall back to manual path input */ }
const st = S.status || {};
const size = st.layout?.size;
const d = $('#dlg');
@@ -649,8 +678,8 @@ async function chooseSource() {
async function onProgressClick(e) {
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return;
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); }
else if (act === 'stop') await rpc('scan_stop', { job: S.job });
if (act === 'pause') { await rpc('scan_pause', { job: S.job }); toast('Pausando…'); tick(); }
else if (act === 'stop') { await rpc('scan_stop', { job: S.job }); tick(); }
else if (act === 'resume') {
try { await rpc('scan_resume', { job: S.job }); stopPoll(); S.poll = setInterval(tick, 500); tick(); }
catch (err) { toast(err.message); chooseSource(); }
@@ -685,19 +714,25 @@ async function onProgressClick(e) {
}
async function recover(sel) {
// From folder browsing: each marked folder is recreated from itself down
// (a/b/Projeto -> Projeto/...), so there is no layout to choose.
const bySelection = !!sel.selection;
const f = await askForm({
title: 'Recuperar para…',
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>',
text: 'Escolha uma pasta em <b>outro disco</b>. O Guará nunca grava no disco de origem, para não sobrescrever o que ainda dá para salvar. <span class="sub">Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.</span>'
+ (bySelection ? ' <span class="sub">Cada pasta marcada é recriada no destino a partir dela mesma, com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino.</span>' : '')
+ (bySelection && sel.filtered ? ' <span class="sub"><b>Atenção:</b> pastas marcadas são recuperadas inteiras; os filtros ativos não se aplicam a elas.</span>' : ''),
ok: 'Recuperar',
fields: [
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } },
{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] },
...(bySelection ? [] : [{ name: 'layout', label: 'Organizar os arquivos', value: 'folders', options: [['folders', 'Pastas originais (e por tipo quando não há pasta)'], ['type', 'Por tipo (Fotos, Documentos, Vídeos...)'], ['date', 'Por data (ano e mês)'], ['flat', 'Tudo na mesma pasta']] }]),
],
});
if (!f || !f.dest) return;
const dest = f.dest;
const { filtered, ...target } = sel;
try {
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout, ...sel });
const { job } = await rpc('recover_start', { job: S.job, dest, layout: f.layout || 'folders', ...target });
watchAux(job, 'Recuperando arquivos', (st) => {
const r = st.report || {};
const ok = (r.recovered || []).length, bad = (r.failed || []).length;
@@ -709,7 +744,7 @@ async function recover(sel) {
} catch (e) { info({ title: 'Não foi possível recuperar', html: `<p>${esc(e.message)}</p>` }); }
}
// Modal de progresso de tarefas auxiliares (recuperação / imagem).
// Progress modal for auxiliary jobs (recovery / disk imaging).
function watchAux(job, title, doneHtml) {
const d = $('#dlg');
d.onclose = null;
@@ -738,89 +773,131 @@ function watchAux(job, title, doneHtml) {
async function openJobFromList(s) { openJob(s); }
// ------------------------------------------------------------------ configurações (janela)
// ------------------------------------------------------------------ settings modal
$('#settingsBtn')?.addEventListener('click', () => {
const d = $('#dlg');
d.innerHTML = `<form method="dialog"><h2>Configurações</h2>
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o GitHub para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''}
<label class="fld"><span>Apagar sessões salvas sozinhas após</span><select class="select" name="retention"><option value="0">Nunca</option><option value="7">7 dias</option><option value="30">30 dias</option><option value="90">90 dias</option></select></label>
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''}
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
d.querySelector('[name=lang]').value = SETTINGS.lang;
d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); });
d.querySelector('[name=retention]').value = String([0, 7, 30, 90].includes(SETTINGS.session_retention_days) ? SETTINGS.session_retention_days : 0);
d.querySelector('#updNow')?.addEventListener('click', (e) => {
e.preventDefault();
if (UPD) applyUpdate();
else checkUpdate(true);
});
d.onclose = () => {
if (d.returnValue !== 'ok') return;
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked };
const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked, session_retention_days: +d.querySelector('[name=retention]').value };
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
setLang(patch.lang);
patch.ui_lang = currentLang();
saveSettings(patch);
saveSettings(patch).then(() => rpc('sessions_prune', {}).then(loadRecent).catch(() => {}));
EX?.relang?.();
toast('Salvo');
};
d.showModal();
});
// ------------------------------------------------------------------ atualização do app
// O núcleo do app (Tauri) consulta o `latest.json` da release no GitHub, confere a assinatura do
// pacote com a chave pública embutida, instala e reabre. No navegador (`guara ui`) não há nada a fazer.
// ------------------------------------------------------------------ app updater
// The desktop shell (Tauri) queries Gitea Releases API and notifies the user.
// No-op in browser mode (`guara ui`).
let UPD = null;
let updating = false;
async function applyUpdate() {
if (updating || !tauri?.core?.invoke) return;
updating = true;
const updApply = $('#updApply');
const updNow = $('#updNow');
const setPhase = (txt, pct) => {
const card = $('#updateCard');
if (card) {
let bar = card.querySelector('.upd-bar');
if (!bar) { bar = document.createElement('div'); bar.className = 'bar upd-bar'; bar.innerHTML = '<i></i>'; card.appendChild(bar); }
bar.classList.toggle('indet', pct == null);
bar.firstChild.style.width = pct == null ? '35%' : `${pct}%`;
}
if (pct != null) txt = `${txt} ${pct}%`;
if (updApply) { updApply.disabled = true; updApply.textContent = txt; }
if (updNow) { updNow.disabled = true; updNow.textContent = txt; }
};
setPhase('Baixando a atualização…');
let unlisten = null;
if (tauri?.event?.listen) {
try {
unlisten = await tauri.event.listen('update-progress', (event) => {
const phase = event.payload?.phase;
const pct = event.payload?.percent;
if (phase === 'downloading') setPhase('Baixando a atualização…', pct);
else if (phase === 'verifying') setPhase('Verificando integridade…');
else if (phase === 'installing') setPhase('Instalando a atualização…');
else if (phase === 'restarting') setPhase('Reiniciando…');
});
} catch (_) {}
}
try {
await tauri.core.invoke('update_apply');
setPhase('Atualização concluída');
} catch (e) {
updating = false;
$('#updateCard .upd-bar')?.remove();
if (updApply) { updApply.disabled = false; updApply.textContent = 'Atualizar agora'; }
if (updNow) { updNow.disabled = false; updNow.textContent = UPD?.version ? `atualizar para ${UPD.version}` : 'Verificar agora'; }
toast(`Falha na atualização: ${e}`);
} finally {
if (unlisten) unlisten();
}
}
async function checkUpdate(manual) {
if (!tauri?.core?.invoke) return;
const updNow = $('#updNow');
if (updNow) updNow.textContent = 'checando…';
try {
UPD = await tauri.core.invoke('update_check');
const status = await tauri.core.invoke('update_check');
if (status?.update_available) {
UPD = {
current: status.current_version,
version: status.latest_version,
url: 'https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases',
};
renderUpdateCard();
if (manual) toast(UPD ? `Nova versão ${UPD.version} disponível` : 'Você já está na versão mais recente');
if (updNow) updNow.textContent = `atualizar para ${status.latest_version}`;
if (manual) toast(`Nova versão ${status.latest_version} disponível`);
} else {
UPD = null;
renderUpdateCard();
if (updNow) updNow.textContent = 'já atualizado';
if (manual) toast('Você já está na versão mais recente');
}
} catch (e) {
if (updNow) updNow.textContent = 'Verificar agora';
if (manual) toast(`Não foi possível verificar atualizações: ${e}`);
}
}
function renderUpdateCard(state) {
function renderUpdateCard() {
let el = $('#updateCard');
if (!UPD) { el?.remove(); return; }
if (!el) { el = document.createElement('div'); el.id = 'updateCard'; el.className = 'update-card'; el.setAttribute('role', 'status'); document.body.appendChild(el); }
if (state === 'busy') return;
el.innerHTML = `<div class="upd-head"><b>Nova versão do Guará disponível</b><span class="mono upd-ver">v${esc(UPD.current)} → v${esc(UPD.version)}</span></div>
${UPD.auto ? '' : '<p class="sub">Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:</p><p class="mono upd-url">' + esc(UPD.url) + '</p>'}
<p class="sub">Instalação automática da nova versão com um clique:</p>
<div class="upd-actions">
${UPD.auto ? '<button class="btn primary sm" id="updGo">Atualizar agora</button>' : ''}
${UPD.notes ? '<button class="btn sm" id="updNotes">Novidades</button>' : ''}
<button class="btn primary sm" id="updApply">Atualizar agora</button>
<button class="btn sm" id="updLater">Depois</button>
</div>`;
$('#updGo')?.addEventListener('click', installUpdate);
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
$('#updNotes')?.addEventListener('click', () => info({ title: `Guará ${UPD.version}`, html: `<pre class="upd-notes">${esc(UPD.notes)}</pre>` }));
$('#updApply')?.addEventListener('click', () => applyUpdate());
}
async function installUpdate() {
const el = $('#updateCard');
el.innerHTML = `<div class="upd-head"><b>Atualizando o Guará…</b></div>
<div class="bar indet" id="updBar"><i style="width:0"></i></div><p class="sub" id="updMsg">Baixando a atualização…</p>`;
renderUpdateCard('busy');
try {
await tauri.core.invoke('update_install');
} catch (e) {
toast(String(e));
renderUpdateCard();
}
}
function updateProgress(p) {
const bar = $('#updBar'); const msg = $('#updMsg');
if (!bar || !msg) return;
if (p.installing) { bar.classList.add('indet'); msg.textContent = 'Instalando… o Guará vai reabrir sozinho.'; return; }
if (p.total) {
const pct = Math.min(100, Math.round((p.downloaded / p.total) * 100));
bar.classList.remove('indet');
bar.firstElementChild.style.width = `${pct}%`;
msg.textContent = `Baixando… ${pct}%`;
}
}
// ------------------------------------------------------------------ fechar com varredura em andamento
// ------------------------------------------------------------------ close while scan is running
function askClose() {
return new Promise((resolve) => {
const d = $('#dlg');
@@ -846,14 +923,14 @@ function watchClose() {
try {
await rpc('scan_pause', { job: S.job });
for (let i = 0; i < 40; i++) { const st = await rpc('status', { job: S.job }); if (st.status !== 'running') break; await new Promise((r) => setTimeout(r, 250)); }
await new Promise((r) => setTimeout(r, 600)); // dá tempo do salvamento automático
} catch { /* sai mesmo assim */ }
await new Promise((r) => setTimeout(r, 600)); // allow auto-save to flush
} catch { /* exit anyway */ }
}
win.destroy();
});
}
// ------------------------------------------------------------------ início do app
// ------------------------------------------------------------------ app startup
(async function init() {
try {
const v = await rpc('version');
@@ -868,14 +945,15 @@ function watchClose() {
setLang(SETTINGS.lang);
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
watchClose();
tauri?.event?.listen?.('update-progress', (e) => updateProgress(e.payload));
if (SETTINGS.updates !== false) checkUpdate(false);
if (tauri) S.elevated = await tauri.core.invoke('is_elevated').catch(() => true);
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
} catch (e) {
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando <code>guara ui</code> (ele inclui o token).</div></div></div>`;
return;
}
await loadDevices();
try { await rpc('sessions_prune', {}); } catch { /* retention is best-effort */ }
await loadRecent();
renderHome();
})();
+230 -32
View File
@@ -1,10 +1,10 @@
// Explorador de arquivos recuperados: grade de miniaturas (virtualizada, aguenta
// dezenas de milhares de itens), navegação por tipo/data/pasta/câmera, seleção
// estilo gerenciador de arquivos (Ctrl/Shift/teclado), painel de detalhes e
// visualização rápida em tela cheia.
// Recovered file explorer: thumbnail grid (virtualized, handles
// tens of thousands of items), navigation by type/date/folder/camera, file-manager
// style selection (Ctrl/Shift/keyboard), inspector panel, and
// fullscreen quick look.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
import { LOC } from './i18n.js';
import { LOC, tr } from './i18n.js';
const CAT = {
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'],
@@ -28,6 +28,8 @@ const I = {
pin: svg('<path d="M12 21s-6-5.3-6-10a6 6 0 1 1 12 0c0 4.7-6 10-6 10z"/><circle cx="12" cy="11" r="2"/>', 14),
warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20),
folder: svg('<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'),
folderBig: svg('<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>', 22),
};
export function createExplorer(deps) {
@@ -62,11 +64,19 @@ export function createExplorer(deps) {
lastFound: -1,
wasEnriching: false,
status: null,
// folder browsing: current folder ('' = root, '@carved' = found by
// signature), its subfolders, and a selection of its own that keeps
// marked and unmarked folders and files across levels
path: '',
dirs: [],
curDir: null,
fs: { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() },
fsCount: null,
};
try {
E.view = localStorage.getItem('guara-view') || 'grid';
E.tile = +localStorage.getItem('guara-tile') || 150;
} catch { /* sem armazenamento */ }
} catch { /* no storage */ }
host.innerHTML = `<div class="xp" data-view="${E.view}">
<div class="xp-bar">
@@ -74,7 +84,7 @@ export function createExplorer(deps) {
<div class="xp-crumb" id="xpCrumb"></div>
<input class="input xp-q" id="xpQ" type="search" placeholder="Buscar por nome, pasta ou câmera" aria-label="Buscar">
<select class="select" id="xpSort" aria-label="Ordenar">${SORTS.map(([k, n]) => `<option value="${k}">${n}</option>`).join('')}</select>
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button></div>
<div class="theme-switch" role="group" aria-label="Modo de exibição"><button data-view="grid" aria-label="Grade" title="Grade de miniaturas">${I.grid}</button><button data-view="list" aria-label="Lista" title="Lista">${I.list}</button><button data-view="folders" aria-label="Pastas" title="Navegar pelas pastas">${I.folder}</button></div>
<input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
</div>
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
@@ -91,7 +101,7 @@ export function createExplorer(deps) {
$('#xpSort').value = E.sort;
$('#xpSize').value = E.tile;
// ------------------------------------------------------------ dados
// ------------------------------------------------------------ data
const toQuery = () => {
const f = E.filters;
const q = {};
@@ -112,7 +122,10 @@ export function createExplorer(deps) {
};
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
const folderMode = () => E.view === 'folders';
async function reload() {
if (folderMode()) return reloadFolder();
const token = ++E.reloadToken;
E.lastReload = Date.now();
const base = { ...toQuery(), sort: E.sort };
@@ -132,6 +145,29 @@ export function createExplorer(deps) {
paintAll();
}
async function reloadFolder() {
const token = ++E.reloadToken;
E.lastReload = Date.now();
const { folder, ...filters } = toQuery();
const base = { ...filters, sort: E.sort === 'date' || E.sort === 'date_asc' ? E.sort : E.sort || 'name' };
let items = [], total = 0, dirs = null;
for (;;) {
let r;
try { r = await rpc('browse', { job, path: E.path, query: { ...base, offset: items.length, limit: 2000 } }); } catch (e) { toast(e.message); return; }
if (token !== E.reloadToken) return;
if (!dirs) dirs = r.folders;
items = items.concat(r.files);
total = r.total_files;
if (!r.files.length || items.length >= total || items.length >= 30000) break;
}
E.dirs = dirs || [];
E.items = items;
E.total = total;
paintCrumb();
relayout();
paintAll();
}
async function loadFacets() {
try { E.facets = await rpc('facets', { job }); } catch { return; }
paintNav();
@@ -139,8 +175,8 @@ export function createExplorer(deps) {
const reloadAll = () => { reload(); loadFacets(); };
// ------------------------------------------------------------ layout virtual
// Agrupa por dia; se houver poucos itens por dia, agrupa por mês.
// ------------------------------------------------------------ virtual layout
// Group by day; if there are too few items per day, group by month.
let groupBy = 'day';
const dayKey = (t) => (t == null ? 'Sem data' : new Date(t * 1000).toISOString().slice(0, groupBy === 'day' ? 10 : 7));
const dayLabel = (k) => {
@@ -150,14 +186,14 @@ export function createExplorer(deps) {
: new Date(k + '-01T00:00:00Z').toLocaleDateString(LOC(), { timeZone: 'UTC', month: 'long', year: 'numeric' });
};
const fmtDate = (t) => (t == null ? 'sem data' : new Date(t * 1000).toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'short' }));
// Metadados do núcleo trazem datas em ISO 8601; o idioma decide a ordem.
// Core metadata provides ISO 8601 dates; the active locale determines the order.
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
const fmtMeta = (v) => ISO_DATE.test(v) ? new Date(v.endsWith('Z') ? v : v + 'Z').toLocaleString(LOC(), { timeZone: 'UTC', dateStyle: 'medium', timeStyle: 'medium' }) : v;
const fmtDay = (t) => (t == null ? '' : new Date(t * 1000).toLocaleDateString(LOC(), { timeZone: 'UTC', day: '2-digit', month: 'short', year: 'numeric' }));
function geometry() {
const W = Math.max(200, scroll.clientWidth - PAD * 2);
if (E.view === 'list') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
if (E.view === 'list' || E.view === 'folders') return { W, cols: 1, tw: W, cap: 0, rowH: 54 };
const T = E.tile;
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
const tw = Math.floor((W - GAP * (cols - 1)) / cols);
@@ -176,6 +212,9 @@ export function createExplorer(deps) {
}
const rows = [];
let y = PAD;
if (folderMode()) {
E.dirs.forEach((_, d) => { rows.push({ t: 'dir', y, h: G.rowH, d }); y += G.rowH; });
}
let cur = null, rowItems = [];
const flush = () => {
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
@@ -195,7 +234,7 @@ export function createExplorer(deps) {
});
flush();
if (grouped) {
// contagem por grupo
// count per group
for (let r = 0; r < rows.length; r++) {
if (rows[r].t !== 'head') continue;
let n = 0;
@@ -238,15 +277,90 @@ export function createExplorer(deps) {
el.style.top = `${row.y}px`;
el.style.height = `${row.h}px`;
el.dataset.ri = ri;
el.innerHTML = row.t === 'head' ? headHtml(row) : row.idx.map((i) => (E.view === 'list' ? listHtml(i) : tileHtml(i))).join('');
el.innerHTML = row.t === 'head' ? headHtml(row) : row.t === 'dir' ? dirHtml(row.d) : row.idx.map((i) => (E.view === 'grid' ? tileHtml(i) : listHtml(i))).join('');
E.rendered.set(ri, el);
frag.appendChild(el);
}
sizer.appendChild(frag);
}
// ------------------------------------------------------------ itens
const isSel = (id) => E.allFiltered || E.sel.has(id);
// ------------------------------------------------------------ items
// `p` is inside `d` (or is `d`); '' holds everything, '@carved' only itself.
const under = (p, d) => d === '' || p === d || (d !== '@carved' && p.startsWith(d + '/'));
// Deepest marked/unmarked folder holding `p` decides, as in the core.
const covered = (p) => {
let best = null;
for (const d of E.fs.folders) if (under(p, d) && (best == null || d.length > best[0].length)) best = [d, true];
for (const d of E.fs.xfolders) if (under(p, d) && (best == null || d.length >= best[0].length)) best = [d, false];
return !!best && best[1];
};
const dirOf = (it) => (it.origin === 'carved' ? '@carved' : (it.dir || '').replace(/^\/+|\/+$/g, ''));
const fileOn = (it) => !E.fs.xfiles.has(it.id) && (E.fs.files.has(it.id) || covered(dirOf(it)));
// 'on', 'off' or 'mixed' for a folder row
const dirState = (d) => {
const on = covered(d);
const inside = (p) => p !== d && under(p, d);
const marks = on
? [...E.fs.xfolders].some(inside) || [...E.fs.xfiles.values()].some((p) => under(p, d)) || [...E.fs.folders].some(inside)
: [...E.fs.folders].some(inside) || [...E.fs.files.values()].some((p) => under(p, d));
return marks ? 'mixed' : on ? 'on' : 'off';
};
const fsEmpty = () => !E.fs.folders.size && !E.fs.files.size;
const dropInside = (d) => {
for (const k of [...E.fs.folders]) if (under(k, d)) E.fs.folders.delete(k);
for (const k of [...E.fs.xfolders]) if (under(k, d)) E.fs.xfolders.delete(k);
for (const [id, p] of [...E.fs.files]) if (under(p, d)) E.fs.files.delete(id);
for (const [id, p] of [...E.fs.xfiles]) if (under(p, d)) E.fs.xfiles.delete(id);
};
// Like a file manager's tri-state box: a fully marked folder is unmarked;
// an unmarked or partly marked one becomes fully marked.
function toggleDir(d) {
const unmark = dirState(d) === 'on';
dropInside(d);
if (unmark) { if (covered(d)) E.fs.xfolders.add(d); } else if (!covered(d)) E.fs.folders.add(d);
fsChanged();
}
function toggleFile(it) {
const p = dirOf(it);
if (fileOn(it)) { E.fs.files.delete(it.id); if (covered(p)) E.fs.xfiles.set(it.id, p); } else { E.fs.xfiles.delete(it.id); if (!covered(p)) E.fs.files.set(it.id, p); }
fsChanged();
}
const fsSelection = () => ({
folders: [...E.fs.folders], ids: [...E.fs.files.keys()], exclude: [...E.fs.xfiles.keys()], exclude_folders: [...E.fs.xfolders],
});
let countT;
function fsChanged() {
paintSel();
clearTimeout(countT);
if (fsEmpty()) { E.fsCount = null; paintActions(); return; }
countT = setTimeout(async () => {
try { E.fsCount = await rpc('selection_count', { job, selection: fsSelection() }); } catch { E.fsCount = null; }
paintActions();
}, 120);
}
function goTo(path) {
E.path = path; E.curDir = null; E.curId = null;
scroll.scrollTop = 0;
paintInspector();
reload();
}
const parentOf = (p) => (p === '@carved' || !p.includes('/') ? '' : p.slice(0, p.lastIndexOf('/')));
const dirLabel = (p) => (p === '' ? 'Raiz' : p === '@carved' ? 'Por assinatura' : p.split('/').pop());
function dirHtml(d) {
const it = E.dirs[d];
const st = dirState(it.path);
return `<div class="xp-lrow xp-dir" role="option" data-dir="${d}" aria-selected="${st === 'on'}" data-state="${st}" ${E.curDir === it.path ? 'data-cur="1"' : ''} title="${esc(it.path === '@carved' ? 'Arquivos encontrados por assinatura, sem pasta conhecida' : it.path)}">
<span class="xp-chk" data-chk="1" aria-hidden="true"></span><div class="xp-lth"><span class="xp-lico xp-fico">${I.folderBig}</span></div>
<div class="xp-lnm"><b>${esc(it.name)}</b><small>${it.files.toLocaleString(LOC())} ${tr(it.files === 1 ? 'arquivo' : 'arquivos')}</small></div>
<span class="xp-lcol"></span><span class="xp-lcol num">${human(it.bytes)}</span><span></span>
</div>`;
}
const isSel = (id) => {
if (folderMode()) { const it = E.items[idxOf(id)]; return !!it && fileOn(it); }
return E.allFiltered || E.sel.has(id);
};
function headHtml(row) {
return `<div class="xp-head"><b>${esc(dayLabel(row.key))}</b><small>${row.count} ${row.count === 1 ? 'item' : 'itens'}</small><button class="xp-hsel" data-hsel="${row.start}" data-n="${row.count}" aria-label="Selecionar este grupo">Selecionar grupo</button></div>`;
@@ -288,7 +402,7 @@ export function createExplorer(deps) {
</div>`;
}
// imagens que falham viram um aviso (arquivo possivelmente danificado)
// failed images become a warning (file possibly damaged)
scroll.addEventListener('error', (e) => {
const img = e.target;
if (img.tagName !== 'IMG' || !img.dataset.th) return;
@@ -300,7 +414,7 @@ export function createExplorer(deps) {
: `<div class="xp-ico c-bad" title="Não foi possível gerar a miniatura (arquivo possivelmente danificado)"><span>${I.warn}</span><b>?</b></div>`);
}, true);
// ------------------------------------------------------------ seleção
// ------------------------------------------------------------ selection
const selCount = () => (E.allFiltered ? E.total : E.sel.size);
const idxOf = (id) => E.items.findIndex((x) => x.id === id);
@@ -310,7 +424,15 @@ export function createExplorer(deps) {
el.setAttribute('aria-selected', String(isSel(id)));
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur;
};
scroll.querySelectorAll('.xp-tile, .xp-lrow').forEach(set);
scroll.querySelectorAll('.xp-tile, .xp-lrow:not(.xp-dir)').forEach(set);
scroll.querySelectorAll('.xp-dir').forEach((el) => {
const d = E.dirs[+el.dataset.dir];
if (!d) return;
const st = dirState(d.path);
el.dataset.state = st;
el.setAttribute('aria-selected', String(st === 'on'));
if (E.curDir === d.path) el.dataset.cur = '1'; else delete el.dataset.cur;
});
paintActions();
}
@@ -353,6 +475,21 @@ export function createExplorer(deps) {
new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
scroll.addEventListener('click', (e) => {
const dr = e.target.closest('.xp-dir');
if (dr) {
const d = E.dirs[+dr.dataset.dir];
if (e.target.closest('[data-chk]') || e.ctrlKey || e.metaKey) toggleDir(d.path);
else { E.curDir = d.path; E.curId = null; paintSel(); paintInspector(); }
return;
}
if (folderMode()) {
const el = e.target.closest('.xp-lrow');
if (!el) return;
const it = E.items[+el.dataset.i];
if (e.target.closest('[data-chk]') || e.ctrlKey || e.metaKey) toggleFile(it);
else { E.curDir = null; E.curId = it.id; paintSel(); schedInspector(); }
return;
}
const hs = e.target.closest('[data-hsel]');
if (hs) {
const start = +hs.dataset.hsel, n = +hs.dataset.n;
@@ -368,10 +505,24 @@ export function createExplorer(deps) {
else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
});
scroll.addEventListener('dblclick', (e) => {
const dr = e.target.closest('.xp-dir');
if (dr) { if (!e.target.closest('[data-chk]')) goTo(E.dirs[+dr.dataset.dir].path); return; }
if (folderMode()) {
const el = e.target.closest('.xp-lrow');
if (el && !e.target.closest('[data-chk]')) openLightbox(+el.dataset.i);
return;
}
const el = e.target.closest('.xp-tile, .xp-lrow');
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
});
scroll.addEventListener('keydown', (e) => {
if (folderMode()) {
if ((e.key === 'Backspace' || (e.altKey && e.key === 'ArrowUp')) && E.path !== '') { e.preventDefault(); goTo(parentOf(E.path)); return; }
if (e.key === 'Enter' && E.curDir != null) { e.preventDefault(); goTo(E.curDir); return; }
if (e.key === ' ' && E.curDir != null) { e.preventDefault(); toggleDir(E.curDir); return; }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); toggleDir(E.path); return; }
if (e.key === 'Escape') { E.fs = { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() }; fsChanged(); return; }
}
if (!E.items.length) return;
let i = idxOf(E.curId);
if (i < 0) i = 0;
@@ -387,8 +538,21 @@ export function createExplorer(deps) {
else if (e.key === 'Escape') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
});
// ------------------------------------------------------------ barra de ações
// ------------------------------------------------------------ action bar
function paintActions() {
if (folderMode()) {
const c = E.fsCount;
const here = dirState(E.path);
const html = !fsEmpty()
? `<span class="info">${c ? `<b>${c.files.toLocaleString(LOC())}</b> ${tr(c.files === 1 ? 'arquivo' : 'arquivos')} · ${human(c.bytes)}` : 'Calculando seleção…'}</span>
<button class="btn sm" data-a="clear">Limpar seleção</button>
${here !== 'on' ? `<button class="btn sm" data-a="here">Marcar esta pasta</button>` : ''}
<button class="btn primary" data-a="frec" ${c && c.files ? '' : 'disabled'}>Recuperar seleção</button>`
: `<span class="info">${E.dirs.length.toLocaleString(LOC())} ${E.dirs.length === 1 ? 'pasta' : 'pastas'} · ${E.total.toLocaleString(LOC())} ${E.total === 1 ? 'arquivo' : 'arquivos'} aqui</span>
${E.dirs.length || E.total ? '<button class="btn primary" data-a="here">Marcar esta pasta</button>' : ''}`;
if (actions._h !== html) { actions._h = html; actions.innerHTML = html; }
return;
}
const n = selCount();
const html = n
? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
@@ -401,13 +565,16 @@ export function createExplorer(deps) {
}
actions.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (folderMode() && a === 'clear') { E.fs = { folders: new Set(), xfolders: new Set(), files: new Map(), xfiles: new Map() }; fsChanged(); return; }
if (a === 'here') { if (dirState(E.path) !== 'on') { dropInside(E.path); if (!covered(E.path)) E.fs.folders.add(E.path); fsChanged(); } return; }
if (a === 'frec') { onRecover({ selection: fsSelection(), filtered: anyFilter() }); return; }
if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
else if (a === 'all') { E.allFiltered = true; paintSel(); }
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
else if (a === 'recall') onRecover({ filter: toQuery() });
});
// ------------------------------------------------------------ navegação lateral
// ------------------------------------------------------------ sidebar navigation
function paintNav() {
const f = E.facets;
if (!f) return;
@@ -424,7 +591,7 @@ export function createExplorer(deps) {
+ (open ? y.months.map((m) => node(MONTHS[m.month - 1], m.count, `data-f="month" data-y="${y.year}" data-v="${m.month}"`, F.year === y.year && F.month === m.month, 1)).join('') : '');
}).join('') + (f.undated ? node('Sem data', f.undated, 'data-f="undated" data-v="1"', F.undated) : '');
const folders = f.folders.filter((d) => {
// só mostra se todos os ancestrais estiverem expandidos
// only show if all ancestors are expanded
const parts = d.path.split('/');
for (let k = 1; k < parts.length; k++) if (!E.expanded.has('d' + parts.slice(0, k).join('/')) && !(F.folder || '').startsWith(parts.slice(0, k).join('/'))) return false;
return true;
@@ -461,6 +628,12 @@ export function createExplorer(deps) {
if (!b) return;
const F = E.filters;
const f = b.dataset.f, v = b.dataset.v;
if (folderMode() && f === 'folder') {
if (!v.startsWith('@')) E.expanded.add('d' + v);
nav._h = null; paintNav();
root.classList.remove('show-nav');
return goTo(v === '@raiz' ? '' : v);
}
if (f === 'all') clearFilters(false);
else if (f === 'year') { const same = F.year === +v && !F.month; F.year = same ? null : +v; F.month = null; F.undated = false; E.expanded.add('y' + v); }
else if (f === 'month') { const same = F.year === +b.dataset.y && F.month === +v; F.year = same ? null : +b.dataset.y; F.month = same ? null : +v; F.undated = false; }
@@ -490,6 +663,12 @@ export function createExplorer(deps) {
}
function paintCrumb() {
if (folderMode()) {
const parts = E.path === '' ? [] : E.path === '@carved' ? ['@carved'] : E.path.split('/').map((_, i, a) => a.slice(0, i + 1).join('/'));
const link = (p, label) => `<button class="xp-cpart" data-go="${esc(p)}">${esc(label)}</button>`;
$('#xpCrumb').innerHTML = `<nav class="xp-path" aria-label="Pasta atual">${[link('', 'Raiz'), ...parts.map((p) => link(p, dirLabel(p)))].join('<span class="xp-csep">›</span>')}</nav>`;
return;
}
const F = E.filters, parts = [];
if (F.category) parts.push(CAT[F.category][0]);
if (F.year) parts.push(String(F.year));
@@ -528,7 +707,12 @@ export function createExplorer(deps) {
changed();
});
// ------------------------------------------------------------ barra superior
$('#xpCrumb').addEventListener('click', (e) => {
const b = e.target.closest('[data-go]');
if (b && folderMode()) goTo(b.dataset.go);
});
// ------------------------------------------------------------ top bar
let qt;
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
$('#xpSort').addEventListener('change', (e) => { E.sort = e.target.value; reload(); });
@@ -538,9 +722,11 @@ export function createExplorer(deps) {
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
};
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
const was = folderMode();
E.view = b.dataset.view;
try { localStorage.setItem('guara-view', E.view); } catch { /* */ }
paintView(); relayout();
paintView();
if (was !== folderMode()) { E.curId = null; E.curDir = null; E.dirs = []; paintCrumb(); paintInspector(); actions._h = null; reload(); } else relayout();
}));
$('#xpSize').addEventListener('input', (e) => {
E.tile = +e.target.value;
@@ -550,7 +736,7 @@ export function createExplorer(deps) {
$('#xpNavBtn').onclick = () => root.classList.toggle('show-nav');
$('#xpVeil').onclick = () => root.classList.remove('show-nav', 'show-insp');
// ------------------------------------------------------------ painel de detalhes
// ------------------------------------------------------------ inspector panel
let inspT, stopPv = null, metaToken = 0;
const schedInspector = () => { clearTimeout(inspT); inspT = setTimeout(paintInspector, 90); };
@@ -561,7 +747,17 @@ export function createExplorer(deps) {
function paintInspector() {
stopPv?.(); stopPv = null;
const n = selCount();
if (folderMode() && E.curDir != null) {
const d = E.dirs.find((x) => x.path === E.curDir);
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
if (d) {
insp.innerHTML = `${close}<div class="xp-folder-insp"><div class="xp-fbig">${I.folderBig}</div><h3>${esc(d.name)}</h3>
<dl class="kv"><dt>Caminho</dt><dd>${esc(d.path === '@carved' ? 'Encontrados por assinatura (sem pasta conhecida)' : d.path)}</dd><dt>Arquivos</dt><dd>${d.files.toLocaleString(LOC())}</dd><dt>Tamanho</dt><dd>${human(d.bytes)}</dd></dl>
<div class="row"><button class="btn" data-i="open">Abrir pasta</button><button class="btn primary" data-i="mark">${dirState(d.path) === 'on' ? 'Desmarcar pasta' : 'Marcar pasta inteira'}</button></div></div>`;
return;
}
}
const n = folderMode() ? 0 : selCount();
const it = E.items[idxOf(E.curId)];
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
if (n > 1) {
@@ -584,7 +780,7 @@ export function createExplorer(deps) {
<div class="xp-meta" id="xpMeta"></div>
<div class="row"><button class="btn sm" data-i="full">Tela cheia</button><button class="btn primary sm" data-i="rec1">Recuperar este arquivo</button></div>`;
stopPv = renderPreview($('#xpPv'), it, { url, thumb: kindOf(it) === 'image', inlineMedia, db: dbApi(it), onOpen: () => openLightbox(idxOf(it.id)), onExternal: () => openExternal(it) });
// clicar na miniatura abre o modal grande
// clicking the thumbnail opens the large modal
$('#xpPv').addEventListener('click', (ev) => {
if (ev.target.closest('button, a, .pv-pdfbar')) return;
openLightbox(idxOf(it.id));
@@ -599,7 +795,7 @@ export function createExplorer(deps) {
$('#xpOsm')?.addEventListener('click', () => rpc('open_url', { url: `https://www.openstreetmap.org/?mlat=${m.gps[0]}&mlon=${m.gps[1]}#map=15/${m.gps[0]}/${m.gps[1]}` }).catch(() => {}));
}).catch(() => {});
}
/** Coordenadas e, se o usuário permitiu, um mapa (blocos do OpenStreetMap, 3x3 ao redor do ponto). */
/** Coordinates and, if enabled by the user, a map (3x3 OpenStreetMap tiles around the point). */
function gpsHtml([lat, lon]) {
let map = '';
if (deps.getSettings?.().maps) {
@@ -623,9 +819,11 @@ export function createExplorer(deps) {
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
else if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); }
else if (a === 'close') root.classList.remove('show-insp');
else if (a === 'open' && E.curDir != null) goTo(E.curDir);
else if (a === 'mark' && E.curDir != null) { toggleDir(E.curDir); paintInspector(); }
});
// ------------------------------------------------------------ tela cheia
// ------------------------------------------------------------ fullscreen
let stopLb = null;
function openLightbox(i) {
const it = E.items[i];
@@ -649,14 +847,14 @@ export function createExplorer(deps) {
if (!d.open) d.showModal();
}
// ------------------------------------------------------------ paint geral
// ------------------------------------------------------------ main paint
function paintAll() { paintCrumb(); paintChips(); paintActions(); renderWindow(); }
paintView(); paintCrumb(); paintActions(); paintInspector();
reloadAll();
return {
/** Chamado a cada atualização de status da varredura. */
/** Called on every scan status update. */
onStatus(st) {
E.status = st;
const enriching = !!st.enriching;
@@ -672,7 +870,7 @@ export function createExplorer(deps) {
},
query: () => toQuery(),
destroy() { E.reloadToken++; stopPv?.(); stopLb?.(); host.innerHTML = ''; },
/** Idioma trocado em Configurações: refaz o que foi montado em JS (datas, meses, rótulos). */
/** Language changed in Settings: rebuild JS-rendered elements (dates, months, labels). */
relang() { paintCrumb(); paintChips(); paintNav(); paintActions(); paintInspector(); relayout(); },
focus() { scroll.focus({ preventScroll: true }); },
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 870 KiB

+72 -27
View File
@@ -1,6 +1,6 @@
// Dicionário português -> inglês da interface. {n} marca um valor variável.
// Portuguese -> English UI dictionary. {n} marks a variable placeholder.
export const EN = {
// saúde do disco (SMART)
// disk health (SMART)
'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',
@@ -38,7 +38,7 @@ export const EN = {
'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.',
// barra lateral e estrutura
// sidebar and layout
'Recuperação de arquivos': 'File recovery',
'Dispositivos': 'Devices',
'Dispositivos e sessões': 'Devices and sessions',
@@ -64,7 +64,7 @@ export const EN = {
'· requer admin': '· needs admin',
'requer admin': 'needs admin',
'{n} · requer admin': '{n} · needs admin',
// início
// home
'Encontre o que você achou que tinha perdido.': 'Find what you thought was lost.',
'Escolha um disco, SSD ou pendrive ao lado. O Guará lê tudo: arquivos apagados, partições perdidas e espaço não particionado, sem escrever nada no disco de origem.': 'Pick a disk, SSD or USB drive on the left. Guará reads everything: deleted files, lost partitions and unpartitioned space, without writing anything to the source disk.',
'Uma varredura, tudo junto': 'One scan, everything together',
@@ -74,7 +74,7 @@ export const EN = {
'Origem protegida': 'Protected source',
'Somente leitura. A recuperação sempre vai para outra pasta, com SHA-256 de tudo.': 'Read-only. Recovery always goes to another folder, with SHA-256 of everything.',
'Nenhum disco apareceu.': 'No disks showed up.',
// dispositivo
// device
'Mapa do disco': 'Disk map',
'Como procurar': 'How to search',
'Recomendado': 'Recommended',
@@ -108,7 +108,7 @@ export const EN = {
'encontrada por varredura': 'found by scanning',
'Desconhecido': 'Unknown',
'Este SSD usa TRIM: o sistema avisa o disco quais blocos foram apagados e o próprio SSD pode zerá-los. Arquivos apagados há bastante tempo tendem a não ser recuperáveis.': 'This SSD uses TRIM: the system tells the disk which blocks were deleted and the SSD itself may zero them. Files deleted a long time ago tend to be unrecoverable.',
// saúde do disco
// disk health
'Saúde do disco': 'Disk health',
'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.',
@@ -119,7 +119,7 @@ export const EN = {
'Varrer mesmo assim': 'Scan anyway',
'Cancelar': 'Cancel',
'Este disco parece estar com defeito': 'This disk seems to be failing',
// varredura
// scan
'‹ Dispositivos': '‹ Devices',
'Procurando…': 'Searching…',
'Varredura concluída': 'Scan complete',
@@ -152,7 +152,7 @@ export const EN = {
'Lendo tabela de partições': 'Reading partition table',
'Lendo metadados {n} (partição {n})': 'Reading {n} metadata (partition {n})',
'Varredura profunda por assinatura': 'Deep signature scan',
// explorador
// explorer
'Navegação': 'Navigation',
'Filtros': 'Filters',
'Todos os arquivos': 'All files',
@@ -246,7 +246,7 @@ export const EN = {
'Tem localização GPS': 'Has GPS location',
'Confiança {n}': 'Confidence {n}',
'Nada por aqui': 'Nothing here',
// pré-visualização
// preview
'Imagem': 'Image',
'Vídeo': 'Video',
'PDF': 'PDF',
@@ -277,7 +277,7 @@ export const EN = {
'Neste app o vídeo abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, video opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'Neste app o áudio abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'In this app, audio opens in your player: the embedded Linux browser does not ship the codecs and would freeze the window.',
'pasta': 'folder',
// banco de dados
// database
'Abrir o visualizador': 'Open the viewer',
'{n} tabela(s) · {n} linhas · páginas de {n}': '{n} table(s) · {n} rows · {n} pages',
'{n} linhas · {n} colunas': '{n} rows · {n} columns',
@@ -297,7 +297,7 @@ export const EN = {
'{n} a {n} de {n}': '{n} to {n} of {n}',
'‹ Anterior': '‹ Previous',
'Próximas ›': 'Next ›',
// recuperar
// recover
'Recuperar para…': 'Recover to…',
'Recuperar': 'Recover',
'Pasta de destino': 'Destination folder',
@@ -336,7 +336,7 @@ export const EN = {
'{n} arquivo(s) não puderam ser gravados.': '{n} file(s) could not be written.',
'Se a recuperação for interrompida, escolha a mesma pasta de novo: o Guará pula o que já foi conferido.': 'If the recovery is interrupted, pick the same folder again: Guará skips what was already verified.',
'{n} já estavam recuperados e conferidos (pulados).': '{n} were already recovered and verified (skipped).',
// imagem, hash, busca
// image, hash, search
'Criando imagem do disco': 'Creating disk image',
'Nenhum setor ruim encontrado.': 'No bad sectors found.',
'Calculando hashes da origem': 'Computing source hashes',
@@ -354,7 +354,7 @@ export const EN = {
'{n} ocorrência{n}': '{n} occurrence{n}',
'Termos: {n}': 'Terms: {n}',
'{n}% · {n} até agora': '{n}% · {n} so far',
// sessões e origem
// sessions and source
'Abrir arquivo de imagem': 'Open disk image file',
'Caminho completo de uma imagem de disco (.img, .dd, .iso…).': 'Full path of a disk image (.img, .dd, .iso…).',
'Caminho do arquivo de sessão (.json) criado pelo Guará.': 'Path of the session file (.json) created by Guará.',
@@ -374,7 +374,7 @@ export const EN = {
'mesmo disco': 'same disk',
'tamanho igual, conteúdo diferente': 'same size, different content',
'mesmo tamanho (precisa de administrador para conferir)': 'same size (needs administrator to verify)',
// configurações e fechamento
// settings and shutdown
'Idioma': 'Language',
'Notificações': 'Notifications',
'Avisar quando uma tarefa longa terminar': 'Notify when a long task finishes',
@@ -389,14 +389,14 @@ export const EN = {
'Sair sem pausar': 'Quit without pausing',
'Continuar varrendo': 'Keep scanning',
'A sessão é salva automaticamente. Pausando agora, você retoma exatamente do ponto em que parou.': 'The session is saved automatically. If you pause now, you resume exactly where you stopped.',
// erros comuns vindos do núcleo
// common errors from the core
'Não foi possível falar com o núcleo do Guará: {n}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando': 'Could not talk to the Guará core: {n}. If you opened this page manually, use the full address shown by the command',
'Não foi possível reiniciar como administrador': 'Could not restart as administrator',
'Autorização cancelada.': 'Authorization canceled.',
'Origem desconectada': 'Source disconnected',
'Este disco está em uso (montado em': 'This disk is in use (mounted at',
'). O Guará só lê, mas evite gravar nele enquanto recupera, porque qualquer escrita pode sobrescrever o que você quer salvar.': '). Guará only reads, but avoid writing to it while recovering, because any write can overwrite what you want to save.',
// formulário de opções de varredura
// scan options form
'Volumes cifrados, RAID e vídeos': 'Encrypted volumes, RAID and videos',
'Senha do volume (BitLocker, LUKS, FileVault 2)': 'Volume password (BitLocker, LUKS, FileVault 2)',
'Chave de recuperação do BitLocker': 'BitLocker recovery key',
@@ -407,7 +407,7 @@ export const EN = {
'Vídeo íntegro da mesma câmera/drone (refaz o índice de MP4 sem moov)': 'Intact video from the same camera/drone (rebuilds the index of an MP4 without moov)',
'Lembrar as chaves dos volumes abertos na sessão': 'Remember the keys of opened volumes in the session',
'A senha nunca é gravada; só a chave derivada.': 'The password is never saved; only the derived key.',
// criar imagem do disco
// disk image creation
'Arquivo de destino': 'Destination file',
'Número do caso (E01/AFF4)': 'Case number (E01/AFF4)',
'Formato da imagem': 'Image format',
@@ -416,18 +416,18 @@ export const EN = {
'Examinador (E01/AFF4)': 'Examiner (E01/AFF4)',
'Imagem salva em': 'Image saved to',
'Hashes do conteúdo inteiro de': 'Hashes of the entire content of',
// resultados e partições
// results and partitions
'arquivo(s) salvos em': 'file(s) saved to',
'arquivos em {n}': 'files in {n}',
'Espaço não particionado: {n}': 'Unpartitioned space: {n}',
'Partição {n}': 'Partition {n}',
'erro {n}': 'error {n}',
// saúde do disco (campos vindos do núcleo)
// disk health (fields from the core)
'Autoavaliação SMART': 'SMART self-assessment',
'Vida útil usada': 'Life used',
'aprovado': 'passed',
'REPROVADO: o próprio disco prevê falha': 'FAILED: the disk itself predicts failure',
// pré-visualização
// preview
'Pré-visualização': 'Preview',
'Pré-visualização de {n}': 'Preview of {n}',
'vídeo': 'video',
@@ -438,7 +438,7 @@ export const EN = {
'diretório do ZIP não encontrado (arquivo possivelmente incompleto)': 'ZIP directory not found (file possibly incomplete)',
'visualizador indisponível': 'viewer unavailable',
'© colaboradores do OpenStreetMap': '© OpenStreetMap contributors',
// explorador: textos que a interface divide em nós separados
// explorer: strings split across separate DOM nodes by the UI
'selecionado': 'selected',
'selecionados': 'selected',
'selecionado (todos desta vista)': 'selected (all in this view)',
@@ -459,7 +459,7 @@ export const EN = {
'Conteúdo do arquivo compactado · {n}': 'Archive contents · {n}',
'Banco de dados SQLite · {n}': 'SQLite database · {n}',
'Dados brutos (hexadecimal) · {n}': 'Raw data (hexadecimal) · {n}',
// tipos e campos de metadados vindos do núcleo
// metadata types and fields from the core
'Imagem JPEG': 'JPEG image',
'Imagem PNG': 'PNG image',
'Imagem GIF': 'GIF image',
@@ -482,7 +482,7 @@ export const EN = {
'Última modificação por': 'Last modified by',
'Páginas': 'Pages',
'Duração': 'Duration',
// saúde do disco e confiança vindas do núcleo
// disk health and confidence levels from the core
'SSD (sem rotação)': 'SSD (no rotation)',
'Cache de gravação': 'Write cache',
'Segurança ATA': 'ATA security',
@@ -490,13 +490,13 @@ export const EN = {
'membro de RAID': 'RAID member',
'sem rodapé/estrutura completa: pode estar truncado': 'no footer/complete structure: may be truncated',
'vídeo sem índice (moov)': 'video without index (moov)',
// visualizador de SQLite
// SQLite viewer
'Nenhuma tabela encontrada no esquema. A primeira página pode estar danificada.': 'No table found in the schema. The first page may be damaged.',
'Tabela virtual: o conteúdo não fica armazenado como linhas comuns.': 'Virtual table: the content is not stored as ordinary rows.',
'Tabela WITHOUT ROWID: as linhas ficam numa árvore de índice, ainda não exibida.': 'WITHOUT ROWID table: the rows live in an index tree, not shown yet.',
'Arquivo truncado: o cabeçalho indica {n} páginas, mas só há {n}. Parte dos dados pode estar faltando.': 'Truncated file: the header says {n} pages, but there are only {n}. Part of the data may be missing.',
// mensagens de erro e avisos do núcleo (valores variáveis usam {n})
// core error and warning messages (variable values use {n})
'job {n} não existe': 'job {n} does not exist',
'a varredura não está pausada': 'the scan is not paused',
'A origem desta sessão não está disponível ({n}). Reconecte o disco ou escolha a origem para continuar.': 'The source of this session is unavailable ({n}). Reconnect the disk or choose the source to continue.',
@@ -582,12 +582,57 @@ export const EN = {
'Atualizando o Guará…': 'Updating Guará…',
'Baixando a atualização…': 'Downloading the update…',
'Baixando… {n}%': 'Downloading… {n}%',
'Instalando… o Guará vai reabrir sozinho.': 'Installing… Guará will reopen by itself.',
'Instalando a atualização…': 'Installing the update…',
'Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:': 'This install comes from a system package and cannot update itself. Download the new version from the release page:',
'Atualizações automáticas': 'Automatic updates',
'Ao abrir, consulta o GitHub para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks GitHub for a new version (one request, nothing of yours is sent)',
'Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks Gitea for a new version (one request, nothing of yours is sent)',
'Verificar agora': 'Check now',
'checando…': 'checking…',
'atualizar para {n}': 'update to {n}',
'já atualizado': 'already up to date',
'Nova versão {n} disponível': 'New version {n} available',
'Você já está na versão mais recente': 'You are already on the latest version',
'Não foi possível verificar atualizações: {n}': 'Could not check for updates: {n}',
'Instalação automática da nova versão com um clique:': 'One-click automatic installation of the new version:',
'Verificando integridade…': 'Verifying checksum…',
'Reiniciando…': 'Restarting…',
'Atualização concluída': 'Update completed',
'Falha na atualização: {n}': 'Update failed: {n}',
'Cada pasta marcada é recriada no destino a partir dela mesma, com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino.': 'Each marked folder is recreated in the destination from itself down, with all its subfolders. Files marked on their own go to the root of the destination.',
'Atenção:': 'Note:',
'pastas marcadas são recuperadas inteiras; os filtros ativos não se aplicam a elas.': 'marked folders are recovered whole; the active filters do not apply to them.',
'Navegar pelas pastas': 'Browse folders',
'Arquivos encontrados por assinatura, sem pasta conhecida': 'Files found by signature, with no known folder',
'Calculando seleção…': 'Counting the selection…',
'Marcar esta pasta': 'Mark this folder',
'{n} {n} · {n} {n} aqui': '{n} {n} · {n} {n} here',
'Pasta atual': 'Current folder',
'Encontrados por assinatura (sem pasta conhecida)': 'Found by signature (no known folder)',
'Desmarcar pasta': 'Unmark folder',
'Marcar pasta inteira': 'Mark whole folder',
'Abrir pasta': 'Open folder',
'Recuperar seleção': 'Recover selection',
'Caminho': 'Path',
'Arquivos': 'Files',
'arquivo': 'file',
'pastas': 'folders',
'{n} arquivo': '{n} file',
'{n} arquivos': '{n} files',
'{n} {n} · {n}': '{n} {n} · {n}',
'Apagar esta sessão': 'Delete this session',
'Apagar todas as sessões salvas sozinhas': 'Delete all autosaved sessions',
'Apagar sessão salva': 'Delete saved session',
'Apagar': 'Delete',
'Apagar tudo': 'Delete all',
'Apagar todas as sessões salvas': 'Delete all saved sessions',
'Todas as sessões salvas sozinhas serão apagadas de vez. Sessões abertas agora são mantidas.': 'All autosaved sessions will be permanently deleted. Sessions currently open are kept.',
'Sessão apagada': 'Session deleted',
'Nada para apagar': 'Nothing to delete',
'Apagar sessões salvas sozinhas após': 'Delete autosaved sessions after',
'Nunca': 'Never',
'7 dias': '7 days',
'30 dias': '30 days',
'90 dias': '90 days',
'será apagada de vez. Os arquivos do disco não são afetados.': 'will be permanently deleted. Files on the disk are not affected.',
'Sessões apagadas: {n}': 'Sessions deleted: {n}',
};
+8 -8
View File
@@ -1,7 +1,7 @@
// Idioma da interface. O texto-fonte é em português; no modo inglês um tradutor
// troca os textos exibidos (nós de texto e atributos) usando um dicionário, e
// continua observando a página para traduzir o que for desenhado depois.
// Textos com valores variáveis usam {n} no dicionário (ex.: "{n} arquivos" -> "{n} files").
// UI language. Source strings are in Portuguese; in English mode a translator
// replaces displayed strings (text nodes and attributes) using a dictionary, and
// keeps observing the page to translate dynamically rendered elements.
// Variable placeholders use {n} in the dictionary (e.g. "{n} files").
import { EN } from './i18n-en.js';
@@ -16,7 +16,7 @@ export function resolveLang(pref) {
}
const ATTRS = ['placeholder', 'title', 'aria-label', 'alt', 'data-empty'];
// Dados do usuário (nomes, caminhos, conteúdo) nunca são traduzidos.
// User data (names, paths, content) is never translated.
const SKIP = '.xp-cap, .xp-lnm, .db-table, .pv-zip, .hit, pre, code, .mono, .xp-ititle b, dd, textarea, input, script, style';
const exact = new Map(Object.entries(EN).filter(([k]) => !k.includes('{n}')));
@@ -26,7 +26,7 @@ const patterns = Object.entries(EN)
const parts = k.split('{n}').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return { re: new RegExp('^' + parts.join('(.+?)') + '$', 's'), to: v };
})
// padrões mais específicos (mais texto fixo) primeiro
// more specific patterns (more fixed text) first
.sort((a, b) => b.re.source.length - a.re.source.length);
const cache = new Map();
@@ -43,7 +43,7 @@ export function tr(text) {
const m = core.match(p.re);
if (m) {
let i = 0;
// os valores capturados também podem ter partes traduzíveis (ex.: nomes de tipos)
// captured values may also contain translatable parts (e.g. type names)
out = p.to.replace(/\{n\}/g, () => tr(m[++i] ?? ''));
break;
}
@@ -55,7 +55,7 @@ export function tr(text) {
return lead + out + trail;
}
const orig = new WeakMap(); // nó -> texto original em português
const orig = new WeakMap(); // node -> original source text
let observer = null;
function translateNode(n) {
+4 -4
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Guará</title>
<link rel="icon" href="guara-s.png" type="image/png">
<link rel="icon" href="logo.png" type="image/png">
<link rel="stylesheet" href="app.css">
</head>
<body>
@@ -14,17 +14,17 @@
<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 class="logo-img" src="guara-s.png" width="24" height="24" alt=""><strong>Guará</strong>
<img class="logo-img" src="logo.png" width="24" height="24" alt=""><strong>Guará</strong>
</header>
<aside class="sidebar" id="sidebar" aria-label="Dispositivos e sessões">
<div class="brand"><img class="logo-img" src="guara-s.png" width="30" height="30" alt=""><div><strong>Guará</strong><small>Recuperação de arquivos</small></div></div>
<div class="brand"><img class="logo-img" src="logo.png" width="30" height="30" alt=""><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>
<button class="side-action" id="openImageBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" stroke="currentColor" stroke-width="1.8" fill="none"/></svg>Abrir arquivo de imagem…</button>
<div class="side-title">Sessões</div>
<ul id="sessionList" class="list"></ul>
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small></div>
<div class="side-title">Recentes <small style="font-weight:400;text-transform:none;letter-spacing:0">(salvas sozinhas)</small> <button class="icon-btn small" id="clearRecentBtn" title="Apagar todas as sessões salvas sozinhas" aria-label="Apagar todas as sessões salvas sozinhas"><svg viewBox="0 0 24 24" width="16" height="16"><path d="M5 7h14M10 7V5h4v2m-6 0l1 12h6l1-12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
<ul id="recentList" class="list"></ul>
<button class="side-action" id="loadSessionBtn"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>Abrir sessão salva…</button>
</nav>
+12 -12
View File
@@ -1,8 +1,8 @@
import { LOC } from './i18n.js';
// Pré-visualização de arquivos recuperados.
// O conteúdo é lido em streaming do núcleo (com Range) pela URL `url`; nada é
// gravado em disco. Tipos: imagem, vídeo, áudio, PDF (pdf.js), texto, documentos
// Office/OpenDocument (texto extraído), listagem de ZIP e visão hexadecimal.
// Preview of recovered files.
// Content is streamed from the core (with Range support) via `url`; nothing is
// written to disk. Supported types: image, video, audio, PDF (pdf.js), text,
// Office/OpenDocument documents (extracted text), ZIP listing, and hex view.
const IMG = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'ico']);
const VID = new Set(['mp4', 'm4v', 'mov', 'webm', 'mkv', 'ogv']);
@@ -44,7 +44,7 @@ async function fetchRange(url, a, b) {
return new Uint8Array(await r.arrayBuffer());
}
// ------------------------------------------------------------------ ZIP mínimo
// ------------------------------------------------------------------ minimal ZIP reader
async function zipDir(url, size) {
const tailLen = Math.min(size, 66000);
const tail = await fetchRange(url, size - tailLen, size - 1);
@@ -86,7 +86,7 @@ async function zipRead(url, e, cap = 8 << 20) {
const xml = (bytes) => new DOMParser().parseFromString(new TextDecoder().decode(bytes), 'application/xml');
// ------------------------------------------------------------------ renderizadores
// ------------------------------------------------------------------ renderers
function note(host, html, cls = '') {
host.insertAdjacentHTML('beforeend', `<div class="pv-note ${cls}">${html}</div>`);
}
@@ -248,7 +248,7 @@ async function office(host, item, url, state) {
const rows = [...d.getElementsByTagName('table:table-row')].slice(0, 80);
html = `<div class="pv-sheet"><table><tbody>${rows.map((r) => `<tr>${[...r.getElementsByTagName('table:table-cell')].slice(0, 16).map((c) => `<td>${esc(c.textContent)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} else {
// pptx / odp: texto por slide
// pptx / odp: per-slide text
let slides;
if (ext === 'pptx') {
const files = ents.filter((e) => /^ppt\/slides\/slide\d+\.xml$/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).slice(0, 60);
@@ -275,9 +275,9 @@ async function zip(host, item, url, state) {
note(host, `${ents.length} item(ns) no arquivo compactado.`);
}
// ------------------------------------------------------------------ bancos de dados (SQLite)
// ------------------------------------------------------------------ databases (SQLite)
/** Valores inteiros que parecem datas (Unix, Chrome/WebKit, macOS) viram uma dica legível. */
/** Integer values that look like timestamps (Unix, Chrome/WebKit, macOS) become a readable hint. */
function tsHint(v) {
if (!Number.isInteger(v)) return null;
let ms = null, kind = '';
@@ -361,7 +361,7 @@ async function database(host, item, state, large, db, onOpen) {
}
// ------------------------------------------------------------------ API
/** Desenha a pré-visualização de `item` dentro de `host`. Devolve uma função de limpeza. */
/** Renders the preview of `item` inside `host`. Returns a cleanup function. */
function mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal }) {
const ico = kind === 'video' ? '🎞' : '🎵';
const canInline = inlineMedia && large;
@@ -385,8 +385,8 @@ export function renderPreview(host, item, { url, large = false, thumb = false, i
host.classList.remove('zoomable', 'actual');
host.innerHTML = '<div class="pv-loading"><i></i></div>';
const kind = kindOf(item);
// Vídeo e áudio: nunca criamos <video>/<audio> no painel pequeno, e no Linux do app desktop
// (WebKitGTK sem plugins de mídia) nem na tela grande, pois o processo da página morre.
// Video and audio: never instantiate <video>/<audio> in the small panel, nor in the large view
// on Linux desktop (WebKitGTK without media plugins), as the webview process would crash.
if ((kind === 'video' || kind === 'audio') && (!large || !inlineMedia)) {
mediaCard(host, item, kind, { large, inlineMedia, onOpen, onExternal });
return () => { state.dead = true; };
+2 -2
View File
@@ -1,3 +1,3 @@
# Bibliotecas de terceiros
# Third-party libraries
- **pdf.js** 4.10.38 (build "legacy"), © Mozilla, licença Apache-2.0 (`PDFJS-LICENSE`). Usada só para pré-visualizar PDFs, localmente e sem internet. Origem: https://github.com/mozilla/pdf.js
- **pdf.js** 4.10.38 ("legacy" build), © Mozilla, Apache-2.0 license (`PDFJS-LICENSE`). Used exclusively for local offline PDF preview. Source: https://github.com/mozilla/pdf.js
+2 -1
View File
@@ -1,10 +1,11 @@
[package]
name = "guara-cli"
description = "Guará: recuperação de arquivos pelo terminal (e servidor da interface gráfica)"
description = "Guará: command-line file recovery tool and web UI server"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version.workspace = true
[[bin]]
+99 -74
View File
@@ -20,34 +20,34 @@ struct Cli {
#[derive(Subcommand)]
enum SignCmd {
/// Cria o par de chaves de assinatura (recusa sobrescrever uma chave existente)
/// Generate a signing keypair (refuses to overwrite an existing key)
Keygen {
/// Onde gravar a chave privada (padrão: assinatura.key na pasta de configuração ou GUARA_SIGNING_KEY)
/// Where to write the private key (default: assinatura.key in the config dir or GUARA_SIGNING_KEY)
#[arg(long)]
key: Option<String>,
},
/// Assina um arquivo e grava <arquivo>.assinatura.json
/// Sign a file and write <file>.assinatura.json
File {
path: String,
#[arg(long)]
key: Option<String>,
/// Nome de quem assina (padrão: GUARA_EXAMINER)
/// Signer name (default: GUARA_EXAMINER)
#[arg(long)]
signer: Option<String>,
},
/// Confere a assinatura de um arquivo
/// Verify a file signature
Verify {
path: String,
/// Arquivo de assinatura (padrão: <arquivo>.assinatura.json)
/// Signature file (default: <file>.assinatura.json)
#[arg(long)]
sig: Option<String>,
/// Impressão digital esperada da chave, publicada pelo perito
/// Expected key fingerprint published by the examiner
#[arg(long)]
trust: Option<String>,
#[arg(long)]
json: bool,
},
/// Confere uma pasta de recuperação: assinatura do manifesto e hash de cada arquivo
/// Verify a recovery folder: manifest signature and hash of every file
Recovery {
dir: String,
#[arg(long)]
@@ -59,103 +59,103 @@ enum SignCmd {
#[derive(Subcommand)]
enum Cmd {
/// Lista discos e pendrives detectados
/// List detected disks and USB drives
Devices {
#[arg(long)]
json: bool,
},
/// Mostra partições, sistemas de arquivos, espaço não particionado e volumes cifrados
/// Show partitions, filesystems, unpartitioned space, and encrypted volumes
Inspect {
/// Dispositivo (ex.: /dev/sdb, \\.\PhysicalDrive1) ou arquivo de imagem
/// Device (e.g. /dev/sdb, \\.\PhysicalDrive1) or image file
path: String,
#[arg(long)]
json: bool,
#[command(flatten)]
asm: AsmArgs,
},
/// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos
/// Disk health (SMART): status, temperature, usage, and attribute table
Health {
/// Dispositivo ou partição (o SMART lido é o do disco que a contém)
/// Device or partition (reads SMART from the containing disk)
path: String,
#[arg(long)]
json: bool,
},
/// Procura arquivos apagados/perdidos (metadados + assinatura)
/// Scan for deleted/lost files (metadata + file carving)
Scan(ScanArgs),
/// Continua uma varredura pausada (Ctrl+C) a partir do arquivo de sessão
/// Resume a paused scan (Ctrl+C) from the session file
Resume {
session: String,
#[command(flatten)]
out: OutArgs,
},
/// Recupera arquivos de uma sessão salva
/// Recover files from a saved session
Recover {
session: String,
/// Pasta de destino (em OUTRO disco)
/// Destination directory (on ANOTHER disk)
dest: String,
#[command(flatten)]
filter: FilterArgs,
},
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
/// Create a fault-tolerant disk image (skipping bad sectors) to work on a copy
Image {
path: String,
dest: String,
/// Formato da imagem: raw (padrão), e01 (Expert Witness) ou aff4 (AFF4 v1.0), com hash e metadados
/// Image format: raw (default), e01 (Expert Witness), or aff4 (AFF4 v1.0), with hash and metadata
#[arg(long, default_value = "raw")]
format: String,
/// E01: número do caso
/// E01: case number
#[arg(long, default_value = "")]
case: String,
/// E01: examinador
/// E01: examiner
#[arg(long, default_value = "")]
examiner: String,
/// E01: descrição da evidência
/// E01: evidence description
#[arg(long, default_value = "")]
description: String,
/// E01: número da evidência
/// E01: evidence number
#[arg(long, default_value = "")]
evidence: String,
/// E01: observações
/// E01: notes
#[arg(long, default_value = "")]
notes: String,
/// E01: tamanho máximo de cada segmento em MiB (0 = um arquivo só)
/// E01: maximum segment size in MiB (0 = single file)
#[arg(long, default_value_t = 1500)]
segment_mib: u64,
/// E01: compressão 0 (nenhuma) a 9
/// E01: compression level 0 (none) to 9
#[arg(long, default_value_t = 1)]
compression: u32,
},
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
/// Verify an E01/Ex01 image: recomputes MD5 and compares against the embedded hash
Verify { path: String },
/// Assina e confere arquivos (manifestos, relatórios) e pastas de recuperação com Ed25519
/// Sign and verify files (manifests, reports) and recovery folders with Ed25519
#[command(subcommand)]
Sign(SignCmd),
/// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash
/// Show the audit trail (who did what and when) or verify its hash chain
Audit {
/// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG)
/// Audit log file (default: auditoria.jsonl in the config dir or GUARA_AUDIT_LOG)
#[arg(long)]
log: Option<String>,
/// Só confere a cadeia de hashes; sai com erro se ela estiver quebrada
/// Only verify the hash chain; exits with an error if broken
#[arg(long)]
verify: bool,
/// Mostra só as últimas N entradas
/// Show only the last N entries
#[arg(long)]
tail: Option<usize>,
#[arg(long)]
json: bool,
},
/// Exporta os achados de uma sessão para CSV (abre no Excel)
/// Export session findings to CSV (Excel-compatible)
Export {
session: String,
/// Arquivo CSV de saída
/// Output CSV file
csv: String,
#[command(flatten)]
filter: FilterArgs,
},
/// Gera o relatório de recuperação (identificação do caso, origem, achados, hashes): PDF se o arquivo terminar em .pdf, senão HTML
/// Generate the recovery report (case info, source, findings, hashes): PDF if output ends in .pdf, otherwise HTML
Report {
session: String,
/// Arquivo de saída (.pdf ou .html)
/// Output file (.pdf or .html)
out: String,
#[arg(long, default_value = "")]
case: String,
@@ -165,20 +165,20 @@ enum Cmd {
org: String,
#[arg(long, default_value = "")]
notes: String,
/// Calcula MD5/SHA-1/SHA-256 da origem e inclui no relatório (pode demorar)
/// Compute source MD5/SHA-1/SHA-256 and include in the report (may take time)
#[arg(long)]
hash_source: bool,
},
/// Procura texto em todo o disco, inclusive espaço livre (UTF-8 e UTF-16)
/// Search for text across the entire disk, including free space (UTF-8 and UTF-16)
Search {
path: String,
/// Termos a procurar
/// Search terms
#[arg(required = true)]
terms: Vec<String>,
/// Diferencia maiúsculas de minúsculas
/// Case-sensitive search
#[arg(long)]
case_sensitive: bool,
/// Não procura em UTF-16
/// Skip UTF-16 search
#[arg(long)]
no_utf16: bool,
#[arg(long, default_value_t = 200)]
@@ -186,11 +186,16 @@ enum Cmd {
#[arg(long)]
json: bool,
},
/// Calcula MD5, SHA-1 e SHA-256 do dispositivo ou imagem (verificação de integridade)
/// Compute MD5, SHA-1, and SHA-256 of the device or image (integrity check)
Hash { path: String },
/// Lista as sessões salvas automaticamente
/// List autosaved sessions
Sessions,
/// Abre a interface gráfica no navegador (modo local)
/// Check for and apply `guara` CLI updates
Update {
#[arg(long)]
apply: bool,
},
/// Open the web UI in the browser (local mode)
Ui {
#[arg(long, default_value_t = 0)]
port: u16,
@@ -201,72 +206,72 @@ enum Cmd {
#[derive(clap::Args, Clone)]
struct FilterArgs {
/// Categorias: image, document, video, audio, archive, database, other
/// Categories: image, document, video, audio, archive, database, other
#[arg(long, value_delimiter = ',')]
category: Vec<String>,
/// Confiança mínima: low, medium, high
/// Minimum confidence: low, medium, high
#[arg(long, default_value = "low")]
min_confidence: String,
/// Só nomes/caminhos que contenham este texto
/// Only match names/paths containing this substring
#[arg(long)]
name: Option<String>,
/// Como organizar o que for recuperado: folders (pastas originais), type, date ou flat
/// How to organize recovered files: folders (original paths), type, date, or flat
#[arg(long, default_value = "folders")]
organize: String,
}
#[derive(clap::Args, Clone)]
struct OutArgs {
/// Salva a sessão (necessária para recover/resume)
/// Save session file (required for recover/resume)
#[arg(long)]
session: Option<String>,
/// Lista os arquivos encontrados
/// List found files
#[arg(long)]
list: bool,
#[arg(long)]
json: bool,
}
/// Montagem do disco: volumes cifrados, RAID e LVM.
/// Disk assembly: encrypted volumes, RAID, and LVM.
#[derive(clap::Args, Clone, Default)]
struct AsmArgs {
/// Senha de volume cifrado (BitLocker, LUKS, FileVault 2). Prefira --ask-password ou GUARA_PASSWORD
/// Encrypted volume password (BitLocker, LUKS, FileVault 2). Prefer --ask-password or GUARA_PASSWORD
#[arg(long = "password")]
passwords: Vec<String>,
/// Pergunta a senha no terminal, sem mostrar o que é digitado
/// Prompt for the password in the terminal without echoing
#[arg(long)]
ask_password: bool,
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos)
/// BitLocker numeric recovery password (8 groups of 6 digits)
#[arg(long = "recovery-key")]
recovery_keys: Vec<String>,
/// Arquivo de chave (.BEK do BitLocker ou keyfile do LUKS)
/// Key file (BitLocker .BEK or LUKS keyfile)
#[arg(long = "key-file")]
key_files: Vec<String>,
/// Chave do volume em hexadecimal (a que o Guará mostra ao abrir um volume)
/// Hex-encoded volume master key (shown by Guará when unlocking a volume)
#[arg(long = "volume-key")]
volume_keys: Vec<String>,
/// Outro disco ou imagem do mesmo conjunto (RAID/LVM). Pode repetir; "-" marca um disco ausente
/// Additional disk or image from the same set (RAID/LVM). Repeatable; "-" marks a missing disk
#[arg(long = "member")]
members: Vec<String>,
/// Os discos (alvo + --member, nesta ordem) formam um RAID sem metadados: descobre sozinho nível, faixa e layout
/// Treat disks (target + --member, in order) as a metadata-less RAID and auto-detect level, chunk size, and layout
#[arg(long)]
raid: bool,
/// Fixa o nível do RAID: 0, 5, 6, 1
/// Override RAID level: 0, 5, 6, 1
#[arg(long)]
raid_level: Option<String>,
/// Fixa o tamanho da faixa do RAID em KiB
/// Override RAID chunk size in KiB
#[arg(long)]
raid_chunk_kb: Option<u64>,
/// Fixa o layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
/// Override RAID parity layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
#[arg(long)]
raid_layout: Option<String>,
/// Ordem dos discos no RAID (índices; 0 = alvo)
/// Disk order in the RAID array (indices; 0 = target)
#[arg(long, value_delimiter = ',')]
raid_order: Vec<usize>,
/// Guarda na sessão as chaves dos volumes abertos (a senha nunca é guardada)
/// Store derived volume keys in the session file (passwords are never stored)
#[arg(long)]
remember_keys: bool,
/// Vídeo íntegro da mesma câmera/drone para refazer o índice de MP4 sem moov (pode repetir)
/// Intact reference video from the same camera/drone to rebuild headerless MP4 moov atoms (repeatable)
#[arg(long = "reference")]
references: Vec<String>,
}
@@ -343,15 +348,15 @@ impl AsmArgs {
#[derive(clap::Args)]
struct ScanArgs {
/// Dispositivo ou imagem
/// Device or image path
path: String,
/// Varredura rápida: só metadados dos sistemas de arquivos
/// Quick scan: filesystem metadata only
#[arg(long)]
quick: bool,
/// Também varre áreas ainda alocadas (mais lento)
/// Also carve currently allocated space (slower)
#[arg(long)]
include_allocated: bool,
/// Recupera tudo que casar com os filtros para esta pasta ao terminar
/// Recover all files matching the filters into this folder when finished
#[arg(long)]
recover: Option<String>,
#[command(flatten)]
@@ -666,6 +671,26 @@ fn run(cli: Cli) -> Result<(), String> {
}
Ok(())
}
Cmd::Update { apply } => {
let current = env!("CARGO_PKG_VERSION");
if apply {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let version = guara_core::update::apply(&exe).map_err(|e| e.to_string())?;
println!("atualizado para {version}");
} else {
let status = guara_core::update::check(current).map_err(|e| e.to_string())?;
if status.update_available {
println!(
"atualização disponível: {} -> {} (rode `guara update --apply`)",
status.current_version,
status.latest_version.unwrap_or_default()
);
} else {
println!("já na última versão ({})", status.current_version);
}
}
Ok(())
}
Cmd::Ui { port, no_open } => serve::run(port, !no_open),
}
}
@@ -684,7 +709,7 @@ fn query_from(f: &FilterArgs) -> Result<Query, String> {
"high" | "alta" => Confidence::High,
o => return Err(format!("confiança inválida: {o}")),
};
// Query aceita uma categoria; várias categorias são tratadas filtrando depois.
// Query accepts a single category; multiple categories are filtered afterwards.
Ok(Query {
category: if cats.len() == 1 { Some(cats[0]) } else { None },
min_confidence: Some(min),
@@ -756,7 +781,7 @@ fn recover_job(
filter: &FilterArgs,
) -> Result<(), String> {
let mut q = query_from(filter)?;
// Várias categorias: seleciona por ids.
// Multiple categories: select by IDs.
let cats = parse_cats(&filter.category)?;
let mut ids: Option<Vec<u64>> = None;
if cats.len() > 1 {
@@ -787,7 +812,7 @@ fn recover_job(
filter.organize
))?;
let rj = eng
.start_recover(scan_job, ids, Some(q), dest, layout)
.start_recover(scan_job, ids, Some(q), None, dest, layout)
.map_err(s)?;
let st = wait(eng, &rj, None)?;
eprintln!();
@@ -815,7 +840,7 @@ fn recover_job(
Ok(())
}
/// Confere o hash gravado numa imagem E01/Ex01 ou AFF4. `Some(true)` = confere.
/// Verifies the embedded hash of an E01/Ex01 or AFF4 image. `Some(true)` = matches.
fn verify_image(path: &str) -> Result<Option<bool>, String> {
if path.to_ascii_lowercase().ends_with(".aff4") {
let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(path)).map_err(s)?;
@@ -869,7 +894,7 @@ fn verify_image(path: &str) -> Result<Option<bool>, String> {
}
}
/// `guara audit`: lista a trilha ou confere a cadeia de hashes.
/// `guara audit`: lists the audit trail or verifies its hash chain.
fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) -> Result<(), String> {
let log = match log {
Some(p) => guara_core::audit::Log::at(p),
@@ -924,7 +949,7 @@ fn audit(log: Option<String>, verify: bool, tail: Option<usize>, json: bool) ->
Ok(())
}
/// `guara sign`: chaves, assinatura e conferência.
/// `guara sign`: key generation, signing, and verification.
fn sign_cmd(c: SignCmd) -> Result<(), String> {
use guara_core::sign;
use std::path::{Path, PathBuf};
+4 -8
View File
@@ -1,4 +1,4 @@
//! Servidor local da interface (127.0.0.1 + token): mesma UI do app Tauri.
//! Local UI server (127.0.0.1 + token): serves the same UI as the Tauri app.
use guara_core::service::Engine;
use serde_json::json;
@@ -13,8 +13,7 @@ const PREVIEW_JS: &str = include_str!("../../../apps/gui/ui/preview.js");
const I18N_JS: &str = include_str!("../../../apps/gui/ui/i18n.js");
const I18N_EN_JS: &str = include_str!("../../../apps/gui/ui/i18n-en.js");
const EXPLORER_JS: &str = include_str!("../../../apps/gui/ui/explorer.js");
const LOGO: &[u8] = include_bytes!("../../../apps/gui/ui/guara.png");
const LOGO_S: &[u8] = include_bytes!("../../../apps/gui/ui/guara-s.png");
const LOGO: &[u8] = include_bytes!("../../../logo.png");
const PDF_JS: &str = include_str!("../../../apps/gui/ui/vendor/pdf.min.mjs");
const PDF_WORKER: &str = include_str!("../../../apps/gui/ui/vendor/pdf.worker.min.mjs");
@@ -44,7 +43,7 @@ pub fn run(port: u16, open: bool) -> Result<(), String> {
]);
for req in server.incoming_requests() {
let (eng, token, hosts) = (eng.clone(), token.clone(), hosts.clone());
// Uma thread por requisição: vídeo/áudio em streaming não pode travar a interface.
// One thread per request: streaming video/audio must not block the UI.
std::thread::spawn(move || handle(req, &eng, &token, &hosts[..]));
}
Ok(())
@@ -98,12 +97,9 @@ fn handle(req: Request, eng: &Engine, token: &str, hosts: &[String]) {
Response::from_string(I18N_EN_JS).with_header(ct("text/javascript; charset=utf-8")),
);
}
(Method::Get, "/guara.png") => {
(Method::Get, "/logo.png") => {
let _ = req.respond(Response::from_data(LOGO).with_header(ct("image/png")));
}
(Method::Get, "/guara-s.png") => {
let _ = req.respond(Response::from_data(LOGO_S).with_header(ct("image/png")));
}
(Method::Get, "/explorer.js") => {
let _ = req.respond(
Response::from_string(EXPLORER_JS)
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more