Jeff Johnson (My apps, PayPal.Me, Mastodon)

Feedback Assistant Boycott

Disabled Safari extensions are not fully disabled, and other problems

November 30 2023

What happens when you update a Safari extension in the App Store while Safari is open?

If it's a Safari app extension, such as my own StopTheMadness, you see this:

Close Safari to Update

In contrast, a Safari web extension, such as my own Homecoming for Mastodon, can be updated while Safari is open. I have a theory about why, which I'll discuss later in this blog post. (I delineated Safari app extensions and Safari web extensions in another blog post. TL;DR Safari app extensions are exclusive to Safari for Mac, whereas Safari web extensions are cross-platform, supporting both Mac and iOS, and maintaining API compatibility with Chrome and Firefox extensions.)

Needless to say, it can be annoying to be forced to quit Safari in order to update a Safari extension in the App Store. A few days ago, Nick Heer of Pixel Envy emailed me with a clever workaround: Nick discovered that if you disable a Safari app extension in Safari Extensions Settings, you can then update the extension in the App Store without quitting Safari!

Safari Extensions Settings pane

Nick asked me if there were any downsides to this solution. My attempt to answer Nick's question sent me down a rabbit hole that ultimately led to a blog post, the natural end to many of my rabbit holes. What I learned during my deep digging was… disturbing.

A web page is basically a collection of files, mainly HTML, JavaScript, and CSS files. Web browser extensions, including Safari extensions, can inject their own files into web pages. JavaScript files injected from extensions are called content scripts, and CSS files injected from extensions are called style sheets. StopTheMadness injects a content script and a style sheet into web pages. When you disable StopTheMadness in Safari Extension Settings, Safari disables the injected style sheet in open web pages. However, it turns out that Safari does not disable the injected content script. Thus, the toggle in Safari Extension Settings is a bit misleading. Even when disabled, a part of StopTheMadness remains in open Safari tabs—indeed, remains functional or at least semi-functional—until the tab is closed or reloaded.

The good news is that when you navigate to a new page in a Safari tab after disabling the extension, its content script won't get injected into the new page. The bad news is that if you navigate back to the old page with Safari's back button, the disabled extension's injected content script remains in the cache of the old page.

Following Nick Heer's workaround, when you subsequently reenable StopTheMadness after updating to the latest version in the App Store while Safari is still open, Safari injects the updated extension's content script and style sheet into open web pages that the extension has permission to access, which is typically all of them, including the pages with leftover content scripts from the previous version of the extension. Consequently, an App Store update can leave you with two different versions of the extension's content script running simultaneously in the same web pages! This is a very undesirable situation, because the two competing scripts could conflict in unpredictable ways. You've suddenly gone from stopping the madness to starting the madness. In hindsight, therefore, quitting Safari in order to update extensions seems like a good idea, and that's what I recommend to avoid potential issues and weird behavior.

You may be wondering, since the App Store allows you to update Safari web extensions without quitting Safari, how do they avoid the issues faced by StopTheMadness and other Safari app extensions? The answer, surprisingly, is that they don't! In my testing, after you update a Safari web extension while Safari is running, open web pages will contain duplicate content scripts, injected from the old and the new version of the extension. In this respect, there's no difference between Safari web extensions and Safari app extensions. The same problem exists, regardless of the extension type.

I said at the beginning of the blog post that I have a theory about why the App Store has different update requirements for Safari app extensions and Safari web extensions. I can now state my theory: the reason has nothing to do with injected content scripts and everything to do with extension processes. Safari creates a separate process for each extension, which you can see in the Activity Monitor app.

Activity Monitor Process Name StopTheMadness (Safari)

For Safari app extensions, the lifetime of the extension process appears to match the lifetime of Safari itself. (The extension process uses 0% CPU while the extension is inactive, so there's no need to worry about resource consumption.)

For Safari web extensions, the lifetime of the extension process depends on the extension's background script. Safari supports both persistent and non-persistent background scripts on macOS but only non-persistent background scripts on iOS. The extension process of a Safari web extension with a persistent background script runs as long as Safari does, just like a Safari app extension. On the other hand, the extension process of a Safari web extension with a non-persistent background script (or no background script) is created and destroyed on demand. Homecoming for Mastodon has a non-persistent background script.

Activity Monitor Process Name Homecoming for Mastodon (Personal) Safari Web Extension

As an experiment, I force quit the StopTheMadness extension process in Activity Monitor while the extension was still enabled, and I discovered that the App Store suddenly allowed me to update StopTheMadness without quitting Safari! Coincidentally, disabling StopTheMadness in Safari Extensions Settings also makes the StopTheMadness extension process quit, which may explain why Nick Heer's trick works.

Moreover, whenever I tried to update Homecoming for Mastodon in the App Store while its extension process was running, the extension process always quit on its own, without my intervention, thereby allowing me to update successfully. Safari can terminate the extension process when necessary, because the extension has a non-persistent background script.

An aside on my testing method: every time I release an update in the App Store, I compress the updated app into a zip file with Finder and add the app's version number to the file name, for the purpose of archiving. Then if I ever need to test an older version of the app, I just unzip it. My app archive is perfect for testing App Store updates, since I can easily revert to an older version and update to the latest version as many times as I want. Of course, this method only works on the Mac, as Apple in its infinite looping wisdom has forbidden users from directly accessing the iOS file system. Nonetheless, I strongly suspect that the conclusions of this blog post apply also to extensions in Mobile Safari. There's already known to be a lot of overlap between Mac Safari and Mobile Safari.

I don't develop a Safari web extension in the App Store with a persistent background script, so unfortunately I was unable to test that particular scenario. I'm very curious whether a persistent background script would prevent the App Store from updating the extension while Safari is open. If so, that would provide further confirmation of my theory.

I've commingled the issues of App Store extension updates and extension content script duplicates in this blog post, but the conclusion of my investigation seems to be that they're actually separate issues. After all, you can disable and reenable an extension in Safari without updating it, and you'll still see the issue of duplicated injected content scripts. Although two scripts would be identical in this case, because they're both from the same extension version, they can still conflict with each other or cause other unexpected problems, because the extension was not designed to have duplicate content scripts running simultaneously in the same web page frame.

How does Safari's behavior compare to other web browsers? To summarize, Safari (1) does inject the extension's content scripts into open web pages when enabling the extension, (2) does not disable the extension's content scripts when disabling the extension, and (3) does include the disabled extension's content scripts in the page cache.

In my testing, Chrome (1) does not inject the extension's content scripts into open web pages when enabling the extension, (2) does not disable the extension's content scripts when disabling the extension, and (3) does not include the disabled extension's content scripts in the page cache. Firefox (1) does inject the extension's content scripts into open web pages when enabling the extension, (2) does disable the extension's content scripts when disabling the extension, and (3) does not include the disabled extension's content scripts in the page cache (because of 1).

Like Safari extensions, disabled Chrome extensions are not fully disabled. Safari and Chrome suffer that same problem. However, Chrome doesn't suffer the problem of duplicated content scripts in the same web page, because it doesn't inject a new content script into a page that already has an old injected script. Firefox, to its credit, doesn't suffer either problem. Safari is the only browser with the duplicated content script problem.

By the way, if longtime readers get a vaguely familiar feeling from this blog post, that's because I wrote a couple of vaguely similar blog posts several years ago. Back then, Safari was launching the aforementioned app extension process even when the extension was disabled. Apple Product Security initially dismissed my report, but Apple later fixed the issue and credited me. At the end of that episode I said, "A disabled Safari app extension is now truly disabled in every way." Clearly, that statement did not age well!

Feedback Assistant Boycott

Jeff Johnson (My apps, PayPal.Me, Mastodon)