From fbe6a6c34f77793b31984aa9bb93ea7a49e094ec Mon Sep 17 00:00:00 2001 From: windycn Date: Sat, 26 Sep 2026 17:03:05 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=AB=98=E5=88=86?= =?UTF-8?q?=E5=B1=8F=E4=BA=A4=E4=BA=92=E4=B8=8E=E7=AA=97=E5=8F=A3=E5=86=B2?= =?UTF-8?q?=E7=AA=81=EF=BC=8C=E5=8A=A0=E5=85=A5=E6=9B=B4=E6=96=B0=E3=80=81?= =?UTF-8?q?=E5=A4=96=E8=A7=82=E8=AE=BE=E7=BD=AE=E5=92=8C=20GPT-6=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/windows.yml | 33 +++ tests/WinCodexBar.CoreTests/Program.cs | 63 ++++++ .../WinCodexBar.CoreTests.csproj | 15 ++ tests/WinCodexBar.WindowsTests/Program.cs | 141 ++++++++++++ .../WinCodexBar.WindowsTests.csproj | 4 + windows/CodexBarWin/CodexBarWin.csproj | 9 +- windows/CodexBarWin/Models/CodexBarConfig.cs | 24 ++- windows/CodexBarWin/Program.cs | 9 +- .../CodexBarWin/Services/AccountRegistry.cs | 2 +- .../CodexBarWin/Services/AppUpdateService.cs | 201 ++++++++++++++++++ windows/CodexBarWin/Services/AtomicFile.cs | 17 ++ .../Services/CodexBarConfigStore.cs | 32 ++- .../CodexBarWin/Services/CodexRadarService.cs | 34 ++- .../CodexBarWin/Tray/CodexBarTrayContext.cs | 157 ++++++++++++-- windows/CodexBarWin/UI/AdaptiveForm.cs | 77 +++++++ windows/CodexBarWin/UI/AppAppearance.cs | 8 + .../CodexBarWin/UI/CodexBarDashboardForm.cs | 46 ++-- windows/CodexBarWin/UI/ConfirmActionDialog.cs | 6 +- windows/CodexBarWin/UI/FluentButton.cs | 5 +- windows/CodexBarWin/UI/FluentToggleSwitch.cs | 17 +- windows/CodexBarWin/UI/MicaSupport.cs | 2 + .../CodexBarWin/UI/OpenAIOAuthLoginForm.cs | 4 +- windows/CodexBarWin/UI/SettingCard.cs | 38 ++-- windows/CodexBarWin/UI/SettingsForm.cs | 59 ++++- windows/CodexBarWin/UI/TrayPopupForm.cs | 94 +++++--- 25 files changed, 962 insertions(+), 135 deletions(-) create mode 100644 .github/workflows/windows.yml create mode 100644 tests/WinCodexBar.CoreTests/Program.cs create mode 100644 tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj create mode 100644 tests/WinCodexBar.WindowsTests/Program.cs create mode 100644 tests/WinCodexBar.WindowsTests/WinCodexBar.WindowsTests.csproj create mode 100644 windows/CodexBarWin/Services/AppUpdateService.cs create mode 100644 windows/CodexBarWin/Services/AtomicFile.cs create mode 100644 windows/CodexBarWin/UI/AdaptiveForm.cs create mode 100644 windows/CodexBarWin/UI/AppAppearance.cs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..4694edf --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,33 @@ +name: Windows 构建与回归 +on: + push: + branches: [main, fix/windows-0.2] + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: 核心回归 + run: dotnet run --project tests/WinCodexBar.CoreTests -c Release + - name: Windows 窗口回归 + run: dotnet run --project tests/WinCodexBar.WindowsTests -c Release + - name: 发布三个架构 + shell: pwsh + run: | + foreach ($rid in @('win-x64', 'win-x86', 'win-arm64')) { + dotnet publish windows/CodexBarWin/CodexBarWin.csproj -c Release -r $rid --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o "artifacts/$rid" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-validation-and-packages + path: | + artifacts/ + tests/WinCodexBar.WindowsTests/bin/Release/net8.0-windows/screenshots/ diff --git a/tests/WinCodexBar.CoreTests/Program.cs b/tests/WinCodexBar.CoreTests/Program.cs new file mode 100644 index 0000000..877a574 --- /dev/null +++ b/tests/WinCodexBar.CoreTests/Program.cs @@ -0,0 +1,63 @@ +using CodexBarWin.Models; +using CodexBarWin.Services; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; + +var scratch = Path.Combine(Path.GetTempPath(), "wincodexbar-test-" + Guid.NewGuid().ToString("N")); +Environment.SetEnvironmentVariable("CODEXBAR_HOME", scratch); +Directory.CreateDirectory(scratch); +var passed = 0; +void Check(bool result, string name) { if (!result) throw new Exception(name); Console.WriteLine("PASS " + name); passed++; } +void Reject(Action action, string name) { try { action(); } catch (InvalidDataException) { Check(true, name); return; } throw new Exception("Expected rejection: " + name); } +try +{ + var settings = new CodexBarConfigStore(); + settings.Update(c => { c.KeepAwakeEnabled = false; c.AdvancedKeepAwakeEnabled = false; c.StartWithWindows = false; c.AutoCheckUpdates = false; c.UiScalePercent = 200; c.TrayIconStyle = "percent"; c.Global.DefaultModel = "custom-model"; }); + settings.Load(); + Check(!settings.Config.KeepAwakeEnabled && !settings.Config.AdvancedKeepAwakeEnabled && !settings.Config.StartWithWindows && !settings.Config.AutoCheckUpdates, "false settings survive restart"); + Check(settings.Config.UiScalePercent == 200 && settings.Config.TrayIconStyle == "percent", "appearance survives restart"); + Check(settings.Config.Global.DefaultModel == "custom-model", "upgrade preserves custom model"); + Check(settings.Config.OpenAI.TokenPricePresets.ContainsKey("gpt-6-astra") && settings.Config.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion == 10, "GPT-6 presets merged"); + var clone = settings.Config.Clone(); clone.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion = 1; + Check(settings.Config.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion == 10, "draft pricing isolated"); + File.WriteAllText(CodexPaths.WindowsSettingsPath, "{\"KeepAwakeEnabled\":false,\"openai\":{\"auto_refresh_interval_seconds\":2147483647},\"ui_scale_percent\":-5}"); + settings.Load(); + Check(!settings.Config.KeepAwakeEnabled && settings.Config.OpenAI.AutoRefreshIntervalSeconds == 86400 && settings.Config.UiScalePercent == 0, "legacy settings and invalid ranges"); + var registry = new AccountRegistry(); + registry.UpsertAccount(new TokenAccount { AccountId = "fake-a", Email = "a@example.test" }, true); + registry.UpsertAccount(new TokenAccount { AccountId = "fake-b", Email = "b@example.test" }, false); + Check(registry.RemoveAccount("fake-a") && registry.ActiveAccountId == "fake-b", "delete active chooses remaining account"); + registry.Save(); registry.Load(); Check(registry.Accounts.Count == 1 && registry.Accounts[0].AccountId == "fake-b", "deleted account stays deleted after reload"); + Check(registry.RemoveAccount("fake-b") && registry.ActiveAccountId is null, "delete last account clears selection"); + Check(!registry.RemoveAccount("fake-b"), "repeat delete harmless"); + using var radar = JsonDocument.Parse("{\"window_open\":true,\"window\":{\"opened_at\":\"2026-09-26T08:07:13+08:00\"},\"prediction\":{\"level\":\"low\",\"updated_at\":\"2026-07-13T22:08:06+08:00\"}}"); + var prediction = CodexRadarService.Parse(radar.RootElement); + Check(prediction.WindowOpen == true && prediction.DisplayText.Contains("速蹬窗口开启") && !prediction.DisplayText.Contains("低"), "current window takes precedence over stale probability"); + using var closed = JsonDocument.Parse("{\"window_open\":false,\"prediction\":null}"); + Check(CodexRadarService.Parse(closed.RootElement).DisplayText.Contains("未开启"), "explicit closed window supported without prediction"); + using var empty = JsonDocument.Parse("{}"); Check(!CodexRadarService.Parse(empty.RootElement).IsAvailable, "missing radar data unavailable"); + string Release(string tag, string architecture = "x64", bool preview = false) => JsonSerializer.Serialize(new { + tag_name = tag, draft = false, prerelease = preview, + assets = new[] { new { name = $"WinCodexBar-{tag.TrimStart('v')}-win-{architecture}.zip", browser_download_url = $"https://github.com/windycn/WinCodexBar/releases/download/{tag}/WinCodexBar-{tag.TrimStart('v')}-win-{architecture}.zip" }, + new { name = "SHA256SUMS.txt", browser_download_url = $"https://github.com/windycn/WinCodexBar/releases/download/{tag}/SHA256SUMS.txt" } } + }); + foreach (var arch in new[] { "x86", "x64", "arm64" }) + Check(AppUpdateService.ParseRelease(Release("v0.3.0", arch), new Version(0,2,0,0), arch)?.AssetName.EndsWith(arch + ".zip") == true, "matching update architecture " + arch); + Check(AppUpdateService.ParseRelease(Release("v0.2.0"), new Version(0,2,0,0), "x64") is null, "same version not updated"); + Check(AppUpdateService.ParseRelease(Release("v0.3.0", preview:true), new Version(0,2,0), "x64") is null, "prerelease ignored"); + Reject(() => AppUpdateService.ParseRelease(Release("v0.3.0").Replace("https://github.com/", "https://example.test/"), new Version(0,2,0), "x64"), "foreign download rejected"); + Reject(() => AppUpdateService.ParseRelease(Release("v0.3.0"), new Version(0,2,0), "arm64"), "missing architecture rejected"); + var zip = Path.Combine(scratch, "test.zip"); + using (var archive = ZipFile.Open(zip, ZipArchiveMode.Create)) { using var writer = new StreamWriter(archive.CreateEntry("WinCodexBar.exe").Open()); writer.Write("fake binary"); } + var hash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(zip))); + AppUpdateService.VerifyChecksum(zip, "test.zip", hash + " test.zip"); Check(true, "matching checksum accepted"); + Reject(() => AppUpdateService.VerifyChecksum(zip, "test.zip", new string('0',64) + " test.zip"), "corrupt download rejected"); + AppUpdateService.ExtractPackage(zip, Path.Combine(scratch,"valid")); Check(File.Exists(Path.Combine(scratch,"valid","WinCodexBar.exe")), "valid update package extracted"); + var bad = Path.Combine(scratch,"bad.zip"); using (var archive = ZipFile.Open(bad, ZipArchiveMode.Create)) archive.CreateEntry("../escape"); + Reject(() => AppUpdateService.ExtractPackage(bad, Path.Combine(scratch,"bad")), "zip traversal rejected"); + var script = AppUpdateService.BuildInstallScript("C:\\O'Brien\\package", "C:\\app", "C:\\data", 1234); + Check(script.Contains("O''Brien") && script.Contains("previous-program") && script.Contains("before-update-"), "installer escapes paths and includes backups"); + Console.WriteLine($"{passed} checks passed"); +} +finally { Directory.Delete(scratch, true); } diff --git a/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj b/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj new file mode 100644 index 0000000..a1c8b0d --- /dev/null +++ b/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj @@ -0,0 +1,15 @@ + + Exenet8.0enableenable + + + + + + + + + + + + + diff --git a/tests/WinCodexBar.WindowsTests/Program.cs b/tests/WinCodexBar.WindowsTests/Program.cs new file mode 100644 index 0000000..bbd0d5a --- /dev/null +++ b/tests/WinCodexBar.WindowsTests/Program.cs @@ -0,0 +1,141 @@ +using CodexBarWin.Models; +using CodexBarWin.Services; +using CodexBarWin.UI; +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; + +internal static class Program +{ + private static int _passed; + private static readonly BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + private static void Check(bool condition, string label) { if (!condition) throw new Exception(label); Console.WriteLine("PASS " + label); _passed++; } + private static object? Field(object target, string name) => target.GetType().GetField(name, Private)!.GetValue(target); + private static object? Call(object target, string name, params object[] args) => target.GetType().GetMethod(name, Private)!.Invoke(target, args); + private static IEnumerable Descendants(Control c) => c.Controls.Cast().SelectMany(child => new[] { child }.Concat(Descendants(child))); + private static void Paint(Form form, string? file = null) + { + Application.DoEvents(); + using var bitmap = new Bitmap(form.Width, form.Height); + form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, bitmap.Size)); + if (file is not null) bitmap.Save(Path.Combine(AppContext.BaseDirectory, "screenshots", file)); + } + [STAThread] + private static void Main() + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + Application.EnableVisualStyles(); + var home = Path.Combine(Path.GetTempPath(), "WinCodexBar UI ' " + Guid.NewGuid().ToString("N")); + Environment.SetEnvironmentVariable("CODEXBAR_HOME", home); + Directory.CreateDirectory(home); + Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory,"screenshots")); + try + { + var store = new CodexBarConfigStore(); + var wake = new KeepAwakeService(store); // 不 Load,不改变机器唤醒状态。 + foreach (var scale in new[] {100,150,200,250}) + { + AppAppearance.ScalePercent = scale; + using var settings = new SettingsForm(store, wake, () => {}, () => {}); + settings.Show(); + foreach (var size in new[] {new Size(800,600),new Size(1366,768),new Size(1920,1080)}) + { + settings.Size = size; settings.ApplyAppearance(); + var pageType = typeof(SettingsForm).GetNestedType("Page", BindingFlags.NonPublic)!; + foreach (var page in Enum.GetValues(pageType)) + { + Call(settings,"SelectPage",page!); Paint(settings); + Check(Screen.FromControl(settings).WorkingArea.Contains(settings.Bounds), $"settings fit {scale}% {size} {page}"); + foreach (var card in Descendants(settings).OfType()) + { + if (card.Action is not { } action) continue; + Check(card.ClientRectangle.Contains(action.Bounds), $"action within card {scale}% {page}"); + foreach (var label in card.Controls.OfType