-
Notifications
You must be signed in to change notification settings - Fork 307
Add packaged-app identity/AUMID groundwork and Windows gating to PackagedApp extension #9914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Evangelink
merged 11 commits into
main
from
dev/amauryleve/improve-packagedapp-extension
Jul 14, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
084511a
Improve PackagedApp extension with public-API-only groundwork
Evangelink 1c2ad90
Update .xlf files to be in sync with .resx
Evangelink 2ae1632
Address review: drop throwing Try* probe and add launcher tests
Evangelink 213236f
Add acceptance test for packaged-layout fail-fast
Evangelink bc457d6
Merge branch 'main' into dev/amauryleve/improve-packagedapp-extension
Evangelink 5016e78
Address PR review: resolve app by executable, honest packaged-app des…
Evangelink 785c906
Address second review pass: BOM, honest descriptions, correct trackin…
Evangelink d3abf8e
Correct public XML docs on packaged-app registration hooks
Evangelink 7f34eb3
Detect packaged layout when manifest is at an ancestor package root
Evangelink 86e9866
Drop UWP from the supported non-packaged path descriptions
Evangelink 13d6732
Merge branch 'main' into dev/amauryleve/improve-packagedapp-extension
Evangelink File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
36 changes: 36 additions & 0 deletions
36
src/Platform/Microsoft.Testing.Extensions.PackagedApp/AppxApplicationInfo.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT license. See LICENSE file in the project root for full license information. | ||
|
|
||
| namespace Microsoft.Testing.Extensions.PackagedApp; | ||
|
|
||
| /// <summary> | ||
| /// A single application declared by a packaged Windows app's <c>AppxManifest.xml</c> | ||
| /// (<c>Applications/Application</c>). A package can declare more than one, each with its own | ||
| /// Application User Model ID, so the launcher resolves the one matching the requested executable | ||
| /// rather than assuming a single application. | ||
| /// </summary> | ||
| internal sealed class AppxApplicationInfo | ||
| { | ||
| internal AppxApplicationInfo(string id, string? executable, string appUserModelId) | ||
| { | ||
| Id = id; | ||
| Executable = executable; | ||
| AppUserModelId = appUserModelId; | ||
| } | ||
|
|
||
| /// <summary>Gets the application id (the manifest's <c>Application/@Id</c>).</summary> | ||
| public string Id { get; } | ||
|
|
||
| /// <summary> | ||
| /// Gets the application's executable file name (the manifest's <c>Application/@Executable</c>), | ||
| /// or <see langword="null"/> when the manifest declares none. Used to disambiguate a package that | ||
| /// declares multiple applications by matching the executable the platform asked to launch. | ||
| /// </summary> | ||
| public string? Executable { get; } | ||
|
|
||
| /// <summary> | ||
| /// Gets the Application User Model ID (<c>{PackageFamilyName}!{Id}</c>) used to activate this | ||
| /// application. | ||
| /// </summary> | ||
| public string AppUserModelId { get; } | ||
| } |
242 changes: 242 additions & 0 deletions
242
src/Platform/Microsoft.Testing.Extensions.PackagedApp/AppxManifestInfo.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT license. See LICENSE file in the project root for full license information. | ||
|
|
||
| using System.Security.Cryptography; | ||
|
|
||
| using Microsoft.Testing.Extensions.PackagedApp.Resources; | ||
|
|
||
| namespace Microsoft.Testing.Extensions.PackagedApp; | ||
|
|
||
| /// <summary> | ||
| /// The identity of a packaged Windows (UWP/WinUI) app, read from its <c>AppxManifest.xml</c>. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// A packaged app is launched by <em>Application User Model ID</em> (AUMID), never by | ||
| /// <c>Process.Start</c>. The AUMID is <c>{PackageFamilyName}!{ApplicationId}</c>, and the package | ||
| /// family name is <c>{PackageName}_{publisherId}</c> where <c>publisherId</c> is a 13-character hash | ||
| /// of the manifest's <c>Publisher</c>. VSTest reads these values from VS-internal deployment | ||
| /// components; this type computes them from the manifest using only public, cross-platform managed | ||
| /// APIs so the extension does not depend on the Visual Studio install. | ||
| /// </para> | ||
| /// </remarks> | ||
| internal sealed class AppxManifestInfo | ||
| { | ||
| /// <summary> | ||
| /// The canonical file name of a packaged app's manifest inside its (loose or extracted) layout. | ||
| /// </summary> | ||
| internal const string AppxManifestFileName = "AppxManifest.xml"; | ||
|
|
||
| // The alphabet Windows uses to encode the publisher hash (Douglas Crockford's base32: the digits | ||
| // and lowercase letters with i, l, o and u removed). Must not be reordered. | ||
| private const string PublisherHashAlphabet = "0123456789abcdefghjkmnpqrstvwxyz"; | ||
|
|
||
| private AppxManifestInfo(string packageName, string publisher, IReadOnlyList<AppxApplicationInfo> applications) | ||
| { | ||
| PackageName = packageName; | ||
| Publisher = publisher; | ||
| PackageFamilyName = $"{packageName}_{ComputePublisherId(publisher)}"; | ||
| Applications = applications; | ||
| } | ||
|
|
||
| /// <summary>Gets the package name (the manifest's <c>Identity/@Name</c>).</summary> | ||
| public string PackageName { get; } | ||
|
|
||
| /// <summary>Gets the publisher (the manifest's <c>Identity/@Publisher</c>).</summary> | ||
| public string Publisher { get; } | ||
|
|
||
| /// <summary>Gets the package family name (<c>{PackageName}_{publisherId}</c>).</summary> | ||
| public string PackageFamilyName { get; } | ||
|
|
||
| /// <summary> | ||
| /// Gets the applications declared by the manifest (<c>Applications/Application</c>), in manifest | ||
| /// order. A package can declare several applications, so callers must select the one they want | ||
| /// (see <see cref="ResolveApplication(string?)"/>) rather than assuming a single entry. The list | ||
| /// is empty when the manifest declares no application. | ||
| /// </summary> | ||
| public IReadOnlyList<AppxApplicationInfo> Applications { get; } | ||
|
|
||
| /// <summary> | ||
| /// Returns the path to the <c>AppxManifest.xml</c> at the root of <paramref name="layoutDirectory"/> | ||
| /// when the directory is a packaged-app layout. This is a cheap, non-throwing probe: it only tests | ||
| /// for the file's existence and never parses it. Callers that need the parsed identity pass the | ||
| /// returned path to <see cref="ReadFromManifest(string)"/>. | ||
| /// </summary> | ||
| /// <param name="layoutDirectory">The directory to probe for an <c>AppxManifest.xml</c>.</param> | ||
| /// <returns> | ||
| /// The full path to the manifest when the directory is a packaged-app layout; otherwise | ||
| /// <see langword="null"/>. | ||
| /// </returns> | ||
| public static string? GetManifestPath(string layoutDirectory) | ||
| { | ||
| string manifestPath = Path.Combine(layoutDirectory, AppxManifestFileName); | ||
| return File.Exists(manifestPath) ? manifestPath : null; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Searches <paramref name="startDirectory"/> and each of its ancestors for an | ||
| /// <c>AppxManifest.xml</c> and returns the path to the nearest one. A packaged app's manifest | ||
| /// lives at the package layout root, but an <c>Application/@Executable</c> can point into a | ||
| /// subdirectory (for example <c>bin\host.exe</c>), so the executable's own directory is not | ||
| /// necessarily the root. Walking up locates the manifest in that valid layout too, so the launcher | ||
| /// does not miss a packaged layout and fall through to <c>Process.Start</c>. Like | ||
| /// <see cref="GetManifestPath(string)"/> this is a cheap existence probe that never parses. | ||
| /// </summary> | ||
| /// <param name="startDirectory">The directory to start searching from (typically the executable's directory).</param> | ||
| /// <returns> | ||
| /// The full path to the nearest <c>AppxManifest.xml</c> at or above <paramref name="startDirectory"/>; | ||
| /// otherwise <see langword="null"/>. | ||
| /// </returns> | ||
| public static string? FindManifestPath(string startDirectory) | ||
| { | ||
| for (DirectoryInfo? directory = new(startDirectory); directory is not null; directory = directory.Parent) | ||
| { | ||
| string? manifestPath = GetManifestPath(directory.FullName); | ||
| if (manifestPath is not null) | ||
| { | ||
| return manifestPath; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /// <summary>Reads and parses the manifest at <paramref name="manifestPath"/>.</summary> | ||
| /// <param name="manifestPath">The path to an <c>AppxManifest.xml</c>.</param> | ||
| /// <returns>The parsed manifest info.</returns> | ||
| public static AppxManifestInfo ReadFromManifest(string manifestPath) | ||
| { | ||
| using FileStream stream = File.OpenRead(manifestPath); | ||
| return ReadFromManifest(stream); | ||
| } | ||
|
|
||
| /// <summary>Reads and parses a manifest from <paramref name="manifestStream"/>.</summary> | ||
| /// <param name="manifestStream">A stream over an <c>AppxManifest.xml</c>.</param> | ||
| /// <returns>The parsed manifest info.</returns> | ||
| public static AppxManifestInfo ReadFromManifest(Stream manifestStream) | ||
| { | ||
| var document = XDocument.Load(manifestStream); | ||
| XElement? root = document.Root; | ||
|
|
||
| // Match by local name so we are resilient to the manifest schema version (the foundation | ||
| // namespace URI changes across Windows 10 SDK revisions). | ||
| XElement? identity = root?.Elements().FirstOrDefault(static e => e.Name.LocalName == "Identity"); | ||
| string? name = identity?.Attribute("Name")?.Value; | ||
| string? publisher = identity?.Attribute("Publisher")?.Value; | ||
|
|
||
| if (name is null || name.Length == 0 || publisher is null || publisher.Length == 0) | ||
| { | ||
| throw new InvalidOperationException(ExtensionResources.InvalidAppxManifestMissingIdentity); | ||
| } | ||
|
|
||
| string packageFamilyName = $"{name}_{ComputePublisherId(publisher)}"; | ||
|
|
||
| // A package may declare several applications, each with its own id (and AUMID). Capture them | ||
| // all so the launcher can resolve the one matching the executable it was asked to launch, | ||
| // instead of guessing with the first entry. | ||
| List<AppxApplicationInfo> applications = root?.Elements().FirstOrDefault(static e => e.Name.LocalName == "Applications")? | ||
| .Elements().Where(static e => e.Name.LocalName == "Application") | ||
| .Select(application => | ||
| { | ||
| string? applicationId = application.Attribute("Id")?.Value; | ||
| string? executable = application.Attribute("Executable")?.Value; | ||
| return applicationId is null || applicationId.Length == 0 | ||
| ? null | ||
| : new AppxApplicationInfo(applicationId, executable, $"{packageFamilyName}!{applicationId}"); | ||
| }) | ||
| .Where(static application => application is not null) | ||
| .Select(static application => application!) | ||
| .ToList() | ||
| ?? []; | ||
|
|
||
| return new AppxManifestInfo(name, publisher, applications); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Resolves the application whose test host the platform asked to launch. Most packages declare a | ||
| /// single application, which is returned directly. When a package declares several, the entry is | ||
| /// disambiguated by matching <paramref name="executableFileName"/> against each | ||
| /// <c>Application/@Executable</c>; an ambiguous request (no match, or several matches) is rejected | ||
| /// rather than silently defaulting to the first application, which would identify the wrong app. | ||
| /// </summary> | ||
| /// <param name="executableFileName"> | ||
| /// The file name (not full path) of the executable the platform asked to launch, used to pick the | ||
| /// matching application when the manifest declares more than one. | ||
| /// </param> | ||
| /// <returns> | ||
| /// The matching application, or <see langword="null"/> when the manifest declares no application. | ||
| /// </returns> | ||
| /// <exception cref="InvalidOperationException"> | ||
| /// The manifest declares multiple applications and <paramref name="executableFileName"/> does not | ||
| /// match exactly one of them. | ||
| /// </exception> | ||
| public AppxApplicationInfo? ResolveApplication(string? executableFileName) | ||
| { | ||
| switch (Applications.Count) | ||
| { | ||
| case 0: | ||
| return null; | ||
| case 1: | ||
| return Applications[0]; | ||
| } | ||
|
|
||
| AppxApplicationInfo[] matches = [.. Applications.Where(application => | ||
| application.Executable is not null | ||
| && string.Equals(GetExecutableFileName(application.Executable), executableFileName, StringComparison.OrdinalIgnoreCase))]; | ||
|
Evangelink marked this conversation as resolved.
Evangelink marked this conversation as resolved.
|
||
|
|
||
| return matches.Length == 1 | ||
| ? matches[0] | ||
| : throw new InvalidOperationException( | ||
| string.Format( | ||
| CultureInfo.CurrentCulture, | ||
| ExtensionResources.AmbiguousAppxManifestApplication, | ||
| executableFileName, | ||
| string.Join(", ", Applications.Select(static application => application.AppUserModelId)))); | ||
| } | ||
|
|
||
| // The manifest's Application/@Executable is package-root-relative and may include a subdirectory | ||
| // (for example "bin\host.exe"), always with Windows separators. Reduce it to the bare file name so | ||
| // it can be matched against the executable file name the platform asked to launch, independently of | ||
| // the OS running the parser. | ||
| private static string GetExecutableFileName(string executable) | ||
| { | ||
| int lastSeparator = executable.LastIndexOfAny(['\\', '/']); | ||
| return lastSeparator < 0 ? executable : executable[(lastSeparator + 1)..]; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Computes the 13-character publisher id (the suffix of the package family name) for | ||
| /// <paramref name="publisher"/>, using the public Windows algorithm: base32-encode the first 8 | ||
| /// bytes of the SHA-256 hash of the UTF-16LE publisher string. | ||
| /// </summary> | ||
| /// <param name="publisher">The manifest's <c>Identity/@Publisher</c> value.</param> | ||
| /// <returns>The 13-character publisher id.</returns> | ||
| internal static string ComputePublisherId(string publisher) | ||
| { | ||
| byte[] hash = SHA256.HashData(Encoding.Unicode.GetBytes(publisher)); | ||
|
|
||
| // Take the first 8 bytes (64 bits) and encode them big-endian as 13 base32 characters. 64 bits | ||
| // is not a multiple of 5, so the final character carries the 4 leftover bits padded with a 0. | ||
| var builder = new StringBuilder(13); | ||
| int buffer = 0; | ||
| int bitsInBuffer = 0; | ||
| for (int i = 0; i < 8; i++) | ||
| { | ||
| buffer = (buffer << 8) | hash[i]; | ||
| bitsInBuffer += 8; | ||
| while (bitsInBuffer >= 5) | ||
| { | ||
| bitsInBuffer -= 5; | ||
| builder.Append(PublisherHashAlphabet[(buffer >> bitsInBuffer) & 0x1F]); | ||
| buffer &= (1 << bitsInBuffer) - 1; | ||
| } | ||
| } | ||
|
|
||
| if (bitsInBuffer > 0) | ||
| { | ||
| builder.Append(PublisherHashAlphabet[(buffer << (5 - bitsInBuffer)) & 0x1F]); | ||
| } | ||
|
|
||
| return builder.ToString(); | ||
| } | ||
| } | ||
17 changes: 17 additions & 0 deletions
17
src/Platform/Microsoft.Testing.Extensions.PackagedApp/InternalAPI/InternalAPI.Unshipped.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,18 @@ | ||
| #nullable enable | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo.AppUserModelId.get -> string! | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo.AppxApplicationInfo(string! id, string? executable, string! appUserModelId) -> void | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo.Executable.get -> string? | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo.Id.get -> string! | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.Applications.get -> System.Collections.Generic.IReadOnlyList<Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo!>! | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.PackageFamilyName.get -> string! | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.PackageName.get -> string! | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.Publisher.get -> string! | ||
| Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.ResolveApplication(string? executableFileName) -> Microsoft.Testing.Extensions.PackagedApp.AppxApplicationInfo? | ||
| const Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.AppxManifestFileName = "AppxManifest.xml" -> string! | ||
| static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.ComputePublisherId(string! publisher) -> string! | ||
| static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.FindManifestPath(string! startDirectory) -> string? | ||
| static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.GetManifestPath(string! layoutDirectory) -> string? | ||
| static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.ReadFromManifest(System.IO.Stream! manifestStream) -> Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo! | ||
| static Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo.ReadFromManifest(string! manifestPath) -> Microsoft.Testing.Extensions.PackagedApp.AppxManifestInfo! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.