Joshua
Joshua

Reputation: 1723

How to programatically open Settings/Preferences window in a macOS SwiftUI App

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

Answers (7)

JanApotheker
JanApotheker

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

Joshua
Joshua

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

Sherwin Zadeh
Sherwin Zadeh

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

dieb
dieb

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

stephancasas
stephancasas

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 via Mirror 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

Alex
Alex

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

Manny
Manny

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

Related Questions