Setting Other Applications' Keyboard Shortcuts using NSUserDefaults - Defaults Not Updating

Hello,

I’m writing a setup script to facilitate setting up custom keyboard shortcuts on new Macs.

Keyboard shortcuts are stored as a dictionary of {“Menu Item Name”:“Keyboard Shortcut String”} under the NSUserKeyEquivalents key in each application’s preference database, whose on-disk location varies depending on whether the application is sandboxed.

For example, assigning the “Strikethrough” menu item to command-shift-x would store this as {"Strikethrough":"@$x"}.

Initially, I was hoping to do this using a shell script that calls defaults, but this has some practical limitations…

  • defaults is not well-suited to managing nested data structures.
  • defaults generally requires values to be set with NextStep-style plist syntax (or XML fragments), which can be quite difficult to generate for non-ASCII values.

…and you end up with weirdness like this:

  • defaults write com.citrix.XenAppViewer NSUserKeyEquivalents --array '{"Preferences\\U2026" = "@,";}' – Assigns “Preferences…” to command-comma.
  • defaults write com.apple.mail NSUserKeyEquivalents --array-add "\033Mailbox\033Erase Deleted Items\033In All Accounts" "@$\\U2327" – Assigns “Mailbox->Erase Deleted Items->In All Accounts” to comand-shift-clear.

Because of this, ASObjC seems ideally suited for setting the keyboard shortcuts, since it has no dependencies on new machines (unlike ObjC, swift, swift-sh, or PyObjC).


The issue:

I’m having trouble getting changes to NSUserDefaults to be recognized by the applications. With my code below, the keyboard shortcuts appear to be written correctly to disk (and appear in ~/Library/Preferences/$BUNDLE_ID.plist), but are not recognized by the applications or System Preferences, nor by defaults.

Any advice would be greatly appreciated!

use scripting additions
use framework "Foundation"

-- Test example.
set prefs_before to my getCustomKeyboardShortcuts("com.apple.Notes")
my addCustomKeyboardShortcut("com.apple.Notes", "Bulleted List", "@l")
set prefs_after to my getCustomKeyboardShortcuts("com.apple.Notes")
set defaults_after to do shell script "defaults read com.apple.Notes NSUserKeyEquivalents"
return

--- HANDLERS ---
-- Getting user defaults.
to getUserDefaults(bundle_id)
	return current application's NSUserDefaults's alloc()'s initWithSuiteName:bundle_id
end getUserDefaults

-- Setting keyboard shortcuts.
to getCustomKeyboardShortcuts(bundle_id)
	set user_defaults to my getUserDefaults(bundle_id)
	set shortcut_dictionary to user_defaults's dictionaryForKey:"NSUserKeyEquivalents"
	return shortcut_dictionary
end getCustomKeyboardShortcuts

to addCustomKeyboardShortcut(bundle_id, menu_title, keyboard_shortcut)
	set user_defaults to my getUserDefaults(bundle_id)
	set shortcut_dictionary to user_defaults's dictionaryForKey:"NSUserKeyEquivalents"
	set mutable_dictionary to current application's NSMutableDictionary's dictionaryWithDictionary:shortcut_dictionary
	mutable_dictionary's setObject:keyboard_shortcut forKey:menu_title
	user_defaults's setObject:mutable_dictionary forKey:"NSUserKeyEquivalents"
	#user_defaults's synchronize() -- No effect.
end addCustomKeyboardShortcut

Running this results in:
prefs_before is {"Strikethrough":"@$x"}
prefs_after is {"Strikethrough":"@$x", "Bulleted List":"@l"} – (Great!)
but, defaults_after is still {"Strikethrough":"@$x"} – (Not so great!)

So the question is, how do I get cfprefsd to recognize that I’ve updated the defaults?

I feel that it’s one of two issues:

  1. I’m not correctly persisting the values using NSUserDefaults.
  2. I’m not writing to the correct plist (~/Library/Preferences/com.apple.Notes.plist vs. ~/Library/Containers/com.apple.Notes/Data/Library/Preferences/com.apple.Notes.plist), and if so, how do I write to the containerized path using NSUserDefaults?

Background FYI:

  • The above example was run with 1 custom keyboard shortcut added manually to the Notes application using System Preferences.
  • NSUserDefaults domains are case-sensitive! Cf. com.apple.mail vs. com.apple.Notes.
  • In order for custom keyboard shortcuts for a given application to display in System Preferences, the bundle id for the application must appear exactly once in the array stored under “com.apple.custommenu.apps” in “com.apple.universalaccess.plist”.
  • Adding, editing, or removing a keyboard shortcut to the System Preferences pane causes duplicate settings to be written to the application’s legacy (~/Library/Preferences) & containerized path (if the container itself exists).
  • System Preferences always loads the keyboard shortcut from the containerized path (unless the container itself does not exist), even if the application & defaults load it from the legacy path.
  • Certain Apple-developed applications, notably Safari, Mail, and Preview, have their preference files protected with SIP (both read & write access), but defaults has not been updated to handle this gracefully.

I suspect that’s the explanation, but I’m afraid I don’t know the solution.

1 Like

Thanks so much for looking it over, Shane (I’ve been using your excellent books as reference!). There’s just not much information out there about accessing the defaults of other applications, and it gets very complex with sandboxing & SIP. If my NSUserDefaults usage above looks good, then it looks like it’s a plist location issue that I will work through.

I’ll post the library when I’m done, since I imagine it could be useful for others.

Could you try making the changes manually with my Prefs Editor from apps.tempel.org

If that is recognized by the apps, then it should work with your code as well and I may be able to help you figure out what’s wrong

2 Likes

Thanks Thomas! I’ve heard great things about your application – it’s on my list of things to try out!

After a lot of frustration, I think I’m mostly there now – I just need to do some more testing & improve the interface.

Below, I’ll refer to the legacy path for preferences (~/Library/Preferences/$BUNDLE_ID.plist) as well as the containerized/sandboxed path (~/Library/Containers/$BUNDLE_ID/Data/Library/Preferences/$BUNDLE_ID.plist).

Some additional things I’ve learned about how keyboard shortcuts are managed on macOS:

  • System Preferences:

    • System Preferences always reads from the containerized path if the container exists, or from the legacy path if it does not. This is true even if the application itself does not read from the containerized path.
    • System Preferences always writes to the legacy path and writes to the containerized path if the container itself exists.
      • The reason for writing to the legacy path for containerized applications is not clear. Perhaps it is intended to allow other applications to read a protected application’s keyboard shortcuts, or perhaps it is just a bug.
      • When removing the last shortcut for a given application, the legacy path is not updated if the container exists. I regard this as a bug.
  • Applications:

    • Sandboxed applications read NSUserKeyEquivalents from the containerized path, and non-sandboxed applications read from the legacy path.
    • Applications also separately read NSUserKeyEquivalents from NSGlobalDomain to get the global shortcuts, which requires special handling of NSUserDefaults (see below).
  • NSUserDefaults:

    • NSUserDefaults normally retrieves values in the following search order (abbreviated; see here):
      1. The application’s domain identified by its bundle id
      2. NSGlobalaDomain
      3. NSRegistrationDomain, which are fallback values set programmatically by the program
    • NSUserDefault’s standard methods normally search in this order, and return the first value that exists for a given key. Note that this is not sufficient for managing keyboard shortcuts, since trying to read from the application’s domain when no custom keyboard shortcuts are set will instead return the value from NSGlobalDomain. The solution for this seems to be calling persistentDomainForName: on an NSUserDefaults instance.
    • Critically, it is also possible to specify a preference domain using a custom path, similar to the defaults command-line tool. From what I can see, this is undocumented, and allows the caller to differentiate between the legacy and containerized domains, using the form /Path/To/$BUNDLE_ID).

Because of this, my solution does the following:
* Like System Preferences, always write to the legacy path and the containerized path if the container exists.
* Read preferences using persistentDomainForName:, to avoid values stored in NSGlobalDomain from being returned if no shortcuts are set in the application’s domain.

Here’s my working solution, which requires some cleaning up & testing (remember, Full Disk Access must be enabled or SIP disabled):

(* Public Release 1.0 *)

use scripting additions
use framework "Foundation"


--- HANDLERS ---
-- Getting a user defaults object.
to getUserDefaults(domain)
	-- Return a NSUserDefaults object for domain, which may be a bundle ID or path-style domain.
	if domain is "NSGlobalDomain" then set domain to ".NSGlobalDomain"
	return current application's NSUserDefaults's alloc()'s initWithSuiteName:domain
end getUserDefaults


-- Getting preference domain paths.
to applicationHasContainer(bundle_id)
	-- Return true if a container exists for bundle_id, false otherwise.
	return my fileExists(POSIX path of (path to home folder) & "Library/Containers/" & bundle_id & "/")
end applicationHasContainer

to getLegacyDomain(bundle_id)
	-- Return the standard preferences domain for bundle_id (without the .plist extension) starting with "~/Library/Preferences/".
	if bundle_id is "NSGlobalDomain" then set bundle_id to ".GlobalPreferences"
	return POSIX path of (path to home folder) & "Library/Preferences/" & bundle_id
end getLegacyDomain

to getSandboxedDomain(bundle_id)
	-- Return the containerized preferences domain for bundle_id (without the .plist extension) for use with sandboxed applications.
	return POSIX path of (path to home folder) & "Library/Containers/" & bundle_id & "/Data/Library/Preferences/" & bundle_id
end getSandboxedDomain


-- Controlling whether applications should appear in the System Preferences "Application Shortcuts" list.
to getGUICustomKeyboardShortcutApplications()
	-- Return the ordered list of applications appearing in the custom keyboard shortcut section of System Preferences.
	set gui_defaults to my getUserDefaults("com.apple.universalaccess")
	return (gui_defaults's stringArrayForKey:"com.apple.custommenu.apps") as list
end getGUICustomKeyboardShortcutApplications

to setGUICustomKeyboardShortcutApplications(bundle_ids)
	-- Set the ordered list of applications appearing in the custom keyboard shortcut section of System Preferences.
	set gui_defaults to my getUserDefaults("com.apple.universalaccess")
	gui_defaults's setObject:(my getUniqueItems(bundle_ids)) forKey:"com.apple.custommenu.apps"
end setGUICustomKeyboardShortcutApplications

to hasGUICustomKeyboardShortcutApplications(bundle_id)
	-- Return whether the application id bundle_id appears in the custom keyboard shortcut section of System Preferences.
	return {bundle_id} is in my getGUICustomKeyboardShortcutApplications()
end hasGUICustomKeyboardShortcutApplications

to addGUICustomKeyboardShortcutApplication(bundle_id)
	-- Add the application id bundle_id to the custom keyboard shortcut section of System Preferences if it is not already included therein.
	set gui_defaults to my getUserDefaults("com.apple.universalaccess")
	set gui_bundle_ids to (gui_defaults's stringArrayForKey:"com.apple.custommenu.apps") as list
	if {bundle_id} is not in gui_bundle_ids then gui_defaults's setObject:(gui_bundle_ids & {bundle_id}) forKey:"com.apple.custommenu.apps"
end addGUICustomKeyboardShortcutApplication

to removeGUICustomKeyboardShortcutApplication(bundle_id)
	-- Remove the application id bundle_id from the custom keyboard shortcut section of System Preferences if it is found therein.
	set gui_defaults to my getUserDefaults("com.apple.universalaccess")
	set gui_bundle_ids to (gui_defaults's stringArrayForKey:"com.apple.custommenu.apps") as list
	if {bundle_id} is in gui_bundle_ids then
		if length of gui_bundle_ids is 1 then -- This is the last bundle ID.
			gui_defaults's setObject:(missing value) forKey:"com.apple.custommenu.apps"
		else -- Other bundle IDs still exist.
			gui_defaults's setObject:(my makeListByRemovingItem(gui_bundle_ids, bundle_id)) forKey:"com.apple.custommenu.apps"
		end if
	end if
end removeGUICustomKeyboardShortcutApplication


-- Manipulating custom keyboard shortcuts.
to getCustomKeyboardShortcutDictionary(bundle_id)
	-- Return an NSDictionary representation of the custom keyboard shortcuts for application id bundle_id.
	-- Like System Preferences, this retrieves the values from the sandboxed domain if the container exists, or the legacy domain if it does not.
	
	-- Get the dictionary representation of the specified domain only (without using the standard defaults search path).
	set user_defaults to my getUserDefaults(bundle_id)
	if my applicationHasContainer(bundle_id) then -- Sandboxed application.
		set dictionary_representation to user_defaults's persistentDomainForName:(my getSandboxedDomain(bundle_id))
	else -- Not sandboxed.
		if bundle_id is "NSGlobalDomain" then
			set dictionary_representation to user_defaults's dictionaryRepresentation()
		else -- Legacy application.
			set dictionary_representation to user_defaults's persistentDomainForName:(my getLegacyDomain(bundle_id))
		end if
	end if
	
	-- Return the dictionary represenation, or an empty dictionary if no shortcuts are found.
	if dictionary_representation is missing value then -- The defaults domain does not exist.
		set shortcut_dictionary to current application's NSDictionary's dictionary()
	else -- The domain exists (but the key may not).
		set shortcut_dictionary to dictionary_representation's objectForKey:"NSUserKeyEquivalents"
		if shortcut_dictionary is missing value then set shortcut_dictionary to current application's NSDictionary's dictionary()
	end if
	return shortcut_dictionary
end getCustomKeyboardShortcutDictionary

to setCustomKeyboardShortcutDictionary(bundle_id, shortcut_dictionary)
	-- Set the custom keyboard shortcuts for application id bundle_id using the NSDictionary shortcut_dictionary.
	-- Like System Preferences, this sets the shortcuts in the legacy domain & sandboxed domain (if the container exists).
	
	-- Get the user defaults.
	set has_container to my applicationHasContainer(bundle_id)
	set legacy_defaults to my getUserDefaults(my getLegacyDomain(bundle_id))
	if has_container then set sandboxed_defaults to my getUserDefaults(my getSandboxedDomain(bundle_id))
	
	-- Add the new custom keyboard shortcut.
	legacy_defaults's setObject:shortcut_dictionary forKey:"NSUserKeyEquivalents"
	if has_container then sandboxed_defaults's setObject:shortcut_dictionary forKey:"NSUserKeyEquivalents"
end setCustomKeyboardShortcutDictionary

to addCustomKeyboardShortcut(bundle_id, menu_title, keyboard_shortcut)
	-- Add the custom keyboard shortcut keyboard_shortcut for menu item menu_title for application id bundle_id. If the menu item already has a keyboard shortcut, it will be updated to the new shortcut.
	-- The menu title is processed to handle menu hierarchies.
	-- Like System Preferences, this adds the shortcut to the legacy domain & sandboxed domain (if the container exists).
	
	-- Get the user defaults.
	set has_container to my applicationHasContainer(bundle_id)
	set legacy_defaults to my getUserDefaults(my getLegacyDomain(bundle_id))
	if has_container then set sandboxed_defaults to my getUserDefaults(my getSandboxedDomain(bundle_id))
	
	-- Get the current keyboard shortcuts.
	set shortcut_dictionary to my getCustomKeyboardShortcutDictionary(bundle_id)
	set mutable_dictionary to current application's NSMutableDictionary's dictionaryWithDictionary:shortcut_dictionary
	
	-- Add the new custom keyboard shortcut.
	mutable_dictionary's setObject:keyboard_shortcut forKey:(my encodeMenuTitle(menu_title))
	legacy_defaults's setObject:mutable_dictionary forKey:"NSUserKeyEquivalents"
	if has_container then sandboxed_defaults's setObject:mutable_dictionary forKey:"NSUserKeyEquivalents"
end addCustomKeyboardShortcut

to removeCustomKeyboardShortcut(bundle_id, menu_title)
	-- Remove the custom keyboard shortcut for menu item menu_title for application id bundle_id.
	-- The menu title is processed to handle menu hierarchies.
	-- Unlike System Preferences, this always removes the shortcut from the legacy domain & sandboxed domain (if the container exists).
	
	-- Get the user defaults.
	set has_container to my applicationHasContainer(bundle_id)
	set legacy_defaults to my getUserDefaults(my getLegacyDomain(bundle_id))
	if has_container then set sandboxed_defaults to my getUserDefaults(my getSandboxedDomain(bundle_id))
	
	-- Get the current keyboard shortcuts.
	set shortcut_dictionary to my getCustomKeyboardShortcutDictionary(bundle_id)
	set mutable_dictionary to current application's NSMutableDictionary's dictionaryWithDictionary:shortcut_dictionary
	
	-- Remove the custom keyboard shortcut.
	mutable_dictionary's removeObjectForKey:(my encodeMenuTitle(menu_title))
	if mutable_dictionary's |count|() as integer is 0 then set mutable_dictionary to missing value
	legacy_defaults's setObject:mutable_dictionary forKey:"NSUserKeyEquivalents"
	if has_container then sandboxed_defaults's setObject:mutable_dictionary forKey:"NSUserKeyEquivalents"
end removeCustomKeyboardShortcut


-- Formatting custom keyboard shortcuts.
to encodeMenuTitle(menu_title)
	-- Convert the plain-text representation of menu hierarchie menu_title (e.g. "File->Save") to its escaped version.
	if menu_title contains "->" then
		return character id 27 & my replaceText(menu_title, "->", character id 27)
	else
		return menu_title
	end if
end encodeMenuTitle


-- Library helpers.
to fileExists(file_specifier)
	try
		((file_specifier as «class furl») as alias)
		return true
	on error number -1700
		try
			using terms from application "System Events"
				((path of file_specifier) as alias)
				return true
			end using terms from
		end try
	end try
	return false
end fileExists

to getUniqueItems(L)	
	set unique_items to {}
	repeat with an_item in L
		set an_item to contents of an_item
		if an_item is not in unique_items then set the end of unique_items to an_item
	end repeat
	return unique_items
end getUniqueItems

to makeListByRemovingItem(L, O)
	set L_length to length of L
	set new_L to {}
	repeat with i from 1 to L_length
		set L_item to item i of L
		if L_item is not O then set the end of new_L to L_item
	end repeat
	return new_L
end makeListByRemovingItem

to replaceText(s, search_string, replacement_string)
	set previous_TIDs to AppleScript's text item delimiters
	set AppleScript's text item delimiters to search_string
	set s_text_items to text items of s
	set AppleScript's text item delimiters to replacement_string
	set s to s_text_items as string
	set AppleScript's text item delimiters to previous_TIDs
	return s
end replaceText

If anyone’s interested in testing it or providing feedback, happy to hear it!

1 Like

I’ve updated & extensively commented the code in the previous post. It should now be fully functional & feature-complete! This has been a long, tortuous (and torturous) road for me, so I’m thrilled to be able to share something that works.

At a later date, I will try to fully document the keyboard shortcut format, and determine if there are any API ergonomics to improve.

In brief, the shortcuts use the same format as in DefaultKeyBinding.dict (but delete is \U0008 and forward delete is \U007F). The following are used to specify the modifier keys:

  • ^ is control
  • ~ is option
  • $ is shift
  • @ is command
  • # is used to denote number pad keys on full keyboards (e.g. #1 means 1 key on the number pad)

There’s also a little known feature in System Preferences that allows you to specify full menu hierarchies by separating the items with -> (e.g. File->Save, which is converted behind the scenes to use the escape character as a leading & separating character), which allows for disambiguation in the event multiple menu items exist with the same name. That’s supported here as well & is automatically converted.

The example below shows how to add global shortcuts and application-specific shortcuts for Finder (a non-sandboxed application) and Notes (a sandboxed application).

addGUICustomKeyboardShortcutApplication ensures the application will appear in the list in System Preferences (but is not necessary for the shortcut to be functional).

If creating your own shortcuts, remember:

  • Bundle IDs are case sensitive.
  • Full Disk Access must be enabled for applications protected by SIP (e.g. Safari, Mail, Preview).

-- Add global application shortcuts.
addGUICustomKeyboardShortcutApplication("NSGlobalDomain")
addCustomKeyboardShortcut("NSGlobalDomain", "File->Save As…", "@$s")
addCustomKeyboardShortcut("NSGlobalDomain", "System Preferences…", "@~^,")

-- Add Finder shortcuts.
addGUICustomKeyboardShortcutApplication("com.apple.finder")
addCustomKeyboardShortcut("com.apple.finder", "Show Package Contents", "@s")

-- Add notes shortcuts.
addGUICustomKeyboardShortcutApplication("com.apple.Notes")
addCustomKeyboardShortcut("com.apple.Notes", "Strikethrough", "@$x")

Applications must be re-started for the changes to take effect. If anyone knows how to post a notification to notify the applications that the preferences have changed (like System Preferences does on recent versions of macOS), I’d love to know!

Hi, tree_frog. At first, please name this script.

And write your copyright header to this script. This is yours.

I stored your script as an AppleScript Library and put it to ~/Library/Script Libraries/

Then I wrote a sample script to add keyboard shortcut to my “Kamenoko” app full written in AppleScript and sold at Mac App Store.

use AppleScript version "2.4" -- Yosemite (10.10) or later
use framework "Foundation"
use scripting additions
use kLib : script "Keyboard Shortcuts Lib"

-- Add Kamenoko shortcuts.
addGUICustomKeyboardShortcutApplication("jp.piyomarusoft.kamenoko1") of kLib
addCustomKeyboardShortcut("jp.piyomarusoft.kamenoko1", "About Kamenoko", "@^s") of kLib

スクリーンショット 2022-02-06 1.57.45

I had to restart my app to show the new keyboard shortcut (Command-Control-s).

I use Japanese language user environment with my Mac. Finder seemed not to accept new sohrtcut. Non-ascii menu item may not be able to add accept new shortcut.

This script is a very valuable one, I ensure.

Takaaki Naganoya
http://piyocast.com/as/

1 Like