Compare commits

85 changed files with 6205 additions and 3291 deletions

No files matched your search

+21
View File
@@ -6,6 +6,10 @@ yarn-error.log*
# Build output
dist/
release/
test-results/
playwright-report/
Untrace-data/
build/
# Environment variables
@@ -54,3 +58,20 @@ coverage/
# Temporary folders
tmp/
temp/
# Publishing hygiene
.env.*
!.env.example
!.env.sample
!.env.template
*.pem
*.key
*.pfx
*.p12
*.keystore
id_rsa
id_ed25519
*.zip
# Vite temporary configuration bundles
*.timestamp-*.mjs
+37
View File
@@ -91,6 +91,37 @@ Light theme: `document.documentElement.setAttribute('data-theme', 'light')`. Dar
## Untrace specifics
### Batch workflows (1.6.0, 2026-09-10)
`BatchWorkspace.svelte` owns the sequential queue. `processing.js` creates a terminable worker per scan/encode task. `image-worker.js` uses the shared scanner and cleaner with OffscreenCanvas. Keep incomplete scans visibly uncertain. `desktop/preload.cjs` exposes only folder selection and saving; `desktop/output.cjs` validates the sender and creates unique files exclusively in the user-selected directory. Never expose generic IPC or unrestricted filesystem access. Saved export settings use `untrace-export`; theme settings keep their existing key.
### Themes and branding (1.3.0, 2026-09-10)
The derivative is named **Untrace Desktop**, as requested by the user. Branding
lives in `desktop/brand.json`; build metadata in `package.json` must match it.
Keep upstream Untrace attribution and the MIT license.
The user explicitly requested more themes. `src/themes.css` is the local palette
source: it overrides existing semantic colour tokens for dark, graphite, paper,
cobalt and ember without editing vendored `src/tokens.css`. Palette definitions may
contain raw colours; components must continue using tokens. Exclude both palette
files (`tokens.css`, `themes.css`) from the raw-colour self-check. No new token names
are introduced. The theme catalog and shared pre-paint bootstrap are in
`src/lib/theme-catalog.js`. Appearance is a native dialog with radio controls.
### Desktop workspace (1.2.0, 2026-09-10)
The active application is `src/lib/Workspace.svelte`, mounted by `src/App.svelte`.
It uses a full-window preview and inspector, not the historical landing page or
stepper. Older website components remain as upstream reference and are not imported.
The active stylesheet is `src/app.css`; it composes the existing tokens and keeps
comfortable density. There is one primary action: choose an image, clean it, or
save its result. Help is a native dialog. Keep the image processing limitations
accurate when editing copy. `npm test` verifies the workspace in Electron and
`npm run portable` creates the Windows release. Earlier specifics below describe
the upstream baseline.
Applied to this repository on 2026-09-05, design system v2.0.0.
- `src/tokens.css` is the vendored copy. `src/app.css` imports it and adds only
@@ -127,3 +158,9 @@ NEW TOKEN breakpoints — 560px / 768px / 900px
`src/lib/clean.js` contains one literal hex, `JPEG_MATTE = "#FFFFFF"`. That is
image data written to a canvas, not interface styling, so it is intentionally
not a token.
### Windows distribution (1.7.0, 2026-10-25)
`npm run release:windows` builds separate x64/x86 installer and portable packages. `scripts/build-windows.mjs` pins Electron 44.3.0 for x64 and 43.8.3 for x86 because 44 removed ia32 binaries. Electron 43 support ends in January 2027; do not silently remove that support limitation from the docs. Installed settings use `%APPDATA%\Untrace Desktop`; portable launcher settings remain in the existing adjacent `Untrace-data` folder. `desktop/data-path.cjs` centralizes this distinction. Preserve settings on uninstall. Run installer checks sequentially and separately from app tests, because the installer closes other processes with the application executable name.
Both remotes default to `main`. See `docs/UPSTREAM-REVIEW.md` before importing upstream website changes. Regenerate real-workspace screenshots with `npm run screenshots` using only the generated sample artwork.
+89
View File
@@ -0,0 +1,89 @@
# Changelog
## 1.7.0 - 2026-10-25
- Added per-user Windows installers and portable launchers for x64 and x86.
- Store installed-app preferences in the user's application-data directory; preserve portable settings beside the launcher.
- Use Electron 43.8.3 for x86 because Electron 44 no longer ships 32-bit binaries; keep Electron 44.3.0 for x64.
- Added reproducible full-workspace screenshots with generated sample artwork.
- Reviewed upstream through 6d5e92b; no outstanding image-engine fixes require importing the web interface.
- Set main as the default branch on both hosting services.
## 1.6.1 - 2026-09-10
- Filter folder selections, multiple-file selections and drag-and-drop before opening a batch: only supported images enter the queue. Non-image files no longer appear as skipped rows.
- Show a clear message when a selection contains no supported images, preserving the current workspace.
- Added regression coverage for documents and executables inside nested folders and mixed drag-and-drop selections.
## 1.6.0 - 2026-09-10
- Added batch cleaning for multiple files, selected folders and dropped folders, including nested contents and per-file results.
- Added native output-folder selection and collision-safe batch saving. Existing files are preserved; failed saves retain prepared outputs for retry.
- Added before/after metadata comparison with field values, detected changes and explicit uncertainty when parsing fails. GPS and camera fields are prioritised.
- Added prominent original-image GPS warnings and coordinate display without online maps.
- Added an exact export-size preview that reuses its prepared output, plus batch output sizes before saving.
- Remember export format and JPEG quality across launches and share settings between single-image and batch modes.
- Moved metadata scanning and image encoding into background workers. Cancel stops active processing; batch saving finishes the current file before stopping the remaining queue.
- Release saved batch image blobs to reduce retained memory; keep comparison details available for the session.
- Preserve previously discarded parsed metadata fields and replace misleading clean claims after parser errors with incomplete-scan reporting.
- Report encoder-generated colour profiles and avoid an unnecessary second JPEG pass for those profiles.
- Added regression tests for the new workflows, file collisions, path validation, cancellation, save failures and retries. Updated README and portable build.
## 1.5.0 - 2026-09-10
- Added eight VS Code-inspired palettes: One Dark, Dracula, Tokyo Night, Night Owl, GitHub Dark, GitHub Light, Catppuccin Mocha and Catppuccin Latte. Retained all six existing themes and saved preferences.
- Reworked Appearance into a wider, scrollable gallery grouped into editor favourites and Untrace originals, with the heading and Done button kept in view.
- Adapted secondary text and action colours for readable app controls; theme choices still leave image pixels unchanged.
- Added theme inspiration credits and extended the existing contrast checks to all fourteen palettes.
## 1.4.0 - 2026-09-10
- Replaced Midnight, Forest, Plum and Sand with four redesigned themes: Graphite, Paper, Cobalt and Ember. Light and Dark are unchanged.
- Use neutral surfaces and stronger tonal separation rather than colouring every surface with the accent hue.
- Added a distinct dark navy frame to Cobalt's light workspace; Graphite uses silver controls, Paper uses dark olive ink, and Ember uses warm orange accents.
- Migrate saved selections: Midnight to Cobalt, Forest to Graphite, Plum to Ember, and Sand to Paper.
- Updated previews and documentation, and checked text contrast in both the workspace and Cobalt's contrasting frame.
## 1.3.0 - 2026-09-10
- Rebranded this derivative as **Untrace Desktop**, including the workspace, Help, window title, Windows product metadata and portable executable name. Retained original Untrace attribution and MIT license.
- Added Midnight, Forest, Plum and Sand palettes alongside Light and Dark.
- Replaced the light/dark toggle with an Appearance dialog containing live theme previews and accessible radio controls.
- Added Follow Windows, which tracks system theme changes while the app is open.
- Persist theme choices, retain legacy light/dark preferences, and apply saved themes before first paint using shared startup logic.
- Preserve the existing `Untrace-data` folder and storage origin across the rename.
- Added contrast, persistence, system preference and rebranding checks. Theme changes affect the interface only.
## 1.2.0 - 2026-09-10
- Replaced the landing page with a desktop workspace: application rail, file toolbar, central preview, persistent inspector, export controls and status bar.
- Removed the promotional sections and moved usage guidance into an accessible Help dialog.
- Added original/cleaned preview switching, fit/actual-size viewing and responsive layouts for smaller windows.
- Added automatic, PNG and JPEG export options, with JPEG quality controls.
- Fixed PNG output being unnecessarily converted to JPEG when required IHDR image headers were mistaken for removable metadata; PNG export now preserves transparency.
- Added Ctrl+O (open), Ctrl+V (paste), Ctrl+Enter (clean), Ctrl+S (save), Esc (close image), and F1 (help).
- Accept drag-and-drop and pasted images throughout the workspace; release replaced previews and invalidate exports after setting changes.
- Replaced the misleading “already clean” message with a qualified metadata scan result.
- Simplified startup markup, removed unused website UI from the bundle, and retained offline Electron packaging.
- Extended integration checks for themes, keyboard controls, PNG alpha, export invalidation, preview switching and small-window layout.
## 1.1.0 - 2026-09-10
- Added a Windows x64 portable executable with bundled Electron runtime.
- Added offline packaging, content security policy, sandboxed rendering, denied permissions, blocked network requests, and restricted external links.
- Store preferences in `Untrace-data` beside the executable.
- Fixed black backgrounds when large transparent images become JPEG by applying a white matte.
- Fixed misleading success badges and removal lists when decoding fails.
- Preserve the original filename on failure; safely handle filenames without extensions.
- Accept recognized image extensions when Windows supplies an empty or generic MIME type.
- Prevent repeated cleaning requests while processing.
- Added desktop integration tests, build commands, and Windows documentation.
## Upstream baseline - 2026-09-10
Imported developersharif/untrace at commit `99b4b223525a0b85da8ab0994066b5151d6a83fa`.
This date records the import, not the upstream release date. Original MIT license retained.
+60
View File
@@ -0,0 +1,60 @@
# Publishing Untrace Desktop
## Repository layout
The desktop edition is maintained on `main`. The pre-existing GitHub web edition is preserved on `web-before-desktop`, and its commits remain in the shared history.
- `origin`: https://github.com/donishki/Untrace.git
- `gitea`: https://git.ptbox.org/dasp/Untrace.git
- `upstream`: https://github.com/developersharif/untrace.git (reference only)
Both repositories already exist and default to `main`. Retain the MIT license and upstream attribution when distributing source or binaries.
## Verify and package
On Windows x64 with Node.js 22.13 or newer:
```powershell
npm ci
npm test
npm run release:windows
node scripts/smoke-portable.mjs x64
node scripts/smoke-portable.mjs x86
```
Review `git status --short` and `git diff` before committing. Keep dependencies, generated builds, local settings, secrets and test outputs out of Git. Inspect all newly added files as well as tracked changes. Commit the reviewed source and publishing documentation before tagging a release.
## Publish the reviewed commit
Use the current branch name for both hosts:
```powershell
$branch = git branch --show-current
git push -u origin $branch
git push gitea $branch
```
After validating the built release and ensuring the tag does not already exist:
```powershell
git tag -a v1.7.0 -m 'Untrace Desktop 1.7.0'
git push origin v1.7.0
git push gitea v1.7.0
```
Create a release on each host for the same tag. Upload exactly these six files:
- `release/x64/Untrace-Desktop-1.7.0-setup-x64.exe`
- `release/x64/Untrace-Desktop-1.7.0-portable-x64.exe`
- `release/x86/Untrace-Desktop-1.7.0-setup-x86.exe`
- `release/x86/Untrace-Desktop-1.7.0-portable-x86.exe`
- `release/Untrace-Desktop-Source-v1.7.0.zip`, made with `git archive` from the release tag
- `release/SHA256SUMS.txt`, containing hashes for all five files above
Test installed-app startup and uninstall for both architectures, preserving user preferences. Run the full packaged test suite on x86 because it uses a different Electron major version. The x64 runtime is 44.3.0; x86 uses 43.8.3 (the last supported 32-bit series, reaching end of support in January 2027). Do not change the runtime pins without repeating package tests.
Include MIT attribution, theme credits, the Windows requirements, and the unsigned-executable notice. Do not upload user data, intermediate archives, blockmaps, or test outputs. Validate the six remote assets before publishing each draft. When resuming an upload, locate drafts through the release list as tag lookup may omit drafts.
README.md links to the latest releases on both hosts. See [upstream review](docs/UPSTREAM-REVIEW.md) before importing web-edition changes. Regenerate the real application screenshots with `npm run screenshots`; the scenic image is generated test artwork.
Run installer checks sequentially, with no other Untrace processes running: NSIS uninstall closes running copies by executable name. Use scripts/test-installers.ps1 -Architecture x64, then x86, and only then run packaged regression tests. The x86 packages were verified under WOW64 on a 64-bit Windows host; native 32-bit Windows was not separately tested.
+125 -54
View File
@@ -1,80 +1,151 @@
# Untrace
<div align="center">
**Remove content credentials from an image.**
# Untrace Desktop
Untrace strips C2PA content credentials, AI generation tags, GPS coordinates
and EXIF metadata from a photo. It runs entirely in the browser — the file is
read and rewritten in the tab, never uploaded.
**Inspect image metadata. Save a cleaner copy. Keep it local.**
<https://untrace.realbrain.cc/>
An offline image metadata cleaner for Windows, with batch processing and fourteen themes.
## What it removes
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Windows x64 / x86](https://img.shields.io/badge/Windows-x64%20%7C%20x86-0078D4)](#run)
[![Version](https://img.shields.io/badge/Version-1.7.0-466853)](CHANGELOG.md)
| | |
[Download — GitHub](https://github.com/donishki/Untrace/releases/latest) · [Download — Gitea](https://git.ptbox.org/dasp/Untrace/releases/latest) · [Build and verify](#build-and-verify) · [Changelog](CHANGELOG.md) · [Publishing guide](PUBLISHING.md)
</div>
An offline desktop edition of [developersharif/untrace](https://github.com/developersharif/untrace). Inspect image metadata, rewrite the image, and save a separate copy. This locally built derivative retains the original MIT license and attribution.
## Screenshots
Real screenshots of version 1.7.0 using generated landscape artwork, without personal photos or paths.
### A focused workspace
![Dark workspace with a landscape preview and cleaned image export](docs/screenshots/workspace-dark.png)
### Light appearance
![Light workspace showing the image preview and metadata inspector](docs/screenshots/workspace-light.png)
### Batch processing
![Batch queue with four prepared images and export controls](docs/screenshots/batch-light.png)
## Run
Download from [GitHub Releases](https://github.com/donishki/Untrace/releases/latest) or [Gitea Releases](https://git.ptbox.org/dasp/Untrace/releases/latest).
| Package | Use |
| --- | --- |
| C2PA content credentials | Manifests, claims, assertions and signatures from the Content Authenticity Initiative |
| AI generation tags | ChatGPT, DALL·E, Midjourney, Stable Diffusion, Adobe Firefly, Google Imagen |
| JUMBF containers | The APP11 and APP2 boxes that carry provenance inside a JPEG |
| GPS location | Latitude, longitude, altitude, bearing and their timestamp |
| Camera and device | Make, model, lens, serial numbers, exposure and shooting settings |
| Timestamps | When the photo was taken, digitised and last modified |
| XMP and IPTC | Creator names, captions, keywords, copyright, editing history |
| Comments and thumbnails | User comments, maker notes, embedded previews |
| PNG text chunks | tEXt, iTXt and zTXt blocks, including generation prompts |
| `Untrace-Desktop-1.7.0-setup-x64.exe` | Install on 64-bit Windows. Recommended for most PCs. |
| `Untrace-Desktop-1.7.0-setup-x86.exe` | Install on 32-bit Windows. Also runs on 64-bit Windows. |
| `Untrace-Desktop-1.7.0-portable-x64.exe` | Run the 64-bit app from a writable folder or USB drive. |
| `Untrace-Desktop-1.7.0-portable-x86.exe` | Run the 32-bit portable app. |
Supported input: JPEG, PNG, TIFF, HEIC, AVIF. Output is JPEG, or PNG when the
original was a small PNG.
Installers work for your Windows account, offer an installation folder and shortcuts, and can be removed through Windows Settings > Apps. No administrator rights are required. Settings survive upgrades and uninstall. Install one architecture at a time; x64 and x86 installed editions share preferences.
## How it works
Requires Windows 10 or 11 for x64, or Windows 10 for x86. The x86 edition uses Electron 43.8.3, the last Electron series supporting 32-bit Windows; its support ends in January 2027. The x64 edition uses Electron 44.3.0. No Node.js, browser installation, or internet is required to run either edition. Executables are unsigned; Windows may show an unknown-publisher or SmartScreen prompt. Portable launchers extract the runtime to a temporary folder.
Detection and removal are two separate passes.
Open or drop an image, review the Inspector, choose an export format, select **Clean image**, and save the separate copy. You can also select several images or a folder for a batch.
**Detection** (`src/lib/metadata.js`) reads the file twice: once through
[exifr](https://github.com/MikeKovarik/exifr) for structured segments, and once
as raw bytes to find C2PA strings and JUMBF boxes that exifr does not surface.
The results are merged and bucketed into named sections, then turned into the
findings list the review step renders.
The window has an image workspace, a scrolling metadata inspector, export settings, before/after viewing, fit/100% zoom, fourteen themes and Help. The bottom status bar shows progress. There are no promotional sections or external pages required to use the app.
**Removal** (`src/lib/clean.js`) decodes the image to pixels on a canvas and
re-encodes it. Nothing carries over, so every metadata container is dropped at
once rather than deleting known tags one by one and leaving the rest behind.
The output is then re-read to confirm it is clean; if anything survived, a
second pass forces JPEG on a white matte.
| Shortcut | Action |
| --- | --- |
| Ctrl+O | Open image |
| Ctrl+V | Paste image |
| Ctrl+Enter | Clean image or remaining batch images |
| Ctrl+S | Save the cleaned copy or prepared batch files |
| Esc | Cancel active processing, close a dialog, or close the current image |
| F1 | Open Help |
If the browser cannot decode or re-encode the file, the original bytes are
returned and the interface says so — it never reports a clean that did not
happen.
## Batch cleaning
## Design
Select several files with **Open image**, use **Open folder**, or drop images/folders into an empty or single-image workspace. Folders include nested files. The batch queue scans files first, flags GPS data, and reports Ready, Cleaned, Saved, Failed or Cancelled for each file. Select a row for details and metadata comparison. Only supported image files enter the queue; documents, executables and other unsupported files are ignored entirely; failed decoding never produces a claimed clean export.
The interface follows the
[RealBrain Design System](https://realbrain.cc/design-system/) v2.0. Tokens are
vendored at `src/tokens.css`; refresh with:
Choose a format and **Clean batch**, then review the actual output sizes. Use **Choose output folder** and **Save cleaned files** to write the results. Subfolders are combined into the selected destination; duplicate names get numbered suffixes. Existing files and originals are never overwritten by batch saving. A failed save retains its prepared output so it can be retried after selecting a writable folder or freeing disk space.
```bash
curl -o src/tokens.css https://realbrain.cc/design-system/tokens.css
**Cancel processing** or **Esc** stops scanning/encoding and leaves finished results available. During saving, the current file finishes and the remaining files stop. Use the clean/save buttons to continue unfinished work. Closing the batch or the app discards unsaved results; the queue is not restored across restarts. Close the current batch before opening another selection.
Images are decoded sequentially in background workers. Prepared output blobs are held until saved or discarded, so very large batches can still use substantial memory. Saved batch blobs are released while comparison details remain available.
## Inspection and export size
A prominent **Location data in original** warning shows detected coordinates (or GPS field counts) without contacting a map service. The warning describes the original, even after cleaning.
For one image, **Preview export size** calculates the actual encoded output size using the current format and quality. It does the encoding work once; **Clean image** reuses that prepared output. Changing settings invalidates the preview and any unsaved export. In batch mode, cleaning prepares all output sizes before any files are saved.
**Compare metadata** shows detected fields before and after cleaning, with Not detected afterward, Still present, Changed, Output field or Unverified results. GPS and camera fields appear first. Scanning failures are reported as incomplete; absence of detected metadata is not proof of its absence. Required image headers and a standard colour profile generated by the encoder can appear in the output.
Format and JPEG quality are saved automatically for the next launch and shared between single-image and batch workflows. Output-folder access is requested for each new batch and is not persisted.
## Appearance
Use the palette button at the bottom left to open **Appearance**. Choose Light, Dark, Graphite, Paper, Cobalt or Ember. Graphite uses neutral charcoal and silver; Paper pairs warm white with dark olive ink; Cobalt combines a dark navy frame with a light workspace; Ember pairs carbon black with warm orange. Eight additional editor-inspired palettes are available: One Dark, Dracula, Tokyo Night, Night Owl, GitHub Dark, GitHub Light, Catppuccin Mocha and Catppuccin Latte. The picker groups editor favourites and Untrace originals in a scrolling gallery with live previews and a fixed Done button. See [theme inspirations and credits](THEME-CREDITS.md). **Follow Windows** tracks the operating system's light/dark setting, including changes while the app is open.
Choices are saved automatically and restored before the workspace first appears. Themes affect the interface only, not previews or exported image pixels. Existing light/dark preferences are retained. Earlier custom themes migrate automatically: Midnight to Cobalt, Forest to Graphite, Plum to Ember, and Sand to Paper. The renamed edition still uses the same `Untrace-data` folder so updating does not discard settings. Keep the new EXE alongside that folder.
Untrace Desktop is a derivative of developersharif's Untrace, not an official upstream release. The original project remains credited in Help and the MIT license is retained.
## Export and limitations
- **Automatic:** PNGs below one million pixels remain PNG; other decodable images become JPEG at 92% quality.
- **PNG:** preserve transparency and avoid JPEG compression. Files can be larger.
- **JPEG:** choose 85%, 92% or 100% quality. Transparent areas become white. Even 100% JPEG quality is not lossless.
- Changing export settings invalidates the previous result. Clean again before saving.
- JPEG and PNG work best. AVIF support depends on the decoder. HEIC and TIFF may need conversion to JPEG or PNG first.
- Rewriting removes metadata containers, including EXIF, GPS, XMP and C2PA content credentials. Original colour profiles, animation and additional pages are not preserved; colours can change. The encoder can add a standard sRGB profile, which is shown in metadata comparison.
- If verification detects remaining EXIF/XMP/IPTC metadata, a second pass uses JPEG at 95% quality on white and reports that conversion. Failed decoding is reported as **Not cleaned** and offers no clean download.
- Scanning is heuristic; the raw signature scan covers the first megabyte. No findings are not proof of no metadata. Verification is best effort, not a forensic certificate.
- Removing metadata does not remove visible watermarks or identifying content, guarantee anonymity, or prevent platform AI labels.
## Portable data and privacy
Portable preferences and Chromium session files live in `Untrace-data` beside the launcher. Installed and source-run editions use `%APPDATA%\Untrace Desktop`, separate from portable settings. Keep it with the EXE to retain preferences. Close Untrace Desktop before deleting that folder to reset it. The runtime also uses the Windows temporary folder, so portable does not mean forensic trace-free.
Images are processed locally and originals are not automatically overwritten. The application blocks HTTP/WebSocket requests, denies permission requests, and has no telemetry or updater. The source link in Help opens your default browser when clicked. The renderer is sandboxed, with Node integration disabled and a restrictive content security policy. A narrow preload bridge opens the native output-folder chooser and saves encoded bytes only inside that selected destination, using exclusive file creation. Image scanning and encoding run in terminable background workers.
## Why Electron?
Electron embeds Chromium and Node, which makes this portable build self-contained and gives it a consistent rendering engine. Interface appearance comes from the application design, not a special Electron theme. Electron has a substantial runtime and process-memory overhead; it is not the lightest option for a small utility.
Tauri can reuse this Svelte interface and produce a smaller package by using the system WebView2 runtime on Windows. A self-contained offline package must account for that dependency. Switching wrappers alone does not make image decoding and encoding faster; those operations need separate profiling. Electron is retained for this version's standalone portable requirement.
References: [Electron overview](https://www.electronjs.org/docs/latest/), [Electron performance](https://www.electronjs.org/docs/latest/tutorial/performance), [Tauri webviews](https://tauri.app/reference/webview-versions/).
## Build and verify
Use Node.js 22.13 or newer and npm on Windows x64 / x86. Dependency downloads require internet access.
```powershell
npm ci
npm test
npm run release:windows
node scripts/smoke-portable.mjs x64
node scripts/smoke-portable.mjs x86
```
`AGENTS.md` carries the rules, the self-check commands, and this repository's
open token proposals. Read it before touching any CSS.
Installers and portable EXEs are written to `release/x64/` and `release/x86/`. The unpacked apps are in `release/x64/win-unpacked/` and `release/x86/win-ia32-unpacked/`; keep all their files together. `npm run portable` builds both portable launchers; `npm run installer` builds both installers. `npm run desktop` launches from source, and `npm run dev` runs the same workspace in a development browser. Desktop packaging removes remote font imports from generated assets without modifying vendored tokens; system fonts are used offline.
## Development
To run the full suite against the packaged application:
```bash
npm install
npm run dev # vite dev server
npm run build # production build to dist/
npm run preview # serve the build
```powershell
$env:UNTRACE_TEST_EXE = (Resolve-Path 'release/x64/win-unpacked/Untrace Desktop.exe').Path
npx playwright test
Remove-Item Env:UNTRACE_TEST_EXE
```
Stack: Svelte 4, Vite 5, exifr. No CSS framework — the design system is plain
CSS.
Checks cover offline operation, renderer isolation, file acceptance, JPEG white matte, metadata removal, PNG transparency, export invalidation, preview switching, keyboard controls, small-window layout, saving and failure reporting. Additional checks cover real GPS-tagged JPEGs, metadata comparison, exact size previews, saved export preferences, nested-folder batches, duplicate names, worker cancellation/retry, cancelled saves, failed-save retry and output-path validation. Tests save theme screenshots and a local idle process-memory sample to `test-results/`; summed working sets can double-count shared pages and are not a cross-framework benchmark.
## Who it is for
The smoke script checks extraction and startup of the single-file launcher. Use the folder build for full Playwright Electron tests because the portable launcher does not forward the child process's debugger output. To regenerate the icon from the source SVG: `npm run build:desktop`, then `node scripts/make-icon.mjs`.
Journalists protecting sources, activists maintaining anonymity, photographers
selling stock, and anyone who would rather a photo not carry its own history.
See [CHANGELOG.md](CHANGELOG.md) for dated changes and [LICENSE](LICENSE) for the original license. Electron licenses accompany the runtime.
## Project repositories
Source and releases: [GitHub](https://github.com/donishki/Untrace) and [Gitea](https://git.ptbox.org/dasp/Untrace). The original project is [developersharif/untrace](https://github.com/developersharif/untrace).
## License
MIT.
Untrace Desktop retains the upstream MIT license, copyright (c) 2025 Sharif. Desktop modifications by Dario P. See [LICENSE](LICENSE) and [THEME-CREDITS.md](THEME-CREDITS.md).
+16
View File
@@ -0,0 +1,16 @@
# Theme inspirations
Added 2026-09-10 for Untrace Desktop 1.5.0.
These are independent palette adaptations for an image-cleaning application, not official ports or endorsed releases. No extension code or artwork is bundled. Backgrounds and familiar accent colours take inspiration from the projects below; semantic roles, surface hierarchy and text contrast are adapted for Untrace. In particular, editor comment colours are often too faint for small interface labels, so secondary text is brighter here.
| Untrace theme | Inspiration / original project |
| --- | --- |
| One Dark | [One Dark Pro](https://marketplace.visualstudio.com/items?itemName=zhuangtongfa.Material-theme), Binaryify and contributors; based on Atom One Dark |
| Dracula | [Dracula](https://github.com/dracula/dracula-theme), Zeno Rocha and contributors |
| Tokyo Night | [Tokyo Night](https://marketplace.visualstudio.com/items?itemName=enkia.tokyo-night), enkia |
| Night Owl | [Night Owl](https://marketplace.visualstudio.com/items?itemName=sdras.night-owl), Sarah Drasner and contributors |
| GitHub Dark / Light | [GitHub Theme](https://marketplace.visualstudio.com/items?itemName=GitHub.github-vscode-theme), GitHub and contributors |
| Catppuccin Mocha / Latte | [Catppuccin for VS Code](https://marketplace.visualstudio.com/items?itemName=Catppuccin.catppuccin-vsc), Catppuccin contributors |
The selection draws on established VS Code themes; it is not a measured installation ranking. All themes work offline and use the same native radio controls and shared component styles. Light, Dark, Graphite, Paper, Cobalt and Ember remain available as Untrace originals.
+4
View File
@@ -0,0 +1,4 @@
{
"name": "Untrace Desktop",
"appId": "local.untrace.desktop"
}
+9
View File
@@ -0,0 +1,9 @@
const path = require('node:path');
function dataPath({ env, appData }) {
if (env.UNTRACE_DATA_DIR) return env.UNTRACE_DATA_DIR;
if (env.PORTABLE_EXECUTABLE_DIR) return path.join(env.PORTABLE_EXECUTABLE_DIR, 'Untrace-data');
return path.join(appData, 'Untrace Desktop');
}
module.exports = { dataPath };
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+53
View File
@@ -0,0 +1,53 @@
const { app, BrowserWindow, session, protocol, net, shell, dialog, ipcMain } = require('electron');
const path = require('node:path');
const fs = require('node:fs');
const { pathToFileURL } = require('node:url');
const { name: productName, appId } = require('./brand.json');
app.setName(productName);
app.setAppUserModelId(appId);
// Portable settings travel with the launcher. Installed settings survive upgrades/uninstall.
const data = require('./data-path.cjs').dataPath({ env: process.env, appData: app.getPath('appData') });
try {
fs.mkdirSync(data, { recursive: true });
app.setPath('userData', data);
app.setPath('sessionData', data);
} catch {
dialog.showErrorBox(`${productName} cannot start`, `Move ${productName} to a writable folder and try again.`);
app.exit(1);
}
protocol.registerSchemesAsPrivileged([{ scheme: 'untrace', privileges: { standard: true, secure: true, supportFetchAPI: true } }]);
const externalHosts = new Set(['github.com', 'www.buymeacoffee.com', 'artimic.com', 'realbrain.cc']);
function openLink(url) {
try {
const target = new URL(url);
if (target.protocol === 'https:' && externalHosts.has(target.hostname)) shell.openExternal(url);
} catch {}
}
app.whenReady().then(() => {
require('./output.cjs').registerOutput({ ipcMain, dialog, BrowserWindow });
const root = path.join(app.getAppPath(), 'dist');
protocol.handle('untrace', request => {
const url = new URL(request.url);
const file = path.resolve(root, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
if (url.host !== 'app' || !file.startsWith(root + path.sep)) return new Response('Forbidden', { status: 403 });
return net.fetch(pathToFileURL(file).href);
});
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
session.defaultSession.setPermissionCheckHandler(() => false);
session.defaultSession.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, (_details, callback) => callback({ cancel: true }));
const win = new BrowserWindow({
width: 1200, height: 900, minWidth: 640, minHeight: 640,
title: productName, show: false, autoHideMenuBar: true,
webPreferences: { preload: path.join(__dirname, 'preload.cjs'), nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true },
});
win.removeMenu();
win.webContents.setWindowOpenHandler(({ url }) => { openLink(url); return { action: 'deny' }; });
win.webContents.on('will-navigate', (event, url) => {
const target = new URL(url);
if (target.protocol !== 'untrace:' || target.host !== 'app') { event.preventDefault(); openLink(url); }
});
win.once('ready-to-show', () => { if (!process.env.UNTRACE_TEST) win.show(); });
win.loadURL('untrace://app/');
});
app.on('window-all-closed', () => app.quit());
+57
View File
@@ -0,0 +1,57 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
// Exclusive creation protects originals, existing exports and case-insensitive collisions.
async function saveUnique(directory, filename, bytes) {
if (typeof filename !== 'string' || !/^[^<>:"/\\|?*\x00-\x1f]+\.(png|jpg)$/i.test(filename) || filename.length > 220 || /[. ]$/.test(filename) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])\./i.test(filename)) throw new Error('Invalid output filename.');
if (!(bytes instanceof Uint8Array) || !bytes.byteLength) throw new Error('No encoded image data.');
const extension = path.extname(filename), stem = filename.slice(0, -extension.length);
for (let index = 1; index <= 10000; index++) {
const name = `${stem}${index === 1 ? '' : '-' + index}${extension}`;
const target = path.join(directory, name);
let handle;
try { handle = await fs.open(target, 'wx'); }
catch (error) { if (error.code === 'EEXIST') continue; throw error; }
try {
await handle.writeFile(bytes);
await handle.close();
return { filename: name, path: target };
} catch (error) {
await handle.close().catch(() => {});
await fs.unlink(target).catch(() => {});
throw error;
}
}
throw new Error('Too many files with this name. Choose another output folder.');
}
function registerOutput({ ipcMain, dialog, BrowserWindow }) {
const folders = new Map();
function check(event) {
const url = new URL(event.senderFrame.url);
if (event.senderFrame !== event.sender.mainFrame || url.protocol !== 'untrace:' || url.host !== 'app') throw new Error('Untrusted sender.');
}
ipcMain.handle('output:choose', async event => {
check(event);
const selection = await dialog.showOpenDialog(BrowserWindow.fromWebContents(event.sender), { title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'] });
if (selection.canceled || !selection.filePaths[0]) return null;
const directory = await fs.realpath(selection.filePaths[0]);
const token = randomUUID();
folders.set(event.sender.id, { token, directory });
return { token, path: directory };
});
ipcMain.handle('output:save', async (event, token, filename, bytes) => {
check(event);
const folder = folders.get(event.sender.id);
if (!folder || token !== folder.token) throw new Error('Choose an output folder first.');
try { return await saveUnique(folder.directory, filename, bytes); }
catch (error) {
if (['EACCES', 'EPERM', 'EROFS'].includes(error.code)) throw new Error('The output folder is not writable. Choose another folder.');
if (error.code === 'ENOSPC') throw new Error('The drive is full. Free space or choose another folder.');
if (error.code === 'ENOENT') throw new Error('The output folder is unavailable. Choose it again.');
throw error;
}
});
}
module.exports = { saveUnique, registerOutput };
+5
View File
@@ -0,0 +1,5 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('untraceDesktop', {
chooseOutputFolder: () => ipcRenderer.invoke('output:choose'),
saveOutput: (token, filename, bytes) => ipcRenderer.invoke('output:save', token, filename, bytes),
});
+13
View File
@@ -0,0 +1,13 @@
# Upstream review — 2026-10-25
Reviewed [developersharif/untrace](https://github.com/developersharif/untrace) through commit `6d5e92b14d371ebe6133e5613259a858a79d7a0a` on upstream/main.
`git log main..upstream/main` is empty: all current upstream commits are already ancestors of this desktop edition. The earlier history-preserving merge intentionally kept the desktop working tree; ancestry alone does not mean every web feature was adopted.
The four web-edition commits after the original desktop baseline (`99b4b22`) add landing-page branding/animation, privacy guides and SEO/social assets, web batch review/ZIP downloads, and CNAME handling. They do not change `src/lib/clean.js`, `src/lib/metadata.js`, or `package.json` relative to that baseline. No scanner/cleaner fix or dependency update needs importing from these commits.
The desktop edition already implements sequential worker-based batches, cancellation, metadata comparison, and restricted native-folder saving. Importing the browser Batch component or website App/Vite configuration would replace or complicate those workflows without a required bug fix. No upstream commit was cherry-picked for 1.7.0.
Keep the original web implementation on `web-before-desktop` for reference. Both GitHub and Gitea should default to `main`. Gitea originally selected the first pushed branch, `web-before-desktop`; its repository default was corrected to `main`.
For future reviews, fetch upstream, inspect changes to shared processing modules separately from website changes, and test any selected fix against offline desktop, cancellation, batch saving, and both supported architectures before publishing.
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

@@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Do social media sites remove EXIF data? What to know before you post | Untrace</title>
<meta name="description" content="Many platforms strip location from the photos they display, but not all do, and policies change. Learn when you can rely on it and why cleaning the file yourself is safer." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/do-social-media-sites-remove-exif-data/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/do-social-media-sites-remove-exif-data/" />
<meta property="og:title" content="Do social media sites remove EXIF data from photos?" />
<meta property="og:description" content="Many platforms strip location from the photos they display, but not all do, and policies change. Learn when you can rely on it and why cleaning the file yourself is safer." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "Do social media sites remove EXIF data from photos?",
"description": "Many platforms strip location from the photos they display, but not all do, and policies change. Learn when you can rely on it and why cleaning the file yourself is safer.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/do-social-media-sites-remove-exif-data/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Do social media sites remove EXIF data from photos?",
"item": "https://untrace.realbrain.cc/guides/do-social-media-sites-remove-exif-data/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">Do social media sites remove EXIF data from photos?</li></ol>
<article>
<h1>Do social media sites remove EXIF data from photos?</h1>
<p class="lead">Many large platforms remove location and camera details from the version of a photo they display. That does not mean every site does, and it does not cover the other ways a photo reaches people.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>What platforms commonly do</h2>
<p>Large social networks generally re-encode uploads and drop most embedded metadata from the public copy. Smaller sites, forums, marketplaces, blogs and some messaging options often serve your file exactly as you uploaded it, GPS included.</p>
<p>Platform behaviour is not guaranteed and it changes without notice. Treat a platform's policy as a bonus and not as your privacy plan.</p>
<h2>Where metadata still leaks</h2>
<ul>
<li><strong>Original-file sharing:</strong> email attachments, cloud links, and chat apps' file or document options usually keep everything.</li>
<li><strong>Marketplaces and classifieds:</strong> listing photos of items at home can show your address through the coordinates.</li>
<li><strong>Your own website or blog:</strong> uploads to a CMS are served as they are unless a plugin strips them.</li>
<li><strong>Before upload:</strong> the platform itself receives your original, metadata included, even if it hides it from viewers.</li>
</ul>
<h2>A safer habit</h2>
<p>Clean the file before it leaves your device. In <a href="/#tool">Untrace</a> you can see what a photo carries, remove it, and share the copy. Nothing is uploaded, so the platform and everyone else only ever receives the clean file.</p>
<div class="callout"><p><strong>What this does not hide.</strong> Metadata removal does not change what is visible: street signs, landmarks, reflections and shadows can still reveal where a photo was taken.</p></div>
<p>More detail: <a href="/guides/what-is-exif-data/">What is EXIF data?</a> and <a href="/guides/remove-gps-from-photo/">how to remove GPS from a photo</a>.</p>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>Is it safe to post a photo with GPS data on social media?</h3><p>It may be hidden from viewers, but it still reaches the platform, and other places the file goes may keep it. Cleaning first avoids the question.</p><h3>Which sites keep EXIF data?</h3><p>Any site that serves your original file can. That includes many forums, personal sites and marketplaces. Check the result by downloading your own post and scanning it.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-gps-from-photo/">How to remove GPS location from a photo</a></li><li><a href="/guides/remove-exif-data-iphone/">How to remove EXIF data from photos on iPhone</a></li><li><a href="/guides/remove-exif-data-android/">How to remove EXIF and location data from photos on Android</a></li><li><a href="/guides/remove-exif-data-windows-mac/">How to remove EXIF data from photos on Windows and Mac</a></li><li><a href="/guides/how-to-view-exif-data/">How to view the EXIF data of a photo</a></li><li><a href="/guides/photo-metadata-privacy-checklist/">Photo metadata privacy checklist</a></li><li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li><li><a href="/guides/what-is-c2pa/">What is C2PA and how do Content Credentials work?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
-120
View File
@@ -1,120 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>How to view EXIF data of a photo on any device | Untrace</title>
<meta name="description" content="See the hidden EXIF data inside a photo on Windows, Mac, iPhone, Android or in your browser: location, camera, time and more, before you share the file." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/how-to-view-exif-data/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/how-to-view-exif-data/" />
<meta property="og:title" content="How to view the EXIF data of a photo" />
<meta property="og:description" content="See the hidden EXIF data inside a photo on Windows, Mac, iPhone, Android or in your browser: location, camera, time and more, before you share the file." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "How to view the EXIF data of a photo",
"description": "See the hidden EXIF data inside a photo on Windows, Mac, iPhone, Android or in your browser: location, camera, time and more, before you share the file.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/how-to-view-exif-data/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "How to view the EXIF data of a photo",
"item": "https://untrace.realbrain.cc/guides/how-to-view-exif-data/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">How to view the EXIF data of a photo</li></ol>
<article>
<h1>How to view the EXIF data of a photo</h1>
<p class="lead">You can read a photo's hidden details in seconds, on any device. Checking first tells you whether a photo is safe to share.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>In your browser, on any device</h2>
<p>Add the photo to <a href="/#tool">Untrace</a>. It lists every metadata block it finds, such as GPS location, camera and device, timestamps, XMP and IPTC fields and C2PA content credentials, and it can show the raw values. The photo is read inside your browser and is not uploaded.</p>
<h2>On Windows</h2>
<p>Right-click the photo, choose Properties, then the Details tab. It shows camera, date, and GPS coordinates if present.</p>
<h2>On Mac</h2>
<p>Open the photo in Preview, choose Tools, then Show Inspector. The Exif and GPS tabs show the stored values.</p>
<h2>On iPhone</h2>
<p>Open the photo in Photos and tap the info button or swipe up. You will see the camera, lens, file size and a map if the photo has a location.</p>
<h2>On Android</h2>
<p>Open the photo in your gallery or Google Photos and swipe up or tap Details. Location, device and capture time appear there.</p>
<h2>What to look for</h2>
<ul>
<li><strong>GPS or a map:</strong> the photo can reveal where it was taken.</li>
<li><strong>Make, model and serial numbers:</strong> identify the device.</li>
<li><strong>Date and time:</strong> show when you were there.</li>
<li><strong>Software and creator fields:</strong> reveal the apps you use and sometimes your name.</li>
</ul>
<p>If any of that should stay private, remove it before sharing. See <a href="/guides/what-is-exif-data/">what EXIF data is</a> for the full list.</p>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>Why do some photos show no EXIF data?</h3><p>The photo may have been edited, converted or shared through an app that strips it. Screenshots and many downloaded images carry little or none.</p><h3>Can I see EXIF data without installing software?</h3><p>Yes. Your operating system and Untrace in your browser both show it without installing anything.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-gps-from-photo/">How to remove GPS location from a photo</a></li><li><a href="/guides/remove-exif-data-iphone/">How to remove EXIF data from photos on iPhone</a></li><li><a href="/guides/remove-exif-data-android/">How to remove EXIF and location data from photos on Android</a></li><li><a href="/guides/remove-exif-data-windows-mac/">How to remove EXIF data from photos on Windows and Mac</a></li><li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social media sites remove EXIF data from photos?</a></li><li><a href="/guides/photo-metadata-privacy-checklist/">Photo metadata privacy checklist</a></li><li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li><li><a href="/guides/what-is-c2pa/">What is C2PA and how do Content Credentials work?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
-46
View File
@@ -1,46 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Photo privacy guides: EXIF, GPS and C2PA | Untrace</title>
<meta name="description" content="Plain-language guides to the hidden data inside photos: EXIF, GPS location and C2PA Content Credentials, and how to remove them." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/" />
<meta property="og:title" content="Photo privacy guides" />
<meta property="og:description" content="Plain-language guides to the hidden data inside photos: EXIF, GPS location and C2PA Content Credentials, and how to remove them." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/">Clean a photo</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<article>
<h1>Photo privacy guides</h1>
<p class="lead">Plain-language explanations of the hidden data inside photos, and how to remove it.</p>
</article>
<div class="index-list"><a class="card" href="/guides/remove-gps-from-photo/"><strong>How to remove GPS location from a photo</strong><span>Most phones save the exact latitude and longitude of every photo they take. If the file keeps that data when you share it, anyone who receives it can read where you were.</span></a><a class="card" href="/guides/remove-exif-data-iphone/"><strong>How to remove EXIF data from photos on iPhone</strong><span>iPhone photos carry their location, the device model and the time they were taken. iOS lets you remove some of that, but not all of it. This guide covers each option and what it leaves behind.</span></a><a class="card" href="/guides/remove-exif-data-android/"><strong>How to remove EXIF and location data from photos on Android</strong><span>Android phones usually write GPS coordinates, the device model and a timestamp into every photo. The steps differ between manufacturers, so this guide covers the common ones and a method that works on any phone.</span></a><a class="card" href="/guides/remove-exif-data-windows-mac/"><strong>How to remove EXIF data from photos on Windows and Mac</strong><span>Both Windows and macOS can remove some photo metadata without extra software. Here is what each built-in tool does, where it falls short, and a method that works the same on either system.</span></a><a class="card" href="/guides/how-to-view-exif-data/"><strong>How to view the EXIF data of a photo</strong><span>You can read a photo's hidden details in seconds, on any device. Checking first tells you whether a photo is safe to share.</span></a><a class="card" href="/guides/do-social-media-sites-remove-exif-data/"><strong>Do social media sites remove EXIF data from photos?</strong><span>Many large platforms remove location and camera details from the version of a photo they display. That does not mean every site does, and it does not cover the other ways a photo reaches people.</span></a><a class="card" href="/guides/photo-metadata-privacy-checklist/"><strong>Photo metadata privacy checklist</strong><span>Run through this list before you post, sell or send a photo. It takes under a minute and covers the details people most often leak by accident.</span></a><a class="card" href="/guides/what-is-exif-data/"><strong>What is EXIF data?</strong><span>EXIF (Exchangeable Image File Format) is a block of hidden information that cameras and phones write into a photo file. It travels with the picture unless something removes it.</span></a><a class="card" href="/guides/what-is-c2pa/"><strong>What is C2PA and how do Content Credentials work?</strong><span>C2PA is an open standard from the Coalition for Content Provenance and Authenticity. It lets software attach a cryptographically signed record to an image describing where it came from and how it was changed.</span></a></div>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
@@ -1,121 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Photo metadata privacy checklist: before you share a picture | Untrace</title>
<meta name="description" content="A short checklist to protect your location and identity before you post, sell or send a photo: what to check, what to remove and when it matters most." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/photo-metadata-privacy-checklist/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/photo-metadata-privacy-checklist/" />
<meta property="og:title" content="Photo metadata privacy checklist" />
<meta property="og:description" content="A short checklist to protect your location and identity before you post, sell or send a photo: what to check, what to remove and when it matters most." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "Photo metadata privacy checklist",
"description": "A short checklist to protect your location and identity before you post, sell or send a photo: what to check, what to remove and when it matters most.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/photo-metadata-privacy-checklist/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Photo metadata privacy checklist",
"item": "https://untrace.realbrain.cc/guides/photo-metadata-privacy-checklist/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">Photo metadata privacy checklist</li></ol>
<article>
<h1>Photo metadata privacy checklist</h1>
<p class="lead">Run through this list before you post, sell or send a photo. It takes under a minute and covers the details people most often leak by accident.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>Before you share</h2>
<ol>
<li><strong>Check for location.</strong> If the photo was taken at home, work or school, assume it has GPS coordinates until you have looked.</li>
<li><strong>Check the device and time.</strong> Camera model, serial numbers and capture time can link photos to one person.</li>
<li><strong>Check creator and software fields.</strong> They can hold your name, your editing apps or an account.</li>
<li><strong>Check for content credentials.</strong> Some apps and AI tools add a signed edit history. See <a href="/guides/what-is-c2pa/">what C2PA is</a>.</li>
<li><strong>Clean the file, then check again.</strong> Scan the cleaned copy to confirm nothing survived.</li>
</ol>
<h2>When it matters most</h2>
<ul>
<li><strong>Selling items online:</strong> photos taken in your home can expose your address to buyers.</li>
<li><strong>Posting about children or family:</strong> remove location from photos taken at home or school.</li>
<li><strong>Journalism and activism:</strong> metadata can identify a source, a device or a place.</li>
<li><strong>Dating and meetups:</strong> a precise location should never travel with a photo to a stranger.</li>
<li><strong>Sending original files:</strong> email, cloud links and document-style attachments keep everything.</li>
</ul>
<h2>Make it a habit</h2>
<p>Switch off location tags in your camera app, then clean any photo you share as a file. <a href="/#tool">Untrace</a> handles up to 50 photos at once in your browser, so there is nothing to upload and nothing to install.</p>
<div class="callout"><p><strong>Remember what metadata cannot hide.</strong> The picture itself can still reveal a place. Check backgrounds, signs and reflections as well.</p></div>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>How long does the checklist take?</h3><p>Under a minute for a single photo. Batch cleaning makes it quick for an album too.</p><h3>Do I need to clean every photo I share?</h3><p>Only the ones that leave your control as files. Photos you only view privately do not need it, but cleaning before you send is a safe default.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-gps-from-photo/">How to remove GPS location from a photo</a></li><li><a href="/guides/remove-exif-data-iphone/">How to remove EXIF data from photos on iPhone</a></li><li><a href="/guides/remove-exif-data-android/">How to remove EXIF and location data from photos on Android</a></li><li><a href="/guides/remove-exif-data-windows-mac/">How to remove EXIF data from photos on Windows and Mac</a></li><li><a href="/guides/how-to-view-exif-data/">How to view the EXIF data of a photo</a></li><li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social media sites remove EXIF data from photos?</a></li><li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li><li><a href="/guides/what-is-c2pa/">What is C2PA and how do Content Credentials work?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
-120
View File
@@ -1,120 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>How to remove EXIF and location data from photos on Android | Untrace</title>
<meta name="description" content="Remove EXIF and GPS data from Android photos: switch off location tags, remove location in Google Photos or Samsung Gallery, and clean the whole file in your browser." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/remove-exif-data-android/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/remove-exif-data-android/" />
<meta property="og:title" content="How to remove EXIF and location data from photos on Android" />
<meta property="og:description" content="Remove EXIF and GPS data from Android photos: switch off location tags, remove location in Google Photos or Samsung Gallery, and clean the whole file in your browser." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "How to remove EXIF and location data from photos on Android",
"description": "Remove EXIF and GPS data from Android photos: switch off location tags, remove location in Google Photos or Samsung Gallery, and clean the whole file in your browser.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/remove-exif-data-android/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "How to remove EXIF and location data from photos on Android",
"item": "https://untrace.realbrain.cc/guides/remove-exif-data-android/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">How to remove EXIF and location data from photos on Android</li></ol>
<article>
<h1>How to remove EXIF and location data from photos on Android</h1>
<p class="lead">Android phones usually write GPS coordinates, the device model and a timestamp into every photo. The steps differ between manufacturers, so this guide covers the common ones and a method that works on any phone.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>Stop adding location to new photos</h2>
<p>Open your camera app, go to its settings and switch off Location tags (the label can vary by manufacturer). New photos will stop carrying coordinates. Existing photos are unchanged.</p>
<h2>Remove location from one photo</h2>
<ul>
<li><strong>Google Photos:</strong> open the photo, swipe up for details, tap the pencil beside the location and choose Remove location.</li>
<li><strong>Samsung Gallery:</strong> open the photo, tap the More or Details option, edit the location and remove it.</li>
</ul>
<p>These steps remove location only. The camera model and capture time stay in the file.</p>
<h2>Remove location when you share</h2>
<p>Many Android versions offer a switch to remove location data on the share screen, for example in Samsung's share sheet. Options differ by phone and app, so check the share screen before you send. If you do not see one, clean the file first.</p>
<h2>Remove all metadata with Untrace</h2>
<ol>
<li>Open <a href="/#tool">Untrace</a> in Chrome and choose the photo.</li>
<li>Check what it found, then choose Clean image.</li>
<li>Download the cleaned copy and share that one.</li>
</ol>
<p>The photo never leaves your phone, because Untrace has no server to receive it. For a whole album, add up to 50 photos at once and take them as one ZIP.</p>
<div class="callout"><p><strong>Check it worked.</strong> Add the cleaned copy to Untrace again. It should report that no metadata was found.</p></div>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>Will cleaning change how the photo looks?</h3><p>No. The pixels stay the same. The file is re-encoded, so its size can change a little.</p><h3>Do messaging apps remove EXIF data?</h3><p>Some do when you send a photo as an image, but sending it as a document or file usually keeps everything. Clean the file yourself if it matters.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-gps-from-photo/">How to remove GPS location from a photo</a></li><li><a href="/guides/remove-exif-data-iphone/">How to remove EXIF data from photos on iPhone</a></li><li><a href="/guides/remove-exif-data-windows-mac/">How to remove EXIF data from photos on Windows and Mac</a></li><li><a href="/guides/how-to-view-exif-data/">How to view the EXIF data of a photo</a></li><li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social media sites remove EXIF data from photos?</a></li><li><a href="/guides/photo-metadata-privacy-checklist/">Photo metadata privacy checklist</a></li><li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li><li><a href="/guides/what-is-c2pa/">What is C2PA and how do Content Credentials work?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
-121
View File
@@ -1,121 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>How to remove EXIF data from photos on iPhone | Untrace</title>
<meta name="description" content="Remove location and other EXIF data from iPhone photos: turn off camera location, strip it per photo or per share, and clean the whole file in your browser." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/remove-exif-data-iphone/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/remove-exif-data-iphone/" />
<meta property="og:title" content="How to remove EXIF data from photos on iPhone" />
<meta property="og:description" content="Remove location and other EXIF data from iPhone photos: turn off camera location, strip it per photo or per share, and clean the whole file in your browser." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "How to remove EXIF data from photos on iPhone",
"description": "Remove location and other EXIF data from iPhone photos: turn off camera location, strip it per photo or per share, and clean the whole file in your browser.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/remove-exif-data-iphone/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "How to remove EXIF data from photos on iPhone",
"item": "https://untrace.realbrain.cc/guides/remove-exif-data-iphone/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">How to remove EXIF data from photos on iPhone</li></ol>
<article>
<h1>How to remove EXIF data from photos on iPhone</h1>
<p class="lead">iPhone photos carry their location, the device model and the time they were taken. iOS lets you remove some of that, but not all of it. This guide covers each option and what it leaves behind.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>Stop adding location to new photos</h2>
<p>Open Settings, then Privacy &amp; Security, Location Services, Camera, and choose Never. New photos will no longer carry GPS coordinates. Photos already in your library keep theirs.</p>
<h2>Remove location from one photo</h2>
<ol>
<li>Open the photo in Photos and tap the info button, or swipe up.</li>
<li>Tap Adjust next to the location line.</li>
<li>Choose No Location.</li>
</ol>
<p>This removes the coordinates from the photo in your library. Other details, such as the device model and capture time, stay.</p>
<h2>Remove location only when you share</h2>
<p>In the share sheet, tap Options at the top and turn Location off before you send. Your original keeps its location, and the copy you share does not. This setting covers location, not the full EXIF block.</p>
<h2>Remove all metadata with Untrace</h2>
<ol>
<li>Open <a href="/#tool">Untrace</a> in Safari and choose the photo from your library.</li>
<li>Review what it found, then choose Clean image.</li>
<li>Save the cleaned copy to Photos or Files.</li>
</ol>
<p>Nothing is uploaded: the photo is read and rewritten in the browser on your phone. To clean many photos together, add up to 50 at once and download them as one ZIP.</p>
<div class="callout"><p><strong>HEIC photos.</strong> iPhones save HEIC by default. Safari can decode it, but some other browsers cannot. If a photo will not open, switch Settings, Camera, Formats to Most Compatible so new photos are saved as JPEG.</p></div>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>Does a screenshot remove EXIF data?</h3><p>A screenshot is a new file without the original's camera details, and it normally has no GPS coordinates. It can still record the device and time.</p><h3>Does AirDrop or Messages keep location?</h3><p>They can. Sharing the original file usually includes its metadata, so use the Location switch in Options or clean the file first.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-gps-from-photo/">How to remove GPS location from a photo</a></li><li><a href="/guides/remove-exif-data-android/">How to remove EXIF and location data from photos on Android</a></li><li><a href="/guides/remove-exif-data-windows-mac/">How to remove EXIF data from photos on Windows and Mac</a></li><li><a href="/guides/how-to-view-exif-data/">How to view the EXIF data of a photo</a></li><li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social media sites remove EXIF data from photos?</a></li><li><a href="/guides/photo-metadata-privacy-checklist/">Photo metadata privacy checklist</a></li><li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li><li><a href="/guides/what-is-c2pa/">What is C2PA and how do Content Credentials work?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
@@ -1,127 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>How to remove EXIF data from photos on Windows and Mac | Untrace</title>
<meta name="description" content="Remove EXIF and location data from photos on Windows or Mac using built-in tools, plus a browser method that cleans every metadata block and works on any computer." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/remove-exif-data-windows-mac/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/remove-exif-data-windows-mac/" />
<meta property="og:title" content="How to remove EXIF data from photos on Windows and Mac" />
<meta property="og:description" content="Remove EXIF and location data from photos on Windows or Mac using built-in tools, plus a browser method that cleans every metadata block and works on any computer." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "How to remove EXIF data from photos on Windows and Mac",
"description": "Remove EXIF and location data from photos on Windows or Mac using built-in tools, plus a browser method that cleans every metadata block and works on any computer.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/remove-exif-data-windows-mac/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "How to remove EXIF data from photos on Windows and Mac",
"item": "https://untrace.realbrain.cc/guides/remove-exif-data-windows-mac/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">How to remove EXIF data from photos on Windows and Mac</li></ol>
<article>
<h1>How to remove EXIF data from photos on Windows and Mac</h1>
<p class="lead">Both Windows and macOS can remove some photo metadata without extra software. Here is what each built-in tool does, where it falls short, and a method that works the same on either system.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>Windows: remove properties from a file</h2>
<ol>
<li>Right-click the photo and choose Properties.</li>
<li>Open the Details tab and select Remove Properties and Personal Information.</li>
<li>Choose Create a copy with all possible properties removed, or pick the properties to remove from the original, then select OK.</li>
</ol>
<p>Windows removes the properties it knows about. It can leave behind blocks it does not display, so check the result if the photo is sensitive.</p>
<h2>Mac: remove location in Preview</h2>
<ol>
<li>Open the photo in Preview.</li>
<li>Choose Tools, then Show Inspector, and open the Info tab.</li>
<li>Select the GPS tab and choose Remove Location Info.</li>
<li>Save the file.</li>
</ol>
<p>This removes GPS coordinates only. Camera and timestamp details stay in the file.</p>
<h2>Mac: leave location out when you export from Photos</h2>
<p>In Photos, choose File, Export, then Export Photo, and clear the Location Information option before exporting. The exported copy has no coordinates, and the original in your library is unchanged.</p>
<h2>Any computer: clean everything in your browser</h2>
<ol>
<li>Open <a href="/#tool">Untrace</a> and drop in one photo or up to 50.</li>
<li>Review what each file hides.</li>
<li>Choose Clean image, then download the files or one ZIP.</li>
</ol>
<p>Untrace re-encodes the picture to pixels, so every metadata block is left behind, not just the ones the operating system lists. The files stay on your computer.</p>
<div class="callout"><p><strong>Command line.</strong> If you already use ExifTool, <code>exiftool -all= photo.jpg</code> removes all metadata from a file. It keeps a backup copy named photo.jpg_original unless you add <code>-overwrite_original</code>.</p></div>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>Does renaming or compressing a photo remove EXIF data?</h3><p>Renaming does not. Compressing or re-saving can, but it depends on the app, so confirm by checking the result.</p><h3>Is removing EXIF data reversible?</h3><p>No. Once the metadata is gone from the copy, it cannot be rebuilt from that copy. Keep the original if you want the details for yourself.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-gps-from-photo/">How to remove GPS location from a photo</a></li><li><a href="/guides/remove-exif-data-iphone/">How to remove EXIF data from photos on iPhone</a></li><li><a href="/guides/remove-exif-data-android/">How to remove EXIF and location data from photos on Android</a></li><li><a href="/guides/how-to-view-exif-data/">How to view the EXIF data of a photo</a></li><li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social media sites remove EXIF data from photos?</a></li><li><a href="/guides/photo-metadata-privacy-checklist/">Photo metadata privacy checklist</a></li><li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li><li><a href="/guides/what-is-c2pa/">What is C2PA and how do Content Credentials work?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
-123
View File
@@ -1,123 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>How to remove GPS location from a photo before you share it | Untrace</title>
<meta name="description" content="Photos can carry the exact coordinates where they were taken. Learn how to check for GPS data and remove it on iPhone, Android or in your browser." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/remove-gps-from-photo/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/remove-gps-from-photo/" />
<meta property="og:title" content="How to remove GPS location from a photo" />
<meta property="og:description" content="Photos can carry the exact coordinates where they were taken. Learn how to check for GPS data and remove it on iPhone, Android or in your browser." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "How to remove GPS location from a photo",
"description": "Photos can carry the exact coordinates where they were taken. Learn how to check for GPS data and remove it on iPhone, Android or in your browser.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/remove-gps-from-photo/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "How to remove GPS location from a photo",
"item": "https://untrace.realbrain.cc/guides/remove-gps-from-photo/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">How to remove GPS location from a photo</li></ol>
<article>
<h1>How to remove GPS location from a photo</h1>
<p class="lead">Most phones save the exact latitude and longitude of every photo they take. If the file keeps that data when you share it, anyone who receives it can read where you were.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>How do you know a photo has GPS data?</h2>
<p>Location is stored in the photo's EXIF block, in fields called GPSLatitude and GPSLongitude, usually accurate to a few metres. Phones add it whenever the camera app has location access. The simplest check is to drop the photo into <a href="/#tool">Untrace</a>: it lists every metadata block it finds, including GPS, before changing anything.</p>
<h2>Option 1: turn off location tagging in the camera</h2>
<ul>
<li><strong>iPhone:</strong> Settings, Privacy &amp; Security, Location Services, Camera, then choose Never.</li>
<li><strong>Android:</strong> open the camera app, open its settings and switch off Location tags (the wording varies by manufacturer).</li>
</ul>
<p>This only protects photos you take from now on. Photos already on your phone keep their coordinates.</p>
<h2>Option 2: remove location when you share from your phone</h2>
<p>On iPhone, the share sheet has an Options button with a Location switch you can turn off for that share. Google Photos on Android offers a Remove location action in a photo's details. Both remove coordinates only. The camera model, timestamps and other EXIF fields stay in the file.</p>
<h2>Option 3: remove all metadata in your browser</h2>
<ol>
<li>Open <a href="/#tool">Untrace</a> and add the photo.</li>
<li>Review the list of what it found, GPS included.</li>
<li>Choose Clean image and download the copy.</li>
</ol>
<p>Untrace redraws the picture to pixels and writes a new file, so GPS, camera and time data are not carried over. It works offline from your file's point of view: nothing is uploaded.</p>
<div class="callout"><p><strong>Check the result.</strong> Drop the cleaned file back into Untrace. It should report that nothing was found.</p></div>
<h2>Does social media remove GPS for you?</h2>
<p>Some large platforms remove location data from the version they display, but not all do, and policies change. Photos sent as an original file by email, a chat app's file option or a cloud link usually keep everything. Cleaning the file yourself means you do not depend on the platform.</p>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>Does removing GPS data change the picture?</h3><p>The visible image stays the same. Untrace re-encodes it, so the file size and format can change slightly.</p><h3>Can someone find my location from a photo without GPS data?</h3><p>Yes, from what the picture shows, such as street signs or landmarks. Metadata removal does not hide what is visible.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-exif-data-iphone/">How to remove EXIF data from photos on iPhone</a></li><li><a href="/guides/remove-exif-data-android/">How to remove EXIF and location data from photos on Android</a></li><li><a href="/guides/remove-exif-data-windows-mac/">How to remove EXIF data from photos on Windows and Mac</a></li><li><a href="/guides/how-to-view-exif-data/">How to view the EXIF data of a photo</a></li><li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social media sites remove EXIF data from photos?</a></li><li><a href="/guides/photo-metadata-privacy-checklist/">Photo metadata privacy checklist</a></li><li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li><li><a href="/guides/what-is-c2pa/">What is C2PA and how do Content Credentials work?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
-113
View File
@@ -1,113 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>What is C2PA? Content Credentials explained, with their limits | Untrace</title>
<meta name="description" content="C2PA Content Credentials are a signed record of how an image was made and edited. Learn what they contain, what they can and cannot prove, and what they mean for privacy." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/what-is-c2pa/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/what-is-c2pa/" />
<meta property="og:title" content="What is C2PA and how do Content Credentials work?" />
<meta property="og:description" content="C2PA Content Credentials are a signed record of how an image was made and edited. Learn what they contain, what they can and cannot prove, and what they mean for privacy." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "What is C2PA and how do Content Credentials work?",
"description": "C2PA Content Credentials are a signed record of how an image was made and edited. Learn what they contain, what they can and cannot prove, and what they mean for privacy.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/what-is-c2pa/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "What is C2PA and how do Content Credentials work?",
"item": "https://untrace.realbrain.cc/guides/what-is-c2pa/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">What is C2PA and how do Content Credentials work?</li></ol>
<article>
<h1>What is C2PA and how do Content Credentials work?</h1>
<p class="lead">C2PA is an open standard from the Coalition for Content Provenance and Authenticity. It lets software attach a cryptographically signed record to an image describing where it came from and how it was changed.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>What is inside a C2PA manifest?</h2>
<p>The record, called a manifest, can list the tool that created or edited the file, the actions taken, when they happened and who signed it. In a JPEG it is stored in JUMBF boxes inside an APP11 segment. Cameras, editing apps and some AI image generators write these manifests, and some platforms read them to show a Content Credentials or AI info label.</p>
<h2>What can it prove?</h2>
<p>A valid manifest shows that a signer vouches for the recorded history and that the file has not been altered since signing. It does not prove the picture is true, and a missing manifest does not prove the picture is fake or real.</p>
<h2>Why does it often disappear?</h2>
<p>The manifest lives in the file, not in the pixels. Taking a screenshot, converting the format or re-saving through many apps drops it. Some platforms remove it on upload, others keep or read it. Researchers are pairing metadata with invisible watermarks and fingerprints so provenance can survive, but support is uneven.</p>
<h2>What does it mean for your privacy?</h2>
<p>Credentials can name your software, your account or organisation, and your editing steps, and that record goes wherever the file goes. If you want to share a picture without that history, you can <a href="/#tool">scan and clean it in Untrace</a> first, with nothing uploaded.</p>
<div class="callout"><p><strong>Use it responsibly.</strong> Removing credentials is a privacy choice, not a licence to mislead. If you publish AI-generated or heavily edited work where disclosure is expected, such as news, contests or advertising, disclose it yourself. Pixel-level detectors and invisible watermarks are not affected by metadata removal.</p></div>
<p>Related: <a href="/guides/what-is-exif-data/">What is EXIF data?</a></p>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>Is C2PA the same as EXIF?</h3><p>No. EXIF records camera and location details. C2PA records a signed history of creation and edits. Untrace reads and removes both.</p><h3>Can C2PA be forged?</h3><p>The signature protects the manifest from tampering, but anyone can create a manifest with their own certificate, so trust depends on who signed it.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-gps-from-photo/">How to remove GPS location from a photo</a></li><li><a href="/guides/remove-exif-data-iphone/">How to remove EXIF data from photos on iPhone</a></li><li><a href="/guides/remove-exif-data-android/">How to remove EXIF and location data from photos on Android</a></li><li><a href="/guides/remove-exif-data-windows-mac/">How to remove EXIF data from photos on Windows and Mac</a></li><li><a href="/guides/how-to-view-exif-data/">How to view the EXIF data of a photo</a></li><li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social media sites remove EXIF data from photos?</a></li><li><a href="/guides/photo-metadata-privacy-checklist/">Photo metadata privacy checklist</a></li><li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
-119
View File
@@ -1,119 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>What is EXIF data? What your photos reveal and how to remove it | Untrace</title>
<meta name="description" content="EXIF data is hidden information stored inside every photo: location, camera, time and software. See what it contains, who can read it and how to remove it." />
<link rel="canonical" href="https://untrace.realbrain.cc/guides/what-is-exif-data/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>(function(){try{var s=localStorage.getItem("untrace-theme");var t=s==="light"||s==="dark"?s:window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";if(t==="light")document.documentElement.setAttribute("data-theme","light")}catch(e){}})();</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/guides/what-is-exif-data/" />
<meta property="og:title" content="What is EXIF data?" />
<meta property="og:description" content="EXIF data is hidden information stored inside every photo: location, camera, time and software. See what it contains, who can read it and how to remove it." />
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"headline": "What is EXIF data?",
"description": "EXIF data is hidden information stored inside every photo: location, camera, time and software. See what it contains, who can read it and how to remove it.",
"datePublished": "2026-10-20",
"dateModified": "2026-10-20",
"inLanguage": "en",
"mainEntityOfPage": "https://untrace.realbrain.cc/guides/what-is-exif-data/",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"author": {
"@type": "Organization",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/"
},
"publisher": {
"@type": "Organization",
"name": "Untrace",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png"
}
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Untrace",
"item": "https://untrace.realbrain.cc/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Guides",
"item": "https://untrace.realbrain.cc/guides/"
},
{
"@type": "ListItem",
"position": 3,
"name": "What is EXIF data?",
"item": "https://untrace.realbrain.cc/guides/what-is-exif-data/"
}
]
}
]
}</script>
</head>
<body>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home"><span class="r-mark" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></svg></span><span>Untrace</span></a>
<nav class="actions" aria-label="Site"><a class="btn btn-ghost" href="/guides/">Guides</a></nav>
</div>
</header>
<main class="guide">
<div class="wrap">
<ol class="crumbs"><li><a href="/">Untrace</a></li><li><a href="/guides/">Guides</a></li><li aria-current="page">What is EXIF data?</li></ol>
<article>
<h1>What is EXIF data?</h1>
<p class="lead">EXIF (Exchangeable Image File Format) is a block of hidden information that cameras and phones write into a photo file. It travels with the picture unless something removes it.</p>
<p class="meta">Updated 2026-10-20 · Written by the Untrace team</p>
<h2>What does EXIF data contain?</h2>
<ul>
<li><strong>Location:</strong> GPS latitude, longitude, altitude and direction.</li>
<li><strong>Device:</strong> camera or phone make and model, lens, and sometimes a serial number.</li>
<li><strong>Time:</strong> when the photo was taken and when it was edited.</li>
<li><strong>Settings:</strong> exposure, aperture, ISO, focal length and flash.</li>
<li><strong>Software:</strong> the app or editor that last saved the file.</li>
<li><strong>A thumbnail:</strong> a small embedded preview, which can differ from the visible image if the photo was cropped.</li>
</ul>
<p>Related blocks sit alongside EXIF: XMP and IPTC hold captions, creator names and copyright, and C2PA content credentials can hold a signed edit history (see <a href="/guides/what-is-c2pa/">What is C2PA?</a>).</p>
<h2>Why does it matter for privacy?</h2>
<p>A photo of your front door shared with its GPS data tells the recipient where you live. A date and device model can link photos to one person. Journalists, sellers on marketplaces and anyone posting from home have good reason to check before sharing.</p>
<h2>How can you view EXIF data?</h2>
<p>On most computers, right-click the file and open its properties or info panel. The quickest complete view is to add the file to <a href="/#tool">Untrace</a>, which lists every metadata block, including ones the operating system does not show.</p>
<h2>How do you remove it?</h2>
<p>Editing tags one at a time risks missing a block. Untrace instead decodes the image to pixels and writes a fresh file, which leaves every metadata container behind, then re-reads the output to confirm nothing survived. For the location case specifically, see <a href="/guides/remove-gps-from-photo/">how to remove GPS from a photo</a>.</p>
<div class="cta"><p><strong>See what your own photo is hiding.</strong> Untrace lists every metadata block, then removes it, all inside your browser.</p><a class="btn btn-primary" href="/#tool">Scan a photo</a></div>
<h2>Common questions</h2>
<h3>Is EXIF data bad?</h3><p>No. It is useful for organising your own photos. It becomes a privacy problem only when you share the file with people who do not need it.</p><h3>Do screenshots have EXIF data?</h3><p>They usually carry far less, often no location, but they can still include device and time information.</p>
<nav class="related" aria-label="Related guides"><h2>Keep reading</h2><ul><li><a href="/guides/remove-gps-from-photo/">How to remove GPS location from a photo</a></li><li><a href="/guides/remove-exif-data-iphone/">How to remove EXIF data from photos on iPhone</a></li><li><a href="/guides/remove-exif-data-android/">How to remove EXIF and location data from photos on Android</a></li><li><a href="/guides/remove-exif-data-windows-mac/">How to remove EXIF data from photos on Windows and Mac</a></li><li><a href="/guides/how-to-view-exif-data/">How to view the EXIF data of a photo</a></li><li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social media sites remove EXIF data from photos?</a></li><li><a href="/guides/photo-metadata-privacy-checklist/">Photo metadata privacy checklist</a></li><li><a href="/guides/what-is-c2pa/">What is C2PA and how do Content Credentials work?</a></li></ul></nav>
</article>
</div>
</main>
<script type="module" src="/src/guide.js"></script>
</body>
</html>
+6 -148
View File
@@ -2,160 +2,18 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Remove EXIF, GPS &amp; C2PA metadata from photos – free, private | Untrace</title>
<meta
name="description"
content="See and remove hidden metadata from a photo: GPS location, camera details, EXIF and C2PA content credentials. Runs in your browser, so the image is never uploaded. JPEG, PNG, TIFF, HEIC and AVIF."
/>
<link rel="canonical" href="https://untrace.realbrain.cc/" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<!-- Theme: applied before first paint so the page never flashes the wrong
surface. Mirrors the logic in src/lib/theme.js. -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
<script>
(function () {
try {
var saved = localStorage.getItem("untrace-theme");
var theme =
saved === "light" || saved === "dark"
? saved
: window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
if (theme === "light")
document.documentElement.setAttribute("data-theme", "light");
} catch (e) {}
})();
</script>
<title>Untrace Desktop · Image cleaner</title>
<meta name="description" content="An on-device image metadata workspace. Inspect, clean and save a separate copy." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" sizes="16x16" href="/favicon-16x16.svg" />
<link rel="icon" type="image/svg+xml" sizes="32x32" href="/favicon-32x32.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<!-- The design system pulls IBM Plex from Google Fonts; starting the
handshake here removes it from the stylesheet's critical path. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=IBM+Plex+Sans:wght@400;500;600&amp;display=swap"
/>
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Untrace" />
<meta property="og:url" content="https://untrace.realbrain.cc/" />
<meta property="og:title" content="Remove EXIF, GPS and C2PA metadata from photos" />
<meta
property="og:description"
content="Free, private browser tool that shows and removes hidden photo metadata. Nothing is uploaded."
/>
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Remove EXIF, GPS and C2PA metadata from photos" />
<meta
name="twitter:description"
content="Free, private browser tool that shows and removes hidden photo metadata. Nothing is uploaded."
/>
<meta name="twitter:image" content="https://untrace.realbrain.cc/thumb.jpg" />
<meta name="application-name" content="Untrace" />
<meta name="apple-mobile-web-app-title" content="Untrace" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "https://untrace.realbrain.cc/#website",
"url": "https://untrace.realbrain.cc/",
"name": "Untrace",
"alternateName": [
"Untrace AI",
"Untrace content credentials remover"
],
"description": "See and remove EXIF, GPS and C2PA metadata from photos in the browser.",
"inLanguage": "en",
"publisher": {
"@id": "https://untrace.realbrain.cc/#org"
}
},
{
"@type": "Organization",
"@id": "https://untrace.realbrain.cc/#org",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/",
"logo": {
"@type": "ImageObject",
"url": "https://untrace.realbrain.cc/logo-512.png",
"width": 512,
"height": 512
},
"sameAs": [
"https://github.com/developersharif/untrace",
"https://realbrain.cc/"
]
},
{
"@type": "WebApplication",
"@id": "https://untrace.realbrain.cc/#app",
"name": "Untrace",
"url": "https://untrace.realbrain.cc/",
"applicationCategory": "UtilitiesApplication",
"applicationSubCategory": "Image privacy tool",
"operatingSystem": "Any browser",
"browserRequirements": "Requires JavaScript and the HTML canvas API",
"isAccessibleForFree": true,
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"publisher": {
"@id": "https://untrace.realbrain.cc/#org"
},
"description": "Free browser tool that shows and removes GPS coordinates, EXIF metadata and C2PA content credentials from photos without uploading them.",
"image": "https://untrace.realbrain.cc/thumb.jpg",
"license": "https://opensource.org/licenses/MIT",
"featureList": [
"Remove C2PA content credentials",
"Scan a photo and list every hidden metadata block before changing it",
"Remove JUMBF provenance containers",
"Remove GPS location data",
"Remove camera make, model and settings",
"Remove capture timestamps",
"Remove XMP, IPTC and Photoshop metadata",
"Remove PNG text chunks",
"Client-side processing with no upload",
"Supports JPEG, PNG, TIFF, HEIC and AVIF"
]
}
]
}
<script>
/* theme-bootstrap */
</script>
</head>
<body>
<div id="app"></div>
<noscript>
<p style="padding: 24px; font-family: system-ui, sans-serif">
Untrace removes EXIF, GPS and C2PA content credentials from photos. It
reads and rewrites the file inside your browser, so JavaScript has to
be enabled for it to work.
</p>
</noscript>
<noscript>Untrace Desktop needs JavaScript to process images on this device.</noscript>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+3677 -7
View File
File diff suppressed because it is too large. Load diff
+84 -7
View File
@@ -1,23 +1,100 @@
{
"name": "untrace",
"description": "Remove C2PA content credentials, AI generation tags and EXIF metadata from images, entirely in the browser.",
"version": "1.0.0",
"name": "untrace-desktop",
"description": "Offline image metadata cleaner for Windows with a dedicated desktop workspace.",
"version": "1.7.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"deploy": "npm run build && gh-pages -d dist"
"deploy": "npm run build && gh-pages -d dist",
"build:desktop": "npm run build && node scripts/prepare-desktop.mjs",
"desktop": "npm run build:desktop && electron .",
"portable": "npm run build:desktop && node scripts/build-windows.mjs portable",
"test": "npm run build:desktop && playwright test",
"installer": "npm run build:desktop && node scripts/build-windows.mjs nsis",
"release:windows": "npm run build:desktop && node scripts/build-windows.mjs",
"screenshots": "npm run build:desktop && node scripts/screenshots.mjs"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"electron": "^44.3.0",
"electron-builder": "^26.15.3",
"gh-pages": "^6.1.0",
"lightningcss": "^1.30.1",
"svelte": "^4.2.0",
"vite": "^5.0.0",
"lightningcss": "^1.30.1"
"vite": "^5.0.0"
},
"dependencies": {
"exifr": "^7.1.3"
},
"homepage": "https://untrace.realbrain.cc/"
"homepage": "https://github.com/donishki/Untrace",
"main": "desktop/main.cjs",
"license": "MIT",
"author": "developersharif",
"build": {
"appId": "local.untrace.desktop",
"productName": "Untrace Desktop",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"desktop/**/*",
"package.json",
"LICENSE"
],
"win": {
"target": [
"portable"
],
"signExecutable": false,
"icon": "desktop/icon.ico"
},
"portable": {
"artifactName": "Untrace-Desktop-${version}-portable-${arch}.exe"
},
"extraFiles": [
{
"from": "README.md",
"to": "README.md"
},
{
"from": "CHANGELOG.md",
"to": "CHANGELOG.md"
},
{
"from": "LICENSE",
"to": "LICENSE-Untrace.txt"
},
{
"from": "THEME-CREDITS.md",
"to": "THEME-CREDITS.md"
}
],
"nsis": {
"oneClick": false,
"perMachine": false,
"allowElevation": false,
"allowToChangeInstallationDirectory": true,
"runAfterFinish": false,
"deleteAppDataOnUninstall": false,
"artifactName": "Untrace-Desktop-${version}-setup-${arch}.exe",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"license": "LICENSE"
},
"compression": "normal"
},
"repository": {
"type": "git",
"url": "https://github.com/donishki/Untrace.git"
},
"bugs": {
"url": "https://github.com/donishki/Untrace/issues"
},
"contributors": [
"Dario P. (Untrace Desktop)"
]
}
+2
View File
@@ -0,0 +1,2 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({ testDir: './tests', workers: 1, timeout: 60000, expect: { timeout: 15000 }, reporter: 'list' });
-1
View File
@@ -1 +0,0 @@
untrace.realbrain.cc
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

+4 -3
View File
@@ -1,4 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace">
<rect width="48" height="48" rx="8" fill="#3450C8"/>
<g transform="translate(4.799999999999997 4.799999999999997) scale(1.6000000000000003)" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity=".55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity=".28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></g>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace Desktop">
<rect width="48" height="48" rx="10" fill="#3450C8"/>
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace"
font-size="28" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>
</svg>

Before

Width:  |  Height:  |  Size: 604 B

After

Width:  |  Height:  |  Size: 355 B

+3 -2
View File
@@ -1,4 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace Desktop">
<rect width="48" height="48" rx="10" fill="#3450C8"/>
<g transform="translate(7.200000000000003 7.200000000000003) scale(1.3999999999999997)" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity=".55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity=".28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></g>
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace"
font-size="28" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>
</svg>

Before

Width:  |  Height:  |  Size: 605 B

After

Width:  |  Height:  |  Size: 355 B

+3 -2
View File
@@ -1,4 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace">
<rect width="48" height="48" rx="0" fill="#3450C8"/>
<g transform="translate(12.0 12.0) scale(1.0)" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity=".55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity=".28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></g>
<rect width="48" height="48" fill="#3450C8"/>
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace"
font-size="22" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>
</svg>

Before

Width:  |  Height:  |  Size: 563 B

After

Width:  |  Height:  |  Size: 339 B

+4 -3
View File
@@ -1,4 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace">
<rect width="48" height="48" rx="10" fill="#3450C8"/>
<g transform="translate(9.120000000000001 9.120000000000001) scale(1.24)" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7"/><path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity=".55"/><path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity=".28"/><path d="M8 15.5 11 12l2 2.2 1.5-1.7"/></g>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace Desktop">
<rect width="48" height="48" rx="10" fill="#121216"/>
<path d="M24 9 36 14v10c0 7-5 12-12 15-7-3-12-8-12-15V14L24 9Z" fill="none" stroke="#8FA8FF" stroke-width="2.5" stroke-linejoin="round"/>
<path d="m18.5 24 4 4 7-7" fill="none" stroke="#8FA8FF" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 591 B

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

+3 -15
View File
@@ -1,8 +1,8 @@
{
"id": "/",
"name": "Untrace — photo metadata and content credentials remover",
"short_name": "Untrace",
"description": "See and remove EXIF, GPS and C2PA metadata from photos, entirely in your browser.",
"name": "Untrace Desktop",
"short_name": "Untrace Desktop",
"description": "Inspect and clean image metadata in an offline desktop workspace.",
"start_url": "/",
"scope": "/",
"display": "standalone",
@@ -20,18 +20,6 @@
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"categories": [
+2 -51
View File
@@ -2,57 +2,8 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://untrace.realbrain.cc/</loc>
<lastmod>2026-10-20</lastmod>
<lastmod>2026-09-05</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.8</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/remove-gps-from-photo/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/remove-exif-data-iphone/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/remove-exif-data-android/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/remove-exif-data-windows-mac/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/how-to-view-exif-data/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/do-social-media-sites-remove-exif-data/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/photo-metadata-privacy-checklist/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/what-is-exif-data/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
<url>
<loc>https://untrace.realbrain.cc/guides/what-is-c2pa/</loc>
<lastmod>2026-10-20</lastmod>
<priority>0.7</priority>
</url>
</urlset>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+18
View File
@@ -0,0 +1,18 @@
import { build, Platform, Arch } from 'electron-builder';
const requested = process.argv[2];
if (requested && !['portable', 'nsis'].includes(requested)) throw new Error('Expected portable or nsis.');
const targets = requested ? [requested] : ['portable', 'nsis'];
// Electron 43 is the last series with Windows ia32 binaries.
for (const [label, arch, electronVersion] of [['x64', Arch.x64, '44.3.0'], ['x86', Arch.ia32, '43.8.3']]) {
await build({
targets: Platform.WINDOWS.createTarget(targets, arch),
publish: 'never',
config: {
electronVersion,
directories: { output: `release/${label}` },
portable: { artifactName: `Untrace-Desktop-\${version}-portable-${label}.exe` },
nsis: { artifactName: `Untrace-Desktop-\${version}-setup-${label}.exe` },
},
});
}
+27
View File
@@ -0,0 +1,27 @@
import { _electron as electron } from '@playwright/test';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const data = await mkdtemp(path.join(os.tmpdir(), 'untrace-icon-'));
const env = { ...process.env, UNTRACE_TEST: '1', UNTRACE_DATA_DIR: data };
delete env.ELECTRON_RUN_AS_NODE;
const app = await electron.launch({ args: ['.'], env });
try {
const page = await app.firstWindow();
const encoded = await page.evaluate(async () => {
const img = new Image(); img.src = '/favicon.svg'; await img.decode();
const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 256;
canvas.getContext('2d').drawImage(img, 0, 0, 256, 256);
return canvas.toDataURL('image/png').split(',')[1];
});
const png = Buffer.from(encoded, 'base64');
const header = Buffer.alloc(22);
header.writeUInt16LE(1, 2); header.writeUInt16LE(1, 4);
header.writeUInt16LE(1, 10); header.writeUInt16LE(32, 12);
header.writeUInt32LE(png.length, 14); header.writeUInt32LE(22, 18);
await writeFile('desktop/icon.ico', Buffer.concat([header, png]));
} finally {
await app.close();
await rm(data, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}
+20
View File
@@ -0,0 +1,20 @@
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
let html = await readFile('dist/index.html', 'utf8');
const { build } = JSON.parse(await readFile('package.json', 'utf8'));
const brand = JSON.parse(await readFile('desktop/brand.json', 'utf8'));
if (build.productName !== brand.name || build.appId !== brand.appId) throw new Error('Desktop brand and package configuration must match.');
html = html.replace(/<link\b[^>]*href="https:\/\/fonts\.[^>]*>/g, '');
html = html.replace(/<title>.*?<\/title>/, `<title>${build.productName}</title>`);
const hashes = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)]
.map(match => `'sha256-${createHash('sha256').update(match[1]).digest('base64')}'`);
const policy = `default-src 'self'; script-src 'self' ${hashes.join(' ')}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self' blob:; object-src 'none'; base-uri 'none'; form-action 'none'`;
html = html.replace('<head>', `<head>\n <meta http-equiv="Content-Security-Policy" content="${policy}">`);
await writeFile('dist/index.html', html);
for (const file of await readdir('dist/assets')) {
if (!file.endsWith('.css')) continue;
const location = `dist/assets/${file}`;
const css = await readFile(location, 'utf8');
await writeFile(location, css.replace(/@import\s*(?:url\([^)]*fonts\.googleapis[^)]*\)|["'][^"']*fonts\.googleapis[^"']*["'])\s*;/g, ''));
}
+54
View File
@@ -0,0 +1,54 @@
import { _electron as electron, expect } from '@playwright/test';
import { mkdtemp, rm, mkdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const data = await mkdtemp(path.join(os.tmpdir(), 'untrace-screenshots-'));
const env = { ...process.env, UNTRACE_TEST: '1', UNTRACE_DATA_DIR: data };
delete env.ELECTRON_RUN_AS_NODE;
const app = await electron.launch({ args: ['.', '--disable-features=CalculateNativeWinOcclusion', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'], env });
await mkdir('docs/screenshots', { recursive: true });
try {
const page = await app.firstWindow();
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setContentSize(1440, 960); win.webContents.setBackgroundThrottling(false); });
await page.waitForLoadState('networkidle');
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(() => localStorage.setItem('untrace-theme', 'graphite'));
await page.reload();
// Original generated artwork, not a private photo. Only sample pixels enter the app.
const bytes = Buffer.from(await page.evaluate(() => {
const canvas = document.createElement('canvas'); canvas.width = 1800; canvas.height = 1200;
const c = canvas.getContext('2d');
const sky = c.createLinearGradient(0, 0, 0, 750); sky.addColorStop(0, '#254e6b'); sky.addColorStop(.65, '#9bb7b2'); sky.addColorStop(1, '#f6d5aa'); c.fillStyle = sky; c.fillRect(0, 0, 1800, 1200);
c.fillStyle = '#f6e7bd'; c.beginPath(); c.arc(1300, 270, 68, 0, Math.PI * 2); c.fill();
function mountain(points, color) { c.fillStyle = color; c.beginPath(); c.moveTo(0, 760); for (const [x,y] of points) c.lineTo(x,y); c.lineTo(1800,760); c.closePath(); c.fill(); }
mountain([[0,520],[240,300],[420,460],[680,180],[950,480],[1220,260],[1500,430],[1800,220]], '#637e8b');
mountain([[0,680],[340,430],[600,680],[890,360],[1170,610],[1430,390],[1800,640]], '#345867');
c.fillStyle = '#dce5df'; c.beginPath(); c.moveTo(510,395); c.lineTo(680,180); c.lineTo(800,323); c.lineTo(710,292); c.lineTo(670,335); c.lineTo(625,307); c.fill();
const lake = c.createLinearGradient(0, 740, 0, 1200); lake.addColorStop(0,'#9bb5b0'); lake.addColorStop(1,'#274d5d'); c.fillStyle = lake; c.fillRect(0,740,1800,460);
for(let i=0;i<60;i++){ const y=770+i*7; c.fillStyle=i%3===0?'#99b0aa66':'#c9d5bd33'; c.fillRect((i*137)%1800,y,80+(i*31)%340,2); }
for(let i=0;i<42;i++){const x=i*47, h=65+(i*43)%130; c.fillStyle='#193f47'; c.beginPath(); c.moveTo(x,760-h); c.lineTo(x-24,770); c.lineTo(x+24,770); c.fill();}
c.fillStyle='#183c45'; c.beginPath(); c.moveTo(0,970); c.quadraticCurveTo(260,880,470,1200); c.lineTo(0,1200); c.fill();
return canvas.toDataURL('image/jpeg', .95).split(',')[1];
}), 'base64');
const sample = { name: 'Alpine-lake.jpg', mimeType: 'image/jpeg', buffer: bytes };
await page.getByLabel('Open image file', { exact: true }).setInputFiles(sample);
await page.getByLabel('File format', { exact: true }).selectOption('image/jpeg');
await page.getByRole('button', { name: 'Clean image', exact: true }).click();
await expect(page.getByRole('link', { name: 'Save clean image' })).toBeVisible();
await page.screenshot({ path: 'docs/screenshots/workspace-dark.png' });
await page.evaluate(() => localStorage.setItem('untrace-theme', 'light'));
await page.reload();
await page.getByLabel('Open image file', { exact: true }).setInputFiles(sample);
await expect(page.getByRole('button', { name: 'Clean image', exact: true })).toBeEnabled();
await page.screenshot({ path: 'docs/screenshots/workspace-light.png' });
await page.getByLabel('Open image file', { exact: true }).setInputFiles(['Alpine-lake.jpg','Mountain-reflections.jpg','Morning-light.jpg','Forest-shore.jpg'].map(name => ({ ...sample, name })));
await expect(page.getByRole('button', { name: 'Clean batch', exact: true })).toBeEnabled();
await page.getByRole('button', { name: 'Clean batch', exact: true }).click();
await expect(page.getByText('Cleaned', { exact: true })).toHaveCount(4);
await page.screenshot({ path: 'docs/screenshots/batch-light.png' });
console.log('Captured three real application screenshots using generated sample artwork.');
} finally {
await app.close();
await rm(data, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}
+22
View File
@@ -0,0 +1,22 @@
import { _electron as electron, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const [executablePath, architecture] = process.argv.slice(2);
if (!executablePath || !['x64', 'ia32'].includes(architecture)) throw new Error('Pass installed executable path and x64 or ia32.');
const env = { ...process.env, UNTRACE_TEST: '1' };
delete env.ELECTRON_RUN_AS_NODE;
delete env.UNTRACE_DATA_DIR;
delete env.PORTABLE_EXECUTABLE_DIR;
const app = await electron.launch({ executablePath, env });
try {
const page = await app.firstWindow();
await expect(page.getByRole('heading', { name: 'Inspector', exact: true })).toBeVisible();
const actual = await app.evaluate(({ app }) => ({ architecture: process.arch, data: app.getPath('userData'), appData: app.getPath('appData'), version: app.getVersion() }));
const { version } = JSON.parse(await readFile('package.json', 'utf8'));
if (actual.architecture !== architecture || actual.version !== version) throw new Error('Installed architecture/version mismatch.');
if (actual.data !== path.join(actual.appData, 'Untrace Desktop')) throw new Error('Installed settings path mismatch.');
if (await page.evaluate(() => typeof window.require) !== 'undefined') throw new Error('Renderer has Node access.');
if (await page.evaluate(() => fetch('https://example.com').then(() => true).catch(() => false))) throw new Error('Unexpected network access.');
console.log(`PASS: installed ${architecture} ${version} launches offline with isolated renderer and per-user settings.`);
} finally { await app.close(); }
+36
View File
@@ -0,0 +1,36 @@
import { spawn } from 'node:child_process';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { chromium } from '@playwright/test';
const data = await mkdtemp(path.join(os.tmpdir(), 'untrace-portable-'));
const env = { ...process.env, UNTRACE_TEST: '1', UNTRACE_DATA_DIR: data };
delete env.ELECTRON_RUN_AS_NODE;
const { version, build } = JSON.parse(await readFile('package.json', 'utf8'));
const architecture = process.argv[2] || 'x64';
if (!['x64', 'x86'].includes(architecture)) throw new Error('Expected x64 or x86.');
const artifact = `Untrace-Desktop-${version}-portable-${architecture}.exe`;
const processHandle = spawn(path.resolve('release', architecture, artifact), ['--remote-debugging-port=19384'], { env, windowsHide: true, stdio: 'ignore' });
let launchError;
processHandle.on('error', error => { launchError = error; });
let browser;
try {
for (let attempt = 0; attempt < 120; attempt++) {
if (launchError) throw launchError;
try { browser = await chromium.connectOverCDP('http://127.0.0.1:19384', { timeout: 1000 }); break; }
catch { await new Promise(resolve => setTimeout(resolve, 500)); }
}
if (!browser) throw new Error('Portable app did not start within 60 seconds.');
const page = browser.contexts()[0].pages()[0];
await page.waitForURL('untrace://app/');
await page.getByLabel('Open image file', { exact: true }).waitFor({ state: 'attached' });
if (await page.title() !== build.productName) throw new Error('Unexpected window');
console.log(`PASS: ${architecture} single-file portable extracted and loaded the offline app.`);
const cdp = await browser.newBrowserCDPSession();
await Promise.race([cdp.send('Browser.close').catch(() => {}), new Promise(resolve => setTimeout(resolve, 1000))]);
} finally {
if (browser) await Promise.race([browser.close().catch(() => {}), new Promise(resolve => setTimeout(resolve, 1000))]);
await new Promise(resolve => setTimeout(resolve, 1500));
await rm(data, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 });
}
+35
View File
@@ -0,0 +1,35 @@
param([ValidateSet('x64','x86')][string]$Architecture)
$ErrorActionPreference='Stop'
$root=Split-Path $PSScriptRoot -Parent
$version=(Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json).version
$existing=@(Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue | Where-Object DisplayName -like 'Untrace*')
if($existing.Count){throw 'An Untrace installation already exists. Run installer verification in a clean Windows account.'}
$installer=Join-Path $root "release/$Architecture/Untrace-Desktop-$version-setup-$Architecture.exe"
$target=Join-Path ([IO.Path]::GetTempPath()) ('Untrace-install-check-'+$Architecture+'-'+[guid]::NewGuid().ToString('N'))
$profile=Join-Path $env:APPDATA 'Untrace Desktop'
New-Item -ItemType Directory -Path $profile -Force | Out-Null
$sentinel=Join-Path $profile ('.install-check-'+[guid]::NewGuid().ToString('N'))
Set-Content -LiteralPath $sentinel -Value 'preserve-settings'
Push-Location $root
try {
$process=Start-Process -FilePath $installer -ArgumentList "/S /currentuser /D=$target" -PassThru -Wait -WindowStyle Hidden
if($process.ExitCode -ne 0){throw "Installer failed: $($process.ExitCode)"}
$exe=Join-Path $target 'Untrace Desktop.exe'
if(-not (Test-Path -LiteralPath $exe)){throw 'Installed executable is missing'}
$arch=if($Architecture -eq 'x86'){'ia32'}else{'x64'}
node scripts/smoke-installed.mjs $exe $arch
if($LASTEXITCODE -ne 0){throw 'Installed smoke test failed'}
$uninstaller=Get-ChildItem -LiteralPath $target -Filter 'Uninstall*.exe' | Select-Object -First 1
if(-not $uninstaller){throw 'Uninstaller is missing'}
$process=Start-Process -FilePath $uninstaller.FullName -ArgumentList '/S /currentuser' -PassThru -Wait -WindowStyle Hidden
if($process.ExitCode -ne 0){throw "Uninstaller failed: $($process.ExitCode)"}
for($attempt=0;$attempt -lt 30 -and (Test-Path -LiteralPath $exe);$attempt++){Start-Sleep -Milliseconds 500}
if(Test-Path -LiteralPath $exe){throw 'Uninstall did not remove the installed executable'}
if((Get-Content -LiteralPath $sentinel -Raw).Trim() -ne 'preserve-settings'){throw 'Uninstall removed settings'}
$left=@(Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue | Where-Object DisplayName -like 'Untrace*')
if($left.Count){throw 'Uninstall registration remains'}
Write-Output "PASS: $Architecture install, installed startup, uninstall, and settings preservation."
} finally {
Pop-Location
if(Test-Path -LiteralPath $sentinel){Remove-Item -LiteralPath $sentinel}
}
+5 -144
View File
@@ -1,147 +1,8 @@
<script>
import { onMount } from "svelte";
import { theme } from "./lib/theme.js";
import TopBar from "./lib/TopBar.svelte";
import Tool from "./lib/Tool.svelte";
import Removals from "./lib/Removals.svelte";
import HowItWorks from "./lib/HowItWorks.svelte";
import Faq from "./lib/Faq.svelte";
import Footer from "./lib/Footer.svelte";
import Toast from "./lib/Toast.svelte";
import HeroDemo from "./lib/HeroDemo.svelte";
import Trust from "./lib/Trust.svelte";
let toasts = [];
let nextId = 0;
const assurances = [
{
title: "Runs in your browser",
detail: "No upload, no queue, no server that could keep a copy.",
},
{
title: "Reads the whole file",
detail: "EXIF, XMP, IPTC and the JUMBF boxes that hold C2PA data.",
},
{
title: "Shows its work",
detail: "Every finding is listed before anything is changed.",
},
];
function notify(event) {
const id = nextId++;
toasts = [...toasts, { id, ...event.detail }];
setTimeout(() => dismiss(id), 5000);
}
function dismiss(id) {
toasts = toasts.filter((toast) => toast.id !== id);
}
<script>
import { onMount } from 'svelte';
import { theme } from './lib/theme.js';
import Workspace from './lib/Workspace.svelte';
onMount(() => theme.init());
</script>
<TopBar />
<main>
<div class="wrap">
<div class="intro">
<section class="lede">
<p class="eyebrow">EXIF · GPS · C2PA</p>
<h1>Remove hidden metadata and content credentials from a photo</h1>
<p>
Every photo can carry your location, your device and a signed record
of how it was made. Untrace shows you what is hidden and removes it —
free, and without the file ever leaving your device.
</p>
<div class="chip-row">
<span class="chip">Free</span>
<span class="chip">No upload</span>
<span class="chip">No sign-in</span>
<span class="chip">Open source</span>
</div>
<a class="btn btn-secondary" href="#tool">Clean a photo</a>
</section>
<HeroDemo />
</div>
<Tool on:notify={notify} />
<ul class="assurances">
{#each assurances as item}
<li>
<h2>{item.title}</h2>
<p>{item.detail}</p>
</li>
{/each}
</ul>
<div class="stack sections">
<Removals />
<HowItWorks />
<Trust />
<Faq />
</div>
</div>
</main>
<Footer />
<Toast {toasts} {dismiss} />
<style>
main {
padding-bottom: var(--space-8);
}
.intro {
display: grid;
gap: var(--space-4);
align-items: center;
}
@media (min-width: 900px) {
.intro {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-7);
}
}
.assurances {
display: grid;
gap: var(--space-3);
margin-top: var(--space-5);
list-style: none;
}
@media (min-width: 768px) {
.assurances {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
}
.assurances li {
display: grid;
gap: var(--space-1);
padding-left: var(--space-4);
border-left: var(--border-w) solid var(--border-strong);
}
.assurances h2 {
font-size: var(--fs-2);
line-height: var(--lh-2);
font-weight: 600;
}
.assurances p {
color: var(--text-3);
font-size: var(--fs-1);
line-height: var(--lh-2);
}
.sections {
margin-top: var(--space-9);
}
</style>
<Workspace />
+198 -639
View File
@@ -1,640 +1,199 @@
/* ==========================================================================
Untrace — application stylesheet
==========================================================================
Design system: RealBrain v2.0. Tokens come from ./tokens.css (vendored,
never edited by hand). Everything below composes those tokens only.
Refresh the vendored copy with:
curl -o src/tokens.css https://realbrain.cc/design-system/tokens.css
========================================================================== */
@import "./tokens.css";
/* --------------------------------------------------------------------------
Values the design system has no token for yet. Flagged for the system
owners rather than sprinkled around as raw values:
NEW TOKEN --border-w: 1px — hairline width, used by every surface
NEW TOKEN --topbar-h: 56px — sticky product bar height
(derived: --space-7 + --space-2)
NEW TOKEN breakpoints — 560px / 768px / 900px
Breakpoints cannot be custom properties: a media query is evaluated before
the cascade, so var() is not available there. Until the system ships
@custom-media or a documented scale, every file in this app uses exactly
these three widths and no others.
-------------------------------------------------------------------------- */
:root {
--border-w: 1px;
--topbar-h: calc(var(--space-7) + var(--space-2));
}
/* An <a> carrying .btn is a button, not prose: the base underline-on-hover
from tokens.css does not belong on it. */
a.btn:hover {
text-decoration: none;
}
/* --------------------------------------------------------------------------
Layout
-------------------------------------------------------------------------- */
.wrap {
width: 100%;
max-width: var(--max-marketing);
margin-inline: auto;
padding-inline: var(--space-4);
}
@media (min-width: 768px) {
.wrap {
padding-inline: var(--space-6);
}
}
.stack {
display: grid;
gap: var(--space-7);
}
@media (min-width: 768px) {
.stack {
gap: var(--space-9);
}
}
/* --------------------------------------------------------------------------
Product bar
-------------------------------------------------------------------------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
height: var(--topbar-h);
display: flex;
align-items: center;
background: var(--bg);
border-bottom: var(--border-w) solid var(--border);
}
.topbar .inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.wordmark {
display: inline-flex;
align-items: center;
gap: var(--space-3);
color: var(--text-1);
font-size: var(--fs-3);
line-height: var(--lh-3);
font-weight: 600;
letter-spacing: -0.01em;
}
.wordmark:hover {
text-decoration: none;
}
/* --------------------------------------------------------------------------
Section headings
-------------------------------------------------------------------------- */
.eyebrow {
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: var(--lh-1);
color: var(--accent);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.section-head {
display: grid;
gap: var(--space-2);
margin-bottom: var(--space-5);
max-width: calc(var(--space-9) * 6);
}
.section-head h2 {
font-size: var(--fs-6);
line-height: var(--lh-6);
font-weight: 600;
letter-spacing: -0.01em;
}
.section-head p {
color: var(--text-2);
font-size: var(--fs-3);
line-height: var(--lh-3);
}
@media (min-width: 768px) {
.section-head h2 {
font-size: var(--fs-7);
line-height: var(--lh-7);
}
}
/* --------------------------------------------------------------------------
Hero
-------------------------------------------------------------------------- */
.lede {
display: grid;
gap: var(--space-4);
justify-items: start;
padding-block: var(--space-7) var(--space-6);
}
.lede h1 {
font-size: var(--fs-7);
line-height: var(--lh-7);
font-weight: 600;
letter-spacing: -0.02em;
max-width: calc(var(--space-9) * 8);
}
.lede p {
color: var(--text-2);
font-size: var(--fs-4);
line-height: var(--lh-4);
max-width: calc(var(--space-9) * 6);
}
@media (min-width: 768px) {
.lede {
padding-block: var(--space-8) var(--space-7);
}
.lede h1 {
font-size: var(--fs-8);
line-height: var(--lh-8);
}
}
/* --------------------------------------------------------------------------
Chips — neutral mono labels for formats and counts
-------------------------------------------------------------------------- */
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-full);
background: var(--surface);
color: var(--text-2);
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: var(--lh-1);
white-space: nowrap;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* --------------------------------------------------------------------------
Stepper
-------------------------------------------------------------------------- */
.stepper {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
padding-bottom: var(--space-4);
border-bottom: var(--border-w) solid var(--border);
margin-bottom: var(--space-5);
}
.step {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
color: var(--text-3);
font-size: var(--fs-1);
line-height: var(--lh-1);
font-weight: 500;
}
.step .num {
display: inline-grid;
place-content: center;
flex: none;
width: var(--space-5);
height: var(--space-5);
border-radius: var(--radius-full);
border: var(--border-w) solid var(--border-strong);
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: 1;
transition:
background var(--dur-2) var(--ease),
border-color var(--dur-2) var(--ease),
color var(--dur-2) var(--ease);
}
.step .label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.step[data-state="current"] {
color: var(--text-1);
}
.step[data-state="current"] .num {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.step[data-state="done"] {
color: var(--text-2);
}
.step[data-state="done"] .num {
background: var(--surface);
border-color: var(--success);
color: var(--success);
}
/* --------------------------------------------------------------------------
Dropzone
-------------------------------------------------------------------------- */
.dropzone {
display: grid;
justify-items: center;
align-content: center;
gap: var(--space-3);
width: 100%;
min-height: calc(var(--space-9) * 2);
padding: var(--space-7) var(--space-5);
text-align: center;
background: var(--bg);
border: var(--border-w) dashed var(--border-strong);
border-radius: var(--radius-md);
color: inherit;
font-family: var(--font-ui);
cursor: pointer;
transition:
border-color var(--dur-2) var(--ease),
background var(--dur-2) var(--ease);
}
.dropzone:hover:not(:disabled) {
border-color: var(--text-3);
background: var(--surface);
}
.dropzone[data-drag="true"] {
border-color: var(--accent);
border-style: solid;
background: color-mix(in oklab, var(--accent), transparent 94%);
}
.dropzone:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.dropzone .glyph {
display: grid;
place-content: center;
width: calc(var(--space-6) + var(--space-2));
height: calc(var(--space-6) + var(--space-2));
border-radius: var(--radius-md);
border: var(--border-w) solid var(--border-strong);
color: var(--text-3);
transition: color var(--dur-2) var(--ease);
}
.dropzone[data-drag="true"] .glyph {
color: var(--accent);
border-color: var(--accent);
}
.dropzone .title {
font-size: var(--fs-4);
line-height: var(--lh-4);
font-weight: 600;
color: var(--text-1);
}
.dropzone .hint {
font-size: var(--fs-2);
line-height: var(--lh-2);
color: var(--text-3);
max-width: calc(var(--space-9) * 4);
}
/* --------------------------------------------------------------------------
Image preview
-------------------------------------------------------------------------- */
.preview {
display: grid;
place-content: center;
padding: var(--space-4);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
}
.preview img {
display: block;
max-width: 100%;
max-height: 34vh;
border-radius: var(--radius-sm);
}
@media (min-width: 768px) {
.preview img {
max-height: 40vh;
}
}
/* --------------------------------------------------------------------------
Key/value + finding rows
-------------------------------------------------------------------------- */
.rows {
display: grid;
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--surface);
}
.rows > * + * {
border-top: var(--border-w) solid var(--border);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
font-size: var(--fs-2);
line-height: var(--lh-2);
}
.row .k {
color: var(--text-2);
min-width: 0;
}
.row .v {
font-family: var(--font-mono);
color: var(--text-1);
text-align: right;
white-space: nowrap;
}
/* --------------------------------------------------------------------------
Raw metadata dump
-------------------------------------------------------------------------- */
.raw {
margin: 0;
padding: var(--space-4);
max-height: calc(var(--space-9) * 3);
overflow: auto;
background: var(--bg);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: var(--lh-2);
color: var(--text-2);
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 2;
}
/* --------------------------------------------------------------------------
Disclosure (FAQ + expandable panels)
-------------------------------------------------------------------------- */
.disclosure {
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.disclosure + .disclosure {
margin-top: var(--space-2);
}
.disclosure > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4);
min-height: var(--control-h);
cursor: pointer;
list-style: none;
font-size: var(--fs-3);
line-height: var(--lh-3);
font-weight: 500;
color: var(--text-1);
transition: color var(--dur-1) var(--ease);
}
.disclosure > summary::-webkit-details-marker {
display: none;
}
.disclosure > summary:hover {
color: var(--accent);
}
.disclosure > summary .caret {
flex: none;
color: var(--text-3);
transition: transform var(--dur-2) var(--ease);
}
.disclosure[open] > summary .caret {
transform: rotate(180deg);
}
.disclosure .body {
padding: 0 var(--space-4) var(--space-4);
color: var(--text-2);
font-size: var(--fs-2);
line-height: var(--lh-3);
}
.disclosure .body a {
text-decoration: underline;
}
/* --------------------------------------------------------------------------
Numbered how-to steps
-------------------------------------------------------------------------- */
.howto {
display: grid;
gap: var(--space-4);
counter-reset: howto;
list-style: none;
}
@media (min-width: 768px) {
.howto {
grid-template-columns: repeat(3, 1fr);
gap: var(--space-5);
}
}
.howto > li {
counter-increment: howto;
display: grid;
gap: var(--space-2);
padding: var(--space-5);
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
}
.howto > li::before {
content: counter(howto);
display: grid;
place-content: center;
width: var(--space-6);
height: var(--space-6);
border-radius: var(--radius-sm);
background: var(--elevated);
border: var(--border-w) solid var(--border-strong);
color: var(--accent);
font-family: var(--font-mono);
font-size: var(--fs-2);
line-height: 1;
margin-bottom: var(--space-1);
}
.howto h3 {
font-size: var(--fs-3);
line-height: var(--lh-3);
font-weight: 600;
}
.howto p {
color: var(--text-2);
font-size: var(--fs-2);
line-height: var(--lh-2);
}
/* --------------------------------------------------------------------------
Feature / removal grid
-------------------------------------------------------------------------- */
.tiles {
display: grid;
gap: var(--space-3);
grid-template-columns: 1fr;
}
@media (min-width: 560px) {
.tiles {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 900px) {
.tiles {
grid-template-columns: repeat(3, 1fr);
}
}
.tile {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
}
.tile h3 {
font-size: var(--fs-2);
line-height: var(--lh-2);
font-weight: 600;
color: var(--text-1);
}
.tile p {
font-size: var(--fs-1);
line-height: var(--lh-2);
color: var(--text-3);
}
/* --------------------------------------------------------------------------
Mobile action bar — keeps the one primary action reachable with a thumb
-------------------------------------------------------------------------- */
.action-bar {
position: sticky;
bottom: 0;
z-index: 10;
display: grid;
gap: var(--space-2);
padding: var(--space-3) var(--space-4)
calc(var(--space-3) + env(safe-area-inset-bottom));
margin-inline: calc(var(--space-4) * -1);
background: var(--surface);
border-top: var(--border-w) solid var(--border);
}
@media (min-width: 768px) {
.action-bar {
position: static;
display: flex;
gap: var(--space-3);
padding: 0;
margin-inline: 0;
background: transparent;
border-top: 0;
}
}
.btn-block {
width: 100%;
}
@media (min-width: 768px) {
.btn-block {
width: auto;
}
}
/* --------------------------------------------------------------------------
Footer
-------------------------------------------------------------------------- */
.site-footer {
padding-block: var(--space-7);
border-top: var(--border-w) solid var(--border);
color: var(--text-3);
font-size: var(--fs-2);
line-height: var(--lh-2);
}
/* --------------------------------------------------------------------------
Utilities that earn their keep
-------------------------------------------------------------------------- */
.visually-hidden {
position: absolute;
width: var(--border-w);
height: var(--border-w);
margin: calc(var(--border-w) * -1);
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.divider {
height: var(--border-w);
background: var(--border);
border: 0;
@import './tokens.css';
@import './themes.css';
/* Existing project proposal: one-pixel hairlines; breakpoints at 560/768/900. */
:root { --border-w: 1px; }
html, body, #app { height: 100%; }
body { overflow: hidden; }
button, select, summary, a { -webkit-app-region: no-drag; }
a.btn:hover { text-decoration: none; }
.visually-hidden { position: absolute; width: 0; height: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.desktop-app { height: 100dvh; display: grid; grid-template-columns: var(--space-8) minmax(0, 1fr) calc(var(--space-9) * 3 + var(--space-6)); grid-template-rows: var(--space-8) minmax(0, 1fr) var(--space-6); }
.app-rail { grid-row: 1 / -1; display: flex; flex-direction: column; align-items: center; gap: var(--space-5); padding: var(--space-4) var(--space-2); background: var(--surface); border-right: var(--border-w) solid var(--border); }
.brand-mark { display: grid; place-items: center; height: var(--space-6); color: var(--text-1); }
.rail-current { width: var(--space-7); height: var(--space-7); display: grid; place-items: center; background: var(--bg); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-1); }
.rail-bottom { margin-top: auto; display: grid; gap: var(--space-2); }
.app-header { grid-column: 2 / -1; display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-inline: var(--space-5); border-bottom: var(--border-w) solid var(--border); background: var(--surface); }
.app-heading { display: flex; align-items: center; gap: var(--space-4); min-width: 0; }
.app-header, .app-rail { color: var(--text-1); }
.app-heading h1 { font-size: var(--fs-4); line-height: var(--lh-4); font-weight: 600; }
.app-heading > span:last-child { font-size: var(--fs-2); color: var(--text-3); }
.header-divider { height: var(--space-4); border-left: var(--border-w) solid var(--border-strong); }
.local-badge { display: flex; align-items: center; gap: var(--space-2); font-size: var(--fs-1); color: var(--text-3); }
.editor { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.editor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-5); }
.toolbar-file { display: flex; align-items: center; gap: var(--space-2); min-width: 0; color: var(--text-3); font-size: var(--fs-2); }
.toolbar-file svg { flex: none; }
.toolbar-file span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.toolbar-actions { display: flex; gap: var(--space-1); flex: none; }
.image-workspace { position: relative; flex: 1; min-height: 0; margin-inline: var(--space-5); display: flex; flex-direction: column; border: var(--border-w) solid var(--border); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.welcome { flex: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-4); text-align: center; padding: var(--space-6); border: var(--border-w) dashed var(--border-strong); border-radius: var(--radius-md); margin: var(--space-3); }
.image-emblem { position: relative; width: var(--space-8); height: var(--space-8); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-lg); display: grid; place-items: center; color: var(--text-2); background: var(--bg); margin-bottom: var(--space-3); }
.image-emblem > svg { width: var(--space-6); height: var(--space-6); }
.emblem-badge { position: absolute; right: calc(var(--space-2) * -1); bottom: calc(var(--space-2) * -1); display: grid; place-items: center; width: var(--space-6); height: var(--space-6); background: var(--surface); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-md); }
.welcome-copy { display: grid; gap: var(--space-3); margin-bottom: var(--space-2); }
.overline { font-size: var(--fs-1); line-height: var(--lh-1); font-weight: 500; color: var(--text-3); }
.welcome h2 { font-size: var(--fs-7); line-height: var(--lh-7); font-weight: 500; letter-spacing: -0.025em; }
.welcome-copy > p:last-child { font-size: var(--fs-2); line-height: var(--lh-3); color: var(--text-3); }
.shortcut-hint { color: var(--text-3); display: flex; gap: var(--space-2); align-items: center; font-size: var(--fs-1); }
kbd { font: inherit; font-family: var(--font-mono); font-size: var(--fs-1); padding: var(--space-1) var(--space-2); border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); color: var(--text-2); }
.format-hint { display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--text-3); font-size: var(--fs-1); margin-top: var(--space-5); }
.format-hint span { color: var(--border-strong); }
.workspace-note { display: flex; justify-content: center; align-items: center; gap: var(--space-2); padding: var(--space-4); color: var(--text-3); font-size: var(--fs-1); line-height: var(--lh-1); }
.workspace-note svg { flex: none; }
.preview-controls, .preview-bottom { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-2) var(--space-3); flex: none; }
.preview-controls { border-bottom: var(--border-w) solid var(--border); }
.preview-bottom { border-top: var(--border-w) solid var(--border); color: var(--text-3); font-size: var(--fs-1); font-family: var(--font-mono); }
.view-switch, .zoom-controls { display: flex; align-items: center; gap: var(--space-1); }
/* State indicator sits on the wrapper, retaining the shared button styles. */
.view-switch { border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); }
[aria-pressed='true'] { text-decoration: underline; text-underline-offset: var(--space-1); }
.preview-label { font-size: var(--fs-1); color: var(--text-3); font-family: var(--font-mono); }
.preview-viewport { flex: 1; min-height: 0; overflow: auto; display: flex; padding: var(--space-5); background: var(--bg); }
.workspace-image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; margin: auto; }
.actual-size .workspace-image { max-width: none; max-height: none; flex-shrink: 0; }
.preview-unavailable { display: grid; justify-items: center; align-content: center; gap: var(--space-3); text-align: center; margin: auto; color: var(--text-3); }
.preview-unavailable h2 { font-size: var(--fs-4); font-weight: 500; color: var(--text-2); }
.preview-unavailable p { font-size: var(--fs-2); }
.drop-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; gap: var(--space-4); align-items: center; justify-content: center; background: var(--surface); border: var(--border-w) dashed var(--accent); color: var(--text-1); pointer-events: none; z-index: 5; }
.drop-overlay h2 { font-size: var(--fs-5); font-weight: 500; }
.processing-overlay { position: absolute; left: 50%; bottom: var(--space-8); transform: translateX(-50%); display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); background: var(--surface); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--fs-2); white-space: nowrap; }
.inspector { display: flex; flex-direction: column; min-height: 0; min-width: 0; background: var(--surface); border-left: var(--border-w) solid var(--border); }
.inspector-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-5); }
.inspector-heading h2 { font-size: var(--fs-3); line-height: var(--lh-3); font-weight: 500; }
.subtle { color: var(--text-3); font-size: var(--fs-1); }
.inspector-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
.inspector-section { padding: var(--space-4) var(--space-5); border-top: var(--border-w) solid var(--border); }
.section-label { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); margin-bottom: var(--space-4); color: var(--text-3); }
.section-label h3 { color: var(--text-2); font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; }
.source-placeholder { display: flex; align-items: center; gap: var(--space-3); color: var(--text-3); }
.source-placeholder p { font-size: var(--fs-2); color: var(--text-2); }
.source-placeholder span { display: block; font-size: var(--fs-1); color: var(--text-3); }
.source-name { font-size: var(--fs-2); font-weight: 500; overflow-wrap: anywhere; margin-bottom: var(--space-3); }
.properties { display: grid; gap: var(--space-2); font-size: var(--fs-1); }
.properties > div, .shortcut-list > div { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.properties dt { color: var(--text-3); }
.properties dd { color: var(--text-2); font-family: var(--font-mono); }
.section-description { font-size: var(--fs-1); color: var(--text-3); margin-bottom: var(--space-4); }
.coverage-list { list-style: none; display: grid; gap: var(--space-2); }
.coverage-list li { display: flex; align-items: center; gap: var(--space-3); font-size: var(--fs-2); color: var(--text-3); }
.count-badge { font-size: var(--fs-1); color: var(--text-3); }
.metadata-list { display: grid; gap: var(--space-4); list-style: none; }
.metadata-list p { font-size: var(--fs-1); color: var(--text-3); line-height: var(--lh-1); margin-top: var(--space-1); overflow-wrap: anywhere; }
.metadata-details { margin-top: var(--space-5); font-size: var(--fs-1); color: var(--text-3); }
.metadata-details summary { cursor: pointer; padding-block: var(--space-2); }
.metadata-details pre { font-family: var(--font-mono); font-size: var(--fs-1); line-height: var(--lh-2); white-space: pre-wrap; overflow-wrap: anywhere; max-height: calc(var(--space-9) * 3); overflow: auto; padding: var(--space-3); background: var(--bg); border-radius: var(--radius-sm); margin-top: var(--space-2); }
.scan-note { display: flex; gap: var(--space-2); font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.scan-note svg { flex: none; margin-top: var(--space-1); }
.export-fields { display: grid; gap: var(--space-4); }
.export-hint { font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.inspector-action { display: grid; gap: var(--space-3); padding: var(--space-5); border-top: var(--border-w) solid var(--border); }
.inspector-action p { font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.inspector-action .output-name { overflow-wrap: anywhere; color: var(--text-2); }
.output-name span { display: block; color: var(--text-3); }
.app-statusbar { grid-column: 2 / -1; display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-inline: var(--space-4); border-top: var(--border-w) solid var(--border); background: var(--surface); font-size: var(--fs-1); color: var(--text-3); }
.statusbar-local { display: flex; align-items: center; gap: var(--space-2); }
.inline-notice { display: flex; align-items: center; gap: var(--space-3); margin: 0 var(--space-5) var(--space-3); font-size: var(--fs-2); color: var(--error); }
.inline-notice > svg { flex: none; }
.inline-notice > span { flex: 1; }
.help-content { display: grid; gap: var(--space-5); }
.help-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.help-heading h2 { font-size: var(--fs-6); line-height: var(--lh-6); font-weight: 500; margin-top: var(--space-2); }
.help-content h3 { font-size: var(--fs-3); font-weight: 500; }
.help-content > p { font-size: var(--fs-2); line-height: var(--lh-3); color: var(--text-2); }
.shortcut-list { display: grid; gap: var(--space-2); font-size: var(--fs-2); color: var(--text-2); }
.help-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); padding-top: var(--space-4); border-top: var(--border-w) solid var(--border); font-size: var(--fs-1); color: var(--text-3); }
@media (max-width: 900px) {
.desktop-app { grid-template-columns: var(--space-8) minmax(0, 1fr) calc(var(--space-9) * 3); }
.editor-toolbar { padding-inline: var(--space-3); }
.image-workspace { margin-inline: var(--space-3); }
.welcome { padding: var(--space-4); }
.welcome h2 { font-size: var(--fs-6); line-height: var(--lh-6); }
.desktop-break { display: none; }
.statusbar-tip { display: none; }
}
@media (max-width: 768px) {
body { overflow: auto; }
.desktop-app { height: auto; min-height: 100dvh; grid-template-columns: var(--space-8) minmax(0, 1fr); grid-template-rows: var(--space-8) auto auto var(--space-6); }
.app-rail { position: sticky; top: 0; height: 100dvh; }
.editor { grid-column: 2; min-height: calc(var(--space-9) * 6); }
.image-workspace { min-height: calc(var(--space-9) * 4); }
.preview-viewport { height: calc(var(--space-9) * 4); flex: none; }
.inspector { grid-column: 2; border-left: 0; border-top: var(--border-w) solid var(--border); }
.inspector-scroll { overflow: visible; }
.app-statusbar { grid-column: 2; }
.local-badge { display: none; }
.app-heading { gap: var(--space-3); }
}
/* Theme picker: palette previews use the same semantic tokens as the app. */
.appearance-content { display: grid; gap: var(--space-4); }
.appearance-description { font-size: var(--fs-2); line-height: var(--lh-2); color: var(--text-2); }
.theme-choices { border: 0; min-width: 0; display: grid; gap: var(--space-4); }
.system-choice { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-md); cursor: pointer; }
.system-choice > span { flex: 1; }
.system-choice > svg { color: var(--text-3); }
.theme-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.theme-choice { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-2); border: var(--border-w) solid var(--border); border-radius: var(--radius-md); cursor: pointer; min-width: 0; }
.theme-choice:hover { border-color: var(--border-strong); }
.theme-choice.selected { border-color: var(--text-2); }
.theme-choice:focus-within, .system-choice:focus-within { outline: var(--border-w) solid var(--accent); outline-offset: var(--space-1); }
.theme-choice-label { display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-1) var(--space-1); }
.theme-choice-label input, .system-choice input { flex: none; }
.theme-name { display: block; font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; color: var(--text-1); }
.theme-description { display: block; font-size: var(--fs-1); line-height: var(--lh-1); color: var(--text-3); }
.theme-mini { height: var(--space-8); display: grid; grid-template-columns: var(--space-3) minmax(0, 1fr) var(--space-6); grid-template-rows: var(--space-3) 1fr; gap: var(--border-w); padding: var(--border-w); background: var(--border); border-radius: var(--radius-sm); overflow: hidden; }
.mini-rail { grid-row: 1 / -1; background: var(--surface); }
.mini-header { grid-column: 2 / -1; background: var(--surface); }
.mini-canvas { display: grid; place-items: center; background: var(--bg); }
.mini-image { display: grid; place-items: center; color: var(--text-2); }
.mini-inspector { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-1); background: var(--surface); }
.mini-inspector > span { height: var(--space-1); background: var(--border-strong); border-radius: var(--radius-sm); }
.mini-inspector .mini-action { margin-top: auto; height: var(--space-2); background: var(--accent); }
.appearance-note { display: flex; align-items: flex-start; gap: var(--space-2); font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.appearance-note svg { flex: none; }
.appearance-footer { display: flex; justify-content: flex-end; }
/* Batch queue and inspection use the same semantic palette as the workspace. */
.batch-workspace { flex: 1; min-height: 0; display: flex; flex-direction: column; margin-inline: var(--space-5); border: var(--border-w) solid var(--border); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.batch-heading { display: grid; gap: var(--space-2); padding: var(--space-5); border-bottom: var(--border-w) solid var(--border); }
.batch-heading h2 { font-size: var(--fs-5); line-height: var(--lh-5); font-weight: 500; }
.batch-heading p, .batch-progress { font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
.batch-list { flex: 1; min-height: 0; overflow: auto; }
.batch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto var(--space-9); align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border-bottom: var(--border-w) solid var(--border); }
.batch-row.selected { background: var(--bg); }
.batch-file { min-width: 0; justify-content: flex-start; }
.batch-file span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.batch-row-status { display: grid; gap: var(--space-1); font-size: var(--fs-1); }
.batch-size { font: var(--fs-1)/var(--lh-1) var(--font-mono); color: var(--text-3); text-align: right; }
.batch-progress { padding: var(--space-3) var(--space-5); border-top: var(--border-w) solid var(--border); }
.batch-detail { overflow-wrap: anywhere; font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-2); margin-block: var(--space-3); }
.gps-badge { display: flex; gap: var(--space-1); align-items: center; color: var(--warning); }
.location-warning { display: flex; gap: var(--space-2); padding: var(--space-4); margin: var(--space-3); border: var(--border-w) solid var(--border-strong); border-radius: var(--radius-sm); font-size: var(--fs-1); line-height: var(--lh-2); background: var(--bg); }
.location-warning > svg { flex: none; color: var(--warning); }
.location-warning strong { font-weight: 500; color: var(--text-1); }
.location-warning p { color: var(--text-2); overflow-wrap: anywhere; }
.size-preview { display: grid; gap: var(--space-3); margin-top: var(--space-4); }
.comparison-dialog { max-height: calc(100dvh - var(--space-6)); overflow: hidden; }
.comparison-content { display: flex; flex-direction: column; gap: var(--space-4); max-height: calc(100dvh - var(--space-9)); }
.comparison-content .overline { overflow-wrap: anywhere; }
.comparison-scroll { overflow: auto; min-height: 0; }
.comparison-scroll th, .comparison-scroll td { white-space: pre-wrap; overflow-wrap: anywhere; max-width: calc(var(--space-9) * 3); font-size: var(--fs-1); font-weight: 400; }
.comparison-scroll th { font-weight: 500; }
@media (max-width: 768px) {
.editor-toolbar { flex-wrap: wrap; }
.batch-workspace { min-height: calc(var(--space-9) * 3); max-height: calc(var(--space-9) * 5); }
.batch-row { grid-template-columns: minmax(0, 1fr) auto; }
.batch-size { display: none; }
.processing-overlay { max-width: calc(100% - var(--space-4)); flex-wrap: wrap; justify-content: center; white-space: normal; }
}
.appearance-dialog { max-height: calc(100dvh - var(--space-6)); overflow: hidden; }
.appearance-content { max-height: calc(100dvh - var(--space-9)); grid-template-rows: auto auto minmax(0, 1fr) auto auto; }
.theme-choices { overflow-y: auto; padding: var(--space-1); scrollbar-gutter: stable; }
.theme-group { display: grid; gap: var(--space-3); }
.theme-group-title { font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; color: var(--text-2); }
.theme-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 768px) {
.theme-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
-123
View File
@@ -1,123 +0,0 @@
/* Guide pages: long-form reading layout on top of the design system. */
.guide {
padding-block: var(--space-7) var(--space-9);
}
.guide .crumbs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
padding-left: 0;
color: var(--text-3);
font-size: var(--fs-2);
line-height: var(--lh-2);
list-style: none;
}
.guide .crumbs li + li::before {
content: "/";
margin-right: var(--space-2);
}
.guide article {
display: grid;
gap: var(--space-4);
max-width: calc(var(--space-9) * 6);
}
.guide h1 {
font-size: var(--fs-7);
line-height: var(--lh-7);
font-weight: 600;
letter-spacing: -0.02em;
}
.guide h2 {
margin-top: var(--space-5);
font-size: var(--fs-5);
line-height: var(--lh-5);
font-weight: 600;
}
.guide .lead {
color: var(--text-2);
font-size: var(--fs-4);
line-height: var(--lh-4);
}
.guide p,
.guide li {
font-size: var(--fs-3);
line-height: var(--lh-3);
color: var(--text-2);
}
.guide ul,
.guide ol {
display: grid;
gap: var(--space-2);
padding-left: var(--space-5);
}
.guide strong {
color: var(--text-1);
font-weight: 600;
}
.guide .callout {
padding: var(--space-4);
border: var(--border-w) solid var(--border);
border-left: var(--space-1) solid var(--accent);
border-radius: var(--radius-md);
background: var(--surface);
}
.guide .cta {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
padding: var(--space-5);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
}
.guide .cta p {
flex: 1 1 calc(var(--space-9) * 3);
color: var(--text-1);
}
.guide .meta {
color: var(--text-3);
font-size: var(--fs-2);
line-height: var(--lh-2);
}
.guide .related ul {
list-style: none;
padding-left: 0;
}
.guide .index-list {
display: grid;
gap: var(--space-3);
margin-top: var(--space-5);
}
.guide .index-list a.card {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
color: var(--text-1);
}
.guide .index-list a.card:hover {
text-decoration: none;
border-color: var(--accent);
}
.guide .index-list span {
color: var(--text-2);
}
-2
View File
@@ -1,2 +0,0 @@
import './app.css'
import './guide.css'
+47
View File
@@ -0,0 +1,47 @@
<script>
import { THEMES, theme } from './theme.js';
import Icon from './Icon.svelte';
let dialog;
export function open() { dialog.showModal(); }
export function isOpen() { return !!dialog?.open; }
</script>
<dialog class="modal appearance-dialog" bind:this={dialog} aria-labelledby="appearance-title" style:margin="auto" style:width="calc(100% - var(--space-6))" style:max-width="calc(var(--space-9) * 8)">
<div class="appearance-content">
<div class="help-heading">
<div><span class="overline">Make it yours</span><h2 id="appearance-title">Appearance</h2></div>
<button class="btn btn-ghost" type="button" on:click={() => dialog.close()} aria-label="Close appearance"><Icon name="close" /></button>
</div>
<div class="appearance-description">Choose a theme for your workspace. Your choice is saved automatically.</div>
<fieldset class="theme-choices">
<legend class="visually-hidden">Workspace theme</legend>
<label class="system-choice">
<input class="radio" type="radio" name="workspace-theme" value="system" checked={$theme === 'system'} on:change={() => theme.select('system')} />
<span><span class="theme-name">Follow Windows</span><span class="theme-description">Match the system light or dark setting.</span></span>
<Icon name="monitor" />
</label>
{#each [{ name: 'Editor favourites', items: THEMES.filter(item => item.editor) }, { name: 'Untrace originals', items: THEMES.filter(item => !item.editor) }] as group}
<div class="theme-group">
<h3 class="theme-group-title">{group.name}</h3>
<div class="theme-grid">
{#each group.items as item}
<label class="theme-choice" class:selected={$theme === item.id}>
<div class="theme-mini" data-theme={item.id} aria-hidden="true">
<span class="mini-rail"></span><span class="mini-header"></span>
<span class="mini-canvas"><span class="mini-image"><Icon name="image" size="sm" /></span></span>
<span class="mini-inspector"><span></span><span></span><span class="mini-action"></span></span>
</div>
<span class="theme-choice-label">
<input class="radio" type="radio" name="workspace-theme" value={item.id} checked={$theme === item.id} on:change={() => theme.select(item.id)} />
<span><span class="theme-name">{item.name}</span><span class="theme-description">{item.description}</span></span>
</span>
</label>
{/each}
</div>
</div>
{/each}
</fieldset>
<div class="appearance-note"><Icon name="image" size="sm" />Themes change the workspace, never your image or exported colours.</div>
<div class="appearance-footer"><button class="btn btn-secondary" type="button" on:click={() => dialog.close()}>Done</button></div>
</div>
</dialog>
-281
View File
@@ -1,281 +0,0 @@
<script>
import { createEventDispatcher, onDestroy } from "svelte";
import { extractMetadata, findings, isClean } from "./metadata.js";
import { cleanImage, formatBytes } from "./clean.js";
import { createZip } from "./zip.js";
import Icon from "./Icon.svelte";
export let files;
const dispatch = createEventDispatcher();
/** @type {'scanning'|'review'|'cleaning'|'done'} */
let stage = "scanning";
let items = files.map((file, id) => ({
id,
file,
state: "pending", // pending | scanned | cleaning | done | failed
labels: [],
clean: false,
result: null,
}));
let zipping = false;
let cancelled = false;
$: scanned = items.filter((item) => item.state !== "pending").length;
$: done = items.filter((item) => item.state === "done" || item.state === "failed").length;
$: succeeded = items.filter((item) => item.state === "done");
$: dirty = items.filter((item) => !item.clean).length;
function patch(id, values) {
items = items.map((item) => (item.id === id ? { ...item, ...values } : item));
}
async function scanAll() {
for (const item of items) {
if (cancelled) return;
try {
const metadata = await extractMetadata(item.file);
patch(item.id, {
state: "scanned",
clean: isClean(metadata),
labels: findings(metadata).filter((f) => f.severity !== "info"),
});
} catch {
patch(item.id, { state: "scanned", clean: false, labels: [] });
}
}
stage = "review";
}
async function cleanAll() {
stage = "cleaning";
for (const item of items) {
if (cancelled) return;
patch(item.id, { state: "cleaning" });
const result = await cleanImage(item.file);
patch(item.id, { state: result.fallback ? "failed" : "done", result });
}
stage = "done";
dispatch("notify", {
tone: items.some((i) => i.state === "failed") ? "err" : "ok",
message: items.some((i) => i.state === "failed")
? "Some images could not be re-encoded and were skipped."
: `Metadata removed from ${items.length} images.`,
});
}
async function downloadZip() {
zipping = true;
try {
const used = new Set();
const entries = [];
for (const item of succeeded) {
let name = item.result.filename;
for (let n = 2; used.has(name); n++) {
name = item.result.filename.replace(/(\.[^.]+)$/, `-${n}$1`);
}
used.add(name);
const data = new Uint8Array(await (await fetch(item.result.url)).arrayBuffer());
entries.push({ name, data });
}
const url = URL.createObjectURL(createZip(entries));
const link = document.createElement("a");
link.href = url;
link.download = "untrace-cleaned.zip";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch (error) {
console.error("ZIP failed:", error);
dispatch("notify", { tone: "err", message: "Could not build the ZIP. Download the images one by one instead." });
} finally {
zipping = false;
}
}
function reset() {
cancelled = true;
dispatch("reset");
}
scanAll();
onDestroy(() => {
cancelled = true;
for (const item of items) if (item.result?.url) URL.revokeObjectURL(item.result.url);
});
</script>
<div class="batch" aria-live="polite">
<div class="head">
<h3>
{#if stage === "scanning"}
Scanning {scanned} of {items.length}…
{:else if stage === "review"}
{items.length} images ready, {dirty} with hidden data
{:else if stage === "cleaning"}
Cleaning {done} of {items.length}…
{:else}
{succeeded.length} of {items.length} cleaned
{/if}
</h3>
<progress
max={items.length}
value={stage === "scanning" ? scanned : stage === "review" ? items.length : done}
aria-label="Progress"
></progress>
</div>
<ul class="list">
{#each items as item (item.id)}
<li class="item">
<div class="name">
<span class="file" title={item.file.name}>{item.file.name}</span>
<span class="size">{formatBytes(item.file.size)}</span>
</div>
<div class="state">
{#if item.state === "pending"}
<span class="status info">Waiting</span>
{:else if item.state === "cleaning"}
<span class="status info">Cleaning</span>
{:else if item.state === "done"}
<span class="status ok">Clean</span>
<a class="btn btn-ghost dl" href={item.result.url} download={item.result.filename}>
<Icon name="download" size="sm" /> Download
</a>
{:else if item.state === "failed"}
<span class="status err">Could not re-encode</span>
{:else if item.clean}
<span class="status ok">Already clean</span>
{:else}
{#each item.labels.slice(0, 3) as label}
<span class="status {label.severity}">{label.label}</span>
{/each}
{#if item.labels.length > 3}
<span class="more">+{item.labels.length - 3} more</span>
{/if}
{/if}
</div>
</li>
{/each}
</ul>
<div class="action-bar">
{#if stage === "done"}
<button
type="button"
class="btn btn-primary btn-block"
aria-disabled={zipping || succeeded.length === 0}
on:click={() => !zipping && succeeded.length > 0 && downloadZip()}
>
<Icon name="download" size="sm" />
{zipping ? "Building ZIP…" : `Download ${succeeded.length} as ZIP`}
</button>
<button type="button" class="btn btn-secondary btn-block" on:click={reset}>
<Icon name="refresh" size="sm" />
Clean more images
</button>
{:else}
<button
type="button"
class="btn btn-primary btn-block"
aria-disabled={stage !== "review"}
on:click={() => stage === "review" && cleanAll()}
>
{#if stage === "cleaning"}
<span class="spinner" aria-hidden="true"></span>
Cleaning…
{:else}
<Icon name="shield" size="sm" />
Clean {items.length} images
{/if}
</button>
<button type="button" class="btn btn-ghost btn-block" disabled={stage === "cleaning"} on:click={reset}>
Choose different images
</button>
{/if}
</div>
</div>
<style>
.batch {
display: grid;
gap: var(--space-4);
}
.head {
display: grid;
gap: var(--space-2);
}
.head h3 {
font-size: var(--fs-4);
line-height: var(--lh-4);
font-weight: 600;
}
progress {
width: 100%;
height: var(--space-2);
accent-color: var(--accent);
}
.list {
display: grid;
gap: var(--space-2);
list-style: none;
max-height: calc(var(--space-9) * 5);
overflow-y: auto;
}
.item {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
@media (min-width: 768px) {
.item {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
}
.name {
display: flex;
align-items: baseline;
gap: var(--space-3);
min-width: 0;
}
.file {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.size,
.more {
color: var(--text-3);
font-size: var(--fs-1);
line-height: var(--lh-1);
white-space: nowrap;
}
.state {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
.dl {
height: var(--space-7);
padding-inline: var(--space-2);
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<script>
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import Icon from './Icon.svelte';
import ExportSettings from './ExportSettings.svelte';
import MetadataComparison from './MetadataComparison.svelte';
import { processImage } from './processing.js';
import { isAccepted, formatMetadata } from './metadata.js';
import { locationSummary } from './metadata-comparison.js';
import { formatBytes } from './clean.js';
export let files = [];
export let format;
export let quality;
export let busy = false;
export let stateLabel = 'Batch ready';
const dispatch = createEventDispatcher();
let rows = files.filter(entry => isAccepted(entry.file)).map((entry, id) => ({ ...entry, id, status: 'Queued', detail: '' }));
let selected = 0;
let controller, comparison, output;
let message = '', operation = '', cancelled = false, choosing = false;
$: current = rows[selected];
$: available = rows.filter(row => row.result && row.status !== 'Saved');
$: remaining = rows.filter(row => isAccepted(row.file) && !row.result);
$: totalSize = rows.reduce((sum, row) => sum + (row.result?.size || 0), 0);
$: summary = ['Cleaned', 'Saved', 'Failed', 'Cancelled'].map(status => `${rows.filter(row => row.status === status).length} ${status.toLowerCase()}`).join(' · ');
$: gps = locationSummary(current?.metadata);
function update(row, values) { Object.assign(row, values); rows = [...rows]; }
function start(label) { controller = new AbortController(); cancelled = false; busy = true; operation = label; message = ''; }
function finish() { busy = false; operation = ''; stateLabel = cancelled ? 'Batch cancelled' : 'Batch ready'; }
export function isDialogOpen() { return comparison?.isOpen(); }
export function cancel() {
if (!busy) return;
cancelled = true;
controller?.abort();
stateLabel = operation === 'Saving' ? 'Finishing current save' : 'Cancelling';
message = operation === 'Saving' ? 'The current file will finish saving. Remaining files will not be saved.' : 'Stopped processing. Completed results are kept; remaining images can be retried.';
}
async function scan() {
start('Scanning');
try {
for (const row of rows) {
if (cancelled) break;
if (row.status !== 'Queued') continue;
update(row, { status: 'Scanning' }); stateLabel = `Scanning ${row.id + 1} of ${rows.length}`;
try {
const metadata = await processImage('scan', row.file, {}, controller.signal);
update(row, { metadata, status: 'Ready', detail: metadata.scanError || '' });
} catch (error) {
update(row, { status: error.name === 'AbortError' ? 'Cancelled' : 'Failed', detail: error.name === 'AbortError' ? 'Scan cancelled.' : error.message });
}
}
if (cancelled) rows = rows.map(row => row.status === 'Queued' ? { ...row, status: 'Cancelled', detail: 'Not scanned.' } : row);
} finally { finish(); }
}
export async function clean() {
if (busy || choosing || !remaining.length) return;
start('Cleaning');
const pending = [...remaining];
try {
for (let index = 0; index < pending.length && !cancelled; index++) {
const row = pending[index];
update(row, { status: 'Cleaning', detail: '' }); stateLabel = `Cleaning ${index + 1} of ${pending.length}`;
try {
if (!row.metadata) update(row, { metadata: await processImage('scan', row.file, {}, controller.signal) });
const result = await processImage('clean', row.file, { format, quality: format === 'auto' ? 0.92 : Number(quality) }, controller.signal);
if (result.fallback) throw new Error('Could not decode or rewrite this image. Convert it to JPEG or PNG and try again.');
update(row, { result, status: 'Cleaned', detail: result.wasSecondPass ? 'Second pass: JPEG on white.' : '' });
} catch (error) {
update(row, { status: error.name === 'AbortError' ? 'Cancelled' : 'Failed', detail: error.name === 'AbortError' ? 'Cleaning cancelled.' : error.message });
}
}
if (cancelled) for (const row of pending) if (!row.result && !['Failed', 'Cancelled'].includes(row.status)) update(row, { status: 'Cancelled', detail: 'Not processed.' });
} finally { finish(); }
}
async function chooseOutput() {
if (busy || choosing) return;
choosing = true;
try {
if (!window.untraceDesktop) throw new Error('Folder saving is available in the Windows desktop app.');
const next = await window.untraceDesktop.chooseOutputFolder();
if (next) output = next;
} catch (error) { message = error.message; }
finally { choosing = false; }
}
export async function save() {
if (busy || choosing || !available.length) return;
if (!output) await chooseOutput();
if (!output) return;
start('Saving');
const pending = [...available];
try {
for (let index = 0; index < pending.length && !cancelled; index++) {
const row = pending[index];
stateLabel = `Saving ${index + 1} of ${pending.length}`;
update(row, { status: 'Saving' });
try {
const bytes = new Uint8Array(await row.result.blob.arrayBuffer());
if (cancelled) { update(row, { status: 'Cleaned' }); break; }
const saved = await window.untraceDesktop.saveOutput(output.token, row.result.filename, bytes);
update(row, { status: 'Saved', detail: saved.path, saved, result: { ...row.result, blob: null } });
} catch (error) { update(row, { status: 'Failed', detail: `Save failed: ${error.message}` }); }
}
} finally { finish(); }
}
function settingsChanged() {
rows = rows.map(row => isAccepted(row.file) ? { ...row, result: null, saved: null, status: row.metadata ? 'Ready' : 'Queued', detail: '' } : row);
message = 'Export settings changed. Clean again to prepare new copies. Files already saved stay unchanged.';
}
onMount(scan);
onDestroy(() => { cancelled = true; controller?.abort(); });
</script>
<main class="editor">
<div class="editor-toolbar"><div class="toolbar-file"><Icon name="folder" size="sm" /><span>{rows.length} files</span></div><button class="btn btn-secondary" disabled={busy || choosing} on:click={() => dispatch('close')}>Close batch</button></div>
{#if message}<div class="inline-notice" role="status"><Icon name="info" size="sm" /><span>{message}</span></div>{/if}
<section class="batch-workspace" aria-label="Batch files">
<div class="batch-heading"><h2>Batch cleaning</h2><p>{summary}</p></div>
<div class="batch-list">
{#each rows as row}
<div class="batch-row" class:selected={selected === row.id}>
<button class="btn btn-ghost batch-file" aria-pressed={selected === row.id} on:click={() => selected = row.id}><Icon name="image" size="sm" /><span title={row.name}>{row.name}</span></button>
<div class="batch-row-status"><span class="status" class:ok={row.status === 'Cleaned' || row.status === 'Saved'} class:err={row.status === 'Failed'} class:warn={row.status === 'Cancelled'} class:info={['Scanning', 'Cleaning', 'Saving'].includes(row.status)}>{row.status}</span>{#if locationSummary(row.metadata)}<span class="gps-badge"><Icon name="pin" size="sm" />GPS</span>{/if}</div>
<span class="batch-size">{row.result ? formatBytes(row.result.size) : row.file ? formatBytes(row.file.size) : '—'}</span>
</div>
{/each}
</div>
<div class="batch-progress" role="status">{busy ? stateLabel : `${rows.length} files · ${formatBytes(totalSize)} prepared output`}</div>
</section>
<div class="workspace-note"><Icon name="shield" size="sm" /><span>Existing files are never overwritten. Duplicate names receive a number.</span></div>
</main>
<aside class="inspector" aria-label="Batch inspector">
<div class="inspector-heading"><h2>Inspector</h2><span class="subtle">Batch</span></div>
<div class="inspector-scroll">
<section class="inspector-section"><div class="section-label"><h3>Selected file</h3></div>
{#if current}<p class="source-name">{current.name}</p><p class="status" class:err={current.status === 'Failed'}>{current.status}</p>
{#if current.detail}<p class="batch-detail">{current.detail}</p>{/if}
{#if gps}<div class="location-warning" role="status"><Icon name="pin" size="sm" /><div><strong>Location data in original</strong><p>{gps}</p></div></div>{/if}
{#if current.result}<dl class="properties"><div><dt>Original size</dt><dd>{formatBytes(current.file.size)}</dd></div><div><dt>Export size</dt><dd>{formatBytes(current.result.size)}</dd></div><div><dt>Size change</dt><dd>{Math.round(current.result.sizeDelta)}%</dd></div></dl><button class="btn btn-secondary" on:click={() => comparison.open(current.metadata, current.result.metadata, current.name)}>Compare metadata</button>{/if}
{#if current.metadata}<details class="metadata-details"><summary>View original metadata</summary><pre>{formatMetadata(current.metadata)}</pre></details>{/if}
{/if}
</section>
<section class="inspector-section"><div class="section-label"><h3>Export</h3><Icon name="download" size="sm" /></div><ExportSettings bind:format bind:quality {busy} on:change={settingsChanged} /><p class="export-hint batch-detail">Clean the batch to calculate exact sizes before saving.</p></section>
<section class="inspector-section"><div class="section-label"><h3>Output folder</h3><Icon name="folder" size="sm" /></div><p class="batch-detail">{output?.path || 'Choose where to save the cleaned copies.'}</p><button class="btn btn-secondary" on:click={chooseOutput} disabled={busy || choosing}>{output ? 'Change output folder' : 'Choose output folder'}</button><p class="export-hint batch-detail">Subfolders are combined here. Your source folders stay unchanged.</p></section>
</div>
<div class="inspector-action">
{#if busy}<p role="status">{stateLabel}</p><button class="btn btn-secondary" on:click={cancel} disabled={cancelled}>{cancelled ? 'Cancelling…' : 'Cancel processing'}</button>
{:else}
{#if available.length}<p>{available.length} files ready to save · {formatBytes(available.reduce((sum, row) => sum + row.result.size, 0))}</p><button class="btn btn-primary" disabled={choosing} on:click={save}>Save cleaned files</button>{/if}
{#if remaining.length}<button class="btn" class:btn-primary={!available.length} class:btn-secondary={!!available.length} disabled={choosing} on:click={clean}>{rows.some(row => row.result) ? 'Clean remaining images' : 'Clean batch'}</button>{/if}
{#if !available.length && !remaining.length}<p>Batch complete. {rows.filter(row => row.status === 'Saved').length} copies saved.</p>{/if}
{/if}
</div>
</aside>
<MetadataComparison bind:this={comparison} />
+10 -29
View File
@@ -11,33 +11,15 @@
let fileInput;
let depth = 0;
const MAX_FILES = 50;
function offer(list) {
const incoming = [...(list || [])].filter(Boolean);
if (incoming.length === 0) return;
const accepted = incoming.filter(isAccepted);
if (accepted.length === 0) {
const type = incoming[0].type;
function offer(file) {
if (!file) return;
if (!isAccepted(file)) {
dispatch("reject", {
message: `Untrace cannot read ${type || "that file type"}. Choose JPEG, PNG, TIFF, HEIC or AVIF images.`,
message: `Untrace cannot read ${file.type || "that file type"}. Choose a JPEG, PNG, TIFF, HEIC or AVIF image.`,
});
return;
}
const skipped = incoming.length - accepted.length;
const capped = accepted.slice(0, MAX_FILES);
const over = accepted.length - capped.length;
if (skipped > 0 || over > 0) {
dispatch("reject", {
message:
over > 0
? `Only the first ${MAX_FILES} images were added.`
: `Skipped ${skipped} file${skipped === 1 ? "" : "s"} Untrace cannot read.`,
});
}
dispatch("select", { files: capped });
dispatch("select", { file });
}
function onDrop(event) {
@@ -45,7 +27,7 @@
depth = 0;
dragging = false;
if (busy) return;
offer(event.dataTransfer?.files);
offer(event.dataTransfer?.files?.[0]);
}
function onDragEnter(event) {
@@ -62,7 +44,7 @@
}
function onChange(event) {
offer(event.target.files);
offer(event.target.files?.[0]);
// Reset so choosing the same file twice still fires a change event.
event.target.value = "";
}
@@ -72,7 +54,7 @@
const item = [...(event.clipboardData?.items || [])].find((entry) =>
entry.type.startsWith("image/")
);
if (item) offer([item.getAsFile()]);
if (item) offer(item.getAsFile());
}
</script>
@@ -95,10 +77,10 @@
>
<span class="glyph"><Icon name="upload" size="lg" /></span>
<span class="title">
{dragging ? "Drop to scan" : "Drop images, or choose files"}
{dragging ? "Drop to scan it" : "Drop an image, or choose a file"}
</span>
<span class="hint">
Nothing is uploaded. Add one image or up to 50 at once.
Nothing is uploaded. The image is read and rewritten inside this tab.
</span>
<span class="chip-row formats">
<span class="chip">JPEG</span>
@@ -116,7 +98,6 @@
<input
class="visually-hidden"
type="file"
multiple
accept={ACCEPTED_EXTENSIONS}
bind:this={fileInput}
on:change={onChange}
+15
View File
@@ -0,0 +1,15 @@
<script>
import { createEventDispatcher } from 'svelte';
import { saveExportSettings } from './export-settings.js';
export let format = 'auto';
export let quality = 0.92;
export let busy = false;
const dispatch = createEventDispatcher();
function changed() { saveExportSettings(format, quality); dispatch('change'); }
</script>
<div class="export-fields">
<div class="field"><label for="export-format">File format</label><select id="export-format" class="select" bind:value={format} on:change={changed} disabled={busy}><option value="auto">Automatic</option><option value="image/png">PNG · preserve transparency</option><option value="image/jpeg">JPEG · smaller file</option></select></div>
{#if format === 'image/jpeg'}<div class="field"><label for="export-quality">JPEG quality</label><select id="export-quality" class="select" bind:value={quality} on:change={changed} disabled={busy}><option value={0.92}>High · 92%</option><option value={1}>Maximum · 100%</option><option value={0.85}>Balanced · 85%</option></select></div>{/if}
<p class="export-hint">{format === 'image/png' ? 'Preserves transparency. Files may be larger.' : format === 'image/jpeg' ? 'Transparency is flattened onto white.' : 'Small PNGs stay PNG. Other images become JPEG on white.'} Settings are remembered.</p>
</div>
+2 -19
View File
@@ -1,6 +1,5 @@
<script>
import Icon from "./Icon.svelte";
import Logo from "./Logo.svelte";
const year = new Date().getFullYear();
</script>
@@ -10,7 +9,7 @@
<div class="cols">
<div>
<p class="wordmark">
<Logo />
<span class="r-mark" aria-hidden="true">U</span>
<span>Untrace</span>
</p>
<p class="blurb">
@@ -25,26 +24,10 @@
<li><a href="#tool">Clean an image</a></li>
<li><a href="#what-is-removed">What is removed</a></li>
<li><a href="#how-it-works">How it works</a></li>
<li><a href="#trust">Verify it yourself</a></li>
<li><a href="#faq">Questions</a></li>
</ul>
</nav>
<nav aria-label="Guides">
<h2>Guides</h2>
<ul>
<li><a href="/guides/remove-gps-from-photo/">Remove GPS from a photo</a></li>
<li><a href="/guides/remove-exif-data-iphone/">Remove EXIF on iPhone</a></li>
<li><a href="/guides/remove-exif-data-android/">Remove EXIF on Android</a></li>
<li><a href="/guides/do-social-media-sites-remove-exif-data/">Do social sites strip EXIF?</a></li>
<li><a href="/guides/remove-exif-data-windows-mac/">Remove EXIF on Windows and Mac</a></li>
<li><a href="/guides/how-to-view-exif-data/">View a photo's EXIF data</a></li>
<li><a href="/guides/photo-metadata-privacy-checklist/">Privacy checklist</a></li>
<li><a href="/guides/what-is-exif-data/">What is EXIF data?</a></li>
<li><a href="/guides/what-is-c2pa/">What is C2PA?</a></li>
</ul>
</nav>
<nav aria-label="Project">
<h2>Project</h2>
<ul>
@@ -116,7 +99,7 @@
@media (min-width: 900px) {
.cols {
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: var(--space-6);
}
}
-257
View File
@@ -1,257 +0,0 @@
<script>
import Icon from "./Icon.svelte";
let paused = false;
const tags = [
{ label: "GPS 40.7128 N", icon: "pin", cls: "t1", dx: -1, dy: -1 },
{ label: "iPhone 15 Pro", icon: "camera", cls: "t2", dx: 1, dy: -1 },
{ label: "C2PA credentials", icon: "shield", cls: "t3", dx: 1, dy: 0.5 },
{ label: "Taken 14:32", icon: "clock", cls: "t4", dx: -1, dy: 1 },
{ label: "AI tool tag", icon: "sparkle", cls: "t5", dx: 1, dy: 1 },
];
</script>
<figure class="demo" class:paused>
<div
class="stage"
role="img"
aria-label="Animation: a photo with hidden tags for location, camera, time and content credentials is scanned, the tags lift away, and the photo ends up clean."
>
<div class="photo" aria-hidden="true">
<svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice">
<rect class="sky" width="320" height="200" />
<circle class="sun" cx="236" cy="62" r="26" />
<path class="hill far" d="M0 150 90 96l70 54 60-38 100 70v18H0Z" />
<path class="hill near" d="M0 176 110 128l90 48 120-34v58H0Z" />
</svg>
<span class="scan"></span>
<span class="clean-badge"><Icon name="check" size="sm" /> Clean</span>
</div>
{#each tags as tag}
<span class="tag {tag.cls}" style="--dx:{tag.dx}; --dy:{tag.dy}" aria-hidden="true">
<Icon name={tag.icon} size="sm" />
{tag.label}
</span>
{/each}
</div>
<figcaption>
<ol class="beats" aria-hidden="true">
<li class="b1"><span>1</span> Scan the file</li>
<li class="b2"><span>2</span> Tags lift off</li>
<li class="b3"><span>3</span> Clean copy</li>
</ol>
<button
type="button"
class="btn btn-ghost pause"
aria-pressed={paused}
on:click={() => (paused = !paused)}
>
<Icon name={paused ? "play" : "pause"} size="sm" />
{paused ? "Play animation" : "Pause animation"}
</button>
</figcaption>
</figure>
<style>
.demo {
display: grid;
gap: var(--space-3);
width: 100%;
max-width: calc(var(--space-9) * 6);
}
.stage {
position: relative;
padding: var(--space-6) var(--space-4);
overflow: hidden;
border: var(--border-w) solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
}
.photo {
position: relative;
overflow: hidden;
aspect-ratio: 8 / 5;
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--elevated);
}
.photo svg {
display: block;
width: 100%;
height: 100%;
}
.sky { fill: var(--elevated); }
.sun { fill: var(--accent); }
.hill.far { fill: var(--border-strong); }
.hill.near { fill: var(--border); }
/* ---- Scan line ---- */
.scan {
position: absolute;
inset-inline: 0;
top: 0;
height: var(--space-1);
background: var(--accent);
opacity: 0;
animation: scan 8s var(--ease) infinite;
}
/* ---- Clean badge ---- */
.clean-badge {
position: absolute;
right: var(--space-3);
bottom: var(--space-3);
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
background: var(--success);
color: var(--bg);
font-size: var(--fs-1);
line-height: var(--lh-1);
font-weight: 600;
opacity: 0;
animation: badge 8s var(--ease) infinite;
}
/* ---- Metadata tags ---- */
.tag {
position: absolute;
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius-full);
background: var(--bg);
color: var(--text-1);
font-family: var(--font-mono);
font-size: var(--fs-1);
line-height: var(--lh-1);
white-space: nowrap;
animation: lift 8s var(--ease) infinite;
}
.t1 { top: var(--space-3); left: var(--space-2); }
.t2 { top: var(--space-3); right: var(--space-2); }
.t3 { top: 46%; right: var(--space-2); }
.t4 { bottom: var(--space-3); left: var(--space-2); }
.t5 { bottom: var(--space-3); right: var(--space-2); }
@keyframes lift {
0%, 20% { opacity: 1; transform: none; border-color: var(--border-strong); }
28%, 36% { opacity: 1; transform: none; border-color: var(--accent); }
56% {
opacity: 0;
transform: translate(calc(var(--dx) * var(--space-8)), calc(var(--dy) * var(--space-7)));
border-color: var(--accent);
}
92% { opacity: 0; transform: translate(calc(var(--dx) * var(--space-8)), calc(var(--dy) * var(--space-7))); }
100% { opacity: 1; transform: none; border-color: var(--border-strong); }
}
@keyframes scan {
0%, 18% { top: 0; opacity: 0; }
22% { top: 0; opacity: 1; }
38% { top: calc(100% - var(--space-1)); opacity: 1; }
42%, 100% { top: calc(100% - var(--space-1)); opacity: 0; }
}
@keyframes badge {
0%, 54% { opacity: 0; transform: translateY(var(--space-2)); }
62%, 90% { opacity: 1; transform: none; }
97%, 100% { opacity: 0; transform: none; }
}
/* ---- Captions ---- */
figcaption {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.beats {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
list-style: none;
font-size: var(--fs-2);
line-height: var(--lh-2);
color: var(--text-3);
}
.beats li {
display: inline-flex;
align-items: center;
gap: var(--space-2);
animation: beat 8s var(--ease) infinite;
}
.beats span {
display: inline-grid;
place-items: center;
width: var(--space-5);
height: var(--space-5);
border: var(--border-w) solid currentColor;
border-radius: var(--radius-full);
font-family: var(--font-mono);
font-size: var(--fs-1);
}
.b2 { animation-name: beat-2; }
.b3 { animation-name: beat-3; }
@keyframes beat {
0%, 36% { color: var(--text-1); }
40%, 100% { color: var(--text-3); }
}
@keyframes beat-2 {
0%, 36% { color: var(--text-3); }
40%, 56% { color: var(--text-1); }
60%, 100% { color: var(--text-3); }
}
@keyframes beat-3 {
0%, 56% { color: var(--text-3); }
60%, 92% { color: var(--text-1); }
96%, 100% { color: var(--text-3); }
}
.pause {
height: var(--space-7);
padding-inline: var(--space-3);
}
/* ---- Pause ---- */
.paused .tag,
.paused .scan,
.paused .clean-badge,
.paused .beats li {
animation-play-state: paused;
}
/* ---- Reduced motion: show the finished state, still ---- */
@media (prefers-reduced-motion: reduce) {
.tag,
.scan,
.clean-badge,
.beats li {
animation: none;
}
.tag { opacity: 0.45; text-decoration: line-through; }
.scan { display: none; }
.clean-badge { opacity: 1; }
.beats li { color: var(--text-2); }
.pause { display: none; }
}
</style>
+6 -5
View File
@@ -7,6 +7,12 @@
export let size = "md";
const paths = {
palette: "M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 1-3.7 1.5 1.5 0 0 1 .8-2.8H17a4 4 0 0 0 4-4C21 6.4 17 3 12 3ZM7 10h.01M10 7h.01M15 7h.01M18 10h.01",
monitor: "M3 4h18v13H3V4Zm5 17h8m-4-4v4",
folder: "M3 7V5h6l2 2h10v12H3V7Zm0 3h18",
plus: "M12 5v14M5 12h14",
help: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm-3-12a3 3 0 0 1 6 0c0 2-3 2-3 4m0 3v.1",
info: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-11v6m0-9v.1",
upload:
"M12 16V4m0 0L8 8m4-4 4 4M4 15v2a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-2",
shield: "M12 3l7 3v6c0 4-3 7-7 8-4-1-7-4-7-8V6l7-3Zm-2.5 8.5 2 2 3.5-3.5",
@@ -30,11 +36,6 @@
lock: "M7 11V8a5 5 0 0 1 10 0v3m-11 0h12v9H6v-9Z",
image:
"M4 5h16v14H4V5Zm0 10 4.5-4.5 4 4L15 12l5 5",
pause: "M9 5v14m6-14v14",
play: "M8 5l11 7-11 7V5Z",
book: "M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2V5Zm4 0h7",
eye: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z",
code: "m8 8-4 4 4 4m8-8 4 4-4 4m-2.5-10-3 12",
};
const boxes = { sm: "16", md: "20", lg: "24" };
-17
View File
@@ -1,17 +0,0 @@
<script>
/**
* The Untrace mark: a photo frame that is open on one side, with the edge
* dissolving into dashes — the image stays, the trace leaves. Colours come
* from the `.r-mark` tile it sits in, so it follows the theme.
*/
export let label = false;
</script>
<span class="r-mark" role={label ? "img" : undefined} aria-label={label ? "Untrace" : undefined} aria-hidden={label ? undefined : "true"}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<path d="M15 4.5H8A3.5 3.5 0 0 0 4.5 8v8A3.5 3.5 0 0 0 8 19.5h7" />
<path d="M18.5 4.5v.01M18.5 9v.01M18.5 14v.01M18.5 19v.01" stroke-width="2.4" opacity="0.55" />
<path d="M21 4.5v.01M21 9v.01" stroke-width="2" opacity="0.28" />
<path d="M8 15.5 11 12l2 2.2 1.5-1.7" />
</svg>
</span>
+24
View File
@@ -0,0 +1,24 @@
<script>
import Icon from './Icon.svelte';
import { compareMetadata } from './metadata-comparison.js';
let dialog;
let before, after, filename;
$: rows = compareMetadata(before, after);
export function open(original, cleaned, name) { before = original; after = cleaned; filename = name; dialog.showModal(); }
export function isOpen() { return !!dialog?.open; }
</script>
<dialog class="modal comparison-dialog" bind:this={dialog} aria-labelledby="comparison-title" style:margin="auto" style:width="calc(100% - var(--space-6))" style:max-width="calc(var(--space-9) * 10)">
<div class="comparison-content">
<div class="help-heading"><div><span class="overline">{filename}</span><h2 id="comparison-title">Metadata comparison</h2></div><button class="btn btn-ghost" aria-label="Close comparison" on:click={() => dialog.close()}><Icon name="close" /></button></div>
<p class="export-hint">Fields detected in the original and the encoded copy. Image headers and an encoder-generated colour profile can remain or change. “Not detected afterward” is a scan result, not proof that all metadata is gone.</p>
{#if before?.scanError || after?.scanError}<p class="status warn">Some metadata could not be read. Missing fields are unverified where parsing failed.</p>{/if}
<div class="comparison-scroll">
<table class="table"><thead><tr><th scope="col">Field</th><th scope="col">Original</th><th scope="col">Cleaned copy</th><th scope="col">Result</th></tr></thead><tbody>
{#each rows as row}<tr><th scope="row">{row.name}</th><td>{row.before}</td><td>{row.after}</td><td>{row.status}</td></tr>{/each}
{#if !rows.length}<tr><td colspan="4">No recognized fields to compare.</td></tr>{/if}
</tbody></table>
</div>
<div class="appearance-footer"><button class="btn btn-secondary" on:click={() => dialog.close()}>Done</button></div>
</div>
</dialog>
+3 -1
View File
@@ -28,7 +28,7 @@
<div class="side">
<p class="cap">
After <span class="status ok">clean</span>
After <span class:ok={!result.fallback} class:err={result.fallback} class="status">{result.fallback ? "Not cleaned" : "clean"}</span>
</p>
<figure class="preview">
<img src={result.url} alt="Result, after cleaning" />
@@ -60,11 +60,13 @@
</p>
{/if}
{#if !result.fallback}
<ul class="removed">
{#each removed as item}
<li><span class="status ok">{item}</span></li>
{/each}
</ul>
{/if}
</div>
</div>
+2 -18
View File
@@ -8,7 +8,6 @@
import ReviewPanel from "./ReviewPanel.svelte";
import ResultPanel from "./ResultPanel.svelte";
import Icon from "./Icon.svelte";
import Batch from "./Batch.svelte";
const dispatch = createEventDispatcher();
@@ -20,8 +19,6 @@
let metadata = null;
let previewUrl = null;
let result = null;
/** Set when more than one image is added; Batch owns that flow. */
let batchFiles = null;
$: current = stage === "idle" || stage === "scanning" ? 0 : stage === "done" ? 2 : 1;
$: busy = stage === "scanning" || stage === "cleaning";
@@ -41,11 +38,7 @@
async function onSelect(event) {
release();
if (event.detail.files.length > 1) {
batchFiles = event.detail.files;
return;
}
file = event.detail.files[0];
file = event.detail.file;
previewUrl = URL.createObjectURL(file);
metadata = null;
stage = "scanning";
@@ -64,7 +57,7 @@
}
async function clean() {
if (!file) return;
if (!file || stage === "cleaning") return;
stage = "cleaning";
result = await cleanImage(file);
stage = "done";
@@ -82,7 +75,6 @@
function reset() {
release();
batchFiles = null;
file = null;
metadata = null;
stage = "idle";
@@ -94,13 +86,6 @@
<section class="card tool" id="tool" aria-labelledby="tool-heading">
<h2 class="visually-hidden" id="tool-heading">Clean an image</h2>
{#if batchFiles}
<Batch
files={batchFiles}
on:notify={(e) => dispatch("notify", e.detail)}
on:reset={reset}
/>
{:else}
<ol class="stepper">
{#each steps as label, index}
<li class="step" data-state={stepState(index, current)}>
@@ -194,7 +179,6 @@
{/if}
</div>
{/if}
{/if}
</section>
<style>
+1 -12
View File
@@ -1,18 +1,16 @@
<script>
import ThemeToggle from "./ThemeToggle.svelte";
import Icon from "./Icon.svelte";
import Logo from "./Logo.svelte";
</script>
<header class="topbar">
<div class="wrap inner">
<a class="wordmark" href="/" aria-label="Untrace home">
<Logo />
<span class="r-mark" aria-hidden="true">U</span>
<span>Untrace</span>
</a>
<nav class="actions" aria-label="Site">
<a class="btn btn-ghost guides" href="/guides/">Guides</a>
<a
class="btn btn-ghost repo"
href="https://github.com/developersharif/untrace"
@@ -43,15 +41,6 @@
text-decoration: none;
}
.guides {
height: var(--space-8);
padding-inline: var(--space-3);
}
.guides:hover {
text-decoration: none;
}
.repo span {
display: none;
}
-93
View File
@@ -1,93 +0,0 @@
<script>
import Icon from "./Icon.svelte";
const checks = [
{
icon: "eye",
title: "Watch the network tab",
detail:
"Open your browser's developer tools, choose Network, then clean an image. No request carries your file anywhere, because there is no server to send it to.",
},
{
icon: "code",
title: "Read the source",
detail:
"Untrace is MIT licensed. The whole cleaner is one short file you can read in a few minutes.",
},
{
icon: "scan",
title: "Scan the result again",
detail:
"Drop the cleaned copy back in. It reports nothing found, and the tool re-reads every output before it offers the download.",
},
];
const limits = [
"Pixels are not changed, so visual AI detectors and invisible watermarks such as SynthID can still recognise an image.",
"Platforms that already stored a copy of your credentials keep it. Clean the file before you upload.",
"Non-JPEG images are re-encoded to JPEG, so the file size and format can change.",
"Use it to protect your privacy. Do not use it to pass AI-generated work off as a photograph where disclosure is expected or required.",
];
</script>
<section class="section" id="trust" aria-labelledby="trust-heading">
<div class="section-head">
<p class="eyebrow">Trust</p>
<h2 id="trust-heading">Verify it yourself, and know the limits</h2>
<p>
A privacy tool should not ask for faith. Here is how to check each claim,
and what Untrace cannot do.
</p>
</div>
<div class="tiles">
{#each checks as item}
<article class="tile">
<span class="ico"><Icon name={item.icon} /></span>
<h3>{item.title}</h3>
<p>{item.detail}</p>
</article>
{/each}
</div>
<div class="card limits">
<h3>What Untrace does not do</h3>
<ul>
{#each limits as line}
<li>{line}</li>
{/each}
</ul>
</div>
</section>
<style>
.section {
scroll-margin-top: calc(var(--topbar-h) + var(--space-4));
}
.ico {
display: inline-flex;
color: var(--accent);
margin-bottom: var(--space-2);
}
.limits {
margin-top: var(--space-4);
padding: var(--space-5);
display: grid;
gap: var(--space-3);
}
.limits h3 {
font-size: var(--fs-4);
line-height: var(--lh-4);
font-weight: 600;
}
.limits ul {
display: grid;
gap: var(--space-2);
padding-left: var(--space-4);
color: var(--text-2);
}
</style>
+297
View File
@@ -0,0 +1,297 @@
<script>
import { onDestroy, tick } from 'svelte';
import Icon from './Icon.svelte';
import Appearance from './Appearance.svelte';
import { ACCEPTED_EXTENSIONS, isAccepted, findings, countEntries, formatMetadata } from './metadata.js';
import { formatBytes } from './clean.js';
import BatchWorkspace from './BatchWorkspace.svelte';
import MetadataComparison from './MetadataComparison.svelte';
import ExportSettings from './ExportSettings.svelte';
import { loadExportSettings } from './export-settings.js';
import { processImage } from './processing.js';
import { locationSummary } from './metadata-comparison.js';
import { readDroppedFiles } from './drop-files.js';
const savedSettings = loadExportSettings();
let batchFiles = [], batch, batchBusy = false, batchState = 'Batch ready';
let comparison, folderInput, controller, estimate = null;
const version = __APP_VERSION__;
const appName = __APP_NAME__;
let appearance;
let file = null;
let metadata = null;
let previewUrl = null;
let result = null;
let stage = 'idle';
let view = 'original';
let zoom = 'fit';
let format = savedSettings.format;
let quality = savedSettings.quality;
let dimensions = '';
let previewFailed = false;
let dragging = false;
let dragDepth = 0;
let input;
let help;
let saveLink;
let notice = '';
let generation = 0;
$: busy = batchBusy || ['scanning', 'cleaning', 'estimating', 'collecting'].includes(stage);
$: gps = locationSummary(metadata);
$: ready = !!result && !result.fallback;
$: list = findings(metadata);
$: entries = countEntries(metadata);
$: currentUrl = view === 'cleaned' && ready ? result.url : previewUrl;
$: stateLabel = batchFiles.length ? batchState : stage === 'collecting' ? 'Reading folder' : stage === 'estimating' ? 'Calculating export size' : stage === 'scanning' ? 'Scanning image' : stage === 'cleaning' ? 'Cleaning image' : result?.fallback ? 'Could not clean' : ready ? 'Ready to save' : file ? 'Ready to clean' : 'Ready';
function releaseResult() {
if (result?.url) URL.revokeObjectURL(result.url);
result = null;
estimate = null;
view = 'original';
}
function release() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
releaseResult();
}
function reset() {
if (busy) return;
generation++;
release();
file = null; metadata = null; dimensions = ''; notice = ''; previewFailed = false;
stage = 'idle'; zoom = 'fit';
}
function dialogOpen() { return help?.open || appearance?.isOpen() || comparison?.isOpen() || batch?.isDialogOpen(); }
function cancel() {
if (batchFiles.length) { batch?.cancel(); return; }
generation++; controller?.abort();
if (file && !metadata) metadata = { scanError: 'Original metadata scan was cancelled.' };
stage = file ? 'review' : 'idle';
notice = 'Processing cancelled. Your original is unchanged.';
}
function offerFiles(entries, forceBatch = false) {
if (busy || dialogOpen()) return;
entries = entries.filter(entry => isAccepted(entry.file));
if (!entries.length) { notice = 'No supported images found. Choose JPEG, PNG, AVIF, HEIC or TIFF images.'; return; }
if (entries.length === 1 && !forceBatch) { offer(entries[0].file); return; }
reset(); batchFiles = entries;
}
function closeBatch() { batchFiles = []; batchBusy = false; reset(); }
async function offer(next) {
if (!next || busy || dialogOpen()) return;
if (!isAccepted(next)) {
notice = 'That file type is not supported. Choose a JPEG, PNG, AVIF, HEIC or TIFF image.';
return;
}
release();
const run = ++generation;
file = next; metadata = null; dimensions = ''; previewFailed = false; notice = ''; zoom = 'fit';
previewUrl = URL.createObjectURL(next);
stage = 'scanning'; controller = new AbortController();
try {
const scanned = await processImage('scan', next, {}, controller.signal);
if (run !== generation) return;
metadata = scanned;
if (scanned.scanError) notice = scanned.scanError;
stage = 'review';
} catch {
if (run !== generation) return;
stage = 'review';
notice = 'The metadata could not be read. You can try rewriting the image, or open another file.';
}
}
function choose() { if (!busy && !batchFiles.length) input.click(); }
function onChange(event) { offerFiles([...event.target.files].map(file => ({ file, name: file.webkitRelativePath || file.name })), event.target === folderInput); event.target.value = ''; }
async function clean(sizeOnly = false) {
if (batchFiles.length) { batch?.clean(); return; }
if (!file || busy) return;
const run = generation;
stage = sizeOnly ? 'estimating' : 'cleaning'; notice = ''; controller = new AbortController();
try {
const cleaned = estimate || await processImage('clean', file, { format, quality: format === 'auto' ? 0.92 : Number(quality) }, controller.signal);
if (run !== generation) return;
if (sizeOnly && !cleaned.fallback) { estimate = cleaned; stage = 'review'; return; }
releaseResult();
result = { ...cleaned, url: URL.createObjectURL(cleaned.blob) };
view = result.fallback ? 'original' : 'cleaned'; stage = 'done';
} catch (error) {
if (run !== generation) return;
stage = 'review'; notice = error.message || 'Could not process this image. Try another file.';
}
}
function settingsChanged() {
estimate = null;
if (result) { releaseResult(); stage = 'review'; }
}
function onPaste(event) {
if (busy || dialogOpen() || batchFiles.length) return;
const item = [...(event.clipboardData?.items || [])].find(entry => entry.type.startsWith('image/'));
if (item) { event.preventDefault(); offer(item.getAsFile()); }
}
async function onDrop(event) {
event.preventDefault(); dragging = false; dragDepth = 0;
if (busy || dialogOpen() || batchFiles.length) return;
controller = new AbortController();
const run = ++generation;
stage = 'collecting';
try {
const entries = await readDroppedFiles(event.dataTransfer?.items || [], event.dataTransfer?.files || [], controller.signal);
if (run !== generation) return;
stage = file ? 'review' : 'idle'; await tick(); offerFiles(entries);
} catch (error) {
if (run !== generation) return;
stage = file ? 'review' : 'idle'; notice = 'Could not read the dropped files. Try Open folder or Open image.';
}
}
function onDragEnter(event) {
if (![...(event.dataTransfer?.types || [])].includes('Files') || busy || dialogOpen() || batchFiles.length) return;
event.preventDefault(); dragDepth++; dragging = true;
}
function onDragLeave() { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) dragging = false; }
function onKey(event) {
if (dialogOpen()) return;
if (event.ctrlKey || event.metaKey) {
if (event.key.toLowerCase() === 'o') { event.preventDefault(); choose(); }
if (event.key.toLowerCase() === 's') { event.preventDefault(); if (batchFiles.length) batch?.save(); else if (ready) saveLink?.click(); }
if (event.key === 'Enter') { event.preventDefault(); if (!ready) clean(false); }
}
if (event.key === 'Escape' && busy) { cancel(); return; }
if (event.key === 'Escape' && !batchFiles.length && !busy && !['SELECT', 'INPUT'].includes(event.target.tagName)) reset();
if (event.key === 'F1') { event.preventDefault(); help.showModal(); }
}
onDestroy(() => { generation++; controller?.abort(); release(); });
</script>
<svelte:window on:keydown={onKey} on:paste={onPaste} on:dragenter={onDragEnter} on:dragleave={onDragLeave} on:dragover|preventDefault on:drop={onDrop} />
<div class="desktop-app density-comfortable">
<aside class="app-rail" aria-label="Application tools">
<div class="brand-mark" title={appName}><Icon name="shield" size="lg" /></div>
<div class="rail-current" aria-label="Image cleaner" title="Image cleaner"><Icon name="image" /></div>
<div class="rail-bottom">
<button class="btn btn-ghost" type="button" on:click={() => help.showModal()} aria-label="Help and shortcuts" title="Help and shortcuts (F1)"><Icon name="help" /></button>
<button class="btn btn-ghost" type="button" on:click={() => appearance.open()} aria-label="Choose a theme" title="Appearance"><Icon name="palette" /></button>
</div>
</aside>
<header class="app-header">
<div class="app-heading"><h1>{appName}</h1><span class="header-divider"></span><span>Image cleaner</span></div>
<span class="local-badge"><Icon name="lock" size="sm" /> Local workspace</span>
</header>
{#if batchFiles.length}
<BatchWorkspace bind:this={batch} files={batchFiles} bind:format bind:quality bind:busy={batchBusy} bind:stateLabel={batchState} on:close={closeBatch} />
{:else}
<main class="editor">
<div class="editor-toolbar">
<div class="toolbar-file"><Icon name="image" size="sm" /><span title={file?.name}>{file?.name || 'Untitled workspace'}</span></div>
<div class="toolbar-actions">
{#if file}<button class="btn btn-ghost" type="button" on:click={reset} disabled={busy} title="Close image (Esc)" aria-label="Close image"><Icon name="close" size="sm" /></button>{/if}
<button class="btn btn-ghost" type="button" on:click={() => folderInput.click()} disabled={busy}>Open folder</button>
<button class="btn btn-secondary" type="button" on:click={choose} disabled={busy} title="Open image (Ctrl+O)"><Icon name="folder" size="sm" />Open image</button>
</div>
</div>
{#if notice}<div class="inline-notice" role="alert"><Icon name="info" size="sm" /><span>{notice}</span><button class="btn btn-ghost" type="button" aria-label="Dismiss message" on:click={() => notice = ''}><Icon name="close" size="sm" /></button></div>{/if}
<section class="image-workspace" class:dragging aria-label="Image preview">
{#if !file}
<div class="welcome">
<div class="image-emblem"><Icon name="image" size="lg" /><span class="emblem-badge"><Icon name="shield" size="sm" /></span></div>
<div class="welcome-copy"><p class="overline">A fresh start for your images</p><h2>Keep the image.<br />Leave the metadata.</h2><p>Drop images or a folder to inspect and remove<br class="desktop-break" /> the information stored inside it.</p></div>
<button class="btn btn-primary" type="button" on:click={choose}><Icon name="plus" size="sm" />Choose an image</button>
<span class="shortcut-hint">or paste from clipboard <kbd>Ctrl V</kbd></span>
<div class="format-hint">JPEG <span>·</span> PNG <span>·</span> AVIF <span>·</span> HEIC <span>·</span> TIFF</div>
</div>
{:else}
<div class="preview-controls">
<div class="view-switch" aria-label="Preview version">
<button class="btn btn-ghost" type="button" aria-pressed={view === 'original'} on:click={() => view = 'original'}>Original</button>
<button class="btn btn-ghost" type="button" aria-pressed={view === 'cleaned'} disabled={!ready} on:click={() => view = 'cleaned'}>Cleaned copy</button>
</div>
<span class="preview-label">{view === 'cleaned' && ready ? formatBytes(result.size) : formatBytes(file.size)}</span>
</div>
<div class="preview-viewport" class:actual-size={zoom === 'actual'}>
{#if previewFailed}
<div class="preview-unavailable"><Icon name="image" size="lg" /><h2>Preview unavailable</h2><p>This format could not be decoded.<br />Try converting it to JPEG or PNG first.</p></div>
{:else}
<img class="workspace-image" src={currentUrl} alt={view === 'cleaned' ? 'Cleaned image preview' : 'Original image preview'} on:load={event => dimensions = `${event.target.naturalWidth} × ${event.target.naturalHeight}`} on:error={() => previewFailed = true} />
{/if}
</div>
<div class="preview-bottom"><span>{dimensions || 'Image preview'}</span><div class="zoom-controls"><button class="btn btn-ghost" type="button" aria-pressed={zoom === 'fit'} on:click={() => zoom = 'fit'}>Fit</button><button class="btn btn-ghost" type="button" aria-pressed={zoom === 'actual'} on:click={() => zoom = 'actual'}>100%</button></div></div>
{/if}
{#if dragging}<div class="drop-overlay"><Icon name="upload" size="lg" /><h2>Drop to open images</h2></div>{/if}
{#if busy}<div class="processing-overlay" role="status"><span class="spinner"></span>{stateLabel}… <button class="btn btn-secondary" on:click={cancel}>Cancel processing</button></div>{/if}
</section>
<div class="workspace-note"><Icon name="shield" size="sm" /><span>Your original stays untouched. Save a separate, cleaned copy.</span></div>
</main>
<aside class="inspector" aria-label="Image inspector">
<div class="inspector-heading"><h2>Inspector</h2><span class="subtle">{file ? '1 image' : 'No image'}</span></div>
<div class="inspector-scroll">
<section class="inspector-section">
<div class="section-label"><h3>Source</h3><Icon name="file" size="sm" /></div>
{#if file}
<p class="source-name" title={file.name}>{file.name}</p>
<dl class="properties"><div><dt>File size</dt><dd>{formatBytes(file.size)}</dd></div><div><dt>Dimensions</dt><dd>{dimensions || '—'}</dd></div></dl>
{:else}
<div class="source-placeholder"><Icon name="image" /><p>No image selected<span>Open a file to get started.</span></p></div>
{/if}
</section>
{#if gps}<div class="location-warning" role="status"><Icon name="pin" size="sm" /><div><strong>Location data in original</strong><p>{gps}</p><p>Sharing this original can reveal where it was taken.</p></div></div>{/if}
<section class="inspector-section">
<div class="section-label"><h3>Metadata</h3>{#if file && !busy}<span class="count-badge">{entries} entries</span>{:else}<Icon name="scan" size="sm" />{/if}</div>
{#if stage === 'scanning'}
<p class="subtle">Inspecting the file…</p>
{:else if file && list.length}
<ul class="metadata-list">{#each list as item}<li><span class="status {item.severity}">{item.label}</span><p>{item.detail}</p></li>{/each}</ul>
<details class="metadata-details"><summary>View raw metadata</summary><pre>{formatMetadata(metadata)}</pre></details>
{:else if file}
<p class="scan-note"><Icon name="info" size="sm" /><span>No recognized metadata found. A rewrite can still remove unrecognized containers.</span></p>
{:else}
<p class="section-description">See what travels with your image.</p>
<ul class="coverage-list"><li><Icon name="pin" size="sm" />Location & GPS</li><li><Icon name="camera" size="sm" />Camera & device</li><li><Icon name="clock" size="sm" />Dates & timestamps</li><li><Icon name="shield" size="sm" />Content credentials</li><li><Icon name="file" size="sm" />EXIF, XMP & comments</li></ul>
{/if}
</section>
{#if ready}<section class="inspector-section"><div class="section-label"><h3>After cleaning</h3></div><p class="export-hint">{result.metadata?.scanError || 'Compare detected fields in the original and the cleaned copy.'}</p><button class="btn btn-secondary" on:click={() => comparison.open(metadata, result.metadata, file.name)}>Compare metadata</button></section>{/if}
<section class="inspector-section">
<div class="section-label"><h3>Export</h3><Icon name="download" size="sm" /></div>
<ExportSettings bind:format bind:quality {busy} on:change={settingsChanged} />
{#if file && !ready}<div class="size-preview">
{#if estimate}<dl class="properties"><div><dt>Export size</dt><dd>{formatBytes(estimate.size)}</dd></div><div><dt>Size change</dt><dd>{Math.round(estimate.sizeDelta)}%</dd></div></dl><p class="export-hint">Exact encoded size. This prepared output will be reused when you clean.</p>
{:else}<button class="btn btn-secondary" disabled={busy} on:click={() => clean(true)}>Preview export size</button><p class="export-hint">Calculate the actual file size before cleaning and saving.</p>{/if}
</div>{/if}
</section>
</div>
<div class="inspector-action" aria-live="polite">
{#if result?.fallback}
<span class="status err">Not cleaned</span><p>This image could not be rewritten. Convert it to JPEG or PNG and try again.</p>
<button class="btn btn-primary" type="button" on:click={choose}>Try another image</button>
{:else if ready}
<span class="status ok">Ready to save</span><p class="output-name" title={result.filename}>{result.filename}<span>{formatBytes(result.size)} · {Math.round(result.sizeDelta)}% size change</span></p>
{#if result.wasSecondPass}<p>A second pass used JPEG on white to remove remaining metadata.</p>{/if}
<a class="btn btn-primary" href={result.url} download={result.filename} bind:this={saveLink}><Icon name="download" size="sm" />Save clean image</a>
{:else}
<p>{file ? 'Rewrites the image without its embedded metadata.' : 'Your export will be ready here.'}</p>
<button class="btn" class:btn-primary={!!file} class:btn-secondary={!file} type="button" disabled={!file} aria-disabled={busy} on:click={() => clean(false)}>{#if busy}<span class="spinner"></span>{stateLabel}…{:else}<Icon name="shield" size="sm" />Clean image{/if}</button>
{/if}
</div>
</aside>
{/if}
<footer class="app-statusbar"><span class="status" class:ok={!busy} class:info={busy}>{stateLabel}</span><span class="statusbar-tip">{busy ? 'Esc to cancel processing' : batchFiles.length ? 'Ctrl Enter to clean · Ctrl S to save' : ready ? 'Ctrl S to save a copy' : file ? 'Ctrl Enter to clean' : 'Ctrl O to open an image'}</span><span class="statusbar-local"><Icon name="lock" size="sm" />On-device processing</span></footer>
</div>
<input class="visually-hidden" type="file" multiple accept={ACCEPTED_EXTENSIONS} bind:this={input} on:change={onChange} tabindex="-1" aria-label="Open image file" />
<input class="visually-hidden" type="file" webkitdirectory multiple accept={ACCEPTED_EXTENSIONS} bind:this={folderInput} on:change={onChange} tabindex="-1" aria-label="Open image folder" />
<MetadataComparison bind:this={comparison} />
<Appearance bind:this={appearance} />
<dialog class="modal" bind:this={help} aria-labelledby="help-title" style:margin="auto">
<div class="help-content"><div class="help-heading"><div><p class="overline">{appName}</p><h2 id="help-title">A little less attached.</h2></div><button type="button" class="btn btn-ghost" aria-label="Close help" on:click={() => help.close()}><Icon name="close" /></button></div>
<p>Open one image, several images, or a folder. Inspect metadata, clean, then save separate copies. Batch mode includes subfolders and gives each file a result. Use Cancel processing or Esc to stop; completed copies are kept. Everything is processed on your device.</p>
<h3>Keyboard shortcuts</h3><dl class="shortcut-list"><div><dt>Open image</dt><dd><kbd>Ctrl O</kbd></dd></div><div><dt>Paste image</dt><dd><kbd>Ctrl V</kbd></dd></div><div><dt>Clean image</dt><dd><kbd>Ctrl Enter</kbd></dd></div><div><dt>Save clean copy</dt><dd><kbd>Ctrl S</kbd></dd></div><div><dt>Close image</dt><dd><kbd>Esc</kbd></dd></div></dl>
<h3>Good to know</h3><p>JPEG and PNG work best. HEIC, TIFF and some AVIF files may need conversion first. JPEG is lossy; PNG can preserve transparency. Colour profiles, animation and additional pages are not preserved.</p><p>Metadata scanning is best effort. Removing metadata does not remove visible watermarks or identifying image content, guarantee anonymity, or prevent AI labels.</p><div class="help-footer"><span>Untrace {version} · MIT license</span><a href="https://github.com/developersharif/untrace" target="_blank" rel="noreferrer">Original project ↗</a></div></div>
</dialog>
+27 -10
View File
@@ -19,6 +19,7 @@ const SECOND_PASS_QUALITY = 0.95;
const PNG_PIXEL_LIMIT = 1000000;
function loadImage(source) {
if (typeof document === 'undefined') return createImageBitmap(source).then(img => ({ img }));
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(source);
const img = new Image();
@@ -33,11 +34,12 @@ function loadImage(source) {
function redraw(img, { type, quality, fillWhite = false }) {
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const canvas = typeof document === 'undefined' ? new OffscreenCanvas(img.width, img.height) : document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas is unavailable.");
ctx.clearRect(0, 0, canvas.width, canvas.height);
// A white base stops transparent areas turning black when the second pass
@@ -50,6 +52,10 @@ function redraw(img, { type, quality, fillWhite = false }) {
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(img, 0, 0);
if (canvas.convertToBlob) {
canvas.convertToBlob({ type, quality }).then(resolve, reject);
return;
}
canvas.toBlob(
(blob) =>
blob
@@ -71,7 +77,12 @@ async function stillHasMetadata(blob) {
gps: true,
xmp: true,
iptc: true,
icc: true,
// Chromium can generate a standard sRGB profile during encoding.
// It is reported in comparison, but another lossy pass cannot remove it.
icc: false,
// Required image headers describe the pixels, not removable metadata.
ihdr: false,
jfif: false,
silentErrors: true,
});
return Boolean(remaining && Object.keys(remaining).length > 0);
@@ -83,8 +94,8 @@ async function stillHasMetadata(blob) {
}
function outputName(originalName, blobType) {
const extension = originalName.split(".").pop();
const base = originalName.replace(new RegExp(`\\.${extension}$`, "i"), "");
const extension = originalName.includes(".") ? originalName.split(".").pop() : "";
const base = originalName.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 180) || 'image';
let finalExtension = extension;
if (blobType === "image/jpeg") finalExtension = "jpg";
@@ -99,19 +110,22 @@ function outputName(originalName, blobType) {
* `fallback: true` means the image could not be re-encoded and the original
* bytes are being returned untouched. The caller must not claim success.
*/
export async function cleanImage(file) {
export async function cleanImage(file, { format = 'auto', quality = JPEG_QUALITY } = {}) {
let objectUrl;
let bitmap;
try {
const { img, url } = await loadImage(file);
objectUrl = url;
bitmap = img;
const keepPng =
file.type === "image/png" && img.width * img.height < PNG_PIXEL_LIMIT;
const keepPng = format === 'image/png' || (format === 'auto' &&
(file.type === "image/png" || /\.png$/i.test(file.name)) && img.width * img.height < PNG_PIXEL_LIMIT);
let blob = await redraw(img, {
type: keepPng ? "image/png" : "image/jpeg",
quality: keepPng ? undefined : JPEG_QUALITY,
quality: keepPng ? undefined : Math.min(1, Math.max(0.1, quality)),
fillWhite: !keepPng,
});
let wasSecondPass = false;
@@ -125,7 +139,8 @@ export async function cleanImage(file) {
});
wasSecondPass = true;
} finally {
URL.revokeObjectURL(second.url);
if (second.url) URL.revokeObjectURL(second.url);
second.img.close?.();
}
}
@@ -135,13 +150,15 @@ export async function cleanImage(file) {
return result(file, file, { wasSecondPass: false, fallback: true });
} finally {
if (objectUrl) URL.revokeObjectURL(objectUrl);
bitmap?.close?.();
}
}
function result(blob, originalFile, { wasSecondPass, fallback }) {
return {
blob,
url: URL.createObjectURL(blob),
filename: outputName(originalFile.name, blob.type),
filename: fallback ? originalFile.name : outputName(originalFile.name, blob.type),
size: blob.size,
originalSize: originalFile.size,
sizeDelta: originalFile.size
+29
View File
@@ -0,0 +1,29 @@
// readEntries returns batches (often 100 entries); drain each directory fully.
export async function readDroppedFiles(items, fallbackFiles, signal) {
const entries = [...items].map(item => item.webkitGetAsEntry?.()).filter(Boolean);
if (!entries.length) return [...fallbackFiles].map(file => ({ file, name: file.name }));
const files = [];
async function visit(entry, prefix = '') {
signal?.throwIfAborted();
const name = prefix + entry.name;
try {
if (entry.isFile) {
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
files.push({ file, name });
} else if (entry.isDirectory) {
const reader = entry.createReader();
while (true) {
signal?.throwIfAborted();
const group = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
if (!group.length) break;
for (const child of group) await visit(child, name + '/');
}
}
} catch (error) {
if (signal?.aborted) throw error;
files.push({ name, error: 'Could not read this file or folder.' });
}
}
for (const entry of entries) await visit(entry);
return files;
}
+12
View File
@@ -0,0 +1,12 @@
const KEY = 'untrace-export';
export function loadExportSettings() {
let saved;
try { saved = JSON.parse(localStorage.getItem(KEY)); } catch { /* Use defaults. */ }
return {
format: ['auto', 'image/png', 'image/jpeg'].includes(saved?.format) ? saved.format : 'auto',
quality: [0.85, 0.92, 1].includes(saved?.quality) ? saved.quality : 0.92,
};
}
export function saveExportSettings(format, quality) {
try { localStorage.setItem(KEY, JSON.stringify({ format, quality: Number(quality) })); } catch { /* Keep session settings. */ }
}
+6 -19
View File
@@ -1,7 +1,6 @@
/**
* FAQ content, rendered on the page. Google no longer shows FAQ rich results
* for sites like this one, so there is no FAQPage structured data to keep in
* sync — the visible text is the single source.
* FAQ content. Kept in one place because the same questions and answers are
* mirrored as FAQPage structured data in index.html — edit both together.
*/
export const faqs = [
{
@@ -13,25 +12,17 @@ export const faqs = [
a: "Add the image to Untrace, review what it found, then choose Clean image and download the result. Untrace decodes the picture to raw pixels and writes a new file, so the C2PA manifest, the JUMBF container holding it, and every other metadata block are simply not carried over.",
},
{
q: "Why would I remove content credentials?",
a: "For privacy. Credentials can name the software you use, your account and the steps of your edit, and that record travels with the file to everyone who receives it. Removing it is the same kind of choice as removing GPS coordinates before you share a photo.",
q: "Does this remove the AI label on LinkedIn?",
a: "LinkedIn shows its AI label when it reads C2PA content credentials in the file you upload. Cleaning the image before you upload removes the data the label is generated from. It does not change a label on a post that is already published — delete that post and upload the clean copy instead.",
},
{
q: "Is it okay to remove them from AI-generated images?",
a: "Removing metadata is legal and common, but do not use it to pass AI-generated work off as a real photograph where disclosure is expected or required. Many platforms, publishers, contests and some laws ask you to disclose AI use, and that duty does not depend on whether a file carries a tag.",
},
{
q: "Will platforms still detect AI images after cleaning?",
a: "Possibly. Untrace removes metadata only. Platforms also run visual classifiers, and some generators embed invisible watermarks in the pixels themselves. None of that is touched, and Untrace does not claim otherwise.",
q: "Does it remove the AI tag on Instagram or Facebook?",
a: "Meta reads the same C2PA signals plus its own classifiers. Removing the credentials removes the metadata signal, so the automatic “AI info” tag is far less likely to appear. Visual detection is separate and no metadata tool can affect it.",
},
{
q: "What is C2PA and JUMBF?",
a: "C2PA is the Coalition for Content Provenance and Authenticity specification that defines the provenance record. JUMBF is the JPEG box format that record is stored in, usually inside an APP11 segment. Untrace scans for both and reports them before cleaning.",
},
{
q: "How do I know my image is not uploaded?",
a: "Open your browser's developer tools, choose the Network tab and clean an image. No request carries the file. The code is open source under the MIT licence, so you can also read exactly what it does.",
},
{
q: "Is my image uploaded anywhere?",
a: "No. Reading the metadata and writing the clean file both happen in your browser using the File and Canvas APIs. Nothing is sent over the network, there is no account, and Untrace has no server to store an image on.",
@@ -40,10 +31,6 @@ export const faqs = [
q: "Does cleaning reduce image quality?",
a: "Small PNGs are rewritten as PNG with no quality loss. Everything else is re-encoded as JPEG at quality 0.92, which is visually indistinguishable at normal viewing sizes. The tool shows the size change so you can see exactly what happened.",
},
{
q: "Can I clean several images at once?",
a: "Yes. Choose or drop up to 50 images together. Untrace scans them all, shows what each one hides, cleans them one after another in your browser, and lets you download them individually or as one ZIP.",
},
{
q: "Which formats are supported?",
a: "JPEG, PNG, TIFF, HEIC and AVIF. The cleaned file is returned as JPEG, or as PNG when the original was a small PNG.",
+18
View File
@@ -0,0 +1,18 @@
import { extractMetadata } from './metadata.js';
import { cleanImage } from './clean.js';
self.onmessage = async ({ data: { task, file, options } }) => {
try {
if (task === 'scan') {
self.postMessage({ value: await extractMetadata(file) });
return;
}
const result = await cleanImage(file, options);
URL.revokeObjectURL(result.url);
delete result.url;
if (!result.fallback) result.metadata = await extractMetadata(result.blob);
self.postMessage({ value: result });
} catch (error) {
self.postMessage({ error: error.message || 'Image processing failed.' });
}
};
+38
View File
@@ -0,0 +1,38 @@
import { sections } from './metadata.js';
function fields(metadata) {
return new Map(sections(metadata).flatMap(([section, values]) =>
Object.entries(values).map(([name, value]) => [`${section} / ${name}`, value])));
}
export function displayValue(value) {
if (value === undefined) return 'Not detected';
if (value instanceof Date) return value.toISOString();
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
export function compareMetadata(before, after) {
const a = fields(before), b = fields(after);
const order = name => name.startsWith('GPS /') ? 0 : name.startsWith('Camera /') ? 1 : name.startsWith('Color Profile /') ? 3 : 2;
return [...new Set([...a.keys(), ...b.keys()])].sort((x, y) => order(x) - order(y) || x.localeCompare(y)).map(name => ({
name,
before: !a.has(name) && before?.scanError ? 'Unknown' : displayValue(a.get(name)),
after: !b.has(name) && after?.scanError ? 'Unknown' : displayValue(b.get(name)),
status: !b.has(name) ? (after?.scanError ? 'Unverified' : 'Not detected afterward')
: !a.has(name) ? (before?.scanError ? 'Unverified' : 'Output field')
: displayValue(a.get(name)) === displayValue(b.get(name)) ? 'Still present' : 'Changed',
}));
}
export function locationSummary(metadata) {
const gps = metadata?.GPS;
if (!gps || !Object.keys(gps).length) return null;
const coordinate = (decimal, dms, ref) => {
if (typeof decimal === 'number' && Number.isFinite(decimal)) return decimal.toFixed(6);
if (Array.isArray(dms) && dms.length === 3 && dms.every(Number.isFinite)) {
const value = dms[0] + dms[1] / 60 + dms[2] / 3600;
return (value * (/^[SW]$/i.test(ref) ? -1 : 1)).toFixed(6);
}
return null;
};
const lat = coordinate(gps.latitude, gps.GPSLatitude, gps.GPSLatitudeRef);
const lon = coordinate(gps.longitude, gps.GPSLongitude, gps.GPSLongitudeRef);
return lat !== null && lon !== null ? `Coordinates: ${lat}, ${lon}` : `${Object.keys(gps).length} GPS fields detected. See raw metadata for details.`;
}
+22 -50
View File
@@ -18,7 +18,7 @@ export const ACCEPTED_TYPES = [
export const ACCEPTED_EXTENSIONS = ".jpg,.jpeg,.png,.tiff,.tif,.heic,.avif";
/** Sections that describe the result rather than holding metadata values. */
const META_KEYS = ["fileType", "hasMetadata", "message"];
const META_KEYS = ["fileType", "hasMetadata", "message", "scanError"];
const GPS_FIELDS = [
"latitude",
@@ -159,7 +159,8 @@ const AI_VALUE_KEYWORDS = [
];
export function isAccepted(file) {
return Boolean(file) && ACCEPTED_TYPES.includes(file.type);
return Boolean(file) && (ACCEPTED_TYPES.includes(file.type) ||
((!file.type || file.type === "application/octet-stream") && /\.(jpe?g|png|tiff?|heic|avif)$/i.test(file.name)));
}
function pick(source, fields) {
@@ -178,7 +179,8 @@ export async function extractMetadata(file) {
try {
const { parse } = await import("exifr");
const exifData = await parse(file, {
let exifData, scanError;
try { exifData = await parse(file, {
tiff: true,
exif: true,
gps: true,
@@ -190,6 +192,9 @@ export async function extractMetadata(file) {
iptc: true,
icc: true,
ihdr: true,
text: true,
itxt: true,
ztxt: true,
jfif: true,
chunked: true,
firstChunkSize: 131072,
@@ -223,49 +228,22 @@ export async function extractMetadata(file) {
skip: [],
});
const buffer = await file.arrayBuffer();
} catch { scanError = 'Some metadata could not be parsed. Results may be incomplete.'; }
const buffer = await file.slice(0, 1024 * 1024).arrayBuffer();
const rawMetadata = await extractRawMetadata(
new Uint8Array(buffer),
file.type
);
let allMetadata = { ...(exifData || {}), ...rawMetadata };
if (file.type.includes("png") && Object.keys(allMetadata).length === 0) {
try {
const pngMetadata = await parse(file, {
ihdr: true,
itxt: true,
text: true,
ztxt: true,
time: true,
phys: true,
splt: true,
hist: true,
chrm: true,
gama: true,
iccp: true,
sbit: true,
srgb: true,
bkgd: true,
trns: true,
mergeOutput: false,
translateKeys: false,
sanitize: false,
});
if (pngMetadata && Object.keys(pngMetadata).length > 0) {
allMetadata = { ...allMetadata, ...pngMetadata };
}
} catch {
/* PNG-specific parsing is best effort */
}
}
const allMetadata = { ...(exifData || {}), ...rawMetadata };
if (Object.keys(allMetadata).length === 0) {
return cleanResult(file);
return cleanResult(file, scanError);
}
return categorise(allMetadata, rawMetadata);
const result = categorise(allMetadata, rawMetadata);
if (scanError) result.scanError = scanError;
return result;
} catch (error) {
return cleanResult(file, error);
}
@@ -274,23 +252,12 @@ export async function extractMetadata(file) {
function cleanResult(file, error) {
const isPng = file.type.includes("png");
const isJpeg = file.type.includes("jpeg") || file.type.includes("jpg");
let message;
if (isPng) {
message = "PNG files rarely carry EXIF metadata.";
} else if (
error &&
(error.message?.includes("not supported") ||
error.message?.includes("invalid"))
) {
message = "No EXIF data found in this image.";
} else {
message = "No metadata found. This image is already clean.";
}
const message = error ? 'Metadata could not be fully read. Results may be incomplete.' : 'No recognized metadata found. This is not proof that none exists.';
return {
fileType: isPng ? "png" : isJpeg ? "jpeg" : "other",
hasMetadata: false,
...(error ? { scanError: message } : {}),
message,
};
}
@@ -300,6 +267,7 @@ function categorise(allMetadata, rawMetadata) {
const keys = Object.keys(allMetadata);
const c2paFields = keys.filter((key) =>
!(key in rawMetadata) && !key.toLowerCase().includes('profile') &&
C2PA_KEY_HINTS.some((hint) => key.toLowerCase().includes(hint))
);
assign(metadata, "C2PA/AI Provenance", pick(allMetadata, c2paFields));
@@ -397,6 +365,10 @@ function categorise(allMetadata, rawMetadata) {
};
}
const assigned = new Set(Object.values(metadata).flatMap(values => Object.keys(values)));
const remaining = keys.filter(key => !assigned.has(key) && !(key in rawMetadata));
if (remaining.length) metadata.Other = { ...(metadata.Other || {}), ...pick(allMetadata, remaining) };
return metadata;
}
+17
View File
@@ -0,0 +1,17 @@
// Each task owns a worker. Termination stops decode/encode and suppresses stale results.
export function processImage(task, file, options = {}, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException('Cancelled', 'AbortError')); return; }
const worker = new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' });
const finish = (callback, value) => {
worker.terminate();
signal?.removeEventListener('abort', cancel);
callback(value);
};
const cancel = () => finish(reject, new DOMException('Cancelled', 'AbortError'));
signal?.addEventListener('abort', cancel, { once: true });
worker.onmessage = ({ data }) => data.error ? finish(reject, new Error(data.error)) : finish(resolve, data.value);
worker.onerror = event => { event.preventDefault(); finish(reject, new Error(event.message || 'Image processing failed.')); };
worker.postMessage({ task, file, options });
});
}
+35
View File
@@ -0,0 +1,35 @@
export const THEMES = [
{ id: 'light', name: 'Light', description: 'Clean white, blue accents', scheme: 'light' },
{ id: 'dark', name: 'Dark', description: 'Charcoal, soft periwinkle', scheme: 'dark' },
{ id: 'graphite', name: 'Graphite', description: 'Neutral charcoal, silver', scheme: 'dark', replaces: 'forest' },
{ id: 'paper', name: 'Paper', description: 'Warm paper, dark olive ink', scheme: 'light', replaces: 'sand' },
{ id: 'cobalt', name: 'Cobalt', description: 'Navy frame, crisp blue', scheme: 'light', replaces: 'midnight' },
{ id: 'ember', name: 'Ember', description: 'Carbon black, warm orange', scheme: 'dark', replaces: 'plum' },
{ id: 'one-dark', name: 'One Dark', description: 'Slate grey, signature blue', scheme: 'dark', editor: true },
{ id: 'dracula', name: 'Dracula', description: 'Purple charcoal, lavender', scheme: 'dark', editor: true },
{ id: 'tokyo-night', name: 'Tokyo Night', description: 'Inky blue, electric periwinkle', scheme: 'dark', editor: true },
{ id: 'night-owl', name: 'Night Owl', description: 'Deep ocean, mint accents', scheme: 'dark', editor: true },
{ id: 'github-dark', name: 'GitHub Dark', description: 'Cool graphite, GitHub green', scheme: 'dark', editor: true },
{ id: 'github-light', name: 'GitHub Light', description: 'Porcelain, forest green', scheme: 'light', editor: true },
{ id: 'catppuccin-mocha', name: 'Catppuccin Mocha', description: 'Soft charcoal, pastel mauve', scheme: 'dark', editor: true },
{ id: 'catppuccin-latte', name: 'Catppuccin Latte', description: 'Creamy grey, violet accents', scheme: 'light', editor: true },
];
// Standalone so Vite can inline the same startup logic before the first paint.
export function bootstrapTheme(themes) {
let choice = 'system';
try {
const saved = localStorage.getItem('untrace-theme');
const match = themes.find(item => item.id === saved || item.replaces === saved);
if (saved === 'system') choice = saved;
else if (match) {
choice = match.id;
if (saved !== choice) localStorage.setItem('untrace-theme', choice);
}
} catch { /* Storage may be unavailable. */ }
const resolved = choice === 'system'
? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: choice;
document.documentElement.setAttribute('data-theme', resolved);
return choice;
}
+30 -49
View File
@@ -1,62 +1,43 @@
import { writable } from "svelte/store";
import { writable } from 'svelte/store';
import { THEMES, bootstrapTheme } from './theme-catalog.js';
const STORAGE_KEY = "untrace-theme";
/**
* The design system is dark by default and opts into light with
* data-theme="light" on <html>. A saved choice always wins; otherwise the
* operating system decides, falling back to dark.
*/
function preferredTheme() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "light" || saved === "dark") return saved;
} catch {
/* storage can be blocked — fall through to the media query */
}
return window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
}
function apply(theme) {
if (theme === "light") {
document.documentElement.setAttribute("data-theme", "light");
} else {
document.documentElement.removeAttribute("data-theme");
}
}
export { THEMES };
const STORAGE_KEY = 'untrace-theme'; // Keep existing portable preferences.
const valid = choice => choice === 'system' || THEMES.some(item => item.id === choice);
function createTheme() {
const { subscribe, set } = writable("dark");
const { subscribe, set } = writable('system');
let current = 'system';
let media;
function apply(choice) {
current = valid(choice) ? choice : 'system';
const resolved = current === 'system'
? ((media || matchMedia('(prefers-color-scheme: light)')).matches ? 'light' : 'dark')
: current;
document.documentElement.setAttribute('data-theme', resolved);
set(current);
}
function select(choice) {
apply(choice);
try { localStorage.setItem(STORAGE_KEY, current); } catch { /* Keep session choice. */ }
}
return {
subscribe,
init() {
const theme = preferredTheme();
apply(theme);
set(theme);
},
toggle() {
const next =
document.documentElement.getAttribute("data-theme") === "light"
? "dark"
: "light";
apply(next);
set(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
/* a blocked store just means the choice is not remembered */
}
media = matchMedia('(prefers-color-scheme: light)');
apply(bootstrapTheme(THEMES));
const onSystemChange = () => { if (current === 'system') apply('system'); };
media.addEventListener('change', onSystemChange);
return () => media.removeEventListener('change', onSystemChange);
},
select,
// Retained for the unused upstream toggle component.
toggle() { select(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'); },
};
}
export const theme = createTheme();
/** Duration helper so Svelte transitions honour prefers-reduced-motion. */
export function motionSafe(ms) {
if (typeof window === "undefined") return ms;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms;
if (typeof window === 'undefined') return ms;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : ms;
}
-90
View File
@@ -1,90 +0,0 @@
/**
* Minimal ZIP writer (stored, no compression). Images are already compressed,
* so deflating them gains nothing, and this avoids a dependency.
*/
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(bytes) {
let crc = 0xffffffff;
for (let i = 0; i < bytes.length; i++) {
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function header(size, write) {
const view = new DataView(new ArrayBuffer(size));
write(view);
return new Uint8Array(view.buffer);
}
/** @param {{name: string, data: Uint8Array}[]} files @returns {Blob} */
export function createZip(files) {
const encoder = new TextEncoder();
const now = new Date();
const time = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
const date = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
const parts = [];
const central = [];
let offset = 0;
for (const { name, data } of files) {
const nameBytes = encoder.encode(name);
const crc = crc32(data);
const local = header(30, (v) => {
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 0x0800, true); // UTF-8 names
v.setUint16(8, 0, true); // stored
v.setUint16(10, time, true);
v.setUint16(12, date, true);
v.setUint32(14, crc, true);
v.setUint32(18, data.length, true);
v.setUint32(22, data.length, true);
v.setUint16(26, nameBytes.length, true);
});
parts.push(local, nameBytes, data);
central.push(
header(46, (v) => {
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint16(8, 0x0800, true);
v.setUint16(10, 0, true);
v.setUint16(12, time, true);
v.setUint16(14, date, true);
v.setUint32(16, crc, true);
v.setUint32(20, data.length, true);
v.setUint32(24, data.length, true);
v.setUint16(28, nameBytes.length, true);
v.setUint32(42, offset, true);
}),
nameBytes
);
offset += local.length + nameBytes.length + data.length;
}
const centralSize = central.reduce((sum, part) => sum + part.length, 0);
const end = header(22, (v) => {
v.setUint32(0, 0x06054b50, true);
v.setUint16(8, files.length, true);
v.setUint16(10, files.length, true);
v.setUint32(12, centralSize, true);
v.setUint32(16, offset, true);
});
return new Blob([...parts, ...central, end], { type: "application/zip" });
}
+254
View File
@@ -0,0 +1,254 @@
/* Local theme palettes override existing semantic tokens. The upstream
tokens.css remains untouched. No component-specific colour values. */
[data-theme='dark'] {
--bg: #080808;
--surface: #121216;
--elevated: #1a1a20;
--text-1: #f4f4f6;
--text-2: #a9a9b4;
--text-3: #83838f;
--accent: #8fa8ff;
--accent-fg: #080808;
--border: #26262e;
--border-strong: #3a3a46;
--success: #3ecf8e;
--warning: #e8b23d;
--error: #f26d6d;
--info: #62b0f5;
color-scheme: dark;
}
/* Neutral surfaces keep photographs visually separate from the theme. */
[data-theme='graphite'] {
--bg: #18191b;
--surface: #232528;
--elevated: #303338;
--text-1: #f4f4f2;
--text-2: #c8c9cb;
--text-3: #a5a8ac;
--accent: #ecece9;
--accent-fg: #18191b;
--border: #3b3e43;
--border-strong: #656a72;
--success: #8bd4b0;
--warning: #e9c58c;
--error: #f49e9e;
--info: #acc7ec;
color-scheme: dark;
}
[data-theme='paper'] {
--bg: #e9e6df;
--surface: #faf9f6;
--elevated: #ffffff;
--text-1: #242521;
--text-2: #4d5048;
--text-3: #62655c;
--accent: #343c32;
--accent-fg: #faf9f6;
--border: #d0d0c7;
--border-strong: #959b8b;
--success: #326446;
--warning: #805b12;
--error: #a23a37;
--info: #3f5c85;
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
color-scheme: light;
}
[data-theme='cobalt'] {
--bg: #e3e8f0;
--surface: #f9fbff;
--elevated: #ffffff;
--text-1: #162132;
--text-2: #3e4e63;
--text-3: #566579;
--accent: #244ddb;
--accent-fg: #ffffff;
--border: #c9d2e0;
--border-strong: #8698b2;
--success: #226948;
--warning: #805909;
--error: #b03742;
--info: #285abc;
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
color-scheme: light;
}
/* Cobalt has a dark frame and a light working area. The same roles are
overridden locally, so controls keep their shared component styles. */
html[data-theme='cobalt'] :is(.app-header, .app-rail),
.theme-mini[data-theme='cobalt'] > :is(.mini-header, .mini-rail) {
--bg: #273a5d;
--surface: #15243e;
--elevated: #304564;
--text-1: #f2f5fb;
--text-2: #c6d1e5;
--text-3: #a2b4ce;
--border: #344564;
--border-strong: #61799d;
--accent: #9ab5ff;
--accent-fg: #15243e;
color-scheme: dark;
}
[data-theme='ember'] {
--bg: #101010;
--surface: #1d1d1d;
--elevated: #2c2c2c;
--text-1: #f5f3ef;
--text-2: #cdc9c3;
--text-3: #a7a39d;
--accent: #ffa168;
--accent-fg: #21150d;
--border: #363432;
--border-strong: #71685e;
--success: #9ed0a2;
--warning: #ebce87;
--error: #ffa1a1;
--info: #a4c5ed;
color-scheme: dark;
}
/* Editor-inspired adaptations; sources and design notes: THEME-CREDITS.md. */
[data-theme='one-dark'] {
--bg: #21252b;
--surface: #282c34;
--elevated: #343a45;
--text-1: #abb2bf;
--text-2: #abb2bf;
--text-3: #a4acba;
--accent: #61afef;
--accent-fg: #18212b;
--border: #3e4451;
--border-strong: #687386;
--success: #98c379;
--warning: #e5c07b;
--error: #e06c75;
--info: #61afef;
color-scheme: dark;
}
[data-theme='dracula'] {
--bg: #21222c;
--surface: #282a36;
--elevated: #343746;
--text-1: #f8f8f2;
--text-2: #d6d6e5;
--text-3: #aaaec9;
--accent: #bd93f9;
--accent-fg: #282a36;
--border: #44475a;
--border-strong: #727997;
--success: #50fa7b;
--warning: #f1fa8c;
--error: #ff5555;
--info: #8be9fd;
color-scheme: dark;
}
[data-theme='tokyo-night'] {
--bg: #16161e;
--surface: #1a1b26;
--elevated: #24283b;
--text-1: #c0caf5;
--text-2: #a9b1d6;
--text-3: #939dc2;
--accent: #7aa2f7;
--accent-fg: #16161e;
--border: #343b58;
--border-strong: #636f98;
--success: #9ece6a;
--warning: #e0af68;
--error: #f7768e;
--info: #7dcfff;
color-scheme: dark;
}
[data-theme='night-owl'] {
--bg: #011627;
--surface: #071e30;
--elevated: #112b40;
--text-1: #d6deeb;
--text-2: #b6c8d9;
--text-3: #8fa8be;
--accent: #7fdbca;
--accent-fg: #011627;
--border: #234157;
--border-strong: #53748d;
--success: #addb67;
--warning: #ecc48d;
--error: #ef8080;
--info: #82aaff;
color-scheme: dark;
}
[data-theme='github-dark'] {
--bg: #0d1117;
--surface: #161b22;
--elevated: #21262d;
--text-1: #e6edf3;
--text-2: #b1bac4;
--text-3: #919ba6;
--accent: #3fb950;
--accent-fg: #0d1117;
--border: #30363d;
--border-strong: #687584;
--success: #56d364;
--warning: #e3b341;
--error: #ff7b72;
--info: #79c0ff;
color-scheme: dark;
}
[data-theme='github-light'] {
--bg: #f6f8fa;
--surface: #ffffff;
--elevated: #ffffff;
--text-1: #1f2328;
--text-2: #424a53;
--text-3: #59636e;
--accent: #1a7f37;
--accent-fg: #ffffff;
--border: #d0d7de;
--border-strong: #8c959f;
--success: #1a7f37;
--warning: #7d4e00;
--error: #cf222e;
--info: #0969da;
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
color-scheme: light;
}
[data-theme='catppuccin-mocha'] {
--bg: #181825;
--surface: #1e1e2e;
--elevated: #313244;
--text-1: #cdd6f4;
--text-2: #bac2de;
--text-3: #a6adc8;
--accent: #cba6f7;
--accent-fg: #1e1e2e;
--border: #45475a;
--border-strong: #6c7086;
--success: #a6e3a1;
--warning: #f9e2af;
--error: #f38ba8;
--info: #89b4fa;
color-scheme: dark;
}
[data-theme='catppuccin-latte'] {
--bg: #e6e9ef;
--surface: #eff1f5;
--elevated: #ffffff;
--text-1: #4c4f69;
--text-2: #5c5f77;
--text-3: #62657a;
--accent: #8839ef;
--accent-fg: #ffffff;
--border: #bcc0cc;
--border-strong: #8c8fa1;
--success: #33751b;
--warning: #865900;
--error: #d20f39;
--info: #1e66f5;
--shadow-sm: none;
--shadow-md: none;
--shadow-lg: none;
color-scheme: light;
}
+12
View File
@@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test';
import { createRequire } from 'node:module';
import path from 'node:path';
const { dataPath } = createRequire(import.meta.url)('../desktop/data-path.cjs');
test('installed settings stay outside program files and portable settings travel with the launcher', () => {
const appData = path.resolve('test-profile');
const portable = path.resolve('usb');
expect(dataPath({ env: {}, appData })).toBe(path.join(appData, 'Untrace Desktop'));
expect(dataPath({ env: { PORTABLE_EXECUTABLE_DIR: portable }, appData })).toBe(path.join(portable, 'Untrace-data'));
expect(dataPath({ env: { PORTABLE_EXECUTABLE_DIR: portable, UNTRACE_DATA_DIR: 'test-override' }, appData })).toBe('test-override');
});
+119
View File
@@ -0,0 +1,119 @@
import { test, expect, _electron as electron } from '@playwright/test';
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { isAccepted } from '../src/lib/metadata.js';
function withTextMetadata(png) {
const payload = Buffer.from('tEXtSoftware\0OpenAI API');
let crc = 0xffffffff;
for (const byte of payload) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
}
const size = Buffer.alloc(4); size.writeUInt32BE(payload.length - 4);
const checksum = Buffer.alloc(4); checksum.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
return Buffer.concat([png.subarray(0, -12), size, payload, checksum, png.subarray(-12)]);
}
test('Windows files without a MIME type are accepted by extension', () => {
expect(isAccepted({ name: 'PHOTO.JPG', type: '' })).toBe(true);
expect(isAccepted({ name: 'photo.png', type: 'application/octet-stream' })).toBe(true);
expect(isAccepted({ name: 'program.exe', type: '' })).toBe(false);
expect(isAccepted({ name: 'fake.jpg', type: 'text/plain' })).toBe(false);
});
test('offline desktop cleans images and reports corrupt input honestly', async () => {
const data = await mkdtemp(path.join(os.tmpdir(), 'untrace-test-'));
const env = { ...process.env, UNTRACE_TEST: '1', UNTRACE_DATA_DIR: data };
delete env.ELECTRON_RUN_AS_NODE;
const app = await electron.launch({
...(process.env.UNTRACE_TEST_EXE ? { executablePath: process.env.UNTRACE_TEST_EXE } : {}),
// Hidden Windows test windows must keep rendering and canvas work scheduled.
args: [...(process.env.UNTRACE_TEST_EXE ? [] : ['.']), '--disable-features=CalculateNativeWinOcclusion', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'],
env,
});
try {
const page = await app.firstWindow();
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setBackgroundThrottling(false));
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.waitForLoadState('networkidle');
await expect(page.getByRole('heading', { name: 'Inspector', exact: true })).toBeVisible();
await expect(page.locator('.btn-primary')).toHaveCount(1);
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/workspace-light.png' });
await page.getByRole('button', { name: 'Choose a theme' }).click();
await page.getByRole('radio', { name: /^Dark / }).check();
await page.getByRole('button', { name: 'Done', exact: true }).click();
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/workspace-dark.png' });
await page.keyboard.press('F1');
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
const fileChooser = page.waitForEvent('filechooser');
await page.keyboard.press('Control+o');
await fileChooser;
const metrics = await app.evaluate(({ app }) => app.getAppMetrics().map(({ type, memory }) => ({ type, workingSetKB: memory.workingSetSize })));
await writeFile('test-results/idle-memory.json', JSON.stringify(metrics, null, 2));
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(640, 700));
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/workspace-small.png', fullPage: true });
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1200, 900));
expect(await page.evaluate(() => typeof window.require)).toBe('undefined');
expect(await page.evaluate(() => fetch('https://example.com').then(() => false).catch(() => true))).toBe(true);
const image = await page.evaluate(() => {
const canvas = document.createElement('canvas');
canvas.width = 1100; canvas.height = 1000;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'red'; ctx.fillRect(50, 50, 100, 100);
return canvas.toDataURL('image/png').split(',')[1];
});
await page.getByLabel('Open image file', { exact: true }).setInputFiles({ name: 'transparent.png', mimeType: 'image/png', buffer: withTextMetadata(Buffer.from(image, 'base64')) });
await page.getByRole('button', { name: 'Clean image', exact: true }).click();
const download = page.getByRole('link', { name: 'Save clean image' });
await expect(download).toBeVisible();
await expect(download).toHaveAttribute('download', 'transparent-cleaned.jpg');
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/workspace-result.png' });
const output = await download.getAttribute('href');
await page.getByRole('button', { name: 'Choose a theme' }).click();
await page.getByRole('radio', { name: /^Ember / }).check();
await page.getByRole('button', { name: 'Done', exact: true }).click();
await expect(download).toHaveAttribute('href', output);
const pixel = await page.evaluate(async url => {
const img = new Image(); img.src = url; await img.decode();
const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height;
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
return [...ctx.getImageData(0, 0, 1, 1).data];
}, output);
expect(pixel).toEqual([255, 255, 255, 255]);
await app.evaluate(({ session }, destination) => session.defaultSession.once('will-download', (_event, item) => item.setSavePath(destination)), path.join(data, 'result.jpg'));
await download.click();
await expect.poll(async () => (await readFile(path.join(data, 'result.jpg')).catch(() => Buffer.alloc(0))).length).toBeGreaterThan(0);
expect((await readFile(path.join(data, 'result.jpg'))).includes(Buffer.from('OpenAI API'))).toBe(false);
// Choosing lossless PNG invalidates the previous export and preserves alpha.
await page.getByLabel('File format', { exact: true }).selectOption('image/png');
await expect(download).toHaveCount(0);
await page.keyboard.press('Control+Enter');
await expect(download).toHaveAttribute('download', 'transparent-cleaned.png');
const alpha = await page.evaluate(async url => {
const img = new Image(); img.src = url; await img.decode();
const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1;
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, 1, 1).data[3];
}, await download.getAttribute('href'));
expect(alpha).toBe(0);
await page.getByRole('button', { name: 'Original', exact: true }).click();
await expect(page.getByAltText('Original image preview')).toBeVisible();
await page.getByRole('button', { name: 'Cleaned copy', exact: true }).click();
await expect(page.getByAltText('Cleaned image preview')).toBeVisible();
await page.getByRole('button', { name: 'Close image', exact: true }).click();
await page.getByLabel('Open image file', { exact: true }).setInputFiles({ name: 'broken.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('not an image') });
await page.getByRole('button', { name: 'Clean image', exact: true }).click();
await expect(page.getByText('Not cleaned', { exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: 'Save clean image' })).toHaveCount(0);
await expect(page.locator('.removed')).toHaveCount(0);
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/desktop.png', fullPage: true });
} finally {
await app.close();
await rm(data, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}
});
+219
View File
@@ -0,0 +1,219 @@
import { test, expect, _electron as electron } from '@playwright/test';
import { mkdtemp, rm, readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { compareMetadata, locationSummary } from '../src/lib/metadata-comparison.js';
import { readDroppedFiles } from '../src/lib/drop-files.js';
const { saveUnique } = createRequire(import.meta.url)('../desktop/output.cjs');
function gpsJpeg(jpeg) {
const tiff = Buffer.alloc(128);
tiff.write('II'); tiff.writeUInt16LE(42, 2); tiff.writeUInt32LE(8, 4);
tiff.writeUInt16LE(1, 8); // IFD0: GPS pointer
tiff.writeUInt16LE(0x8825, 10); tiff.writeUInt16LE(4, 12); tiff.writeUInt32LE(1, 14); tiff.writeUInt32LE(26, 18);
tiff.writeUInt16LE(4, 26);
function entry(offset, tag, type, count, value) {
tiff.writeUInt16LE(tag, offset); tiff.writeUInt16LE(type, offset + 2); tiff.writeUInt32LE(count, offset + 4); tiff.writeUInt32LE(value, offset + 8);
}
entry(28, 1, 2, 2, 78); // N
entry(40, 2, 5, 3, 80);
entry(52, 3, 2, 2, 69); // E
entry(64, 4, 5, 3, 104);
[52, 30, 0, 13, 24, 0].forEach((v, i) => { tiff.writeUInt32LE(v, 80 + i * 8); tiff.writeUInt32LE(1, 84 + i * 8); });
const payload = Buffer.concat([Buffer.from('Exif\0\0'), tiff]);
const marker = Buffer.alloc(4); marker.writeUInt16BE(0xffe1); marker.writeUInt16BE(payload.length + 2, 2);
return Buffer.concat([jpeg.subarray(0, 2), marker, payload, jpeg.subarray(2)]);
}
async function launch() {
const data = await mkdtemp(path.join(os.tmpdir(), 'untrace-features-'));
const env = { ...process.env, UNTRACE_TEST: '1', UNTRACE_DATA_DIR: data };
delete env.ELECTRON_RUN_AS_NODE;
const app = await electron.launch({ ...(process.env.UNTRACE_TEST_EXE ? { executablePath: process.env.UNTRACE_TEST_EXE } : {}), args: [...(process.env.UNTRACE_TEST_EXE ? [] : ['.']), '--disable-features=CalculateNativeWinOcclusion', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'], env });
const page = await app.firstWindow();
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setBackgroundThrottling(false));
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.waitForLoadState('networkidle');
return { app, page, data, close: async () => { await app.close(); await rm(data, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } };
}
async function jpeg(page) {
return Buffer.from(await page.evaluate(() => {
const canvas = document.createElement('canvas'); canvas.width = 160; canvas.height = 100;
const ctx = canvas.getContext('2d'); ctx.fillStyle = 'steelblue'; ctx.fillRect(0, 0, 160, 100);
return canvas.toDataURL('image/jpeg').split(',')[1];
}), 'base64');
}
test('comparison preserves uncertainty and folder enumeration drains every page', async () => {
expect(compareMetadata({ GPS: { latitude: 0 } }, { scanError: 'Invalid segment' })[0].status).toBe('Unverified');
expect(locationSummary({ GPS: { latitude: 0, longitude: 0 } })).toContain('0.000000, 0.000000');
const groups = [[{ isFile: true, name: 'one.png', file: resolve => resolve({ name: 'one.png' }) }], [{ isFile: true, name: 'two.png', file: resolve => resolve({ name: 'two.png' }) }], []];
const entry = { isDirectory: true, name: 'folder', createReader: () => ({ readEntries: resolve => resolve(groups.shift()) }) };
expect((await readDroppedFiles([{ webkitGetAsEntry: () => entry }], [])).map(item => item.name)).toEqual(['folder/one.png', 'folder/two.png']);
});
test('output writes never overwrite and reject paths outside the chosen folder', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'untrace-output-'));
try {
await writeFile(path.join(dir, 'photo-cleaned.jpg'), 'original');
const writes = await Promise.all([saveUnique(dir, 'photo-cleaned.jpg', Uint8Array.of(1)), saveUnique(dir, 'photo-cleaned.jpg', Uint8Array.of(2))]);
expect(new Set(writes.map(file => file.filename)).size).toBe(2);
expect(await readFile(path.join(dir, 'photo-cleaned.jpg'), 'utf8')).toBe('original');
await expect(saveUnique(dir, '../escape.jpg', Uint8Array.of(1))).rejects.toThrow('Invalid output filename');
await expect(saveUnique(dir, 'CON.jpg', Uint8Array.of(1))).rejects.toThrow('Invalid output filename');
} finally { await rm(dir, { recursive: true, force: true }); }
});
test('GPS warning, exact size preview, comparison, and export preferences', async () => {
const { app, page, close } = await launch();
try {
const buffer = gpsJpeg(await jpeg(page));
await page.getByLabel('Open image file', { exact: true }).setInputFiles({ name: 'location.jpg', mimeType: 'image/jpeg', buffer });
await expect(page.getByText('Location data in original', { exact: true })).toBeVisible();
await expect(page.getByText('Coordinates: 52.500000, 13.400000', { exact: true })).toBeVisible();
await page.getByLabel('File format', { exact: true }).selectOption('image/jpeg');
await page.getByLabel('JPEG quality', { exact: true }).selectOption('0.85');
await page.getByRole('button', { name: 'Preview export size', exact: true }).click();
await expect(page.getByText('Exact encoded size.', { exact: false })).toBeVisible();
const size = await page.locator('.size-preview dd').first().textContent();
await page.getByRole('button', { name: 'Clean image', exact: true }).click();
await expect(page.getByRole('link', { name: 'Save clean image' })).toBeVisible();
await expect(page.locator('.output-name')).toContainText(size);
await page.getByRole('button', { name: 'Compare metadata', exact: true }).click();
const comparison = page.getByRole('dialog', { name: 'Metadata comparison' });
await expect(comparison.getByRole('row').filter({ hasText: 'GPS / latitude' })).toContainText('Not detected afterward');
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/metadata-comparison.png' });
await page.keyboard.press('Escape');
await expect(page.getByRole('link', { name: 'Save clean image' })).toBeVisible();
const prepared = await page.getByRole('link', { name: 'Save clean image' }).getAttribute('href');
await page.getByLabel('Open image file', { exact: true }).setInputFiles([{ name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('notes') }, { name: 'document.pdf', mimeType: 'application/pdf', buffer: Buffer.from('document') }]);
await expect(page.getByText('No supported images found.', { exact: false })).toBeVisible();
await expect(page.getByRole('link', { name: 'Save clean image' })).toHaveAttribute('href', prepared);
await expect(page.getByRole('heading', { name: 'Batch cleaning' })).toHaveCount(0);
await page.reload();
await expect(page.getByLabel('File format', { exact: true })).toHaveValue('image/jpeg');
await expect(page.getByLabel('JPEG quality', { exact: true })).toHaveValue('0.85');
await page.evaluate(() => localStorage.setItem('untrace-export', '{broken'));
await page.reload();
await expect(page.getByLabel('File format', { exact: true })).toHaveValue('auto');
} finally { await close(); }
});
test('nested folder batch reports mixed results and saves unique copies', async () => {
const { app, page, data, close } = await launch();
try {
const source = path.join(data, 'source'), output = path.join(data, 'output');
await mkdir(path.join(source, 'nested'), { recursive: true }); await mkdir(output);
const buffer = gpsJpeg(await jpeg(page));
await writeFile(path.join(source, 'photo.jpg'), buffer);
await writeFile(path.join(source, 'nested', 'photo.jpg'), buffer);
await writeFile(path.join(source, 'broken.jpg'), 'not an image');
await writeFile(path.join(source, 'notes.txt'), 'keep this');
await writeFile(path.join(source, 'nested', 'document.pdf'), 'not a picture');
await writeFile(path.join(source, 'program.exe'), 'not a picture');
await writeFile(path.join(output, 'photo-cleaned.jpg'), 'existing');
await app.evaluate(({ dialog }, folder) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [folder] }); }, output);
await page.getByLabel('Open image folder', { exact: true }).setInputFiles(source);
await expect(page.getByRole('heading', { name: 'Batch cleaning' })).toBeVisible();
await expect(page.locator('.batch-row')).toHaveCount(3);
await expect(page.locator('.batch-list')).not.toContainText('notes.txt');
await expect(page.locator('.batch-list')).not.toContainText('document.pdf');
await expect(page.locator('.batch-list')).not.toContainText('program.exe');
await page.getByRole('button', { name: 'Clean batch', exact: true }).click();
await expect(page.locator('.batch-heading')).toContainText('2 cleaned');
await expect(page.locator('.batch-heading')).toContainText('1 failed');
await page.getByRole('button', { name: 'Save cleaned files', exact: true }).click();
await expect(page.locator('.batch-heading')).toContainText('2 saved');
expect((await readdir(output)).sort()).toEqual(['photo-cleaned-2.jpg', 'photo-cleaned-3.jpg', 'photo-cleaned.jpg']);
expect(await readFile(path.join(output, 'photo-cleaned.jpg'), 'utf8')).toBe('existing');
expect(await readFile(path.join(source, 'photo.jpg'))).toEqual(buffer);
expect((await readFile(path.join(output, 'photo-cleaned-2.jpg'))).includes(Buffer.from('Exif\0\0'))).toBe(false);
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/batch-results.png' });
await expect(page.evaluate(() => window.untraceDesktop.saveOutput('fake-token', 'escape.jpg', new Uint8Array([1])))).rejects.toThrow('Choose an output folder');
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(640, 700));
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
} finally { await close(); }
});
test('cancellation terminates active work and remaining batch files can be retried', async () => {
const { page, close } = await launch();
try {
const buffer = await jpeg(page);
await page.evaluate(bytes => {
const drop = new DataTransfer();
for (const name of ['one.jpg', 'two.jpg']) drop.items.add(new File([new Uint8Array(bytes)], name, { type: 'image/jpeg' }));
drop.items.add(new File(['notes'], 'notes.txt', { type: 'text/plain' }));
window.dispatchEvent(new DragEvent('drop', { dataTransfer: drop }));
}, [...buffer]);
await expect(page.getByRole('button', { name: 'Clean batch', exact: true })).toBeVisible();
await expect(page.locator('.batch-row')).toHaveCount(2);
await expect(page.locator('.batch-list')).not.toContainText('notes.txt');
await page.evaluate(() => {
const NativeWorker = window.Worker;
window.__terminated = 0;
window.__cleanTasks = 0;
window.Worker = class extends NativeWorker {
postMessage(data) { if (data.task === 'clean' && ++window.__cleanTasks === 2 && !window.__held) { window.__held = true; return; } super.postMessage(data); }
terminate() { window.__terminated++; super.terminate(); }
};
});
await page.getByRole('button', { name: 'Clean batch', exact: true }).click();
await expect.poll(() => page.evaluate(() => window.__held)).toBe(true);
await page.getByRole('button', { name: 'Cancel processing', exact: true }).click();
await expect(page.locator('.batch-heading')).toContainText('1 cancelled');
await expect(page.locator('.batch-heading')).toContainText('1 cleaned');
expect(await page.evaluate(() => window.__terminated)).toBeGreaterThan(0);
await expect(page.getByRole('button', { name: 'Save cleaned files' })).toBeVisible();
await page.getByRole('button', { name: 'Clean remaining images', exact: true }).click();
await expect(page.locator('.batch-heading')).toContainText('2 cleaned');
await page.getByRole('button', { name: 'Close batch', exact: true }).click();
await page.getByLabel('Open image file', { exact: true }).setInputFiles({ name: 'single.jpg', mimeType: 'image/jpeg', buffer });
await expect(page.getByRole('button', { name: 'Preview export size', exact: true })).toBeEnabled();
await page.evaluate(() => { window.__held = false; window.__cleanTasks = 1; });
await page.getByRole('button', { name: 'Preview export size', exact: true }).click();
await page.getByRole('button', { name: 'Cancel processing', exact: true }).click();
await expect(page.getByText('Processing cancelled. Your original is unchanged.')).toBeVisible();
await expect(page.getByRole('link', { name: 'Save clean image' })).toHaveCount(0);
} finally { await close(); }
});
test('save cancellation finishes the current file and failed saves can be retried', async () => {
const { app, page, data, close } = await launch();
try {
const output = path.join(data, 'output'); await mkdir(output);
const buffer = await jpeg(page);
await app.evaluate(({ dialog }, folder) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [folder] }); }, output);
await page.getByLabel('Open image file', { exact: true }).setInputFiles([{ name: 'one.jpg', mimeType: 'image/jpeg', buffer }, { name: 'two.jpg', mimeType: 'image/jpeg', buffer }]);
await page.getByRole('button', { name: 'Clean batch', exact: true }).click();
await expect(page.locator('.batch-heading')).toContainText('2 cleaned');
await page.getByRole('button', { name: 'Choose output folder', exact: true }).click();
// A drive disappears after selection: retain prepared blobs for a save retry.
await rm(output, { recursive: true });
await page.getByRole('button', { name: 'Save cleaned files', exact: true }).click();
await expect(page.locator('.batch-heading')).toContainText('2 failed');
await mkdir(output);
// Hold the first real file write so cancellation can be exercised deterministically.
await app.evaluate(async () => {
const fs = process.getBuiltinModule('fs/promises');
const nativeOpen = fs.open;
fs.open = async (...args) => {
const handle = await nativeOpen(...args);
if (!global.__writeHeld && args[1] === 'wx') {
global.__writeHeld = true;
const nativeWrite = handle.writeFile.bind(handle);
handle.writeFile = async bytes => { await new Promise(resolve => global.__releaseWrite = resolve); return nativeWrite(bytes); };
}
return handle;
};
});
await page.getByRole('button', { name: 'Save cleaned files', exact: true }).click();
await expect.poll(() => app.evaluate(() => typeof global.__releaseWrite)).toBe('function');
await page.getByRole('button', { name: 'Cancel processing', exact: true }).click();
await app.evaluate(() => global.__releaseWrite());
await expect(page.locator('.batch-heading')).toContainText('1 saved');
expect(await readdir(output)).toEqual(['one-cleaned.jpg']);
expect((await readFile(path.join(output, 'one-cleaned.jpg'))).length).toBeGreaterThan(0);
await page.getByRole('button', { name: 'Save cleaned files', exact: true }).click();
await expect(page.locator('.batch-heading')).toContainText('2 saved');
} finally { await close(); }
});
+102
View File
@@ -0,0 +1,102 @@
import { test, expect, _electron as electron } from '@playwright/test';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { THEMES } from '../src/lib/theme-catalog.js';
test('themes persist, follow Windows, and keep readable contrast', async () => {
const data = await mkdtemp(path.join(os.tmpdir(), 'untrace-themes-'));
const env = { ...process.env, UNTRACE_TEST: '1', UNTRACE_DATA_DIR: data };
delete env.ELECTRON_RUN_AS_NODE;
const app = await electron.launch({
...(process.env.UNTRACE_TEST_EXE ? { executablePath: process.env.UNTRACE_TEST_EXE } : {}),
args: [...(process.env.UNTRACE_TEST_EXE ? [] : ['.']), '--disable-features=CalculateNativeWinOcclusion', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'], env,
});
try {
const page = await app.firstWindow();
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setBackgroundThrottling(false));
await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'reduce' });
await page.waitForLoadState('networkidle');
await expect(page).toHaveTitle('Untrace Desktop');
await expect(page.getByRole('heading', { name: 'Untrace Desktop', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Choose a theme' }).click();
const dialog = page.getByRole('dialog', { name: 'Appearance' });
await expect(dialog).toBeVisible();
for (const item of THEMES) {
await page.getByRole('radio', { name: new RegExp(`^${item.name} `) }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', item.id);
const contrasts = await page.evaluate(() => {
const style = getComputedStyle(document.documentElement);
function luminance(value) {
let hex = value.trim().replace('#', '');
if (hex.length === 3) hex = [...hex].map(char => char + char).join('');
const values = hex.match(/../g).map(part => parseInt(part, 16) / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
}
function ratio(a, b, scope = style) {
const x = luminance(scope.getPropertyValue(a)), y = luminance(scope.getPropertyValue(b));
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
const header = getComputedStyle(document.querySelector('.app-header'));
return ['--text-1', '--text-2', '--text-3'].flatMap(text => ['--bg', '--surface'].map(bg => ratio(text, bg))).concat(ratio('--accent-fg', '--accent'), ratio('--text-3', '--surface', header));
});
if (!process.env.UNTRACE_TEST_EXE && (item.replaces || item.id === 'tokyo-night')) {
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.screenshot({ path: 'test-results/theme-' + item.id + '.png' });
await page.getByRole('button', { name: 'Choose a theme' }).click();
}
expect(Math.min(...contrasts), `${item.name} text contrast`).toBeGreaterThanOrEqual(4.5);
}
await page.getByRole('radio', { name: /^Tokyo Night / }).check();
await page.evaluate(() => document.querySelector('.theme-choices').scrollTop = 0);
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/editor-themes.png' });
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'tokyo-night');
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(640, 700));
await page.getByRole('button', { name: 'Choose a theme' }).click();
await page.getByRole('radio', { name: /^Ember / }).check();
const fits = await dialog.evaluate(el => {
const rect = el.getBoundingClientRect();
const done = el.querySelector('.appearance-footer').getBoundingClientRect();
return rect.left >= 0 && rect.right <= innerWidth && rect.top >= 0 && rect.bottom <= innerHeight && done.bottom <= innerHeight && el.scrollWidth <= el.clientWidth;
});
expect(fits, 'picker and Done fit a small window').toBe(true);
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/themes-small.png' });
await page.getByRole('button', { name: 'Done', exact: true }).click();
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1200, 900));
await page.getByRole('button', { name: 'Choose a theme' }).click();
await page.getByRole('radio', { name: /^Cobalt / }).check();
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/appearance.png' });
await page.getByRole('button', { name: 'Done', exact: true }).click();
if (!process.env.UNTRACE_TEST_EXE) await page.screenshot({ path: 'test-results/cobalt.png' });
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'cobalt');
await page.getByRole('button', { name: 'Choose a theme' }).click();
await expect(page.getByRole('radio', { name: /^Cobalt / })).toBeChecked();
await page.getByRole('radio', { name: /^Follow Windows/ }).check();
await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'reduce' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.getByRole('radio', { name: /^Graphite / }).check();
await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'graphite');
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
// Saved names from the previous release migrate before the first paint.
for (const item of THEMES.filter(item => item.replaces)) {
await page.evaluate(id => localStorage.setItem('untrace-theme', id), item.replaces);
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', item.id);
expect(await page.evaluate(() => localStorage.getItem('untrace-theme'))).toBe(item.id);
}
// Invalid legacy data falls back safely instead of applying an unknown palette.
await page.evaluate(() => localStorage.setItem('untrace-theme', 'unknown-theme'));
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
} finally {
await app.close();
await rm(data, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}
});
+13 -14
View File
@@ -1,27 +1,26 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { resolve } from 'node:path'
import { readFileSync } from 'node:fs'
import { THEMES, bootstrapTheme } from './src/lib/theme-catalog.js'
const guides = ['', 'remove-gps-from-photo', 'remove-exif-data-iphone', 'remove-exif-data-android', 'remove-exif-data-windows-mac', 'how-to-view-exif-data', 'do-social-media-sites-remove-exif-data', 'photo-metadata-privacy-checklist', 'what-is-exif-data', 'what-is-c2pa']
const packageInfo = JSON.parse(readFileSync('./package.json', 'utf8'))
const brand = JSON.parse(readFileSync('./desktop/brand.json', 'utf8'))
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
plugins: [svelte(), {
name: 'theme-startup',
transformIndexHtml: {
order: 'pre',
handler: html => html.replace('/* theme-bootstrap */', `(${bootstrapTheme.toString()})(${JSON.stringify(THEMES)});`),
},
}],
define: { __APP_VERSION__: JSON.stringify(packageInfo.version), __APP_NAME__: JSON.stringify(brand.name) },
base: '/',
worker: { format: 'es' },
build: {
outDir: 'dist',
assetsDir: 'assets',
cssMinify: 'lightningcss',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
...Object.fromEntries(
guides.map((slug) => [
`guides-${slug || 'index'}`,
resolve(__dirname, 'guides', slug, 'index.html'),
])
),
},
},
},
})