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.
7.8 KiB
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
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.
- Detecting the drag.
DragMonitorinstalls global (and local) monitors for.leftMouseDraggedetc. Mouse events need no permission. On the first drag event it readsNSPasteboard(name: .drag)for file URLs. If there are none, it is not a file drag and we ignore it. - Showing the overlay.
WheelController.presentcreates a borderlessOverlayWindowat.screenSaver - 1, non-key,ignoresMouseEvents = false, with a full-screenDropCatcherview. A 60 Hz timer pollsNSEvent.mouseLocationandNSEvent.modifierFlagsto decide which wheel to show and what is highlighted — because a non-key window cannot read modifier flags from the event stream. - Consuming the drop.
DropCatcheris a realNSDraggingDestination. 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. - 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. - 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.
WebPEncodershells out tocwebp, falls back to an ffmpeg with libwebp, and marks the format unavailable otherwise.sipscannot write WebP — verified, do not try. - SVG, PSD, RAW are often undecodable by ImageIO.
Raster.firstImagefalls back toqlmanage/Quick Look rasterisation. - Lossless "compression" (PNG/BMP/TIFF) ignores ImageIO's quality property, so
ImageTools.compressposterises instead. If you ever reach forpngquant, 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.formatDescriptionsis[Any]— you needas! CMFormatDescription(as?is rejected as always-succeeds). Theload(.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.missingToolAdvicesupplies thebrew install …line for the UI.
Build/bundle notes
- The app is an
LSUIElementmenu bar app; the bundle is assembled bybuild-app.shwith a handwrittenInfo.plist. Do not try to generate it fromPackage.swift, and do not commitKonverter.appordist/. CFBundleIdentifierisdev.tzgyn.konverter.AppDelegate.terminateOtherInstances()kills stale copies at launch so the monitors never double up.Settings(our class) collides with SwiftUI'sSettingsscene — the scene must be writtenSwiftUI.Settings { EmptyView() }.- The Finder Quick Action is an
NSServicesentry callingconvertFiles:userData:error:on the app delegate. It follows the same path as the menu bar item. SMAppService.mainApppowers "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:
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.