If you write a menubar or ‘Task Bar’ app, it’s an App Store requirement that it doesn’t automatically install itself as a LaunchAgent or in Login Items. Users should be given the choice as to whether they want your tool to run each time they start their Mac up.
Although adding an app to Login Items is not a difficult task for the user – and for apps in the Dock this is even easier – it is kinder to provide this control as a feature. It is also one of the most frustratingly complex parts of writing such small tools, as the best solutions appear reliant on a helper app.
Daunted by what I read in this item on Stack Overflow, this tutorial, and another tutorial, I wanted something less fiddly which would work for a menubar app which is not sandboxed. This is what I came up with.
First, we need an outlet for the menu command, so that we can add or remove a status tick √ by it:
@IBOutlet weak var runonStartupCommand: NSMenuItem!
My original plan was to call three simple lines of AppleScript to get a list of what is installed in Login Items, to add the app to that list, and to remove it from the list. The first of these is the most important: if you can’t find out whether your app is already a Login Item, then you can’t use this method.
Unfortunately, support for running AppleScript from Swift 4.0 has several problems. It leaks memory, I gather, although as users are hardly likely to toggle this control very often, that shouldn’t be a worry here. The wall that I hit here was getting the list of Login Items back from running the script – all attempts to do so either caused a crash, or returned a blank. So the code to run the two other AppleScript lines doesn’t even bother looking for a result:
func doScriptScript(source: String) {
if let appleScript = NSAppleScript(source: source) {
var errorDict: NSDictionary? = nil
var _ = appleScript.executeAndReturnError(&errorDict)
} }
The only reliable and robust way to get the list of Login Items was to call a shell script to run the AppleScript using osascript
:
func doShellScript() -> String {
let theLP = "/usr/bin/osascript"
let theParms = ["-e", "tell application \"System Events\" to get the name of every login item"]
let task = Process()
task.launchPath = theLP
task.arguments = theParms
let outPipe = Pipe()
task.standardOutput = outPipe
task.launch()
let fileHandle = outPipe.fileHandleForReading
let data = fileHandle.readDataToEndOfFile()
task.waitUntilExit()
let status = task.terminationStatus
if (status != 0) {
return "Failed, error = " + String(status)
}
else {
return (NSString(data: data, encoding: String.Encoding.utf8.rawValue)! as String)
} }
The Action to run when the user selects the menu command is then:
@IBAction func runonStartupClicked(_ sender: NSMenuItem) {
First check whether the app is already in Login Items:
let theStr = self.doShellScript()
if theStr.contains("Bailiff") {
If it is, remove it from there
let theCmd4 = "tell application \"System Events\" to delete login item \"Bailiff\""
self.doScriptScript(source: theCmd4)
} else {
If it isn’t, add it. To do this we need to know the app’s bundle path, and embed that in the script:
let theCmd1 = "tell application \"System Events\" to make login item at end with properties {path:\""
let theCmd2 = "\", hidden:false}"
let thePath = Bundle.main.bundlePath
self.doScriptScript(source: (theCmd1 + thePath + theCmd2))
}
Now check again whether the app is in Login Items. If it is, put a √ by the menu command; if not, don’t.
let theStr2 = self.doShellScript()
if theStr2.contains("Bailiff") {
runonStartupCommand.state = NSControl.StateValue.on
} else {
runonStartupCommand.state = NSControl.StateValue.off
} }
Finally, we need to add the same check when the app starts up, to know whether to put a √ by that command:
func applicationDidFinishLaunching(_ aNotification: Notification) {
// other stuff first, then
let theStr2 = self.doShellScript()
if theStr2.contains("Bailiff") {
runonStartupCommand.state = NSControl.StateValue.on
} else {
runonStartupCommand.state = NSControl.StateValue.off
} }
The only potentially puzzling behaviour which this might cause is if a user is already running Bailiff, and then changes its status in the Login Items directly in the Users & Groups pane. Coping with that more gracefully would require notification from that pane of the change in status, so that the menu item could be refreshed. For now, I’ll assume that the chances of a user doing that are low, and in any case the checks in the Action function should resync this when the user tries the command next.