diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82ac217..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: @@ -217,37 +231,70 @@ 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 - runs-on: ubuntu-latest + 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 rpm 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 \ --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 + 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' @@ -264,26 +311,26 @@ 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: 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 - 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}-linux-x64.tar.gz" \ -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")" - echo "Created MetaLens-${VERSION}-Linux.tar.gz" + echo "Created MetaLens-${VERSION}-linux-x64.tar.gz" else echo "Warning: no MetaLens-linux-* directory found, skipping tar.gz" fi @@ -293,13 +340,73 @@ jobs: name: linux-packages-beta 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 (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, build-sidecar-linux-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 rpm-build + - uses: actions/checkout@v7 + - 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' + 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 @@ -350,6 +457,7 @@ jobs: files: | artifacts/windows-installer-beta/** artifacts/linux-packages-beta/** + artifacts/linux-rpm-beta/** # ───────────────────────────────────────────────────────────────────────────── # STABLE RELEASE (main only) — Rebuild from main with stable metadata @@ -415,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: | @@ -427,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: @@ -474,37 +579,70 @@ 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] - runs-on: ubuntu-latest + 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 rpm 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 \ --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 + 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' @@ -517,26 +655,26 @@ 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: 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 - 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}-linux-x64.tar.gz" \ -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")" - echo "Created MetaLens-${VERSION}-Linux.tar.gz" + echo "Created MetaLens-${VERSION}-linux-x64.tar.gz" else echo "Warning: no MetaLens-linux-* directory found, skipping tar.gz" fi @@ -553,13 +691,76 @@ jobs: name: linux-packages-stable 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 (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, build-sidecar-linux-stable] + 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 rpm-build + - uses: actions/checkout@v7 + - 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' + 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 @@ -607,3 +808,4 @@ jobs: files: | artifacts/windows-installer-stable/** artifacts/linux-packages-stable/** + artifacts/linux-rpm-stable/** diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9e14e8d..f26a742 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,16 +30,16 @@ jobs: uses: actions/checkout@v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.2 + uses: github/codeql-action/init@v4.39.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} config-file: ./.github/codeql/codeql-config.yml - name: Autobuild - uses: github/codeql-action/autobuild@v4.38.2 + uses: github/codeql-action/autobuild@v4.39.1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.38.2 + uses: github/codeql-action/analyze@v4.39.1 with: category: /language:${{ matrix.language }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f02a8a3..7b8c3c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,260 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) — --- +## [0.4.0] — 2026-11-02 + +### Security +- API responses no longer contain the raw text of an exception (which could carry paths or library + internals): warnings and Undo notices use a short fixed description (`describe_error`, e.g. "permission + denied"); the full detail goes to the local log only. +- 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 +- **Folder tree navigation.** The Home bookmark now expands the tree along the whole path (drive → … → Home), + selects the folder and scrolls it into view. Right-clicking a folder opens a menu: *Expand* / *Collapse* + (that folder only), *Expand all* (every folder of the tree; folders not yet loaded are read down to 3 + levels from the drive root) and *Collapse all*. +- **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. +- **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. +- 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. 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). + +### Dependencies +- Python: fastapi 0.141.1 → 0.162.0, pypdf 6.21.2 → 6.21.4, pytest 9.1.1 → 9.2.0, + pytest-asyncio 1.4.0 → 1.4.1. +- Frontend: react and react-dom 19.3.0 → 19.3.1, lucide-react → 1.65.0, @vitejs/plugin-react + 6.1.1 → 6.1.2. Electron: 44.9.3 → 45.1.0. +- GitHub Actions: github/codeql-action 4.38.2 → 4.39.1. +- Tests: with pytest 9.2 `monkeypatch.setattr` on Starlette's `app.state` removes the attribute at + teardown; `python/tests/conftest.py` now restores the sidecar token with an autouse fixture. + +### 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. +- 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`. +- 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`. + +--- + ## [0.3.5] — 2026-10-27 ### Security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80edde6..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 @@ -188,6 +190,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 + +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. + --- ## 🔧 Dependency Updates @@ -200,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 @@ -211,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 ``` --- @@ -230,5 +239,5 @@ Thank you for contributing to MetaLens! Your efforts help make file metadata man --- -*Last updated: 2026-10-05* +*Last updated: 2026-11-02* *← [Code of Conduct](./CODE_OF_CONDUCT.md) | [Security →](./SECURITY.md)* diff --git a/README.md b/README.md index de8d568..1c5314c 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)). --- @@ -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,21 +67,24 @@ 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 -- 💾 **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 --- ## 🔧 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** (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: 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) @@ -89,7 +92,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** 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 @@ -116,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 @@ -154,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) @@ -193,7 +201,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. @@ -238,5 +246,5 @@ Distributed with MIT License · [View License](LICENSE) --- -*Last updated: 2026-10-27* +*Last updated: 2026-11-02* *← [Requirements](docs/REQUIREMENTS.md) | [Docs →](docs/)* diff --git a/SECURITY.md b/SECURITY.md index eb5f548..c2a2f02 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 +- **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 --- @@ -22,13 +23,13 @@ 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 - ✅ 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 --- @@ -37,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 --- @@ -106,5 +108,5 @@ We appreciate responsible disclosure and will credit you in the security advisor --- -*Last updated: 2026-10-27* +*Last updated: 2026-11-02* *← [Contributing](./CONTRIBUTING.md) | [Back to README →](./README.md)* diff --git a/docs/API.md b/docs/API.md index d4c2938..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 the About dialog and by Electron to know the sidecar is ready. +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-27* +*Last updated: 2026-11-02* *← [Usage](./USAGE.md) | [Back to README →](../README.md)* diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 7b400fd..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] @@ -33,37 +33,44 @@ Step-by-step instructions for installing MetaLens on Windows and Linux, plus bui ## 🐧 Linux Installation +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 -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 +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 ``` ### 📦 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-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-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-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. +> 📖 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. --- ## ✅ 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) @@ -86,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 @@ -103,6 +110,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 (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. + --- ## 🔧 Troubleshooting @@ -128,9 +141,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 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`. --- @@ -149,5 +162,5 @@ sudo apt --fix-broken install # Debian/Ubuntu, after a failed .deb install --- -*Last updated: 2026-10-05* +*Last updated: 2026-11-02* *← [Requirements](./REQUIREMENTS.md) | [Usage →](./USAGE.md)* diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index ad5d8ff..31e6b80 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 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+ | --- @@ -21,12 +21,14 @@ 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:** `.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. @@ -49,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 | --- @@ -61,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. @@ -71,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 --- @@ -89,5 +91,5 @@ See [Installation Guide → Build from Source](./INSTALLATION.md#-build-from-sou --- -*Last updated: 2026-10-05* +*Last updated: 2026-11-02* *← [README](../README.md) | [Installation →](./INSTALLATION.md)* diff --git a/docs/USAGE.md b/docs/USAGE.md index ec374fa..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,21 +90,44 @@ 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. --- ## ℹ️ 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. + +--- + +## 🔔 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-07-14* +*Last updated: 2026-11-02* *← [Installation](./INSTALLATION.md) | [API Reference →](./API.md)* diff --git a/electron/main.js b/electron/main.js index 3728a40..2cfe8c9 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,10 @@ 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') // ─────────────────────── Windows/Squirrel install & update events ──────────── // Squirrel (electron-forge maker-squirrel) launches the app with special flags @@ -36,6 +40,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 +110,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 +197,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'], @@ -151,44 +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: '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 ────────────────────────────────────────────── @@ -234,6 +290,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/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/package-lock.json b/electron/package-lock.json index 03601d3..5c35056 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "metalens", - "version": "0.3.5", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalens", - "version": "0.3.5", + "version": "0.4.0", "license": "MIT", "devDependencies": { "@electron-forge/cli": "^8.0.1", @@ -14,7 +14,7 @@ "@electron-forge/maker-rpm": "^8.0.1", "@electron-forge/maker-squirrel": "^8.0.1", "@electron-forge/maker-zip": "^8.0.1", - "electron": "^44.9.3" + "electron": "^45.1.0" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -1059,9 +1059,9 @@ } }, "node_modules/electron": { - "version": "44.9.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-44.9.3.tgz", - "integrity": "sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==", + "version": "45.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-45.1.0.tgz", + "integrity": "sha512-i7qKVAh/KR5v1pf56NhGGX2QYHcdjvLgS7koL6ui50c+zUDGLRunR95nXcTpHPURKOQ4GDBG8BmXiDgOQ8DD0A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/electron/package.json b/electron/package.json index f8a1e9d..712a6c6 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "metalens", - "version": "0.3.5", + "version": "0.4.0", "description": "Universal File Metadata Manager", "author": "Graziano Mariella", "license": "MIT", @@ -16,6 +16,6 @@ "@electron-forge/maker-rpm": "^8.0.1", "@electron-forge/maker-squirrel": "^8.0.1", "@electron-forge/maker-zip": "^8.0.1", - "electron": "^44.9.3" + "electron": "^45.1.0" } } 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/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 bb8e686..97f1aa8 100644 --- a/electron/test/preload-client.test.js +++ b/electron/test/preload-client.test.js @@ -43,8 +43,33 @@ 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' }) }) + +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 dd8a6cf..9069775 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,23 +1,23 @@ { "name": "metalens-frontend", - "version": "0.3.5", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalens-frontend", - "version": "0.3.5", + "version": "0.4.0", "dependencies": { "@fontsource/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", - "lucide-react": "^1.48.0", + "lucide-react": "^1.65.0", "react": "^19.3.0", - "react-dom": "^19.3.0" + "react-dom": "^19.3.1" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@testing-library/react": "^16.3.3", - "@vitejs/plugin-react": "^6.1.1", + "@vitejs/plugin-react": "^6.1.2", "jsdom": "^30.1.3", "tailwindcss": "^4.3.3", "vite": "^8.3.5", @@ -1030,7 +1030,9 @@ "license": "MIT" }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.1", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", "dev": true, "license": "MIT", "dependencies": { @@ -1042,7 +1044,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", - "oxc-transform-react": "^0.145.0", + "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -1677,10 +1679,18 @@ } }, "node_modules/lucide-react": { - "version": "1.48.0", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.65.0.tgz", + "integrity": "sha512-roSdtzudEY39i2hn5LLjA8toYfhF2z4S10WSqZF4ubXpHlOmNTvaxMxZLdJfQDi6pEifQWX+cRmYihKSsvAxfA==", "license": "ISC", "peerDependencies": { + "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/lz-string": { @@ -1823,20 +1833,24 @@ } }, "node_modules/react": { - "version": "19.3.0", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.3.0", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", "license": "MIT", "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.3.0" + "react": "^19.3.1" } }, "node_modules/react-is": { diff --git a/frontend/package.json b/frontend/package.json index 1f4a1d8..9f1ed68 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "metalens-frontend", - "version": "0.3.5", + "version": "0.4.0", "private": true, "type": "module", "scripts": { @@ -13,14 +13,14 @@ "dependencies": { "@fontsource/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", - "lucide-react": "^1.48.0", + "lucide-react": "^1.65.0", "react": "^19.3.0", - "react-dom": "^19.3.0" + "react-dom": "^19.3.1" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@testing-library/react": "^16.3.3", - "@vitejs/plugin-react": "^6.1.1", + "@vitejs/plugin-react": "^6.1.2", "jsdom": "^30.1.3", "tailwindcss": "^4.3.3", "vite": "^8.3.5", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8ffa371..0d1de78 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,10 +5,14 @@ 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 { 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) @@ -16,28 +20,65 @@ 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) // Splitter widths (px) 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(() => { - if (!window.electronAPI) return + 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) - 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') + } + }, []) + + // 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() } - }, [selectedFile, undo, canUndo]) + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) + + useEffect(() => { + const unsubscribe = window.electronAPI?.onMenuCheckUpdates?.(() => { + setShowAbout(true) + checkUpdates() + }) + return () => { if (typeof unsubscribe === 'function') unsubscribe() } + }, [checkUpdates]) async function openFolderDialog() { if (!window.electronAPI) return @@ -46,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) + } } } @@ -108,7 +171,7 @@ export default function App() { Open Folder {fileB && ( @@ -123,7 +186,13 @@ export default function App() { {undoMsg} )} -
+
+ {updates.notice && ( + + )}
@@ -132,7 +201,8 @@ export default function App() {
{/* Left: Folder tree */}
- + setFilesReloadKey(value => value + 1)} />
{/* Resize handle L */} @@ -142,6 +212,7 @@ export default function App() {
@@ -164,7 +235,7 @@ export default function App() {
{/* About dialog */} - {showAbout && setShowAbout(false)} />} + {showAbout && setShowAbout(false)} />}
) } 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/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/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: "