Compare commits

...

23 Commits

Author SHA1 Message Date
e597ce1e5c feat: 优化休息提醒弹窗,去除白框闪现 2026-01-21 18:05:59 +08:00
e506f41c72 fix: 修复休息弹窗时间显示跳变缺陷 2026-01-21 17:30:54 +08:00
6baa367ef5 fix: 修复软件图标显示问题 2026-01-20 21:32:28 +08:00
c406832e18 update: 更新文档 2026-01-20 14:56:58 +08:00
6e09de5316 refactor: 移除媒体检测逻辑,简化代码体积 2026-01-20 12:10:06 +08:00
cb6a4f4d2c feat: 重构媒体播放检测逻辑,使用后台STA线程持续监控并集成WinRT API。 2026-01-20 11:23:06 +08:00
37bef1ead3 fix: 修复长时间运行后的卡死问题 2026-01-19 20:56:07 +08:00
d421b9b72b feat: 优化异常处理,新增日志记录 2026-01-19 17:48:49 +08:00
0ab770d464 fix: 修复软件未完全退出问题 2026-01-19 10:08:07 +08:00
fe47293da5 feat: 处理系统恢复以重置媒体检测 2026-01-19 09:58:12 +08:00
78c07a12d0 feat: 优化第一次启动时的弹窗提醒 2026-01-18 20:55:31 +08:00
4b5609d275 fix: 精简优化代码 2026-01-18 20:41:10 +08:00
4ef611dc21 fix: 优化视频/音频检测 2026-01-18 20:33:46 +08:00
1c7d48cd7a add: 新增项目说明文件 2026-01-18 19:15:32 +08:00
b0e785bd06 feat: 添加便携模式和打包脚本,精简打包大小 2026-01-17 17:58:37 +08:00
c276e9e2b9 refactor: 项目稳定性优化 2026-01-17 17:00:15 +08:00
7abd445039 feat: 实现单实例应用程序支持 2026-01-17 16:34:18 +08:00
74ca8e4d57 fix: 修复任务栏固定和右键菜单图标不显示问题 2026-01-17 16:26:16 +08:00
50955e84c7 feat: 添加暂停和恢复功能以优化工作计时管理 2026-01-17 14:12:52 +08:00
694b40e06b feat: 添加标题按钮的悬停背景色设置以支持深浅色模式 2026-01-17 14:02:35 +08:00
052aa060cc feat: 添加媒体播放检测功能以优化工作状态管理 2026-01-17 13:53:57 +08:00
e26c015e09 fix: 优化休息时间计算逻辑,使用计数器避免时间跳变 2026-01-17 13:26:14 +08:00
ff8c7f032d fix: 界面细节优化 2026-01-17 13:19:08 +08:00
18 changed files with 1411 additions and 284 deletions

64
.gitignore vendored
View File

@@ -4,22 +4,6 @@
[Bb]in/
[Oo]bj/
[Oo]ut/
# Visual Studio 用户特定文件
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Visual Studio 缓存/选项目录
.vs/
.vscode/
# Visual Studio 代码分析结果
*.sln.iml
# 构建结果
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
@@ -30,17 +14,25 @@ x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# .NET Core
project.lock.json
project.fragment.lock.json
[Dd]ist/
artifacts/
# NuGet 包
# Visual Studio / IDE
.vs/
.vscode/
.idea/
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.sln.iml
# .NET Core / NuGet
project.lock.json
project.fragment.lock.json
*.nupkg
*.snupkg
**/packages/*
@@ -52,29 +44,14 @@ artifacts/
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# .NET Core 项目文件
project.lock.json
project.fragment.lock.json
artifacts/
# Rider
.idea/
*.sln.iml
# VS Code
.vscode/
# 用户特定文件
*.user
*.suo
*.userosscache
*.sln.docstates
# 临时文件
*.tmp
*.temp
*.log
*.cache
*.bak
*.swp
*~
# 操作系统文件
.DS_Store
@@ -82,10 +59,5 @@ Thumbs.db
ehthumbs.db
Desktop.ini
# 备份文件
*.bak
*.swp
*~
# 设置文件(如果包含敏感信息)
# settings.json

View File

@@ -1,5 +1,6 @@
using System;
using System.Windows.Forms;
using Microsoft.Win32;
namespace TimerApp
{
@@ -10,12 +11,19 @@ namespace TimerApp
Resting
}
public class ActivityMonitor
public sealed class ActivityMonitor : IDisposable
{
private System.Windows.Forms.Timer _timer;
private DateTime _lastWorkStartTime;
private CancellationTokenSource? _cts;
private readonly object _lock = new object();
private TimeSpan _accumulatedWorkTime;
private DateTime _restStartTime;
private int _restElapsedSeconds;
private bool _isPaused;
private bool _disposed;
// 状态检测缓存
private int _checkTickCounter;
private const int CheckIntervalTicks = 3; // 每3秒检查一次空闲状态
private long _cachedIdleMs;
// 配置 (默认值)
public TimeSpan WorkDuration { get; set; } = TimeSpan.FromMinutes(20);
@@ -23,41 +31,68 @@ namespace TimerApp
public TimeSpan IdleThreshold { get; set; } = TimeSpan.FromSeconds(30);
public MonitorState CurrentState { get; private set; } = MonitorState.Idle;
public bool IsPaused
{
get { lock (_lock) return _isPaused; }
private set { lock (_lock) _isPaused = value; }
}
// 事件
public event EventHandler<TimeSpan> WorkProgressChanged; // 剩余工作时间
public event EventHandler<TimeSpan> RestProgressChanged; // 剩余休息时间
public event EventHandler RestStarted;
public event EventHandler RestEnded;
public event EventHandler StateChanged;
public event EventHandler<TimeSpan>? WorkProgressChanged; // 剩余工作时间
public event EventHandler<TimeSpan>? RestProgressChanged; // 剩余休息时间
public event EventHandler? RestStarted;
public event EventHandler? RestEnded;
public event EventHandler? StateChanged;
public ActivityMonitor()
{
_timer = new System.Windows.Forms.Timer();
_timer.Interval = 1000; // 1秒检查一次
_timer.Tick += Timer_Tick;
}
public void Start()
{
_timer.Start();
lock (_lock)
{
StopInternal();
_cts = new CancellationTokenSource();
var token = _cts.Token;
// 启动时立即触发一次检测
_checkTickCounter = CheckIntervalTicks;
ResetWork();
Task.Run(() => MonitorLoop(token), token);
}
}
public void Stop()
{
_timer.Stop();
lock (_lock)
{
StopInternal();
}
}
private void StopInternal()
{
if (_cts != null)
{
_cts.Cancel();
_cts.Dispose();
_cts = null;
}
}
private void ResetWork()
{
// Must be called within lock
_accumulatedWorkTime = TimeSpan.Zero;
_lastWorkStartTime = DateTime.Now;
ChangeState(MonitorState.Idle);
}
private void ChangeState(MonitorState newState)
{
// Must be called within lock
if (CurrentState != newState)
{
CurrentState = newState;
@@ -65,18 +100,83 @@ namespace TimerApp
}
}
private void Timer_Tick(object sender, EventArgs e)
private async Task MonitorLoop(CancellationToken token)
{
long idleMs = NativeMethods.GetIdleTime();
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(1000, token);
OnTick();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.LogError("Error in ActivityMonitor MonitorLoop", ex);
}
}
}
private void OnTick()
{
// 在锁外执行耗时的系统检测
long idleMs = 0;
bool shouldUpdate = false;
lock (_lock)
{
// 如果处于暂停状态,不处理计时逻辑
if (_isPaused) return;
// 检查是否需要更新状态
_checkTickCounter++;
if (_checkTickCounter >= CheckIntervalTicks)
{
_checkTickCounter = 0;
// 需要更新,但在锁外进行
shouldUpdate = true;
}
// 如果不需要更新,直接使用缓存值
if (!shouldUpdate)
{
idleMs = _cachedIdleMs;
}
}
// 在锁外执行实际的检测
if (shouldUpdate)
{
idleMs = NativeMethods.GetIdleTime();
// 更新缓存
lock (_lock)
{
_cachedIdleMs = idleMs;
}
}
lock (_lock)
{
// 再次检查暂停状态
if (_isPaused) return;
// 使用(可能是新更新的)缓存值
idleMs = _cachedIdleMs;
TimeSpan idleTime = TimeSpan.FromMilliseconds(idleMs);
if (CurrentState == MonitorState.Resting)
{
// 休息模式逻辑
TimeSpan timeInRest = DateTime.Now - _restStartTime;
TimeSpan remainingRest = RestDuration - timeInRest;
// 使用计数器而不是时间差,避免秒数跳变
_restElapsedSeconds++;
int totalRestSeconds = (int)RestDuration.TotalSeconds;
int remainingSeconds = totalRestSeconds - _restElapsedSeconds;
if (remainingRest <= TimeSpan.Zero)
if (remainingSeconds <= 0)
{
// 休息结束
RestEnded?.Invoke(this, EventArgs.Empty);
@@ -84,26 +184,25 @@ namespace TimerApp
}
else
{
TimeSpan remainingRest = TimeSpan.FromSeconds(remainingSeconds);
RestProgressChanged?.Invoke(this, remainingRest);
}
}
else
{
// 工作/空闲模式逻辑
if (idleTime > IdleThreshold)
bool isUserInactive = idleTime > IdleThreshold;
if (isUserInactive)
{
// 用户离开了
// 用户确实离开了 -> 进入空闲状态
if (CurrentState == MonitorState.Working)
{
// 如果正在工作,但离开了,暂停工作计时?
// 简单起见,如果离开时间过长,可以视为一种“休息”,或者只是暂停累积
// 这里我们简单处理:如果空闲时间超过阈值,状态变为空闲
ChangeState(MonitorState.Idle);
}
// 如果在 Idle 状态,且空闲时间非常长(比如超过了休息时间),
// 是否应该重置工作计时器
// 假设用户去开会了1小时回来应该重新计算20分钟。
// 重置工作计时器
if (idleTime > RestDuration)
{
_accumulatedWorkTime = TimeSpan.Zero;
@@ -116,11 +215,9 @@ namespace TimerApp
{
// 从空闲变为工作
ChangeState(MonitorState.Working);
_lastWorkStartTime = DateTime.Now;
}
// 累加工作时间
// 简单的累加逻辑:这一秒是工作的
// 正常工作:累加时间
_accumulatedWorkTime += TimeSpan.FromSeconds(1);
// 检查是否达到工作时长
@@ -129,7 +226,7 @@ namespace TimerApp
if (remainingWork <= TimeSpan.Zero)
{
// 触发休息
_restStartTime = DateTime.Now;
_restElapsedSeconds = 0; // 重置休息计数器
ChangeState(MonitorState.Resting);
RestStarted?.Invoke(this, EventArgs.Empty);
}
@@ -140,16 +237,11 @@ namespace TimerApp
}
}
}
// 用于强制重置或测试
public void ForceRest()
{
_restStartTime = DateTime.Now;
ChangeState(MonitorState.Resting);
RestStarted?.Invoke(this, EventArgs.Empty);
}
public void RefreshStatus()
{
lock (_lock)
{
if (CurrentState == MonitorState.Working)
{
@@ -158,19 +250,21 @@ namespace TimerApp
}
else if (CurrentState == MonitorState.Resting)
{
TimeSpan timeInRest = DateTime.Now - _restStartTime;
TimeSpan remaining = RestDuration - timeInRest;
int totalRestSeconds = (int)RestDuration.TotalSeconds;
int remainingSeconds = totalRestSeconds - _restElapsedSeconds;
if (remainingSeconds < 0) remainingSeconds = 0;
TimeSpan remaining = TimeSpan.FromSeconds(remainingSeconds);
RestProgressChanged?.Invoke(this, remaining);
}
}
}
public void Restart()
{
lock (_lock)
{
_accumulatedWorkTime = TimeSpan.Zero;
_lastWorkStartTime = DateTime.Now;
// Ensure timer is running
if (!_timer.Enabled) _timer.Start();
_isPaused = false;
// Force state to Working since user manually restarted
ChangeState(MonitorState.Working);
@@ -179,4 +273,41 @@ namespace TimerApp
RefreshStatus();
}
}
public void Pause()
{
lock (_lock)
{
if (!_isPaused && CurrentState != MonitorState.Idle)
{
_isPaused = true;
StateChanged?.Invoke(this, EventArgs.Empty);
}
}
}
public void Resume()
{
lock (_lock)
{
if (_isPaused)
{
_isPaused = false;
StateChanged?.Invoke(this, EventArgs.Empty);
RefreshStatus();
}
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// SystemEvents.PowerModeChanged -= OnPowerModeChanged;
Stop();
GC.SuppressFinalize(this);
}
}
}

View File

@@ -10,17 +10,39 @@ namespace TimerApp
public int RestMinutes { get; set; } = 1;
public int IdleThresholdSeconds { get; set; } = 30;
public bool IsDarkMode { get; set; } = true;
public bool HasShownMinimizeTip { get; set; } = false;
private static string ConfigPath => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json");
private static string LegacyConfigPath => Path.Combine(AppContext.BaseDirectory, "settings.json");
private static string ConfigPath
{
get
{
string dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TimerApp");
return Path.Combine(dir, "settings.json");
}
}
private static string EffectiveConfigPath => PortableMode.IsPortable ? LegacyConfigPath : ConfigPath;
public static AppSettings Load()
{
try
{
if (File.Exists(ConfigPath))
string path;
if (PortableMode.IsPortable)
{
string json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<AppSettings>(json);
path = LegacyConfigPath;
}
else
{
path = File.Exists(ConfigPath) ? ConfigPath : LegacyConfigPath;
}
if (File.Exists(path))
{
string json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
}
catch
@@ -34,8 +56,12 @@ namespace TimerApp
{
try
{
string path = EffectiveConfigPath;
string? dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir))
Directory.CreateDirectory(dir);
string json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigPath, json);
File.WriteAllText(path, json);
}
catch
{

View File

@@ -1,66 +1,149 @@
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;
DrawClock(g, size);
IntPtr hIcon = bmp.GetHicon();
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 (Pen pen = new Pen(Color.White, 4))
{
g.DrawEllipse(pen, rect);
}
// 定义轮廓颜色和宽度
Color outlineColor = Color.FromArgb(64, 64, 64);
float mainWidth = Math.Max(2, size / 16f);
float outlineWidth = mainWidth + Math.Max(1, size / 32f);
float handWidth = Math.Max(2, size / 22f);
float handOutlineWidth = handWidth + Math.Max(1, size / 32f);
// 两个耳朵 (闹钟铃)
// 1. 绘制耳朵 (先画轮廓)
using (Pen outlinePen = new Pen(outlineColor, Math.Max(1, size / 32f)))
using (Brush earBrush = new SolidBrush(Color.White))
{
// 左耳
g.FillPie(earBrush, 0, 0, size/2, size/2, 200, 50);
g.DrawPie(outlinePen, 0, 0, size / 2, size / 2, 200, 50);
g.FillPie(earBrush, 0, 0, size / 2, size / 2, 200, 50);
// 右耳
g.FillPie(earBrush, size/2, 0, size/2, size/2, 290, 50);
g.DrawPie(outlinePen, size / 2, 0, size / 2, size / 2, 290, 50);
g.FillPie(earBrush, size / 2, 0, size / 2, size / 2, 290, 50);
}
// 重绘外圈盖住耳朵连接处
using (Brush bgBrush = new SolidBrush(Color.FromArgb(30, 30, 30))) // 深色背景
// 2. 绘制表盘外圈 (先画轮廓)
using (Pen outlinePen = new Pen(outlineColor, outlineWidth))
using (Pen pen = new Pen(Color.White, mainWidth))
using (Brush bgBrush = new SolidBrush(Color.Black))
{
// 填充表盘背景,确保浅色模式下也能看清
g.FillEllipse(bgBrush, rect);
}
using (Pen pen = new Pen(Color.White, 3))
{
g.DrawEllipse(outlinePen, rect);
g.DrawEllipse(pen, rect);
}
// 指针
// 3. 绘制指针 (先画轮廓)
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));
}
Point handEnd1 = new Point(center.X + Math.Max(2, size * 10 / 64), center.Y - Math.Max(2, size * 10 / 64));
Point handEnd2 = new Point(center.X, center.Y - Math.Max(3, size * 18 / 64));
// 转换为图标
// 注意GetHicon 需要释放
IntPtr hIcon = bmp.GetHicon();
return Icon.FromHandle(hIcon);
using (Pen outlinePen = new Pen(outlineColor, handOutlineWidth))
using (Pen handPen = new Pen(Color.LightGreen, handWidth))
{
outlinePen.EndCap = LineCap.Round;
outlinePen.StartCap = LineCap.Round;
handPen.EndCap = LineCap.Round;
handPen.StartCap = LineCap.Round;
// 指针1
g.DrawLine(outlinePen, center, handEnd1);
g.DrawLine(handPen, center, handEnd1);
// 指针2
g.DrawLine(outlinePen, center, handEnd2);
g.DrawLine(handPen, center, handEnd2);
}
}
}

36
Logger.cs Normal file
View File

@@ -0,0 +1,36 @@
using System;
using System.IO;
using System.Text;
namespace TimerApp
{
public static class Logger
{
private static readonly string LogFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "error.log");
private static readonly object LockObj = new object();
public static void LogError(string message, Exception? ex = null)
{
try
{
lock (LockObj)
{
var sb = new StringBuilder();
sb.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] ERROR: {message}");
if (ex != null)
{
sb.AppendLine($"Exception: {ex.Message}");
sb.AppendLine($"StackTrace: {ex.StackTrace}");
}
sb.AppendLine(new string('-', 50));
File.AppendAllText(LogFile, sb.ToString());
}
}
catch
{
// Failed to log, nothing we can do
}
}
}
}

43
MainForm.Designer.cs generated
View File

@@ -13,9 +13,11 @@ namespace TimerApp
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
if (disposing)
{
components.Dispose();
_monitor?.Dispose();
_restForm?.Dispose();
components?.Dispose();
}
base.Dispose(disposing);
}
@@ -41,6 +43,7 @@ namespace TimerApp
this.btnStartStop = new System.Windows.Forms.Button();
this.btnReset = new System.Windows.Forms.Button();
this.btnPause = new System.Windows.Forms.Button();
this.lblStatus = new System.Windows.Forms.Label();
this.lblTimeLeft = new System.Windows.Forms.Label();
this.notifyIcon1 = new System.Windows.Forms.NotifyIcon(this.components);
@@ -169,6 +172,7 @@ namespace TimerApp
// pnlSettings
//
this.pnlSettings.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(30)))), ((int)(((byte)(30)))), ((int)(((byte)(30)))));
this.pnlSettings.Controls.Add(this.btnPause);
this.pnlSettings.Controls.Add(this.btnReset);
this.pnlSettings.Controls.Add(this.btnStartStop);
this.pnlSettings.Controls.Add(this.btnRestPlus);
@@ -190,9 +194,9 @@ namespace TimerApp
//
this.label1.AutoSize = true;
this.label1.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(30)))), ((int)(((byte)(30)))), ((int)(((byte)(30)))));
this.label1.Font = new System.Drawing.Font("Microsoft YaHei UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.label1.Font = new System.Drawing.Font("Microsoft YaHei UI", 10F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.label1.ForeColor = System.Drawing.Color.LightGray;
this.label1.Location = new System.Drawing.Point(40, 20);
this.label1.Location = new System.Drawing.Point(40, 22);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(100, 23);
this.label1.TabIndex = 0;
@@ -246,9 +250,9 @@ namespace TimerApp
//
this.label2.AutoSize = true;
this.label2.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(30)))), ((int)(((byte)(30)))), ((int)(((byte)(30)))));
this.label2.Font = new System.Drawing.Font("Microsoft YaHei UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.label2.Font = new System.Drawing.Font("Microsoft YaHei UI", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.label2.ForeColor = System.Drawing.Color.LightGray;
this.label2.Location = new System.Drawing.Point(40, 60);
this.label2.Location = new System.Drawing.Point(40, 62);
this.label2.Name = "label2";
this.label2.Size = new System.Drawing.Size(100, 23);
this.label2.TabIndex = 4;
@@ -305,11 +309,11 @@ namespace TimerApp
this.btnStartStop.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnStartStop.Font = new System.Drawing.Font("Microsoft YaHei UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.btnStartStop.ForeColor = System.Drawing.Color.White;
this.btnStartStop.Location = new System.Drawing.Point(170, 110);
this.btnStartStop.Location = new System.Drawing.Point(210, 110);
this.btnStartStop.Name = "btnStartStop";
this.btnStartStop.Size = new System.Drawing.Size(110, 35);
this.btnStartStop.Size = new System.Drawing.Size(70, 35);
this.btnStartStop.TabIndex = 4;
this.btnStartStop.Text = "应用设置";
this.btnStartStop.Text = "应用";
this.btnStartStop.UseVisualStyleBackColor = false;
this.btnStartStop.Click += new System.EventHandler(this.btnStartStop_Click);
@@ -323,12 +327,28 @@ namespace TimerApp
this.btnReset.ForeColor = System.Drawing.Color.White;
this.btnReset.Location = new System.Drawing.Point(40, 110);
this.btnReset.Name = "btnReset";
this.btnReset.Size = new System.Drawing.Size(110, 35);
this.btnReset.Size = new System.Drawing.Size(70, 35);
this.btnReset.TabIndex = 8;
this.btnReset.Text = "重置计时";
this.btnReset.Text = "重置";
this.btnReset.UseVisualStyleBackColor = false;
this.btnReset.Click += new System.EventHandler(this.btnReset_Click);
//
// btnPause
//
this.btnPause.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(63)))), ((int)(((byte)(63)))), ((int)(((byte)(70)))));
this.btnPause.FlatAppearance.BorderSize = 0;
this.btnPause.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnPause.Font = new System.Drawing.Font("Microsoft YaHei UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.btnPause.ForeColor = System.Drawing.Color.White;
this.btnPause.Location = new System.Drawing.Point(125, 110);
this.btnPause.Name = "btnPause";
this.btnPause.Size = new System.Drawing.Size(70, 35);
this.btnPause.TabIndex = 9;
this.btnPause.Text = "暂停";
this.btnPause.UseVisualStyleBackColor = false;
this.btnPause.Click += new System.EventHandler(this.btnPause_Click);
//
// btnHide
//
@@ -419,6 +439,7 @@ namespace TimerApp
private System.Windows.Forms.Button btnRestPlus;
private System.Windows.Forms.Button btnStartStop;
private System.Windows.Forms.Button btnReset;
private System.Windows.Forms.Button btnPause;
private System.Windows.Forms.Label lblStatus;
private System.Windows.Forms.Label lblTimeLeft;
private System.Windows.Forms.NotifyIcon notifyIcon1;

View File

@@ -2,14 +2,15 @@ using System;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using Microsoft.Win32;
namespace TimerApp
{
public partial class MainForm : Form
{
private ActivityMonitor _monitor;
private ActivityMonitor _monitor = null!;
private AppSettings _settings;
private RestForm _restForm;
private RestForm? _restForm;
// Colors
private Color _darkBg = Color.FromArgb(30, 30, 30);
@@ -24,10 +25,6 @@ namespace TimerApp
public static extern bool ReleaseCapture();
[DllImport("user32.dll")]
public static extern int SendMessage(IntPtr hWnd, int Msg, int wParam, int lParam);
[DllImport("user32.dll")]
static extern bool CreateCaret(IntPtr hWnd, IntPtr hBitmap, int nWidth, int nHeight);
[DllImport("user32.dll")]
static extern bool ShowCaret(IntPtr hWnd);
private const int WM_NCLBUTTONDOWN = 0xA1;
private const int HT_CAPTION = 0x2;
@@ -61,6 +58,7 @@ namespace TimerApp
btnRestPlus.BackColor = panelColor;
btnStartStop.BackColor = dark ? Color.FromArgb(63, 63, 70) : Color.White;
btnReset.BackColor = dark ? Color.FromArgb(63, 63, 70) : Color.White;
btnPause.BackColor = dark ? Color.FromArgb(63, 63, 70) : Color.White;
// 优化绘制,减少闪烁
this.SetStyle(ControlStyles.AllPaintingInWmPaint |
@@ -105,8 +103,8 @@ namespace TimerApp
// Manual input validation
txtWork.KeyPress += ValidateDigitInput;
txtRest.KeyPress += ValidateDigitInput;
txtWork.Leave += (s, ev) => ValidateRange((TextBox)s, 1, 120);
txtRest.Leave += (s, ev) => ValidateRange((TextBox)s, 1, 30);
txtWork.Leave += (s, ev) => ValidateRange((TextBox)s!, 1, 120);
txtRest.Leave += (s, ev) => ValidateRange((TextBox)s!, 1, 30);
// Focus handling (remove custom caret)
txtWork.KeyDown += TextBox_KeyDown;
@@ -132,13 +130,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();
@@ -155,7 +160,7 @@ namespace TimerApp
}
}
private void ValidateDigitInput(object sender, KeyPressEventArgs e)
private void ValidateDigitInput(object? sender, KeyPressEventArgs e)
{
// Allow control keys (backspace, etc.) and digits
if (!char.IsControl(e.KeyChar) && !char.IsDigit(e.KeyChar))
@@ -208,7 +213,7 @@ namespace TimerApp
// CustomCaret removed to use system caret with centered text
private void TextBox_KeyDown(object sender, KeyEventArgs e)
private void TextBox_KeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
{
@@ -256,6 +261,7 @@ namespace TimerApp
// Update buttons
UpdateButtonStyle(btnStartStop, dark);
UpdateButtonStyle(btnReset, dark);
UpdateButtonStyle(btnPause, dark);
UpdateButtonStyle(btnHide, dark);
// Numeric buttons and text
@@ -270,6 +276,22 @@ namespace TimerApp
btnClose.ForeColor = text;
btnMin.ForeColor = text;
// Set hover background colors for title buttons
if (dark)
{
// 深色模式下使用深色悬停背景
btnClose.FlatAppearance.MouseOverBackColor = Color.FromArgb(232, 17, 35); // 深色模式下的红色(稍微暗一点)
btnMin.FlatAppearance.MouseOverBackColor = Color.FromArgb(63, 63, 70);
btnTheme.FlatAppearance.MouseOverBackColor = Color.FromArgb(63, 63, 70);
}
else
{
// 浅色模式下使用浅色悬停背景
btnClose.FlatAppearance.MouseOverBackColor = Color.Red;
btnMin.FlatAppearance.MouseOverBackColor = Color.FromArgb(220, 220, 220); // 浅灰色,接近标题栏背景
btnTheme.FlatAppearance.MouseOverBackColor = Color.FromArgb(220, 220, 220); // 浅灰色,接近标题栏背景
}
// Theme button with Segoe MDL2 Assets
btnTheme.ForeColor = text;
btnTheme.Font = new Font("Segoe MDL2 Assets", 10F, FontStyle.Regular, GraphicsUnit.Point);
@@ -356,6 +378,20 @@ namespace TimerApp
_monitor.Restart();
}
private void btnPause_Click(object sender, EventArgs e)
{
if (_monitor.IsPaused)
{
_monitor.Resume();
btnPause.Text = "暂停";
}
else
{
_monitor.Pause();
btnPause.Text = "恢复";
}
}
private void ApplySettings()
{
int workMin = 20;
@@ -368,11 +404,11 @@ namespace TimerApp
_monitor.IdleThreshold = TimeSpan.FromSeconds(_settings.IdleThresholdSeconds);
}
private void Monitor_StateChanged(object sender, EventArgs e)
private void Monitor_StateChanged(object? sender, EventArgs e)
{
if (InvokeRequired)
{
Invoke(new Action<object, EventArgs>(Monitor_StateChanged), sender, e);
BeginInvoke(new Action<object?, EventArgs>(Monitor_StateChanged), sender, e);
return;
}
UpdateStatusUI();
@@ -384,6 +420,18 @@ namespace TimerApp
bool dark = _settings.IsDarkMode;
// 更新暂停按钮状态
if (_monitor.CurrentState == MonitorState.Idle)
{
btnPause.Enabled = false;
btnPause.Text = "暂停";
}
else
{
btnPause.Enabled = true;
btnPause.Text = _monitor.IsPaused ? "恢复" : "暂停";
}
switch (_monitor.CurrentState)
{
case MonitorState.Idle:
@@ -393,43 +441,49 @@ namespace TimerApp
lblTimeLeft.ForeColor = Color.Gray;
break;
case MonitorState.Working:
lblStatus.Text = "状态: 工作中";
lblStatus.Text = _monitor.IsPaused ? "状态: 工作中 (已暂停)" : "状态: 工作中";
lblStatus.ForeColor = dark ? Color.LightGreen : Color.Green;
lblTimeLeft.ForeColor = dark ? Color.White : Color.Black;
break;
case MonitorState.Resting:
lblStatus.Text = "状态: 休息中";
lblStatus.Text = _monitor.IsPaused ? "状态: 休息中 (已暂停)" : "状态: 休息中";
lblStatus.ForeColor = dark ? Color.LightSkyBlue : Color.Blue;
lblTimeLeft.ForeColor = dark ? Color.LightSkyBlue : Color.Blue;
break;
}
}
private void Monitor_WorkProgressChanged(object sender, TimeSpan remaining)
private void Monitor_WorkProgressChanged(object? sender, TimeSpan remaining)
{
if (InvokeRequired)
{
Invoke(new Action<object, TimeSpan>(Monitor_WorkProgressChanged), sender, remaining);
BeginInvoke(new Action<object?, TimeSpan>(Monitor_WorkProgressChanged), sender, remaining);
return;
}
lblTimeLeft.Text = $"{remaining.Minutes:D2}:{remaining.Seconds:D2}";
// Update tray tooltip
string newText;
if (remaining.TotalMinutes < 1)
{
notifyIcon1.Text = $"即将休息: {remaining.Seconds}秒";
newText = $"即将休息: {remaining.Seconds}秒";
}
else
{
notifyIcon1.Text = $"工作中: 剩余 {remaining.Minutes} 分钟";
newText = $"工作中: 剩余 {remaining.Minutes} 分钟";
}
if (notifyIcon1.Text != newText)
{
notifyIcon1.Text = newText;
}
}
private void Monitor_RestStarted(object sender, EventArgs e)
private void Monitor_RestStarted(object? sender, EventArgs e)
{
if (InvokeRequired)
{
Invoke(new Action<object, EventArgs>(Monitor_RestStarted), sender, e);
BeginInvoke(new Action<object?, EventArgs>(Monitor_RestStarted), sender, e);
return;
}
@@ -440,28 +494,36 @@ namespace TimerApp
_restForm.SkipRequested += RestForm_SkipRequested;
}
// 初始化显示时间,避免显示默认值
_restForm.UpdateTime(_monitor.RestDuration);
_restForm.Show();
}
private void RestForm_SkipRequested(object sender, EventArgs e)
private void RestForm_SkipRequested(object? sender, EventArgs e)
{
_monitor.Stop();
_monitor.Start();
}
private void Monitor_RestProgressChanged(object sender, TimeSpan remaining)
private void Monitor_RestProgressChanged(object? sender, TimeSpan remaining)
{
if (InvokeRequired)
{
BeginInvoke(new Action<object?, TimeSpan>(Monitor_RestProgressChanged), sender, remaining);
return;
}
if (_restForm != null && !_restForm.IsDisposed && _restForm.Visible)
{
_restForm.UpdateTime(remaining);
}
}
private void Monitor_RestEnded(object sender, EventArgs e)
private void Monitor_RestEnded(object? sender, EventArgs e)
{
if (InvokeRequired)
{
Invoke(new Action<object, EventArgs>(Monitor_RestEnded), sender, e);
BeginInvoke(new Action<object?, EventArgs>(Monitor_RestEnded), sender, e);
return;
}
@@ -473,15 +535,14 @@ namespace TimerApp
notifyIcon1.ShowBalloonTip(3000, "休息结束", "继续加油工作吧!", ToolTipIcon.Info);
}
private bool _hasShownMinimizeTip = false;
private void btnHide_Click(object sender, EventArgs e)
{
this.Hide();
if (!_hasShownMinimizeTip)
if (!_settings.HasShownMinimizeTip)
{
notifyIcon1.ShowBalloonTip(2000, "已隐藏", "程序仍在后台运行,双击托盘图标恢复。", ToolTipIcon.Info);
_hasShownMinimizeTip = true;
_settings.HasShownMinimizeTip = true;
_settings.Save();
}
}
@@ -491,36 +552,68 @@ namespace TimerApp
{
e.Cancel = true;
this.Hide();
if (!_hasShownMinimizeTip)
if (!_settings.HasShownMinimizeTip)
{
notifyIcon1.ShowBalloonTip(2000, "已隐藏", "程序仍在后台运行,双击托盘图标恢复。", ToolTipIcon.Info);
_hasShownMinimizeTip = true;
_settings.HasShownMinimizeTip = true;
_settings.Save();
}
}
else
{
notifyIcon1.Visible = false;
notifyIcon1.Dispose();
}
}
private void notifyIcon1_MouseDoubleClick(object sender, MouseEventArgs e)
private void notifyIcon1_MouseDoubleClick(object? sender, MouseEventArgs e)
{
ShowForm();
}
private void toolStripMenuItemShow_Click(object sender, EventArgs e)
private void toolStripMenuItemShow_Click(object? sender, EventArgs e)
{
ShowForm();
}
private void ShowForm()
{
if (this.IsDisposed) return;
this.Show();
if (this.WindowState == FormWindowState.Minimized)
{
this.WindowState = FormWindowState.Normal;
}
this.Activate();
this.BringToFront();
}
public void ActivateFromExternal()
{
if (InvokeRequired)
{
BeginInvoke(new Action(ActivateFromExternal));
return;
}
this.Show();
this.WindowState = FormWindowState.Normal;
this.ShowInTaskbar = true;
this.BringToFront();
this.Activate();
this.TopMost = true;
this.TopMost = false;
}
private void toolStripMenuItemExit_Click(object sender, EventArgs e)
{
_monitor.Stop();
_monitor.Dispose();
notifyIcon1.Visible = false;
notifyIcon1.Dispose();
Application.Exit();
Environment.Exit(0);
}
private void pnlTitle_MouseDown(object sender, MouseEventArgs e)

View File

@@ -1,5 +1,7 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace TimerApp
{
@@ -19,34 +21,37 @@ namespace TimerApp
[DllImport("user32.dll")]
static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
[DllImport("kernel32.dll")]
static extern uint GetTickCount();
/// <summary>
/// 获取系统空闲时间(毫秒)
/// </summary>
/// <returns></returns>
public static long GetIdleTime()
{
LASTINPUTINFO lastInputInfo = new LASTINPUTINFO();
lastInputInfo.cbSize = (uint)Marshal.SizeOf(lastInputInfo);
lastInputInfo.dwTime = 0;
LASTINPUTINFO lastInputInfo = new LASTINPUTINFO
{
cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>(),
dwTime = 0
};
if (GetLastInputInfo(ref lastInputInfo))
{
// Environment.TickCount 可能会在大约 24.9 天后翻转为负数,
// 但 GetLastInputInfo 返回的也是 uint (DWORD),所以我们统一转为 long 处理差值
// 或者直接使用 unchecked 减法处理溢出
// 更稳健的做法是使用 GetTickCount64 (Vista+),但 Environment.TickCount 在 .NET Core 3.1+ 已经是 64位了(Environment.TickCount64)
// 这里为了兼容性,我们简单处理。注意 GetLastInputInfo 返回的是 uint 毫秒数。
long envTicks = Environment.TickCount;
// 处理 TickCount 翻转问题 (Environment.TickCount 是 intGetLastInputInfo 是 uint)
// 简单的做法:
return (long)GetTickCount() - (long)lastInputInfo.dwTime;
uint tickCount = GetTickCount();
uint idleMs = unchecked(tickCount - lastInputInfo.dwTime);
return idleMs;
}
return 0;
}
[DllImport("kernel32.dll")]
static extern uint GetTickCount();
static NativeMethods()
{
}
public static void Shutdown()
{
}
}
}

46
PortableMode.cs Normal file
View File

@@ -0,0 +1,46 @@
using System;
namespace TimerApp
{
internal static class PortableMode
{
private const string PortableEnvVarName = "TIMERAPP_PORTABLE";
public static bool IsPortable
{
get
{
try
{
string? env = Environment.GetEnvironmentVariable(PortableEnvVarName);
if (!string.IsNullOrWhiteSpace(env))
{
if (string.Equals(env, "0", StringComparison.OrdinalIgnoreCase) ||
string.Equals(env, "false", StringComparison.OrdinalIgnoreCase) ||
string.Equals(env, "no", StringComparison.OrdinalIgnoreCase) ||
string.Equals(env, "off", StringComparison.OrdinalIgnoreCase) ||
string.Equals(env, "disable", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.Equals(env, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(env, "true", StringComparison.OrdinalIgnoreCase) ||
string.Equals(env, "yes", StringComparison.OrdinalIgnoreCase) ||
string.Equals(env, "on", StringComparison.OrdinalIgnoreCase) ||
string.Equals(env, "enable", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return true;
}
catch
{
return true;
}
}
}
}
}

View File

@@ -8,9 +8,48 @@ static class Program
[STAThread]
static void Main()
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
// 设置全局异常处理
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
Application.ThreadException += Application_ThreadException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
if (!SingleInstanceManager.TryAcquire(out var instance) || instance is null)
{
SingleInstanceManager.SignalExistingInstance();
return;
}
try
{
using (instance)
{
TaskbarIntegration.InitializeProcess();
ApplicationConfiguration.Initialize();
TaskbarIntegration.InitializeShortcuts();
var mainForm = new MainForm();
instance.StartServer(mainForm.ActivateFromExternal);
Application.Run(mainForm);
}
}
catch (Exception ex)
{
Logger.LogError("Fatal error in Main", ex);
MessageBox.Show($"程序发生严重错误即将退出:\n{ex.Message}", "TimerApp Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private static void Application_ThreadException(object sender, System.Threading.ThreadExceptionEventArgs e)
{
Logger.LogError("Unhandled UI Exception", e.Exception);
// 这里可以选择不退出,或者提示用户
// MessageBox.Show("发生未知错误,程序将尝试继续运行。", "错误", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
var ex = e.ExceptionObject as Exception;
Logger.LogError("Unhandled Domain Exception" + (e.IsTerminating ? " (Terminating)" : ""), ex);
}
}

BIN
Properties/app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

109
README.md Normal file
View File

@@ -0,0 +1,109 @@
# TimerApp
一个常驻后台的“工作/休息”计时提醒工具,核心目标是把休息从“靠自觉”变成“靠节奏”:工作一段时间后进入休息提示,休息结束再回到下一轮工作。
## 产品定位
- 适用人群:需要规律休息/护眼提醒的人,或希望把“休息”纳入工作节奏的人
- 目标:减少连续久坐、避免长时间盯屏,通过强提示把休息真正落地
- 使用方式:后台常驻 + 托盘入口,默认不打扰,只有到点进入休息时才强提醒
## 核心概念
- 一个周期:工作一段时间 → 休息一段时间 → 开始下一轮
- 三种状态:
- 空闲:系统判断你没有在“有效工作”,本轮工作时长不推进
- 工作:系统判断你在“有效工作”,本轮工作时长持续推进
- 休息:工作时长达到阈值后进入的强提醒阶段,休息倒计时结束后回到下一轮
## 核心逻辑(状态机)
- 初始进入空闲:避免一启动就强行把你当作“正在工作”
- 空闲 → 工作:当检测到你处于活动状态,开始累积本轮工作时长
- 工作 → 空闲:当检测到你长时间未操作,暂停本轮工作推进
- 工作 → 休息:当本轮工作时长累计达到设定值,进入休息提醒并开始倒计时
- 休息 → 空闲:休息倒计时结束,提示“休息结束”,回到空闲等待下一次有效活动
## 计时规则(核心策略)
- “工作时长”不是自然时间,而是“有效工作时间”
- 你在活动状态时才累积
- 你离开电脑/长时间不操作时不会累积
- 空闲时间很长时会重置本轮进度
- 设计目的:避免“离开一小时回来只剩 1 分钟就休息”的反直觉体验
- 直观理解:离开足够久就视为已经中断了一轮工作,回来从新的一轮开始更合理
- 休息倒计时以“秒级稳定推进”为目标
- 设计目的:让休息提示的倒计时更平滑、更可预期
## 休息提醒(强提示的边界)
- 休息阶段会出现遮罩式提醒,核心是“让你意识到现在该休息了”
- 休息阶段提供“跳过”
- 设计目的:保证强提示不与真实工作紧急程度冲突,必要时可快速回到工作
- 休息结束会给出结束提示,帮助你在不打开主界面的情况下也能知道已进入下一轮
## 使用说明(产品层)
- 设置工作/休息时长:决定每轮节奏的长度
- 暂停/恢复:临时不想被计时推进或进入休息时使用
- 重置:放弃当前进度,从新一轮开始
- 隐藏到托盘:把窗口收起但继续运行(避免误关)
- 退出:真正结束应用与提醒(需要显式操作)
## 常见场景
- 你在电脑前持续工作:按设定节奏进入休息提醒
- 你离开电脑一会儿再回来:这段时间不算作有效工作,避免“人在不在都算工作”
- 你刚好在关键时刻被提醒:可以跳过本次休息,稍后再通过节奏回到下一轮
## 运行逻辑(从用户视角)
- 启动后读取上次的时长与偏好,进入后台常驻
- 当检测到用户处于活动状态时,进入“工作”并开始累积工作时长
- 当检测到用户长时间未操作时,进入“空闲”,并按规则暂停/重置工作进度,避免把不在工作的时间算进“工作时长”
- 当工作时长达到阈值时,进入“休息”,弹出遮罩式提醒并倒计时
- 休息倒计时结束后,关闭提醒并提示“休息结束”,回到下一轮
## 配置与便携模式
- **配置文件策略**
- 优先读取 `LocalAppData\TimerApp\settings.json`
- 若不存在,尝试读取程序运行目录下的 `settings.json`(兼容便携模式)
- **便携模式 (Portable Mode)**
- **默认开启**:程序会在运行目录下读写 `settings.json`,且**不会**自动创建开始菜单/任务栏快捷方式。
- **关闭方式**:设置环境变量 `TIMERAPP_PORTABLE=0` (或 `false`/`off`/`disable`)。
- 关闭后,配置将强制存储在 `LocalAppData`
- 启动时会自动尝试修复/创建开始菜单与任务栏快捷方式(包含 AppUserModelID 支持,解决任务栏图标重叠问题)。
## 交互与体验取舍
- 托盘常驻:关闭窗口不等于退出,避免误关导致节奏中断;需要显式“退出”才结束进程
- 手动控制:支持调整工作/休息时长、暂停/恢复、重置一轮,用于临时打断或重新开始
- 休息阶段可跳过:允许用户在必要场景下结束本次休息并立刻回到下一轮
## 系统要求
- Windows 10 / 11
- .NET Desktop Runtime 9.0 (若使用 Framework-dependent 发布版本)
## 编译与发布
- 开发环境要求
- WindowsWinForms 桌面程序)
- .NET SDK 9项目目标框架net9.0-windows
- 本地编译
- Debug
- `dotnet build`
- Release
- `dotnet build -c Release`
- 本地运行
- `dotnet run`
- 便携版打包(生成 dist zip
- 推荐使用 PowerShell 脚本一键发布:
- `.\scripts\publish-portable.ps1`
- **生成独立版 (Self-contained)**:目标机无需安装 .NET Runtime但体积较大
- `.\scripts\publish-portable.ps1 -SelfContained $true`
- **指定架构**(如 x64
- `.\scripts\publish-portable.ps1 -Rid win-x64`
- **开启裁剪 (Trim)**(实验性):
- `.\scripts\publish-portable.ps1 -Trim $true`

View File

@@ -6,11 +6,23 @@ namespace TimerApp
{
public class RestForm : Form
{
private Label lblMessage;
private Label lblTimer;
private Button btnSkip;
private Label lblMessage = null!;
private Label lblTimer = null!;
private Button btnSkip = null!;
public event EventHandler SkipRequested;
public event EventHandler? SkipRequested;
protected override CreateParams CreateParams
{
get
{
CreateParams cp = base.CreateParams;
// Turn on WS_EX_TOOLWINDOW style bit (0x80)
// 这可以防止窗口出现在 Alt-Tab 列表中,并减少任务栏闪烁风险
cp.ExStyle |= 0x80;
return cp;
}
}
public RestForm()
{
@@ -29,10 +41,11 @@ namespace TimerApp
// Form 设置
//
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.None;
this.WindowState = FormWindowState.Maximized;
this.StartPosition = FormStartPosition.Manual;
this.Location = new System.Drawing.Point(-32000, -32000); // 初始位置在屏幕外
this.TopMost = true;
this.BackColor = System.Drawing.Color.Black;
this.Opacity = 0.90; // 90% 不透明度
this.Opacity = 0; // 初始隐藏,避免闪烁
this.ShowInTaskbar = false;
this.Name = "RestForm";
this.Text = "Rest Now";
@@ -63,7 +76,7 @@ namespace TimerApp
// 初始大小会在CenterControls中动态调整
this.lblTimer.Size = new System.Drawing.Size(400, 180);
this.lblTimer.TabIndex = 1;
this.lblTimer.Text = "01:00";
this.lblTimer.Text = "--:--";
this.lblTimer.TextAlign = ContentAlignment.MiddleCenter;
//
@@ -99,12 +112,34 @@ namespace TimerApp
this.PerformLayout();
}
private void RestForm_Load(object sender, EventArgs e)
private void RestForm_Load(object? sender, EventArgs e)
{
CenterControls();
}
private void RestForm_Resize(object sender, EventArgs e)
protected override void OnVisibleChanged(EventArgs e)
{
base.OnVisibleChanged(e);
if (this.Visible)
{
// 移回屏幕并最大化
this.Location = new Point(0, 0);
this.WindowState = FormWindowState.Maximized;
this.Refresh();
// 延迟显示,确保布局调整完成
System.Windows.Forms.Timer t = new System.Windows.Forms.Timer();
t.Interval = 50;
t.Tick += (s, ev) => {
t.Stop();
t.Dispose();
this.Opacity = 0.90;
};
t.Start();
}
}
private void RestForm_Resize(object? sender, EventArgs e)
{
CenterControls();
}
@@ -143,7 +178,7 @@ namespace TimerApp
{
if (lblTimer.InvokeRequired)
{
lblTimer.Invoke(new Action<TimeSpan>(UpdateTime), remaining);
lblTimer.BeginInvoke(new Action<TimeSpan>(UpdateTime), remaining);
}
else
{
@@ -153,7 +188,7 @@ namespace TimerApp
}
}
private void btnSkip_Click(object sender, EventArgs e)
private void btnSkip_Click(object? sender, EventArgs e)
{
SkipRequested?.Invoke(this, EventArgs.Empty);
this.Close();

126
SingleInstanceManager.cs Normal file
View File

@@ -0,0 +1,126 @@
using System;
using System.IO;
using System.IO.Pipes;
using System.Threading;
using System.Threading.Tasks;
namespace TimerApp
{
internal sealed class SingleInstanceManager : IDisposable
{
private const string MutexName = "Local\\TimerApp.SingleInstance";
private const string PipeName = "TimerApp.SingleInstancePipe";
private readonly Mutex? _mutex;
private readonly bool _ownsMutex;
private readonly bool _enableIpc;
private readonly CancellationTokenSource _cts = new();
private SingleInstanceManager(Mutex? mutex, bool ownsMutex, bool enableIpc)
{
_mutex = mutex;
_ownsMutex = ownsMutex;
_enableIpc = enableIpc;
}
public static bool TryAcquire(out SingleInstanceManager? manager)
{
manager = null;
try
{
var mutex = new Mutex(true, MutexName, out bool createdNew);
if (!createdNew)
{
mutex.Dispose();
return false;
}
manager = new SingleInstanceManager(mutex, ownsMutex: true, enableIpc: true);
return true;
}
catch
{
manager = new SingleInstanceManager(mutex: null, ownsMutex: false, enableIpc: false);
return true;
}
}
public static void SignalExistingInstance()
{
try
{
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
client.Connect(200);
using var writer = new StreamWriter(client) { AutoFlush = true };
writer.WriteLine("show");
}
catch
{
}
}
public void StartServer(Action onShowRequested)
{
if (!_enableIpc) return;
Task.Run(() => ServerLoop(onShowRequested, _cts.Token));
}
private static async Task ServerLoop(Action onShowRequested, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
using var server = new NamedPipeServerStream(
PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous
);
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(server);
string? line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (string.Equals(line, "show", StringComparison.OrdinalIgnoreCase))
onShowRequested();
}
catch (OperationCanceledException)
{
return;
}
catch
{
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
}
}
}
public void Dispose()
{
try
{
_cts.Cancel();
}
catch
{
}
try
{
if (_ownsMutex)
{
_mutex?.ReleaseMutex();
}
}
catch
{
}
_mutex?.Dispose();
_cts.Dispose();
}
}
}

308
TaskbarIntegration.cs Normal file
View File

@@ -0,0 +1,308 @@
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()
{
if (PortableMode.IsPortable)
return;
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);
}
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
@@ -6,6 +6,7 @@
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>Properties\app.ico</ApplicationIcon>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,5 @@
@echo off
setlocal
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0publish-portable.ps1" %*
exit /b %errorlevel%

View File

@@ -0,0 +1,91 @@
param(
[string]$Rid = "win-x64",
[bool]$SelfContained = $false,
[bool]$SingleFile = $true,
[bool]$EnableCompressionInSingleFile = $true,
[bool]$ReadyToRun = $false,
[bool]$Trim = $false,
[bool]$InvariantGlobalization = $false
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$project = Join-Path $repoRoot "TimerApp.csproj"
$artifactsDir = Join-Path $repoRoot "artifacts"
$distDir = Join-Path $repoRoot "dist"
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
$EnableCompressionInSingleFile = $EnableCompressionInSingleFile -and $SelfContained
$variantParts = @()
$variantParts += $(if ($SelfContained) { "sc" } else { "fd" })
$variantParts += $(if ($SingleFile) { "single" } else { "multi" })
if ($SingleFile -and $EnableCompressionInSingleFile) { $variantParts += "comp" }
if ($ReadyToRun) { $variantParts += "r2r" }
if ($Trim) { $variantParts += "trim" }
if ($InvariantGlobalization) { $variantParts += "invglob" }
$variant = ($variantParts -join "-")
$defaultVariant = "fd-single"
$publishBaseDir = Join-Path $artifactsDir "publish"
$publishDir = Join-Path (Join-Path $publishBaseDir $Rid) $variant
New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
$props = @(
"-p:SelfContained=$SelfContained",
"-p:PublishSingleFile=$SingleFile",
"-p:PublishReadyToRun=$ReadyToRun",
"-p:DebugType=None",
"-p:DebugSymbols=false"
)
if ($SingleFile) {
$props += "-p:IncludeNativeLibrariesForSelfExtract=true"
if ($SelfContained) {
$props += "-p:EnableCompressionInSingleFile=$EnableCompressionInSingleFile"
}
}
if ($Trim) {
$props += "-p:PublishTrimmed=true"
$props += "-p:TrimMode=partial"
}
if ($InvariantGlobalization) {
$props += "-p:InvariantGlobalization=true"
}
dotnet publish $project `
-c Release `
-r $Rid `
@props `
-o $publishDir
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE"
}
$zipName = if ($variant -eq $defaultVariant) { "TimerApp-Portable-$Rid.zip" } else { "TimerApp-Portable-$Rid-$variant.zip" }
$zipPath = Join-Path $distDir $zipName
if (Test-Path $zipPath) {
try { [System.IO.File]::Delete($zipPath) } catch { }
}
Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $zipPath -Force
$mainExe = Join-Path $publishDir "TimerApp.exe"
if (Test-Path $mainExe) {
$exeMiB = [Math]::Round(((Get-Item $mainExe).Length / 1MB), 1)
Write-Host "EXE size: $exeMiB MiB"
}
$zipMiB = [Math]::Round(((Get-Item $zipPath).Length / 1MB), 1)
Write-Host "ZIP size: $zipMiB MiB"
Write-Host "Portable zip created:"
Write-Host " $zipPath"