Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/MauiSherpa.Core/Interfaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3549,3 +3549,17 @@ public interface IFormPage<TResult>
/// </summary>
Task<TResult?> GetResultAsync();
}

public interface IDeepLinkValidationService
{
Task<AasaValidationResult> ValidateAppleAppSiteAssociationAsync(string domain);
Task<AssetLinksValidationResult> ValidateAssetLinksAsync(string domain);
}

public record AasaValidationResult(bool Found, bool Valid, string? RawJson,
IReadOnlyList<AasaAppEntry> Apps, string? ErrorMessage, bool Signed = false);
public record AasaAppEntry(string AppId, IReadOnlyList<string> Paths);

public record AssetLinksValidationResult(bool Found, bool Valid, string? RawJson,
IReadOnlyList<AssetLinksEntry> Entries, string? ErrorMessage);
public record AssetLinksEntry(string Package, string? Sha256Fingerprint);
173 changes: 173 additions & 0 deletions src/MauiSherpa.Core/Services/DeepLinkValidationService.cs
Original file line number Diff line number Diff line change
@@ -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<AasaValidationResult> ValidateAppleAppSiteAssociationAsync(string domain)
{
try
{
var url = $"https://{domain}/.well-known/apple-app-site-association";
var response = await Http.GetAsync(url).ConfigureAwait(false);
Comment on lines +10 to +20
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new service adds non-trivial parsing/validation logic (AASA modern/legacy formats, signed-vs-raw content, assetlinks relation filtering) but there are no accompanying unit tests under tests/MauiSherpa.Core.Tests/Services. Adding tests for success, missing/invalid JSON, non-2xx responses, and signed AASA decoding would help prevent regressions. Consider also making the HTTP dependency injectable (instead of a static HttpClient) to enable deterministic tests.

Suggested change
static readonly HttpClient Http = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
public async Task<AasaValidationResult> ValidateAppleAppSiteAssociationAsync(string domain)
{
try
{
var url = $"https://{domain}/.well-known/apple-app-site-association";
var response = await Http.GetAsync(url).ConfigureAwait(false);
readonly HttpClient http;
static HttpClient CreateDefaultHttpClient()
{
return new HttpClient
{
Timeout = TimeSpan.FromSeconds(10)
};
}
public DeepLinkValidationService()
: this(CreateDefaultHttpClient())
{
}
public DeepLinkValidationService(HttpClient httpClient)
{
http = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task<AasaValidationResult> ValidateAppleAppSiteAssociationAsync(string domain)
{
try
{
var url = $"https://{domain}/.well-known/apple-app-site-association";
var response = await http.GetAsync(url).ConfigureAwait(false);

Copilot uses AI. Check for mistakes.

Comment on lines +15 to +21
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

domain is interpolated directly into the URL string. If a caller passes unexpected input (e.g., containing @ userinfo or /), the request can be sent to an unintended host (e.g., https://example.com@evil.com/... resolves to evil.com). Validate that domain is a hostname (e.g., via Uri.CheckHostName and rejecting characters like @, /, :), or build the URI via UriBuilder to avoid URL-injection edge cases.

Suggested change
public async Task<AasaValidationResult> ValidateAppleAppSiteAssociationAsync(string domain)
{
try
{
var url = $"https://{domain}/.well-known/apple-app-site-association";
var response = await Http.GetAsync(url).ConfigureAwait(false);
static bool IsValidHostName(string domain)
{
if (string.IsNullOrWhiteSpace(domain))
return false;
if (domain.IndexOfAny(['@', '/', '\\', ':', '?', '#']) >= 0)
return false;
return Uri.CheckHostName(domain) != UriHostNameType.Unknown;
}
public async Task<AasaValidationResult> ValidateAppleAppSiteAssociationAsync(string domain)
{
try
{
if (!IsValidHostName(domain))
{
return new AasaValidationResult(
false,
false,
null,
Array.Empty<AasaAppEntry>(),
"Invalid domain name",
false);
}
var requestUri = new UriBuilder("https", domain)
{
Path = ".well-known/apple-app-site-association"
}.Uri;
var response = await Http.GetAsync(requestUri).ConfigureAwait(false);

Copilot uses AI. Check for mistakes.
if (!response.IsSuccessStatusCode)
{
return new AasaValidationResult(false, false, null, Array.Empty<AasaAppEntry>(),
$"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<AasaAppEntry>();

if (doc.RootElement.TryGetProperty("applinks", out var applinks) &&
applinks.TryGetProperty("details", out var details))
{
foreach (var detail in details.EnumerateArray())
{
var appIds = new List<string>();
var paths = new List<string>();

// 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<AasaAppEntry>(), "Request timed out", false);
}
catch (Exception ex)
{
return new AasaValidationResult(false, false, null, Array.Empty<AasaAppEntry>(), 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<AssetLinksValidationResult> 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<AssetLinksEntry>(),
$"HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
}

var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var entries = new List<AssetLinksEntry>();

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<AssetLinksEntry>(), "Request timed out");
}
catch (Exception ex)
{
return new AssetLinksValidationResult(false, false, null, Array.Empty<AssetLinksEntry>(), ex.Message);
}
}
}
1 change: 1 addition & 0 deletions src/MauiSherpa.MacOS/MacOSMauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ public static MauiApp CreateMauiApp()
builder.Services.AddSingleton<IPhysicalDeviceService, MauiSherpa.Core.Services.PhysicalDeviceService>();
builder.Services.AddSingleton<IPhysicalDeviceLogService, PhysicalDeviceLogService>();
builder.Services.AddSingleton<SimInspectorService>();
builder.Services.AddSingleton<IDeepLinkValidationService, DeepLinkValidationService>();

// Cloud Secrets Storage services
builder.Services.AddSingleton<ICloudSecretsProviderFactory, CloudSecretsProviderFactory>();
Expand Down
123 changes: 123 additions & 0 deletions src/MauiSherpa/Components/DeviceToolsTab.razor
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
@inject DeviceInspectorService Inspector
@inject IAlertService AlertService
@inject IDialogService DialogService
@inject IDeepLinkValidationService DeepLinkValidator
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDeepLinkValidationService is injected here, but it isn’t registered in the main app DI container (src/MauiSherpa/MauiProgram.cs). Rendering this component will fail on Mac Catalyst/Windows unless the service is added to the shared registrations (and any other relevant heads).

Suggested change
@inject IDeepLinkValidationService DeepLinkValidator

Copilot uses AI. Check for mistakes.
@implements IDisposable

<div class="device-tools-tab">
Expand Down Expand Up @@ -204,7 +205,60 @@
disabled="@(isOpeningLink || string.IsNullOrWhiteSpace(deepLinkUrl))">
<i class="fas @(isOpeningLink ? "fa-spinner fa-spin" : "fa-rocket")"></i> Launch
</button>
<button class="btn btn-sm btn-secondary" @onclick="ValidateAssetLinks"
disabled="@(isValidatingLink || string.IsNullOrWhiteSpace(deepLinkUrl))">
<i class="fas @(isValidatingLink ? "fa-spinner fa-spin" : "fa-shield-alt")"></i> Validate assetlinks.json
</button>
</div>
@if (IsNonWebScheme(deepLinkUrl))
{
<div class="deep-link-warning">
<i class="fas fa-triangle-exclamation"></i>
<span>Custom URL scheme detected. assetlinks.json validation requires an <code>https://</code> URL. Launch will still attempt to open the scheme on the device.</span>
</div>
}
@if (assetLinksResult != null)
{
<div class="validation-result @(assetLinksResult.Valid ? "success" : assetLinksResult.Found ? "warning" : "error")">
<div class="validation-status">
<i class="fas @(assetLinksResult.Valid ? "fa-check-circle" : assetLinksResult.Found ? "fa-exclamation-triangle" : "fa-times-circle")"></i>
@if (assetLinksResult.Valid)
{
<span>assetlinks.json found &mdash; @assetLinksResult.Entries.Count app(s) configured</span>
}
else if (assetLinksResult.Found)
{
<span>assetlinks.json found but no handle_all_urls entries detected</span>
}
else
{
<span>@(assetLinksResult.ErrorMessage ?? "assetlinks.json not found")</span>
}
</div>
@if (assetLinksResult.Entries.Count > 0)
{
<div class="validation-entries">
@foreach (var entry in assetLinksResult.Entries)
{
<div class="validation-entry">
<strong>@entry.Package</strong>
@if (entry.Sha256Fingerprint != null)
{
<span class="validation-fingerprint" title="@entry.Sha256Fingerprint">@entry.Sha256Fingerprint[..Math.Min(23, entry.Sha256Fingerprint.Length)]&hellip;</span>
}
</div>
}
</div>
}
@if (assetLinksResult.RawJson != null)
{
<details class="validation-raw">
<summary>Raw JSON</summary>
<pre>@FormatJson(assetLinksResult.RawJson)</pre>
</details>
}
</div>
}
</div>
</div>
</div>
Expand Down Expand Up @@ -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; }
Comment on lines +332 to +347
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These validation styles (.validation-result, .validation-status, .validation-raw, .deep-link-warning, etc.) are duplicated between DeviceToolsTab and SimToolsTab. Consider moving them into a shared stylesheet (e.g., a common component CSS block or wwwroot CSS) to avoid future drift and reduce maintenance overhead.

Copilot uses AI. Check for mistakes.
.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; }
</style>

@code {
Expand Down Expand Up @@ -307,6 +380,8 @@
// Deep links
private string deepLinkUrl = "";
private bool isOpeningLink;
private bool isValidatingLink;
private AssetLinksValidationResult? assetLinksResult;

protected override void OnInitialized()
{
Expand Down Expand Up @@ -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"))
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI messaging indicates assetlinks.json validation requires an https:// URL, but ExtractDomain accepts both http and https. Consider either enforcing https here, or adjusting the user-facing message to match the actual accepted schemes.

Suggested change
(uri.Scheme == "https" || uri.Scheme == "http"))
uri.Scheme == Uri.UriSchemeHttps)

Copilot uses AI. Check for mistakes.
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; }
}
Comment on lines +595 to +618
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExtractDomain, IsNonWebScheme, and FormatJson are duplicated in both DeviceToolsTab and SimToolsTab. Consider factoring these into a shared helper (e.g., a small static utility in the UI project) so fixes/behavior changes (scheme handling, formatting options, etc.) only need to be made once.

Copilot uses AI. Check for mistakes.

public void Dispose()
{
Inspector.DeviceChanged -= OnDeviceChanged;
Expand Down
Loading
Loading