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.
Hopefully, you found interest in my scribbles. If you have commentary or a response, I'd love to hear it.