Swipe up, swipe down, spin around, and tap your foot to the rhythm of the Macarena. You’ve now got access to the ✨ secret developer menu ✨.
Some of the earliest known cheat codes and secret debug menus date back to the 1980s, but with today’s technology and security in mind - what option works best for your app?
As engineers, we spend so much time crafting the best user experience imaginable - but how much time goes into making it easier for us to iterate? A well developed debugger menu can shave hours off of testing your new features, or resolving existing bugs.
The possibilities are endless, limited only by your imagination and the effort you're willing to commit. Changing feature toggles, resetting caches, changing environments, viewing logs or the current state, are just a few ideas of what can be achieved.
At Sidetrack, our in-app debug menu allows us to quickly review the individual cached messages that are used to project the latest service information. As a single view in our app can be a sum of 100 different messages, one error early in the chain can quickly cause incorrect information to show in many places.
But where does a debug menu live, and who should have access? Depending on your personal risk factors, the type of issues you commonly see, and your methods of development, you may find one or more of the solutions in this post helpful.
Simulator and Debug Builds
Using compiler directives, you can identify code which should not be compiled into certain builds of your application.
#if DEBUG will ensure the wrapped code is not included in release builds of your application. Likewise,
#if targetEnvironment(simulator) will only compile when you're targeting a build for your iOS simulator.
This is a very simple solution, and there is no possibility of end users accessing this functionality as it will simply not exist in their version of the app. However, if your goal is to have access to your debug menu in production, then this doesn't help.
When SwiftUI was initially released, previews were wrapped in an
#if DEBUG directive to prevent them bloating the App Store binary. As of Xcode 11, Apple does this automatically for you. Similarly, tools such as FLEX and Inject disable certain functionality when compiled for release builds, demonstrating a good use case.
It can be possible to detect whether the build of your app was installed via TestFlight by checking the App Store receipt URL. This can be helpful to turn on debug capabilities to specific opt-in users.
However, there is no differentiation between internal TestFlight or various external TestFlight groups (which may mean providing access to a larger group than you intended). Additionally, this solution does not work for Catalyst apps or apps distributed via TestFlight for macOS. It's not officially supported by Apple, and could break at any time.
You could look into creating a separate TestFlight build configuration. This would enable you to use compiler directives (similar to
#if DEBUG) and create special builds just for TestFlight. You could send different builds to different groups, giving you full control.
However, this provides a lot of overhead and it becomes easy to accidentally send the wrong build to the App Store. This has been seen many times before, even by Apple themselves who have accidentally shipped a build containing developer tools to the public numerous times before.
Gestures are the art of hiding functionality in plain sight, simply through obscurity. Users have come to expect some amount of gestures, but you can definitely take it a step further to unlock debug menus if the user knows the right pattern.
Konami Code is a cheat code made up of a sequence of 10 button presses, made popular in the 1980s by a game called Contra where the input would provide the player with 30 extra lives. Nowadays, it has been adapted for mobile utilising swipe gestures. There are many open-source libraries providing this behaviour - I know this because if you check the open-source acknowledgements page of a number of large apps, you may just find a reference to it.
Gestures may well get the job done, but they are not secure and can be easily stumbled into. If you do use them, please ensure that you consider users of non-visual assistive technologies and how it could impact their experience.
URL Schemes and Text Inputs
Defining a custom URL scheme in iOS is as simple as adding a couple lines to your Info.plist and handling the URL in your AppDelegate file or SwiftUI
onOpenURL block. Instead of a URL if you have a text input field, such as a search bar, you could use the value from it instead.
This would allow a test user to enter a URL such as
sidetrack://super-secret-debug-menu into an app like Safari and your app grants them access to the debug menu.
However, secret strings and URLs like these can be easily discovered if the user is determined enough. For example, the iOS Phone app has a number of discovered numbers which, if called, provides access to hidden functionality.
If your application supports login, then you may find that utilising this system is a fantastic way to unlock debug functionality for certain users. This can be achieved by either hard-coding user identifiers into the app (which would require an app release to change) or adding a new parameter to the user's profile response (though beware of the integrity of the response!)
This can often be a very easy option building on top of existing functionality. However, not everybody has a login capability in their app, and indeed you may still want to access the debug menu when you're not logged in.
Trusted Profiles (Our Solution)
The solution we landed on with Sidetrack was to utilise a configuration profile. This is a special kind of certificate which can be created using Keychain Access and installed onto your mobile devices as a custom profile. Your mobile app can then detect the presence of this custom profile by verifying a certificate file which is embedded in the app binary.
While slightly increasing complexity, this addresses all of the major concerns raised with other solutions. A user cannot accidentally stumble into this, and it does not require any existing functionality within your app. It works with both debug and release builds, no matter where or how they're distributed.
My favourite feature is that the same certificate can be used across an unlimited number of devices and apps. This is fantastic for indie developers and companies alike, who want a single solution that works for all of their products and teams.
You can also setup multiple certificates, unlocking different tiers of privilege, or to provide expiry dates for contractors or guests who only need access for a set period of time.
But how exactly does it work? Well it relies on Apple's security framework, more specifically the SecTrustEvaluate function, which allows us evaluate whether or not we trust a given certificate (the profile which is loaded from the app binary). As the certificate uses a custom signing authority, this will only evaluate as
true if the device has a special configuration profile installed which we generate separately and is linked to the signing authority. Only the matching profile will cause the code to succeed.
We've provided step-by-step instructions on how to generate your own profiles and certificates, with the Swift code needed to verify trust, in this Gist. We're also working on an open-source command-line tool to make it accessible to everybody, so why not star our repository and watch to be notified of future updates?
We understand this is not an exhaustive list either. In our research we identified many other solutions including:
- A password or PIN code requested on opening the debug menu
- Unlocking the debug menu using a remote push notification
- Verifying specific device metadata such as name or connected WiFi address
- Utilising a shared keychain or user defaults group, and another app installed on the same device
We'd love to hear your feedback, perhaps there's a solution we didn't explore, or you've learnt something new here. Either way you can reach us over on our Twitter.