client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较

This commit is contained in:
Qiufeng
2026-08-20 20:38:21 +08:00
parent 33b7d57498
commit 9abf7f8213
60 changed files with 10025 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<Application
x:Class="EveryPublish.Client.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.UI.Xaml;
namespace EveryPublish.Client;
public partial class App : Application
{
private Window? _window;
public App()
{
InitializeComponent();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
var startMinimized = args.Arguments.Contains("--minimized");
_window = new MainWindow();
if (!startMinimized)
{
_window.Activate();
}
}
}
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<RootNamespace>EveryPublish.Client</RootNamespace>
<AssemblyName>EveryPublish.Client</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Platforms>x64</Platforms>
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<EnableMsixTooling>true</EnableMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.2.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.5.240802000" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" />
<PackageReference Include="H.NotifyIcon.WinUI" Version="2.0.131" />
</ItemGroup>
</Project>
+26
View File
@@ -0,0 +1,26 @@
<Window
x:Class="EveryPublish.Client.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="EveryPublish 客户端">
<NavigationView x:Name="NavView"
PaneDisplayMode="Top"
IsSettingsVisible="False"
SelectionChanged="NavView_SelectionChanged">
<NavigationView.MenuItems>
<NavigationViewItem Content="状态" Tag="status">
<NavigationViewItem.Icon><SymbolIcon Symbol="Home"/></NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="配对" Tag="pair">
<NavigationViewItem.Icon><SymbolIcon Symbol="Link"/></NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="挑战" Tag="challenges">
<NavigationViewItem.Icon><SymbolIcon Symbol="Permissions"/></NavigationViewItem.Icon>
</NavigationViewItem>
<NavigationViewItem Content="设置" Tag="settings">
<NavigationViewItem.Icon><SymbolIcon Symbol="Setting"/></NavigationViewItem.Icon>
</NavigationViewItem>
</NavigationView.MenuItems>
<Frame x:Name="ContentFrame"/>
</NavigationView>
</Window>
+119
View File
@@ -0,0 +1,119 @@
using System;
using System.IO;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using EveryPublish.Client.Pages;
using EveryPublish.Client.Services;
using H.NotifyIcon;
namespace EveryPublish.Client;
public sealed partial class MainWindow : Window
{
private readonly string _dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EveryPublish");
private readonly CoreClient _core;
private readonly Watchdog _watchdog;
private readonly ClientContext _context;
private TaskbarIcon? _tray;
private bool _exiting;
/// <summary>供设置页「退出客户端」调用</summary>
public static Action? ExitAction;
public MainWindow()
{
InitializeComponent();
Directory.CreateDirectory(_dataDir);
_core = new CoreClient(_dataDir);
_watchdog = new Watchdog(_core, _dataDir, OnCoreFatal);
_context = new ClientContext(_core, _dataDir, _watchdog);
ExitAction = ExitApp;
AppWindow.Closing += OnClosing;
InitTray();
NavView.SelectedItem = NavView.MenuItems[0];
Navigate("status");
_watchdog.Start();
}
private void OnCoreFatal()
{
DispatcherQueue.TryEnqueue(async () =>
{
var dialog = new ContentDialog
{
Title = "核心进程异常",
Content = "agent-core 连续重启失败,请检查安装目录或重新安装。",
CloseButtonText = "知道了",
XamlRoot = Content.XamlRoot,
};
await dialog.ShowAsync();
});
}
private void InitTray()
{
try
{
_tray = new TaskbarIcon { ToolTipText = "EveryPublish 客户端" };
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "app.ico");
if (File.Exists(iconPath))
{
_tray.IconSource = new BitmapImage(new Uri(iconPath));
}
// H.NotifyIcon 2.x:左/右键命令打开主窗口(退出入口在设置页)
_tray.LeftClickCommand = new SimpleCommand(ShowWindow);
_tray.RightClickCommand = new SimpleCommand(ShowWindow);
}
catch
{
// 托盘不可用不影响主功能
}
}
private void ShowWindow()
{
AppWindow.Show();
Activate();
}
private void OnClosing(AppWindow sender, AppWindowClosingEventArgs args)
{
if (!_exiting)
{
args.Cancel = true;
AppWindow.Hide(); // 隐藏到托盘(WinUI 3 无 Window.Hide,用 AppWindow.Hide)
}
}
private void ExitApp()
{
_exiting = true;
_tray?.Dispose();
_watchdog.Dispose();
_core.Dispose();
Close();
}
private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
if (args.SelectedItem is NavigationViewItem item && item.Tag is string tag)
{
Navigate(tag);
}
}
private void Navigate(string tag)
{
Type page = tag switch
{
"pair" => typeof(PairPage),
"challenges" => typeof(ChallengesPage),
"settings" => typeof(SettingsPage),
_ => typeof(StatusPage),
};
ContentFrame.Navigate(page, _context);
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
namespace EveryPublish.Client.Models;
/// <summary>agent-core 写入的 local.json(随机端口 + token)</summary>
public class LocalInfo
{
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("token")]
public string Token { get; set; } = "";
}
/// <summary>核心状态(/status 返回)</summary>
public class CoreStatus
{
[JsonPropertyName("online")]
public bool Online { get; set; }
[JsonPropertyName("session")]
public string Session { get; set; } = "";
[JsonPropertyName("server")]
public string Server { get; set; } = "";
[JsonPropertyName("deviceId")]
public long DeviceId { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = "";
}
/// <summary>挑战(/challenges 返回,与 shared/proto.Challenge 一致)</summary>
public class ChallengeItem
{
[JsonPropertyName("challengeId")]
public string ChallengeId { get; set; } = "";
[JsonPropertyName("accountId")]
public string AccountId { get; set; } = "";
[JsonPropertyName("platform")]
public string Platform { get; set; } = "";
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("qrToken")]
public string QrToken { get; set; } = "";
[JsonPropertyName("prompt")]
public string Prompt { get; set; } = "";
[JsonPropertyName("expiresAt")]
public long ExpiresAt { get; set; }
}
+47
View File
@@ -0,0 +1,47 @@
# EveryPublish 客户端打包脚本(Windows 上执行)
# 产出 dist/EveryPublish-Client-<版本>-win-x64.zip:WinUI 壳 + agent-core.exe 同目录。
param(
[string]$Version = "0.2.0"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot # client/ui
$dist = Join-Path $root "..\dist"
New-Item -ItemType Directory -Force -Path $dist | Out-Null
Write-Host "[1/5] 生成图标 app.ico ..."
Add-Type -AssemblyName System.Drawing
$assets = Join-Path $root "Assets"
New-Item -ItemType Directory -Force -Path $assets | Out-Null
$bmp = New-Object System.Drawing.Bitmap 32, 32
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.Clear([System.Drawing.Color]::FromArgb(0, 110, 255))
$font = New-Object System.Drawing.Font("Arial", 16, [System.Drawing.FontStyle]::Bold)
$g.DrawString("E", $font, [System.Drawing.Brushes]::White, 7, 5)
$g.Dispose()
$icon = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
$fs = [System.IO.File]::Create((Join-Path $assets "app.ico"))
$icon.Save($fs)
$fs.Close()
Write-Host "[2/5] 交叉编译 agent-core.exe (Go) ..."
Push-Location (Join-Path $root "..\core")
$env:GOOS = "windows"
$env:GOARCH = "amd64"
go build -o (Join-Path $dist "agent-core.exe") .
Pop-Location
Write-Host "[3/5] dotnet publish WinUI 壳 ..."
$publishDir = Join-Path $dist "publish"
Push-Location $root
dotnet publish EveryPublish.Client.csproj -c Release -r win-x64 -p:Platform=x64 -o $publishDir
Pop-Location
Write-Host "[4/5] 组装产物 ..."
Copy-Item (Join-Path $dist "agent-core.exe") $publishDir -Force
Copy-Item -Recurse -Force $assets (Join-Path $publishDir "Assets")
Write-Host "[5/5] 压缩 ..."
$zip = Join-Path $dist "EveryPublish-Client-$Version-win-x64.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $zip -Force
Write-Host "OK: $zip" -ForegroundColor Green
+31
View File
@@ -0,0 +1,31 @@
# EveryPublish Windows 构建前环境自检(PowerShell)
Write-Host "=== EveryPublish Windows 构建自检 ===" -ForegroundColor Cyan
function Check($name, $cmd) {
try {
$out = Invoke-Expression $cmd 2>&1 | Out-String
$first = ($out.Trim() -split "`n")[0]
Write-Host "[OK] $name : $first" -ForegroundColor Green
return $true
} catch {
Write-Host "[FAIL] $name 未安装或不可用" -ForegroundColor Red
return $false
}
}
$ok = $true
$ok = (Check "dotnet SDK" "dotnet --version") -and $ok
$ok = (Check "Go 工具链" "go version") -and $ok
# 版本校验:.NET 需 8.x,Go 需 1.23+
$dotnetVer = (dotnet --version 2>$null)
if ($dotnetVer -and -not $dotnetVer.StartsWith("8.")) {
Write-Host "[WARN] dotnet 版本 $dotnetVer,建议 .NET 8 SDK" -ForegroundColor Yellow
}
if (-not $ok) {
Write-Host "请先安装依赖(见 client/ui/README.md)后重试" -ForegroundColor Red
exit 1
}
Write-Host "环境就绪:可运行 Packaging\build-installer.ps1 打包" -ForegroundColor Green
+38
View File
@@ -0,0 +1,38 @@
<Page
x:Class="EveryPublish.Client.Pages.ChallengesPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Margin="32" RowSpacing="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Spacing="8">
<TextBlock Text="二次验证挑战" Style="{StaticResource TitleTextBlockStyle}"/>
<TextBlock Text="平台要求扫码/验证码时在此处理;超时可在网页端一键重发。" Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="12">
<ListView x:Name="ChallengeList" SelectionChanged="ChallengeList_SelectionChanged">
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Padding="8" Spacing="4">
<TextBlock FontWeight="SemiBold">
<Run Text="平台:"/><Run Text="{Binding Platform}"/>
<Run Text=" 类型:"/><Run Text="{Binding Kind}"/>
</TextBlock>
<TextBlock Text="{Binding Prompt}" TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<TextBlock x:Name="EmptyText" Text="暂无待处理挑战" Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<StackPanel x:Name="SolvePanel" Spacing="8" Visibility="Collapsed" MaxWidth="420">
<Image x:Name="QrImage" MaxHeight="220" MaxWidth="220" Visibility="Collapsed" HorizontalAlignment="Left"/>
<TextBlock x:Name="SolveHint" Text="验证码 / 确认值"/>
<TextBox x:Name="ValueBox" PlaceholderText="输入收到的验证码"/>
<Button x:Name="SolveButton" Content="提交" Style="{StaticResource AccentButtonStyle}" Click="Solve_Click" HorizontalAlignment="Left"/>
<TextBlock x:Name="SolveResult" Text="" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Grid>
</Page>
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using EveryPublish.Client.Models;
using EveryPublish.Client.Services;
namespace EveryPublish.Client.Pages;
public sealed partial class ChallengesPage : Page
{
private ClientContext _ctx = null!;
private DispatcherTimer? _timer;
private ChallengeItem? _selected;
public ChallengesPage()
{
InitializeComponent();
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
_timer.Tick += async (s, e) => await RefreshAsync();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
_ctx = (ClientContext)e.Parameter;
_ctx.Core.ReloadLocalJson();
_timer?.Start();
_ = RefreshAsync();
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
_timer?.Stop();
}
private async Task RefreshAsync()
{
var list = await _ctx.Core.GetChallengesAsync();
ChallengeList.ItemsSource = list;
EmptyText.Visibility = list.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
if (_selected == null)
{
SolvePanel.Visibility = Visibility.Collapsed;
}
}
private void ChallengeList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
_selected = ChallengeList.SelectedItem as ChallengeItem;
if (_selected == null)
{
SolvePanel.Visibility = Visibility.Collapsed;
return;
}
SolvePanel.Visibility = Visibility.Visible;
SolveHint.Text = _selected.Kind switch
{
"qr" => "扫码挑战(请在对应平台 App 扫码;完成后点击「我已确认」提交)",
_ => "输入收到的验证码 / 确认值",
};
// 二维码展示:qrUrl 为 http URL 或 file:// 本地路径
var qr = !string.IsNullOrEmpty(_selected.QrUrl) ? _selected.QrUrl : _selected.QrToken;
if (!string.IsNullOrEmpty(qr) && (qr.StartsWith("http") || qr.StartsWith("file://")))
{
QrImage.Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(new Uri(qr));
QrImage.Visibility = Visibility.Visible;
}
else
{
QrImage.Visibility = Visibility.Collapsed;
}
ValueBox.Text = "";
SolveResult.Text = "";
}
private async void Solve_Click(object sender, RoutedEventArgs e)
{
if (_selected == null) return;
var value = ValueBox.Text.Trim();
if (_selected.Kind == "qr" && string.IsNullOrEmpty(value))
{
value = "confirmed";
}
if (string.IsNullOrEmpty(value))
{
SolveResult.Text = "请输入验证码";
return;
}
SolveButton.IsEnabled = false;
var (ok, message) = await _ctx.Core.SolveAsync(_selected.ChallengeId, value);
SolveButton.IsEnabled = true;
SolveResult.Text = ok ? "已提交,等待服务器确认" : message;
if (ok)
{
_selected = null;
await RefreshAsync();
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<Page
x:Class="EveryPublish.Client.Pages.PairPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel Margin="32" Spacing="16" MaxWidth="480">
<TextBlock Text="设备配对" Style="{StaticResource TitleTextBlockStyle}"/>
<InfoBar IsOpen="True" Severity="Informational" IsClosable="False">
<InfoBar.Message>
<TextBlock Text="1. 登录网页控制台 →「设置 → Agent 设备」生成配对码;2. 在下方输入配对码完成绑定。"/>
</InfoBar.Message>
</InfoBar>
<TextBox x:Name="CodeBox" Header="配对码" PlaceholderText="6 位配对码(5 分钟内有效)"/>
<TextBox x:Name="NameBox" Header="设备名(选填)" PlaceholderText="例如:办公室电脑-01"/>
<Button x:Name="PairButton" Content="配对" Style="{StaticResource AccentButtonStyle}" Click="Pair_Click" HorizontalAlignment="Left"/>
<TextBlock x:Name="PairResult" Text="" TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</Page>
+37
View File
@@ -0,0 +1,37 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using EveryPublish.Client.Services;
namespace EveryPublish.Client.Pages;
public sealed partial class PairPage : Page
{
private ClientContext _ctx = null!;
public PairPage()
{
InitializeComponent();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
_ctx = (ClientContext)e.Parameter;
_ctx.Core.ReloadLocalJson();
}
private async void Pair_Click(object sender, RoutedEventArgs e)
{
var code = CodeBox.Text.Trim();
var name = NameBox.Text.Trim();
if (string.IsNullOrEmpty(code))
{
PairResult.Text = "请输入配对码";
return;
}
PairButton.IsEnabled = false;
PairResult.Text = "配对中...";
var (ok, message) = await _ctx.Core.PairAsync(code, name);
PairButton.IsEnabled = true;
PairResult.Text = ok ? "配对成功,正在连接服务器(可到「状态」页查看)" : message;
}
}
+20
View File
@@ -0,0 +1,20 @@
<Page
x:Class="EveryPublish.Client.Pages.SettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel Margin="32" Spacing="16" MaxWidth="520">
<TextBlock Text="设置" Style="{StaticResource TitleTextBlockStyle}"/>
<TextBox x:Name="ServerBox" Header="服务器地址" PlaceholderText="http://127.0.0.1:8090"/>
<ToggleSwitch x:Name="AutoStartToggle" Header="开机自启" OnContent="开" OffContent="关"/>
<ToggleSwitch x:Name="FakeAllToggle" Header="模拟执行(无真实账号的安装联调)" OnContent="开" OffContent="关"/>
<TextBlock><Run Text="数据目录:" FontWeight="SemiBold"/><Run x:Name="DataDirText" Text="..."/></TextBlock>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="保存并重启核心" Style="{StaticResource AccentButtonStyle}" Click="Save_Click"/>
<Button Content="打开数据目录" Click="OpenDir_Click"/>
<Button Content="退出客户端" Click="Exit_Click"/>
</StackPanel>
<TextBlock x:Name="ResultText" Text="" TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</Page>
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.IO;
using System.Text.Json;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.System;
using EveryPublish.Client.Services;
namespace EveryPublish.Client.Pages;
public sealed partial class SettingsPage : Page
{
private ClientContext _ctx = null!;
public SettingsPage()
{
InitializeComponent();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
_ctx = (ClientContext)e.Parameter;
DataDirText.Text = _ctx.DataDir;
ServerBox.Text = ReadServer();
AutoStartToggle.IsOn = AutoStart.IsEnabled();
FakeAllToggle.IsOn = ReadBool("fakeAll");
}
private bool ReadBool(string key)
{
try
{
var path = Path.Combine(_ctx.DataDir, "config.json");
if (!File.Exists(path)) return false;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.TryGetProperty(key, out var v)) return v.GetBoolean();
}
catch { /* 忽略 */ }
return false;
}
private string ReadServer()
{
try
{
var path = Path.Combine(_ctx.DataDir, "config.json");
if (!File.Exists(path)) return "http://127.0.0.1:8090";
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (doc.RootElement.TryGetProperty("server", out var s)) return s.GetString() ?? "";
}
catch { /* 忽略 */ }
return "http://127.0.0.1:8090";
}
private void WriteServer(string server)
{
var path = Path.Combine(_ctx.DataDir, "config.json");
var doc = File.Exists(path)
? JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(path))
: JsonDocument.Parse("{}").RootElement;
using var json = JsonDocument.Parse(doc.GetRawText());
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json.RootElement.GetRawText()) ?? new();
dict["server"] = JsonSerializer.SerializeToElement(server);
File.WriteAllText(path, JsonSerializer.Serialize(dict, new JsonSerializerOptions { WriteIndented = true }));
}
private void Save_Click(object sender, RoutedEventArgs e)
{
var server = ServerBox.Text.Trim();
if (string.IsNullOrEmpty(server))
{
ResultText.Text = "服务器地址不能为空";
return;
}
WriteServer(server);
WriteBool("fakeAll", FakeAllToggle.IsOn);
AutoStart.SetEnabled(AutoStartToggle.IsOn);
_ctx.Watchdog.Restart();
ResultText.Text = "已保存,核心进程已重启";
}
private void WriteBool(string key, bool value)
{
var path = Path.Combine(_ctx.DataDir, "config.json");
var dict = File.Exists(path)
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(File.ReadAllText(path)) ?? new()
: new();
dict[key] = JsonSerializer.SerializeToElement(value);
File.WriteAllText(path, JsonSerializer.Serialize(dict, new JsonSerializerOptions { WriteIndented = true }));
}
private async void OpenDir_Click(object sender, RoutedEventArgs e)
{
Directory.CreateDirectory(_ctx.DataDir);
await Launcher.LaunchFolderPathAsync(_ctx.DataDir);
}
private void Exit_Click(object sender, RoutedEventArgs e)
{
MainWindow.ExitAction?.Invoke();
}
}
+26
View File
@@ -0,0 +1,26 @@
<Page
x:Class="EveryPublish.Client.Pages.StatusPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer>
<StackPanel Margin="32" Spacing="16">
<TextBlock Text="运行状态" Style="{StaticResource TitleTextBlockStyle}"/>
<InfoBar IsOpen="True" Severity="Informational" IsClosable="False">
<InfoBar.Message>
<TextBlock Text="客户端后台常驻运行;关闭窗口将最小化到托盘,不会退出。"/>
</InfoBar.Message>
</InfoBar>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="连接状态:" VerticalAlignment="Center"/>
<Ellipse x:Name="OnlineDot" Width="10" Height="10" Fill="Gray" VerticalAlignment="Center"/>
<TextBlock x:Name="OnlineText" Text="..." VerticalAlignment="Center" FontWeight="SemiBold"/>
</StackPanel>
<TextBlock><Run Text="服务器:" FontWeight="SemiBold"/><Run x:Name="ServerText" Text="..."/></TextBlock>
<TextBlock><Run Text="设备:" FontWeight="SemiBold"/><Run x:Name="DeviceText" Text="..."/></TextBlock>
<TextBlock><Run Text="会话:" FontWeight="SemiBold"/><Run x:Name="SessionText" Text="..."/></TextBlock>
</StackPanel>
<Button x:Name="OpenWebButton" Content="打开网页控制台" Click="OpenWeb_Click" HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</Page>
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.System;
using EveryPublish.Client.Services;
namespace EveryPublish.Client.Pages;
public sealed partial class StatusPage : Page
{
private ClientContext _ctx = null!;
private DispatcherTimer? _timer;
public StatusPage()
{
InitializeComponent();
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
_timer.Tick += async (s, e) => await RefreshAsync();
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
_ctx = (ClientContext)e.Parameter;
_ctx.Core.ReloadLocalJson();
_timer?.Start();
_ = RefreshAsync();
}
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
_timer?.Stop();
}
private async Task RefreshAsync()
{
_ctx.Core.ReloadLocalJson();
var status = await _ctx.Core.GetStatusAsync();
if (status == null)
{
OnlineText.Text = "核心进程未就绪";
OnlineDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Gray);
return;
}
ServerText.Text = status.Server;
DeviceText.Text = status.DeviceId > 0 ? "#" + status.DeviceId + "(" + status.Name + ")" : "未配对";
SessionText.Text = status.Session;
if (status.Online)
{
OnlineText.Text = "在线";
OnlineDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.LimeGreen);
}
else
{
OnlineText.Text = status.DeviceId > 0 ? "离线(自动重连中)" : "未配对";
OnlineDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Orange);
}
}
private async void OpenWeb_Click(object sender, RoutedEventArgs e)
{
var status = await _ctx.Core.GetStatusAsync();
var url = status?.Server ?? "http://127.0.0.1:8090";
await Launcher.LaunchUriAsync(new Uri(url));
}
}
+30
View File
@@ -0,0 +1,30 @@
# EveryPublish Windows 客户端(WinUI 3 壳)
## 结构
- 本目录:WinUI 3(C#/.NET 8)外壳——托盘、配对引导、挑战窗口、状态页、设置,
spawn 并看门狗守护 `agent-core.exe`(../core,Go)。
- 壳与 core 的桥:`data/local.json`(core 启动写随机端口 + token),壳经 127.0.0.1 HTTP 控制。
## Windows 构建(需 Windows 10/11 + .NET 8 SDK)
1. 安装 .NET 8 SDK(https://dotnet.microsoft.com/download/dotnet/8.0)与 Go 1.23+。
2. 首次构建前还原依赖:`dotnet restore EveryPublish.Client.csproj`(需网络)。
3. 一键打包:`powershell -ExecutionPolicy Bypass -File Packaging\build-installer.ps1`。
产出 `../dist/EveryPublish-Client-0.2.0-win-x64.zip`。
4. 解压运行 `EveryPublish.Client.exe`(无需安装 .NET 运行时与 WindowsAppSDK,已自包含)。
## 手动验证清单(D9 验收)
1. 首次启动:窗口显示「未配对」;托盘图标出现。
2. 网页端生成配对码 → 客户端「配对」页输入 → 配对成功 → 状态页「在线」。
3. 网页端新建账号并绑定 → 客户端「挑战」页出现挑战 → 提交验证码 → 网页端账号变「已绑定」。
4. 网页端新建任务 → 审核通过 → 客户端状态页保持在线 → 网页端任务变「发布成功」。
5. 关闭窗口 → 最小化到托盘(进程仍运行);托盘「退出」才真正退出。
6. 设置页开启「开机自启」→ 注销/重启后客户端自动运行。
## 已知注意
- H.NotifyIcon.WinUI 锁定 2.0.131(NuGet 已验证存在):托盘左/右键打开主窗口(2.x 无 WPF 式 ContextMenu),退出入口在「设置 → 退出客户端」。
- 构建前可先跑 `Packaging\precheck.ps1` 自检 .NET 8 SDK 与 Go 工具链。
- 若出现 WinUI 编译错误,多为 XAML 控件 API 细节,按报错微调即可(工程结构已就位)。
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.Win32;
namespace EveryPublish.Client.Services;
/// <summary>开机自启(HKCU Run)</summary>
public static class AutoStart
{
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "EveryPublishClient";
public static bool IsEnabled()
{
using var key = Registry.CurrentUser.OpenSubKey(RunKey);
return key?.GetValue(ValueName) != null;
}
public static void SetEnabled(bool enabled)
{
using var key = Registry.CurrentUser.CreateSubKey(RunKey);
if (key == null) return;
if (enabled)
{
var exe = Environment.ProcessPath ?? "";
key.SetValue(ValueName, "\"" + exe + "\" --minimized");
}
else
{
key.DeleteValue(ValueName, false);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace EveryPublish.Client.Services;
/// <summary>页面共享上下文(导航参数)</summary>
public class ClientContext
{
public CoreClient Core { get; }
public string DataDir { get; }
public Watchdog Watchdog { get; }
public ClientContext(CoreClient core, string dataDir, Watchdog watchdog)
{
Core = core;
DataDir = dataDir;
Watchdog = watchdog;
}
}
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using EveryPublish.Client.Models;
namespace EveryPublish.Client.Services;
/// <summary>agent-core 本机控制客户端(读 local.json → 127.0.0.1 + token 鉴权)</summary>
public class CoreClient : IDisposable
{
private readonly HttpClient _http;
private readonly string _dataDir;
private string _base = "";
private string _token = "";
public CoreClient(string dataDir)
{
_dataDir = dataDir;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
ReloadLocalJson();
}
/// <summary>重新读取 data/local.json(端口/token,core 每次启动随机)</summary>
public void ReloadLocalJson()
{
try
{
var path = Path.Combine(_dataDir, "local.json");
if (!File.Exists(path)) return;
var info = JsonSerializer.Deserialize<LocalInfo>(File.ReadAllText(path));
if (info == null || info.Port <= 0 || string.IsNullOrEmpty(info.Token)) return;
_base = "http://127.0.0.1:" + info.Port;
_token = info.Token;
}
catch
{
// core 尚未写入 local.json,稍后重试
}
}
private HttpRequestMessage NewRequest(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, _base + path);
req.Headers.Add("X-Agent-Token", _token);
return req;
}
public async Task<CoreStatus?> GetStatusAsync()
{
try
{
using var resp = await _http.SendAsync(NewRequest(HttpMethod.Get, "/status"));
if (!resp.IsSuccessStatusCode) return null;
return await resp.Content.ReadFromJsonAsync<CoreStatus>();
}
catch
{
return null;
}
}
public async Task<List<ChallengeItem>> GetChallengesAsync()
{
try
{
using var resp = await _http.SendAsync(NewRequest(HttpMethod.Get, "/challenges"));
if (!resp.IsSuccessStatusCode) return new List<ChallengeItem>();
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
if (doc.TryGetProperty("list", out var list))
{
return list.Deserialize<List<ChallengeItem>>() ?? new List<ChallengeItem>();
}
return new List<ChallengeItem>();
}
catch
{
return new List<ChallengeItem>();
}
}
public Task<(bool Ok, string Message)> PairAsync(string code, string name)
{
return PostAsync("/pair", new { code, name });
}
public Task<(bool Ok, string Message)> SolveAsync(string challengeId, string value)
{
return PostAsync("/challenges/" + challengeId, new { value });
}
private async Task<(bool Ok, string Message)> PostAsync(string path, object body)
{
try
{
using var req = NewRequest(HttpMethod.Post, path);
req.Content = JsonContent.Create(body);
using var resp = await _http.SendAsync(req);
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>();
var ok = doc.TryGetProperty("code", out var c) && c.GetInt32() == 0;
var msg = doc.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
return (ok, msg);
}
catch (Exception ex)
{
return (false, "无法连接客户端核心:" + ex.Message);
}
}
public void Dispose()
{
_http.Dispose();
}
}
+21
View File
@@ -0,0 +1,21 @@
using System;
using System.Windows.Input;
namespace EveryPublish.Client.Services;
/// <summary>极简 ICommand 实现(托盘命令用,免 CommunityToolkit 依赖)</summary>
public class SimpleCommand : ICommand
{
private readonly Action _action;
public SimpleCommand(Action action)
{
_action = action;
}
public event EventHandler? CanExecuteChanged { add { } remove { } }
public bool CanExecute(object? parameter) => true;
public void Execute(object? parameter) => _action();
}
+91
View File
@@ -0,0 +1,91 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
namespace EveryPublish.Client.Services;
/// <summary>看门狗:spawn agent-core 子进程,5s 心跳检查,失败自动拉起,3 次失败告警</summary>
public class Watchdog : IDisposable
{
private readonly CoreClient _core;
private readonly string _dataDir;
private readonly Action _onFatal;
private Process? _proc;
private Timer? _timer;
private int _failCount;
public Watchdog(CoreClient core, string dataDir, Action onFatal)
{
_core = core;
_dataDir = dataDir;
_onFatal = onFatal;
}
public void Start()
{
Spawn();
_timer = new Timer(_ => Check(), null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
public void Restart()
{
try { _proc?.Kill(); } catch { /* 忽略 */ }
Spawn();
}
private void Spawn()
{
var exe = Path.Combine(AppContext.BaseDirectory, "agent-core.exe");
if (!File.Exists(exe))
{
_onFatal();
return;
}
var psi = new ProcessStartInfo
{
FileName = exe,
Arguments = "-dir \"" + _dataDir + "\"",
WorkingDirectory = Path.GetDirectoryName(exe) ?? AppContext.BaseDirectory,
CreateNoWindow = true,
UseShellExecute = false,
};
_proc = Process.Start(psi);
}
private void Check()
{
_core.ReloadLocalJson();
var healthy = false;
if (_proc != null && !_proc.HasExited)
{
try
{
var status = _core.GetStatusAsync().GetAwaiter().GetResult();
healthy = status != null;
}
catch
{
healthy = false;
}
}
if (healthy)
{
_failCount = 0;
return;
}
_failCount++;
if (_failCount >= 3)
{
_onFatal();
return;
}
Spawn();
}
public void Dispose()
{
_timer?.Dispose();
try { _proc?.Kill(); } catch { /* 忽略 */ }
}
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="EveryPublish.Client.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>