Ctrl K
ComunidadGuías…

CLAUDE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# CLAUDE.md

Guidance for Claude Code (claude.ai/code) working in this repository.

## Project overview

FileMinify compresses and converts files, and merges anything printable into a
single PDF. React 19 + TypeScript frontend, Node/Express backend shelling out
to FFmpeg, Ghostscript, ImageMagick and LibreOffice, all in Docker behind nginx.

This repo began as a redesign of the first FileMinify, which is now retired
(its repository was deleted on 2026-09-23). History up to `f4d6b79` is that
project's; everything after is the rework. Its last fixes (rate limiter,
upload holes, smoke suite) live on here as `d96fa4c`, `53673ab` and `3f95c5c`.

The history was rewritten once, on 2026-09-23, before the repo went public: a
personal document committed by mistake early on was removed from every commit
and author emails were normalised. Commit IDs before that date differ from any
older clone or PR link. Security reports: see SECURITY.md.

## Running it

**This project runs in Docker Compose. Do not run npm on the host — Node is not
installed there.**

```bash
docker compose up -d --build            # everything
docker compose up -d --build frontend   # after a frontend change
docker compose up -d --build backend    # after a backend change
docker compose logs --tail=30 backend
```

App on **http://localhost:3051**, API on **4001**. The offset from 3050/4000
dates from running beside the first version; the ports stay as they are, since
deployments and bookmarks use them.

The frontend image build runs `tsc -b && eslint . && vite build`, so a clean build
is the type check and lint. It must be `tsc -b`: the root tsconfig.json is a
solution file (`"files": []` plus references), and plain `tsc` checks zero files
- it did, silently, until the build was fixed. No test framework is configured.

Lint is ESLint 10 with react-hooks 7, whose recommended set includes the React
Compiler rules: render must be pure (no `Date.now()` or other impure calls while
rendering) and state isn't set straight inside an effect. To follow a prop, keep
its previous value in state and adjust during render (see `FileRow`'s `wasBusy`
and `AdjustPanel`'s `followed`); errors fail the build.

On React 19 a component takes `ref` as an ordinary prop (see `ui/button.tsx`,
which Radix's `asChild` passes a ref through); don't reach for `forwardRef`.

## Architecture

### Frontend

- `src/processing.ts` — the decision layer. `routeFor` decides compression vs
  conversion, `requestBodyFor` turns a tier into what each backend route actually
  understands, plus staleness and output naming.
- `src/formats.ts` — the single source of truth for which target formats each
  source type supports. Mirrors the branches in `backend/routes/conversion.js`.
- `src/components/file-processor/FileProcessor.tsx` — orchestrator. Per-file XHR
  upload with real progress, two concurrency lanes (3 general, 1 for LibreOffice
  work), rerun, download-all.
- `FileRow` / `AdjustPanel` / `DropZone` / `TierControl` / `MergeDialog` — the UI.
- `src/lib/api.ts` (fetch wrappers), `src/lib/format.ts` (byte and percentage
  formatting), `src/hooks/useFiles.ts` (zustand store), `src/types.ts`.

### Backend

- `server.js` — Express setup, `/health`, `/config` (upload limits for the
  frontend), and an hourly sweep deleting temp files
  and stale `merge-*` scratch directories older than an hour.
- `routes/upload.js` — Multer, `<uuid><ext>` naming, MIME allowlist.
- `routes/compression.js` — Sharp (images), Ghostscript (PDF), FFmpeg (video).
- `routes/conversion.js` — between formats, using the shared converters.
- `routes/merge.js` — ordered merge into one PDF via pdf-lib.
- `utils/converters.js` — `toPdf` plus `PDF_SOURCE_EXTS`, shared by conversion and
  merge, and the LibreOffice lock.
- `utils/run.js` — every external command goes through `run(name, args)`, which
  asks `utils/tools.js` for the binary. In Docker a name is itself; on Windows
  it is found on PATH, in winget's folders or in the usual install folders.
  It remembers each running child, so `killAll()` can stop them (the whole
  tree on Windows) when the desktop app quits.
- `utils/desktop.js` — the desktop app's messages over `process.parentPort`
  (see "Desktop app"); does nothing without it.
- `utils/paths.js` — `TEMP_DIR`, the one place uploads and results live.

### Windows build (`desktop/`, `packaging/winget/`)

The same backend runs natively, serving the built frontend itself (and the API
under `/api`, as nginx would) when `FM_STATIC_DIR` is set. The desktop app's
main process sets that and the other `FM_*` variables (data dir,
`FM_HOST=127.0.0.1`, `FM_TRUST_PROXY=0`), with overrides from
`%LOCALAPPDATA%\FileMinify\settings.env` (see "Desktop app").
- **Phone access** (listening beyond `127.0.0.1`) is switched in the app
  (`setPhoneAccess` over the bridge, from the phone panel) or in the tray.
  Main saves `FM_HOST` in settings.env, restarts the server and reloads the
  window with `/?phone`.
- **The phone panel.** In native mode `/config` gives the PC itself (never
  another machine) a `phone` block: `enabled`, plus the addresses from
  `utils/network.js` (virtual adapters left out, the default route's
  first). The header's "Use on phone" button and `PhoneDialog` show it as a
  QR code (`uqr`), asked afresh on every opening.
  `e2e/tests/phone.spec.ts` fakes that block, since Docker never sends it.
- **Closing the window** quits, unless phone access is on: then it hides to
  the tray, whose Quit stops it.
- **Tools.** The oneClick installer asks nothing; the app offers the missing
  tools on its first start (winget in a visible console:
  `installTools()` / `missingTools()` in `utils/tools.js`, asked for over
  `process.parentPort`).
- **No HTTP endpoint starts a program.** 1.0.x had `routes/native.js`
  (`/native/update`, `/native/tools`) and `utils/update.js`; 2.0 removed
  them. The update is electron-updater's.
- **Notices and logs.** The app shows update and tools notices
  (`Notices.tsx`); "Later" and "Skip" live in localStorage
  (`lib/updateNotice.ts`) while the header's "Update available" stays. Logs
  go to `%LOCALAPPDATA%\FileMinify\logs` (fileminify.log from the backend,
  desktop.log from main), opened from the tray. The UI reaches all of this
  through the bridge, which `e2e/tests/native.spec.ts` fakes.
`desktop/build.sh` stages the backend (with its own production node_modules),
the built app and Ghostscript, and electron-builder makes the per-user NSIS
installer (`desktop/electron-builder.yml`, with `desktop/build/installer.nsh`;
see "Desktop app"). The winget manifest pulls in
FFmpeg, ImageMagick, LibreOffice and the VC++ runtime. Ghostscript is bundled
because winget has none (its installer is interactive-only). Without any
`FM_*` variable set, nothing about the Docker setup changes.

What nginx did and native mode now does itself:
- **Host check against DNS rebinding.** Only `localhost`, the PC's own name
  and plain IP addresses are answered, since Origin == Host alone passes a
  rebound name.
- **Upload timeout.** Node's `requestTimeout` is raised to the job timeout,
  because a phone's upload now streams straight into Node.
- **`X-Frame-Options: DENY`** and nginx's CSP.

### Desktop app (2.0, released 2026-09-27 as v2.0.0: `desktop/`, replaced 1.0.x's `windows/`)

Electron 44.4.5 (Node 24), electron-builder 26.16.1, electron-updater 6.8.9,
pinned exactly. This is the contract the pieces are built against:

- **Layout.**
  - The asar holds only `desktop/main.cjs`, `preload.cjs`, `lib/*.cjs` and
    `lib/assets/` (the tray icon, 16 px plus `@1.5x`/`@2x`, rendered from
    `public/icons/icon-512.png`).
  - Beside it, as plain `extraResources`: `resources\backend` (the backend
    staged by `desktop/build.sh`, with its own `npm ci --omit=dev`, sharp
    included), `resources\dist`, `resources\tools\gs` and `resources\magick`.
  - Installed per user by a oneClick NSIS installer to
    `%LOCALAPPDATA%\Programs\fileminify-app\FileMinify.exe`.
  - `appId com.bardurnielsen.fileminify` never changes: the uninstall key and
    winget's ProductCode derive from it.
  - The data folder is `%LOCALAPPDATA%\FileMinify`, as in 1.0.x (settings.env,
    logs, temp), plus `app\` for Electron's own.
- **Server.** A `utilityProcess` runs `resources\backend\server.js`, with an
  env built by main:
  1. Inherited `FM_*`, `MAGICK_*`, `ELECTRON_RUN_AS_NODE` and `NODE_OPTIONS`
     are dropped.
  2. The 1.0.x launcher defaults apply (PORT 3051, FM_HOST 127.0.0.1,
     FM_TRUST_PROXY 0, MAX_FILE_SIZE 500MB, PROCESS_TIMEOUT_MIN 30,
     FM_DATA_DIR, FM_STATIC_DIR).
  3. The bundled Ghostscript and `MAGICK_CONFIGURE_PATH` are set.
  4. settings.env overrides all of that.
  5. `FM_VERSION` comes from `app.getVersion()`.
- **Main process** (`desktop/main.cjs`, `lib/`): `config` (data dir,
  settings.env read and `saveHost` exactly as launcher.js did, the server's
  env), `log` (`logs\desktop.log`, rotated at 5 MB, two kept; the server's
  output goes there too), `server`, `window`, `tray`, `ipc`, `updater`.
  - Start: single-instance lock (a second launch shows the window), server,
    then the window. A port in use is a dialog naming `PORT=`, then quit. A
    server that dies is restarted once; again within 10 minutes, the user
    picks Restart or Quit.
  - Close button: phone access off quits (asking first if busy); on, the
    window hides to the tray, with a one-time balloon. Tray: Open, Phone
    access, Open log folder, Quit. Quitting sends `shutdown` first, and an
    update's `quitAndInstall` skips the question and the tray.
  - Window: size and place in `app\window.json`, restored only if still on a
    screen; no menu; new windows denied, only `https://github.com/bardurnielsen/file-minify/`
    links and the author's profile (exactly `https://github.com/bardurnielsen`,
    the footer's "Get in touch") open in the browser; navigation stays on the app's origin; every
    permission but `clipboard-sanitized-write` denied. Downloads save
    straight to Downloads (`name (1).ext` when taken); the page gets only an
    opaque id, never a path.
  - Markers in `app\`: `first-run-done` (the tools question was asked; not
    asked with `FM_NO_WINDOW`), `tray-hint-shown`, and `disable-gpu`, written
    when the GPU process fails to launch or crashes: the app relaunches
    without GPU acceleration (unless busy; then from the next start).
  - Updates: electron-updater, nothing automatic; checked when the page asks
    and every 12 h (cached 12 h). Any check error means "no update".
- **Messages over `process.parentPort`** (`backend/utils/desktop.js`, which
  does nothing outside the app):
  - Server to main: `{type:'listening',host,port}`, `{type:'listen-error',code}`,
    and `{type:'busy',busy}` when processing requests in flight (`trackJobs`:
    uploads, compressions, conversions, merges, from the window or a phone)
    go from none to some and back.
  - Main to server, also: `{type:'phone-network',id}` gets the QR address's
    network (`{category,name,kind,index}` or null), and
    `{type:'network-changed',id}` drops the 20 s network/firewall cache.
  - Main to server: `{type:'install-tools',id}` gets
    `{type:'install-tools',id,outcome,packages}`; `{type:'missing-tools',id}`
    gets `{type:'missing-tools',id,missing}`; `{type:'shutdown'}` closes the
    server and exits.
  - The window loads `http://127.0.0.1:<port>/` only after `listening`.
- **Bridge `window.fileminify`** (preload, `contextIsolation` and `sandbox`),
  typed in `src/lib/native.ts`: `updateStatus()`, `startUpdate()`,
  `onUpdateProgress(cb)`, `installTools()`, `setPhoneAccess(on)`,
  `openLogFolder()`, `openNetworkSettings(kind)`, `makeNetworkPrivate()`,
  `onDownloadSaved(cb)`, `showDownload(id)`,
  `setBusy(busy)`.
  - `ipcMain` handlers accept only the app window from the app origin.
  - No HTTP endpoint starts a program: `/native/*` goes, and `/config` keeps
    its read-only `phone`, `version`, `missingTools` and `installingTools`.
  - What the UI expects of main. `startUpdate()` resolves once the download
    is verified, just before `quitAndInstall`, and rejects if it fails;
    `onUpdateProgress` gives 0-100 meanwhile. `installTools()` resolves with
    `{ok:false, problem}` rather than rejecting; `'running'` is waited for
    like a fresh start (polling `/config`'s `installingTools`).
    `setPhoneAccess(on)` resolves once the server is back with the new
    setting; main then reloads the window, with `?phone` when turned on.
    `onDownloadSaved` fires for every download saved to Downloads, with an
    `id` that `showDownload(id)` accepts. `setBusy` is sent on every change,
    starting with `false`: true while any file uploads, waits in the queue or
    processes, or a merge runs.
  - Without the bridge (a browser tab on the same PC) the UI offers no update
    or tools install and points to the app's window or its tray icon.
  - `e2e/tests/desktop-fake.ts` fakes the bridge for the Docker stack;
    `DESKTOP_BRIDGE_KEYS` there is what the real preload must expose.
- **Priority.** The server runs below normal priority, and the tools it starts
  inherit it: an encode gets the whole processor when the PC is otherwise
  idle, but what the user is doing comes first.
- **Public networks.** `/config`'s `phone` says `publicNetwork` and, if so,
  `firewall` (`blocks`/`allows`/`unknown`, from FileMinify.exe's own inbound
  rules for the Public profile), and `networkKind` (no name: the profile name can be a DNS domain, and the
  SSID needs Location permission on Windows 11 24H2). The panel
  warns only when the firewall doesn't allow it. `makeNetworkPrivate()` runs
  `Set-NetConnectionProfile -InterfaceIndex <n> -NetworkCategory Private`
  behind Windows' admin prompt (n from the backend, checked to be an
  integer). `openNetworkSettings(kind)` opens only `ms-settings:network-wifi`,
  `-ethernet` or `ms-settings:network`.
- **Busy work and sleep.** "Busy" is the page's own work (`setBusy`) or the
  server's `busy`. While busy, Windows is kept awake
  (`powerSaveBlocker 'prevent-app-suspension'`, released 30 s after the
  last job), and quitting, updating or switching phone access asks first.
- **Start with Windows** (a tray checkbox): a login item named `FileMinify`
  that runs `FileMinify.exe --hidden`, which starts in the tray only; the
  tools question waits until the window first opens. It is read back by its
  name (`launchItems`), and the uninstaller deletes the Run value.
- **settings.env keys added:** `FM_UPDATE_PRERELEASE=1` (testers get betas),
  `FM_DISABLE_GPU=1`, `FM_UPDATE_CHECK=0` (never look). For
  CI only: `FM_UPDATE_FEED` (a generic feed; `http://127.0.0.1` or
  `http://localhost` only, anything else is ignored) and `FM_NO_WINDOW=1`
  (server and tray, no window). `FM_NO_WINDOW` and `FM_DISABLE_GPU` are
  also read from the environment.
- **Every release must stay updatable from 1.0.3.**
  - It is the `releases/latest` (published, not a draft, not a prerelease).
  - Its asset is named exactly `FileMinify-Setup-X.Y.Z.exe`, with a matching
    `vX.Y.Z` tag, and GitHub's sha256 digest is present.
  - It installs with no arguments and no elevation, stops the old app
    itself, and starts the new one.
  - The installer's `customInit` removes a 1.0.x install *without* running
    Inno's uninstaller, which would delete settings.env.
  - A prerelease is only ever `vX.Y.Z-beta.N` (the build and release jobs
    refuse anything else). electron-updater reads the part after the `-` as
    a channel: an install from an `-rc.N` would only look for more rc
    releases, never the stable one, while `beta` (and `alpha`) take stable.
  - A published release is never replaced: the release job fills in and
    publishes a draft, and fails on a published one. Release a new version.
- **Installer** (`desktop/build/installer.nsh`, electron-builder's NSIS
  macros, and the PowerShell scripts beside it, unpacked to the installer's
  temp folder and read in as a script block, so no execution policy applies;
  paths reach them in `FMSETUP_*` environment variables, never quoted into a
  command line).
  - `customInit`, before anything is extracted, finds 1.0.x by its Inno
    uninstall key (`{A714B514-…}_is1`) or its files. `stop-processes.ps1`
    stops the old `node.exe` (by exact path), the bundled Ghostscript under
    its `tools\gs\`, the FFmpeg, ImageMagick and LibreOffice processes it
    started (down the parent chain; never its whole tree, since setup is its
    child when 1.0.3's updater starts it) and its Edge window. Then it
    deletes 1.0.x's files by name, its shortcuts, the Edge profile
    (`FileMinify\window`) and, last, the key. settings.env, logs and temp
    stay. Every step can run again: a file still locked keeps the key, and
    the next install finishes the job. Half done (`node.exe` held), the
    phone-access and log-folder shortcuts still go, the key gets
    `SystemComponent=1` (out of Apps & Features) and Inno's `unins000.*` are
    deleted, so nobody can run the uninstaller that would wipe 2.0's data.
    It also moves its working directory out of the old folder, which 1.0.3's
    updater starts setup in, notes whether a 1.0.x user had a desktop
    shortcut, and whether a silent install (winget) is about to stop a
    running FileMinify (2.x or 1.0.x). It must never ask anything (a
    MessageBox needs `/SD`): winget and CI run it with `/S`, 1.0.3's updater
    with no arguments.
  - `customCheckAppRunning` replaces electron-builder's check (which broke on
    a quote in the path): an update (`--updated`) first gets 8 s to quit by
    itself, then everything running from the install folder is stopped,
    with the tools it started (`stop-processes.ps1`; `taskkill` by name if
    PowerShell can't run). `customUnInstallCheck` is electron-builder's own
    with `/SD` on its MessageBox.
  - `customInstall` grants "ALL APPLICATION PACKAGES" read access to the
    install folder (the per-user GPU start-up failure); deletes the
    updater's `current.blockmap` unless it is an update (installed any other
    way it is stale, and the next differential download would fail and fetch
    everything) and 1.0.3's leftover `%TEMP%\FileMinify-update-*` downloads
    (not the one it runs from). After a 1.0.x install it removes the new
    desktop shortcut if the user had none, and `retarget-shortcuts.ps1`
    points shortcuts to the old `node.exe` (desktop, Start-menu folders,
    taskbar pins) at the new exe, with the app's AppUserModelID. If a silent
    install stopped FileMinify, it starts it again with `--hidden`.
  - `customUnInstall` removes `%LOCALAPPDATA%\FileMinify` and the updater's
    cache, except during an update (`--updated`).

## Invariants — these are fixed bugs, do not regress them

1. **`formats.ts` is the source of truth.** Never offer a file its own format, or
   one its type cannot reach. Adding a backend format means updating both sides.
2. **One routing rule.** `routeFor` decides both how a file is processed and where
   it is downloaded from. These were once two hand-maintained condition lists that
   drifted, sending downloads to the wrong route.
3. **`-y` on every FFmpeg invocation.** Without it FFmpeg prompts before
   overwriting an existing output and blocks forever, because `exec()` gives it no
   stdin. This hung the app three separate times: two-pass output to `/dev/null`,
   and converting a video to its own format (making output == input). Any shelled
   command that might overwrite needs its non-interactive flag.
4. **LibreOffice is serialised** through `withOfficeLock`. Headless mode holds a
   per-profile lock; a second concurrent conversion fails outright.
5. **`format: 'original'` means "keep the source format"** and is resolved to the
   source extension server-side. It must never reach a filename or an encoder as a
   literal — it once produced files named `compressed-<uuid>.original`.
6. **The backend port stays on `127.0.0.1`.** Published on `0.0.0.0` it is a second
   front door that skips nginx, and because `trust proxy` makes Express believe
   `X-Forwarded-For`, a direct client can set that header per request and rotate
   itself out of the rate limiter. Verified: rotating the header held
   `RateLimit-Remaining` at 599 indefinitely.
7. **nginx forwards `Host` as `$http_host`, not `$host`.** `$host` strips the port,
   so the same-origin check compared `example` against an `Origin` of
   `example:3051` and rejected the app's own uploads with a 403.
8. **An nginx `add_header` in a nested location drops every inherited one.** The
   asset-cache block therefore uses `expires` alone — when it also set
   `Cache-Control` by hand, every JS and CSS file was served with no CSP and no
   `X-Frame-Options`.
9. **No `cpus:` on the backend.** Docker refuses to start a container whose
   `cpus` exceeds the host's core count, rather than clamping — `cpus: 4.0`,
   picked on a 12-core dev machine, stopped the backend dead on CI's two-core
   runner and would have done the same on any smaller server. Memory is the
   limit that protects the host; keep `mem_limit`. A value at or below 1 (as
   the frontend uses) is safe anywhere. To be a good neighbour on a shared
   host the backend uses `cpu_shares` instead: a relative weight, which only
   bites under contention and cannot be too large for any host.
10. **Never run a bare `convert` on Windows.** `System32\convert.exe` is
    Windows' disk-format converter; ImageMagick there is `magick`.
    `utils/tools.js` maps the name. Call tools by name through `run()`, never
    by a path or an `exec` of your own.
11. **No Unix-only paths or separators in tool arguments.** The null device is
    `os.devNull`, not `/dev/null`. `-x265-params` splits on `:`, which a
    Windows path contains, so x265's two-pass stats file is named relative
    to the temp dir that FFmpeg runs in.
12. **Natively, nothing is in front of the backend**, so the desktop app sets
    `FM_TRUST_PROXY=0`. Trusting `X-Forwarded-For` there reopens invariant 6's
    rate-limiter bypass. Verified: with 0, rotating the header still counts
    down.
13. **ImageMagick is told the decoder** (`magickInput`: `png:<file>`), never
    left to guess. It sniffs a file's bytes over its name, so an upload
    declared PNG that is really SVG was rendered, and SVG can read local files.
    In Docker only a missing SVG delegate stopped it; ImageMagick 7 on Windows
    renders SVG itself. Verified by giving a container `rsvg-convert`: the old
    call made a PDF, the pinned one refuses. `desktop/magick/policy.xml` is
    the second lock. Smoke: "svg disguised as png".


14. **No HTTP endpoint starts a program.** 1.0.x's `/native/*` did, guarded to
    the PC itself. In 2.0, updating and installing tools go through the
    desktop bridge (`window.fileminify`), whose handlers accept only the app
    window, from the app origin, while our own server listens.
15. **`appId` and the 1.0.x migration are permanent.** The appId
    `com.bardurnielsen.fileminify` is where the uninstall key, winget's
    ProductCode and every update come from. `customInit`'s 1.0.x removal must
    stay in every release, because a 1.0.3 that updates late still gets
    whatever is newest.
16. **Every release stays updatable from 1.0.3:**
    - It is published as `releases/latest`, never as a draft.
    - Its asset is named exactly `FileMinify-Setup-X.Y.Z.exe`, with a
      matching `vX.Y.Z` tag and GitHub's sha256 digest.
    - Prereleases are only `vX.Y.Z-beta.N`: an `rc` channel never gets the
      stable release in electron-updater.
    - A published release's installer is never replaced; release a new
      version instead.

    The release job checks all of this.

## Behaviour worth knowing

- **Compression can inflate.** Re-encoding already-compressed input often grows it.
  When the result is no smaller and the format is unchanged, the backend keeps the
  original and reports `compressionRatio: "1.00"`. The UI shows this as "already
  compact", not as a failure.
- **Video ignores numeric quality.** With `maxSize` set, FFmpeg runs two-pass
  targeting that size and the quality value does nothing. PDF and video take a
  *named* level (`low|medium|high`), not a number — hence tiers.
- **Video tiers differ by resolution and a bitrate ceiling, not just CRF.**
  On an already-lean source (a 4 Mbps 1080p phone clip) CRF alone asked for
  more bits than the source had, so Balanced and Best both came out larger and
  the original was kept. Each tier now also caps peak bitrate at a share of the
  source's (45/55/80% for H.264, a quarter lower for H.265, scaled down further
  when the picture is), and by default caps the short side (720p / 1080p /
  1440p). Nothing is ever kept at 4K: 1440p is the ceiling for every path,
  including target size and an explicit `resolution` (`1440|1080|720|480`;
  `source` is still accepted and means 1440). On the test server a 4K Best encode
  ran at 0.08x real time. A `resolution` option overrides the tier's cap; with
  a target size, the resolution is picked from the bitrate the target allows.
  `codec: 'h265'` is MP4/MOV only and tagged `hvc1` for Apple players. See
  `VIDEO_SETTINGS` in `routes/compression.js`.
- **Videos wait for the user.** A dropped video uploads straight away but is
  held (`FileItem.hold`, status `ready`) with its settings panel open until
  Compress is pressed: an encode takes minutes, can't be cancelled, and target
  size is the setting that matters most. Everything else starts on drop.
- **`set_mempolicy: Operation not permitted` from x265 is harmless.** x265 asks
  the kernel to place each worker thread's memory (a NUMA optimisation); newer
  Docker seccomp profiles refuse that call without CAP_SYS_NICE, so it prints
  one line per thread and carries on. Don't grant the capability to silence it.
- **Rename and share.** `FileItem.baseName` is a user-given name without the
  extension (`cleanBaseName` strips path and reserved characters);
  `displayName()` is what every name shown, downloaded or shared uses. Share
  (`lib/share.ts`) uses the Web Share API: shown only when `canShareFiles()`
  says this browser can share those exact names and types (secure context
  needed; Chrome on Android won't share .mov/.avi). share() must run within
  the tap's activation window, so a fetch that outlasts it caches the File
  and asks for a second tap, which shares at once. `e2e/tests/share.spec.ts`
  fakes the share sheet to check names, types and contents.
- **Installable (PWA).** `public/manifest.json` (named .json because nginx's
  stock MIME table has no .webmanifest; don't add a `types` block, it replaces
  the whole table) and `public/icons/`, rendered from `favicon.svg`'s
  light-scheme colours; the maskable ones have the glyph scaled to 0.85 so it
  stays inside Android's 80% safe zone. Browsers install only from a secure
  origin, so over plain http on the LAN it needs HTTPS in front (or Chrome's
  "insecure origins treated as secure" flag). `e2e/tests/install.spec.ts` asks
  Chrome for installability errors.
- **Results describe themselves.** `POST /compression` returns `details` (video:
  preset, codec, output and source short side; images: quality; PDFs: preset),
  null when the original was kept. `outputNameFor` builds the download name from
  it and `describeResult` the row's line; the backend writes the same into the
  file (`-metadata comment` on video, EXIF ImageDescription - ASCII only - on
  images, Producer on repacked PDFs). Video output drops all source metadata
  (`-map_metadata -1`), GPS included; Sharp already drops it for images.
- **Photos are auto-oriented** (`autoOrient()` in Sharp, `-auto-orient` for
  image→PDF). Sharp strips the EXIF Orientation tag with the rest of the
  metadata, so without it a phone's portrait photo came out sideways. Pinned in
  smoke by `fx-rotated.jpg`.
- **Office → PDF honours the tier.** The conversion route takes an optional
  `quality` (`low|medium|high`) for Office files and runs the LibreOffice PDF
  through Ghostscript at that level, keeping whichever is smaller. Other
  conversions ignore quality.
- **Every Ghostscript PDF is then repacked by pdf-lib** into compressed object
  streams (lossless). Ghostscript 10.00 writes objects loose, so a spreadsheet
  with thousands of hyperlinks stayed ~1.3 MB at every tier; repacked it is
  ~570 KB. Best effort: if pdf-lib can't read the file, or the repack isn't
  smaller, the Ghostscript output is kept.
- **Ghostscript exits 0 on a PDF that needs a password to open** and writes a
  blank page, which used to come back as a 95% saving. `needsPassword` spots
  its stderr message and the route answers 422 instead. PDFs that only
  restrict printing or editing still work. Merge reports the same case itself.
- **The email sizes** on the video target size aim for 7 MB and 18 MB: email
  grows an attachment by about a third (base64), so those fit a 10 MB and a
  25 MB limit.
- **`maxSize` is video only**: a target file size in MB. Images once treated it as
  a silent megapixel cap; that is gone.
- **A missing `format` means `original`** on compression, same as sending it.
- **Merging is atomic.** One unconvertible file fails the whole merge, with
  `failedId` naming it. The UI offers leaving it out.
- **Merge uses the processed output** (`processedId ?? serverId`), so a converted
  docx is not pushed through LibreOffice twice.
- **A foreign `Origin` is rejected with a 403**, not just denied CORS headers. A
  multipart POST is a "simple" request: the browser sends it regardless and only
  hides the reply, so CORS alone still let a hostile page upload a file and start
  an encode. `CORS_ORIGIN` opts specific origins back in; a request with no
  `Origin` at all (curl, the smoke suite) is left alone.
- **The rate limiter is mounted on the paths nginx actually delivers** (`/upload`,
  `/compression`, `/conversion`, `/merge`) rather than `/api/`, which the proxy
  strips. `trust proxy` is set to 1 so clients are counted by `X-Forwarded-For`
  and not by nginx's own container address. `/health` is exempt.
- **PNG quality is non-monotonic in Sharp** — "Smaller" can produce a larger PNG
  than "Balanced".
- Temp files are `<uuid>.<ext>` in `/app/temp` on the `temp_files` volume, swept
  hourly. The volume survives `docker compose down`; use `-v` to clear it.

## Testing

Two suites, both against a running stack and both in CI (jobs `backend` and
`e2e`, required on `main`):

- `backend/test/smoke.sh` - the API: every route, the error paths and the
  security regressions (below).
- `e2e/run.sh` - the browser suite (Playwright, `e2e/tests/`): drop-and-process,
  held videos and their settings, labelled download names, merge, sliders and
  the format picker, untyped uploads, the drag overlay, the video queue, the
  settings panel's exit, and the desktop app's notices, phone switch and
  download toast against a faked bridge. It runs in the Playwright image whose tag run.sh reads
  from `e2e/package.json` (pinned exactly, so a Dependabot bump moves both),
  and makes its test videos with the backend's ffmpeg (`e2e/make-media.sh`).
  Every test also fails on any browser console error. Each of its bug tests
  was checked by re-introducing the bug and watching it fail.
- `e2e/screenshots/update.sh` regenerates the README screenshots
  (`docs/screenshots/*.png`, dark theme only - light ones vanished into
  GitHub's white page) from demo files it makes with the backend's tools.
  Each shot is then framed in the browser - rounded to the element's own
  radius, soft shadow, faint light hairline, transparent background - since a
  README can't carry CSS: the shadow lifts it off GitHub's white page, the
  hairline edges it on the dark one. Re-run it after a visible UI change; don't edit the PNGs.

A separate workflow, `windows.yml` (not a required check), runs on Windows
runners when the backend, app or `desktop/` change:
- `build`: `desktop/build.sh <version> --next <version+1>`, the installer plus
  a second build one patch up (`build/desktop-next`) that serves as the
  update feed. Both are kept as run artifacts; the first is what to try on a
  real PC.
- `installed`: installs it silently, starts it with stale `FM_VERSION` and
  `MAGICK_CONFIGURE_PATH` (which it must ignore) and a DevTools port, runs
  the smoke suite at `:3051/api`, checks `/native/*` is gone and the version
  and ImageMagick policy, takes a screenshot, then runs the desktop suite
  (`e2e/desktop/`, `e2e/desktop.config.ts`: Playwright over CDP, in file
  order). That checks the bridge's keys against `DESKTOP_BRIDGE_KEYS`, a
  download to Downloads, phone access on and off, a second start, the update
  to desktop-next through the Update button (`FM_UPDATE_FEED` to a local
  `python -m http.server`, which also serves the installed version's
  blockmap, and must be asked for it), and that closing the window stops
  everything. Then it uninstalls and checks nothing is left.
- `migration`, three times: installs the real 1.0.3 (hash-checked), seeds
  settings.env, logs, an Edge profile, a taskbar pin and a stale
  `%TEMP%\FileMinify-update-*`, starts the old app and an Edge, then runs
  the new installer silently (`/S`, no desktop icon), as 1.0.3's updater
  does (a detached child of the old `node.exe`, no arguments, in the old
  folder, with its stale environment), or silently with the old `node.exe`
  held open (it must stop half way: key kept but hidden, `unins000.*`
  gone), then again once let go. The old processes, folder, key and
  shortcuts must be gone, the pin and any desktop icon pointing at the new
  exe (and no desktop icon where 1.0.x had none), settings.env
  byte-identical, and the new app running with phone access still on.
- `release`, on a `v*` tag: checks latest.yml against the exe, then one
  `gh release create` with the exe, its blockmap and latest.yml, never a
  draft (a draft made by hand is filled in and published; a published
  release fails the job); a `vX.Y.Z-beta.N` tag is a prerelease, and any
  other tag with a `-` fails. It then checks `releases/latest`
  still meets the 1.0.3 rules above, and prints the SHA-256 for the winget
  manifest. The native mode can be exercised on Linux
too: run the backend image with `FM_STATIC_DIR` pointing at a built `dist/`,
then run smoke and `e2e/run.sh` against it.

Per-server settings (`MAX_FILE_MB`, `PROCESS_TIMEOUT_MIN`, `BACKEND_CPU_SHARES`,
`TEMP_DIR`) come from an optional `.env` beside `docker-compose.yml`; see
`.env.example`. nginx's config is a template (`frontend/nginx.conf.template`)
whose upload limit and read timeout `frontend/nginx-limits.envsh` derives from
the same settings at container start - never hard-code them again.

`backend/test/bench.sh` times every tier and codec on the current host; use it to
choose those settings.

No unit tests. There is an API smoke suite at `backend/test/smoke.sh`, with
fixtures beside it. It needs the stack running and defaults to :4001; pass a
base URL to point it elsewhere. It exits non-zero on failure.

It covers upload, compression, conversion, merge, error handling, and a security block
pinning the bugs fixed in `53673ab` — format injection, `id` path traversal on
the download and delete routes, MIME rejection, the upload size cap, and the
same-origin check in both directions. Those last cases are regression tests: if
one starts failing, a hole has reopened.

CI (`.github/workflows/docker-build.yml`) runs one job per image: `frontend`
builds (and so type-checks and lints), `backend` builds, starts that exact image and runs
the smoke suite. Each caches under its own `gha` scope - with a shared scope the
two overwrote each other and the apt layer was rebuilt every run. Build contexts
are trimmed per image by `<Dockerfile>.dockerignore`. Docs-only pushes to `main`
skip CI, but PRs always run it: `main` requires both checks, and a required
check that never reports would block the PR. Actions are pinned to commit SHAs;
Dependabot (`.github/dependabot.yml`) proposes updates for npm, the Docker base
images and the actions weekly.

`curl` needs an explicit `;type=<mime>` on `-F` uploads or the MIME allowlist
rejects the file.