This little project cdm about because of a need to install an ipa from our TeamCity (our CI server). I’ve heard people fawning all over Jenkins, but I have very mixed feelings on open source. Sometimes it’s great, and sometimes you start contributing to an open source project and find yourself wondering how the thing ever managed to work in the first place. Which is not to say that paid software doesn’t have that problem, Â but in my experience it involves more people with degrees… usually.
Ranting aside, the basic idea of this project is to have a web page that you can go to, type in some information about a build artifact and then install straight from the web page.
In order to accomplish this, we will be making something like this
This is easily extendable, but I don’t need things to be complicated. The way it works is someone will go to this page, enter in the artifact’s build number and hit install application, then the ipa will be downloaded and installed from our CI server. This keeps our QA from having to know anything other than the build number (which they know from our issue tracking software)
The page uses ASP.Net MVC’s default bootstrap theme, and we’ll use Angular.js to formulate the url for the install application button.
Installing and ipa
Unfortunately, things are not as simple as just downloading the ipa and saying install. In fact, the ability to get to the ipa doesn’t really do much for you. You could download the ipa and then use iTunes to install it, but where’s the fun in that?
Here’s how things work:Â itms-services:// is a url schema that iOS handles. what you need to do is formulate a url like this
1 |
itms-services://?action=download-manifest&url=<some url> |
and the url needs to be referencing a plist that looks something like this
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE PLIST PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>items</key> <array> <dict> <key>assets</key> <array> <dict> <key>kind</key> <string>software-package</string> <key>url</key> <string>!!!!!!YOU NEED THE URL TO THE IPA HERE!!!!!!</string> </dict> </array> <key>metadata</key> <dict> <key>bundle-identifier</key> <string>!!!!!!YOU NEED THE BUNDLE IDENTIFIER TO THE IPA HERE!!!!!!</string> <key>bundle-version</key> <string>!!!!!!YOU NEED THE BUNDLE VERSION HERE (1.0.0)!!!!!!</string> <key>kind</key> <string>software</string> <key>title</key> <string>!!!!!!YOU NEED THE APPLICATION TITLE HERE!!!!!!</string> </dict> </dict> </array> </dict> </plist> |
Once you have the plist somewhere and the ipa somewhere, you just need to open the link in safari.
NOTE: as of iOS 7.1, your url to the plist (in the itms-source url) MUST USE HTTPS. It will not work with http, though it seems to work just fine with self signed certificates. Prior to iOS 7.1, you could use http.
Serving up the Plist from ASP.Net
Here’s  a neat little trick I learned while doing this. You can just create a cshtml file, and put the xml inside it. That way you get all the benefits of the Razor stuff, and you don’t have to parse xml manually. It’s really neat.
We’ll create a cshtml template for the plist we want to generate, then we’ll have the controller accept some parameters to dynamically generate the plist we need.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
@{ Response.ContentType = "applicaiton/xml"; Layout = null; } <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE PLIST PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>items</key> <array> <dict> <key>assets</key> <array> <dict> <key>kind</key> <string>software-package</string> <key>url</key> <string>@(ViewBag.FullURL)</string> </dict> </array> <key>metadata</key> <dict> <key>bundle-identifier</key> <string>com.exampleMyApp</string> <key>bundle-version</key> <string>1.0.0</string> <key>kind</key> <string>software</string> <key>title</key> <string>MyApp</string> </dict> </dict> </array> </dict> </plist> |
Inside of the cshtml, we manually set the content type to “application/xml” you could also use “text/xml”. And we manually reset the Layout. If you don’t do this, you may end up accidentally rendering your xml inside of your html layout document.
We’ll just pass the URL to download the ipa in to the cshtml with the ViewBag. you could totally create a model, but I didn’t bother because this was rapid prototyping when I implemented it.
Then on your controller, you just need something like this to serve up the dynamically generated plist
1 2 3 4 5 |
public ActionResult Plist(string buildNumber) { ViewBag.DownloadURL = //build your url to the ipa here return View(); } |
Making the Page Work
Now that we have dynamically generated plists for installation, we need to make the Install Application button have something similar to this for the href
1 |
itms-services://?action=download-manifest&url=https://some-machine/applicationInstaller/Installer/plist?buildNumber=123 |
To the _Layout I added
ng-app="mobileInstaller"
and the script tag to pull the mi Angular.js 1.2.2 then index.cshtml looks like this
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<div data-ng-controller="installerController" data-ng-cloak=""> <div> <label for="buildNumberField">Artifact Build Number:</label> <input id="buildNumberField" class="form-control" type="text" data-ng-model="buildNumber" /> </div> <br /> <div> <a class="btn btn-primary" ng-href="{{generateHref(buildNumber)}}" ng-disabled="shouldDisableButton()">Install Application </a> </div> <br /> </div> @section Scripts{ @Scripts.Render("~/Scripts/installerController.js") } |
And the InstallerController looks like this
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
var app = angular.module('mobileInstaller', []) .config([ '$compileProvider', function ($compileProvider) { $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|itms-services):/); // Angular before v1.2 uses $compileProvider.urlSanitizationWhitelist(...) } ]); (function () { 'use strict'; var controllerId = 'installerController'; angular.module('mobileInstaller').controller(controllerId, ['$scope', installerController]); function installerController($scope) { $scope.generateHref = function(buildNumber) { var itmsServices = "itms-services://?action=download-manifest&url="; var url = "https://some-machine/MobileInstaller/Installer/plist?buildNumber=" + buildNumber; return itmsServices + encodeURIComponent(url); } $scope.shouldDisableButton = function() { var b = $scope.buildNumber; return b == undefined || !/\S/.test(b); } } })(); |
I didn’t feel like declaring my module in a separate file for such a small project, so I just did it at the top of my controller. Also notice that you have to do
1 |
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|itms-services):/); |
otherwise angular.js will turn the ng-href into a
unsafe:itms-services://...
url, and that breaks things.
You also have to make sure that if you have url encoded parameters in your url to the ipa, you must encode them to prevent the items services from mis-parsing the url