Compare commits

..
6 Commits
Author SHA1 Message Date
Dario P. f3b4205ee1 Preserve web edition history while adopting desktop release 2026-10-25 01:22:14 +02:00
Dario P. 7ce81b0727 Publish Untrace Desktop 1.6.1 with offline batch workflows 2026-10-25 01:22:14 +02:00
SharifandClaude Sonnet 5.5 6d5e92b14d Add four more privacy guides and keep CNAME in the build
Windows/Mac removal, viewing EXIF, social media behaviour and a sharing
checklist. public/CNAME stops gh-pages deploys from dropping the custom
domain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:30:48 +06:00
SharifandClaude Sonnet 5.5 edf5debe34 Add social preview image and iPhone/Android EXIF guides
New 1200x630 preview card with the new logo, two high-intent guides,
cross-linked related lists, updated sitemap, footer and guides index.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:27:56 +06:00
SharifandClaude Sonnet 5.5 1a9b726354 Add batch cleaning with per-file review and ZIP download
Up to 50 images at once; single-image flow unchanged. Includes a
dependency-free stored-ZIP writer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:25:12 +06:00
SharifandClaude Sonnet 5.5 28a8151cc0 Add logo, animated explainer, trust section and photo-privacy guides
Reposition around EXIF/GPS/C2PA privacy, replace dead HowTo/FAQ schema,
add three guide pages with sitemap and internal links.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:23:55 +06:00
47 changed files with 5958 additions and 1208 deletions

No files matched your search

+21
View File
@@ -6,6 +6,10 @@ yarn-error.log*
# Build output # Build output
dist/ dist/
release/
test-results/
playwright-report/
Untrace-data/
build/ build/
# Environment variables # Environment variables
@@ -54,3 +58,20 @@ coverage/
# Temporary folders # Temporary folders
tmp/ tmp/
temp/ 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
+31
View File
@@ -91,6 +91,37 @@ Light theme: `document.documentElement.setAttribute('data-theme', 'light')`. Dar
## Untrace specifics ## 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. 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 - `src/tokens.css` is the vendored copy. `src/app.css` imports it and adds only
+79
View File
@@ -0,0 +1,79 @@
# Changelog
## 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.
+54
View File
@@ -0,0 +1,54 @@
# 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)
Create empty repositories at the two publishing destinations before pushing. Do not initialize them with a README or license. 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 portable
node scripts/smoke-portable.mjs
```
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.6.1 -m 'Untrace Desktop 1.6.1'
git push origin v1.6.1
git push gitea v1.6.1
```
Create a release on each host for the same tag. Upload `release/Untrace-Desktop-1.6.1-portable-x64.exe`, a SHA-256 checksum file, and matching source. Include the MIT license, theme credits, version highlights from CHANGELOG.md, Windows x64 requirements, and the unsigned-executable notice. Do not upload `Untrace-data` or test output.
Generate the checksum after building:
```powershell
$asset = 'release/Untrace-Desktop-1.6.1-portable-x64.exe'
$hash = (Get-FileHash -LiteralPath $asset -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $(Split-Path -Leaf $asset)" | Set-Content -Encoding ascii release/SHA256SUMS.txt
```
README.md links to the latest releases on both hosts. Publish identical assets and source on both hosts.
+107 -55
View File
@@ -1,80 +1,132 @@
# Untrace <div align="center">
**Remove content credentials from an image.** # Untrace Desktop
Untrace strips C2PA content credentials, AI generation tags, GPS coordinates **Inspect image metadata. Save a cleaner copy. Keep it local.**
and EXIF metadata from a photo. It runs entirely in the browser — the file is
read and rewritten in the tab, never uploaded.
<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](https://img.shields.io/badge/Windows-x64-0078D4)](#run)
[![Version](https://img.shields.io/badge/Version-1.6.1-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)
|---|---|
| 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 |
Supported input: JPEG, PNG, TIFF, HEIC, AVIF. Output is JPEG, or PNG when the </div>
original was a small PNG.
## How it works 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.
Detection and removal are two separate passes. ## Screenshots
**Detection** (`src/lib/metadata.js`) reads the file twice: once through Metadata comparison using a generated test image (captured in version 1.6.1).
[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.
**Removal** (`src/lib/clean.js`) decodes the image to pixels on a canvas and ![Original and cleaned image metadata comparison](docs/screenshots/metadata-comparison.png)
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.
If the browser cannot decode or re-encode the file, the original bytes are ## Run
returned and the interface says so — it never reports a clean that did not
happen.
## Design 1. Put `Untrace-Desktop-1.6.1-portable-x64.exe` in a writable folder or USB drive and double-click it.
2. Choose **Open image**, drop a file anywhere in the workspace, or paste an image.
3. Review the **Inspector**, choose an export format, and select **Clean image**.
4. Compare **Original** and **Cleaned copy**, then choose **Save clean image**.
The interface follows the Requires 64-bit Windows 10 or 11. No installation, administrator rights, Node.js, browser installation or internet is required. The EXE is unsigned; Windows may show an unknown-publisher or SmartScreen prompt. Initial launch extracts the bundled runtime to a temporary folder.
[RealBrain Design System](https://realbrain.cc/design-system/) v2.0. Tokens are
vendored at `src/tokens.css`; refresh with:
```bash 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.
curl -o src/tokens.css https://realbrain.cc/design-system/tokens.css
| 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 |
## Batch cleaning
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.
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.
**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
Preferences and Chromium session files live in `Untrace-data` beside the executable. 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. Dependency downloads require internet access.
```powershell
npm ci
npm test
npm run portable
node scripts/smoke-portable.mjs
``` ```
`AGENTS.md` carries the rules, the self-check commands, and this repository's The EXE is written to `release/`. `release/win-unpacked/Untrace Desktop.exe` is the alternative folder-based build; keep all its files together. `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.
open token proposals. Read it before touching any CSS.
## Development To run the full suite against the packaged application:
```bash ```powershell
npm install $env:UNTRACE_TEST_EXE = (Resolve-Path 'release/win-unpacked/Untrace Desktop.exe').Path
npm run dev # vite dev server npx playwright test
npm run build # production build to dist/ Remove-Item Env:UNTRACE_TEST_EXE
npm run preview # serve the build
``` ```
Stack: Svelte 4, Vite 5, exifr. No CSS framework — the design system is plain 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.
CSS.
## 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 See [CHANGELOG.md](CHANGELOG.md) for dated changes and [LICENSE](LICENSE) for the original license. Electron licenses accompany the runtime.
selling stock, and anyone who would rather a photo not carry its own history.
## 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 ## 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"
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+54
View File
@@ -0,0 +1,54 @@
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);
// Keep preferences alongside the portable executable, including on USB drives.
const home = process.env.PORTABLE_EXECUTABLE_DIR || path.dirname(app.getPath('exe'));
const data = process.env.UNTRACE_DATA_DIR || path.join(home, 'Untrace-data');
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),
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+6 -255
View File
@@ -2,267 +2,18 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Remove content credentials from an image – free online remover | Untrace</title>
<meta
name="description"
content="Free tool to remove C2PA content credentials, AI generation tags and EXIF metadata from an image. Runs in your browser, so the photo is never uploaded. Works with 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="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" /> <title>Untrace Desktop · Image cleaner</title>
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" /> <meta name="description" content="An on-device image metadata workspace. Inspect, clean and save a separate copy." />
<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>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" sizes="16x16" href="/favicon-16x16.svg" /> <script>
<link rel="icon" type="image/svg+xml" sizes="32x32" href="/favicon-32x32.svg" /> /* theme-bootstrap */
<link rel="apple-touch-icon" href="/favicon.svg" />
<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 content credentials from an image" />
<meta
property="og:description"
content="Free browser tool that strips C2PA content credentials, AI generation tags and EXIF 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 content credentials from an image" />
<meta
name="twitter:description"
content="Free browser tool that strips C2PA content credentials, AI generation tags and EXIF 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": "Remove C2PA content credentials, AI generation tags and EXIF metadata from images 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": "https://untrace.realbrain.cc/favicon.svg",
"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 removes C2PA content credentials, AI generation tags, GPS coordinates and EXIF metadata from images without uploading them.",
"featureList": [
"Remove C2PA content credentials",
"Remove AI generation tags from ChatGPT, DALL-E, Midjourney, Stable Diffusion, Adobe Firefly and Google Imagen",
"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"
]
},
{
"@type": "HowTo",
"@id": "https://untrace.realbrain.cc/#howto",
"name": "How to remove content credentials from an image",
"description": "Strip C2PA content credentials and AI generation tags from a photo in three steps, without uploading the file.",
"totalTime": "PT1M",
"tool": [
{
"@type": "HowToTool",
"name": "Untrace"
}
],
"step": [
{
"@type": "HowToStep",
"position": 1,
"name": "Add the image",
"text": "Drop a JPEG, PNG, TIFF, HEIC or AVIF file onto Untrace, choose one from your device, or paste it from the clipboard.",
"url": "https://untrace.realbrain.cc/#tool"
},
{
"@type": "HowToStep",
"position": 2,
"name": "Review what is hidden",
"text": "Untrace lists every metadata block it finds, including C2PA content credentials, AI generation tags, GPS coordinates, camera details and timestamps.",
"url": "https://untrace.realbrain.cc/#tool"
},
{
"@type": "HowToStep",
"position": 3,
"name": "Clean and download",
"text": "Choose Clean image. The photo is decoded to pixels and written back out, leaving every metadata container behind, then download the clean copy.",
"url": "https://untrace.realbrain.cc/#tool"
}
]
},
{
"@type": "FAQPage",
"@id": "https://untrace.realbrain.cc/#faq",
"mainEntity": [
{
"@type": "Question",
"name": "What are content credentials on an image?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Content credentials are a signed record of how an image was made and edited, written into the file by the C2PA standard. They can name the tool that generated the image, list every edit, and identify the account that created it. Platforms read that record to show an “AI info” or “Content credentials” label next to the picture."
}
},
{
"@type": "Question",
"name": "How do I remove content credentials from an image?",
"acceptedAnswer": {
"@type": "Answer",
"text": "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."
}
},
{
"@type": "Question",
"name": "Does this remove the AI label on LinkedIn?",
"acceptedAnswer": {
"@type": "Answer",
"text": "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."
}
},
{
"@type": "Question",
"name": "Does it remove the AI tag on Instagram or Facebook?",
"acceptedAnswer": {
"@type": "Answer",
"text": "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."
}
},
{
"@type": "Question",
"name": "What is C2PA and JUMBF?",
"acceptedAnswer": {
"@type": "Answer",
"text": "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."
}
},
{
"@type": "Question",
"name": "Is my image uploaded anywhere?",
"acceptedAnswer": {
"@type": "Answer",
"text": "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."
}
},
{
"@type": "Question",
"name": "Does cleaning reduce image quality?",
"acceptedAnswer": {
"@type": "Answer",
"text": "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."
}
},
{
"@type": "Question",
"name": "Which formats are supported?",
"acceptedAnswer": {
"@type": "Answer",
"text": "JPEG, PNG, TIFF, HEIC and AVIF. The cleaned file is returned as JPEG, or as PNG when the original was a small PNG."
}
},
{
"@type": "Question",
"name": "Does it also remove EXIF and GPS data?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. EXIF, GPS, XMP, IPTC, Photoshop blocks, maker notes and embedded thumbnails all go at the same time as the content credentials, because the whole metadata section is dropped rather than edited."
}
}
]
}
]
}
</script> </script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<noscript> <noscript>Untrace Desktop needs JavaScript to process images on this device.</noscript>
<p style="padding: 24px; font-family: system-ui, sans-serif">
Untrace removes C2PA content credentials, AI generation tags and EXIF
metadata from images. It reads and rewrites the file inside your
browser, so JavaScript has to be enabled for it to work.
</p>
</noscript>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>
+3677 -7
View File
File diff suppressed because it is too large. Load diff
+68 -7
View File
@@ -1,23 +1,84 @@
{ {
"name": "untrace", "name": "untrace-desktop",
"description": "Remove C2PA content credentials, AI generation tags and EXIF metadata from images, entirely in the browser.", "description": "Offline image metadata cleaner for Windows with a dedicated desktop workspace.",
"version": "1.0.0", "version": "1.6.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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 && electron-builder --win portable --x64",
"test": "npm run build:desktop && playwright test"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"electron": "^44.3.0",
"electron-builder": "^26.15.3",
"gh-pages": "^6.1.0", "gh-pages": "^6.1.0",
"lightningcss": "^1.30.1",
"svelte": "^4.2.0", "svelte": "^4.2.0",
"vite": "^5.0.0", "vite": "^5.0.0"
"lightningcss": "^1.30.1"
}, },
"dependencies": { "dependencies": {
"exifr": "^7.1.3" "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-x64.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"
}
]
},
"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 -1
View File
@@ -1,4 +1,4 @@
<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"/> <rect width="48" height="48" rx="10" fill="#3450C8"/>
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace" <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> font-size="28" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 355 B

+1 -1
View File
@@ -1,4 +1,4 @@
<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"/> <rect width="48" height="48" rx="10" fill="#3450C8"/>
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace" <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> font-size="28" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 355 B

+4 -4
View File
@@ -1,5 +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"/> <rect width="48" height="48" rx="10" fill="#121216"/>
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace" <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"/>
font-size="28" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text> <path d="m18.5 24 4 4 7-7" fill="none" stroke="#8FA8FF" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 433 B

+8 -4
View File
@@ -1,8 +1,8 @@
{ {
"id": "/", "id": "/",
"name": "Untrace — content credentials and metadata remover", "name": "Untrace Desktop",
"short_name": "Untrace", "short_name": "Untrace Desktop",
"description": "Remove C2PA content credentials, AI generation tags and EXIF metadata from images, entirely in your browser.", "description": "Inspect and clean image metadata in an offline desktop workspace.",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
@@ -22,7 +22,11 @@
"purpose": "maskable" "purpose": "maskable"
} }
], ],
"categories": ["productivity", "utilities", "photo"], "categories": [
"productivity",
"utilities",
"photo"
],
"lang": "en", "lang": "en",
"dir": "ltr", "dir": "ltr",
"orientation": "any" "orientation": "any"
+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, ''));
}
+31
View File
@@ -0,0 +1,31 @@
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 artifact = build.portable.artifactName.replace('${version}', version);
const processHandle = spawn(path.resolve('release', artifact), ['--remote-debugging-port=19384'], { env, windowsHide: true, stdio: 'ignore' });
let browser;
try {
for (let attempt = 0; attempt < 120; attempt++) {
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: 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 });
}
+5 -123
View File
@@ -1,126 +1,8 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from 'svelte';
import { theme } from "./lib/theme.js"; import { theme } from './lib/theme.js';
import TopBar from "./lib/TopBar.svelte"; import Workspace from './lib/Workspace.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";
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);
}
onMount(() => theme.init()); onMount(() => theme.init());
</script> </script>
<TopBar /> <Workspace />
<main>
<div class="wrap">
<section class="lede">
<p class="eyebrow">C2PA · AI credentials · EXIF</p>
<h1>Remove content credentials from an image</h1>
<p>
Untrace strips C2PA content credentials, AI generation tags, GPS
coordinates and EXIF metadata from a photo — free, and without the file
ever leaving your device.
</p>
<div class="chip-row">
<span class="chip">Free</span>
<span class="chip">No sign-in</span>
<span class="chip">Open source</span>
</div>
</section>
<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 />
<Faq />
</div>
</div>
</main>
<Footer />
<Toast {toasts} {dismiss} />
<style>
main {
padding-bottom: var(--space-8);
}
.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>
+198 -639
View File
@@ -1,640 +1,199 @@
/* ========================================================================== @import './tokens.css';
Untrace — application stylesheet @import './themes.css';
==========================================================================
Design system: RealBrain v2.0. Tokens come from ./tokens.css (vendored, /* Existing project proposal: one-pixel hairlines; breakpoints at 560/768/900. */
never edited by hand). Everything below composes those tokens only. :root { --border-w: 1px; }
Refresh the vendored copy with: html, body, #app { height: 100%; }
curl -o src/tokens.css https://realbrain.cc/design-system/tokens.css body { overflow: hidden; }
========================================================================== */ button, select, summary, a { -webkit-app-region: no-drag; }
a.btn:hover { text-decoration: none; }
@import "./tokens.css"; .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); }
Values the design system has no token for yet. Flagged for the system .brand-mark { display: grid; place-items: center; height: var(--space-6); color: var(--text-1); }
owners rather than sprinkled around as raw values: .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); }
NEW TOKEN --border-w: 1px — hairline width, used by every surface .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); }
NEW TOKEN --topbar-h: 56px — sticky product bar height .app-heading { display: flex; align-items: center; gap: var(--space-4); min-width: 0; }
(derived: --space-7 + --space-2) .app-header, .app-rail { color: var(--text-1); }
NEW TOKEN breakpoints — 560px / 768px / 900px .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); }
Breakpoints cannot be custom properties: a media query is evaluated before .header-divider { height: var(--space-4); border-left: var(--border-w) solid var(--border-strong); }
the cascade, so var() is not available there. Until the system ships .local-badge { display: flex; align-items: center; gap: var(--space-2); font-size: var(--fs-1); color: var(--text-3); }
@custom-media or a documented scale, every file in this app uses exactly .editor { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
these three widths and no others. .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); }
:root { .toolbar-file svg { flex: none; }
--border-w: 1px; .toolbar-file span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
--topbar-h: calc(var(--space-7) + var(--space-2)); .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); }
/* An <a> carrying .btn is a button, not prose: the base underline-on-hover .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); }
from tokens.css does not belong on it. */ .image-emblem > svg { width: var(--space-6); height: var(--space-6); }
a.btn:hover { .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); }
text-decoration: none; .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); }
Layout .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); }
.wrap { .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); }
width: 100%; .format-hint span { color: var(--border-strong); }
max-width: var(--max-marketing); .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); }
margin-inline: auto; .workspace-note svg { flex: none; }
padding-inline: var(--space-4); .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); }
@media (min-width: 768px) { .view-switch, .zoom-controls { display: flex; align-items: center; gap: var(--space-1); }
.wrap { /* State indicator sits on the wrapper, retaining the shared button styles. */
padding-inline: var(--space-6); .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); }
.stack { .workspace-image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; margin: auto; }
display: grid; .actual-size .workspace-image { max-width: none; max-height: none; flex-shrink: 0; }
gap: var(--space-7); .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); }
@media (min-width: 768px) { .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; }
.stack { .drop-overlay h2 { font-size: var(--fs-5); font-weight: 500; }
gap: var(--space-9); .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); }
Product bar .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); }
.topbar { .section-label { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); margin-bottom: var(--space-4); color: var(--text-3); }
position: sticky; .section-label h3 { color: var(--text-2); font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; }
top: 0; .source-placeholder { display: flex; align-items: center; gap: var(--space-3); color: var(--text-3); }
z-index: 20; .source-placeholder p { font-size: var(--fs-2); color: var(--text-2); }
height: var(--topbar-h); .source-placeholder span { display: block; font-size: var(--fs-1); color: var(--text-3); }
display: flex; .source-name { font-size: var(--fs-2); font-weight: 500; overflow-wrap: anywhere; margin-bottom: var(--space-3); }
align-items: center; .properties { display: grid; gap: var(--space-2); font-size: var(--fs-1); }
background: var(--bg); .properties > div, .shortcut-list > div { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
border-bottom: var(--border-w) solid var(--border); .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); }
.topbar .inner { .coverage-list { list-style: none; display: grid; gap: var(--space-2); }
display: flex; .coverage-list li { display: flex; align-items: center; gap: var(--space-3); font-size: var(--fs-2); color: var(--text-3); }
align-items: center; .count-badge { font-size: var(--fs-1); color: var(--text-3); }
justify-content: space-between; .metadata-list { display: grid; gap: var(--space-4); list-style: none; }
gap: var(--space-3); .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); }
.wordmark { .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); }
display: inline-flex; .scan-note { display: flex; gap: var(--space-2); font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
align-items: center; .scan-note svg { flex: none; margin-top: var(--space-1); }
gap: var(--space-3); .export-fields { display: grid; gap: var(--space-4); }
color: var(--text-1); .export-hint { font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
font-size: var(--fs-3); .inspector-action { display: grid; gap: var(--space-3); padding: var(--space-5); border-top: var(--border-w) solid var(--border); }
line-height: var(--lh-3); .inspector-action p { font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-3); }
font-weight: 600; .inspector-action .output-name { overflow-wrap: anywhere; color: var(--text-2); }
letter-spacing: -0.01em; .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); }
.wordmark:hover { .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); }
text-decoration: none; .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); }
Section headings .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; }
.eyebrow { .help-content > p { font-size: var(--fs-2); line-height: var(--lh-3); color: var(--text-2); }
font-family: var(--font-mono); .shortcut-list { display: grid; gap: var(--space-2); font-size: var(--fs-2); color: var(--text-2); }
font-size: var(--fs-1); .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); }
line-height: var(--lh-1); @media (max-width: 900px) {
color: var(--accent); .desktop-app { grid-template-columns: var(--space-8) minmax(0, 1fr) calc(var(--space-9) * 3); }
letter-spacing: 0.1em; .editor-toolbar { padding-inline: var(--space-3); }
text-transform: uppercase; .image-workspace { margin-inline: var(--space-3); }
} .welcome { padding: var(--space-4); }
.welcome h2 { font-size: var(--fs-6); line-height: var(--lh-6); }
.section-head { .desktop-break { display: none; }
display: grid; .statusbar-tip { display: none; }
gap: var(--space-2); }
margin-bottom: var(--space-5); @media (max-width: 768px) {
max-width: calc(var(--space-9) * 6); 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; }
.section-head h2 { .editor { grid-column: 2; min-height: calc(var(--space-9) * 6); }
font-size: var(--fs-6); .image-workspace { min-height: calc(var(--space-9) * 4); }
line-height: var(--lh-6); .preview-viewport { height: calc(var(--space-9) * 4); flex: none; }
font-weight: 600; .inspector { grid-column: 2; border-left: 0; border-top: var(--border-w) solid var(--border); }
letter-spacing: -0.01em; .inspector-scroll { overflow: visible; }
} .app-statusbar { grid-column: 2; }
.local-badge { display: none; }
.section-head p { .app-heading { gap: var(--space-3); }
color: var(--text-2); }
font-size: var(--fs-3);
line-height: var(--lh-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); }
@media (min-width: 768px) { .theme-choices { border: 0; min-width: 0; display: grid; gap: var(--space-4); }
.section-head h2 { .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; }
font-size: var(--fs-7); .system-choice > span { flex: 1; }
line-height: var(--lh-7); .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); }
Hero .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); }
.lede { .theme-choice-label input, .system-choice input { flex: none; }
display: grid; .theme-name { display: block; font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; color: var(--text-1); }
gap: var(--space-4); .theme-description { display: block; font-size: var(--fs-1); line-height: var(--lh-1); color: var(--text-3); }
justify-items: start; .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; }
padding-block: var(--space-7) var(--space-6); .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); }
.lede h1 { .mini-image { display: grid; place-items: center; color: var(--text-2); }
font-size: var(--fs-7); .mini-inspector { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-1); background: var(--surface); }
line-height: var(--lh-7); .mini-inspector > span { height: var(--space-1); background: var(--border-strong); border-radius: var(--radius-sm); }
font-weight: 600; .mini-inspector .mini-action { margin-top: auto; height: var(--space-2); background: var(--accent); }
letter-spacing: -0.02em; .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); }
max-width: calc(var(--space-9) * 8); .appearance-note svg { flex: none; }
} .appearance-footer { display: flex; justify-content: flex-end; }
.lede p { /* Batch queue and inspection use the same semantic palette as the workspace. */
color: var(--text-2); .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; }
font-size: var(--fs-4); .batch-heading { display: grid; gap: var(--space-2); padding: var(--space-5); border-bottom: var(--border-w) solid var(--border); }
line-height: var(--lh-4); .batch-heading h2 { font-size: var(--fs-5); line-height: var(--lh-5); font-weight: 500; }
max-width: calc(var(--space-9) * 6); .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); }
@media (min-width: 768px) { .batch-row.selected { background: var(--bg); }
.lede { .batch-file { min-width: 0; justify-content: flex-start; }
padding-block: var(--space-8) var(--space-7); .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; }
.lede h1 { .batch-progress { padding: var(--space-3) var(--space-5); border-top: var(--border-w) solid var(--border); }
font-size: var(--fs-8); .batch-detail { overflow-wrap: anywhere; font-size: var(--fs-1); line-height: var(--lh-2); color: var(--text-2); margin-block: var(--space-3); }
line-height: var(--lh-8); .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; }
Chips — neutral mono labels for formats and counts .size-preview { display: grid; gap: var(--space-3); margin-top: var(--space-4); }
-------------------------------------------------------------------------- */ .comparison-dialog { max-height: calc(100dvh - var(--space-6)); overflow: hidden; }
.chip { .comparison-content { display: flex; flex-direction: column; gap: var(--space-4); max-height: calc(100dvh - var(--space-9)); }
display: inline-flex; .comparison-content .overline { overflow-wrap: anywhere; }
align-items: center; .comparison-scroll { overflow: auto; min-height: 0; }
gap: var(--space-2); .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; }
padding: var(--space-1) var(--space-3); .comparison-scroll th { font-weight: 500; }
border: var(--border-w) solid var(--border); @media (max-width: 768px) {
border-radius: var(--radius-full); .editor-toolbar { flex-wrap: wrap; }
background: var(--surface); .batch-workspace { min-height: calc(var(--space-9) * 3); max-height: calc(var(--space-9) * 5); }
color: var(--text-2); .batch-row { grid-template-columns: minmax(0, 1fr) auto; }
font-family: var(--font-mono); .batch-size { display: none; }
font-size: var(--fs-1); .processing-overlay { max-width: calc(100% - var(--space-4)); flex-wrap: wrap; justify-content: center; white-space: normal; }
line-height: var(--lh-1); }
white-space: nowrap;
} .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; }
.chip-row { .theme-choices { overflow-y: auto; padding: var(--space-1); scrollbar-gutter: stable; }
display: flex; .theme-group { display: grid; gap: var(--space-3); }
flex-wrap: wrap; .theme-group-title { font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; color: var(--text-2); }
gap: var(--space-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)); }
/* --------------------------------------------------------------------------
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;
} }
+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>
+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} />
+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>
+6
View File
@@ -7,6 +7,12 @@
export let size = "md"; export let size = "md";
const paths = { 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: upload:
"M12 16V4m0 0L8 8m4-4 4 4M4 15v2a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-2", "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", 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",
+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"> <div class="side">
<p class="cap"> <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> </p>
<figure class="preview"> <figure class="preview">
<img src={result.url} alt="Result, after cleaning" /> <img src={result.url} alt="Result, after cleaning" />
@@ -60,11 +60,13 @@
</p> </p>
{/if} {/if}
{#if !result.fallback}
<ul class="removed"> <ul class="removed">
{#each removed as item} {#each removed as item}
<li><span class="status ok">{item}</span></li> <li><span class="status ok">{item}</span></li>
{/each} {/each}
</ul> </ul>
{/if}
</div> </div>
</div> </div>
+1 -1
View File
@@ -57,7 +57,7 @@
} }
async function clean() { async function clean() {
if (!file) return; if (!file || stage === "cleaning") return;
stage = "cleaning"; stage = "cleaning";
result = await cleanImage(file); result = await cleanImage(file);
stage = "done"; stage = "done";
+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; const PNG_PIXEL_LIMIT = 1000000;
function loadImage(source) { function loadImage(source) {
if (typeof document === 'undefined') return createImageBitmap(source).then(img => ({ img }));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = URL.createObjectURL(source); const url = URL.createObjectURL(source);
const img = new Image(); const img = new Image();
@@ -33,11 +34,12 @@ function loadImage(source) {
function redraw(img, { type, quality, fillWhite = false }) { function redraw(img, { type, quality, fillWhite = false }) {
return new Promise((resolve, reject) => { 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.width = img.width;
canvas.height = img.height; canvas.height = img.height;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas is unavailable.");
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
// A white base stops transparent areas turning black when the second pass // 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.globalCompositeOperation = "source-over";
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
if (canvas.convertToBlob) {
canvas.convertToBlob({ type, quality }).then(resolve, reject);
return;
}
canvas.toBlob( canvas.toBlob(
(blob) => (blob) =>
blob blob
@@ -71,7 +77,12 @@ async function stillHasMetadata(blob) {
gps: true, gps: true,
xmp: true, xmp: true,
iptc: 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, silentErrors: true,
}); });
return Boolean(remaining && Object.keys(remaining).length > 0); return Boolean(remaining && Object.keys(remaining).length > 0);
@@ -83,8 +94,8 @@ async function stillHasMetadata(blob) {
} }
function outputName(originalName, blobType) { function outputName(originalName, blobType) {
const extension = originalName.split(".").pop(); const extension = originalName.includes(".") ? originalName.split(".").pop() : "";
const base = originalName.replace(new RegExp(`\\.${extension}$`, "i"), ""); const base = originalName.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 180) || 'image';
let finalExtension = extension; let finalExtension = extension;
if (blobType === "image/jpeg") finalExtension = "jpg"; 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 * `fallback: true` means the image could not be re-encoded and the original
* bytes are being returned untouched. The caller must not claim success. * 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 objectUrl;
let bitmap;
try { try {
const { img, url } = await loadImage(file); const { img, url } = await loadImage(file);
objectUrl = url; objectUrl = url;
bitmap = img;
const keepPng = const keepPng = format === 'image/png' || (format === 'auto' &&
file.type === "image/png" && img.width * img.height < PNG_PIXEL_LIMIT; (file.type === "image/png" || /\.png$/i.test(file.name)) && img.width * img.height < PNG_PIXEL_LIMIT);
let blob = await redraw(img, { let blob = await redraw(img, {
type: keepPng ? "image/png" : "image/jpeg", 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; let wasSecondPass = false;
@@ -125,7 +139,8 @@ export async function cleanImage(file) {
}); });
wasSecondPass = true; wasSecondPass = true;
} finally { } 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 }); return result(file, file, { wasSecondPass: false, fallback: true });
} finally { } finally {
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
bitmap?.close?.();
} }
} }
function result(blob, originalFile, { wasSecondPass, fallback }) { function result(blob, originalFile, { wasSecondPass, fallback }) {
return { return {
blob,
url: URL.createObjectURL(blob), url: URL.createObjectURL(blob),
filename: outputName(originalFile.name, blob.type), filename: fallback ? originalFile.name : outputName(originalFile.name, blob.type),
size: blob.size, size: blob.size,
originalSize: originalFile.size, originalSize: originalFile.size,
sizeDelta: 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. */ }
}
+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"; export const ACCEPTED_EXTENSIONS = ".jpg,.jpeg,.png,.tiff,.tif,.heic,.avif";
/** Sections that describe the result rather than holding metadata values. */ /** 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 = [ const GPS_FIELDS = [
"latitude", "latitude",
@@ -159,7 +159,8 @@ const AI_VALUE_KEYWORDS = [
]; ];
export function isAccepted(file) { 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) { function pick(source, fields) {
@@ -178,7 +179,8 @@ export async function extractMetadata(file) {
try { try {
const { parse } = await import("exifr"); const { parse } = await import("exifr");
const exifData = await parse(file, { let exifData, scanError;
try { exifData = await parse(file, {
tiff: true, tiff: true,
exif: true, exif: true,
gps: true, gps: true,
@@ -190,6 +192,9 @@ export async function extractMetadata(file) {
iptc: true, iptc: true,
icc: true, icc: true,
ihdr: true, ihdr: true,
text: true,
itxt: true,
ztxt: true,
jfif: true, jfif: true,
chunked: true, chunked: true,
firstChunkSize: 131072, firstChunkSize: 131072,
@@ -223,49 +228,22 @@ export async function extractMetadata(file) {
skip: [], 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( const rawMetadata = await extractRawMetadata(
new Uint8Array(buffer), new Uint8Array(buffer),
file.type file.type
); );
let allMetadata = { ...(exifData || {}), ...rawMetadata }; const 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 */
}
}
if (Object.keys(allMetadata).length === 0) { 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) { } catch (error) {
return cleanResult(file, error); return cleanResult(file, error);
} }
@@ -274,23 +252,12 @@ export async function extractMetadata(file) {
function cleanResult(file, error) { function cleanResult(file, error) {
const isPng = file.type.includes("png"); const isPng = file.type.includes("png");
const isJpeg = file.type.includes("jpeg") || file.type.includes("jpg"); const isJpeg = file.type.includes("jpeg") || file.type.includes("jpg");
let message; const message = error ? 'Metadata could not be fully read. Results may be incomplete.' : 'No recognized metadata found. This is not proof that none exists.';
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.";
}
return { return {
fileType: isPng ? "png" : isJpeg ? "jpeg" : "other", fileType: isPng ? "png" : isJpeg ? "jpeg" : "other",
hasMetadata: false, hasMetadata: false,
...(error ? { scanError: message } : {}),
message, message,
}; };
} }
@@ -300,6 +267,7 @@ function categorise(allMetadata, rawMetadata) {
const keys = Object.keys(allMetadata); const keys = Object.keys(allMetadata);
const c2paFields = keys.filter((key) => const c2paFields = keys.filter((key) =>
!(key in rawMetadata) && !key.toLowerCase().includes('profile') &&
C2PA_KEY_HINTS.some((hint) => key.toLowerCase().includes(hint)) C2PA_KEY_HINTS.some((hint) => key.toLowerCase().includes(hint))
); );
assign(metadata, "C2PA/AI Provenance", pick(allMetadata, c2paFields)); 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; 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"; export { THEMES };
const STORAGE_KEY = 'untrace-theme'; // Keep existing portable preferences.
/** const valid = choice => choice === 'system' || THEMES.some(item => item.id === choice);
* 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");
}
}
function createTheme() { 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 { return {
subscribe, subscribe,
init() { init() {
const theme = preferredTheme(); media = matchMedia('(prefers-color-scheme: light)');
apply(theme); apply(bootstrapTheme(THEMES));
set(theme); const onSystemChange = () => { if (current === 'system') apply('system'); };
}, media.addEventListener('change', onSystemChange);
toggle() { return () => media.removeEventListener('change', onSystemChange);
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 */
}
}, },
select,
// Retained for the unused upstream toggle component.
toggle() { select(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'); },
}; };
} }
export const theme = createTheme(); export const theme = createTheme();
/** Duration helper so Svelte transitions honour prefers-reduced-motion. */
export function motionSafe(ms) { export function motionSafe(ms) {
if (typeof window === "undefined") return ms; if (typeof window === 'undefined') return ms;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms; return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : ms;
} }
+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;
}
+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 });
}
});
+14 -1
View File
@@ -1,10 +1,23 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelte } from '@sveltejs/vite-plugin-svelte'
import { readFileSync } from 'node:fs'
import { THEMES, bootstrapTheme } from './src/lib/theme-catalog.js'
const packageInfo = JSON.parse(readFileSync('./package.json', 'utf8'))
const brand = JSON.parse(readFileSync('./desktop/brand.json', 'utf8'))
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ 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: '/', base: '/',
worker: { format: 'es' },
build: { build: {
outDir: 'dist', outDir: 'dist',
assetsDir: 'assets', assetsDir: 'assets',