76

I would like to ship a configuration profile with my iPhone application, and install it if needed.

Mind you, we're talking about a configuration profile, not a provisioning profile.

First off, such a task is possible. If you place a config profile on a Web page and click on it from Safari, it will get installed. If you e-mail a profile and click the attachment, it will install as well. "Installed" in this case means "The installation UI is invoked" - but I could not even get that far.

So I was working under the theory that initiating a profile installation involves navigating to it as a URL. I added the profile to my app bundle.

A) First, I tried [sharedApp openURL] with the file:// URL into my bundle. No such luck - nothing happens.

B) I then added an HTML page to my bundle that has a link to the profile, and loaded it into a UIWebView. Clicking on the link does nothing. Loading an identical page from a Web server in Safari, however, works fine - the link is clickable, the profile installs. I provided a UIWebViewDelegate, answering YES to every navigation request - no difference.

C) Then I tried to load the same Web page from my bundle in Safari (using [sharedApp openURL] - nothing happens. I guess, Safari cannot see files inside my app bundle.

D) Uploading the page and the profile on a Web server is doable, but a pain on the organizational level, not to mention an extra source of failures (what if no 3G coverage? etc.).

So my big question is: **how do I install a profile programmatically?

And the little questions are: what can make a link non-clickable within a UIWebView? Is it possible to load a file:// URL from my bundle in Safari? If not, is there a local location on iPhone where I can place files and Safari can find them?

EDIT on B): the problem is somehow in the fact that we're linking to a profile. I renamed it from .mobileconfig to .xml ('cause it's really XML), altered the link. And the link worked in my UIWebView. Renamed it back - same stuff. It looks as if UIWebView is reluctant to do application-wide stuff - since installation of the profile closes the app. I tried telling it that it's OK - by means of UIWebViewDelegate - but that did not convince. Same behavior for mailto: URLs within UIWebView.

For mailto: URLs the common technique is to translate them into [openURL] calls, but that doesn't quite work for my case, see scenario A.

For itms: URLs, however, UIWebView works as expected...

EDIT2: tried feeding a data URL to Safari via [openURL] - does not work, see here: iPhone Open DATA: Url In Safari

EDIT3: found a lot of info on how Safari does not support file:// URLs. UIWebView, however, very much does. Also, Safari on the simulator open them just fine. The latter bit is the most frustrating.


EDIT4: I never found a solution. Instead, I put together a two-bit Web interface where the users can order the profile e-mailed to them.

Community
  • 1
  • 1
Seva Alekseyev
  • 59,826
  • 25
  • 160
  • 281

10 Answers10

39

1) Install a local server like RoutingHTTPServer

2) Configure the custom header :

[httpServer setDefaultHeader:@"Content-Type" value:@"application/x-apple-aspen-config"];

3) Configure the local root path for the mobileconfig file (Documents):

[httpServer setDocumentRoot:[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]];

4) In order to allow time for the web server to send the file, add this :

Appdelegate.h

UIBackgroundTaskIdentifier bgTask;

Appdelegate.m
- (void)applicationDidEnterBackground:(UIApplication *)application {
    NSAssert(self->bgTask == UIBackgroundTaskInvalid, nil);
    bgTask = [application beginBackgroundTaskWithExpirationHandler: ^{
        dispatch_async(dispatch_get_main_queue(), ^{
            [application endBackgroundTask:self->bgTask];
            self->bgTask = UIBackgroundTaskInvalid;
        });
    }];
}

5) In your controller, call safari with the name of the mobileconfig stored in Documents :

[[UIApplication sharedApplication] openURL:[NSURL URLWithString: @"http://localhost:12345/MyProfile.mobileconfig"]];
Michaël
  • 6,676
  • 3
  • 36
  • 55
  • @SevaAlekseyev Did this solution ever helped you? – Oleg Danu Dec 13 '12 at 21:21
  • No, we rethought the whole architecture instead. There's a public reverse proxy now. – Seva Alekseyev Dec 13 '12 at 23:33
  • I assume the app "App Icons" does exactly this. You can install configuration including web clip icons, even with Air plane mode turned on. https://itunes.apple.com/jp/app/ios-7-yongniapuriaikon-homu/id619910206?mt=8&ign-mpt=uo%3D4 – Jonny Oct 31 '13 at 04:34
  • Can you please explain how to install RoutingHTTPServer? I am not able to build this and install on iPhone. – iamMobile Mar 04 '15 at 22:27
  • i got this work in my app, and uploaded it to App store, but Apple rejected it saying "We found that your app uses public APIs in a manner not prescribed by Apple". Does Any one have suggestion? What can i do now? – Madhu Aug 12 '15 at 05:47
  • It seems the this doesn't work for iOS 9... anyone can confirm? – Gianluca Dec 21 '15 at 16:54
  • 2
    Did you used this on an AppStore destinated app? Was the .mobileconfig file already signed with a trusted certificate o with a self-signed certificate? I would like to know if apple could reject an app that install a self-signed mobileconfig – iGenio Nov 05 '16 at 10:11
  • 2
    Is it possible to redirect back to the app from safari browser once we install our configure profile? – Abilash Balasubramanian Jan 10 '18 at 13:59
  • 2
    @igenio SmartJoin.us was accepted into the App Store and uses an unsigned configuration profile which it serves up to the Safari from a web server in the app – alfwatt Mar 28 '18 at 19:42
28

The answer from malinois worked for me, BUT, I wanted a solution that came back to the app automatically after the user installed the mobileconfig.

It took me 4 hours, but here is the solution, built on malinois' idea of having a local http server: you return HTML to safari that refreshes itself; the first time the server returns the mobileconfig, and the second time it returns the custom url-scheme to get back to your app. The UX is what I wanted: the app calls safari, safari opens mobileconfig, when user hits "done" on mobileconfig, then safari loads your app again (custom url scheme).

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    // Override point for customization after application launch.

    _httpServer = [[RoutingHTTPServer alloc] init];
    [_httpServer setPort:8000];                               // TODO: make sure this port isn't already in use

    _firstTime = TRUE;
    [_httpServer handleMethod:@"GET" withPath:@"/start" target:self selector:@selector(handleMobileconfigRootRequest:withResponse:)];
    [_httpServer handleMethod:@"GET" withPath:@"/load" target:self selector:@selector(handleMobileconfigLoadRequest:withResponse:)];

    NSMutableString* path = [NSMutableString stringWithString:[[NSBundle mainBundle] bundlePath]];
    [path appendString:@"/your.mobileconfig"];
    _mobileconfigData = [NSData dataWithContentsOfFile:path];

    [_httpServer start:NULL];

    return YES;
}

- (void)handleMobileconfigRootRequest:(RouteRequest *)request withResponse:(RouteResponse *)response {
    NSLog(@"handleMobileconfigRootRequest");
    [response respondWithString:@"<HTML><HEAD><title>Profile Install</title>\
     </HEAD><script> \
     function load() { window.location.href='http://localhost:8000/load/'; } \
     var int=self.setInterval(function(){load()},400); \
     </script><BODY></BODY></HTML>"];
}

- (void)handleMobileconfigLoadRequest:(RouteRequest *)request withResponse:(RouteResponse *)response {
    if( _firstTime ) {
        NSLog(@"handleMobileconfigLoadRequest, first time");
        _firstTime = FALSE;

        [response setHeader:@"Content-Type" value:@"application/x-apple-aspen-config"];
        [response respondWithData:_mobileconfigData];
    } else {
        NSLog(@"handleMobileconfigLoadRequest, NOT first time");
        [response setStatusCode:302]; // or 301
        [response setHeader:@"Location" value:@"yourapp://custom/scheme"];
    }
}

... and here is the code to call into this from the app (ie viewcontroller):

[[UIApplication sharedApplication] openURL:[NSURL URLWithString: @"http://localhost:8000/start/"]];

Hope this helps someone.

xaphod
  • 6,392
  • 2
  • 37
  • 45
  • 1
    if this works then awesome. Thanks a lot. I was looking for the solution for a long time. – aparna May 02 '14 at 10:00
  • I am using this successfully. You might need to adjust the reload time interval -- 400ms here – xaphod May 06 '14 at 09:37
  • Is there any other way to return to the app after isntallation? The user is pressing "done" button and I wonder if there is any handler for that? – EmilDo Jun 08 '14 at 17:06
  • If you mean the native UI for installing a profile, and the Done button at the top-right, then no, there's no public API that allows control of that which I know of. – xaphod Jun 08 '14 at 21:53
  • It goes back to app after installation of profile. However, when I open safari again, it always also go to the app. How can I stop to go back to app and only allow one time? – Khant Thu Linn Sep 14 '14 at 03:50
  • Try adding to the javascript to close the window after reload, or, execute the action a set number of times. You will need to experiment – xaphod Sep 14 '14 at 07:42
  • 1
    this is amazing. will try this out. definitely a huge win for enterprise apps. – ninjaneer Mar 31 '15 at 19:28
  • @xaphod Hey I have implemented the code, which you have mentioned above. And I am quite near to get things to be done. My problem is. When I launch the application, first it open up the blank page in safari and then when I come back to application then it redirect me to profile installation page. Any idea why this is happening. Deadly stuck here. Any help is appreciated. Why initially it is opening blank page in safari? Tried multiple things but no luck yet. Please help me out – iGatiTech Apr 10 '17 at 13:07
  • Because you are serving a blank page with javascript. Thats what the answer is. – xaphod Apr 10 '17 at 22:12
  • @xaphod Then what change I should make in above code? – iGatiTech Apr 11 '17 at 03:39
  • @xaphod You can answer on my question as well if you know the answer. http://stackoverflow.com/questions/43336903/configuration-profile-installation-on-iphone-programatically – iGatiTech Apr 11 '17 at 04:43
  • what about the permissions required in the settings? – Ishika Jul 27 '19 at 13:58
  • @xaphod is this possible to make it work in cross-platform mobile techs like Flutter? Do we need to write this in native side only? do you have swift code for this? – yogi May 10 '22 at 11:12
11

I have written a class for installing a mobileconfig file via Safari and then returning to the app. It relies on the http server engine Swifter which I found to be working well. I want to share my code below for doing this. It is inspired by multiple code sources I found floating in the www. So if you find pieces of your own code, contributions to you.

class ConfigServer: NSObject {

    //TODO: Don't foget to add your custom app url scheme to info.plist if you have one!

    private enum ConfigState: Int
    {
        case Stopped, Ready, InstalledConfig, BackToApp
    }

    internal let listeningPort: in_port_t! = 8080
    internal var configName: String! = "Profile install"
    private var localServer: HttpServer!
    private var returnURL: String!
    private var configData: NSData!

    private var serverState: ConfigState = .Stopped
    private var startTime: NSDate!
    private var registeredForNotifications = false
    private var backgroundTask = UIBackgroundTaskInvalid

    deinit
    {
        unregisterFromNotifications()
    }

    init(configData: NSData, returnURL: String)
    {
        super.init()
        self.returnURL = returnURL
        self.configData = configData
        localServer = HttpServer()
        self.setupHandlers()
    }

    //MARK:- Control functions

    internal func start() -> Bool
    {
        let page = self.baseURL("start/")
        let url: NSURL = NSURL(string: page)!
        if UIApplication.sharedApplication().canOpenURL(url) {
            var error: NSError?
            localServer.start(listeningPort, error: &error)
            if error == nil {
                startTime = NSDate()
                serverState = .Ready
                registerForNotifications()
                UIApplication.sharedApplication().openURL(url)
                return true
            } else {
                self.stop()
            }
        }
        return false
    }

    internal func stop()
    {
        if serverState != .Stopped {
            serverState = .Stopped
            unregisterFromNotifications()
        }
    }

    //MARK:- Private functions

    private func setupHandlers()
    {
        localServer["/start"] = { request in
            if self.serverState == .Ready {
                let page = self.basePage("install/")
                return .OK(.HTML(page))
            } else {
                return .NotFound
            }
        }
        localServer["/install"] = { request in
            switch self.serverState {
            case .Stopped:
                return .NotFound
            case .Ready:
                self.serverState = .InstalledConfig
                return HttpResponse.RAW(200, "OK", ["Content-Type": "application/x-apple-aspen-config"], self.configData!)
            case .InstalledConfig:
                return .MovedPermanently(self.returnURL)
            case .BackToApp:
                let page = self.basePage(nil)
                return .OK(.HTML(page))
            }
        }
    }

    private func baseURL(pathComponent: String?) -> String
    {
        var page = "http://localhost:\(listeningPort)"
        if let component = pathComponent {
            page += "/\(component)"
        }
        return page
    }

    private func basePage(pathComponent: String?) -> String
    {
        var page = "<!doctype html><html>" + "<head><meta charset='utf-8'><title>\(self.configName)</title></head>"
        if let component = pathComponent {
            let script = "function load() { window.location.href='\(self.baseURL(component))'; }window.setInterval(load, 600);"
            page += "<script>\(script)</script>"
        }
        page += "<body></body></html>"
        return page
    }

    private func returnedToApp() {
        if serverState != .Stopped {
            serverState = .BackToApp
            localServer.stop()
        }
        // Do whatever else you need to to
    }

    private func registerForNotifications() {
        if !registeredForNotifications {
            let notificationCenter = NSNotificationCenter.defaultCenter()
            notificationCenter.addObserver(self, selector: "didEnterBackground:", name: UIApplicationDidEnterBackgroundNotification, object: nil)
            notificationCenter.addObserver(self, selector: "willEnterForeground:", name: UIApplicationWillEnterForegroundNotification, object: nil)
            registeredForNotifications = true
        }
    }

    private func unregisterFromNotifications() {
        if registeredForNotifications {
            let notificationCenter = NSNotificationCenter.defaultCenter()
            notificationCenter.removeObserver(self, name: UIApplicationDidEnterBackgroundNotification, object: nil)
            notificationCenter.removeObserver(self, name: UIApplicationWillEnterForegroundNotification, object: nil)
            registeredForNotifications = false
        }
    }

    internal func didEnterBackground(notification: NSNotification) {
        if serverState != .Stopped {
            startBackgroundTask()
        }
    }

    internal func willEnterForeground(notification: NSNotification) {
        if backgroundTask != UIBackgroundTaskInvalid {
            stopBackgroundTask()
            returnedToApp()
        }
    }

    private func startBackgroundTask() {
        let application = UIApplication.sharedApplication()
        backgroundTask = application.beginBackgroundTaskWithExpirationHandler() {
            dispatch_async(dispatch_get_main_queue()) {
                self.stopBackgroundTask()
            }
        }
    }

    private func stopBackgroundTask() {
        if backgroundTask != UIBackgroundTaskInvalid {
            UIApplication.sharedApplication().endBackgroundTask(self.backgroundTask)
            backgroundTask = UIBackgroundTaskInvalid
        }
    }
}
freshking
  • 1,824
  • 18
  • 31
  • Worked for me as well! Note that the code shown here only works for an older version of Swifter. – Pieter Meiresone Sep 02 '16 at 10:39
  • 1
    I've converted the code to Swift 3 and the latest Swifter. Works quite well but having difficulty when the system returns back to Safari, the Safari reloads the page (and gets into loop) and the dialog for going back to the app disappears (and Safari needs to be killed). Thanks – Tom Oct 07 '16 at 03:01
  • 6
    My Swift 3 addition edit was rejected by some smart people who know nothing about ios and/or Swift (and the difference between version 2 and 3). I've put it in gist instead https://gist.github.com/3ph/beb43b4389bd627a271b1476a7622cc5. I know posting links goes against SO but so do apparently some people. – Tom Oct 07 '16 at 10:00
  • 1
    Your gist is really great and worked for me once. After that I have to reset the safari cache to get it work again. My tests with additional meta informations and header fields didn't solve it. Only solution I found is to randomize the "install"-String in setupHandlers(). – ObjectAlchemist Oct 19 '16 at 08:06
  • 3
    Ok, I solved my problem. It only works with a scheme. Giving an empty string as return url results in the described behaviour. With a valid scheme the dialog will be shown, but after that the safari is broken (when cancelling the dialog). Instead of returning a .MovedPermanently(self.returnURL) I suggest to return a webpage with a button inside. In error case user is able to close the page than. – ObjectAlchemist Oct 19 '16 at 09:46
  • how do i use in my project? – Boosa Ramesh Feb 09 '17 at 08:42
  • Will it pass App Store review criteria? – Ramis Jun 19 '17 at 11:27
4

I think what you are looking for is "Over the Air Enrollment" using the Simple Certificate Enrollment Protocol (SCEP). Have a look at the OTA Enrollment Guide and the SCEP Payload section of the Enterprise Deployment Guide.

According to the Device Config Overview you only have four options:

  • Desktop installation via USB
  • Email (attachment)
  • Website (via Safari)
  • Over-the-Air Enrollment and Distribution
slf
  • 22,595
  • 11
  • 77
  • 101
1

Have you tried just having the app mail the user the config profile the first time it starts up?

-(IBAction)mailConfigProfile {
     MFMailComposeViewController *email = [[MFMailComposeViewController alloc] init];
     email.mailComposeDelegate = self;

     [email setSubject:@"My App's Configuration Profile"];

     NSString *filePath = [[NSBundle mainBundle] pathForResource:@"MyAppConfig" ofType:@"mobileconfig"];  
     NSData *configData = [NSData dataWithContentsOfFile:filePath]; 
     [email addAttachmentData:configData mimeType:@"application/x-apple-aspen-config" fileName:@"MyAppConfig.mobileconfig"];

     NSString *emailBody = @"Please tap the attachment to install the configuration profile for My App.";
     [email setMessageBody:emailBody isHTML:YES];

     [self presentModalViewController:email animated:YES];
     [email release];
}

I made it an IBAction in case you want to tie it to a button so the user can re-send it to themselves at any time. Note that I may not have the correct MIME type in the example above, you should verify that.

smountcastle
  • 620
  • 5
  • 14
  • Will give it a try. I'm not sure that an e-mail attachment in the process of mail composition is openable. Besides, instructing the users will be a pain. Has "desperate workaround" written all over it... – Seva Alekseyev Apr 22 '10 at 04:05
  • The user wouldn't open the attachment while composing. The work-flow would be launch your app, it realizes that the config profile isn't installed, it does the above to initiate the mail composition, the user types in their email address and hits send. Then they open Mail app and download the email, clicking on the attachment to install it. I agree that it seems like a desperate workaround. Alternatively you can figure out how Mail is dispatching the application/x-apple-aspen-config file and just do that (though it might be a private API, I don't know). – smountcastle Apr 22 '10 at 13:22
1

Just host the file on a website with the extension *.mobileconfig and set the MIME type to application/x-apple-aspen-config. The user will be prompted, but if they accept the profile should be installed.

You cannot install these profiles programmatically.

Brandon
  • 1,164
  • 14
  • 22
0

This page explains how to use images from your bundle in a UIWebView.

Perhaps the same would work for a configuration profile as well.

Jon-Eric
  • 16,977
  • 9
  • 65
  • 97
  • 1
    Nope. And the funny part is, it's somehow the specifics of the profile. When I provide a text file with a link to it, the UIWebView navigates to it as expected. – Seva Alekseyev Feb 25 '10 at 22:54
0

I've though of another way in which it might work (unfortunately I don't have a configuration profile to test out with):

// Create a UIViewController which contains a UIWebView
- (void)viewDidLoad {
    [super viewDidLoad];
    // Tells the webView to load the config profile
    [self.webView loadRequest:[NSURLRequest requestWithURL:self.cpUrl]];
}

// Then in your code when you see that the profile hasn't been installed:
ConfigProfileViewController *cpVC = 
        [[ConfigProfileViewController alloc] initWithNibName:@"MobileConfigView"
                                                      bundle:nil];
NSString *cpPath = [[NSBundle mainBundle] pathForResource:@"configProfileName"
                                                   ofType:@".mobileconfig"];
cpVC.cpURL = [NSURL URLWithString:cpPath];
// Then if your app has a nav controller you can just push the view 
// on and it will load your mobile config (which should install it).
[self.navigationController pushViewController:controller animated:YES];
[cpVC release];
smountcastle
  • 620
  • 5
  • 14
  • That's a slight rephrasing of option B - instead of HTML, then link to profile, link to the profile straight away. I think I've tried this, unsuccessfully, along the way. – Seva Alekseyev Apr 22 '10 at 19:51
0

This is a great thread, and especially the blog mentioned above.

For those doing Xamarin, here's my added 2 cents. I embedded the leaf cert in my app as Content, then used the following code to check it:

        using Foundation;
        using Security;

        NSData data = NSData.FromFile("Leaf.cer");
        SecCertificate cert = new SecCertificate(data);
        SecPolicy policy = SecPolicy.CreateBasicX509Policy();
        SecTrust trust = new SecTrust(cert, policy);
        SecTrustResult result = trust.Evaluate();
        return SecTrustResult.Unspecified == result; // true if installed

(Man, I love how clean that code is, vs. either of Apple's languages)

Eliot Gillum
  • 832
  • 9
  • 18
  • are you using private API's? or did Xamarin developers do the dirty job? – Ohad Cohen Jan 12 '17 at 15:53
  • The configuration profile does the heavy lifting, no private APIs required. What's dirty is the process for walking a user through installing a profile from an app--iOS can only process the file from Safari or Mail, it's not exactly the smoothest of experiences and getting the user back into the app afterwards is extra fun. – Eliot Gillum Jan 13 '17 at 21:48
0

Not sure why you need a configuration profile, but you can try to hack with this delegate from the UIWebView:

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType{
    if (navigationType == UIWebViewNavigationTypeLinkClicked) {
        //do something with link clicked
        return NO;
    }
    return YES;
}

Otherwise, you may consider enable the installation from a secure server.

Hoang Pham
  • 6,899
  • 11
  • 57
  • 70