17 Commits
Author SHA1 Message Date
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
45 changed files with 1899 additions and 985 deletions

No files matched your search

+5 -7
View File
@@ -26,7 +26,7 @@ env:
jobs: jobs:
lint: lint:
name: Format and clippy name: Format and clippy
runs-on: ubuntu-latest runs-on: linux-arm64
env: env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-lint
steps: steps:
@@ -39,15 +39,13 @@ jobs:
node-version: 22 node-version: 22
- name: UI translation coverage - name: UI translation coverage
run: node scripts/check-ui-i18n.mjs run: node scripts/check-ui-i18n.mjs
- name: Updater latest.json unit tests
run: python3 scripts/test_make_latest_json.py
- run: cargo fmt --all -- --check - run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets - run: cargo clippy --workspace --all-targets
# Linux-only runner (ARM64). # Linux-only runner (ARM64).
test: test:
name: Tests (Linux) name: Tests (Linux)
runs-on: ubuntu-latest runs-on: linux-arm64
env: env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-test
steps: steps:
@@ -69,7 +67,7 @@ jobs:
if: runner.os == 'Linux' if: runner.os == 'Linux'
continue-on-error: true continue-on-error: true
run: | run: |
if [ ! -f target/testdata/xfs-real-delete/disk.img ]; then 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 sudo scripts/make-xfs-real-deletion.sh
fi fi
- name: Run tests - name: Run tests
@@ -80,7 +78,7 @@ jobs:
audit: audit:
name: Dependency audit name: Dependency audit
runs-on: ubuntu-latest runs-on: linux-arm64
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
@@ -94,7 +92,7 @@ jobs:
gui: gui:
name: Check desktop app (Linux) name: Check desktop app (Linux)
runs-on: ubuntu-latest runs-on: linux-arm64
env: env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-gui
steps: steps:
+12 -35
View File
@@ -1,16 +1,12 @@
name: Release name: Release
# Triggered when a v* tag is pushed: creates the Gitea Release entry with notes # 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 # from CHANGELOG.md and uploads the compiled CLI (scripts/package-cli.sh) with
# app (Linux aarch64, Linux x86_64, and Windows via cargo-xwin cross-compilation), # a .sha256 checksum alongside each archive, plus the desktop bundles (Linux
# and generates the latest.json manifest read by the desktop updater. # aarch64, Linux x86_64, and Windows via cargo-xwin).
# #
# No macOS: Tauri requires Apple's SDK and code-signing hardware/account, which # No macOS: Tauri requires Apple's SDK and code-signing hardware/account, which
# this Linux ARM64 runner does not have. # this Linux ARM64 runner does not have.
#
# 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: on:
push: push:
tags: ['v*'] tags: ['v*']
@@ -50,22 +46,22 @@ jobs:
cli: cli:
name: CLI (${{ matrix.platform }}) name: CLI (${{ matrix.platform }})
needs: release needs: release
runs-on: ubuntu-latest runs-on: ${{ matrix.runner }}
env: env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-cli-${{ matrix.platform }} CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-cli-${{ matrix.platform }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- { platform: linux-aarch64, target: aarch64-unknown-linux-gnu, apt: "" } - { 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" } - { platform: linux-x86_64, target: x86_64-unknown-linux-gnu, apt: "gcc-x86-64-linux-gnu", runner: linux-amd64 }
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Platform cross-linker - name: Platform cross-linker
if: ${{ matrix.apt != '' }} if: ${{ matrix.apt != '' && matrix.runner != 'linux-amd64' }}
run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }} run: scripts/apt-install.sh /opt/hostedtoolcache/apt-cache/cli-x86_64 ${{ matrix.apt }}
- name: Build and package - name: Build and package
run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt run: scripts/package-cli.sh "${{ gitea.ref_name }}" "${{ matrix.platform }}" "${{ matrix.target }}" | tee packages.txt
@@ -78,15 +74,16 @@ jobs:
gui: gui:
name: Desktop app (${{ matrix.platform }}) name: Desktop app (${{ matrix.platform }})
needs: release needs: release
runs-on: ubuntu-latest runs-on: ${{ matrix.runner }}
env: env:
CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-gui-${{ matrix.platform }} CARGO_TARGET_DIR: /opt/hostedtoolcache/cargo-target/guara-release-gui-${{ matrix.platform }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.GUARA_TAURI_SIGNING_PRIVATE_KEY || secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.GUARA_TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
platform: [linux-aarch64, linux-x86_64, windows] include:
- { platform: linux-aarch64, runner: linux-arm64 }
- { platform: linux-x86_64, runner: linux-amd64 }
- { platform: windows, runner: linux-amd64 }
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
@@ -129,23 +126,3 @@ jobs:
TOKEN: ${{ gitea.token }} TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }} API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/* run: scripts/release-upload.sh "${{ gitea.ref_name }}" dist-gui/*
updater:
name: Build latest.json
needs: gui
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: latest.json
env:
TAG: ${{ gitea.ref_name }}
TOKEN: ${{ gitea.token }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
run: |
scripts/release-notes.sh "$TAG" > notes.md
python3 scripts/make-latest-json.py "$TAG" notes.md latest.json
- 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 }}" latest.json
+30
View File
@@ -4,6 +4,36 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [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 ## [1.3.2] - 2026-10-30
The project moved from GitHub to Gitea, and the update check followed it. The project moved from GitHub to Gitea, and the update check followed it.
+30
View File
@@ -4,6 +4,36 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer. Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [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 ## [1.3.2] - 2026-10-30
O projeto saiu do GitHub para o Gitea, e a verificação de atualização foi junto. O projeto saiu do GitHub para o Gitea, e a verificação de atualização foi junto.
+1 -3
View File
@@ -23,13 +23,11 @@ On Linux the tests build images with `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatpro
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. 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 ## 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`). 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`. 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. There is no macOS build: it needs Apple's SDK and code signing, which a Linux runner cannot provide.
+1 -3
View File
@@ -23,13 +23,11 @@ No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatpro
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. 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 ## 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`). 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. 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. Não há build para macOS: ele exige o SDK e a assinatura da Apple, que um runner Linux não tem.
Generated
+2 -2
View File
@@ -416,7 +416,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-cli" name = "guara-cli"
version = "1.3.2" version = "1.4.0"
dependencies = [ dependencies = [
"clap", "clap",
"ctrlc", "ctrlc",
@@ -429,7 +429,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "1.3.2" version = "1.4.0"
dependencies = [ dependencies = [
"aes", "aes",
"argon2", "argon2",
+2 -2
View File
@@ -3,10 +3,10 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"] members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package] [workspace.package]
version = "1.3.2" version = "1.4.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara" repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
authors = ["Guará contributors"] authors = ["Guará contributors"]
rust-version = "1.80" rust-version = "1.80"
+5 -1
View File
@@ -45,7 +45,7 @@ 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 package has a `.sha256` next to it. To build from source: `cargo build
--release -p guara-cli` (Rust 1.80+, Linux). --release -p guara-cli` (Rust 1.80+, Linux).
**Updates:** the desktop app (AppImage and Windows) checks Gitea 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 will warn you. Check the SHA-256. > The binaries are **not signed yet**: Windows SmartScreen will warn you. Check the SHA-256.
@@ -77,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> <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. - **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. - **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). - **Finder or Explorer style selection** and full-screen **quick look** (Space, Enter or double click).
- **Details panel** with preview, dates and metadata. - **Details panel** with preview, dates and metadata.
+5 -1
View File
@@ -45,7 +45,7 @@ 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 CLI tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p
guara-cli` (Rust 1.80+, Linux). guara-cli` (Rust 1.80+, Linux).
**Atualizações:** o app desktop (AppImage e Windows) consulta o Gitea 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 vai avisar. Confira o SHA-256. > Os binários ainda **não são assinados**: o Windows SmartScreen vai avisar. Confira o SHA-256.
@@ -77,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> <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. - **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. - **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). - **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. - **Painel de detalhes** com pré-visualização, datas e metadados.
+18 -514
View File
@@ -450,16 +450,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -483,7 +473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation 0.10.1", "core-foundation",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -496,7 +486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation 0.10.1", "core-foundation",
"libc", "libc",
] ]
@@ -897,16 +887,6 @@ dependencies = [
"typeid", "typeid",
] ]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -932,16 +912,6 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.15" version = "0.1.15"
@@ -1187,17 +1157,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -1383,7 +1342,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "1.3.2" version = "1.4.0"
dependencies = [ dependencies = [
"aes", "aes",
"argon2", "argon2",
@@ -1414,7 +1373,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-gui" name = "guara-gui"
version = "1.3.2" version = "1.4.0"
dependencies = [ dependencies = [
"guara-core", "guara-core",
"libc", "libc",
@@ -1423,7 +1382,6 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-updater",
] ]
[[package]] [[package]]
@@ -1548,21 +1506,6 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.21" version = "0.1.21"
@@ -1582,11 +1525,9 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1905,36 +1846,6 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2082,12 +1993,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.3" version = "0.8.3"
@@ -2163,12 +2068,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2456,18 +2355,6 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2531,32 +2418,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2961,20 +2828,15 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3012,20 +2874,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "ripemd" name = "ripemd"
version = "0.2.0" version = "0.2.0"
@@ -3050,92 +2898,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -3160,15 +2922,6 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3226,29 +2979,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.38.0" version = "0.38.0"
@@ -3482,22 +3212,6 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.4" version = "1.0.4"
@@ -3622,12 +3336,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.9" version = "1.0.9"
@@ -3691,27 +3399,6 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3733,7 +3420,7 @@ checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"core-foundation 0.10.1", "core-foundation",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3743,7 +3430,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni 0.21.1", "jni",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3776,17 +3463,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -3810,7 +3486,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"jni 0.21.1", "jni",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -3966,40 +3642,6 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b"
dependencies = [
"base64 0.23.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"libc",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.21",
"time",
"tokio",
"url",
"windows-sys 0.61.2",
"zip",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.12.2" version = "2.12.2"
@@ -4010,7 +3652,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni 0.21.1", "jni",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -4033,7 +3675,7 @@ checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni 0.21.1", "jni",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -4099,19 +3741,6 @@ dependencies = [
"toml 1.1.8+spec-1.1.0", "toml 1.1.8+spec-1.1.0",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "tendril" name = "tendril"
version = "0.5.1" version = "0.5.1"
@@ -4233,16 +3862,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.20" version = "0.7.20"
@@ -4480,12 +4099,6 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4748,15 +4361,6 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.39.1" version = "0.39.1"
@@ -4927,17 +4531,6 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@@ -4962,16 +4555,7 @@ version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [ dependencies = [
"windows-targets 0.42.2", "windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -4989,29 +4573,13 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm 0.42.2", "windows_aarch64_gnullvm",
"windows_aarch64_msvc 0.42.2", "windows_aarch64_msvc",
"windows_i686_gnu 0.42.2", "windows_i686_gnu",
"windows_i686_msvc 0.42.2", "windows_i686_msvc",
"windows_x86_64_gnu 0.42.2", "windows_x86_64_gnu",
"windows_x86_64_gnullvm 0.42.2", "windows_x86_64_gnullvm",
"windows_x86_64_msvc 0.42.2", "windows_x86_64_msvc",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
] ]
[[package]] [[package]]
@@ -5038,90 +4606,42 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.5.40"
@@ -5180,7 +4700,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni 0.21.1", "jni",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -5226,16 +4746,6 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "xattr"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.4" version = "0.8.4"
@@ -5280,12 +4790,6 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
+2 -3
View File
@@ -3,11 +3,11 @@
[package] [package]
name = "guara-gui" name = "guara-gui"
description = "Guará: graphical file recovery interface" description = "Guará: graphical file recovery interface"
version = "1.3.2" version = "1.4.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
authors = ["Guará contributors"] authors = ["Guará contributors"]
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara" repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
rust-version = "1.80" rust-version = "1.80"
[workspace] [workspace]
@@ -18,7 +18,6 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
guara-core = { path = "../../../crates/guara-core" } guara-core = { path = "../../../crates/guara-core" }
@@ -1 +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 {"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
@@ -2281,60 +2281,6 @@
"type": "string", "type": "string",
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
} }
] ]
}, },
@@ -2281,60 +2281,6 @@
"type": "string", "type": "string",
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
} }
] ]
}, },
+48 -77
View File
@@ -21,82 +21,26 @@ async fn rpc(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} }
/// New version found on Gitea releases.
#[derive(serde::Serialize)]
struct UpdateInfo {
current: String,
version: String,
notes: String,
/// 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,
}
fn self_update_possible() -> bool {
!cfg!(target_os = "linux") || std::env::var_os("APPIMAGE").is_some()
}
/// Checks for a newer version. `None` = already up to date.
#[tauri::command] #[tauri::command]
async fn update_check(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> { fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, String> {
use tauri_plugin_updater::UpdaterExt; let status = state.0.update_check().map_err(|e| e.to_string())?;
let found = app serde_json::to_value(status).map_err(|e| e.to_string())
.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
),
version: u.version,
auto: self_update_possible(),
}))
} }
/// Downloads, verifies the signature (embedded public key), installs, and restarts the app.
/// Progress is emitted via the `update-progress` event.
#[tauri::command] #[tauri::command]
async fn update_install(app: tauri::AppHandle) -> Result<(), String> { fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> {
use tauri::Emitter; use tauri::Emitter;
use tauri_plugin_updater::UpdaterExt; #[cfg(any(target_os = "linux", target_os = "macos"))]
if !self_update_possible() { let exe = elevated_exe().unwrap_or_else(|_| std::env::current_exe().unwrap_or_default());
return Err("Esta instalação não se atualiza sozinha (pacote do sistema). Baixe a versão nova na página da release.".into()); #[cfg(not(any(target_os = "linux", target_os = "macos")))]
} let exe = std::env::current_exe().unwrap_or_default();
let update = app
.updater() state
.map_err(|e| e.to_string())? .0
.check() .update_apply_desktop(&exe, move |phase| {
.await let _ = app.emit("update-progress", serde_json::json!({ "phase": phase }));
.map_err(|e| e.to_string())? })
.ok_or("Já está na versão mais recente.")?; .map_err(|e| e.to_string())
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}"))?;
// 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` when the process already has sufficient privileges to read raw disks. /// `true` when the process already has sufficient privileges to read raw disks.
@@ -308,13 +252,13 @@ fn main() {
let for_protocol = engine.clone(); let for_protocol = engine.clone();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(State(engine)) .manage(State(engine))
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| { .register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
let eng = for_protocol.clone(); let eng = for_protocol.clone();
std::thread::spawn(move || responder.respond(serve_file(&eng, &request))); std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
}) })
.setup(|app| { .setup(|app| {
use tauri::{Listener, Manager};
// When launched elevated by the unprivileged instance, terminate the old process. // When launched elevated by the unprivileged instance, terminate the old process.
#[cfg(unix)] #[cfg(unix)]
if let Some(pid) = std::env::var("GUARA_REPLACE_PID") if let Some(pid) = std::env::var("GUARA_REPLACE_PID")
@@ -332,15 +276,42 @@ fn main() {
}); });
} }
// Reading raw disks requires root: request elevation as soon as the app launches, // The window starts hidden (tauri.conf.json: "visible": false) so the
// without waiting for the user to look for a button. If authorization fails, // unprivileged instance never flashes on screen before being replaced.
// the window stays open and the UI displays a warning. // 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"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
{
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() { if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
use tauri::Emitter;
let handle = app.handle().clone(); let handle = app.handle().clone();
let handle_for_failure = app.handle().clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let _ = do_relaunch_elevated(handle); // 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(()) Ok(())
}) })
@@ -349,7 +320,7 @@ fn main() {
is_elevated, is_elevated,
relaunch_elevated, relaunch_elevated,
update_check, update_check,
update_install update_apply
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("erro ao iniciar o Guará"); .expect("erro ao iniciar o Guará");
+4 -15
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Guará", "productName": "Guará",
"version": "1.3.2", "version": "1.4.0",
"identifier": "dev.guara.app", "identifier": "dev.guara.app",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -15,7 +15,8 @@
"height": 780, "height": 780,
"minWidth": 420, "minWidth": 420,
"minHeight": 560, "minHeight": 560,
"resizable": true "resizable": true,
"visible": false
} }
], ],
"security": { "security": {
@@ -34,18 +35,6 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
], ]
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhENzRCRUU5NEI3QTAxQzgKUldUSUFYcEw2YjUwamFzMnpXM3ZKTEZPN1JIeXdLL3Q5SEF1M1FxQ284WFM0UWNsdlAyNUloWTEK",
"endpoints": [
"https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/latest/download/latest.json"
],
"windows": {
"installMode": "passive"
}
}
} }
} }
+15
View File
@@ -422,6 +422,21 @@ dialog.lightbox[open] { display: grid; }
.xp-insp .viewer { max-height: 38vh; min-height: 150px; } .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-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; } .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-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-insp-x { display: none; align-self: flex-end; }
.xp-veil { display: none; } .xp-veil { display: none; }
+76 -41
View File
@@ -687,19 +687,25 @@ async function onProgressClick(e) {
} }
async function recover(sel) { 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({ const f = await askForm({
title: 'Recuperar para…', 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', ok: 'Recuperar',
fields: [ fields: [
{ name: 'dest', label: 'Pasta de destino', value: (S.defaults.home || '') + (S.defaults.sep || '/') + 'Guara-Recuperados', pick: { directory: true, multiple: false } }, { 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; if (!f || !f.dest) return;
const dest = f.dest; const dest = f.dest;
const { filtered, ...target } = sel;
try { 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) => { watchAux(job, 'Recuperando arquivos', (st) => {
const r = st.report || {}; const r = st.report || {};
const ok = (r.recovered || []).length, bad = (r.failed || []).length; const ok = (r.recovered || []).length, bad = (r.failed || []).length;
@@ -750,7 +756,11 @@ $('#settingsBtn')?.addEventListener('click', () => {
${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>` : ''} ${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>`; <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('[name=lang]').value = SETTINGS.lang;
d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); }); d.querySelector('#updNow')?.addEventListener('click', (e) => {
e.preventDefault();
if (UPD) applyUpdate();
else checkUpdate(true);
});
d.onclose = () => { d.onclose = () => {
if (d.returnValue !== 'ok') return; 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 };
@@ -765,61 +775,87 @@ $('#settingsBtn')?.addEventListener('click', () => {
}); });
// ------------------------------------------------------------------ app updater // ------------------------------------------------------------------ app updater
// The desktop shell (Tauri) queries `latest.json` from the Gitea release, verifies the // The desktop shell (Tauri) queries Gitea Releases API and notifies the user.
// package signature against the embedded public key, installs, and relaunches. No-op in browser mode (`guara ui`). // No-op in browser mode (`guara ui`).
let UPD = null; 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) => {
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;
if (phase === 'downloading') setPhase('Baixando a atualização…');
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;
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) { async function checkUpdate(manual) {
if (!tauri?.core?.invoke) return; if (!tauri?.core?.invoke) return;
const updNow = $('#updNow');
if (updNow) updNow.textContent = 'checando…';
try { 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(); 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) { } catch (e) {
if (updNow) updNow.textContent = 'Verificar agora';
if (manual) toast(`Não foi possível verificar atualizações: ${e}`); if (manual) toast(`Não foi possível verificar atualizações: ${e}`);
} }
} }
function renderUpdateCard(state) { function renderUpdateCard() {
let el = $('#updateCard'); let el = $('#updateCard');
if (!UPD) { el?.remove(); return; } 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 (!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> 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"> <div class="upd-actions">
${UPD.auto ? '<button class="btn primary sm" id="updGo">Atualizar agora</button>' : ''} <button class="btn primary sm" id="updApply">Atualizar agora</button>
${UPD.notes ? '<button class="btn sm" id="updNotes">Novidades</button>' : ''}
<button class="btn sm" id="updLater">Depois</button> <button class="btn sm" id="updLater">Depois</button>
</div>`; </div>`;
$('#updGo')?.addEventListener('click', installUpdate);
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); }); $('#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}%`;
}
} }
// ------------------------------------------------------------------ close while scan is running // ------------------------------------------------------------------ close while scan is running
@@ -870,7 +906,6 @@ function watchClose() {
setLang(SETTINGS.lang); setLang(SETTINGS.lang);
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() }); if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
watchClose(); watchClose();
tauri?.event?.listen?.('update-progress', (e) => updateProgress(e.payload));
if (SETTINGS.updates !== false) checkUpdate(false); if (SETTINGS.updates !== false) checkUpdate(false);
if (tauri) S.elevated = await tauri.core.invoke('is_elevated').catch(() => true); 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')); }); tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
+206 -8
View File
@@ -4,7 +4,7 @@
// fullscreen quick look. // fullscreen quick look.
import { renderPreview, kindOf, KIND_LABEL } from './preview.js'; import { renderPreview, kindOf, KIND_LABEL } from './preview.js';
import { LOC } from './i18n.js'; import { LOC, tr } from './i18n.js';
const CAT = { const CAT = {
image: ['Fotos', '🖼'], document: ['Documentos', '📄'], video: ['Vídeos', '🎞'], audio: ['Áudio', '🎵'], 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), 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), warn: svg('<path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.01"/>', 22),
close: svg('<path d="M6 6l12 12M18 6L6 18"/>', 20), 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) { export function createExplorer(deps) {
@@ -62,6 +64,14 @@ export function createExplorer(deps) {
lastFound: -1, lastFound: -1,
wasEnriching: false, wasEnriching: false,
status: null, 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 { try {
E.view = localStorage.getItem('guara-view') || 'grid'; E.view = localStorage.getItem('guara-view') || 'grid';
@@ -74,7 +84,7 @@ export function createExplorer(deps) {
<div class="xp-crumb" id="xpCrumb"></div> <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"> <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> <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"> <input type="range" id="xpSize" min="100" max="300" step="4" aria-label="Tamanho das miniaturas">
</div> </div>
<aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside> <aside class="xp-nav" id="xpNav" aria-label="Navegação"></aside>
@@ -112,7 +122,10 @@ export function createExplorer(deps) {
}; };
const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v)); const anyFilter = () => Object.entries(E.filters).some(([k, v]) => (k === 'query' ? !!v : !!v));
const folderMode = () => E.view === 'folders';
async function reload() { async function reload() {
if (folderMode()) return reloadFolder();
const token = ++E.reloadToken; const token = ++E.reloadToken;
E.lastReload = Date.now(); E.lastReload = Date.now();
const base = { ...toQuery(), sort: E.sort }; const base = { ...toQuery(), sort: E.sort };
@@ -132,6 +145,29 @@ export function createExplorer(deps) {
paintAll(); 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() { async function loadFacets() {
try { E.facets = await rpc('facets', { job }); } catch { return; } try { E.facets = await rpc('facets', { job }); } catch { return; }
paintNav(); paintNav();
@@ -157,7 +193,7 @@ export function createExplorer(deps) {
function geometry() { function geometry() {
const W = Math.max(200, scroll.clientWidth - PAD * 2); 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 T = E.tile;
const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP))); const cols = Math.max(1, Math.floor((W + GAP) / (T + GAP)));
const tw = Math.floor((W - GAP * (cols - 1)) / cols); const tw = Math.floor((W - GAP * (cols - 1)) / cols);
@@ -176,6 +212,9 @@ export function createExplorer(deps) {
} }
const rows = []; const rows = [];
let y = PAD; 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 = []; let cur = null, rowItems = [];
const flush = () => { const flush = () => {
if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; } if (rowItems.length) { rows.push({ t: 'row', y, h: G.rowH, idx: rowItems }); y += G.rowH; rowItems = []; }
@@ -238,7 +277,7 @@ export function createExplorer(deps) {
el.style.top = `${row.y}px`; el.style.top = `${row.y}px`;
el.style.height = `${row.h}px`; el.style.height = `${row.h}px`;
el.dataset.ri = ri; 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); E.rendered.set(ri, el);
frag.appendChild(el); frag.appendChild(el);
} }
@@ -246,7 +285,82 @@ export function createExplorer(deps) {
} }
// ------------------------------------------------------------ items // ------------------------------------------------------------ items
const isSel = (id) => E.allFiltered || E.sel.has(id); // `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) { 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>`; 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>`;
@@ -310,7 +424,15 @@ export function createExplorer(deps) {
el.setAttribute('aria-selected', String(isSel(id))); el.setAttribute('aria-selected', String(isSel(id)));
if (E.curId === id) el.dataset.cur = '1'; else delete el.dataset.cur; 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(); 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); new ResizeObserver(() => { if (Math.abs(geometry().W - G.W) > 1 || geometry().cols !== G.cols) relayout(); else renderWindow(); }).observe(scroll);
scroll.addEventListener('click', (e) => { 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]'); const hs = e.target.closest('[data-hsel]');
if (hs) { if (hs) {
const start = +hs.dataset.hsel, n = +hs.dataset.n; 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 }); else setCursor(i, { extend: e.shiftKey, toggle: e.ctrlKey || e.metaKey });
}); });
scroll.addEventListener('dblclick', (e) => { 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'); const el = e.target.closest('.xp-tile, .xp-lrow');
if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); } if (el) { setCursor(+el.dataset.i); openLightbox(+el.dataset.i); }
}); });
scroll.addEventListener('keydown', (e) => { 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; if (!E.items.length) return;
let i = idxOf(E.curId); let i = idxOf(E.curId);
if (i < 0) i = 0; if (i < 0) i = 0;
@@ -389,6 +540,19 @@ export function createExplorer(deps) {
// ------------------------------------------------------------ action bar // ------------------------------------------------------------ action bar
function paintActions() { 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 n = selCount();
const html = n const html = n
? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span> ? `<span class="info"><b>${n.toLocaleString(LOC())}</b> selecionado${n === 1 ? '' : 's'}${E.allFiltered ? ' (todos desta vista)' : ''}</span>
@@ -401,6 +565,9 @@ export function createExplorer(deps) {
} }
actions.addEventListener('click', (e) => { actions.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a; 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(); } if (a === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); }
else if (a === 'all') { E.allFiltered = true; paintSel(); } else if (a === 'all') { E.allFiltered = true; paintSel(); }
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] }); else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] });
@@ -461,6 +628,12 @@ export function createExplorer(deps) {
if (!b) return; if (!b) return;
const F = E.filters; const F = E.filters;
const f = b.dataset.f, v = b.dataset.v; 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); 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 === '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; } 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() { 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 = []; const F = E.filters, parts = [];
if (F.category) parts.push(CAT[F.category][0]); if (F.category) parts.push(CAT[F.category][0]);
if (F.year) parts.push(String(F.year)); if (F.year) parts.push(String(F.year));
@@ -528,6 +707,11 @@ export function createExplorer(deps) {
changed(); changed();
}); });
$('#xpCrumb').addEventListener('click', (e) => {
const b = e.target.closest('[data-go]');
if (b && folderMode()) goTo(b.dataset.go);
});
// ------------------------------------------------------------ top bar // ------------------------------------------------------------ top bar
let qt; let qt;
$('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); }); $('#xpQ').addEventListener('input', (e) => { clearTimeout(qt); qt = setTimeout(() => { E.filters.query = e.target.value.trim(); changed(); }, 250); });
@@ -538,9 +722,11 @@ export function createExplorer(deps) {
$('#xpSize').style.display = E.view === 'grid' ? '' : 'none'; $('#xpSize').style.display = E.view === 'grid' ? '' : 'none';
}; };
host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => { host.querySelectorAll('button[data-view]').forEach((b) => (b.onclick = () => {
const was = folderMode();
E.view = b.dataset.view; E.view = b.dataset.view;
try { localStorage.setItem('guara-view', E.view); } catch { /* */ } 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) => { $('#xpSize').addEventListener('input', (e) => {
E.tile = +e.target.value; E.tile = +e.target.value;
@@ -561,7 +747,17 @@ export function createExplorer(deps) {
function paintInspector() { function paintInspector() {
stopPv?.(); stopPv = null; 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 it = E.items[idxOf(E.curId)];
const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`; const close = `<button class="icon-btn xp-insp-x" data-i="close" aria-label="Fechar">${I.close}</button>`;
if (n > 1) { if (n > 1) {
@@ -623,6 +819,8 @@ export function createExplorer(deps) {
else if (a === 'rec') onRecover(E.allFiltered ? { filter: toQuery() } : { ids: [...E.sel] }); 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 === 'clear') { E.sel = new Set(); E.allFiltered = false; paintSel(); paintInspector(); }
else if (a === 'close') root.classList.remove('show-insp'); 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(); }
}); });
// ------------------------------------------------------------ fullscreen // ------------------------------------------------------------ fullscreen
+30 -1
View File
@@ -582,12 +582,41 @@ export const EN = {
'Atualizando o Guará…': 'Updating Guará…', 'Atualizando o Guará…': 'Updating Guará…',
'Baixando a atualização…': 'Downloading the update…', 'Baixando a atualização…': 'Downloading the update…',
'Baixando… {n}%': 'Downloading… {n}%', '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:', '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', 'Atualizações automáticas': 'Automatic updates',
'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)', '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', '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', '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', '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}', '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}',
}; };
+26 -1
View File
@@ -190,6 +190,11 @@ enum Cmd {
Hash { path: String }, Hash { path: String },
/// List autosaved sessions /// List autosaved sessions
Sessions, Sessions,
/// Check for and apply `guara` CLI updates
Update {
#[arg(long)]
apply: bool,
},
/// Open the web UI in the browser (local mode) /// Open the web UI in the browser (local mode)
Ui { Ui {
#[arg(long, default_value_t = 0)] #[arg(long, default_value_t = 0)]
@@ -666,6 +671,26 @@ fn run(cli: Cli) -> Result<(), String> {
} }
Ok(()) 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), Cmd::Ui { port, no_open } => serve::run(port, !no_open),
} }
} }
@@ -787,7 +812,7 @@ fn recover_job(
filter.organize filter.organize
))?; ))?;
let rj = eng 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)?; .map_err(s)?;
let st = wait(eng, &rj, None)?; let st = wait(eng, &rj, None)?;
eprintln!(); eprintln!();
+311
View File
@@ -170,6 +170,140 @@ pub fn item_json(f: &Found) -> Value {
}) })
} }
/// Folder a file belongs to for browsing: `None` for signature-carved files,
/// `""` for the volume root.
fn browse_dir(f: &Found) -> Option<&str> {
if f.origin == Origin::Carved {
return None;
}
Some(f.dir.as_deref().unwrap_or("").trim_matches('/'))
}
/// One level of the folder tree: the direct subfolders of `path` (with file and
/// byte totals over everything below them) and the files directly inside it,
/// paginated. `path` is `""` for the root or `"@carved"` for files found by
/// signature, which have no folder. Filters in `q` apply; its `folder` is ignored.
pub fn browse(found: &[Found], path: &str, q: &Query) -> Value {
let path = path.trim_matches('/');
let q = Query {
folder: None,
..q.clone()
};
let selected = q.select(found);
let mut folders: BTreeMap<&str, (u64, u64)> = BTreeMap::new();
let mut files: Vec<&Found> = Vec::new();
let mut carved = (0u64, 0u64);
for f in selected {
let Some(dir) = browse_dir(f) else {
if path == "@carved" {
files.push(f);
} else if path.is_empty() {
carved.0 += 1;
carved.1 += f.size;
}
continue;
};
if dir == path {
files.push(f);
continue;
}
let rest = if path.is_empty() {
dir
} else if let Some(rest) = dir.strip_prefix(path).and_then(|r| r.strip_prefix('/')) {
rest
} else {
continue;
};
let child = rest.split('/').next().unwrap_or(rest);
let child_path = &dir[..dir.len() - rest.len() + child.len()];
let entry = folders.entry(child_path).or_default();
entry.0 += 1;
entry.1 += f.size;
}
let mut out: Vec<Value> = folders
.iter()
.map(|(p, (n, bytes))| {
json!({ "name": p.rsplit('/').next().unwrap_or(p), "path": p, "files": n, "bytes": bytes })
})
.collect();
if carved.0 > 0 {
out.push(json!({ "name": "Por assinatura", "path": "@carved", "files": carved.0, "bytes": carved.1 }));
}
sort_items(&mut files, Some(q.sort.as_deref().unwrap_or("name")));
let total = files.len();
let page: Vec<Value> = files
.into_iter()
.skip(q.offset)
.take(q.limit.clamp(1, 5000))
.map(item_json)
.collect();
json!({ "path": path, "folders": out, "files": page, "total_files": total })
}
/// What the user marked in folder browsing: whole folders (recursive; `""` is
/// everything, `"@carved"` the files found by signature), single files, and
/// files or subfolders unmarked inside a marked folder.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Selection {
#[serde(default)]
pub folders: Vec<String>,
#[serde(default)]
pub ids: Vec<u64>,
#[serde(default)]
pub exclude: Vec<u64>,
#[serde(default)]
pub exclude_folders: Vec<String>,
}
/// Whether `f` is inside the browsing folder `folder` (recursively).
pub fn in_folder(f: &Found, folder: &str) -> bool {
let folder = folder.trim_matches('/');
if folder.is_empty() {
return true;
}
match browse_dir(f) {
None => folder == "@carved",
Some(dir) => dir == folder || dir.strip_prefix(folder).is_some_and(|r| r.starts_with('/')),
}
}
/// Files a selection resolves to, in discovery order. For each file the
/// deepest marked or unmarked folder holding it decides; marked folders take
/// everything inside them, whatever filters the view had. A file marked on its
/// own is in, and an excluded id always wins.
pub fn resolve_selection<'a>(found: &'a [Found], sel: &Selection) -> Vec<&'a Found> {
let ids: std::collections::HashSet<u64> = sel.ids.iter().copied().collect();
let exclude: std::collections::HashSet<u64> = sel.exclude.iter().copied().collect();
let marks: Vec<(&str, bool)> = sel
.folders
.iter()
.map(|d| (d.trim_matches('/'), true))
.chain(
sel.exclude_folders
.iter()
.map(|d| (d.trim_matches('/'), false)),
)
.collect();
found
.iter()
.filter(|f| !exclude.contains(&f.id))
.filter(|f| {
ids.contains(&f.id)
|| marks
.iter()
.filter(|(d, _)| in_folder(f, d))
.max_by_key(|(d, included)| (d.len(), !included))
.is_some_and(|(_, included)| *included)
})
.collect()
}
/// Files and bytes a selection resolves to.
pub fn selection_count(found: &[Found], sel: &Selection) -> Value {
let files = resolve_selection(found, sel);
json!({ "files": files.len(), "bytes": files.iter().map(|f| f.size).sum::<u64>() })
}
/// Facet counts for sidebar navigation. /// Facet counts for sidebar navigation.
pub fn facets(found: &[Found]) -> Value { pub fn facets(found: &[Found]) -> Value {
let mut cats: HashMap<&'static str, usize> = HashMap::new(); let mut cats: HashMap<&'static str, usize> = HashMap::new();
@@ -375,3 +509,180 @@ pub fn quick_sig(src: &dyn crate::source::Source, f: &Found) -> Option<u64> {
} }
Some(h) Some(h)
} }
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<Found> {
vec![
Found::test_file(1, "DCIM/100CANON", "a.jpg", 100),
Found::test_file(2, "DCIM", "b.jpg", 10),
Found::test_file(3, "", "x.txt", 5),
Found::test_carved(4, "c.jpg", 7),
Found::test_file(5, "DCIM/100CANON", "notes.txt", 1),
]
}
fn folders(v: &Value) -> Vec<(String, u64, u64)> {
v["folders"]
.as_array()
.unwrap()
.iter()
.map(|f| {
(
f["path"].as_str().unwrap().to_string(),
f["files"].as_u64().unwrap(),
f["bytes"].as_u64().unwrap(),
)
})
.collect()
}
fn names(v: &Value) -> Vec<String> {
v["files"]
.as_array()
.unwrap()
.iter()
.map(|f| f["name"].as_str().unwrap().to_string())
.collect()
}
#[test]
fn browse_lists_direct_children() {
let v = browse(&sample(), "", &Query::default());
assert_eq!(
folders(&v),
vec![("DCIM".to_string(), 3, 111), ("@carved".to_string(), 1, 7)]
);
assert_eq!(v["folders"][1]["name"], "Por assinatura");
assert_eq!(names(&v), vec!["x.txt"]);
assert_eq!(v["total_files"], 1);
}
#[test]
fn browse_inside_folder() {
let v = browse(&sample(), "DCIM", &Query::default());
assert_eq!(folders(&v), vec![("DCIM/100CANON".to_string(), 2, 101)]);
assert_eq!(v["folders"][0]["name"], "100CANON");
assert_eq!(names(&v), vec!["b.jpg"]);
let carved = browse(&sample(), "@carved", &Query::default());
assert!(folders(&carved).is_empty());
assert_eq!(names(&carved), vec!["c.jpg"]);
}
#[test]
fn browse_applies_filters() {
let q = Query {
category: Some(Category::Image),
..Query::default()
};
let v = browse(&sample(), "", &q);
assert_eq!(
folders(&v),
vec![("DCIM".to_string(), 2, 110), ("@carved".to_string(), 1, 7)]
);
assert!(names(&v).is_empty());
}
#[test]
fn prefix_is_not_parent() {
let found = vec![
Found::test_file(1, "Foto", "a.jpg", 1),
Found::test_file(2, "Fotos", "b.jpg", 1),
];
let v = browse(&found, "Foto", &Query::default());
assert_eq!(names(&v), vec!["a.jpg"]);
assert!(folders(&v).is_empty());
}
fn ids(v: Vec<&Found>) -> Vec<u64> {
v.into_iter().map(|f| f.id).collect()
}
#[test]
fn resolve_selection_folder_and_ids() {
let found = sample();
let sel = Selection {
folders: vec!["DCIM/100CANON".into()],
ids: vec![3],
exclude: vec![],
exclude_folders: vec![],
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 5]);
let all = Selection {
folders: vec!["".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&found, &all)), vec![1, 2, 3, 4, 5]);
}
#[test]
fn exclude_wins_over_ids() {
let found = sample();
let sel = Selection {
folders: vec!["DCIM".into()],
ids: vec![5],
exclude: vec![5, 2],
exclude_folders: vec![],
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1]);
}
#[test]
fn excluded_subfolder_is_left_out() {
let found = sample();
let sel = Selection {
folders: vec!["DCIM".into()],
exclude_folders: vec!["DCIM/100CANON".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![2]);
// a folder marked again inside the excluded one comes back
let sel = Selection {
folders: vec!["".into(), "DCIM/100CANON".into()],
exclude_folders: vec!["DCIM".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![1, 3, 4, 5]);
assert_eq!(
selection_count(&found, &sel),
json!({ "files": 4, "bytes": 113 })
);
}
#[test]
fn selection_carved_folder() {
let found = sample();
let sel = Selection {
folders: vec!["@carved".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&found, &sel)), vec![4]);
let prefix = vec![
Found::test_file(1, "Foto", "a.jpg", 1),
Found::test_file(2, "Fotos", "b.jpg", 1),
];
let sel = Selection {
folders: vec!["Foto".into()],
..Selection::default()
};
assert_eq!(ids(resolve_selection(&prefix, &sel)), vec![1]);
}
#[test]
fn browse_paginates_files() {
let found: Vec<Found> = (0..250)
.map(|i| Found::test_file(i, "big", &format!("f{i:03}.txt"), 1))
.collect();
let q = Query {
limit: 100,
offset: 200,
..Query::default()
};
let v = browse(&found, "big", &q);
assert_eq!(v["total_files"], 250);
assert_eq!(names(&v).len(), 50);
assert_eq!(names(&v)[0], "f200.txt");
}
}
+1
View File
@@ -50,6 +50,7 @@ pub mod source;
pub mod sqlite; pub mod sqlite;
pub mod thumb; pub mod thumb;
pub mod types; pub mod types;
pub mod update;
pub mod vdisk; pub mod vdisk;
pub mod volume; pub mod volume;
+173 -1
View File
@@ -169,6 +169,25 @@ pub fn recover_resumable(
resume: bool, resume: bool,
cancelled: &(dyn Fn() -> bool + Sync), cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64), progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> {
recover_resumable_with(
src, files, dest, layout, None, source_key, resume, cancelled, progress,
)
}
/// [`recover_resumable`] for a folder-browsing selection: with `roots`, each
/// file lands relative to its marked folder and `layout` is ignored.
#[allow(clippy::too_many_arguments)]
pub fn recover_resumable_with(
src: &dyn Source,
files: &[&Found],
dest: &Path,
layout: RecoverLayout,
roots: Option<&SelectionRoots>,
source_key: &str,
resume: bool,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(usize, usize, u64),
) -> Result<RecoverReport> { ) -> Result<RecoverReport> {
use std::io::Write; use std::io::Write;
crate::owner::create_dir_all_owned(dest)?; crate::owner::create_dir_all_owned(dest)?;
@@ -219,7 +238,7 @@ pub fn recover_resumable(
let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p);
} }
} }
let rel = target_rel(f, layout); let rel = target_rel_in(f, layout, roots);
let mut path = dest.join(&rel); let mut path = dest.join(&rel);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
if let Err(e) = crate::owner::create_dir_all_owned(parent) { if let Err(e) = crate::owner::create_dir_all_owned(parent) {
@@ -292,6 +311,97 @@ pub fn recover_resumable(
Ok(report) Ok(report)
} }
/// Marked folders of a folder-browsing recovery and the name each one gets in
/// the destination: `a/b/Projeto` is recreated as `Projeto/...`.
#[derive(Debug, Clone, Default)]
pub struct SelectionRoots {
/// `(folder path, destination name)`; a nested marked folder is dropped
/// because the outermost one already covers it.
roots: Vec<(String, String)>,
}
impl SelectionRoots {
pub fn new(folders: &[String]) -> Self {
let mut paths: Vec<String> = folders
.iter()
.map(|f| f.trim_matches('/').to_string())
.collect();
paths.sort();
paths.dedup();
let covered = |p: &str, by: &str| {
p != by
&& p != "@carved"
&& (by.is_empty() || p.strip_prefix(by).is_some_and(|r| r.starts_with('/')))
};
let kept: Vec<String> = paths
.iter()
.filter(|p| !paths.iter().any(|q| covered(p, q)))
.cloned()
.collect();
let mut used: HashSet<String> = HashSet::new();
let roots = kept
.into_iter()
.map(|path| {
let base = match path.as_str() {
"" => String::new(),
"@carved" => "Por assinatura".to_string(),
p => sanitize_name(p.rsplit('/').next().unwrap_or(p)),
};
let mut name = base.clone();
let mut n = 2;
while !base.is_empty() && !used.insert(name.clone()) {
name = format!("{base} ({n})");
n += 1;
}
(path, name)
})
.collect();
Self { roots }
}
/// Destination folder for `f`, or `None` when no marked folder holds it
/// (a file marked on its own goes to the destination root).
fn rel_dir(&self, f: &Found) -> Option<PathBuf> {
let (path, name) = self
.roots
.iter()
.find(|(path, _)| crate::explorer::in_folder(f, path))?;
let mut out = PathBuf::new();
if !name.is_empty() {
out.push(name);
}
if f.origin == Origin::Carved {
if path.is_empty() {
out.push("Por assinatura");
}
return Some(out);
}
let dir = f.dir.as_deref().unwrap_or("").trim_matches('/');
let rest = if path.is_empty() {
dir
} else {
dir[path.len()..].trim_start_matches('/')
};
for part in rest.split('/').filter(|p| !p.is_empty()) {
out.push(sanitize_name(part));
}
Some(out)
}
}
/// Destination path of `f`: relative to its marked folder when recovering a
/// selection, otherwise by `layout`.
fn target_rel_in(f: &Found, layout: RecoverLayout, roots: Option<&SelectionRoots>) -> PathBuf {
match roots {
Some(roots) => {
let mut p = roots.rel_dir(f).unwrap_or_default();
p.push(sanitize_name(&f.name));
p
}
None => target_rel(f, layout),
}
}
fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf { fn target_rel(f: &Found, layout: RecoverLayout) -> PathBuf {
let mut p = PathBuf::new(); let mut p = PathBuf::new();
match layout { match layout {
@@ -524,3 +634,65 @@ impl Source for FoundSource<'_> {
read_found_at(self.src, self.found, offset, buf) read_found_at(self.src, self.found, offset, buf)
} }
} }
#[cfg(test)]
mod selection_tests {
use super::*;
fn rel(roots: &[&str], f: &Found) -> String {
let roots = SelectionRoots::new(&roots.iter().map(|r| r.to_string()).collect::<Vec<_>>());
target_rel_in(f, RecoverLayout::Folders, Some(&roots))
.to_string_lossy()
.replace('\\', "/")
}
#[test]
fn selection_target_relative() {
let f = Found::test_file(1, "a/b/Projeto/sub", "x.jpg", 1);
assert_eq!(rel(&["a/b/Projeto"], &f), "Projeto/sub/x.jpg");
let top = Found::test_file(2, "a/b/Projeto", "y.txt", 1);
assert_eq!(rel(&["a/b/Projeto"], &top), "Projeto/y.txt");
}
#[test]
fn selection_nested_uses_outermost() {
let f = Found::test_file(1, "a/Projeto/sub", "x.jpg", 1);
assert_eq!(
rel(&["a/Projeto/sub", "a/Projeto"], &f),
"Projeto/sub/x.jpg"
);
}
#[test]
fn selection_same_name_suffix() {
let roots = ["b/Projeto", "a/Projeto"];
assert_eq!(
rel(&roots, &Found::test_file(1, "a/Projeto", "x.jpg", 1)),
"Projeto/x.jpg"
);
assert_eq!(
rel(&roots, &Found::test_file(2, "b/Projeto", "y.jpg", 1)),
"Projeto (2)/y.jpg"
);
}
#[test]
fn selection_loose_file_at_root() {
let f = Found::test_file(1, "Documentos", "solto.pdf", 1);
assert_eq!(rel(&["Outra"], &f), "solto.pdf");
assert_eq!(
rel(&["@carved"], &Found::test_carved(2, "c.jpg", 1)),
"Por assinatura/c.jpg"
);
assert_eq!(
rel(&[""], &Found::test_file(3, "a/b", "z.txt", 1)),
"a/b/z.txt"
);
}
#[test]
fn selection_target_sanitizes() {
let f = Found::test_file(1, "x/Proj:e?to/sub*", "a.jpg", 1);
assert_eq!(rel(&["x/Proj:e?to"], &f), "Proj_e_to/sub_/a.jpg");
}
}
+37 -1
View File
@@ -101,6 +101,37 @@ pub fn csv(found: &[&Found]) -> String {
out out
} }
/// The Guará logo, embedded so the report renders identically as a
/// standalone, offline HTML/PDF export (same reasoning as
/// `Sabiá/crates/sabia-core/src/report.rs`, which this mirrors): a
/// `src="/logo.png"` reference would 404 once the file is saved outside the
/// app's own server, and a forensic report that only half-renders without
/// network access defeats its purpose as a portable piece of evidence.
const LOGO_PNG: &[u8] = include_bytes!("../../../logo.png");
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Encodes the embedded logo as a `data:image/png;base64,...` URI.
fn logo_data_uri() -> String {
let mut out = String::with_capacity(LOGO_PNG.len().div_ceil(3) * 4 + 22);
out.push_str("data:image/png;base64,");
for chunk in LOGO_PNG.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied();
let b2 = chunk.get(2).copied();
out.push(B64[(b0 >> 2) as usize] as char);
out.push(B64[(((b0 & 0b11) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
out.push(match b1 {
Some(b1) => B64[(((b1 & 0b1111) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char,
None => '=',
});
out.push(match b2 {
Some(b2) => B64[(b2 & 0b111111) as usize] as char,
None => '=',
});
}
out
}
fn esc(s: &str) -> String { fn esc(s: &str) -> String {
s.replace('&', "&amp;") s.replace('&', "&amp;")
.replace('<', "&lt;") .replace('<', "&lt;")
@@ -135,6 +166,9 @@ pub fn html(r: &ReportInput) -> String {
r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style> r#"<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Guará: relatório de recuperação</title><style>
:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7} :root{color-scheme:light}*{box-sizing:border-box}body{margin:0;font:14px/1.5 -apple-system,"Segoe UI",Inter,Roboto,sans-serif;color:#10201f;background:#f3f7f7}
main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px} main{max-width:1100px;margin:0 auto;padding:36px 28px 60px}header{border-bottom:3px solid #0f766e;padding-bottom:14px;margin-bottom:22px}
.brand{display:flex;align-items:center;gap:10px}.brand img{width:30px;height:30px;object-fit:contain}
.badges{display:flex;gap:6px;margin-top:8px}.badge{font:700 11px ui-monospace,Menlo,Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;padding:2px 8px;border-radius:3px;border:1px solid #0f766e;color:#0f766e;background:#e7f3f2}
.badge-tlp{color:#8a5a00;border-color:#8a5a00;background:#fff0cf}
h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d} h1{margin:0;font-size:28px;letter-spacing:-.02em}h2{margin:30px 0 10px;font-size:17px;color:#0f766e}small,.mut{color:#5b6f6d}
table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)} table{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600} th,td{padding:7px 10px;text-align:left;border-bottom:1px solid #e4eded;vertical-align:top;font-size:13px}th{background:#e7f3f2;color:#0b4f4a;font-weight:600}
@@ -145,7 +179,9 @@ td.n{text-align:right;font-variant-numeric:tabular-nums}.mono{font-family:ui-mon
footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}} footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background:#fff}main{padding:0}table{box-shadow:none}h2{break-after:avoid}tr{break-inside:avoid}}
</style></head><body><main>"#, </style></head><body><main>"#,
); );
o.push_str("<header><h1>Relatório de recuperação de arquivos</h1><div class=\"mut\">Gerado pelo Guará "); o.push_str("<header><div class=\"brand\"><img src=\"");
o.push_str(&logo_data_uri());
o.push_str("\" alt=\"\"><h1>Relatório de recuperação de arquivos</h1></div><div class=\"badges\"><span class=\"badge badge-tlp\">TLP:AMBER</span><span class=\"badge\">Uso autorizado</span></div><div class=\"mut\">Gerado pelo Guará ");
o.push_str(env!("CARGO_PKG_VERSION")); o.push_str(env!("CARGO_PKG_VERSION"));
o.push_str(&format!(" em {}</div></header>", fmt_unix(now))); o.push_str(&format!(" em {}</div></header>", fmt_unix(now)));
+77 -2
View File
@@ -359,6 +359,13 @@ impl Engine {
Ok(json!({ "total": total, "items": page })) Ok(json!({ "total": total, "items": page }))
} }
/// One level of the folder tree (see [`crate::explorer::browse`]).
pub fn browse(&self, id: &str, path: &str, q: &Query) -> Result<Value> {
let j = self.job(id)?;
let s = j.st.lock().unwrap();
Ok(crate::explorer::browse(&s.found, path, q))
}
/// Sidebar navigation facet counts (categories, dates, folders, cameras). /// Sidebar navigation facet counts (categories, dates, folders, cameras).
pub fn facets(&self, id: &str) -> Result<Value> { pub fn facets(&self, id: &str) -> Result<Value> {
let j = self.job(id)?; let j = self.job(id)?;
@@ -463,6 +470,7 @@ impl Engine {
scan_id: &str, scan_id: &str,
ids: Option<Vec<u64>>, ids: Option<Vec<u64>>,
filter: Option<Query>, filter: Option<Query>,
selection: Option<crate::explorer::Selection>,
dest: &str, dest: &str,
layout: recover::RecoverLayout, layout: recover::RecoverLayout,
) -> Result<String> { ) -> Result<String> {
@@ -470,6 +478,12 @@ impl Engine {
let src = sj.source().ok_or_else(no_source)?; let src = sj.source().ok_or_else(no_source)?;
let files: Vec<Found> = { let files: Vec<Found> = {
let s = sj.st.lock().unwrap(); let s = sj.st.lock().unwrap();
if let Some(sel) = &selection {
crate::explorer::resolve_selection(&s.found, sel)
.into_iter()
.cloned()
.collect()
} else {
match (&ids, &filter) { match (&ids, &filter) {
(Some(ids), _) => ids (Some(ids), _) => ids
.iter() .iter()
@@ -478,7 +492,11 @@ impl Engine {
(None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(), (None, Some(q)) => q.select(&s.found).into_iter().cloned().collect(),
(None, None) => s.found.clone(), (None, None) => s.found.clone(),
} }
}
}; };
let roots = selection
.as_ref()
.map(|sel| recover::SelectionRoots::new(&sel.folders));
if files.is_empty() { if files.is_empty() {
return Err(Error::msg("nenhum arquivo selecionado")); return Err(Error::msg("nenhum arquivo selecionado"));
} }
@@ -526,11 +544,12 @@ impl Engine {
}; };
}; };
let refs: Vec<&Found> = files.iter().collect(); let refs: Vec<&Found> = files.iter().collect();
let res = recover::recover_resumable( let res = recover::recover_resumable_with(
src.as_ref(), src.as_ref(),
&refs, &refs,
&dest_path, &dest_path,
layout, layout,
roots.as_ref(),
&source_key, &source_key,
true, true,
&cancelled, &cancelled,
@@ -1232,6 +1251,28 @@ impl Engine {
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.ok_or_else(|| Error::msg("id ausente"))? .ok_or_else(|| Error::msg("id ausente"))?
)?), )?),
"browse" => {
let q: Query = p
.get("query")
.cloned()
.filter(|v| !v.is_null())
.map(serde_json::from_value)
.transpose()?
.unwrap_or_default();
let path = p.get("path").and_then(|v| v.as_str()).unwrap_or("");
self.browse(&s("job")?, path, &q)?
}
"selection_count" => {
let sel: crate::explorer::Selection = p
.get("selection")
.cloned()
.map(serde_json::from_value)
.transpose()?
.unwrap_or_default();
let j = self.job(&s("job")?)?;
let st = j.st.lock().unwrap();
crate::explorer::selection_count(&st.found, &sel)
}
"results" => { "results" => {
let q: Query = p let q: Query = p
.get("query") .get("query")
@@ -1265,7 +1306,13 @@ impl Engine {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.and_then(recover::RecoverLayout::parse) .and_then(recover::RecoverLayout::parse)
.unwrap_or_default(); .unwrap_or_default();
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?, layout)? }) let selection: Option<crate::explorer::Selection> = p
.get("selection")
.cloned()
.filter(|v| !v.is_null())
.map(serde_json::from_value)
.transpose()?;
json!({ "job": self.start_recover(&s("job")?, ids, filter, selection, &s("dest")?, layout)? })
} }
"image_start" => { "image_start" => {
let format: image::ImageFormat = p let format: image::ImageFormat = p
@@ -1303,9 +1350,37 @@ impl Engine {
"version" => { "version" => {
json!({ "version": env!("CARGO_PKG_VERSION"), "os": std::env::consts::OS }) json!({ "version": env!("CARGO_PKG_VERSION"), "os": std::env::consts::OS })
} }
"update_check" => {
let st = self.update_check()?;
json!(st)
}
"update_apply" => {
let version = self.update_apply()?;
json!({ "version": version })
}
other => return Err(Error::msg(format!("método desconhecido: {other}"))), other => return Err(Error::msg(format!("método desconhecido: {other}"))),
}) })
} }
pub fn update_check(&self) -> Result<crate::update::UpdateStatus> {
crate::update::check(crate::VERSION)
}
pub fn update_apply(&self) -> Result<String> {
let exe = std::env::current_exe()?;
crate::update::apply(&exe)
}
pub fn update_apply_desktop<F>(
&self,
current_exe: &std::path::Path,
on_progress: F,
) -> Result<String>
where
F: Fn(&str),
{
crate::update::apply_desktop_with_progress(current_exe, on_progress)
}
} }
pub use crate::explorer::Query; pub use crate::explorer::Query;
+1 -1
View File
@@ -23,7 +23,7 @@ pub struct Settings {
/// Show OpenStreetMap tiles for geotagged photos (fetches tiles over the network). /// Show OpenStreetMap tiles for geotagged photos (fetches tiles over the network).
#[serde(default)] #[serde(default)]
pub maps: bool, pub maps: bool,
/// Check Gitea Releases for updates on startup (fetches `latest.json` once /// Check Gitea Releases for updates on startup (one query to the releases API,
/// without sending any telemetry). /// without sending any telemetry).
#[serde(default = "yes")] #[serde(default = "yes")]
pub updates: bool, pub updates: bool,
+37
View File
@@ -510,3 +510,40 @@ impl Default for ScanOptions {
} }
} }
} }
#[cfg(test)]
impl Found {
/// Minimal resident file for unit tests: `dir` `""` means the volume root.
pub(crate) fn test_file(id: u64, dir: &str, name: &str, size: u64) -> Self {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
Found {
id,
name: name.into(),
dir: Some(dir.into()),
category: crate::signatures::category_for_ext(&ext),
ext,
size,
origin: Origin::Fat,
confidence: Confidence::High,
data: DataLoc::Resident { bytes: Vec::new() },
modified: None,
created: None,
accessed: None,
taken: None,
camera: None,
px: None,
gps: false,
sig: None,
note: None,
}
}
/// The same, found by signature (no folder).
pub(crate) fn test_carved(id: u64, name: &str, size: u64) -> Self {
Found {
dir: None,
origin: Origin::Carved,
..Self::test_file(id, "", name, size)
}
}
}
+570
View File
@@ -0,0 +1,570 @@
//! CLI self-updater: queries the latest Gitea Release, downloads the matching
//! platform archive, verifies its SHA-256 checksum, and atomically replaces the
//! running `guara` binary.
//!
//! Uses the system `curl` and `tar` binaries to avoid pulling a full TLS stack
//! into `guara-core`.
use crate::error::{Error, Result};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
const LATEST_RELEASE_URL: &str =
"https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/guara/releases/latest";
/// Human-facing Gitea Releases page for desktop GUI users.
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/ciberfauna/guara/releases";
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UpdateStatus {
pub current_version: String,
pub latest_version: Option<String>,
pub update_available: bool,
}
fn curl_text(url: &str) -> Result<String> {
let out = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: guara-updater", url])
.output()
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
if !out.status.success() {
return Err(Error::msg(format!(
"curl {url}: {}",
String::from_utf8_lossy(&out.stderr)
)));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn curl_download(url: &str, dest: &Path) -> Result<()> {
let status = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
.arg(dest)
.arg(url)
.status()
.map_err(|e| Error::msg(format!("curl {url}: {e}")))?;
if !status.success() {
return Err(Error::msg(format!("curl {url}: download falhou")));
}
Ok(())
}
/// Returns the expected release asset suffix for the current OS/architecture
/// (e.g. `"-linux-x86_64.tar.gz"`).
fn asset_suffix_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"windows"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
"aarch64"
};
let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
format!("-{os}-{arch}.{ext}")
}
/// Queries the latest published release and compares it against the running
/// `CARGO_PKG_VERSION`.
pub fn check(current_version: &str) -> Result<UpdateStatus> {
let url = LATEST_RELEASE_URL;
let body = match curl_text(url) {
Ok(b) => b,
Err(_) => {
// Offline or no published releases yet: treat as no update available.
return Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: None,
update_available: false,
});
}
};
let release: Release = serde_json::from_str(&body)?;
let latest = release.tag_name.trim_start_matches('v').to_string();
let update_available = latest != current_version;
Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: Some(latest),
update_available,
})
}
/// Downloads, verifies, and installs the latest CLI release in place.
/// Returns the newly installed version string.
pub fn apply(current_exe: &PathBuf) -> Result<String> {
// Guard against replacing `guara-gui` with the CLI binary (`guara`);
// desktop GUI updates use the installer/package from the release page instead.
let is_cli = current_exe
.file_stem()
.map(|stem| stem == "guara")
.unwrap_or(false);
if !is_cli {
return Err(Error::msg(format!(
"a atualização automática vale só para o CLI `guara`; para o app desktop, baixe o instalador em {RELEASES_PAGE_URL}"
)));
}
let body = curl_text(LATEST_RELEASE_URL)?;
let release: Release = serde_json::from_str(&body)?;
let suffix = asset_suffix_for_platform();
let asset = release
.assets
.iter()
.find(|a| a.name.ends_with(&suffix))
.ok_or_else(|| {
Error::msg(format!(
"nenhum asset terminado em `{suffix}` na release {}",
release.tag_name
))
})?;
let checksum_asset = release
.assets
.iter()
.find(|a| a.name == format!("{}.sha256", asset.name));
let tmp_dir = std::env::temp_dir().join("guara-update");
fs::create_dir_all(&tmp_dir)?;
let archive_path = tmp_dir.join(&asset.name);
curl_download(&asset.browser_download_url, &archive_path)?;
if let Some(checksum_asset) = checksum_asset {
let expected = curl_text(&checksum_asset.browser_download_url)?;
let expected_hash = expected
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let data = fs::read(&archive_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash: String = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
if actual_hash != expected_hash {
return Err(Error::msg(
"checksum SHA-256 não confere — atualização descartada",
));
}
}
// Extract the archive (`tar xf` auto-detects `.tar.gz` and `.zip` on modern
// libarchive/bsdtar) and atomically replace the current executable.
let extract_dir = tmp_dir.join("extracted");
let _ = fs::remove_dir_all(&extract_dir);
fs::create_dir_all(&extract_dir)?;
let status = Command::new("tar")
.arg("xf")
.arg(&archive_path)
.arg("-C")
.arg(&extract_dir)
.status()?;
if !status.success() {
return Err(Error::msg("tar xf: extração falhou"));
}
let new_binary = find_binary(&extract_dir)?;
let backup = current_exe.with_extension("bak");
let _ = fs::remove_file(&backup);
fs::rename(current_exe, &backup)?;
fs::copy(&new_binary, current_exe)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(current_exe)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(current_exe, perms)?;
}
Ok(release.tag_name)
}
/// Locates `name` at the archive root or one directory level below
/// (`guara-vX.Y.Z-<platform>/guara`).
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
fn is_binary(name: &std::ffi::OsStr) -> bool {
name == "guara" || name == "guara.exe"
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
if is_binary(&entry.file_name()) {
return Ok(entry.path());
}
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
for inner in fs::read_dir(entry.path())? {
let inner = inner?;
if is_binary(&inner.file_name()) {
return Ok(inner.path());
}
}
}
}
Err(Error::msg("binário não encontrado no pacote baixado"))
}
/// Desktop GUI installation type detected at runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DesktopInstall {
/// Linux AppImage: real filesystem path of the .AppImage file (from APPIMAGE env var).
AppImage(PathBuf),
/// Linux Debian package installed via dpkg: `current_exe` is reliable here.
Deb,
/// Windows NSIS installer: downloaded setup executable run with `/S`
/// (silent). Does not relaunch the app on its own — see the comment on
/// the `WindowsInstaller` apply path.
WindowsInstaller,
}
/// Detects the desktop installation type given an optional `APPIMAGE` environment
/// variable value and the current executable path.
pub fn detect_desktop_install_from(
appimage_env: Option<&std::ffi::OsStr>,
current_exe: &Path,
) -> Result<DesktopInstall> {
#[cfg(windows)]
{
let _ = (appimage_env, current_exe);
Ok(DesktopInstall::WindowsInstaller)
}
#[cfg(target_os = "linux")]
{
if let Some(val) = appimage_env {
let path = PathBuf::from(val);
if path.is_file() {
return Ok(DesktopInstall::AppImage(path));
}
}
if current_exe.is_file()
&& current_exe
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("appimage"))
.unwrap_or(false)
{
return Ok(DesktopInstall::AppImage(current_exe.to_path_buf()));
}
Ok(DesktopInstall::Deb)
}
#[cfg(not(any(windows, target_os = "linux")))]
{
let _ = (appimage_env, current_exe);
Err(Error::msg(format!(
"automatic self-update is not supported on this platform; please download manually from {RELEASES_PAGE_URL}"
)))
}
}
/// Detects the desktop installation type using the live environment.
pub fn detect_desktop_install() -> Result<DesktopInstall> {
let exe = std::env::current_exe()?;
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), &exe)
}
/// Returns the expected release asset suffix for a desktop installation and CPU architecture.
pub fn gui_asset_suffix_for(install: &DesktopInstall, arch: &str) -> Result<&'static str> {
match install {
DesktopInstall::AppImage(_) => match arch {
"x86_64" => Ok("_amd64.AppImage"),
"aarch64" => Ok("_aarch64.AppImage"),
other => Err(Error::msg(format!(
"unsupported architecture for AppImage: {other}"
))),
},
DesktopInstall::Deb => match arch {
"x86_64" => Ok("_amd64.deb"),
"aarch64" => Ok("_arm64.deb"),
other => Err(Error::msg(format!(
"unsupported architecture for deb package: {other}"
))),
},
DesktopInstall::WindowsInstaller => match arch {
"x86_64" => Ok("_x64-setup.exe"),
other => Err(Error::msg(format!(
"unsupported architecture for Windows installer: {other}"
))),
},
}
}
/// Returns the expected release asset suffix for the current architecture.
pub fn gui_asset_suffix(install: &DesktopInstall) -> Result<&'static str> {
gui_asset_suffix_for(install, std::env::consts::ARCH)
}
/// Downloads, verifies, and installs the latest desktop GUI release in place.
pub fn apply_desktop(current_exe: &Path) -> Result<String> {
apply_desktop_with_progress(current_exe, |_| {})
}
/// Downloads, verifies, and installs the latest desktop GUI release in place,
/// notifying the progress callback of phase transitions (`"downloading"`,
/// `"verifying"`, `"installing"`, `"restarting"`).
pub fn apply_desktop_with_progress<F>(current_exe: &Path, on_progress: F) -> Result<String>
where
F: Fn(&str),
{
let install =
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), current_exe)?;
let body = curl_text(LATEST_RELEASE_URL)?;
let release: Release = serde_json::from_str(&body)?;
let suffix = gui_asset_suffix(&install)?;
let asset = release
.assets
.iter()
.find(|a| a.name.ends_with(suffix))
.ok_or_else(|| {
Error::msg(format!(
"no asset ending with `{suffix}` in release {}",
release.tag_name
))
})?;
let checksum_asset = release
.assets
.iter()
.find(|a| a.name == format!("{}.sha256", asset.name));
let tmp_dir = std::env::temp_dir().join("guara-desktop-update");
fs::create_dir_all(&tmp_dir)?;
let download_path = tmp_dir.join(&asset.name);
on_progress("downloading");
curl_download(&asset.browser_download_url, &download_path)?;
if let Some(checksum_asset) = checksum_asset {
on_progress("verifying");
let expected = curl_text(&checksum_asset.browser_download_url)?;
let expected_hash = expected
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let data = fs::read(&download_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash: String = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
if actual_hash != expected_hash {
return Err(Error::msg(
"checksum SHA-256 não confere — atualização descartada",
));
}
}
on_progress("installing");
match install {
DesktopInstall::AppImage(target_appimage) => {
#[cfg(target_os = "linux")]
{
let parent_dir = target_appimage.parent().unwrap_or_else(|| Path::new("."));
let temp_name = format!(
".{}.tmp-{}",
target_appimage
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("appimage"),
std::process::id()
);
let temp_file = parent_dir.join(temp_name);
fs::copy(&download_path, &temp_file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&temp_file)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&temp_file, perms)?;
}
fs::rename(&temp_file, &target_appimage)?;
let _ = fs::remove_file(&download_path);
on_progress("restarting");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(&target_appimage)
.args(std::env::args().skip(1))
.exec();
return Err(Error::msg(format!("failed to relaunch AppImage: {err}")));
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (&target_appimage, &download_path);
return Err(Error::msg("AppImage updates are only supported on Linux"));
}
}
DesktopInstall::Deb => {
#[cfg(target_os = "linux")]
{
let status = Command::new("dpkg")
.args(["-i"])
.arg(&download_path)
.status()
.map_err(|e| Error::msg(format!("dpkg -i: {e}")))?;
if !status.success() {
return Err(Error::msg(format!(
"dpkg -i failed with exit code {:?}",
status.code()
)));
}
let _ = fs::remove_file(&download_path);
on_progress("restarting");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(current_exe)
.args(std::env::args().skip(1))
.exec();
return Err(Error::msg(format!("failed to relaunch updated app: {err}")));
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (&download_path, current_exe);
return Err(Error::msg("deb updates are only supported on Linux"));
}
}
DesktopInstall::WindowsInstaller => {
#[cfg(windows)]
{
// `/S` is the one NSIS silent-install switch with a documented,
// stable meaning. There is no standard NSIS or Tauri flag that
// makes a silent install relaunch the app afterwards — that was
// claimed in an earlier draft of this code and could not be
// verified against NSIS's or Tauri's documentation; passing an
// invented flag to a stock NSIS installer is silently ignored,
// not harmful, but also not a relaunch mechanism. Spawning
// detached and exiting immediately (instead of waiting on the
// child) also avoids a self-deadlock: if the installer's script
// waits for this process to close before it can overwrite the
// running .exe, blocking here on the installer's exit status
// would hang forever. The practical effect: the update installs
// silently, but the app does not reopen itself — the user needs
// to start it again. This needs real-world confirmation on
// Windows before relying on it; if testing shows the bundled
// NSIS template does support an explicit relaunch flag or step,
// wire it in here instead of this comment.
on_progress("restarting");
Command::new(&download_path)
.arg("/S")
.spawn()
.map_err(|e| Error::msg(format!("failed to launch installer: {e}")))?;
std::process::exit(0);
}
#[cfg(not(windows))]
{
let _ = &download_path;
return Err(Error::msg(
"Windows installer updates are only supported on Windows",
));
}
}
}
#[allow(unreachable_code)]
Ok(release.tag_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_refuses_to_overwrite_the_desktop_app() {
let err = apply(&PathBuf::from("/opt/guara/guara-gui")).unwrap_err();
assert!(err.to_string().contains("só para o CLI"), "{err}");
let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err();
assert!(err.to_string().contains(RELEASES_PAGE_URL), "{err}");
}
#[test]
fn detect_desktop_install_variants() {
#[cfg(target_os = "linux")]
{
let temp_appimage =
std::env::temp_dir().join(format!("test-{}.AppImage", std::process::id()));
let _ = fs::write(&temp_appimage, b"dummy appimage");
let res = detect_desktop_install_from(
Some(temp_appimage.as_os_str()),
Path::new("/usr/bin/guara-gui"),
)
.unwrap();
assert_eq!(res, DesktopInstall::AppImage(temp_appimage.clone()));
let res_fallback = detect_desktop_install_from(None, &temp_appimage).unwrap();
assert_eq!(
res_fallback,
DesktopInstall::AppImage(temp_appimage.clone())
);
let _ = fs::remove_file(&temp_appimage);
let res_deb =
detect_desktop_install_from(None, Path::new("/usr/bin/guara-gui")).unwrap();
assert_eq!(res_deb, DesktopInstall::Deb);
}
#[cfg(windows)]
{
let res = detect_desktop_install_from(
None,
Path::new("C:/Program Files/Guara/guara-gui.exe"),
)
.unwrap();
assert_eq!(res, DesktopInstall::WindowsInstaller);
}
}
#[test]
fn gui_asset_suffix_matches_known_release_naming() {
let appimage = DesktopInstall::AppImage(PathBuf::from("/tmp/Guara.AppImage"));
assert_eq!(
gui_asset_suffix_for(&appimage, "x86_64").unwrap(),
"_amd64.AppImage"
);
assert_eq!(
gui_asset_suffix_for(&appimage, "aarch64").unwrap(),
"_aarch64.AppImage"
);
let deb = DesktopInstall::Deb;
assert_eq!(gui_asset_suffix_for(&deb, "x86_64").unwrap(), "_amd64.deb");
assert_eq!(gui_asset_suffix_for(&deb, "aarch64").unwrap(), "_arm64.deb");
let win = DesktopInstall::WindowsInstaller;
assert_eq!(
gui_asset_suffix_for(&win, "x86_64").unwrap(),
"_x64-setup.exe"
);
assert!(gui_asset_suffix_for(&appimage, "mips").is_err());
assert!(gui_asset_suffix_for(&deb, "riscv64").is_err());
assert!(gui_asset_suffix_for(&win, "arm").is_err());
}
}
+122
View File
@@ -381,6 +381,7 @@ fn sessao_da_v0_1_0_abre_e_funciona() {
&job, &job,
None, None,
Some(service::Query::default()), Some(service::Query::default()),
None,
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders, recover::RecoverLayout::Folders,
) )
@@ -437,6 +438,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
&job, &job,
None, None,
None, None,
None,
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders, recover::RecoverLayout::Folders,
) )
@@ -452,6 +454,7 @@ fn sessao_abre_sem_a_origem_e_pode_ser_reconectada() {
&job, &job,
None, None,
None, None,
None,
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders, recover::RecoverLayout::Folders,
) )
@@ -712,6 +715,7 @@ fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() {
&job, &job,
None, None,
None, None,
None,
out.path().to_str().unwrap(), out.path().to_str().unwrap(),
recover::RecoverLayout::Folders, recover::RecoverLayout::Folders,
) )
@@ -719,3 +723,121 @@ fn espaco_insuficiente_no_destino_e_avisado_antes_de_copiar() {
assert!(e.to_string().contains("Espaço insuficiente"), "{e}"); assert!(e.to_string().contains("Espaço insuficiente"), "{e}");
assert!(recover::free_space(out.path()).unwrap_or(0) > 0); assert!(recover::free_space(out.path()).unwrap_or(0) > 0);
} }
/// Folder browsing on FAT32 (deleted short names lose their first character,
/// hence `_raia.jpg`): one level at a time, and recovering a marked folder
/// recreates only that folder (plus single marked files at the root, minus the
/// unmarked ones).
#[test]
fn navegacao_por_pastas_e_recuperacao_da_selecao() {
init();
let Some(dir) = fixtures() else { return };
let img = dir.join("fat32.img");
let eng = service::Engine::new();
let job = eng
.start_scan(img.to_str().unwrap(), ScanOptions::default())
.unwrap();
wait_done(&eng, &job);
let root = eng.browse(&job, "", &service::Query::default()).unwrap();
let folders: Vec<(String, u64)> = root["folders"]
.as_array()
.unwrap()
.iter()
.filter(|f| f["path"] != "@carved")
.map(|f| {
(
f["path"].as_str().unwrap().to_string(),
f["files"].as_u64().unwrap(),
)
})
.collect();
assert!(folders.contains(&("Documentos".into(), 5)), "{folders:?}");
assert!(folders.contains(&("Fotos".into(), 2)), "{folders:?}");
let docs = eng
.browse(&job, "Documentos", &service::Query::default())
.unwrap();
assert_eq!(docs["folders"][0]["path"], "Documentos/Trabalho");
assert_eq!(docs["folders"][0]["files"], 3);
let all = eng
.results(
&job,
&service::Query {
limit: 5000,
..Default::default()
},
)
.unwrap();
let id_of = |name: &str| {
all["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["name"] == name && i["origin"] == "fat")
.unwrap_or_else(|| panic!("{name} not found"))["id"]
.as_u64()
.unwrap()
};
let selection = explorer::Selection {
folders: vec!["Documentos/Trabalho".into()],
ids: vec![id_of("_raia.jpg")],
exclude: vec![id_of("tese.docx")],
exclude_folders: vec![],
};
let out = tempfile::tempdir().unwrap();
let rj = eng
.start_recover(
&job,
None,
None,
Some(selection),
out.path().to_str().unwrap(),
recover::RecoverLayout::Folders,
)
.unwrap();
wait_done(&eng, &rj);
let got = hashes_in(out.path());
let mut paths: Vec<String> = got
.values()
.map(|p| {
p.strip_prefix(out.path())
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
paths.sort();
assert_eq!(
paths,
vec!["Trabalho/_otas.txt", "Trabalho/relatorio.pdf", "_raia.jpg"]
);
let expected = expected(&dir, "fat32");
for (hash, original) in &expected {
let wanted = [
"Documentos/Trabalho/notas.txt",
"Documentos/Trabalho/relatorio.pdf",
"Fotos/praia.jpg",
];
if wanted.contains(&original.as_str()) {
assert!(got.contains_key(hash), "content of {original} differs");
}
}
assert!(out.path().join("guara-manifesto.json").is_file());
let empty = explorer::Selection {
folders: vec!["Nada/Aqui".into()],
..Default::default()
};
let e = eng
.start_recover(
&job,
None,
None,
Some(empty),
out.path().to_str().unwrap(),
recover::RecoverLayout::Folders,
)
.unwrap_err();
assert!(e.to_string().contains("nenhum arquivo selecionado"), "{e}");
}
+7
View File
@@ -149,3 +149,10 @@ guara report s.json report.html --case 2026-001 --examiner "Name" --hash-source
``` ```
Includes case identification, source, partitions, summary, hashes, the audit trail status and the list of files. `--hash-source` adds the hash of the whole source. The format follows the extension: `report.pdf` writes an A4 PDF (standard fonts, nothing external), anything else writes HTML. Includes case identification, source, partitions, summary, hashes, the audit trail status and the list of files. `--hash-source` adds the hash of the whole source. The format follows the extension: `report.pdf` writes an A4 PDF (standard fonts, nothing external), anything else writes HTML.
## `update` — updating
```bash
guara update # check for a new version
guara update --apply # download, verify the SHA-256 and swap the executable
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 KiB

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 KiB

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+12 -2
View File
@@ -11,12 +11,22 @@ Guará has a desktop app (Tauri) and the same front end in the browser with `gua
After a scan, results open in a file-manager style explorer built for thousands of items: After a scan, results open in a file-manager style explorer built for thousands of items:
- **Left:** navigation by type, real photo date (EXIF, by year and month), folder, camera, confidence and GPS, with an option to hide small files and duplicates. - **Left:** navigation by type, real photo date (EXIF, by year and month), folder, camera, confidence and GPS, with an option to hide small files and duplicates.
- **Center:** thumbnail grid or list, grouped by day or month, with virtual scrolling. A filter chip shows what is active and clears it in one click. - **Center:** thumbnail grid or list, grouped by day or month, with virtual scrolling, or the **Folders** view (below) to browse one folder at a time. A filter chip shows what is active and clears it in one click.
- **Right:** details panel with the preview, folder, dates (taken, modified, created, accessed), camera, resolution and the file metadata. The badge shows the **confidence** of the file (high, medium or low). - **Right:** details panel with the preview, folder, dates (taken, modified, created, accessed), camera, resolution and the file metadata. The badge shows the **confidence** of the file (high, medium or low).
- **Bottom bar:** selection count and the recovery buttons. Recovery always goes to another folder and writes a manifest with MD5, SHA-1 and SHA-256. - **Bottom bar:** selection count and the recovery buttons. Recovery always goes to another folder and writes a manifest with MD5, SHA-1 and SHA-256.
Arrow keys move through the files, and Space opens the full-screen quick look. Selection works like Finder or Explorer (Ctrl, Shift, keyboard). Arrow keys move through the files, and Space opens the full-screen quick look. Selection works like Finder or Explorer (Ctrl, Shift, keyboard).
### Browsing by folder
<p align="center"><img src="img/folders-light-en.png" width="900" alt="The Folders view: the root of the recovered files with DCIM ticked, Viagens partly ticked, the selection total at the bottom and the folder details on the right"></p>
The third view button (the folder icon, next to grid and list) turns the explorer into a file manager over what was found. The center shows **one folder at a time**: its subfolders first, each with how many files and how much space it holds counting everything below it, then the files directly inside. A breadcrumb at the top (`Root › Documents › Project`) goes back up; double-click or Enter opens a folder, Backspace or Alt+↑ goes up, and clicking a folder in the sidebar tree jumps to it. Files found only by signature, which have no known folder, sit in **By signature** at the root. Filters (type, date, confidence…) still apply and change the counts.
To recover, tick folders and files with their boxes; the selection survives navigation. Ticking a folder takes **everything inside it**, at any depth, even what the active filters hide. Unticking a subfolder or a file inside a ticked folder leaves the folder half-ticked and keeps that item out. The bottom bar shows the exact total, and **Recover selection** recreates each ticked folder in the destination **from itself down**: recovering `Documents/Clients/Project` writes `Project/...` with all its subfolders. Files ticked on their own go to the root of the destination, and two ticked folders with the same name become `Project` and `Project (2)`. The manifest with MD5, SHA-1 and SHA-256 is written as always.
Only folders that hold at least one recovered file appear: Guará knows folders through the paths of the files it finds.
## Themes ## Themes
The switch at the bottom of the sidebar offers **Auto** (follows the system), **Light** and **Dark**. The choice is saved. The switch at the bottom of the sidebar offers **Auto** (follows the system), **Light** and **Dark**. The choice is saved.
@@ -29,7 +39,7 @@ The switch at the bottom of the sidebar offers **Auto** (follows the system), **
## Updates ## Updates
When the desktop app opens it asks Gitea for the release's `latest.json`. If a newer version exists, a card appears at the bottom right with the versions, **What's new** (the CHANGELOG notes) and **Update now**. The app downloads the package, verifies its signature against the public key built into the app, installs and reopens by itself. On Linux this works for the AppImage (the file is replaced in place); installs from `.deb` or `.rpm` only show the notice with the release address. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. The check sends nothing besides what any browser sends to Gitea. `guara ui` in the browser does not update itself. When the desktop app opens it asks Gitea for the latest release. If a newer version exists, a card appears at the bottom right with the versions and the release address. For the CLI, `guara update` checks for and installs updates with SHA-256 verification. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. The check sends nothing besides what any browser sends to Gitea. `guara ui` in the browser does not update itself.
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p> <p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
+7
View File
@@ -149,3 +149,10 @@ guara report s.json relatorio.html --case 2026-001 --examiner "Nome" --hash-sour
``` ```
Traz identificação do caso, origem, partições, resumo, hashes, a situação da trilha de auditoria e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira. O formato segue a extensão: `relatorio.pdf` gera um PDF A4 (fontes padrão, nada externo); qualquer outra gera HTML. Traz identificação do caso, origem, partições, resumo, hashes, a situação da trilha de auditoria e a lista dos arquivos. `--hash-source` inclui o hash da origem inteira. O formato segue a extensão: `relatorio.pdf` gera um PDF A4 (fontes padrão, nada externo); qualquer outra gera HTML.
## `update` — atualizações
```bash
guara update # verifica se há versão nova
guara update --apply # baixa, confere o SHA-256 e troca o executável
```
+12 -2
View File
@@ -11,12 +11,22 @@ O Guará tem um app desktop (Tauri) e o mesmo front-end no navegador com `guara
Depois da varredura, os resultados abrem num explorador no estilo de um gerenciador de arquivos, pensado para milhares de itens: Depois da varredura, os resultados abrem num explorador no estilo de um gerenciador de arquivos, pensado para milhares de itens:
- **Esquerda:** navegação por tipo, data real da foto (EXIF, por ano e mês), pasta, câmera, confiança e GPS, com opção de ocultar arquivos pequenos e duplicadas. - **Esquerda:** navegação por tipo, data real da foto (EXIF, por ano e mês), pasta, câmera, confiança e GPS, com opção de ocultar arquivos pequenos e duplicadas.
- **Centro:** grade de miniaturas ou lista, agrupadas por dia ou mês, com rolagem virtual. Uma etiqueta mostra o filtro ativo e o remove com um clique. - **Centro:** grade de miniaturas ou lista, agrupadas por dia ou mês, com rolagem virtual, ou a visualização **Pastas** (abaixo) para navegar uma pasta por vez. Uma etiqueta mostra o filtro ativo e o remove com um clique.
- **Direita:** painel de detalhes com a pré-visualização, pasta, datas (tirada, modificada, criada, acessada), câmera, resolução e os metadados do arquivo. O selo mostra a **confiança** do arquivo (alta, média ou baixa). - **Direita:** painel de detalhes com a pré-visualização, pasta, datas (tirada, modificada, criada, acessada), câmera, resolução e os metadados do arquivo. O selo mostra a **confiança** do arquivo (alta, média ou baixa).
- **Barra inferior:** quantidade selecionada e os botões de recuperação. A recuperação sempre vai para outra pasta e grava um manifesto com MD5, SHA-1 e SHA-256. - **Barra inferior:** quantidade selecionada e os botões de recuperação. A recuperação sempre vai para outra pasta e grava um manifesto com MD5, SHA-1 e SHA-256.
As setas navegam pelos arquivos e a barra de espaço abre a visualização rápida em tela cheia. A seleção funciona como no Finder ou no Explorer (Ctrl, Shift e teclado). As setas navegam pelos arquivos e a barra de espaço abre a visualização rápida em tela cheia. A seleção funciona como no Finder ou no Explorer (Ctrl, Shift e teclado).
### Navegando por pastas
<p align="center"><img src="../img/folders-light-pt.png" width="900" alt="A visualização Pastas: a raiz dos arquivos encontrados com DCIM marcada, Viagens parcialmente marcada, o total da seleção embaixo e os detalhes da pasta à direita"></p>
O terceiro botão de visualização (o ícone de pasta, ao lado de grade e lista) transforma o explorador num gerenciador de arquivos sobre o que foi encontrado. O centro mostra **uma pasta por vez**: primeiro as subpastas, cada uma com quantos arquivos e quanto espaço contém somando tudo abaixo dela, depois os arquivos que estão diretamente nela. Um caminho no topo (`Raiz › Documentos › Projeto`) volta para cima; duplo clique ou Enter abre uma pasta, Backspace ou Alt+↑ sobe, e clicar numa pasta da árvore lateral vai direto até ela. Arquivos achados só por assinatura, que não têm pasta conhecida, ficam em **Por assinatura** na raiz. Os filtros (tipo, data, confiança…) continuam valendo e mudam as contagens.
Para recuperar, marque pastas e arquivos pelas caixas; a seleção continua ao navegar. Marcar uma pasta leva **tudo o que está dentro dela**, em qualquer profundidade, inclusive o que os filtros ativos escondem. Desmarcar uma subpasta ou um arquivo dentro de uma pasta marcada deixa a pasta parcialmente marcada e mantém aquele item de fora. A barra de baixo mostra o total exato, e **Recuperar seleção** recria cada pasta marcada no destino **a partir dela mesma**: recuperar `Documentos/Clientes/Projeto` grava `Projeto/...` com todas as subpastas. Arquivos marcados soltos vão para a raiz do destino, e duas pastas marcadas com o mesmo nome viram `Projeto` e `Projeto (2)`. O manifesto com MD5, SHA-1 e SHA-256 é gravado como sempre.
Só aparecem pastas que contêm pelo menos um arquivo recuperado: o Guará conhece as pastas pelos caminhos dos arquivos que encontra.
## Temas ## Temas
O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Claro** e **Escuro**. A escolha fica salva. O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Claro** e **Escuro**. A escolha fica salva.
@@ -29,7 +39,7 @@ Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Id
## Atualizações ## Atualizações
Ao abrir, o app desktop pede ao Gitea o `latest.json` da release. Havendo versão mais nova, aparece um cartão no canto inferior direito com as versões, **Novidades** (as notas do CHANGELOG) e **Atualizar agora**. O app baixa o pacote, confere a assinatura com a chave pública embutida nele, instala e reabre sozinho. No Linux isso vale para o AppImage (o arquivo é substituído no lugar); instalações por `.deb` ou `.rpm` só mostram o aviso com o endereço da release. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. A consulta não envia nada além do que qualquer navegador envia ao Gitea. O `guara ui` no navegador não se atualiza sozinho. Ao abrir, o app desktop consulta o Gitea para verificar se há uma versão mais nova. Havendo versão nova, aparece um cartão no canto inferior direito com as versões e o endereço da release. Para o CLI, `guara update` verifica e instala atualizações com conferência de SHA-256. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. A consulta não envia nada além do que qualquer navegador envia ao Gitea. O `guara ui` no navegador não se atualiza sozinho.
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p> <p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
+13 -15
View File
@@ -7,11 +7,6 @@
# scripts/build-gui.sh windows # NSIS installer, cross-built from Linux via cargo-xwin # scripts/build-gui.sh windows # NSIS installer, cross-built from Linux via cargo-xwin
# scripts/build-gui.sh windows-docker # Same as above inside an isolated container # scripts/build-gui.sh windows-docker # Same as above inside an isolated container
# #
# The Tauri updater signs each bundle (createUpdaterArtifacts: true), so
# TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD must be set
# in the environment. If unset locally, this script loads ~/.tauri/guara-updater.key
# when available.
#
# Windows builds use cargo-xwin and NSIS (requires: nsis, lld, llvm, clang, and # Windows builds use cargo-xwin and NSIS (requires: nsis, lld, llvm, clang, and
# `cargo install cargo-xwin`). # `cargo install cargo-xwin`).
set -euo pipefail set -euo pipefail
@@ -20,19 +15,12 @@ cd "$(dirname "$0")/.."
platform=${1:?usage: build-gui.sh <linux|linux-x86_64|linux-aarch64|windows|windows-docker>} platform=${1:?usage: build-gui.sh <linux|linux-x86_64|linux-aarch64|windows|windows-docker>}
tauri() { npx --yes @tauri-apps/cli@2 "$@"; } tauri() { npx --yes @tauri-apps/cli@2 "$@"; }
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ] && [ -f "$HOME/.tauri/guara-updater.key" ]; then
export TAURI_SIGNING_PRIVATE_KEY="$(cat "$HOME/.tauri/guara-updater.key")"
[ -f "$HOME/.tauri/guara-updater.pass" ] && export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat "$HOME/.tauri/guara-updater.pass")"
fi
: "${TAURI_SIGNING_PRIVATE_KEY:?set TAURI_SIGNING_PRIVATE_KEY (or generate a local key with 'tauri signer generate')}"
if [ "$platform" = windows-docker ]; then if [ "$platform" = windows-docker ]; then
docker volume create guara-cargo >/dev/null docker volume create guara-cargo >/dev/null
docker volume create guara-rustup >/dev/null docker volume create guara-rustup >/dev/null
mkdir -p dist-gui mkdir -p dist-gui
exec docker run --rm -v "$PWD":/src -v guara-cargo:/root/.cargo -v guara-rustup:/root/.rustup \ exec docker run --rm -v "$PWD":/src -v guara-cargo:/root/.cargo -v guara-rustup:/root/.rustup \
-w /src -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ -w /src -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \
-e TAURI_SIGNING_PRIVATE_KEY -e TAURI_SIGNING_PRIVATE_KEY_PASSWORD \
ubuntu:24.04 bash -c ' ubuntu:24.04 bash -c '
set -e set -e
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
@@ -81,7 +69,6 @@ case "$platform" in
rm -rf "$bundle/appimage" "$bundle/deb" rm -rf "$bundle/appimage" "$bundle/deb"
tauri build --bundles appimage,deb tauri build --bundles appimage,deb
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/ cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
cp "$bundle"/appimage/*.AppImage.sig ../../../dist-gui/ 2>/dev/null || true
;; ;;
linux-x86_64) linux-x86_64)
if [ "$(uname -m)" = x86_64 ]; then if [ "$(uname -m)" = x86_64 ]; then
@@ -169,7 +156,6 @@ EOF
tauri build --target x86_64-unknown-linux-gnu --bundles appimage,deb tauri build --target x86_64-unknown-linux-gnu --bundles appimage,deb
fi fi
cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/ cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/
cp "$bundle"/appimage/*.AppImage.sig ../../../dist-gui/ 2>/dev/null || true
;; ;;
windows) windows)
rustup target add x86_64-pc-windows-msvc >/dev/null rustup target add x86_64-pc-windows-msvc >/dev/null
@@ -177,9 +163,21 @@ EOF
rm -rf "$bundle" rm -rf "$bundle"
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
cp "$bundle"/*-setup.exe ../../../dist-gui/ cp "$bundle"/*-setup.exe ../../../dist-gui/
cp "$bundle"/*-setup.exe.sig ../../../dist-gui/ 2>/dev/null || true
;; ;;
*) echo "unknown platform: $platform" >&2; exit 2 ;; *) echo "unknown platform: $platform" >&2; exit 2 ;;
esac esac
(
cd ../../../dist-gui
for asset in *; do
[ -f "$asset" ] || continue
case "$asset" in
*.sha256) ;;
*)
sha256sum "$asset" > "$asset.sha256"
;;
esac
done
)
ls -1 ../../../dist-gui ls -1 ../../../dist-gui
-1
View File
@@ -21,7 +21,6 @@ step "formatting (cargo fmt)" cargo fmt --all -- --check
# Same flags as CI: warnings are treated as errors across all targets, including tests # Same flags as CI: warnings are treated as errors across all targets, including tests
step "clippy (workspace, all targets)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets step "clippy (workspace, all targets)" env RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets
step "UI translation coverage" node scripts/check-ui-i18n.mjs step "UI translation coverage" node scripts/check-ui-i18n.mjs
step "latest.json unit tests" python3 -B scripts/test_make_latest_json.py
step "bash script syntax" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done' step "bash script syntax" bash -c 'for f in scripts/*.sh .githooks/*; do bash -n "$f" || exit 1; done'
step "workflows and templates (valid YAML)" python3 -c " step "workflows and templates (valid YAML)" python3 -c "
import glob, yaml import glob, yaml
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env python3
"""Builds the latest.json manifest read by the Tauri updater from the release
bundles and `.sig` signatures already uploaded to the Gitea release.
Runs in the final publish job after all matrix packaging jobs complete. Building
the manifest in a single step avoids race conditions from parallel matrix jobs.
Usage: make-latest-json.py <tag> <notes.md> <out.json>
Requires API (Gitea repository API URL) and TOKEN in the environment.
"""
import json
import os
import sys
import urllib.parse
import urllib.request
from datetime import datetime, timezone
# (platform keys, package filename patterns). {v} = version without leading "v".
# No macOS: no Apple hardware/signing runner is available.
PLATFORMS = [
(["linux-x86_64", "linux-x86_64-appimage"], ("Guará_{v}_amd64.AppImage", "Guara_{v}_amd64.AppImage")),
(["linux-aarch64", "linux-aarch64-appimage"], ("Guará_{v}_aarch64.AppImage", "Guara_{v}_aarch64.AppImage")),
(["windows-x86_64", "windows-x86_64-nsis"], ("Guará_{v}_x64-setup.exe", "Guara_{v}_x64-setup.exe")),
]
def api_get(path):
req = urllib.request.Request(f"{os.environ['API']}{path}", headers={"Authorization": f"token {os.environ['TOKEN']}"})
return json.load(urllib.request.urlopen(req))
def api_get_text(url):
safe_url = urllib.parse.quote(url, safe=":/?&=%")
req = urllib.request.Request(safe_url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
return urllib.request.urlopen(req).read().decode()
def build(version, notes, assets, pub_date):
"""`assets` = {package_name: (url, signature)}. Returns the latest.json dict.
Platforms missing a package or signature are omitted; the caller decides whether that is fatal."""
platforms = {}
for keys, patterns in PLATFORMS:
name = next((n for p in patterns if (n := p.format(v=version)) in assets), None)
if name is None:
continue
url, sig = assets[name]
for k in keys:
platforms.setdefault(k, {"signature": sig.strip(), "url": url})
return {"version": version, "notes": notes.strip(), "pub_date": pub_date, "platforms": platforms}
def main():
tag, notes_path, out = sys.argv[1:4]
version = tag.lstrip("v")
rel = api_get(f"/releases/tags/{tag}")
by_name = {a["name"]: a for a in rel["assets"]}
assets = {}
for _, patterns in PLATFORMS:
name = next((n for p in patterns if (n := p.format(v=version)) in by_name), None)
if name is None:
continue
sig_name = name + ".sig"
if sig_name in by_name:
sig = api_get_text(by_name[sig_name]["browser_download_url"])
assets[name] = (by_name[name]["browser_download_url"], sig)
data = build(version, open(notes_path, encoding="utf-8").read(), assets,
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
# Require signed packages for Linux (x86_64 and aarch64) and Windows.
platforms = data["platforms"]
for must in ("linux-x86_64", "linux-aarch64", "windows-x86_64"):
if must not in platforms:
sys.exit(f"missing package or signature for {must} (found {sorted(platforms)}); "
"verify that TAURI_SIGNING_PRIVATE_KEY is set in repository secrets")
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
print(f"{out}: {sorted(data['platforms'])}")
if __name__ == "__main__":
main()
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env python3
"""Unit tests for scripts/make-latest-json.py: python3 scripts/test_make_latest_json.py"""
import importlib.util
import os
import sys
import unittest
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("m", os.path.join(os.path.dirname(__file__), "make-latest-json.py"))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
BASE = "https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases/download/v1.3.2/"
def assets(version="1.3.2"):
names = [patterns[0].format(v=version) for _, patterns in m.PLATFORMS]
return {n: (BASE + n, f"sig-of-{n}\n") for n in names}
class LatestJson(unittest.TestCase):
def test_all_platforms_and_variants(self):
d = m.build("1.3.2", "### What's New\n", assets(), "2026-10-30T12:00:00Z")
self.assertEqual(d["version"], "1.3.2")
self.assertEqual(d["notes"], "### What's New")
p = d["platforms"]
for k in ("linux-x86_64", "linux-x86_64-appimage", "linux-aarch64", "windows-x86_64", "windows-x86_64-nsis"):
self.assertIn(k, p)
self.assertTrue(p["windows-x86_64"]["url"].endswith("x64-setup.exe"))
self.assertEqual(p["linux-x86_64"]["signature"], "sig-of-Guará_1.3.2_amd64.AppImage")
def test_missing_package_is_left_out(self):
a = assets()
del a["Guará_1.3.2_x64-setup.exe"]
d = m.build("1.3.2", "", a, "2026-10-30T12:00:00Z")
self.assertNotIn("windows-x86_64", d["platforms"])
self.assertIn("linux-x86_64", d["platforms"])
def test_other_versions_do_not_leak(self):
d = m.build("1.3.2", "", assets("1.2.0"), "2026-10-30T12:00:00Z")
self.assertEqual(d["platforms"], {})
def test_accepts_legacy_unaccented_name(self):
a = assets()
old = "Guara_1.3.2_amd64.AppImage"
_, sig = a.pop("Guará_1.3.2_amd64.AppImage")
a[old] = (BASE + old, sig)
d = m.build("1.3.2", "", a, "2026-10-30T12:00:00Z")
self.assertIn("linux-x86_64", d["platforms"])
self.assertTrue(d["platforms"]["linux-x86_64"]["url"].endswith(old))
if __name__ == "__main__":
unittest.main()