Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
# 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 # 41 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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user