Files
ZerkyLiu 8ae90f81b4 上传文件至「/」
同步最新发布版1.2.3
2026-01-27 13:43:10 +01:00

1481 lines
53 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<IsUIAccessDelegate>(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<int>(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<bool>();
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<DialogResult> ShowNonModalConfirmAsync(Form owner, string text, string title)
{
var tcs = new TaskCompletionSource<DialogResult>();
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<ReleaseInfo?> 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<ReleaseInfo?> 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<int>? progress = null)
{
using (var client = CreateWebClient(true))
{
var tcs = new TaskCompletionSource<bool>();
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;
}
}