About a month ago I published Tendedero, a small open source macOS app that hangs every screenshot you take on a thin clothesline across the top of the screen. You drag a photo off the line into Slack, a folder or the Trash, and it is gone. That is the whole app.
It just passed 24,000 downloads. Building it meant poking at corners of macOS that are barely documented, so here is what I found, in case it saves someone else an afternoon.
01A Mac app with no Xcode project
The repo is a plain Swift package, plus one bash script that turns its output into a signed .app. There is no .xcodeproj, and I never opened Xcode.
An .app is just a folder with a known shape, so the script makes one:
swift buildonce per architecture (arm64-apple-macosx14.0andx86_64-apple-macosx14.0), thenlipo -createto get a universal binary.- The
Info.plistis a heredoc in the script.LSUIElementset to true keeps the app out of the Dock. - The icon is drawn in code by a Swift script, rendered to PNG, scaled with
sipsand packed withiconutil. Changing the icon means editing code, and the diff is reviewable. codesign --options runtime --timestampwith the Developer ID found in the keychain. If there isn't one, it falls back to ad hoc signing, so contributors can still build and run it.
Notarization is notarytool submit --wait with a keychain profile, then stapler staple.
The DMG was harder than any of that. Its window layout (background, icon positions, window size) is stored in a .DS_Store, and the only reliable way to write one is to mount the image read-write and have Finder lay it out over AppleScript. A detail that cost me an evening: I use a tiling window manager, and it resized the Finder window, so Finder saved the wrong size into the image. The script now pauses the window manager while it runs.
The macOS 27 surprise
On macOS 27, the Command Line Tools ship an SDK whose SwiftUI needs a macro plugin, and that plugin only comes with full Xcode. So swift build fails on any file that touches SwiftUI. The workaround is to retry with the newest macOS 26 SDK, which the tools still install alongside:
if [ -z "${SDKROOT:-}" ] && ! swift build -c "$CONFIG" --triple "$triple"; then
FALLBACK="$(ls -d /Library/Developer/CommandLineTools/SDKs/MacOSX26*.sdk | sort -V | tail -1)"
export SDKROOT="$FALLBACK"
fi
02Finding out where screenshots go
macOS keeps its screenshot settings in the com.apple.screencapture defaults domain. Up to macOS 26, the folder is in location. macOS 27 split it into location-screenshot and location-screenrecording, and it ignores the old key. The same thing happened to target (file, clipboard, Preview...), which became target-screenshot and target-screenrecording. An app that only reads the old keys silently stops seeing screenshots on macOS 27, so Tendedero reads the new key first and writes both.
You have to read it through CFPreferences, not by parsing the plist, because cfprefsd caches it:
CFPreferencesAppSynchronize("com.apple.screencapture" as CFString)
let raw = CFPreferencesCopyAppValue("location-screenshot" as CFString, domain) as? String
?? CFPreferencesCopyAppValue("location" as CFString, domain) as? String
Most people's screenshots go to the Desktop, which is full of files that are not screenshots. macOS tags every capture with an extended attribute, so the app can tell them apart without guessing from file names (which change with the system language):
getxattr(path, "com.apple.metadata:kMDItemIsScreenCapture", nil, 0, 0, 0) >= 0
To watch the folder, I open it with O_EVTONLY and use a DispatchSource.makeFileSystemObjectSource for writes, renames and deletes. screencaptureui writes a hidden temporary file and then renames it, so each event is debounced by 200 ms before the folder is listed again.
A launch bug worth knowing about
open() on the Desktop blocks while macOS shows the "Tendedero would like to access files in your Desktop folder" prompt. I first called it from applicationDidFinishLaunching, so while the prompt was up the app had not finished launching: there was no menu bar icon and no signal handlers, for as long as the prompt stayed unanswered. Now the folder is opened on a background queue.
03The five second delay, and taking over the settings
When the floating thumbnail is on (it is by default), macOS does not write the file until the thumbnail goes away, about five seconds later. That kills the "it flies straight to the line" effect.
So Tendedero has an optional inbox mode. It turns show-thumbnail off, points the locations at its own folder in Application Support and forces the targets to file. The Desktop then only gets the captures you choose to keep. The risky part is giving the settings back:
- The user's previous values are saved before the first change. If they already point at our folder (after a crash, say), they are never saved as the "previous" values.
- They are restored on quit, and also on
SIGTERM,SIGINTandSIGHUP, because a plainkillor a forced logout skipsapplicationWillTerminate:
for sig in [SIGTERM, SIGINT, SIGHUP] {
signal(sig, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
source.setEventHandler {
if Inbox.isEnabled { Inbox.restore() }
exit(0)
}
source.resume()
}
Writing a nil value deletes the key, which hands it back to the macOS default. That matters: most people never set location at all, and writing ~/Desktop back on their behalf would not be the same thing.
04Where on screen was that screenshot taken?
When you capture a region, the photo lifts off that exact spot and flies up to the line. I didn't have to track the capture UI for this. macOS writes the captured area onto the file, in another extended attribute, as a binary plist of four numbers:
com.apple.metadata:kMDItemScreenCaptureGlobalRect
The rectangle is in global points with the origin at the top left of the main display. AppKit's origin is at the bottom left, so the Y axis gets flipped against the main screen's height, and that is it.
05Drag and drop, and the Dock's Trash
The gestures had to feel physical. Dragging into an app copies the file and the photo stays on the line. Dragging into a Finder folder moves it, and it leaves the line. Dragging onto the Trash deletes it.
Most of that comes from one NSDraggingSource method. Apps pick .copy, Finder picks .move, and .delete is what makes the Dock's Trash light up:
func draggingSession(_ session: NSDraggingSession,
sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
context == .outsideApplication ? [.copy, .move, .delete] : []
}
The non-obvious part: when you drop on the Dock's Trash, the Dock does not trash anything. It only reports .delete back to the source, and the source is expected to move the file to the Trash itself. And when Finder accepts a move, it finishes the move a bit after the drag session ends, so the app checks again 600 ms later before deciding whether the photo left the line.
06Staying out of full screen apps
A clothesline across a presentation or a movie is not welcome. AppKit has no public API that tells you whether a display is currently showing a full screen Space. The window server knows, though, and two private CoreGraphics calls answer it without any permission prompt. They are the same ones tools like yabai have used for years:
@_silgen_name("CGSMainConnectionID")
private func CGSMainConnectionID() -> Int32
@_silgen_name("CGSCopyManagedDisplaySpaces")
private func CGSCopyManagedDisplaySpaces(_ connection: Int32) -> CFArray
Each entry has a Display Identifier (a UUID you can match with CGDisplayCreateUUIDFromDisplayID) and a Current Space whose type is 4 for full screen. One catch: with "Displays have separate Spaces" turned off, there is a single entry covering every screen.
This is the only private API in the app, but not the only thing keeping it out of the App Store. The App Store requires the sandbox. Finding and redirecting screenshots means changing another app's preferences, which a sandboxed app can only do with a temporary exception entitlement, and App Review decides those case by case. So Tendedero ships as a notarized DMG instead.
07Notes on the launch itself
- One post on X with a short video did almost all of it: 440 stars on the first day. A video showed the idea better than any text could.
- 41 issues and 62 pull requests in the first three weeks, for what was then a 3,000 line app. I merged 20. The ones I closed were mostly good ideas that would have made the app bigger. The hardest part of maintaining it is deciding what it is not.
- Translations came in faster than features. It now speaks eleven languages, and a small script checks that every string in the code exists in each language and that no language keeps strings the code no longer uses.
- A Homebrew tap took ten minutes to set up and removes the "download, open the DMG, drag to Applications" step for a lot of people:
brew install --cask alejandrobujan/tap/tendedero.