Files
TZGyn a01d5def89 Give mixed selections something to do
A Drag of different kinds showed an empty wheel, because the catalog only
answers for a single category. Filee's answer is a mixed profile, so add
Zip (one archive, flat names) and Merge (non-PDFs are converted to PDF
first); a multi-file drag of one kind gets Zip too.
2026-10-20 01:30:42 +08:00

171 lines
8.7 KiB
Markdown

# AGENTS.md
Working notes for `Konverter` — a menu bar file converter for macOS, written in
Swift with SwiftPM and no external dependencies. Read this before changing the
drag machinery or the conversion catalog.
## Commands
```bash
swift build # debug
swift build -c release
./.build/debug/Konverter --self-test # 46 checks, exits non-zero on failure
./.build/debug/Konverter --convert <files…> --to <ext>
./.build/debug/Konverter --convert <file> --tool <toolID> --param k=v
./build-app.sh <version> # → Konverter.app (signed)
./release.sh <version> # notarize + staple + zip + sha256
open Konverter.app
```
`--self-test` and `--convert` are handled in `AppDelegate.applicationDidFinishLaunching`
*before* any UI is installed, so they work headlessly and never steal focus. The
self-test writes its fixtures into a temp directory and cleans up. A `SKIP` line
means an optional engine (ffmpeg / LibreOffice / `cwebp`) is absent, not a bug;
a `FAIL` line is a bug.
Keep the self-test green and add a check whenever you add a converter or a
tool — it is the only automated coverage in the repo.
## Layout
```
Sources/Konverter/
App.swift @main, AppDelegate, monitor wiring, Services handler
CLI.swift --convert mode
SelfTest.swift the verification suite
Core/
Format.swift Format, FileCategory, FileKind, ConversionCatalog
Tools.swift ToolID, ToolField, ToolParams, Timecode
Actions.swift ActionID + FileActions (AirDrop, Float, Copy, …)
Settings.swift UserDefaults-backed preferences, LoginItem
JobStore.swift job queue, progress, reveal, notifications
OutputNaming.swift destination paths, conflict policy
Shell.swift Process wrapper, tool lookup
Toolchain.swift optional engine detection (ffmpeg, soffice, cwebp)
WheelOption.swift WheelItem/WheelKind and wheel construction
Drag/
DragMonitor.swift global mouse monitors + drag pasteboard reading
WheelController.swift overlay window, geometry, DropCatcher, commit
WheelView.swift SwiftUI radial wheel and list views
Convert/
Converter.swift dispatch table + Availability rules
ToolRunner.swift every ToolID implementation
ImageConverter.swift, ImageTools.swift
PDFConverter.swift, DocumentConverter.swift, TextDocument.swift, Markdown.swift
AVConverter.swift, MediaTools.swift, FFmpeg.swift
ArchiveConverter.swift, SpreadsheetConverter.swift, EbookConverter.swift
SubtitleConverter.swift, Office.swift
UI/
MenuBar.swift, QuickMenu.swift, PreferencesWindow.swift, OptionsWindow.swift
ResultWindow.swift, JobPanel.swift, FloatPanel.swift, Notifier.swift
```
`Converter.convert(source:to:at:)` handles plain format changes;
`ToolRunner.run(tool:inputs:params:)` handles transforms. Both funnel through
`Availability` for the "is the engine present?" question, and both report
errors as `ConversionError` (a `LocalizedError`) so the CLI and the UI show the
same wording.
## How the drag wheel works
This is the part with real constraints, so it is worth stating plainly.
1. **Detecting the drag.** `DragMonitor` installs global (and local) monitors for
`.leftMouseDragged` etc. Mouse events need **no** permission. On the first
drag event it reads `NSPasteboard(name: .drag)` for file URLs. If there are
none, it is not a file drag and we ignore it.
2. **Showing the overlay.** `WheelController.present` creates a borderless
`OverlayWindow` at `.screenSaver - 1`, non-key, `ignoresMouseEvents = false`,
with a full-screen `DropCatcher` view. A 60 Hz timer polls
`NSEvent.mouseLocation` and `NSEvent.modifierFlags` to decide which wheel to
show and what is highlighted — because a non-key window cannot read modifier
flags from the event stream.
3. **Consuming the drop.** `DropCatcher` is a real `NSDraggingDestination`. That
is what stops Finder from moving the file: Finder hands the drop to us and
considers the drag handled. Never replace this with a borderless window that
merely *looks* like it is on top — Finder will happily move the file out from
under you.
4. **Ending.** Drop → `commitDrop()` runs the job. Escape, dead-zone drop, or a
mouse-up outside the wheel → `cancel()`, which hides the overlay and lets the
drag continue wherever it landed.
5. **Keys are optional.** Escape/arrows/Return come from a *global key* monitor,
which does require Accessibility. All of them are also reachable by pointer,
so the app must never *require* that permission. Do not move essential
behaviour into the key path.
Two ordering traps live here. `NSEvent.modifierFlags` is global state, not the
event's flags — read it during the tick, not once at drag start. And the wheel
geometry (`WheelController.geometry`/hit testing) is unit-tested by the
"Wheel geometry" section of the self-test; if you change the radius or segment
maths, update those expectations rather than deleting them.
## Conversion gotchas
- **WebP** has no ImageIO encoder on macOS. `WebPEncoder` shells out to `cwebp`,
falls back to an ffmpeg with libwebp, and marks the format unavailable
otherwise. `sips` cannot write WebP — verified, do not try.
- **SVG, PSD, RAW** are often undecodable by ImageIO. `Raster.firstImage` falls
back to `qlmanage`/Quick Look rasterisation.
- **Lossless "compression"** (PNG/BMP/TIFF) ignores ImageIO's quality property, so
`ImageTools.compress` posterises instead. If you ever reach for `pngquant`,
remember it is not a dependency and must stay optional.
- **PPTX/EPUB/XLSX/DOCX/RTF** are hand-written OOXML/EPUB/zip writers. They are
deliberately minimal: enough structure for Word/Numbers/Preview to open them
cleanly, no styling round-trip. LibreOffice is used when real layout fidelity
matters (PAGES/KEY/NUMBERS/PPT/XLS).
- **`track.formatDescriptions` is `[Any]`** — you need `as! CMFormatDescription`
(`as?` is rejected as always-succeeds). The `load(.formatDescriptions)` API
would be nicer but is async and the media-info path is synchronous.
- Optional engines must always degrade, never crash: absence of ffmpeg or
LibreOffice should surface as an unavailable wheel item with a reason, and
`Toolchain.missingToolAdvice` supplies the `brew install …` line for the UI.
- **Mixed selections must never produce an empty wheel.** `WheelBuilder` falls
back to `ToolID.combineTools(for:)` (Zip, Merge) whenever a drag has no single
category, and appends Zip to every multi-file tools wheel. `mergePDF` converts
non-PDF inputs through `Converter.convert(_:to:.pdf)` into a temp folder, so
it accepts photos and documents, not just PDFs. If you add a tool that works
across kinds, add it to `combineTools` and cover it in the self-test.
## Reference material
Research on the apps this project is modelled on lives in an untracked
`.reference/` folder (`.reference/.gitignore` contains `*`, so nothing there is
ever committed). Currently: `tangerine.md` (the closed-source app we clone),
`filee.md` + a shallow clone of `KnifeLemon/Filee` (MIT, the open-source
sibling), and `README.md` indexing them. Read `filee.md` before designing a
feature that Filee already has — the "borrow next" list there is the roadmap.
## Build/bundle notes
- The app is an `LSUIElement` menu bar app; the bundle is assembled by
`build-app.sh` with a handwritten `Info.plist`. Do not try to generate it from
`Package.swift`, and do not commit `Konverter.app` or `dist/`.
- `CFBundleIdentifier` is `dev.tzgyn.konverter`. `AppDelegate.terminateOtherInstances()`
kills stale copies at launch so the monitors never double up.
- `Settings` (our class) collides with SwiftUI's `Settings` scene — the scene
must be written `SwiftUI.Settings { EmptyView() }`.
- The Finder Quick Action is an `NSServices` entry calling `convertFiles:userData:error:`
on the app delegate. It follows the same path as the menu bar item.
- `SMAppService.mainApp` powers "open at login".
## Releasing
Notarization is mandatory: an unnotarized build is not a release. `release.sh`
will not print a sha256 unless Apple accepted the submission, the ticket is
stapled, and `spctl -a -vv Konverter.app` reports `source=Notarized Developer
ID`. Never suggest `xattr -dr com.apple.quarantine` or
`brew install --cask --no-quarantine` as a workaround — fix the signing problem.
Push `main` before `tea releases create` (the tag must point at shipped source),
then bump `Casks/konverter.rb` in `TZGyn/homebrew-tap` with the new version and
sha256, and print the upgrade script:
```bash
brew update
brew upgrade --cask konverter
```
Credentials: `~/.config/kapture/AuthKey_K5KXMMSB28.p8`, key id `K5KXMMSB28`,
issuer `3e25e5dc-8b75-453f-a71c-c7f3feaac628`, team `RX6767S6BW`. Never commit
the key.