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

8.7 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          # 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:

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.