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.
171 lines
8.7 KiB
Markdown
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.
|