Why I Built This

I've been running all sorts of experiments with the Zhipu AI API lately, and I had absolutely no sense of how fast my tokens were burning through. Checking the dashboard meant navigating through multiple pages, and by the time I refreshed, I'd already forgotten the numbers. I wanted something — a tiny widget perched in the corner of my desktop, telling me in real time exactly how many tokens I'd burned today.

I looked around and found nothing that fit the bill, so I built it myself. That's how ZKanban was born — a Windows desktop floating widget purpose-built for monitoring Zhipu AI API usage.

Here's a screenshot of the finished product. A translucent frosted-glass card that stays pinned to the top-right corner of your desktop, supporting drag, collapse, and auto-refresh. You can switch between chart mode and overview mode, view token consumption trends from 1 to 60 days, and even overlay multiple models for comparison.

Core Features

  • Chart Mode: 1/7/30/60-day token consumption trends, multi-model overlay, Catmull-Rom spline-smoothed curves
  • Overview Mode: Quota cards + top-N model consumption at a glance
  • Auto-Refresh: Configurable 1–240 minute intervals, keeps login session alive in the background
  • Historical Cache: One JSON file per day, 60-day lookback, gap detection
  • Secure Storage: Windows DPAPI-encrypted credentials, decryptable only by the current user
  • Desktop Widget: Always-on-top, draggable, collapsible, dark frosted-glass theme

Technical Architecture

The entire project is built on Avalonia UI 12 (.NET 10), producing a single self-contained executable — no runtime installation required.

LayerTechnologyDescription
UI FrameworkAvalonia UI 12Cross-platform .NET UI framework, latest version at time of writing
Data CollectionAvalonia.Controls.WebView (WebView2)Embedded browser engine for automated login + API proxying
Credential EncryptionWindows DPAPICurrentUser scope, system-level encryption
Chart RenderingPure Avalonia CanvasHand-rolled Catmull-Rom splines, no third-party chart library
Build & DistributionSelf-contained SingleFile + TrimmedGitHub Actions auto-build, single-exe release

The most noteworthy part isn't the UI — it's how data is collected. The Zhipu open platform has no public usage query API, so I used a somewhat hacky approach — using WebView2 as a headless browser proxy.

Highlight 1: WebView2 as a Headless Browser Proxy

This is the most interesting part of the project. The idea is simple but practical:

  1. Launch a hidden WebView2 browser instance in the background
  2. Automatically navigate to the bigmodel.cn login page
  3. Simulate user actions to complete login (fill forms, click buttons)
  4. After successful login, extract the bigmodel_token_production cookie and organization/project info from localStorage
  5. Using these credentials, fire XHR requests within the browser context to call Zhipu's internal backend APIs

Why "within the browser context"? Because the Zhipu backend APIs validate Referer and Origin headers — direct requests via HttpClient get blocked. But JavaScript executing inside WebView2 naturally carries the correct Origin and cookies, bypassing the checks perfectly.

Here's the core FetchUsageDataAsync method:

private async Task<T?> FetchUsageDataAsync<T>(NativeWebView webView, string relativeUrl)
{
    if (_cachedAuth is null)
    {
        var cookieManager = webView.TryGetCookieManager()
            ?? throw new InvalidOperationException("WebView 未初始化或 Cookie 管理器不可用。");
        var cookies = await cookieManager.GetCookiesAsync();
        var token = cookies.FirstOrDefault(cookie =>
            cookie.Name.Equals("bigmodel_token_production",
                StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty;
        var organization = await ExecuteJsonAsync<string>(webView,
            "localStorage.getItem('Bigmodel-Organization') || ''");
        var project = await ExecuteJsonAsync<string>(webView,
            "localStorage.getItem('Bigmodel-Project') || ''");
        _cachedAuth = new AuthContext(token, organization, project);
    }

    var script = $"""
        (() => {
          const xhr = new XMLHttpRequest();
          xhr.open('GET', {{JsonSerializer.Serialize(relativeUrl)}}, false);
          xhr.withCredentials = true;
          xhr.setRequestHeader('Authorization',
              {{JsonSerializer.Serialize(Uri.UnescapeDataString(_cachedAuth.Token))}});
          xhr.setRequestHeader('Bigmodel-Organization',
              {{JsonSerializer.Serialize(_cachedAuth.Organization)}});
          xhr.setRequestHeader('Bigmodel-Project',
              {{JsonSerializer.Serialize(_cachedAuth.Project)}});
          xhr.setRequestHeader('Set-Language', 'zh');
          xhr.send();
          return xhr.responseText;
        })();
        """;
    var raw = await ExecuteJsonAsync<string>(webView, script);
    return JsonSerializer.Deserialize<T>(raw);
}

Note that this XHR is synchronous (xhr.open('GET', url, false)). That's because this code runs inside WebView2's InvokeScript, where async operations can't directly return results to the C# side. Synchronous XHR is generally discouraged in web pages, but in this "script injection + immediate return" scenario, it's the simplest approach.

The auto-login part is equally interesting — it needs to switch to the "account login" tab, fill in the username and password, then click the login button. The key is correctly triggering Vue/React data-binding events:

const setValue = (selector, value) => {
  const input = [...document.querySelectorAll(selector)].find(visible);
  if (!input) return false;
  // 必须通过原生 setter 设值,否则 Vue/React 无法感知变化
  const setter = Object.getOwnPropertyDescriptor(
      HTMLInputElement.prototype, 'value')?.set;
  if (setter) setter.call(input, value); else input.value = value;
  input.dispatchEvent(new Event('input', { bubbles: true }));
  input.dispatchEvent(new Event('change', { bubbles: true }));
  return true;
};

There's a crucial detail here: Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set. Directly setting input.value can be intercepted by Vue/React's two-way binding — the framework may have overridden the setter internally. By grabbing the native setter to set the value, then manually dispatching input and change events, the framework correctly picks up the change.

Highlight 2: Hand-Drawn Charts on Pure Canvas

The entire charting module uses no third-party chart libraries whatsoever (no OxyPlot, no LiveChart — none of them). It's drawn directly on an Avalonia Canvas. The reason is simple: I only need one type of chart — a line trend chart with multi-model overlay. Pulling in a full charting library felt like overkill.

The most important part is the smooth curve implementation. Raw data points are discrete, and connecting them directly produces jagged edges that look terrible. I used Catmull-Rom splines converted to cubic Bézier curves, requiring only about 30 lines of core code:

private Geometry BuildSmoothPath()
{
    var pts = Points;
    var minY = double.MaxValue;
    var maxY = double.MinValue;
    for (var i = 0; i < pts.Count; i++)
    {
        if (pts[i].Y < minY) minY = pts[i].Y;
        if (pts[i].Y > maxY) maxY = pts[i].Y;
    }

    var sb = new System.Text.StringBuilder();
    sb.Append(CultureInfo.InvariantCulture, $"M {pts[0].X},{pts[0].Y}");

    for (var i = 0; i < pts.Count - 1; i++)
    {
        var p0 = i > 0 ? pts[i - 1] : pts[i];
        var p1 = pts[i];
        var p2 = pts[i + 1];
        var p3 = i < pts.Count - 2 ? pts[i + 2] : pts[i + 1];

        // Catmull-Rom → Cubic Bézier 控制点
        // τ = 1/6 是均匀参数化下的标准张力系数
        var cp1x = p1.X + (p2.X - p0.X) / 6;
        var cp1y = Math.Clamp(p1.Y + (p2.Y - p0.Y) / 6, minY, maxY);
        var cp2x = p2.X - (p3.X - p1.X) / 6;
        var cp2y = Math.Clamp(p2.Y - (p3.Y - p1.Y) / 6, minY, maxY);

        sb.Append(CultureInfo.InvariantCulture,
            $" C {cp1x},{cp1y} {cp2x},{cp2y} {p2.X},{p2.Y}");
    }

    return Geometry.Parse(sb.ToString());
}

The math is intuitive: given four points P0, P1, P2, P3, the two control points for the P1→P2 Bézier segment are:

CP1 = P1 + (P2 - P0) / 6
CP2 = P2 - (P3 - P1) / 6

Dividing by 6 corresponds to the tension coefficient τ=1/6 under uniform Catmull-Rom parameterization, which maps cleanly to the cubic Bézier basis functions. One subtle detail: the Y values are clamped via Math.Clamp to prevent the curve from overshooting beyond the data range — this matters for token usage data because negative token consumption is meaningless.

Finally, Avalonia's Geometry.Parse parses the path mini-language (M x,y C cp1x,cp1y cp2x,cp2y x,y) to produce a geometry object, which is handed off to Avalonia.Controls.Shapes.Path for rendering. The whole thing is clean and efficient, with 13 unit tests specifically covering various edge cases in chart layout calculations.

Highlight 3: DPAPI Credential Encryption

User credentials need to be stored locally, but they obviously can't sit in plaintext in a JSON file. I went straight for Windows DPAPI (Data Protection API) — one line of code does the job:

private static string Encrypt(string plainText)
{
    var bytes = Encoding.UTF8.GetBytes(plainText);
    var encrypted = ProtectedData.Protect(
        bytes, null, DataProtectionScope.CurrentUser);
    return Convert.ToBase64String(encrypted);
}

private static string Decrypt(string? protectedText)
{
    var bytes = Convert.FromBase64String(protectedText);
    var decrypted = ProtectedData.Unprotect(
        bytes, null, DataProtectionScope.CurrentUser);
    return Encoding.UTF8.GetString(decrypted);
}

DataProtectionScope.CurrentUser means only the current Windows user can decrypt the data — other users on the same machine can't read it. There's no need to manage keys yourself; Windows derives the encryption key from the user's login credentials under the hood.

No AES, no hardcoded keys, no third-party crypto libraries — DPAPI was built exactly for this purpose.

Migrating from WPF to Avalonia

ZKanban was originally written in WPF. After completing the first version, I spent an afternoon migrating it to Avalonia. There were two reasons:

  1. Avalonia 12 had just been released, with official WebView2 control support (Avalonia.Controls.WebView) — something that had been missing before
  2. Cross-platform potential. While WebView2 currently only supports Windows, Avalonia itself is cross-platform. If a Linux/macOS WebView solution emerges in the future, the migration cost would be nearly zero

The migration was surprisingly smooth — done in just 4 commits:

  • MainWindow migrated from WPF to Avalonia, replaced with NativeWebView
  • SettingsWindow migrated, wrote a custom SimpleMessageBox to replace WPF's MessageBox
  • Automation services and ViewModels fully updated to Avalonia types (IBrush, Point, Geometry, etc.)
  • Updated build tooling, removed old WPF screenshots

Most of the effort went into type mappings: SolidColorBrush namespace changes, Geometry namespace changes, XAML namespaces switched from WPF to Avalonia. The logic code barely needed any changes.

Lessons Learned

"No API" doesn't mean "can't build it." Many developers give up when a platform lacks a public API. But as long as there's a web page accessible in a browser, you can theoretically "create" an API through WebView automation. This approach is incredibly practical for monitoring tools and data collection utilities.

Don't use a library just for the sake of using one. For charts, I only needed Canvas + 30 lines of spline code. Pulling in LiveChart or OxyPlot would have bloated the package size and made style customization harder. 30 lines of hand-written code is fully controllable and easy to debug when something goes wrong.

Single-file publish is a game-changer. .NET's self-contained single-file publishing is mature now — trimmed to a reasonable size, users download one exe and double-click to run, no runtime installation needed. Paired with GitHub Actions auto-publishing, pushing a tag produces a Release instantly. The experience is buttery smooth.

Wrap-up

ZKanban's total codebase is roughly 3,400 lines — 496 lines of hand-written XAML for UI styling, 580 lines for the automation service, and 700+ lines for the main window logic. Small but complete: every layer from data collection to chart rendering to secure storage has technical decisions worth discussing.

The project is open source on GitHub (MIT license). Stars and issues are welcome. If you're using the Zhipu AI API, give this little widget a try.

Roadmap:

  • Support usage monitoring for more API platforms (OpenAI, Anthropic, etc.)
  • Add usage alert notifications (popup when token consumption exceeds a threshold)
  • Explore Avalonia cross-platform possibilities