肇鑫的技术博客

业精于勤,荒于嬉

Get Critical Alert Permission from User instead of Apple

When a user uses "Do not disturb" mode, your notifications no longer get sound and banner, unless your app can post a Critical Alert.

However, to get the entitlement of critical alert, you have to ask Apple to give your permission. Someone complains that their requests to Apple even took months without a response.

Critical Alerts entitlement

I found a new way to get the permission directly from the user, instead of Apple.

Steps

UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert, .criticalAlert]) { granted, error in
    guard error == nil else {
        DispatchQueue.main.async {
            NSSound.beep()
            let alert = NSAlert(error: error!)
            alert.runModal()
        }
        
        return
    }
}

Above code will get an error as you don't have the entitlement of critical alert. But we could use code below:

UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert]) { granted, error in
    guard error == nil else {
        DispatchQueue.main.async {
            NSSound.beep()
            let alert = NSAlert(error: error!)
            alert.runModal()
        }
        
        return
    }
    
    if granted {
        UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert, .criticalAlert]) { granted, error in
            /* You will get an error here as you don't have the critical alert entitlement.
                But since you have already got the permission for normal notifications, you now get a disabled critical alert permission.
                You are just one step to the critical alert. Ask your user to manually enable the permisssion.
                The error should be igonred here.
                */

            UNUserNotificationCenter.current().getNotificationSettings { settings in
                if settings.criticalAlertSetting != .enabled {
                    DispatchQueue.main.async {
                        NSSound.beep()
                        showCriticalAlert = true
                    }
                }
            }
        }
    }
}

We request the permission twice. First, we request the common notification permission. When a user allow that, we request the critical alert permission. Since we have already got the common notification permission the second permission request won't pop up. Also as we don't have critical alert entitlement from Apple, we will get an error. But the error can be ignored. Then when you open the app's notification settings, there is a critical alert permission, with disabled state. So what you only need to do is to show an alert and ask the user to enable the critical alert if the user wants to get notified in "Do not disturb" mode.

Sample App

I have already got an sample app on sale in App Store. You can try it for free.

Stand Reminder

The Login Item in macOS Ventura

Apps Crash on Ventura

Before Ventura, if I wanted some app to launch when a user logged in, I used this code:

private func setAutoStart() {
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
        fatalError()
    }
}

However, in macOS Ventura, SMLoginItemSetEnabled(_:_:) global function is deprecated, and what's more, it takes no effects so it returns false and fatalError() is executed. So every app of mine that used auto launch when logged in crashed on macOS Ventura.

How to Solve

To solve the issue is easy, we must update the code and use the new API in macOS Ventura and later.

private func setAutoStart() {
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if #available(macOS 13.0, *) {
        if shouldEnable {
            try? SMAppService().register()
        } else {
            try? SMAppService().unregister()
        }
    } else {
        if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
            fatalError()
        }
    }
}

Other Related Issues

In Ventura, you may find that many versions of the same app that created login items all launched when you logged in. Once I had 3 "Stand Reminder" and 2 "Poster 2" in different versions auto launched when I logged in.

I found that the extra apps were debug versions that I had tested with Xcode. So there must be some relations with the new API.

Workaround

You can simply manually remove the debug version apps to solve the issue. But this method takes to much time as you always have new debug versions.

Or you can stop this feature on debug versions after you fully tested it.

private func setAutoStart() {
    #if !DEBUG
    let shouldEnable = Defaults[.autoLaunchWhenLogin]
    
    if #available(macOS 13.0, *) {
        if shouldEnable {
            try? SMAppService().register()
        } else {
            try? SMAppService().unregister()
        }
    } else {
        if !SMLoginItemSetEnabled("com.parussoft.Stand-Reminder-Launcher" as CFString, shouldEnable) {
            fatalError()
        }
    }
    #endif
}

An Interesting Issue of @State Variable Not Set after Set

In my recent project, a @State variable was not set after set.

        self.project?.currentTransUnit?.isVerified = true
        self.project?.update() // debug point. 
        // po "self.project?.currentTransUnit?.isVerified" prints false.

So I created another project to narrow the issue.

//
//  Model_SampleApp.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import SwiftUI

@main
struct Model_SampleApp: App {
    @State private var foo:Foo? = Foo(bar: Bar(item: Item(name: "Johnny")))
    
    var body: some Scene {
        WindowGroup {
            ContentView(foo: $foo)
        }
    }
}
//
//  ContentView.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import SwiftUI

struct ContentView: View {
    @Binding var foo:Foo?
    
    var body: some View {
        VStack {
            Text(foo?.bar.item.name ?? "nil")
            
            HStack {
                TextField("name", text: Binding(get: {
                    foo?.bar.item.name ?? "nil"
                }, set: { newValue in
                    foo?.bar.item.name = newValue
                }))
            }
        }
        .padding()
        .onChange(of: foo) { newValue in
            print(newValue)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(foo: .constant(nil))
    }
}
//
//  Model.swift
//  Model Sample
//
//  Created by zhaoxin on 2022/8/28.
//

import Foundation

struct Foo:Equatable {
    var bar:Bar
}

struct Bar:Equatable {
    var item:Item
}

struct Item:Equatable {
    var name:String
    let id = UUID()
}

All things went fine. I noticed that onChange required Equatable. So I did a test.

func onChange<V>(of value: V, perform action: @escaping (V) -> Void) -> some View where V : Equatable
struct Item:Equatable {
    var name:String
    let id = UUID()
    
    static func == (lhs: Item, rhs: Item) -> Bool {
        return lhs.id == rhs.id
    }
}

Now the issue happened again. So I guess for optimize performance. When set to a @State variable, SwiftUI compared the previous and current value by Equatable. If they were the same, SwiftUI thought they were identical and didn't replace that current with the previous value. So if we implemented the Equatable's method with part implementation on purpose, the issue appeared on the values which were not mentioned in Equatable's method.