在iPhone上编程安装配置文件

76
我希望能够在我的 iPhone 应用程序中附带配置文件,并在需要时安装它。请注意,这里指的是配置文件,而不是预配资料。
首先,这个任务是可能的。如果您将配置文件放在网页上并从 Safari 中点击它,它会被安装。如果您通过电子邮件发送配置文件并单击附件,它也会被安装。在这种情况下,“已安装”意味着“调用了安装 UI”-但我甚至没能做到那一步。
因此,我认为启动配置文件安装涉及导航到其作为 URL。我将配置文件添加到我的应用程序包中。
A) 首先,我尝试使用 file://URL 通过 [sharedApp openURL] 打开我的应用程序包中的文件。没有运气 - 什么都没有发生。
B) 然后,我将一个包含配置文件链接的 HTML 页面添加到我的应用程序包中,并将其加载到 UIWebView 中。单击链接什么也没发生。但是,从 Web 服务器在 Safari 中加载相同的页面却可以正常工作 - 链接是可点击的,配置文件也会被安装。我提供了一个 UIWebViewDelegate,在每个导航请求中回答 YES - 没有任何区别。
C) 然后,我尝试在 Safari 中从我的包中加载相同的 Web 页面(使用[sharedApp openURL])- 什么也没发生。我猜测,Safari 无法看到我的应用程序包中的文件。
D) 将页面和配置文件上传到 Web 服务器是可行的,但在组织层面上很麻烦,更不用说是失败的另一个原因(如果没有3G覆盖怎么办?等等)。
因此,我的一个重要问题是:如何通过编程方式安装配置文件?
另外还有一些小问题:什么会使链接在 UIWebView 中无法单击?是否可以从我的应用程序包中的 file://URL 在 Safari 中加载页面?如果不行,是否有本地位置可以放置文件,以便 Safari 可以找到它们呢?

编辑B):问题在于我们链接到了一个个人资料。我将它从.mobileconfig重命名为.xml(因为它真的是XML),修改了链接。然后这个链接在我的UIWebView中工作了。重新命名回来——同样的问题。看起来像是UIWebView不愿意做应用程序级别的操作——因为安装配置文件会关闭应用程序。我尝试通过UIWebViewDelegate告诉它没问题,但并没有说服它。同样的行为也出现在UIWebView内的mailto: URL。

对于mailto: URL,常见的技巧是将它们转换为[openURL]调用,但这对我的情况不太适用,请参阅方案A。

对于itms: URL,UIWebView按预期工作...

编辑2:尝试通过[openURL]向Safari提供数据URL——不起作用,请参阅这里:iPhone在Safari中打开数据:Url

编辑3:发现很多关于Safari不支持file:// URL的信息。然而,UIWebView非常支持。而且模拟器上的Safari可以打开它们。最后一个部分最让人沮丧。


编辑4:我从未找到解决方案。相反,我制作了一个简单的网络界面,用户可以通过它将配置文件发送到他们的电子邮件。


2
这里可能存在安全问题。苹果公司可能不希望您能够从应用程序内更改载波配置文件,这可能会启用绑定功能,禁用语音邮件等。 - Brad Larson
2
需要很多明确的用户协议。此外,我可以直接从我的应用程序向用户提供相关网页链接(选项D),所以他们的控制并不像铁板钉钉一样牢靠。 - Seva Alekseyev
你好 Seva,我也想做同样的事情,你最终有解决办法了吗? - Iphone_bharat
@Iphone_bharat:不,不是真的。我做了一个变通方法。 - Seva Alekseyev
我猜这个限制同样适用于TestFlight风格的应用程序安装。出于同样的原因。这就是为什么TestFlight或HockeyApp在iOS上都没有真正的本地应用程序的原因。 - funroll
显示剩余4条评论
10个回答

39

1)安装本地服务器,比如RoutingHTTPServer

2)配置自定义头部:

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

3)为移动配置文件(文档)配置本地根路径:

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

4) 为了让 Web 服务器有时间发送文件,请添加以下内容:

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)在您的控制器中,使用“Documents”中存储的mobileconfig名称来调用Safari:

[[UIApplication sharedApplication] openURL:[NSURL URLWithString: @"http://localhost:12345/MyProfile.mobileconfig"]];

我认为应用程序“App Icons”正是这样做的。您可以安装配置,包括Web剪辑图标,即使打开了飞行模式。https://itunes.apple.com/jp/app/ios-7-yongniapuriaikon-homu/id619910206?mt=8&ign-mpt=uo%3D4 - Jonny
请问您能否解释一下如何安装RoutingHTTPServer?我无法在iPhone上构建和安装它。 - iamMobile
2
你在一个AppStore目标应用程序上使用过这个吗?.mobileconfig文件已经签署了受信任的证书还是自签名证书?我想知道苹果是否会拒绝安装自签名的mobileconfig的应用程序。 - iGenio
2
我们是否可以在安装或配置文件后从Safari浏览器重定向回应用程序? - Abilash Balasubramanian
2
@igenio SmartJoin.us已被App Store接受,并使用未签名的配置文件,该配置文件由应用程序中的Web服务器提供给Safari。 - alfwatt
显示剩余4条评论

28
malinois的答案对我很有用,但是我想要一个解决方案,在用户安装mobileconfig后自动返回到应用程序。
我花了4个小时,但是这里是解决方案,基于malinois的本地http服务器:您向safari返回刷新自身的HTML;第一次服务器返回mobileconfig,第二次返回自定义url-scheme以返回到您的应用程序。用户体验是我想要的:应用程序调用safari,safari打开mobileconfig,当用户在mobileconfig上点击“完成”时,safari再次加载您的应用程序(自定义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"];
    }
}

以下是从应用程序(即ViewController)调用此代码的代码:

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

希望这能对某些人有所帮助。

1
如果这个有效,那太棒了。非常感谢。我已经寻找解决方案很长时间了。 - aparna
1
这太棒了。我会试一下的。对企业应用来说,这绝对是一个巨大的胜利。 - ninjaneer
@xaphod 嗨,我已经实现了你上面提到的代码,并且离完成还很近。我的问题是:当我启动应用程序时,它首先在Safari中打开一个空白页面,然后当我回到应用程序时,它会将我重定向到配置文件安装页面。不知道为什么会这样。卡得死死的。任何帮助都将不胜感激。为什么最初会在Safari中打开空白页面?尝试了多种方法,但还没有运气。请帮帮我。 - iGatiTech
因为您正在使用JavaScript提供空白页面。这就是答案。 - xaphod
@xaphod 那么我应该在上面的代码中做出什么改变? - iGatiTech
显示剩余8条评论

11

我编写了一个类,用于通过Safari安装mobileconfig文件,然后返回到应用程序。它依赖于http服务器引擎Swifter,我发现它工作得很好。 下面是我分享的代码,用于执行此操作。它受到我在互联网上找到的多个代码来源的启发。因此,如果您发现自己的代码片段,请接受我的致谢。

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
        }
    }
}

1
我已将代码转换为Swift 3和最新的Swifter。它运行得相当不错,但是当系统返回Safari时遇到了困难,Safari会重新加载页面(并进入循环),返回应用程序的对话框消失了(需要杀死Safari)。谢谢。 - Tom
6
我的Swift 3增补编辑被一些聪明的人拒绝了,他们对iOS和/或Swift(以及版本2和3之间的区别)一无所知。我已经将它放在gist上了,链接是https://gist.github.com/3ph/beb43b4389bd627a271b1476a7622cc5。我知道发布链接违反了SO的规定,但显然有些人也这样做了。 - Tom
1
你的代码片段真的很棒,曾经为我解决了问题。但之后我不得不重置Safari缓存才能再次使用它。我的测试中加入额外的元信息和头部字段都没有解决这个问题。唯一的解决方案是在setupHandlers()中随机化“install”字符串。 - ObjectAlchemist
3
好的,我解决了我的问题。它只能使用方案运作。返回一个空字符串作为url会导致所描述的行为。如果有有效的方案,对话框将显示出来,但在取消对话框后Safari却会出现故障。建议不要返回.MovedPermanently(self.returnURL),而是返回一个带有按钮的网页。在错误情况下,用户可以关闭该页面。 - ObjectAlchemist
它能通过App Store的审核标准吗? - Ramis
显示剩余2条评论

4
我认为你需要的是使用简单证书登记协议(SCEP)的“空中注册”。请查看OTA Enrollment GuideEnterprise Deployment Guide中的SCEP负载部分,以获得更多信息。根据Device Config Overview,你只有四个选项:通过USB进行桌面安装、通过电子邮件(附件)、通过Safari访问网站以及空中注册和分发。

看起来是我选项 D 的更高级版本 :) 不过找得不错。 - Seva Alekseyev
有很多产品可以为您完成这项工作,无需自己开发。 - slf

1

你尝试过在应用程序第一次启动时直接向用户发送配置文件吗?

-(IBAction)mailConfigProfile {
     MFMailComposeViewController *email = [[MFMailComposeViewController alloc] init];
     email.mailComposeDelegate = self;
[email setSubject:@"我的应用程序配置文件"];
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 = @"请点击附件安装 My App 的配置文件。"; [email setMessageBody:emailBody isHTML:YES];
[self presentModalViewController:email animated:YES]; [email release]; }

我将其设置为 IBAction,以便您可以将其绑定到按钮上,以便用户随时重新发送给自己。请注意,上面的示例中可能没有正确的 MIME 类型,您应该进行验证。


会尝试一下。我不确定在邮件撰写过程中的电子邮件附件是否可以打开。此外,指导用户可能很麻烦。看起来需要一个“绝望的解决方法”... - Seva Alekseyev
用户在撰写邮件时不会打开附件。工作流程是启动您的应用程序,它会意识到配置文件未安装,然后执行上述操作以启动邮件组合,用户输入其电子邮件地址并点击发送。然后他们打开邮件应用程序并下载电子邮件,点击附件以安装它。我同意这似乎是一个拼命的解决方法。或者,您可以找出邮件如何分派application/x-apple-aspen-config文件,然后只需执行该操作(尽管它可能是私有API,我不知道)。 - smountcastle

1

只需将文件托管在带有扩展名*.mobileconfig的网站上,并将MIME类型设置为application/x-apple-aspen-config。用户将会收到提示,但如果他们接受,则应安装配置文件。

您无法以编程方式安装这些配置文件。


0

这是一个很棒的帖子,特别是上面提到的博客链接

对于那些使用Xamarin的人,这是我的两分钱。我将叶子证书嵌入我的应用程序中作为内容,然后使用以下代码进行检查:

        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

哇,我喜欢这段代码的简洁程度,相比苹果的任何一种语言都要好。


你是否使用了私有API?还是Xamarin开发人员已经完成了这项工作? - Ohad Cohen
配置文件会完成大部分工作,不需要使用私有API。比较麻烦的是,需要通过应用程序引导用户安装配置文件的过程——iOS只能从Safari或邮件中处理该文件,这并不是最顺畅的体验,而且在之后让用户回到应用程序也很有趣。 - Eliot Gillum

0

不确定您为什么需要配置文件,但您可以尝试使用UIWebView的此委托进行黑客攻击:

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

否则,您可以考虑启用从安全服务器安装。

这个问题还没有被回答吗? - Hoang Pham

0

此页面介绍了如何在UIWebView中使用来自您的Bundle的图像。

也许同样的方法也适用于配置文件。


1
不是的。有趣的是,这似乎与配置文件的具体内容有关。当我提供一个包含链接的文本文件时,UIWebView会按预期导航到它。 - Seva Alekseyev

0

我想到了另一种可能的解决方法(不幸的是,我没有配置文件来测试):

// 创建一个包含 UIWebView 的 UIViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    // 告诉 webView 加载配置文件
    [self.webView loadRequest:[NSURLRequest requestWithURL:self.cpUrl]];
}
// 当你发现配置文件未安装时,在代码中添加以下内容: ConfigProfileViewController *cpVC = [[ConfigProfileViewController alloc] initWithNibName:@"MobileConfigView" bundle:nil]; NSString *cpPath = [[NSBundle mainBundle] pathForResource:@"configProfileName" ofType:@".mobileconfig"]; cpVC.cpURL = [NSURL URLWithString:cpPath]; // 如果你的应用程序有导航控制器,你可以将视图推入并加载移动配置文件(这应该会安装它)。 [self.navigationController pushViewController:controller animated:YES]; [cpVC release];

这是对选项B的轻微改述 - 不是先链接到HTML,再链接到个人资料页面,而是直接链接到个人资料页面。我想我之前尝试过这个方法,但不成功。 - Seva Alekseyev

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接