Available for Elixir Consulting.

Isolating Mac Application Menu Behaviors

Posted on

A Place for Everything, and Everything in It’s Place

My side project is a Mac app and last week I was working on a small story about sending feedback.

Send Feedback under Help Menu

As a user,

I want to be able to Submit Feedback via the Help menu,

So that I let the developer know what I’d like changed.

Acceptance Criteria:

  • Under the Help menu there should be option to submit feedback.
  • Upon selecting this menu item a new email will be open.
  • to: mzornek+storyteller@gmail.com
  • subject: [Storyteller Feedback] [1.0(101)] — that is the version number and build number

This was easy enough to get working but I wasn’t in love with my first implementation. If you read up on the Menu documentation for macOS you’ll find out application menus will follow the Responder Chain . A responder chain of a document-based application looks like this:

responder chain of a document-based application

Now while this is a document-based application this behavior is an application-level behavior. The best spot to put it is in the AppDelegate but I don’t like polluting that class.

My new solutions helps improve the situation in lieu of the framework’s design constraints. I still have the IBAction inside the AppDelegate but it now forwards the behavior to another object that is more isolated, with a single responsibility and is easier to test.

// AppDelegate+SubmitFeedback.swift
import Cocoa

extension AppDelegate {
    @IBAction private func submitFeedback(sender: AnyObject?) {
        submitFeedbackService.submitFeedback()
    }
}


// SubmitFeedbackService.swift
import Cocoa

protocol URLOpener {
    func openURL(url: NSURL) -> Bool
}

extension NSWorkspace: URLOpener { }

struct SubmitFeedbackService {

    private var to: String {
        return "mzornek+storyteller@gmail.com".urlEscape()
    }

    private var subject: String {
        return "[Feedback: Storyteller \(versionString)] ".urlEscape()
    }

    private var versionString: String {
        let appVersion = NSBundle.mainBundle().appVersion
        let bundleVersion = NSBundle.mainBundle().appBundleVersion
        return "\(appVersion) (\(bundleVersion))"
    }

    private let urlOpener: URLOpener

    init(workspace: URLOpener = NSWorkspace.sharedWorkspace()) {
        urlOpener = workspace
    }

    func submitFeedback() {
        let urlTemplate = "mailto:\(to)?subject=\(subject)"
        guard let emailURL = NSURL(string: urlTemplate) else {
            assertionFailure("Email should parse fine.")
            return
        }
        urlOpener.openURL(emailURL)
    }
}

private extension String {
    func urlEscape() -> String {
        guard let result = self.stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.URLQueryAllowedCharacterSet()) else {
            assertionFailure("Could not escape string for URL")
            return self
        }
        return result
    }
}

// SubmitFeedbackServiceTests.swift

import XCTest
@testable import Storyteller

class SubmitFeedbackServiceTests: XCTestCase {

    func testCallingSubmitFeedbackOpensAMailtoURL() {
        let mockWorkspace = NSWorkspaceMock()
        let service = SubmitFeedbackService(workspace: mockWorkspace)
        service.submitFeedback()
        XCTAssertNotNil(mockWorkspace.lastOpenedURL)
        XCTAssertEqual(mockWorkspace.lastOpenedURL!.scheme, "mailto")
    }

}

class NSWorkspaceMock: NSObject, URLOpener {
    var lastOpenedURL: NSURL?
    func openURL(url: NSURL) -> Bool {
        lastOpenedURL = url
        return true
    }
}

Feels cleaner to me but I welcome feedback. I also suspect SubmitFeedbackService will evolve in time as there is other communication needs in the future.

PS: I hope to share more about the implementation of project in the future. I know there is a void of Mac application programming discussions going on out in the web. I will try to help out with my own journalling the best I can. Questions welcome.