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

Deep dive into a macOS default web browser bug

December 20 2024

This blog post has undergone some revision and correction since first published. It turns out, contrary to my initial assumption, that the code signatures of the apps is largely irrelevant. Thanks to Avi Drissman of the Google Chrome team for assistance!

According to the Apple Developer Documentation, "macOS Launch Services is an API that enables a running app to open other apps or their document files, similar to the Finder or the Dock." Launch Services covers an assortment of functionality on macOS, including control over your default web browser, which you can (normally) see and change in System Settings under (for some strange reason) Desktop & Dock.

Launch Services has become quite buggy in recent years. One notorious bug was the case of the disappearing Safari extensions, introduced in macOS 11 Big Sur and finally fixed in macOS 13 Ventura. In this blog post, I'll discuss a current bug involving multiple versions of the same app installed on one Mac. The bug is particularly painful for Mac software developers such as myself, because we're constantly building and running different versions of the apps that we develop.

Technically speaking, apps are treated as the same by Launch Services if they share a bundle identifier. For illustration, I'll use my app Link Unshortener. The App Store installs it at /Applications/Link Unshortener.app in the file system. In Terminal app, the following command displays the contents of Link Unshortener's Info.plist file, which contains essential information about the app:

cat '/Applications/Link Unshortener.app/Contents/Info.plist'

Link Unshortener's bundle identifier:

<key>CFBundleIdentifier</key>
<string>com.underpassapp.LinkUnshortener</string>

Link Unshortener's bundle version:

<key>CFBundleVersion</key>
<string>54</string>

I've set Link Unshortener as the default web browser on my Macs, which is possible because the app also declares in its Info.plist file that it handles http and https website URL schemes. This command opens the specified URL in the default web browser, which for me is Link Unshortener:

open https://www.apple.com

When you set the default web browser on your Mac, Launch Services records not only the bundle identifier of the app but also, for some strange reason, its bundle version. You can find this information in the ~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist file:

<key>LSHandlerPreferredVersions</key>
<dict>
<key>LSHandlerRoleAll</key>
<string>54.0</string>
</dict>
<key>LSHandlerRoleAll</key>
<string>com.underpassapp.linkunshortener</string>
<key>LSHandlerURLScheme</key>
<string>http</string>

As long as you don't update your default web browser, you can move the app almost anywhere in the file system on your Mac—or onto the file system of another Mac connected by File Sharing!—and Launch Services will find it. (I was stunned to discover that if my default web browser is not currently installed on one Mac, then open https://www.apple.com will open it from another Mac on my LAN.)

Inevitably, you will install a new version of your default web browser, after which Launch Services will no longer be able to find an app that matches the specific bundle version in its records. What happens then? As you might expect, Launch Services attempts to fall back to an app with the same bundle identifier. If there's only one such app installed, then open https://www.apple.com will open it.

If there's more than one app installed with the same bundle identifier as your default web browser, Launch Services faces a kind of dilemma in selecting a fallback. This might not be a common scenario for end users, but it's a common scenario for me as a software developer. In addition to the latest App Store version of Link Unshortener, I may also have a newer, unpublished version of Link Unshortener that I'm currently working on.

Launch Services has an interesting fallback algorithm for selecting the default web browser. Apps inside the root-level /Applications folder are preferred to apps elsewhere in the file system (preferred even to apps inside the user-level ~/Applications folder). This preference is unfortunate for me, because the Xcode developer tools standardly build apps in the ~/Library/Developer/Xcode folder. Thus, the App Store version of Link Unshortener would be selected by Launch Services over the development version. For the purpose of testing Link Unshortener, the standard Xcode build folder is deficient.

To work around the Launch Services preference for /Applications, I've changed the build location in the advanced project settings of Link Unshortener's Xcode project to use a custom, absolute path inside a subfolder of the /Applications folder, which means that building Link Unshortener in Xcode installs the app within the /Applications folder. A subfolder is used because the App Store app is owned by the root user, so Xcode doesn't have permission to overwrite it, and anyway I wouldn't want Xcode to remove the App Store version. As a result of my build configuration, the /Applications folder contains two versions of Link Unshortener, usually with different version numbers.

When there's more than one version of an app inside the /Applications folder, Launch Services selects the one with the higher version number (assuming that neither app has the same bundle version as your original choice of default web browser, stored in the Launch Services preferences file). Consequently, open https://www.apple.com uses my newer version built by Xcode rather than the older version installed by the App Store. This allows me to test the full functionality of Link Unshortener during development.

You can see the same behavior from the Terminal command to open an app with the specified bundle identifier:

open -b com.underpassapp.LinkUnshortener

This command again prefers apps inside the /Applications folder to apps elsewhere in the file system. And it prefers the app with the highest version number to apps with lower version numbers.

A long time ago, in a paragraph far, far above, I claimed that I was going to discuss a Launch Services bug involving the default web browser. This claim was true. With the necessary background out of the way, I'll get on with it. There are a number of components to the bug:

  1. Xcode build registers the built app with Launch Services, but Xcode clean does not unregister the built app with Launch Services.
  2. Xcode clean does not directly remove the built app but only the build products folder containing the app.
  3. Removing a parent folder of an app does not unregister the app with Launch Services.
  4. Launch Services gets confused by a stale record of a nonexistent app.

I'll discuss these components in more detail. If you check the Xcode build transcript, you can see that the last step of the build process is to register the built app with Launch Services:

/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -R -trusted /Applications/XcodeBuilds/LinkUnshortener/Build/Products/Debug/Link\ Unshortener.app

On the other hand, no invocation of lsregister -f -u occurs when cleaning the build folder in Xcode.

The good news is that the command below does automatically unregister the app with Launch Services (as does moving the app bundle to the Trash in Finder):

rm -fR /Applications/XcodeBuilds/LinkUnshortener/Build/Products/Debug/Link\ Unshortener.app

The bad news is that Xcode clean doesn't run the above command! Rather, Xcode clean does the equivalent of this command:

rm -fR /Applications/XcodeBuilds/LinkUnshortener/Build/Products

Unfortunately, neither the above command nor moving the build products folder to the Trash in Finder automatically unregisters apps inside the removed build products folder. Instead, removing a parent folder of an app leaves a stale Launch Services record for the app:

% /System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -dump Bundle | grep 'Link Unshortener.app'
path: /Applications/Link Unshortener.app (0x4de54)
path: /Applications/XcodeBuilds/LinkUnshortener/Build/Products/Debug/Link Unshortener.app (0x4dfd8)

It's worth noting that moving a parent folder of an app to the Trash and emptying the Trash does automatically unregister the app with Launch Services. Xcode clean doesn't use the Trash, however.

The final piece of the puzzle is the algorithm to select the default web browser. Launch Services becomes confused by a stale record of a nonexistent, removed app. You might expect that open https://www.apple.com would fall back to the existing App Store version of Link Unshortener at /Applications/Link Unshortener.app if Launch Services can't find the version of Link Unshortener at /Applications/XcodeBuilds/LinkUnshortener/Build/Products/Debug/Link Unshortener.app, which no longer exists. That's not what happens, though. Rather than falling back to the next valid web browser candidate, Launch Services simply gives up and opens the URL in Safari, regardless of whether you're a Safari user. Even stranger, System Settings shows that my default web browser is Arc! Why Arc? Because it's the first alphabetically in the sorted list of my installed web browsers.

In this strange, buggy situation, changing my default web browser from Arc back to Link Unshortener doesn't stick. The next time I open System Settings, it shows Arc again, and open https://www.apple.com continues to open Safari rather than Link Unshortener. Using the Launch Services API to change my default web browser to Link Unshortener (for example with my open source app Default web browser) is similarly futile.

The only solution to this problem is to delete the stale Launch Services record by force unregistering the removed app:

/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -u '/Applications/XcodeBuilds/LinkUnshortener/Build/Products/Debug/Link Unshortener.app'

After that command is run, /Applications/Link Unshortener.app is restored as the default web browser, and the command open https://www.apple.com once again opens Link Unshortener rather than Safari.

One puzzling aspect of the default web browser selection bug is that unlike the open https://www.apple.com command (which opens the specified URL in the default web browser), the open -b com.underpassapp.LinkUnshortener command (which opens the app with the specified bundle identifier) does successfully fall back to the App Store version of Link Unshortener in the face of a stale Launch Services record for the removed development version of Link Unshortener. There's no confusion in that case.

The bug that I've discussed in this blog post may remind longtime readers of another blog post that I wrote almost five years ago about deleting DerivedData the right way. In that old blog post, I warned against using a popular method of deleting the Xcode DerivedData folder:

rm -fR ~/Library/Developer/Xcode/DerivedData

Coincidentally, the reason for my warning back then was the same as in today's blog post: removing a parent folder of an app doesn't unregister the app with Launch Services! Of course, I wasn't aware at that time of the default web browser bug; indeed, I don't know whether the default web browser bug existed at that time. Nonetheless, filling your Launch Services database with stale app records always seemed like a bad idea. It took a number of years to discover exactly how bad this could be.

By the way, if you'd like to test the default web browser bug yourself, you can use my open source app PrivateWindow, which can be set as the default web browser on your Mac. You don't actually need to enable PrivateWindow in Accessibility System Settings, as described in the installation instructions, because the only point of the test is to open the PrivateWindow app, not to open a private window in Safari.

There are three versions of the PrivateWindow app, which is perfect for testing with Launch Services. First, download version 1.0, install it at /Applications/PrivateWindow.app, and set it as your default web browser. Remember that Launch Services will record its specific bundle version. Then delete version 1.0 and install version 2.0 at /Applications/PrivateWindow.app. It should continue to be your default web browser. Finally, install version 3.0 at /Applications/Testing/PrivateWindow.app. Everything I've said about Link Unshortener in this blog post should apply to PrivateWindow as well.

Appendix: Safari Extensions

It turns out that Safari also becomes confused by stale Launch Services records of removed Safari extension apps. For example, I can set the Xcode project of my Safari extension StopTheMadness Pro to build here:

/Applications/XcodeBuilds/StopTheMadnessPro/Build/Products/Debug/StopTheMadness Pro.app

If I clean the build folder and then launch Safari, StopTheMadness Pro disappears from the Extensions pane in Safari Settings. It also disappears from Safari window toolbars. (Older versions of Safari would crash in this situation, but I think Apple fixed that after I reported a problem.)

The only way to restore the extension in Safari from the App Store version of StopTheMadness Pro at /Applications/StopTheMadness Pro.app is to force unregister the removed development version:

/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f -u '/Applications/XcodeBuilds/StopTheMadnessPro/Build/Products/Debug/StopTheMadness Pro.app'

I started building StopTheMadness Pro within the /Applications folder because of another bug: "Regression: Ventura Safari no longer find higher version extensions outside the Applications folder" (FB11795767). However, Safari's confusion over stale Launch Services records made it impossible to continue with that method. What I do now is assign a different bundle identifier to the development version of StopTheMadness Pro, which eliminates any conflict with the App Store version of StopTheMadness Pro. Of course, two different bundle identifiers means that StopTheMadness Pro appears twice in Safari Extensions Settings, so I have to disable and enable the extensions there to switch between them.

Addendum February 18 2026

On December 22, 2024, shortly after publishing this blog post, I filed a bug report with Apple Feedback Assistant: FB16144150 “Removing the parent folder of an app doesn't unregister the app from Launch Services.” Yesterday, more than a year later, I finally received a response from Apple. The resolution was “Investigation complete - Works as currently designed,” and Apple wrote a comment:

Please know that LaunchServices does not listen for filesystem events; we rely on cleaning up the database as we are asked about items. So, the next attempt to launch that application, or bind a document to it, would result in its deletion from the database. We also keep items in the database if they're on a different volume, because that may get unmounted (and so be unavailable), but come back when remounted. Often it's the Spotlight daemon which does listen to these events that eventually gets around to asking us and causing the database to update.

This was interesting information, so I did some additional testing. I started by adding the /Applications/ folder to the Spotlight Search Privacy list in System Settings, and verified afterward that a Spotlight search returned no results from that folder. My purpose was to ensure that Spotlight would not listen for filesystem events and trigger LaunchServices changes.

As before, I used my open source app PrivateWindow for testing, and set it as the default web browser. It turns out that if I run rm -fR /Applications/Testing/PrivateWindow.app in Terminal and immediately -dump the LaunchServices database, the app is still registered. But open https://www.apple.com will open /Applications/PrivateWindow.app and unregister /Applications/Testing/PrivateWindow.app from the database. So far, Apple’s comment seems accurate.

The alternative scenario is to run rm -fR /Applications/Testing instead, removing the parent folder of the app instead of just the app. Once again, immediately afterward the app is still registered in the LaunchServices database. However, this time open https://www.apple.com does not unregister the app from the database. Moreover, I now notice a difference in behavior between macOS 15 Sequoia and macOS 26 Tahoe. On Sequoia and earlier, as the blog post originally mentioned, LaunchServices gets confused and opens the URL in Safari rather than in PrivateWindow (/Applications/PrivateWindow.app). On Tahoe, the URL does open in the PrivateWindow app, correctly!

The System Settings Desktop & Dock pane remains confused on Tahoe, incorrectly displaying the first app in the list as the Default web browser, as on Sequoia. I’m not sure which API that System Settings is using, but my open source Default web browser app, which uses the deprecated function LSCopyDefaultHandlerForURLScheme(), appears to be accurate on Tahoe (not confused like on Sequoia and earlier). Deprecated API are the best API!

The Apple response makes no mention of a change in behavior on Tahoe. The good news is that the issue is less troublesome on Tahoe, though System Settings still needs to be fixed. What I believe is still weird about LaunchServices is that it treats the folder /Applications/Testing as if it were a mounted volume, like /Volumes/PrivateWindow from my downloadable dmg file, or like a detachable external disk. This would explain why, when a URL is opened, LaunchServices unregisters /Applications/Testing/PrivateWindow.app if /Applications/Testing still exists—still mounted, as it were—but does not unregister the app if /Applications/Testing is removed entirely. I don’t know why LaunchServices would treat every arbitrary folder as a mount point.

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