Taking on new clients for Elixir and web development.
Mike Zornek

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.


About the Author. Mike Zornek is a developer and teacher focusing on product design and development with a heavy focus on Elixir and LiveView. In between his projects, Mike helps other teams through consulting. During off hours, he enjoyed watching Phillies baseball and playing relaxing video games.

Hopefully, you found interest in my scribbles. If you have commentary or a response, I'd love to hear it.