├── GitVersion.yml ├── AssemblyInfo.cs ├── .github ├── CODEOWNERS └── workflows │ └── ci.yml ├── .idea └── .idea.IntuneAppBuilder │ └── .idea │ └── .gitignore ├── .gitattributes ├── Source ├── IntuneAppBuilder │ ├── Services │ │ ├── IIntuneAppPublishingService.cs │ │ ├── IIntuneAppPackagingService.cs │ │ ├── IntuneAppPublishingService.cs │ │ └── IntuneAppPackagingService.cs │ ├── Builders │ │ ├── MobileAppContentRequestBuilder.cs │ │ ├── IIntuneAppPackageBuilder.cs │ │ ├── PathIntuneAppPackageBuilder.cs │ │ ├── MobileAppItemRequestBuilderExtensions.cs │ │ ├── MobileAppContentFileRenewUploadRequestBuilder.cs │ │ ├── MobileAppContentResponse.cs │ │ ├── MobileAppContentFileRequestBuilder.cs │ │ ├── MobileAppContentFileCommitRequestBuilder.cs │ │ ├── MobileAppContentFilesRequestBuilder.cs │ │ └── ContentVersionsRequestBuilder.cs │ ├── IntuneAppBuilder.csproj │ ├── Domain │ │ ├── MobileAppContentFileCommitRequest.cs │ │ ├── IntuneAppPackage.cs │ │ └── MobileMsiManifest.cs │ ├── ServiceCollectionExtensions.cs │ └── Util │ │ ├── ComObject.cs │ │ └── MsiUtil.cs └── Console │ ├── Console.csproj │ └── Program.cs ├── Directory.Build.targets ├── Tests └── IntegrationTests │ ├── Util │ ├── FileUtil.cs │ ├── HttpUtil.cs │ └── EnvironmentVariableUsernamePasswordProvider.cs │ ├── IntegrationTests.csproj │ └── ProgramTests.cs ├── Directory.Build.props ├── README.md ├── IntuneAppBuilder.sln ├── default.ruleset ├── .gitignore ├── LICENSE └── IntuneAppBuilder.sln.DotSettings /GitVersion.yml: -------------------------------------------------------------------------------- 1 | mode: Mainline 2 | next-version: 4.0.0 -------------------------------------------------------------------------------- /AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("IntuneAppBuilder.IntegrationTests")] -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in the repo. 5 | * @simeoncloud/code-reviewers 6 | -------------------------------------------------------------------------------- /.idea/.idea.IntuneAppBuilder/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /contentModel.xml 6 | /.idea.IntuneAppBuilder.iml 7 | /projectSettingsUpdater.xml 8 | /modules.xml 9 | # Datasource local storage ignored files 10 | /dataSources/ 11 | /dataSources.local.xml 12 | # Editor-based HTTP Client requests 13 | /httpRequests/ 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # https://stackoverflow.com/questions/170961/whats-the-best-crlf-carriage-return-line-feed-handling-strategy-with-git 2 | # Auto detect text files and perform LF normalization 3 | * text=auto 4 | 5 | ## GRAPHICS 6 | *.ai binary 7 | *.bmp binary 8 | *.eps binary 9 | *.gif binary 10 | *.ico binary 11 | *.jng binary 12 | *.jp2 binary 13 | *.jpg binary 14 | *.jpeg binary 15 | *.jpx binary 16 | *.jxr binary 17 | *.pdf binary 18 | *.png binary 19 | *.psb binary 20 | *.psd binary 21 | *.svg text 22 | *.svgz binary 23 | *.tif binary 24 | *.tiff binary 25 | *.wbmp binary 26 | *.webp binary 27 | *.nupkg binary 28 | -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Services/IIntuneAppPublishingService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using IntuneAppBuilder.Domain; 3 | 4 | namespace IntuneAppBuilder.Services 5 | { 6 | /// 7 | /// Functionality for interacting with Intune APIs. 8 | /// 9 | public interface IIntuneAppPublishingService 10 | { 11 | /// 12 | /// Uploads a file for a mobileApp and sets it as the current contentVersion. 13 | /// 14 | /// A package created using the packaging service. 15 | /// 16 | Task PublishAsync(IntuneAppPackage package); 17 | } 18 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/MobileAppContentRequestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.Kiota.Abstractions; 3 | 4 | namespace IntuneAppBuilder.Builders 5 | { 6 | public sealed class MobileAppContentRequestBuilder : BaseRequestBuilder 7 | { 8 | public MobileAppContentRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/deviceAppManagement/mobileApps/{mobileApp%2Did}/{mobileApp%2Dtype}/contentVersions/{mobileAppContent%2Did}", pathParameters) 9 | { 10 | } 11 | 12 | public MobileAppContentFilesRequestBuilder Files => new(PathParameters, RequestAdapter); 13 | } 14 | } -------------------------------------------------------------------------------- /Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | $(MSBuildThisFileDirectory)/default.ruleset 5 | true 6 | 7 | 8 | 9 | 10 | all 11 | analyzers 12 | 13 | 14 | All 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Util/FileUtil.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | 3 | namespace IntuneAppBuilder.IntegrationTests.Util 4 | { 5 | internal static class FileUtil 6 | { 7 | /// 8 | /// Creates directory if it doesn't exist, empties directory if it does. 9 | /// 10 | /// 11 | public static void CreateEmptyDirectory(this DirectoryInfo directory) 12 | { 13 | if (!directory.Exists) directory.Create(); 14 | else 15 | foreach (var fsi in directory.EnumerateFileSystemInfos()) 16 | if (fsi is DirectoryInfo di) di.Delete(true); 17 | else fsi.Delete(); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Services/IIntuneAppPackagingService.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using IntuneAppBuilder.Domain; 4 | 5 | namespace IntuneAppBuilder.Services 6 | { 7 | /// 8 | /// Functionality for packaging Intune apps. 9 | /// 10 | public interface IIntuneAppPackagingService 11 | { 12 | /// 13 | /// Creates an intunewin package from a file or directory for use with a mobileApp. 14 | /// 15 | Task BuildPackageAsync(string sourcePath = ".", string setupFilePath = null); 16 | 17 | /// 18 | /// Packages an intunewin file for direct uploading through the portal. Essentially just zips the existing intunewin 19 | /// file with a specific folder structure. 20 | /// 21 | Task BuildPackageForPortalAsync(IntuneAppPackage package, Stream outputStream); 22 | } 23 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/IIntuneAppPackageBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using IntuneAppBuilder.Domain; 3 | using Microsoft.Graph.Beta.Models; 4 | 5 | namespace IntuneAppBuilder.Builders 6 | { 7 | /// 8 | /// Implementers support building a mobileApp package file. 9 | /// 10 | public interface IIntuneAppPackageBuilder 11 | { 12 | /// 13 | /// The name of the app that this instance builds. This is matched against the displayName of a mobileApp when 14 | /// publishing. If specified as a guid, is treated directly as a mobileApp id. 15 | /// 16 | string Name => GetType().Name.Replace("_", " "); 17 | 18 | /// 19 | /// Builds an app package. The call to BuildAsync is invoked with Environment.CurrentDirectory set to a dedicated, 20 | /// transient temp directory created by the caller. 21 | /// 22 | Task BuildAsync(MobileLobApp app); 23 | } 24 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/PathIntuneAppPackageBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using IntuneAppBuilder.Domain; 4 | using IntuneAppBuilder.Services; 5 | using Microsoft.Graph.Beta.Models; 6 | 7 | namespace IntuneAppBuilder.Builders 8 | { 9 | /// 10 | /// Builds an app package from all files in a specified directory or from a single file. 11 | /// 12 | public class PathIntuneAppPackageBuilder : IIntuneAppPackageBuilder 13 | { 14 | private readonly IIntuneAppPackagingService packagingService; 15 | 16 | private readonly string path; 17 | 18 | public PathIntuneAppPackageBuilder(string path, IIntuneAppPackagingService packagingService) 19 | { 20 | Name = Path.GetFullPath(path); 21 | this.path = path; 22 | this.packagingService = packagingService; 23 | } 24 | 25 | public string Name { get; } 26 | 27 | public Task BuildAsync(MobileLobApp app) => packagingService.BuildPackageAsync(path); 28 | } 29 | } -------------------------------------------------------------------------------- /Source/Console/Console.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | true 7 | $(SolutionName) 8 | Simeon 9 | 10 | IntuneAppBuilder creates and deploys Intune packages for MSI and Win32 applications. This dotnet tool converts installation files into the .intunewin format that can then be published using the tool or uploaded manually into the Intune Portal. 11 | 12 | $(NoWarn);NU5104 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/IntuneAppBuilder.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | $(SolutionName) 6 | $(AssemblyName) 7 | $(NoWarn);NU5104 8 | Simeon 9 | 10 | IntuneAppBuilder creates and deploys Intune packages for MSI and Win32 applications. This package exposes services that can be consumed by other dotnet applications as a package reference. 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | $([System.IO.Directory]::GetFiles('$(MSBuildThisFileDirectory)', '*.sln')) 4 | $([System.IO.Path]::GetFileNameWithoutExtension($(SlnFiles))) 5 | $(SolutionName).$(MSBuildProjectName) 6 | $(AssemblyName) 7 | 8 | 9 | 10 | 11 | $(NoWarn);AD0001 12 | 13 | 14 | 15 | https://github.com/simeoncloud/IntuneAppBuilder 16 | true 17 | true 18 | snupkg 19 | true 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Tests/IntegrationTests/Util/HttpUtil.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Net.Http; 3 | using System.Threading.Tasks; 4 | 5 | namespace IntuneAppBuilder.IntegrationTests.Util 6 | { 7 | public static class HttpUtil 8 | { 9 | /// 10 | /// Downloads a file using an HttpClient. 11 | /// 12 | public static async Task DownloadFileAsync(this HttpClient client, string uri, string filePath = null) => await client.DownloadFileAsync(new HttpRequestMessage(HttpMethod.Get, uri), filePath); 13 | 14 | /// 15 | /// Downloads a file using an HttpClient. 16 | /// 17 | private static async Task DownloadFileAsync(this HttpClient client, HttpRequestMessage request, string filePath = null) 18 | { 19 | await using (Stream contentStream = await (await client.SendAsync(request)).EnsureSuccessStatusCode().Content.ReadAsStreamAsync(), 20 | fileStream = new FileStream(filePath ?? Path.GetFileName(request.RequestUri.LocalPath), FileMode.Create, FileAccess.Write, FileShare.None, 1024, true)) 21 | { 22 | await contentStream.CopyToAsync(fileStream); 23 | } 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/MobileAppItemRequestBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Reflection; 3 | using Microsoft.Graph.Beta.DeviceAppManagement.MobileApps.Item; 4 | using Microsoft.Kiota.Abstractions; 5 | 6 | namespace IntuneAppBuilder.Builders 7 | { 8 | public static class MobileAppItemRequestBuilderExtensions 9 | { 10 | public static ContentVersionsRequestBuilder ContentVersions(this MobileAppItemRequestBuilder mobileAppItemRequestBuilder, string mobileAppType) 11 | { 12 | #pragma warning disable S3011 13 | var urlTplParams = new Dictionary((Dictionary)mobileAppItemRequestBuilder.GetType().GetProperty("PathParameters", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(mobileAppItemRequestBuilder)!); 14 | if (!string.IsNullOrWhiteSpace(mobileAppType)) urlTplParams.Add("mobileApp%2Dtype", mobileAppType); 15 | return new ContentVersionsRequestBuilder(urlTplParams, 16 | (IRequestAdapter)mobileAppItemRequestBuilder.GetType().GetProperty("RequestAdapter", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(mobileAppItemRequestBuilder)); 17 | #pragma warning restore S3011 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Domain/MobileAppContentFileCommitRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.Json.Serialization; 4 | using Microsoft.Graph.Beta.Models; 5 | using Microsoft.Kiota.Abstractions.Serialization; 6 | 7 | namespace IntuneAppBuilder.Domain 8 | { 9 | public sealed class MobileAppContentFileCommitRequest : IParsable 10 | { 11 | [JsonPropertyName("fileEncryptionInfo")] 12 | public FileEncryptionInfo FileEncryptionInfo { get; set; } 13 | 14 | public IDictionary> GetFieldDeserializers() => 15 | new Dictionary> 16 | { 17 | { "fileEncryptionInfo", n => { FileEncryptionInfo = n.GetObjectValue(FileEncryptionInfo.CreateFromDiscriminatorValue); } } 18 | }; 19 | 20 | public void Serialize(ISerializationWriter writer) 21 | { 22 | _ = writer ?? throw new ArgumentNullException(nameof(writer)); 23 | writer.WriteObjectValue("fileEncryptionInfo", FileEncryptionInfo); 24 | } 25 | 26 | public static MobileAppContentFileCommitRequest CreateFromDiscriminatorValue(IParseNode parseNode) 27 | { 28 | _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); 29 | return new MobileAppContentFileCommitRequest(); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/MobileAppContentFileRenewUploadRequestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Microsoft.Graph.Beta.Models.ODataErrors; 4 | using Microsoft.Kiota.Abstractions; 5 | using Microsoft.Kiota.Abstractions.Serialization; 6 | 7 | namespace IntuneAppBuilder.Builders 8 | { 9 | public sealed class MobileAppContentFileRenewUploadRequestBuilder : BaseRequestBuilder 10 | { 11 | public MobileAppContentFileRenewUploadRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/deviceAppManagement/mobileApps/{mobileApp%2Did}/{mobileApp%2Dtype}/contentVersions/{mobileAppContent%2Did}/files/{mobileAppContentFile%2Did}/microsoft.graph.renewUpload", pathParameters) 12 | { 13 | } 14 | 15 | public async Task PostAsync() 16 | { 17 | var requestInfo = new RequestInformation 18 | { 19 | HttpMethod = Method.POST, 20 | UrlTemplate = UrlTemplate, 21 | PathParameters = PathParameters 22 | }; 23 | var errorMapping = new Dictionary> 24 | { 25 | { "4XX", ODataError.CreateFromDiscriminatorValue }, 26 | { "5XX", ODataError.CreateFromDiscriminatorValue } 27 | }; 28 | await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/MobileAppContentResponse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Microsoft.Graph.Beta.Models; 5 | using Microsoft.Kiota.Abstractions.Serialization; 6 | 7 | namespace IntuneAppBuilder.Builders 8 | { 9 | public sealed class MobileAppContentCollectionResponse : BaseCollectionPaginationCountResponse, IParsable 10 | { 11 | #nullable enable 12 | public IEnumerable? Value 13 | # nullable disable 14 | { 15 | get => BackingStore?.Get>("value"); 16 | private set => BackingStore?.Set(nameof(value), value); 17 | } 18 | 19 | public new IDictionary> GetFieldDeserializers() => 20 | new Dictionary>(base.GetFieldDeserializers()) 21 | { 22 | { 23 | "value", 24 | n => 25 | { 26 | var collectionOfObjectValues = n.GetCollectionOfObjectValues(MobileAppContent.CreateFromDiscriminatorValue); 27 | Value = collectionOfObjectValues.ToList(); 28 | } 29 | } 30 | }; 31 | 32 | public new void Serialize(ISerializationWriter writer) 33 | { 34 | if (writer == null) 35 | throw new ArgumentNullException(nameof(writer)); 36 | base.Serialize(writer); 37 | writer.WriteCollectionOfObjectValues("value", Value); 38 | } 39 | 40 | public static 41 | new MobileAppContentCollectionResponse CreateFromDiscriminatorValue(IParseNode parseNode) 42 | { 43 | if (parseNode == null) 44 | throw new ArgumentNullException(nameof(parseNode)); 45 | return new MobileAppContentCollectionResponse(); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/Util/EnvironmentVariableUsernamePasswordProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Azure.Core; 7 | using Azure.Identity; 8 | using Microsoft.Graph.Authentication; 9 | using Microsoft.Kiota.Abstractions; 10 | using Microsoft.Kiota.Abstractions.Authentication; 11 | 12 | namespace IntuneAppBuilder.IntegrationTests.Util 13 | { 14 | /// 15 | /// Auth provider for Graph client that uses environment variables and resource owner flow. 16 | /// 17 | public sealed class EnvironmentVariableUsernamePasswordProvider : IAuthenticationProvider 18 | { 19 | private readonly Lazy tokenCredential = new(() => 20 | { 21 | new[] { "Username", "Password" }.Select(var => $"AadAuth:{var}").Select(Environment.GetEnvironmentVariable).Where(string.IsNullOrEmpty).ToList() 22 | .ForEach(missingVar => throw new InvalidOperationException($"Environment variable {missingVar} is not specified.")); 23 | 24 | var username = Environment.GetEnvironmentVariable("AadAuth:Username"); 25 | return new UsernamePasswordCredential( 26 | username, 27 | Environment.GetEnvironmentVariable("AadAuth:Password"), 28 | username?.Split('@').Last(), 29 | "14d82eec-204b-4c2f-b7e8-296a70dab67e"); // Microsoft Graph PowerShell well known client id 30 | }); 31 | 32 | private IAuthenticationProvider InnerProvider => new AzureIdentityAuthenticationProvider(tokenCredential.Value, scopes: "DeviceManagementApps.ReadWrite.All"); 33 | 34 | public async Task AuthenticateRequestAsync(RequestInformation request, Dictionary additionalAuthenticationContext = default, CancellationToken cancellationToken = default) => await InnerProvider.AuthenticateRequestAsync(request, additionalAuthenticationContext, cancellationToken); 35 | } 36 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/MobileAppContentFileRequestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Microsoft.Graph.Beta.Models; 4 | using Microsoft.Graph.Beta.Models.ODataErrors; 5 | using Microsoft.Kiota.Abstractions; 6 | using Microsoft.Kiota.Abstractions.Serialization; 7 | 8 | namespace IntuneAppBuilder.Builders 9 | { 10 | public sealed class MobileAppContentFileRequestBuilder : BaseRequestBuilder 11 | { 12 | public MobileAppContentFileRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/deviceAppManagement/mobileApps/{mobileApp%2Did}/{mobileApp%2Dtype}/contentVersions/{mobileAppContent%2Did}/files/{mobileAppContentFile%2Did}", pathParameters) 13 | { 14 | } 15 | 16 | public MobileAppContentFileCommitRequestBuilder Commit => new(PathParameters, RequestAdapter); 17 | 18 | public MobileAppContentFileRenewUploadRequestBuilder RenewUpload => new(PathParameters, RequestAdapter); 19 | 20 | #nullable enable 21 | public async Task GetAsync() 22 | { 23 | #nullable restore 24 | var requestInfo = ToGetRequestInformation(); 25 | var errorMapping = new Dictionary> 26 | { 27 | { "4XX", ODataError.CreateFromDiscriminatorValue }, 28 | { "5XX", ODataError.CreateFromDiscriminatorValue } 29 | }; 30 | return await RequestAdapter.SendAsync(requestInfo, MobileAppContentFile.CreateFromDiscriminatorValue, errorMapping); 31 | } 32 | 33 | public RequestInformation ToGetRequestInformation() 34 | { 35 | var requestInfo = new RequestInformation 36 | { 37 | HttpMethod = Method.GET, 38 | UrlTemplate = UrlTemplate, 39 | PathParameters = PathParameters 40 | }; 41 | requestInfo.Headers.Add("Accept", "application/json"); 42 | 43 | return requestInfo; 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/MobileAppContentFileCommitRequestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using IntuneAppBuilder.Domain; 5 | using Microsoft.Graph.Beta.Models.ODataErrors; 6 | using Microsoft.Kiota.Abstractions; 7 | using Microsoft.Kiota.Abstractions.Serialization; 8 | 9 | namespace IntuneAppBuilder.Builders 10 | { 11 | public sealed class MobileAppContentFileCommitRequestBuilder : BaseRequestBuilder 12 | { 13 | public MobileAppContentFileCommitRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/deviceAppManagement/mobileApps/{mobileApp%2Did}/{mobileApp%2Dtype}/contentVersions/{mobileAppContent%2Did}/files/{mobileAppContentFile%2Did}/microsoft.graph.commit", pathParameters) 14 | { 15 | } 16 | 17 | public async Task PostAsync(MobileAppContentFileCommitRequest body = null) 18 | { 19 | var requestInfo = ToPostRequestInformation(body); 20 | var errorMapping = new Dictionary> 21 | { 22 | { "4XX", ODataError.CreateFromDiscriminatorValue }, 23 | { "5XX", ODataError.CreateFromDiscriminatorValue } 24 | }; 25 | await RequestAdapter.SendAsync(requestInfo, MobileAppContentFileCommitRequest.CreateFromDiscriminatorValue, errorMapping); 26 | } 27 | 28 | private RequestInformation ToPostRequestInformation(MobileAppContentFileCommitRequest body) 29 | { 30 | _ = body ?? throw new ArgumentNullException(nameof(body)); 31 | var requestInfo = new RequestInformation 32 | { 33 | HttpMethod = Method.POST, 34 | UrlTemplate = UrlTemplate, 35 | PathParameters = PathParameters 36 | }; 37 | requestInfo.Headers.Add("Accept", "application/json"); 38 | requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body); 39 | 40 | return requestInfo; 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Domain/IntuneAppPackage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Text.Json.Serialization; 5 | using Microsoft.Graph.Beta.Models; 6 | using Microsoft.Kiota.Abstractions.Serialization; 7 | 8 | namespace IntuneAppBuilder.Domain 9 | { 10 | /// 11 | /// Metadata about a package produced for an Intune mobile app. 12 | /// 13 | #pragma warning disable S3881 // "IDisposable" should be implemented correctly 14 | public sealed class IntuneAppPackage : IDisposable, IParsable 15 | #pragma warning restore S3881 // "IDisposable" should be implemented correctly 16 | { 17 | [JsonPropertyName("app")] 18 | public MobileLobApp App { get; set; } 19 | 20 | /// 21 | /// Data stream containing the intunewin package contents. Stream must support seek operations. 22 | /// 23 | [JsonIgnore] 24 | public Stream Data { get; set; } 25 | 26 | [JsonPropertyName("encryptionInfo")] 27 | public FileEncryptionInfo EncryptionInfo { get; set; } 28 | 29 | [JsonPropertyName("file")] 30 | public MobileAppContentFile File { get; set; } 31 | 32 | public void Dispose() => Data?.Dispose(); 33 | 34 | public IDictionary> GetFieldDeserializers() => 35 | new Dictionary> 36 | { 37 | { "app", n => { App = n.GetObjectValue(MobileLobApp.CreateFromDiscriminatorValue); } }, 38 | { "encryptionInfo", n => { EncryptionInfo = n.GetObjectValue(FileEncryptionInfo.CreateFromDiscriminatorValue); } }, 39 | { "file", n => { File = n.GetObjectValue(MobileAppContentFile.CreateFromDiscriminatorValue); } } 40 | }; 41 | 42 | public void Serialize(ISerializationWriter writer) 43 | { 44 | _ = writer ?? throw new ArgumentNullException(nameof(writer)); 45 | writer.WriteObjectValue("app", App); 46 | writer.WriteObjectValue("encryptionInfo", EncryptionInfo); 47 | writer.WriteObjectValue("file", File); 48 | } 49 | 50 | public static IntuneAppPackage CreateFromDiscriminatorValue(IParseNode parseNode) 51 | { 52 | _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); 53 | return new IntuneAppPackage(); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Domain/MobileMsiManifest.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Text; 3 | using System.Xml; 4 | using System.Xml.Serialization; 5 | 6 | namespace IntuneAppBuilder.Domain 7 | { 8 | /// 9 | /// A statically typed manifest for an MSI app. Gets converted to a byte array and included on MobileAppContentFile. 10 | /// 11 | [XmlRoot("MobileMsiData")] 12 | public class MobileMsiManifest 13 | { 14 | [XmlAttribute] 15 | public bool MsiContainsSystemFolders { get; set; } 16 | 17 | [XmlAttribute] 18 | public bool MsiContainsSystemRegistryKeys { get; set; } 19 | 20 | [XmlAttribute] 21 | public string MsiExecutionContext { get; set; } 22 | 23 | [XmlAttribute] 24 | public bool MsiIncludesServices { get; set; } 25 | 26 | [XmlAttribute] 27 | public bool MsiIsMachineInstall { get; set; } 28 | 29 | [XmlAttribute] 30 | public bool MsiIsUserInstall { get; set; } 31 | 32 | [XmlAttribute] 33 | public bool MsiRequiresReboot { get; set; } 34 | 35 | [XmlAttribute] 36 | public string MsiUpgradeCode { get; set; } 37 | 38 | public byte[] ToByteArray() 39 | { 40 | var serializer = new XmlSerializer(typeof(MobileMsiManifest)); 41 | 42 | using var ms = new MemoryStream(); 43 | using var writer = new XmlWriter(ms); 44 | serializer.Serialize(writer, this, new XmlSerializerNamespaces(new[] 45 | { 46 | new XmlQualifiedName(string.Empty, string.Empty) 47 | })); 48 | return ms.ToArray(); 49 | } 50 | 51 | public static MobileMsiManifest FromByteArray(byte[] data) 52 | { 53 | var serializer = new XmlSerializer(typeof(MobileMsiManifest)); 54 | 55 | if (data == null) return default; 56 | using var ms = new MemoryStream(data); 57 | return (MobileMsiManifest)serializer.Deserialize(ms); 58 | } 59 | 60 | private sealed class XmlWriter : XmlTextWriter 61 | { 62 | public XmlWriter(Stream stream) : base(stream, Encoding.ASCII) 63 | { 64 | } 65 | 66 | public override void WriteEndElement() => 67 | // do not auto-close xml tags 68 | WriteFullEndElement(); 69 | 70 | public override void WriteStartDocument() 71 | { 72 | // do not write xml declaration 73 | } 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IdentityModel.Tokens.Jwt; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using Azure.Core; 6 | using Azure.Identity; 7 | using IntuneAppBuilder.Services; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.DependencyInjection.Extensions; 10 | using Microsoft.Graph.Beta; 11 | 12 | namespace IntuneAppBuilder 13 | { 14 | public static class ServiceCollectionExtensions 15 | { 16 | /// 17 | /// Registers required services. 18 | /// 19 | /// 20 | /// 21 | public static IServiceCollection AddIntuneAppBuilder(this IServiceCollection services, string token = null) 22 | { 23 | services.AddLogging(); 24 | services.AddHttpClient(); 25 | services.TryAddSingleton(sp => sp.GetRequiredService().CreateClient()); 26 | services.TryAddTransient(); 27 | services.TryAddTransient(); 28 | services.TryAddSingleton(sp => new GraphServiceClient(CreateTokenCredential(token), new[] { "DeviceManagementApps.ReadWrite.All" })); 29 | return services; 30 | } 31 | 32 | /// 33 | /// For more granular control, register GraphServiceClient yourself. 34 | /// 35 | /// 36 | private static TokenCredential CreateTokenCredential(string token = null) 37 | { 38 | if (token != null) 39 | { 40 | var handler = new JwtSecurityTokenHandler(); 41 | var jwtSecurityToken = handler.ReadJwtToken(token); 42 | var tokenExpirationTicks = long.Parse(jwtSecurityToken.Claims.First(claim => claim.Type.Equals("exp")).Value); 43 | return DelegatedTokenCredential.Create((_, _) => new AccessToken(token, DateTimeOffset.FromUnixTimeSeconds(tokenExpirationTicks).UtcDateTime)); 44 | } 45 | 46 | // Microsoft Graph PowerShell well known client id 47 | const string microsoftGraphPowerShellClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; 48 | 49 | return new DeviceCodeCredential(new DeviceCodeCredentialOptions 50 | { 51 | ClientId = microsoftGraphPowerShellClientId, 52 | DeviceCodeCallback = async (dcr, _) => await Console.Out.WriteLineAsync(dcr.Message) 53 | }); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | te# IntuneAppBuilder 2 | 3 | Package MSI and Win32 application packages as .intunewin format to Microsoft Intune with this cross-platform tool. 4 | 5 | ## Overview 6 | 7 | Use the Simeon IntuneAppBuilder tool to create and deploy Microsoft Intune packages for MSI and Win32 applications. The 8 | tool converts installation files into the .intunewin format that can then be published using the tool or uploaded 9 | manually into the Intune Portal. 10 | 11 | IntuneAppBuilder is an open source component from the Simeon Microsoft 365 Management toolset. Learn more about Simeon’s 12 | full functionality at https://simeoncloud.com. 13 | 14 | [![Build Status](https://github.com/simeoncloud/IntuneAppBuilder/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/simeoncloud/IntuneAppBuilder/actions/workflows/ci.yml?query=branch%3Amaster) 15 | 16 | ## Getting Started 17 | 18 | 1. **[Get .NET Core 3.1 SDK](https://dotnet.microsoft.com/download)** (or higher) 19 | 20 | 2. **Install** from an elevated command prompt 21 | 22 | ``` 23 | dotnet tool install -g IntuneAppBuilder.Console 24 | IntuneAppBuilder [args] 25 | ``` 26 | 27 | 3. **Run** from a command prompt to print usage instructions 28 | 29 | ``` 30 | IntuneAppBuilder 31 | 32 | Usage: 33 | IntuneAppBuilder [options] [command] 34 | 35 | Options: 36 | --version Show version information 37 | -?, -h, --help Show help and usage information 38 | 39 | Commands: 40 | pack 41 | publish 42 | 43 | ``` 44 | 45 | The tool can ```pack``` your app or ```publish``` an app you have previously packaged. 46 | 47 | 4. **Package** an app 48 | 49 | ``` 50 | IntuneAppBuilder pack --source .\MyAppInstallFiles --output .\MyAppPackage 51 | ``` 52 | 53 | You should see 3 files in the output folder: 54 | 55 | - MyAppInstallFiles.intunewin.json - this file contains metadata about the packaged app 56 | - MyAppInstallFiles.intunewin - this file can be used directly to publish the app using 57 | the ```IntuneAppBuilder publish``` command 58 | - MyAppInstallFiles.portal.intunewin - this file can be uploaded to the Intune Portal as a Win32 app 59 | 60 | 5. **Publish** an app 61 | 62 | ``` 63 | IntuneAppBuilder publish --source .\MyAppPackage\MyAppInstallFiles.intunewin.json 64 | ``` 65 | 66 | IntuneAppBuilder will publish the app content. You will be prompted to sign in to your tenant when publishing. 67 | 68 | 6. **Configure** 69 | 70 | After publishing, you can find the app in your Intune portal and make any required changes (assigning, updating the 71 | command line, detection rules, etc.). 72 | 73 | ## Authentication 74 | 75 | IntuneAppBuilder uses 76 | the [device code flow](https://learn.microsoft.com/en-us/azure/active-directory/develop/scenario-desktop-acquire-token-device-code-flow) 77 | to authenticate by default. Optionally, an access token may be provided as a parameter to the publish command instead: 78 | 79 | ``` 80 | IntuneAppBuilder publish --source .\MyAppPackage\MyAppInstallFiles.intunewin.json --token 81 | ``` 82 | 83 | ## Notes 84 | 85 | The Windows Installer COM service is used to retrieve information about MSIs if one is included in your application. 86 | When the tool is running on a non-Windows system, the tool will log a warning and continue creating the package without 87 | the additional MSI metadata. 88 | 89 | ## Dependencies 90 | 91 | - **Package Name**: [xunit](https://github.com/xunit/xunit) and [xunit.runner.visualstudio](https://github.com/xunit/visualstudio.xunit) 92 | - **Version**: 2.4.0 93 | - **Author**: xunit 94 | - **License**: [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) 95 | -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/MobileAppContentFilesRequestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Microsoft.Graph.Beta.Models; 6 | using Microsoft.Graph.Beta.Models.ODataErrors; 7 | using Microsoft.Kiota.Abstractions; 8 | using Microsoft.Kiota.Abstractions.Serialization; 9 | 10 | namespace IntuneAppBuilder.Builders 11 | { 12 | public sealed class MobileAppContentFilesRequestBuilder : BaseRequestBuilder 13 | { 14 | public MobileAppContentFilesRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/deviceAppManagement/mobileApps/{mobileApp%2Did}/{mobileApp%2Dtype}/contentVersions/{mobileAppContent%2Did}/files", pathParameters) 15 | { 16 | } 17 | 18 | public MobileAppContentFileRequestBuilder this[string position] 19 | { 20 | get 21 | { 22 | var urlTplParams = new Dictionary(PathParameters); 23 | if (!string.IsNullOrWhiteSpace(position)) urlTplParams.Add("mobileAppContentFile%2Did", position); 24 | return new MobileAppContentFileRequestBuilder(urlTplParams, RequestAdapter); 25 | } 26 | } 27 | 28 | #nullable enable 29 | public async Task PostAsync(MobileAppContentFile body, Action? requestConfiguration = default, CancellationToken cancellationToken = default) 30 | { 31 | #nullable restore 32 | _ = body ?? throw new ArgumentNullException(nameof(body)); 33 | var requestInfo = ToPostRequestInformation(body, requestConfiguration); 34 | var errorMapping = new Dictionary> 35 | { 36 | { "4XX", ODataError.CreateFromDiscriminatorValue }, 37 | { "5XX", ODataError.CreateFromDiscriminatorValue } 38 | }; 39 | return await RequestAdapter.SendAsync(requestInfo, MobileAppContentFile.CreateFromDiscriminatorValue, errorMapping, cancellationToken); 40 | } 41 | 42 | #nullable enable 43 | private RequestInformation ToPostRequestInformation(MobileAppContentFile body, Action? requestConfiguration = default) 44 | { 45 | #nullable restore 46 | _ = body ?? throw new ArgumentNullException(nameof(body)); 47 | var requestInfo = new RequestInformation 48 | { 49 | HttpMethod = Method.POST, 50 | UrlTemplate = UrlTemplate, 51 | PathParameters = PathParameters 52 | }; 53 | requestInfo.Headers.Add("Accept", "application/json"); 54 | requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body); 55 | if (requestConfiguration != null) 56 | { 57 | var requestConfig = new MobileAppContentFilesRequestBuilderPostRequestConfiguration(); 58 | requestConfiguration.Invoke(requestConfig); 59 | requestInfo.AddRequestOptions(requestConfig.Options); 60 | } 61 | 62 | return requestInfo; 63 | } 64 | 65 | public sealed class MobileAppContentFilesRequestBuilderPostRequestConfiguration 66 | { 67 | public MobileAppContentFilesRequestBuilderPostRequestConfiguration() => Options = new List(); 68 | 69 | public IList Options { get; } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Util/ComObject.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Dynamic; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Runtime.ExceptionServices; 7 | using System.Runtime.InteropServices; 8 | using System.Runtime.Versioning; 9 | 10 | namespace IntuneAppBuilder.Util; 11 | 12 | /// 13 | /// dotnet core doesn't support COM using dynamic. 14 | /// 15 | internal sealed class ComObject : DynamicObject, IDisposable 16 | { 17 | private readonly object instance; 18 | 19 | private ComObject(object instance) => this.instance = instance; 20 | 21 | [SupportedOSPlatform("windows")] 22 | public void Dispose() => Marshal.FinalReleaseComObject(instance); 23 | 24 | [DebuggerNonUserCode] 25 | public override bool TryGetMember(GetMemberBinder binder, out object result) 26 | { 27 | try 28 | { 29 | result = Wrap(instance.GetType().InvokeMember( 30 | binder.Name, 31 | BindingFlags.GetProperty, 32 | Type.DefaultBinder, 33 | instance, 34 | new object[] { } 35 | )); 36 | } 37 | catch (TargetInvocationException ex) when (ex.InnerException != null) 38 | { 39 | result = null; 40 | ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); 41 | } 42 | 43 | return true; 44 | } 45 | 46 | [DebuggerNonUserCode] 47 | public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) 48 | { 49 | var name = binder.Name; 50 | var flags = BindingFlags.InvokeMethod; 51 | if (name.StartsWith("get_") || name.StartsWith("set_")) 52 | { 53 | flags = name.StartsWith("get_") ? BindingFlags.GetProperty : BindingFlags.SetProperty; 54 | name = name.Substring(4); 55 | } 56 | 57 | try 58 | { 59 | result = Wrap(instance.GetType().InvokeMember( 60 | name, 61 | flags, 62 | Type.DefaultBinder, 63 | instance, 64 | args.Select(Unwrap).ToArray())); 65 | } 66 | catch (TargetInvocationException ex) when (ex.InnerException != null) 67 | { 68 | result = null; 69 | ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); 70 | } 71 | 72 | return true; 73 | } 74 | 75 | [DebuggerNonUserCode] 76 | public override bool TrySetMember(SetMemberBinder binder, object value) 77 | { 78 | try 79 | { 80 | instance.GetType() 81 | .InvokeMember( 82 | binder.Name, 83 | BindingFlags.SetProperty, 84 | Type.DefaultBinder, 85 | instance, 86 | new[] 87 | { 88 | Unwrap(value) 89 | } 90 | ); 91 | } 92 | catch (TargetInvocationException ex) when (ex.InnerException != null) 93 | { 94 | ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); 95 | } 96 | 97 | return true; 98 | } 99 | 100 | private object Unwrap(object value) => 101 | value is ComObject comObject 102 | ? comObject.instance 103 | : value; 104 | 105 | private object Wrap(object value) => value is MarshalByRefObject ? new ComObject(value) : value; 106 | 107 | [SupportedOSPlatform("windows")] 108 | public static ComObject CreateObject(string progId) => new(Activator.CreateInstance(Type.GetTypeFromProgID(progId, true)!)); 109 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | schedule: 5 | - cron: "30 7 * * *" 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | types: [ opened, synchronize, reopened ] 11 | branches: 12 | - master 13 | 14 | env: 15 | BuildConfiguration: 'Release' 16 | Verbosity: 'normal' 17 | ArtifactName: 'Artifacts' 18 | ArtifactDirectory: './artifacts/' 19 | 20 | defaults: 21 | run: 22 | shell: pwsh 23 | 24 | jobs: 25 | Build: 26 | runs-on: windows-latest 27 | concurrency: build-job 28 | steps: 29 | - uses: actions/checkout@v3 30 | if: github.event_name == 'pull_request' 31 | with: 32 | fetch-depth: 0 33 | ref: ${{ github.event.pull_request.head.ref }} 34 | 35 | - uses: actions/checkout@v3 36 | if: github.event_name != 'pull_request' 37 | with: 38 | fetch-depth: 0 39 | 40 | - name: Set Up .NET Core 41 | uses: actions/setup-dotnet@v3 42 | with: 43 | dotnet-version: '8.0.x' 44 | 45 | - name: dotnet restore 46 | run: dotnet restore --verbosity $env:Verbosity 47 | 48 | - name: dotnet build 49 | run: $env:GITHUB_REF = '${{ github.head_ref }}'; dotnet build --configuration $env:BuildConfiguration --verbosity $env:Verbosity --no-restore 50 | 51 | - name: dotnet pack 52 | run: $env:GITHUB_REF = '${{ github.head_ref }}'; dotnet pack --configuration $env:BuildConfiguration --verbosity $env:Verbosity --no-build -o $env:ArtifactDirectory 53 | 54 | - name: dotnet test 55 | run: dotnet test --configuration $env:BuildConfiguration --no-build --verbosity $env:Verbosity 56 | env: 57 | AadAuth:Username: ${{ secrets.TEST_AADAUTH_USERNAME }} 58 | AadAuth:Password: ${{ secrets.TEST_AADAUTH_PASSWORD }} 59 | 60 | - name: Publish Pipeline Artifacts 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: ${{ env.ArtifactName }} 64 | path: ${{ env.ArtifactDirectory }} 65 | 66 | Deploy: 67 | if: github.event_name != 'schedule' 68 | needs: Build 69 | name: Deploy to nuget.org 70 | environment: nuget.org 71 | runs-on: windows-latest 72 | steps: 73 | - name: Set Up .NET Core 74 | uses: actions/setup-dotnet@v1 75 | with: 76 | dotnet-version: '8.0.x' 77 | 78 | - name: Download Artifact from Previous Job 79 | uses: actions/download-artifact@v4 80 | with: 81 | name: ${{ env.ArtifactName }} 82 | 83 | - name: Exclude Duplicate Package Versions 84 | run: | 85 | $versionInfo = gci . *.nupkg -Recurse |% { $file = $_; $_.Name | Select-String -Pattern '^(.*?)\.((?:\.?[0-9]+){3,}(?:[-a-z]+)?)\.nupkg$' |%{ $_.Matches[0] } |% { @{ Path = $file.FullName; Name = $_.Groups[1].Value; Version = $_.Groups[2].Value } } } 86 | $versionInfo |% { 87 | if (Find-Package $_.Name -RequiredVersion $_.Version -Source https://api.nuget.org/v3/index.json -EA SilentlyContinue) { 88 | $message = "$($_.Path) already exists in nuget.org - skipping" 89 | Write-Warning $message 90 | Write-Host "##vso[task.logissue type=warning]$message" 91 | Remove-Item $_.Path 92 | } 93 | else { 94 | Write-Host "$($_.Path) does not exist in nuget.org - will push" 95 | } 96 | } 97 | 98 | - name: NuGet Push 99 | run: | 100 | if (gci *.nupkg) { 101 | dotnet nuget push *.nupkg --api-key $env:NUGET_KEY --source https://api.nuget.org/v3/index.json 102 | } 103 | else { 104 | Write-Host "No packages to publish. Skipping..." 105 | } 106 | env: 107 | NUGET_KEY: ${{ secrets.NUGET_API_KEY }} 108 | -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Builders/ContentVersionsRequestBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Microsoft.Graph.Beta.Models; 5 | using Microsoft.Graph.Beta.Models.ODataErrors; 6 | using Microsoft.Kiota.Abstractions; 7 | using Microsoft.Kiota.Abstractions.Serialization; 8 | 9 | namespace IntuneAppBuilder.Builders 10 | { 11 | public sealed class ContentVersionsRequestBuilder : BaseRequestBuilder 12 | { 13 | public ContentVersionsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/deviceAppManagement/mobileApps/{mobileApp%2Did}/{mobileApp%2Dtype}/contentVersions{?%24orderby}", pathParameters) 14 | { 15 | } 16 | 17 | public MobileAppContentRequestBuilder this[string position] 18 | { 19 | get 20 | { 21 | var urlTplParams = new Dictionary(PathParameters); 22 | if (!string.IsNullOrWhiteSpace(position)) urlTplParams.Add("mobileAppContent%2Did", position); 23 | return new MobileAppContentRequestBuilder(urlTplParams, RequestAdapter); 24 | } 25 | } 26 | 27 | public async Task GetAsync(Action requestConfiguration = default) 28 | { 29 | var requestInfo = ToGetRequestInformation(requestConfiguration); 30 | var errorMapping = new Dictionary> 31 | { 32 | { "4XX", ODataError.CreateFromDiscriminatorValue }, 33 | { "5XX", ODataError.CreateFromDiscriminatorValue } 34 | }; 35 | return await RequestAdapter.SendAsync(requestInfo, MobileAppContentCollectionResponse.CreateFromDiscriminatorValue, errorMapping); 36 | } 37 | 38 | #nullable enable 39 | public async Task PostAsync(MobileAppContent body) 40 | { 41 | #nullable restore 42 | _ = body ?? throw new ArgumentNullException(nameof(body)); 43 | var requestInfo = ToPostRequestInformation(body); 44 | var errorMapping = new Dictionary> 45 | { 46 | { "4XX", ODataError.CreateFromDiscriminatorValue }, 47 | { "5XX", ODataError.CreateFromDiscriminatorValue } 48 | }; 49 | return await RequestAdapter.SendAsync(requestInfo, MobileAppContent.CreateFromDiscriminatorValue, errorMapping); 50 | } 51 | 52 | private RequestInformation ToGetRequestInformation(Action requestConfiguration = default) 53 | { 54 | var requestInfo = new RequestInformation 55 | { 56 | HttpMethod = Method.GET, 57 | UrlTemplate = UrlTemplate, 58 | PathParameters = PathParameters 59 | }; 60 | requestInfo.Headers.Add("Accept", "application/json"); 61 | if (requestConfiguration != null) 62 | { 63 | var requestConfig = new ContentVersionsRequestBuilderGetRequestConfiguration(); 64 | requestConfiguration.Invoke(requestConfig); 65 | requestInfo.AddQueryParameters(requestConfig.QueryParameters); 66 | } 67 | 68 | return requestInfo; 69 | } 70 | 71 | private RequestInformation ToPostRequestInformation(MobileAppContent body) 72 | { 73 | _ = body ?? throw new ArgumentNullException(nameof(body)); 74 | var requestInfo = new RequestInformation 75 | { 76 | HttpMethod = Method.POST, 77 | UrlTemplate = UrlTemplate, 78 | PathParameters = PathParameters 79 | }; 80 | requestInfo.Headers.Add("Accept", "application/json"); 81 | requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body); 82 | 83 | return requestInfo; 84 | } 85 | 86 | public sealed class ContentVersionsRequestBuilderGetRequestConfiguration 87 | { 88 | public ContentVersionsRequestBuilderGetQueryParameters QueryParameters { get; } = new(); 89 | } 90 | 91 | public sealed class ContentVersionsRequestBuilderGetQueryParameters 92 | { 93 | #nullable enable 94 | [QueryParameter("%24orderby")] 95 | public string[]? Orderby { get; set; } 96 | #nullable restore 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /IntuneAppBuilder.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29920.165 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Source", "Source", "{3CF20AE9-5E33-4261-A039-7A37477352CF}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{54940CBD-D84B-4896-8A92-B28468FF9784}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTests", "Tests\IntegrationTests\IntegrationTests.csproj", "{1A30D217-26D7-453E-A9B8-94D8F6E49545}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FA4842C8-A899-40E3-ADDF-F156EBBE68FB}" 13 | ProjectSection(SolutionItems) = preProject 14 | .github\workflows\ci.yml = .github\workflows\ci.yml 15 | Directory.Build.props = Directory.Build.props 16 | Directory.Build.targets = Directory.Build.targets 17 | GitVersion.yml = GitVersion.yml 18 | README.md = README.md 19 | EndProjectSection 20 | EndProject 21 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntuneAppBuilder", "Source\IntuneAppBuilder\IntuneAppBuilder.csproj", "{EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}" 22 | EndProject 23 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Console", "Source\Console\Console.csproj", "{407928C0-F313-4622-A817-B88A572724E2}" 24 | EndProject 25 | Global 26 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 27 | Debug|Any CPU = Debug|Any CPU 28 | Debug|x64 = Debug|x64 29 | Debug|x86 = Debug|x86 30 | Release|Any CPU = Release|Any CPU 31 | Release|x64 = Release|x64 32 | Release|x86 = Release|x86 33 | EndGlobalSection 34 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 35 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Debug|x64.ActiveCfg = Debug|Any CPU 38 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Debug|x64.Build.0 = Debug|Any CPU 39 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Debug|x86.ActiveCfg = Debug|Any CPU 40 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Debug|x86.Build.0 = Debug|Any CPU 41 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Release|x64.ActiveCfg = Release|Any CPU 44 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Release|x64.Build.0 = Release|Any CPU 45 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Release|x86.ActiveCfg = Release|Any CPU 46 | {1A30D217-26D7-453E-A9B8-94D8F6E49545}.Release|x86.Build.0 = Release|Any CPU 47 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Debug|x64.ActiveCfg = Debug|Any CPU 50 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Debug|x64.Build.0 = Debug|Any CPU 51 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Debug|x86.ActiveCfg = Debug|Any CPU 52 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Debug|x86.Build.0 = Debug|Any CPU 53 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Release|x64.ActiveCfg = Release|Any CPU 56 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Release|x64.Build.0 = Release|Any CPU 57 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Release|x86.ActiveCfg = Release|Any CPU 58 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7}.Release|x86.Build.0 = Release|Any CPU 59 | {407928C0-F313-4622-A817-B88A572724E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 60 | {407928C0-F313-4622-A817-B88A572724E2}.Debug|Any CPU.Build.0 = Debug|Any CPU 61 | {407928C0-F313-4622-A817-B88A572724E2}.Debug|x64.ActiveCfg = Debug|Any CPU 62 | {407928C0-F313-4622-A817-B88A572724E2}.Debug|x64.Build.0 = Debug|Any CPU 63 | {407928C0-F313-4622-A817-B88A572724E2}.Debug|x86.ActiveCfg = Debug|Any CPU 64 | {407928C0-F313-4622-A817-B88A572724E2}.Debug|x86.Build.0 = Debug|Any CPU 65 | {407928C0-F313-4622-A817-B88A572724E2}.Release|Any CPU.ActiveCfg = Release|Any CPU 66 | {407928C0-F313-4622-A817-B88A572724E2}.Release|Any CPU.Build.0 = Release|Any CPU 67 | {407928C0-F313-4622-A817-B88A572724E2}.Release|x64.ActiveCfg = Release|Any CPU 68 | {407928C0-F313-4622-A817-B88A572724E2}.Release|x64.Build.0 = Release|Any CPU 69 | {407928C0-F313-4622-A817-B88A572724E2}.Release|x86.ActiveCfg = Release|Any CPU 70 | {407928C0-F313-4622-A817-B88A572724E2}.Release|x86.Build.0 = Release|Any CPU 71 | EndGlobalSection 72 | GlobalSection(SolutionProperties) = preSolution 73 | HideSolutionNode = FALSE 74 | EndGlobalSection 75 | GlobalSection(NestedProjects) = preSolution 76 | {1A30D217-26D7-453E-A9B8-94D8F6E49545} = {54940CBD-D84B-4896-8A92-B28468FF9784} 77 | {EB847511-FC4F-4649-BE7C-F7CA2AEE41A7} = {3CF20AE9-5E33-4261-A039-7A37477352CF} 78 | {407928C0-F313-4622-A817-B88A572724E2} = {3CF20AE9-5E33-4261-A039-7A37477352CF} 79 | EndGlobalSection 80 | GlobalSection(ExtensibilityGlobals) = postSolution 81 | SolutionGuid = {C56009EB-B668-47FB-899B-5F63CF0C5252} 82 | EndGlobalSection 83 | EndGlobal 84 | -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Util/MsiUtil.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Runtime.InteropServices; 5 | using System.Runtime.Versioning; 6 | using IntuneAppBuilder.Domain; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Graph.Beta.Models; 9 | 10 | namespace IntuneAppBuilder.Util 11 | { 12 | /// 13 | /// Helper for reading MSI metadata. Relies on Windows OS. When the tool is not run on Windows, reading MSI metadata 14 | /// will be skipped. 15 | /// 16 | #pragma warning disable S3881 // "IDisposable" should be implemented correctly 17 | internal sealed class MsiUtil : IDisposable 18 | #pragma warning restore S3881 // "IDisposable" should be implemented correctly 19 | { 20 | private readonly dynamic database; 21 | 22 | private readonly dynamic installer; 23 | 24 | [SupportedOSPlatform("windows")] 25 | public MsiUtil(string path, ILogger logger) 26 | { 27 | if (!File.Exists(path)) 28 | throw new FileNotFoundException("MSI file was not found.", path); 29 | 30 | try 31 | { 32 | try 33 | { 34 | installer = ComObject.CreateObject("WindowsInstaller.Installer"); 35 | } 36 | catch 37 | { 38 | logger.LogWarning("Could not create WindowsInstaller COM object. Ensure that you are running on Windows and that the Windows Installer service is available."); 39 | return; 40 | } 41 | 42 | try 43 | { 44 | database = installer.OpenDatabase(path, 0); 45 | } 46 | catch (COMException ex) 47 | { 48 | throw new InvalidDataException($"The specified Windows Installer file {path} could not be opened. Verify the file is a valid Windows Installer file.", ex); 49 | } 50 | } 51 | catch 52 | { 53 | Dispose(); 54 | throw; 55 | } 56 | } 57 | 58 | [SupportedOSPlatform("windows")] 59 | public void Dispose() 60 | { 61 | new[] { installer, database }.OfType().ToList().ForEach(x => x.Dispose()); 62 | // otherwise MSI can remain locked for some time 63 | #pragma warning disable S1215 // "GC.Collect" should not be called 64 | GC.Collect(); 65 | #pragma warning restore S1215 // "GC.Collect" should not be called 66 | } 67 | 68 | public (Win32LobAppMsiInformation Info, MobileMsiManifest Manifest) ReadMsiInfo() 69 | { 70 | if (installer == null) return default; // non-Windows platform 71 | 72 | var info = new Win32LobAppMsiInformation(); 73 | 74 | info.ProductName = RetrievePropertyWithSummaryInfo("ProductName", 3); 75 | info.ProductCode = ReadProperty("ProductCode"); 76 | info.ProductVersion = ReadProperty("ProductVersion"); 77 | info.UpgradeCode = ReadProperty("UpgradeCode", false); 78 | info.Publisher = RetrievePropertyWithSummaryInfo("Manufacturer", 4); 79 | info.PackageType = GetPackageType(); 80 | info.RequiresReboot = ReadProperty("REBOOT", false) is { } s && !string.IsNullOrEmpty(s) && s[0] == 'F'; 81 | 82 | var manifest = GetManifest(info); 83 | 84 | return (info, manifest); 85 | } 86 | 87 | private MobileMsiManifest GetManifest(Win32LobAppMsiInformation info) 88 | { 89 | #pragma warning disable S125 // Sections of code should not be commented out 90 | return new MobileMsiManifest 91 | { 92 | MsiExecutionContext = GetMsiExecutionContext(info.PackageType), 93 | MsiUpgradeCode = info.UpgradeCode, 94 | MsiRequiresReboot = info.RequiresReboot.GetValueOrDefault(), 95 | MsiIsUserInstall = IsUserInstall(), 96 | MsiIsMachineInstall = info.PackageType == Win32LobAppMsiPackageType.PerMachine || (info.PackageType == Win32LobAppMsiPackageType.DualPurpose && !string.IsNullOrEmpty(ReadProperty("MSIINSTALLPERUSER", false))) 97 | }; 98 | } 99 | 100 | private string GetMsiExecutionContext(Win32LobAppMsiPackageType? type) 101 | { 102 | switch (type) 103 | { 104 | case Win32LobAppMsiPackageType.PerUser: 105 | return "User"; 106 | case Win32LobAppMsiPackageType.PerMachine: 107 | return "System"; 108 | default: 109 | return "Any"; 110 | } 111 | } 112 | 113 | private bool IsUserInstall() => 114 | (GetPackageType() is { } type 115 | && type == Win32LobAppMsiPackageType.PerUser) 116 | || (type == Win32LobAppMsiPackageType.DualPurpose 117 | && !string.IsNullOrEmpty(ReadProperty("MSIINSTALLPERUSER", false))); 118 | 119 | private Win32LobAppMsiPackageType GetPackageType() 120 | { 121 | switch (ReadProperty("ALLUSERS", false)) 122 | { 123 | case var s when string.IsNullOrEmpty(s): return Win32LobAppMsiPackageType.PerUser; 124 | case var s when s == "1": return Win32LobAppMsiPackageType.PerMachine; 125 | case var s when s == "2": return Win32LobAppMsiPackageType.DualPurpose; 126 | case var s: throw new InvalidDataException($"Invalid ALLUSERS property value: {s}."); 127 | } 128 | } 129 | 130 | private string ReadProperty(string name, bool throwOnNotFound = true) 131 | { 132 | try 133 | { 134 | var view = database.OpenView("SELECT Value FROM Property WHERE Property ='" + name + "'"); 135 | view.Execute(); 136 | var record = view.Fetch(); 137 | if (record == null && throwOnNotFound) throw new ArgumentException($"Property not found: {name}."); 138 | return record?.get_StringData(1).Trim(); 139 | } 140 | catch (Exception ex) when (ex is ArgumentException || ex is COMException) 141 | { 142 | if (throwOnNotFound) throw; 143 | return null; 144 | } 145 | } 146 | 147 | private string RetrievePropertyWithSummaryInfo(string propertyName, int summaryId) 148 | { 149 | try 150 | { 151 | var text = ReadProperty(propertyName, false); 152 | if (!string.IsNullOrEmpty(text)) return text; 153 | return database.get_SummaryInformation(0).get_Property(summaryId) as string; 154 | } 155 | catch (COMException) 156 | { 157 | return null; 158 | } 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/ProgramTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | using IntuneAppBuilder.IntegrationTests.Util; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Graph.Beta; 10 | using Microsoft.Graph.Beta.Models; 11 | using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; 12 | using Xunit; 13 | using Xunit.Abstractions; 14 | using FileSystemInfo = System.IO.FileSystemInfo; 15 | using Program = IntuneAppBuilder.Console.Program; 16 | 17 | namespace IntuneAppBuilder.IntegrationTests 18 | { 19 | public sealed class ProgramTests 20 | { 21 | private readonly ITestOutputHelper testOutputHelper; 22 | 23 | public ProgramTests(ITestOutputHelper testOutputHelper) => this.testOutputHelper = testOutputHelper; 24 | 25 | [Fact(Skip = "Run manually")] 26 | public async Task LargeWin32() => 27 | await ExecuteInDirectory($"C:\\temp\\{nameof(Win32)}", async () => 28 | { 29 | await DeleteAppAsync("big"); 30 | 31 | Directory.CreateDirectory("big"); 32 | 33 | testOutputHelper.WriteLine($"Available space: {string.Join(", ", DriveInfo.GetDrives().Where(i => i.IsReady).Select(i => $"{i.Name} - {i.AvailableFreeSpace / 1024 / 1024}MB"))}."); 34 | 35 | var sw = Stopwatch.StartNew(); 36 | const int sizeInMb = 1024 * 7; 37 | var data = new byte[8192]; 38 | var rng = new Random(); 39 | using (var fs = new FileStream("big/big.exe", FileMode.Create, FileAccess.Write, FileShare.None)) 40 | { 41 | for (var i = 0; i < sizeInMb * 128; i++) 42 | { 43 | rng.NextBytes(data); 44 | fs.Write(data, 0, data.Length); 45 | } 46 | } 47 | 48 | testOutputHelper.WriteLine($"Generated {sizeInMb}MB file in {sw.ElapsedMilliseconds / 1000} seconds."); 49 | 50 | await Program.PackAsync(new FileSystemInfo[] { new DirectoryInfo("big") }, ".", GetServices()); 51 | 52 | Assert.True(File.Exists("big.intunewin")); 53 | Assert.True(File.Exists("big.portal.intunewin")); 54 | Assert.True(File.Exists("big.intunewin.json")); 55 | 56 | await Program.PublishAsync(new FileSystemInfo[] { new FileInfo("big.intunewin.json") }, services: GetServices()); 57 | // publish second time to test updating 58 | await Program.PublishAsync(new FileSystemInfo[] { new FileInfo("big.intunewin.json") }, services: GetServices()); 59 | 60 | await DeleteAppAsync("big"); 61 | }); 62 | 63 | [Fact] 64 | public async Task Msi() => 65 | await ExecuteInDirectory(nameof(Msi), async () => 66 | { 67 | await DeleteAppAsync("Remote Desktop"); 68 | 69 | var http = new HttpClient(); 70 | var tempPath = Path.Combine(Path.GetTempPath(), "wvd.msi"); 71 | if (!File.Exists(tempPath)) await http.DownloadFileAsync("https://aka.ms/wvdclient", tempPath); 72 | File.Copy(tempPath, "wvd.msi"); 73 | 74 | await Program.PackAsync(new FileSystemInfo[] { new FileInfo("wvd.msi") }, ".", GetServices()); 75 | 76 | Assert.True(File.Exists("wvd.intunewin")); 77 | Assert.True(File.Exists("wvd.portal.intunewin")); 78 | Assert.True(File.Exists("wvd.intunewin.json")); 79 | 80 | await Program.PublishAsync(new FileSystemInfo[] { new FileInfo("wvd.intunewin.json") }, services: GetServices()); 81 | // publish second time to test udpating 82 | await Program.PublishAsync(new FileSystemInfo[] { new FileInfo("wvd.intunewin.json") }, services: GetServices()); 83 | 84 | await DeleteAppAsync("Remote Desktop"); 85 | }); 86 | 87 | [Fact] 88 | public async Task Win32() => 89 | await ExecuteInDirectory(nameof(Win32), async () => 90 | { 91 | await DeleteAppAsync("Remote Desktop"); 92 | 93 | var http = new HttpClient(); 94 | var tempPath = Path.Combine(Path.GetTempPath(), "wvd.msi"); 95 | if (!File.Exists(tempPath)) await http.DownloadFileAsync("https://aka.ms/wvdclient", tempPath); 96 | Directory.CreateDirectory("wvd"); 97 | File.Copy(tempPath, "wvd/wvd.msi"); 98 | 99 | await Program.PackAsync(new FileSystemInfo[] { new DirectoryInfo("wvd") }, ".", GetServices()); 100 | 101 | Assert.True(File.Exists("wvd.intunewin")); 102 | Assert.True(File.Exists("wvd.portal.intunewin")); 103 | Assert.True(File.Exists("wvd.intunewin.json")); 104 | 105 | await Program.PublishAsync(new FileSystemInfo[] { new FileInfo("wvd.intunewin.json") }, services: GetServices()); 106 | // publish second time to test udpating 107 | await Program.PublishAsync(new FileSystemInfo[] { new FileInfo("wvd.intunewin.json") }, services: GetServices()); 108 | 109 | await DeleteAppAsync("Remote Desktop"); 110 | }); 111 | 112 | private async Task DeleteAppAsync(string name) 113 | { 114 | var graph = GetServices().BuildServiceProvider().GetRequiredService(); 115 | var apps = (await graph.DeviceAppManagement.MobileApps.GetAsync(requestConfiguration => requestConfiguration.QueryParameters.Filter = $"displayName eq '{name}'"))!.Value!.OfType(); 116 | foreach (var app in apps) 117 | { 118 | await graph.DeviceAppManagement.MobileApps[app.Id] 119 | .DeleteAsync(requestConfiguration => requestConfiguration.Options.Add(new RetryHandlerOption 120 | { 121 | MaxRetry = 3, 122 | ShouldRetry = (_, _, message) => !message.IsSuccessStatusCode 123 | })); 124 | } 125 | } 126 | 127 | private IServiceCollection GetServices() => 128 | Program.GetServices() 129 | .AddSingleton(_ => new GraphServiceClient(new EnvironmentVariableUsernamePasswordProvider())); 130 | 131 | private static async Task ExecuteInDirectory(string path, Func action) 132 | { 133 | new DirectoryInfo(path).CreateEmptyDirectory(); 134 | 135 | var cd = Environment.CurrentDirectory; 136 | try 137 | { 138 | Environment.CurrentDirectory = path; 139 | await action(); 140 | } 141 | finally 142 | { 143 | Environment.CurrentDirectory = cd; 144 | try 145 | { 146 | Directory.Delete(path, true); 147 | } 148 | catch (IOException) 149 | { 150 | Trace.TraceInformation($"Failed to delete {path}."); 151 | } 152 | } 153 | } 154 | } 155 | } -------------------------------------------------------------------------------- /default.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | -------------------------------------------------------------------------------- /Source/Console/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.CommandLine; 4 | using System.CommandLine.Invocation; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Text; 9 | using System.Text.Json; 10 | using System.Threading.Tasks; 11 | using IntuneAppBuilder.Builders; 12 | using IntuneAppBuilder.Domain; 13 | using IntuneAppBuilder.Services; 14 | using Microsoft.Extensions.DependencyInjection; 15 | using Microsoft.Extensions.Logging; 16 | using Microsoft.Kiota.Serialization.Json; 17 | 18 | namespace IntuneAppBuilder.Console 19 | { 20 | internal static class Program 21 | { 22 | public static async Task Main(string[] args) 23 | { 24 | if (!args.Any()) args = new[] { "--help" }; 25 | 26 | var pack = new Command("pack") 27 | { 28 | new Option(new[] { "--source", "-s" }, 29 | "Specifies a source to package. May be a directory with files for a Win32 app or a single msi file. May be specified multiple times.") 30 | { Name = "sources", IsRequired = true }, 31 | new Option(new[] { "--output", "-o" }, () => ".", 32 | "Specifies an output directory for packaging artifacts. Each packaged application will exist as a raw intunewin file, a portal-ready portal.intunewin file, and an intunewin.json file containing metadata. Defaults to the working directory.") 33 | }; 34 | #pragma warning disable S3011 35 | pack.Handler = CommandHandler.Create(typeof(Program).GetMethod(nameof(PackAsync), BindingFlags.Static | BindingFlags.NonPublic)!); 36 | #pragma warning restore S3011 37 | 38 | var publish = new Command("publish") 39 | { 40 | new Option(new[] { "--source", "-s" }, 41 | "Specifies a source to publish. May be a directory with *.intunewin.json files or a single json file") 42 | { Name = "sources", IsRequired = true }, 43 | new Option(new[] { "--token", "-t" }, 44 | "Specifies an access token to use when publishing.") 45 | { Name = "token", IsRequired = false } 46 | }; 47 | #pragma warning disable S3011 48 | publish.Handler = CommandHandler.Create(typeof(Program).GetMethod(nameof(PublishAsync), BindingFlags.Static | BindingFlags.NonPublic)!); 49 | #pragma warning restore S3011 50 | 51 | var root = new RootCommand 52 | { 53 | pack, 54 | publish 55 | }; 56 | root.TreatUnmatchedTokensAsErrors = true; 57 | 58 | return await root.InvokeAsync(args); 59 | } 60 | 61 | internal static IServiceCollection GetServices(string token = null) 62 | { 63 | var services = new ServiceCollection(); 64 | services.AddIntuneAppBuilder(token); 65 | services.AddLogging(builder => 66 | { 67 | // don't write info for HttpClient 68 | builder.AddFilter((category, level) => category.StartsWith("System.Net.Http.HttpClient") ? level >= LogLevel.Warning : level >= LogLevel.Information); 69 | builder.AddConsole(); 70 | }); 71 | return services; 72 | } 73 | 74 | internal static async Task PackAsync(FileSystemInfo[] sources, string output, IServiceCollection services = null) 75 | { 76 | services ??= GetServices(); 77 | 78 | output = Path.GetFullPath(output); 79 | 80 | AddBuilders(sources, services); 81 | 82 | var sp = services.BuildServiceProvider(); 83 | foreach (var builder in sp.GetRequiredService>()) await BuildAsync(builder, sp.GetRequiredService(), output, GetLogger(sp)); 84 | } 85 | 86 | internal static async Task PublishAsync(FileSystemInfo[] sources, string token = null, IServiceCollection services = null) 87 | { 88 | if (token != null && services != null) 89 | { 90 | throw new ArgumentException($"Cannot specify both {nameof(token)} and {nameof(services)}."); 91 | } 92 | 93 | services ??= GetServices(token); 94 | var sp = services.BuildServiceProvider(); 95 | var publishingService = sp.GetRequiredService(); 96 | var logger = GetLogger(sp); 97 | 98 | var sourceFiles = new List(sources.OfType()); 99 | sourceFiles.AddRange(sources.OfType().SelectMany(di => di.EnumerateFiles("*.intunewin.json", SearchOption.AllDirectories))); 100 | foreach (var file in sourceFiles) 101 | { 102 | using var package = ReadPackage(file, logger); 103 | await publishingService.PublishAsync(package); 104 | } 105 | } 106 | 107 | /// 108 | /// Registers the correct builder type for each source from the command line. 109 | /// 110 | /// 111 | /// 112 | private static void AddBuilders(IEnumerable sources, IServiceCollection services) 113 | { 114 | foreach (var source in sources) 115 | { 116 | if (!source.Exists) throw new InvalidOperationException($"{source.FullName} does not exist."); 117 | 118 | if (source.Extension.Equals(".msi", StringComparison.OrdinalIgnoreCase) || source is DirectoryInfo) 119 | { 120 | services.AddSingleton(sp => ActivatorUtilities.CreateInstance(sp, source.FullName)); 121 | } 122 | else 123 | { 124 | throw new InvalidOperationException($"{source} is not a supported packaging source."); 125 | } 126 | } 127 | } 128 | 129 | /// 130 | /// Invokes the builder in a dedicated working directory. 131 | /// 132 | private static async Task BuildAsync(IIntuneAppPackageBuilder builder, IIntuneAppPackagingService packagingService, string output, ILogger logger) 133 | { 134 | var cd = Environment.CurrentDirectory; 135 | Environment.CurrentDirectory = output; 136 | try 137 | { 138 | var package = await builder.BuildAsync(null); 139 | package.Data.Position = 0; 140 | 141 | var baseFileName = Path.GetFileNameWithoutExtension(package.App.FileName); 142 | 143 | using (var jsonSerializerWriter = new JsonSerializationWriter()) 144 | { 145 | jsonSerializerWriter.WriteObjectValue(string.Empty, package); 146 | var serializedStream = jsonSerializerWriter.GetSerializedContent(); 147 | using (var reader = new StreamReader(serializedStream, Encoding.UTF8)) 148 | { 149 | var packageJsonString = await reader.ReadToEndAsync(); 150 | File.WriteAllText($"{baseFileName}.intunewin.json", packageJsonString); 151 | } 152 | } 153 | 154 | await using (var fs = File.Open($"{baseFileName}.intunewin", FileMode.Create, FileAccess.Write, FileShare.Read)) 155 | { 156 | await package.Data.CopyToAsync(fs); 157 | } 158 | 159 | await using (var fs = File.Open($"{baseFileName}.portal.intunewin", FileMode.Create, FileAccess.Write, FileShare.Read)) 160 | { 161 | await packagingService.BuildPackageForPortalAsync(package, fs); 162 | } 163 | 164 | logger.LogInformation($"Finished writing {baseFileName} package files to {output}."); 165 | } 166 | finally 167 | { 168 | Environment.CurrentDirectory = cd; 169 | } 170 | } 171 | 172 | private static ILogger GetLogger(IServiceProvider sp) => sp.GetRequiredService().CreateLogger(nameof(Program)); 173 | 174 | private static IntuneAppPackage ReadPackage(FileInfo file, ILogger logger) 175 | { 176 | logger.LogInformation($"Loading package from file {file.FullName}."); 177 | 178 | var jsonParseNode = new JsonParseNode(JsonDocument.Parse(File.ReadAllText(file.FullName)).RootElement); 179 | var package = jsonParseNode.GetObjectValue(IntuneAppPackage.CreateFromDiscriminatorValue); 180 | var dataPath = Path.Combine(file.DirectoryName!, Path.GetFileNameWithoutExtension(file.FullName)); 181 | if (!File.Exists(dataPath)) throw new FileNotFoundException($"Could not find data file at {dataPath}."); 182 | logger.LogInformation($"Using package data file {dataPath}"); 183 | package!.Data = File.Open(dataPath, FileMode.Open, FileAccess.Read, FileShare.Read); 184 | return package; 185 | } 186 | } 187 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Simeon Cloud 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Services/IntuneAppPublishingService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Net; 7 | using System.Threading.Tasks; 8 | using Azure; 9 | using IntuneAppBuilder.Builders; 10 | using IntuneAppBuilder.Domain; 11 | using Microsoft.Extensions.Logging; 12 | using Microsoft.Graph.Beta; 13 | using Microsoft.Graph.Beta.Models; 14 | using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; 15 | using Azure.Storage.Blobs.Specialized; 16 | 17 | namespace IntuneAppBuilder.Services 18 | { 19 | internal sealed class IntuneAppPublishingService : IIntuneAppPublishingService 20 | { 21 | private readonly ILogger logger; 22 | private readonly GraphServiceClient msGraphClient; 23 | 24 | public IntuneAppPublishingService(ILogger logger, GraphServiceClient msGraphClient) 25 | { 26 | this.logger = logger; 27 | this.msGraphClient = msGraphClient; 28 | } 29 | 30 | public async Task PublishAsync(IntuneAppPackage package) 31 | { 32 | logger.LogInformation($"Publishing Intune app package for {package.App.DisplayName}."); 33 | 34 | var app = await GetAppAsync(package.App); 35 | 36 | var sw = Stopwatch.StartNew(); 37 | 38 | var requestBuilder = msGraphClient.DeviceAppManagement.MobileApps[app.Id]; 39 | var contentVersionsRequestBuilder = requestBuilder.ContentVersions(app.OdataType.TrimStart('#')); 40 | 41 | MobileAppContent content = null; 42 | 43 | // if content has never been committed, need to use last created content if one exists, otherwise an error is thrown 44 | if (app.CommittedContentVersion == null) content = (await contentVersionsRequestBuilder.GetAsync(requestConfiguration => requestConfiguration.QueryParameters.Orderby = new[] { "id desc" }))!.Value!.FirstOrDefault(); 45 | 46 | content ??= await contentVersionsRequestBuilder.PostAsync(new MobileAppContent()); 47 | 48 | // manifests are only supported if the app is a WindowsMobileMSI (not a Win32 app installing an msi) 49 | if (!(app is WindowsMobileMSI)) package.File.Manifest = null; 50 | 51 | await CreateAppContentFileAsync(contentVersionsRequestBuilder[content!.Id], package); 52 | 53 | var update = (MobileLobApp)Activator.CreateInstance(app.GetType()); 54 | update!.CommittedContentVersion = content.Id; 55 | await msGraphClient.DeviceAppManagement.MobileApps[app.Id].PatchAsync(update); 56 | 57 | logger.LogInformation($"Published Intune app package for {app.DisplayName} in {sw.ElapsedMilliseconds}ms."); 58 | } 59 | 60 | private async Task AddContentFileAsync(MobileAppContentRequestBuilder requestBuilder, IntuneAppPackage package) => 61 | await requestBuilder.Files.PostAsync(package.File, requestConfiguration => requestConfiguration.Options.Add(new RetryHandlerOption 62 | { 63 | MaxRetry = 10, 64 | Delay = 30, 65 | ShouldRetry = (_, _, message) => message.StatusCode == HttpStatusCode.NotFound 66 | })); 67 | 68 | private async Task CreateAppContentFileAsync(MobileAppContentRequestBuilder requestBuilder, IntuneAppPackage package) 69 | { 70 | // add content file 71 | var contentFile = await AddContentFileAsync(requestBuilder, package); 72 | 73 | // refetch until we can get the uri to upload to 74 | contentFile = await WaitForStateAsync(requestBuilder.Files[contentFile.Id], MobileAppContentFileUploadState.AzureStorageUriRequestSuccess); 75 | 76 | var sw = Stopwatch.StartNew(); 77 | 78 | await CreateBlobAsync(package, contentFile, requestBuilder.Files[contentFile.Id]); 79 | 80 | logger.LogInformation($"Uploaded app content file in {sw.ElapsedMilliseconds}ms."); 81 | 82 | // commit 83 | await requestBuilder.Files[contentFile.Id].Commit.PostAsync(new MobileAppContentFileCommitRequest { FileEncryptionInfo = package.EncryptionInfo }); 84 | 85 | // refetch until has committed 86 | await WaitForStateAsync(requestBuilder.Files[contentFile.Id], MobileAppContentFileUploadState.CommitFileSuccess); 87 | } 88 | 89 | private async Task CreateBlobAsync(IntuneAppPackage package, MobileAppContentFile contentFile, MobileAppContentFileRequestBuilder contentFileRequestBuilder) 90 | { 91 | var blockCount = 0; 92 | var blockIds = new List(); 93 | 94 | const int chunkSize = 25 * 1024 * 1024; 95 | package.Data.Seek(0, SeekOrigin.Begin); 96 | var lastBlockId = (Math.Ceiling((double)package.Data.Length / chunkSize) - 1).ToString("0000"); 97 | var sw = Stopwatch.StartNew(); 98 | foreach (var chunk in Chunk(package.Data, chunkSize, false)) 99 | { 100 | if (sw.ElapsedMilliseconds >= 450000) 101 | { 102 | contentFile = await RenewStorageUri(contentFileRequestBuilder); 103 | sw.Restart(); 104 | } 105 | 106 | var blockId = blockCount++.ToString("0000"); 107 | logger.LogInformation($"Uploading block {blockId} of {lastBlockId} to {contentFile.AzureStorageUri}."); 108 | 109 | await using (var ms = new MemoryStream(chunk)) 110 | { 111 | try 112 | { 113 | await TryPutBlockAsync(contentFile, blockId, ms); 114 | } 115 | catch (RequestFailedException ex) when (ex.Status == 403) 116 | { 117 | // normally the timer should account for renewing upload URIs, but the Intune APIs are fundamentally unstable and sometimes 403s will be encountered randomly 118 | contentFile = await RenewStorageUri(contentFileRequestBuilder); 119 | sw.Restart(); 120 | await TryPutBlockAsync(contentFile, blockId, ms); 121 | } 122 | } 123 | 124 | blockIds.Add(blockId); 125 | } 126 | 127 | await new BlockBlobClient(new Uri(contentFile.AzureStorageUri)).CommitBlockListAsync(blockIds); 128 | } 129 | 130 | /// 131 | /// Gets an existing or creates a new app. 132 | /// 133 | /// 134 | /// 135 | private async Task GetAppAsync(MobileLobApp app) 136 | { 137 | MobileLobApp result; 138 | if (Guid.TryParse(app.Id, out var _)) 139 | // resolve from id 140 | { 141 | result = await msGraphClient.DeviceAppManagement.MobileApps[app.Id].GetAsync() as MobileLobApp ?? throw new ArgumentException($"App {app.Id} should be a {nameof(MobileLobApp)}.", nameof(app)); 142 | } 143 | else 144 | { 145 | // resolve from name 146 | result = (await msGraphClient.DeviceAppManagement.MobileApps.GetAsync(requestConfiguration => requestConfiguration.QueryParameters.Filter = $"displayName eq '{app.DisplayName}'"))?.Value?.OfType().FirstOrDefault(); 147 | } 148 | 149 | if (result == null) 150 | { 151 | SetDefaults(app); 152 | // create new 153 | logger.LogInformation($"App {app.DisplayName} does not exist - creating new app."); 154 | result = (MobileLobApp)await msGraphClient.DeviceAppManagement.MobileApps.PostAsync(app); 155 | } 156 | 157 | if (app.OdataType.TrimStart('#') != result.OdataType.TrimStart('#')) 158 | { 159 | throw new NotSupportedException($"Found existing application {result.DisplayName}, but it of type {result.OdataType.TrimStart('#')} and the app being deployed is of type {app.OdataType.TrimStart('#')} - delete the existing app and try again."); 160 | } 161 | 162 | logger.LogInformation($"Using app {result.Id} ({result.DisplayName})."); 163 | 164 | return result; 165 | } 166 | 167 | private async Task RenewStorageUri(MobileAppContentFileRequestBuilder contentFileRequestBuilder) 168 | { 169 | logger.LogInformation($"Renewing SAS URI for {contentFileRequestBuilder.ToGetRequestInformation().URI}."); 170 | await contentFileRequestBuilder.RenewUpload.PostAsync(); 171 | return await WaitForStateAsync(contentFileRequestBuilder, MobileAppContentFileUploadState.AzureStorageUriRenewalSuccess); 172 | } 173 | 174 | private async Task TryPutBlockAsync(MobileAppContentFile contentFile, string blockId, Stream stream) 175 | { 176 | var attemptCount = 0; 177 | var position = stream.Position; 178 | while (true) 179 | try 180 | { 181 | await new BlockBlobClient(new Uri(contentFile.AzureStorageUri)).StageBlockAsync(blockId, stream, null); 182 | break; 183 | } 184 | catch (RequestFailedException ex) 185 | { 186 | if (!new[] { 307, 403, 400 }.Contains(ex.Status) || attemptCount++ > 30) throw; 187 | logger.LogInformation($"Encountered retryable error ({ex.Status}) uploading blob to {contentFile.AzureStorageUri} - will retry in 10 seconds."); 188 | stream.Position = position; 189 | await Task.Delay(10000); 190 | } 191 | } 192 | 193 | // waits for the desired status, refreshing the file along the way 194 | private async Task WaitForStateAsync(MobileAppContentFileRequestBuilder contentFileRequestBuilder, MobileAppContentFileUploadState state) 195 | { 196 | logger.LogInformation($"Waiting for app content file to have a state of {state}."); 197 | 198 | var waitStopwatch = Stopwatch.StartNew(); 199 | 200 | while (true) 201 | { 202 | var contentFile = await contentFileRequestBuilder.GetAsync(); 203 | 204 | if (contentFile.UploadState == state) 205 | { 206 | logger.LogInformation($"Waited {waitStopwatch.ElapsedMilliseconds}ms for app content file to have a state of {state}."); 207 | return contentFile; 208 | } 209 | 210 | var failedStates = new[] 211 | { 212 | MobileAppContentFileUploadState.AzureStorageUriRequestFailed, 213 | MobileAppContentFileUploadState.AzureStorageUriRenewalFailed, 214 | MobileAppContentFileUploadState.CommitFileFailed 215 | }; 216 | 217 | if (failedStates.Contains(contentFile.UploadState.GetValueOrDefault())) throw new InvalidOperationException($"{nameof(contentFile.UploadState)} is in a failed state of {contentFile.UploadState} - was waiting for {state}."); 218 | const int waitTimeout = 600000; 219 | const int testInterval = 2000; 220 | if (waitStopwatch.ElapsedMilliseconds > waitTimeout) throw new InvalidOperationException($"Timed out waiting for {nameof(contentFile.UploadState)} of {state} - current state is {contentFile.UploadState}."); 221 | await Task.Delay(testInterval); 222 | } 223 | } 224 | 225 | /// 226 | /// Chunks a stream into buffers. 227 | /// 228 | private static IEnumerable Chunk(Stream source, int chunkSize, bool disposeSourceStream = true) 229 | { 230 | var buffer = new byte[chunkSize]; 231 | 232 | try 233 | { 234 | int bytesRead; 235 | while ((bytesRead = source.Read(buffer, 0, buffer.Length)) > 0) 236 | { 237 | var chunk = new byte[bytesRead]; 238 | Array.Copy(buffer, chunk, chunk.Length); 239 | yield return chunk; 240 | } 241 | } 242 | finally 243 | { 244 | if (disposeSourceStream) source.Dispose(); 245 | } 246 | } 247 | 248 | /// 249 | /// Gets a copy of the app with default values for null properties that are required. 250 | /// 251 | /// 252 | private static void SetDefaults(MobileLobApp app) 253 | { 254 | if (app is Win32LobApp win32) 255 | { 256 | SetDefaults(win32); 257 | } 258 | } 259 | 260 | private static void SetDefaults(Win32LobApp app) 261 | { 262 | // set required properties with default values if not already specified - can be changed later in the portal 263 | app.InstallExperience ??= new Win32LobAppInstallExperience { RunAsAccount = RunAsAccountType.System }; 264 | app.InstallCommandLine ??= app.MsiInformation == null ? app.SetupFilePath : $"msiexec /i \"{app.SetupFilePath}\""; 265 | app.UninstallCommandLine ??= app.MsiInformation == null ? "echo Not Supported" : $"msiexec /x \"{app.MsiInformation.ProductCode}\""; 266 | if (app.DetectionRules == null) 267 | { 268 | if (app.MsiInformation == null) 269 | { 270 | // no way to infer - use empty PS script 271 | app.DetectionRules = new List 272 | { 273 | new Win32LobAppPowerShellScriptDetection 274 | { 275 | ScriptContent = Convert.ToBase64String(new byte[0]) 276 | } 277 | }; 278 | } 279 | else 280 | { 281 | app.DetectionRules = new List 282 | { 283 | new Win32LobAppProductCodeDetection 284 | { 285 | ProductCode = app.MsiInformation.ProductCode 286 | } 287 | }; 288 | } 289 | } 290 | } 291 | } 292 | } -------------------------------------------------------------------------------- /Source/IntuneAppBuilder/Services/IntuneAppPackagingService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.IO.Compression; 5 | using System.Linq; 6 | using System.Security.Cryptography; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using System.Xml; 10 | using System.Xml.Serialization; 11 | using IntuneAppBuilder.Domain; 12 | using IntuneAppBuilder.Util; 13 | using Microsoft.Extensions.Logging; 14 | using Microsoft.Graph.Beta.Models; 15 | 16 | namespace IntuneAppBuilder.Services 17 | { 18 | internal sealed class IntuneAppPackagingService : IIntuneAppPackagingService 19 | { 20 | private readonly ILogger logger; 21 | 22 | public IntuneAppPackagingService(ILogger logger) => this.logger = logger; 23 | 24 | #pragma warning disable S1541 25 | public async Task BuildPackageAsync(string sourcePath = ".", string setupFilePath = null) 26 | #pragma warning restore S1541 27 | { 28 | var sw = Stopwatch.StartNew(); 29 | 30 | var originalSourcePath = Path.GetFullPath(sourcePath); 31 | logger.LogInformation($"Creating Intune app package from {originalSourcePath}."); 32 | 33 | var name = Path.GetFileNameWithoutExtension(Path.GetFullPath(sourcePath)); 34 | 35 | var zip = ZipContent(sourcePath, setupFilePath); 36 | if (zip.ZipFilePath != null) 37 | { 38 | sourcePath = zip.ZipFilePath; 39 | setupFilePath = zip.SetupFilePath; 40 | } 41 | 42 | if (!File.Exists(sourcePath)) throw new FileNotFoundException($"Could not find source file {sourcePath}."); 43 | 44 | setupFilePath ??= sourcePath; 45 | 46 | logger.LogInformation($"Generating encrypted version of {sourcePath}."); 47 | 48 | var data = new FileStream(Path.GetRandomFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.DeleteOnClose); 49 | var encryptionInfo = await EncryptFileAsync(sourcePath, data); 50 | data.Position = 0; 51 | 52 | var msiInfo = GetMsiInfo(setupFilePath); 53 | 54 | var app = msiInfo.Info != null && zip.ZipFilePath == null // a zip of a folder with an MSI is still a Win32LobApp, not a WindowsMobileMSI 55 | ? new WindowsMobileMSI 56 | { 57 | ProductCode = msiInfo.Info.ProductCode, 58 | ProductVersion = msiInfo.Info.ProductVersion, 59 | IdentityVersion = msiInfo.Info.ProductVersion, 60 | FileName = Path.GetFileName(setupFilePath) 61 | } as MobileLobApp 62 | : new Win32LobApp 63 | { 64 | FileName = $"{name}.intunewin", 65 | SetupFilePath = Path.GetFileName(setupFilePath), 66 | MsiInformation = msiInfo.Info, 67 | InstallExperience = new Win32LobAppInstallExperience { RunAsAccount = GetRunAsAccountType(msiInfo) } 68 | }; 69 | 70 | app.DisplayName = msiInfo.Info?.ProductName ?? name; 71 | app.Publisher = msiInfo.Info?.Publisher; 72 | 73 | var file = new MobileAppContentFile 74 | { 75 | Name = app.FileName, 76 | Size = new FileInfo(sourcePath).Length, 77 | SizeEncrypted = data.Length, 78 | Manifest = msiInfo.Manifest?.ToByteArray() 79 | }; 80 | 81 | var result = new IntuneAppPackage 82 | { 83 | Data = data, 84 | App = app, 85 | EncryptionInfo = encryptionInfo, 86 | File = file 87 | }; 88 | 89 | if (zip.ZipFilePath != null) File.Delete(zip.ZipFilePath); 90 | 91 | logger.LogInformation($"Created Intune app package from {originalSourcePath} in {sw.ElapsedMilliseconds}ms."); 92 | 93 | return result; 94 | } 95 | 96 | public async Task BuildPackageForPortalAsync(IntuneAppPackage package, Stream outputStream) 97 | { 98 | var sw = Stopwatch.StartNew(); 99 | 100 | logger.LogInformation($"Creating Intune portal package for {package.App.FileName}."); 101 | 102 | using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create); 103 | 104 | // the portal can only read if no compression is used 105 | 106 | var packageEntry = archive.CreateEntry("IntuneWinPackage/Contents/IntunePackage.intunewin", CompressionLevel.NoCompression); 107 | package.Data.Position = 0; 108 | using (var dataEntryStream = packageEntry.Open()) 109 | { 110 | await package.Data.CopyToAsync(dataEntryStream); 111 | } 112 | 113 | var detectionEntry = archive.CreateEntry("IntuneWinPackage/Metadata/Detection.xml", CompressionLevel.NoCompression); 114 | using (var detectionEntryStream = detectionEntry.Open()) 115 | { 116 | using var writer = new StreamWriter(detectionEntryStream); 117 | await writer.WriteAsync(GetDetectionXml(package)); 118 | } 119 | 120 | logger.LogInformation($"Created Intune portal package for {package.App.FileName} in {sw.ElapsedMilliseconds}ms."); 121 | } 122 | 123 | /// 124 | /// Algorithm to encrypt file for upload to Intune as intunewin. 125 | /// 126 | private async Task EncryptFileAsync(string sourceFilePath, Stream outputStream) 127 | { 128 | byte[] CreateIVEncryptionKey() 129 | { 130 | using (var aes = Aes.Create()) 131 | { 132 | return aes.IV; 133 | } 134 | } 135 | 136 | byte[] CreateEncryptionKey() 137 | { 138 | using var provider = Aes.Create(); 139 | provider.GenerateKey(); 140 | return provider.Key; 141 | } 142 | 143 | var encryptionKey = CreateEncryptionKey(); 144 | var hmacKey = CreateEncryptionKey(); 145 | var initializationVector = CreateIVEncryptionKey(); 146 | 147 | async Task EncryptFileWithIVAsync() 148 | { 149 | using (var aes = Aes.Create()) 150 | using (var hmacSha256 = new HMACSHA256 { Key = hmacKey }) 151 | { 152 | var hmacLength = hmacSha256.HashSize / 8; 153 | const int bufferBlockSize = 1024 * 4; 154 | var buffer = new byte[bufferBlockSize]; 155 | 156 | await outputStream.WriteAsync(buffer, 0, hmacLength + initializationVector.Length); 157 | using (var sourceStream = File.Open(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) 158 | using (var encryptor = aes.CreateEncryptor(encryptionKey, initializationVector)) 159 | using (var cryptoStream = new CryptoStream(outputStream, encryptor, CryptoStreamMode.Write, true)) 160 | { 161 | int bytesRead; 162 | while ((bytesRead = sourceStream.Read(buffer, 0, bufferBlockSize)) > 0) 163 | { 164 | await cryptoStream.WriteAsync(buffer, 0, bytesRead); 165 | cryptoStream.Flush(); 166 | } 167 | 168 | cryptoStream.FlushFinalBlock(); 169 | } 170 | 171 | outputStream.Seek(hmacLength, SeekOrigin.Begin); 172 | await outputStream.WriteAsync(initializationVector, 0, initializationVector.Length); 173 | outputStream.Seek(hmacLength, SeekOrigin.Begin); 174 | 175 | var hmac = hmacSha256.ComputeHash(outputStream); 176 | 177 | outputStream.Seek(0, SeekOrigin.Begin); 178 | await outputStream.WriteAsync(hmac, 0, hmac.Length); 179 | 180 | return hmac; 181 | } 182 | } 183 | 184 | // Create the encrypted target file and compute the HMAC value. 185 | var mac = await EncryptFileWithIVAsync(); 186 | 187 | // Compute the SHA256 hash of the source file and convert the result to bytes. 188 | using (var sha256 = SHA256.Create()) 189 | using (var fs = File.OpenRead(sourceFilePath)) 190 | { 191 | return new FileEncryptionInfo 192 | { 193 | EncryptionKey = encryptionKey, 194 | MacKey = hmacKey, 195 | InitializationVector = initializationVector, 196 | Mac = mac, 197 | ProfileIdentifier = "ProfileVersion1", 198 | FileDigest = sha256.ComputeHash(fs), 199 | FileDigestAlgorithm = "SHA256" 200 | }; 201 | } 202 | } 203 | 204 | /// 205 | /// This file is included in the zip file the portal expects. 206 | /// It is essentially a collection of metadata, used specifically by the portal javascript to patch data on the mobile 207 | /// app and its content (e.g. the Manifest of the content file). 208 | /// 209 | private string GetDetectionXml(IntuneAppPackage package) 210 | { 211 | var xml = new XmlDocument(); 212 | 213 | XmlElement AppendElement(XmlNode parent, string name, object value = null) 214 | { 215 | var e = xml.CreateElement(name); 216 | if (value != null) e.InnerText = value.ToString(); 217 | parent.AppendChild(e); 218 | return e; 219 | } 220 | 221 | var infoElement = AppendElement(xml, "ApplicationInfo"); 222 | xml.DocumentElement?.SetAttribute("ToolVersion", "1.4.0.0"); 223 | AppendElement(infoElement, "Name", package.App.DisplayName); 224 | AppendElement(infoElement, "UnencryptedContentSize", package.File.Size); 225 | AppendElement(infoElement, "FileName", "IntunePackage.intunewin"); 226 | AppendElement(infoElement, "SetupFile", package.App is Win32LobApp win32 ? win32.SetupFilePath : package.App.FileName); 227 | 228 | var namespaces = new XmlSerializerNamespaces(new[] 229 | { 230 | new XmlQualifiedName(string.Empty, string.Empty) 231 | }); 232 | 233 | using (var writer = infoElement.CreateNavigator().AppendChild()) 234 | { 235 | writer.WriteWhitespace(""); 236 | 237 | var overrides = new XmlAttributeOverrides(); 238 | overrides.Add(typeof(FileEncryptionInfo), nameof(FileEncryptionInfo.BackingStore), new XmlAttributes { XmlIgnore = true }); 239 | overrides.Add(typeof(FileEncryptionInfo), nameof(FileEncryptionInfo.AdditionalData), new XmlAttributes { XmlIgnore = true }); 240 | overrides.Add(typeof(FileEncryptionInfo), nameof(FileEncryptionInfo.OdataType), new XmlAttributes { XmlIgnore = true }); 241 | 242 | new XmlSerializer(typeof(FileEncryptionInfo), overrides, new Type[0], 243 | new XmlRootAttribute("EncryptionInfo"), null) 244 | .Serialize(writer, package.EncryptionInfo, namespaces); 245 | } 246 | 247 | if (package.File.Manifest != null) 248 | using (var writer = infoElement.CreateNavigator().AppendChild()) 249 | { 250 | writer.WriteWhitespace(""); 251 | 252 | var overrides = new XmlAttributeOverrides(); 253 | typeof(MobileMsiManifest).GetProperties().ToList().ForEach(p => 254 | { 255 | if (p.DeclaringType != null) 256 | overrides.Add(p.DeclaringType, p.Name, new XmlAttributes()); 257 | }); 258 | new XmlSerializer(typeof(MobileMsiManifest), overrides, new Type[0], new XmlRootAttribute("MsiInfo"), string.Empty) 259 | .Serialize(writer, MobileMsiManifest.FromByteArray(package.File.Manifest), namespaces); 260 | } 261 | 262 | return FormatXml(xml); 263 | } 264 | 265 | private (Win32LobAppMsiInformation Info, MobileMsiManifest Manifest) GetMsiInfo(string setupFilePath) 266 | { 267 | if (OperatingSystem.IsWindows() && ".msi".Equals(Path.GetExtension(setupFilePath), StringComparison.OrdinalIgnoreCase)) 268 | { 269 | using (var util = new MsiUtil(setupFilePath, logger)) 270 | { 271 | return util.ReadMsiInfo(); 272 | } 273 | } 274 | 275 | return default; 276 | } 277 | 278 | private (string ZipFilePath, string SetupFilePath) ZipContent(string sourcePath, string setupFilePath) 279 | { 280 | string zipFilePath = null; 281 | if (Directory.Exists(sourcePath)) 282 | { 283 | sourcePath = Path.GetFullPath(sourcePath); 284 | zipFilePath = Path.Combine(Path.GetTempPath(), $"{Path.GetRandomFileName()}.{Path.GetFileNameWithoutExtension(Path.GetFullPath(sourcePath))}.intunewin.zip"); 285 | if (File.Exists(zipFilePath)) File.Delete(zipFilePath); 286 | logger.LogInformation($"Creating intermediate zip of {sourcePath} at {zipFilePath}."); 287 | ZipFile.CreateFromDirectory(sourcePath, zipFilePath, CompressionLevel.Optimal, false); 288 | if (setupFilePath == null) setupFilePath = Directory.GetFiles(sourcePath, "*.msi").FirstOrDefault() ?? Directory.GetFiles(sourcePath, "*.exe").FirstOrDefault(); 289 | } 290 | 291 | return (zipFilePath, setupFilePath); 292 | } 293 | 294 | private static string FormatXml(XmlDocument doc) 295 | { 296 | var sb = new StringBuilder(); 297 | var settings = new XmlWriterSettings 298 | { 299 | Indent = true, 300 | IndentChars = " ", 301 | NewLineChars = "\r\n", 302 | NewLineHandling = NewLineHandling.Replace, 303 | OmitXmlDeclaration = true 304 | }; 305 | using (var writer = XmlWriter.Create(sb, settings)) 306 | { 307 | doc.Save(writer); 308 | } 309 | 310 | return sb.ToString(); 311 | } 312 | 313 | private static RunAsAccountType GetRunAsAccountType((Win32LobAppMsiInformation Info, MobileMsiManifest Manifest) msiInfo) => msiInfo.Info?.PackageType == Win32LobAppMsiPackageType.PerUser ? RunAsAccountType.User : RunAsAccountType.System; 314 | } 315 | } -------------------------------------------------------------------------------- /IntuneAppBuilder.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | True 3 | Disabled 4 | 5 | False 6 | False 7 | 8 | 9 | 10 | WARNING 11 | WARNING 12 | SUGGESTION 13 | SUGGESTION 14 | WARNING 15 | WARNING 16 | SUGGESTION 17 | HINT 18 | WARNING 19 | SUGGESTION 20 | SUGGESTION 21 | HINT 22 | DO_NOT_SHOW 23 | HINT 24 | HINT 25 | SUGGESTION 26 | HINT 27 | DO_NOT_SHOW 28 | DO_NOT_SHOW 29 | DO_NOT_SHOW 30 | DO_NOT_SHOW 31 | DO_NOT_SHOW 32 | DO_NOT_SHOW 33 | DO_NOT_SHOW 34 | DO_NOT_SHOW 35 | DO_NOT_SHOW 36 | DO_NOT_SHOW 37 | DO_NOT_SHOW 38 | DO_NOT_SHOW 39 | DO_NOT_SHOW 40 | SUGGESTION 41 | WARNING 42 | SUGGESTION 43 | True 44 | Built-in: Full Cleanup 45 | 46 | 47 | True 48 | False 49 | ExpressionBody 50 | ExpressionBody 51 | ExpressionBody 52 | BlockScoped 53 | False 54 | 1 55 | 1 56 | 1 57 | 1 58 | 1 59 | False 60 | 1 61 | NEVER 62 | NEVER 63 | False 64 | True 65 | True 66 | False 67 | 2 68 | 2 69 | False 70 | <?xml version="1.0" encoding="utf-16"?> 71 | <Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> 72 | <TypePattern DisplayName="Non-reorderable types"> 73 | <TypePattern.Match> 74 | <Or> 75 | <And> 76 | <Kind Is="Interface" /> 77 | <Or> 78 | <HasAttribute Name="System.Runtime.InteropServices.InterfaceTypeAttribute" /> 79 | <HasAttribute Name="System.Runtime.InteropServices.ComImport" /> 80 | </Or> 81 | </And> 82 | <Kind Is="Struct" /> 83 | <HasAttribute Name="JetBrains.Annotations.NoReorderAttribute" /> 84 | <HasAttribute Name="JetBrains.Annotations.NoReorder" /> 85 | </Or> 86 | </TypePattern.Match> 87 | </TypePattern> 88 | <TypePattern DisplayName="xUnit.net Test Classes" RemoveRegions="All"> 89 | <TypePattern.Match> 90 | <And> 91 | <Kind Is="Class" /> 92 | <HasMember> 93 | <And> 94 | <Kind Is="Method" /> 95 | <HasAttribute Name="Xunit.FactAttribute" Inherited="True" /> 96 | <HasAttribute Name="Xunit.TheoryAttribute" Inherited="True" /> 97 | </And> 98 | </HasMember> 99 | </And> 100 | </TypePattern.Match> 101 | <Entry DisplayName="Setup/Teardown Methods"> 102 | <Entry.Match> 103 | <Or> 104 | <Kind Is="Constructor" /> 105 | <And> 106 | <Kind Is="Method" /> 107 | <ImplementsInterface Name="System.IDisposable" /> 108 | </And> 109 | </Or> 110 | </Entry.Match> 111 | <Entry.SortBy> 112 | <Kind Order="Constructor" /> 113 | </Entry.SortBy> 114 | </Entry> 115 | <Entry DisplayName="All other members" /> 116 | <Entry DisplayName="Test Methods" Priority="100"> 117 | <Entry.Match> 118 | <And> 119 | <Kind Is="Method" /> 120 | <HasAttribute Name="Xunit.FactAttribute" /> 121 | <HasAttribute Name="Xunit.TheoryAttribute" /> 122 | </And> 123 | </Entry.Match> 124 | <Entry.SortBy> 125 | <Name /> 126 | </Entry.SortBy> 127 | </Entry> 128 | </TypePattern> 129 | <TypePattern DisplayName="NUnit Test Fixtures" RemoveRegions="All"> 130 | <TypePattern.Match> 131 | <And> 132 | <Kind Is="Class" /> 133 | <Or> 134 | <HasAttribute Name="NUnit.Framework.TestFixtureAttribute" Inherited="True" /> 135 | <HasAttribute Name="NUnit.Framework.TestFixtureSourceAttribute" Inherited="True" /> 136 | <HasMember> 137 | <And> 138 | <Kind Is="Method" /> 139 | <HasAttribute Name="NUnit.Framework.TestAttribute" /> 140 | <HasAttribute Name="NUnit.Framework.TestCaseAttribute" /> 141 | <HasAttribute Name="NUnit.Framework.TestCaseSourceAttribute" /> 142 | </And> 143 | </HasMember> 144 | </Or> 145 | </And> 146 | </TypePattern.Match> 147 | <Entry DisplayName="Setup/Teardown Methods"> 148 | <Entry.Match> 149 | <And> 150 | <Kind Is="Method" /> 151 | <Or> 152 | <HasAttribute Name="NUnit.Framework.SetUpAttribute" Inherited="True" /> 153 | <HasAttribute Name="NUnit.Framework.TearDownAttribute" Inherited="True" /> 154 | <HasAttribute Name="NUnit.Framework.TestFixtureSetUpAttribute" Inherited="True" /> 155 | <HasAttribute Name="NUnit.Framework.TestFixtureTearDownAttribute" Inherited="True" /> 156 | <HasAttribute Name="NUnit.Framework.OneTimeSetUpAttribute" Inherited="True" /> 157 | <HasAttribute Name="NUnit.Framework.OneTimeTearDownAttribute" Inherited="True" /> 158 | </Or> 159 | </And> 160 | </Entry.Match> 161 | </Entry> 162 | <Entry DisplayName="All other members" /> 163 | <Entry DisplayName="Test Methods" Priority="100"> 164 | <Entry.Match> 165 | <And> 166 | <Kind Is="Method" /> 167 | <HasAttribute Name="NUnit.Framework.TestAttribute" /> 168 | <HasAttribute Name="NUnit.Framework.TestCaseAttribute" /> 169 | <HasAttribute Name="NUnit.Framework.TestCaseSourceAttribute" /> 170 | </And> 171 | </Entry.Match> 172 | <Entry.SortBy> 173 | <Name /> 174 | </Entry.SortBy> 175 | </Entry> 176 | </TypePattern> 177 | <TypePattern DisplayName="Default Pattern"> 178 | <Entry DisplayName="Public Delegates" Priority="100"> 179 | <Entry.Match> 180 | <And> 181 | <Access Is="Public" /> 182 | <Kind Is="Delegate" /> 183 | </And> 184 | </Entry.Match> 185 | <Entry.SortBy> 186 | <Name /> 187 | </Entry.SortBy> 188 | </Entry> 189 | <Entry DisplayName="Public Enums" Priority="100"> 190 | <Entry.Match> 191 | <And> 192 | <Access Is="Public" /> 193 | <Kind Is="Enum" /> 194 | </And> 195 | </Entry.Match> 196 | <Entry.SortBy> 197 | <Name /> 198 | </Entry.SortBy> 199 | </Entry> 200 | <Entry DisplayName="Static Fields and Constants" Priority="100"> 201 | <Entry.Match> 202 | <Or> 203 | <Kind Is="Constant" /> 204 | <And> 205 | <Kind Is="Field" /> 206 | <Static /> 207 | </And> 208 | </Or> 209 | </Entry.Match> 210 | <Entry.SortBy> 211 | <Kind Is="Member" /> 212 | <Name /> 213 | </Entry.SortBy> 214 | </Entry> 215 | <Entry DisplayName="Fields"> 216 | <Entry.Match> 217 | <And> 218 | <Kind Is="Field" /> 219 | <Not> 220 | <Static /> 221 | </Not> 222 | </And> 223 | </Entry.Match> 224 | <Entry.SortBy> 225 | <Readonly /> 226 | <Name /> 227 | </Entry.SortBy> 228 | </Entry> 229 | <Entry DisplayName="Constructors" Priority="100"> 230 | <Entry.Match> 231 | <Kind Is="Constructor" /> 232 | </Entry.Match> 233 | <Entry.SortBy> 234 | <Static /> 235 | </Entry.SortBy> 236 | </Entry> 237 | <Entry DisplayName="Properties, Indexers" Priority="100"> 238 | <Entry.Match> 239 | <Or> 240 | <Kind Is="Property" /> 241 | <Kind Is="Indexer" /> 242 | </Or> 243 | </Entry.Match> 244 | <Entry.SortBy> 245 | <Access /> 246 | <Name /> 247 | </Entry.SortBy> 248 | </Entry> 249 | <Entry DisplayName="Interface Implementations" Priority="100"> 250 | <Entry.Match> 251 | <And> 252 | <Kind Is="Member" /> 253 | <ImplementsInterface /> 254 | </And> 255 | </Entry.Match> 256 | <Entry.SortBy> 257 | <ImplementsInterface /> 258 | <Access /> 259 | <Name /> 260 | </Entry.SortBy> 261 | </Entry> 262 | <Entry DisplayName="All other members"> 263 | <Entry.SortBy> 264 | <Abstract /> 265 | <Override /> 266 | <Virtual /> 267 | <Access /> 268 | <Name /> 269 | </Entry.SortBy> 270 | </Entry> 271 | <Entry DisplayName="Static members" Priority="100"> 272 | <Entry.Match> 273 | <Static /> 274 | </Entry.Match> 275 | <Entry.SortBy> 276 | <Abstract /> 277 | <Override /> 278 | <Virtual /> 279 | <Access /> 280 | <Name /> 281 | </Entry.SortBy> 282 | </Entry> 283 | <Entry DisplayName="Nested Types"> 284 | <Entry.Match> 285 | <Kind Is="Type" /> 286 | </Entry.Match> 287 | <Entry.SortBy> 288 | <Access /> 289 | </Entry.SortBy> 290 | </Entry> 291 | </TypePattern> 292 | </Patterns> 293 | False 294 | False 295 | 296 | AD 297 | IV 298 | MS 299 | PS 300 | False 301 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Is" Suffix="" Style="AaBb" /></Policy> 302 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Is" Suffix="" Style="AaBb" /></Policy> 303 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Is" Suffix="" Style="AaBb" /></Policy> 304 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 305 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 306 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="Is" Suffix="" Style="AaBb" /></Policy> 307 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 308 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 309 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 310 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 311 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 312 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 313 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 314 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 315 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 316 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 317 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 318 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 319 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 320 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 321 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 322 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 323 | <Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /> 324 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 325 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 326 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 327 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 328 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 329 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 330 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 331 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 332 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 333 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 334 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 335 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 336 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 337 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 338 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 339 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 340 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 341 | <Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /> 342 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 343 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 344 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 345 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 346 | <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> 347 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 348 | <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> 349 | DISABLED 350 | TEMP_FOLDER 351 | LIVE_MONITOR 352 | LIVE_MONITOR 353 | LIVE_MONITOR 354 | LIVE_MONITOR 355 | LIVE_MONITOR 356 | LIVE_MONITOR 357 | LIVE_MONITOR 358 | LIVE_MONITOR 359 | LIVE_MONITOR 360 | LIVE_MONITOR 361 | LIVE_MONITOR 362 | LIVE_MONITOR 363 | LIVE_MONITOR 364 | LIVE_MONITOR 365 | NOTIFY 366 | AUTO_FIX 367 | AUTO_FIX 368 | AUTO_FIX 369 | AUTO_FIX 370 | AUTO_FIX 371 | AUTO_FIX 372 | AUTO_FIX 373 | True 374 | True 375 | True 376 | True 377 | True 378 | True 379 | True --------------------------------------------------------------------------------