├── 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 | [](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
--------------------------------------------------------------------------------