From 74ca8e4d57e3ef86b6f9c48b3fee50849b1a0e00 Mon Sep 17 00:00:00 2001 From: Solin Date: Sat, 17 Jan 2026 16:26:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=A0=8F=E5=9B=BA=E5=AE=9A=E5=92=8C=E5=8F=B3=E9=94=AE=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E5=9B=BE=E6=A0=87=E4=B8=8D=E6=98=BE=E7=A4=BA=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IconGenerator.cs | 139 ++++++++++++------- MainForm.cs | 15 ++- Program.cs | 4 +- TaskbarIntegration.cs | 305 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 47 deletions(-) create mode 100644 TaskbarIntegration.cs diff --git a/IconGenerator.cs b/IconGenerator.cs index bf13708..b09f2a6 100644 --- a/IconGenerator.cs +++ b/IconGenerator.cs @@ -1,66 +1,115 @@ using System; using System.Drawing; using System.Drawing.Drawing2D; +using System.Drawing.Imaging; using System.IO; +using System.Runtime.InteropServices; namespace TimerApp { public static class IconGenerator { - public static Icon GenerateClockIcon() + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr hIcon); + + public static Icon GenerateClockIcon(int size = 64) { - int size = 64; + if (size < 16) + size = 16; + using (Bitmap bmp = new Bitmap(size, size)) using (Graphics g = Graphics.FromImage(bmp)) { g.SmoothingMode = SmoothingMode.AntiAlias; g.Clear(Color.Transparent); - // 绘制闹钟主体 - int margin = 4; - int clockSize = size - margin * 2; - Rectangle rect = new Rectangle(margin, margin, clockSize, clockSize); + DrawClock(g, size); - // 外圈 - using (Pen pen = new Pen(Color.White, 4)) - { - g.DrawEllipse(pen, rect); - } - - // 两个耳朵 (闹钟铃) - using (Brush earBrush = new SolidBrush(Color.White)) - { - // 左耳 - g.FillPie(earBrush, 0, 0, size/2, size/2, 200, 50); - // 右耳 - g.FillPie(earBrush, size/2, 0, size/2, size/2, 290, 50); - } - - // 重绘外圈盖住耳朵连接处 - using (Brush bgBrush = new SolidBrush(Color.FromArgb(30, 30, 30))) // 深色背景 - { - g.FillEllipse(bgBrush, rect); - } - using (Pen pen = new Pen(Color.White, 3)) - { - g.DrawEllipse(pen, rect); - } - - // 指针 - Point center = new Point(size / 2, size / 2); - using (Pen handPen = new Pen(Color.LightGreen, 3)) - { - handPen.EndCap = LineCap.Round; - // 时针 - g.DrawLine(handPen, center, new Point(center.X + 10, center.Y - 10)); - // 分针 - g.DrawLine(handPen, center, new Point(center.X, center.Y - 18)); - } - - // 转换为图标 - // 注意:GetHicon 需要释放 IntPtr hIcon = bmp.GetHicon(); - return Icon.FromHandle(hIcon); + try + { + using Icon icon = Icon.FromHandle(hIcon); + return (Icon)icon.Clone(); + } + finally + { + DestroyIcon(hIcon); + } + } + } + + public static void WriteClockIco(string filePath, int size = 256) + { + byte[] icoBytes = CreateClockIcoBytes(size); + File.WriteAllBytes(filePath, icoBytes); + } + + private static byte[] CreateClockIcoBytes(int size) + { + if (size <= 0) + size = 256; + if (size > 256) + size = 256; + if (size < 16) + size = 16; + + byte[] pngBytes; + using (Bitmap bmp = new Bitmap(size, size)) + using (Graphics g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + g.Clear(Color.Transparent); + DrawClock(g, size); + + using var ms = new MemoryStream(); + bmp.Save(ms, ImageFormat.Png); + pngBytes = ms.ToArray(); + } + + using var icoStream = new MemoryStream(6 + 16 + pngBytes.Length); + using (var bw = new BinaryWriter(icoStream, System.Text.Encoding.UTF8, leaveOpen: true)) + { + bw.Write((ushort)0); + bw.Write((ushort)1); + bw.Write((ushort)1); + + bw.Write((byte)(size == 256 ? 0 : size)); + bw.Write((byte)(size == 256 ? 0 : size)); + bw.Write((byte)0); + bw.Write((byte)0); + bw.Write((ushort)1); + bw.Write((ushort)32); + bw.Write((uint)pngBytes.Length); + bw.Write((uint)(6 + 16)); + } + + icoStream.Write(pngBytes, 0, pngBytes.Length); + return icoStream.ToArray(); + } + + private static void DrawClock(Graphics g, int size) + { + int margin = Math.Max(2, size / 16); + int clockSize = size - margin * 2; + Rectangle rect = new Rectangle(margin, margin, clockSize, clockSize); + + using (Brush earBrush = new SolidBrush(Color.White)) + { + g.FillPie(earBrush, 0, 0, size / 2, size / 2, 200, 50); + g.FillPie(earBrush, size / 2, 0, size / 2, size / 2, 290, 50); + } + + using (Pen pen = new Pen(Color.White, Math.Max(2, size / 16))) + { + g.DrawEllipse(pen, rect); + } + + Point center = new Point(size / 2, size / 2); + using (Pen handPen = new Pen(Color.LightGreen, Math.Max(2, size / 22))) + { + handPen.EndCap = LineCap.Round; + g.DrawLine(handPen, center, new Point(center.X + Math.Max(2, size * 10 / 64), center.Y - Math.Max(2, size * 10 / 64))); + g.DrawLine(handPen, center, new Point(center.X, center.Y - Math.Max(3, size * 18 / 64))); } } } diff --git a/MainForm.cs b/MainForm.cs index 34c159d..3c773e4 100644 --- a/MainForm.cs +++ b/MainForm.cs @@ -133,13 +133,20 @@ namespace TimerApp try { // Generate and set custom icon - Icon icon = IconGenerator.GenerateClockIcon(); + Icon icon = IconGenerator.GenerateClockIcon(64); this.Icon = icon; notifyIcon1.Icon = icon; + + contextMenuStrip1.ShowImageMargin = true; + toolStripMenuItemShow.Image = null; + toolStripMenuItemExit.Image = null; } catch { notifyIcon1.Icon = SystemIcons.Application; + contextMenuStrip1.ShowImageMargin = true; + toolStripMenuItemShow.Image = null; + toolStripMenuItemExit.Image = null; } UpdateStatusUI(); @@ -541,6 +548,11 @@ namespace TimerApp _hasShownMinimizeTip = true; } } + else + { + notifyIcon1.Visible = false; + notifyIcon1.Dispose(); + } } private void notifyIcon1_MouseDoubleClick(object sender, MouseEventArgs e) @@ -564,6 +576,7 @@ namespace TimerApp { _monitor.Stop(); notifyIcon1.Visible = false; + notifyIcon1.Dispose(); Application.Exit(); } diff --git a/Program.cs b/Program.cs index f7fe7c5..8dc5d8b 100644 --- a/Program.cs +++ b/Program.cs @@ -8,9 +8,11 @@ static class Program [STAThread] static void Main() { + TaskbarIntegration.InitializeProcess(); // To customize application configuration such as set high DPI settings or default font, // see https://aka.ms/applicationconfiguration. ApplicationConfiguration.Initialize(); + TaskbarIntegration.InitializeShortcuts(); Application.Run(new MainForm()); } -} \ No newline at end of file +} diff --git a/TaskbarIntegration.cs b/TaskbarIntegration.cs new file mode 100644 index 0000000..201759e --- /dev/null +++ b/TaskbarIntegration.cs @@ -0,0 +1,305 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace TimerApp +{ + internal static class TaskbarIntegration + { + private const string AppId = "TimerApp"; + private const string DisplayName = "TimerApp"; + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int SetCurrentProcessExplicitAppUserModelID(string AppID); + + [DllImport("shell32.dll")] + private static extern void SHChangeNotify(uint wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + + private const uint SHCNE_ASSOCCHANGED = 0x08000000; + private const uint SHCNF_IDLIST = 0x0000; + + public static void InitializeProcess() + { + TrySetAppUserModelId(); + } + + public static void InitializeShortcuts() + { + string? iconPath = TryEnsureIconFile(); + if (iconPath is null) + return; + + TryEnsureStartMenuShortcut(iconPath); + TryUpdatePinnedTaskbarShortcuts(iconPath); + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, IntPtr.Zero, IntPtr.Zero); + } + + private static void TrySetAppUserModelId() + { + try + { + SetCurrentProcessExplicitAppUserModelID(AppId); + } + catch + { + } + } + + private static string? TryEnsureIconFile() + { + try + { + string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TimerApp"); + Directory.CreateDirectory(dir); + + string iconPath = Path.Combine(dir, "TimerApp.ico"); + IconGenerator.WriteClockIco(iconPath, 256); + + return iconPath; + } + catch + { + return null; + } + } + + private static void TryEnsureStartMenuShortcut(string iconPath) + { + try + { + string exePath = Environment.ProcessPath ?? string.Empty; + if (string.IsNullOrWhiteSpace(exePath) || !File.Exists(exePath)) + return; + + string programsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), + "Programs" + ); + Directory.CreateDirectory(programsDir); + + string lnkPath = Path.Combine(programsDir, "TimerApp.lnk"); + CreateOrUpdateShortcut(lnkPath, exePath, iconPath, AppId); + } + catch + { + } + } + + private static void TryUpdatePinnedTaskbarShortcuts(string iconPath) + { + try + { + string exePath = Environment.ProcessPath ?? string.Empty; + if (string.IsNullOrWhiteSpace(exePath) || !File.Exists(exePath)) + return; + + string pinnedDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "Microsoft", + "Internet Explorer", + "Quick Launch", + "User Pinned", + "TaskBar" + ); + + if (!Directory.Exists(pinnedDir)) + return; + + foreach (string lnkPath in Directory.EnumerateFiles(pinnedDir, "*.lnk", SearchOption.TopDirectoryOnly)) + { + if (!TryGetShortcutTarget(lnkPath, out string targetPath)) + continue; + + if (!string.Equals(Path.GetFullPath(targetPath), Path.GetFullPath(exePath), StringComparison.OrdinalIgnoreCase)) + continue; + + CreateOrUpdateShortcut(lnkPath, exePath, iconPath, AppId); + } + } + catch + { + } + } + + private static bool TryGetShortcutTarget(string lnkPath, out string targetPath) + { + targetPath = string.Empty; + IShellLinkW? link = null; + try + { + link = (IShellLinkW)new ShellLink(); + ((IPersistFile)link).Load(lnkPath, 0); + + var sb = new StringBuilder(260); + link.GetPath(sb, sb.Capacity, out _, 0); + string path = sb.ToString(); + if (string.IsNullOrWhiteSpace(path)) + return false; + + targetPath = path; + return true; + } + catch + { + return false; + } + finally + { + if (link is not null) + Marshal.FinalReleaseComObject(link); + } + } + + private static void CreateOrUpdateShortcut(string lnkPath, string exePath, string iconPath, string appId) + { + IShellLinkW? link = null; + IPropertyStore? store = null; + try + { + link = (IShellLinkW)new ShellLink(); + link.SetPath(exePath); + link.SetIconLocation(iconPath, 0); + link.SetArguments(string.Empty); + link.SetWorkingDirectory(Path.GetDirectoryName(exePath) ?? string.Empty); + + store = (IPropertyStore)link; + using var pvAppId = PropVariant.FromString(appId); + using var pvRelaunchCommand = PropVariant.FromString(exePath); + using var pvRelaunchName = PropVariant.FromString(DisplayName); + using var pvRelaunchIcon = PropVariant.FromString(iconPath); + + store.SetValue(PropertyKey.PKEY_AppUserModel_ID, pvAppId); + store.SetValue(PropertyKey.PKEY_AppUserModel_RelaunchCommand, pvRelaunchCommand); + store.SetValue(PropertyKey.PKEY_AppUserModel_RelaunchDisplayNameResource, pvRelaunchName); + store.SetValue(PropertyKey.PKEY_AppUserModel_RelaunchIconResource, pvRelaunchIcon); + store.Commit(); + + ((IPersistFile)link).Save(lnkPath, true); + } + finally + { + if (store is not null) + Marshal.FinalReleaseComObject(store); + if (link is not null) + Marshal.FinalReleaseComObject(link); + } + } + + [ComImport] + [Guid("00021401-0000-0000-C000-000000000046")] + private class ShellLink + { + } + + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214F9-0000-0000-C000-000000000046")] + private interface IShellLinkW + { + void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cch, out WIN32_FIND_DATAW pfd, uint fFlags); + void GetIDList(out IntPtr ppidl); + void SetIDList(IntPtr pidl); + void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cch); + void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cch); + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cch); + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + void GetHotkey(out short pwHotkey); + void SetHotkey(short wHotkey); + void GetShowCmd(out int piShowCmd); + void SetShowCmd(int iShowCmd); + void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cch, out int piIcon); + void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved); + void Resolve(IntPtr hwnd, uint fFlags); + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); + } + + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("0000010B-0000-0000-C000-000000000046")] + private interface IPersistFile + { + void GetClassID(out Guid pClassID); + void IsDirty(); + void Load([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode); + void Save([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, [MarshalAs(UnmanagedType.Bool)] bool fRemember); + void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string pszFileName); + void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string ppszFileName); + } + + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] + private interface IPropertyStore + { + uint GetCount(out uint cProps); + uint GetAt(uint iProp, out PropertyKey pkey); + uint GetValue(ref PropertyKey key, out PropVariant pv); + uint SetValue(ref PropertyKey key, [In] PropVariant pv); + uint Commit(); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WIN32_FIND_DATAW + { + public uint dwFileAttributes; + public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime; + public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime; + public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime; + public uint nFileSizeHigh; + public uint nFileSizeLow; + public uint dwReserved0; + public uint dwReserved1; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string cFileName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] + public string cAlternateFileName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PropertyKey + { + public Guid fmtid; + public uint pid; + + public PropertyKey(Guid fmtid, uint pid) + { + this.fmtid = fmtid; + this.pid = pid; + } + + public static PropertyKey PKEY_AppUserModel_ID { get; } = new PropertyKey(new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), 5); + public static PropertyKey PKEY_AppUserModel_RelaunchCommand { get; } = new PropertyKey(new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), 2); + public static PropertyKey PKEY_AppUserModel_RelaunchIconResource { get; } = new PropertyKey(new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), 3); + public static PropertyKey PKEY_AppUserModel_RelaunchDisplayNameResource { get; } = new PropertyKey(new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), 4); + } + + [StructLayout(LayoutKind.Explicit)] + private sealed class PropVariant : IDisposable + { + [FieldOffset(0)] + private readonly ushort vt; + [FieldOffset(8)] + private readonly IntPtr pointerValue; + + private PropVariant(string value) + { + vt = 31; + pointerValue = Marshal.StringToCoTaskMemUni(value); + } + + public static PropVariant FromString(string value) => new PropVariant(value); + + public void Dispose() + { + PropVariantClear(this); + } + + [DllImport("ole32.dll")] + private static extern int PropVariantClear([In, Out] PropVariant pvar); + } + } +}