Making a macOS screen saver in swift with SceneKit

Lately, I have been thinking of a lost art in Cocoa ( now macOS ) developers: creating screen saver plugins for the preference pane. As it turns out, Apple has pretty outdated documentation, there is not much information on the internet, and Xcode does not have a template for starting out in swift -- only Objective C. My goal was not only to make a screen saver plugin with swift, but also using sceneKit. This proved to be very challenging, if not impossible.

This year, at the 2016 WWDC, I decided to ask a few of the Apple engineers how I can get this done. After talking to 3 or 4 different enigneers, they had come to the conlusion that this task may not be possible or supported by Apple.

I later found out after much trial and error that it can be done, with a few caveats. Here is the swift 2 finished source.

Now, lets walk through the details ...

Start with the boilerplate screen saver project

In Xcode, Choose Project -> New. OS X : System Plugin.

Give it a name and go

As of Xcode 7.3, there is no option for swift. We will fix that later.

Add some code in Objective C for testing

Lets print "hello screen saver plugin" with a white background.
Edit the MySwiftScreenSaver.m file:

    - (void)drawRect:(NSRect)rect
    {
        [super drawRect:rect];

        [[NSColor whiteColor] setFill];
        NSRectFill(self.bounds);
        [[NSColor blackColor] set];
        NSString *helloStr = @"hello screen saver plugin";
        [helloStr drawAtPoint:NSMakePoint(100.0, 200.0) withAttributes:nil];
    }

Testing your new screen saver project

You will do this a lot. Get used to this procedure for testing your project:

  • close the screen saver proferences ( quit or force quit )
  • run the project
  • find the output saver file in the Product folder of your project

  • right click on the saver file and click "Open with External Editor". It will ask you if you want to install this for this user, or all users

  • You should see a white screen in preview, not a black one. If you click "preview", you should see your hello screen saver text in black.

Add a swift class to replace builtin Objective C ones

  • File -> New File -> macOS -> Cocoa Class
  • Name it SwiftSS, subclass NSObject, language swift
  • When asked if you want to create a bridging header, you can say no (don't create) unless you want to bridge in Objective C code from an older project

  • Source:

    import ScreenSaver
    
    
    class SwiftSS: ScreenSaverView {
    
    
    
    override func drawRect(dirtyRect: NSRect) {
        super.drawRect(dirtyRect)
    
    
        NSColor.redColor().setFill()
        NSRectFill(self.bounds)
        NSColor.blackColor().set()
    
    
        let hello:NSString = "hello SWIFT screen saver plugin"
        hello.drawAtPoint(NSPoint(x: 100.0, y: 200.0), withAttributes:nil)
    }
    
    }

Debug broken screen saver

Just adding swift into your project will break it. Here is what you will see in system preferences when something goes wrong running a screen saver, as you can see it is not terribly informative:
Lets open console to see what went wrong
can't load libswiftAppKit and can't get principle class

Fix principle class

in your info.plist change the principle class to your new swift class name: SwiftSS

Edit Build settings

Edit your target settings. In build phases, set Always Embed Swift Standard Libraries to YES

Hello swift! Soo swifty

You should have a working swift screen saver now. You should see a red background and if you preview it shoud now say hello SWIFT screen saver plugin

Delete the Objective C files

  • delete / trash MySwiftScreenSaverView.h and MySwiftScreenSaverView.m that were created by the initial Xcode template, they are no longer needed. If you accidentally created a bridging header its safe to delete that as well.
  • test your project again by building and installing into a ( closed ) preference pane.

Now lets move on the sceneKit section.

Adding in SceneKit

  • create a new sceneKit project just to get the code and assets to copy into our project
    • File -> New Project -> OS X -> Game
    • Give this a name like "DeleteMe". It does not really matter since you are just using it for reference code and assets.
  • copy and paste the relevant swift code to generate a scene into your SwiftSS.swift file:

Lets put this into a new function:

        func prepareSceneKitView() {

          // create a new scene
          let scene = SCNScene(named: "art.scnassets/ship.scn")!

          // create and add a camera to the scene
          let cameraNode = SCNNode()
          cameraNode.camera = SCNCamera()
          scene.rootNode.addChildNode(cameraNode)

          // place the camera
          cameraNode.position = SCNVector3(x: 0, y: 0, z: 15)

          // create and add a light to the scene
          let lightNode = SCNNode()
          lightNode.light = SCNLight()
          lightNode.light!.type = SCNLightTypeOmni
          lightNode.position = SCNVector3(x: 0, y: 10, z: 10)
          scene.rootNode.addChildNode(lightNode)

          // create and add an ambient light to the scene
          let ambientLightNode = SCNNode()
          ambientLightNode.light = SCNLight()
          ambientLightNode.light!.type = SCNLightTypeAmbient
          ambientLightNode.light!.color = NSColor.darkGrayColor()
          scene.rootNode.addChildNode(ambientLightNode)

          // retrieve the ship node
          let ship = scene.rootNode.childNodeWithName("ship", recursively: true)!

          // animate the 3d object
          ship.runAction(SCNAction.repeatActionForever(SCNAction.rotateByX(0, y: 2, z: 0, duration: 1)))

          // retrieve the SCNView
          let scnView = self.scnView

          // set the scene to the view
          scnView.scene = scene

          // allows the user to manipulate the camera
          scnView.allowsCameraControl = true

          // show statistics such as fps and timing information
          scnView.showsStatistics = true   
        }
  • Now lets add this the the initialization of the screen saver instead of drawRect; we only want to initialize sceneKit once. This is done by overriding the init(frame:isPreview) function in the ScreenSaverView class. For now, lets just initialize an empty scene and make its background yellow so we can see it working:
    override init?(frame: NSRect, isPreview: Bool) {

        super.init(frame: frame, isPreview: isPreview)

        //probably not needed, but cant hurt to check in case we re-use this code later
        for subview in self.subviews {
            subview.removeFromSuperview()
        }

        //initialize the sceneKit view
        self.scnView = SCNView.init(frame: self.bounds)

        //prepare it with a scene
        //prepareSceneKitView()

        //set scnView background color
        scnView.backgroundColor = NSColor.yellowColor()

        //add it in as a subview
        self.addSubview(self.scnView)
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
  • add in the correct import for sceneKit and var at the top of the class to hold the scnView:
import ScreenSaver  
import SceneKit

class SwiftSS: ScreenSaverView {

    var scnView: SCNView!
  • Remove the drawRect function, it is no longer needed.
  • implement a stub required init?(coder: NSCoder) method which calls super as Xcode will complain if you do not.
      required init?(coder: NSCoder) {
          super.init(coder: coder)
      }

Drag in the assets from the temporary project

Drag the two items from the art.scnassets folder from the temporary project into yours. Put them in the root of the project for now ( you can organize them later). Make sure to check "copy items if needed" so Xcode will copy the assets into your local projects folder. Also double check that your screen saver target is checked so it will add these items to your bundle now and going forward.
Afterwards, your project structure should look like this:

Fix paths on Assets

Screen saver plugins do not use the main bundle of the project, so many of the convenience methods provided by the asset classes will not load the content properly. These are the named: functions on classes like NSImage and SCNScene.

  • SceneKit Scenes Notice in the prepareSceneKit function, we have this line: let scene = SCNScene(named: "art.scnassets/ship.scn")! which is forcing the optional scene to be loaded into the scene variable. If we ran this function right now, it would fail with a nil optional. This is because the convenience initializer for sceneKit named is assuming that this file "ship.scn" is in the bundle for the project. In our environment this is not the case. Lets fix that by creating an extension which will load the scene from the correct bundle by classname.
    • New file -> macOS -> Other -> Empty
    • save as "SCNSceneExtension.swift"
            import Cocoa
            import SceneKit

            extension SCNScene {

                public convenience init?(pathAwareName name: String) {
                    let bundle = NSBundle(forClass:object_getClass(SwiftSS))
                    if let sceneURL = bundle.URLForResource( name, withExtension: nil) {
                        try! self.init(URL: sceneURL, options: nil )

                    } else {
                        self.init(named: name)
                    }
                }
            }
  • call prepareForSceneKitView() in your init method
        //initialize the sceneKit view
        self.scnView = SCNView.init(frame: self.bounds)
        //prepare it with a scene
        prepareSceneKitView()
        //set scnView background color
        scnView.backgroundColor = NSColor.yellowColor()
  • edit the prepareForSceneKit() line that loads the scene to use the new extension instead of the usual scene( named: ) method to the pathAwareName: method instead:
    let scene = SCNScene(pathAwareName: "ship.scn")!

You should start seeing the ship scene now:

Using OpenGL rendering instead of Metal

In testing sceneKit inside the screen saver environment, there seems to be quite a bit of dropped frames and on laptops the fans will spin up to keep the machine cool. So far, it appears switching the rendering API to OpenGL will solve this problem:

//openGL seems to perform better on SS + SceneKit.  On multiple monitors this was causing fans to kick in using default ( Metal )
        let options = [SCNPreferredRenderingAPIKey: SCNRenderingAPI.OpenGLCore32.rawValue]
        self.scnView = SCNView.init(frame: self.bounds, options: options)

Other Hacks Due to Bundle Issues

  • NSImage

The bundle for name issue also affects NSImages. Lets address that in a similar manner to the previous scene loading solution.
Note: To load NSImages, you now need to use the pathAwareName method instead of named: when initializing:

import Cocoa

extension NSImage {

    public convenience init?(pathAwareName name:String ) {
        if let imageURL = NSBundle.pathAwareBundle().URLForResource(name, withExtension: nil) {
            self.init(contentsOfURL: imageURL)!
        } else {
            self.init(named: name)
        }
    }
}
  • Custom fonts

Same song, new dance. Create an extension on NSBundle to register fonts fron the alternate bundle

import Cocoa

extension NSBundle {  
    static func pathAwareBundle() -> NSBundle {
        return NSBundle(forClass:object_getClass(swiftSS))
    }

    static func registerFonts() {
        //force registration of the fonts
        let bundle = self.pathAwareBundle()
        if let fontURLs = bundle.URLsForResourcesWithExtension( "TTF" , subdirectory: "" ) {

            for fontURL in fontURLs {
                //let url = NSURL(fileURLWithPath: fontURL as String)
                var errorRef: Unmanaged<CFError>?
                let succeeded = CTFontManagerRegisterFontsForURL(fontURL, .Process, &errorRef)

                if (errorRef != nil) {
                    let error = errorRef!.takeRetainedValue()
                }
            }
        }
    }
}
  • Forcing textures in Scenes to use alternate bundle

If you want to organize your assets into a subfolder, you will need to refactor your SCScene extension to also force any textures that are images to the alternate bundle by calling a new method repointMaterialForChildNodes after init.

import Cocoa  
import SceneKit

extension SCNScene {

    public convenience init?(pathAwareName name: String) {
        let bundle = NSBundle.pathAwareBundle()
        if let sceneURL = bundle.URLForResource( name, withExtension: nil) {
            try! self.init(URL: sceneURL, options: nil )

            //attempt to fix materials
            repointMaterialForChildNodes(self.rootNode)

        } else {
            self.init(named: name)
        }
    }

    func repointMaterialForChildNodes( node: SCNNode) {
        for childNode in (node.childNodes) {
            //repoint all materials
            if let geometry = childNode.geometry {
                for material in geometry.materials {
                    repointMaterial(material)
                }
            }
            for newNode in childNode.childNodes {
                repointMaterialForChildNodes(newNode)
            }
        }

    }

    func repointMaterial(material: SCNMaterial) {
        let properties = [ material.diffuse, material.normal, material.specular, material.reflective]
        for property in properties {
            repointContentInMaterialProperty(property)
        }
    }

    func repointContentInMaterialProperty( property: SCNMaterialProperty ) {
        if let contents = property.contents {
            if let filename = filenameInMaterialContentDescription( contents.description ) {
                if let newImage = NSImage(pathAwareName: filename) {
                    property.contents = newImage
                }
            }
        }
    }

    func filenameInMaterialContentDescription(contentString: String ) -> String? {
        let l = contentString.componentsSeparatedByString(" -- ")
        let filePath = l[0]
        if let url = NSURL.init(string: filePath) {
            if let components = url.pathComponents {
                if let lastComp = components.last {
                    return lastComp
                }
            }
        }

        //else
        return nil
    }
}

Warning: This solution is extremely brittle and likely to break at some point in the future. Essentially we walk through the node in the scene tree and inspect the material properties content and if it has the string " -- " in it, we assume it is pointing to a file ( an image ). Then we instantiate an NSImage pointing to the alternate bundle and re-assign the texture. The upside is it should work to get your scenes textures rendering if you are using folders to organize your assets.

Disable rendering on multiple monitors

By default the screen saver engine will run a seperate intance of each ScreenSaverView on each montitor. With sceneKit and openGL based scenes, you might be taxing the CPU of your users too much by rendering the 3D scenes on each monitor. The best solution is to detect with is the main monitor ( the one with the menu bar on it) and only add the sceneKit scene to that one. On the others, do not a subview and it will render these black by default.

Here is the finished source for reference.

Mike Hill

Read more posts by this author.