Skip to content

Commit

Permalink
PowerShell store apps (#59)
Browse files Browse the repository at this point in the history
Add PowerShell command to publish a Microsoft Store app to Intune and added some unit tests.
  • Loading branch information
svrooij authored May 3, 2024
1 parent 8fecb33 commit d5488ed
Show file tree
Hide file tree
Showing 20 changed files with 586 additions and 295 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ jobs:

- name: 📝 Code Coverage report
run: |
dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.1.23
reportgenerator -reports:${{github.workspace}}/coverage.cobertura.xml -targetdir:${{github.workspace}}/report -reporttypes:MarkdownSummaryGithub -filefilters:-*.g.cs "-classfilters:-WixSharp.*;-WingetIntune.Os.*;-WingetIntune.Internal.MsStore.*" -verbosity:Warning
dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.2.5
reportgenerator -reports:${{github.workspace}}/coverage.cobertura.xml -targetdir:${{github.workspace}}/report -reporttypes:MarkdownSummaryGithub -filefilters:-*.g.cs "-classfilters:-WixSharp.*;-WingetIntune.Os.*;-WingetIntune.Internal.MsStore.Models.*" -verbosity:Warning
sed -i 's/# Summary/## 📝 Code Coverage/g' ${{github.workspace}}/report/SummaryGithub.md
sed -i 's/## Coverage/### 📝 Code Coverage details/g' ${{github.workspace}}/report/SummaryGithub.md
cat ${{github.workspace}}/report/*.md >> $GITHUB_STEP_SUMMARY
Expand Down
2 changes: 1 addition & 1 deletion WingetIntune.sln
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Svrooij.WinTuner.CmdLets",
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4E745B8F-95AD-42CC-A462-EBA6896413E2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Winget.CommunityRepository.Ef", "src\Winget.CommunityRepository.Ef\Winget.CommunityRepository.Ef.csproj", "{516169B3-872B-443B-8736-27E2A08E7091}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Winget.CommunityRepository.Ef", "src\Winget.CommunityRepository.Ef\Winget.CommunityRepository.Ef.csproj", "{516169B3-872B-443B-8736-27E2A08E7091}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Apps", "Apps", "{4D1367F6-2076-481E-A7BD-0BFA6BEFCA3D}"
EndProject
Expand Down
93 changes: 93 additions & 0 deletions src/Svrooij.WinTuner.CmdLets/Commands/DeployWtMsStoreApp.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using Microsoft.Extensions.Logging;
using Svrooij.PowerShell.DependencyInjection;
using System;
using System.Management.Automation;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using WingetIntune.Graph;
using GraphModels = Microsoft.Graph.Beta.Models;

namespace Svrooij.WinTuner.CmdLets.Commands;
/// <summary>
/// <para type="synopsis">Create a MsStore app in Intune</para>
/// <para type="description">Use this command to create an Microsoft Store app in Microsoft Intune</para>
/// <para type="link" uri="https://wintuner.app/docs/wintuner-powershell/Deploy-WtMsStoreApp">Documentation</para>
/// </summary>
/// <example>
/// <para type="description">Add Firefox to Intune, using interactive authentication</para>
/// <code>Deploy-WtMsStoreApp -PackageId 9NZVDKPMR9RD -Username [email protected]</code>
/// </example>
[Cmdlet(VerbsLifecycle.Deploy, "WtMsStoreApp", DefaultParameterSetName = nameof(PackageId))]
[OutputType(typeof(GraphModels.WinGetApp))]
public class DeployWtMsStoreApp : BaseIntuneCmdlet
{
/// <summary>
/// <para type="description">The package id to upload to Intune.</para>
/// </summary>
[Parameter(
Mandatory = true,
Position = 0,
ParameterSetName = nameof(PackageId),
ValueFromPipeline = false,
ValueFromPipelineByPropertyName = false,
HelpMessage = "The package id to upload to Intune.")]
public string? PackageId { get; set; }

/// <summary>
/// <para type="description">Name of the app to look for, first match will be created.</para>
/// </summary>
[Parameter(
Mandatory = true,
Position = 0,
ParameterSetName = nameof(SearchQuery),
ValueFromPipeline = false,
ValueFromPipelineByPropertyName = false,
HelpMessage = "Name of the app to look for, first match will be created.")]
public string? SearchQuery { get; set; }

[ServiceDependency]
private ILogger<DeployWtMsStoreApp>? logger;

[ServiceDependency]
private GraphStoreAppUploader? graphStoreAppUploader;

[ServiceDependency]
private HttpClient? httpClient;

/// <inheritdoc/>
public override async Task ProcessRecordAsync(CancellationToken cancellationToken)
{
ValidateAuthenticationParameters();
if (ParameterSetName == nameof(SearchQuery))
{
ArgumentException.ThrowIfNullOrWhiteSpace(SearchQuery);
logger!.LogInformation("Searching package id for {searchQuery}", SearchQuery);
PackageId = await graphStoreAppUploader!.GetStoreIdForNameAsync(SearchQuery!, cancellationToken);
if (string.IsNullOrEmpty(PackageId))
{
logger!.LogError("No package found for {searchQuery}", SearchQuery);
return;
}
}

// At this moment the package ID should always be filled.
ArgumentException.ThrowIfNullOrWhiteSpace(PackageId);

logger!.LogInformation("Uploading MSStore app {PackageId} to Intune", PackageId);
var graphServiceClient = CreateGraphServiceClient(httpClient!);
try
{
var app = await graphStoreAppUploader!.CreateStoreAppAsync(graphServiceClient, PackageId, cancellationToken);

logger!.LogInformation("Created MSStore app {PackageId} with id {appId}", PackageId, app!.Id);
WriteObject(app);
}
catch (Exception ex)
{
logger!.LogError(ex, "Error creating MSStore app {PackageId}", PackageId);
}


}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public override async Task ProcessRecordAsync(CancellationToken cancellationToke
if (Categories is not null && Categories.Any())
{
logger?.LogInformation("Adding categories to app {appId}", AppId);
await graphServiceClient.AddIntuneCategoriesToApp(AppId!, Categories, cancellationToken);
await graphServiceClient.AddIntuneCategoriesToAppAsync(AppId!, Categories, cancellationToken);
}

if ((AvailableFor is not null && AvailableFor.Any()) ||
Expand Down
5 changes: 0 additions & 5 deletions src/WingetIntune/Graph/GraphAppUploader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@
using Microsoft.Graph.Beta;
using Microsoft.Graph.Beta.Models;
using Microsoft.Graph.Beta.Models.ODataErrors;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WingetIntune.Intune;
using WingetIntune.Models;

Expand Down
96 changes: 96 additions & 0 deletions src/WingetIntune/Graph/GraphStoreAppUploader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using Microsoft.Extensions.Logging;
using Microsoft.Graph.Beta;
using Microsoft.Graph.Beta.Models;
using Microsoft.Graph.Beta.Models.ODataErrors;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WingetIntune.Models;

namespace WingetIntune.Graph;
public class GraphStoreAppUploader
{
private readonly ILogger<GraphStoreAppUploader> logger;
private readonly IFileManager fileManager;
private readonly Internal.MsStore.MicrosoftStoreClient microsoftStoreClient;
private readonly Mapper mapper = new();

public GraphStoreAppUploader(ILogger<GraphStoreAppUploader> logger, IFileManager fileManager, Internal.MsStore.MicrosoftStoreClient microsoftStoreClient)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(fileManager);
ArgumentNullException.ThrowIfNull(microsoftStoreClient);
this.logger = logger;
this.fileManager = fileManager;
this.microsoftStoreClient = microsoftStoreClient;
}

public Task<string?> GetStoreIdForNameAsync(string searchstring, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(searchstring);
return microsoftStoreClient.GetPackageIdForFirstMatchAsync(searchstring, cancellationToken);
}

public async Task<WinGetApp?> CreateStoreAppAsync(GraphServiceClient graphServiceClient, string packageId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(graphServiceClient);
ArgumentException.ThrowIfNullOrEmpty(packageId);
ArgumentNullException.ThrowIfNull(cancellationToken);

var catalog = await microsoftStoreClient.GetDisplayCatalogAsync(packageId!, cancellationToken);
ArgumentNullException.ThrowIfNull(catalog);
if (!(catalog.Products?.Count() > 0))
{
logger.LogError("No products found for {packageId}", packageId);
return null;
}

var app = mapper.ToWinGetApp(catalog!);

try
{
var imagePath = Path.GetTempFileName();
var uriPart = catalog.Products.First()?.LocalizedProperties.FirstOrDefault()?.Images?.FirstOrDefault(i => i.Height == 300 && i.Width == 300)?.Uri; // && i.ImagePurpose.Equals("Tile", StringComparison.OrdinalIgnoreCase)
if (uriPart is null)
{
logger.LogWarning("No image found for {packageId}", packageId);
}
else
{
var imageUrl = $"http:{uriPart}";
await fileManager.DownloadFileAsync(imageUrl, imagePath, overrideFile: true, cancellationToken: cancellationToken);
app.LargeIcon = new MimeContent
{
Type = "image/png",
Value = await fileManager.ReadAllBytesAsync(imagePath, cancellationToken)
};
}

}
catch (Exception ex)
{
logger.LogError(ex, "Error downloading image for {packageId}", packageId);
}

logger.LogInformation("Creating new WinGetApp (MsStore) for {packageId}", packageId);

try
{
var createdApp = await graphServiceClient.DeviceAppManagement.MobileApps.PostAsync(app, cancellationToken);
logger.LogInformation("MsStore app {packageIdentifier} created in Intune {appId}", createdApp?.PackageIdentifier, createdApp?.Id);
return createdApp;
}
catch (ODataError ex)
{
logger.LogError(ex, "Error publishing app {message}", ex.Error?.Message);
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Error publishing app");
throw;
}
}
}
2 changes: 1 addition & 1 deletion src/WingetIntune/Graph/GraphWorkflows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace WingetIntune.Graph;

public static class GraphWorkflows
{
public static async Task AddIntuneCategoriesToApp(this GraphServiceClient graphServiceClient, string appId, string[] categories, CancellationToken cancellationToken)
public static async Task AddIntuneCategoriesToAppAsync(this GraphServiceClient graphServiceClient, string appId, string[] categories, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(graphServiceClient);
ArgumentException.ThrowIfNullOrEmpty(appId);
Expand Down
Loading

0 comments on commit d5488ed

Please sign in to comment.