Compare commits

..
Author SHA1 Message Date
SuperDooper 48e6f64c9a chore: release notes for beta.24 2026-05-08 16:00:28 +02:00
SuperDooper d55a92623c fix: add LoadFromCacheAsync, same JS fallbacks in WebView2 refresh path 2026-05-08 16:00:04 +02:00
SuperDooper c70a090983 fix: load from cache immediately after login, background refresh after 3s 2026-05-08 16:00:01 +02:00
SuperDooper cc0270326e fix: add /api/organizations + personal /api/usage fallbacks in login JS 2026-05-08 16:00:00 +02:00
SuperDooper 7601452f93 chore: release notes for beta.23 2026-05-08 15:50:04 +02:00
SuperDooper 5a78a021cb fix: trust cookies/cached auth for IsSignedIn, wider cookie domain filter 2026-05-08 15:49:47 +02:00
SuperDooper 7d465c13d8 fix: show update label in About, delay before refresh after login 2026-05-08 15:49:47 +02:00
SuperDooper ca67039e3e feat: add update-available label in About section 2026-05-08 15:49:46 +02:00
SuperDooper 1e4cdc09b3 fix: broaden JS orgId/email paths, URL-only auto-close 2026-05-08 15:49:45 +02:00
SuperDooper 4e154bcaa7 chore: release notes for beta.22 2026-05-08 15:30:34 +02:00
SuperDooper ea784384c1 fix: remove unused _dialogOpen field 2026-05-08 15:30:27 +02:00
SuperDooper 4e813995c9 fix: Unwrap Task<Task<string>> from Dispatcher.InvokeAsync with async lambda 2026-05-08 15:30:26 +02:00
SuperDooper 0ffd690098 chore: release notes for beta.21 2026-05-08 15:05:43 +02:00
SuperDooper c083256882 feat: hidden WebView2 window for authenticated API calls 2026-05-08 15:05:35 +02:00
SuperDooper fd94c96d83 fix: WebView2 fallback for API calls, cached usage from login, decouple auth from HttpClient 2026-05-08 15:05:31 +02:00
SuperDooper d22eb7381c feat: add Email and UsageJson cache fields 2026-05-08 15:05:26 +02:00
SuperDooper dd59f4565c fix: auto-close on any signed-in claude.ai page; fetch bootstrap+usage via WebView2 at login 2026-05-08 15:05:25 +02:00
SuperDooper f548210387 chore: release notes for beta.20 2026-05-08 14:54:35 +02:00
SuperDooper 89659322ac fix: decouple auth check from org ID, add UA header, try multiple org ID paths 2026-05-08 14:54:28 +02:00
7 changed files with 332 additions and 64 deletions
+2
View File
@@ -9,6 +9,8 @@ public class AppSettings
public string CookieStore { get; set; } = "";
public string BurnHistory { get; set; } = "";
public string OrgId { get; set; } = "";
public string Email { get; set; } = "";
public string UsageJson { get; set; } = "";
public int RefreshInterval { get; set; } = 120;
public bool ShowInTaskbar { get; set; } = true;
public bool BetaChannel { get; set; } = false;
+53 -5
View File
@@ -2,6 +2,7 @@ using Microsoft.Web.WebView2.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using System.Windows;
@@ -9,6 +10,8 @@ namespace ClaudeCheckerWindows;
public partial class LoginWindow : Window
{
private bool _closing;
public LoginWindow()
{
InitializeComponent();
@@ -27,7 +30,7 @@ public partial class LoginWindow : Window
Browser.CoreWebView2.NavigationCompleted += async (_, e) =>
{
if (!e.IsSuccess) return;
if (!e.IsSuccess || _closing) return;
var uri = Browser.CoreWebView2.Source;
if (!uri.Contains("claude.ai")) return;
@@ -44,23 +47,68 @@ public partial class LoginWindow : Window
: "Complete sign-in, then click Done.";
});
// Auto-close only on explicit post-login redirects, not on initial load
if (signedIn && (uri.Contains("/chats") || uri.Contains("/new")))
{
// Auto-close once we land on any claude.ai page that isn't the login flow
if (!uri.Contains("/login") && !uri.Contains("/signin") && signedIn)
await SaveAndClose(cookies);
}
};
}
private async void Done_Click(object sender, RoutedEventArgs e)
{
if (_closing) return;
var cookies = await Browser.CoreWebView2.CookieManager.GetCookiesAsync("https://claude.ai");
await SaveAndClose(cookies);
}
private async Task SaveAndClose(IReadOnlyList<CoreWebView2Cookie> cookies)
{
if (_closing) return;
_closing = true;
UsageViewModel.SaveCookies(cookies);
// Fetch bootstrap + usage from within WebView2 (already authenticated, no header issues)
try
{
const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json();
let id=b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid
||b?.default_organization?.uuid||null;
const e=b?.account?.email_address||b?.account?.email||b?.email||null;
if(!id){
try{const ol=await(await fetch('/api/organizations',h)).json();
if(Array.isArray(ol)&&ol.length>0)id=ol[0]?.uuid||null;}catch(e2){}
}
if(!id){
try{const pu=await(await fetch('/api/usage',h)).json();
if(pu&&!pu.error)return{email:e,orgId:null,usage:pu};}catch(e3){}
return{email:e,orgId:null,usage:null};
}
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json();
return{email:e,orgId:id,usage:u};
}catch(ex){return{error:String(ex)};}})()";
var json = await Browser.CoreWebView2.ExecuteScriptAsync(script);
if (json != "null" && !string.IsNullOrEmpty(json))
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("email", out var em) && em.ValueKind == JsonValueKind.String)
AppSettings.Default.Email = em.GetString() ?? "";
if (root.TryGetProperty("orgId", out var oi) && oi.ValueKind == JsonValueKind.String)
AppSettings.Default.OrgId = oi.GetString() ?? "";
if (root.TryGetProperty("usage", out var us) && us.ValueKind == JsonValueKind.Object)
AppSettings.Default.UsageJson = us.GetRawText();
AppSettings.Default.Save();
}
}
catch { }
await Dispatcher.InvokeAsync(() => DialogResult = true);
}
}
+9 -3
View File
@@ -173,9 +173,15 @@
<TextBlock Text="Built by superdooper86 &amp; claude"
FontSize="11" Foreground="{DynamicResource SecondaryBrush}"/>
</StackPanel>
<Button Grid.Column="2" x:Name="CheckUpdateButton"
Style="{StaticResource SmallButton}"
Click="CheckUpdate_Click" VerticalAlignment="Center"/>
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
<Button x:Name="CheckUpdateButton"
Style="{StaticResource SmallButton}"
Click="CheckUpdate_Click"/>
<TextBlock x:Name="UpdateAvailableText" FontSize="10"
Foreground="{DynamicResource BlueBrush}"
HorizontalAlignment="Center"
Visibility="Collapsed" Margin="0,4,0,0"/>
</StackPanel>
</Grid>
</Border>
+31 -5
View File
@@ -14,7 +14,7 @@ public partial class PopupWindow : Window
private static readonly UsageViewModel VM = App.ViewModel;
private static readonly UpdateManager Updater = App.Updater;
private readonly DispatcherTimer _clockTimer;
private bool _dialogOpen;
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
@@ -199,7 +199,10 @@ public partial class PopupWindow : Window
private void OnUpdaterChanged(string? prop)
{
if (prop is nameof(UpdateManager.UpdateAvailable) or nameof(UpdateManager.LatestVersion))
{
UpdateBannerState();
UpdateAboutSection();
}
if (prop is nameof(UpdateManager.DownloadProgress))
UpdateProgress.Value = Updater.DownloadProgress * 100;
if (prop is nameof(UpdateManager.StatusMessage))
@@ -208,6 +211,21 @@ public partial class PopupWindow : Window
InstallButton.Visibility = Visibility.Collapsed;
}
private void UpdateAboutSection()
{
if (Updater.UpdateAvailable)
{
UpdateAvailableText.Text = $"v{Updater.LatestVersion} available";
UpdateAvailableText.Visibility = Visibility.Visible;
CheckUpdateButton.Content = "Install";
}
else
{
UpdateAvailableText.Visibility = Visibility.Collapsed;
CheckUpdateButton.Content = "Check";
}
}
private void UpdateBannerState()
{
var visible = Updater.UpdateAvailable;
@@ -236,7 +254,7 @@ public partial class PopupWindow : Window
{
VersionLabel.Text = $"v{Updater.CurrentVersion}";
BetaToggle.IsChecked = Updater.BetaChannel;
CheckUpdateButton.Content = Updater.UpdateAvailable ? "Install" : "Check";
UpdateAboutSection();
var intervals = new[] { ("1 min", 60), ("2 min", 120), ("3 min", 180),
("4 min", 240), ("5 min", 300), ("10 min", 600) };
@@ -312,14 +330,22 @@ public partial class PopupWindow : Window
private async void SignIn_Click(object s, RoutedEventArgs e)
{
_dialogOpen = true;
var login = new LoginWindow { Owner = this };
if (login.ShowDialog() == true)
{
await VM.RefreshAsync();
// Show cached data from SaveAndClose immediately (no WebView2 needed)
await VM.LoadFromCacheAsync();
InitSettings();
ShowMain();
// Full refresh in background after WebView2 folder is definitely released
_ = Task.Run(async () =>
{
await Task.Delay(3000);
await VM.RefreshAsync();
await Application.Current.Dispatcher.InvokeAsync(InitSettings);
});
}
_dialogOpen = false;
Show();
Activate();
}
+5 -6
View File
@@ -1,7 +1,6 @@
## What's new in beta.19
## What's new in beta.24
- Fixed dark mode: button hover no longer white, ComboBox fully themed
- Fixed logo and version badge in Settings panel
- Fixed signed-in email showing blank (TextBrush instead of hardcoded white)
- Fixed session limits not loading: org ID now fetched dynamically from API
- Added anthropic-client-platform header for correct API responses
- Sign-in now shows email and data immediately after login (loads from cache set during login flow)
- Background refresh runs 3 seconds after login — avoids WebView2 user data folder lock race
- Added /api/organizations and personal /api/usage fallbacks when bootstrap has no org ID
- Broader JS paths for email and org ID
+173 -45
View File
@@ -78,7 +78,13 @@ public class UsageViewModel : INotifyPropertyChanged
try
{
var cookies = await GetCookiesAsync();
if (cookies.Count == 0)
// Check if we have any persistent proof that the user authenticated
bool hasCookies = cookies.Count > 0;
bool hasCachedAuth = !string.IsNullOrEmpty(AppSettings.Default.Email) ||
!string.IsNullOrEmpty(AppSettings.Default.OrgId);
if (!hasCookies && !hasCachedAuth)
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
@@ -89,34 +95,29 @@ public class UsageViewModel : INotifyPropertyChanged
return;
}
using var http = BuildClient(cookies);
// Try HttpClient first (fast path) — may be rejected by server CORS/header checks
var (limits, overage, prepaid, email, orgId, ok) = hasCookies
? await TryHttpRefreshAsync(cookies)
: ([], null, null, null, null, false);
// Bootstrap gives us email + org ID. If it fails, cookies are invalid.
var (email, orgId) = await FetchBootstrapAsync(http);
if (string.IsNullOrEmpty(orgId))
// Fall back to WebView2 (uses the shared browser session, not HttpClient)
if (!ok)
(limits, email, orgId) = await TryWebView2RefreshAsync();
// Fill in any blanks from cached values saved at login time
if (string.IsNullOrEmpty(email)) email = AppSettings.Default.Email;
if (string.IsNullOrEmpty(orgId)) orgId = AppSettings.Default.OrgId;
if (limits.Count == 0 && !string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{
await Application.Current.Dispatcher.InvokeAsync(() =>
try
{
IsSignedIn = false;
ErrorMessage = "Session expired — click Sign In to re-authenticate.";
IsLoading = false;
});
return;
var cached = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>(
AppSettings.Default.UsageJson, JsonOpts);
limits = BuildLimits(cached);
}
catch { }
}
// Cache org ID for resilience
AppSettings.Default.OrgId = orgId;
AppSettings.Default.Save();
var usageTask = FetchAsync<UsageResponse>(http, $"https://claude.ai/api/organizations/{orgId}/usage");
var overageTask = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit");
var prepaidTask = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
await Task.WhenAll(usageTask, overageTask, prepaidTask);
var usage = usageTask.Result;
var limits = BuildLimits(usage);
foreach (var limit in limits)
{
var key = limit.Window.ToString();
@@ -131,9 +132,11 @@ public class UsageViewModel : INotifyPropertyChanged
await Application.Current.Dispatcher.InvokeAsync(() =>
{
Limits = limits;
Overage = overageTask.Result;
Prepaid = prepaidTask.Result;
UserEmail = email ?? UserEmail;
Overage = overage;
Prepaid = prepaid;
if (!string.IsNullOrEmpty(email)) UserEmail = email;
// Trust that cookies / cached auth mean the user IS signed in,
// even if the live fetch failed this cycle.
IsSignedIn = true;
ErrorMessage = null;
LastUpdated = DateTime.Now;
@@ -150,10 +153,137 @@ public class UsageViewModel : INotifyPropertyChanged
}
}
private async Task<(List<AgentLimit>, OverageSpendLimit?, PrepaidCredits?, string?, string?, bool)>
TryHttpRefreshAsync(List<(string Name, string Value, string Domain, string Path)> cookies)
{
try
{
using var http = BuildClient(cookies);
var bootstrapResp = await http.GetAsync("https://claude.ai/api/bootstrap");
if (!bootstrapResp.IsSuccessStatusCode)
return ([], null, null, null, null, false);
var (email, orgId) = ParseBootstrap(await bootstrapResp.Content.ReadAsStringAsync());
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId))
orgId = AppSettings.Default.OrgId;
if (string.IsNullOrEmpty(orgId))
return ([], null, null, email, orgId, true);
AppSettings.Default.OrgId = orgId;
AppSettings.Default.Save();
var ut = FetchAsync<UsageResponse>(http, $"https://claude.ai/api/organizations/{orgId}/usage");
var ot = FetchAsync<OverageSpendLimit>(http, $"https://claude.ai/api/organizations/{orgId}/overage_spend_limit");
var pt = FetchAsync<PrepaidCredits>(http, $"https://claude.ai/api/organizations/{orgId}/prepaid/credits");
await Task.WhenAll(ut, ot, pt);
return (BuildLimits(ut.Result), ot.Result, pt.Result, email, orgId, true);
}
catch { return ([], null, null, null, null, false); }
}
private static async Task<(List<AgentLimit>, string?, string?)> TryWebView2RefreshAsync()
{
try
{
const string script = @"(async()=>{try{
const h={headers:{accept:'application/json'}};
const b=await(await fetch('/api/bootstrap',h)).json();
let id=b?.memberships?.[0]?.organization?.uuid||b?.organizations?.[0]?.uuid
||b?.default_organization?.uuid||null;
const em=b?.account?.email_address||b?.account?.email||b?.email||null;
if(!id){
try{const ol=await(await fetch('/api/organizations',h)).json();
if(Array.isArray(ol)&&ol.length>0)id=ol[0]?.uuid||null;}catch(e2){}
}
if(!id){
try{const pu=await(await fetch('/api/usage',h)).json();
if(pu&&!pu.error)return{email:em,orgId:null,usage:pu};}catch(e3){}
return{email:em,orgId:null,usage:null};
}
const u=await(await fetch('/api/organizations/'+id+'/usage',h)).json();
return{email:em,orgId:id,usage:u};
}catch(ex){return null;}})()";
var resultJson = await Application.Current.Dispatcher.InvokeAsync(async () =>
{
var host = new WebViewFetchWindow();
host.Show();
try { return await host.FetchAsync("https://claude.ai", script); }
finally { host.Close(); }
}).Task.Unwrap();
if (resultJson == null || resultJson == "null") return ([], null, null);
using var doc = System.Text.Json.JsonDocument.Parse(resultJson);
var root = doc.RootElement;
string? email = root.TryGetProperty("email", out var em) && em.ValueKind == System.Text.Json.JsonValueKind.String
? em.GetString() : null;
string? orgId = root.TryGetProperty("orgId", out var oi) && oi.ValueKind == System.Text.Json.JsonValueKind.String
? oi.GetString() : null;
List<AgentLimit> limits = [];
if (root.TryGetProperty("usage", out var us) && us.ValueKind == System.Text.Json.JsonValueKind.Object)
{
var usage = System.Text.Json.JsonSerializer.Deserialize<UsageResponse>(
us.GetRawText(), new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
limits = BuildLimits(usage);
// Cache for next time
AppSettings.Default.UsageJson = us.GetRawText();
if (!string.IsNullOrEmpty(orgId)) AppSettings.Default.OrgId = orgId;
if (!string.IsNullOrEmpty(email)) AppSettings.Default.Email = email;
AppSettings.Default.Save();
}
return (limits, email, orgId);
}
catch { return ([], null, null); }
}
public async Task LoadFromCacheAsync()
{
var email = AppSettings.Default.Email;
var orgId = AppSettings.Default.OrgId;
var cookie = AppSettings.Default.CookieStore;
bool isAuth = !string.IsNullOrEmpty(email) || !string.IsNullOrEmpty(orgId)
|| !string.IsNullOrEmpty(cookie);
if (!isAuth) return;
List<AgentLimit> limits = [];
if (!string.IsNullOrEmpty(AppSettings.Default.UsageJson))
{
try
{
var cached = JsonSerializer.Deserialize<UsageResponse>(
AppSettings.Default.UsageJson, JsonOpts);
limits = BuildLimits(cached);
}
catch { }
}
await Application.Current.Dispatcher.InvokeAsync(() =>
{
if (!string.IsNullOrEmpty(email)) UserEmail = email;
if (limits.Count > 0) Limits = limits;
IsSignedIn = true;
ErrorMessage = limits.Count == 0 ? "Signed in — refreshing data…" : null;
LastUpdated = limits.Count > 0 ? DateTime.Now : LastUpdated;
});
}
public async Task SignOutAsync()
{
AppSettings.Default.CookieStore = "";
AppSettings.Default.OrgId = "";
AppSettings.Default.Email = "";
AppSettings.Default.UsageJson = "";
AppSettings.Default.Save();
await Application.Current.Dispatcher.InvokeAsync(() =>
@@ -181,7 +311,7 @@ public class UsageViewModel : INotifyPropertyChanged
public static void SaveCookies(IEnumerable<CoreWebView2Cookie> cookies)
{
var entries = cookies
.Where(c => c.Domain.Contains("claude.ai"))
.Where(c => c.Domain.Contains("claude.ai") || c.Domain.Contains("anthropic.com"))
.Select(c => new CookieEntry { Name = c.Name, Value = c.Value, Domain = c.Domain, Path = c.Path })
.ToList();
AppSettings.Default.CookieStore = JsonSerializer.Serialize(entries);
@@ -193,7 +323,9 @@ public class UsageViewModel : INotifyPropertyChanged
var handler = new HttpClientHandler { UseCookies = false };
var http = new HttpClient(handler);
http.DefaultRequestHeaders.Add("accept", "application/json");
http.DefaultRequestHeaders.Add("anthropic-client-platform", "web");
http.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
var cookieHeader = string.Join("; ", cookies.Select(c => $"{c.Name}={c.Value}"));
http.DefaultRequestHeaders.Add("Cookie", cookieHeader);
return http;
@@ -211,17 +343,13 @@ public class UsageViewModel : INotifyPropertyChanged
catch { return null; }
}
// Returns (email, orgId). orgId null means auth failed.
private static async Task<(string? Email, string? OrgId)> FetchBootstrapAsync(HttpClient http)
// Parse email and org ID out of bootstrap JSON (multiple fallback paths for org ID)
private static (string? Email, string? OrgId) ParseBootstrap(string json)
{
try
{
var resp = await http.GetAsync("https://claude.ai/api/bootstrap");
if (!resp.IsSuccessStatusCode) return (null, null);
var json = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var root = doc.RootElement;
string? email = null;
string? orgId = null;
@@ -230,7 +358,7 @@ public class UsageViewModel : INotifyPropertyChanged
acct.TryGetProperty("email_address", out var em))
email = em.GetString();
// Try memberships[0].organization.uuid
// Path 1: memberships[0].organization.uuid
if (root.TryGetProperty("memberships", out var mems) && mems.GetArrayLength() > 0)
{
var first = mems[0];
@@ -239,13 +367,13 @@ public class UsageViewModel : INotifyPropertyChanged
orgId = uuid.GetString();
}
// Fall back to cached org ID
if (string.IsNullOrEmpty(orgId) && !string.IsNullOrEmpty(AppSettings.Default.OrgId))
orgId = AppSettings.Default.OrgId;
// Last resort: dedicated organizations endpoint
if (string.IsNullOrEmpty(orgId))
orgId = await FetchOrgIdFromListAsync(http);
// Path 2: organizations[0].uuid (flat list on root)
if (string.IsNullOrEmpty(orgId) &&
root.TryGetProperty("organizations", out var orgs) && orgs.GetArrayLength() > 0)
{
if (orgs[0].TryGetProperty("uuid", out var uuid))
orgId = uuid.GetString();
}
return (email, orgId);
}
@@ -272,7 +400,7 @@ public class UsageViewModel : INotifyPropertyChanged
catch { return null; }
}
private List<AgentLimit> BuildLimits(UsageResponse? usage)
private static List<AgentLimit> BuildLimits(UsageResponse? usage)
{
if (usage == null) return [];
var now = DateTime.Now;
+59
View File
@@ -0,0 +1,59 @@
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using System;
using System.IO;
using System.Threading.Tasks;
using System.Windows;
namespace ClaudeCheckerWindows;
// Invisible 1×1 window that hosts a WebView2 for authenticated API calls.
// Uses the same user data folder as LoginWindow so the session is shared.
internal sealed class WebViewFetchWindow : Window
{
private readonly WebView2 _wv = new();
public WebViewFetchWindow()
{
Width = 1;
Height = 1;
Left = -9999;
Top = -9999;
ShowInTaskbar = false;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Opacity = 0;
Content = _wv;
}
public async Task<string?> FetchAsync(string navigateUrl, string script, int timeoutMs = 20000)
{
var env = await CoreWebView2Environment.CreateAsync(userDataFolder:
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeChecker", "WebView2"));
await _wv.EnsureCoreWebView2Async(env);
var tcs = new TaskCompletionSource<string?>();
EventHandler<CoreWebView2NavigationCompletedEventArgs>? handler = null;
handler = async (_, e) =>
{
_wv.CoreWebView2.NavigationCompleted -= handler;
if (!e.IsSuccess) { tcs.TrySetResult(null); return; }
try
{
var result = await _wv.CoreWebView2.ExecuteScriptAsync(script);
tcs.TrySetResult(result);
}
catch { tcs.TrySetResult(null); }
};
_wv.CoreWebView2.NavigationCompleted += handler;
_wv.CoreWebView2.Navigate(navigateUrl);
_ = Task.Delay(timeoutMs).ContinueWith(_ => tcs.TrySetResult(null));
return await tcs.Task;
}
}