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:

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.