-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathClaudeUsageControl.xaml.cs
More file actions
811 lines (757 loc) · 35.3 KB
/
ClaudeUsageControl.xaml.cs
File metadata and controls
811 lines (757 loc) · 35.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
/* *******************************************************************************************************************
* Application: ClaudeCodeExtension
*
* Autor: Daniel Carvalho Liedke / Claude Code
*
* Copyright © Daniel Carvalho Liedke 2026
* Usage and reproduction in any manner whatsoever without the written permission of Daniel Carvalho Liedke is strictly forbidden.
*
* Purpose: WebView2-backed control that hosts claude.ai/settings/usage with a thin toolbar
* (refresh, auto-refresh, open-in-browser, sign-out) and broadcasts scraped usage
* data so the inline bars in the main panel can stay in sync.
*
* *******************************************************************************************************************/
using Microsoft.Web.WebView2.Core;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
namespace ClaudeCodeVS
{
/// <summary>
/// User control that embeds claude.ai/settings/usage in a WebView2.
/// Trims everything outside the "plan usage limits" section via injected
/// JS so the user only sees the relevant bars. Also posts the scraped
/// values to <see cref="UsageDataReceived"/> for the inline mini-bars.
/// </summary>
public partial class ClaudeUsageControl : UserControl
{
public const string UsageUrl = "https://claude.ai/settings/usage";
public const string WebView2DownloadUrl = "https://developer.microsoft.com/en-us/microsoft-edge/webview2/";
private static readonly string SharedCookiePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeCodeExtension", "shared_cookies.json");
private DispatcherTimer _autoRefreshTimer;
private bool _initialized;
private bool _suppressComboEvent;
private DateTime _lastRedirectAttemptUtc = DateTime.MinValue;
private DateTime _lastCookieSaveUtc = DateTime.MinValue;
/// <summary>
/// Fires when a usage snapshot is successfully scraped from the page.
/// </summary>
public event EventHandler<UsageSnapshot> UsageDataReceived;
/// <summary>
/// Fires when the auto-refresh combo box value changes. Hosts persist
/// the new value to settings.
/// </summary>
public event EventHandler<int> AutoRefreshChanged;
public ClaudeUsageControl()
{
InitializeComponent();
this.Loaded += OnLoaded;
}
#pragma warning disable VSTHRD100 // async void Loaded handler is required by WPF
private async void OnLoaded(object sender, RoutedEventArgs e)
#pragma warning restore VSTHRD100
{
if (_initialized) return;
_initialized = true;
try { await InitializeWebViewAsync(); }
catch (Exception ex) { Debug.WriteLine("ClaudeUsageControl.OnLoaded failed: " + ex); }
}
private async Task InitializeWebViewAsync()
{
try
{
// WebView2 exclusively locks its user data folder — two VS processes sharing
// the same folder causes the second to throw during environment creation.
// Use a per-process folder so multiple VS instances coexist without conflict.
int pid = System.Diagnostics.Process.GetCurrentProcess().Id;
var userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeCodeExtension", "WebView2_" + pid);
Directory.CreateDirectory(userDataFolder);
CleanupStaleWebView2Folders();
var env = await ClaudeUsageWebViewEnvironment.GetOrCreateAsync(userDataFolder);
await WebView.EnsureCoreWebView2Async(env);
// Re-focus after Ctrl+Scroll zoom so WebView2 re-establishes cursor tracking.
// Without this the mouse cursor disappears until the user clicks again.
WebView.ZoomFactorChanged += (s, e) =>
{
#pragma warning disable VSTHRD001, VSTHRD110
_ = Dispatcher.BeginInvoke(new Action(() =>
{
try { WebView?.Focus(); } catch { }
}), System.Windows.Threading.DispatcherPriority.Background);
#pragma warning restore VSTHRD001, VSTHRD110
};
// Import cookies saved by another VS instance so the user stays logged in.
await LoadSharedCookiesAsync();
WebView.CoreWebView2.WebMessageReceived += OnWebMessageReceived;
WebView.CoreWebView2.NavigationCompleted += OnNavigationCompleted;
WebView.CoreWebView2.SourceChanged += OnSourceChanged;
WebView.CoreWebView2.NewWindowRequested += OnNewWindowRequested;
await WebView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(BuildInjectedScript(trim: true));
WebView.CoreWebView2.Navigate(UsageUrl);
}
catch (Exception ex)
{
Debug.WriteLine("ClaudeUsageControl: WebView2 init failed: " + ex);
ShowError("WebView2 runtime is required to display the Claude usage page. " +
"Click below to install it, then reopen this window.");
}
}
/// <summary>
/// Builds the JS injected on every navigation. Two responsibilities:
/// (1) trim the page so only the usage section is visible,
/// (2) extract the usage values and post them back via webview.postMessage.
/// Selectors rely on stable ARIA attributes rather than Tailwind class names.
/// </summary>
public static string BuildInjectedScript(bool trim)
{
string trimFlag = trim ? "true" : "false";
// Two responsibilities: (1) trim the page so only the usage section
// is visible, (2) extract usage values and post via webview.postMessage.
//
// Trim is done via CSS injection rather than mutating DOM structure.
// The previous approach (walk up from section, hide siblings at each
// ancestor level) breaks React re-renders: when navigation puts a
// new tree under <body> after login, the old display:none stylings
// don't apply but the DOM has shifted, leaving a blank page.
// CSS selectors targeting common chrome (nav/header/sidebar) survive
// re-renders cleanly.
//
// No MutationObserver: React re-renders fire it constantly and the
// resulting postMessage flood saturates host->renderer IPC, making
// clicks feel laggy or get dropped. Lightweight setInterval polling
// is enough for usage numbers that change every few minutes.
return @"
(function(){
const TRIM = " + trimFlag + @";
let styleInjected = false;
let lastJson = '';
function findSection(){
const bar = document.querySelector('[role=\""progressbar\""][aria-valuenow]');
if (!bar) return null;
return bar.closest('section') || bar.parentElement;
}
// Drives the page-isolation strategy: instead of fighting Tailwind's
// grid/flex/max-w cascade to expand a deeply-nested progress bar row,
// we identify the smallest content container that holds the progress
// bars (the `<div tabindex=\""-1\"" class=\""outline-none\"">` wrapper that
// sits directly above `<div class=\""pb-8\""><section>...`), walk up to
// body marking every ancestor as `data-claude-usage-path`, and mark
// every sibling along the way as `data-claude-usage-hide`. The CSS
// below then collapses the path elements to a plain block layout at
// 100% width and hides the rest of the page entirely. React re-renders
// are tolerated because tick() re-applies the data attributes on every
// pass, so newly-mounted siblings get re-marked the next cycle.
function findIsolationTarget(){
let node = document.querySelector('div[tabindex=\""-1\""].outline-none');
if (node && node.querySelector('[role=\""progressbar\""]')) return node;
const bar = document.querySelector('[role=\""progressbar\""][aria-valuenow]');
if (!bar) return null;
let n = bar.parentElement;
while (n && n !== document.body) {
if (n.tagName === 'DIV' && n.getAttribute('tabindex') === '-1') return n;
n = n.parentElement;
}
const section = bar.closest('section');
return section ? section.parentElement : bar.parentElement;
}
function injectTrimStyle(){
if (styleInjected) return;
const style = document.createElement('style');
style.id = '__claude_usage_trim_css__';
style.textContent =
// Always-hidden chrome — survives every React re-render because the
// selectors target tag names / class fragments rather than specific
// node identities.
'nav, header, aside, footer { display: none !important; }' +
'[class*=\""sidebar\""], [class*=\""Sidebar\""] { display: none !important; }' +
'[data-testid*=\""nav\""], [data-testid*=\""sidebar\""] { display: none !important; }' +
// Anything we've explicitly marked as hidden via data attribute.
'[data-claude-usage-hide=\""1\""] { display: none !important; }' +
// Body / root: full viewport, comfortable padding, no scroll lock.
// Padding lives on <body> rather than on the path elements because
// the path rule below sets `padding: 0 !important` to neutralize
// Tailwind's `px-4 md:px-8 lg:px-8` etc. on intermediate ancestors.
'html, body { max-width: none !important; width: 100% !important; min-width: 100% !important; margin: 0 !important; overflow-x: hidden !important; }' +
'body { overflow-y: auto !important; padding: 16px 20px !important; box-sizing: border-box !important; }' +
// Force an explicit cursor on the page. WebView2 hosted in WPF only
// renders its own mouse cursor while the page declares one — the
// claude.ai body class set leaves cursor at auto, which the
// WebView2 surface translates to no-cursor-at-all until the user
// clicks and the WebView2 control gains focus. Setting cursor
// default on html/body ensures a visible cursor from the moment
// the tool window opens; element-level cursor pointer etc. on
// links/buttons (Tailwind cursor-pointer) still applies on top.
'html, body { cursor: default !important; }' +
// Path elements (every ancestor between body and the target wrapper):
// collapse to a plain block at 100% width. `display: block` neutralizes
// any grid/flex/grid-cols layout so the previously-allocated 220px
// settings-nav column disappears once the nav is hidden.
'[data-claude-usage-path=\""1\""] {' +
' display: block !important;' +
' width: 100% !important;' +
' max-width: none !important;' +
' min-width: 0 !important;' +
' margin: 0 !important;' +
' padding: 0 !important;' +
' box-sizing: border-box !important;' +
' grid-template-columns: unset !important;' +
' grid-template-rows: unset !important;' +
' flex: 1 1 auto !important;' +
' overflow: visible !important;' +
' height: auto !important;' +
' min-height: 0 !important;' +
'}' +
// Target wrapper: same baseline as the path so its content reaches
// the full panel width.
'[data-claude-usage-keep=\""1\""] {' +
' display: block !important;' +
' width: 100% !important;' +
' max-width: none !important;' +
' margin: 0 !important;' +
' padding: 0 !important;' +
' box-sizing: border-box !important;' +
'}' +
// Inside the kept content, strip every max-width cap (Tailwind
// `max-w-*` arbitrary values, inline styles, etc.) and force flex
// rows to stay on a single line so the 13rem label and the
// `flex-1` bar column share one row instead of wrapping.
'[data-claude-usage-keep=\""1\""] *, [data-claude-usage-keep=\""1\""] {' +
' max-width: none !important;' +
' box-sizing: border-box !important;' +
'}' +
'[data-claude-usage-keep=\""1\""] [class*=\""max-w\""], [data-claude-usage-keep=\""1\""] [style*=\""max-width\""] {' +
' max-width: none !important;' +
'}' +
'[data-claude-usage-keep=\""1\""] .flex, [data-claude-usage-keep=\""1\""] [class*=\""flex-row\""] {' +
' flex-wrap: nowrap !important;' +
'}' +
// Bar element fills its column; clear any residual min-width clamp
// and fixed flex-basis the page might have stamped previously.
'[role=\""progressbar\""] { width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; }';
(document.head || document.documentElement).appendChild(style);
styleInjected = true;
}
let scrolledOnce = false;
function isolatePath(target){
if (!target) return;
target.setAttribute('data-claude-usage-keep', '1');
let node = target;
let depth = 0;
while (node && node !== document.body && depth < 30) {
const parent = node.parentElement;
if (!parent) break;
// Mark intermediate ancestors as `path` so the CSS collapses them
// to `display: block` at 100% width with zero padding/margin. We
// deliberately do NOT mark <body> itself — body keeps its own
// padding/margin styling from the rule above so the bars and
// labels have breathing room from the WebView2 panel edges.
if (parent !== document.body) {
parent.setAttribute('data-claude-usage-path', '1');
}
// Hide every sibling on this level except STYLE/SCRIPT and anything
// we've already marked as part of the path or as the target. This
// runs even when `parent === document.body` so any hidden body
// children (Intercom widgets, notification regions, etc.) don't
// bleed into the visible area.
for (const sibling of parent.children) {
if (sibling === node) continue;
const tag = sibling.tagName;
if (tag === 'STYLE' || tag === 'SCRIPT') continue;
if (sibling.hasAttribute('data-claude-usage-keep')) continue;
if (sibling.hasAttribute('data-claude-usage-path')) continue;
sibling.setAttribute('data-claude-usage-hide', '1');
}
node = parent;
depth++;
}
}
function clearStaleInlineWidths(target){
// Old builds of this script stamped inline width/min-width/flex on
// the bar's ancestors. Those overrides survive a navigation because
// the underlying React tree is the same instance, so on re-entry we
// wipe them inside the kept subtree to give the new CSS a clean slate.
if (!target) return;
const divs = target.querySelectorAll('div');
for (const d of divs) {
if (!d.style) continue;
if (d.getAttribute && d.getAttribute('role') === 'progressbar') continue;
// Skip children of progressbar containers — they hold the inline fill width (e.g. 18%).
if (d.closest && d.closest('[role=\""progressbar\""]')) continue;
d.style.width = '';
d.style.minWidth = '';
d.style.maxWidth = '';
d.style.flex = '';
d.style.flexBasis = '';
}
const bars = target.querySelectorAll('[role=\""progressbar\""]');
for (const bar of bars) {
if (bar.style) {
bar.style.width = '100%';
bar.style.maxWidth = 'none';
bar.style.minWidth = '0';
bar.style.flex = '1 1 auto';
}
}
}
function trimPage(section){
injectTrimStyle();
const target = findIsolationTarget() || (section && section.parentElement);
if (target) {
isolatePath(target);
clearStaleInlineWidths(target);
if (!scrolledOnce) {
try { window.scrollTo({ top: 0, behavior: 'instant' }); } catch (e) {}
scrolledOnce = true;
}
}
}
function extract(){
try {
const section = findSection();
if (!section) return null;
const bars = section.querySelectorAll('[role=\""progressbar\""][aria-valuenow]');
if (bars.length < 2) return null;
const rows = [];
for (const bar of bars) {
let row = bar.parentElement;
let labels = [];
for (let depth = 0; depth < 8 && row && row !== section; depth++) {
labels = row.querySelectorAll('p');
if (labels.length >= 2) break;
row = row.parentElement;
}
let label = '', reset = '';
if (labels.length >= 2) {
label = (labels[0].textContent || '').trim();
reset = (labels[1].textContent || '').trim();
} else if (labels.length === 1) {
label = (labels[0].textContent || '').trim();
}
rows.push({
label: label,
reset: reset,
pct: parseInt(bar.getAttribute('aria-valuenow') || '0', 10)
});
}
if (rows.length < 2) return null;
return {
SessionLabel: rows[0].label,
SessionReset: rows[0].reset,
SessionPercent: rows[0].pct,
WeeklyLabel: rows[1].label,
WeeklyReset: rows[1].reset,
WeeklyPercent: rows[1].pct
};
} catch (e) { return null; }
}
function postSnapshot(){
const data = extract();
if (!data) return;
const json = JSON.stringify(data);
if (json === lastJson) return;
lastJson = json;
if (window.chrome && window.chrome.webview) {
try { window.chrome.webview.postMessage(json); } catch (e) {}
}
}
function tick(){
const section = findSection();
if (TRIM && section) trimPage(section);
postSnapshot();
}
tick();
setTimeout(tick, 500);
setTimeout(tick, 1500);
setTimeout(tick, 3500);
setInterval(tick, 7000);
// Re-expand widths when the tool window gets resized — page containers
// can hold stale inline widths from the initial render.
window.addEventListener('resize', function(){ tick(); });
})();
";
}
private bool _firstNavigationCompleted;
private readonly TaskCompletionSource<bool> _firstNavTcs = new TaskCompletionSource<bool>();
private bool _needsReloadOnShow;
private bool _backgroundInitMode;
/// <summary>
/// True once OnLoaded has started WebView2 initialization.
/// Used by the host to avoid a redundant show-hide when the scraper is already running.
/// </summary>
public bool IsWebViewInitialized => _initialized;
/// <summary>
/// Returns a Task that completes when the first page navigation finishes (or timeoutMs elapses).
/// Used by the host to know when it is safe to hide the frame after a background-init show.
/// </summary>
public Task WaitForFirstNavigationAsync(int timeoutMs = 15000)
=> Task.WhenAny(_firstNavTcs.Task, Task.Delay(timeoutMs));
/// <summary>
/// Set true before a background-init show so OnWindowBecameVisible skips Focus() and
/// does not steal keyboard focus from the active VS editor.
/// </summary>
public void SetBackgroundInitMode(bool value) => _backgroundInitMode = value;
/// <summary>
/// Marks that the next explicit show should trigger a Navigate to recover any
/// black-page rendering surface left by being hidden mid-initialization.
/// </summary>
public void MarkNeedsReloadOnShow() => _needsReloadOnShow = true;
private void OnNavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e)
{
_firstNavTcs.TrySetResult(true);
if (LoadingText != null) LoadingText.Visibility = Visibility.Collapsed;
UpdateStatus();
TryRedirectToUsage();
// WebView2 hosted in WPF doesn't render its mouse cursor until
// the control gains focus for the first time. Without this the
// cursor stays invisible while hovering the tool window until
// the user clicks somewhere inside, which feels broken. The
// injected CSS already declares `cursor: default` on the page,
// and a one-time programmatic Focus() on the first successful
// navigation primes the WebView2 surface so the cursor renders
// immediately. Subsequent navigations (SPA route changes,
// refreshes) do NOT call Focus() again — that would steal
// keyboard focus from whatever the user is doing in VS.
// Only prime the cursor when actually visible — the window may have been
// created hidden (bars-only startup path) and we must not steal focus then.
if (!_firstNavigationCompleted && IsVisible)
{
_firstNavigationCompleted = true;
try { WebView?.Focus(); }
catch (Exception ex) { Debug.WriteLine("ClaudeUsageControl: initial Focus() failed: " + ex); }
}
}
/// <summary>
/// claude.ai is a Next.js SPA — after OAuth login it pushes
/// state (history.pushState) to /new without doing a full page load,
/// so NavigationCompleted never fires. SourceChanged catches those
/// SPA route transitions.
/// </summary>
private void OnSourceChanged(object sender, CoreWebView2SourceChangedEventArgs e)
{
TryRedirectToUsage();
}
/// <summary>
/// After login, claude.ai bounces the user to a post-auth landing
/// (/new, /chats, /projects, /recents) instead of the page we asked
/// for. Detect those specific landings and re-navigate to
/// /settings/usage. We whitelist the post-auth paths rather than
/// blacklist /login because the unauthenticated home page (root /)
/// is also a valid resting state when the user has signed out — a
/// blacklist there would cause an infinite loop /settings/usage → /
/// → /settings/usage → ... A 5s debounce catches double-fires from
/// SPA pushState + NavigationCompleted on the same route change.
/// </summary>
private void TryRedirectToUsage()
{
try
{
var core = WebView?.CoreWebView2;
if (core == null) return;
if (!Uri.TryCreate(core.Source, UriKind.Absolute, out var uri)) return;
if (!uri.Host.Equals("claude.ai", StringComparison.OrdinalIgnoreCase)) return;
string path = uri.AbsolutePath ?? "/";
bool isPostAuthLanding =
path.Equals("/new", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/chats", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/chat/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/projects", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/recents", StringComparison.OrdinalIgnoreCase);
if (!isPostAuthLanding) return;
var now = DateTime.UtcNow;
if ((now - _lastRedirectAttemptUtc).TotalSeconds < 5) return;
_lastRedirectAttemptUtc = now;
core.Navigate(UsageUrl);
}
catch (Exception ex)
{
Debug.WriteLine("ClaudeUsageControl: redirect to usage failed: " + ex);
}
}
private void OnNewWindowRequested(object sender, CoreWebView2NewWindowRequestedEventArgs e)
{
string uri = e.Uri ?? "";
bool isHelpLink =
uri.StartsWith("https://support.claude.com/", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("https://support.anthropic.com/", StringComparison.OrdinalIgnoreCase) ||
uri.StartsWith("https://docs.anthropic.com/", StringComparison.OrdinalIgnoreCase);
if (isHelpLink)
{
try
{
e.Handled = true;
Process.Start(new ProcessStartInfo(uri) { UseShellExecute = true });
}
catch { }
return;
}
// Leave e.Handled = false for everything else (Google/Apple OAuth,
// any other window.open). WebView2's default behavior is to open a
// real popup browser window itself, which gives the OAuth flow
// correct window.opener / postMessage / shared cookies / working
// window.close() — the things that break when we try to manage the
// popup ourselves with a separate WebView2 instance.
}
private void OnWebMessageReceived(object sender, CoreWebView2WebMessageReceivedEventArgs e)
{
try
{
string json = e.TryGetWebMessageAsString();
if (string.IsNullOrEmpty(json)) return;
var snap = JsonConvert.DeserializeObject<UsageSnapshot>(json);
if (snap == null) return;
UsageDataReceived?.Invoke(this, snap);
UpdateStatus();
// Persist cookies so other VS instances can reuse this session (throttled).
_ = SaveSharedCookiesAsync();
}
catch (Exception ex)
{
Debug.WriteLine("ClaudeUsageControl: scrape parse failed: " + ex);
}
}
private void UpdateStatus()
{
if (StatusText == null) return;
StatusText.Text = "Last checked: " + DateTime.Now.ToString("HH:mm:ss");
}
public void ApplyAutoRefreshSeconds(int seconds)
{
_suppressComboEvent = true;
try
{
int idx = 0;
if (seconds >= 300) idx = 4;
else if (seconds >= 120) idx = 3;
else if (seconds >= 60) idx = 2;
else if (seconds >= 30) idx = 1;
if (AutoRefreshCombo != null) AutoRefreshCombo.SelectedIndex = idx;
}
finally { _suppressComboEvent = false; }
RestartAutoRefreshTimer(seconds);
}
private void RestartAutoRefreshTimer(int seconds)
{
_autoRefreshTimer?.Stop();
_autoRefreshTimer = null;
if (seconds <= 0) return;
_autoRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(seconds) };
_autoRefreshTimer.Tick += (s, e) => Reload();
_autoRefreshTimer.Start();
}
public void Reload()
{
try { WebView?.CoreWebView2?.Reload(); } catch { }
}
/// <summary>
/// Called by the host tool window each time it becomes visible.
/// - Skips everything during background-init show-hide (no focus theft).
/// - Re-navigates to recover a black WebView2 surface if marked during background init.
/// - Primes the cursor so it renders without requiring a click.
/// </summary>
public void OnWindowBecameVisible()
{
if (_backgroundInitMode) return; // startup show-hide — do not steal focus
_firstNavigationCompleted = true; // suppress duplicate Focus() from OnNavigationCompleted
if (_needsReloadOnShow)
{
_needsReloadOnShow = false;
// Navigate rather than Reload to guarantee the rendering surface is rebuilt
// after being hidden mid-initialization (which can leave a black WebView2).
try { WebView?.CoreWebView2?.Navigate(UsageUrl); } catch { }
}
try { WebView?.Focus(); } catch { }
}
private void RefreshButton_Click(object sender, RoutedEventArgs e) => Reload();
private void AutoRefreshCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_suppressComboEvent) return;
var item = AutoRefreshCombo?.SelectedItem as ComboBoxItem;
if (item?.Tag is string tag && int.TryParse(tag, out int seconds))
{
RestartAutoRefreshTimer(seconds);
AutoRefreshChanged?.Invoke(this, seconds);
}
}
private void OpenInBrowserButton_Click(object sender, RoutedEventArgs e)
{
try { Process.Start(new ProcessStartInfo(UsageUrl) { UseShellExecute = true }); } catch { }
}
#pragma warning disable VSTHRD100 // async void Click handler is required by WPF
private async void SignOutButton_Click(object sender, RoutedEventArgs e)
#pragma warning restore VSTHRD100
{
try
{
var cm = WebView?.CoreWebView2?.CookieManager;
if (cm == null) return;
var cookies = await cm.GetCookiesAsync("https://claude.ai");
foreach (var c in cookies) cm.DeleteCookie(c);
cookies = await cm.GetCookiesAsync("https://anthropic.com");
foreach (var c in cookies) cm.DeleteCookie(c);
try { if (File.Exists(SharedCookiePath)) File.Delete(SharedCookiePath); } catch { }
Reload();
}
catch (Exception ex)
{
Debug.WriteLine("ClaudeUsageControl: sign out failed: " + ex);
}
}
private void InstallWebView2Button_Click(object sender, RoutedEventArgs e)
{
try { Process.Start(new ProcessStartInfo(WebView2DownloadUrl) { UseShellExecute = true }); } catch { }
}
private void ShowError(string message)
{
if (LoadingText != null) LoadingText.Visibility = Visibility.Collapsed;
if (ErrorPanel != null) ErrorPanel.Visibility = Visibility.Visible;
if (ErrorText != null) ErrorText.Text = message;
if (WebView != null) WebView.Visibility = Visibility.Collapsed;
}
private async Task LoadSharedCookiesAsync()
{
try
{
if (!File.Exists(SharedCookiePath)) return;
string json = File.ReadAllText(SharedCookiePath);
var dtos = JsonConvert.DeserializeObject<List<CookieDto>>(json);
if (dtos == null || dtos.Count == 0) return;
var cm = WebView?.CoreWebView2?.CookieManager;
if (cm == null) return;
foreach (var dto in dtos)
{
try
{
if (dto.Expires != DateTime.MinValue && dto.Expires < DateTime.UtcNow) continue;
var cookie = cm.CreateCookie(dto.Name, dto.Value, dto.Domain, dto.Path);
cookie.Expires = dto.Expires;
cookie.IsHttpOnly = dto.IsHttpOnly;
cookie.IsSecure = dto.IsSecure;
cookie.SameSite = (CoreWebView2CookieSameSiteKind)dto.SameSite;
cm.AddOrUpdateCookie(cookie);
}
catch { }
}
}
catch (Exception ex)
{
Debug.WriteLine("ClaudeUsageControl: LoadSharedCookiesAsync failed: " + ex);
}
}
private async Task SaveSharedCookiesAsync()
{
if ((DateTime.UtcNow - _lastCookieSaveUtc).TotalSeconds < 60) return;
_lastCookieSaveUtc = DateTime.UtcNow;
try
{
var cm = WebView?.CoreWebView2?.CookieManager;
if (cm == null) return;
var all = new List<CoreWebView2Cookie>();
foreach (var domain in new[] { "https://claude.ai", "https://anthropic.com" })
all.AddRange(await cm.GetCookiesAsync(domain));
var dtos = all.Select(c => new CookieDto
{
Name = c.Name,
Value = c.Value,
Domain = c.Domain,
Path = c.Path,
Expires = c.Expires,
IsHttpOnly = c.IsHttpOnly,
IsSecure = c.IsSecure,
SameSite = (int)c.SameSite
}).ToList();
File.WriteAllText(SharedCookiePath, JsonConvert.SerializeObject(dtos));
}
catch (Exception ex)
{
Debug.WriteLine("ClaudeUsageControl: SaveSharedCookiesAsync failed: " + ex);
}
}
private class CookieDto
{
public string Name { get; set; }
public string Value { get; set; }
public string Domain { get; set; }
public string Path { get; set; }
public DateTime Expires { get; set; }
public bool IsHttpOnly { get; set; }
public bool IsSecure { get; set; }
public int SameSite { get; set; }
}
private static void CleanupStaleWebView2Folders()
{
try
{
var baseDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ClaudeCodeExtension");
if (!Directory.Exists(baseDir)) return;
foreach (var dir in Directory.GetDirectories(baseDir, "WebView2_*"))
{
var pidStr = Path.GetFileName(dir).Substring("WebView2_".Length);
if (!int.TryParse(pidStr, out int pid)) continue;
try { System.Diagnostics.Process.GetProcessById(pid); }
catch (ArgumentException)
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
}
catch { }
}
public void Cleanup()
{
try
{
_autoRefreshTimer?.Stop();
_autoRefreshTimer = null;
if (WebView?.CoreWebView2 != null)
{
WebView.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
WebView.CoreWebView2.NavigationCompleted -= OnNavigationCompleted;
WebView.CoreWebView2.SourceChanged -= OnSourceChanged;
WebView.CoreWebView2.NewWindowRequested -= OnNewWindowRequested;
}
WebView?.Dispose();
}
catch { }
}
}
/// <summary>
/// Shared <see cref="CoreWebView2Environment"/> so the visible tool window
/// and the hidden inline-bars scraper can share cookies (single sign-on)
/// while running in the same process.
/// </summary>
internal static class ClaudeUsageWebViewEnvironment
{
private static CoreWebView2Environment _env;
private static readonly object _lock = new object();
private static Task<CoreWebView2Environment> _pending;
public static Task<CoreWebView2Environment> GetOrCreateAsync(string userDataFolder)
{
lock (_lock)
{
if (_env != null) return Task.FromResult(_env);
if (_pending != null) return _pending;
_pending = CreateAsync(userDataFolder);
return _pending;
}
}
private static async Task<CoreWebView2Environment> CreateAsync(string userDataFolder)
{
var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder, null);
lock (_lock) { _env = env; _pending = null; }
return env;
}
}
}