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`