# Finding new screenshots, and the macOS 27 custom folder bug

> How a Mac app finds new screenshots: a file system watcher, a poll, the capture tag, and the macOS 27 key that broke custom folders.

Canonical: https://www.notchstar.com/blog/finding-new-screenshots-macos-27-custom-folder/
Last updated: 2026-11-04

*A folder watcher, a one second poll, a capture tag and a preference key that moved. How a notch app notices a screenshot, and why a custom folder broke it.*

Published 2026-11-04.

## TL;DR

- To put a new screenshot in the notch, an app has to know which folder macOS saves into, notice a new file, decide it is a real capture, and wait until it has bytes. NotchStar does this with a folder event, a one second poll, an extended attribute and a name list.
- In version 1.0.5 (12 October 2026) the changelog lists a fix: on macOS 27 with a custom screenshot folder, screenshots never reached the notch. The code now reads a second preference key, `location-screenshot`, besides the old `location`.
- The first version of the watcher is in git and shows what was read before: only `location`. The macOS 27 behaviour itself is taken from the code comment and the changelog; it could not be reproduced on the Mac used for this post, which has no custom folder set.

## The problem

NotchStar hangs screenshots in a strip under the notch, up to 12 of them, each for 5 minutes unless used. For that, the app needs a stream of "a new screenshot exists at this path" events, with no Screen Recording permission and no private API. It watches the folder macOS writes to.

That sounds like one line of code and it is five questions:

| Question | Where it is answered |
|---|---|
| Which folder does macOS save into | `ScreenshotWatcher.systemFolder()` and `ScreenshotScan.folder` |
| Did something new appear | a file system event plus a poll |
| Is the new file a screenshot | the capture tag, the folder rule, a name list |
| Is the file finished | a size check |
| Did the user change the folder meanwhile | the poll compares the folder each time |

## Which folder: the preference and the key that moved

macOS keeps the screenshot settings in the `com.apple.screencapture` preferences domain. The folder is a string value there. The first version of the watcher, committed on 11 October 2026, read it like this:

```swift
let configured = UserDefaults.standard.persistentDomain(forName: "com.apple.screencapture")?["location"] as? String
```

On 12 October 2026, release 1.0.5 changed the reading. The comment on the folder function in `ScreenshotScan.swift` states the finding: macOS 27 keeps the folder in `location-screenshot` and ignores the old `location`, while earlier versions only have `location`. The commit message for the release says the same: the watcher reads `location-screenshot` (macOS 27) and `location`. The user visible line in the changelog is "Fixed: on macOS 27 with a custom screenshot folder, screenshots never reached the notch."

The consequence is easy to see from the code. If the key your app reads is not the key the system writes, the answer is "not set", and a not set folder means the default, which is the Desktop. A user with screenshots going to `~/Pictures/Screenshots` would have an app quietly watching the wrong place. There is no error, because nothing failed.

The current reader asks the preferences system for both keys and takes the first one that is not blank:

```swift
static func configuredLocation() -> String? {
    let domain = "com.apple.screencapture" as CFString
    CFPreferencesAppSynchronize(domain)
    let modern = CFPreferencesCopyAppValue("location-screenshot" as CFString, domain) as? String
    let legacy = CFPreferencesCopyAppValue("location" as CFString, domain) as? String
    return [modern, legacy].compactMap { $0 }.first { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
}
```

Two details deserve a sentence each. The new key comes first, so a Mac that has both uses the modern one. And `CFPreferencesAppSynchronize` runs before the read, because the user can change the folder at any time in the Screenshot app's options and the app must see the current value, not a cached one.

What the Mac used for this post shows, recorded on 4 November 2026 (macOS 27.0.1, build 26A434), from `defaults read com.apple.screencapture`: the domain exists with the keys `show-thumbnail`, `style`, `target-screenrecording`, `target-screenshot`, `video` and `last-analytics-stamp`. Asking for `location` and for `location-screenshot` each answered that the key could not be found, because no custom folder is set here. So the new keys can be seen, but the claim that macOS 27 writes the folder to `location-screenshot` is the project's, and was not reproduced for this post.

> **Lesson:** A preference you read but never wrote can change its name in the next macOS release. Read the old and the new key, prefer the new one, and fall back to a default only when both are blank.
The rest of the folder logic is a function with tests. A blank value means Desktop, `~` and `~/` are expanded against the home folder, and if the folder does not exist or is not a directory, `systemFolder()` falls back to the Desktop, "the same way macOS falls back". An environment variable, `NOTCHSTAR_SCREENSHOT_DIR`, overrides everything and is how tests point the watcher at a temporary folder.

```swift
XCTAssertEqual(ScreenshotScan.folder(home: home, configured: nil).path, "/Users/ada/Desktop")
XCTAssertEqual(ScreenshotScan.folder(home: home, configured: "~/Pictures/Screenshots").path, "/Users/ada/Pictures/Screenshots")
```

## Noticing a new file: an event and a poll

The watcher uses two mechanisms together, and the doc comment says why: the system tells the app the moment the folder changes, and a slow poll also runs, to follow the folder if the user moves it.

The event is a dispatch source on the folder with `.write` and `.rename`:

```swift
let fd = await Task.detached(priority: .utility) { open(dir.path, O_EVTONLY) }.value
guard fd >= 0 else { return }
let src = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fd, eventMask: [.write, .rename], queue: .main)
src.setEventHandler { [weak self] in
    Task { @MainActor [weak self] in
        try? await Task.sleep(for: .milliseconds(150))
        await self?.poll()
    }
}
```

`open` happens off the main thread because it can block while macOS asks permission for Desktop access. The handler waits 150 milliseconds before scanning, and the comment says the reason: macOS writes a hidden file and renames it.

That claim was checked with a small standalone program on 4 November 2026. It watched a temporary folder, wrote a hidden `.Screenshot.png.tmp` and renamed it to `Screenshot test.png`. The event handler fired twice for the one capture, about 0.5 ms apart in the first run (0.50 and 0.52 ms) and 1.5 ms apart in the second (1.48 and 1.54 ms), measured from just before the write. Only the renamed file was visible to a directory listing that skips hidden files. One capture, two events: that is the case for waiting a moment and for scanning in a way that tolerates being called twice.

The scanning function does that. `poll()` guards against overlapping runs with two flags, `scanning` and `rescan`, so a second trigger during a scan marks a repeat and is not run in parallel:

```swift
func poll() async {
    if scanning { rescan = true; return }
    scanning = true
    defer { scanning = false }
    repeat {
        rescan = false
        // list the folder, compare with `known`, admit new captures
    } while rescan
}
```

The poll itself runs every second (`pollInterval` is `.seconds(1)`), even without events. If the folder path has changed since the last pass, the watcher adopts the new folder, takes a fresh listing as the baseline and restarts the event source on it. The first listing, at startup, also only remembers what is already there, so old screenshots do not flood the strip.

The earlier shape of the watcher shows what the event source replaced. The version committed on 11 October 2026 polled every 400 milliseconds, with no event source, and on a folder change it only took a new baseline and returned. Release 1.0.5 lengthened the poll to one second and added the event for speed. Fewer wake ups and a faster reaction came from the same change, though neither was measured as a number.

## Is it a screenshot: three tests in order

Listing the Desktop gives every file on it, not just captures. `ScreenshotScan.scan` decides for each new path:

1. Not an image extension (`png`, `jpg`, `jpeg`, `heic`, `tif`, `tiff`): remember it and move on.
2. If the folder is dedicated, meaning its name is `Screenshots`, any image counts. Otherwise the file must be tagged as a capture or have a name that looks like one.
3. If the file has zero bytes, do not remember it; look again next pass.

The tag is an extended attribute that macOS sets on captures, whatever the file is called:

```swift
static func isTaggedCapture(_ url: URL) -> Bool {
    url.withUnsafeFileSystemRepresentation { path in
        guard let path else { return false }
        return getxattr(path, "com.apple.metadata:kMDItemIsScreenCapture", nil, 0, 0, 0) >= 0
    }
}
```

It works in every language and survives a rename. The name list is the fallback: 8 prefixes, compared case insensitively, including `screenshot`, `screen shot`, `bildschirmfoto`, `captura de pantalla`, `schermata` and `skärmavbild`. The tests cover both ways of failing: `Screenshot.txt` and `notes.png` on the Desktop are remembered and never admitted, an empty file is admitted only once it has 40,000 bytes in the test, and an already seen file stays off the strip.

## Numbers and method

| Measure | Value | Method |
|---|---|---|
| Poll interval | 1 s | `pollInterval = .seconds(1)` in `ScreenshotWatcher.swift` |
| Wait after a folder event | 150 ms | `Task.sleep(for: .milliseconds(150))` |
| Events for one hidden write plus rename | 2 | standalone `swiftc` program, 4 November 2026, run twice |
| Gap between the two events | 0.02 ms and 0.06 ms | same program, from the printed values |
| Name prefixes | 8 | `namePrefixes` in `ScreenshotScan.swift` |
| Strip capacity and lifetime | 12 items, 5 minutes | project facts, `ScreenshotTray.swift` |
| OS and build used | macOS 27.0.1, 26A434 | `sw_vers`, 4 November 2026 |
| Keys found on this Mac | `show-thumbnail`, `style`, `target-screenrecording`, `target-screenshot`, `video`, `last-analytics-stamp` | `defaults read com.apple.screencapture`, 4 November 2026 |

No timing is claimed for how long the whole path takes from pressing the shortcut to the strip. That depends on macOS saving the file, and the preview setting matters: with the floating thumbnail on, the file may not be saved for about five seconds. NotchStar's optional "Show screenshots instantly" turns that preview off, saves the user's own value first and puts it back when the app quits.

## What is still bad

- The macOS 27 key behaviour rests on the project's own finding and was not reproduced for this post. The Mac used here has no custom folder.
- A future macOS can rename the key again. The code has no way to detect that except a user report.
- The Desktop case depends on the capture tag or the name list. A localisation whose prefix is not in the list, with the tag missing, would not be recognised on the Desktop. In a folder called `Screenshots` any image is accepted, including ones that are not screenshots.
- The dedicated folder rule is a name match on `Screenshots`. A custom folder with another name is treated like the Desktop.
- The poll runs every second for as long as the watcher is on. Its energy cost has not been measured.
- Listing a folder can be slow when it is a cloud synced Desktop. The code lists off the main thread for that reason, and a slow listing still delays admission.

## Try it yourself

1. Run `defaults read com.apple.screencapture` and note which keys exist on your Mac.
2. Change the save location in the Screenshot app options, and read the domain again. See which key changed. Put it back afterwards.
3. In a scratch program, open the folder with `O_EVTONLY` and attach a dispatch source for `.write` and `.rename`. Take a screenshot and count the events.
4. On a captured file, run `xattr -l` on it and look for `com.apple.metadata:kMDItemIsScreenCapture`.
5. Rename the file and check whether the attribute is still there.

## Cheat sheet

| Need | Do this |
|---|---|
| Find the save folder | Read `location-screenshot`, then `location`, then use the Desktop |
| Get a fresh value | Call `CFPreferencesAppSynchronize` first |
| React quickly | A dispatch source on the folder with `.write` and `.rename` |
| Survive missed events and folder changes | Also poll, and compare the folder each time |
| Avoid half written files | Wait about 150 ms after an event and skip zero byte files |
| Recognise a capture in any language | The `kMDItemIsScreenCapture` attribute, then a name list |
| Stay off the main thread | List and `open` in a detached utility task |

## Close

NotchStar is a Mac app that puts ten tools in the notch, and this watcher feeds its screenshot strip. If you want to try it, the details are at [notchstar.com](https://www.notchstar.com).

## More posts

- [Clipboard history without a daemon: NSPasteboard changeCount and concealed types](https://www.notchstar.com/blog/nspasteboard-changecount-clipboard-history-concealed/)
- [A license client with no Keychain: a 0600 file, weekly revalidation and a soft wall](https://www.notchstar.com/blog/license-client-without-keychain-swift/)
