macOS automation

In a vain attempt* to get Desktop Icon Manager (DIM) into the Mac App Store, we wrote a class that simply ‘hides’ or ‘reveals’ the Desktop icons with a notification. Quite useful when giving presentations (alas, our Desktops are usually ‘messy’ with current work).

The class (aptly named ‘Hider’), once initialized (usually in AppDelegate), waits for a notification to be posted and then simply toggles between hiding or showing the Desktop icons. Mac App Store requires all apps to be sandboxed. This puts many restrictions on what an app is allowed to do. For example, while one can find which picture (or not) is currently displayed on the Desktop, you’re not allowed to use that picture.

The Hider class gets around this by using some low lying Core Graphics routines to take a picture of the Desktop (with out the icons) and then simply overlays this picture on the Desktop thereby giving the illusion that the icons are gone from the Desktop.

Here is the Hider class that we found worked quite well, even with multiple monitors from macOS 10.10 to the current macOS 10.14.5. I reserve the rights to this code (please acknowledge Entonos if you do use it).

//
//  Hider.swift
//
//  Created by Entonos on 19/6/5.
//  Copyright © 2019 Entonos. All rights reserved.
//
// this class listens for "doHide" notifications on NotificationCenter.default
// when it receives a notification, it toggles between two states: Hide and Show
//   Hide state overlays a picture of the Desktop on each physical Screen thereby hiding the Desktop icons from the user's view
//      if physical Screens change (resolution, number, ...) the overlay(s) will be updated automatically
//      if the user switches Spaces the overlay(s) will be updated automatically
//   Show state destroys the overlay revealing the real Desktop and its icons and stops this class observing notificaitons of Screen and Space changes
//
// works mostly ok. we use some Core Graphics low-level api (see DesktopPictures extension to NSImage at end of file) to grab the Desktop picture.
// for efficiency we'll only create the number of overlays (each sitting in its own window) as there are (physical) screens. but we'll force those windows to go onto all Spaces.
// by setting the appropriate window.level, expose and mission control not see these windows.
//
// the only downside is if the user drags and tries to drop on the Desktop, it won't work (it's not the Desktop!), however its sort of consistent since they
// just told us to hide the icons so why would they add some now? it is good that clicking on the fake desktop does activate the Finder. so that's consistent.
//
// works in a sandbox app (as of macOS 10.14.5)
//
// use:
//  in AppDelegate
//    let hider = Hider() // class is now listening for toggle
//
//  in ViewController, NSMenu, hotkey, etc, send a notification to toggle
//    NotificationCenter.default.post(name: NSNotification.Name("doHide"), object: nil)
//


import Cocoa

class Hider {
    init() {  // get notified when user wants to toggle
        NotificationCenter.default.addObserver(self, selector: #selector(self.doHide), name: NSNotification.Name("doHide"), object: nil) 
    }
    
    var transWindow = [NSWindow]()  // our current Desktop pictures (empty means we're in the Show state)
    
    @objc func doHide() {
        if transWindow.count == 0 {  // appears the user want to hide icons
            for screen in NSScreen.screens {  // create the corresponding windows
                transWindow.append(createWin(screen))
            }
            spaceChange() // and go display them
            // get notified when Spaces or Screens change
            NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(self.spaceChange), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
            NotificationCenter.default.addObserver(self, selector: #selector(self.screenChanged), name: NSApplication.didChangeScreenParametersNotification, object: nil)    
        } else {
            // stop notifications for Screen and Space chages
            NSWorkspace.shared.notificationCenter.removeObserver(self, name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)  
            NotificationCenter.default.removeObserver(self, name: NSApplication.didChangeScreenParametersNotification, object: nil)
            // teardown
            for (index, win) in transWindow.enumerated() {
                win.orderOut(self)
                transWindow[index].windowController?.window = nil
            }
            // we use the fact that transWindow.count = 0 keep track if the icons are hidden or not.
            transWindow.removeAll() 
        }
    }
    
    @objc func screenChanged() {  // call back for when the user reconfigured the Screen
        let screens = NSScreen.screens
        if screens.count > transWindow.count {  // number of screens increase, so create some new windows
            for i in (transWindow.count)..<screens.count {
                transWindow.append(createWin(screens[i]))
            }
        }
         spaceChange()  // regardless of what happened, update the overlays just in case
    }
    
    func createWin(_ screen: NSScreen) -> NSWindow {  
        // create a window w/ the same size as the screen we're given
        return resetWin(NSWindow(contentRect: NSMakeRect(0, 0, NSWidth(screen.frame), NSHeight(screen.frame)), styleMask: .borderless, backing: .buffered, defer: true, screen: screen))
    }
    
    func resetWin(_ win: NSWindow) -> NSWindow {
        win.collectionBehavior = NSWindow.CollectionBehavior.canJoinAllSpaces          // we want the window to follow Spaces around
        win.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.backstopMenu)))  //hack? this makes mission control and expose ignore the window
        // rest is to make the window dumb
        win.canHide = false
        win.isExcludedFromWindowsMenu = true
        win.hidesOnDeactivate = false
        win.discardCursorRects()
        win.discardEvents(matching: NSEvent.EventTypeMask.any, before: nil)
        win.ignoresMouseEvents = true
        win.orderBack(nil)
        win.isRestorable = false
        win.animationBehavior = .none
        return win
    }
    
    @objc func spaceChange() {
        var desktopPics = NSImage.desktopPictures()  // grab pictures of the Desktop(s)
        for (index, screen) in NSScreen.screens.enumerated() {  // cycle through the physical Screens
            for (numPic, desktopPic) in desktopPics.enumerated() {  // find the first desktop picture that has the same size as this screen
                if desktopPic.size.height == screen.frame.height && desktopPic.size.width == screen.frame.width {
                    // get an imageView w/ the correct size and picture
                    let imageView = NSImageView(frame: screen.frame)
                    imageView.image = desktopPic
                    // make sure the window has the same size as the screen
                    if screen.frame != transWindow[index].frame {transWindow[index].setFrame(screen.frame, display: false, animate: false)}
                    // ok, replace the view
                    transWindow[index].contentView = imageView
                    // hopefully to avoid problems on which screen and which desktop, get rid of the ones we've done
                    desktopPics.remove(at: numPic)
                    break
                }
            }
        }
    }
}

extension NSImage { //don't need to do an extension, but it appears fun, so let's do it.
    
    static func desktopPictures() -> [NSImage] {  // for each desktop we find, take a picture add it onto an array and return it

        var images = [NSImage]()
        for window in CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as! [[ String : Any]] {
            print(window)
            // we need windows owned by Dock
            guard let owner = window["kCGWindowOwnerName"] as? String else {continue}
            if owner != "Dock" {
                continue
            }
            // we need windows named like "Desktop Picture %"
            guard let name = window["kCGWindowName"] as? String else {continue}
            if !name.hasPrefix("Desktop Picture") {
                continue
            }
            // ok, this belongs to a screen. grab a picture of it and append to the return array
            guard let index = window["kCGWindowNumber"] as? CGWindowID else {continue}  //pendantic
            let cgImage = CGWindowListCreateImage(CGRect.null, CGWindowListOption(arrayLiteral: CGWindowListOption.optionIncludingWindow), index, CGWindowImageOption.nominalResolution)
            images.append(NSImage(cgImage: cgImage!, size: NSMakeSize(CGFloat(cgImage!.width), CGFloat(cgImage!.height))))
        }
        // return the array of Desktop pictures
        return images
    }
}

*While DIM followed all of the Mac App Store guidelines, only the Finder knows the names and positions of the icons on the Desktop. DIM uses AppleScript to ask the Finder about the icons. Since macOS 10.14, this is severely limited by Apple on security grounds (which is quite valid). However, for DIM to do its main job (memorize and restore Desktop icon arrangements), the guidelines require the user to install an AppleScript. Alas, the Mac App Store Review finds this unacceptable. To get out of this catch-22, we wrote this class so that DIM could do something (namely hide/show Desktop icons) without the user installing the AppleScript. Alas, it wasn’t enough to get past Review. It’s all good, you can still get a notarized and harden version of DIM, albeit outside of the Mac App Store.

Add a Comment

Your email address will not be published. Required fields are marked *