From 7247f122ab4357d21183e18c8c5a19efdd11e0bf Mon Sep 17 00:00:00 2001 From: taocihei <82604831+taocihei@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:13:13 -0700 Subject: [PATCH] Fix network time sync and mixed-DPI displays --- FloatingClock/App.config | 7 +- FloatingClock/App.xaml.cs | 3 +- FloatingClock/DisplayHelper.cs | 204 +++++++++++++++ FloatingClock/FloatingClock.csproj | 6 +- FloatingClock/MainWindow.xaml.cs | 301 ++++++++++++++++------- FloatingClock/MouseHook.cs | 15 +- FloatingClock/NetworkTimeProvider.cs | 176 +++++++++++++ FloatingClock/OptionsWindow.cs | 31 ++- FloatingClock/Properties/AssemblyInfo.cs | 4 +- FloatingClock/app.manifest | 23 ++ README.md | 4 +- 11 files changed, 662 insertions(+), 112 deletions(-) create mode 100644 FloatingClock/DisplayHelper.cs create mode 100644 FloatingClock/NetworkTimeProvider.cs create mode 100644 FloatingClock/app.manifest diff --git a/FloatingClock/App.config b/FloatingClock/App.config index 07e7950..57876ed 100644 --- a/FloatingClock/App.config +++ b/FloatingClock/App.config @@ -1,6 +1,9 @@ + + + - - + + diff --git a/FloatingClock/App.xaml.cs b/FloatingClock/App.xaml.cs index 71d894d..c1175de 100644 --- a/FloatingClock/App.xaml.cs +++ b/FloatingClock/App.xaml.cs @@ -12,8 +12,9 @@ public partial class App : Application /// private void App_OnExit(object sender, ExitEventArgs e) { + NetworkTimeProvider.Stop(); MouseHook.UnhookWindowsHookEx(MouseHook._hookID); } } -} \ No newline at end of file +} diff --git a/FloatingClock/DisplayHelper.cs b/FloatingClock/DisplayHelper.cs new file mode 100644 index 0000000..0773e23 --- /dev/null +++ b/FloatingClock/DisplayHelper.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace FloatingClock +{ + /// + /// Multi-monitor helpers. All rectangles and positions exposed by this class are physical pixels. + /// + internal static class DisplayHelper + { + private const int EnumCurrentSettings = -1; + private const int MdtEffectiveDpi = 0; + private const uint MonitorDefaultToNearest = 2; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct DevMode + { + private const int CchDeviceName = 32; + private const int CchFormName = 32; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CchDeviceName)] + public string dmDeviceName; + public ushort dmSpecVersion; + public ushort dmDriverVersion; + public ushort dmSize; + public ushort dmDriverExtra; + public uint dmFields; + public int dmPositionX; + public int dmPositionY; + public uint dmDisplayOrientation; + public uint dmDisplayFixedOutput; + public short dmColor; + public short dmDuplex; + public short dmYResolution; + public short dmTTOption; + public short dmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = CchFormName)] + public string dmFormName; + public ushort dmLogPixels; + public uint dmBitsPerPel; + public uint dmPelsWidth; + public uint dmPelsHeight; + public uint dmDisplayFlags; + public uint dmDisplayFrequency; + public uint dmICMMethod; + public uint dmICMIntent; + public uint dmMediaType; + public uint dmDitherType; + public uint dmReserved1; + public uint dmReserved2; + public uint dmPanningWidth; + public uint dmPanningHeight; + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool EnumDisplaySettings( + string deviceName, int modeNum, ref DevMode devMode); + + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromPoint(Point pt, uint flags); + + [DllImport("shcore.dll")] + private static extern int GetDpiForMonitor( + IntPtr monitor, int dpiType, out uint dpiX, out uint dpiY); + + internal static Screen[] GetScreensInFriendlyOrder() + { + return Screen.AllScreens + .OrderByDescending(s => s.Primary) + .ThenBy(s => GetPhysicalBounds(s).Left) + .ThenBy(s => GetPhysicalBounds(s).Top) + .ToArray(); + } + + internal static Screen FindByDeviceName(string deviceName) + { + if (string.IsNullOrWhiteSpace(deviceName)) return null; + return Screen.AllScreens.FirstOrDefault(s => + string.Equals(s.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase)); + } + + internal static Size GetCurrentResolution(Screen screen) + { + DevMode mode; + if (TryGetCurrentMode(screen, out mode)) + return new Size((int)mode.dmPelsWidth, (int)mode.dmPelsHeight); + + return screen.Bounds.Size; + } + + private static bool TryGetCurrentMode(Screen screen, out DevMode mode) + { + mode = new DevMode(); + mode.dmSize = (ushort)Marshal.SizeOf(typeof(DevMode)); + return EnumDisplaySettings(screen.DeviceName, EnumCurrentSettings, ref mode) + && mode.dmPelsWidth > 0 && mode.dmPelsHeight > 0; + } + + internal static Rectangle GetPhysicalBounds(Screen screen) + { + DevMode mode; + if (!TryGetCurrentMode(screen, out mode)) return screen.Bounds; + return new Rectangle(mode.dmPositionX, mode.dmPositionY, + (int)mode.dmPelsWidth, (int)mode.dmPelsHeight); + } + + internal static Rectangle GetPhysicalWorkingArea(Screen screen) + { + Rectangle physical = GetPhysicalBounds(screen); + Rectangle logical = screen.Bounds; + Rectangle work = screen.WorkingArea; + if (logical.Width <= 0 || logical.Height <= 0) return physical; + + double sx = physical.Width / (double)logical.Width; + double sy = physical.Height / (double)logical.Height; + int left = physical.Left + (int)Math.Round((work.Left - logical.Left) * sx); + int top = physical.Top + (int)Math.Round((work.Top - logical.Top) * sy); + int right = physical.Left + (int)Math.Round((work.Right - logical.Left) * sx); + int bottom = physical.Top + (int)Math.Round((work.Bottom - logical.Top) * sy); + return Rectangle.FromLTRB(left, top, right, bottom); + } + + internal static uint GetEffectiveDpi(Screen screen) + { + // 在 System-DPI-aware 的旧版 WPF 中 Screen.Bounds 会被缩放,显示模式仍是物理像素。 + // 先用二者比例恢复目标屏 DPI,避免把主屏 DPI 错套到其它屏幕。 + Size physical = GetCurrentResolution(screen); + if (screen.Bounds.Width > 0) + { + double ratio = physical.Width / (double)screen.Bounds.Width; + if (ratio > 1.01) + return (uint)Math.Round(96.0 * ratio); + } + + try + { + var center = new Point( + screen.Bounds.Left + screen.Bounds.Width / 2, + screen.Bounds.Top + screen.Bounds.Height / 2); + IntPtr monitor = MonitorFromPoint(center, MonitorDefaultToNearest); + uint x, y; + if (monitor != IntPtr.Zero && GetDpiForMonitor(monitor, MdtEffectiveDpi, out x, out y) == 0 && x > 0) + return x; + } + catch (DllNotFoundException) { } + catch (EntryPointNotFoundException) { } + + return 96; + } + + internal static int GetScalePercent(Screen screen) + { + return (int)Math.Round(GetEffectiveDpi(screen) * 100.0 / 96.0); + } + + internal static string FriendlyDeviceName(Screen screen) + { + string name = screen.DeviceName ?? "Display"; + const string prefix = @"\\.\DISPLAY"; + return name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? "Display " + name.Substring(prefix.Length) + : name; + } + + internal static Rectangle GetVirtualBounds() + { + var screens = Screen.AllScreens; + if (screens.Length == 0) return Rectangle.Empty; + + int left = screens.Min(s => GetPhysicalBounds(s).Left); + int top = screens.Min(s => GetPhysicalBounds(s).Top); + int right = screens.Max(s => GetPhysicalBounds(s).Right); + int bottom = screens.Max(s => GetPhysicalBounds(s).Bottom); + return Rectangle.FromLTRB(left, top, right, bottom); + } + + internal static Screen FindByPhysicalPoint(Point point) + { + Screen nearest = null; + long nearestDistance = long.MaxValue; + foreach (Screen screen in Screen.AllScreens) + { + Rectangle bounds = GetPhysicalBounds(screen); + if (bounds.Contains(point)) return screen; + + int dx = point.X < bounds.Left ? bounds.Left - point.X + : point.X >= bounds.Right ? point.X - bounds.Right + 1 : 0; + int dy = point.Y < bounds.Top ? bounds.Top - point.Y + : point.Y >= bounds.Bottom ? point.Y - bounds.Bottom + 1 : 0; + long distance = (long)dx * dx + (long)dy * dy; + if (distance < nearestDistance) + { + nearestDistance = distance; + nearest = screen; + } + } + return nearest ?? Screen.PrimaryScreen; + } + } +} diff --git a/FloatingClock/FloatingClock.csproj b/FloatingClock/FloatingClock.csproj index 0f16bb2..5c5cee5 100644 --- a/FloatingClock/FloatingClock.csproj +++ b/FloatingClock/FloatingClock.csproj @@ -39,6 +39,7 @@ clock.ico + app.manifest @@ -71,6 +72,8 @@ Code + + @@ -104,6 +107,7 @@ + @@ -119,4 +123,4 @@ --> - \ No newline at end of file + diff --git a/FloatingClock/MainWindow.xaml.cs b/FloatingClock/MainWindow.xaml.cs index c7180ba..2a211e4 100644 --- a/FloatingClock/MainWindow.xaml.cs +++ b/FloatingClock/MainWindow.xaml.cs @@ -1,6 +1,5 @@ using System; using System.Drawing; -using System.Threading.Tasks; using System.Windows; using System.Windows.Forms; using System.Windows.Input; @@ -38,6 +37,7 @@ public partial class MainWindow public static double ClockScaleY = 1.0; // 纵向缩放 public static int ClockBackShade = 17; // 背景底色明暗 0(黑) ~ 255(白) public static int TargetScreen = -1; // -1=跟随鼠标所在屏;0..N=指定屏幕 + public static string TargetScreenDeviceName = ""; // 稳定标识,避免屏幕枚举顺序变化后选错 public static string ClockSkin = "默认(玻璃白)"; // 当前皮肤名 public static bool LinkScale = true; // 宽高等比联动 @@ -190,9 +190,10 @@ internal void SetLinkScale(bool v) internal void SetTimeZone(string id) { - TimeZoneId = id ?? ""; + TimeZoneId = NormalizeTimeZoneId(id); SaveReg(nameof(TimeZoneId), TimeZoneId); Refresh(); + RestartRefreshDispatcher(); } internal void SetPosition(int pos) @@ -205,7 +206,21 @@ internal void SetPosition(int pos) internal void SetTargetScreen(int idx) { TargetScreen = idx; + var screens = Screen.AllScreens; + TargetScreenDeviceName = idx >= 0 && idx < screens.Length ? screens[idx].DeviceName : ""; SaveReg(nameof(TargetScreen), idx); + SaveReg(nameof(TargetScreenDeviceName), TargetScreenDeviceName); + if (WindowIsVisible) SetPositionOnCurrentDisplay(); + } + + internal void SetTargetScreen(string deviceName) + { + TargetScreenDeviceName = deviceName ?? ""; + var screens = Screen.AllScreens; + TargetScreen = Array.FindIndex(screens, s => string.Equals( + s.DeviceName, TargetScreenDeviceName, StringComparison.OrdinalIgnoreCase)); + SaveReg(nameof(TargetScreenDeviceName), TargetScreenDeviceName); + SaveReg(nameof(TargetScreen), TargetScreen); if (WindowIsVisible) SetPositionOnCurrentDisplay(); } @@ -394,8 +409,71 @@ internal void SetAutoStart(bool v) private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct WindowRect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hWnd, out WindowRect rect); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext); + private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); - private const uint SWP_NOMOVE = 0x0002, SWP_NOSIZE = 0x0001, SWP_NOACTIVATE = 0x0010; + private static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new IntPtr(-4); + private const uint SWP_NOMOVE = 0x0002, SWP_NOSIZE = 0x0001, SWP_NOZORDER = 0x0004, + SWP_NOACTIVATE = 0x0010; + + private IntPtr WindowHandle + { + get { return new System.Windows.Interop.WindowInteropHelper(this).Handle; } + } + + private bool TryGetPhysicalWindowRect(out WindowRect rect) + { + rect = new WindowRect(); + IntPtr hwnd = WindowHandle; + if (hwnd == IntPtr.Zero) return false; + IntPtr previous = IntPtr.Zero; + try + { + previous = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + return GetWindowRect(hwnd, out rect); + } + catch (EntryPointNotFoundException) + { + return GetWindowRect(hwnd, out rect); + } + finally + { + if (previous != IntPtr.Zero) SetThreadDpiAwarenessContext(previous); + } + } + + private void MoveWindowPhysical(int left, int top) + { + IntPtr hwnd = WindowHandle; + if (hwnd == IntPtr.Zero) return; + IntPtr previous = IntPtr.Zero; + try + { + previous = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + SetWindowPos(hwnd, IntPtr.Zero, left, top, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + } + catch (EntryPointNotFoundException) + { + SetWindowPos(hwnd, IntPtr.Zero, left, top, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + } + finally + { + if (previous != IntPtr.Zero) SetThreadDpiAwarenessContext(previous); + } + } /// /// 用 Win32 强制把窗口提到最前(对付其它置顶 / 全屏窗口) @@ -424,8 +502,17 @@ private void TopmostReassert(object sender, EventArgs e) /// private void SaveCustomPosition() { - CustomLeft = Left; - CustomTop = Top; + WindowRect rect; + if (TryGetPhysicalWindowRect(out rect)) + { + CustomLeft = rect.Left; + CustomTop = rect.Top; + } + else + { + CustomLeft = Left; + CustomTop = Top; + } ClockPosition = 5; SaveReg("CustomLeft", CustomLeft.ToString(System.Globalization.CultureInfo.InvariantCulture)); SaveReg("CustomTop", CustomTop.ToString(System.Globalization.CultureInfo.InvariantCulture)); @@ -528,36 +615,59 @@ private void ResizeEnd(object sender, System.Windows.Input.MouseButtonEventArgs /// private void SnapToEdges() { - var win = Application.Current.MainWindow; - double w = win.ActualWidth, h = win.ActualHeight; - if (w < 1 || h < 1) return; - double dpi = Screen.PrimaryScreen.Bounds.Height / SystemParameters.PrimaryScreenHeight; - var center = new System.Drawing.Point( - (int)((win.Left + w / 2) * dpi), (int)((win.Top + h / 2) * dpi)); - var scr = Screen.FromPoint(center); - double waL = scr.WorkingArea.Left / dpi, waT = scr.WorkingArea.Top / dpi; - double waR = scr.WorkingArea.Right / dpi, waB = scr.WorkingArea.Bottom / dpi; + WindowRect rect; + if (!TryGetPhysicalWindowRect(out rect)) return; + int w = rect.Right - rect.Left; + int h = rect.Bottom - rect.Top; + var center = new System.Drawing.Point(rect.Left + w / 2, rect.Top + h / 2); + var scr = DisplayHelper.FindByPhysicalPoint(center); + int threshold = Math.Max(8, (int)Math.Round(SnapDist * DisplayHelper.GetEffectiveDpi(scr) / 96.0)); + var work = DisplayHelper.GetPhysicalWorkingArea(scr); + int waL = work.Left, waT = work.Top; + int waR = work.Right, waB = work.Bottom; + + int left = rect.Left, top = rect.Top; + if (Math.Abs(left - waL) <= threshold) left = waL; + else if (Math.Abs((left + w) - waR) <= threshold) left = waR - w; + if (Math.Abs(top - waT) <= threshold) top = waT; + else if (Math.Abs((top + h) - waB) <= threshold) top = waB - h; + MoveWindowPhysical(left, top); + } - double left = win.Left, top = win.Top; - if (Math.Abs(left - waL) <= SnapDist) left = waL; - else if (Math.Abs((left + w) - waR) <= SnapDist) left = waR - w; - if (Math.Abs(top - waT) <= SnapDist) top = waT; - else if (Math.Abs((top + h) - waB) <= SnapDist) top = waB - h; - win.Left = left; win.Top = top; + /// + /// 规范化旧配置或常见别名。 + /// + private static string NormalizeTimeZoneId(string id) + { + string value = (id ?? "").Trim(); + if (string.Equals(value, "Beijing Standard Time", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "Asia/Shanghai", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "PRC", StringComparison.OrdinalIgnoreCase)) + return "China Standard Time"; + return value; } /// - /// 取当前时间(考虑自定义时区) + /// 取当前时间(考虑自定义时区)。以 UTC 时间戳为唯一基准,避免本地时区被重复换算。 /// - private static DateTime GetNow() + private static DateTimeOffset GetNow() { - if (string.IsNullOrEmpty(TimeZoneId)) return DateTime.Now; + DateTimeOffset utcNow = NetworkTimeProvider.UtcNow; + if (string.IsNullOrEmpty(TimeZoneId)) + return TimeZoneInfo.ConvertTime(utcNow, TimeZoneInfo.Local); try { - var tz = TimeZoneInfo.FindSystemTimeZoneById(TimeZoneId); - return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); + var tz = TimeZoneInfo.FindSystemTimeZoneById(NormalizeTimeZoneId(TimeZoneId)); + return TimeZoneInfo.ConvertTime(utcNow, tz); + } + catch + { + // 北京时间没有夏令时;即使系统时区数据库损坏,也保持 UTC+8 的正确结果。 + if (string.Equals(NormalizeTimeZoneId(TimeZoneId), "China Standard Time", + StringComparison.OrdinalIgnoreCase)) + return utcNow.ToOffset(TimeSpan.FromHours(8)); + return DateTimeOffset.Now; } - catch { return DateTime.Now; } } /// @@ -576,7 +686,7 @@ public MainWindow() SecondsEnabled = Convert.ToBoolean(registryKey.GetValue(nameof(SecondsEnabled), Convert.ToInt32(SecondsEnabled))); HideIfFocusLost = Convert.ToBoolean(registryKey.GetValue(nameof(HideIfFocusLost), Convert.ToInt32(HideIfFocusLost))); DisableGlass = Convert.ToBoolean(registryKey.GetValue(nameof(DisableGlass), Convert.ToInt32(DisableGlass))); - TimeZoneId = Convert.ToString(registryKey.GetValue(nameof(TimeZoneId), TimeZoneId)); + TimeZoneId = NormalizeTimeZoneId(Convert.ToString(registryKey.GetValue(nameof(TimeZoneId), TimeZoneId))); ClockPosition = Convert.ToInt32(registryKey.GetValue(nameof(ClockPosition), ClockPosition)); LanguageCode = Convert.ToString(registryKey.GetValue(nameof(LanguageCode), LanguageCode)); Locked = Convert.ToBoolean(registryKey.GetValue(nameof(Locked), Convert.ToInt32(Locked))); @@ -589,10 +699,25 @@ public MainWindow() ClockScaleY = ParseDouble(registryKey.GetValue(nameof(ClockScaleY)), ClockScaleY); ClockBackShade = Convert.ToInt32(registryKey.GetValue(nameof(ClockBackShade), ClockBackShade)); TargetScreen = Convert.ToInt32(registryKey.GetValue(nameof(TargetScreen), TargetScreen)); + TargetScreenDeviceName = Convert.ToString(registryKey.GetValue(nameof(TargetScreenDeviceName), "")); + if (string.IsNullOrWhiteSpace(TargetScreenDeviceName) + && TargetScreen >= 0 && TargetScreen < Screen.AllScreens.Length) + { + // 一次性迁移旧版的易变数组索引为稳定的 Windows 显示器设备名。 + TargetScreenDeviceName = Screen.AllScreens[TargetScreen].DeviceName; + registryKey.SetValue(nameof(TargetScreenDeviceName), TargetScreenDeviceName); + } ClockSkin = Convert.ToString(registryKey.GetValue(nameof(ClockSkin), ClockSkin)); LinkScale = Convert.ToBoolean(registryKey.GetValue(nameof(LinkScale), Convert.ToInt32(LinkScale))); registryKey.Close(); + NetworkTimeProvider.Start(() => Dispatcher.BeginInvoke(new Action(() => + { + Refresh(); + RestartRefreshDispatcher(); + }))); + + InitializeRefreshDispatcher(); Refresh(); ApplyBackground(); @@ -600,7 +725,6 @@ public MainWindow() ApplySkin(SkinLibrary.Find(ClockSkin) ?? SkinLibrary.Builtin()[0]); ShowClock(); - InitializeRefreshDispatcher(); EnableSeconds(SecondsEnabled); @@ -628,17 +752,7 @@ private void EnableSeconds(bool enable) SecondsEnabled = enable; OptionalSeconds.Visibility = enable ? Visibility.Visible : Visibility.Hidden; Refresh(); - InitializeRefreshDispatcher(); - - if (enable) - { - refreshDispatcher.Start(); - } - else - { - WaitToFullMinuteAndRefresh(); - - } + RestartRefreshDispatcher(); } /// @@ -650,7 +764,7 @@ public void ShowClock() { Refresh(); InitializeAnimationIn(); - WaitToFullMinuteAndRefresh(); + RestartRefreshDispatcher(); } else { @@ -665,9 +779,9 @@ private void LoadCurrentClockData() { var timeNow = GetNow(); var ci = Loc.Culture; - Hours.Text = timeNow.ToString("HH"); - Minutes.Text = timeNow.ToString("mm"); - Seconds.Text = timeNow.ToString("ss"); + Hours.Text = timeNow.ToString("HH", System.Globalization.CultureInfo.InvariantCulture); + Minutes.Text = timeNow.ToString("mm", System.Globalization.CultureInfo.InvariantCulture); + Seconds.Text = timeNow.ToString("ss", System.Globalization.CultureInfo.InvariantCulture); DayOfTheWeek.Text = ci.TextInfo.ToTitleCase(timeNow.ToString("dddd", ci)); DayOfTheMonth.Text = timeNow.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); } @@ -677,24 +791,35 @@ private void LoadCurrentClockData() /// private void InitializeRefreshDispatcher() { + if (refreshDispatcher != null) return; refreshDispatcher = new DispatcherTimer(); - refreshDispatcher.Tick += Refresh; - if(SecondsEnabled) - refreshDispatcher.Interval = new TimeSpan(0,0,1); - else - refreshDispatcher.Interval = new TimeSpan(0, 1, 0); + refreshDispatcher.Tick += RefreshDispatcherTick; } /// - /// Wait to full minute refresh data and start refresh Dispatcher + /// 单次定时到下一个整秒/整分钟。每次 Tick 重新校准,避免 DispatcherTimer 长期漂移。 /// - private async void WaitToFullMinuteAndRefresh() + private void RestartRefreshDispatcher() { - await Task.Delay((60 - DateTime.Now.Second) * 1000); - Refresh(); + if (refreshDispatcher == null) InitializeRefreshDispatcher(); + refreshDispatcher.Stop(); + if (!WindowIsVisible) return; + + DateTimeOffset now = GetNow(); + int delayMs = SecondsEnabled + ? 1000 - now.Millisecond + : ((59 - now.Second) * 1000 + (1000 - now.Millisecond)); + refreshDispatcher.Interval = TimeSpan.FromMilliseconds(Math.Max(50, delayMs)); refreshDispatcher.Start(); } + private void RefreshDispatcherTick(object sender, EventArgs e) + { + refreshDispatcher.Stop(); + Refresh(); + RestartRefreshDispatcher(); + } + /// /// DispatcherTimer Refresh Event /// @@ -711,55 +836,53 @@ private void Refresh(object sender = null, EventArgs e = null) private void SetPositionOnCurrentDisplay() { var screens = Screen.AllScreens; - Screen activeScreen = (TargetScreen >= 0 && TargetScreen < screens.Length) - ? screens[TargetScreen] - : Screen.FromPoint(Control.MousePosition); - int resHeight = Screen.PrimaryScreen.Bounds.Height; - double actualHeight = SystemParameters.PrimaryScreenHeight; - double dpi = resHeight / actualHeight; - - double waX = activeScreen.WorkingArea.X / dpi; - double waY = activeScreen.WorkingArea.Y / dpi; - double waW = activeScreen.WorkingArea.Width / dpi; - double waH = activeScreen.WorkingArea.Height / dpi; - - double w = Application.Current.MainWindow.ActualWidth; - double h = Application.Current.MainWindow.ActualHeight; - if (w < 1) w = 420; - if (h < 1) h = 180; - const double margin = 50; - - double left, top; + Screen activeScreen = DisplayHelper.FindByDeviceName(TargetScreenDeviceName); + if (activeScreen == null && TargetScreen >= 0 && TargetScreen < screens.Length) + activeScreen = screens[TargetScreen]; + if (activeScreen == null) + { + POINTAPI cursor; + GetCursorPos(out cursor); + activeScreen = DisplayHelper.FindByPhysicalPoint(new System.Drawing.Point(cursor.X, cursor.Y)); + } + + var wa = DisplayHelper.GetPhysicalWorkingArea(activeScreen); + double scale = DisplayHelper.GetEffectiveDpi(activeScreen) / 96.0; + int w = Math.Max(1, (int)Math.Round((ActualWidth > 1 ? ActualWidth : 420) * scale)); + int h = Math.Max(1, (int)Math.Round((ActualHeight > 1 ? ActualHeight : 180) * scale)); + int margin = Math.Max(16, (int)Math.Round(50 * scale)); + + int left, top; switch (ClockPosition) { case 1: // 左上 - left = waX + margin; top = waY + margin; break; + left = wa.Left + margin; top = wa.Top + margin; break; case 2: // 右上 - left = waX + waW - w - margin; top = waY + margin; break; + left = wa.Right - w - margin; top = wa.Top + margin; break; case 3: // 右下 - left = waX + waW - w - margin; top = waY + waH - h - margin; break; + left = wa.Right - w - margin; top = wa.Bottom - h - margin; break; case 4: // 居中 - left = waX + (waW - w) / 2; top = waY + (waH - h) / 2; break; - case 5: // 自定义(手动拖动记忆)—— 绝对坐标,跨屏也能回到原位 + left = wa.Left + (wa.Width - w) / 2; top = wa.Top + (wa.Height - h) / 2; break; + case 5: // 自定义位置使用物理像素,混合 DPI 环境也不会产生坐标断层 if (!double.IsNaN(CustomLeft) && !double.IsNaN(CustomTop)) { - // 用整个虚拟桌面(所有屏幕合并)范围钳制,而不是鼠标所在的单块屏, - // 否则开机鼠标在主屏时,保存在副屏的坐标会被拉回主屏。 - double vX = SystemParameters.VirtualScreenLeft; - double vY = SystemParameters.VirtualScreenTop; - double vW = SystemParameters.VirtualScreenWidth; - double vH = SystemParameters.VirtualScreenHeight; - Application.Current.MainWindow.Left = Math.Max(vX, Math.Min(CustomLeft, vX + vW - w)); - Application.Current.MainWindow.Top = Math.Max(vY, Math.Min(CustomTop, vY + vH - h)); + var savedPoint = new System.Drawing.Point((int)Math.Round(CustomLeft), (int)Math.Round(CustomTop)); + var savedScreen = DisplayHelper.FindByPhysicalPoint(savedPoint); + var savedArea = DisplayHelper.GetPhysicalWorkingArea(savedScreen); + double savedScale = DisplayHelper.GetEffectiveDpi(savedScreen) / 96.0; + int savedW = Math.Max(1, (int)Math.Round((ActualWidth > 1 ? ActualWidth : 420) * savedScale)); + int savedH = Math.Max(1, (int)Math.Round((ActualHeight > 1 ? ActualHeight : 180) * savedScale)); + left = Math.Max(savedArea.Left, Math.Min(savedPoint.X, savedArea.Right - savedW)); + top = Math.Max(savedArea.Top, Math.Min(savedPoint.Y, savedArea.Bottom - savedH)); + MoveWindowPhysical(left, top); return; } - left = waX + margin; top = waY + waH - h - margin; + left = wa.Left + margin; top = wa.Bottom - h - margin; break; default: // 0 左下 - left = waX + margin; top = waY + waH - h - margin; break; + left = wa.Left + margin; top = wa.Bottom - h - margin; break; } - Application.Current.MainWindow.Left = left; - Application.Current.MainWindow.Top = top; + MoveWindowPhysical(left, top); } /// @@ -965,4 +1088,4 @@ private static void OpacityFadeOut(object sender, EventArgs e) } } -} \ No newline at end of file +} diff --git a/FloatingClock/MouseHook.cs b/FloatingClock/MouseHook.cs index dce9d57..6a271d9 100644 --- a/FloatingClock/MouseHook.cs +++ b/FloatingClock/MouseHook.cs @@ -67,13 +67,16 @@ private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (MainWindow.WindowIsVisible || nCode < 0) return CallNextHookEx(_hookID, nCode, wParam, lParam); var hookStruct = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT)); - var activeScreen = Screen.FromPoint(Control.MousePosition); - if (hookStruct.pt.x >= activeScreen.Bounds.X + activeScreen.Bounds.Width - 25) + var activeScreen = DisplayHelper.FindByPhysicalPoint( + new System.Drawing.Point(hookStruct.pt.x, hookStruct.pt.y)); + var bounds = DisplayHelper.GetPhysicalBounds(activeScreen); + int edge = Math.Max(25, (int)Math.Round(25 * DisplayHelper.GetEffectiveDpi(activeScreen) / 96.0)); + if (hookStruct.pt.x >= bounds.Right - edge) { if ( - (hookStruct.pt.y <= activeScreen.Bounds.Y + 25) + (hookStruct.pt.y <= bounds.Top + edge) || - (hookStruct.pt.y >= activeScreen.Bounds.Y + activeScreen.Bounds.Height - 25) + (hookStruct.pt.y >= bounds.Bottom - edge) ) { cornerIsActive = true; @@ -82,9 +85,9 @@ private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { DisableCorner(); } - if (!cornerIsActive || (hookStruct.pt.y < activeScreen.Bounds.Y + (activeScreen.Bounds.Height / 5)) || + if (!cornerIsActive || (hookStruct.pt.y < bounds.Top + (bounds.Height / 5)) || (hookStruct.pt.y > - activeScreen.Bounds.Y + activeScreen.Bounds.Height - (activeScreen.Bounds.Height / 5))) + bounds.Bottom - (bounds.Height / 5))) return CallNextHookEx(_hookID, nCode, wParam, lParam); MainWindow.Current.ShowClock(); cornerIsActive = false; diff --git a/FloatingClock/NetworkTimeProvider.cs b/FloatingClock/NetworkTimeProvider.cs new file mode 100644 index 0000000..4063ba9 --- /dev/null +++ b/FloatingClock/NetworkTimeProvider.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32; + +namespace FloatingClock +{ + /// + /// Supplies UTC corrected against HTTPS Date headers. This keeps the clock accurate even when + /// Windows is intentionally set to another wall-clock value or the Windows Time service is disabled. + /// + internal static class NetworkTimeProvider + { + private const string SettingsKey = @"SOFTWARE\BaalTech\FloatingClock"; + private const string OffsetValue = "NetworkUtcOffsetTicks"; + private const string SyncValue = "NetworkUtcLastSync"; + private static readonly object Gate = new object(); + private static readonly string[] Sources = + { + "https://www.time.gov/", + "https://www.nist.gov/", + "https://www.microsoft.com/" + }; + + private static TimeSpan _utcOffset; + private static bool _hasCorrection; + private static Timer _timer; + private static Action _onUpdated; + private static int _syncing; + + internal static bool HasCorrection + { + get { lock (Gate) return _hasCorrection; } + } + + internal static TimeSpan CurrentOffset + { + get { lock (Gate) return _utcOffset; } + } + + internal static DateTimeOffset UtcNow + { + get + { + lock (Gate) + return DateTimeOffset.UtcNow + (_hasCorrection ? _utcOffset : TimeSpan.Zero); + } + } + + internal static void Start(Action onUpdated) + { + _onUpdated = onUpdated; + LoadCachedCorrection(); + ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12; + if (_timer == null) + _timer = new Timer(SyncTimerCallback, null, TimeSpan.Zero, TimeSpan.FromMinutes(30)); + } + + internal static void Stop() + { + if (_timer != null) + { + _timer.Dispose(); + _timer = null; + } + } + + private static void SyncTimerCallback(object state) + { + SynchronizeAsync(); + } + + private static async void SynchronizeAsync() + { + if (Interlocked.Exchange(ref _syncing, 1) != 0) return; + try + { + var offsets = new List(); + using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(8) }) + { + client.DefaultRequestHeaders.UserAgent.ParseAdd("FloatingClock/1.3"); + foreach (string source in Sources) + { + TimeSpan? sample = await QueryOffsetAsync(client, source).ConfigureAwait(false); + if (sample.HasValue) offsets.Add(sample.Value); + } + } + + if (offsets.Count == 0) return; + long[] ticks = offsets.Select(x => x.Ticks).OrderBy(x => x).ToArray(); + var median = TimeSpan.FromTicks(ticks[ticks.Length / 2]); + + // Reject obviously invalid proxy/captive-portal dates while allowing deliberately shifted PCs. + if (Math.Abs(median.TotalDays) > 7) return; + + lock (Gate) + { + _utcOffset = median; + _hasCorrection = true; + } + SaveCachedCorrection(median); + var callback = _onUpdated; + if (callback != null) + { + try { callback(); } + catch { } + } + } + finally + { + Interlocked.Exchange(ref _syncing, 0); + } + } + + private static async Task QueryOffsetAsync(HttpClient client, string source) + { + try + { + var stopwatch = Stopwatch.StartNew(); + using (var request = new HttpRequestMessage(HttpMethod.Head, source)) + using (var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false)) + { + stopwatch.Stop(); + if (!response.IsSuccessStatusCode || !response.Headers.Date.HasValue) return null; + DateTimeOffset estimatedServerNow = response.Headers.Date.Value + + TimeSpan.FromTicks(stopwatch.Elapsed.Ticks / 2); + DateTimeOffset localAfter = DateTimeOffset.UtcNow; + return estimatedServerNow - localAfter; + } + } + catch + { + return null; + } + } + + private static void LoadCachedCorrection() + { + try + { + using (RegistryKey key = Registry.CurrentUser.OpenSubKey(SettingsKey, false)) + { + if (key == null) return; + long ticks; + if (!long.TryParse(Convert.ToString(key.GetValue(OffsetValue, "")), out ticks)) return; + // Cached correction is safe for offline startup; the background sync refreshes it immediately. + lock (Gate) + { + _utcOffset = TimeSpan.FromTicks(ticks); + _hasCorrection = true; + } + } + } + catch { } + } + + private static void SaveCachedCorrection(TimeSpan offset) + { + try + { + using (RegistryKey key = Registry.CurrentUser.CreateSubKey(SettingsKey)) + { + key.SetValue(OffsetValue, offset.Ticks.ToString()); + key.SetValue(SyncValue, DateTimeOffset.UtcNow.ToString("o")); + } + } + catch { } + } + } +} diff --git a/FloatingClock/OptionsWindow.cs b/FloatingClock/OptionsWindow.cs index d5a3bfe..b150247 100644 --- a/FloatingClock/OptionsWindow.cs +++ b/FloatingClock/OptionsWindow.cs @@ -384,8 +384,17 @@ private FrameworkElement ScaleRows() private FrameworkElement TimeZoneRow() { var opts = new List { new ComboOption { Text = Loc.T("tz_local"), Value = "" } }; + // 北京时间固定放在最前,避免在很长的系统时区列表中误选同为 UTC+8 的其它区域。 + TimeZoneInfo beijing = null; + try { beijing = TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"); } + catch { } + if (beijing != null) + opts.Add(new ComboOption { Text = TzText(beijing), Value = beijing.Id }); foreach (var tz in TimeZoneInfo.GetSystemTimeZones()) + { + if (string.Equals(tz.Id, "China Standard Time", StringComparison.OrdinalIgnoreCase)) continue; opts.Add(new ComboOption { Text = TzText(tz), Value = tz.Id }); + } return ComboRow("tz_label", opts.ToArray(), MainWindow.TimeZoneId ?? "", v => MainWindow.Current.SetTimeZone((string)v)); } @@ -396,7 +405,8 @@ private FrameworkElement TimeZoneRow() private static string TzText(TimeZoneInfo tz) { if (Loc.Lang != "zh") return tz.DisplayName; - var off = tz.BaseUtcOffset; + // 显示“当前”偏移而不是标准时偏移,夏令时地区不会再差一小时。 + var off = tz.GetUtcOffset(DateTime.UtcNow); string sign = off < TimeSpan.Zero ? "-" : "+"; string prefix = string.Format("(UTC{0}{1:00}:{2:00}) ", sign, Math.Abs(off.Hours), Math.Abs(off.Minutes)); string cn; @@ -495,18 +505,21 @@ private FrameworkElement PositionRow() private FrameworkElement ScreenRow() { - var opts = new List { new ComboOption { Text = Loc.T("screen_auto"), Value = -1 } }; - var screens = System.Windows.Forms.Screen.AllScreens; + var opts = new List { new ComboOption { Text = Loc.T("screen_auto"), Value = "" } }; + var screens = DisplayHelper.GetScreensInFriendlyOrder(); for (int i = 0; i < screens.Length; i++) { var s = screens[i]; - string label = (Loc.Lang == "zh" ? "屏幕 " : "Display ") + (i + 1) - + " " + s.Bounds.Width + "×" + s.Bounds.Height - + (s.Primary ? (Loc.Lang == "zh" ? " · 主" : " · Primary") : ""); - opts.Add(new ComboOption { Text = label, Value = i }); + var resolution = DisplayHelper.GetCurrentResolution(s); + int scale = DisplayHelper.GetScalePercent(s); + string label = DisplayHelper.FriendlyDeviceName(s) + + " " + resolution.Width + "×" + resolution.Height + + " " + scale + "%" + + (s.Primary ? (Loc.Lang == "zh" ? " · 主屏" : " · Primary") : ""); + opts.Add(new ComboOption { Text = label, Value = s.DeviceName }); } - return ComboRow("screen_label", opts.ToArray(), MainWindow.TargetScreen, - v => MainWindow.Current.SetTargetScreen((int)v)); + return ComboRow("screen_label", opts.ToArray(), MainWindow.TargetScreenDeviceName ?? "", + v => MainWindow.Current.SetTargetScreen((string)v)); } private FrameworkElement SkinRow() diff --git a/FloatingClock/Properties/AssemblyInfo.cs b/FloatingClock/Properties/AssemblyInfo.cs index 87bbe78..9001cad 100644 --- a/FloatingClock/Properties/AssemblyInfo.cs +++ b/FloatingClock/Properties/AssemblyInfo.cs @@ -52,5 +52,5 @@ // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.2.0.0")] -[assembly: AssemblyFileVersion("1.2.0.0")] \ No newline at end of file +[assembly: AssemblyVersion("1.3.1.0")] +[assembly: AssemblyFileVersion("1.3.1.0")] diff --git a/FloatingClock/app.manifest b/FloatingClock/app.manifest new file mode 100644 index 0000000..98bfc13 --- /dev/null +++ b/FloatingClock/app.manifest @@ -0,0 +1,23 @@ + + + + + + + + + + + + + true/pm + PerMonitorV2,PerMonitor + true + + + + + + + + diff --git a/README.md b/README.md index 5dc4cd2..fc64014 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ - 玻璃(Aero)背景 / 纯色背景切换 **区域** -- 自定义时区(时区名跟随界面语言,中文内置常见时区译名) +- 自定义时区(HTTPS 联网 UTC 校准并缓存偏差;北京时间固定为 UTC+08:00) - 显示位置:左下 / 左上 / 右上 / 右下 / 居中 / 自定义(拖动记忆) -- 多显示器:指定显示在某块屏幕,或跟随鼠标所在屏 +- 多显示器:Per-Monitor V2 DPI、自适应各屏缩放、显示物理分辨率并按设备名稳定识别 - 界面语言:中文 / English / 跟随系统 - 日期格式:`yyyy-MM-dd`