@mpgd/cli 0.47.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/command-launcher.d.ts +13 -0
- package/dist/command-launcher.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +126 -7
- package/dist/microsoft-store-submission.js +11 -6
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-target.js +2121 -350
- package/dist/windows-host/Core/BridgeCommand.cs +17 -0
- package/dist/windows-host/Core/Mpgd.MicrosoftStore.Host.csproj +9 -0
- package/dist/windows-host/Core/NativeBridgeContracts.cs +33 -0
- package/dist/windows-host/Core/NativeBridgeHost.cs +162 -0
- package/dist/windows-host/Core/NativeBridgeOrigin.cs +19 -0
- package/dist/windows-host/Core/NativeBridgeProtocol.cs +335 -0
- package/dist/windows-host/Core/NativeBridgeRequestGate.cs +76 -0
- package/dist/windows-host/Core/NativeBridgeResult.cs +65 -0
- package/dist/windows-host/README.md +118 -0
- package/dist/windows-host/WinUI/DispatcherQueueBridgeDispatcher.cs +39 -0
- package/dist/windows-host/WinUI/WebView2DocumentTransport.cs +46 -0
- package/dist/windows-host/WinUI/WebView2NativeBridge.cs +185 -0
- package/dist/windows-host/WinUI/WindowStoreContext.cs +20 -0
- package/dist/windows-native-tools.d.ts +4 -0
- package/dist/windows-native-tools.js +21 -0
- package/native-tools/windows-msix/WindowsMsixAcceptance.psm1 +333 -0
- package/package.json +24 -16
- package/templates/lite-overlay/.agents/skills/use-mpgd-kit/SKILL.md +22 -0
- package/templates/lite-overlay/AGENTS.md +27 -0
- package/templates/lite-overlay/README.md +37 -0
- package/templates/lite-overlay/agent/acceptance.md +27 -0
- package/templates/lite-overlay/agent/brief.md +17 -0
- package/templates/lite-overlay/apps/target-devvit/README.md +134 -0
- package/templates/lite-overlay/docs/MPGD_KIT_WORKFLOWS.md +37 -0
- package/templates/lite-overlay/src/lite/tapGame.ts +77 -0
- package/templates/lite-overlay/src/platform/devvitEntrypoint.ts +116 -0
- package/templates/lite-overlay/src/platform/devvitInlineMode.css +146 -0
- package/templates/lite-overlay/src/runtime/createGame.ts +57 -0
- package/templates/phaser-game/README.md +5 -3
- package/templates/phaser-game/apps/target-ait/package.json +2 -2
- package/templates/phaser-game/apps/target-ait/src/main.ts +5 -5
- package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +137 -18
- package/templates/phaser-game/package.json +1 -1
- package/templates/phaser-game/pnpm-workspace.yaml +2 -0
- package/templates/phaser-game/src/env.d.ts +2 -0
- package/templates/phaser-game/src/platform/buildGateways/ait.ts +1 -1
- package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +9 -4
- package/templates/phaser-game/src/platform/buildGateways/microsoftStore.ts +2 -5
- package/templates/phaser-game/src/platform/buildGateways/reddit.ts +1 -1
- package/templates/phaser-game/src/platform/buildGateways/verse8.ts +1 -2
- package/templates/phaser-game/src/platform/verse8Agent8Client.ts +5 -6
- package/templates/phaser-game/vite.shared.ts +40 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
namespace Mpgd.MicrosoftStore.Host;
|
|
2
|
+
|
|
3
|
+
public abstract record BridgeCommand(string RequestId, string Method);
|
|
4
|
+
|
|
5
|
+
public sealed record GetCatalogDetailsCommand(
|
|
6
|
+
string RequestId,
|
|
7
|
+
IReadOnlyList<string> ItemIds)
|
|
8
|
+
: BridgeCommand(RequestId, NativeBridgeProtocol.CatalogGetDetailsMethod);
|
|
9
|
+
|
|
10
|
+
public sealed record SignInCommand(string RequestId)
|
|
11
|
+
: BridgeCommand(RequestId, NativeBridgeProtocol.IdentitySignInMethod);
|
|
12
|
+
|
|
13
|
+
public sealed record ListPurchasesCommand(string RequestId)
|
|
14
|
+
: BridgeCommand(RequestId, NativeBridgeProtocol.PurchaseListMethod);
|
|
15
|
+
|
|
16
|
+
public sealed record RequestPurchaseCommand(string RequestId, string ItemId)
|
|
17
|
+
: BridgeCommand(RequestId, NativeBridgeProtocol.PurchaseRequestMethod);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
<PropertyGroup>
|
|
3
|
+
<TargetFramework>net8.0</TargetFramework>
|
|
4
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
5
|
+
<Nullable>enable</Nullable>
|
|
6
|
+
<LangVersion>12</LangVersion>
|
|
7
|
+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
8
|
+
</PropertyGroup>
|
|
9
|
+
</Project>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
namespace Mpgd.MicrosoftStore.Host;
|
|
2
|
+
|
|
3
|
+
// Only these web-facing results are serialized. SDK credentials and backend
|
|
4
|
+
// collections tickets stay inside the game-owned implementation.
|
|
5
|
+
public sealed record NativeProductPrice(string CurrencyCode, string Formatted);
|
|
6
|
+
public sealed record NativeProductDetail(string ItemId, string Title, string? Description, NativeProductPrice Price);
|
|
7
|
+
public sealed record NativeProductDetailsResult(IReadOnlyList<NativeProductDetail> Items);
|
|
8
|
+
public sealed record NativePlayerSession(long ExpiresAt, string PlayerId, string SessionToken);
|
|
9
|
+
public sealed record NativePurchase(string ItemId, string PurchaseToken);
|
|
10
|
+
public sealed record NativePurchasesResult(IReadOnlyList<NativePurchase> Items);
|
|
11
|
+
public sealed record NativePurchaseResult(string Status, string? PurchaseToken = null);
|
|
12
|
+
|
|
13
|
+
public interface INativeStoreOperations
|
|
14
|
+
{
|
|
15
|
+
Task<NativeProductDetailsResult> GetDetailsAsync(GetCatalogDetailsCommand command, CancellationToken cancellation);
|
|
16
|
+
Task<NativePlayerSession> SignInAsync(CancellationToken cancellation);
|
|
17
|
+
Task<NativePurchasesResult> ListPurchasesAsync(CancellationToken cancellation);
|
|
18
|
+
Task<NativePurchaseResult> RequestPurchaseAsync(RequestPurchaseCommand command, CancellationToken cancellation);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// <summary>Run the callback on the owning window's UI thread. Cancellation must
|
|
22
|
+
/// prevent callbacks that are still queued from starting.</summary>
|
|
23
|
+
public interface INativeBridgeDispatcher
|
|
24
|
+
{
|
|
25
|
+
Task RunAsync(Func<Task> callback, CancellationToken cancellation);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public enum NativeBridgeFailure { UserCancelled, IdentityUnavailable, StoreUnavailable }
|
|
29
|
+
|
|
30
|
+
public sealed class NativeBridgeOperationException(NativeBridgeFailure failure) : Exception
|
|
31
|
+
{
|
|
32
|
+
public NativeBridgeFailure Failure { get; } = failure;
|
|
33
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
namespace Mpgd.MicrosoftStore.Host;
|
|
2
|
+
|
|
3
|
+
/// <summary>A window-owned native bridge. InvalidateDocument on every top-level
|
|
4
|
+
/// accepted navigation start, activate when an allowed document commits, invalidate on
|
|
5
|
+
/// navigation failure, and dispose when the window closes. PostResponse must
|
|
6
|
+
/// synchronously send JSON.</summary>
|
|
7
|
+
public sealed class NativeBridgeHost : IDisposable
|
|
8
|
+
{
|
|
9
|
+
private readonly NativeBridgeOrigin origin;
|
|
10
|
+
private readonly HashSet<string> allowedItems;
|
|
11
|
+
private readonly INativeStoreOperations operations;
|
|
12
|
+
private readonly INativeBridgeDispatcher dispatcher;
|
|
13
|
+
private readonly Action<string> postResponse;
|
|
14
|
+
private readonly SemaphoreSlim serial = new(1, 1);
|
|
15
|
+
private readonly object sync = new();
|
|
16
|
+
private Document? document;
|
|
17
|
+
private bool closed;
|
|
18
|
+
private readonly int maximumPendingRequests;
|
|
19
|
+
|
|
20
|
+
private sealed record Document(CancellationTokenSource Lifetime, NativeBridgeRequestGate Gate);
|
|
21
|
+
|
|
22
|
+
public NativeBridgeHost(NativeBridgeOrigin origin, IEnumerable<string> allowedItemIds,
|
|
23
|
+
INativeStoreOperations operations, INativeBridgeDispatcher dispatcher,
|
|
24
|
+
Action<string> postResponse, int maximumPendingRequests = 64)
|
|
25
|
+
{
|
|
26
|
+
ArgumentNullException.ThrowIfNull(origin);
|
|
27
|
+
ArgumentNullException.ThrowIfNull(allowedItemIds);
|
|
28
|
+
ArgumentNullException.ThrowIfNull(operations);
|
|
29
|
+
ArgumentNullException.ThrowIfNull(dispatcher);
|
|
30
|
+
ArgumentNullException.ThrowIfNull(postResponse);
|
|
31
|
+
if (maximumPendingRequests < 1) throw new ArgumentOutOfRangeException(nameof(maximumPendingRequests));
|
|
32
|
+
this.origin = origin;
|
|
33
|
+
allowedItems = new(allowedItemIds, StringComparer.Ordinal);
|
|
34
|
+
if (allowedItems.Any(item => !NativeBridgeProtocol.IsValidIdentifier(item, NativeBridgeProtocol.MaximumItemIdLength)))
|
|
35
|
+
throw new ArgumentException("Invalid product allowlist.", nameof(allowedItemIds));
|
|
36
|
+
this.operations = operations;
|
|
37
|
+
this.dispatcher = dispatcher;
|
|
38
|
+
this.postResponse = postResponse;
|
|
39
|
+
this.maximumPendingRequests = maximumPendingRequests;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public bool ActivateDocument(string source)
|
|
43
|
+
{
|
|
44
|
+
InvalidateDocument();
|
|
45
|
+
lock (sync)
|
|
46
|
+
{
|
|
47
|
+
if (closed || !origin.Allows(source)) return false;
|
|
48
|
+
document = new(new(), new(maximumPendingRequests));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public void InvalidateDocument()
|
|
54
|
+
{
|
|
55
|
+
Document? previous;
|
|
56
|
+
lock (sync)
|
|
57
|
+
{
|
|
58
|
+
previous = document;
|
|
59
|
+
document = null;
|
|
60
|
+
}
|
|
61
|
+
// Cancellation callbacks are game code and must run outside the state lock.
|
|
62
|
+
try { previous?.Lifetime.Cancel(); }
|
|
63
|
+
catch (AggregateException)
|
|
64
|
+
{
|
|
65
|
+
// Cancel invokes every registered callback before aggregating its
|
|
66
|
+
// exceptions. State is already revoked; a game callback must not
|
|
67
|
+
// abort window cleanup or allow an old document to publish again.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public async Task HandleMessageAsync(string source, string message, CancellationToken cancellation = default)
|
|
72
|
+
{
|
|
73
|
+
Document current;
|
|
74
|
+
lock (sync)
|
|
75
|
+
{
|
|
76
|
+
if (closed || document is null || !origin.Allows(source)) return;
|
|
77
|
+
current = document;
|
|
78
|
+
}
|
|
79
|
+
using var linked = CancellationTokenSource.CreateLinkedTokenSource(current.Lifetime.Token, cancellation);
|
|
80
|
+
var token = linked.Token;
|
|
81
|
+
try
|
|
82
|
+
{
|
|
83
|
+
if (!NativeBridgeProtocol.TryParseCommand(message, allowedItems, out var command, out var parseError) || command is null)
|
|
84
|
+
{
|
|
85
|
+
if (NativeBridgeProtocol.TryReadRouting(message, out var id, out var method))
|
|
86
|
+
await SendAsync(current, NativeBridgeResult.Failure(id, method, parseError), token).ConfigureAwait(false);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!current.Gate.TryEnter(command.RequestId, out var lease, out var gateError))
|
|
90
|
+
{
|
|
91
|
+
await SendAsync(current, NativeBridgeResult.Failure(command.RequestId, command.Method, gateError), token).ConfigureAwait(false);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
using (lease)
|
|
95
|
+
{
|
|
96
|
+
await serial.WaitAsync(token).ConfigureAwait(false);
|
|
97
|
+
string response;
|
|
98
|
+
try
|
|
99
|
+
{
|
|
100
|
+
token.ThrowIfCancellationRequested();
|
|
101
|
+
string? success = null;
|
|
102
|
+
await dispatcher.RunAsync(async () =>
|
|
103
|
+
{
|
|
104
|
+
token.ThrowIfCancellationRequested();
|
|
105
|
+
object result = command switch
|
|
106
|
+
{
|
|
107
|
+
GetCatalogDetailsCommand catalog => await operations.GetDetailsAsync(catalog, token),
|
|
108
|
+
SignInCommand => await operations.SignInAsync(token),
|
|
109
|
+
ListPurchasesCommand => await operations.ListPurchasesAsync(token),
|
|
110
|
+
RequestPurchaseCommand purchase => await operations.RequestPurchaseAsync(purchase, token),
|
|
111
|
+
_ => throw new InvalidOperationException(),
|
|
112
|
+
};
|
|
113
|
+
token.ThrowIfCancellationRequested();
|
|
114
|
+
success = NativeBridgeResult.Success(command, result, allowedItems);
|
|
115
|
+
}, token).ConfigureAwait(false);
|
|
116
|
+
response = success ?? throw new InvalidOperationException();
|
|
117
|
+
}
|
|
118
|
+
catch (OperationCanceledException) when (token.IsCancellationRequested) { return; }
|
|
119
|
+
catch (NativeBridgeOperationException error)
|
|
120
|
+
{
|
|
121
|
+
if (command is RequestPurchaseCommand && error.Failure == NativeBridgeFailure.UserCancelled)
|
|
122
|
+
{
|
|
123
|
+
response = NativeBridgeResult.Success(command, new NativePurchaseResult("not-purchased"), allowedItems);
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
{
|
|
127
|
+
string code = error.Failure switch
|
|
128
|
+
{
|
|
129
|
+
NativeBridgeFailure.UserCancelled => "USER_CANCELLED",
|
|
130
|
+
NativeBridgeFailure.IdentityUnavailable => "IDENTITY_UNAVAILABLE",
|
|
131
|
+
NativeBridgeFailure.StoreUnavailable => "STORE_UNAVAILABLE",
|
|
132
|
+
_ => "NATIVE_OPERATION_FAILED",
|
|
133
|
+
};
|
|
134
|
+
response = NativeBridgeResult.Failure(command.RequestId, command.Method, code);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch { response = NativeBridgeResult.Failure(command.RequestId, command.Method, "NATIVE_OPERATION_FAILED"); }
|
|
138
|
+
finally { serial.Release(); }
|
|
139
|
+
await SendAsync(current, response, token).ConfigureAwait(false);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (OperationCanceledException) when (token.IsCancellationRequested) { }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private Task SendAsync(Document current, string response, CancellationToken token) => dispatcher.RunAsync(() =>
|
|
146
|
+
{
|
|
147
|
+
lock (sync)
|
|
148
|
+
{
|
|
149
|
+
if (!closed && ReferenceEquals(document, current) && !token.IsCancellationRequested)
|
|
150
|
+
postResponse(response);
|
|
151
|
+
}
|
|
152
|
+
return Task.CompletedTask;
|
|
153
|
+
}, token);
|
|
154
|
+
|
|
155
|
+
public void Dispose()
|
|
156
|
+
{
|
|
157
|
+
lock (sync) { if (closed) return; closed = true; }
|
|
158
|
+
InvalidateDocument();
|
|
159
|
+
// In-flight callbacks retain their document and may still observe its
|
|
160
|
+
// token. The semaphore remains valid until this host is collected.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
namespace Mpgd.MicrosoftStore.Host;
|
|
2
|
+
|
|
3
|
+
public sealed class NativeBridgeOrigin
|
|
4
|
+
{
|
|
5
|
+
private readonly Uri origin;
|
|
6
|
+
|
|
7
|
+
public NativeBridgeOrigin(Uri startUri)
|
|
8
|
+
{
|
|
9
|
+
ArgumentNullException.ThrowIfNull(startUri);
|
|
10
|
+
if (!startUri.IsAbsoluteUri || startUri.Scheme != Uri.UriSchemeHttps || startUri.UserInfo.Length != 0)
|
|
11
|
+
throw new ArgumentException("A credential-free HTTPS start URI is required.", nameof(startUri));
|
|
12
|
+
origin = startUri;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public bool Allows(string? source) => Uri.TryCreate(source, UriKind.Absolute, out var uri)
|
|
16
|
+
&& uri.Scheme == Uri.UriSchemeHttps && uri.UserInfo.Length == 0
|
|
17
|
+
&& string.Equals(uri.IdnHost, origin.IdnHost, StringComparison.OrdinalIgnoreCase)
|
|
18
|
+
&& uri.Port == origin.Port;
|
|
19
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
using System.Globalization;
|
|
2
|
+
using System.Text.Json;
|
|
3
|
+
|
|
4
|
+
namespace Mpgd.MicrosoftStore.Host;
|
|
5
|
+
|
|
6
|
+
public static class NativeBridgeProtocol
|
|
7
|
+
{
|
|
8
|
+
public const string Protocol = "mpgd.microsoft-store.native.v1";
|
|
9
|
+
public const string CatalogGetDetailsMethod = "catalog.getDetails";
|
|
10
|
+
public const string IdentitySignInMethod = "identity.signIn";
|
|
11
|
+
public const string PurchaseListMethod = "purchase.list";
|
|
12
|
+
public const string PurchaseRequestMethod = "purchase.request";
|
|
13
|
+
|
|
14
|
+
public const string InvalidRequestError = "INVALID_REQUEST";
|
|
15
|
+
public const string BridgeBusyError = "BRIDGE_BUSY";
|
|
16
|
+
public const string DuplicateRequestError = "DUPLICATE_REQUEST";
|
|
17
|
+
public const string ProductNotAllowedError = "PRODUCT_NOT_ALLOWED";
|
|
18
|
+
public const string UnsupportedMethodError = "UNSUPPORTED_METHOD";
|
|
19
|
+
|
|
20
|
+
public const int MaximumMessageLength = 64 * 1024;
|
|
21
|
+
public const int MaximumItemCount = 64;
|
|
22
|
+
public const int MaximumItemIdLength = 256;
|
|
23
|
+
public const int MaximumMethodNameLength = 64;
|
|
24
|
+
public const int MaximumRequestIdLength = 128;
|
|
25
|
+
|
|
26
|
+
private static readonly JsonDocumentOptions DocumentOptions = new()
|
|
27
|
+
{
|
|
28
|
+
AllowTrailingCommas = false,
|
|
29
|
+
CommentHandling = JsonCommentHandling.Disallow,
|
|
30
|
+
MaxDepth = 8,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
private static readonly HashSet<string> RequestProperties =
|
|
34
|
+
["protocol", "requestId", "method", "payload"];
|
|
35
|
+
private static readonly HashSet<string> EmptyProperties = [];
|
|
36
|
+
private static readonly HashSet<string> CatalogPayloadProperties = ["itemIds"];
|
|
37
|
+
private static readonly HashSet<string> PurchasePayloadProperties = ["itemId"];
|
|
38
|
+
|
|
39
|
+
public static bool TryParseCommand(
|
|
40
|
+
string message,
|
|
41
|
+
IReadOnlySet<string> allowedItemIds,
|
|
42
|
+
out BridgeCommand? command,
|
|
43
|
+
out string errorCode)
|
|
44
|
+
{
|
|
45
|
+
command = null;
|
|
46
|
+
errorCode = InvalidRequestError;
|
|
47
|
+
if (string.IsNullOrEmpty(message) || message.Length > MaximumMessageLength)
|
|
48
|
+
{
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try
|
|
53
|
+
{
|
|
54
|
+
using JsonDocument document = JsonDocument.Parse(message, DocumentOptions);
|
|
55
|
+
JsonElement root = document.RootElement;
|
|
56
|
+
if (root.ValueKind != JsonValueKind.Object || !HasExactProperties(root, RequestProperties))
|
|
57
|
+
{
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!TryReadExactString(root, "protocol", Protocol.Length, out string protocol)
|
|
62
|
+
|| protocol != Protocol
|
|
63
|
+
|| !TryReadIdentifier(
|
|
64
|
+
root,
|
|
65
|
+
"requestId",
|
|
66
|
+
MaximumRequestIdLength,
|
|
67
|
+
out string requestId)
|
|
68
|
+
|| !TryReadIdentifier(
|
|
69
|
+
root,
|
|
70
|
+
"method",
|
|
71
|
+
MaximumMethodNameLength,
|
|
72
|
+
out string method)
|
|
73
|
+
|| !root.TryGetProperty("payload", out JsonElement payload)
|
|
74
|
+
|| payload.ValueKind != JsonValueKind.Object)
|
|
75
|
+
{
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
switch (method)
|
|
80
|
+
{
|
|
81
|
+
case CatalogGetDetailsMethod:
|
|
82
|
+
return TryParseCatalogCommand(
|
|
83
|
+
requestId,
|
|
84
|
+
payload,
|
|
85
|
+
allowedItemIds,
|
|
86
|
+
out command,
|
|
87
|
+
out errorCode);
|
|
88
|
+
case IdentitySignInMethod:
|
|
89
|
+
if (!HasExactProperties(payload, EmptyProperties))
|
|
90
|
+
{
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
command = new SignInCommand(requestId);
|
|
95
|
+
return true;
|
|
96
|
+
case PurchaseListMethod:
|
|
97
|
+
if (!HasExactProperties(payload, EmptyProperties))
|
|
98
|
+
{
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
command = new ListPurchasesCommand(requestId);
|
|
103
|
+
return true;
|
|
104
|
+
case PurchaseRequestMethod:
|
|
105
|
+
return TryParsePurchaseCommand(
|
|
106
|
+
requestId,
|
|
107
|
+
payload,
|
|
108
|
+
allowedItemIds,
|
|
109
|
+
out command,
|
|
110
|
+
out errorCode);
|
|
111
|
+
default:
|
|
112
|
+
errorCode = UnsupportedMethodError;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (JsonException)
|
|
117
|
+
{
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
catch (InvalidOperationException)
|
|
121
|
+
{
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public static bool TryReadRouting(
|
|
127
|
+
string message,
|
|
128
|
+
out string requestId,
|
|
129
|
+
out string method)
|
|
130
|
+
{
|
|
131
|
+
requestId = string.Empty;
|
|
132
|
+
method = string.Empty;
|
|
133
|
+
if (string.IsNullOrEmpty(message) || message.Length > MaximumMessageLength)
|
|
134
|
+
{
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try
|
|
139
|
+
{
|
|
140
|
+
using JsonDocument document = JsonDocument.Parse(message, DocumentOptions);
|
|
141
|
+
JsonElement root = document.RootElement;
|
|
142
|
+
if (root.ValueKind != JsonValueKind.Object
|
|
143
|
+
|| !TryReadExactString(root, "protocol", Protocol.Length, out string protocol)
|
|
144
|
+
|| protocol != Protocol
|
|
145
|
+
|| !TryReadIdentifier(
|
|
146
|
+
root,
|
|
147
|
+
"requestId",
|
|
148
|
+
MaximumRequestIdLength,
|
|
149
|
+
out requestId)
|
|
150
|
+
|| !TryReadIdentifier(
|
|
151
|
+
root,
|
|
152
|
+
"method",
|
|
153
|
+
MaximumMethodNameLength,
|
|
154
|
+
out method))
|
|
155
|
+
{
|
|
156
|
+
requestId = string.Empty;
|
|
157
|
+
method = string.Empty;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
catch (JsonException)
|
|
164
|
+
{
|
|
165
|
+
requestId = string.Empty;
|
|
166
|
+
method = string.Empty;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
catch (InvalidOperationException)
|
|
170
|
+
{
|
|
171
|
+
requestId = string.Empty;
|
|
172
|
+
method = string.Empty;
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private static bool TryParseCatalogCommand(
|
|
178
|
+
string requestId,
|
|
179
|
+
JsonElement payload,
|
|
180
|
+
IReadOnlySet<string> allowedItemIds,
|
|
181
|
+
out BridgeCommand? command,
|
|
182
|
+
out string errorCode)
|
|
183
|
+
{
|
|
184
|
+
command = null;
|
|
185
|
+
errorCode = InvalidRequestError;
|
|
186
|
+
if (!HasExactProperties(payload, CatalogPayloadProperties)
|
|
187
|
+
|| !payload.TryGetProperty("itemIds", out JsonElement itemIds)
|
|
188
|
+
|| itemIds.ValueKind != JsonValueKind.Array
|
|
189
|
+
|| itemIds.GetArrayLength() is < 1 or > MaximumItemCount)
|
|
190
|
+
{
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
List<string> normalized = new(itemIds.GetArrayLength());
|
|
195
|
+
HashSet<string> unique = new(StringComparer.Ordinal);
|
|
196
|
+
foreach (JsonElement itemIdValue in itemIds.EnumerateArray())
|
|
197
|
+
{
|
|
198
|
+
if (!TryReadIdentifier(itemIdValue, MaximumItemIdLength, out string itemId)
|
|
199
|
+
|| !unique.Add(itemId))
|
|
200
|
+
{
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!allowedItemIds.Contains(itemId))
|
|
205
|
+
{
|
|
206
|
+
errorCode = ProductNotAllowedError;
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
normalized.Add(itemId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
command = new GetCatalogDetailsCommand(requestId, normalized.AsReadOnly());
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private static bool TryParsePurchaseCommand(
|
|
218
|
+
string requestId,
|
|
219
|
+
JsonElement payload,
|
|
220
|
+
IReadOnlySet<string> allowedItemIds,
|
|
221
|
+
out BridgeCommand? command,
|
|
222
|
+
out string errorCode)
|
|
223
|
+
{
|
|
224
|
+
command = null;
|
|
225
|
+
errorCode = InvalidRequestError;
|
|
226
|
+
if (!HasExactProperties(payload, PurchasePayloadProperties)
|
|
227
|
+
|| !TryReadIdentifier(
|
|
228
|
+
payload,
|
|
229
|
+
"itemId",
|
|
230
|
+
MaximumItemIdLength,
|
|
231
|
+
out string itemId))
|
|
232
|
+
{
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!allowedItemIds.Contains(itemId))
|
|
237
|
+
{
|
|
238
|
+
errorCode = ProductNotAllowedError;
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
command = new RequestPurchaseCommand(requestId, itemId);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private static bool HasExactProperties(JsonElement value, IReadOnlySet<string> expected)
|
|
247
|
+
{
|
|
248
|
+
HashSet<string> seen = new(StringComparer.Ordinal);
|
|
249
|
+
foreach (JsonProperty property in value.EnumerateObject())
|
|
250
|
+
{
|
|
251
|
+
if (!expected.Contains(property.Name) || !seen.Add(property.Name))
|
|
252
|
+
{
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return seen.Count == expected.Count;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private static bool TryReadExactString(
|
|
261
|
+
JsonElement owner,
|
|
262
|
+
string propertyName,
|
|
263
|
+
int expectedLength,
|
|
264
|
+
out string value)
|
|
265
|
+
{
|
|
266
|
+
value = string.Empty;
|
|
267
|
+
if (!owner.TryGetProperty(propertyName, out JsonElement candidate)
|
|
268
|
+
|| candidate.ValueKind != JsonValueKind.String
|
|
269
|
+
|| candidate.GetString() is not string text
|
|
270
|
+
|| text.Length != expectedLength)
|
|
271
|
+
{
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
value = text;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private static bool TryReadIdentifier(
|
|
280
|
+
JsonElement owner,
|
|
281
|
+
string propertyName,
|
|
282
|
+
int maximumLength,
|
|
283
|
+
out string value)
|
|
284
|
+
{
|
|
285
|
+
value = string.Empty;
|
|
286
|
+
return owner.TryGetProperty(propertyName, out JsonElement candidate)
|
|
287
|
+
&& TryReadIdentifier(candidate, maximumLength, out value);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private static bool TryReadIdentifier(
|
|
291
|
+
JsonElement candidate,
|
|
292
|
+
int maximumLength,
|
|
293
|
+
out string value)
|
|
294
|
+
{
|
|
295
|
+
value = string.Empty;
|
|
296
|
+
if (candidate.ValueKind != JsonValueKind.String
|
|
297
|
+
|| candidate.GetString() is not string text
|
|
298
|
+
|| !IsValidIdentifier(text, maximumLength))
|
|
299
|
+
{
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
value = text;
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
internal static bool IsValidIdentifier(string? text, int maximumLength) => text is not null
|
|
308
|
+
&& text.Length is >= 1 && text.Length <= maximumLength && text == text.Trim()
|
|
309
|
+
&& !ContainsForbiddenIdentifierCharacter(text);
|
|
310
|
+
|
|
311
|
+
private static bool ContainsForbiddenIdentifierCharacter(string value)
|
|
312
|
+
{
|
|
313
|
+
for (int index = 0; index < value.Length; index += 1)
|
|
314
|
+
{
|
|
315
|
+
char character = value[index];
|
|
316
|
+
if (char.IsSurrogate(character) && !char.IsSurrogatePair(value, index))
|
|
317
|
+
{
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory(value, index);
|
|
322
|
+
if (category is UnicodeCategory.Control or UnicodeCategory.Format)
|
|
323
|
+
{
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (char.IsHighSurrogate(character))
|
|
328
|
+
{
|
|
329
|
+
index += 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
using System.Diagnostics.CodeAnalysis;
|
|
2
|
+
|
|
3
|
+
namespace Mpgd.MicrosoftStore.Host;
|
|
4
|
+
|
|
5
|
+
public sealed class NativeBridgeRequestGate
|
|
6
|
+
{
|
|
7
|
+
private readonly HashSet<string> activeRequestIds = new(StringComparer.Ordinal);
|
|
8
|
+
private readonly int maximumPendingRequests;
|
|
9
|
+
private readonly object sync = new();
|
|
10
|
+
private int pendingRequests;
|
|
11
|
+
|
|
12
|
+
public NativeBridgeRequestGate(
|
|
13
|
+
int maximumPendingRequests = 64)
|
|
14
|
+
{
|
|
15
|
+
if (maximumPendingRequests < 1)
|
|
16
|
+
{
|
|
17
|
+
throw new ArgumentOutOfRangeException(nameof(maximumPendingRequests));
|
|
18
|
+
}
|
|
19
|
+
this.maximumPendingRequests = maximumPendingRequests;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public bool TryEnter(
|
|
23
|
+
string requestId,
|
|
24
|
+
[NotNullWhen(true)] out NativeBridgeRequestLease? lease,
|
|
25
|
+
out string errorCode)
|
|
26
|
+
{
|
|
27
|
+
ArgumentException.ThrowIfNullOrWhiteSpace(requestId);
|
|
28
|
+
lock (sync)
|
|
29
|
+
{
|
|
30
|
+
lease = null;
|
|
31
|
+
if (activeRequestIds.Contains(requestId))
|
|
32
|
+
{
|
|
33
|
+
errorCode = NativeBridgeProtocol.DuplicateRequestError;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (pendingRequests >= maximumPendingRequests)
|
|
37
|
+
{
|
|
38
|
+
errorCode = NativeBridgeProtocol.BridgeBusyError;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
activeRequestIds.Add(requestId);
|
|
43
|
+
pendingRequests += 1;
|
|
44
|
+
lease = new NativeBridgeRequestLease(() => Release(requestId));
|
|
45
|
+
errorCode = string.Empty;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private void Release(string requestId)
|
|
51
|
+
{
|
|
52
|
+
lock (sync)
|
|
53
|
+
{
|
|
54
|
+
if (pendingRequests < 1 || !activeRequestIds.Remove(requestId))
|
|
55
|
+
{
|
|
56
|
+
throw new InvalidOperationException("Native bridge request lease underflow.");
|
|
57
|
+
}
|
|
58
|
+
pendingRequests -= 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public sealed class NativeBridgeRequestLease : IDisposable
|
|
64
|
+
{
|
|
65
|
+
private Action? release;
|
|
66
|
+
|
|
67
|
+
internal NativeBridgeRequestLease(Action release)
|
|
68
|
+
{
|
|
69
|
+
this.release = release;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public void Dispose()
|
|
73
|
+
{
|
|
74
|
+
Interlocked.Exchange(ref release, null)?.Invoke();
|
|
75
|
+
}
|
|
76
|
+
}
|