Screen Recording Permission Has Four Answers, Not Two

Screen Recording Permission Has Four Answers, Not Two

September 10, 2026
Four states, not two

The first thing anybody building a screen recorder on macOS discovers is that asking for permission is easy and handling the answer is not. There is a call, it triggers a system prompt, and the user says yes or no. That is the model in your head, and it is wrong in a way that produces a support queue.

There are four states, not two. Two of them are indistinguishable from your code and require completely different things from your interface. This is what we learned building the screen capture side of HappyRec.

The four states

Four states, not two
Granted is one of four answers, and two of the others look the same from code.

Not determined is the state before you have ever asked. This is the only state in which a prompt will appear. Call the API here and the system asks the user; call it in any other state and it does not.

Granted is the state you designed for. You get a stream, you start it, you record.

Denied means the user said no, once, at some point in the past — possibly months ago, possibly on a build of your app they have since deleted and reinstalled. The API returns an error. No prompt appears. There is nothing you can do from code to change this, and there never will be.

Restricted means a policy prevents it. A managed device, a parental control, a mobile device management profile. The user is not refusing; the user cannot agree.

Denied and restricted return the same error. If your interface says “please allow screen recording in System Settings” to a user under an MDM policy, you have sent them to a switch they are not permitted to touch, and they will conclude your app is broken.

You only get one prompt, ever

This is the part that catches people, and it is worth being precise about because the behaviour is different from what most developers assume from other platforms.

The system prompt appears exactly once per application identity. If the user dismisses it, that counts as a refusal. If they click deny by reflex — and a meaningful number of people click deny on any dialog that appears before they understand the app — that decision is permanent from your code’s point of view. You cannot ask again. You cannot re-trigger the prompt. The only route back is the user opening System Settings themselves and turning your app on.

The consequence is that the moment you choose to trigger the prompt is one of the most important decisions in the product. Trigger it on first launch, before the person knows what your app does, and you will lose a share of your users permanently to a reflex click.

Ask at the moment they want the thing

We moved the request to the moment the user presses record for the first time. By then they have opened the app deliberately, they have chosen what to capture, and they are expecting something to happen. The prompt arrives as an answer to their own action rather than as an interruption.

Before it appears, we show one sentence of our own explaining what is about to be asked and why. The system prompt has room for a short usage description and nothing else; the explanation has to come from you, in your own window, immediately before.

What to do in each state

  1. Not determined — explain, then ask. One sentence, then the prompt. Do not ask on launch.
  2. Granted — proceed, and show unmistakably that recording is happening. An indicator that only you can see is not an indicator.
  3. Denied — explain that permission was refused earlier, and open the exact settings pane for them. Do not just name it; open it.
  4. Restricted — say that a policy on this device prevents screen recording and that an administrator has to change it. Do not send them to a settings pane they cannot use.

Distinguishing denied from restricted is not possible from the capture API alone, and this is where a small amount of extra work pays off. The managed-device signals available to a Mac application are enough to tell you that the machine is under management, which is sufficient to change the wording from “you refused this” to “your organisation prevents this”. Getting that sentence right is the difference between a support mail and no support mail.

What you are allowed to capture

What you are allowed to ask for
A filter is a promise about what will be captured, and some of those promises can be broken by the user.

Permission granted, the next question is what to point the capture at, and the choice has consequences that only appear during a long recording.

A display filter is the most robust. It survives windows opening, closing, moving and being minimised, because it is capturing a screen rather than a thing on it.

A window filter is a handle to an object the user controls. They will move it, which is fine, and they will close it, which ends your stream mid-recording. If you offer window capture, you must decide in advance what happens then: end the take cleanly and tell them, or fall back to the display. Doing nothing means the recording stops and the file may not be finalised.

An application filter is the middle ground and, for most screen-recording use cases, the one people actually want. It captures every window belonging to an app, so a user switching between two documents in the same application stays captured.

There is also the exclusion filter — a display minus specified windows — which is what you use to keep your own recorder interface out of the recording. Forgetting this produces the infinite-mirror effect the first time somebody records a screen that contains your own control panel.

Frames stop arriving, and that is normal

Frames stop; the clock does not
No new frame does not mean something failed. A static screen produces nothing at all.

ScreenCaptureKit delivers frames when the screen changes. A user reading a document produces no frames for as long as they read. This is efficient and correct and it breaks the first watchdog anybody writes.

The naive health check — if no frame has arrived for two seconds, the stream is stuck, restart it — will fire constantly during ordinary use. We shipped that once. It restarted the stream eleven times during a five-minute recording of somebody reading, and each restart cost a visible hitch.

The correct model is that the video track is driven by the clock, not by frame arrival. At each interval the writer needs a sample; if a new frame has arrived, use it, and if not, present the previous surface again with the current timestamp. The file ends up with the right duration and the right content, and a static screen costs almost nothing to encode.

The audio clock is the reference

When a recording contains both audio and video, one of them has to be authoritative about how long the recording is, and it should always be the audio. Audio arrives at a fixed rate from hardware that does not skip; video arrives irregularly by design. Stretching audio to match a video timeline produces artefacts a listener notices immediately. Repeating a video frame to match an audio timeline produces nothing anybody sees.

Testing this properly

Permission states are difficult to test because your development machine has already granted everything, months ago, and there is no obvious way back.

The command that resets the privacy database for a specific service and bundle identifier is the single most useful thing in this entire area, and it should be in your test script rather than in somebody’s memory. Reset, launch, and walk the flow as a new user — including refusing, so that you see the denied path as your users will.

For the restricted state, a test device under a management profile is the only honest way to check it. If you do not have one, at minimum write the copy for that case and confirm it can be triggered by a debug flag, so the string exists and has been read by somebody.

  • Reset the permission and refuse it. Does your app explain, or show an empty window?
  • Reset and dismiss the prompt without choosing. Same path as refusal — does it behave the same?
  • Grant, then revoke in System Settings while the app is running. Does the running stream fail cleanly?
  • Capture a single window, then close that window. Does the recording end properly or leave a broken file?
  • Record a static screen for two minutes. Is the file two minutes long?

The fourth and fifth are the ones that find real bugs, and neither takes more than a minute.

The indicator you do not control

On modern macOS, screen recording puts an indicator in the menu bar that your application does not own and cannot suppress. This is a good thing and it changes what your own interface needs to do.

Because the system already tells the user that recording is happening, your job is not to prove it. Your job is to make it possible to stop. A recorder whose only stop control is inside a window the user has since covered with the thing they are recording is a recorder people force-quit, and force-quitting mid-write is how files end up unfinalised.

This is most of the argument for a menu bar presence in an application of this kind. Not because a menu bar app is fashionable, but because the one control that must always be reachable is stop.

The icon is the interface
The icon carries the state, because the window will be covered by whatever is being recorded.

The state the icon must show is not just recording or not. There are four, and the fourth is the one that earns the rest: a recording that stopped without the user stopping it. If your icon can only show good news, people stop looking at it.

What happens when permission is revoked mid-recording

A user can open System Settings during a recording and turn your app off. It is rare and it does happen, usually by somebody exploring settings while a long recording runs in the background.

The stream stops delivering. If you have built the frame handling correctly, this looks exactly like a static screen — no frames arriving — which means a naive implementation will happily record another twenty minutes of a frozen image and produce a file the user cannot use.

Distinguishing the two requires listening for the stream error rather than inferring from frame arrival. The stream tells you it has stopped; the absence of frames does not. This is the same lesson as the watchdog, from the other direction: frame arrival is not a health signal in either direction.

When it happens, the right behaviour is to finalise what you have, tell the user plainly what occurred, and keep the file. A partial recording that ends where permission was withdrawn is far more useful than a full-length file of a frozen screen.

Multiple displays, and the one users actually mean

A machine with two screens presents a choice your interface has to make explicit, because there is no sensible default.

The main display is where the menu bar is, which is not necessarily where the work is. The display containing the frontmost window changes as the user works. The display your own window is on is the least likely to be the one they want recorded, since they are about to move away from it.

We ask, with a preview of each screen, and we remember the answer per session rather than permanently — because the reason a person has two screens is that they use both, and last week’s choice is not evidence about today’s.

One detail that costs nothing and prevents a class of confusion: label the screens with their actual names and resolutions rather than Display 1 and Display 2. Nobody knows which one the system considers first.

Performance, and where it actually goes

Screen capture at full resolution on a high-density display is a large amount of pixel data per second, and the instinct is to worry about the encoder. In practice the encoder is rarely the problem on modern hardware; the problem is everything you do to the frame before it gets there.

A frame arrives as a surface that is already on the GPU. Every operation that reads it into main memory — converting it to an image type for convenience, drawing it into a bitmap context, handing it to a framework that expects a different representation — costs a full copy of a very large buffer, sixty times a second.

The rule that kept our capture path fast is that a screen frame is never converted, only composited and written. Anything that needs to inspect a frame gets a downscaled copy on a separate queue, at a much lower rate, and never blocks the capture path.

If frame rate collapses under load, look at what touches the frame before the encoder, not at the encoder. In our case the entire problem was one convenience conversion written during a prototype and never removed.

What we would tell anyone building this

  1. Model four states, not two. Denied and restricted need different words.
  2. Ask at the moment of use, never on launch. You get one prompt for the life of the install.
  3. Explain before the prompt, in your own window, in one sentence.
  4. Open the settings pane for a denied user rather than naming it.
  5. Prefer application or display filters over window filters unless the user explicitly asked for a window.
  6. Exclude your own interface from the capture.
  7. Drive the video track from the clock, not from frame arrival, and let audio be the reference.
  8. Put the permission reset in your test script. You will need it every week.

None of this is difficult once the model is right. The trouble comes entirely from starting with a two-state assumption that seems obviously correct and is quietly wrong for a meaningful share of the people who will install your app.