PageSourceSearch

https://decovar.dev/blog/2025/04/03/distributing-ios-applications-without-app-store/

html decovar.dev collected 2026-09-25 21:01:00 UTC 106,653 bytes, 943 lines download raw bytes

1<!DOCTYPE html>
2<html>
3    <head>
4        <meta charset="utf-8">
5        <title>Distributing iOS applications within a company from a website without App Store | Declaration of VAR</title>
6        <link rel="canonical" href="https://decovar.dev/blog/2025/04/03/distributing-ios-applications-without-app-store/">
7
8        <meta name="generator" content="Hugo 0.164.0">
9        <meta name="viewport" content="width=device-width, initial-scale=1"><link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon.png">
10<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
11<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
12<link rel="manifest" href="/site.webmanifest">
13<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
14<meta name="msapplication-TileColor" content="#da532c">
15<meta name="theme-color" content="#ffffff">
16<meta name="author" content="retif">
17        <meta name="description" content="It is possible (always has been) to distribute iOS applications within a company via one&#39;s own website (so without App Store). The only two things (aside from Apple (Enterprise?) Developer account) you need is a special manifest.plist file and a URI with itms-services:// scheme.">
18
19        
20            <link rel="alternate" type="application/rss+xml" href="/index.xml" title="Declaration of VAR">
21        
22        
23
24        
25
26        <link rel="stylesheet" href="https://decovar.dev/css/_main.min.549391869168c0284e4dc8f955a6f367ce7760d23485c2782d96f6f295e6ec0f.css">
27    
28    
28<script type="application/ld+json">
29    {
30        "@context": "https://schema.org",
31        "@type": "BlogPosting",
32        "headline": "Distributing iOS applications within a company from a website without App Store",
33        "image": "https://decovar.dev/blog/2025/04/03/distributing-ios-applications-without-app-store/images/installing-ios-application-from-website-blurred.png",
34        "author":
35        {
36            "@type": "Person",
37            "name": "retif",
38            "url": "https://decovar.dev/about/"
39        },
40        "datePublished": "2025-04-03T13:52:24+02:00",
41        "dateModified": "2025-04-04T10:21:05+02:00",
42        "keywords": "apple, ios, web, dotnet"
43    }
44    </script>
44
45
46    </head>
47    <body><header id="header">
48    <h1 id="homeTitle"><a href="/">Declaration of VAR</a></h1>
49    <nav id="navigation-desktop" role="navigation"><a href="/blog/">Blog</a>
50<a href="/projects/">Projects</a>
51<a href="https://status.decovar.dev/">Status</a>
52<a href="/etc/">[ etc ]</a>
53<a href="/about/">About</a>
54</nav>
55    <button id="hamburger" class="hamburger hamburger--collapse" type="button"
56        onclick="toggleHamburger(this);">
57        <span class="hamburger-box">
58            <span class="hamburger-inner"></span>
59        </span>
60    </button>
61</header>
62<nav id="navigation-mobile" role="navigation"><a href="/blog/">Blog</a>
63<a href="/projects/">Projects</a>
64<a href="https://status.decovar.dev/">Status</a>
65<a href="/etc/">[ etc ]</a>
66<a href="/about/">About</a>
67</nav>
68<main id="main" aria-role="main">
69            <div id="main-container">
70                <div style="margin-bottom:20px;">
71    <article class="post">
72      <div>
73        <h1 class="post-title">Distributing iOS applications within a company from a website without App Store</h1>
74        <div class="post-meta">
75          <div title="Published at">
76              <span><i class="bi-calendar"></i></span>
77              2025-04-03 13:52:24 &#43;0200
78          </div>
79          
80          <div title="Last modified at">
81              <span><i class="bi-pencil"></i></span>
82              2025-04-04 10:21:05 &#43;0200
83          </div>
84          
85          <div><i class="bi-clock"></i> 25 min read</div>
86        </div>
87      </div>
88      <div class="post-content">
89        <p>We&rsquo;ve been using <a href="https://appcenter.ms" rel="external">App Center</a> (<em>former HockeyApp, which got <a href="https://devblogs.microsoft.com/appcenter/hockeyapp-is-being-retired/" rel="external">retired</a> and replaced by App Center</em>) for distributing our iOS applications within the company. And when we learned about a year ago that App Center also is going to be <a href="https://learn.microsoft.com/en-us/appcenter/retirement" rel="external">retired</a> on 2025-03-31, we started looking at alternatives. Having found none that would be good enough, we decided to implement our own.</p>
90
91
92    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/installing-ios-application-from-website-blurred.png" alt="Installing an iOS application from a website">
93
94<p>Since we already had a .NET/MVC-based website for distribution-related stuff, we added this functionality there, and that turned out to be a rather trivial task. The .NET/MVC has nothing to do with it really - that&rsquo;s just what we had, and you&rsquo;ll see for yourself that this can be done with any other framework or even with a bare static website.</p>
95<p>I should have published this article much earlier before the App Center retirement date, and I was intending too, but I just never found time, sorry about that.</p>
96<nav id="TableOfContents">
97  <ul>
98    <li><a href="#why-would-one-need-app-center-or-a-similar-service">Why would one need App Center or a similar service</a>
99      <ul>
100        <li><a href="#alternatives">Alternatives</a></li>
101        <li><a href="#app-center-itself-wasnt-great-either">App Center itself wasn&rsquo;t great either</a></li>
102      </ul>
103    </li>
104    <li><a href="#how-is-it-even-possible-to-distribute-ios-applications-without-app-store">How is it even possible to distribute iOS applications without App Store</a>
105      <ul>
106        <li><a href="#building-the-application">Building the application</a></li>
107        <li><a href="#the-manifestplist">The manifest.plist</a></li>
108        <li><a href="#the-itms-services-uri">
108The itms-services URI</a></li>
109      </ul>
110    </li>
111    <li><a href="#caveats-and-other-apple-specifics">Caveats and other Apple specifics</a>
112      <ul>
113        <li><a href="#authenticationauthorization">Authentication/authorization</a>
114          <ul>
115            <li><a href="#both-routes-are-protected">Both routes are protected</a></li>
116            <li><a href="#only-the-application-file-route-is-protected">Only the application file route is protected</a></li>
117            <li><a href="#both-routes-are-not-protected">Both routes are not protected</a></li>
118          </ul>
119        </li>
120        <li><a href="#launching-downloaded-applications-on-ios-devices">Launching downloaded applications on iOS devices</a></li>
121        <li><a href="#limited-number-of-devices">Limited number of devices</a></li>
122        <li><a href="#applications-expiration">Applications expiration</a></li>
123      </ul>
124    </li>
125    <li><a href="#hosting-ios-applications-on-a-netmvc-website">Hosting iOS applications on a .NET/MVC website</a>
126      <ul>
127        <li><a href="#template-string-for-manifestplist">Template string for manifest.plist</a></li>
128        <li><a href="#initial-download-request">Initial download request</a></li>
129        <li><a href="#downloading-the-manifestplist">Downloading the manifest.plist</a></li>
130        <li><a href="#downloading-the-application">Downloading the application</a></li>
131      </ul>
132    </li>
133  </ul>
134</nav>
135<h1 id="why-would-one-need-app-center-or-a-similar-service">Why would one need App Center or a similar service</h1>
136<p>Because fucking Apple didn&rsquo;t make it simple to distribute iOS applications. You cannot just place an <a href="https://en.wikipedia.org/wiki/.ipa" rel="external">.ipa</a> binary on your website, so users could download and install it.</p>
137<p>Well, actually, you can, and that is what this article is about, but more on that <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#how-is-it-even-possible-to-distribute-ios-applications-without-app-store">a bit later</a>. For now, assuming that it&rsquo;s all black magic to you and that you don&rsquo;t want to touch App Store / TestFlight with a 100 meters long pole, the only possibility one might have is to use a special service that could host <code>.ipa</code> binaries and perform required voodoo passes to make them installable on users devices. And App Center is one of such services. Was.</p>
138<p>To clarify, here I am talking about distributing &ldquo;internal&rdquo; iOS applications within a company, so this is not about publishing applications for general public - for that, I imagine, you would still need to submerge yourself into the great marsh of App Store / TestFlight things, so if that is your case, then this article will likely be of no use for you.</p>
139<h2 id="alternatives">Alternatives</h2>
140<p>Having announced the retirement of App Center, Microsoft <a href="https://learn.microsoft.com/en-us/appcenter/retirement#alternative-solutions" rel="external">suggested</a> App Store and TestFlight as alternatives, but we were using (<em>HockeyApp and then</em>) App Center exactly because we did not want to deal with App Store and TestFlight, so that suggestion wasn&rsquo;t helpful at all.</p>
141<p>We did find some other alternatives (<em>the list is sorted chronologically - when we discovered those services, starting from oldest</em>):</p>
142<ul>
143<li><a href="https://firebase.google.com/docs/app-distribution" rel="external">Firebase App Distribution</a></li>
144<li><a href="https://docs.appcircle.io/distribute/create-or-select-a-distribution-profile" rel="external">Appcircle</a></li>
145<li><a href="https://getupdraft.com" rel="external">Updraft</a></li>
146<li><a href="https://applivery.com" rel="external">Applivery</a></li>
147<li><a href="https://buildstash.com" rel="external">Buildstash</a> (<em>here&rsquo;s also their <a href="https://blog.buildstash.com/visual-studio-app-center-closes-what-now/" rel="external">blog post</a> about the situation with some more history and details</em>)</li>
148</ul>
149<p>but neither of them looked like an improvement over App Center. If anything, they look considerably more complicated to deal with than App Center. And there probably is some pricing involved too (<em>App Center was free of charge</em>).</p>
150<p>If I was forced to choose from this list, I&rsquo;d probably go with Buildstash, but for our single need of simply distributing iOS applications their lots of other functionality would be a total overkill.</p>
151<p>There is actually one more alternative - <a href="https://distapp.lhf.my.id" rel="external">DistApp</a> - which seems to be providing exactly that one functionality that we need, but by the moment we saw that service, we already had implemented our own solution.</p>
152<h2 id="app-center-itself-wasnt-great-either">App Center itself wasn&rsquo;t great either</h2>
153<p>We actually started slowly looking for an alternative to App Center at least a couple of years ago, so way before its retirement was announced, as over time we eventually came to realization that for us App Center was quite an inconvenient way of distributing applications. Namely, because of:</p>
154<ul>
155<li>the process of downloading/installing the applications was so not intuitive to users, we had to create a step-by-step tutorial (<em>even a video</em>) for that;</li>
156<li>at some point it became impossible to create a new account with a bare e-mail, and they started to force users into using one of the OAuth providers;</li>
157<li>not very well-defined access management:
158<ul>
159<li>applications can have users;</li>
160<li>but there are also distribution groups, which also can have users;</li>
161<li>at the same time, every version of the same application can have different distribution groups;</li>
162<li>it is quite difficult to keep track of all that and have an overview of who has access to what.</li>
163</ul>
164</li>
165</ul>
166<p>Out of those the worst was the access management. Aside from the already listed points, there was one other issue - despite the fact that App Center belongs to Microsoft and even provides a SAML SSO login, and that it even has an option to &ldquo;integrate&rdquo; with Azure AD - none of that provides any means for access management (<em>or anything useful, really</em>). For example, every time there was a new user who needed to be granted access to an application, administrator needed to do that manually - that scales very poorly.</p>
167<p>A <a href="https://github.com/microsoft/appcenter/issues/300" rel="external">related request</a> was reported in their repository back in 2019, but got no development whatsoever over all these years.</p>
168<p>So we weren&rsquo;t happy with App Center, but we were tolerating it, as the service was provided free of charge and it worked quite well for the essential part of distributing iOS applications. But now has come the time to find something else.</p>
169<h1 id="how-is-it-even-possible-to-distribute-ios-applications-without-app-store">How is it even possible to distribute iOS applications without App Store</h1>
170<p>But how is it even possible to distribute iOS applications without App Store / TestFlight? Did (<em>HockeyApp and</em>) App Center do something shady?</p>
171<p>As we learned (<em>should&rsquo;ve googled that years ago</em>), there is nothing shady about it - that is a very <a href="https://support.apple.com/guide/deployment/distribute-proprietary-in-house-apps-depce7cefc4d/web#dep30d16db76" rel="external">official feature</a>, which meant to be used exactly for distributing internal applications via one&rsquo;s own website. You just need to know about it.</p>
172<p>In short, all that is required (<em>aside from having Apple Developer (Enterprise?) account</em>) is:</p>
173<ul>
174<li>a particular <code>manifest.plist</code> file;</li>
175<li>a URI with <code>itms-services://</code> scheme.</li>
176</ul>
177<p>The &ldquo;Enterprise&rdquo; part I am not sure about, and in general my knowledge about these Apple certificates/profiles/things is very, very vague. You certainly(?) do need to have an <a href="https://developer.apple.com/account/" rel="external">Apple Developer</a> account (<em>99 USD per year?</em>), but from my understanding that needs to be not just a &ldquo;regular&rdquo; account but one that belongs to <a href="https://developer.apple.com/programs/enterprise/" rel="external">Apple Developer Enterprise Program</a> (<em>299 USD per year?</em>). From what I read in the eligibility requirements, our company does not qualify to have that, and yet it seems that we do have it somehow. I really cannot say for sure and I have no idea where to check this.</p>
178<p>But assuming that your company has all the required memberships/certificates/whatever, the technical details are the following.</p>
179<h2 id="building-the-application">Building the application</h2>
180<p>Speaking about fucking Apple not making things simple, before you can build your application you need to prepare quite a lot of things, which include getting signing/distribution <a href="https://developer.apple.com/help/account/certificates/certificates-overview" rel="external">certificates</a>, <a href="https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile" rel="external">provisioning profiles</a> and god knows what else.</p>
181<p>The entire procedure is so convoluted that I won&rsquo;t be able to reproduce it even having done everything just 5 minutes ago. I am not even sure that the links that I provided are the correct ones. We even have a special &ldquo;iOS guy&rdquo; in the team, and he is the only one who knows how to set things up. On a good day.</p>
182<p>When you have all the signing-related things in order, then you can produce an <code>.ipa</code> binary like this:</p>
183<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>$ cd /path/to/your/application/project/
184</span></span><span style="display:flex;"><span>
185</span></span><span style="display:flex;"><span><span style="color:#75715e">
185# our applications Xcode projects are generated with CMake (as they are cross-platform),</span>
186</span></span><span style="display:flex;"><span><span style="color:#75715e"># so you can skip this step</span>
187</span></span><span style="display:flex;"><span>$ mkdir build <span style="color:#f92672">&amp;&amp;</span> cd $_
188</span></span><span style="display:flex;"><span>$ cmake -G Xcode ..
189</span></span><span style="display:flex;"><span>
190</span></span><span style="display:flex;"><span>$ xcodebuild <span style="color:#ae81ff">\
191</span></span></span><span style="display:flex;"><span>    -scheme SomeApplication archive <span style="color:#ae81ff">\
192</span></span></span><span style="display:flex;"><span>    -archivePath SomeApplication.xcarchive <span style="color:#ae81ff">\
193</span></span></span><span style="display:flex;"><span>    -allowProvisioningUpdates <span style="color:#ae81ff">\
194</span></span></span><span style="display:flex;"><span>    -destination <span style="color:#e6db74">&#34;generic/platform=iOS&#34;</span>
195</span></span><span style="display:flex;"><span>
196</span></span><span style="display:flex;"><span>$ xcodebuild <span style="color:#ae81ff">\
197</span></span></span><span style="display:flex;"><span>    -exportArchive <span style="color:#ae81ff">\
198</span></span></span><span style="display:flex;"><span>    -exportPath ./ipa <span style="color:#ae81ff">\
199</span></span></span><span style="display:flex;"><span>    -archivePath <span style="color:#e6db74">&#34;SomeApplication.xcarchive&#34;</span> <span style="color:#ae81ff">\
200</span></span></span><span style="display:flex;"><span>    -exportOptionsPlist ../export-options.plist <span style="color:#ae81ff">\
201</span></span></span><span style="display:flex;"><span>    -allowProvisioningUpdates
202</span></span></code></pre></div><p>where <code>export-options.plist</code> contains the following:</p>
203<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#75715e">&lt;!DOCTYPE plist PUBLIC &#34;-//Apple//DTD PLIST 1.0//EN&#34; &#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd&#34;&gt;</span>
204</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;plist</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;1.0&#34;</span><span style="color:#f92672">&gt;</span>
205</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;dict&gt;</span>
206</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;key&gt;</span>method<span style="color:#f92672">&lt;/key&gt;</span>
207</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;string&gt;</span>enterprise<span style="color:#f92672">&lt;/string&gt;</span>
208</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;key&gt;</span>teamID<span style="color:#f92672">&lt;/key&gt;</span>
209</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;string&gt;</span>SOME-ID-HERE<span style="color:#f92672">&lt;/string&gt;</span>
210</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/dict&gt;</span>
211</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/plist&gt;</span>
212</span></span></code></pre></div><p>As a result you will have a <code>SomeApplication.ipa</code> file, which you will need to upload to your server. Nothing special about that procedure, just a regular file upload via SFTP or whatever you use for that. Although you will probably also want to protect that file with authentication/authorization, but more on that <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#authenticationauthorization">later</a>.</p>
213<h2 id="the-manifestplist">The manifest.plist</h2>
214<p>Next thing you need is a special <code>manifest.plist</code> file. For example, if the application name is <code>LidarScanner</code>, then <code>manifest.plist</code> will have the following contents:</p>
215<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#75715e">&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;</span>
216</span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;!DOCTYPE plist PUBLIC &#34;-//Apple//DTD PLIST 1.0//EN&#34; &#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd&#34;&gt;</span>
217</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;plist</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;1.0&#34;</span><span style="color:#f92672">&gt;</span>
218</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;dict&gt;</span>
219</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;key&gt;</span>items<span style="color:#f92672">&lt;/key&gt;</span>
220</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;array&gt;</span>
221</span></span><span style="display:flex;"><span>
221        <span style="color:#f92672">&lt;dict&gt;</span>
222</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;key&gt;</span>assets<span style="color:#f92672">&lt;/key&gt;</span>
223</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;array&gt;</span>
224</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;dict&gt;</span>
225</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>kind<span style="color:#f92672">&lt;/key&gt;</span>
226</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>software-package<span style="color:#f92672">&lt;/string&gt;</span>
227</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>url<span style="color:#f92672">&lt;/key&gt;</span>
228</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>https://your.company.com/downloads/lidar-scanner.ipa<span style="color:#f92672">&lt;/string&gt;</span>
229</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;/dict&gt;</span>
230</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;dict&gt;</span>
231</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>kind<span style="color:#f92672">&lt;/key&gt;</span>
232</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>display-image<span style="color:#f92672">&lt;/string&gt;</span>
233</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>url<span style="color:#f92672">&lt;/key&gt;</span>
234</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>https://your.company.com/favicon-32x32.png<span style="color:#f92672">&lt;/string&gt;</span>
235</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;/dict&gt;</span>
236</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;dict&gt;</span>
237</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>kind<span style="color:#f92672">&lt;/key&gt;</span>
238</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>full-size-image<span style="color:#f92672">&lt;/string&gt;</span>
239</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>url<span style="color:#f92672">&lt;/key&gt;</span>
240</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>https://your.company.com/favicon-256x256.png<span style="color:#f92672">&lt;/string&gt;</span>
241</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;/dict&gt;</span>
242</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;/array&gt;</span>
243</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;key&gt;</span>metadata<span style="color:#f92672">&lt;/key&gt;</span>
244</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;dict&gt;</span>
245</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>bundle-identifier<span style="color:#f92672">&lt;/key&gt;</span>
246</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>com.company.LidarScanner<span style="color:#f92672">&lt;/string&gt;</span>
247</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>bundle-version<span style="color:#f92672">&lt;/key&gt;</span>
248</span></span><span style="display:flex;"><span>
248                <span style="color:#f92672">&lt;string&gt;</span>1.0<span style="color:#f92672">&lt;/string&gt;</span>
249</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>kind<span style="color:#f92672">&lt;/key&gt;</span>
250</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>software<span style="color:#f92672">&lt;/string&gt;</span>
251</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>platform-identifier<span style="color:#f92672">&lt;/key&gt;</span>
252</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>com.apple.platform.iphoneos<span style="color:#f92672">&lt;/string&gt;</span>
253</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>title<span style="color:#f92672">&lt;/key&gt;</span>
254</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>LidarScanner<span style="color:#f92672">&lt;/string&gt;</span>
255</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;/dict&gt;</span>
256</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;/dict&gt;</span>
257</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/array&gt;</span>
258</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/dict&gt;</span>
259</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/plist&gt;</span>
260</span></span></code></pre></div><p>For this to work you will need to upload the <code>lidar-scanner.ipa</code> binary and this <code>manifest.plist</code> file to <code>/downloads/</code> path in your website root directory on the server. Actually, <code>manifest.plist</code> can be placed anywhere (<em>and it doesn&rsquo;t even have to be a &ldquo;physical&rdquo; file</em>), but the location of <code>lidar-scanner.ipa</code> should match the one specified in the <code>&lt;string&gt;</code> value for that <code>software-package</code> section in the manifest.</p>
261<p>One more requirement is that the <code>*.ipa</code> URL must be with HTTPS, and the certificate needs to be trusted on the installing device (<em>so you might have some troubles if your SSL/TLS certificate is self-signed</em>).</p>
262<h2 id="the-itms-services-uri">
262The itms-services URI</h2>
263<p>The last thing to do is adding this link anywhere on your website:</p>
264<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>itms-services://?action<span style="color:#f92672">=</span>download-manifest&amp;url<span style="color:#f92672">=</span>https://your.company.com/downloads/manifest.plist
265</span></span></code></pre></div><p>So on a HTML page it would be something like this:</p>
266<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#f92672">a</span> <span style="color:#a6e22e">href</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;itms-services://?action=download-manifest&amp;url=https://your.company.com/downloads/manifest.plist&#34;</span>&gt;
267</span></span><span style="display:flex;"><span>    Install iOS application
268</span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">a</span>&gt;
269</span></span></code></pre></div><p>Here it assumes that the <code>manifest.plist</code> file is located at the <code>/downloads/</code> path in your website root directory on the server. If you placed it elsewhere, then obviously you&rsquo;ll need to adjust that link. And similar to the <code>.ipa</code> URL, it most likely also needs to be with HTTPS (<em>and trusted SSL/TLS certificate</em>).</p>
270<p>Now, once you click on that link in Safari web-browser on one of your iOS devices, the following modal dialog should appear:</p>
271
272
273    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/installing-ios-application-from-website-dialog.png" alt="Modal dialog for installing an iOS application from a website">
274
275<p>and after you click <code>Install</code>, the application will start downloading/installing on your iPhone/iPad.</p>
276<p>This is it, that&rsquo;s the whole magic - just a <code>manifest.plist</code> file and a <code>itms-services://</code> link.</p>
277<h1 id="caveats-and-other-apple-specifics">Caveats and other Apple specifics</h1>
278<p>It was never Apple&rsquo;s intention to make developers life easier, so be prepared for various caveats, nuances and limitations.</p>
279<h2 id="authenticationauthorization">Authentication/authorization</h2>
280<p>You most likely do not want your applications to be available to anyone on the internet, so there definitely should be some authentication on your website (<em>unless it&rsquo;s an internal in-house website, but even then it should probably still have authentication in place</em>). Having said that, the next thing I&rsquo;ll say is that actually your applications kind of will be available to anyone on the internet!</p>
281<p>When I was investigating how the downloads from App Center work, I discovered that the <code>*.ipa</code> files were in fact available for downloading <u>without authentication</u>. In reality it is not as scary as it sounds, because the generated download URLs look like this:</p>
282<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>https://appcenter-filemanagement-distrib2ede6f06e.azureedge.net/dlz9e271-7016-61va-9640-ac0b1k457der/SomeApplication.ipa?sv<span style="color:#f92672">=</span>2025-02-01&amp;sr<span style="color:#f92672">=</span>c&amp;sig<span style="color:#f92672">=</span>YPKVqmfXpecBzveG1nWDPnd4Lh37XM%2F2pLM6D0GWX%2Bg%3D&amp;se<span style="color:#f92672">=</span>2025-03-20T15%3A05%3A50Z&amp;sp<span style="color:#f92672">=</span>r&amp;download_origin<span style="color:#f92672">=</span>appcenter
283</span></span></code></pre></div><p>so it won&rsquo;t be trivial to guess that long GUID, which probably can count as a kind of &ldquo;credentials&rdquo;. Moreover, these URLs have an expiration time, so eventually they will expire and will be returning <code>403</code>.</p>
284<p>But while the URL was still valid, I tested the download from a different device in a private browser tab (<em>where I was not authenticated on App Center</em>), and I also tried it with a bare cURL from a desktop PC, even with a different internet provider (<em>to make sure that I have a different IP address</em>), and it still succeeded:</p>
285<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>$ curl -sI <span style="color:#e6db74">&#39;https://appcenter-filemanagement-distrib2ede6f06e.azureedge.net/dlz9e271-7016-61va-9640-ac0b1k45
2857der/SomeApplication.ipa?sv=2025-02-01&amp;sr=c&amp;sig=YPKVqmfXpecBzveG1nWDPnd4Lh37XM%2F2pLM6D0GWX%2Bg%3D&amp;se=2025-03-20T15%3A05%3A50Z&amp;sp=r&amp;download_origin=appcenter&#39;</span> <span style="color:#ae81ff">\
286</span></span></span><span style="display:flex;"><span>    -H <span style="color:#e6db74">&#39;User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0&#39;</span> <span style="color:#ae81ff">\
287</span></span></span><span style="display:flex;"><span>    -H <span style="color:#e6db74">&#39;Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.5&#39;</span> <span style="color:#ae81ff">\
288</span></span></span><span style="display:flex;"><span>    -H <span style="color:#e6db74">&#39;Accept-Encoding: gzip, deflate, br&#39;</span> <span style="color:#ae81ff">\
289</span></span></span><span style="display:flex;"><span>    -H <span style="color:#e6db74">&#39;DNT: 1&#39;</span> <span style="color:#ae81ff">\
290</span></span></span><span style="display:flex;"><span>    -H <span style="color:#e6db74">&#39;Connection: keep-alive&#39;</span> <span style="color:#ae81ff">\
291</span></span></span><span style="display:flex;"><span>    | grep -i Content-Length <span style="color:#ae81ff">\
292</span></span></span><span style="display:flex;"><span>    | awk <span style="color:#e6db74">&#39;{print $2/1024/1024 &#34; MB&#34;}&#39;</span>
293</span></span><span style="display:flex;"><span>10.5228 MB
294</span></span></code></pre></div><p>As you can see, it is indeed available with no authentication. So if the generated URL is somehow known/leaked and has not expired yet, then anyone on the internet can download the application.</p>
295<p>But why is it so? Did App Center people do something wrong? Because Apple&rsquo;s <a href="https://support.apple.com/guide/deployment/distribute-proprietary-in-house-apps-depce7cefc4d/web#dep30d16db76" rel="external">documentation</a> says:</p>
296<blockquote>
297    <p>
298        Make sure that users are <b>authenticated</b> and that the website is accessible from your intranet or the internet, depending on your needs.
299    </p>
300    <p>
301        [...]
302    </p>
303    <p>
304        Upload these items to an area of your website that your <b>authenticated</b> users can access:
305    </p>
306    <ul>
307        <li>The manifest file (with a .plist filename extension)</li>
308        <li>The app file (with a .ipa filename extension)</li>
309    </ul>
310</blockquote>
311<p>&hellip;so authentication is explicitly mentioned there. But it turned out to be a <b>goddamn lie</b> (<em>or at least it is for some authentication methods/schemas?</em>), as actually both the <code>manifest.plist</code> and the <code>*.ipa</code> file have to be available without authentication.</p>
312<p>I don&rsquo;t know why, and at first I thought that this is because Apple needs to query stuff on the website from their servers, but there were no requests from Apple IP addresses in my web server logs (<em>there were only requests from my iOS devices</em>). So then my next guess is that it&rsquo;s iOS internals who needs to query stuff on the website, and those requests apparently are happening outside of the authenticated Safari session(?), so they are quite naturally failing.</p>
313<p>Specifically, below you can take a look at NGINX logs for requests from my iOS device when I was trying to download an application from our website.</p>
314<h3 id="both-routes-are-protected">Both routes are protected</h3>
315<p>When the <code>manifest.plist</code> route is protected with authentication/authorization:</p>
316<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:37:02 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /files/applications/lidar-scanner/v1.3.42/manifest.plist HTTP/1.1&#34;</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;https://your.company.com/applications/lidar-scanner&#34;</span> <span style="color:#e6db74">&#34;Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1&#34;</span>
317</span></span><span style="display:flex;"><span>
317MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:37:04 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1/manifest.plist HTTP/1.1&#34;</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
318</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:37:04 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /Account/Login?ReturnUrl=%2Fdownloads%2Fapplications%2Flidar-scanner%2Fv1.3.42%2Fipa%2F694277a0-a42c-4f83-a8e9-f74207d217d1%2Fmanifest.plist HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">1581</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
319</span></span></code></pre></div><p>then it doesn&rsquo;t even get to the <code>*.ipa</code> route, which is also protected with authentication/authorization, but it didn&rsquo;t matter in this case, as it failed already on getting the <code>manifest.plist</code> and got redirected to the login route, which results in nothing, as these requests are happening behind the scenes (<em>so user gets no prompts</em>).</p>
320<h3 id="only-the-application-file-route-is-protected">Only the application file route is protected</h3>
321<p>Then I removed authentication/authorization requirement from the <code>manifest.plist</code> route but kept it for the <code>*.ipa</code> route, and for that scenario I got the following logs:</p>
322<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:21 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /files/applications/lidar-scanner/v1.3.42/manifest.plist HTTP/1.1&#34;</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;https://your.company.com/applications/lidar-scanner&#34;</span> <span style="color:#e6db74">&#34;Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1&#34;</span>
323</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:21 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1/manifest.plist HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">496</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
324</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;HEAD /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1&#34;</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
325</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;HEAD /Account/Login?ReturnUrl=%2Fdownloads%2Fapplications%2Flidar-scanner%2Fv1.3.42%2Fipa%2F694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1&#34;</span> <span style="color:#ae81ff">405</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
326</span></span><span style="display:flex;"><span>
326MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /favicon-32x32.png HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">2004</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
327</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /favicon-32x32.png HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">2004</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
328</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;HEAD /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1&#34;</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
329</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;HEAD /Account/Login?ReturnUrl=%2Fdownloads%2Fapplications%2Flidar-scanner%2Fv1.3.42%2Fipa%2F694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1&#34;</span> <span style="color:#ae81ff">405</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
330</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1&#34;</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
331</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /Account/Login?ReturnUrl=%2Fdownloads%2Fapplications%2Flidar-scanner%2Fv1.3.42%2Fipa%2F694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">1565</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
332</span></span></code></pre></div><p>So this time it:</p>
333<ol>
334<li>Succeeded with the <code>manifest.plist</code> route, as it is now available for unauthenticated requests;</li>
335<li>Tried to query the <code>*.ipa</code> route with <code>HEAD</code>, which failed the same way and again got redirected to the login route (<em>and then failed there with <code>405</code>, because that route doesn&rsquo;t allow <code>HEAD</code> requests in our backend</em>);</li>
336<li>However, it didn&rsquo;t stop there, as then it fetched one of the images specified in the manifest (<em>twice, for some reason</em>);</li>
337<li>Then it tried to send the same <code>HEAD</code> request again, with the same unsuccessful result, and apparently it still didn&rsquo;t matter;
338<ul>
339<li>so what is the point of those <code>HEAD</code> requests, if their (<em>double</em>) failure does not interrupt the process?</li>
340</ul>
341</li>
342<li>And then seemingly it actually did try to <code>GET</code> the application&rsquo;s <code>*.ipa</code> file, but that also resulted in a redirect to the login route, where it finally halted.</li>
343</ol>
344<p>On the iOS device though I got the application icon, but with a cloud symbol in front of the title, and trying to open it I got this useless error message:</p>
345
346
347    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/ipa-route-authentication.png" alt="Description">
348
349<h3 id="both-routes-are-not-protected">Both routes are not protected</h3>
350<p>And only when I removed the authentication/authorization requirement from both the <code>manifest.plist</code> and the <code>*.ipa</code> routes, then everything succeeded (<em>but still queried the same image twice, for some reason</em>):</p>
351<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>
351MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:28 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /files/applications/lidar-scanner/v1.3.42/manifest.plist HTTP/1.1&#34;</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;https://your.company.com/applications/lidar-scanner&#34;</span> <span style="color:#e6db74">&#34;Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1&#34;</span>
352</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:28 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /downloads/applications/lidar-scanner/v1.3.42/ipa/97707e38-079e-4844-9cbf-67cf3c4584c1/manifest.plist HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">497</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
353</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:31 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;HEAD /downloads/applications/lidar-scanner/v1.3.42/ipa/97707e38-079e-4844-9cbf-67cf3c4584c1 HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
354</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:31 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /favicon-32x32.png HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">
3542004</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
355</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:31 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /favicon-32x32.png HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">2004</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
356</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:31 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">&#34;GET /downloads/applications/lidar-scanner/v1.3.42/ipa/97707e38-079e-4844-9cbf-67cf3c4584c1 HTTP/1.1&#34;</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">11874414</span> <span style="color:#e6db74">&#34;-&#34;</span> <span style="color:#e6db74">&#34;com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1&#34;</span>
357</span></span></code></pre></div><p>Thus, the only thing that can have authentication/authorization is the page where you publish the <code>itms-services://</code> link.</p>
358<p>So App Center (<em>and all other similar services?</em>) had no other choice but to implement those generated short-lived unauthenticated download URLs in order not to expose static <code>*.ipa</code> URLs for anonymous downloads. And that is <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#initial-download-request">what we did</a> on our side too (<em>as you can see in the web server logs above</em>).</p>
359<h2 id="launching-downloaded-applications-on-ios-devices">Launching downloaded applications on iOS devices</h2>
360<p>After downloading/installing the application you will need to perform a certain ritual in order to be able to launch it. To clarify, this applies no matter what solution you will choose for hosting your applications: it was there with HockeyApp, it was there with App Center, and it will be there with any other similar service, including a self-hosted solution which we are talking about here.</p>
361<p>So, thanks to Apple, the downloaded application cannot be launched right away (<em>unless this device already had other applications from this organization/profile before</em>). Trying to launch it for the first time, you will get the following message (<em>I don&rsquo;t know why it says <code>iPhone Distribution</code>, but it works equally fine on both iPhones and iPads</em>):</p>
362
363
364    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/untrusted-developer.png" alt="Trying to launch iOS application, untrusted enterprise developer">
365
366<p>What this message means is that you need to go to <code>Settings</code> → <code>General</code> and there will be this <code>ENTERPRISE APP</code> thing (<em>the last section of the <code>General</code> pane</em>):</p>
367
368
369    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/ipad-settings-enterprise-app.png" alt="Enterprise applications in iPad settings">
370
371<p>In there there will be a button/label for trusting this profile:</p>
372
373
374    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/ipad-settings-enterprise-app-trust.png" alt="Enterprise applications in iPad settings, trusting button">
375
376<p>pressing on which will give you one final confirmation dialog:</p>
377
378
379    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/ipad-settings-enterprise-app-trust-confirmation.png" alt="Enterprise applications in iPad settings, trust confirmation">
380
381<p>After that you will finally be able to launch the application. I can admit that this is not a terrible security measure against potentially malicious applications, but it is quite tiresome nevertheless.</p>
382<p>And you guessed it right, you will need to teach/explain this procedure to every single user/employee that you have. It might not look like much to you now, but wait until you&rsquo;ll be helping with the same bloody set of problems for the 1000th time, as no one ever fucking reads any manuals
382/tutorials. You can even make a super detailed step-by-step video (<em>we did, in addition to the one we already had for downloading applications from App Center</em>), but even then there will be individuals who will manage to fail to follow the procedure.</p>
383<h2 id="limited-number-of-devices">Limited number of devices</h2>
384<p>From the little I know about the Apple side of things, there are different &ldquo;types&rdquo; of distribution. If I am not mistaken, the most common ones are a so-called &ldquo;Ad Hoc&rdquo; distribution and a so-called &ldquo;Enterprise&rdquo; distribution. Both of these seem to have different limitations, but for the love of god I could not find a clear explanation in Apple documentation. So the following details are just my silly understanding of what I could find all over the internet.</p>
385<p>With &ldquo;Ad Hoc&rdquo; type of distribution there seems to be a limit of 100 devices of each type (<em>100 iPhones, 100 iPads, etc</em>) to which you can install your applications. And each of those devices (<em>their unique identifiers - UDID</em>) has to be <a href="https://developer.apple.com/documentation/xcode/distributing-your-app-to-registered-devices#Collect-device-identifiers-iOS-iPadOS-tvOS-visionOS-watchOS" rel="external">registered</a> to your Apple Developer account <em>before</em> you&rsquo;ll try to install/run your applications on it. Or at least I read something about that somewhere, probably <a href="https://developer.apple.com/help/account/provisioning-profiles/create-an-ad-hoc-provisioning-profile/" rel="external">here</a>.</p>
386<p>With the &ldquo;Enterprise&rdquo; type of distribution (<em>which is apparently the one we have</em>) those limitations do not apply, as we just keep installing our iOS applications on more and more employees devices without registering shit anywhere (<em>unless it happens automatically behind the scenes?</em>), and nothing &ldquo;bad&rdquo; has happened yet, everything keeps working fine. And <a href="https://docs.getupdraft.com/ios/code-signing-explained-certificates-identifiers-profiles-what#distribution-enterprise" rel="external">this section</a> in Updraft documentation seems to be describing the same.</p>
387<h2 id="applications-expiration">Applications expiration</h2>
388<p>With Apple being Apple there is a surprise awaiting for you where you discover that applications expire in one year. Or rather their &ldquo;<em>provisioning profile</em>&rdquo; does. So in one year all your users will get a pumpkin instead of an application that was working fine just yesterday:</p>
389
390
391    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/integrity-could-not-be-verified.png" alt="Error on trying to launch expired iOS application, integrity could not be verified">
392
393<p>To fix that you will need to re-build and re-sign the absolutely fucking same application project just to &ldquo;refresh&rdquo; the expiration countdown. And then you will need to tell all your users/employees that they need to go to your website and re-install that &ldquo;renewed&rdquo; but otherwise absolutely identical application on their devices.</p>
394<p>Again, I don&rsquo;t remember where I read about this, probably <a href="https://developer.apple.com/library/archive/recipes/ProvisioningPortal_Recipes/CreatingaDistributionProvisioningProfile/CreatingaDistributionProvisioningProfile.html" rel="external">here</a>. Or actually here&rsquo;s what <a href="https://learn.microsoft.com/en-us/intune/intune-service/apps/app-provisioning-profile-ios" rel="external">Microsoft&rsquo;s documentation</a> says about this:</p>
395<blockquote>The enterprise signing certificate that you use to sign apps typically lasts for three years. However, the provisioning profile expires after a year. While the certificate is still valid, Intune gives you the tools to proactively assign a new provisioning profile to devices that have apps that are nearing expiry. After the certificate expires, you must sign the app again with a new certificate and embed a new provisioning profile with the key of the new certificate.</blockquote>
396<p>So there is one more surprise: in addition to provisioning profile expiring after 1 year, your signing certificate will also be expiring every 3 years.</p>
397<h1 id="hosting-ios-applications-on-a-netmvc-website">Hosting iOS applications on a .NET/MVC website</h1>
398<p>As I already said in the beginning, now when you know <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#how-is-it-even-possible-to-distribute-ios-applications-without-app-store">what it takes</a> to distribute an (<em>&ldquo;
398Enterprise&rdquo;</em>) iOS application, it should be rather trivial for you to implement this on your own website, no matter what web-development framework you are using there. In a simplest case even a single static HTML page would do. Or maybe even a bare <code>itms-services://</code> link sent via e-mail should be enough.</p>
399<p>In our case we have a .NET/MVC-based website, so I&rsquo;ll share the related code fragments. Note that I removed some parts from that code for simplicity/confidentiality, so there might be missing variables or other errors in there. This is just a general example after all, so don&rsquo;t expect it to work &ldquo;out of the box&rdquo;.</p>
400<p>To reiterate the <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#authenticationauthorization">key point</a> of having some backend code instead of just a &ldquo;plain&rdquo; HTML page: the goal here is to auto-generate short-lived download URLs in order not to expose static <code>*.ipa</code> files without authentication.</p>
401<h2 id="template-string-for-manifestplist">Template string for manifest.plist</h2>
402<p>As you&rsquo;ll probably have more than one application to distribute, and given that every application will likely have more than one version, it quickly becomes impractical to compose static <code>manifest.plist</code> manually. You could probably auto-generate them with a script every time a new application/version is published, but instead we decided to generate them &ldquo;on the fly&rdquo; using a template string:</p>
403<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span>private const string _manifestPlist = &#34;&#34;&#34;
404</span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;?xml version=&#34;1.0&#34; encoding=&#34;UTF-8&#34;?&gt;</span>
405</span></span><span style="display:flex;"><span><span style="color:#75715e">&lt;!DOCTYPE plist PUBLIC &#34;-//Apple//DTD PLIST 1.0//EN&#34; &#34;http://www.apple.com/DTDs/PropertyList-1.0.dtd&#34;&gt;</span>
406</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;plist</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">&#34;1.0&#34;</span><span style="color:#f92672">&gt;</span>
407</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;dict&gt;</span>
408</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;key&gt;</span>items<span style="color:#f92672">&lt;/key&gt;</span>
409</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;array&gt;</span>
410</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&lt;dict&gt;</span>
411</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;key&gt;</span>assets<span style="color:#f92672">&lt;/key&gt;</span>
412</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;array&gt;</span>
413</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;dict&gt;</span>
414</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>kind<span style="color:#f92672">&lt;/key&gt;</span>
415</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>software-package<span style="color:#f92672">&lt;/string&gt;</span>
416</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>url<span style="color:#f92672">&lt;/key&gt;</span>
417</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>https://your.company.com/downloads/applications/{0}/v{1}/ipa/{2}<span style="color:#f92672">&lt;/string&gt;</span>
418</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;/dict&gt;</span>
419</span></span><span style="display:flex;"><span>
419                <span style="color:#f92672">&lt;dict&gt;</span>
420</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>kind<span style="color:#f92672">&lt;/key&gt;</span>
421</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>display-image<span style="color:#f92672">&lt;/string&gt;</span>
422</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>url<span style="color:#f92672">&lt;/key&gt;</span>
423</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>https://your.company.com/favicon-32x32.png<span style="color:#f92672">&lt;/string&gt;</span>
424</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;/dict&gt;</span>
425</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;dict&gt;</span>
426</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>kind<span style="color:#f92672">&lt;/key&gt;</span>
427</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>full-size-image<span style="color:#f92672">&lt;/string&gt;</span>
428</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;key&gt;</span>url<span style="color:#f92672">&lt;/key&gt;</span>
429</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">&lt;string&gt;</span>https://your.company.com/favicon-256x256.png<span style="color:#f92672">&lt;/string&gt;</span>
430</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;/dict&gt;</span>
431</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;/array&gt;</span>
432</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;key&gt;</span>metadata<span style="color:#f92672">&lt;/key&gt;</span>
433</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;dict&gt;</span>
434</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>bundle-identifier<span style="color:#f92672">&lt;/key&gt;</span>
435</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>{3}<span style="color:#f92672">&lt;/string&gt;</span>
436</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>bundle-version<span style="color:#f92672">&lt;/key&gt;</span>
437</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>1.0<span style="color:#f92672">&lt;/string&gt;</span>
438</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>kind<span style="color:#f92672">&lt;/key&gt;</span>
439</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>software<span style="color:#f92672">&lt;/string&gt;</span>
440</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>platform-identifier<span style="color:#f92672">&lt;/key&gt;</span>
441</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>com.apple.platform.iphoneos<span style="color:#f92672">&lt;/string&gt;</span>
442</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;key&gt;</span>title<span style="color:#f92672">&lt;/key&gt;</span>
443</span></span><span style="display:flex;"><span>                <span style="color:#f92672">&lt;string&gt;</span>{4}<span style="color:#f92672">&lt;/string&gt;</span>
444</span></span><span style="display:flex;"><span>            <span style="color:#f92672">&lt;/dict&gt;</span>
445</span></span><span style="display:flex;"><span>
445        <span style="color:#f92672">&lt;/dict&gt;</span>
446</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&lt;/array&gt;</span>
447</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/dict&gt;</span>
448</span></span><span style="display:flex;"><span><span style="color:#f92672">&lt;/plist&gt;</span>
449</span></span><span style="display:flex;"><span>&#34;&#34;&#34;;
450</span></span></code></pre></div><p>Not sure if <code>bundle-version</code> value should also be templated or not, but having it hardcoded to <code>1.0</code> didn&rsquo;t seem to affect anything so far.</p>
451<p>It would probably be more correct to generate the <code>manifest.plist</code> with <a href="https://learn.microsoft.com/en-us/dotnet/api/system.xml.linq.xdocument" rel="external">XDocument</a>, but it didn&rsquo;t seem to be very trivial in case of Apple&rsquo;s <code>*.plist</code> files schema, and also performance-wise it is likely (<em>although I haven&rsquo;t measured it</em>) more efficient to just substitute variables in a template string than to construct a <code>XDocument</code> object every time.</p>
452<h2 id="initial-download-request">Initial download request</h2>
453<p>As we want to pre-process the download request first before redirecting it to the <code>itms-services://</code> URI, the download links on the website look like this:</p>
454<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#f92672">a</span> <span style="color:#a6e22e">href</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;/files/applications/some-application/v1.2.3/manifest.plist&#34;</span>&gt;
455</span></span><span style="display:flex;"><span>    Install
456</span></span><span style="display:flex;"><span>&lt;/<span style="color:#f92672">a</span>&gt;
457</span></span></code></pre></div><p>And here&rsquo;s the route/action for processing the download request when user clicks on that link:</p>
458<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// note the absence of the [AllowAnonymous] attribute, as this action is protected</span>
459</span></span><span style="display:flex;"><span><span style="color:#75715e">// with authentication/authorization policies on the controller level</span>
460</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[HttpGet(&#34;/files/applications/{applicationSlug}/v{applicationVersion}/manifest.plist&#34;)]</span>
461</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> IActionResult ApplicationDownloadFile(
462</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> applicationSlug,
463</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> applicationVersion
464</span></span><span style="display:flex;"><span>)
465</span></span><span style="display:flex;"><span>{
466</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// check in the database if such application even exists</span>
467</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> application = _databaseContext.GetApplication(applicationSlug);
468</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (application == <span style="color:#66d9ef">null</span>)
469</span></span><span style="display:flex;"><span>    {
470</span></span><span style="display:flex;"><span>        _logger.Warn(<span style="color:#e6db74">$&#34;There is no [{applicationSlug}] application in the database&#34;</span>);
471</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> NotFound();
472</span></span><span style="display:flex;"><span>    }
473</span></span><span style="display:flex;"><span>
474</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> downloadGUID = Guid.NewGuid();
475</span></span><span style="display:flex;"><span>
476</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// check in the database if there is already an anonymous download</span>
477</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// for this version of the application (and get its data/info)</span>
478</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> applicationDownloadAnonymous = _databaseContext.GetApplicationVersionDownloadAnonymous(
479</span></span><span style="display:flex;"><span>        applicationSlug,
480</span></span><span style="display:flex;"><span>        applicationVersion
481</span></span><span style="display:flex;"><span>    );
482</span></span><span style="display:flex;"><span>
483</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (
484</span></span><span style="display:flex;"><span>
484        <span style="color:#75715e">// no such download at all</span>
485</span></span><span style="display:flex;"><span>        applicationDownloadAnonymous == <span style="color:#66d9ef">null</span>
486</span></span><span style="display:flex;"><span>        ||
487</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// there was a download, but it has expired by now</span>
488</span></span><span style="display:flex;"><span>        DownloadLinkHasExpired(applicationDownloadAnonymous.Created)
489</span></span><span style="display:flex;"><span>    )
490</span></span><span style="display:flex;"><span>    {
491</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (applicationDownloadAnonymous == <span style="color:#66d9ef">null</span>) <span style="color:#75715e">// does not exist at all</span>
492</span></span><span style="display:flex;"><span>        {
493</span></span><span style="display:flex;"><span>            _logger.Debug(
494</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">new</span> StringBuilder()
495</span></span><span style="display:flex;"><span>                    .Append(<span style="color:#e6db74">&#34;There is no existing anonymous download for &#34;</span>)
496</span></span><span style="display:flex;"><span>                    .Append(<span style="color:#e6db74">$&#34;[{applicationSlug}], will create one&#34;</span>)
497</span></span><span style="display:flex;"><span>                    .ToString()
498</span></span><span style="display:flex;"><span>            );
499</span></span><span style="display:flex;"><span>        }
500</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span> <span style="color:#75715e">// exists but expired</span>
501</span></span><span style="display:flex;"><span>        {
502</span></span><span style="display:flex;"><span>            _logger.Debug(
503</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">new</span> StringBuilder()
504</span></span><span style="display:flex;"><span>                    .Append(<span style="color:#e6db74">$&#34;There is an existing anonymous download for &#34;</span>)
505</span></span><span style="display:flex;"><span>                    .Append(<span style="color:#e6db74">$&#34;[{applicationSlug}] - {applicationDownloadAnonymous.GUID} &#34;</span>)
506</span></span><span style="display:flex;"><span>                    .Append(<span style="color:#e6db74">&#34;-  but it has already expired, will create a new one&#34;</span>)
507</span></span><span style="display:flex;"><span>                    .ToString()
508</span></span><span style="display:flex;"><span>            );
509</span></span><span style="display:flex;"><span>        }
510</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// add a new download to the database</span>
511</span></span><span style="display:flex;"><span>        _databaseContext.AddApplicationVersionDownloadAnonymous(
512</span></span><span style="display:flex;"><span>            applicationSlug,
513</span></span><span style="display:flex;"><span>            applicationVersion,
514</span></span><span style="display:flex;"><span>            downloadGUID,
515</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">$&#34;{applicationSlug}.ipa&#34;</span> <span style="color:#75715e">// iOS downloads might not be the only downloads of such nature, so there might need to be some additional logic here</span>
516</span></span><span style="display:flex;"><span>        );
517</span></span><span style="display:flex;"><span>    }
518</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span>
519</span></span><span style="display:flex;"><span>    {
520</span></span><span style="display:flex;"><span>        _logger.Debug(
521</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">new</span> StringBuilder()
522</span></span><span style="display:flex;"><span>                .Append(<span style="color:#e6db74">$&#34;Found an existing anonymous download for [{applicationSlug}] &#34;</span>)
523</span></span><span style="display:flex;"><span>                .Append(<span style="color:#e6db74">$&#34;that has not expired yet: {applicationDownloadAnonymous.GUID}&#34;</span>)
524</span></span><span style="display:flex;"><span>                .ToString()
525</span></span><span style="display:flex;"><span>        );
526</span></span><span style="display:flex;"><span>        downloadGUID = applicationDownloadAnonymous.GUID;
527</span></span><span style="display:flex;"><span>    }
528</span></span><span style="display:flex;"><span>
529</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// redirect the request to itms-services:// URI of the actual manifest.plist</span>
530</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> Redirect(
531</span></span><span style="display:flex;"><span>
531        <span style="color:#66d9ef">new</span> StringBuilder()
532</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">&#34;itms-services://?action=download-manifest&amp;url=&#34;</span>)
533</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">&#34;https://your.company.com&#34;</span>)
534</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">$&#34;/downloads/applications/{applicationSlug}/v{applicationVersion}&#34;</span>)
535</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">$&#34;/ipa/{downloadGUID}/manifest.plist&#34;</span>)
536</span></span><span style="display:flex;"><span>            .ToString()
537</span></span><span style="display:flex;"><span>    );
538</span></span><span style="display:flex;"><span>}
539</span></span></code></pre></div><p>The function for checking the download URL expiration:</p>
540<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">bool</span> DownloadLinkHasExpired(DateTime dtCreated)
541</span></span><span style="display:flex;"><span>{
542</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> (DateTime.Now - dtCreated).TotalHours &gt; _configuration.GetSection(
543</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// instead of getting it from appsettings.json, you can just as well simply hardcode whatever value you want here</span>
544</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// (we&#39;ve set 2 hours)</span>
545</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;ApplicationsGallery:DownloadLinkExpirationHours&#34;</span>
546</span></span><span style="display:flex;"><span>    ).Get&lt;<span style="color:#66d9ef">int</span>&gt;();
547</span></span><span style="display:flex;"><span>}
548</span></span></code></pre></div><h2 id="downloading-the-manifestplist">Downloading the manifest.plist</h2>
549<p>The route/action for getting the <code>manifest.plist</code>:</p>
550<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// as you saw in the web server logs, this route should not have authentication,</span>
551</span></span><span style="display:flex;"><span><span style="color:#75715e">// otherwise iOS device services will fail to query stuff</span>
552</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[AllowAnonymous]</span>
553</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Route(&#34;/downloads/applications/{applicationSlug}/v{applicationVersion}/ipa/{downloadGUID}/manifest.plist&#34;)]</span>
554</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> IActionResult ApplicationDownloadIpaManifest(
555</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> applicationSlug,
556</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> applicationVersion,
557</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> downloadGUID
558</span></span><span style="display:flex;"><span>)
559</span></span><span style="display:flex;"><span>{
560</span></span><span style="display:flex;"><span>    _logger.Debug(
561</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> StringBuilder()
562</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">$&#34;Got a [{HttpContext.Request.Method}] request to download a manifest &#34;</span>)
563</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">$&#34;for the application [{applicationSlug}], version [{applicationVersion}], &#34;</span>)
564</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">$&#34;download GUID [{downloadGUID}]&#34;</span>)
565</span></span><span style="display:flex;"><span>            .ToString()
566</span></span><span style="display:flex;"><span>    );
567</span></span><span style="display:flex;"><span>
568</span></span><span style="display:flex;"><span>    ApplicationDownloadAnonymous applicationDownloadAnonymous = <span style="color:#66d9ef">null</span>;
569</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>
570</span></span><span style="display:flex;"><span>    {
571</span></span><span style="display:flex;"><span>
571        <span style="color:#75715e">// as we don&#39;t trust anyone, check for the existence of the requested download</span>
572</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// in the database (and get its data/info)</span>
573</span></span><span style="display:flex;"><span>        applicationDownloadAnonymous = _databaseContext.GetApplicationVersionDownloadAnonymous(
574</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">new</span> Guid(downloadGUID)
575</span></span><span style="display:flex;"><span>        );
576</span></span><span style="display:flex;"><span>    }
577</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">catch</span> (Exception ex)
578</span></span><span style="display:flex;"><span>    {
579</span></span><span style="display:flex;"><span>        _logger.Error(
580</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">new</span> StringBuilder()
581</span></span><span style="display:flex;"><span>                .Append(<span style="color:#e6db74">$&#34;Error on trying to get the download for GUID [{downloadGUID}]. &#34;</span>)
582</span></span><span style="display:flex;"><span>                .Append(ex.Message)
583</span></span><span style="display:flex;"><span>                .ToString()
584</span></span><span style="display:flex;"><span>        );
585</span></span><span style="display:flex;"><span>
586</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> NotFound();
587</span></span><span style="display:flex;"><span>    }
588</span></span><span style="display:flex;"><span>
589</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (applicationDownloadAnonymous == <span style="color:#66d9ef">null</span>)
590</span></span><span style="display:flex;"><span>    {
591</span></span><span style="display:flex;"><span>        _logger.Warn(<span style="color:#e6db74">$&#34;There is no download with GUID [{downloadGUID}]&#34;</span>);
592</span></span><span style="display:flex;"><span>
593</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// and maybe even do something more here (send an alert to admins, at least), for example</span>
594</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// if there are way too many &#34;failed&#34; requests, as it might indicate that someone is trying</span>
595</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// to guess/bruteforce unauthenticated download URLs</span>
596</span></span><span style="display:flex;"><span>
597</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> NotFound();
598</span></span><span style="display:flex;"><span>    }
599</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">if</span> (DownloadLinkHasExpired(applicationDownloadAnonymous.Created))
600</span></span><span style="display:flex;"><span>    {
601</span></span><span style="display:flex;"><span>        _logger.Warn(<span style="color:#e6db74">$&#34;The download link for GUID [{downloadGUID}] has expired&#34;</span>);
602</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// but don&#39;t let user know about this</span>
603</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> NotFound();
604</span></span><span style="display:flex;"><span>    }
605</span></span><span style="display:flex;"><span>
606</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// this is where the manifest template string gets substituted with the actual values</span>
607</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// to generate the resulting manifest.plist</span>
608</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> manifest = <span style="color:#66d9ef">string</span>.Format(_manifestPlist,
609</span></span><span style="display:flex;"><span>        applicationSlug,
610</span></span><span style="display:flex;"><span>        applicationVersion,
611</span></span><span style="display:flex;"><span>        downloadGUID,
612</span></span><span style="display:flex;"><span>        applicationDownloadAnonymous.AppleManifestBundleIdentifier,
613</span></span><span style="display:flex;"><span>        applicationDownloadAnonymous.AppleManifestTitle
614</span></span><span style="display:flex;"><span>    );
615</span></span><span style="display:flex;"><span>
616</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> ContentResult
617</span></span><span style="display:flex;"><span>    {
618</span></span><span style="display:flex;"><span>        Content = manifest.ToString(),
619</span></span><span style="display:flex;"><span>        ContentType = <span style="color:#e6db74">&#34;text/xml&#34;</span>,
620</span></span><span style="display:flex;"><span>        StatusCode = <span style="color:#ae81ff">200</span>
621</span></span><span style="display:flex;"><span>    };
622</span></span><span style="display:flex;"><span>}
623</span></span></code></pre></div><h2 id="downloading-the-application">Downloading the application</h2>
624<p>Finally, the route/action for downloading the <code>*.ipa</code> file:</p>
625<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// as you saw in the web server logs, this route should not have authentication either</span>
626</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[AllowAnonymous]</span>
627</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Route(&#34;/downloads/applications/{applicationSlug}/v{applicationVersion}
627/ipa/{downloadGUID}&#34;)]</span>
628</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> IActionResult ApplicationDownloadIpa(
629</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> applicationSlug,
630</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> applicationVersion,
631</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">string</span> downloadGUID
632</span></span><span style="display:flex;"><span>)
633</span></span><span style="display:flex;"><span>{
634</span></span><span style="display:flex;"><span>    _logger.Debug(
635</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">new</span> StringBuilder()
636</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">$&#34;Got a [{HttpContext.Request.Method}] request to download the IPA file &#34;</span>)
637</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">$&#34;for the application [{applicationSlug}], version [{applicationVersion}], &#34;</span>)
638</span></span><span style="display:flex;"><span>            .Append(<span style="color:#e6db74">$&#34;download GUID [{downloadGUID}]&#34;</span>)
639</span></span><span style="display:flex;"><span>            .ToString()
640</span></span><span style="display:flex;"><span>    );
641</span></span><span style="display:flex;"><span>
642</span></span><span style="display:flex;"><span>    ApplicationDownloadAnonymous applicationDownloadAnonymous = <span style="color:#66d9ef">null</span>;
643</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>
644</span></span><span style="display:flex;"><span>    {
645</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// check in the database if this download even exists (and get its data/info)</span>
646</span></span><span style="display:flex;"><span>        applicationDownloadAnonymous = _databaseContext.GetApplicationVersionDownloadAnonymous(
647</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">new</span> Guid(downloadGUID)
648</span></span><span style="display:flex;"><span>        );
649</span></span><span style="display:flex;"><span>    }
650</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">catch</span> (Exception ex)
651</span></span><span style="display:flex;"><span>    {
652</span></span><span style="display:flex;"><span>        _logger.Error(
653</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">new</span> StringBuilder()
654</span></span><span style="display:flex;"><span>                .Append(<span style="color:#e6db74">$&#34;Error on trying to get the download for GUID [{downloadGUID}]. &#34;</span>)
655</span></span><span style="display:flex;"><span>                .Append(ex.Message)
656</span></span><span style="display:flex;"><span>                .ToString()
657</span></span><span style="display:flex;"><span>        );
658</span></span><span style="display:flex;"><span>
659</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// if (_hostingEnvironment.IsDevelopment())</span>
660</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// {</span>
661</span></span><span style="display:flex;"><span>        <span style="color:#75715e">//     return new ContentResult</span>
662</span></span><span style="display:flex;"><span>        <span style="color:#75715e">//     {</span>
663</span></span><span style="display:flex;"><span>        <span style="color:#75715e">//         Content = &#34;Failed to query the database.&#34;,</span>
664</span></span><span style="display:flex;"><span>        <span style="color:#75715e">//         ContentType = &#34;text/plain&#34;,</span>
665</span></span><span style="display:flex;"><span>        <span style="color:#75715e">//         StatusCode = 500</span>
666</span></span><span style="display:flex;"><span>        <span style="color:#75715e">//     };</span>
667</span></span><span style="display:flex;"><span>
667        <span style="color:#75715e">// }</span>
668</span></span><span style="display:flex;"><span>
669</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> NotFound();
670</span></span><span style="display:flex;"><span>    }
671</span></span><span style="display:flex;"><span>
672</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (applicationDownloadAnonymous == <span style="color:#66d9ef">null</span>)
673</span></span><span style="display:flex;"><span>    {
674</span></span><span style="display:flex;"><span>        _logger.Warn(<span style="color:#e6db74">$&#34;There is no download with GUID [{downloadGUID}]&#34;</span>);
675</span></span><span style="display:flex;"><span>
676</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// yet again, maybe do something more here (send an alert to admins, at least), for example</span>
677</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// if there are way too many &#34;failed&#34; requests, as it might indicate that someone is trying</span>
678</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// to guess/bruteforce unauthenticated download URLs</span>
679</span></span><span style="display:flex;"><span>
680</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> NotFound();
681</span></span><span style="display:flex;"><span>    }
682</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">if</span> (DownloadLinkHasExpired(applicationDownloadAnonymous.Created))
683</span></span><span style="display:flex;"><span>    {
684</span></span><span style="display:flex;"><span>        _logger.Warn(<span style="color:#e6db74">$&#34;The download link for GUID [{downloadGUID}] has expired&#34;</span>);
685</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// still don&#39;t let user know about this</span>
686</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> NotFound();
687</span></span><span style="display:flex;"><span>    }
688</span></span><span style="display:flex;"><span>
689</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> downloadPath = <span style="color:#e6db74">$&#34;{_hostingEnvironment.WebRootPath}/files/applications/{applicationSlug}/v{applicationVersion}/{applicationDownloadAnonymous.FileName}&#34;</span>;
690</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (!System.IO.File.Exists(downloadPath))
691</span></span><span style="display:flex;"><span>    {
692</span></span><span style="display:flex;"><span>        _logger.Warn(<span style="color:#e6db74">$&#34;Could not find the file [{downloadPath}]&#34;</span>);
693</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// user should probabably not know about this either</span>
694</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> NotFound();
695</span></span><span style="display:flex;"><span>    }
696</span></span><span style="display:flex;"><span>
697</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> PhysicalFile(
698</span></span><span style="display:flex;"><span>        downloadPath,
699</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;application/octet-stream&#34;</span>,
700</span></span><span style="display:flex;"><span>        applicationDownloadAnonymous.FileName
701</span></span><span style="display:flex;"><span>    );
702</span></span><span style="display:flex;"><span>}
703</span></span></code></pre></div><p>It should be obvious and already known by now, but probably won&rsquo;t hurt to explicitly state this one more time anyway: direct static URLs to the &ldquo;physical&rdquo; <code>/files/applications/{applicationSlug}/v{applicationVersion}/some-application.ipa</code> files should never be exposed in your website routes without authentication/authorization. That is the entire point of having those temporary GUID-based URLs in the unprotected/anonymous <code>ApplicationDownloadIpa()</code> route/action, which is meant to be serving the actual files via <a href="https://learn.microsoft.com/en-us
703/dotnet/api/microsoft.aspnetcore.mvc.controllerbase.physicalfile#microsoft-aspnetcore-mvc-controllerbase-physicalfile(system-string-system-string-system-string)" rel="external">PhysicalFile</a>.</p>
704<p>Now, if it&rsquo;s all good, your users/employees will be able to download and install iOS applications from your company website:</p>
705<p>
706
707
708    <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/installing-ios-application-from-website.png" alt="Installing an iOS application from a website">
709
710</p>
711<p>If anything, the screenshot above shows an admin view with additional controls. Regular users will see a simplified &ldquo;less capable&rdquo; variant.</p>
712      </div>
713    </article><div id="tags">
714        <i class="bi-hash"></i>
715        <a class="tag" href="https://decovar.dev/tags/apple/">apple</a><a class="tag" href="https://decovar.dev/tags/ios/">ios</a><a class="tag" href="https://decovar.dev/tags/web/">web</a><a class="tag" href="https://decovar.dev/tags/dotnet/">dotnet</a></div><hr class="comments-divider">
716<div id="comments">
717    <div id="remark42"></div>
718</div>
719
720
721                </div>
722                <footer id="footer">
723                    <div>
724                        2014 - 2026,
725                        <a href="/about/">Declaration of VAR</a>
726                    </div>
727                    <div><a href="/about/#licenses">CC BY-SA 4.0 / GPLv3</a></div>
728                </footer>
729            </div><div id="sidebar">
730    <div>
731        <input id="search-query" placeholder="Search in blog titles and summaries" />
732        <div id="search-results" style="display:none;"></div>
733    </div>
734    <div id="sidebars" style="margin-top:20px;">
735        <a id="avatar" href="/about">
736            <img class="app-header-avatar" src="/images/avas/spike.png" />
737        </a>
738        <div class="sidebar-section" style="margin-top:1em;">
739            <h3 style="margin-top:0;">Donation</h3>
740            <p style="margin:8px 0 0 0;">
741                You can support me by donating cryptocurrency to either of the following wallets
742                (<i><a href="/donation.txt" class="link-decorated">signed variant</a></i>):
743            </p>
744            <ul class="cryptocurrency-wallets">
745                <li class="cryptocurrency-wallet-information">
746                    <p>XLM</p>
747                    <pre><code>GDYB2QGJYWT6VPT7O7INWIGRGJF5ZEAQ7Y73S4T47IOP2QQ554NPN3ZM</code></pre>
748                </li>
749                <li class="cryptocurrency-wallet-information">
750                    <p>BTC</p>
751                    <pre><code>bc1q2kfqwm2kwyw37ay2g4tnguasvp6j4hsmtrgjte</code></pre>
752                </li>
753            </ul>
754        </div>
755        <div class="sidebar-section">
756            <h3>Feeds</h3>
757            <div class="sidebar-row">
758                <a href="/index.xml" title="RSS feed for blog posts">
759                    <img src="/images/rss.png" class="sidebar-row-icon">
760                </a>
761                
762                <a href="https://t.me/decovar" title="Telegram channel">
763                    <img src="/images/telegram.png" class="sidebar-row-icon">
764                </a>
765                <a href="/etc/comments/" title="Latest comments">
766                    <img src="/images/comments.png" class="sidebar-row-icon"
767                        aria-description="Original image: https://flaticon.com/free-icon/chat_4021713">
768                </a>
769            </div>
770        </div>
771        
772        
773        
774        <div class="sidebar-section">
775            <h3>Tags</h3>
776            <ul class="tags-list tags-list-horizontal">
777                
778                <li><a href="/tags/web">#web&nbsp;(70)</a></li>
779                
780                <li><a href="/tags/fail">#fail&nbsp;(40)</a></li>
781                
782                <li><a href="/tags/dotnet">#dotnet&nbsp;(38)</a></li>
783                
784                <li><a href="/tags/qt">#qt&nbsp;(35)</a></li>
785                
786                <li><a href="/tags/linux">#linux&nbsp;(28)</a></li>
787                
788                <li><a href="/tags/review">#review&nbsp;(28)</a></li>
789                
790                <li><a href="/tags/devops">#devops&nbsp;(23)</a></li>
791                
792                <li><a href="/tags/macos">#macos&nbsp;(23)</a></li>
793                
794                <li><a href="/tags/windows">#windows&nbsp;(19)</a></li>
795                
796                <li><a href="/tags/mestuff">#mestuff&nbsp;(17)</a></li>
797                
798                <li><a href="/tags/movies">#movies&nbsp;(15)</a></li>
799                
800                <li><a href="/tags/norway">
800#norway&nbsp;(15)</a></li>
801                
802                <li><a href="/tags/tractor">#tractor&nbsp;(15)</a></li>
803                
804                <li><a href="/tags/cpp">#cpp&nbsp;(13)</a></li>
805                
806                <li><a href="/tags/cmake">#cmake&nbsp;(12)</a></li>
807                
808                <li><a href="/tags/irl">#irl&nbsp;(12)</a></li>
809                
810                <li><a href="/tags/python">#python&nbsp;(12)</a></li>
811                
812                <li><a href="/tags/telegram">#telegram&nbsp;(12)</a></li>
813                
814                <li><a href="/tags/embedded">#embedded&nbsp;(11)</a></li>
815                
816                <li><a href="/tags/soft">#soft&nbsp;(10)</a></li>
817                
818                <li><a href="/tags/travel">#travel&nbsp;(10)</a></li>
819                
820                <li><a href="/tags/applescript">#applescript&nbsp;(9)</a></li>
821                
822                <li><a href="/tags/piracy">#piracy&nbsp;(9)</a></li>
823                
824                <li><a href="/tags/ios">#ios&nbsp;(8)</a></li>
825                
826                <li><a href="/tags/octopress">#octopress&nbsp;(8)</a></li>
827                
828                <li><a href="/tags/apple">#apple&nbsp;(7)</a></li>
829                
830                <li><a href="/tags/banks">#banks&nbsp;(7)</a></li>
831                
832                <li><a href="/tags/games">#games&nbsp;(7)</a></li>
833                
834                <li><a href="/tags/javascript">#javascript&nbsp;(6)</a></li>
835                
836                <li><a href="/tags/photo">#photo&nbsp;(6)</a></li>
837                
838                <li><a href="/tags/sql">#sql&nbsp;(6)</a></li>
839                
840                <li><a href="/tags/russia">#russia&nbsp;(5)</a></li>
841                
842                <li><a href="/tags/android">#android&nbsp;(4)</a></li>
843                
844                <li><a href="/tags/hugo">#hugo&nbsp;(4)</a></li>
845                
846                <li><a href="/tags/usa">#usa&nbsp;(4)</a></li>
847                
848                <li><a href="/tags/wordpress">#wordpress&nbsp;(4)</a></li>
849                
850                <li><a href="/tags/azure">#azure&nbsp;(3)</a></li>
851                
852                <li><a href="/tags/tv">#tv&nbsp;(3)</a></li>
853                
854                <li><a href="/tags/home-automation">#home-automation&nbsp;(2)</a></li>
855                
856                <li><a href="/tags/privacy">#privacy&nbsp;(2)</a></li>
857                
858                <li><a href="/tags/steam-deck">#steam-deck&nbsp;(2)</a></li>
859                
860                <li><a href="/tags/tvos">#tvos&nbsp;(2)</a></li>
861                
862                <li><a href="/tags/unsolved">#unsolved&nbsp;(2)</a></li>
863                
864                <li><a href="/tags/books">#books&nbsp;(1)</a></li>
865                
866                <li><a href="/tags/ffmpeg">#ffmpeg&nbsp;(1)</a></li>
867                
868                <li><a href="/tags/ldap">#ldap&nbsp;(1)</a></li>
869                
870                <li><a href="/tags/music">#music&nbsp;(1)</a></li>
871                
872                <li><a href="/tags/science">#science&nbsp;(1)</a></li>
873                
874                <li><a href="/tags/vr">#vr&nbsp;(1)</a></li>
875                
876            </ul>
877        </div>
878    </div>
879</div>
880</main>
881
882        
882<script>
883            
884            const navigationMobile = document.getElementById("navigation-mobile");
885            const hamburgerOpenClassName = "is-active";
886            
887
888            window.onload = () =>
889            {
890                
891                
892                
893                
894                
895                
896                
897                
898                
899                
900                
901            }
902
903            function toggleHamburger(hamburger)
904            {
905                if (hamburger.classList.contains(hamburgerOpenClassName))
906                {
907
908                    hamburger.classList.remove(hamburgerOpenClassName);
909                    
910                    navigationMobile.style.display = "none";
911                }
912                else
913                {
914                    hamburger.classList.add(hamburgerOpenClassName);
915                    
916                    navigationMobile.style.display = "flex";
917                }
918            }
919        </script>
919
920
921        
921<script src="/js/mark.js"></script>
921
922        
922<script src="/js/search.js"></script>
922
923    
924    
924<script>
925        var remark_config = {
926            host: "https:\/\/comments.decovar.dev",
927            site_id: "decovar.dev",
928            components: ["embed"],
929            url: "https:\/\/decovar.dev\/blog\/2025\/04\/03\/distributing-ios-applications-without-app-store\/",
930            max_shown_comments: 25,
931            theme: "light",
932            page_title: "Distributing iOS applications within a company from a website without App Store",
933            locale: "en",
934            simple_view: false
935        };
936    </script>
936
937
938<script>!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),c=".js",d=n.head||n.body;"noModule"in r?(r.type="module",c=".mjs"):r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+c,d.appendChild(r)}}(remark_config.components||["embed"],document);</script>
938
939
940
941    
942    </body>
943</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.