Updates the 2026-05-22 audit to reflect the three shipped fixes:
- #2 yarn audit CI gate (PR #196)
- #3 devDependency vulnerabilities 42 → 0 (PR #196)
- #4 style-src 'unsafe-inline' removed from all three CSP layers (PR #197)
New score: 72/82 (was 66/82). Sole remaining HIGH: .env keystore password.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#193
Migrate three inline-style React props to CSS classes / CSSOM:
- ErrorExpansion: cursor:copy and copy-hint color moved to BEM classes
- SegmentedControl: dynamic transform now driven by --ec-segment-offset CSS var, set via useLayoutEffect + ref.style.setProperty
Remove 'unsafe-inline' from style-src in all three enforcement layers:
- vite.config.web.ts (prod only; dev keeps it for HMR)
- nginx.conf (both CSP directives)
- public/_headers (Cloudflare Pages)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upgrades @capacitor/cli to ^7.6.5 and vite to ^7.3.3, adds yarn resolutions to force patched transitive versions for minimist, ansi-regex, rollup, picomatch, postcss and brace-expansion. Reduces audit findings from 42 (2 critical, 21 high, 19 moderate) to zero. Wires yarn audit --level high into CI test job.
Fixes silent file-delivery failure on Android Capacitor APK. Cleaned files now save via SAF (user picks location) and share via native intent (FileProvider-wrapped URI).
- Custom Capacitor plugin (SaveToDownloadsPlugin.java) with saveAs() (SAF) and share() (ACTION_SEND).
- FileProvider configured in AndroidManifest + res/xml/file_paths.xml.
- BrowserFileBytes writes cleaned bytes to app-private cache; URI stashed for Save/Share.
- Per-row Save + Share icons; per-batch Save zip + Share zip buttons.
- outputPath added to FileEntry to thread the cleaned-output path through to the UI.
- @capacitor/filesystem ^7 added; @capacitor/share intentionally not used (rejects content:// URIs).
Closes#186
Scored 66/82. High: plaintext keystore password in .env. Medium: dev-dep vulns (minimist critical, ansi-regex high), no SCA in CI, style-src unsafe-inline in all CSP layers.
Adds FfmpegFallbackStrategy as a peer to ExifToolFallbackStrategy, routing MP4/MOV/M4V (Phase 1) and MKV/WebM (Phase 2) through @ffmpeg/core. On by default for all three distributions (standalone HTML, Capacitor APK, PWA self-host); VITE_ENABLE_FFMPEG_FALLBACK=false opts out. Takes priority over VideoStrategy for the MP4 family; VideoStrategy stays registered as the opt-out fallback until a subsequent PR deletes it.
Closes#182. Closes#43.
Resolves the four documented walker KNOWN_GAPS categorically: handler-name leak (#38), compressor-name leak (#39), mvhd.next_track_id leak (#111), GPMF/GPS coordinates leak (#42). On gopro-fusion.mp4 (5.1 MB GPMF + tmcd + fdsc) and dji-phantom4.mov (236 MB UserData GPS log) the forensic battery reports zero device-fingerprint survival across every recovery technique.
Key architectural choices:
- **Main-thread @ffmpeg/core, not @ffmpeg/ffmpeg wrapper.** The wrapper hardcodes type:"module" Workers from Blob URLs, which fail silently under null-origin file:// in Chromium — the standalone build hung forever on every video strip. @ffmpeg/ffmpeg dropped from package.json.
- **Stream mapping -map 0 -map -0:d? -map -0:s? -map -0:t?**. Preserves input track order while dropping data/subtitle/timecode streams. Avoids the eng→und reorder bug of -map 0:v?/-map 0:a?, and sidesteps mat2's exit-234 on action-cam files (GoPro Fusion has tmcd/fdsc).
- **Post-strip pass rewrites the udta box type to 'free'** (ISO/IEC 14496-12 §8.1.2 padding) to neutralise ffmpeg's hardcoded HandlerType:Metadata + HandlerVendorID:Apple stub. Length-preserving so stco/co64 offsets stay valid. Handles both regular and largesize headers via headerStart+4.
- **mdhd.language left as ffmpeg's 'und'** — considered zeroing but reverted: 0x0000 is an invalid ISO 639-2/T code, ffprobe falls back to displaying '(eng)' for invalid codes (actively misleading downstream tools).
- **Diff race fix.** @uswriting/exiftool's parseMetadata uses module-level singletons (Perl, MemoryFS, stdout/stderr StringBuilders). WasmProcessor now serializes all diff builds across the processor's lifetime via a Promise chain — guarantees no two parseMetadata calls overlap, whether within an entry or across the fire-and-forget chunk-drained queue.
- **ExifTool family-1 group names surfaced verbatim** — IFD0, ExifIFD, XMP-dc, Track1, etc. Refuses to collapse to umbrella labels like 'EXIF' because the collapse caused (source, name) key collisions across sub-groups (Track1:HandlerType vs Track2:HandlerType produced spurious diffs on multi-track MP4).
- **Standalone HTML stays single-file.** Two-asset Vite plugin gzip+base64-inlines ffmpeg-core.js + ffmpeg-core.wasm into <script type=text/plain> tags, mirroring the zeroperl pattern. With tree-shaking via __WITH_STANDALONE_INLINE__ the standalone HTML went 116MB → 24MB.
Forensic verification: docs/forensic/ffmpeg-fallback.md + tools/forensic/ffmpeg-fallback.ts cover synthetic-mp4/mkv/webm + phone-baseline (2.7MB Android) + gopro-fusion (5MB action-cam) + dji-phantom4 (236MB drone) with zero sentinel/fingerprint survival across the recovery battery. Gap analyses for all three formats at docs/gap-analysis/mp4-ffmpeg.md, mkv.md, webm.md. POC at docs/poc/ffmpeg-wasm.md.
Production deps go from 5 → 6: @ffmpeg/core@0.12.10 (GPL-2.0-or-later; combined distributable inherits, MIT codebase unchanged, source pointer in README per GPL compliance).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local-only Android release signing, env-var driven. The maintainer keeps the .jks on their machine; CI age-encrypted-in-repo variant deferred to a follow-up. Adds signingConfigs.release reading 4 env vars (v1+v2+v3 enabled), scripts/build-apk-local.sh --release with .env auto-sourcing and --env-file secret handling, .env.sample template, root .gitignore for *.jks/*.keystore, peer-promoted Signed release APK section in docs/android-apk.md, and new docs/android-signing.md with the keystore ceremony, threat model, backup checklist, and rotation procedure.
Closes#165.
Capacitor 7 enables edge-to-edge on Android by default, so the WebView extends behind the system status bar. Add viewport-fit=cover to the meta viewport tag and apply env(safe-area-inset-top, 0px) to .app and .settings-drawer so content clears the clock/battery area.
Desktop/web fallback is 0px — zero visual change on non-Android targets.
Closes#178. Chunk B.1 of #174.
Removes the legacy per-strategy MetadataItem[] enumeration from JPEG/PNG/MP4/fallback (ExifTool read covers them richer than our walkers ever did), while keeping Office and PDF walker emission plumbed through a new walkerEntries: MetadataEntry[] channel on StripResult.
Plus two perf fixes:
- Standalone WASM stashed in <script type='text/plain'> instead of inlining the 25 MB Base64 as a module-scope JS literal (~500-1500ms page-load saved)
- Deferred + chunked diff drain so directory drops don't compete with strip CPU on the main thread; cap-eviction regression fixed so all entries get their diff regardless of batch size
Chunk B of #174. Pivots the diff view (#22) from per-strategy delta to a unified ExifTool dump on both source + stripped bytes, displayed in a diffchecker-style two-pane layout.
Refs #22#174#176
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands chunk A of #174: ExifTool-in-WASM fallback strategy. Pins @uswriting/exiftool@1.0.9 + @6over3/zeroperl-ts@1.0.10, claims .webp/.gif/.avif after per-format forensic verification (zero sentinel survival), and wires the strategy last in strategy_registry.ts behind the VITE_ENABLE_EXIFTOOL_FALLBACK build flag (default on; escape hatch only).
Browser bundling: Vite emits zeroperl.wasm via a ?url asset import + dynamic-import-inside-typeof-window guard so vitest/tsx don't try to resolve the ?url suffix. The 25 MB WASM is served via runtimeCaching (CacheFirst) — fetched on first WebP/GIF/AVIF drop and cached forever after; JPEG users never trigger the fetch.
Forensic harness adopts KNOWN_GAPS / UNVERIFIABLE discipline from tools/forensic/video.ts. Surfaced TIFF leaking IFD0 tags (ExifTool limitation; not claimed) and BMP being unwriteable by ExifTool (not claimed). HEIC/HEIF deferred to chunk C (fixture builder requires libheif).
No-network proof: zero sock_* imports in zeroperl.wasm; static grep finds no JS network primitives; dynamic verification under `node --permission` (no --allow-net) passes.
Refs #174 (does not close — chunks B-F still ahead).
POC of @uswriting/exiftool + @6over3/zeroperl-ts as an opt-in path to RAW/HEIC/AVIF coverage on the standalone + APK targets.
Verified zero outbound network capability (no sock_* in the WASM imports). Sentinel-survival = 0 on JPEG/PNG/MP4/AVIF; PDF write blocked by zeroperl's missing dynamic Perl module loading; ~900 ms/file perf so the engine is on-demand only.
Recommendation lands in docs/poc/webperl-exiftool.md: two thin strategies (ExifToolFallbackStrategy + ExifToolDiffStrategy) sharing one lazy-loaded engine, opt-in via build flag for standalone HTML + Android APK targets.
Implementation tracked in #174.
## Summary
Bring all direction-flavoured docs into sync with the May 2026 state of the project.
**Primary distribution targets are now:**
1. **Desktop offline standalone HTML** (`dist/web-standalone/index.html`) — produced by `yarn build:web:standalone`
2. **Android APK** (Capacitor wrapper) — produced by `.github/workflows/build-android.yml` or `scripts/build-apk-local.sh`
**Demoted to secondary:**
- The deployed web PWA (`dist/web/`) is still buildable and self-hostable via the included Docker + Cloudflare Pages paths, but is no longer the recommended user-facing distribution.
**Out of scope:**
- iOS in any form (App Store, PWA via Safari, Add to Home Screen).
## What changed
| File | Change |
|---|---|
| `.claude/rules/project-direction.md` | Main rewrite: "One code path, two distribution targets"; "Mobile = Android APK, not iOS"; "What's NOT in scope" updated; Phase E.1 issue list recast |
| `.claude/rules/modernization-roadmap.md` | Phase E.1 table: #50 (iOS Photos picker) and #52 (PWA install prompt UX) marked out-of-scope; #23 recast from Web Share Target PWA to Android Intent filter; "PWA is sole channel" claim brought current; key constraints updated |
| `.claude/rules/privacy-invariants.md` | §2 expanded to cover all three distribution paths (standalone inlines everything, APK uses Capacitor's localhost interceptor, self-host PWA caches via service worker) |
| `CLAUDE.md` | Top of file + Tech Stack section reflect dual primary distribution |
| `README.md` | Features list + Project Direction section reflect dual primary, iOS dropped, standalone-on-Android note updated to point at the APK |
| `docs/android-apk.md` | Line 21 note flipped (APK is now primary, not "personal-distribution / not official"); comparison table relabelled; AAR conclusion updated |
| `docs/deploying.md` | Reframed as the self-host PWA doc; iOS Safari install instructions removed; intro note clarifies this is secondary distribution |
| `docs/architecture.md` | History note brought current — mentions APK #156 + standalone HTML as primaries |
| `docs/PRIVACY_GAPS.md` | Android filesystem-isolation note updated to recommend the APK path |
| `docs/standalone-html.md` | "No PWA install" trade-off bullet now points at the Android APK |
10 files changed, 81 insertions, 58 deletions.
## Phase E issues to close as out-of-scope
This direction change makes two open issues out-of-scope. Suggested follow-up — close (not in this PR, since closing is a separate decision):
- **#50** iOS Photos picker UX note — iOS dropped entirely
- **#52** PWA install prompt UX — deployed PWA demoted to self-host only
#23 (Web Share Target PWA) is recast rather than closed — the underlying "let users share files into MetaScrub from the Android gallery" feature is still wanted, but the implementation switches from the Web Share Target API in the manifest to a Capacitor `@capacitor/share` / native Intent filter.
## What I deliberately did NOT touch
- **`CHANGELOG.md`** — historical release notes. The Phase G entry says "PWA is the sole distribution channel" which was accurate at the time; changelogs are snapshots, not living documents.
- **`docs/superpowers/plans/2026-05-14-phase-g-rollout.md` + `docs/superpowers/specs/2026-05-14-phase-g-electron-retirement-design.md`** — historical phase plans/specs describing the work as it was at the time of execution.
- **Playwright e2e mobile-iOS configs** — these test responsive layouts under an iOS Safari user agent, useful coverage independent of iOS being a shipping target. Removing them is a separate test-strategy decision, not a direction-doc concern.
- **Source code** — only two iOS references in `src/`, both in comments describing the mobile-browser landscape generally (not iOS-gated code paths). No code change needed for the direction shift.
## Test plan
- [x] All edits are documentation-only; no source code touched
- [x] No `*.md` linting in CI; prettier-check only targets `src/**/*.{ts,tsx}`
- [ ] Reviewer reads `.claude/rules/project-direction.md` first; satellites mirror its language
- [ ] Decide whether to close#50 + #52 as out-of-scope as a follow-up
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Randa <obuvuyoviz26@gmail.com>
Reviewed-on: http://forgejo.localhost:3000/forgejo_admin/exifcleaner-web/pulls/172
Fixes#164.
Two small fixes bundled:
- EmptyState: hide FolderPickButton on touch-primary devices. The button's underlying input uses webkitdirectory, unsupported on mobile browsers — tapping it falls back to a plain file picker. New shouldRenderFolderPickButton() helper gates the component on isMobileDevice(). Unit + e2e coverage included.
- file_table.css: widen the phone-width RESULT column 100->110px. The 100px budget shipped in #167 was tuned against macOS font rendering; CI's Chromium uses a different system-ui fallback that renders 'Ya estaba limpio' at ~106px, tipping the file_list_layout.spec.ts regression test over.
Fixes#102.
- Phone-width @media block shrinks STATUS/TYPE/BEFORE/AFTER/RESULT to fit iPhone 14 (390px), with the name column getting a readable share via minmax(0, 1fr).
- .file-table__error-text gets white-space: pre-wrap + overflow-wrap: anywhere so the <pre> wraps inside the row instead of clipping at the right edge.
- .file-table__cell .type-pill padding tightened to 4px horizontal so DOCX (58px on WebKit) fits the 56px TYPE column.
- .file-table__reveal hidden at phone widths (reveal.showInFolder is a no-op in browsers anyway).
- New e2e regression test parametrised over JPG/DOCX with phase-2 inject of "Ya estaba limpio" (longest result text in the i18n bundle).
Adds a self-contained local APK build path. Only Docker is required on the host — no JDK, no Android SDK, no Node. Mirrors .github/workflows/build-android.yml inside a vendored image so anyone who clones the repo can build the APK with one command.
- docker/android-builder/Dockerfile — node:22-bookworm-slim (pinned by digest) + Temurin JDK 21 + Android SDK 35 + build-tools 35.0.0 and 34.0.0 (AGP 8.7.x's silent internal minimum). JAVA_HOME symlinked via dpkg --print-architecture so the image works on amd64 and arm64.
- scripts/build-apk-local.sh — orchestrator that builds the image on first run, then runs the container as the host uid:gid with the repo bind-mounted and three repo-local cache dirs. Uses --network host (Docker Desktop 4.34+ on macOS/Windows) to dodge a default-bridge apt-download failure.
- docs/android-apk.md — splits Prerequisites into Docker (Path A, recommended) and Native (Path B), adds a Local Docker build section.
- .gitignore — adds .docker-cache/.
Forgejo 10.0.3 only implements the v1 artifact API. v4 forks (both upstream and forgejo/) speak v2. Pin to actions/upload-artifact@v3. Bump when stack moves to Forgejo 11+.
Hotfix for run #159. Forgejo runner clones every uses: reference at workflow-prep time, even for steps gated by if:. data.forgejo.org mirrors actions/* but not android-actions/*, so the clone 404s and the workflow fails before any step runs. Inline https://github.com/ URL bypasses the Forgejo mirror.
- Set Arabic as the default language for new users
- Add columnName/columnType i18n keys (English + Arabic)
- Wire FileTable.tsx headers to use t() instead of hardcoded strings
- Seed English in launchPage() so e2e specs keep asserting English strings
- Update settings_schema tests to expect ar default
Two follow-up fixes from the phase3-office-hardening review:
1. ODT meta:user-defined properties now use the meta:name attribute as the display name rather than localName, which collapsed multiple distinct properties into identical rows.
2. Added try/catch around all three parseOfficeProperties call sites in stripOoxml and stripOdt so an unexpected throw cannot escalate to an invalid-file-format error.
Closes#141.
Extracts file-stat computation into useFileStats hook. cleanedCount = Complete + NoMetadataFound, errorCount = Error. StatusBar now renders an N rejected segment when errors are present.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Populate metadataItems in VideoStrategy.strip() for all three metadata sources:
- Timestamp boxes (mvhd/tkhd/mdhd): emit modified items with QT date before/after epoch
- udta tags: walk ilst before blanking, emit removed items per iTunes tag
- uuid XMP box: emit removed XMP item with byte-size summary
Adds parseBoxesSafe() so malformed subtrees never abort the strip.
No strip policy changes.
## Summary
- `totalTagsRemoved` in `App.tsx` was hardcoded to `undefined` (a TODO placeholder left before the diff feature shipped), causing the status bar to always show "0 tags removed"
- Replace with a `reduce` over `state.files`, summing `metadataItems` entries where `action !== "kept"` — the same filter `use_process_files` already uses per file
- No new logic: data was fully populated end-to-end through every strategy → `WasmProcessor` → `AppContext`; only the read in `App.tsx` was missing
## Test plan
- [ ] Drop a JPEG/PNG/PDF/DOCX and confirm the count in the status bar is non-zero after processing
- [ ] All 481 unit tests pass (`yarn test`)
- [ ] `yarn typecheck` clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Randa <obuvuyoviz26@gmail.com>
Reviewed-on: http://localhost:3000/forgejo_admin/exifcleaner-web/pulls/144
Closes#134. Mirrors PR #129 for `tools/forensic/office.ts`.
## Summary
- `tools/forensic/pdf.ts` now invokes mat2 (Poppler/Cairo) as a side-by-side reference alongside ExifTool and Ghostscript. Skip-if-missing locally; mat2 stays out of CI per the convention established in #129.
- New report fields: strip-tool `Producer` fingerprints, `/Info CreationDate` / `ModDate` stamps, and a rasterised-page-content flag (any `/Subtype /Image` XObject in the output).
- The fingerprint sweep runs over `qpdf --qdf` decompressed dump **and** a raw zlib brute-force pass over the input bytes — mat2 hides `/Info` inside a stream qpdf flags with an `unexpected xref entry type` warning and silently omits from the qdf output, so the brute-force pass is what surfaces its `cairo X.Y.Z` Producer + current `CreationDate`.
- `docs/forensic/pdf.md` gains the "Comparison reference: mat2" methodology subsection, mat2 columns in the results + per-sentinel tables, footnoted explanations of why mat2's strip is total (rasterisation), and an interpretation paragraph naming the structural differences.
## Findings vs the issue's open questions
- **XMP / annotations / etc.** — mat2 drops every original indirect object as a side effect of the Poppler/Cairo rewrite. 0 sentinels survive any of the 5 recovery channels.
- **Own ModDate / Producer fingerprint** — mat2 stamps `Producer = cairo 1.18.0 (https://cairographics.org)` and a fresh `/Info CreationDate` set to the current time. `ModDate` is absent. `PdfStrategy` writes none of these.
- **Rasterisation** — confirmed. mat2's output contains `/Subtype /Image` XObjects per page (category difference); the other three tools preserve vector/text content. Documented as the headline tradeoff in the writeup.
On this fixture, `PdfStrategy` and mat2 are equivalent for sentinel survival (0 each). No `docs/gap-analysis/pdf.md` update needed — there's no divergence to note.
## Test plan
- [x] `npx tsx tools/forensic/pdf.ts` → `PdfStrategy` column reports 0 leaked across all 5 recovery channels; mat2 column populated; comparison table prints clean
- [x] `yarn typecheck`
- [x] `yarn test tests/infrastructure/wasm/pdf_strategy.test.ts` (15/15)
- [x] `yarn test tests/infrastructure/wasm/` (244/244)
- [x] `yarn lint`
- [x] `yarn check:deps`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Randa <obuvuyoviz26@gmail.com>
Reviewed-on: http://localhost:3000/forgejo_admin/exifcleaner-web/pulls/140
## Summary
- Files with structural-only cleanup (Office ZIP compaction, Content_Types orphan removal) shrank in size but showed "Already clean" because `metadataItems` was empty
- Fix: also treat `outputBytes < entry.size` as a cleaned result, so any genuine byte reduction surfaces as "Cleaned" regardless of whether named items were found
- Applies to all formats — not Office-specific
## Test plan
- [ ] Drop a DOCX with no author/title/company fields — should now show "Cleaned" with the correct before/after sizes instead of "Already clean"
- [ ] Drop a DOCX with metadata fields — still shows "Cleaned" with named items in the diff
- [ ] Drop a file that was already stripped (no size change, no items) — still shows "Already clean"
- [ ] `yarn test` passes (444 tests)
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: Randa <obuvuyoviz26@gmail.com>
Reviewed-on: http://localhost:3000/forgejo_admin/exifcleaner-web/pulls/142