# 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 --to ./.build/debug/Konverter --convert --tool --param k=v ./build-app.sh # → Konverter.app (signed) ./release.sh # 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.