/* * MyCSharpProject - Shanxue-Education-Points-Coin-Converter * Copyright (C) 2026 ZerkyLiu * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ using System; using System.Drawing; using System.Globalization; using System.Net; using System.Net.Sockets; using System.IO; using System.Diagnostics; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows.Forms; namespace 善学教育积分卡汇率计算器 { public class MainForm : Form { private static readonly decimal[] RatePoints = { 500m, 1000m, 1500m, 2000m, 2500m, 3000m, 3500m, 4000m }; private static readonly decimal[] RateRmb = { 10m, 20m, 35m, 45m, 60m, 75m, 85m, 100m }; private static readonly string[] Zodiac = { "鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊", "猴", "鸡", "狗", "猪" }; private static readonly string[] NtpServers = { "ntp.aliyun.com", "ntp.ntsc.ac.cn", "pool.ntp.org" }; private static readonly string RateTextHorizontal = BuildRateText(true); private static readonly string RateTextVertical = BuildRateText(false); private const string CurrentVersion = "1.2.3"; private const string ReleasePageUrl = "https://gittea.dev/ZerkyLiu/Shanxue-Education-Points-Coin-Converter/releases"; private const string DownloadUrlTemplate = "https://gittea.dev/ZerkyLiu/Shanxue-Education-Points-Coin-Converter/releases/download/{0}/善学教育积分卡汇率计算器.exe"; private readonly Label _currencyLabel; private readonly Label _timeLabel; private readonly Label _timeSourceLabel; private readonly CheckBox _topMostToggle; private readonly Button _aboutButton; private readonly Label _inputLabel; private readonly Label _rmbLabel; private readonly TextBox _pointsInput; private readonly Label _rmbOutput; private readonly Label _rateLabel; private readonly Timer _clockTimer; private readonly Timer _syncTimer; private readonly Timer _fallbackTopMostTimer; private DateTime _currentTime; private string _timeSource; private IntPtr _uiAccessDll; private string? _uiAccessDllPath; private IsUIAccessDelegate? _isUiAccess; private bool _uiAccessEnabled; private bool _usingFallbackTopMost; private bool _usingUiAccessTopMost; private bool _suppressPointsTextChange; private readonly bool _forceFallbackTopMost; private Form? _aboutDialog; private Form? _floatingPrompt; private const string UiAccessRelaunchArg = "--uiaccess-relaunch"; private const string UiAccessResourceSuffix = ".dll.uiaccess.dll"; private const string IconResourceSuffix = ".ico.logo_256x256.ico"; private float _rmbOutputBaseFontSize; public MainForm() { Text = "善学教育积分卡汇率计算器"; StartPosition = FormStartPosition.CenterScreen; ClientSize = new Size(640, 730); MinimumSize = SizeFromClientSize(ClientSize); Font = new Font("Microsoft YaHei UI", 10F); BackColor = Color.WhiteSmoke; AutoScaleMode = AutoScaleMode.Dpi; DoubleBuffered = true; SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 4, Padding = new Padding(28), }; layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); _currencyLabel = new Label { AutoSize = true, Font = new Font("Microsoft YaHei UI", 18F, FontStyle.Bold), ForeColor = Color.FromArgb(38, 50, 56), Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft }; layout.Controls.Add(_currencyLabel, 0, 0); _timeLabel = new Label { AutoSize = true, ForeColor = Color.FromArgb(55, 71, 79), Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft }; _topMostToggle = new CheckBox { Text = "超级置顶", AutoSize = true, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleRight }; _topMostToggle.CheckedChanged += (_, __) => ApplyTopMost(_topMostToggle.Checked); _aboutButton = new Button { Text = "关于", AutoSize = true, Dock = DockStyle.Fill }; _aboutButton.Click += (_, __) => ShowAboutDialog(); _timeSourceLabel = new Label { AutoSize = true, ForeColor = Color.FromArgb(96, 125, 139), Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft }; var rightPanel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2, AutoSize = true }; rightPanel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); rightPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); rightPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); rightPanel.Controls.Add(_aboutButton, 0, 0); rightPanel.Controls.Add(_topMostToggle, 0, 1); var timeRow = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, AutoSize = true }; timeRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); timeRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); timeRow.RowStyles.Add(new RowStyle(SizeType.AutoSize)); timeRow.Controls.Add(_timeLabel, 0, 0); timeRow.Controls.Add(rightPanel, 1, 0); var timePanel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2, AutoSize = true }; timePanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); timePanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); timePanel.RowStyles.Add(new RowStyle(SizeType.AutoSize)); timePanel.Controls.Add(timeRow, 0, 0); timePanel.Controls.Add(_timeSourceLabel, 0, 1); layout.Controls.Add(timePanel, 0, 1); _inputLabel = new Label { Text = "输入积分币数量", AutoSize = true, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft }; _pointsInput = new TextBox { Dock = DockStyle.Fill, TextAlign = HorizontalAlignment.Left, ImeMode = ImeMode.Disable }; _pointsInput.TextChanged += PointsInputOnTextChanged; _pointsInput.KeyPress += PointsInputOnKeyPress; _rmbLabel = new Label { Text = "换算人民币(元)", AutoSize = true, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft }; _rmbOutput = new Label { Text = "0.00", AutoSize = true, Font = new Font("Microsoft YaHei UI", 22F, FontStyle.Bold), ForeColor = Color.FromArgb(0, 150, 136), Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter }; var convLayout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 2, AutoSize = false }; convLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40F)); convLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 60F)); convLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 50F)); convLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 50F)); convLayout.Controls.Add(_inputLabel, 0, 0); convLayout.Controls.Add(_pointsInput, 1, 0); convLayout.Controls.Add(_rmbLabel, 0, 1); convLayout.Controls.Add(_rmbOutput, 1, 1); layout.Controls.Add(convLayout, 0, 2); _rateLabel = new Label { Text = GetRateText(true), AutoSize = true, ForeColor = Color.FromArgb(96, 125, 139), Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft }; layout.Controls.Add(_rateLabel, 0, 3); Controls.Add(layout); _timeSource = "本地"; _currentTime = DateTime.Now; _clockTimer = new Timer { Interval = 1000 }; _clockTimer.Tick += ClockTimerOnTick; _syncTimer = new Timer { Interval = 10 * 60 * 1000 }; _syncTimer.Tick += async (_, __) => await SyncNetworkTimeAsync(); _fallbackTopMostTimer = new Timer { Interval = 1 }; _fallbackTopMostTimer.Tick += (_, __) => ApplyFallbackTopMostTick(); _forceFallbackTopMost = IsWin7OrLower(); ClientSizeChanged += (_, __) => { UpdateRateLayout(); UpdateResponsiveFonts(); UpdateConversion(); }; Shown += (_, __) => _ = InitializeAsync(); } private Task InitializeAsync() { TryInitializeIcon(); TryInitializeUiAccess(); if (TryRelaunchWithUiAccess()) { return Task.CompletedTask; } UpdateTimeLabels(); UpdateRateLayout(); UpdateResponsiveFonts(); UpdateConversion(); StartInputFocus(); _ = SyncNetworkTimeAsync(); _clockTimer.Start(); _syncTimer.Start(); return Task.CompletedTask; } private void UpdateRateLayout() { var horizontal = ClientSize.Width >= 720; _rateLabel.MaximumSize = new Size(Math.Max(0, ClientSize.Width - 120), 0); _rateLabel.Text = horizontal ? RateTextHorizontal : RateTextVertical; } private void UpdateResponsiveFonts() { var w = ClientSize.Width; var h = ClientSize.Height; var resultSize = (float)Math.Max(24, Math.Min(w * 0.06, h * 0.08)); var inputSize = (float)Math.Max(12, Math.Min(w * 0.03, h * 0.04)); _rmbOutputBaseFontSize = resultSize; _rmbOutput.Font = new Font("Microsoft YaHei UI", resultSize, FontStyle.Bold); _pointsInput.Font = new Font("Microsoft YaHei UI", inputSize, FontStyle.Regular); } private static string GetRateText(bool horizontal) { return horizontal ? RateTextHorizontal : RateTextVertical; } private static string BuildRateText(bool horizontal) { var header = "汇率:"; if (horizontal) { var text = header; for (var i = 0; i < RatePoints.Length; i++) { text += $"{RatePoints[i]}币={RateRmb[i]}"; if (i < RatePoints.Length - 1) { text += ","; } } return text; } var newline = Environment.NewLine; var multi = header + newline; for (var i = 0; i < RatePoints.Length; i++) { multi += $"{RatePoints[i]}币={RateRmb[i]}"; if (i < RatePoints.Length - 1) { multi += newline; } } return multi; } private void UpdateTimeLabels() { var timeText = _currentTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); _timeLabel.Text = $"当前时间:{timeText}"; _timeSourceLabel.Text = $"时间来源:{_timeSource}"; _currencyLabel.Text = $"当前积分币:{GetZodiacCurrency(_currentTime)}"; } private void ClockTimerOnTick(object? sender, EventArgs e) { _currentTime = _currentTime.AddSeconds(1); UpdateTimeLabels(); } private async Task SyncNetworkTimeAsync() { var networkTime = await Task.Run(TryGetNetworkTime); if (networkTime.HasValue) { _currentTime = networkTime.Value.ToLocalTime(); _timeSource = "NTP时间服务器"; } else { _currentTime = DateTime.Now; _timeSource = "本地"; } UpdateTimeLabels(); } private static DateTime? TryGetNetworkTime() { foreach (var server in NtpServers) { try { var ntpData = new byte[48]; ntpData[0] = 0x1B; var addresses = Dns.GetHostEntry(server).AddressList; foreach (var address in addresses) { if (address.AddressFamily != AddressFamily.InterNetwork) { continue; } var ipEndPoint = new IPEndPoint(address, 123); using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)) { socket.ReceiveTimeout = 1500; socket.SendTimeout = 1500; socket.Connect(ipEndPoint); socket.Send(ntpData); socket.Receive(ntpData); } const byte serverReplyTime = 40; var intPart = BitConverter.ToUInt32(ntpData, serverReplyTime); var fractPart = BitConverter.ToUInt32(ntpData, serverReplyTime + 4); intPart = SwapEndianness(intPart); fractPart = SwapEndianness(fractPart); var milliseconds = (intPart * 1000L) + ((fractPart * 1000L) / 0x100000000L); var networkDateTime = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(milliseconds); return networkDateTime; } } catch { continue; } } return null; } private static uint SwapEndianness(uint x) { return (x & 0x000000FFU) << 24 | (x & 0x0000FF00U) << 8 | (x & 0x00FF0000U) >> 8 | (x & 0xFF000000U) >> 24; } private static string GetZodiacCurrency(DateTime time) { var index = Mod(time.Year - 2020, 12); return $"{Zodiac[index]}币"; } private static int Mod(int x, int m) { var r = x % m; return r < 0 ? r + m : r; } private void StartInputFocus() { if (!IsHandleCreated) { return; } BeginInvoke(new Action(() => { ActiveControl = _pointsInput; _pointsInput.Focus(); _pointsInput.SelectionStart = _pointsInput.TextLength; })); } private static bool IsWin7OrLower() { var version = Environment.OSVersion.Version; return version.Major < 6 || (version.Major == 6 && version.Minor <= 1); } private void PointsInputOnTextChanged(object? sender, EventArgs e) { if (_suppressPointsTextChange) { return; } var rawText = _pointsInput.Text; var filteredText = new string(rawText.Where(char.IsDigit).ToArray()); if (!string.Equals(rawText, filteredText, StringComparison.Ordinal)) { var selectionStart = _pointsInput.SelectionStart; var removedCount = rawText.Length - filteredText.Length; _suppressPointsTextChange = true; _pointsInput.Text = filteredText; _pointsInput.SelectionStart = Math.Max(0, Math.Min(filteredText.Length, selectionStart - removedCount)); _suppressPointsTextChange = false; } UpdateConversion(); } private void PointsInputOnKeyPress(object? sender, KeyPressEventArgs e) { if (char.IsControl(e.KeyChar) || char.IsDigit(e.KeyChar)) { return; } e.Handled = true; } private void UpdateConversion() { var rawText = _pointsInput.Text.Trim(); if (string.IsNullOrEmpty(rawText)) { SetRmbOutputText("0.00", GetRmbOutputBaseSize()); return; } if (!decimal.TryParse(rawText, out var points)) { SetRmbOutputText("TL", GetRmbOutputBaseSize()); return; } if (points <= 0) { SetRmbOutputText("0.00", GetRmbOutputBaseSize()); return; } try { var rmb = ConvertPointsToRmb(points); if (rmb < 0.01m) { SetRmbOutputText("0.00", GetRmbOutputBaseSize()); return; } UpdateRmbDisplay(rmb); } catch { SetRmbOutputText("TL", GetRmbOutputBaseSize()); } } private static decimal ConvertPointsToRmb(decimal points) { var pointSteps = new[] { 0m, RatePoints[0], RatePoints[1], RatePoints[2], RatePoints[3], RatePoints[4], RatePoints[5], RatePoints[6], RatePoints[7] }; var rmbSteps = new[] { 0m, RateRmb[0], RateRmb[1], RateRmb[2], RateRmb[3], RateRmb[4], RateRmb[5], RateRmb[6], RateRmb[7] }; if (points <= pointSteps[0]) { return 0m; } if (points >= pointSteps[pointSteps.Length - 1]) { return Interpolate(points, pointSteps[pointSteps.Length - 2], pointSteps[pointSteps.Length - 1], rmbSteps[rmbSteps.Length - 2], rmbSteps[rmbSteps.Length - 1]); } for (var i = 1; i < pointSteps.Length; i++) { if (points <= pointSteps[i]) { return Interpolate(points, pointSteps[i - 1], pointSteps[i], rmbSteps[i - 1], rmbSteps[i]); } } return 0m; } private static decimal Interpolate(decimal x, decimal x1, decimal x2, decimal y1, decimal y2) { if (x2 == x1) { return y1; } var ratio = (x - x1) / (x2 - x1); return y1 + (y2 - y1) * ratio; } private void UpdateRmbDisplay(decimal rmb) { var baseSize = GetRmbOutputBaseSize(); var minSize = _rmbLabel.Font.Size; var normalText = rmb.ToString("F2", CultureInfo.InvariantCulture); var normalResult = FitTextToRmbOutput(normalText, baseSize, minSize); if (normalResult.Fits) { SetRmbOutputText(normalResult.Text, normalResult.FontSize); return; } var sciText = rmb.ToString("0.###E+0", CultureInfo.InvariantCulture); var sciResult = FitTextToRmbOutput(sciText, baseSize, minSize); if (sciResult.Fits) { SetRmbOutputText(sciResult.Text, sciResult.FontSize); return; } var ellipsisText = "…请放大窗口"; var ellipsisResult = FitTextToRmbOutput(ellipsisText, baseSize, minSize); SetRmbOutputText(ellipsisResult.Text, ellipsisResult.FontSize); } private float GetRmbOutputBaseSize() { var baseSize = _rmbOutputBaseFontSize > 0 ? _rmbOutputBaseFontSize : _rmbOutput.Font.Size; return Math.Max(baseSize, _rmbLabel.Font.Size); } private (string Text, float FontSize, bool Fits) FitTextToRmbOutput(string text, float baseSize, float minSize) { var targetSize = Math.Max(baseSize, minSize); if (TryFitText(text, targetSize)) { return (text, targetSize, true); } for (var size = targetSize; size >= minSize; size -= 0.5f) { if (TryFitText(text, size)) { return (text, size, true); } } return (text, minSize, false); } private bool TryFitText(string text, float fontSize) { var availableWidth = _rmbOutput.ClientSize.Width; var availableHeight = _rmbOutput.ClientSize.Height; if (availableWidth <= 0 || availableHeight <= 0) { return true; } using (var font = new Font(_rmbOutput.Font.FontFamily, fontSize, _rmbOutput.Font.Style)) { var size = TextRenderer.MeasureText(text, font, new Size(int.MaxValue, int.MaxValue), TextFormatFlags.NoPadding); return size.Width <= availableWidth && size.Height <= availableHeight; } } private void SetRmbOutputText(string text, float fontSize) { if (Math.Abs(_rmbOutput.Font.Size - fontSize) > 0.1f) { _rmbOutput.Font = new Font(_rmbOutput.Font.FontFamily, fontSize, _rmbOutput.Font.Style); } _rmbOutput.Text = text; } protected override void OnFormClosed(FormClosedEventArgs e) { if (_fallbackTopMostTimer != null) { _fallbackTopMostTimer.Stop(); } if (_uiAccessDll != IntPtr.Zero) { FreeLibrary(_uiAccessDll); _uiAccessDll = IntPtr.Zero; } base.OnFormClosed(e); } private void TryInitializeUiAccess() { if (_uiAccessDll != IntPtr.Zero) { return; } var dllPath = EnsureUiAccessExtracted(); if (dllPath == null) { return; } _uiAccessDll = LoadLibraryW(dllPath); if (_uiAccessDll == IntPtr.Zero) { return; } var proc = GetProcAddress(_uiAccessDll, "IsUIAccess"); if (proc == IntPtr.Zero) { FreeLibrary(_uiAccessDll); _uiAccessDll = IntPtr.Zero; return; } _isUiAccess = Marshal.GetDelegateForFunctionPointer(proc); _uiAccessEnabled = _isUiAccess(); } private void TryInitializeIcon() { try { using (var stream = GetEmbeddedResourceStream(IconResourceSuffix)) { if (stream == null) { return; } using (var icon = new Icon(stream)) { Icon = (Icon)icon.Clone(); } } } catch { } } private void ShowAboutDialog() { if (_aboutDialog != null && !_aboutDialog.IsDisposed) { _aboutDialog.Show(); _aboutDialog.BringToFront(); return; } var dialog = new Form { Text = "关于", StartPosition = FormStartPosition.Manual, FormBorderStyle = FormBorderStyle.FixedDialog, MaximizeBox = false, MinimizeBox = false, ShowInTaskbar = false, ClientSize = new Size(520, 420) }; _aboutDialog = dialog; dialog.FormClosed += (_, __) => _aboutDialog = null; var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2 }; layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var textBox = new TextBox { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, Dock = DockStyle.Fill, Font = new Font("Microsoft YaHei UI", 10F), BackColor = SystemColors.Window, BorderStyle = BorderStyle.None }; var buildTime = File.Exists(Application.ExecutablePath) ? File.GetLastWriteTime(Application.ExecutablePath).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) : "未知"; textBox.Text = string.Join(Environment.NewLine, new[] { "善学教育积分卡汇率计算器", $"版本号: {CurrentVersion}", "作者: ZerkyLiu", $"构建时间:{buildTime}", "运行环境:.NET 4.7", $"系统信息:{Environment.OSVersion}", $"UIAccess状态: {(_uiAccessEnabled ? "已启用" : "未启用")}", "", "功能亮点:", "• 动态自适应结果展示", "• 网络时间同步", "• 高精度阶梯换算", "• UIAccess 置顶支持", "• 联网自动更新", "", "未来计划:", "• 修复已知问题" }); var updateButton = new Button { Text = "检查更新", AutoSize = true, Anchor = AnchorStyles.Right }; updateButton.Click += async (_, __) => await HandleUpdateCheckAsync(updateButton); var buttonPanel = new Panel { Dock = DockStyle.Fill, Height = updateButton.Height + 12, Padding = new Padding(0, 8, 0, 0) }; updateButton.Location = new Point(buttonPanel.Width - updateButton.Width, 0); updateButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; buttonPanel.Controls.Add(updateButton); buttonPanel.Resize += (_, __) => { updateButton.Location = new Point(buttonPanel.Width - updateButton.Width, 0); }; layout.Controls.Add(textBox, 0, 0); layout.Controls.Add(buttonPanel, 0, 1); dialog.Controls.Add(layout); CenterFormOnScreen(dialog, this); dialog.Show(); dialog.Activate(); } private async Task HandleUpdateCheckAsync(Button button) { if (!button.Enabled) { return; } var owner = button.FindForm() ?? this; var originalText = button.Text; button.Enabled = false; button.Text = "检查更新中…"; try { var releaseInfo = await GetLatestReleaseInfoAsync(); if (releaseInfo == null || string.IsNullOrEmpty(releaseInfo.Tag)) { await ShowNonModalMessageAsync(owner, "无法获取最新版本信息,请稍后重试。", "更新检查", false); return; } var latestVersion = NormalizeVersion(releaseInfo.Tag); var currentVersion = NormalizeVersion(CurrentVersion); if (CompareVersions(currentVersion, latestVersion) >= 0) { await ShowNonModalMessageAsync(owner, "已经是最新版本。", "更新检查", false); return; } var consent = await ShowNonModalConfirmAsync(owner, $"检测到新版本 {releaseInfo.Tag},是否下载?", "发现新版本"); if (consent != DialogResult.Yes) { return; } var downloadUrl = releaseInfo.DownloadUrl ?? string.Empty; if (string.IsNullOrEmpty(downloadUrl)) { downloadUrl = string.Format(CultureInfo.InvariantCulture, DownloadUrlTemplate, Uri.EscapeDataString(releaseInfo.Tag)); } var targetPath = GetDownloadTargetPath(releaseInfo.Tag); button.Text = "正在下载中…"; var progressPercent = 0; PaintEventHandler painter = (_, e) => { var rect = button.ClientRectangle; var pad = 4; var barH = 4; var x = rect.X + pad; var y = rect.Bottom - barH - pad; var w = Math.Max(0, rect.Width - pad * 2); using (var back = new SolidBrush(Color.FromArgb(220, 220, 220))) { e.Graphics.FillRectangle(back, x, y, w, barH); } var fillW = (int)Math.Round(w * progressPercent / 100.0); using (var fill = new SolidBrush(Color.FromArgb(76, 175, 80))) { e.Graphics.FillRectangle(fill, x, y, fillW, barH); } }; button.Paint += painter; try { await DownloadFileAsync(downloadUrl, targetPath, new Progress(p => { progressPercent = Math.Max(0, Math.Min(100, p)); button.Invalidate(); })); } finally { button.Paint -= painter; } await ShowNonModalMessageAsync(owner, $"已下载到:{targetPath}", "下载完成", false); Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(targetPath) ?? GetDownloadFolder(), UseShellExecute = true }); } catch (Exception ex) { await ShowNonModalMessageAsync(owner, $"更新失败:{ex.Message}", "更新失败", false); } finally { button.Text = originalText; button.Enabled = true; } } private static void CenterFormOnScreen(Form dialog, Form reference) { var screen = Screen.FromControl(reference); var area = screen.WorkingArea; var x = area.X + (area.Width - dialog.Width) / 2; var y = area.Y + (area.Height - dialog.Height) / 2; dialog.Location = new Point(Math.Max(area.X, x), Math.Max(area.Y, y)); } private async Task ShowNonModalMessageAsync(Form owner, string text, string title, bool yesNo) { var tcs = new TaskCompletionSource(); var prompt = CreateFloatingPrompt(owner, title, text, yesNo, out var yesButton, out var noButton); _floatingPrompt = prompt; prompt.FormClosed += (_, __) => { _floatingPrompt = null; tcs.TrySetResult(true); }; prompt.Show(); CenterChildOnOwner(prompt, owner); yesButton.Click += (_, __) => prompt.Close(); if (yesNo) { noButton.Click += (_, __) => prompt.Close(); } await tcs.Task.ConfigureAwait(true); } private async Task ShowNonModalConfirmAsync(Form owner, string text, string title) { var tcs = new TaskCompletionSource(); var prompt = CreateFloatingPrompt(owner, title, text, true, out var yesButton, out var noButton); _floatingPrompt = prompt; prompt.FormClosed += (_, __) => { _floatingPrompt = null; if (!tcs.Task.IsCompleted) { tcs.TrySetResult(DialogResult.No); } }; prompt.Show(); CenterChildOnOwner(prompt, owner); yesButton.Click += (_, __) => { tcs.TrySetResult(DialogResult.Yes); prompt.Close(); }; noButton.Click += (_, __) => { tcs.TrySetResult(DialogResult.No); prompt.Close(); }; return await tcs.Task.ConfigureAwait(true); } private static Form CreateFloatingPrompt(Form owner, string title, string text, bool yesNo, out Button yesButton, out Button noButton) { var f = new Form { Text = title, FormBorderStyle = FormBorderStyle.FixedDialog, StartPosition = FormStartPosition.Manual, MaximizeBox = false, MinimizeBox = false, ShowInTaskbar = false, ClientSize = new Size(380, 160) }; var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2 }; layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); var label = new Label { Text = text, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft, AutoEllipsis = true }; var panel = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill, Padding = new Padding(0, 8, 0, 0), AutoSize = true }; yesButton = new Button { Text = yesNo ? "是" : "确定", AutoSize = true }; noButton = new Button { Text = "否", AutoSize = true }; panel.Controls.Add(yesButton); if (yesNo) { panel.Controls.Add(noButton); } layout.Controls.Add(label, 0, 0); layout.Controls.Add(panel, 0, 1); f.Controls.Add(layout); return f; } private static void CenterChildOnOwner(Form child, Form owner) { var rect = owner.Bounds; var x = rect.X + (rect.Width - child.Width) / 2; var y = rect.Y + (rect.Height - child.Height) / 2; child.Location = new Point(Math.Max(0, x), Math.Max(0, y)); } private static string GetDownloadFolder() { var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var path = string.IsNullOrWhiteSpace(userProfile) ? string.Empty : Path.Combine(userProfile, "Downloads"); if (string.IsNullOrWhiteSpace(path)) { path = Path.GetTempPath(); } Directory.CreateDirectory(path); return path; } private static string GetDownloadTargetPath(string versionTag) { var folder = GetDownloadFolder(); return Path.Combine(folder, $"善学教育积分卡汇率计算器_{versionTag}.exe"); } private sealed class ReleaseInfo { public ReleaseInfo(string tag, string? downloadUrl) { Tag = tag; DownloadUrl = downloadUrl; } public string Tag { get; } public string? DownloadUrl { get; } } private async Task GetLatestReleaseInfoAsync() { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; var result = await TryGetLatestReleaseInfoAsync(false); if (result != null) { return result; } return await TryGetLatestReleaseInfoAsync(true); } private async Task TryGetLatestReleaseInfoAsync(bool useProxy) { try { using (var client = CreateWebClient(useProxy)) { var html = await client.DownloadStringTaskAsync(ReleasePageUrl); var tag = ParseLatestReleaseTag(html) ?? string.Empty; if (tag.Length == 0) { return null; } var tagValue = tag; var downloadUrl = ParseLatestDownloadUrl(html); if (string.IsNullOrEmpty(downloadUrl)) { var tagUrl = BuildReleaseTagUrl(tagValue); var tagHtml = await client.DownloadStringTaskAsync(tagUrl); downloadUrl = ParseLatestDownloadUrl(tagHtml); } return new ReleaseInfo(tagValue, downloadUrl); } } catch { return null; } } private static string? ParseLatestReleaseTag(string html) { if (string.IsNullOrEmpty(html)) { return null; } var match = Regex.Match(html, "/releases/tag/([^\"/<>]+)"); if (!match.Success) { return null; } return Uri.UnescapeDataString(match.Groups[1].Value); } private static string? ParseLatestDownloadUrl(string html) { if (string.IsNullOrEmpty(html)) { return null; } var hrefMatches = Regex.Matches(html, "href=\"([^\"]+/releases/download/[^\"]+)\"", RegexOptions.IgnoreCase); foreach (Match match in hrefMatches) { var link = WebUtility.HtmlDecode(match.Groups[1].Value); if (link.IndexOf(".exe", StringComparison.OrdinalIgnoreCase) < 0) { continue; } return NormalizeDownloadUrl(link); } var rawMatch = Regex.Match(html, "/releases/download/[^\"'<>\\s]+\\.exe", RegexOptions.IgnoreCase); if (rawMatch.Success) { return NormalizeDownloadUrl(rawMatch.Value); } return null; } private static string BuildReleaseTagUrl(string tag) { var escaped = Uri.EscapeDataString(tag); return $"{ReleasePageUrl}/tag/{escaped}"; } private static string NormalizeDownloadUrl(string link) { var cleaned = Uri.UnescapeDataString(link); if (cleaned.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { return cleaned; } var baseUri = new Uri(ReleasePageUrl); var resolved = new Uri(baseUri, cleaned); return resolved.ToString(); } private static string NormalizeVersion(string value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } return value.Trim().TrimStart('v', 'V'); } private static int CompareVersions(string left, string right) { if (Version.TryParse(left, out var leftVersion) && Version.TryParse(right, out var rightVersion)) { return leftVersion.CompareTo(rightVersion); } return string.CompareOrdinal(left, right); } private static async Task DownloadFileAsync(string url, string targetPath, IProgress? progress = null) { using (var client = CreateWebClient(true)) { var tcs = new TaskCompletionSource(); Exception? error = null; client.DownloadProgressChanged += (_, e) => { try { progress?.Report(e.ProgressPercentage); } catch { } }; client.DownloadFileCompleted += (_, e) => { error = e.Error; tcs.TrySetResult(true); }; client.DownloadFileAsync(new Uri(url), targetPath); await tcs.Task.ConfigureAwait(true); if (error != null) { throw error; } } } private static WebClient CreateWebClient(bool useProxy) { var client = new WebClient(); if (useProxy) { var proxy = WebRequest.DefaultWebProxy; if (proxy != null) { proxy.Credentials = CredentialCache.DefaultCredentials; client.Proxy = proxy; } } client.Headers[HttpRequestHeader.UserAgent] = "Shanxue-Updater"; return client; } private bool TryRelaunchWithUiAccess() { if (_uiAccessEnabled) { return false; } var args = Environment.GetCommandLineArgs(); foreach (var arg in args) { if (string.Equals(arg, UiAccessRelaunchArg, StringComparison.OrdinalIgnoreCase)) { return false; } } var dllPath = EnsureUiAccessExtracted(); if (string.IsNullOrEmpty(dllPath)) { return false; } var exePath = Application.ExecutablePath; var forwardedArgs = BuildForwardedArgs(args); var startInfo = new ProcessStartInfo { FileName = "rundll32.exe", Arguments = $"\"{dllPath}\",run \"{exePath}\" {forwardedArgs} {UiAccessRelaunchArg}", UseShellExecute = false, CreateNoWindow = true }; try { Process.Start(startInfo); BeginInvoke(new Action(Close)); return true; } catch { return false; } } private string? EnsureUiAccessExtracted() { if (!string.IsNullOrEmpty(_uiAccessDllPath) && File.Exists(_uiAccessDllPath)) { return _uiAccessDllPath; } var tempDir = Path.Combine(Path.GetTempPath(), "善学教育积分卡汇率计算器"); var dllPath = Path.Combine(tempDir, $"uiaccess_{Process.GetCurrentProcess().Id}.dll"); try { Directory.CreateDirectory(tempDir); if (File.Exists(dllPath)) { _uiAccessDllPath = dllPath; return dllPath; } using (var stream = GetEmbeddedResourceStream(UiAccessResourceSuffix)) { if (stream == null) { return null; } using (var file = new FileStream(dllPath, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite)) { stream.CopyTo(file); } } _uiAccessDllPath = dllPath; return dllPath; } catch { if (File.Exists(dllPath)) { _uiAccessDllPath = dllPath; return dllPath; } return null; } } private static Stream? GetEmbeddedResourceStream(string suffix) { var assembly = typeof(MainForm).Assembly; var resourceName = assembly.GetManifestResourceNames() .FirstOrDefault(name => name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); if (resourceName == null) { return null; } return assembly.GetManifestResourceStream(resourceName); } private static string BuildForwardedArgs(string[] args) { if (args.Length <= 1) { return string.Empty; } var builder = new System.Text.StringBuilder(); for (var i = 1; i < args.Length; i++) { if (string.Equals(args[i], UiAccessRelaunchArg, StringComparison.OrdinalIgnoreCase)) { continue; } if (builder.Length > 0) { builder.Append(' '); } builder.Append(QuoteArg(args[i])); } return builder.ToString(); } private static string QuoteArg(string value) { if (string.IsNullOrEmpty(value)) { return "\"\""; } if (value.IndexOfAny(new[] { ' ', '\t', '"', '\\' }) == -1) { return value; } var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); return $"\"{escaped}\""; } private void ApplyTopMost(bool enabled) { TopMost = enabled; if (!enabled) { SetFallbackTopMost(false); SetUiAccessTopMost(false); if (_uiAccessEnabled && IsHandleCreated) { SetWindowPos(Handle, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); } return; } if (_forceFallbackTopMost) { SetFallbackTopMost(true); ApplyFallbackTopMostTick(); return; } if (_uiAccessEnabled && IsHandleCreated) { SetFallbackTopMost(false); SetUiAccessTopMost(true); SetWindowPos(Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); ApplyFallbackTopMostTick(); return; } SetUiAccessTopMost(false); SetFallbackTopMost(true); ApplyFallbackTopMostTick(); } private void SetUiAccessTopMost(bool enabled) { if (_usingUiAccessTopMost == enabled) { return; } _usingUiAccessTopMost = enabled; if (enabled) { _fallbackTopMostTimer.Start(); } else if (!_usingFallbackTopMost) { _fallbackTopMostTimer.Stop(); } } private void SetFallbackTopMost(bool enabled) { if (_usingFallbackTopMost == enabled) { return; } _usingFallbackTopMost = enabled; _topMostToggle.Text = enabled ? "超级置顶(备)" : "超级置顶"; if (enabled) { _fallbackTopMostTimer.Start(); } else if (!_usingUiAccessTopMost) { _fallbackTopMostTimer.Stop(); } } private void ApplyFallbackTopMostTick() { if ((!_usingFallbackTopMost && !_usingUiAccessTopMost) || !IsHandleCreated) { return; } var target = GetActiveTopMostTarget(); if (target == null || !target.IsHandleCreated) { return; } SetWindowPos(target.Handle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); } private Form? GetActiveTopMostTarget() { var active = Form.ActiveForm; if (active == null || active.IsDisposed || !active.Visible) { return this; } if (active == this || active == _aboutDialog || active == _floatingPrompt) { return active; } return this; } [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate bool IsUIAccessDelegate(); [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern IntPtr LoadLibraryW(string lpFileName); [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GetProcAddress(IntPtr hModule, string procName); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr hModule); [DllImport("user32.dll", SetLastError = true)] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); private static readonly IntPtr HWND_NOTOPMOST = new IntPtr(-2); private const uint SWP_NOMOVE = 0x0002; private const uint SWP_NOSIZE = 0x0001; private const uint SWP_NOACTIVATE = 0x0010; } }