diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 33d0a97..fa5a87f 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, bool Signed = false); +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 0000000..f97071c --- /dev/null +++ b/src/MauiSherpa.Core/Services/DeepLinkValidationService.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography.Pkcs; +using System.Text; +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}", false); + } + + 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) && + 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, wasSigned); + } + catch (TaskCanceledException) + { + 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, 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 + { + 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); + using 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 42424c9..06fd165 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 af850f4..acf339f 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,60 @@ disabled="@(isOpeningLink || string.IsNullOrWhiteSpace(deepLinkUrl))"> Launch +
+ @if (IsNonWebScheme(deepLinkUrl)) + { + + } + @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 +328,25 @@ .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; } + .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 { @@ -307,6 +380,8 @@ // Deep links private string deepLinkUrl = ""; private bool isOpeningLink; + private bool isValidatingLink; + private AssetLinksValidationResult? assetLinksResult; protected override void OnInitialized() { @@ -494,6 +569,54 @@ } } + 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 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 + { + 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 917897f..d9c2513 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,79 @@
+ + +
+
+ + Deep Links +
+
+
+ + +
+
+ + +
+ @if (IsNonWebScheme(deepLinkUrl)) + { + + } + @if (aasaResult != null) + { +
+
+ + @if (aasaResult.Valid) + { + AASA found — @aasaResult.Apps.Count app(s) configured@(aasaResult.Signed ? " (signed)" : "") + } + 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 +362,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 +522,73 @@ } } + // ── 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 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 + { + 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;