/* Copyright © 2015-2019 Lindenberg Software LLC. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ import UIKit import HTTPServerFramework let debug = false // An HTTP server that can be embedded into an iOS application to enable the installation // of iOS configuration profiles. class MobileConfigServer: RoutingHTTPServer { // Ideas for this class taken from // http://stackoverflow.com/questions/2338035/installing-a-configuration-profile-on-iphone-programmatically // Returning to the app after installing the configuration profile worked reasonably well in iOS 8, // but broke more and more in every release of iOS 9, so I removed it. The server now simply provides // a page with a button that the user can tap to return to the app. // iOS 10.3.3 also broke the installation itself by introducing a dialog asking the user whether she // really wants to go to Settings, which got replaced by the return page before the user had // a chance to agree. The button that the user can tap to return to the app is now provided // on the initial page. // It would be nice to close the tab that Safari has opened for us, but window.close() // doesn't close windows that haven't been opened by script, so we leave the page behind. fileprivate enum ServerState: Int { case stopped, ready, sentMobileConfig, backToApp } fileprivate let returnURL: String fileprivate let returnIcon: Data fileprivate let returnIconSize: Int fileprivate let returnIconLabel: String fileprivate let returnIconLabelSize: Int fileprivate var mobileConfigData: Data? fileprivate var mobileConfigName: String? fileprivate var serverState: ServerState = .stopped fileprivate var startTime: Date? fileprivate var registeredForNotifications = false fileprivate var backgroundTask = UIBackgroundTaskInvalid // returnURL is a URL using a custom scheme that, when given to Safari, will return to // the app in which the server is embedded. // returnIcon is an image that can be shown in Safari to invite the user to return to // the app in which the server is embedded. // returnIconSize is the size in CSS pixels in which the icon should be shown in Safari. // returnIconLabel is a string that can be shown in Safari along with the icon. // returnIconLabelSize is the font size in which returnIconLabel should be shown. init(returnURL: String, returnIcon: Data, returnIconSize: Int, returnIconLabel: String, returnIconLabelSize: Int) { self.returnURL = returnURL self.returnIcon = returnIcon self.returnIconSize = returnIconSize self.returnIconLabel = returnIconLabel self.returnIconLabelSize = returnIconLabelSize super.init() handleMethod("GET", withPath: "/start", target: self, selector: #selector(MobileConfigServer.handleStartRequest(_:response:))) handleMethod("GET", withPath: "/load", target: self, selector: #selector(MobileConfigServer.handleLoadRequest(_:response:))) handleMethod("GET", withPath: "/icon", target: self, selector: #selector(MobileConfigServer.handleIconRequest(_:response:))) } deinit { unregisterFromNotifications() } // Starts the server to install the given configuration profile data, using the given name // for the title of web pages that the user might encounter. func start(_ mobileConfigData: Data, mobileConfigName: String) -> Bool { self.mobileConfigData = mobileConfigData self.mobileConfigName = mobileConfigName do { try start() return true } catch _ { return false } } override func start() throws { assert(serverState == .stopped) assert(mobileConfigData != nil) assert(mobileConfigName != nil) do { try super.start() } catch let error as NSError { log("Could not start server, error code \(error.code)") throw error } startTime = Date() serverState = .ready log("Started server, listening on port \(listeningPort())") registerForNotifications() } override func stop() { if serverState != .stopped { log("Stopping server") super.stop() serverState = .stopped unregisterFromNotifications() } } @objc func handleStartRequest(_ request: RouteRequest, response: RouteResponse) { log("Reached handleStartRequest") if serverState == .ready { // To start, send a web page with a script that will try once to replace // itself with something obtained from /load. The data returned for // the load request is the configuration profile, so it doesn't actually // replace the page. // Timeout time: // 800ms lead to noticeable pause on faster devices // 200ms can cause Safari to skip installation and return to the app right away // 600ms seems to work well on all devices let size = returnIconSize let style = "html, body {height: 100%; width: 100%; margin: 0; padding: 0;}" + "p {font-family: -apple-system, Helvetica; text-align: center;}" + "p.name {margin-top: 1em; margin-bottom: 1em; font-size: \(2 * returnIconLabelSize)px;}" + "p.label {margin: 6px; font-size: \(returnIconLabelSize)px;}" + "a {display: block; height: \(size)px; width: \(size)px; margin: 0px calc(50% - \(size / 2)px) 0px calc(50% - \(size / 2)px);}" + "img {height: \(size)px; width: \(size)px; border-radius: 22.5%;}" let script = "function load() { window.location.href='http://localhost:\(listeningPort())/load/'; }" + "window.setTimeout(load, 600);" let page = "" + "
" + "\(mobileConfigName!)
" + "\(returnIconLabel)
" + "" response.respond(with: page) log("Handled /start request; responded with '\(page)") } else { assert(false) response.statusCode = 500 // internal server error log("Handled /start request; reported error") } } @objc func handleLoadRequest(_ request: RouteRequest, response: RouteResponse) { log("Reached handleLoadRequest") switch serverState { case .stopped: assert(false) response.statusCode = 500 // internal server error log("Handled /load request; reported error") case .ready: // We send the mobile config data exactly once. Safari starts the // installation process for the data. response.setHeader("Content-Type", value: "application/x-apple-aspen-config") response.respond(with: mobileConfigData!) log("Handled /load request; returned mobile config data") serverState = .sentMobileConfig case .sentMobileConfig, .backToApp: break } } @objc func handleIconRequest(_ request: RouteRequest, response: RouteResponse) { log("Reached handleIconRequest") switch serverState { case .stopped: assert(false) response.statusCode = 500 // internal server error log("Handled /icon request; reported error") case .ready, .sentMobileConfig, .backToApp: response.setHeader("Content-Type", value: "image/png") response.respond(with: returnIcon) log("Handled /icon request; returned image data") } } fileprivate func returnedToApp() { if serverState != .stopped { serverState = .backToApp } } fileprivate func registerForNotifications() { if !registeredForNotifications { let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(MobileConfigServer.didEnterBackground(_:)), name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil) notificationCenter.addObserver(self, selector: #selector(MobileConfigServer.willEnterForeground(_:)), name: NSNotification.Name.UIApplicationWillEnterForeground, object: nil) registeredForNotifications = true log("Registered for notifications") } } fileprivate func unregisterFromNotifications() { if registeredForNotifications { let notificationCenter = NotificationCenter.default notificationCenter.removeObserver(self, name: NSNotification.Name.UIApplicationDidEnterBackground, object: nil) notificationCenter.removeObserver(self, name: NSNotification.Name.UIApplicationWillEnterForeground, object: nil) registeredForNotifications = false } } @objc func didEnterBackground(_ notification: Notification) { if serverState != .stopped { startBackgroundTask() } } @objc func willEnterForeground(_ notification: Notification) { if backgroundTask != UIBackgroundTaskInvalid { stopBackgroundTask() returnedToApp() } } fileprivate func startBackgroundTask() { assert(backgroundTask == UIBackgroundTaskInvalid) let application = UIApplication.shared backgroundTask = application.beginBackgroundTask() { DispatchQueue.main.async { self.stopBackgroundTask() } } log("Started background task with id \(backgroundTask)") } fileprivate func stopBackgroundTask() { if backgroundTask != UIBackgroundTaskInvalid { UIApplication.shared.endBackgroundTask(self.backgroundTask) backgroundTask = UIBackgroundTaskInvalid log("Stopped background task") } } fileprivate func log(_ message: String) { if debug { var toPrint = message let time = Date().timeIntervalSince(startTime!) let timeString = String(format: "%.3f", time) toPrint = "\(toPrint); time: \(timeString)" print(toPrint) } } }