Compare commits
21 Commits
e26c015e09
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e597ce1e5c | |||
| e506f41c72 | |||
| 6baa367ef5 | |||
| c406832e18 | |||
| 6e09de5316 | |||
| cb6a4f4d2c | |||
| 37bef1ead3 | |||
| d421b9b72b | |||
| 0ab770d464 | |||
| fe47293da5 | |||
| 78c07a12d0 | |||
| 4b5609d275 | |||
| 4ef611dc21 | |||
| 1c7d48cd7a | |||
| b0e785bd06 | |||
| c276e9e2b9 | |||
| 7abd445039 | |||
| 74ca8e4d57 | |||
| 50955e84c7 | |||
| 694b40e06b | |||
| 052aa060cc |
64
.gitignore
vendored
64
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace TimerApp
|
||||
{
|
||||
@@ -10,13 +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 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);
|
||||
@@ -24,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;
|
||||
@@ -66,9 +100,72 @@ 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)
|
||||
@@ -94,20 +191,18 @@ namespace TimerApp
|
||||
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;
|
||||
@@ -120,11 +215,9 @@ namespace TimerApp
|
||||
{
|
||||
// 从空闲变为工作
|
||||
ChangeState(MonitorState.Working);
|
||||
_lastWorkStartTime = DateTime.Now;
|
||||
}
|
||||
|
||||
// 累加工作时间
|
||||
// 简单的累加逻辑:这一秒是工作的
|
||||
// 正常工作:累加时间
|
||||
_accumulatedWorkTime += TimeSpan.FromSeconds(1);
|
||||
|
||||
// 检查是否达到工作时长
|
||||
@@ -133,7 +226,6 @@ namespace TimerApp
|
||||
if (remainingWork <= TimeSpan.Zero)
|
||||
{
|
||||
// 触发休息
|
||||
_restStartTime = DateTime.Now;
|
||||
_restElapsedSeconds = 0; // 重置休息计数器
|
||||
ChangeState(MonitorState.Resting);
|
||||
RestStarted?.Invoke(this, EventArgs.Empty);
|
||||
@@ -145,17 +237,11 @@ namespace TimerApp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用于强制重置或测试
|
||||
public void ForceRest()
|
||||
{
|
||||
_restStartTime = DateTime.Now;
|
||||
_restElapsedSeconds = 0; // 重置休息计数器
|
||||
ChangeState(MonitorState.Resting);
|
||||
RestStarted?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void RefreshStatus()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (CurrentState == MonitorState.Working)
|
||||
{
|
||||
@@ -164,7 +250,6 @@ namespace TimerApp
|
||||
}
|
||||
else if (CurrentState == MonitorState.Resting)
|
||||
{
|
||||
// 使用计数器计算剩余时间,保持一致性
|
||||
int totalRestSeconds = (int)RestDuration.TotalSeconds;
|
||||
int remainingSeconds = totalRestSeconds - _restElapsedSeconds;
|
||||
if (remainingSeconds < 0) remainingSeconds = 0;
|
||||
@@ -172,14 +257,14 @@ namespace TimerApp
|
||||
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);
|
||||
@@ -188,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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
143
IconGenerator.cs
143
IconGenerator.cs
@@ -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
36
Logger.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
MainForm.Designer.cs
generated
35
MainForm.Designer.cs
generated
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
159
MainForm.cs
159
MainForm.cs
@@ -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)
|
||||
|
||||
@@ -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 是 int,GetLastInputInfo 是 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
46
PortableMode.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Program.cs
47
Program.cs
@@ -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
BIN
Properties/app.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
109
README.md
Normal file
109
README.md
Normal 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 发布版本)
|
||||
|
||||
## 编译与发布
|
||||
|
||||
- 开发环境要求
|
||||
- Windows(WinForms 桌面程序)
|
||||
- .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`
|
||||
57
RestForm.cs
57
RestForm.cs
@@ -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
126
SingleInstanceManager.cs
Normal 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
308
TaskbarIntegration.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
5
scripts/publish-portable.cmd
Normal file
5
scripts/publish-portable.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0publish-portable.ps1" %*
|
||||
exit /b %errorlevel%
|
||||
|
||||
91
scripts/publish-portable.ps1
Normal file
91
scripts/publish-portable.ps1
Normal 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"
|
||||
Reference in New Issue
Block a user