From 0087e2967062d83abb2e3d514225023515dfa8a4 Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:06:45 +0100 Subject: [PATCH 01/29] ci: build Ubuntu 24.04/26.04 matrix and RPM in RHEL 9 container The PyInstaller sidecar embeds the build image's glibc, so ubuntu-latest moving to 26.04 would break the Linux packages on 24.04. Build .deb/.tar.gz per Ubuntu LTS and the .rpm inside rockylinux:9. --- .github/workflows/build.yml | 245 ++++++++++++++++++++++++++++++++---- 1 file changed, 223 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82ac217..b142a2a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -220,7 +220,18 @@ jobs: build-linux-beta: if: github.ref == 'refs/heads/develop' needs: get-version-beta - runs-on: ubuntu-latest + name: build-linux-beta (${{ matrix.label }}) + runs-on: ${{ matrix.os }} + # One .deb/.tar.gz per Ubuntu LTS: the PyInstaller sidecar embeds the glibc of the build + # image, so a 26.04 build does not run on 24.04. Do not collapse back to ubuntu-latest. + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + label: ubuntu24 + - os: ubuntu-26.04 + label: ubuntu26 permissions: contents: read env: @@ -228,7 +239,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install system dependencies - run: sudo apt-get update -qq && sudo apt-get install -y rpm fakeroot + run: sudo apt-get update -qq && sudo apt-get install -y fakeroot - uses: actions/setup-python@v7 with: python-version: '3.13' @@ -264,8 +275,6 @@ jobs: run: | cd electron npm version ${{ needs.get-version-beta.outputs.beta_version }} --no-git-tag-version --allow-same-version - - name: Configure RPM macros - run: echo '%_build_id_links none' >> ~/.rpmmacros - name: Cache Electron download uses: actions/cache@v6 with: @@ -275,31 +284,120 @@ jobs: run: | cd electron npm ci --include=dev - npx electron-forge make + # .rpm is built separately in a RHEL container (build-rpm-beta): rpm 6 on + # ubuntu-26.04 breaks electron-installer-redhat. + npx electron-forge make --targets @electron-forge/maker-deb - name: Create Linux tar.gz run: | VERSION=${{ needs.get-version-beta.outputs.beta_version }} APP_DIR=$(find electron/out -maxdepth 1 -type d -name "MetaLens-linux-*" | head -1) if [ -n "$APP_DIR" ]; then - tar -czf "electron/out/make/MetaLens-${VERSION}-Linux.tar.gz" \ + tar -czf "electron/out/make/MetaLens-${VERSION}-${{ matrix.label }}.tar.gz" \ -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")" - echo "Created MetaLens-${VERSION}-Linux.tar.gz" + echo "Created MetaLens-${VERSION}-${{ matrix.label }}.tar.gz" else echo "Warning: no MetaLens-linux-* directory found, skipping tar.gz" fi + - name: Tag .deb with the build distro + run: | + find electron/out/make -type f -name '*.deb' | while read -r f; do + mv "$f" "${f%.deb}-${{ matrix.label }}.deb" + done - name: Upload Linux beta artifacts uses: actions/upload-artifact@v7 with: - name: linux-packages-beta + name: linux-packages-beta-${{ matrix.label }} path: | electron/out/make/**/*.deb - electron/out/make/**/*.rpm electron/out/make/**/*.tar.gz if-no-files-found: error + # RPM built inside a RHEL 9 container (glibc 2.34): the sidecar embeds the build image's + # glibc, so the oldest supported baseline yields a package that also runs on RHEL 10 and + # current Fedora. rpm 4.16 here also avoids the electron-installer-redhat vs rpm >= 4.20 bug. + build-rpm-beta: + if: github.ref == 'refs/heads/develop' + needs: get-version-beta + runs-on: ubuntu-24.04 + container: rockylinux:9 + permissions: + contents: read + env: + ELECTRON_CACHE: ${{ github.workspace }}/.electron-cache + steps: + - name: Install system dependencies + run: dnf install -y git tar gzip xz which findutils binutils rpm-build python3-pip + - uses: actions/checkout@v7 + - name: Set up Python 3.13 (uv) + run: | + # RHEL 9 AppStream has no python3.13; uv installs a python-build-standalone one. + python3 -m pip install uv==0.8.0 + uv python install 3.13 + uv venv --python 3.13 /opt/venv + echo "/opt/venv/bin" >> "$GITHUB_PATH" + echo "VIRTUAL_ENV=/opt/venv" >> "$GITHUB_ENV" + - name: Show toolchain + run: | + python --version + rpmbuild --version + ldd --version | head -1 + - name: Build Python sidecar + run: | + cd python + uv pip install -r requirements.txt -q + uv pip install pyinstaller -q + python -m PyInstaller main.py --onefile --name metalens-sidecar --distpath dist --clean --noconfirm \ + --hidden-import piexif \ + --hidden-import mutagen \ + --hidden-import hachoir \ + --hidden-import olefile \ + --hidden-import openpyxl \ + --hidden-import docx \ + --hidden-import pptx \ + --hidden-import pypdf + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build React frontend + run: | + cd frontend + npm ci + npm run build + - name: Copy frontend dist into electron + run: cp -r frontend/dist electron/frontend + - name: Sync electron version to beta + run: | + cd electron + npm version ${{ needs.get-version-beta.outputs.beta_version }} --no-git-tag-version --allow-same-version + - name: Configure RPM macros + run: echo '%_build_id_links none' >> ~/.rpmmacros + - name: Cache Electron download + uses: actions/cache@v6 + with: + path: ${{ env.ELECTRON_CACHE }} + key: electron-${{ runner.os }}-${{ hashFiles('electron/package-lock.json') }} + - name: Package RPM with Electron Forge + run: | + cd electron + npm ci --include=dev + npx electron-forge make --targets @electron-forge/maker-rpm + - name: Show RPM requirements + run: | + for f in $(find electron/out/make -type f -name '*.rpm'); do + echo "== $f"; rpm -qpR "$f" + done + - name: Upload RPM beta artifact + uses: actions/upload-artifact@v7 + with: + name: linux-rpm-beta + path: electron/out/make/**/*.rpm + if-no-files-found: error + release-beta: if: github.ref == 'refs/heads/develop' - needs: [get-version-beta, build-windows-beta, build-linux-beta] + needs: [get-version-beta, build-windows-beta, build-linux-beta, build-rpm-beta] runs-on: ubuntu-latest permissions: contents: write @@ -349,7 +447,8 @@ jobs: make_latest: false files: | artifacts/windows-installer-beta/** - artifacts/linux-packages-beta/** + artifacts/linux-packages-beta-*/** + artifacts/linux-rpm-beta/** # ───────────────────────────────────────────────────────────────────────────── # STABLE RELEASE (main only) — Rebuild from main with stable metadata @@ -477,7 +576,18 @@ jobs: build-linux-stable: if: github.ref == 'refs/heads/main' needs: [get-version, find-latest-beta] - runs-on: ubuntu-latest + name: build-linux-stable (${{ matrix.label }}) + runs-on: ${{ matrix.os }} + # One .deb/.tar.gz per Ubuntu LTS: the PyInstaller sidecar embeds the glibc of the build + # image, so a 26.04 build does not run on 24.04. Do not collapse back to ubuntu-latest. + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + label: ubuntu24 + - os: ubuntu-26.04 + label: ubuntu26 permissions: contents: read env: @@ -485,7 +595,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install system dependencies - run: sudo apt-get update -qq && sudo apt-get install -y rpm fakeroot + run: sudo apt-get update -qq && sudo apt-get install -y fakeroot - uses: actions/setup-python@v7 with: python-version: '3.13' @@ -517,8 +627,6 @@ jobs: npm run build - name: Copy frontend dist into electron run: cp -r frontend/dist electron/frontend - - name: Configure RPM macros - run: echo '%_build_id_links none' >> ~/.rpmmacros - name: Cache Electron download uses: actions/cache@v6 with: @@ -528,18 +636,25 @@ jobs: run: | cd electron npm ci --include=dev - npx electron-forge make + # .rpm is built separately in a RHEL container (build-rpm-stable): rpm 6 on + # ubuntu-26.04 breaks electron-installer-redhat. + npx electron-forge make --targets @electron-forge/maker-deb - name: Create Linux tar.gz run: | VERSION=${{ needs.get-version.outputs.version }} APP_DIR=$(find electron/out -maxdepth 1 -type d -name "MetaLens-linux-*" | head -1) if [ -n "$APP_DIR" ]; then - tar -czf "electron/out/make/MetaLens-${VERSION}-Linux.tar.gz" \ + tar -czf "electron/out/make/MetaLens-${VERSION}-${{ matrix.label }}.tar.gz" \ -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")" - echo "Created MetaLens-${VERSION}-Linux.tar.gz" + echo "Created MetaLens-${VERSION}-${{ matrix.label }}.tar.gz" else echo "Warning: no MetaLens-linux-* directory found, skipping tar.gz" fi + - name: Tag .deb with the build distro + run: | + find electron/out/make -type f -name '*.deb' | while read -r f; do + mv "$f" "${f%.deb}-${{ matrix.label }}.deb" + done - name: Verify Linux artifacts use stable version run: | if find electron/out -type f -name '*beta*' | grep -q .; then @@ -550,16 +665,101 @@ jobs: - name: Upload Linux stable artifacts uses: actions/upload-artifact@v7 with: - name: linux-packages-stable + name: linux-packages-stable-${{ matrix.label }} path: | electron/out/make/**/*.deb - electron/out/make/**/*.rpm electron/out/make/**/*.tar.gz if-no-files-found: error + # RPM built inside a RHEL 9 container (glibc 2.34): the sidecar embeds the build image's + # glibc, so the oldest supported baseline yields a package that also runs on RHEL 10 and + # current Fedora. rpm 4.16 here also avoids the electron-installer-redhat vs rpm >= 4.20 bug. + build-rpm-stable: + if: github.ref == 'refs/heads/main' + needs: [get-version, find-latest-beta] + runs-on: ubuntu-24.04 + container: rockylinux:9 + permissions: + contents: read + env: + ELECTRON_CACHE: ${{ github.workspace }}/.electron-cache + steps: + - name: Install system dependencies + run: dnf install -y git tar gzip xz which findutils binutils rpm-build python3-pip + - uses: actions/checkout@v7 + - name: Set up Python 3.13 (uv) + run: | + # RHEL 9 AppStream has no python3.13; uv installs a python-build-standalone one. + python3 -m pip install uv==0.8.0 + uv python install 3.13 + uv venv --python 3.13 /opt/venv + echo "/opt/venv/bin" >> "$GITHUB_PATH" + echo "VIRTUAL_ENV=/opt/venv" >> "$GITHUB_ENV" + - name: Show toolchain + run: | + python --version + rpmbuild --version + ldd --version | head -1 + - name: Build Python sidecar + run: | + cd python + uv pip install -r requirements.txt -q + uv pip install pyinstaller -q + python -m PyInstaller main.py --onefile --name metalens-sidecar --distpath dist --clean --noconfirm \ + --hidden-import piexif \ + --hidden-import mutagen \ + --hidden-import hachoir \ + --hidden-import olefile \ + --hidden-import openpyxl \ + --hidden-import docx \ + --hidden-import pptx \ + --hidden-import pypdf + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build React frontend + run: | + cd frontend + npm ci + npm run build + - name: Copy frontend dist into electron + run: cp -r frontend/dist electron/frontend + - name: Configure RPM macros + run: echo '%_build_id_links none' >> ~/.rpmmacros + - name: Cache Electron download + uses: actions/cache@v6 + with: + path: ${{ env.ELECTRON_CACHE }} + key: electron-${{ runner.os }}-${{ hashFiles('electron/package-lock.json') }} + - name: Package RPM with Electron Forge + run: | + cd electron + npm ci --include=dev + npx electron-forge make --targets @electron-forge/maker-rpm + - name: Show RPM requirements + run: | + for f in $(find electron/out/make -type f -name '*.rpm'); do + echo "== $f"; rpm -qpR "$f" + done + - name: Verify RPM artifacts use stable version + run: | + if find electron/out -type f -name '*beta*' | grep -q .; then + find electron/out -type f -name '*beta*' + echo "::error::Stable build produced beta-named artifacts." + exit 1 + fi + - name: Upload RPM stable artifact + uses: actions/upload-artifact@v7 + with: + name: linux-rpm-stable + path: electron/out/make/**/*.rpm + if-no-files-found: error + release-stable: if: github.ref == 'refs/heads/main' - needs: [get-version, find-latest-beta, build-windows-stable, build-linux-stable] + needs: [get-version, find-latest-beta, build-windows-stable, build-linux-stable, build-rpm-stable] runs-on: ubuntu-latest permissions: contents: write @@ -606,4 +806,5 @@ jobs: make_latest: true files: | artifacts/windows-installer-stable/** - artifacts/linux-packages-stable/** + artifacts/linux-packages-stable-*/** + artifacts/linux-rpm-stable/** From 76829b9def1468b5f0de8a318cb91f115f755f9c Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:18:28 +0100 Subject: [PATCH 02/29] chore(release): prepare 0.3.6 beta and document per-distro Linux packages --- CHANGELOG.md | 16 ++++++++++++ CONTRIBUTING.md | 6 ++++- README.md | 10 +++++--- docs/INSTALLATION.md | 50 +++++++++++++++++++++++++++++--------- docs/REQUIREMENTS.md | 18 ++++++++++---- electron/package-lock.json | 4 +-- electron/package.json | 2 +- frontend/package-lock.json | 4 +-- frontend/package.json | 2 +- python/config.py | 2 +- 10 files changed, 86 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f02a8a3..331d16f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — - [ ] **Batch Edit** — Apply a field change to multiple selected files at once - [ ] **Search/Filter Bar** — Filter the file list by name or extension +### CI/CD +- Prepare the release pipeline for GitHub moving `ubuntu-latest` to Ubuntu 26.04 (rollout + 2026-10-19 → 2026-11-19). The PyInstaller sidecar embeds the glibc of the build image, so a + package built on 26.04 would not start on Ubuntu 24.04. +- Linux `.deb` and `.tar.gz` are now built by a matrix on `ubuntu-24.04` and `ubuntu-26.04`; file + names carry the distro (`…-ubuntu24`, `…-ubuntu26`) and no longer include `Linux`. +- The `.rpm` is built in a `rockylinux:9` container (glibc 2.34, rpm 4.16) so it runs on RHEL 9/10 + and current Fedora. Building it on Ubuntu 26.04 fails: RPM 6 breaks + `electron-installer-redhat` 4.0.0 and emits RPM v6 packages. Python 3.13 in the container comes + from `uv`. + +### Documentation +- Installation and requirements guides describe the per-distribution Linux packages, the supported + RHEL/Fedora/Ubuntu versions and the glibc troubleshooting case; `CONTRIBUTING.md` documents why the + Linux jobs are pinned. + --- ## [0.3.5] — 2026-10-27 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80edde6..1909559 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -188,6 +188,10 @@ The body of every GitHub Release is the `CHANGELOG.md` section of that exact ver Preview what a release will say with `python scripts/changelog_notes.py X.Y.Z`. +### Linux packages + +Linux jobs in `.github/workflows/build.yml` are pinned, not `ubuntu-latest`: the PyInstaller sidecar embeds the glibc of the build image. `.deb` and `.tar.gz` come from a matrix on `ubuntu-24.04` and `ubuntu-26.04` (files suffixed `-ubuntu24` / `-ubuntu26`), and the `.rpm` is built in a `rockylinux:9` container (glibc 2.34, rpm 4.16). Do not build the `.rpm` on `ubuntu-26.04`: RPM 6 breaks `electron-installer-redhat` 4.0.0. A workflow change can be tried without publishing a release by dispatching `build.yml` from a temporary branch whose job conditions are relaxed. + --- ## 🔧 Dependency Updates @@ -230,5 +234,5 @@ Thank you for contributing to MetaLens! Your efforts help make file metadata man --- -*Last updated: 2026-10-05* +*Last updated: 2026-10-29* *← [Code of Conduct](./CODE_OF_CONDUCT.md) | [Security →](./SECURITY.md)* diff --git a/README.md b/README.md index de8d568..ebf5cfc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ ### 🪟 Windows / 🐧 Linux -1. Grab the latest installer for your platform from [Releases](../../releases) +1. Grab the latest installer for your platform from [Releases](../../releases) — on Linux pick the package for your distribution (`ubuntu24`, `ubuntu26` or `.rpm`, see [INSTALLATION.md](docs/INSTALLATION.md)) 2. Run it — Windows SmartScreen may warn since the build is unsigned; click **"More info" → "Run anyway"** 3. MetaLens opens with a folder tree on the left — click a folder, then a file, to see its metadata @@ -81,7 +81,9 @@ File (image / audio / video / document / anything) ## 🔧 Version -**v0.3.4** — Security and maintenance release: the build toolchain moves to Electron Forge 8 (resolving the open `extract-zip` advisories), closing the app no longer leaves the background sidecar process running, and dependencies are updated (uvicorn, pypdf, lucide-react, Vite, Electron). No API changes. +**v0.3.5** — Security and reliability release: every sidecar API call is authenticated with a per-session token, the Electron renderer is sandboxed with a Content Security Policy, PDF/Office edits preserve unrelated content, audio/video edits are atomic, and Undo restores original values. Dependencies updated (Pillow, pypdf, Vite, Electron). No API changes. + +**Next (beta)** — Linux packages are now built per distribution: `.deb`/`.tar.gz` for Ubuntu 24.04 and 26.04, and an `.rpm` built on a RHEL 9 baseline for Fedora and RHEL 9/10. 📖 **See full version history** → [CHANGELOG.md](./CHANGELOG.md) @@ -89,7 +91,7 @@ File (image / audio / video / document / anything) ## 📋 System Requirements -- **Windows** 10/11 (64-bit) or a modern **Linux** distro (`.deb`/`.rpm`/`.tar.gz`) +- **Windows** 10/11 (64-bit) or **Linux**: Ubuntu 24.04 / 26.04 (`.deb`/`.tar.gz`, one build per release), Fedora and RHEL 9/10 (`.rpm`) — see [REQUIREMENTS.md](docs/REQUIREMENTS.md) - **RAM** 512 MB free - **Disk** ~300 MB installed - No Python or Node.js needed to *run* the app — only to build it from source @@ -238,5 +240,5 @@ Distributed with MIT License · [View License](LICENSE) --- -*Last updated: 2026-10-27* +*Last updated: 2026-10-29* *← [Requirements](docs/REQUIREMENTS.md) | [Docs →](docs/)* diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 7b400fd..6e46846 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -33,31 +33,53 @@ Step-by-step instructions for installing MetaLens on Windows and Linux, plus bui ## 🐧 Linux Installation +MetaLens bundles a Python sidecar built against the C library (glibc) of the system that produced +the package, so each release ships **one package per Linux family**. Pick the one that matches +your distribution: + +| Your distribution | Package to download | +|---|---| +| Ubuntu 24.04 LTS, Debian 13 and other distros as recent as Ubuntu 24.04 | `…-ubuntu24.deb` or `…-ubuntu24.tar.gz` | +| Ubuntu 26.04 LTS and newer | `…-ubuntu26.deb` or `…-ubuntu26.tar.gz` | +| Fedora (current releases), RHEL 9 / 10 and compatible (Rocky, AlmaLinux) | `metalens--1.x86_64.rpm` | + +> [!NOTE] +> A package built on a newer system does not start on an older one, so do not install the +> `ubuntu26` build on Ubuntu 24.04. The `ubuntu24` build also runs on Ubuntu 26.04. The `.rpm` +> is built on RHEL 9, the oldest supported baseline. Packages are not tested on every +> distribution; if the app opens but the file list never loads, see [Troubleshooting](#-troubleshooting). + ### 📦 Debian / Ubuntu (`.deb`) ```bash -wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/MetaLens-vX.Y.Z-Linux.deb -sudo apt install ./MetaLens-vX.Y.Z-Linux.deb +# Ubuntu 24.04 — use the "ubuntu26" file on Ubuntu 26.04 +wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/ +sudo apt install ./ ``` +`` is the `.deb` ending in `-ubuntu24.deb` (or `-ubuntu26.deb`) listed on the +[Releases](../../../releases) page. + ### 📦 Fedora / RHEL (`.rpm`) ```bash -wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/MetaLens-vX.Y.Z-Linux.rpm -sudo rpm -i MetaLens-vX.Y.Z-Linux.rpm +wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/metalens--1.x86_64.rpm +sudo dnf install ./metalens--1.x86_64.rpm ``` ### 📦 Any distro (`.tar.gz`) ```bash -wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/MetaLens-vX.Y.Z-Linux.tar.gz -tar -xzf MetaLens-vX.Y.Z-Linux.tar.gz -cd MetaLens-vX.Y.Z-Linux +wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/MetaLens--ubuntu24.tar.gz +tar -xzf MetaLens--ubuntu24.tar.gz +cd MetaLens-linux-x64 ./metalens ``` > [!NOTE] -> 📖 Replace `vX.Y.Z` with the version shown on the [Releases](../../../releases) page. +> 📖 Replace `vX.Y.Z` / `` with the version shown on the [Releases](../../../releases) +> page (a stable release is `X.Y.Z`; a beta is `X.Y.Z-beta.N`, and its `.rpm` is named +> `metalens-X.Y.ZbetaN-1.x86_64.rpm`). --- @@ -103,6 +125,12 @@ cd ../electron && npm run make `npm run make` (Electron Forge) produces the Windows `.exe`, Linux `.deb`/`.rpm` under `electron/out/make/`. +> [!NOTE] +> The sidecar embeds the glibc of the machine that builds it, so a locally built package only runs +> on systems at least as recent as yours. To build the `.rpm` you need `rpmbuild` (`rpm-build`); +> `electron-installer-redhat` 4.0.0 fails with RPM 4.20 or newer (including RPM 6), so build it on +> a RHEL 9-compatible system or container, as the release pipeline does. + --- ## 🔧 Troubleshooting @@ -128,9 +156,9 @@ sudo apt --fix-broken install # Debian/Ubuntu, after a failed .deb install ### ❌ App window opens but the file list never loads -**Cause:** The Python sidecar failed to start (rare — usually a port conflict or antivirus blocking the bundled executable). +**Cause:** The Python sidecar failed to start (rare — usually a port conflict or antivirus blocking the bundled executable). On Linux it can also be the wrong package for your distribution: the sidecar needs a glibc at least as recent as the one it was built on, and fails with `version 'GLIBC_2.xx' not found` otherwise. -**Solution:** Restart the app. If it persists, check that no other process is holding the port MetaLens tries to bind on `127.0.0.1`, and confirm your antivirus isn't quarantining `metalens-sidecar.exe`. +**Solution:** Restart the app. On Linux, launch `metalens` from a terminal to see the sidecar's error message, and install the package that matches your distribution (see the table above). If it persists, check that no other process is holding the port MetaLens tries to bind on `127.0.0.1`, and confirm your antivirus isn't quarantining `metalens-sidecar.exe`. --- @@ -149,5 +177,5 @@ sudo apt --fix-broken install # Debian/Ubuntu, after a failed .deb install --- -*Last updated: 2026-10-05* +*Last updated: 2026-10-29* *← [Requirements](./REQUIREMENTS.md) | [Usage →](./USAGE.md)* diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index ad5d8ff..f4bf0f3 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -11,7 +11,7 @@ Requirements for running the pre-built MetaLens application and, separately, for | 📦 Use case | ✅ Requirement | |---|---| -| **Run the compiled app** | Windows 10/11 (64-bit) or a modern Linux distro — nothing else | +| **Run the compiled app** | Windows 10/11 (64-bit) or a Linux distro matching one of the [packages](#-linux) (Ubuntu 24.04+, Fedora, RHEL 9/10) — nothing else | | **Build from source** | Python 3.13, Node.js 22.13+ (24 LTS recommended, used by CI), npm 10+ | --- @@ -21,12 +21,20 @@ Requirements for running the pre-built MetaLens application and, separately, for ### 🪟 Windows - **Minimum:** Windows 10 (64-bit) - **Tested on:** Windows 10, Windows 11 -- **Installer:** `MetaLens-vX.Y.Z-Setup-Windows.exe` (Squirrel.Windows) +- **Installer:** `MetaLens--win-x64.exe` (Squirrel.Windows) - **Status:** ✅ Fully supported ### 🐧 Linux -- **Packages:** `.deb` (Debian/Ubuntu), `.rpm` (Fedora/RHEL), `.tar.gz` (any distro) -- **Status:** ✅ Fully supported +- **Packages:** one set per distribution family, because the bundled sidecar needs a glibc at least as recent as the one it was built on: + + | Package | Built on | For | + |---|---|---| + | `…-ubuntu24.deb` / `…-ubuntu24.tar.gz` | Ubuntu 24.04 LTS (glibc 2.39) | Ubuntu 24.04+, and distros at least as recent (e.g. Debian 13) | + | `…-ubuntu26.deb` / `…-ubuntu26.tar.gz` | Ubuntu 26.04 LTS (glibc 2.43) | Ubuntu 26.04+ | + | `metalens--1.x86_64.rpm` | RHEL 9-compatible container (glibc 2.34) | Fedora (current releases), RHEL 9 / 10, Rocky, AlmaLinux | + +- **Not packaged:** RHEL 8 and older (glibc older than 2.34) +- **Status:** ✅ Supported on the distributions above; not every distribution is tested ### 🍎 macOS - **Status:** ❌ Not currently packaged — no macOS build is produced by CI. Building from source with `npm run make` is possible but unverified. @@ -89,5 +97,5 @@ See [Installation Guide → Build from Source](./INSTALLATION.md#-build-from-sou --- -*Last updated: 2026-10-05* +*Last updated: 2026-10-29* *← [README](../README.md) | [Installation →](./INSTALLATION.md)* diff --git a/electron/package-lock.json b/electron/package-lock.json index 03601d3..4db9c40 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "metalens", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalens", - "version": "0.3.5", + "version": "0.3.6", "license": "MIT", "devDependencies": { "@electron-forge/cli": "^8.0.1", diff --git a/electron/package.json b/electron/package.json index f8a1e9d..d208dbd 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "metalens", - "version": "0.3.5", + "version": "0.3.6", "description": "Universal File Metadata Manager", "author": "Graziano Mariella", "license": "MIT", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dd8a6cf..e417ec2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "metalens-frontend", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalens-frontend", - "version": "0.3.5", + "version": "0.3.6", "dependencies": { "@fontsource/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 1f4a1d8..bd19776 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "metalens-frontend", - "version": "0.3.5", + "version": "0.3.6", "private": true, "type": "module", "scripts": { diff --git a/python/config.py b/python/config.py index 19d5677..fc68820 100644 --- a/python/config.py +++ b/python/config.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -VERSION = "0.3.5" +VERSION = "0.3.6" APP_NAME = "MetaLens" SIDECAR_HOST = "127.0.0.1" From 3106fcc1e2cf1f0136cd3187e82af14ec5b00764 Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:24:18 +0100 Subject: [PATCH 03/29] docs: fix beta rpm file name example --- docs/INSTALLATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 6e46846..90f7e77 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -79,7 +79,7 @@ cd MetaLens-linux-x64 > [!NOTE] > 📖 Replace `vX.Y.Z` / `` with the version shown on the [Releases](../../../releases) > page (a stable release is `X.Y.Z`; a beta is `X.Y.Z-beta.N`, and its `.rpm` is named -> `metalens-X.Y.ZbetaN-1.x86_64.rpm`). +> `metalens-X.Y.Zbeta.N-1.x86_64.rpm`). --- From 6c54887366d16a3a3aec31a64f61706dedfb825c Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:35:19 +0100 Subject: [PATCH 04/29] ci: build one Linux sidecar in a RHEL 9 container for deb, rpm and tar.gz Packages built on ubuntu-24.04 need glibc 2.38 and do not start on Debian 12 or Ubuntu 22.04. Build the sidecar once in rockylinux:9 (glibc 2.34) and reuse it, replacing the Ubuntu 24/26 matrix. Update docs and changelog. --- .github/workflows/build.yml | 260 ++++++++++++++++++------------------ CHANGELOG.md | 23 ++-- CONTRIBUTING.md | 2 +- README.md | 6 +- docs/INSTALLATION.md | 49 +++---- docs/REQUIREMENTS.md | 15 +-- 6 files changed, 165 insertions(+), 190 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b142a2a..9d2bf69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -217,39 +217,39 @@ jobs: path: electron/out/make/**/*.exe if-no-files-found: error - build-linux-beta: + # The PyInstaller sidecar embeds the glibc of the machine that builds it (and of the Python it + # bundles). It is built ONCE, inside a RHEL 9 container (glibc 2.34), and reused by the .deb, + # .tar.gz and .rpm jobs: the result runs on every distro with glibc >= 2.34 (Ubuntu 22.04+, + # Debian 12+, RHEL 9/10, current Fedora). Do NOT build it on ubuntu-latest: ubuntu-24.04 + # produces a sidecar that needs glibc 2.38 and does not start on Debian 12 / Ubuntu 22.04. + build-sidecar-linux-beta: if: github.ref == 'refs/heads/develop' needs: get-version-beta - name: build-linux-beta (${{ matrix.label }}) - runs-on: ${{ matrix.os }} - # One .deb/.tar.gz per Ubuntu LTS: the PyInstaller sidecar embeds the glibc of the build - # image, so a 26.04 build does not run on 24.04. Do not collapse back to ubuntu-latest. - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-24.04 - label: ubuntu24 - - os: ubuntu-26.04 - label: ubuntu26 + runs-on: ubuntu-24.04 + container: rockylinux:9 permissions: contents: read - env: - ELECTRON_CACHE: ${{ github.workspace }}/.electron-cache steps: - - uses: actions/checkout@v7 - name: Install system dependencies - run: sudo apt-get update -qq && sudo apt-get install -y fakeroot - - uses: actions/setup-python@v7 - with: - python-version: '3.13' - cache: pip - cache-dependency-path: python/requirements.txt + run: dnf install -y git tar gzip xz which findutils binutils python3-pip + - uses: actions/checkout@v7 + - name: Set up Python 3.13 (uv) + run: | + # RHEL 9 AppStream has no python3.13; uv installs a python-build-standalone one. + python3 -m pip install uv==0.8.0 + uv python install 3.13 + uv venv --python 3.13 /opt/venv + echo "/opt/venv/bin" >> "$GITHUB_PATH" + echo "VIRTUAL_ENV=/opt/venv" >> "$GITHUB_ENV" + - name: Show toolchain + run: | + python --version + ldd --version | head -1 - name: Build Python sidecar run: | cd python - pip install -r requirements.txt -q - pip install pyinstaller -q + uv pip install -r requirements.txt -q + uv pip install pyinstaller -q python -m PyInstaller main.py --onefile --name metalens-sidecar --distpath dist --clean --noconfirm \ --hidden-import piexif \ --hidden-import mutagen \ @@ -259,6 +259,31 @@ jobs: --hidden-import docx \ --hidden-import pptx \ --hidden-import pypdf + - name: Upload Linux sidecar + uses: actions/upload-artifact@v7 + with: + name: sidecar-linux-beta + path: python/dist/metalens-sidecar + if-no-files-found: error + + build-linux-beta: + if: github.ref == 'refs/heads/develop' + needs: [get-version-beta, build-sidecar-linux-beta] + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + ELECTRON_CACHE: ${{ github.workspace }}/.electron-cache + steps: + - uses: actions/checkout@v7 + - name: Install system dependencies + run: sudo apt-get update -qq && sudo apt-get install -y fakeroot + - uses: actions/download-artifact@v8 + with: + name: sidecar-linux-beta + path: python/dist + - name: Make sidecar executable + run: chmod +x python/dist/metalens-sidecar - uses: actions/setup-node@v7 with: node-version: '24' @@ -280,7 +305,7 @@ jobs: with: path: ${{ env.ELECTRON_CACHE }} key: electron-${{ runner.os }}-${{ hashFiles('electron/package-lock.json') }} - - name: Package with Electron Forge + - name: Package .deb with Electron Forge run: | cd electron npm ci --include=dev @@ -292,32 +317,26 @@ jobs: VERSION=${{ needs.get-version-beta.outputs.beta_version }} APP_DIR=$(find electron/out -maxdepth 1 -type d -name "MetaLens-linux-*" | head -1) if [ -n "$APP_DIR" ]; then - tar -czf "electron/out/make/MetaLens-${VERSION}-${{ matrix.label }}.tar.gz" \ + tar -czf "electron/out/make/MetaLens-${VERSION}-linux-x64.tar.gz" \ -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")" - echo "Created MetaLens-${VERSION}-${{ matrix.label }}.tar.gz" + echo "Created MetaLens-${VERSION}-linux-x64.tar.gz" else echo "Warning: no MetaLens-linux-* directory found, skipping tar.gz" fi - - name: Tag .deb with the build distro - run: | - find electron/out/make -type f -name '*.deb' | while read -r f; do - mv "$f" "${f%.deb}-${{ matrix.label }}.deb" - done - name: Upload Linux beta artifacts uses: actions/upload-artifact@v7 with: - name: linux-packages-beta-${{ matrix.label }} + name: linux-packages-beta path: | electron/out/make/**/*.deb electron/out/make/**/*.tar.gz if-no-files-found: error - # RPM built inside a RHEL 9 container (glibc 2.34): the sidecar embeds the build image's - # glibc, so the oldest supported baseline yields a package that also runs on RHEL 10 and - # current Fedora. rpm 4.16 here also avoids the electron-installer-redhat vs rpm >= 4.20 bug. + # RPM built inside a RHEL 9 container (rpm 4.16): avoids the electron-installer-redhat 4.0.0 + # vs rpm >= 4.20 bug (cp: cannot stat 'usr/*') and keeps RPM v4 packages. build-rpm-beta: if: github.ref == 'refs/heads/develop' - needs: get-version-beta + needs: [get-version-beta, build-sidecar-linux-beta] runs-on: ubuntu-24.04 container: rockylinux:9 permissions: @@ -326,35 +345,14 @@ jobs: ELECTRON_CACHE: ${{ github.workspace }}/.electron-cache steps: - name: Install system dependencies - run: dnf install -y git tar gzip xz which findutils binutils rpm-build python3-pip + run: dnf install -y git tar gzip xz which findutils rpm-build - uses: actions/checkout@v7 - - name: Set up Python 3.13 (uv) - run: | - # RHEL 9 AppStream has no python3.13; uv installs a python-build-standalone one. - python3 -m pip install uv==0.8.0 - uv python install 3.13 - uv venv --python 3.13 /opt/venv - echo "/opt/venv/bin" >> "$GITHUB_PATH" - echo "VIRTUAL_ENV=/opt/venv" >> "$GITHUB_ENV" - - name: Show toolchain - run: | - python --version - rpmbuild --version - ldd --version | head -1 - - name: Build Python sidecar - run: | - cd python - uv pip install -r requirements.txt -q - uv pip install pyinstaller -q - python -m PyInstaller main.py --onefile --name metalens-sidecar --distpath dist --clean --noconfirm \ - --hidden-import piexif \ - --hidden-import mutagen \ - --hidden-import hachoir \ - --hidden-import olefile \ - --hidden-import openpyxl \ - --hidden-import docx \ - --hidden-import pptx \ - --hidden-import pypdf + - uses: actions/download-artifact@v8 + with: + name: sidecar-linux-beta + path: python/dist + - name: Make sidecar executable + run: chmod +x python/dist/metalens-sidecar - uses: actions/setup-node@v7 with: node-version: '24' @@ -447,7 +445,7 @@ jobs: make_latest: false files: | artifacts/windows-installer-beta/** - artifacts/linux-packages-beta-*/** + artifacts/linux-packages-beta/** artifacts/linux-rpm-beta/** # ───────────────────────────────────────────────────────────────────────────── @@ -573,39 +571,39 @@ jobs: path: electron/out/make/**/*.exe if-no-files-found: error - build-linux-stable: + # The PyInstaller sidecar embeds the glibc of the machine that builds it (and of the Python it + # bundles). It is built ONCE, inside a RHEL 9 container (glibc 2.34), and reused by the .deb, + # .tar.gz and .rpm jobs: the result runs on every distro with glibc >= 2.34 (Ubuntu 22.04+, + # Debian 12+, RHEL 9/10, current Fedora). Do NOT build it on ubuntu-latest: ubuntu-24.04 + # produces a sidecar that needs glibc 2.38 and does not start on Debian 12 / Ubuntu 22.04. + build-sidecar-linux-stable: if: github.ref == 'refs/heads/main' needs: [get-version, find-latest-beta] - name: build-linux-stable (${{ matrix.label }}) - runs-on: ${{ matrix.os }} - # One .deb/.tar.gz per Ubuntu LTS: the PyInstaller sidecar embeds the glibc of the build - # image, so a 26.04 build does not run on 24.04. Do not collapse back to ubuntu-latest. - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-24.04 - label: ubuntu24 - - os: ubuntu-26.04 - label: ubuntu26 + runs-on: ubuntu-24.04 + container: rockylinux:9 permissions: contents: read - env: - ELECTRON_CACHE: ${{ github.workspace }}/.electron-cache steps: - - uses: actions/checkout@v7 - name: Install system dependencies - run: sudo apt-get update -qq && sudo apt-get install -y fakeroot - - uses: actions/setup-python@v7 - with: - python-version: '3.13' - cache: pip - cache-dependency-path: python/requirements.txt + run: dnf install -y git tar gzip xz which findutils binutils python3-pip + - uses: actions/checkout@v7 + - name: Set up Python 3.13 (uv) + run: | + # RHEL 9 AppStream has no python3.13; uv installs a python-build-standalone one. + python3 -m pip install uv==0.8.0 + uv python install 3.13 + uv venv --python 3.13 /opt/venv + echo "/opt/venv/bin" >> "$GITHUB_PATH" + echo "VIRTUAL_ENV=/opt/venv" >> "$GITHUB_ENV" + - name: Show toolchain + run: | + python --version + ldd --version | head -1 - name: Build Python sidecar run: | cd python - pip install -r requirements.txt -q - pip install pyinstaller -q + uv pip install -r requirements.txt -q + uv pip install pyinstaller -q python -m PyInstaller main.py --onefile --name metalens-sidecar --distpath dist --clean --noconfirm \ --hidden-import piexif \ --hidden-import mutagen \ @@ -615,6 +613,31 @@ jobs: --hidden-import docx \ --hidden-import pptx \ --hidden-import pypdf + - name: Upload Linux sidecar + uses: actions/upload-artifact@v7 + with: + name: sidecar-linux-stable + path: python/dist/metalens-sidecar + if-no-files-found: error + + build-linux-stable: + if: github.ref == 'refs/heads/main' + needs: [get-version, find-latest-beta, build-sidecar-linux-stable] + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + ELECTRON_CACHE: ${{ github.workspace }}/.electron-cache + steps: + - uses: actions/checkout@v7 + - name: Install system dependencies + run: sudo apt-get update -qq && sudo apt-get install -y fakeroot + - uses: actions/download-artifact@v8 + with: + name: sidecar-linux-stable + path: python/dist + - name: Make sidecar executable + run: chmod +x python/dist/metalens-sidecar - uses: actions/setup-node@v7 with: node-version: '24' @@ -632,7 +655,7 @@ jobs: with: path: ${{ env.ELECTRON_CACHE }} key: electron-${{ runner.os }}-${{ hashFiles('electron/package-lock.json') }} - - name: Package with Electron Forge + - name: Package .deb with Electron Forge run: | cd electron npm ci --include=dev @@ -644,17 +667,12 @@ jobs: VERSION=${{ needs.get-version.outputs.version }} APP_DIR=$(find electron/out -maxdepth 1 -type d -name "MetaLens-linux-*" | head -1) if [ -n "$APP_DIR" ]; then - tar -czf "electron/out/make/MetaLens-${VERSION}-${{ matrix.label }}.tar.gz" \ + tar -czf "electron/out/make/MetaLens-${VERSION}-linux-x64.tar.gz" \ -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")" - echo "Created MetaLens-${VERSION}-${{ matrix.label }}.tar.gz" + echo "Created MetaLens-${VERSION}-linux-x64.tar.gz" else echo "Warning: no MetaLens-linux-* directory found, skipping tar.gz" fi - - name: Tag .deb with the build distro - run: | - find electron/out/make -type f -name '*.deb' | while read -r f; do - mv "$f" "${f%.deb}-${{ matrix.label }}.deb" - done - name: Verify Linux artifacts use stable version run: | if find electron/out -type f -name '*beta*' | grep -q .; then @@ -665,18 +683,17 @@ jobs: - name: Upload Linux stable artifacts uses: actions/upload-artifact@v7 with: - name: linux-packages-stable-${{ matrix.label }} + name: linux-packages-stable path: | electron/out/make/**/*.deb electron/out/make/**/*.tar.gz if-no-files-found: error - # RPM built inside a RHEL 9 container (glibc 2.34): the sidecar embeds the build image's - # glibc, so the oldest supported baseline yields a package that also runs on RHEL 10 and - # current Fedora. rpm 4.16 here also avoids the electron-installer-redhat vs rpm >= 4.20 bug. + # RPM built inside a RHEL 9 container (rpm 4.16): avoids the electron-installer-redhat 4.0.0 + # vs rpm >= 4.20 bug (cp: cannot stat 'usr/*') and keeps RPM v4 packages. build-rpm-stable: if: github.ref == 'refs/heads/main' - needs: [get-version, find-latest-beta] + needs: [get-version, find-latest-beta, build-sidecar-linux-stable] runs-on: ubuntu-24.04 container: rockylinux:9 permissions: @@ -685,35 +702,14 @@ jobs: ELECTRON_CACHE: ${{ github.workspace }}/.electron-cache steps: - name: Install system dependencies - run: dnf install -y git tar gzip xz which findutils binutils rpm-build python3-pip + run: dnf install -y git tar gzip xz which findutils rpm-build - uses: actions/checkout@v7 - - name: Set up Python 3.13 (uv) - run: | - # RHEL 9 AppStream has no python3.13; uv installs a python-build-standalone one. - python3 -m pip install uv==0.8.0 - uv python install 3.13 - uv venv --python 3.13 /opt/venv - echo "/opt/venv/bin" >> "$GITHUB_PATH" - echo "VIRTUAL_ENV=/opt/venv" >> "$GITHUB_ENV" - - name: Show toolchain - run: | - python --version - rpmbuild --version - ldd --version | head -1 - - name: Build Python sidecar - run: | - cd python - uv pip install -r requirements.txt -q - uv pip install pyinstaller -q - python -m PyInstaller main.py --onefile --name metalens-sidecar --distpath dist --clean --noconfirm \ - --hidden-import piexif \ - --hidden-import mutagen \ - --hidden-import hachoir \ - --hidden-import olefile \ - --hidden-import openpyxl \ - --hidden-import docx \ - --hidden-import pptx \ - --hidden-import pypdf + - uses: actions/download-artifact@v8 + with: + name: sidecar-linux-stable + path: python/dist + - name: Make sidecar executable + run: chmod +x python/dist/metalens-sidecar - uses: actions/setup-node@v7 with: node-version: '24' @@ -806,5 +802,5 @@ jobs: make_latest: true files: | artifacts/windows-installer-stable/** - artifacts/linux-packages-stable-*/** + artifacts/linux-packages-stable/** artifacts/linux-rpm-stable/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 331d16f..a8b9ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,19 +15,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — ### CI/CD - Prepare the release pipeline for GitHub moving `ubuntu-latest` to Ubuntu 26.04 (rollout - 2026-10-19 → 2026-11-19). The PyInstaller sidecar embeds the glibc of the build image, so a - package built on 26.04 would not start on Ubuntu 24.04. -- Linux `.deb` and `.tar.gz` are now built by a matrix on `ubuntu-24.04` and `ubuntu-26.04`; file - names carry the distro (`…-ubuntu24`, `…-ubuntu26`) and no longer include `Linux`. -- The `.rpm` is built in a `rockylinux:9` container (glibc 2.34, rpm 4.16) so it runs on RHEL 9/10 - and current Fedora. Building it on Ubuntu 26.04 fails: RPM 6 breaks - `electron-installer-redhat` 4.0.0 and emits RPM v6 packages. Python 3.13 in the container comes - from `uv`. + 2026-10-19 → 2026-11-19). The PyInstaller sidecar embeds the glibc of the build machine: built on + `ubuntu-24.04` it needs glibc 2.38 and did not start on Debian 12 or Ubuntu 22.04. +- The Linux sidecar is now built once in a `rockylinux:9` container (glibc 2.34) and reused by the + `.deb`, `.tar.gz` and `.rpm` jobs, so a single set of Linux packages runs on Ubuntu 22.04+, + Debian 12+, Fedora and RHEL 9/10. Python 3.13 in the container comes from `uv`. +- The `.rpm` is built in that same container (rpm 4.16): on Ubuntu 26.04 RPM 6 breaks + `electron-installer-redhat` 4.0.0 and emits RPM v6 packages. The `.deb` and `.tar.gz` keep using + `ubuntu-24.04`. +- Linux asset names: `metalens__amd64.deb`, `metalens--1.x86_64.rpm`, + `MetaLens--linux-x64.tar.gz`. ### Documentation -- Installation and requirements guides describe the per-distribution Linux packages, the supported - RHEL/Fedora/Ubuntu versions and the glibc troubleshooting case; `CONTRIBUTING.md` documents why the - Linux jobs are pinned. +- Installation and requirements guides describe the Linux packages, the glibc 2.34 baseline and + the glibc troubleshooting case; `CONTRIBUTING.md` documents how the Linux jobs are split. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1909559..5a4d7f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -190,7 +190,7 @@ Preview what a release will say with `python scripts/changelog_notes.py X.Y.Z`. ### Linux packages -Linux jobs in `.github/workflows/build.yml` are pinned, not `ubuntu-latest`: the PyInstaller sidecar embeds the glibc of the build image. `.deb` and `.tar.gz` come from a matrix on `ubuntu-24.04` and `ubuntu-26.04` (files suffixed `-ubuntu24` / `-ubuntu26`), and the `.rpm` is built in a `rockylinux:9` container (glibc 2.34, rpm 4.16). Do not build the `.rpm` on `ubuntu-26.04`: RPM 6 breaks `electron-installer-redhat` 4.0.0. A workflow change can be tried without publishing a release by dispatching `build.yml` from a temporary branch whose job conditions are relaxed. +The PyInstaller sidecar embeds the glibc of the machine that builds it, so the Linux jobs in `.github/workflows/build.yml` never use `ubuntu-latest` for it. `build-sidecar-linux-*` builds the sidecar once in a `rockylinux:9` container (glibc 2.34, Python 3.13 from `uv`); `build-linux-*` (`.deb` and `.tar.gz`, on `ubuntu-24.04`) and `build-rpm-*` (`.rpm`, in the same container) reuse it. Do not build the `.rpm` on `ubuntu-26.04`: RPM 6 breaks `electron-installer-redhat` 4.0.0. A workflow change can be tried without publishing a release by dispatching `build.yml` from a temporary branch whose job conditions are relaxed. --- diff --git a/README.md b/README.md index ebf5cfc..318190c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ ### 🪟 Windows / 🐧 Linux -1. Grab the latest installer for your platform from [Releases](../../releases) — on Linux pick the package for your distribution (`ubuntu24`, `ubuntu26` or `.rpm`, see [INSTALLATION.md](docs/INSTALLATION.md)) +1. Grab the latest installer for your platform from [Releases](../../releases) 2. Run it — Windows SmartScreen may warn since the build is unsigned; click **"More info" → "Run anyway"** 3. MetaLens opens with a folder tree on the left — click a folder, then a file, to see its metadata @@ -83,7 +83,7 @@ File (image / audio / video / document / anything) **v0.3.5** — Security and reliability release: every sidecar API call is authenticated with a per-session token, the Electron renderer is sandboxed with a Content Security Policy, PDF/Office edits preserve unrelated content, audio/video edits are atomic, and Undo restores original values. Dependencies updated (Pillow, pypdf, Vite, Electron). No API changes. -**Next (beta)** — Linux packages are now built per distribution: `.deb`/`.tar.gz` for Ubuntu 24.04 and 26.04, and an `.rpm` built on a RHEL 9 baseline for Fedora and RHEL 9/10. +**Next (beta)** — Linux packages (`.deb`, `.rpm`, `.tar.gz`) now bundle a sidecar built on a RHEL 9 baseline, so one set runs on Ubuntu 22.04+, Debian 12+, Fedora and RHEL 9/10. 📖 **See full version history** → [CHANGELOG.md](./CHANGELOG.md) @@ -91,7 +91,7 @@ File (image / audio / video / document / anything) ## 📋 System Requirements -- **Windows** 10/11 (64-bit) or **Linux**: Ubuntu 24.04 / 26.04 (`.deb`/`.tar.gz`, one build per release), Fedora and RHEL 9/10 (`.rpm`) — see [REQUIREMENTS.md](docs/REQUIREMENTS.md) +- **Windows** 10/11 (64-bit) or **Linux** with glibc 2.34+ (Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9/10): `.deb`/`.rpm`/`.tar.gz` — see [REQUIREMENTS.md](docs/REQUIREMENTS.md) - **RAM** 512 MB free - **Disk** ~300 MB installed - No Python or Node.js needed to *run* the app — only to build it from source diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 90f7e77..c28969c 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -33,53 +33,38 @@ Step-by-step instructions for installing MetaLens on Windows and Linux, plus bui ## 🐧 Linux Installation -MetaLens bundles a Python sidecar built against the C library (glibc) of the system that produced -the package, so each release ships **one package per Linux family**. Pick the one that matches -your distribution: - -| Your distribution | Package to download | -|---|---| -| Ubuntu 24.04 LTS, Debian 13 and other distros as recent as Ubuntu 24.04 | `…-ubuntu24.deb` or `…-ubuntu24.tar.gz` | -| Ubuntu 26.04 LTS and newer | `…-ubuntu26.deb` or `…-ubuntu26.tar.gz` | -| Fedora (current releases), RHEL 9 / 10 and compatible (Rocky, AlmaLinux) | `metalens--1.x86_64.rpm` | - -> [!NOTE] -> A package built on a newer system does not start on an older one, so do not install the -> `ubuntu26` build on Ubuntu 24.04. The `ubuntu24` build also runs on Ubuntu 26.04. The `.rpm` -> is built on RHEL 9, the oldest supported baseline. Packages are not tested on every -> distribution; if the app opens but the file list never loads, see [Troubleshooting](#-troubleshooting). +One set of packages covers every supported distribution: the bundled Python sidecar is built on a +RHEL 9 baseline (glibc 2.34), so it runs on any distribution with **glibc 2.34 or newer** — +Ubuntu 22.04 and newer, Debian 12 and newer, RHEL 9 / 10 and compatible (Rocky, AlmaLinux) and +current Fedora releases. ### 📦 Debian / Ubuntu (`.deb`) ```bash -# Ubuntu 24.04 — use the "ubuntu26" file on Ubuntu 26.04 -wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/ -sudo apt install ./ +wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/metalens_X.Y.Z_amd64.deb +sudo apt install ./metalens_X.Y.Z_amd64.deb ``` -`` is the `.deb` ending in `-ubuntu24.deb` (or `-ubuntu26.deb`) listed on the -[Releases](../../../releases) page. - ### 📦 Fedora / RHEL (`.rpm`) ```bash -wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/metalens--1.x86_64.rpm -sudo dnf install ./metalens--1.x86_64.rpm +wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/metalens-X.Y.Z-1.x86_64.rpm +sudo dnf install ./metalens-X.Y.Z-1.x86_64.rpm ``` ### 📦 Any distro (`.tar.gz`) ```bash -wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/MetaLens--ubuntu24.tar.gz -tar -xzf MetaLens--ubuntu24.tar.gz +wget https://github.com/overwrite00/MetaLens/releases/download/vX.Y.Z/MetaLens-X.Y.Z-linux-x64.tar.gz +tar -xzf MetaLens-X.Y.Z-linux-x64.tar.gz cd MetaLens-linux-x64 ./metalens ``` > [!NOTE] -> 📖 Replace `vX.Y.Z` / `` with the version shown on the [Releases](../../../releases) -> page (a stable release is `X.Y.Z`; a beta is `X.Y.Z-beta.N`, and its `.rpm` is named -> `metalens-X.Y.Zbeta.N-1.x86_64.rpm`). +> 📖 Replace `X.Y.Z` with the version shown on the [Releases](../../../releases) page. Beta +> releases are named `X.Y.Z-beta.N` (the `.deb` uses `X.Y.Z.beta.N` and the `.rpm` `X.Y.Zbeta.N`); +> copy the exact file name from the release page. --- @@ -127,9 +112,9 @@ cd ../electron && npm run make > [!NOTE] > The sidecar embeds the glibc of the machine that builds it, so a locally built package only runs -> on systems at least as recent as yours. To build the `.rpm` you need `rpmbuild` (`rpm-build`); -> `electron-installer-redhat` 4.0.0 fails with RPM 4.20 or newer (including RPM 6), so build it on -> a RHEL 9-compatible system or container, as the release pipeline does. +> on systems at least as recent as yours (the release pipeline builds it on RHEL 9 for that reason). To build +> the `.rpm` you need `rpmbuild` (`rpm-build`); `electron-installer-redhat` 4.0.0 fails with RPM 4.20 or newer +> (including RPM 6), so build it on a RHEL 9-compatible system or container. --- @@ -158,7 +143,7 @@ sudo apt --fix-broken install # Debian/Ubuntu, after a failed .deb install **Cause:** The Python sidecar failed to start (rare — usually a port conflict or antivirus blocking the bundled executable). On Linux it can also be the wrong package for your distribution: the sidecar needs a glibc at least as recent as the one it was built on, and fails with `version 'GLIBC_2.xx' not found` otherwise. -**Solution:** Restart the app. On Linux, launch `metalens` from a terminal to see the sidecar's error message, and install the package that matches your distribution (see the table above). If it persists, check that no other process is holding the port MetaLens tries to bind on `127.0.0.1`, and confirm your antivirus isn't quarantining `metalens-sidecar.exe`. +**Solution:** Restart the app. On Linux, launch `metalens` from a terminal to see the sidecar's error message, and check that your distribution ships glibc 2.34 or newer (`ldd --version`). If it persists, check that no other process is holding the port MetaLens tries to bind on `127.0.0.1`, and confirm your antivirus isn't quarantining `metalens-sidecar.exe`. --- diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index f4bf0f3..08f767e 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -11,7 +11,7 @@ Requirements for running the pre-built MetaLens application and, separately, for | 📦 Use case | ✅ Requirement | |---|---| -| **Run the compiled app** | Windows 10/11 (64-bit) or a Linux distro matching one of the [packages](#-linux) (Ubuntu 24.04+, Fedora, RHEL 9/10) — nothing else | +| **Run the compiled app** | Windows 10/11 (64-bit) or a Linux distro with glibc 2.34+ (Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9/10) — nothing else | | **Build from source** | Python 3.13, Node.js 22.13+ (24 LTS recommended, used by CI), npm 10+ | --- @@ -25,16 +25,9 @@ Requirements for running the pre-built MetaLens application and, separately, for - **Status:** ✅ Fully supported ### 🐧 Linux -- **Packages:** one set per distribution family, because the bundled sidecar needs a glibc at least as recent as the one it was built on: - - | Package | Built on | For | - |---|---|---| - | `…-ubuntu24.deb` / `…-ubuntu24.tar.gz` | Ubuntu 24.04 LTS (glibc 2.39) | Ubuntu 24.04+, and distros at least as recent (e.g. Debian 13) | - | `…-ubuntu26.deb` / `…-ubuntu26.tar.gz` | Ubuntu 26.04 LTS (glibc 2.43) | Ubuntu 26.04+ | - | `metalens--1.x86_64.rpm` | RHEL 9-compatible container (glibc 2.34) | Fedora (current releases), RHEL 9 / 10, Rocky, AlmaLinux | - -- **Not packaged:** RHEL 8 and older (glibc older than 2.34) -- **Status:** ✅ Supported on the distributions above; not every distribution is tested +- **Packages:** `.deb` (Debian/Ubuntu), `.rpm` (Fedora/RHEL), `.tar.gz` (any distro) — one set for all distributions +- **Minimum C library:** glibc 2.34 — the bundled sidecar is built on RHEL 9. This covers Ubuntu 22.04+, Debian 12+, RHEL 9 / 10 (Rocky, AlmaLinux) and current Fedora +- **Not supported:** RHEL 8 and older (glibc older than 2.34) ### 🍎 macOS - **Status:** ❌ Not currently packaged — no macOS build is produced by CI. Building from source with `npm run make` is possible but unverified. From 7f27334f2455fc18a6daa6ee89fcfa8c28ad83c9 Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:45:11 +0100 Subject: [PATCH 05/29] docs: record verified Linux distributions --- docs/REQUIREMENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 08f767e..e9ff920 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -28,6 +28,7 @@ Requirements for running the pre-built MetaLens application and, separately, for - **Packages:** `.deb` (Debian/Ubuntu), `.rpm` (Fedora/RHEL), `.tar.gz` (any distro) — one set for all distributions - **Minimum C library:** glibc 2.34 — the bundled sidecar is built on RHEL 9. This covers Ubuntu 22.04+, Debian 12+, RHEL 9 / 10 (Rocky, AlmaLinux) and current Fedora - **Not supported:** RHEL 8 and older (glibc older than 2.34) +- **Tested:** installing each package and starting the sidecar (not the full desktop window) was verified in containers on Ubuntu 22.04 / 24.04 / 26.04, Debian 12 / 13, Fedora 43 / 44, Rocky Linux 9 and AlmaLinux 10 (`.tar.gz` on Debian 12, Ubuntu 22.04 and Fedora 44) ### 🍎 macOS - **Status:** ❌ Not currently packaged — no macOS build is produced by CI. Building from source with `npm run make` is possible but unverified. From b38c77c485b4f3d8a90529884ce1ddb144badbc8 Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:54:11 +0100 Subject: [PATCH 06/29] fix(ui): make Refresh keep the folder tree and reload the current file list --- CHANGELOG.md | 6 ++++ frontend/src/App.jsx | 5 ++- frontend/src/components/FilePanel.jsx | 32 +++++++++++++---- frontend/src/components/FolderPanel.jsx | 47 +++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8b9ebd..4abc69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — - [ ] **Batch Edit** — Apply a field change to multiple selected files at once - [ ] **Search/Filter Bar** — Filter the file list by name or extension +### Fixed +- The folder panel **Refresh** button no longer collapses the tree and drops the current + folder. It now re-reads the drive list and every expanded folder (keeping expansion and + selection), and reloads the file list of the selected folder, keeping the still-existing file + selection. + ### CI/CD - Prepare the release pipeline for GitHub moving `ubuntu-latest` to Ubuntu 26.04 (rollout 2026-10-19 → 2026-11-19). The PyInstaller sidecar embeds the glibc of the build machine: built on diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8ffa371..b1109d4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ export default function App() { const [undoMsg, setUndoMsg] = useState(null) const { push: undoPush, undo, canUndo, runExclusive, busy } = useUndoStack() const [reloadKey, setReloadKey] = useState(0) + const [filesReloadKey, setFilesReloadKey] = useState(0) // Splitter widths (px) const [leftW, setLeftW] = useState(220) @@ -132,7 +133,8 @@ export default function App() {
{/* Left: Folder tree */}
- + setFilesReloadKey(value => value + 1)} />
{/* Resize handle L */} @@ -142,6 +144,7 @@ export default function App() {
diff --git a/frontend/src/components/FilePanel.jsx b/frontend/src/components/FilePanel.jsx index e2aabf3..b84e707 100644 --- a/frontend/src/components/FilePanel.jsx +++ b/frontend/src/components/FilePanel.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { File, Image, Music, Film, FileText, FileCode, Archive, Aperture, Sheet, Presentation, AlertCircle } from 'lucide-react' import { Spinner } from './ui/Spinner' import { metalens } from '../api/client' @@ -40,7 +40,7 @@ function formatDate(ts) { return new Date(ts * 1000).toLocaleString() } -export function FilePanel({ currentPath, onSelectFile, onSelectTwo }) { +export function FilePanel({ currentPath, reloadKey = 0, onSelectFile, onSelectTwo }) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -48,20 +48,38 @@ export function FilePanel({ currentPath, onSelectFile, onSelectTwo }) { const [sortKey, setSortKey] = useState('name') const [sortAsc, setSortAsc] = useState(true) + const loadedPath = useRef(null) + useEffect(() => { let cancelled = false - setLoading(Boolean(currentPath)) + // Same folder reloaded (Refresh): keep the list and selection visible, just update them. + const reload = Boolean(currentPath) && loadedPath.current === currentPath + loadedPath.current = currentPath + setLoading(Boolean(currentPath) && !reload) setError(null) - setItems([]) - setSelected(new Set()) + if (!reload) { + setItems([]) + setSelected(new Set()) + } if (currentPath) { metalens.list(currentPath) - .then(data => { if (!cancelled) setItems(data.items.filter(i => !i.is_dir)) }) + .then(data => { + if (cancelled) return + const files = data.items.filter(i => !i.is_dir) + setItems(files) + if (reload) { + const paths = new Set(files.map(f => f.path)) + setSelected(prev => { + const next = new Set([...prev].filter(p => paths.has(p))) + return next.size === prev.size ? prev : next + }) + } + }) .catch(e => { if (!cancelled) setError(e.message) }) .finally(() => { if (!cancelled) setLoading(false) }) } return () => { cancelled = true } - }, [currentPath]) + }, [currentPath, reloadKey]) function toggleSort(key) { if (sortKey === key) setSortAsc(a => !a) diff --git a/frontend/src/components/FolderPanel.jsx b/frontend/src/components/FolderPanel.jsx index f661b07..72abf84 100644 --- a/frontend/src/components/FolderPanel.jsx +++ b/frontend/src/components/FolderPanel.jsx @@ -3,7 +3,7 @@ import { Folder, FolderOpen, HardDrive, Home, ChevronRight, ChevronDown, Refresh import { Button } from './ui/Button' import { metalens } from '../api/client' -export function FolderPanel({ onSelectPath }) { +export function FolderPanel({ onSelectPath, onRefresh }) { const [tree, setTree] = useState([]) const [expanded, setExpanded] = useState({}) const [selected, setSelected] = useState(null) @@ -74,6 +74,49 @@ export function FolderPanel({ onSelectPath }) { } } + // Re-read the tree keeping expansion and selection: reload the children of every + // loaded folder (and the drive list), then tell the parent to reload the file list. + async function refresh() { + if (!tree.length) { await initRoots(); return } + const version = ++generation.current + requests.current.clear() + setError(null) + const failed = [] + async function reloadChildren(node) { + let data + try { + data = await metalens.list(node.path) + } catch { + failed.push(node.path) // folder gone or unreadable: collapse it + return { ...node, children: null, loading: false } + } + const old = new Map((node.children || []).map(c => [c.path, c])) + const children = await Promise.all((data.items || []).filter(i => i.is_dir).map(async i => { + const prev = old.get(i.path) + const fresh = { name: i.name, path: i.path, type: 'dir', children: null, loading: false } + return prev && prev.children ? reloadChildren(prev) : fresh + })) + return { ...node, children, loading: false } + } + try { + let roots = tree + if (window.electronAPI) { + const drives = await window.electronAPI.listDrives() + const known = new Map(tree.map(n => [n.path, n])) + roots = drives.length + ? drives.map(d => known.get(d) || { name: d, path: d, type: 'drive', children: null, loading: false }) + : tree + } + const next = await Promise.all(roots.map(r => r.children ? reloadChildren(r) : r)) + if (version !== generation.current) return + setTree(next) + if (failed.length) setExpanded(e => Object.fromEntries(Object.entries(e).filter(([p]) => !failed.includes(p)))) + } catch (e) { + if (version === generation.current) setError(e.message) + } + if (version === generation.current) onRefresh?.() + } + function select(path) { setSelected(path) onSelectPath(path) @@ -107,7 +150,7 @@ export function FolderPanel({ onSelectPath }) { {/* Refresh */}
-
From a9ad48a213d8c64ed7a05718ff93bb557eaeeb31 Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:57:33 +0100 Subject: [PATCH 07/29] test(ui): update Refresh test to the new behavior (keep tree, notify parent) --- frontend/test/correctness.test.jsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/test/correctness.test.jsx b/frontend/test/correctness.test.jsx index 80f8a02..8eed298 100644 --- a/frontend/test/correctness.test.jsx +++ b/frontend/test/correctness.test.jsx @@ -222,13 +222,17 @@ test('hash copy reports success only after clipboard resolves and handles denial vi.unstubAllGlobals() }) -test('refresh resets expanded nodes and folder loading uses the shared API', async () => { +test('refresh keeps expanded nodes and selection, re-reads folders and notifies the parent', async () => { window.electronAPI = { listDrives: vi.fn().mockResolvedValue(['C:\\']), homeDir: vi.fn().mockResolvedValue('C:\\Users') } - metalens.list.mockResolvedValue({ items: [{ name: 'child', path: 'C:\\child', is_dir: true }] }) - render(); fireEvent.click(await screen.findByText('C:\\')) + const onRefresh = vi.fn() + metalens.list.mockResolvedValueOnce({ items: [{ name: 'child', path: 'C:\\child', is_dir: true }] }) + .mockResolvedValueOnce({ items: [{ name: 'child', path: 'C:\\child', is_dir: true }, { name: 'newdir', path: 'C:\\newdir', is_dir: true }] }) + render(); fireEvent.click(await screen.findByText('C:\\')) await screen.findByText('child'); fireEvent.click(screen.getByText('Refresh')) - await waitFor(() => expect(screen.queryByText('child')).toBeNull()) - fireEvent.click(screen.getByText('C:\\')); await screen.findByText('child'); expect(metalens.list).toHaveBeenCalledTimes(2) + await screen.findByText('newdir') + expect(screen.getByText('child')).toBeTruthy() + expect(metalens.list).toHaveBeenCalledTimes(2) + await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1)) }) test('folder errors are visible and retry loads children', async () => { From 24a87f588162e8c560cc526a774533482290384b Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:10:33 +0100 Subject: [PATCH 08/29] fix(ui): Refresh falls back to nearest existing folder and keeps unreadable ones --- CHANGELOG.md | 3 +- frontend/src/components/FolderPanel.jsx | 38 ++++++++++++++++++++++--- frontend/test/correctness.test.jsx | 18 ++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abc69b..3f7f134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — - The folder panel **Refresh** button no longer collapses the tree and drops the current folder. It now re-reads the drive list and every expanded folder (keeping expansion and selection), and reloads the file list of the selected folder, keeping the still-existing file - selection. + selection. If the selected folder was deleted or renamed, the selection moves to the nearest + existing parent folder; folders that are listed but temporarily unreadable keep their state. ### CI/CD - Prepare the release pipeline for GitHub moving `ubuntu-latest` to Ubuntu 26.04 (rollout diff --git a/frontend/src/components/FolderPanel.jsx b/frontend/src/components/FolderPanel.jsx index 72abf84..f1b98b4 100644 --- a/frontend/src/components/FolderPanel.jsx +++ b/frontend/src/components/FolderPanel.jsx @@ -76,19 +76,20 @@ export function FolderPanel({ onSelectPath, onRefresh }) { // Re-read the tree keeping expansion and selection: reload the children of every // loaded folder (and the drive list), then tell the parent to reload the file list. + // A folder that vanished disappears with its parent's listing; one that is listed but + // unreadable keeps its previous state. A vanished selection falls back to the nearest + // existing ancestor. async function refresh() { if (!tree.length) { await initRoots(); return } const version = ++generation.current requests.current.clear() setError(null) - const failed = [] async function reloadChildren(node) { let data try { data = await metalens.list(node.path) } catch { - failed.push(node.path) // folder gone or unreadable: collapse it - return { ...node, children: null, loading: false } + return node } const old = new Map((node.children || []).map(c => [c.path, c])) const children = await Promise.all((data.items || []).filter(i => i.is_dir).map(async i => { @@ -109,8 +110,18 @@ export function FolderPanel({ onSelectPath, onRefresh }) { } const next = await Promise.all(roots.map(r => r.children ? reloadChildren(r) : r)) if (version !== generation.current) return + const paths = new Set() + ;(function collect(nodes) { nodes.forEach(n => { paths.add(n.path); if (n.children) collect(n.children) }) })(next) setTree(next) - if (failed.length) setExpanded(e => Object.fromEntries(Object.entries(e).filter(([p]) => !failed.includes(p)))) + setExpanded(e => Object.fromEntries(Object.entries(e).filter(([p]) => paths.has(p)))) + if (selected && !paths.has(selected)) { + const alive = await nearestExisting(selected) + if (version !== generation.current) return + if (alive !== selected) { + setSelected(alive) + if (alive) onSelectPath(alive) + } + } } catch (e) { if (version === generation.current) setError(e.message) } @@ -199,3 +210,22 @@ function updateNode(nodes, targetPath, updater) { return n }) } + +// Walk up from `path` to the closest folder the sidecar can still list. +async function nearestExisting(path) { + let p = path + while (p) { + try { + await metalens.list(p) + return p + } catch { + const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) + if (i < 0) return null + let parent = i === 0 ? '/' : p.slice(0, i) + if (/^[A-Za-z]:$/.test(parent)) parent += '\\' + if (parent === p) return null + p = parent + } + } + return null +} diff --git a/frontend/test/correctness.test.jsx b/frontend/test/correctness.test.jsx index 8eed298..428955c 100644 --- a/frontend/test/correctness.test.jsx +++ b/frontend/test/correctness.test.jsx @@ -235,6 +235,24 @@ test('refresh keeps expanded nodes and selection, re-reads folders and notifies await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1)) }) +test('refresh moves a vanished selection to the nearest existing ancestor and keeps unreadable folders', async () => { + window.electronAPI = { listDrives: vi.fn().mockResolvedValue(['C:\\']), homeDir: vi.fn().mockResolvedValue('C:\\Users') } + const onSelectPath = vi.fn(), onRefresh = vi.fn() + metalens.list.mockImplementation(async path => { + if (path === 'C:\\') return metalens.list.drive + if (path === 'C:\\a') throw new Error('gone') + return { items: [] } + }) + metalens.list.drive = { items: [{ name: 'a', path: 'C:\\a', is_dir: true }] } + render() + fireEvent.click(await screen.findByText('C:\\')); fireEvent.click(await screen.findByText('a')) + metalens.list.drive = { items: [] } + fireEvent.click(screen.getByText('Refresh')) + await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1)) + expect(screen.queryByText('a')).toBeNull() + expect(onSelectPath).toHaveBeenLastCalledWith('C:\\') +}) + test('folder errors are visible and retry loads children', async () => { window.electronAPI = { listDrives: vi.fn().mockResolvedValue(['C:\\']), homeDir: vi.fn().mockResolvedValue('C:\\Users') } metalens.list.mockRejectedValueOnce(new Error('Access denied')).mockResolvedValueOnce({ items: [{ name: 'child', path: 'C:\\child', is_dir: true }] }) From a54ea1a96fd35634759c44a7e0aa7238f3feac91 Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:03:55 +0100 Subject: [PATCH 09/29] feat: update check, repository link in About, version 0.4.0 Add a user-initiated (or opt-in at startup) update check against the GitHub Releases API, run entirely in the Electron main process. Stable builds see only newer stable releases; beta builds also see newer betas. The update panel shows release notes, release page, ignore/unignore and, on Windows installed builds, a cancellable download with size and SHA-256 verification before the installer is started. The About dialog links to the repository and closes with Esc. Fix the menu-listener cleanup reading window.electronAPI at unmount, and correct docs that claimed About reads the version from /health. Privacy docs now say offline by default. [skip ci] --- CHANGELOG.md | 26 ++++ README.md | 11 +- SECURITY.md | 7 +- docs/API.md | 4 +- docs/INSTALLATION.md | 4 +- docs/REQUIREMENTS.md | 8 +- docs/USAGE.md | 20 ++- electron/main.js | 97 +++++++++++- electron/package-lock.json | 4 +- electron/package.json | 2 +- electron/preload.js | 11 ++ electron/test/preload-client.test.js | 25 +++ electron/test/update-check.test.js | 182 ++++++++++++++++++++++ electron/update-check.js | 196 ++++++++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.jsx | 38 +++-- frontend/src/components/AboutDialog.jsx | 108 ++++++++++++- frontend/src/hooks/useUpdates.js | 99 ++++++++++++ frontend/test/updates.test.jsx | 163 ++++++++++++++++++++ python/config.py | 2 +- 21 files changed, 972 insertions(+), 41 deletions(-) create mode 100644 electron/test/update-check.test.js create mode 100644 electron/update-check.js create mode 100644 frontend/src/hooks/useUpdates.js create mode 100644 frontend/test/updates.test.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7f134..37ff9a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — - [ ] **Batch Edit** — Apply a field change to multiple selected files at once - [ ] **Search/Filter Bar** — Filter the file list by name or extension +### Added +- **Update check.** *Help → Check for Updates…* and *About → Check for updates* look up the latest + release on GitHub. A stable build is only offered newer stable releases; a beta build is also + offered newer betas and the stable release of its version, and the dialog says which kind it is. + The release notes are shown, with **Release page**, **Ignore this version** / **Stop ignoring** + and, on Windows installed builds, **Download & install**: the installer is downloaded to a + temporary folder, verified against the size and SHA-256 digest published by GitHub (a mismatch + discards it), then started while MetaLens closes. The download can be cancelled. On Linux only + the release page is offered. +- The check never runs on its own by default: a **Check for updates automatically at startup** + option (off by default) is stored in `settings.json` in the app data folder. When on, a new + version shows an **Update vX.Y.Z** button in the toolbar; a failed startup check stays silent. + All requests are made by the Electron main process, which only talks to the + `overwrite00/MetaLens` releases and never accepts URLs from the renderer. +- The About dialog links to the project repository on GitHub, and can be closed with Esc. + +### Changed +- Version bumped to 0.4.0 (new feature). The fixes below, previously tracked as 0.3.6 betas, ship + in 0.4.0; there is no 0.3.6 stable release. +- Privacy statements (README, SECURITY, requirements) now say "offline by default" instead of + "no network calls": the update check is the only network access and is user-initiated or opt-in. + ### Fixed +- Closing the app no longer throws in the menu-listener cleanup if `window.electronAPI` is gone. +- Documentation said the About dialog reads the version from the sidecar's `/health`; it shows the + packaged app version (`app.getVersion()`, including the beta suffix). Corrected in the guides. - The folder panel **Refresh** button no longer collapses the tree and drops the current folder. It now re-reads the drive list and every expanded folder (keeping expansion and selection), and reloads the file list of the selected folder, keeping the still-existing file @@ -34,6 +59,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — `MetaLens--linux-x64.tar.gz`. ### Documentation +- New *Updates* section in the usage guide; privacy and security pages describe the update check. - Installation and requirements guides describe the Linux packages, the glibc 2.34 baseline and the glibc troubleshooting case; `CONTRIBUTING.md` documents how the Linux jobs are split. diff --git a/README.md b/README.md index 318190c..39ed7d3 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ MetaLens — Universal File Metadata Manager

-**Universal File Metadata Manager** — read, edit, delete and compare metadata across all major file formats, entirely offline. +**Universal File Metadata Manager** — read, edit, delete and compare metadata across all major file formats, offline by default. > [!TIP] -> 💡 **No account, no cloud, no setup.** Download a binary, double-click, and start browsing — MetaLens never touches the network. +> 💡 **No account, no cloud, no setup.** Download a binary, double-click, and start browsing. MetaLens never touches the network unless you ask it to check for a new version (see [Privacy & Security](#-privacy--security)). --- @@ -74,7 +74,8 @@ File (image / audio / video / document / anything) - 🔑 **File integrity hashes** — on-demand MD5 / SHA-1 / SHA-256 / SHA-512 / BLAKE2b with one-click copy - 🎨 **Cyber dark theme** — professional dark UI with cyan/blue accents - 📦 **Compiled binaries** — no Python or Node.js required to run -- 💾 **Offline-first** — zero network calls, zero telemetry +- 💾 **Offline-first** — zero telemetry; the only network call is the optional update check +- 🔔 **Update check** — manual from *About* / *Help*, or opt-in at startup; downloads are SHA-256 verified - 🆓 **Free & open-source** — MIT license --- @@ -195,7 +196,7 @@ cd ../frontend && npm test - 🔒 **No telemetry** — zero data collection - 📁 **Localhost-only sidecar** — the Python API never binds beyond `127.0.0.1` - 🔓 **Open source** — fully auditable code -- ⚡ **Fully offline** — no internet connection required, ever +- ⚡ **Offline by default** — no internet connection required. The only network access is the update check against the public GitHub Releases API, started by you (**About → Check for updates** or **Help → Check for Updates…**) or, if you enable it, once at startup. It sends no file data, paths or identifiers (only a `User-Agent: MetaLens/` header). Settings are stored locally in `settings.json` in the app data folder 📖 See [SECURITY.md](./SECURITY.md) for the full security model. @@ -240,5 +241,5 @@ Distributed with MIT License · [View License](LICENSE) --- -*Last updated: 2026-10-29* +*Last updated: 2026-10-31* *← [Requirements](docs/REQUIREMENTS.md) | [Docs →](docs/)* diff --git a/SECURITY.md b/SECURITY.md index eb5f548..bfcdc0d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,12 +4,13 @@ MetaLens is designed with security-first principles: -- **Fully offline** — all metadata reading and writing happens on your machine; there is no network call anywhere in the app +- **Offline by default** — all metadata reading and writing happens on your machine. The only network access is the update check (see below), which runs only when you click *Check for updates* or have enabled the opt-in startup check - **Localhost-only sidecar** — the Python (FastAPI) backend binds to `127.0.0.1` only, on a dynamically chosen port, never `0.0.0.0` - **Electron hardening** — `nodeIntegration: false`, `contextIsolation: true`, `sandbox: true`; IPC accepts only the application's main frame. Navigation and popups are blocked and a Content Security Policy limits renderer resources - **Authenticated sidecar** — a fresh random token is generated for each session, passed to Python through the environment and sent in an Authorization header. Requests without valid credentials are rejected; localhost binding alone is not treated as authentication - **Atomic writes** — changes are prepared in a private temporary directory beside the target, with checks for concurrent modification before replacement. Audio and video use the same mechanism. Writer failures leave the original content intact - **Path validation** — a dedicated `core.path_security` module validates and normalizes every path (resolves symlinks, blocks traversal) before any filesystem access, across all API routes +- **Hardened update check** — requests are made by the Electron main process, never by the renderer (whose CSP still only allows the local sidecar). It queries the GitHub Releases API of `overwrite00/MetaLens` only, and the renderer cannot supply URLs. Release and asset URLs must point to that repository, and the installer is downloaded only when you click *Download & install* (Windows installed builds), checked against its size and the SHA-256 digest published by GitHub, and discarded on any mismatch. Release notes are shown as plain text - **Transparent code** — all handler and analysis logic is open-source and auditable --- @@ -22,7 +23,7 @@ MetaLens is designed with security-first principles: - ❌ No telemetry or usage tracking - ❌ No data collection or analytics - ❌ No cloud processing or storage -- ❌ No auto-update phone-home (updates are manual, via GitHub Releases) +- ❌ No automatic phone-home: the update check is manual, or opt-in (off by default) at startup ### Data Control @@ -106,5 +107,5 @@ We appreciate responsible disclosure and will credit you in the security advisor --- -*Last updated: 2026-10-27* +*Last updated: 2026-10-31* *← [Contributing](./CONTRIBUTING.md) | [Back to README →](./README.md)* diff --git a/docs/API.md b/docs/API.md index d4c2938..ec96a1d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -36,7 +36,7 @@ Use Vite together with Electron for development; the standalone browser fallback ## GET `/health` -Health check + version, used by the About dialog and by Electron to know the sidecar is ready. +Health check + version, used by Electron to know the sidecar is ready. (The About dialog shows the packaged app version from Electron, not this value.) **Response** ```json @@ -203,5 +203,5 @@ Defined in `python/core/models.py`. --- -*Last updated: 2026-10-27* +*Last updated: 2026-10-31* *← [Usage](./USAGE.md) | [Back to README →](../README.md)* diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index c28969c..4886352 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -70,7 +70,7 @@ cd MetaLens-linux-x64 ## ✅ Verify Installation -Launch MetaLens, then open **Help → About** (or the About dialog) to confirm the version number matches the one you installed — it's read live from the sidecar's `/health` endpoint. +Launch MetaLens, then open **Help → About** (or the About dialog) to confirm the version number matches the one you installed. To look for a newer release later, see [Updates](./USAGE.md#-updates). You should see: - ✅ A folder tree on the left (drives and home directory) @@ -162,5 +162,5 @@ sudo apt --fix-broken install # Debian/Ubuntu, after a failed .deb install --- -*Last updated: 2026-10-29* +*Last updated: 2026-10-31* *← [Requirements](./REQUIREMENTS.md) | [Usage →](./USAGE.md)* diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index e9ff920..c37492e 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -51,7 +51,7 @@ Requirements for running the pre-built MetaLens application and, separately, for |---|---| | **Python** | The sidecar ships as a compiled binary (`metalens-sidecar[.exe]`) inside the installer | | **Node.js** | The frontend is pre-built and bundled into the Electron app | -| **Internet connection** | MetaLens works fully offline — no cloud dependency, no telemetry | +| **Internet connection** | MetaLens works fully offline — no cloud dependency, no telemetry. A connection is only used for the optional update check | | **Admin/root rights** | Windows installer runs per-user (Squirrel); Linux packages install standard desktop entries | --- @@ -73,13 +73,13 @@ See [Installation Guide → Build from Source](./INSTALLATION.md#-build-from-sou ## 🔒 Data Privacy & Security > [!IMPORTANT] -> 🛡️ **Your files never leave your machine.** MetaLens has no network calls of any kind. +> 🛡️ **Your files never leave your machine.** The only network call MetaLens can make is the update check against GitHub Releases, and only when you ask for it or opt in. - ✅ **No cloud transmission** — all metadata reads/writes happen locally - ✅ **No telemetry** — zero data collection or external tracking - ✅ **Localhost-only sidecar** — the Python API binds to `127.0.0.1` only, never `0.0.0.0` - ✅ **Open source** — full source code available for audit (MIT license) -- ✅ **Offline capable** — no internet connection required at any point +- ✅ **Offline capable** — no internet connection required, except to check for or download a new version --- @@ -91,5 +91,5 @@ See [Installation Guide → Build from Source](./INSTALLATION.md#-build-from-sou --- -*Last updated: 2026-10-29* +*Last updated: 2026-10-31* *← [README](../README.md) | [Installation →](./INSTALLATION.md)* diff --git a/docs/USAGE.md b/docs/USAGE.md index ec374fa..50fc522 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -97,9 +97,25 @@ MetaLens keeps an in-memory undo stack of your last **50** write/delete operatio ## ℹ️ Checking Your Version -Open **Help → About** to see the running app version, read live from the Python sidecar's `/health` endpoint — useful when reporting a bug. +Open **Help → About** to see the running app version (the packaged Electron app version, including any `-beta.N` suffix) — useful when reporting a bug. The dialog also links to the project repository on GitHub. --- -*Last updated: 2026-07-14* +## 🔔 Updates + +MetaLens never checks for updates on its own unless you enable it. + +- **Check now** — **Help → Check for Updates…**, or **About → Check for updates**. This contacts the public GitHub Releases API (`api.github.com`) and compares the result with your version. +- **Check at startup** — tick **Check for updates automatically at startup** in the About dialog (off by default). A failed startup check is silent; if a new version is found, an **Update vX.Y.Z** button appears in the toolbar. +- **Which releases count** — a stable build is only offered newer **stable** releases. A beta build (`X.Y.Z-beta.N`) is offered newer betas as well as the stable release of its version or later, and the dialog says whether the new version is a beta or a stable. +- **What you see** — the release notes (as plain text) and, depending on the platform, buttons: + - **Windows (installed build)** — **Download & install** downloads the installer to a temporary folder, verifies its SHA-256 checksum against the one published by GitHub, then starts it and closes MetaLens. A download that does not match is discarded. Use **Cancel** to stop a download. + - **Linux** — only **Release page** is offered; download the package for your distribution from there (see the [Installation Guide](./INSTALLATION.md)). +- **Ignore this version** — hides the toolbar notice for that version only (a manual check still shows it, marked *ignored*, with **Stop ignoring**). A newer version is announced again. + +The preferences (auto-check on/off, ignored version) are stored in `settings.json` inside the app's user-data folder. + +--- + +*Last updated: 2026-10-31* *← [Installation](./INSTALLATION.md) | [API Reference →](./API.md)* diff --git a/electron/main.js b/electron/main.js index 3728a40..1ee1132 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain, dialog, Menu } = require('electron') +const { app, BrowserWindow, ipcMain, dialog, Menu, shell, net: electronNet } = require('electron') const path = require('path') const { spawn } = require('child_process') const net = require('net') @@ -9,6 +9,9 @@ const { isTrustedFrame, secureWindow } = require('./security-policy') const { waitForSidecar } = require('./sidecar-health') const { handleSquirrelEvent } = require('./squirrel-events') const { spawnTree, killProcessTree } = require('./process-tree') +const { + REPO_URL, checkForUpdates, downloadAsset, cleanupStaleDownloads, createSettingsStore, +} = require('./update-check') // ─────────────────────── Windows/Squirrel install & update events ──────────── // Squirrel (electron-forge maker-squirrel) launches the app with special flags @@ -36,6 +39,10 @@ app.commandLine.appendSwitch('safebrowsing-disable-auto-update') let mainWindow = null let sidecarProcess = null let sidecarPort = null +let settingsStore = null +let lastUpdate = null +let checkInFlight = null +let installAbort = null const sidecarToken = randomBytes(32).toString('hex') const trustedURL = app.isPackaged ? pathToFileURL(path.join(__dirname, 'frontend', 'index.html')).href @@ -102,6 +109,81 @@ function stopSidecar() { } } +// ─────────────────────── Update check ──────────────────────────────────────── +// All network access lives here, never in the renderer (its CSP only allows the +// local sidecar). Nothing runs unless the user clicks "Check for updates" or has +// opted in to the startup check; the renderer never supplies URLs — install +// always uses the release picked by the last check. + +const electronFetch = (...args) => electronNet.fetch(...args) + +function describeNetworkError(error) { + if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return 'The request timed out' + return error?.message || String(error) +} + +function canInstallUpdate(update) { + return Boolean(update?.asset) && app.isPackaged && process.platform === 'win32' +} + +function updateView(update) { + if (!update) return null + return { + version: update.version, + channel: update.channel, + notes: update.notes, + publishedAt: update.publishedAt, + canInstall: canInstallUpdate(update), + ignored: settingsStore.get().ignoredVersion === update.version, + } +} + +async function runUpdateCheck({ auto = false } = {}) { + if (auto && !settingsStore.get().autoCheckUpdates) return { skipped: true } + try { + if (!checkInFlight) { + checkInFlight = checkForUpdates({ fetch: electronFetch, currentVersion: app.getVersion() }) + .finally(() => { checkInFlight = null }) + } + lastUpdate = await checkInFlight + return { currentVersion: app.getVersion(), update: updateView(lastUpdate) } + } catch (error) { + return { error: describeNetworkError(error) } + } +} + +function launchInstaller(file) { + return new Promise((resolve, reject) => { + const child = spawn(file, [], { detached: true, stdio: 'ignore' }) + child.once('error', reject) + child.once('spawn', () => { child.unref(); resolve() }) + }) +} + +async function installUpdate() { + if (installAbort) return { error: 'An update is already being downloaded' } + const update = lastUpdate + if (!canInstallUpdate(update)) return { error: 'Automatic installation is not available for this build' } + const controller = installAbort = new AbortController() + let lastPercent = -1 + const onProgress = ({ received, total }) => { + const percent = Math.floor(received / total * 100) + if (percent === lastPercent) return + lastPercent = percent + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update-progress', { received, total }) + } + try { + const { file } = await downloadAsset({ fetch: electronFetch, asset: update.asset, signal: controller.signal, onProgress }) + await launchInstaller(file) + app.quit() + return { ok: true } + } catch (error) { + return { error: controller.signal.aborted ? 'Download cancelled' : describeNetworkError(error) } + } finally { + installAbort = null + } +} + // ─────────────────────── IPC handlers ──────────────────────────────────────── function setupIPC() { @@ -114,6 +196,14 @@ function setupIPC() { handle('get-app-version', () => app.getVersion()) + handle('open-repo', () => shell.openExternal(REPO_URL)) + handle('updates-get-settings', () => settingsStore.get()) + handle('updates-set-settings', (_, patch) => settingsStore.update(patch)) + handle('updates-check', (_, options) => runUpdateCheck(options)) + handle('updates-open-release', () => lastUpdate ? shell.openExternal(lastUpdate.url) : undefined) + handle('updates-install', () => installUpdate()) + handle('updates-cancel', () => { installAbort?.abort() }) + handle('open-folder-dialog', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'], @@ -183,6 +273,9 @@ function buildMenu() { { label: 'Help', submenu: [ + { label: 'Check for Updates…', + click: () => mainWindow.webContents.send('menu-check-updates') }, + { type: 'separator' }, { label: 'About MetaLens', click: () => mainWindow.webContents.send('menu-about') }, ], @@ -234,6 +327,8 @@ if (!squirrelEvent) app.whenReady().then(async () => { app.quit() return } + settingsStore = createSettingsStore(path.join(app.getPath('userData'), 'settings.json')) + cleanupStaleDownloads().catch(() => {}) setupIPC() buildMenu() await createWindow() diff --git a/electron/package-lock.json b/electron/package-lock.json index 4db9c40..5195683 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "metalens", - "version": "0.3.6", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalens", - "version": "0.3.6", + "version": "0.4.0", "license": "MIT", "devDependencies": { "@electron-forge/cli": "^8.0.1", diff --git a/electron/package.json b/electron/package.json index d208dbd..b7edd47 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "metalens", - "version": "0.3.6", + "version": "0.4.0", "description": "Universal File Metadata Manager", "author": "Graziano Mariella", "license": "MIT", diff --git a/electron/preload.js b/electron/preload.js index f522049..bd5773e 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -14,6 +14,16 @@ contextBridge.exposeInMainWorld('electronAPI', { // Packaged app version (reflects beta suffix, unlike the sidecar's /health version) getAppVersion: () => ipcRenderer.invoke('get-app-version'), + // Repository link and update check (all network access happens in the main process) + openRepo: () => ipcRenderer.invoke('open-repo'), + getUpdateSettings: () => ipcRenderer.invoke('updates-get-settings'), + setUpdateSettings: (patch) => ipcRenderer.invoke('updates-set-settings', patch), + checkForUpdates: (options) => ipcRenderer.invoke('updates-check', options), + openReleasePage: () => ipcRenderer.invoke('updates-open-release'), + installUpdate: () => ipcRenderer.invoke('updates-install'), + cancelUpdate: () => ipcRenderer.invoke('updates-cancel'), + onUpdateProgress: (cb) => subscribe('update-progress', cb), + // Native dialogs openFolderDialog: () => ipcRenderer.invoke('open-folder-dialog'), openFileDialog: () => ipcRenderer.invoke('open-file-dialog'), @@ -29,6 +39,7 @@ contextBridge.exposeInMainWorld('electronAPI', { onMenuOpenFolder: (cb) => subscribe('menu-open-folder', cb), onMenuUndo: (cb) => subscribe('menu-undo', cb), onMenuAbout: (cb) => subscribe('menu-about', cb), + onMenuCheckUpdates: (cb) => subscribe('menu-check-updates', cb), // Cleanup removeAllListeners: (channel) => { diff --git a/electron/test/preload-client.test.js b/electron/test/preload-client.test.js index bb8e686..0d233ec 100644 --- a/electron/test/preload-client.test.js +++ b/electron/test/preload-client.test.js @@ -48,3 +48,28 @@ test('frontend fails closed without Electron and sends session token in header o const authenticated = await import(`${url}?authenticated`) assert.deepEqual(await authenticated.metalens.health(), { status: 'ok' }) }) + +test('preload exposes the update API over fixed channels only', async () => { + let api + const calls = [] + const ipcRenderer = { + invoke: async (...args) => { calls.push(args) }, + on() {}, removeListener() {}, removeAllListeners() {}, + } + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'), { + require: () => ({ contextBridge: { exposeInMainWorld: (_name, surface) => { api = surface } }, ipcRenderer }), + }) + await api.openRepo() + await api.getUpdateSettings() + await api.setUpdateSettings({ autoCheckUpdates: true }) + await api.checkForUpdates({ auto: true }) + await api.openReleasePage() + await api.installUpdate() + await api.cancelUpdate() + assert.deepEqual(calls, [ + ['open-repo'], ['updates-get-settings'], ['updates-set-settings', { autoCheckUpdates: true }], + ['updates-check', { auto: true }], ['updates-open-release'], ['updates-install'], ['updates-cancel'], + ]) + assert.equal(typeof api.onUpdateProgress(() => {}), 'function') + assert.equal(typeof api.onMenuCheckUpdates(() => {}), 'function') +}) diff --git a/electron/test/update-check.test.js b/electron/test/update-check.test.js new file mode 100644 index 0000000..35045cf --- /dev/null +++ b/electron/test/update-check.test.js @@ -0,0 +1,182 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const crypto = require('node:crypto') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { + parseVersion, compareVersions, pickAsset, pickUpdate, checkForUpdates, + downloadAsset, cleanupStaleDownloads, createSettingsStore, +} = require('../update-check') + +const BASE = 'https://github.com/overwrite00/MetaLens' +const release = (tag, extra = {}) => ({ + tag_name: tag, draft: false, prerelease: tag.includes('-'), body: `notes ${tag}`, + html_url: `${BASE}/releases/tag/${tag}`, published_at: '2026-10-30T10:00:00Z', assets: [], ...extra, +}) +const winAsset = (tag, overrides = {}) => ({ + name: `MetaLens-${tag.slice(1)}-win-x64.exe`, + browser_download_url: `${BASE}/releases/download/${tag}/MetaLens-${tag.slice(1)}-win-x64.exe`, + size: 1000, digest: `sha256:${'a'.repeat(64)}`, ...overrides, +}) +const tempDir = t => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ml-test-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + return dir +} + +test('versions follow semver precedence, including beta numbers', () => { + const order = ['0.3.5', '0.3.6-beta.1', '0.3.6-beta.2', '0.3.6-beta.10', '0.3.6', '0.3.7-beta.1', '0.3.7', '0.10.0'] + for (let i = 0; i < order.length - 1; i++) { + assert.equal(compareVersions(parseVersion(order[i]), parseVersion(order[i + 1])), -1, `${order[i]} < ${order[i + 1]}`) + assert.equal(compareVersions(parseVersion(order[i + 1]), parseVersion(order[i])), 1) + } + assert.equal(compareVersions(parseVersion('v0.3.6'), parseVersion('0.3.6')), 0) + assert.equal(parseVersion('latest'), null) + assert.equal(parseVersion('1.2'), null) +}) + +test('a stable build only sees newer stable releases', () => { + const releases = [release('v0.3.7-beta.2'), release('v0.3.6'), release('v0.3.5'), release('v0.3.6', { prerelease: true, tag_name: 'v0.3.8' })] + assert.equal(pickUpdate(releases, '0.3.5', 'win32').version, '0.3.6') + assert.equal(pickUpdate(releases, '0.3.5', 'win32').channel, 'stable') + assert.equal(pickUpdate(releases, '0.3.6', 'win32'), null) +}) + +test('a beta build is told about newer betas and about the stable of its own version', () => { + const current = '0.3.6-beta.2' + assert.deepEqual( + (u => [u.version, u.channel])(pickUpdate([release('v0.3.6-beta.3'), release('v0.3.6-beta.1')], current, 'win32')), + ['0.3.6-beta.3', 'beta']) + assert.deepEqual( + (u => [u.version, u.channel])(pickUpdate([release('v0.3.6-beta.3'), release('v0.3.6')], current, 'win32')), + ['0.3.6', 'stable']) + assert.deepEqual( + (u => [u.version, u.channel])(pickUpdate([release('v0.3.7-beta.1'), release('v0.3.6')], current, 'win32')), + ['0.3.7-beta.1', 'beta']) + assert.equal(pickUpdate([release('v0.3.6-beta.2'), release('v0.3.5')], current, 'win32'), null) +}) + +test('drafts, malformed tags and foreign release pages are ignored', () => { + const releases = [ + release('v9.0.0', { draft: true }), + release('nightly'), + release('v8.0.0', { html_url: 'https://evil.example/releases/tag/v8.0.0' }), + release('v0.4.0'), + ] + assert.equal(pickUpdate(releases, '0.3.6', 'win32').version, '0.4.0') + assert.throws(() => pickUpdate(releases, 'garbage', 'win32'), /Unrecognized/) + assert.equal(pickUpdate('not an array', '0.3.6', 'win32'), null) +}) + +test('release notes are capped and only the Windows installer with a checksum is selectable', () => { + const notes = 'x'.repeat(50000) + const update = pickUpdate([release('v0.4.0', { body: notes, assets: [winAsset('v0.4.0')] })], '0.3.6', 'win32') + assert.equal(update.notes.length, 8000) + assert.deepEqual(update.asset, { + name: 'MetaLens-0.4.0-win-x64.exe', url: `${BASE}/releases/download/v0.4.0/MetaLens-0.4.0-win-x64.exe`, + size: 1000, sha256: 'a'.repeat(64), + }) + assert.equal(pickUpdate([release('v0.4.0', { assets: [winAsset('v0.4.0')] })], '0.3.6', 'linux').asset, null) + + const bad = [ + winAsset('v0.4.0', { digest: null }), + winAsset('v0.4.0', { digest: 'sha256:abc' }), + winAsset('v0.4.0', { browser_download_url: 'https://evil.example/MetaLens-0.4.0-win-x64.exe' }), + winAsset('v0.4.0', { name: '../MetaLens-win-x64.exe' }), + winAsset('v0.4.0', { size: 0 }), + winAsset('v0.4.0', { size: 5 * 1024 ** 3 }), + winAsset('v0.4.0', { name: 'metalens_0.4.0_amd64.deb' }), + ] + for (const asset of bad) assert.equal(pickAsset([asset], 'win32'), null, JSON.stringify(asset)) +}) + +test('checkForUpdates reports HTTP and rate-limit failures clearly', async () => { + const respond = (status, body, headers = {}) => async () => new Response(JSON.stringify(body), { status, headers }) + const run = fetchImpl => checkForUpdates({ fetch: fetchImpl, currentVersion: '0.3.6', platform: 'win32' }) + assert.equal((await run(respond(200, [release('v0.3.7')]))).version, '0.3.7') + assert.equal(await run(respond(200, [])), null) + await assert.rejects(run(respond(403, {}, { 'x-ratelimit-remaining': '0' })), /rate limit/) + await assert.rejects(run(respond(500, {})), /HTTP 500/) + await assert.rejects(run(respond(200, { message: 'nope' })), /Unexpected response/) + let request + await run(async (url, options) => { request = { url, options }; return new Response('[]') }) + assert.match(request.url, /^https:\/\/api\.github\.com\/repos\/overwrite00\/MetaLens\/releases/) + assert.equal(request.options.headers['User-Agent'], 'MetaLens/0.3.6') + assert.ok(request.options.signal) +}) + +const bodyOf = bytes => new Response(bytes).body +const assetFor = bytes => ({ + name: 'MetaLens-0.4.0-win-x64.exe', url: `${BASE}/releases/download/v0.4.0/x.exe`, + size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), +}) + +test('download writes a verified file and reports progress', async t => { + const baseDir = tempDir(t) + const bytes = crypto.randomBytes(5000) + const progress = [] + const { file } = await downloadAsset({ + fetch: async () => ({ ok: true, status: 200, body: bodyOf(bytes) }), + asset: assetFor(bytes), baseDir, onProgress: p => progress.push(p), + }) + assert.equal(path.dirname(path.dirname(file)), baseDir) + assert.deepEqual(fs.readFileSync(file), bytes) + assert.equal(progress.at(-1).received, bytes.length) + assert.equal(progress.at(-1).total, bytes.length) +}) + +test('download rejects and removes files with a wrong checksum, wrong size or HTTP error', async t => { + const baseDir = tempDir(t) + const bytes = crypto.randomBytes(2000) + const run = (asset, response) => downloadAsset({ fetch: async () => response, asset, baseDir }) + await assert.rejects(run({ ...assetFor(bytes), sha256: 'b'.repeat(64) }, { ok: true, body: bodyOf(bytes) }), /Checksum mismatch/) + await assert.rejects(run({ ...assetFor(bytes), size: bytes.length + 1 }, { ok: true, body: bodyOf(bytes) }), /incomplete/) + await assert.rejects(run({ ...assetFor(bytes), size: bytes.length - 1 }, { ok: true, body: bodyOf(bytes) }), /larger than expected/) + await assert.rejects(run(assetFor(bytes), { ok: false, status: 404, body: null }), /HTTP 404/) + assert.deepEqual(fs.readdirSync(baseDir), []) +}) + +test('download can be cancelled and leaves nothing behind', async t => { + const baseDir = tempDir(t) + const controller = new AbortController() + const bytes = crypto.randomBytes(100) + const body = new ReadableStream({ + start(stream) { stream.enqueue(bytes.subarray(0, 50)); controller.abort() }, + }) + await assert.rejects(downloadAsset({ + fetch: async () => ({ ok: true, body }), asset: assetFor(bytes), baseDir, signal: controller.signal, + })) + assert.deepEqual(fs.readdirSync(baseDir), []) +}) + +test('stale update downloads are removed, recent ones and unrelated folders are kept', async t => { + const baseDir = tempDir(t) + for (const name of ['metalens-update-old', 'metalens-update-new', 'other-folder']) fs.mkdirSync(path.join(baseDir, name)) + const old = new Date(Date.now() - 2 * 60 * 60 * 1000) + fs.utimesSync(path.join(baseDir, 'metalens-update-old'), old, old) + fs.utimesSync(path.join(baseDir, 'other-folder'), old, old) + await cleanupStaleDownloads({ baseDir }) + assert.deepEqual(fs.readdirSync(baseDir).sort(), ['metalens-update-new', 'other-folder']) +}) + +test('settings default to off, persist, and reject unknown or invalid values', t => { + const file = path.join(tempDir(t), 'sub', 'settings.json') + const store = createSettingsStore(file) + assert.deepEqual(store.get(), { autoCheckUpdates: false, ignoredVersion: null }) + assert.deepEqual(store.update({ autoCheckUpdates: true, ignoredVersion: '0.3.7' }), { autoCheckUpdates: true, ignoredVersion: '0.3.7' }) + assert.deepEqual(createSettingsStore(file).get(), { autoCheckUpdates: true, ignoredVersion: '0.3.7' }) + assert.throws(() => store.update({ autoCheckUpdates: 'yes' }), /Invalid value/) + assert.throws(() => store.update({ ignoredVersion: 'latest' }), /Invalid value/) + assert.throws(() => store.update({ other: 1 }), /Unknown setting/) + assert.deepEqual(store.update({ ignoredVersion: null }), { autoCheckUpdates: true, ignoredVersion: null }) + assert.deepEqual(fs.readdirSync(path.dirname(file)), ['settings.json']) +}) + +test('corrupt or tampered settings fall back to safe defaults', t => { + const file = path.join(tempDir(t), 'settings.json') + fs.writeFileSync(file, '{ not json') + assert.deepEqual(createSettingsStore(file).get(), { autoCheckUpdates: false, ignoredVersion: null }) + fs.writeFileSync(file, JSON.stringify({ autoCheckUpdates: 'true', ignoredVersion: 5 })) + assert.deepEqual(createSettingsStore(file).get(), { autoCheckUpdates: false, ignoredVersion: null }) +}) diff --git a/electron/update-check.js b/electron/update-check.js new file mode 100644 index 0000000..463fa2b --- /dev/null +++ b/electron/update-check.js @@ -0,0 +1,196 @@ +const fs = require('fs') +const os = require('os') +const path = require('path') +const crypto = require('crypto') +const { Readable, Transform } = require('stream') +const { pipeline } = require('stream/promises') + +const REPO = 'overwrite00/MetaLens' +const REPO_URL = `https://github.com/${REPO}` +const RELEASES_API = `https://api.github.com/repos/${REPO}/releases?per_page=30` +const RELEASE_PAGE_PREFIX = `${REPO_URL}/releases/tag/` +const ASSET_URL_PREFIX = `${REPO_URL}/releases/download/` + +const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/ +const SHA256_RE = /^sha256:([0-9a-f]{64})$/ +const SAFE_NAME_RE = /^[A-Za-z0-9._-]+$/ +const WINDOWS_ASSET_RE = /-win-x64\.exe$/i +const MAX_ASSET_BYTES = 1024 * 1024 * 1024 +const MAX_NOTES_CHARS = 8000 +const REQUEST_TIMEOUT_MS = 15000 + +function parseVersion(text) { + const match = VERSION_RE.exec(String(text).trim()) + if (!match) return null + const [, major, minor, patch, pre] = match + return { major: +major, minor: +minor, patch: +patch, pre: pre ? pre.split('.') : null } +} + +function comparePrerelease(a, b) { + if (!a && !b) return 0 + if (!a) return 1 // a stable release outranks any of its prereleases + if (!b) return -1 + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (a[i] === undefined) return -1 + if (b[i] === undefined) return 1 + const aNum = /^\d+$/.test(a[i]), bNum = /^\d+$/.test(b[i]) + if (aNum && bNum) { if (+a[i] !== +b[i]) return +a[i] < +b[i] ? -1 : 1 } + else if (aNum) return -1 + else if (bNum) return 1 + else if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1 + } + return 0 +} + +function compareVersions(a, b) { + for (const key of ['major', 'minor', 'patch']) { + if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1 + } + return comparePrerelease(a.pre, b.pre) +} + +function pickAsset(assets, platform) { + if (platform !== 'win32' || !Array.isArray(assets)) return null + for (const asset of assets) { + const digest = SHA256_RE.exec(String(asset?.digest)) + if (typeof asset?.name === 'string' && WINDOWS_ASSET_RE.test(asset.name) && + SAFE_NAME_RE.test(asset.name) && digest && + typeof asset.browser_download_url === 'string' && + asset.browser_download_url.startsWith(ASSET_URL_PREFIX) && + Number.isSafeInteger(asset.size) && asset.size > 0 && asset.size <= MAX_ASSET_BYTES) { + return { name: asset.name, url: asset.browser_download_url, size: asset.size, sha256: digest[1] } + } + } + return null +} + +// A stable build only ever compares against stable releases; a beta build also +// considers newer betas, and a stable of the same or later version outranks them. +function pickUpdate(releases, currentVersion, platform) { + const current = parseVersion(currentVersion) + if (!current) throw new Error(`Unrecognized app version "${currentVersion}"`) + const onBeta = current.pre !== null + let best = null + for (const release of Array.isArray(releases) ? releases : []) { + if (!release || release.draft || typeof release.tag_name !== 'string') continue + const parsed = parseVersion(release.tag_name) + if (!parsed) continue + const isBeta = parsed.pre !== null || release.prerelease === true + if (isBeta && !onBeta) continue + if (typeof release.html_url !== 'string' || !release.html_url.startsWith(RELEASE_PAGE_PREFIX)) continue + if (compareVersions(parsed, current) <= 0) continue + if (best && compareVersions(parsed, best.parsed) <= 0) continue + best = { parsed, release, isBeta } + } + if (!best) return null + const { release, isBeta } = best + const version = release.tag_name.replace(/^v/, '') + return { + version, + channel: isBeta ? 'beta' : 'stable', + url: release.html_url, + publishedAt: typeof release.published_at === 'string' ? release.published_at : null, + notes: typeof release.body === 'string' ? release.body.slice(0, MAX_NOTES_CHARS) : '', + asset: pickAsset(release.assets, platform), + } +} + +async function fetchReleases(fetchImpl, userAgent) { + const response = await fetchImpl(RELEASES_API, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': userAgent, 'X-GitHub-Api-Version': '2022-11-28' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!response.ok) { + const limited = response.status === 429 || + (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') + throw new Error(limited + ? 'GitHub rate limit reached, try again later' + : `GitHub responded with HTTP ${response.status}`) + } + const releases = await response.json() + if (!Array.isArray(releases)) throw new Error('Unexpected response from GitHub') + return releases +} + +async function checkForUpdates({ fetch: fetchImpl, currentVersion, platform = process.platform }) { + const releases = await fetchReleases(fetchImpl, `MetaLens/${currentVersion}`) + return pickUpdate(releases, currentVersion, platform) +} + +async function downloadAsset({ fetch: fetchImpl, asset, signal, onProgress = () => {}, baseDir = os.tmpdir() }) { + const dir = await fs.promises.mkdtemp(path.join(baseDir, 'metalens-update-')) + const file = path.join(dir, asset.name) + try { + const response = await fetchImpl(asset.url, { signal }) + if (!response.ok || !response.body) throw new Error(`Download failed (HTTP ${response.status})`) + const hash = crypto.createHash('sha256') + let received = 0 + const meter = new Transform({ + transform(chunk, _encoding, done) { + received += chunk.length + if (received > asset.size) return done(new Error('Downloaded file is larger than expected')) + hash.update(chunk) + onProgress({ received, total: asset.size }) + done(null, chunk) + }, + }) + await pipeline(Readable.fromWeb(response.body), meter, fs.createWriteStream(file, { mode: 0o600 }), { signal }) + if (received !== asset.size) throw new Error('Downloaded file is incomplete') + if (hash.digest('hex') !== asset.sha256) throw new Error('Checksum mismatch: the download was discarded') + return { file, dir } + } catch (error) { + await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +// Installers are left in the temp dir because they are still running when the app +// quits, so remove leftovers of earlier runs (old enough not to belong to a live one). +async function cleanupStaleDownloads({ baseDir = os.tmpdir(), maxAgeMs = 60 * 60 * 1000, now = Date.now() } = {}) { + for (const entry of await fs.promises.readdir(baseDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('metalens-update-')) continue + const dir = path.join(baseDir, entry.name) + try { + const { mtimeMs } = await fs.promises.stat(dir) + if (now - mtimeMs >= maxAgeMs) await fs.promises.rm(dir, { recursive: true, force: true }) + } catch { /* in use or already gone */ } + } +} + +function createSettingsStore(file) { + const defaults = { autoCheckUpdates: false, ignoredVersion: null } + const validators = { + autoCheckUpdates: value => typeof value === 'boolean', + ignoredVersion: value => value === null || (typeof value === 'string' && parseVersion(value) !== null), + } + let settings = { ...defaults } + try { + const stored = JSON.parse(fs.readFileSync(file, 'utf8')) + for (const key of Object.keys(defaults)) { + if (validators[key](stored?.[key])) settings[key] = stored[key] + } + } catch { /* first run or unreadable file: keep the defaults */ } + + return { + get: () => ({ ...settings }), + update(patch) { + const next = { ...settings } + for (const [key, value] of Object.entries(patch ?? {})) { + if (!validators[key]) throw new Error(`Unknown setting "${key}"`) + if (!validators[key](value)) throw new Error(`Invalid value for setting "${key}"`) + next[key] = value + } + const temp = `${file}.tmp` + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(temp, JSON.stringify(next, null, 2)) + fs.renameSync(temp, file) + settings = next + return { ...settings } + }, + } +} + +module.exports = { + REPO_URL, parseVersion, compareVersions, pickAsset, pickUpdate, + checkForUpdates, downloadAsset, cleanupStaleDownloads, createSettingsStore, +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e417ec2..5e1a69f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "metalens-frontend", - "version": "0.3.6", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalens-frontend", - "version": "0.3.6", + "version": "0.4.0", "dependencies": { "@fontsource/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", diff --git a/frontend/package.json b/frontend/package.json index bd19776..4186b5a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "metalens-frontend", - "version": "0.3.6", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b1109d4..ee0ea85 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,8 +5,9 @@ import { DetailPanel } from './components/DetailPanel' import { AboutDialog } from './components/AboutDialog' import { Button } from './components/ui/Button' import { useUndoStack } from './hooks/useUndoStack' +import { useUpdates } from './hooks/useUpdates' import { metalens } from './api/client' -import { FolderOpen, RotateCcw, GitCompare, Layers } from 'lucide-react' +import { FolderOpen, RotateCcw, GitCompare, Layers, CircleArrowUp } from 'lucide-react' import './styles/globals.css' export default function App() { @@ -16,6 +17,8 @@ export default function App() { const [showAbout, setShowAbout] = useState(false) const [undoMsg, setUndoMsg] = useState(null) const { push: undoPush, undo, canUndo, runExclusive, busy } = useUndoStack() + const updates = useUpdates() + const { check: checkUpdates } = updates const [reloadKey, setReloadKey] = useState(0) const [filesReloadKey, setFilesReloadKey] = useState(0) @@ -26,20 +29,29 @@ export default function App() { // ── Menu events from Electron ───────────────────────────────────────────── useEffect(() => { - if (!window.electronAPI) return + const api = window.electronAPI + if (!api) return const openFolder = () => openFolderDialog() const undo = () => handleUndo() const about = () => setShowAbout(true) - window.electronAPI.onMenuOpenFolder(openFolder) - window.electronAPI.onMenuUndo(undo) - window.electronAPI.onMenuAbout(about) + api.onMenuOpenFolder(openFolder) + api.onMenuUndo(undo) + api.onMenuAbout(about) return () => { - window.electronAPI.removeAllListeners('menu-open-folder') - window.electronAPI.removeAllListeners('menu-undo') - window.electronAPI.removeAllListeners('menu-about') + api.removeAllListeners('menu-open-folder') + api.removeAllListeners('menu-undo') + api.removeAllListeners('menu-about') } }, [selectedFile, undo, canUndo]) + useEffect(() => { + const unsubscribe = window.electronAPI?.onMenuCheckUpdates?.(() => { + setShowAbout(true) + checkUpdates() + }) + return () => { if (typeof unsubscribe === 'function') unsubscribe() } + }, [checkUpdates]) + async function openFolderDialog() { if (!window.electronAPI) return const path = await window.electronAPI.openFolderDialog() @@ -124,7 +136,13 @@ export default function App() { {undoMsg} )} -
+
+ {updates.notice && ( + + )}
@@ -167,7 +185,7 @@ export default function App() {
{/* About dialog */} - {showAbout && setShowAbout(false)} />} + {showAbout && setShowAbout(false)} />}
) } diff --git a/frontend/src/components/AboutDialog.jsx b/frontend/src/components/AboutDialog.jsx index 8f64259..61ce229 100644 --- a/frontend/src/components/AboutDialog.jsx +++ b/frontend/src/components/AboutDialog.jsx @@ -1,7 +1,84 @@ import { useEffect, useState } from 'react' -import { X, Layers } from 'lucide-react' +import { X, Layers, ExternalLink, RefreshCw, Download, Loader2 } from 'lucide-react' +import { Button } from './ui/Button' -export function AboutDialog({ onClose }) { +const REPO_URL = 'https://github.com/overwrite00/MetaLens' + +function UpdateSection({ updates }) { + const { status, update, error, progress, settings } = updates + const busy = status === 'checking' || status === 'downloading' + const percent = progress?.total ? Math.floor(progress.received / progress.total * 100) : null + + return ( +
+
+ Updates + +
+ + {status === 'current' && ( +

You are running the latest version.

+ )} + {error &&

{error}

} + + {update && status !== 'checking' && status !== 'current' && ( +
+
+ {update.channel === 'beta' ? 'New beta available: ' : 'New version available: '} + v{update.version} + {update.ignored && (ignored)} +
+ {update.notes && ( +
{update.notes}
+ )} + + {status === 'downloading' ? ( +
+
+
+
+
+ {percent === null ? 'Starting download…' : `Downloading… ${percent}%`} + +
+
+ ) : ( +
+ {update.canInstall && ( + + )} + + +
+ )} +
+ )} + + +
+ ) +} + +export function AboutDialog({ onClose, updates }) { const [version, setVersion] = useState('…') useEffect(() => { @@ -12,14 +89,27 @@ export function AboutDialog({ onClose }) { window.electronAPI.getAppVersion().then(setVersion).catch(() => setVersion('?')) }, []) + useEffect(() => { + const onKey = e => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + function openRepo(e) { + if (!window.electronAPI?.openRepo) return + e.preventDefault() + window.electronAPI.openRepo() + } + return (
-
e.stopPropagation()}> - @@ -42,7 +132,15 @@ export function AboutDialog({ onClose }) {
© 2026 Graziano Mariella
MIT License
+
+ + {updates?.supported && }
) diff --git a/frontend/src/hooks/useUpdates.js b/frontend/src/hooks/useUpdates.js new file mode 100644 index 0000000..0a9ab00 --- /dev/null +++ b/frontend/src/hooks/useUpdates.js @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +const DEFAULT_SETTINGS = { autoCheckUpdates: false, ignoredVersion: null } + +// Update state for the whole app. The network work happens in the Electron main +// process; this hook only drives it through window.electronAPI. Statuses: +// idle | checking | current | available | downloading | error +export function useUpdates() { + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [status, setStatus] = useState('idle') + const [update, setUpdate] = useState(null) + const [error, setError] = useState(null) + const [progress, setProgress] = useState(null) + const alive = useRef(true) + + useEffect(() => { + alive.current = true + return () => { alive.current = false } + }, []) + + const check = useCallback(async ({ auto = false } = {}) => { + const api = window.electronAPI + if (!api?.checkForUpdates) return + setStatus('checking'); setError(null) + let result + try { + result = await api.checkForUpdates({ auto }) + } catch (e) { + result = { error: e.message } + } + if (!alive.current) return + if (result?.skipped) setStatus('idle') + else if (result?.error) { + // A failed startup check stays silent; only a user-requested one reports it. + if (auto) setStatus('idle') + else { setStatus('error'); setError(result.error) } + } else { + setUpdate(result?.update ?? null) + setStatus(result?.update ? 'available' : 'current') + } + }, []) + + useEffect(() => { + const api = window.electronAPI + if (!api?.getUpdateSettings) return + api.getUpdateSettings().then(stored => { + if (!alive.current) return + setSettings(stored) + if (stored.autoCheckUpdates) check({ auto: true }) + }).catch(() => {}) + }, [check]) + + useEffect(() => { + const unsubscribe = window.electronAPI?.onUpdateProgress?.(setProgress) + return () => { if (typeof unsubscribe === 'function') unsubscribe() } + }, []) + + const saveSettings = useCallback(async patch => { + try { + const saved = await window.electronAPI.setUpdateSettings(patch) + if (alive.current) { setSettings(saved); return saved } + } catch (e) { + if (alive.current) { setStatus('error'); setError(e.message) } + } + return null + }, []) + + const setAutoCheck = useCallback(enabled => saveSettings({ autoCheckUpdates: enabled }), [saveSettings]) + + const setIgnored = useCallback(async ignored => { + if (!update) return + const saved = await saveSettings({ ignoredVersion: ignored ? update.version : null }) + if (saved) setUpdate(current => current && { ...current, ignored }) + }, [update, saveSettings]) + + const install = useCallback(async () => { + setStatus('downloading'); setError(null); setProgress(null) + let result + try { + result = await window.electronAPI.installUpdate() + } catch (e) { + result = { error: e.message } + } + if (!alive.current || result?.ok) return + setStatus(result?.error === 'Download cancelled' ? 'available' : 'error') + if (result?.error !== 'Download cancelled') setError(result?.error ?? 'Installation failed') + }, []) + + const cancel = useCallback(() => window.electronAPI?.cancelUpdate?.(), []) + const openRelease = useCallback(() => window.electronAPI?.openReleasePage?.(), []) + + return { + supported: Boolean(window.electronAPI?.checkForUpdates), + settings, status, update, error, progress, + notice: status === 'available' && Boolean(update) && !update.ignored, + check, setAutoCheck, ignore: () => setIgnored(true), unignore: () => setIgnored(false), + install, cancel, openRelease, + } +} diff --git a/frontend/test/updates.test.jsx b/frontend/test/updates.test.jsx new file mode 100644 index 0000000..9a4e01f --- /dev/null +++ b/frontend/test/updates.test.jsx @@ -0,0 +1,163 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import App from '../src/App' + +vi.mock('../src/api/client', () => ({ metalens: {} })) +vi.mock('../src/components/FolderPanel', () => ({ FolderPanel: () => null })) +vi.mock('../src/components/FilePanel', () => ({ FilePanel: () => null })) +vi.mock('../src/components/DetailPanel', () => ({ DetailPanel: () => null })) + +const UPDATE = { version: '0.4.0', channel: 'stable', notes: 'Fixed things', canInstall: true, ignored: false } +let api, menuCheck, progress + +beforeEach(() => { + menuCheck = progress = undefined + const settings = { autoCheckUpdates: false, ignoredVersion: null } + api = { + onMenuOpenFolder: vi.fn(), onMenuUndo: vi.fn(), onMenuAbout: vi.fn(), removeAllListeners: vi.fn(), + onMenuCheckUpdates: cb => { menuCheck = cb; return vi.fn() }, + onUpdateProgress: cb => { progress = cb; return vi.fn() }, + getAppVersion: vi.fn().mockResolvedValue('0.3.6'), + getUpdateSettings: vi.fn(async () => ({ ...settings })), + setUpdateSettings: vi.fn(async patch => Object.assign(settings, patch) && { ...settings }), + checkForUpdates: vi.fn().mockResolvedValue({ currentVersion: '0.3.6', update: { ...UPDATE } }), + installUpdate: vi.fn(), cancelUpdate: vi.fn(), openReleasePage: vi.fn(), openRepo: vi.fn(), + } + window.electronAPI = api +}) +afterEach(() => { delete window.electronAPI }) + +const openAbout = () => fireEvent.click(screen.getByRole('button', { name: 'About' })) + +test('About links to the repository through the main process', async () => { + render() + openAbout() + const link = await screen.findByRole('link', { name: /github\.com\/overwrite00\/MetaLens/ }) + expect(link.getAttribute('href')).toBe('https://github.com/overwrite00/MetaLens') + fireEvent.click(link) + expect(api.openRepo).toHaveBeenCalledOnce() +}) + +test('nothing is checked at startup unless the user opted in', async () => { + render() + await waitFor(() => expect(api.getUpdateSettings).toHaveBeenCalled()) + expect(api.checkForUpdates).not.toHaveBeenCalled() + expect(screen.queryByText(/Update v/)).toBeNull() +}) + +test('opted-in startup check shows a toolbar notice unless the version is ignored', async () => { + api.getUpdateSettings.mockResolvedValue({ autoCheckUpdates: true, ignoredVersion: null }) + const { unmount } = render() + await screen.findByText('Update v0.4.0') + expect(api.checkForUpdates).toHaveBeenCalledWith({ auto: true }) + unmount() + + api.checkForUpdates.mockResolvedValue({ update: { ...UPDATE, ignored: true } }) + render() + await waitFor(() => expect(api.checkForUpdates).toHaveBeenCalledTimes(2)) + expect(screen.queryByText(/Update v/)).toBeNull() +}) + +test('a failed startup check stays silent', async () => { + api.getUpdateSettings.mockResolvedValue({ autoCheckUpdates: true, ignoredVersion: null }) + api.checkForUpdates.mockResolvedValue({ error: 'offline' }) + render() + await waitFor(() => expect(api.checkForUpdates).toHaveBeenCalled()) + openAbout() + await screen.findByRole('dialog') + expect(screen.queryByRole('alert')).toBeNull() +}) + +test('manual check reports an available beta with its notes and a failed check as an error', async () => { + api.checkForUpdates.mockResolvedValueOnce({ update: { ...UPDATE, channel: 'beta', version: '0.4.0-beta.1' } }) + .mockResolvedValueOnce({ error: 'GitHub rate limit reached, try again later' }) + .mockResolvedValueOnce({ update: null }) + render() + openAbout() + fireEvent.click(await screen.findByRole('button', { name: /Check for updates/ })) + await screen.findByText('v0.4.0-beta.1') + expect(screen.getByText(/New beta available/)).toBeTruthy() + expect(screen.getByText('Fixed things')).toBeTruthy() + expect(api.checkForUpdates).toHaveBeenCalledWith({ auto: false }) + + fireEvent.click(screen.getByRole('button', { name: /Check for updates/ })) + expect((await screen.findByRole('alert')).textContent).toMatch(/rate limit/) + + fireEvent.click(screen.getByRole('button', { name: /Check for updates/ })) + await screen.findByText(/latest version/) + expect(screen.queryByRole('alert')).toBeNull() +}) + +test('Help menu check opens About and runs the check', async () => { + render() + await act(async () => menuCheck()) + await screen.findByText('v0.4.0') + expect(screen.getByRole('dialog')).toBeTruthy() +}) + +test('ignoring a version and the auto-check option are persisted through the main process', async () => { + render() + openAbout() + fireEvent.click(await screen.findByRole('button', { name: /Check for updates/ })) + fireEvent.click(await screen.findByRole('button', { name: 'Ignore this version' })) + await screen.findByText('(ignored)') + expect(api.setUpdateSettings).toHaveBeenCalledWith({ ignoredVersion: '0.4.0' }) + expect(screen.queryByText('Update v0.4.0')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Stop ignoring' })) + await waitFor(() => expect(api.setUpdateSettings).toHaveBeenLastCalledWith({ ignoredVersion: null })) + + const box = screen.getByRole('checkbox') + expect(box.checked).toBe(false) + fireEvent.click(box) + await waitFor(() => expect(box.checked).toBe(true)) + expect(api.setUpdateSettings).toHaveBeenLastCalledWith({ autoCheckUpdates: true }) +}) + +test('install shows download progress, can be cancelled, and reports failures', async () => { + let finish + api.installUpdate.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + render() + openAbout() + fireEvent.click(await screen.findByRole('button', { name: /Check for updates/ })) + fireEvent.click(await screen.findByRole('button', { name: /Download & install/ })) + await screen.findByText(/Starting download/) + act(() => progress({ received: 50, total: 200 })) + await screen.findByText('Downloading… 25%') + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(api.cancelUpdate).toHaveBeenCalledOnce() + await act(async () => finish({ error: 'Download cancelled' })) + await screen.findByRole('button', { name: /Download & install/ }) + expect(screen.queryByRole('alert')).toBeNull() + + api.installUpdate.mockResolvedValueOnce({ error: 'Checksum mismatch: the download was discarded' }) + fireEvent.click(screen.getByRole('button', { name: /Download & install/ })) + expect((await screen.findByRole('alert')).textContent).toMatch(/Checksum mismatch/) + expect(screen.getByRole('button', { name: /Download & install/ })).toBeTruthy() +}) + +test('builds that cannot install only offer the release page', async () => { + api.checkForUpdates.mockResolvedValue({ update: { ...UPDATE, canInstall: false } }) + render() + openAbout() + fireEvent.click(await screen.findByRole('button', { name: /Check for updates/ })) + fireEvent.click(await screen.findByRole('button', { name: /Release page/ })) + expect(api.openReleasePage).toHaveBeenCalledOnce() + expect(screen.queryByRole('button', { name: /Download & install/ })).toBeNull() +}) + +test('Escape closes About', async () => { + render() + openAbout() + await screen.findByRole('dialog') + fireEvent.keyDown(window, { key: 'Escape' }) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) +}) + +test('without Electron the update section is hidden and nothing throws', async () => { + delete window.electronAPI + render() + openAbout() + await screen.findByText('v?') + expect(screen.queryByText('Updates')).toBeNull() + expect(screen.getByRole('link', { name: /github\.com/ })).toBeTruthy() +}) diff --git a/python/config.py b/python/config.py index fc68820..e502896 100644 --- a/python/config.py +++ b/python/config.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -VERSION = "0.3.6" +VERSION = "0.4.0" APP_NAME = "MetaLens" SIDECAR_HOST = "127.0.0.1" From c07e54120a6b8fd026661bc714dbc9da7d9bd56a Mon Sep 17 00:00:00 2001 From: 0verwrite <31691645+overwrite00@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:45:46 +0100 Subject: [PATCH 10/29] feat: typed metadata model, exact server-side Undo, IPTC/XMP read-write and sync - Typed canonical values, stable keys and an editable/deletable contract per field, with idempotent writes, round-trip and read-after-write verification (422 lossy_write) - Single verified atomic transaction per /write, /delete and /undo; identical saves leave the file untouched - POST /undo with an in-memory journal; byte-identical restore through a byte patch, falling back to the stored values; chains survive saves that move the mtime - IPTC/XMP read and write for JPEG with a segment writer, IPTC <-> XMP twins kept in step in the same transaction, digest refreshed only when every pair agrees; a mirrored delete keeps a twin that holds other language alternatives - Own codecs for EXIF, MP4 and ID3/Vorbis/ASF tags, PDF XMP and Office packages; hachoir as read-only fallback with an explicit read-only reason - Uniform error body, 503 file_busy for concurrent reads, Windows fixes (hidden files, DACL, pre-1970 mtime) - Typed editor with field addition, token-based Undo and notices in the renderer; integers beyond 2^53 handled exactly - Runtime and dev requirements split; test-python-windows CI job - Docs and changelog aligned with the real behaviour --- .github/workflows/build.yml | 47 +- CHANGELOG.md | 184 +++ CONTRIBUTING.md | 31 +- README.md | 63 +- SECURITY.md | 17 +- docs/API.md | 303 +++- docs/INSTALLATION.md | 6 +- docs/REQUIREMENTS.md | 4 +- docs/USAGE.md | 28 +- electron/main.js | 45 +- electron/menu.js | 54 + electron/test/menu.test.js | 48 + electron/test/preload-client.test.js | 2 +- frontend/src/App.jsx | 70 +- frontend/src/api/client.js | 78 +- frontend/src/components/DetailPanel.jsx | 6 +- frontend/src/components/DiffView.jsx | 46 +- frontend/src/components/FieldInput.jsx | 117 ++ frontend/src/components/MetadataEditor.jsx | 307 +++- frontend/src/components/MetadataTable.jsx | 82 +- frontend/src/components/RecordNotices.jsx | 63 + frontend/src/hooks/useUndoStack.js | 38 +- frontend/src/lib/formatValue.js | 106 ++ frontend/src/lib/keyboard.js | 24 + frontend/src/lib/serializeValue.js | 200 +++ frontend/test/app-undo.test.jsx | 142 +- frontend/test/correctness.test.jsx | 182 ++- frontend/test/editor.test.jsx | 315 ++++ frontend/test/fake-sidecar.js | 92 ++ frontend/test/values-render.test.jsx | 192 +++ python/SECURITY.md | 73 +- python/api/byte_patch.py | 155 ++ python/api/routes.py | 1316 ++++++++++++++-- python/api/undo_journal.py | 195 +++ python/core/PATH_SECURITY_USAGE.md | 51 +- python/core/base_handler.py | 117 ++ python/core/errors.py | 127 ++ python/core/exif_codec.py | 676 +++++++++ python/core/handlers/_mp4_codec.py | 257 ++++ python/core/handlers/_pdf_xmp.py | 265 ++++ python/core/handlers/_tag_codecs.py | 1345 +++++++++++++++++ python/core/handlers/audio_handler.py | 257 ++-- python/core/handlers/filesystem_handler.py | 601 ++++++-- python/core/handlers/hachoir_handler.py | 189 ++- python/core/handlers/image_handler.py | 1566 +++++++++++++++++--- python/core/handlers/office_handler.py | 411 ++++- python/core/handlers/ole_handler.py | 443 +++++- python/core/handlers/pdf_handler.py | 605 +++++++- python/core/handlers/video_handler.py | 113 +- python/core/iptc.py | 210 +++ python/core/jpeg_segments.py | 423 ++++++ python/core/models.py | 117 +- python/core/path_security.py | 586 +++++++- python/core/registry.py | 45 +- python/core/values.py | 370 +++++ python/core/xmp.py | 606 ++++++++ python/main.py | 16 +- python/requirements-dev.txt | 17 + python/requirements.txt | 18 +- python/tests/factories/__init__.py | 151 ++ python/tests/factories/__main__.py | 35 + python/tests/factories/audio.py | 364 +++++ python/tests/factories/fs.py | 142 ++ python/tests/factories/hachoir.py | 132 ++ python/tests/factories/image.py | 425 ++++++ python/tests/factories/mp4.py | 92 ++ python/tests/factories/office.py | 180 +++ python/tests/factories/ole.py | 276 ++++ python/tests/factories/pdf.py | 159 ++ python/tests/test_api_contract.py | 991 +++++++++++++ python/tests/test_atomic_windows.py | 607 ++++++++ python/tests/test_audio_handler.py | 945 ++++++++++++ python/tests/test_contract.py | 891 +++++++++++ python/tests/test_filesystem_handler.py | 510 +++++++ python/tests/test_hachoir_handler.py | 238 +++ python/tests/test_handlers.py | 10 +- python/tests/test_image_handler.py | 1509 +++++++++++++++++++ python/tests/test_integrity_regressions.py | 3 +- python/tests/test_linked_fields.py | 138 ++ python/tests/test_models.py | 176 +++ python/tests/test_mp4.py | 268 ++++ python/tests/test_office_handler.py | 255 ++++ python/tests/test_ole_handler.py | 186 +++ python/tests/test_pdf_handler.py | 580 ++++++++ python/tests/test_registry.py | 103 +- python/tests/test_undo.py | 907 ++++++++++++ python/tests/test_values.py | 301 ++++ 87 files changed, 23318 insertions(+), 1318 deletions(-) create mode 100644 electron/menu.js create mode 100644 electron/test/menu.test.js create mode 100644 frontend/src/components/FieldInput.jsx create mode 100644 frontend/src/components/RecordNotices.jsx create mode 100644 frontend/src/lib/formatValue.js create mode 100644 frontend/src/lib/keyboard.js create mode 100644 frontend/src/lib/serializeValue.js create mode 100644 frontend/test/editor.test.jsx create mode 100644 frontend/test/fake-sidecar.js create mode 100644 frontend/test/values-render.test.jsx create mode 100644 python/api/byte_patch.py create mode 100644 python/api/undo_journal.py create mode 100644 python/core/errors.py create mode 100644 python/core/exif_codec.py create mode 100644 python/core/handlers/_mp4_codec.py create mode 100644 python/core/handlers/_pdf_xmp.py create mode 100644 python/core/handlers/_tag_codecs.py create mode 100644 python/core/iptc.py create mode 100644 python/core/jpeg_segments.py create mode 100644 python/core/values.py create mode 100644 python/core/xmp.py create mode 100644 python/requirements-dev.txt create mode 100644 python/tests/factories/__init__.py create mode 100644 python/tests/factories/__main__.py create mode 100644 python/tests/factories/audio.py create mode 100644 python/tests/factories/fs.py create mode 100644 python/tests/factories/hachoir.py create mode 100644 python/tests/factories/image.py create mode 100644 python/tests/factories/mp4.py create mode 100644 python/tests/factories/office.py create mode 100644 python/tests/factories/ole.py create mode 100644 python/tests/factories/pdf.py create mode 100644 python/tests/test_api_contract.py create mode 100644 python/tests/test_atomic_windows.py create mode 100644 python/tests/test_audio_handler.py create mode 100644 python/tests/test_contract.py create mode 100644 python/tests/test_filesystem_handler.py create mode 100644 python/tests/test_hachoir_handler.py create mode 100644 python/tests/test_image_handler.py create mode 100644 python/tests/test_linked_fields.py create mode 100644 python/tests/test_models.py create mode 100644 python/tests/test_mp4.py create mode 100644 python/tests/test_office_handler.py create mode 100644 python/tests/test_ole_handler.py create mode 100644 python/tests/test_pdf_handler.py create mode 100644 python/tests/test_undo.py create mode 100644 python/tests/test_values.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d2bf69..322a606 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,14 +44,34 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: pip - cache-dependency-path: python/requirements.txt + cache-dependency-path: python/requirements*.txt - name: Install and run tests run: | cd python - pip install -r requirements.txt -q - pip install pytest pytest-asyncio httpx -q + pip install -r requirements-dev.txt -q pytest tests/ -v + # Same suite on Windows: atomic replace (ReplaceFileW), file attributes, DACL/ADS and + # pre-1970 timestamps are Windows-specific. Not a dependency of the beta/stable jobs. + test-python-windows: + if: github.ref == 'refs/heads/develop' || (github.event_name == 'pull_request' && github.base_ref == 'develop') + runs-on: windows-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: python/requirements*.txt + - name: Install dependencies + working-directory: python + run: pip install -r requirements-dev.txt -q + - name: Run tests + working-directory: python + run: python -m pytest tests/ -v + test-frontend: if: github.ref == 'refs/heads/develop' || (github.event_name == 'pull_request' && github.base_ref == 'develop') runs-on: ubuntu-latest @@ -86,7 +106,7 @@ jobs: with: python-version: '3.13' cache: pip - cache-dependency-path: python/requirements.txt + cache-dependency-path: python/requirements*.txt - name: Install Python run: | cd python @@ -100,9 +120,6 @@ jobs: --hidden-import mutagen \ --hidden-import hachoir \ --hidden-import olefile \ - --hidden-import openpyxl \ - --hidden-import docx \ - --hidden-import pptx \ --hidden-import pypdf - uses: actions/setup-node@v7 with: @@ -168,7 +185,7 @@ jobs: with: python-version: '3.13' cache: pip - cache-dependency-path: python/requirements.txt + cache-dependency-path: python/requirements*.txt - name: Build Python sidecar shell: pwsh run: | @@ -180,9 +197,6 @@ jobs: --hidden-import mutagen ` --hidden-import hachoir ` --hidden-import olefile ` - --hidden-import openpyxl ` - --hidden-import docx ` - --hidden-import pptx ` --hidden-import pypdf - uses: actions/setup-node@v7 with: @@ -255,9 +269,6 @@ jobs: --hidden-import mutagen \ --hidden-import hachoir \ --hidden-import olefile \ - --hidden-import openpyxl \ - --hidden-import docx \ - --hidden-import pptx \ --hidden-import pypdf - name: Upload Linux sidecar uses: actions/upload-artifact@v7 @@ -512,7 +523,7 @@ jobs: with: python-version: '3.13' cache: pip - cache-dependency-path: python/requirements.txt + cache-dependency-path: python/requirements*.txt - name: Build Python sidecar shell: pwsh run: | @@ -524,9 +535,6 @@ jobs: --hidden-import mutagen ` --hidden-import hachoir ` --hidden-import olefile ` - --hidden-import openpyxl ` - --hidden-import docx ` - --hidden-import pptx ` --hidden-import pypdf - uses: actions/setup-node@v7 with: @@ -609,9 +617,6 @@ jobs: --hidden-import mutagen \ --hidden-import hachoir \ --hidden-import olefile \ - --hidden-import openpyxl \ - --hidden-import docx \ - --hidden-import pptx \ --hidden-import pypdf - name: Upload Linux sidecar uses: actions/upload-artifact@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ff9a7..cfc7bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — - [ ] **Batch Edit** — Apply a field change to multiple selected files at once - [ ] **Search/Filter Bar** — Filter the file list by name or extension +### Security +- Deleting GPS (or any EXIF field) from a JPEG that contained a second, duplicated APP1 Exif segment + left the old copy, GPS coordinates included, in the file. EXIF is now rewritten in place in the first + Exif segment and duplicates are removed (with a warning), so no byte of the deleted data remains. +- Digitally signed PDFs (`/SigFlags`, a signed `/FT /Sig` field, `/Perms`) were rewritten silently, + invalidating the signature. They are now read-only, with the reason shown, like encrypted PDFs and + signed Office packages. + ### Added - **Update check.** *Help → Check for Updates…* and *About → Check for updates* look up the latest release on GitHub. A stable build is only offered newer stable releases; a beta build is also @@ -28,14 +36,128 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — All requests are made by the Electron main process, which only talks to the `overwrite00/MetaLens` releases and never accepts URLs from the renderer. - The About dialog links to the project repository on GitHub, and can be closed with Esc. +- **XMP and IPTC for JPEG**, read and written: XMP (`xmp::`, e.g. `xmp:dc:title`, + `xmp:dc:subject`, `xmp:photoshop:City`; language alternatives and Bag/Seq lists) and IPTC-IIM in the + Photoshop APP13 segment (`iptc:Keywords`, `iptc:By-line`, `iptc:Caption-Abstract`, ...; repeatable + datasets as lists, written as UTF-8 with the `ESC % G` marker). Everything not edited (other XMP + properties, other 8BIM resources and IIM datasets) is preserved. Extended XMP and legacy IPTC + character sets are read-only. XMP is also read from WebP. +- **IPTC and XMP stay in step**: editing or deleting an IPTC dataset also updates its XMP twin already in the file + (and the other way round) in the same verified transaction, with one Undo; nothing is created on the other + side and a value the twin cannot store is skipped with a note in `warnings`. +- **Byte-identical Undo**: the journal also keeps the original bytes of the region a save changed, so undoing a + save gives back the very same file (FLAC padding, the MP4 `meta` atom, XMP/`core.xml` order included); if the + region was changed by a later save, the Undo falls back to restoring the values. +- **Undo chains**: a save that set the modification time no longer makes its Undo fail after later saves (any + rewrite moves the mtime; only a change by another program is still refused with 409); saving a tag value + that is already stored leaves MP3/FLAC/WAV/OGG/M4A/WMA files untouched; an I/O error during the byte patch + falls back to restoring the values instead of leaving a damaged file. +- **Safer sync and verification**: the IPTC digest is refreshed only when every IPTC/XMP pair in the file agrees; + read-only fields that are not derived from the file are now checked after a save too; an XMP twin that does not + fit its segment is skipped with a note instead of failing the save; a byte patch no longer pushes other Undo + entries out of the journal. +- **Safer mirrored deletes**: deleting an IPTC dataset no longer removes the XMP twin when it holds other + language alternatives (e.g. a German title next to the default one): the twin is kept and the response says so. + Files read by the generic reader (unknown types) now explain why their format metadata is read-only. + A file that changes while the Undo patch is prepared falls back to the value-level Undo instead of failing. +- **Exact Undo on the server**: `/write` and `/delete` return an `undo_token`; the new `POST /undo` + restores the original bytes of every touched field (pictures, cover art, MakerNote and multi-valued + fields included) in one verified transaction. It is refused (409) if the file or the fields changed + meanwhile, and answers 410 for unknown or evicted tokens. The journal is in memory only (50 entries, + 64 MiB) and is lost when the app closes. The file's modification time is not restored implicitly. +- `/write` accepts an optional `delete` list, so edits and deletions are one atomic save. +- **Adding fields** in the editor (*Add field*) from the server's `addable_fields`: e.g. EXIF/XMP/IPTC + fields on a JPEG, ID3 tags on untagged MP3/WAV/AIFF, Vorbis comments on untagged FLAC/Ogg, a title on + an MP4/MOV without tags, PDF and Office core properties. +- **Typed editor**: numbers, checkboxes, dropdowns, exact rationals, list editor for multi-valued + fields, fixed-size pairs (track/disc), date-time with UTC offset; binary fields can only be deleted. + Notices show why the format is read-only, read errors and warnings. +- Records now expose `format_supports_write`, `format_supports_delete`, `read_only_reason`, + `warnings` and `addable_fields`; fields expose `multi`, `length`, `binary` and always `choices`. +- New read-only metadata: Office `docProps/app.xml` (`office:app.*`) and `docProps/custom.xml` + (`office:custom.*`); OLE `DocumentSummaryInformation` (company, manager, category, ...) and + user-defined properties; PNG `eXIf` and WebP EXIF; GIF comment; `pdf:signed`. +- The PDF XMP packet is kept in sync with the edited document-info entries (title, author, subject, + keywords, creator tool, producer, dates); the rest of the packet is preserved. +- Unknown extensions and files without extension are read by hachoir from their content (e.g. a WAV + renamed `.bin` or a JPEG without extension). ### Changed +- Test-only packages (pytest, python-docx, openpyxl, python-pptx, pywin32) moved to `python/requirements-dev.txt`; + `python/requirements.txt` is the sidecar's runtime set. The sidecar build no longer bundles python-docx, openpyxl + or python-pptx (it reads Office packages with `zipfile`/`ElementTree`; verified by running the sidecar in an + environment with only the runtime requirements). +- `file_busy` and `file_not_found` are `ErrorCode` constants like the other error codes. +- `/write` and `/delete` responses carry `undo_unavailable`; `/diff` ignores `fs:atime` and `fs:ctime`. - Version bumped to 0.4.0 (new feature). The fixes below, previously tracked as 0.3.6 betas, ship in 0.4.0; there is no 0.3.6 stable release. - Privacy statements (README, SECURITY, requirements) now say "offline by default" instead of "no network calls": the update check is the only network access and is user-initiated or opt-in. +- **API error body**: every error, authentication included, is now + `{"ok": false, "errors": [{"code", "key", "message"}], "detail": ""}` with stable codes; + all validation problems of a request are reported together, one per key, before the file is touched. + A value-related library error is a 422, never a 500. +- A file that cannot be written (read-only attribute, permissions) now returns **403** `not_writable` + instead of 409; 409 is reserved for concurrent modification, 410 for expired Undo tokens. +- `/hash` with no algorithm (`algorithms=`) returns 400. +- `/diff` compares the same record as `/read`, filesystem layer included, and binary values by SHA-256. +- **Values are typed and canonical** (`python/core/values.py`): multi-valued tags are JSON lists + instead of strings joined with `; `, numbers are numbers, booleans are booleans, rationals keep their + exact `n/d` form, dates are ISO 8601 (`datetime` with UTC offset, `localdatetime` for EXIF), binaries + are `{size, sha256, mime}` descriptors. `img:dpi` is a `[x, y]` number pair (was `"72 x 72"`), + `fs:win_attrs` a list of flags, hachoir values typed (durations in seconds, dates as ISO). +- **Changed keys** (keys are not stored anywhere, but scripts using the API must adapt): `.m4a`/`.m4b` + tags use `mp4:` like MP4/MOV (were `tag:`); hachoir fields are + `hachoir::` (were derived from the label, and could collide); the MP4 bitrate + is `audio:bitrate` of the first audio track (`video:bitrate` removed); ID3 URL frames are grouped by + FrameID (`tag:WOAR`), PRIV frames keyed by owner, never by value. +- The filesystem handler is now a layer added to every record, no longer a primary handler: `/list` + reports `hachoir` for files with no dedicated handler. +- Only `fs:mtime` is writable at the filesystem level (now with UTC offset required, exact to the + microsecond and verified); extended attributes are writable only in the Linux `user.` namespace with + UTF-8 values (non-UTF-8 values are binary, other namespaces and macOS read-only). +- Every save runs in one transaction: validation, changes on a private copy, read-back verification + (a library that drops or alters anything makes the save fail with 422 and leaves the file intact), + then replace. The file was previously copied twice for API writes. +- Office dates without UTC offset are refused (422) instead of being treated as UTC. ### Fixed +- **A save that changes nothing no longer replaces the file.** Writing the values a file already has used to swap in + an identical copy, which detached hard links and changed the file id. The transaction is now discarded instead. +- **Editing IPTC no longer makes other apps prefer a stale XMP.** The Photoshop IPTC digest (0x0425) tells MWG-aware + readers that the XMP is in sync with the IPTC block and wins over it. It is now refreshed only when the XMP is + written in the same save (or none exists); otherwise the old digest stays and the edited IPTC takes precedence. +- **Undo no longer reports a false conflict** when another program inserted or removed a neighbouring XMP property or + IPTC dataset after the save: the position of a value is a restore hint, not part of what is compared. +- **Integers beyond 2^53** (MP4 `plID`, ASF QWORD) are shown exactly and can be edited: the renderer keeps them as + decimal strings and the sidecar parses them exactly. +- A saved change's server warnings (e.g. a file owner that could not be restored) are shown in the editor and after + an Undo; a transient `503 file_busy` during Undo keeps the Undo entry so it can be retried. +- An internal `TypeError` inside the tag codecs is reported as 500 instead of a validation error. +- **Windows: writes no longer drop the folder's inherited permissions.** Every save used to leave the file with + only the explicit ACEs plus SYSTEM/Administrators/owner, so other accounts (Users, a media server's account) lost + access. The effective DACL (inheritance and protection flags included) is now identical before and after. +- **Windows: the Archive attribute is set after a modification**, as for any other write (backup tools relying on + it see the change). +- **Windows: reads and writes that overlap another program's access no longer fail with 500.** Transient sharing + violations are retried briefly; a file that stays locked answers 503 `file_busy` (404 if it vanished). +- **Multi-picture JPEG (MPO/MPF) files stay valid** when EXIF, XMP or IPTC are edited: the MP index offsets are + recomputed, and edits that cannot be done safely leave the file read-only instead of corrupting the second image. +- **Malformed EXIF can no longer make `/read` or `/diff` fail with 500.** A broken IFD1 thumbnail pointer (count 0, + wrong or unknown type), also inside PNG eXIf chunks, now yields a read-only EXIF with a warning. +- JPEGs with stray bytes between segments are read leniently (EXIF/XMP/IPTC visible, writing disabled) instead of + showing no EXIF at all. +- **Saving an EXIF field without changing it no longer rewrites the file** (no MakerNote shift, no mtime change, no + undo entry), and Undo of an EXIF edit restores the original block layout, so a MakerNote with absolute offsets + resolves again. +- Undo of an IPTC edit removes the UTF-8 marker added by the write; XMP edits reuse the packet padding instead of + failing near 64 KB; an empty `dc:subject` bag can be filled. +- Undo of a deleted Vorbis/ASF entry puts it back at its original position; Undo of a tag added to a WAV/AIFF file + without tags removes the empty ID3 chunk; ID3 timestamps are validated against real calendar ranges. +- A saved change that exceeds the Undo budget (e.g. deleting a 70 MB cover) is reported by the server + (`undo_unavailable`) and shown in the editor instead of looking like a plain "Saved". +- `fs:mtime` is read-only on files with the READONLY attribute (the write was refused anyway); PDFs with very large + streams answer 422 instead of 500; internal `TypeError`s are no longer reported as validation errors. - Closing the app no longer throws in the menu-listener cleanup if `window.electronAPI` is gone. - Documentation said the About dialog reads the version from the sidecar's `/health`; it shows the packaged app version (`app.getVersion()`, including the beta suffix). Corrected in the guides. @@ -44,8 +166,61 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — selection), and reloads the file list of the selected folder, keeping the still-existing file selection. If the selected folder was deleted or renamed, the selection moves to the nearest existing parent folder; folders that are listed but temporarily unreadable keep their state. +- JPEG photos straight from a camera (EXIF resolution stored as rationals) could not be edited at all: + reading the DPI failed and blocked every write, delete and Undo (also for any JPEG after its first + MetaLens edit). TIFF/JPEG DPI is now read correctly. +- Editing EXIF no longer drops the JFIF APP0 segment; only the touched segment changes, every other + segment and the image data stay byte-identical. Unknown EXIF tags survive a rewrite. +- EXIF rationals and pairs were altered on save (`1234/100` became `617/50`, `0/0` became `0/1`, + SubjectArea shown as text); they now round-trip exactly. Invalid EXIF dates (`garbage`) are refused, + oversized text gets a 422 instead of a broken segment, Latin-1 EXIF text is shown without U+FFFD and + restored byte for byte, UserComment and XP tags are decoded as text, `UNDEFINED` values such as the + MakerNote are shown as binary (read-only, deletable). +- Multi-valued audio tags (several artists or genres in ID3 or Vorbis comments) were joined into one + string and saved back as a single value; they are lists now. +- ID3 lyrics (`USLT`) showed only the first character; the whole text is shown and edited. +- Binary and structured frames (APIC, GEOB, PRIV, POPM, PCNT, SYLT, WM/Picture, APE binaries, FLAC + pictures) were shown as editable text and an Undo wrote a string in place of the picture; they are + read-only descriptors, deletable and restored exactly. ASF integers and booleans keep their type. +- Text that does not fit an ID3 frame's Latin-1 encoding caused a 500; the frame is upgraded to UTF-8 + (v2.4) or UTF-16 (v2.3). Saving an ID3v2.3 file no longer converts it to v2.4. +- AAC files claimed to be writable and failed on save; they are read-only. +- MP4: track/disc pairs (`trkn`, `disk`), BPM (`tmpo`), compilation (`cpil` stayed true when set to + false), cover art (`covr`) and freeform atoms (UTF-8 values editable, others binary) are handled with + their real types. +- Legacy Office (OLE) text is decoded with the file's codepage (UTF-16 and UTF-8 included), total + editing time is a duration instead of a bogus date, the thumbnail is binary, malformed properties no + longer hide the others. +- hachoir fields with the same label (e.g. the duration of two streams) overwrote each other. +- Filesystem timestamps before 1970 failed on Windows and hid every filesystem field; each field is + now read on its own and a failure becomes a warning. +- Hidden (or system) files on Windows could not be edited (the temporary copy inherited the attribute); they are + written and stay hidden. +- PDF: `null` or invalid dates are refused, `/Trapped` keeps its name type instead of becoming text, + `pdf:encrypted` has the `bool` type. +- **Undo** writes back the exact original bytes instead of re-sending the displayed values (which lost + binaries, multi-valued fields and legacy encodings); deleting several fields and undoing restores all + of them. A failing Undo entry no longer blocks the stack: entries that can no longer be undone (file + changed: 409, expired: 410) are removed with a message, other errors can be retried. +- **Ctrl+Z** inside a text field undoes the typing instead of undoing the last save of the file. +- Error messages in the UI no longer show `[object Object]`; lists and binary values are shown the + same way in the table, the diff and the CSV export, and two different binaries of the same size are + no longer reported as identical. + +### Tests +- Synthetic samples generated at runtime for every supported format (`python/tests/factories/`, 57 + samples, no binaries in the repository) and a generic contract test run on each of them (unique + keys, coherent types, idempotent writes, round-trip of new values, exact Undo of edits, deletions + and bulk deletions, read-only fields refused, error body shape). +- New suites for values, models, API contract, Undo, Windows atomic writes, and each handler + (image/XMP/IPTC/segment writer, audio codecs, MP4, PDF, Office, OLE, hachoir, filesystem); React + tests for value rendering, the typed editor and token-based Undo. +- Python suite: 1364 passed, 14 skipped on Windows (platform-specific tests are skipped on the + other platforms). ### CI/CD +- Test jobs install `python/requirements-dev.txt`; the PyInstaller build jobs install only the runtime + `python/requirements.txt` and no longer pass `--hidden-import` for python-docx, openpyxl and python-pptx. - Prepare the release pipeline for GitHub moving `ubuntu-latest` to Ubuntu 26.04 (rollout 2026-10-19 → 2026-11-19). The PyInstaller sidecar embeds the glibc of the build machine: built on `ubuntu-24.04` it needs glibc 2.38 and did not start on Debian 12 or Ubuntu 22.04. @@ -57,11 +232,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — `ubuntu-24.04`. - Linux asset names: `metalens__amd64.deb`, `metalens--1.x86_64.rpm`, `MetaLens--linux-x64.tar.gz`. +- The Python test suite also runs on `windows-latest` (job `test-python-windows`), where the atomic + replace, file attributes, NTFS streams/DACLs and pre-1970 timestamps behave differently. It does not + gate the beta and stable packaging jobs. ### Documentation - New *Updates* section in the usage guide; privacy and security pages describe the update check. - Installation and requirements guides describe the Linux packages, the glibc 2.34 baseline and the glibc troubleshooting case; `CONTRIBUTING.md` documents how the Linux jobs are split. +- The README, usage guide, API reference and security pages describe what the application actually + does: formats and fields that are really writable, the error body and status codes, `/undo`, the + value model and a table of key namespaces; claims that were not true were removed (e.g. "blocks + traversal", libraries used only by tests, `/health` without `app`, the Windows installer name, + Node.js 20). `CONTRIBUTING.md` explains samples and the contract test for new handlers and no longer + suggests `pip freeze > requirements.txt`. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a4d7f2..738719f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ cd python python -m venv .venv source .venv/bin/activate # Linux # .venv\Scripts\activate # Windows -pip install -r requirements.txt +pip install -r requirements-dev.txt # runtime + test dependencies (requirements.txt = runtime only) # Electron + frontend cd ../electron && npm install @@ -81,7 +81,9 @@ git config core.hooksPath .githooks ### Python - Follow the handler pattern in `python/core/handlers/` — one file per category, implementing `BaseMetadataHandler` -- Register new handlers in `python/core/registry.py` +- Register new handlers in `python/core/handlers/__init__.py` (`HandlerRegistry.register(...)`); `PRIORITY` decides which handler wins (hachoir, `PRIORITY = 100`, is the catch-all) +- Follow the value model in `python/core/values.py`: canonical JSON values, lists for multi-valued fields (never joined strings), binaries as read-only descriptors, stable keys `:` that never depend on the value or label +- A handler that marks fields `editable`/`deletable` must implement `apply_write`/`apply_delete` and the `snapshot`/`restore` pair used by the exact Undo, and must not open its own transaction inside them - Add type hints on public functions ### Frontend @@ -113,20 +115,19 @@ type(scope): description ### Running Tests -All Python changes must pass the test suite: +Run the suites affected by your change (CI runs all of them; the Python suite on Linux and Windows): ```bash -pytest python/tests/ -v +pytest python/tests/ -v # sidecar +cd frontend && npm test && npm run build && npm run verify-icons # renderer +cd electron && npm test # Electron main process ``` ### Test Requirements - **New handlers or routes must include tests** in `python/tests/` -- **All tests must pass**: no regressions allowed -- After frontend changes, verify the production build still compiles: - ```bash - cd frontend && npm run build - ``` +- **New formats need synthetic samples**: register a builder in the matching module of `python/tests/factories/` (files are generated at runtime, no binaries in the repository). `tests/test_contract.py` then runs the generic contract on it: unique JSON-serializable keys, coherent types, idempotent writes, round-trip of new values, exact Undo of edits and deletions, read-only fields refused. Inspect samples with `python -m tests.factories --out ` (from `python/`) +- **All tests must pass**: no regressions allowed. If a test encodes old, wrong behavior, update it with an assertion of equal strength and explain why --- @@ -150,6 +151,7 @@ Fixes #(issue number) or Relates to #(issue number) ## Testing - [ ] Added tests for new functionality - [ ] `pytest python/tests/ -v` passes locally +- [ ] `npm test` passes in `frontend/` and `electron/` (if touched) - [ ] Tested on Windows/Linux (if applicable) ## Checklist @@ -177,7 +179,7 @@ MetaLens uses `MAJOR.MINOR.PATCH`, with `python/config.py` → `VERSION` as the | **MINOR** | New feature, new handler, new panel | | **MAJOR** | Breaking change, milestone 1.0 | -If your change bumps the version, update all four locations: `python/config.py`, `CHANGELOG.md`, `electron/package.json`, `frontend/package.json`. +If your change bumps the version, update: `python/config.py`, `CHANGELOG.md`, `electron/package.json` + `electron/package-lock.json`, `frontend/package.json` + `frontend/package-lock.json` (the `"version"` fields; `npm install --package-lock-only` refreshes the lockfiles). ### Release notes come from the changelog @@ -204,7 +206,9 @@ To update manually: # Python pip list --outdated pip install --upgrade package_name -pip freeze > python/requirements.txt +# then edit the pinned version by hand: runtime packages in python/requirements.txt, +# test-only packages (pytest, python-docx, ...) in python/requirements-dev.txt +# (do not use `pip freeze`: it would add transitive and platform-specific packages) # Node (electron/ or frontend/) npm outdated @@ -215,7 +219,8 @@ Then test thoroughly before committing: ```bash pytest python/tests/ -v -cd frontend && npm run build +cd frontend && npm test && npm run build +cd ../electron && npm test ``` --- @@ -234,5 +239,5 @@ Thank you for contributing to MetaLens! Your efforts help make file metadata man --- -*Last updated: 2026-10-29* +*Last updated: 2026-11-02* *← [Code of Conduct](./CODE_OF_CONDUCT.md) | [Security →](./SECURITY.md)* diff --git a/README.md b/README.md index 39ed7d3..1c5314c 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,13 @@ File (image / audio / video / document / anything) │ ▼ ┌─────────────────────────────────────────────┐ -│ 🖼️ Images → EXIF, IPTC, XMP (+ RAW read) │ -│ 🎵 Audio → ID3, Vorbis comments, tags │ -│ 🎬 Video → container metadata │ -│ 📄 PDF → document info dictionary │ -│ 📊 Office → DOCX/XLSX/PPTX properties │ -│ 📁 Any file → filesystem timestamps, │ -│ permissions, xattr │ +│ 🖼️ Images → EXIF, XMP, IPTC (JPEG edit) │ +│ 🎵 Audio → ID3, Vorbis, ASF, APEv2, MP4 │ +│ 🎬 Video → MP4/MOV tags, other: read │ +│ 📄 PDF → document info + XMP │ +│ 📊 Office → OOXML core/app/custom, OLE │ +│ 📁 Any file → size, timestamps, attributes,│ +│ xattr; hachoir fallback │ └─────────────────────────────────────────────┘ │ ▼ @@ -67,10 +67,10 @@ File (image / audio / video / document / anything) ## ✨ Key Features - 🗂️ **File-manager UI** — folder tree + file list + metadata detail panel -- 🖼️ **All major formats** — images (JPEG/PNG/TIFF/WebP/RAW), audio (MP3/FLAC/OGG/M4A/WAV), video (MP4/MKV/MOV), documents (PDF/DOCX/XLSX/PPTX/DOC/XLS), and any other file via filesystem metadata -- ✏️ **Full operations** — read, edit, delete individual fields, compare/diff two files -- 🔒 **Atomic writes** — temp-file + rename, no file corruption on failure -- ↩️ **Undo stack** — 50 operations per session +- 🖼️ **All major formats** — images (JPEG/PNG/TIFF/WebP/GIF/RAW), audio (MP3/FLAC/OGG/Opus/M4A/WAV/AIFF/WMA/APE), video (MP4/MOV/MKV/AVI), documents (PDF/DOCX/XLSX/PPTX/DOC/XLS/PPT); any other file through the hachoir fallback and the filesystem layer +- ✏️ **Typed editing** — read, edit, add and delete individual fields with inputs matching their type (text, numbers, booleans, dates with UTC offset, exact rationals, lists); the sidecar validates every value and decides what is editable +- 🔒 **Verified atomic writes** — changes go to a private copy beside the file, are read back and checked, then replace the original (`ReplaceFileW` on Windows, `os.replace` elsewhere); a failure leaves the file untouched +- ↩️ **Exact Undo** — up to 50 saves per session, restored byte for byte in the usual case (pictures and other binaries included; very large changes fall back to restoring the values); refused if the file changed meanwhile - 🔑 **File integrity hashes** — on-demand MD5 / SHA-1 / SHA-256 / SHA-512 / BLAKE2b with one-click copy - 🎨 **Cyber dark theme** — professional dark UI with cyan/blue accents - 📦 **Compiled binaries** — no Python or Node.js required to run @@ -82,9 +82,9 @@ File (image / audio / video / document / anything) ## 🔧 Version -**v0.3.5** — Security and reliability release: every sidecar API call is authenticated with a per-session token, the Electron renderer is sandboxed with a Content Security Policy, PDF/Office edits preserve unrelated content, audio/video edits are atomic, and Undo restores original values. Dependencies updated (Pillow, pypdf, Vite, Electron). No API changes. +**v0.3.5** (latest stable) — Security and reliability release: every sidecar API call is authenticated with a per-session token, the Electron renderer is sandboxed with a Content Security Policy, PDF/Office edits preserve unrelated content, audio/video edits are atomic, and Undo restores original values. Dependencies updated (Pillow, pypdf, Vite, Electron). No API changes. -**Next (beta)** — Linux packages (`.deb`, `.rpm`, `.tar.gz`) now bundle a sidecar built on a RHEL 9 baseline, so one set runs on Ubuntu 22.04+, Debian 12+, Fedora and RHEL 9/10. +**Next: 0.4.0** (in development, not yet released) — update check and repository link in *About*; XMP and IPTC reading and writing for JPEG; typed values (lists, numbers, dates, exact rationals) instead of joined strings; adding fields; exact server-side Undo; uniform API errors; Linux packages built on a RHEL 9 baseline (Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9/10). 📖 **See full version history** → [CHANGELOG.md](./CHANGELOG.md) @@ -119,17 +119,18 @@ Explore the sidecar API in [API.md](docs/API.md). | Category | Formats | Read | Write | |---|---|---|---| -| Images | JPEG (supported EXIF fields) | ✅ | ✅ | -| Images | PNG, TIFF, BMP, GIF, WebP | ✅ | — | -| Images (RAW) | CR2, NEF, ARW, DNG | ✅ | — | -| Audio | MP3, FLAC, OGG, M4A, WAV, AIFF, WMA, APE, Opus | ✅ | ✅ | -| Video | MP4, MOV | ✅ | ✅ | -| Video | MKV, AVI | ✅ | — | -| Documents | PDF (unencrypted) | ✅ | ✅ | -| Office | DOCX, XLSX, PPTX, DOCM, XLSM, PPTM (unsigned) | ✅ | ✅ | -| Office (legacy) | DOC, XLS, PPT | ✅ | — | -| Any file | Filesystem timestamps, permissions, xattr | ✅ | ✅ | -| Other | Hundreds of formats via hachoir | ✅ | — | +| Images | JPEG — EXIF/GPS, XMP, IPTC-IIM | ✅ | ✅ | +| Images | TIFF, PNG (text chunks, `eXIf`), WebP (EXIF, XMP), GIF (comment), BMP, ICO | ✅ | — | +| Images (RAW) | CR2, NEF, ARW, DNG, ORF, RW2, PEF, SRW (EXIF read for TIFF-based RAW) | ✅ | — | +| Audio | MP3, WAV, AIFF, TTA (ID3) · FLAC, OGG, OGA, Opus, SPX (Vorbis comments) · WMA (ASF) · APE, WV, MPC (APEv2) · M4A (MP4 atoms) | ✅ | ✅ | +| Audio | AAC (ADTS/ADIF) | ✅ | — | +| Video | MP4, M4V, MOV, M4B, M4R (MP4 atoms) | ✅ | ✅ | +| Video | MKV, WebM, AVI, WMV, FLV, TS, MTS, M2TS (via hachoir) | ✅ | — | +| Documents | PDF — document info + XMP (not encrypted, not digitally signed) | ✅ | ✅ | +| Office | DOCX, XLSX, PPTX, DOCM, XLSM, PPTM — core properties (not digitally signed); `app.xml`/`custom.xml` read-only | ✅ | ✅ | +| Office (legacy) | DOC, XLS, PPT, DOT, XLT, POT (OLE property sets) | ✅ | — | +| Any file | Filesystem: `fs:mtime` writable; size, other timestamps, permissions/owner (POSIX), Windows attributes read-only; xattr (Linux: `user.` writable; macOS: read-only) | ✅ | ✅ (mtime, xattr) | +| Other | Many more formats recognized from their content via hachoir | ✅ | — | ### 🔑 File Integrity @@ -157,20 +158,24 @@ Compute cryptographic hashes on demand from the **Metadata** panel — no impact Electron Main (Node.js) ├── Spawns Python sidecar (FastAPI, localhost only) ├── Manages window, native menus, file dialogs - └── IPC bridge (contextBridge) ↔ React renderer + └── IPC bridge (contextBridge): dialogs, paths, menu events, and the + sidecar port + session token for the renderer + +React renderer ── HTTP + Bearer token ──▶ Python Sidecar (127.0.0.1) Python Sidecar (FastAPI) ├── /list — directory listing with handler detection - ├── /read — full metadata extraction - ├── /write — atomic metadata write-back + ├── /read — full metadata extraction (format + filesystem layer) + ├── /write — verified atomic write (and optional deletes) in one save ├── /delete — field removal + ├── /undo — exact undo of a save by its token ├── /diff — metadata comparison └── /hash — on-demand file hashing React Frontend (Vite + TailwindCSS) ├── FolderPanel — folder tree navigation ├── FilePanel — file list with metadata summary - └── DetailPanel — View / Edit / Diff tabs + └── DetailPanel — Metadata / Edit / Diff tabs ``` 📖 Full endpoint reference → [API.md](docs/API.md) @@ -241,5 +246,5 @@ Distributed with MIT License · [View License](LICENSE) --- -*Last updated: 2026-10-31* +*Last updated: 2026-11-02* *← [Requirements](docs/REQUIREMENTS.md) | [Docs →](docs/)* diff --git a/SECURITY.md b/SECURITY.md index bfcdc0d..c2a2f02 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,8 +8,8 @@ MetaLens is designed with security-first principles: - **Localhost-only sidecar** — the Python (FastAPI) backend binds to `127.0.0.1` only, on a dynamically chosen port, never `0.0.0.0` - **Electron hardening** — `nodeIntegration: false`, `contextIsolation: true`, `sandbox: true`; IPC accepts only the application's main frame. Navigation and popups are blocked and a Content Security Policy limits renderer resources - **Authenticated sidecar** — a fresh random token is generated for each session, passed to Python through the environment and sent in an Authorization header. Requests without valid credentials are rejected; localhost binding alone is not treated as authentication -- **Atomic writes** — changes are prepared in a private temporary directory beside the target, with checks for concurrent modification before replacement. Audio and video use the same mechanism. Writer failures leave the original content intact -- **Path validation** — a dedicated `core.path_security` module validates and normalizes every path (resolves symlinks, blocks traversal) before any filesystem access, across all API routes +- **Atomic, verified writes** — every save (edits and deletions together) is prepared on a copy in a private temporary directory beside the target, read back and checked against the request, and checked for concurrent modification before the copy replaces the original (`ReplaceFileW` on Windows, preserving the security descriptor, alternate data streams and file attributes such as *hidden*; `os.replace` elsewhere). Any failure leaves the original content intact. The replacement is a new file, so hard links to the original are detached. A read-only file is refused (HTTP 403), not modified +- **Path validation** — a dedicated `core.path_security` module normalizes every path (resolves `~`, `..` and symbolic links) and checks that it is an existing, readable regular file or directory before any filesystem access, across all API routes. This is normalization, not a sandbox: an authenticated caller may name any file the user account can access, which is why every request must carry the session token - **Hardened update check** — requests are made by the Electron main process, never by the renderer (whose CSP still only allows the local sidecar). It queries the GitHub Releases API of `overwrite00/MetaLens` only, and the renderer cannot supply URLs. Release and asset URLs must point to that repository, and the installer is downloaded only when you click *Download & install* (Windows installed builds), checked against its size and the SHA-256 digest published by GitHub, and discarded on any mismatch. Release notes are shown as plain text - **Transparent code** — all handler and analysis logic is open-source and auditable @@ -28,8 +28,8 @@ MetaLens is designed with security-first principles: ### Data Control - ✅ Your files and their metadata never leave your machine -- ✅ Nothing is written anywhere except the file you explicitly edit -- ✅ The undo stack lives in memory only — it is cleared when you close the app, never written to disk +- ✅ Nothing is written anywhere except the file you explicitly edit (and its short-lived private temporary copy beside it, removed after the save) +- ✅ The undo stack and the sidecar's Undo journal (original values of the last 50 saves, at most 64 MiB) live in memory only — they are cleared when you close the app, never written to disk - ✅ Full source code transparency --- @@ -38,14 +38,15 @@ MetaLens is designed with security-first principles: ### Metadata Handlers -- Uses established, audited libraries per format: Pillow/piexif (images), mutagen (audio/video), pypdf (PDF), python-docx/openpyxl/python-pptx (Office), olefile (legacy Office), hachoir (fallback) -- Field capabilities are enforced by the API; client-provided editability flags cannot override them. Image metadata outside supported JPEG EXIF, encrypted PDFs, digitally signed Office packages, legacy Office and MKV/AVI are read-only. Writable filesystem fields are handled separately +- Uses established libraries per format: Pillow (image properties), piexif tag tables plus MetaLens's own structure-preserving EXIF/XMP/IPTC codecs and JPEG segment writer (images), mutagen (audio/MP4), pypdf (PDF), Python's `zipfile`/`xml.etree` (Office Open XML packages), olefile (legacy Office), hachoir (read-only fallback for other formats). XMP packets (JPEG, PDF) containing DOCTYPE or entity declarations are refused +- Field capabilities are enforced by the API; client-provided editability flags cannot override them. Writable: JPEG EXIF/XMP/IPTC, audio tags (except AAC), MP4/MOV atoms, PDF document info, Office core properties, `fs:mtime` and Linux `user.` extended attributes. Read-only: other image formats and RAW, AAC, MKV/AVI and other non-MP4 video, encrypted or digitally signed PDFs, digitally signed Office packages (so a signature is never silently invalidated), Office `app.xml`/`custom.xml`, legacy Office, everything read through hachoir +- Every value is validated before the file is touched; library errors caused by a value are reported as validation errors (HTTP 422), never as crashes - Filesystem access always goes through `validate_file_path`/`validate_directory_path` before touching disk ### IPC Boundary - The Electron renderer talks to the OS only through the `preload.js` `contextBridge` surface (dialogs, path helpers, drive listing) — no direct filesystem or process access -- The renderer talks to the Python sidecar only over HTTP to `127.0.0.1:{dynamic_port}`, never a hardcoded port +- The renderer talks to the Python sidecar only over HTTP to `127.0.0.1:{dynamic_port}` (never a hardcoded port), with the port and session token obtained from the main process through the `get-sidecar-connection` IPC channel; the Content Security Policy only allows connections to `127.0.0.1` - The sidecar rejects foreign browser origins and requires its session token, including for `/health`. The normal development path is Vite plus Electron; opening the frontend in a standalone browser does not grant sidecar access --- @@ -107,5 +108,5 @@ We appreciate responsible disclosure and will credit you in the security advisor --- -*Last updated: 2026-10-31* +*Last updated: 2026-11-02* *← [Contributing](./CONTRIBUTING.md) | [Back to README →](./README.md)* diff --git a/docs/API.md b/docs/API.md index ec96a1d..70ae6c9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # 📡 API Reference — MetaLens -REST API exposed by the Python (FastAPI) sidecar that the Electron main process talks to over `127.0.0.1`. +REST API exposed by the Python (FastAPI) sidecar. The React renderer calls it directly over HTTP on `127.0.0.1`, with the port and session token it obtains from the Electron main process through IPC. > [!IMPORTANT] > 🔒 The sidecar binds to `127.0.0.1` **only**, on a port chosen dynamically at launch (never `0.0.0.0`, never a fixed port). It is not reachable from other machines and is not intended to be called directly by end users — this reference is for developers extending MetaLens. @@ -11,13 +11,15 @@ REST API exposed by the Python (FastAPI) sidecar that the Electron main process Every API request, including `/health`, requires `Authorization: Bearer `. Electron creates the token at startup and passes it to Python through `METALENS_SIDECAR_TOKEN`; -the sidecar refuses to start without it. The renderer receives connection details through validated -IPC. Do not put tokens in URLs, source files, logs or Vite environment variables. +the sidecar refuses to start without it. The renderer receives `{port, token}` through the validated +IPC channel `get-sidecar-connection`. Do not put tokens in URLs, source files, logs or Vite environment variables. -Missing or invalid credentials return **401**; an unconfigured imported application returns **503**. -Foreign browser origins return **403**. Allowed origins are the packaged application's `null` origin -and the Vite origins `http://localhost:5173` and `http://127.0.0.1:5173`, with valid credentials still required. -Use Vite together with Electron for development; the standalone browser fallback is disabled. +Missing or invalid credentials return **401** (`unauthorized`); an imported application without a configured +token returns **503** (`auth_not_configured`). Foreign browser origins return **403** (`origin_not_allowed`). +Allowed origins are the packaged application's `null` origin and the Vite origins `http://localhost:5173` +and `http://127.0.0.1:5173`, with valid credentials still required. These responses use the same +[error body](#-errors) as the API. Use Vite together with Electron for development; the standalone +browser fallback is disabled. --- @@ -28,26 +30,30 @@ Use Vite together with Electron for development; the standalone browser fallback 3. [GET /read](#get-read) 4. [POST /write](#post-write) 5. [POST /delete](#post-delete) -6. [GET /diff](#get-diff) -7. [GET /hash](#get-hash) -8. [📦 Data Models](#-data-models) +6. [POST /undo](#post-undo) +7. [GET /diff](#get-diff) +8. [GET /hash](#get-hash) +9. [⚠️ Errors](#-errors) +10. [📦 Data Models](#-data-models) +11. [🔤 Value model](#-value-model) +12. [🗝️ Key namespaces](#-key-namespaces) --- ## GET `/health` -Health check + version, used by Electron to know the sidecar is ready. (The About dialog shows the packaged app version from Electron, not this value.) +Health check + version, used by Electron to know the sidecar is ready (it checks `status` and `app`). The About dialog shows the packaged app version from Electron, not this value. **Response** ```json -{ "status": "ok", "version": "0.2.1", "app": "MetaLens" } +{ "status": "ok", "version": "0.4.0", "app": "MetaLens" } ``` --- ## GET `/list` -Lists the contents of a directory with detected handler per file. +Lists the contents of a directory with the detected format handler per file (directories first, then files, by name). | Param | Type | Description | |---|---|---| @@ -58,92 +64,169 @@ Lists the contents of a directory with detected handler per file. { "path": "/home/user/Pictures", "items": [ - { "name": "photo.jpg", "path": "/home/user/Pictures/photo.jpg", "is_dir": false, "size": 2048301, "mtime": 1752480000, "ext": ".jpg", "handler": "image" } + { "name": "photo.jpg", "path": "/home/user/Pictures/photo.jpg", "is_dir": false, "size": 2048301, "mtime": 1752480000.0, "ext": ".jpg", "handler": "image" } ] } ``` -`handler` is `"—"` when no dedicated handler matches (filesystem-only metadata still applies). +`handler` is one of `image`, `audio`, `video`, `pdf`, `office`, `ole` or `hachoir` (the fallback for any other file, +including files without extension). Directories have `"handler": "—"` and `"size": null`. +Entries that cannot be stat'ed are skipped. Errors: **400** invalid path, **403** directory not readable. --- ## GET `/read` -Reads full metadata for a single file. Resolves the matching handler, then merges filesystem metadata as a fallback layer for any key not already populated by the handler. +Reads the full metadata of one file: the format handler's record plus the filesystem layer (`fs:*`, `xattr:*`), +merged with unique keys. | Param | Type | Description | |---|---|---| | `path` | string (query) | Absolute file path | -**Response** — a [`MetadataRecord`](#-data-models) +**Response** — a [`MetadataRecord`](#metadatarecord). + +The server is the only authority on what can be modified: `editable` / `deletable` on each field, +`format_supports_write` / `format_supports_delete` and `read_only_reason` on the record, and `addable_fields` +(fields that are absent but can be added with `/write`). `read_errors` make the **format** metadata read-only; +`warnings` are informational and never block a write. --- ## POST `/write` -Atomically writes one or more metadata fields back to the file. +Writes one or more fields — and optionally deletes others — in **one** atomic save. **Body** ```json { "path": "/home/user/Pictures/photo.jpg", "fields": [ - { "key": "exif:Artist", "value": "Jane Doe" } - ] + { "key": "exif:Artist", "value": "Jane Doe" }, + { "key": "xmp:dc:subject", "value": ["holiday", "sea"] } + ], + "delete": ["gps:Latitude", "gps:LatitudeRef"] } ``` +`fields` and `delete` are both optional, but at least one must be non-empty. Only `key` and `value` of each +field are read: permission and type flags sent by the client are ignored. + **Response** ```json -{ "ok": true, "errors": [] } +{ "ok": true, "errors": [], "undo_token": "kq3V…", "undo_unavailable": null, "warnings": [] } ``` -Returns **422** for unsupported, unknown or read-only fields, invalid values or malformed requests. -The server derives field permissions itself. Writable filesystem fields such as `fs:mtime` are routed -separately from format metadata, including on files whose format metadata is read-only. -Mixed writes are prepared together before replacing the original. Concurrent changes return **409**. +`warnings` lists non-blocking notices about a save that did succeed (for example the file owner or the Windows +security descriptor SACL could not be restored without a privilege). A save whose values all equal the current ones +is a no-op: nothing is rewritten or replaced (the file keeps its identity, hard links and times). + +`undo_token` identifies the operation for [`POST /undo`](#post-undo); it is `null` when the save changed nothing +(e.g. writing the current values) or no exact Undo could be recorded. Both `/write` and `/delete` also return +`undo_unavailable`: `null` when there is a token or the save really changed nothing, otherwise +`{"code", "message"}` explaining why a change that **was** saved cannot be undone (`undo_too_large`: the pre/post +images exceed the 64 MiB journal budget, `undo_snapshot_failed`: the raw images could not be captured). The +save is not refused in that case; clients should tell the user. A save whose values equal the current ones does +not rewrite the file. + +**Large integers.** Integers beyond 2^53 (an MP4 `plID`, an ASF QWORD) are exact in the JSON the server emits and are +accepted back either as numbers or as decimal strings (`"1152921504606846979"`). JavaScript clients must read them +without converting to `Number` (the MetaLens renderer does this and sends a decimal string). + +How a save works: + +1. Every key and value is validated against the server's own schema **before** the file is touched; all problems + are reported together, one entry per key (**422**). A key must be a field of the record, an `addable_fields` + entry or a valid dynamic key of the format (e.g. `tag:TXXX:`, `xattr:user.`). +2. Values are converted to their canonical form (see [Value model](#-value-model)). +3. The changes are applied to a private copy of the file; the copy is read back and compared with the request: + every written key must hold the canonical value, deleted keys must be gone and no other field may change. + Otherwise the save fails with **422** `lossy_write` and the original is untouched. +4. The copy replaces the original (see [Atomic writes](./USAGE.md#-edit-metadata)). + +Filesystem fields (`fs:mtime`, `xattr:user.*`) are applied after the format metadata in the same save, so a written +`fs:mtime` is the final modification time. They stay writable on files whose format metadata is read-only. --- ## POST `/delete` -Removes the specified metadata keys from the file. +Removes metadata fields. Equivalent to `/write` with only `delete`. **Body** ```json -{ "path": "/home/user/Pictures/photo.jpg", "keys": ["GPSLatitude", "GPSLongitude"] } +{ "path": "/home/user/Pictures/photo.jpg", "keys": ["gps:Latitude", "gps:Longitude"] } +``` + +**Response** +```json +{ "ok": true, "errors": [], "undo_token": "Zt8x…", "undo_unavailable": null, "warnings": [] } ``` +Only fields present in the file with `deletable: true` can be removed; other keys return **422** +(`unknown_field` or `not_deletable`). Computed fields (stream info, image size, `fs:size`, the `fs:` timestamps) are never deletable. + +--- + +## POST `/undo` + +Reverts one earlier `/write` or `/delete` exactly, using the raw pre-images kept by the sidecar. + +**Body** +```json +{ "token": "kq3V…" } +``` + +Only `token` is accepted (the file path comes from the journal, never from the client). + +Besides the field pre-images the journal keeps the original bytes of the region of the file the save changed +(the part between the identical head and tail). When the file is still exactly as the save left it, the Undo puts +those bytes back (files up to 1 GiB), so the file is **byte-identical** to the one before the save (also for what mutagen cannot +remove, e.g. the FLAC padding or the MP4 `meta` atom added with a first tag). If another change touched that +region, or the changed region exceeds 16 MiB, the Undo restores the field values instead (verified the same way). + **Response** ```json -{ "ok": true, "errors": [] } +{ "ok": true, "errors": [], "keys": ["exif:Artist", "gps:Latitude"], "undo_token": null } ``` -Returns **422** if the handler reports `supports_delete: false`. +- Every key touched by the operation must still hold the value the operation left; if any was changed since + (or the file was replaced, moved or is now read by another handler), the Undo is refused with **409** + `concurrent_modification` and the entry is kept. +- The restore runs in one verified atomic transaction: binaries (cover art, pictures, MakerNote), multi-valued + fields and text stored in legacy encodings come back byte for byte. +- An unknown, already used or evicted token returns **410** `undo_expired`. +- The journal lives in sidecar memory only: at most **50** entries and **64 MiB** of snapshot data, oldest evicted + first. It is lost when the sidecar stops (closing the app); an operation larger than the budget simply has no Undo. +- The file's modification time is **not** restored implicitly: after an Undo it is the time of the Undo, unless + the undone operation itself wrote `fs:mtime`. --- ## GET `/diff` -Compares metadata between two files. +Compares the metadata of two files. Each side is the same merged record `/read` returns, filesystem layer included. | Param | Type | Description | |---|---|---| | `a` | string (query) | Absolute path to the first file | | `b` | string (query) | Absolute path to the second file | -**Response** — a [`DiffResult`](#-data-models) +**Response** — a [`DiffResult`](#diffresult). Fields are matched by key; two values are equal only when they have +the same JSON type and value (binary descriptors are compared by size, SHA-256 and MIME, so two different images of +the same size are reported as changed). The filesystem times `fs:atime` (changed by reading) and `fs:ctime` (creation or +metadata-change time, different for any copy) are not compared, so a file diffed with itself reports no changes. --- ## GET `/hash` -Computes cryptographic hashes for a file on demand, streaming it in 1 MB chunks (no full read into memory). +Computes cryptographic hashes of a file on demand, streaming it in 1 MiB chunks (no full read into memory). | Param | Type | Description | |---|---|---| | `path` | string (query) | Absolute file path | -| `algorithms` | string (query, optional) | Comma-separated list. Default: `md5,sha1,sha256,sha512,blake2b` | +| `algorithms` | string (query, optional) | Comma-separated list of `md5`, `sha1`, `sha256`, `sha512`, `blake2b`. Default: all five | **Response** ```json @@ -154,13 +237,52 @@ Computes cryptographic hashes for a file on demand, streaming it in 1 MB chunks } ``` -Returns **400** if an unsupported algorithm name is requested. +Returns **400** (`invalid_parameter`, key `algorithms`) if no algorithm is requested (e.g. `algorithms=`) or an +unsupported name is given, **403** if the file cannot be read. --- -## ⚠️ Error Handling +## ⚠️ Errors -All path-based endpoints validate input through `validate_file_path` / `validate_directory_path` before touching the filesystem (existence checks, traversal protection) and return **400** on violation. +Every error response, including authentication failures, has the same body; `detail` is always a string summary: + +```json +{ + "ok": false, + "errors": [ + { "code": "missing_offset", "key": "fs:mtime", "message": "fs:mtime: ..." }, + { "code": "read_only", "key": "img:width", "message": "img:width: field is read-only" } + ], + "detail": "fs:mtime: ...; img:width: field is read-only" +} +``` + +`key` is `null` for errors not tied to one field. + +| Status | `code` | When | +|---|---|---| +| 400 | `invalid_path`, `invalid_parameter` | Path not absolute / not an existing readable file or directory; bad query parameter (`/hash`) | +| 401 | `unauthorized` | Missing or wrong bearer token | +| 403 | `origin_not_allowed` | Foreign browser origin | +| 403 | `not_writable` | The target file cannot be written (read-only attribute, permissions, no right to create the temporary copy next to it) | +| 403 | `access_denied` | Directory or file not readable (`/list`, `/hash`) | +| 404 / 405 | `not_found` / `method_not_allowed` | Unknown route or method | +| 409 | `concurrent_modification` | The file changed during the save, or (Undo) since the operation | +| 410 | `undo_expired` | Undo token unknown, already used or evicted | +| 422 | field codes below, `invalid_request`, `duplicate_key` | Validation failed; nothing was written | +| 500 | `internal_error` | Unexpected failure (a retained recovery copy, if any, is named in the message) | +| 503 | `auth_not_configured` | Sidecar started without a session token | +| 503 | `file_busy` | The file stays locked by another program after a short retry (response has `Retry-After`); retry later. A vanished file is 404 `file_not_found` | + +Field-level codes (422): `invalid_value`, `wrong_type`, `null_value`, `empty_value`, `out_of_range`, +`invalid_choice`, `wrong_length`, `missing_offset`, `unexpected_offset`, `too_large`, `unsupported`, +`read_only`, `not_deletable`, `unknown_field`, `lossy_write`. Request-schema errors (pydantic) use +`invalid_request` with the field location as `key`. A value-related library error is always reported as 422, +never 500. + +Path validation (`core.path_security`) resolves `~`, `..` and symbolic links and checks that the target exists and +is readable. It is normalization, **not** an authorization mechanism: an authenticated caller may name any file the +operating-system account can access (see [python/SECURITY.md](../python/SECURITY.md)). --- @@ -171,37 +293,104 @@ Defined in `python/core/models.py`. ### `MetadataField` | Field | Type | Notes | |---|---|---| -| `key` | string | Raw field identifier | +| `key` | string | Stable identifier `:` (see [Key namespaces](#-key-namespaces)); unique in a record | | `label` | string | Human-readable label | -| `value` | any | Current value | -| `raw_value` | any | Unformatted underlying value | -| `editable` | bool | Whether `/write` accepts this field | -| `deletable` | bool | Whether `/delete` accepts this key | -| `field_type` | enum | `str \| int \| float \| datetime \| bytes \| enum` | -| `source` | enum | `exif \| iptc \| xmp \| id3 \| vorbis \| pdf \| filesystem \| ...` | -| `choices` | array | Present only for `enum` fields | +| `value` | any JSON | Canonical value (see [Value model](#-value-model)) | +| `raw_value` | any JSON | Underlying value as read, for display; bytes are shown as `""` | +| `editable` | bool | Whether `/write` accepts this key | +| `deletable` | bool | Whether `/delete` (or `/write` `delete`) accepts this key | +| `field_type` | string | `str`, `text`, `int`, `float`, `bool`, `datetime`, `localdatetime`, `rational`, `duration`, `binary`, `enum` | +| `multi` | bool | `value` is a list of items of `field_type` | +| `length` | int \| null | Fixed list size for tuples (e.g. track `[3, 10]`, GPS coordinate of 3 rationals) | +| `choices` | array | Allowed values for `enum` fields; always present, empty otherwise | +| `binary` | object \| array \| null | For `binary` fields: the `{size, sha256, mime}` descriptor (a list when `multi`) | +| `source` | string | Group shown in the UI: `exif`, `gps`, `xmp`, `iptc`, `image`, `png`, `gif`, `id3`, `vorbis`, `flac`, `asf`, `ape`, `mp4`, `audio`, `video`, `pdf`, `office`, `ole`, `hachoir`, `filesystem`, `xattr` | ### `MetadataRecord` -| Field | Type | -|---|---| -| `file_path` | string | -| `file_size` | int | -| `mtime` | float | -| `handler_name` | string | -| `fields` | `MetadataField[]` | -| `read_errors` | string[] | -| `supports_write` | bool | -| `supports_delete` | bool | +| Field | Type | Notes | +|---|---|---| +| `file_path` | string | Resolved path | +| `file_size` | int | | +| `mtime` | float | POSIX seconds | +| `handler_name` | string | Format handler (`image`, `audio`, `video`, `pdf`, `office`, `ole`, `hachoir`) | +| `fields` | `MetadataField[]` | Format fields followed by the filesystem layer | +| `read_errors` | string[] | Problems reading the format metadata; when non-empty the format metadata is read-only | +| `warnings` | string[] | Non-blocking notices (filesystem layer, EXIF MakerNote, duplicated EXIF/XMP segments, renamed duplicate keys, ...) | +| `format_supports_write` | bool | The format metadata of this file can be written | +| `format_supports_delete` | bool | The format metadata of this file can be deleted | +| `read_only_reason` | string \| null | Why the format metadata is read-only (e.g. signed PDF, AAC, legacy OLE, unknown format) | +| `supports_write` | bool | Legacy: format **or** filesystem level writable | +| `supports_delete` | bool | Legacy: format **or** filesystem level deletable | +| `addable_fields` | `MetadataField[]` | Schemas (value `null`) of fields that can be added with `/write` | + +If a handler ever produces two fields with the same key, the later ones are renamed `key#2`, `key#3`…, made +read-only and reported in `warnings`. ### `DiffResult` | Field | Type | |---|---| | `file_a` / `file_b` | string | | `only_in_a` / `only_in_b` | `MetadataField[]` | -| `changed` | `[MetadataField, MetadataField][]` | -| `identical` | `MetadataField[]` | +| `changed` | `{ "field_a": MetadataField, "field_b": MetadataField }[]` | +| `identical` | `MetadataField[]` (the field of file A) | + +All lists are sorted by key. + +--- + +## 🔤 Value model + +`value` is always JSON-native and canonical (`python/core/values.py`). `/write` accepts the forms below and +answers with the canonical form on the next read. + +| `field_type` | Canonical value | Accepted on write | +|---|---|---| +| `str` / `text` | string (`text` = multi-line) | string only (numbers are not coerced); an empty string only where the format can store it (otherwise `empty_value`: delete the field instead) | +| `int` | integer | integer, integral number or strict decimal string (`"121"`), within the range of the stored type | +| `float` | finite number | number or numeric string | +| `bool` | `true` / `false` | boolean, `0`/`1`, or (case-insensitive) `"true"/"false"`, `"1"/"0"`, `"yes"/"no"`, `"on"/"off"` — so `"False"` is false; anything else is refused | +| `datetime` | ISO 8601 **with** UTC offset, e.g. `"2026-01-02T03:04:05+02:00"` | same; a value without offset is refused (`missing_offset`) | +| `localdatetime` | ISO 8601 **without** offset (EXIF wall-clock time) | same; a value with offset is refused (`unexpected_offset`) | +| `rational` | exact `"n/d"` string, never reduced (`"1234/100"` stays) | `"n/d"`, `[n, d]`, an integer or a decimal (`"2.5"` → `"25/10"`) | +| `duration` | seconds (non-negative number) | read-only | +| `binary` | `{size, sha256, mime}` descriptor | never editable; may be deletable | +| `enum` | one of `choices` | one of `choices` | + +- `multi` fields take a JSON **array** (never a joined string); with `length` the array must have exactly that size. +- `null` is never a valid value (`null_value`). +- Text that is not valid in the format's declared encoding is shown decoded as Latin-1 without loss; the exact + original bytes are what Undo restores. + +--- + +## 🗝️ Key namespaces + +Keys are `:` and never depend on the value, the label or the field order. They are not +persisted anywhere (the Undo journal is in memory), so they may change between versions; changes are listed in +`CHANGELOG.md`. + +| Namespace | Handler / formats | Native id | Writable | +|---|---|---|---| +| `img:` | image (all) | `format`, `mode`, `width`, `height`, `dpi` (`[x, y]`, from JFIF/EXIF/Pillow) | no (computed) | +| `exif:` | JPEG, TIFF, TIFF-based RAW (e.g. CR2, NEF, ARW, DNG), PNG `eXIf`, WebP `EXIF` | tag name (`exif:Artist`, `exif:DateTimeOriginal`); a tag outside its usual IFD is `exif::`; unnamed tags `exif:tag_` | JPEG only; image-structure tags, pointers, the thumbnail and `UNDEFINED` binaries (e.g. `MakerNote`) are read-only, binaries deletable | +| `gps:` | as `exif:` (GPS IFD) | tag name without the `GPS` prefix (`gps:Latitude`, `gps:LatitudeRef`) | JPEG only | +| `xmp:` | JPEG (APP1 XMP), WebP `XMP ` | `xmp::` with canonical prefixes (`xmp:dc:title`, `xmp:dc:subject`, `xmp:xmp:Rating`, `xmp:photoshop:City`, `xmp:Iptc4xmpCore:Location`…) | JPEG only, for simple values, language alternatives (`x-default`) and Bag/Seq of simple items; structs and qualifiers are not shown; extended XMP is read-only | +| `iptc:` | JPEG (APP13 `Photoshop 3.0`, resource 0x0404) | mapped IIM name (`iptc:Keywords`, `iptc:By-line`, `iptc:Caption-Abstract`…) or `iptc::` | JPEG only; repeatable datasets are lists; written as UTF-8 (`1:90 = ESC % G`); a legacy (non-UTF-8) charset makes IPTC read-only; an edit or delete of a dataset is mirrored to its XMP twin (and vice versa) when the twin is already in the file: Keywords↔`dc:subject`, By-line↔`dc:creator`, ObjectName↔`dc:title`, Caption-Abstract↔`dc:description`, CopyrightNotice↔`dc:rights`, Headline, City, Province-State, Country, Credit, Source, SpecialInstructions, TransmissionReference, Sub-location, Country code ↔ the matching `photoshop:`/`Iptc4xmpCore:` property. The mirror is part of the same transaction and Undo, never creates a twin, never overrides a key sent in the same request, and a value the twin cannot store is skipped with a `warnings` note | +| `png:` | PNG `tEXt`/`zTXt`/`iTXt` | keyword | no | +| `gif:` | GIF | `comment` | no | +| `tag:` | ID3 (MP3, WAV, AIFF, TTA), Vorbis comments (FLAC, Ogg Vorbis/Opus/Speex/FLAC), ASF (WMA), APEv2 (APE, WavPack, Musepack) | ID3 FrameID (`tag:TPE1`, `tag:TXXX:`, `tag:COMM::`, URL frames grouped by FrameID); lowercase Vorbis name (`tag:artist`); ASF attribute name; APE item key | yes, except AAC and other read-only formats; pictures, private and structured frames (APIC, GEOB, PRIV, POPM, PCNT, SYLT…) are read-only but deletable | +| `flac:` | FLAC | `picture` (picture blocks) | read-only, deletable | +| `mp4:` | MP4 family: M4A/M4B/M4R (audio), MP4/M4V/MOV (video) | `ilst` atom (`mp4:©ART`, `mp4:trkn`, `mp4:covr`, freeform `mp4:----::`) | yes; `covr` and non-UTF-8 freeform values read-only but deletable | +| `audio:` | audio files, first audio track of MP4 | `duration`, `bitrate`, `sample_rate`, `channels`, `bits_per_sample`, `codec` | no (computed) | +| `video:` | MP4 family | `duration` | no (computed) | +| `pdf:` | PDF | `/Info` entry without the slash (`pdf:Title`, `pdf:CreationDate`); computed `pdf:page_count`, `pdf:encrypted`, `pdf:signed` (an `/Info` entry with one of those names is `pdf:#info`) | text and date entries, unless the PDF is encrypted or signed; `/Trapped`, numbers and booleans read-only but deletable | +| `office:` | DOCX/XLSX/PPTX/DOCM/XLSM/PPTM | core property (`office:title`, `office:author`, `office:created`…); `office:app.` (docProps/app.xml), `office:custom.` (docProps/custom.xml) | core properties only, unless the package is signed; `app.`/`custom.` read-only | +| `ole:` | DOC/XLS/PPT/DOT/XLT/POT | SummaryInformation and DocumentSummaryInformation property (`ole:author`, `ole:company`…), user-defined `ole:custom:` | no | +| `hachoir:` | fallback for other files; MKV/WebM/AVI/WMV/FLV/TS/MTS/M2TS (video handler) | `hachoir::` (group 0 = root metadata, then one group per stream) | no | +| `fs:` | every file | `size`, `mtime`, `ctime` (creation time on Windows, metadata change on POSIX), `atime`, `win_attrs` (Windows), `permissions`/`owner`/`group` (POSIX) | `fs:mtime` only | +| `xattr:` | every file (Linux, macOS) | attribute name (`xattr:user.comment`) | Linux `user.` attributes with UTF-8 values (non-UTF-8 values binary, read-only, deletable); other namespaces and macOS read-only | --- -*Last updated: 2026-10-31* +*Last updated: 2026-11-02* *← [Usage](./USAGE.md) | [Back to README →](../README.md)* diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 4886352..ef4006c 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -20,7 +20,7 @@ Step-by-step instructions for installing MetaLens on Windows and Linux, plus bui ## 🪟 Windows Installation 1. Go to the [Releases](../../../releases) page -2. Download **`MetaLens-vX.Y.Z-Setup-Windows.exe`** (latest version) +2. Download **`MetaLens-X.Y.Z-win-x64.exe`** (latest version; betas are named `MetaLens-X.Y.Z-beta.N-win-x64.exe`) 3. Double-click the downloaded file > [!WARNING] @@ -93,7 +93,7 @@ cd python python -m venv .venv source .venv/bin/activate # Linux # .venv\Scripts\activate # Windows -pip install -r requirements.txt +pip install -r requirements.txt # runtime only; requirements-dev.txt adds the test dependencies # 3. Install Node dependencies cd ../electron && npm install @@ -162,5 +162,5 @@ sudo apt --fix-broken install # Debian/Ubuntu, after a failed .deb install --- -*Last updated: 2026-10-31* +*Last updated: 2026-11-02* *← [Requirements](./REQUIREMENTS.md) | [Usage →](./USAGE.md)* diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index c37492e..31e6b80 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -63,7 +63,7 @@ Only needed if you want to modify MetaLens or package it yourself. | 📦 Tool | ✅ Version | 📝 Notes | |---|---|---| | **Python** | 3.13 | Used to run/package the FastAPI sidecar | -| **Node.js** | 20+ | Runs Electron and the Vite build | +| **Node.js** | 22.13+ (24 LTS recommended, used by CI) | Runs Electron Forge and the Vite build (22.13 is the minimum required by Electron Forge 8) | | **npm** | 10+ | Installs both `electron/` and `frontend/` dependencies | See [Installation Guide → Build from Source](./INSTALLATION.md#-build-from-source) for exact steps. @@ -91,5 +91,5 @@ See [Installation Guide → Build from Source](./INSTALLATION.md#-build-from-sou --- -*Last updated: 2026-10-31* +*Last updated: 2026-11-02* *← [README](../README.md) | [Installation →](./INSTALLATION.md)* diff --git a/docs/USAGE.md b/docs/USAGE.md index 50fc522..26a225a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -38,13 +38,17 @@ The **left panel** (`FolderPanel`) lists your available drives and home director The **center panel** (`FilePanel`) shows every file in the currently selected folder, with an icon colored by extension. Click a file to load its metadata into the detail panel on the right. > [!NOTE] -> 📖 Every file type is supported in some form — recognized formats get a dedicated handler (EXIF, ID3, PDF info, Office properties, …); anything else falls back to filesystem metadata (name, size, timestamps, permissions). +> 📖 Every file type is supported in some form — recognized formats get a dedicated handler (EXIF/XMP/IPTC, ID3, PDF info, Office properties, …); any other file is examined by the **hachoir** fallback, which recognizes many formats from their content (also files without extension). Every file also shows the filesystem layer (size, timestamps, Windows attributes or POSIX permissions/owner, extended attributes). --- ## 👁️ View Metadata -The **detail panel** (`DetailPanel`) opens on the **View** tab by default, showing a read-only, grouped table (`MetadataTable`) of every field MetaLens extracted — grouped and color-coded by source (EXIF, IPTC, XMP, ID3, Vorbis comments, PDF info, filesystem, …). +The **detail panel** (`DetailPanel`) opens on the **Metadata** tab by default, showing a read-only table (`MetadataTable`) of every field MetaLens extracted, grouped by source (`exif`, `gps`, `xmp`, `iptc`, `id3`, `vorbis`, `mp4`, `pdf`, `office`, `hachoir`, `filesystem`, …). + +- Multi-valued fields (artists, keywords, …) are shown as lists, never joined into one string. +- Binary values (cover art, pictures, MakerNote, thumbnails) are shown as size, MIME type and SHA-256, not as their bytes. +- Notices above the table explain why the format metadata is read-only (e.g. signed PDF, AAC, legacy Office), report read errors and show warnings (e.g. an EXIF MakerNote that is moved on rewrite, duplicated metadata segments). --- @@ -52,15 +56,16 @@ The **detail panel** (`DetailPanel`) opens on the **View** tab by default, showi Switch to the **Edit** tab to open `MetadataEditor`: -- Editable fields show an input matching their type (text, number, date, or a dropdown for enumerated values) -- Change a value and save — MetaLens writes it back **atomically**: it copies the file to a `.ml_tmp` temp file, applies the change there, then atomically replaces the original (`os.replace`) so a crash or write error never corrupts your file -- If the file's handler doesn't support writing (e.g. RAW images, legacy Office `.doc`/`.xls`/`.ppt`, MKV/AVI video), the Edit tab shows a **read-only** notice instead of an editable form +- Editable fields show an input matching their type: text, multi-line text, number, checkbox (booleans), dropdown (enumerated values), exact `n/d` rationals, a list editor for multi-valued fields, fixed-size inputs for pairs such as track `[number, total]`, and date-time with a separate UTC offset (EXIF dates are local times without offset) +- **Add field** offers the fields the format can store but the file does not have yet (e.g. an ID3 title on an untagged WAV, an XMP keyword list on a JPEG) +- **Save** sends all edits, additions and deletions as **one** save. The sidecar validates every value first (invalid values are reported next to their field and nothing is written), applies the changes to a private copy next to the file, reads the copy back to check that exactly the requested changes were stored, and only then replaces the original (`ReplaceFileW` on Windows, keeping the security descriptor, alternate data streams and attributes such as *hidden*; `os.replace` elsewhere). A failure at any step leaves the original untouched. The file is replaced by a new one, so hard links to it are detached +- When the format metadata is read-only (e.g. RAW, TIFF/PNG/WebP/GIF, AAC, MKV/AVI, legacy `.doc`/`.xls`/`.ppt`, encrypted or digitally signed PDFs, signed Office packages), the Edit tab shows a **read-only** notice with the reason; filesystem fields such as the modification time (`fs:mtime`) stay editable --- ## 🗑️ Delete Metadata Fields -Individual fields marked as **deletable** can be removed from the Edit tab. Deletion uses the same atomic write-back as editing — the original file is only touched once the operation succeeds. +Individual fields marked as **deletable** can be removed from the Edit tab, including binary ones such as cover art (which cannot be edited). Deletions are part of the same verified atomic save as edits. Computed values (image size, stream info, file size, timestamps) cannot be deleted. --- @@ -85,13 +90,20 @@ Each computed hash has a one-click **copy to clipboard** button. Switch to the **Diff** tab (`DiffView`) to compare two files side by side: - Fields only present in file A, only in file B, changed between the two, and identical fields are each shown in their own section +- Both sides include the filesystem layer; binary values are compared by their SHA-256, so two different pictures of the same size are reported as changed +- **CSV** exports the comparison - The diff view also includes its own hash comparison section, so you can confirm at a glance whether two files are byte-identical --- ## ↩️ Undo -MetaLens keeps an in-memory undo stack of your last **50** write/delete operations for the current session (`useUndoStack`). It is **not** persisted to disk — closing the app clears it. Trigger undo from the **Edit** menu or its keyboard shortcut. +MetaLens keeps an undo stack of your last **50** saves for the current session. Trigger it with **Edit → Undo** or **Ctrl+Z** (Cmd+Z on macOS); while the cursor is in a text field, the shortcut undoes your typing instead. + +- Undo is **exact**: the sidecar restores the original bytes of every field the save touched, binaries and multi-valued fields included, in one verified atomic save. +- If the file was changed since the save (by another program or a later edit of the same fields), the Undo is refused and the entry is removed from the stack; the same happens when the sidecar no longer has the entry (more than 50 entries or 64 MiB of saved data). Other errors keep the entry so you can retry. +- The file's modification time is **not** put back: after an Undo it is the time of the Undo, unless the undone save itself changed the modification time. A later save moving the modification time never blocks an earlier Undo; only a change made by another program does. +- Nothing is persisted to disk: closing the app clears the stack and the sidecar's journal. --- @@ -117,5 +129,5 @@ The preferences (auto-check on/off, ignored version) are stored in `settings.jso --- -*Last updated: 2026-10-31* +*Last updated: 2026-11-02* *← [Installation](./INSTALLATION.md) | [API Reference →](./API.md)* diff --git a/electron/main.js b/electron/main.js index 1ee1132..2cfe8c9 100644 --- a/electron/main.js +++ b/electron/main.js @@ -9,6 +9,7 @@ const { isTrustedFrame, secureWindow } = require('./security-policy') const { waitForSidecar } = require('./sidecar-health') const { handleSquirrelEvent } = require('./squirrel-events') const { spawnTree, killProcessTree } = require('./process-tree') +const { buildMenuTemplate } = require('./menu') const { REPO_URL, checkForUpdates, downloadAsset, cleanupStaleDownloads, createSettingsStore, } = require('./update-check') @@ -241,47 +242,9 @@ function setupIPC() { // ─────────────────────── Menu ───────────────────────────────────────────────── function buildMenu() { - const template = [ - { - label: 'File', - submenu: [ - { label: 'Open Folder…', accelerator: 'CmdOrCtrl+O', - click: () => mainWindow.webContents.send('menu-open-folder') }, - { type: 'separator' }, - { role: 'quit' }, - ], - }, - { - label: 'Edit', - submenu: [ - { label: 'Undo', accelerator: 'CmdOrCtrl+Z', - click: () => mainWindow.webContents.send('menu-undo') }, - { type: 'separator' }, - { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }, - ], - }, - { - label: 'View', - submenu: [ - { role: 'reload' }, { role: 'toggleDevTools' }, - { type: 'separator' }, - { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, - { type: 'separator' }, - { role: 'togglefullscreen' }, - ], - }, - { - label: 'Help', - submenu: [ - { label: 'Check for Updates…', - click: () => mainWindow.webContents.send('menu-check-updates') }, - { type: 'separator' }, - { label: 'About MetaLens', - click: () => mainWindow.webContents.send('menu-about') }, - ], - }, - ] - Menu.setApplicationMenu(Menu.buildFromTemplate(template)) + // Menu clicks reach the renderer as fixed IPC events; see menu.js for Ctrl+Z (D11). + const send = (channel, ...args) => mainWindow?.webContents.send(channel, ...args) + Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenuTemplate(send))) } // ─────────────────────── Window ────────────────────────────────────────────── diff --git a/electron/menu.js b/electron/menu.js new file mode 100644 index 0000000..60a1382 --- /dev/null +++ b/electron/menu.js @@ -0,0 +1,54 @@ +// Application menu template, kept free of Electron imports so it can be unit tested. +// +// Edit > Undo (plan D11): Ctrl+Z is displayed but NOT registered as a global +// accelerator (`registerAccelerator: false`, Windows/Linux), so the keystroke reaches +// the page: inside a text field it is the browser's native text undo, elsewhere the +// renderer turns it into the file undo. On macOS the accelerator is always handled by +// the menu: `keyboard: true` tells the renderer to undo the text field instead when +// one has focus. A mouse click on the item is always the file undo. + +function buildMenuTemplate(send) { + return [ + { + label: 'File', + submenu: [ + { label: 'Open Folder…', accelerator: 'CmdOrCtrl+O', + click: () => send('menu-open-folder') }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'Edit', + submenu: [ + { label: 'Undo', accelerator: 'CmdOrCtrl+Z', registerAccelerator: false, + click: (_item, _window, event) => + send('menu-undo', { keyboard: Boolean(event?.triggeredByAccelerator) }) }, + { type: 'separator' }, + { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Help', + submenu: [ + { label: 'Check for Updates…', + click: () => send('menu-check-updates') }, + { type: 'separator' }, + { label: 'About MetaLens', + click: () => send('menu-about') }, + ], + }, + ] +} + +module.exports = { buildMenuTemplate } diff --git a/electron/test/menu.test.js b/electron/test/menu.test.js new file mode 100644 index 0000000..dfccd34 --- /dev/null +++ b/electron/test/menu.test.js @@ -0,0 +1,48 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const { buildMenuTemplate } = require('../menu') + +const items = template => template.flatMap(menu => menu.submenu) +const find = (template, label) => items(template).find(item => item.label === label) + +test('Edit > Undo shows Ctrl+Z but does not register it, so text fields keep native undo (D11)', () => { + const sent = [] + const template = buildMenuTemplate((...args) => sent.push(args)) + const undo = find(template, 'Undo') + assert.equal(undo.accelerator, 'CmdOrCtrl+Z') + assert.equal(undo.registerAccelerator, false) + assert.equal(undo.role, undefined) + // No other item may grab Ctrl+Z (a role: 'undo' or a second accelerator). + assert.deepEqual(items(template).filter(item => item.accelerator === 'CmdOrCtrl+Z' || item.role === 'undo'), [undo]) + + undo.click(undo, null, { triggeredByAccelerator: false }) + undo.click(undo, null, { triggeredByAccelerator: true }) + undo.click(undo, null, undefined) + assert.deepEqual(sent, [ + ['menu-undo', { keyboard: false }], + ['menu-undo', { keyboard: true }], + ['menu-undo', { keyboard: false }], + ]) +}) + +test('the rest of the menu is unchanged', () => { + const sent = [] + const template = buildMenuTemplate((...args) => sent.push(args)) + assert.deepEqual(template.map(menu => menu.label), ['File', 'Edit', 'View', 'Help']) + find(template, 'Open Folder…').click() + find(template, 'Check for Updates…').click() + find(template, 'About MetaLens').click() + assert.deepEqual(sent, [['menu-open-folder'], ['menu-check-updates'], ['menu-about']]) + assert.equal(find(template, 'Open Folder…').accelerator, 'CmdOrCtrl+O') + const roles = items(template).map(item => item.role).filter(Boolean) + for (const role of ['quit', 'copy', 'paste', 'selectAll', 'reload', 'toggleDevTools', 'togglefullscreen']) { + assert.ok(roles.includes(role), role) + } +}) + +test('main.js builds the application menu from the shared template', () => { + const source = require('node:fs').readFileSync(require('node:path').join(__dirname, '..', 'main.js'), 'utf8') + assert.match(source, /require\('\.\/menu'\)/) + assert.match(source, /buildMenuTemplate\(/) + assert.doesNotMatch(source, /accelerator:\s*'CmdOrCtrl\+Z'/) +}) diff --git a/electron/test/preload-client.test.js b/electron/test/preload-client.test.js index 0d233ec..97f1aa8 100644 --- a/electron/test/preload-client.test.js +++ b/electron/test/preload-client.test.js @@ -43,7 +43,7 @@ test('frontend fails closed without Electron and sends session token in header o assert.equal(target, 'http://127.0.0.1:12345/health') assert.equal(options.headers.Authorization, 'Bearer session-secret') assert.equal(target.includes('secret'), false) - return { ok: true, json: async () => ({ status: 'ok' }) } + return { ok: true, json: async () => ({ status: 'ok' }), text: async () => JSON.stringify({ status: 'ok' }) } } const authenticated = await import(`${url}?authenticated`) assert.deepEqual(await authenticated.metalens.health(), { status: 'ok' }) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ee0ea85..0d1de78 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,9 +7,12 @@ import { Button } from './components/ui/Button' import { useUndoStack } from './hooks/useUndoStack' import { useUpdates } from './hooks/useUpdates' import { metalens } from './api/client' +import { isTextEditable, isUndoShortcut } from './lib/keyboard' import { FolderOpen, RotateCcw, GitCompare, Layers, CircleArrowUp } from 'lucide-react' import './styles/globals.css' +const baseName = path => (path ? String(path).split(/[\\/]/).pop() : 'the file') + export default function App() { const [currentPath, setCurrentPath] = useState(null) const [selectedFile, setSelectedFile] = useState(null) @@ -26,13 +29,24 @@ export default function App() { const [leftW, setLeftW] = useState(220) const [rightW, setRightW] = useState(360) const dragging = useRef(null) + const undoMsgTimer = useRef(null) + const handleUndoRef = useRef(null) + handleUndoRef.current = handleUndo // ── Menu events from Electron ───────────────────────────────────────────── useEffect(() => { const api = window.electronAPI if (!api) return const openFolder = () => openFolderDialog() - const undo = () => handleUndo() + // `keyboard`: the menu accelerator fired (macOS, where it cannot be left to the + // page). Inside a text field that is the field's own undo, not the file undo (D11). + const undo = payload => { + if (payload?.keyboard && isTextEditable(document.activeElement)) { + document.execCommand?.('undo') + return + } + handleUndoRef.current() + } const about = () => setShowAbout(true) api.onMenuOpenFolder(openFolder) api.onMenuUndo(undo) @@ -42,7 +56,21 @@ export default function App() { api.removeAllListeners('menu-undo') api.removeAllListeners('menu-about') } - }, [selectedFile, undo, canUndo]) + }, []) + + // Ctrl+Z (Windows/Linux: the menu shows it but leaves it to the page). In a text + // field the event is left alone so the browser undoes the typing; elsewhere it + // undoes the last save. + useEffect(() => { + function onKeyDown(event) { + if (!isUndoShortcut(event) || event.defaultPrevented || event.repeat) return + if (isTextEditable(event.target)) return + event.preventDefault() + handleUndoRef.current() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) useEffect(() => { const unsubscribe = window.electronAPI?.onMenuCheckUpdates?.(() => { @@ -59,17 +87,39 @@ export default function App() { } // ── Undo ────────────────────────────────────────────────────────────────── + function showUndoMsg(message, ms) { + clearTimeout(undoMsgTimer.current) + setUndoMsg(message) + undoMsgTimer.current = setTimeout(() => setUndoMsg(null), ms) + } + useEffect(() => () => clearTimeout(undoMsgTimer.current), []) + + const refreshViews = () => { + // Refresh the record and the file list even if the selection changed meanwhile. + setReloadKey(value => value + 1) + setFilesReloadKey(value => value + 1) + } + async function handleUndo() { try { - const entry = await undo(entry => metalens.write(entry.filePath, entry.fields)) + let result = null + const entry = await undo(async item => { result = await metalens.undo(item.token) }) if (!entry) return - setUndoMsg('Undo applied') - setTimeout(() => setUndoMsg(null), 2000) - // Refresh the current view even if selection changed while undo was queued. - setReloadKey(value => value + 1) + // Non-blocking server warnings (e.g. the file owner could not be restored) stay visible longer. + const warnings = Array.isArray(result?.warnings) ? result.warnings.filter(Boolean) : [] + showUndoMsg(`Undone: ${entry.label || 'last save'} (${baseName(entry.filePath)})${warnings.length ? ` — ${warnings.join('; ')}` : ''}`, + warnings.length ? 6000 : 2500) + refreshViews() } catch (e) { - setUndoMsg(`Undo failed: ${e.message}`) - setTimeout(() => setUndoMsg(null), 3000) + const name = baseName(e.undoEntry?.filePath) + if (e.undoDiscarded === 'changed') { + showUndoMsg(`Undo not possible: ${name} changed since the save (entry removed)`, 6000) + refreshViews() + } else if (e.undoDiscarded === 'expired') { + showUndoMsg(`Undo no longer available for ${name} (entry removed)`, 6000) + } else { + showUndoMsg(`Undo failed: ${e.message}`, 4000) + } } } @@ -121,7 +171,7 @@ export default function App() { Open Folder {fileB && ( diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index ef0a9ec..23830b6 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -13,6 +13,65 @@ async function getConnection() { return connection } +const text = value => { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + try { return JSON.stringify(value) } catch { return String(value) } +} + +/** One `{code, key, message}` entry with string message, from the uniform body or a FastAPI detail item. */ +function normalizeEntry(entry) { + if (entry === null || typeof entry !== 'object') return { code: null, key: null, message: text(entry) } + if ('message' in entry) { + return { code: entry.code ?? null, key: entry.key ?? null, message: text(entry.message) } + } + // FastAPI/pydantic default: {loc: [...], msg, type} + const key = Array.isArray(entry.loc) && entry.loc.length ? entry.loc.join('.') : null + const msg = text(entry.msg ?? entry) + return { code: entry.type ?? null, key, message: key ? `${key}: ${msg}` : msg } +} + +/** + * Error built from an error response (plan B4): `{ok:false, errors:[{code,key,message}], detail}`. + * The Error carries `status`, `code` (first error code), `errors` and `detail`; its message is + * always readable text (never "[object Object]"). + */ +export function apiError(status, statusText, body) { + let errors = [] + if (Array.isArray(body?.errors)) errors = body.errors.map(normalizeEntry) + else if (Array.isArray(body?.detail)) errors = body.detail.map(normalizeEntry) + else if (body?.detail && typeof body.detail === 'object') errors = [normalizeEntry(body.detail)] + const detail = typeof body?.detail === 'string' && body.detail ? body.detail : null + const message = detail + || errors.map(e => e.message).filter(Boolean).join('; ') + || statusText + || `HTTP ${status}` + const error = new Error(message) + error.status = status + error.code = errors.find(e => e.code)?.code ?? null + error.errors = errors + error.detail = detail ?? message + return error +} + +/** + * JSON.parse that keeps integers beyond 2^53 exact: a JS number would round them (e.g. an MP4 + * `plID` or an ASF QWORD), so they come back as their decimal text. Needs the reviver `source` + * context (Chromium 114+, so Electron); elsewhere it degrades to plain JSON.parse. + */ +export function parseJson(raw) { + return JSON.parse(raw, (key, value, context) => ( + typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value) + && typeof context?.source === 'string' && /^-?\d+$/.test(context.source) + ? context.source + : value + )) +} + +async function readJson(res) { + return parseJson(await res.text()) +} + async function api(method, path, body = null) { const { port, token } = await getConnection() const url = `http://127.0.0.1:${port}${path}` @@ -23,18 +82,29 @@ async function api(method, path, body = null) { if (body !== null) opts.body = JSON.stringify(body) const res = await fetch(url, opts) if (!res.ok) { - const err = await res.json().catch(() => ({ detail: res.statusText })) - throw new Error(err.detail || res.statusText) + const err = await readJson(res).catch(() => null) + throw apiError(res.status, res.statusText, err) } - return res.json() + return readJson(res) } +// The server reads only key and value: permission/type flags are never sent. +const writeFields = fields => (fields ?? []).map(({ key, value }) => ({ key, value })) + export const metalens = { health: () => api('GET', '/health'), list: (path) => api('GET', `/list?path=${encodeURIComponent(path)}`), read: (path) => api('GET', `/read?path=${encodeURIComponent(path)}`), - write: (path, fields) => api('POST', '/write', { path, fields }), + // `deleteKeys` (optional) removes fields in the same atomic save. write/delete resolve to + // `{ok, errors, undo_token}`: `undo_token` is null when nothing actually changed. + write: (path, fields, deleteKeys) => api('POST', '/write', deleteKeys?.length + ? { path, fields: writeFields(fields), delete: [...deleteKeys] } + : { path, fields: writeFields(fields) }), delete: (path, keys) => api('POST', '/delete', { path, keys }), + // Exact undo of one save by its token (no path: the server journal knows the file). + // Rejects with status 410 `undo_expired` or 409 `concurrent_modification` when the + // entry can no longer be applied; other errors (403/422/500/network) are retryable. + undo: (token) => api('POST', '/undo', { token }), diff: (a, b) => api('GET', `/diff?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`), hash: (path, algorithms) => api('GET', `/hash?path=${encodeURIComponent(path)}&algorithms=${encodeURIComponent(algorithms)}`), } diff --git a/frontend/src/components/DetailPanel.jsx b/frontend/src/components/DetailPanel.jsx index 78516bb..a212ea9 100644 --- a/frontend/src/components/DetailPanel.jsx +++ b/frontend/src/components/DetailPanel.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { Eye, Pencil, GitCompare, AlertTriangle, ShieldCheck, Copy, Check, Hash } from 'lucide-react' import { Spinner } from './ui/Spinner' import { MetadataTable } from './MetadataTable' +import { RecordNotices } from './RecordNotices' import { MetadataEditor } from './MetadataEditor' import { DiffView } from './DiffView' import { metalens } from '../api/client' @@ -151,9 +152,9 @@ export function DetailPanel({ filePath, filePathB, onUndoPush, runExclusive, mut
{record.handler_name} · {record.fields?.length ?? 0} fields {record.read_errors?.length > 0 && - + - {record.read_errors.length} warning{record.read_errors.length > 1 ? 's' : ''} + {record.read_errors.length} read error{record.read_errors.length > 1 ? 's' : ''} }
@@ -191,6 +192,7 @@ export function DetailPanel({ filePath, filePathB, onUndoPush, runExclusive, mut )} {!loading && !error && filePath && tab === 'view' && (
+
diff --git a/frontend/src/components/DiffView.jsx b/frontend/src/components/DiffView.jsx index f61f2e8..74106c7 100644 --- a/frontend/src/components/DiffView.jsx +++ b/frontend/src/components/DiffView.jsx @@ -3,6 +3,7 @@ import { GitCompare, Download, ShieldCheck, CheckCircle2, XCircle, Hash } from ' import { Button } from './ui/Button' import { Spinner } from './ui/Spinner' import { metalens } from '../api/client' +import { formatValue, uniqueKeys, valueTitle } from '../lib/formatValue' const HASH_ALGORITHMS = ['md5', 'sha1', 'sha256', 'sha512', 'blake2b'] @@ -152,25 +153,24 @@ export function DiffView({ fileA, fileB }) { {diff.only_in_a.length > 0 && (
- {diff.only_in_a.map(f => ( - + {withKeys(diff.only_in_a, f => f.key).map(([f, key]) => ( + ))}
)} {diff.only_in_b.length > 0 && (
- {diff.only_in_b.map(f => ( - + {withKeys(diff.only_in_b, f => f.key).map(([f, key]) => ( + ))}
)} {diff.changed.length > 0 && (
- {diff.changed.map(({ field_a, field_b }) => ( - + {withKeys(diff.changed, c => c.field_a.key).map(([{ field_a, field_b }, key]) => ( + ))}
)} @@ -193,27 +193,43 @@ function Section({ title, color, children }) { ) } -function DiffRow({ label, valueA, valueB, variant }) { +function withKeys(items, getKey) { + const keys = uniqueKeys(items, getKey) + return items.map((item, i) => [item, keys[i]]) +} + +function DiffCell({ field, className }) { + if (!field) return
—
+ return ( +
+ {formatValue(field)} +
+ ) +} + +function DiffRow({ label, fieldKey, a, b, variant }) { const rowBg = variant === 'a' ? 'bg-cyber-danger/5' : variant === 'b' ? 'bg-cyber-success/5' : 'bg-cyber-warning/5' const colA = variant === 'a' ? 'text-cyber-danger' : variant === 'changed' ? 'text-cyber-cyan' : 'text-cyber-dim' const colB = variant === 'b' ? 'text-cyber-success' : variant === 'changed' ? 'text-cyber-purple' : 'text-cyber-dim' return (
-
{label}
-
{valueA}
+
{label}
+
→
-
{valueB}
+
) } export function makeCsv(diff, nameA, nameB) { + // Same text as the table (lists joined with "; "), binary hashes in full. + const cell = f => formatValue(f, { full: true }) const rows = [['Field', nameA, nameB, 'Status']] - diff.only_in_a.forEach(f => rows.push([f.label, String(f.value ?? ''), '', 'only_in_a'])) - diff.only_in_b.forEach(f => rows.push([f.label, '', String(f.value ?? ''), 'only_in_b'])) + diff.only_in_a.forEach(f => rows.push([f.label, cell(f), '', 'only_in_a'])) + diff.only_in_b.forEach(f => rows.push([f.label, '', cell(f), 'only_in_b'])) diff.changed.forEach(({ field_a, field_b }) => - rows.push([field_a.label, String(field_a.value ?? ''), String(field_b.value ?? ''), 'changed'])) - diff.identical.forEach(f => rows.push([f.label, String(f.value ?? ''), String(f.value ?? ''), 'identical'])) + rows.push([field_a.label, cell(field_a), cell(field_b), 'changed'])) + diff.identical.forEach(f => rows.push([f.label, cell(f), cell(f), 'identical'])) return rows.map(r => r.map(cell => { const text = String(cell) diff --git a/frontend/src/components/FieldInput.jsx b/frontend/src/components/FieldInput.jsx new file mode 100644 index 0000000..8ad7542 --- /dev/null +++ b/frontend/src/components/FieldInput.jsx @@ -0,0 +1,117 @@ +import { Plus, X } from 'lucide-react' +import { addItem, inputKind, isList, itemField } from '../lib/serializeValue' + +const BOX = `bg-cyber-bg border border-cyber-border rounded px-2 py-0.5 text-xs font-mono text-cyber-text + focus:border-cyber-cyan focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed transition-colors` +const INVALID = 'border-cyber-danger/70' + +/** + * Typed input for a field draft (see lib/serializeValue). `label` is the accessible + * name of the input (list items: "