diff --git a/.gitea/ISSUE_TEMPLATE/bug.yml b/.gitea/ISSUE_TEMPLATE/bug.yml index 952d7dd..a9c2367 100644 --- a/.gitea/ISSUE_TEMPLATE/bug.yml +++ b/.gitea/ISSUE_TEMPLATE/bug.yml @@ -1,17 +1,17 @@ -name: Relatar um problema -description: Algo não funcionou como esperado +name: Report a bug +description: Something did not work as expected labels: [bug] body: - type: textarea - id: o-que - attributes: { label: "O que aconteceu?", description: "Passos, o que esperava e o que viu." } + id: what-happened + attributes: { label: "What happened?", description: "Steps to reproduce, expected behavior, and actual behavior." } validations: { required: true } - type: input - id: sistema - attributes: { label: "Sistema operacional e versão do Guará", placeholder: "Windows 11 / guara 0.1.0" } + id: system + attributes: { label: "Operating system and Guará version", placeholder: "Ubuntu 24.04 / guara 1.3.2" } - type: input - id: midia - attributes: { label: "Tipo de mídia e sistema de arquivos", placeholder: "pendrive 32 GB, FAT32" } + id: media + attributes: { label: "Media type and filesystem", placeholder: "32 GB USB drive, FAT32" } - type: textarea id: log - attributes: { label: "Saída do terminal (guara inspect / scan)", render: shell } + attributes: { label: "Terminal output (guara inspect / scan)", render: shell } diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md index a4a0623..3adf24e 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.md +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,8 @@ -## O que muda e por quê +## What changes and why ## 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) +- [ ] `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) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 842db65..d89bb00 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: - # Mudança só de documentação ou estado de ferramentas não precisa dos jobs de CI. + # 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'] @@ -15,17 +15,17 @@ concurrency: env: CARGO_TERM_COLOR: always RUSTFLAGS: -D warnings - # O runner mantém /opt/hostedtoolcache num volume que sobrevive entre jobs: - # a toolchain do rustup, o registry do cargo, caches de npm/apt e o diretório - # target ficam ali, então nada disso é baixado ou recompilado do zero. Cada job - # usa o seu próprio target (flags diferentes invalidariam o de outro job). + # 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: Formatação e clippy + name: Format and clippy runs-on: ubuntu-latest env: CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint @@ -37,64 +37,63 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 - - name: Cobertura da tradução da interface + - name: UI translation coverage run: node scripts/check-ui-i18n.mjs - - name: Script do latest.json do atualizador + - name: Updater latest.json unit tests run: python3 scripts/test_make_latest_json.py - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets - # Windows e macOS saem: o Gitea aqui tem um único runner, Linux arm64. + # Linux-only runner (ARM64). test: - name: Testes (Linux) + name: Tests (Linux) runs-on: ubuntu-latest env: CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - - name: Ferramentas para imagens de teste (Linux) + - 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: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4) + - 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 - # Monta a imagem por loop (mount -o loop), o que exige um container - # privilegiado. O runner do Gitea roda os jobs sem privilégio de propósito - # (o CI de repositório público executa código de terceiros), então este - # passo pode falhar aqui; nesse caso os testes que dependem dele são - # pulados em vez de reprovar a execução. - - name: Imagem XFS com exclusão feita pelo kernel (Linux) - id: xfs + # 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: scripts/make-xfs-real-deletion.sh - - name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux) + run: | + if [ ! -f target/testdata/xfs-real-delete/disk.img ]; then + sudo scripts/make-xfs-real-deletion.sh + fi + - name: Run tests shell: bash run: | - if [ "$RUNNER_OS" = "Linux" ] && [ "${{ steps.xfs.outcome }}" = "success" ]; then export GUARA_REQUIRE_FIXTURES=1; fi + if [ "${RUNNER_OS}" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi cargo test --workspace audit: - name: Auditoria de dependências + name: Dependency audit runs-on: ubuntu-latest 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 the compile. + # 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: Compila o app desktop (Linux) + name: Check desktop app (Linux) runs-on: ubuntu-latest env: CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui @@ -104,10 +103,10 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 - - name: Dependências do Tauri + - 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: Gera os ícones + - 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: diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 42fbe1f..32accab 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,17 +1,16 @@ name: Release -# Quando uma tag v* é enviada: cria a entrada em "Releases" com as notas do -# CHANGELOG.md, compila o CLI (Linux x86_64 e aarch64) e o app desktop (Linux -# e Windows, este por compilação cruzada com cargo-xwin), e monta o -# latest.json que o atualizador do app lê. +# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes +# from CHANGELOG.md, builds the CLI (Linux x86_64 and aarch64) and the desktop +# app (Linux aarch64, Linux x86_64, and Windows via cargo-xwin cross-compilation), +# and generates the latest.json manifest read by the desktop updater. # -# Sem macOS: o Tauri precisa do SDK e da assinatura da Apple, presos a -# hardware Apple e a uma conta de desenvolvedor, que este runner não tem. +# No macOS: Tauri requires Apple's SDK and code-signing hardware/account, which +# this Linux ARM64 runner does not have. # -# Segredos de usuário necessários: REGISTRY_TOKEN (não usado aqui, fica para o -# registro de containers de outros projetos), GUARA_TAURI_SIGNING_PRIVATE_KEY e -# GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD (assinam os pacotes para o -# atualizador). Os nomes antigos, sem prefixo, continuam aceitos como fallback. +# Required repository/user secrets: GUARA_TAURI_SIGNING_PRIVATE_KEY and +# GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD (sign updater bundles). Unprefixed +# secret names remain supported as fallback. on: push: tags: ['v*'] @@ -25,7 +24,7 @@ env: jobs: release: - name: Cria a release + name: Create release runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -37,9 +36,9 @@ jobs: TOKEN: ${{ gitea.token }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} run: | - # Uma execução repetida não recria a release. + # 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 "a release $TAG já existe"; exit 0 + echo "release $TAG already exists"; exit 0 fi scripts/release-notes.sh "$TAG" > notes.md pre=false; case "$TAG" in *-*) pre=true ;; esac @@ -65,19 +64,19 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - - name: Linker desta plataforma + - name: Platform cross-linker if: ${{ matrix.apt != '' }} run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }} - - name: Compila e empacota + - name: Build and package run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt - - name: Anexa à release + - 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: App desktop (${{ matrix.platform }}) + name: Desktop app (${{ matrix.platform }}) needs: release runs-on: ubuntu-latest env: @@ -94,11 +93,11 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 - - name: Dependências do Tauri (Linux aarch64) + - 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: Dependências do Tauri (Linux x86_64) + - name: Tauri dependencies (Linux x86_64) if: matrix.platform == 'linux-x86_64' timeout-minutes: 25 run: | @@ -118,21 +117,21 @@ jobs: else scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/tauri-native libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf fi - - name: Ferramentas para compilação cruzada (Windows) + - 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: Compila e empacota + - name: Build and package run: scripts/build-gui.sh "${{ matrix.platform }}" - - name: Anexa à release + - 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/* - atualizador: - name: Monta o latest.json + updater: + name: Build latest.json needs: gui runs-on: ubuntu-latest steps: @@ -145,7 +144,7 @@ jobs: run: | scripts/release-notes.sh "$TAG" > notes.md python3 scripts/make-latest-json.py "$TAG" notes.md latest.json - - name: Anexa à release + - name: Upload to release env: TOKEN: ${{ gitea.token }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..074da6c --- /dev/null +++ b/.githooks/commit-msg @@ -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 }" +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: [optional scope]: \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 diff --git a/.githooks/pre-push b/.githooks/pre-push index a9b68e1..c7faa7c 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -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" diff --git a/.gitignore b/.gitignore index dbeb29a..aba945d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Build +# Build artifacts target/ apps/gui/src-tauri/target/ node_modules/ @@ -7,15 +7,15 @@ node_modules/ .DS_Store **/*.rs.bk -# Capturas e estado local do Sabiá +# Sabiá local captures and audit log *.pcap *.cap .sabia-audit.log -# Logo copiada para a interface pelo build.rs +# Logo copied to the UI directory by build.rs /apps/gui/ui/logo.png -# Configuração e memória local de agentes de IA — não faz parte do produto +# Local AI agent configuration and state — not part of the product .claude/ .wolf/ .opencode/ @@ -27,16 +27,16 @@ CLAUDE.md AGENTS.md GEMINI.md -# Variáveis de ambiente locais (o setup do Superset copia da raiz) +# Local environment files .env .env.* -# Lixo local +# Local scratch files *.eml __pycache__/ *.pyc -# Pacotes gerados por scripts/package-cli.sh +# Packages generated by scripts/package-cli.sh and scripts/build-gui.sh /guara-v* /sabia-v* /dist-gui diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3990f7e..1aac631 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,13 +7,14 @@ 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 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. @@ -24,7 +25,9 @@ For the external reference images (BitLocker, FileVault 2, dfvfs, AFF4), run `sc 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 +## 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), attach the Linux CLI (x86_64 and aarch64), the desktop app for Linux (x86_64 and aarch64) and Windows, and build the updater's `latest.json`. @@ -48,4 +51,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`. diff --git a/CONTRIBUTING.pt-BR.md b/CONTRIBUTING.pt-BR.md index 2fe8936..345cc16 100644 --- a/CONTRIBUTING.pt-BR.md +++ b/CONTRIBUTING.pt-BR.md @@ -7,13 +7,14 @@ 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 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. @@ -24,7 +25,9 @@ Para as imagens de referência externas (BitLocker, FileVault 2, dfvfs, AFF4), r 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 +## 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), anexar o CLI para Linux (x86_64 e aarch64), o app desktop para Linux (x86_64 e aarch64) e Windows, e montar o `latest.json` do atualizador. @@ -48,4 +51,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`. diff --git a/Cargo.toml b/Cargo.toml index 01a1a9e..030851b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml index efa9860..5f15169 100644 --- a/apps/gui/src-tauri/Cargo.toml +++ b/apps/gui/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ -# 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" +description = "Guará: graphical file recovery interface" version = "1.3.2" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/apps/gui/src-tauri/build.rs b/apps/gui/src-tauri/build.rs index 4dc9406..293484b 100644 --- a/apps/gui/src-tauri/build.rs +++ b/apps/gui/src-tauri/build.rs @@ -3,11 +3,11 @@ fn main() { 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(); diff --git a/apps/gui/src-tauri/capabilities/default.json b/apps/gui/src-tauri/capabilities/default.json index e9590b2..93230df 100644 --- a/apps/gui/src-tauri/capabilities/default.json +++ b/apps/gui/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$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"] } diff --git a/apps/gui/src-tauri/gen/schemas/capabilities.json b/apps/gui/src-tauri/gen/schemas/capabilities.json index 317701f..b3893c2 100644 --- a/apps/gui/src-tauri/gen/schemas/capabilities.json +++ b/apps/gui/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Permissões da janela principal","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close","dialog:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Main window permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-destroy","core:window:allow-close","dialog:default"]}} \ No newline at end of file diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs index e33a4d0..10356ed 100644 --- a/apps/gui/src-tauri/src/main.rs +++ b/apps/gui/src-tauri/src/main.rs @@ -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,23 +6,29 @@ use std::sync::Arc; struct State(Arc); -/// 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 { +async fn rpc( + state: tauri::State<'_, State>, + method: String, + params: serde_json::Value, +) -> Result { let eng = state.0.clone(); - tauri::async_runtime::spawn_blocking(move || eng.rpc(&method, params).map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())? + tauri::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 Gitea. +/// New version found on Gitea releases. #[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. + /// Whether the app can self-update (AppImage, Windows, and macOS). Package installs + /// (.deb/.rpm) only notify the user and open the release page. auto: bool, url: String, } @@ -31,22 +37,30 @@ 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. +/// Checks for a newer version. `None` = already up to date. #[tauri::command] async fn update_check(app: tauri::AppHandle) -> Result, String> { use tauri_plugin_updater::UpdaterExt; - let found = app.updater().map_err(|e| e.to_string())?.check().await.map_err(|e| e.to_string())?; + 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://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/tag/v{}", u.version), + url: format!( + "https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/tag/v{}", + u.version + ), version: u.version, auto: self_update_possible(), })) } -/// Baixa, confere a assinatura (chave pública embutida no app), instala e reabre o app. -/// O progresso sai no evento `update-progress`. +/// Downloads, verifies the signature (embedded public key), installs, and restarts the app. +/// Progress is emitted via the `update-progress` event. #[tauri::command] async fn update_install(app: tauri::AppHandle) -> Result<(), String> { use tauri::Emitter; @@ -67,7 +81,10 @@ async fn update_install(app: tauri::AppHandle) -> Result<(), String> { .download_and_install( move |chunk, total| { got += chunk as u64; - let _ = a.emit("update-progress", serde_json::json!({ "downloaded": got, "total": total })); + let _ = a.emit( + "update-progress", + serde_json::json!({ "downloaded": got, "total": total }), + ); }, move || { let _ = b.emit("update-progress", serde_json::json!({ "installing": true })); @@ -75,16 +92,16 @@ async fn update_install(app: tauri::AppHandle) -> Result<(), String> { ) .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. + // When running as administrator, the new file is owned by root: hand it back to the invoking user. if let Some(p) = std::env::var_os("APPIMAGE") { guara_core::owner::give_to_user(std::path::Path::new(&p)); } app.restart() } -/// `true` quando o processo já tem privilégio suficiente para ler discos brutos. -/// No Windows o manifesto já exige administrador antes mesmo do app abrir, -/// então aqui é sempre `true`. +/// `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)] @@ -97,9 +114,9 @@ fn is_elevated() -> bool { } } -/// 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. +/// 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"))] @@ -120,7 +137,10 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { 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(); @@ -128,7 +148,14 @@ fn do_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}")); } @@ -146,16 +173,21 @@ fn do_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); @@ -165,7 +197,10 @@ fn do_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); @@ -173,31 +208,43 @@ fn do_relaunch_elevated(app: tauri::AppHandle) -> Result<(), String> { Ok(()) } -/// 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 { - 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//`), -/// com suporte a Range para vídeo/áudio/PDF, lendo direto da origem. +/// Serves recovered files for preview (`guara://localhost/file//`), +/// with Range support for video/audio/PDF, reading directly from the source. fn serve_file(eng: &Engine, req: &tauri::http::Request>) -> tauri::http::Response> { 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::().ok())); + let (job, id) = ( + it.next().unwrap_or(""), + it.next().and_then(|s| s.parse::().ok()), + ); let size = req .uri() .query() .and_then(|q| q.split('&').find_map(|kv| kv.strip_prefix("s="))) .and_then(|v| v.parse::().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() @@ -212,8 +259,17 @@ fn serve_file(eng: &Engine, req: &tauri::http::Request>) -> 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::().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::().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) { @@ -225,14 +281,24 @@ fn serve_file(eng: &Engine, req: &tauri::http::Request>) -> 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)) } @@ -249,12 +315,15 @@ fn main() { 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. + // 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::().ok()) { + if let Some(pid) = std::env::var("GUARA_REPLACE_PID") + .ok() + .and_then(|p| p.parse::().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); @@ -263,9 +332,9 @@ fn main() { }); } - // Ler discos brutos exige root: pede elevação assim que o app abre, - // sem esperar o usuário procurar um botão. Se a autorização falhar, - // a janela continua aberta e a interface mostra o aviso. + // Reading raw disks requires root: request elevation as soon as the app launches, + // without waiting for the user to look for a button. If authorization fails, + // the window stays open and the UI displays a warning. #[cfg(any(target_os = "linux", target_os = "macos"))] if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() { let handle = app.handle().clone(); @@ -275,7 +344,13 @@ fn main() { } Ok(()) }) - .invoke_handler(tauri::generate_handler![rpc, is_elevated, relaunch_elevated, update_check, update_install]) + .invoke_handler(tauri::generate_handler![ + rpc, + is_elevated, + relaunch_elevated, + update_check, + update_install + ]) .run(tauri::generate_context!()) .expect("erro ao iniciar o Guará"); } diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index 758b728..88fc6ce 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -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; } @@ -467,7 +467,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 +497,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 +516,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); } diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index f4fd075..2b73977 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -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/ com token. +// Guará: UI. Communicates with the core via `rpc(method, params)`: +// • Tauri app: invoke('rpc') • `guara ui` mode: POST /rpc/ 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) +// ------------------------------------------------------------------ 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 }; 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const human = (n) => { @@ -153,14 +153,14 @@ const S = { 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, }; @@ -259,7 +259,7 @@ $('#loadSessionBtn').onclick = async () => { } catch (e) { info({ title: 'Não foi possível abrir a sessão', html: `

${esc(e.message)}

` }); } }; -// ------------------------------------------------------------------ início +// ------------------------------------------------------------------ home function renderHome() { main.classList.remove('fixed'); stopPoll(); @@ -278,7 +278,7 @@ function renderHome() { $('#elevateHome')?.addEventListener('click', () => tauri.core.invoke('relaunch_elevated').catch((e) => toast(String(e)))); } -// ------------------------------------------------------------------ dispositivo +// ------------------------------------------------------------------ device async function openDevice(d) { main.classList.remove('fixed'); stopPoll(); @@ -291,7 +291,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(() => {}); } @@ -325,7 +325,7 @@ function paintHealth() { ${attrs.map((a) => `${esc(a.id)}${esc(a.name)}${dash(a.value)}${dash(a.worst)}${dash(a.thresh)}${esc(a.raw)}`).join('')} ` : ''; const scope = h.partition ? `

O SMART é do disco inteiro: os dados abaixo são de ${esc(h.disk)}, que contém ${esc(h.partition)}.

` : ''; - // 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]) => `
${esc(k)}
${esc(v)}
`).join(''); el.innerHTML = `${bad ? `
${warnSvg}
${esc(h.summary)} @@ -338,7 +338,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; @@ -532,8 +532,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(); @@ -556,7 +556,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(); @@ -615,7 +615,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'); @@ -711,7 +711,7 @@ async function recover(sel) { } catch (e) { info({ title: 'Não foi possível recuperar', html: `

${esc(e.message)}

` }); } } -// 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; @@ -740,7 +740,7 @@ function watchAux(job, title, doneHtml) { async function openJobFromList(s) { openJob(s); } -// ------------------------------------------------------------------ configurações (janela) +// ------------------------------------------------------------------ settings modal $('#settingsBtn')?.addEventListener('click', () => { const d = $('#dlg'); d.innerHTML = `

Configurações

@@ -764,9 +764,9 @@ $('#settingsBtn')?.addEventListener('click', () => { d.showModal(); }); -// ------------------------------------------------------------------ atualização do app -// O núcleo do app (Tauri) consulta o `latest.json` da release no Gitea, 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 `latest.json` from the Gitea release, verifies the +// package signature against the embedded public key, installs, and relaunches. No-op in browser mode (`guara ui`). let UPD = null; async function checkUpdate(manual) { @@ -822,7 +822,7 @@ function updateProgress(p) { } } -// ------------------------------------------------------------------ fechar com varredura em andamento +// ------------------------------------------------------------------ close while scan is running function askClose() { return new Promise((resolve) => { const d = $('#dlg'); @@ -848,14 +848,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'); diff --git a/apps/gui/ui/explorer.js b/apps/gui/ui/explorer.js index e54e31d..c64093f 100644 --- a/apps/gui/ui/explorer.js +++ b/apps/gui/ui/explorer.js @@ -1,7 +1,7 @@ -// 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'; @@ -66,7 +66,7 @@ export function createExplorer(deps) { try { E.view = localStorage.getItem('guara-view') || 'grid'; E.tile = +localStorage.getItem('guara-tile') || 150; - } catch { /* sem armazenamento */ } + } catch { /* no storage */ } host.innerHTML = `
@@ -91,7 +91,7 @@ export function createExplorer(deps) { $('#xpSort').value = E.sort; $('#xpSize').value = E.tile; - // ------------------------------------------------------------ dados + // ------------------------------------------------------------ data const toQuery = () => { const f = E.filters; const q = {}; @@ -139,8 +139,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,7 +150,7 @@ 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' })); @@ -195,7 +195,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; @@ -245,7 +245,7 @@ export function createExplorer(deps) { sizer.appendChild(frag); } - // ------------------------------------------------------------ itens + // ------------------------------------------------------------ items const isSel = (id) => E.allFiltered || E.sel.has(id); function headHtml(row) { @@ -288,7 +288,7 @@ export function createExplorer(deps) {
`; } - // 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 +300,7 @@ export function createExplorer(deps) { : `
${I.warn}?
`); }, true); - // ------------------------------------------------------------ seleção + // ------------------------------------------------------------ selection const selCount = () => (E.allFiltered ? E.total : E.sel.size); const idxOf = (id) => E.items.findIndex((x) => x.id === id); @@ -387,7 +387,7 @@ 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() { const n = selCount(); const html = n @@ -407,7 +407,7 @@ export function createExplorer(deps) { else if (a === 'recall') onRecover({ filter: toQuery() }); }); - // ------------------------------------------------------------ navegação lateral + // ------------------------------------------------------------ sidebar navigation function paintNav() { const f = E.facets; if (!f) return; @@ -424,7 +424,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; @@ -528,7 +528,7 @@ export function createExplorer(deps) { changed(); }); - // ------------------------------------------------------------ barra superior + // ------------------------------------------------------------ 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(); }); @@ -550,7 +550,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); }; @@ -584,7 +584,7 @@ export function createExplorer(deps) {
`; 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 +599,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) { @@ -625,7 +625,7 @@ export function createExplorer(deps) { else if (a === 'close') root.classList.remove('show-insp'); }); - // ------------------------------------------------------------ tela cheia + // ------------------------------------------------------------ fullscreen let stopLb = null; function openLightbox(i) { const it = E.items[i]; @@ -649,14 +649,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 +672,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 }); }, }; diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index 4783540..0454212 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -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.', diff --git a/apps/gui/ui/i18n.js b/apps/gui/ui/i18n.js index 8ec0690..8ed3338 100644 --- a/apps/gui/ui/i18n.js +++ b/apps/gui/ui/i18n.js @@ -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) { diff --git a/apps/gui/ui/preview.js b/apps/gui/ui/preview.js index cb8dc61..6dfce4f 100644 --- a/apps/gui/ui/preview.js +++ b/apps/gui/ui/preview.js @@ -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', `
${html}
`); } @@ -248,7 +248,7 @@ async function office(host, item, url, state) { const rows = [...d.getElementsByTagName('table:table-row')].slice(0, 80); html = `
${rows.map((r) => `${[...r.getElementsByTagName('table:table-cell')].slice(0, 16).map((c) => ``).join('')}`).join('')}
${esc(c.textContent)}
`; } 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 = '
'; const kind = kindOf(item); - // Vídeo e áudio: nunca criamos