context.new_page() brought a headed browser to the front, so I opened tabs through CDP Target.createTarget with background: true. That call un-hides a hidden browser, so a launch wrapper also re-hides it whenever it isn't the frontmost app, and a one-hour watch of 3 publish runs saw neither browser come to the front.A blog publishing job ran every 15 minutes on the Mac I work on, and each run opened a browser tab through Playwright. Any of those runs could pull the browser over the app I was using. A stats job at 09:20 could do the same. I have no count of how many runs actually took the screen.
The browsers themselves made this hard to avoid. There were two, both headed and always on, both driven over CDP by scheduled jobs: Chrome for Testing on port 9333 and Google Chrome on port 9334. launchd kept both alive with KeepAlive, so closing one didn't help. It relaunched within 30 seconds and its window came to the front. On October 30, at 18:54 and 21:05, both browsers exited normally (exit 0) and relaunched. My notes record the exit code and not the reason.
That made two separate ways onto my screen: a job opening a tab, and a browser coming back after an exit. The fix has one layer for each, and the second layer is needed because the first one un-hides the browser.
Everything here is Mac-only. open, launchd and NSRunningApplication are macOS pieces, and every result below comes from one Mac. The tab layer is Playwright and CDP, but I only ran it there too.
The first row comes from a probe on October 29.
| What ran | What happened |
|---|---|
context.new_page() on a headed browser | The browser came to the front and took the focus |
CDP Target.createTarget with background: true | Focus stayed where it was, but a hidden browser got un-hidden |
open -j, which launches hidden | No effect, because Chrome shows itself when it opens its first window |
open -g on a relaunched Google Chrome with an existing profile | The browser came to the front anyway |
bring_to_front() in one other job | Called on purpose, because that job needs the clipboard |
The two open rows ruled out a launch flag as the whole answer. -g is the flag meant to keep a launched app in the background, and it didn't hold for that relaunch. I can't say whether the existing profile is the cause or only the condition I saw it in, because I didn't check why.
The second row shaped the design. background: true solves the focus problem, and a background tab by itself leaves a hidden browser visible again.
The last row was never a bug. That job pulls the browser forward deliberately, and I decided to leave it that way.
BackgroundTabs replaces new_page() with the CDP call and hands everything else to the real context.
class BackgroundTabs:
def __init__(self, context):
self._context = context
self._cdp = None
def new_page(self):
if self._cdp is None:
self._cdp = self._context.browser.new_browser_cdp_session()
with self._context.expect_page() as opened:
self._cdp.send('Target.createTarget', {'url': 'about:blank', 'background': True})
return opened.value
def __getattr__(self, name):
return getattr(self._context, name)
Reading it from the top:
expect_page() wraps the send, so the caller gets a Playwright page back, the same kind of object new_page() returns.about:blank, so the caller still has to navigate it.__getattr__ forwards every other attribute to the wrapped context.I wrapped the context instead of editing the browser module because a deployment review pins that module's bytes. Session pages and the re-login tab go through the wrapper too, since any path left on new_page() would still take the focus.
If your browser isn't hidden and you only mind the focus change on new tabs, this layer is all my table calls for. It does nothing for the relaunch path.
launchd started each browser through a shell wrapper. This is its launch line:
open -n -g -a "$APP" --args --user-data-dir=... --remote-debugging-port=... --remote-debugging-address=127.0.0.1
-g stays on that line, but the wrapper doesn't depend on it. What keeps the browser out of the way is a hide loop that starts after launch:
The frontmost check is what leaves me a way in. If I switch to the browser with Cmd+Tab or the Dock, it's frontmost, so the loop leaves it visible. By the same rule, a browser un-hidden by a background tab is visible but not frontmost, so the next pass hides it again. That's how the two layers fit together.
The hide is NSRunningApplication.hide, called through the JXA ObjC bridge with osascript -l JavaScript. It's not an Apple Event, so there's no automation permission prompt.
The rest of the wrapper keeps launchd's view of the process correct:
Each path to the screen maps to a different check:
| Path to the screen | Layer that handles it | Check |
|---|---|---|
| A job opens a tab | BackgroundTabs | One-hour watch |
| A browser relaunches | Launch wrapper | Simulated Cmd+Q on the real browser |
| A background tab un-hides a hidden browser | Hide loop | No direct measurement, only the rule |
The temporary-profile check sits outside that table. It's there because a hidden browser is only useful if the jobs can still work in its pages.
The evidence is one hour, 3 runs and one Mac. The publishing job was paused at 21:37 that evening by my decision, so this hour is the only watch on record. Some specific gaps follow from that:
bring_to_front() job would stay there, the same as if I had switched to it. I have no measurement of that case. It would also trip the falsifier below as written, so any such event has to be checked against that job first.-g.The falsifier is specific: either browser comes to the front with no human switching to it, or a hidden browser produces empty screenshots or timeouts. Before calling this settled I'd want a longer watch that includes a 09:20 stats run and a run of the bring_to_front() job, because nothing in that hour's record shows either one.
In a probe on my Mac, context.new_page() brought the headed browser to the front and took the focus. The probe measured the effect, and I didn't check the reason.
I replaced new_page() with a wrapper that sends the CDP call Target.createTarget with background set to true through a browser-level CDP session, inside expect_page() so the caller still gets a Playwright page back. Focus stayed where it was, but this call does un-hide a hidden browser.
open -j had no effect because Chrome shows itself when it opens its first window. A relaunched Google Chrome with an existing profile came to the front even with open -g. I didn't check why, so the wrapper hides the browser after launch instead of depending on the flag.
For the first 20 seconds after launch it hides unconditionally. After that, every 2 seconds, it hides only when the browser is not the frontmost app, so switching to it with Cmd+Tab or the Dock keeps it visible. The hide is NSRunningApplication.hide through the JXA ObjC bridge, which is not an Apple Event, so there's no automation permission prompt.
Either browser coming to the front with no human switching to it, or a hidden browser producing empty screenshots or timeouts. The evidence so far is one hour, 3 runs and one Mac, and that hour's record shows neither the 09:20 stats job nor the job that calls bring_to_front() on purpose.