From 4a50c6c579d473ac914136c782e26fe940e429b1 Mon Sep 17 00:00:00 2001 From: Allan Ritchie Date: Wed, 25 Mar 2026 17:13:05 -0400 Subject: [PATCH 1/2] #75 Initial commit --- src/MauiSherpa.Core/Interfaces.cs | 14 ++ .../Services/DeepLinkValidationService.cs | 142 +++++++++++++++++ src/MauiSherpa.MacOS/MacOSMauiProgram.cs | 1 + .../Components/DeviceToolsTab.razor | 106 +++++++++++++ src/MauiSherpa/Components/SimToolsTab.razor | 149 ++++++++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 src/MauiSherpa.Core/Services/DeepLinkValidationService.cs diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 33d0a977..88b3366a 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -3549,3 +3549,17 @@ public interface IFormPage /// Task GetResultAsync(); } + +public interface IDeepLinkValidationService +{ + Task ValidateAppleAppSiteAssociationAsync(string domain); + Task ValidateAssetLinksAsync(string domain); +} + +public record AasaValidationResult(bool Found, bool Valid, string? RawJson, + IReadOnlyList Apps, string? ErrorMessage); +public record AasaAppEntry(string AppId, IReadOnlyList Paths); + +public record AssetLinksValidationResult(bool Found, bool Valid, string? RawJson, + IReadOnlyList Entries, string? ErrorMessage); +public record AssetLinksEntry(string Package, string? Sha256Fingerprint); diff --git a/src/MauiSherpa.Core/Services/DeepLinkValidationService.cs b/src/MauiSherpa.Core/Services/DeepLinkValidationService.cs new file mode 100644 index 00000000..be3de151 --- /dev/null +++ b/src/MauiSherpa.Core/Services/DeepLinkValidationService.cs @@ -0,0 +1,142 @@ +using System.Text.Json; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +public class DeepLinkValidationService : IDeepLinkValidationService +{ + static readonly HttpClient Http = new() + { + Timeout = TimeSpan.FromSeconds(10) + }; + + public async Task ValidateAppleAppSiteAssociationAsync(string domain) + { + try + { + var url = $"https://{domain}/.well-known/apple-app-site-association"; + var response = await Http.GetAsync(url).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return new AasaValidationResult(false, false, null, Array.Empty(), + $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); + } + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var doc = JsonDocument.Parse(json); + var apps = new List(); + + if (doc.RootElement.TryGetProperty("applinks", out var applinks) && + applinks.TryGetProperty("details", out var details)) + { + foreach (var detail in details.EnumerateArray()) + { + var appIds = new List(); + var paths = new List(); + + // Modern format: appIDs array + if (detail.TryGetProperty("appIDs", out var appIdsEl)) + { + foreach (var id in appIdsEl.EnumerateArray()) + appIds.Add(id.GetString() ?? ""); + } + // Legacy format: appID string + else if (detail.TryGetProperty("appID", out var appIdEl)) + { + appIds.Add(appIdEl.GetString() ?? ""); + } + + // Modern format: components array with path + if (detail.TryGetProperty("components", out var components)) + { + foreach (var comp in components.EnumerateArray()) + { + if (comp.TryGetProperty("/", out var pathEl)) + paths.Add(pathEl.GetString() ?? ""); + } + } + // Legacy format: paths array + else if (detail.TryGetProperty("paths", out var pathsEl)) + { + foreach (var p in pathsEl.EnumerateArray()) + paths.Add(p.GetString() ?? ""); + } + + foreach (var appId in appIds) + apps.Add(new AasaAppEntry(appId, paths)); + + if (appIds.Count == 0 && paths.Count > 0) + apps.Add(new AasaAppEntry("(unknown)", paths)); + } + } + + return new AasaValidationResult(true, apps.Count > 0, json, apps, null); + } + catch (TaskCanceledException) + { + return new AasaValidationResult(false, false, null, Array.Empty(), "Request timed out"); + } + catch (Exception ex) + { + return new AasaValidationResult(false, false, null, Array.Empty(), ex.Message); + } + } + + public async Task ValidateAssetLinksAsync(string domain) + { + try + { + var url = $"https://{domain}/.well-known/assetlinks.json"; + var response = await Http.GetAsync(url).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return new AssetLinksValidationResult(false, false, null, Array.Empty(), + $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); + } + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var doc = JsonDocument.Parse(json); + var entries = new List(); + + foreach (var element in doc.RootElement.EnumerateArray()) + { + if (!element.TryGetProperty("relation", out var relation)) + continue; + + var hasHandleAll = false; + foreach (var rel in relation.EnumerateArray()) + { + if (rel.GetString()?.Contains("delegate_permission/common.handle_all_urls") == true) + { + hasHandleAll = true; + break; + } + } + + if (!hasHandleAll || !element.TryGetProperty("target", out var target)) + continue; + + var package = target.TryGetProperty("package_name", out var pkg) ? pkg.GetString() : null; + if (package == null) continue; + + string? fingerprint = null; + if (target.TryGetProperty("sha256_cert_fingerprints", out var fps) && fps.GetArrayLength() > 0) + fingerprint = fps[0].GetString(); + + entries.Add(new AssetLinksEntry(package, fingerprint)); + } + + return new AssetLinksValidationResult(true, entries.Count > 0, json, entries, null); + } + catch (TaskCanceledException) + { + return new AssetLinksValidationResult(false, false, null, Array.Empty(), "Request timed out"); + } + catch (Exception ex) + { + return new AssetLinksValidationResult(false, false, null, Array.Empty(), ex.Message); + } + } +} diff --git a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs index 42424c98..06fd1654 100644 --- a/src/MauiSherpa.MacOS/MacOSMauiProgram.cs +++ b/src/MauiSherpa.MacOS/MacOSMauiProgram.cs @@ -139,6 +139,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Cloud Secrets Storage services builder.Services.AddSingleton(); diff --git a/src/MauiSherpa/Components/DeviceToolsTab.razor b/src/MauiSherpa/Components/DeviceToolsTab.razor index af850f42..d59f589f 100644 --- a/src/MauiSherpa/Components/DeviceToolsTab.razor +++ b/src/MauiSherpa/Components/DeviceToolsTab.razor @@ -5,6 +5,7 @@ @inject DeviceInspectorService Inspector @inject IAlertService AlertService @inject IDialogService DialogService +@inject IDeepLinkValidationService DeepLinkValidator @implements IDisposable
@@ -204,7 +205,53 @@ disabled="@(isOpeningLink || string.IsNullOrWhiteSpace(deepLinkUrl))"> Launch +
+ @if (assetLinksResult != null) + { +
+
+ + @if (assetLinksResult.Valid) + { + assetlinks.json found — @assetLinksResult.Entries.Count app(s) configured + } + else if (assetLinksResult.Found) + { + assetlinks.json found but no handle_all_urls entries detected + } + else + { + @(assetLinksResult.ErrorMessage ?? "assetlinks.json not found") + } +
+ @if (assetLinksResult.Entries.Count > 0) + { +
+ @foreach (var entry in assetLinksResult.Entries) + { +
+ @entry.Package + @if (entry.Sha256Fingerprint != null) + { + @entry.Sha256Fingerprint[..Math.Min(23, entry.Sha256Fingerprint.Length)]… + } +
+ } +
+ } + @if (assetLinksResult.RawJson != null) + { +
+ Raw JSON +
@FormatJson(assetLinksResult.RawJson)
+
+ } +
+ } @@ -274,6 +321,22 @@ .tool-checkbox-row { display: flex; gap: 0.625rem; align-items: center; height: 28px; } .tool-checkbox { font-size: 0.6875rem; color: var(--text-secondary); display: flex; align-items: center; gap: 0.25rem; cursor: pointer; } .tool-checkbox input { margin: 0; } + + .validation-result { margin-top: 0.375rem; padding: 0.5rem; border-radius: 0.375rem; font-size: 0.6875rem; border: 1px solid var(--border-color); } + .validation-result.success { background: rgba(34,197,94,0.08); border-color: rgba(34,197,94,0.3); } + .validation-result.warning { background: rgba(234,179,8,0.08); border-color: rgba(234,179,8,0.3); } + .validation-result.error { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.3); } + .validation-status { display: flex; align-items: center; gap: 0.375rem; font-weight: 600; } + .validation-status .fa-check-circle { color: #22c55e; } + .validation-status .fa-exclamation-triangle { color: #eab308; } + .validation-status .fa-times-circle { color: #ef4444; } + .validation-entries { margin-top: 0.375rem; display: flex; flex-direction: column; gap: 0.25rem; } + .validation-entry { padding: 0.25rem 0.375rem; background: var(--bg-tertiary); border-radius: 0.25rem; } + .validation-entry strong { color: var(--text-primary); } + .validation-fingerprint { color: var(--text-secondary); margin-left: 0.375rem; font-family: 'Consolas', 'Monaco', monospace; font-size: 0.625rem; } + .validation-raw { margin-top: 0.375rem; } + .validation-raw summary { cursor: pointer; color: var(--text-secondary); font-size: 0.625rem; } + .validation-raw pre { margin: 0.25rem 0 0; padding: 0.375rem; background: var(--bg-tertiary); border-radius: 0.25rem; font-size: 0.625rem; overflow-x: auto; max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; } @code { @@ -307,6 +370,8 @@ // Deep links private string deepLinkUrl = ""; private bool isOpeningLink; + private bool isValidatingLink; + private AssetLinksValidationResult? assetLinksResult; protected override void OnInitialized() { @@ -494,6 +559,47 @@ } } + private async Task ValidateAssetLinks() + { + if (string.IsNullOrWhiteSpace(deepLinkUrl)) return; + isValidatingLink = true; + assetLinksResult = null; + StateHasChanged(); + try + { + var domain = ExtractDomain(deepLinkUrl); + if (domain == null) + { + await AlertService.ShowToastAsync("Enter an https:// URL to validate assetlinks.json"); + return; + } + assetLinksResult = await DeepLinkValidator.ValidateAssetLinksAsync(domain); + } + finally + { + isValidatingLink = false; + StateHasChanged(); + } + } + + private static string? ExtractDomain(string url) + { + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && + (uri.Scheme == "https" || uri.Scheme == "http")) + return uri.Host; + return null; + } + + private static string FormatJson(string json) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + return System.Text.Json.JsonSerializer.Serialize(doc, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + catch { return json; } + } + public void Dispose() { Inspector.DeviceChanged -= OnDeviceChanged; diff --git a/src/MauiSherpa/Components/SimToolsTab.razor b/src/MauiSherpa/Components/SimToolsTab.razor index 917897ff..a4cf80ba 100644 --- a/src/MauiSherpa/Components/SimToolsTab.razor +++ b/src/MauiSherpa/Components/SimToolsTab.razor @@ -5,6 +5,7 @@ @inject SimInspectorService Inspector @inject IAlertService AlertService @inject IDialogService DialogService +@inject IDeepLinkValidationService DeepLinkValidator @implements IDisposable
@@ -179,6 +180,72 @@
+ + +
+
+ + Deep Links +
+
+
+ + +
+
+ + +
+ @if (aasaResult != null) + { +
+
+ + @if (aasaResult.Valid) + { + AASA found — @aasaResult.Apps.Count app(s) configured + } + else if (aasaResult.Found) + { + AASA found but no applinks entries detected + } + else + { + @(aasaResult.ErrorMessage ?? "AASA not found") + } +
+ @if (aasaResult.Apps.Count > 0) + { +
+ @foreach (var app in aasaResult.Apps) + { +
+ @app.AppId + @if (app.Paths.Count > 0) + { + @string.Join(", ", app.Paths) + } +
+ } +
+ } + @if (aasaResult.RawJson != null) + { +
+ Raw JSON +
@FormatJson(aasaResult.RawJson)
+
+ } +
+ } +
+
@code { @@ -269,6 +352,12 @@ private IReadOnlyList? routeWaypoints; private double routeSpeed = 20; + // Deep links + private string deepLinkUrl = ""; + private bool isOpeningLink; + private bool isValidatingLink; + private AasaValidationResult? aasaResult; + // Status bar private string statusTime = ""; private string statusNetwork = ""; @@ -423,6 +512,66 @@ } } + // ── Deep Links ── + + private async Task OpenDeepLink() + { + if (string.IsNullOrWhiteSpace(Udid) || string.IsNullOrWhiteSpace(deepLinkUrl)) return; + isOpeningLink = true; + StateHasChanged(); + try + { + var ok = await SimulatorService.OpenUrlAsync(Udid, deepLinkUrl); + await AlertService.ShowToastAsync(ok ? "Deep link opened" : "Failed to open deep link"); + } + finally + { + isOpeningLink = false; + StateHasChanged(); + } + } + + private async Task ValidateAasa() + { + if (string.IsNullOrWhiteSpace(deepLinkUrl)) return; + isValidatingLink = true; + aasaResult = null; + StateHasChanged(); + try + { + var domain = ExtractDomain(deepLinkUrl); + if (domain == null) + { + await AlertService.ShowToastAsync("Enter an https:// URL to validate AASA"); + return; + } + aasaResult = await DeepLinkValidator.ValidateAppleAppSiteAssociationAsync(domain); + } + finally + { + isValidatingLink = false; + StateHasChanged(); + } + } + + private static string? ExtractDomain(string url) + { + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && + (uri.Scheme == "https" || uri.Scheme == "http")) + return uri.Host; + return null; + } + + private static string FormatJson(string json) + { + try + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + return System.Text.Json.JsonSerializer.Serialize(doc, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + catch { return json; } + } + public void Dispose() { Inspector.DeviceChanged -= OnDeviceChanged; From 649d2f9e20d7b936c9d9d3a210daaee7dc7e1e57 Mon Sep 17 00:00:00 2001 From: Redth Date: Fri, 17 Apr 2026 17:18:43 -0400 Subject: [PATCH 2/2] Support signed PKCS7 AASA and warn on non-http(s) deep links - DeepLinkValidationService now detects CMS/PKCS7-signed AASA blobs (application/pkcs7-mime or DER 0x30) and extracts the inner JSON via SignedCms before parsing, with fallback to raw JSON. - AasaValidationResult gains a Signed flag; SimToolsTab shows '(signed)' on success when the AASA was served as a signed CMS blob. - Dispose JsonDocument instances in the validator. - SimToolsTab and DeviceToolsTab display an inline warning when the deep-link URL is a non-http(s) custom scheme, explaining that AASA / assetlinks.json validation requires an https URL while keeping Launch enabled so users can still try the scheme. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/MauiSherpa.Core/Interfaces.cs | 2 +- .../Services/DeepLinkValidationService.cs | 45 ++++++++++++++++--- .../Components/DeviceToolsTab.razor | 17 +++++++ src/MauiSherpa/Components/SimToolsTab.razor | 19 +++++++- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 88b3366a..fa5a87f4 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -3557,7 +3557,7 @@ public interface IDeepLinkValidationService } public record AasaValidationResult(bool Found, bool Valid, string? RawJson, - IReadOnlyList Apps, string? ErrorMessage); + IReadOnlyList Apps, string? ErrorMessage, bool Signed = false); public record AasaAppEntry(string AppId, IReadOnlyList Paths); public record AssetLinksValidationResult(bool Found, bool Valid, string? RawJson, diff --git a/src/MauiSherpa.Core/Services/DeepLinkValidationService.cs b/src/MauiSherpa.Core/Services/DeepLinkValidationService.cs index be3de151..f97071c1 100644 --- a/src/MauiSherpa.Core/Services/DeepLinkValidationService.cs +++ b/src/MauiSherpa.Core/Services/DeepLinkValidationService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography.Pkcs; +using System.Text; using System.Text.Json; using MauiSherpa.Core.Interfaces; @@ -20,11 +22,13 @@ public async Task ValidateAppleAppSiteAssociationAsync(str if (!response.IsSuccessStatusCode) { return new AasaValidationResult(false, false, null, Array.Empty(), - $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); + $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}", false); } - var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - var doc = JsonDocument.Parse(json); + var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.MediaType; + var (json, wasSigned) = ExtractAasaJson(bytes, contentType); + using var doc = JsonDocument.Parse(json); var apps = new List(); if (doc.RootElement.TryGetProperty("applinks", out var applinks) && @@ -71,18 +75,45 @@ public async Task ValidateAppleAppSiteAssociationAsync(str } } - return new AasaValidationResult(true, apps.Count > 0, json, apps, null); + return new AasaValidationResult(true, apps.Count > 0, json, apps, null, wasSigned); } catch (TaskCanceledException) { - return new AasaValidationResult(false, false, null, Array.Empty(), "Request timed out"); + return new AasaValidationResult(false, false, null, Array.Empty(), "Request timed out", false); } catch (Exception ex) { - return new AasaValidationResult(false, false, null, Array.Empty(), ex.Message); + return new AasaValidationResult(false, false, null, Array.Empty(), ex.Message, false); } } + static (string Json, bool WasSigned) ExtractAasaJson(byte[] bytes, string? contentType) + { + // AASA may be served as a CMS/PKCS7-signed blob (application/pkcs7-mime) or raw JSON. + // DER-encoded CMS starts with 0x30 (SEQUENCE); raw JSON starts with '{' or whitespace. + var looksSigned = + string.Equals(contentType, "application/pkcs7-mime", StringComparison.OrdinalIgnoreCase) || + (bytes.Length > 0 && bytes[0] == 0x30); + + if (looksSigned) + { + try + { + var cms = new SignedCms(); + cms.Decode(bytes); + var inner = cms.ContentInfo.Content; + if (inner is { Length: > 0 }) + return (Encoding.UTF8.GetString(inner), true); + } + catch + { + // Fall through and try to parse as raw JSON anyway. + } + } + + return (Encoding.UTF8.GetString(bytes), false); + } + public async Task ValidateAssetLinksAsync(string domain) { try @@ -97,7 +128,7 @@ public async Task ValidateAssetLinksAsync(string dom } var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - var doc = JsonDocument.Parse(json); + using var doc = JsonDocument.Parse(json); var entries = new List(); foreach (var element in doc.RootElement.EnumerateArray()) diff --git a/src/MauiSherpa/Components/DeviceToolsTab.razor b/src/MauiSherpa/Components/DeviceToolsTab.razor index d59f589f..acf339fc 100644 --- a/src/MauiSherpa/Components/DeviceToolsTab.razor +++ b/src/MauiSherpa/Components/DeviceToolsTab.razor @@ -210,6 +210,13 @@ Validate assetlinks.json + @if (IsNonWebScheme(deepLinkUrl)) + { + + } @if (assetLinksResult != null) {
@@ -337,6 +344,9 @@ .validation-raw { margin-top: 0.375rem; } .validation-raw summary { cursor: pointer; color: var(--text-secondary); font-size: 0.625rem; } .validation-raw pre { margin: 0.25rem 0 0; padding: 0.375rem; background: var(--bg-tertiary); border-radius: 0.25rem; font-size: 0.625rem; overflow-x: auto; max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; } + .deep-link-warning { margin-top: 0.375rem; padding: 0.375rem 0.5rem; border-radius: 0.375rem; font-size: 0.6875rem; background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.3); color: var(--text-primary); display: flex; gap: 0.375rem; align-items: flex-start; } + .deep-link-warning .fa-triangle-exclamation { color: #eab308; margin-top: 2px; } + .deep-link-warning code { background: var(--bg-tertiary); padding: 0 0.25rem; border-radius: 0.1875rem; font-size: 0.625rem; } @code { @@ -590,6 +600,13 @@ return null; } + private static bool IsNonWebScheme(string url) + { + if (string.IsNullOrWhiteSpace(url)) return false; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; + return uri.Scheme != "https" && uri.Scheme != "http"; + } + private static string FormatJson(string json) { try diff --git a/src/MauiSherpa/Components/SimToolsTab.razor b/src/MauiSherpa/Components/SimToolsTab.razor index a4cf80ba..d9c2513a 100644 --- a/src/MauiSherpa/Components/SimToolsTab.razor +++ b/src/MauiSherpa/Components/SimToolsTab.razor @@ -202,6 +202,13 @@ Validate AASA
+ @if (IsNonWebScheme(deepLinkUrl)) + { + + } @if (aasaResult != null) {
@@ -209,7 +216,7 @@ @if (aasaResult.Valid) { - AASA found — @aasaResult.Apps.Count app(s) configured + AASA found — @aasaResult.Apps.Count app(s) configured@(aasaResult.Signed ? " (signed)" : "") } else if (aasaResult.Found) { @@ -332,6 +339,9 @@ .validation-raw { margin-top: 0.375rem; } .validation-raw summary { cursor: pointer; color: var(--text-secondary); font-size: 0.625rem; } .validation-raw pre { margin: 0.25rem 0 0; padding: 0.375rem; background: var(--bg-tertiary); border-radius: 0.25rem; font-size: 0.625rem; overflow-x: auto; max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; } + .deep-link-warning { margin-top: 0.375rem; padding: 0.375rem 0.5rem; border-radius: 0.375rem; font-size: 0.6875rem; background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.3); color: var(--text-primary); display: flex; gap: 0.375rem; align-items: flex-start; } + .deep-link-warning .fa-triangle-exclamation { color: #eab308; margin-top: 2px; } + .deep-link-warning code { background: var(--bg-tertiary); padding: 0 0.25rem; border-radius: 0.1875rem; font-size: 0.625rem; } @code { @@ -562,6 +572,13 @@ return null; } + private static bool IsNonWebScheme(string url) + { + if (string.IsNullOrWhiteSpace(url)) return false; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false; + return uri.Scheme != "https" && uri.Scheme != "http"; + } + private static string FormatJson(string json) { try