@mpgd/cli 0.46.1 → 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.
Files changed (62) hide show
  1. package/dist/capacitor-shell-starter.d.ts +57 -1
  2. package/dist/capacitor-shell-starter.js +311 -34
  3. package/dist/command-launcher.d.ts +13 -0
  4. package/dist/command-launcher.js +24 -0
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +149 -12
  7. package/dist/microsoft-store-submission.js +11 -6
  8. package/dist/native-build-info.json +2 -2
  9. package/dist/native-build-target.js +2413 -345
  10. package/dist/windows-host/Core/BridgeCommand.cs +17 -0
  11. package/dist/windows-host/Core/Mpgd.MicrosoftStore.Host.csproj +9 -0
  12. package/dist/windows-host/Core/NativeBridgeContracts.cs +33 -0
  13. package/dist/windows-host/Core/NativeBridgeHost.cs +162 -0
  14. package/dist/windows-host/Core/NativeBridgeOrigin.cs +19 -0
  15. package/dist/windows-host/Core/NativeBridgeProtocol.cs +335 -0
  16. package/dist/windows-host/Core/NativeBridgeRequestGate.cs +76 -0
  17. package/dist/windows-host/Core/NativeBridgeResult.cs +65 -0
  18. package/dist/windows-host/README.md +118 -0
  19. package/dist/windows-host/WinUI/DispatcherQueueBridgeDispatcher.cs +39 -0
  20. package/dist/windows-host/WinUI/WebView2DocumentTransport.cs +46 -0
  21. package/dist/windows-host/WinUI/WebView2NativeBridge.cs +185 -0
  22. package/dist/windows-host/WinUI/WindowStoreContext.cs +20 -0
  23. package/dist/windows-native-tools.d.ts +4 -0
  24. package/dist/windows-native-tools.js +21 -0
  25. package/native-tools/windows-msix/WindowsMsixAcceptance.psm1 +333 -0
  26. package/package.json +24 -14
  27. package/templates/lite-overlay/.agents/skills/use-mpgd-kit/SKILL.md +22 -0
  28. package/templates/lite-overlay/AGENTS.md +27 -0
  29. package/templates/lite-overlay/README.md +37 -0
  30. package/templates/lite-overlay/agent/acceptance.md +27 -0
  31. package/templates/lite-overlay/agent/brief.md +17 -0
  32. package/templates/lite-overlay/apps/target-devvit/README.md +134 -0
  33. package/templates/lite-overlay/docs/MPGD_KIT_WORKFLOWS.md +37 -0
  34. package/templates/lite-overlay/src/lite/tapGame.ts +77 -0
  35. package/templates/lite-overlay/src/platform/devvitEntrypoint.ts +116 -0
  36. package/templates/lite-overlay/src/platform/devvitInlineMode.css +146 -0
  37. package/templates/lite-overlay/src/runtime/createGame.ts +57 -0
  38. package/templates/phaser-game/README.md +5 -3
  39. package/templates/phaser-game/apps/target-ait/package.json +2 -2
  40. package/templates/phaser-game/apps/target-ait/src/main.ts +17 -0
  41. package/templates/phaser-game/apps/target-ait/src/runtime-config.types.ts +2 -0
  42. package/templates/phaser-game/apps/target-ait/vite.config.ts +27 -0
  43. package/templates/phaser-game/apps/target-devvit/package.json +2 -1
  44. package/templates/phaser-game/apps/target-devvit/src/server/bridge.ts +137 -18
  45. package/templates/phaser-game/apps/target-devvit/src/server/index.ts +35 -1
  46. package/templates/phaser-game/package.json +3 -1
  47. package/templates/phaser-game/pnpm-workspace.yaml +2 -0
  48. package/templates/phaser-game/src/env.d.ts +2 -0
  49. package/templates/phaser-game/src/main.ts +8 -1
  50. package/templates/phaser-game/src/platform/buildGateways/ait.ts +9 -0
  51. package/templates/phaser-game/src/platform/buildGateways/aitSandbox.ts +13 -1
  52. package/templates/phaser-game/src/platform/buildGateways/capacitorAndroid.ts +51 -2
  53. package/templates/phaser-game/src/platform/buildGateways/capacitorIos.ts +51 -2
  54. package/templates/phaser-game/src/platform/buildGateways/microsoftStore.ts +59 -26
  55. package/templates/phaser-game/src/platform/buildGateways/reddit.ts +36 -1
  56. package/templates/phaser-game/src/platform/buildGateways/verse8.ts +39 -2
  57. package/templates/phaser-game/src/platform/commerceAccountBinding.ts +79 -0
  58. package/templates/phaser-game/src/platform/commerceProducts.ts +69 -0
  59. package/templates/phaser-game/src/platform/gameServices.ts +22 -2
  60. package/templates/phaser-game/src/platform/monetizationJournal.ts +286 -0
  61. package/templates/phaser-game/src/platform/verse8Agent8Client.ts +22 -0
  62. 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
+ }