7

I am trying to create a menubar app with the SwiftUI app lifecycle introduced in SwiftUI 2.0 (app struct) where buttons and actions from within the SwiftUI code would update the text of the status bar item. I am creating a status bar item which will trigger a popover containing the SwiftUI view. Per my understanding, the best way to do this is to pass the statusBar down to the children of ContentView, which will be able to use statusBar as an environment object. I am having an issue that I do not understand, however, where code that works in the app delegate fails in the SwiftUI 2.0 init function (without it being clear why). I hope that I have included all relevant code below in an isolated example that gets to the core of the issue.

I have a working solution using NSApplicationDelegateAdaptor where the statusBar is initialized before the ContentView, and the statusBar is simply passed in to the ContentView as an environment object:

import SwiftUI
import AppKit

@main
struct test_status_bar_appApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        Settings{
            EmptyView()
        }
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    var popover = NSPopover.init()
    var statusBar: StatusBarController?

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Create the Status Bar Item with the Popover
        statusBar = StatusBarController.init(popover)

        // Pass the Status Bar Item as an environment object to children of ContentView
        let contentView = ContentView()
            .environmentObject(statusBar!)

        popover.contentViewController = NSHostingController(rootView: contentView)
        popover.contentSize = NSSize(width: 360, height: 360)

        statusBar?.updateStatusBarText(text: "app delegate")
    }
}

This work pretty well. However, it still uses the older app delegate. I was trying to use the new init() function of the app struct in SwiftUI 2.0 instead of delegating app launch to the app (older) delegate. I therefore moved the code above into the init() function:

import SwiftUI
import AppKit

@main
struct test_status_bar_appApp: App {
    var popover = NSPopover.init()
    var statusBar: StatusBarController?

    init () {

        // ISSUE: putting line `statusBar = StatusBarController.init(popover)`
        // (so that it can be passed in as an environment variable)
        // causes error
        statusBar = StatusBarController.init(popover)

        let contentView = ContentView()
            .environmentObject(statusBar!)

        popover.contentViewController = NSHostingController(rootView: contentView)
        popover.contentSize = NSSize(width: 360, height: 360)

        statusBar?.updateStatusBarText(text: "init before")
    }

    var body: some Scene {
        Settings{
            EmptyView()
        }
    }
}

This same sequence (which worked in the app delegate) fails in the init of the app struct, failing in the StatusBarController init on the line containing statusBar = NSStatusBar.init()

The error itself reads Thread 1: hit program assert, with additional error details:

Assertion failed: (CGAtomicGet(&is_initialized)), function CGSConnectionByID, file /System/Volumes/Data/SWE/macOS/BuildRoots/e90674e518/Library/Caches/com.apple.xbs/Sources/SkyLight/SkyLight-588.1/SkyLight/Services/Connection/CGSConnection.mm, line 133.
Assertion failed: (CGAtomicGet(&is_initialized)), function CGSConnectionByID, file /System/Volumes/Data/SWE/macOS/BuildRoots/e90674e518/Library/Caches/com.apple.xbs/Sources/SkyLight/SkyLight-588.1/SkyLight/Services/Connection/CGSConnection.mm, line 133.
(lldb)

However, placing the StatusBarController's initialization after the popover's dimensions are set does work to create a status bar item in the menubar... but unfortunately, in a way that can't be passed to ContentView as an environmentObject. The code for this is below:

import SwiftUI
import AppKit

@main
struct test_status_bar_appApp: App {
    var popover = NSPopover.init()
    var statusBar: StatusBarController?

    init () {
        let contentView = ContentView()
//            .environmentObject(statusBar!)

        popover.contentViewController = NSHostingController(rootView: contentView)
        popover.contentSize = NSSize(width: 360, height: 360)

        // BUT... putting the status bar HERE works,
        // but is sadly not able to be passed
        // as an environmentObject into the ContentView above
        statusBar = StatusBarController.init(popover)
        statusBar?.updateStatusBarText(text: "init after")
    }

    var body: some Scene {
        Settings{
            EmptyView()
        }
    }
}

Would anyone be able to explain what's going on, why this is happening, and perhaps how to fix it? Currently, I do have a working solution that I'm able to pass statusBar into ContentView as an environmentObject via the older app delegate. But ideally, I'd use the newer init() function of the app struct from the SwiftUI lifecycle, as that seems to be the way to do it in the new structure, while preserving the ability to pass statusBar to ContentView as an environmentObject.

Other relevant code for this is below. Here is the StatusBarController:

class StatusBarController: ObservableObject {
    private var statusBar: NSStatusBar
    private var statusItem: NSStatusItem
    private var popover: NSPopover
    
    init(_ popover: NSPopover) {
        self.popover = popover

        statusBar = NSStatusBar.init()
        statusItem = statusBar.statusItem(withLength: 80)
        
        if let statusBarButton = statusItem.button {
            statusBarButton.wantsLayer = true
            statusBarButton.layer?.masksToBounds = true
            statusBarButton.layer?.cornerRadius = 5
        }
    }
    
    func updateStatusBarText(text: String) {
        statusItem.button?.title = text
    }
}

ContentView:

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var statusBar: StatusBarController
    
    var body: some View {
        VStack{
            Text("Hello, world!").padding()
            Button("Ok", action: {
                print("clicked button in swiftui")
                // NOTE: example of trying to use statusBar environment object
                // statusBar.updateStatusBarText(text: "IT's WORKING!!!")
            }).padding()
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

EmptyView:

struct EmptyView: View {
    var body: some View {
        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
    }
}

1 Answers1

0

Try to put your code in a DispatchQueue.main.async { code }

For example:

DispatchQueue.main.async {
   let statusBar = NSStatusBar.system
   var statusBarItem : NSStatusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
        
   statusBarItem.button?.image = NSImage(systemSymbolName: "hare", accessibilityDescription: nil)
   statusBarItem.button?.imageScaling = .scaleProportionallyDown
        
   statusBarItem.button?.action = #selector(self.openMenuBar(sender:))
   statusBarItem.button?.target = self
   statusBarItem.button?.sendAction(on: [.leftMouseDown])
}

For completeness, in this example the click handler should be written in this way:

@objc func openMenuBar(sender: Any) {
    print("Open MenuBar")
}
rikicecchi
  • 53
  • 1
  • 6