Reputation: 1723
I have created a simple macOS-only SwiftUI app and added a Settings screen as per Apple's instructions:
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
Settings {
SettingsView()
}
}
}
It works: the Preferences option shows up in the app menu. Now, I would also like to open this settings window programatically, e.g. when a button is clicked. Is there a way to achieve this?
I was hoping there would be a method in NSApplication
, similar to the About window, but there doesn't seem to be one.
Upvotes: 36
Views: 7514
Reputation: 1906
Here's a solution for macOS <=12 (Monterey and older), 13 (Ventura) & 14 (Sonoma) / 15 (Sequoia):
if #available(macOS 14.0, *) {
SettingsLink {
Text("Settings")
}
}
else {
Button(action: {
if #available(macOS 13.0, *) {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
else {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
}
}, label: {
Text("Settings")
})
}
Upvotes: 12
Reputation: 1723
By inspecting the app menu, I found this solution:
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
I assume that it may not pass App Review for using a private API, though, so less shady alternatives are still welcome.
Update: This has actually passed App Review, so I had marked it as the answer.
Update 2: For a macOS 13 solution, see Manny's answer.
Update 3: For macOS 14 with SwiftUI, you must use the new SettingsLink
view documented here or OpenSettingsAction
as documented here. NSApp.sendAction
no longer works.
Upvotes: 49
Reputation: 1462
On macOS 14.0+, the correct way to programmatically open settings is to use OpenSettingsAction
which is an action you define in the Environment. For example:
struct MyView: View {
@Environment(\.openSettings) private var openSettings
var body: some View {
Button("Open Settings") {
openSettings()
}
}
}
See: https://developer.apple.com/documentation/swiftui/opensettingsaction
Upvotes: 1
Reputation: 339
The following works by creating your own settings window and hiding it as soon as the application launches. Hopefully Apple improves SettingsLink and/or exposes an API to open settings in the future.
struct MyView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Settings...") { openWindow(id: "AppSettings") }
}
}
@main
struct MyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate: AppDelegate
var body: some Scene {
Window("Settings", id: "AppSettings") {
SettingsView().environmentObject(appState)
}.windowResizability(.contentSize)
Settings {
SettingsView().environmentObject(appState)
}
}
@MainActor class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
getWindowById(id: "AppSettings")?.close()
}
}
func getWindowById(id: String) -> NSWindow? {
return NSApp.windows.first { window in
if let identifier = window.identifier {
return identifier.rawValue == id
}
return false
}
}
Upvotes: 0
Reputation: 2128
My take on the answers which have already been given is to probe the application delegate for selector response. This has worked well for me so far:
2023 Update | macOS 14 Sonoma
Apple has added a private property to
NSMenuItem
which contains a type-erased function. For the Settings... menu item of the application menu, this is what receives the user's click. In the extension below, reflection viaMirror
is used to extract and call that function.Because Apple did not include the appropriate identifier values for the Settings…
NSMenuItem
, and they have elected to use a generic selector, the only portable discriminator is the item title — localized to the user's preferences.
import SwiftUI;
fileprivate let kAppMenuInternalIdentifier = "app"
fileprivate let kSettingsLocalizedStringKey = "Settings\\U2026";
extension NSApplication {
/// Open the application settings/preferences window.
func openSettings() {
// macOS 14 Sonoma
if let internalItemAction = NSApp.mainMenu?.item(
withInternalIdentifier: kAppMenuInternalIdentifier
)?.submenu?.item(
withLocalizedTitle: kSettingsLocalizedStringKey
)?.internalItemAction {
internalItemAction();
return;
}
guard let delegate = NSApp.delegate else { return }
// macOS 13 Ventura
var selector = Selector(("showSettingsWindow:"));
if delegate.responds(to: selector) {
delegate.perform(selector, with: nil, with: nil);
return;
}
// macOS 12 Monterrey
selector = Selector(("showPreferencesWindow:"));
if delegate.responds(to: selector) {
delegate.perform(selector, with: nil, with: nil);
return;
}
}
}
// MARK: - NSMenuItem (Private)
extension NSMenuItem {
/// An internal SwiftUI menu item identifier that should be a public property on `NSMenuItem`.
var internalIdentifier: String? {
guard let id = Mirror.firstChild(
withLabel: "id", in: self
)?.value else {
return nil;
}
return "\(id)";
}
/// A callback which is associated directly with this `NSMenuItem`.
var internalItemAction: (() -> Void)? {
guard
let platformItemAction = Mirror.firstChild(
withLabel: "platformItemAction", in: self)?.value,
let typeErasedCallback = Mirror.firstChild(
in: platformItemAction)?.value
else {
return nil;
}
return Mirror.firstChild(
in: typeErasedCallback
)?.value as? () -> Void;
}
}
// MARK: - NSMenu (Private)
extension NSMenu {
/// Get the first `NSMenuItem` whose internal identifier string matches the given value.
func item(withInternalIdentifier identifier: String) -> NSMenuItem? {
self.items.first(where: {
$0.internalIdentifier?.elementsEqual(identifier) ?? false
})
}
/// Get the first `NSMenuItem` whose title is equivalent to the localized string referenced
/// by the given localized string key in the localization table identified by the given table name
/// from the bundle located at the given bundle path.
func item(
withLocalizedTitle localizedTitleKey: String,
inTable tableName: String = "MenuCommands",
fromBundle bundlePath: String = "/System/Library/Frameworks/AppKit.framework"
) -> NSMenuItem? {
guard let localizationResource = Bundle(path: bundlePath) else {
return nil;
}
return self.item(withTitle: NSLocalizedString(
localizedTitleKey,
tableName: tableName,
bundle: localizationResource,
comment: ""));
}
}
// MARK: - Mirror (Helper)
fileprivate extension Mirror {
/// The unconditional first child of the reflection subject.
var firstChild: Child? { self.children.first }
/// The first child of the reflection subject whose label matches the given string.
func firstChild(withLabel label: String) -> Child? {
self.children.first(where: {
$0.label?.elementsEqual(label) ?? false
})
}
/// The unconditional first child of the given subject.
static func firstChild(in subject: Any) -> Child? {
Mirror(reflecting: subject).firstChild
}
/// The first child of the given subject whose label matches the given string.
static func firstChild(
withLabel label: String, in subject: Any
) -> Child? {
Mirror(reflecting: subject).firstChild(withLabel: label)
}
}
Upvotes: 5
Reputation: 177
On macOS 13+, I use this solution:
if #available(macOS 13, *) {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
} else {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
}
Upvotes: 10
Reputation: 309
On macOS 13+ versions the action name has been renamed to Settings
.
So to update Joshua's answer, here is the solution to use:
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
I wonder if there is a settings scene id we can use with the new openWindow
environment method. :)
Upvotes: 30