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/
|
[Bb]in/
|
||||||
[Oo]bj/
|
[Oo]bj/
|
||||||
[Oo]ut/
|
[Oo]ut/
|
||||||
|
|
||||||
# Visual Studio 用户特定文件
|
|
||||||
*.rsuser
|
|
||||||
*.suo
|
|
||||||
*.user
|
|
||||||
*.userosscache
|
|
||||||
*.sln.docstates
|
|
||||||
|
|
||||||
# Visual Studio 缓存/选项目录
|
|
||||||
.vs/
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# Visual Studio 代码分析结果
|
|
||||||
*.sln.iml
|
|
||||||
|
|
||||||
# 构建结果
|
|
||||||
[Dd]ebug/
|
[Dd]ebug/
|
||||||
[Dd]ebugPublic/
|
[Dd]ebugPublic/
|
||||||
[Rr]elease/
|
[Rr]elease/
|
||||||
@@ -30,17 +14,25 @@ x86/
|
|||||||
[Aa][Rr][Mm]/
|
[Aa][Rr][Mm]/
|
||||||
[Aa][Rr][Mm]64/
|
[Aa][Rr][Mm]64/
|
||||||
bld/
|
bld/
|
||||||
[Bb]in/
|
|
||||||
[Oo]bj/
|
|
||||||
[Ll]og/
|
[Ll]og/
|
||||||
[Ll]ogs/
|
[Ll]ogs/
|
||||||
|
[Dd]ist/
|
||||||
# .NET Core
|
|
||||||
project.lock.json
|
|
||||||
project.fragment.lock.json
|
|
||||||
artifacts/
|
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
|
*.nupkg
|
||||||
*.snupkg
|
*.snupkg
|
||||||
**/packages/*
|
**/packages/*
|
||||||
@@ -52,29 +44,14 @@ artifacts/
|
|||||||
[Tt]est[Rr]esult*/
|
[Tt]est[Rr]esult*/
|
||||||
[Bb]uild[Ll]og.*
|
[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
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
*.log
|
*.log
|
||||||
*.cache
|
*.cache
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
# 操作系统文件
|
# 操作系统文件
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -82,10 +59,5 @@ Thumbs.db
|
|||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Desktop.ini
|
Desktop.ini
|
||||||
|
|
||||||
# 备份文件
|
|
||||||
*.bak
|
|
||||||
*.swp
|
|
||||||
*~
|
|
||||||
|
|
||||||
# 设置文件(如果包含敏感信息)
|
# 设置文件(如果包含敏感信息)
|
||||||
# settings.json
|
# settings.json
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace TimerApp
|
namespace TimerApp
|
||||||
{
|
{
|
||||||
@@ -10,13 +11,19 @@ namespace TimerApp
|
|||||||
Resting
|
Resting
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ActivityMonitor
|
public sealed class ActivityMonitor : IDisposable
|
||||||
{
|
{
|
||||||
private System.Windows.Forms.Timer _timer;
|
private CancellationTokenSource? _cts;
|
||||||
private DateTime _lastWorkStartTime;
|
private readonly object _lock = new object();
|
||||||
private TimeSpan _accumulatedWorkTime;
|
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);
|
public TimeSpan WorkDuration { get; set; } = TimeSpan.FromMinutes(20);
|
||||||
@@ -24,41 +31,68 @@ namespace TimerApp
|
|||||||
public TimeSpan IdleThreshold { get; set; } = TimeSpan.FromSeconds(30);
|
public TimeSpan IdleThreshold { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
public MonitorState CurrentState { get; private set; } = MonitorState.Idle;
|
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>? WorkProgressChanged; // 剩余工作时间
|
||||||
public event EventHandler<TimeSpan> RestProgressChanged; // 剩余休息时间
|
public event EventHandler<TimeSpan>? RestProgressChanged; // 剩余休息时间
|
||||||
public event EventHandler RestStarted;
|
public event EventHandler? RestStarted;
|
||||||
public event EventHandler RestEnded;
|
public event EventHandler? RestEnded;
|
||||||
public event EventHandler StateChanged;
|
public event EventHandler? StateChanged;
|
||||||
|
|
||||||
public ActivityMonitor()
|
public ActivityMonitor()
|
||||||
{
|
{
|
||||||
_timer = new System.Windows.Forms.Timer();
|
|
||||||
_timer.Interval = 1000; // 1秒检查一次
|
|
||||||
_timer.Tick += Timer_Tick;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
_timer.Start();
|
lock (_lock)
|
||||||
ResetWork();
|
{
|
||||||
|
StopInternal();
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var token = _cts.Token;
|
||||||
|
|
||||||
|
// 启动时立即触发一次检测
|
||||||
|
_checkTickCounter = CheckIntervalTicks;
|
||||||
|
ResetWork();
|
||||||
|
|
||||||
|
Task.Run(() => MonitorLoop(token), token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
_timer.Stop();
|
lock (_lock)
|
||||||
|
{
|
||||||
|
StopInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopInternal()
|
||||||
|
{
|
||||||
|
if (_cts != null)
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_cts.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ResetWork()
|
private void ResetWork()
|
||||||
{
|
{
|
||||||
|
// Must be called within lock
|
||||||
_accumulatedWorkTime = TimeSpan.Zero;
|
_accumulatedWorkTime = TimeSpan.Zero;
|
||||||
_lastWorkStartTime = DateTime.Now;
|
|
||||||
ChangeState(MonitorState.Idle);
|
ChangeState(MonitorState.Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ChangeState(MonitorState newState)
|
private void ChangeState(MonitorState newState)
|
||||||
{
|
{
|
||||||
|
// Must be called within lock
|
||||||
if (CurrentState != newState)
|
if (CurrentState != newState)
|
||||||
{
|
{
|
||||||
CurrentState = newState;
|
CurrentState = newState;
|
||||||
@@ -66,126 +100,214 @@ namespace TimerApp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Timer_Tick(object sender, EventArgs e)
|
private async Task MonitorLoop(CancellationToken token)
|
||||||
{
|
{
|
||||||
long idleMs = NativeMethods.GetIdleTime();
|
while (!token.IsCancellationRequested)
|
||||||
TimeSpan idleTime = TimeSpan.FromMilliseconds(idleMs);
|
|
||||||
|
|
||||||
if (CurrentState == MonitorState.Resting)
|
|
||||||
{
|
{
|
||||||
// 休息模式逻辑
|
try
|
||||||
// 使用计数器而不是时间差,避免秒数跳变
|
|
||||||
_restElapsedSeconds++;
|
|
||||||
int totalRestSeconds = (int)RestDuration.TotalSeconds;
|
|
||||||
int remainingSeconds = totalRestSeconds - _restElapsedSeconds;
|
|
||||||
|
|
||||||
if (remainingSeconds <= 0)
|
|
||||||
{
|
{
|
||||||
// 休息结束
|
await Task.Delay(1000, token);
|
||||||
RestEnded?.Invoke(this, EventArgs.Empty);
|
OnTick();
|
||||||
ResetWork(); // 重新开始工作周期
|
|
||||||
}
|
}
|
||||||
else
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
TimeSpan remainingRest = TimeSpan.FromSeconds(remainingSeconds);
|
break;
|
||||||
RestProgressChanged?.Invoke(this, remainingRest);
|
|
||||||
}
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
else
|
|
||||||
{
|
|
||||||
// 工作/空闲模式逻辑
|
|
||||||
if (idleTime > IdleThreshold)
|
|
||||||
{
|
{
|
||||||
// 用户离开了
|
Logger.LogError("Error in ActivityMonitor MonitorLoop", ex);
|
||||||
if (CurrentState == MonitorState.Working)
|
|
||||||
{
|
|
||||||
// 如果正在工作,但离开了,暂停工作计时?
|
|
||||||
// 简单起见,如果离开时间过长,可以视为一种“休息”,或者只是暂停累积
|
|
||||||
// 这里我们简单处理:如果空闲时间超过阈值,状态变为空闲
|
|
||||||
ChangeState(MonitorState.Idle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果在 Idle 状态,且空闲时间非常长(比如超过了休息时间),
|
|
||||||
// 是否应该重置工作计时器?
|
|
||||||
// 假设用户去开会了1小时,回来应该重新计算20分钟。
|
|
||||||
if (idleTime > RestDuration)
|
|
||||||
{
|
|
||||||
_accumulatedWorkTime = TimeSpan.Zero;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// 用户在活动
|
|
||||||
if (CurrentState == MonitorState.Idle)
|
|
||||||
{
|
|
||||||
// 从空闲变为工作
|
|
||||||
ChangeState(MonitorState.Working);
|
|
||||||
_lastWorkStartTime = DateTime.Now;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 累加工作时间
|
|
||||||
// 简单的累加逻辑:这一秒是工作的
|
|
||||||
_accumulatedWorkTime += TimeSpan.FromSeconds(1);
|
|
||||||
|
|
||||||
// 检查是否达到工作时长
|
|
||||||
TimeSpan remainingWork = WorkDuration - _accumulatedWorkTime;
|
|
||||||
|
|
||||||
if (remainingWork <= TimeSpan.Zero)
|
|
||||||
{
|
|
||||||
// 触发休息
|
|
||||||
_restStartTime = DateTime.Now;
|
|
||||||
_restElapsedSeconds = 0; // 重置休息计数器
|
|
||||||
ChangeState(MonitorState.Resting);
|
|
||||||
RestStarted?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
WorkProgressChanged?.Invoke(this, remainingWork);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用于强制重置或测试
|
private void OnTick()
|
||||||
public void ForceRest()
|
|
||||||
{
|
{
|
||||||
_restStartTime = DateTime.Now;
|
// 在锁外执行耗时的系统检测
|
||||||
_restElapsedSeconds = 0; // 重置休息计数器
|
long idleMs = 0;
|
||||||
ChangeState(MonitorState.Resting);
|
bool shouldUpdate = false;
|
||||||
RestStarted?.Invoke(this, EventArgs.Empty);
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// 休息模式逻辑
|
||||||
|
// 使用计数器而不是时间差,避免秒数跳变
|
||||||
|
_restElapsedSeconds++;
|
||||||
|
int totalRestSeconds = (int)RestDuration.TotalSeconds;
|
||||||
|
int remainingSeconds = totalRestSeconds - _restElapsedSeconds;
|
||||||
|
|
||||||
|
if (remainingSeconds <= 0)
|
||||||
|
{
|
||||||
|
// 休息结束
|
||||||
|
RestEnded?.Invoke(this, EventArgs.Empty);
|
||||||
|
ResetWork(); // 重新开始工作周期
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TimeSpan remainingRest = TimeSpan.FromSeconds(remainingSeconds);
|
||||||
|
RestProgressChanged?.Invoke(this, remainingRest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 工作/空闲模式逻辑
|
||||||
|
bool isUserInactive = idleTime > IdleThreshold;
|
||||||
|
|
||||||
|
if (isUserInactive)
|
||||||
|
{
|
||||||
|
// 用户确实离开了 -> 进入空闲状态
|
||||||
|
if (CurrentState == MonitorState.Working)
|
||||||
|
{
|
||||||
|
ChangeState(MonitorState.Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果在 Idle 状态,且空闲时间非常长(比如超过了休息时间),
|
||||||
|
// 重置工作计时器
|
||||||
|
if (idleTime > RestDuration)
|
||||||
|
{
|
||||||
|
_accumulatedWorkTime = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 用户在活动
|
||||||
|
if (CurrentState == MonitorState.Idle)
|
||||||
|
{
|
||||||
|
// 从空闲变为工作
|
||||||
|
ChangeState(MonitorState.Working);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常工作:累加时间
|
||||||
|
_accumulatedWorkTime += TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
// 检查是否达到工作时长
|
||||||
|
TimeSpan remainingWork = WorkDuration - _accumulatedWorkTime;
|
||||||
|
|
||||||
|
if (remainingWork <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
// 触发休息
|
||||||
|
_restElapsedSeconds = 0; // 重置休息计数器
|
||||||
|
ChangeState(MonitorState.Resting);
|
||||||
|
RestStarted?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WorkProgressChanged?.Invoke(this, remainingWork);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshStatus()
|
public void RefreshStatus()
|
||||||
{
|
{
|
||||||
if (CurrentState == MonitorState.Working)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
TimeSpan remaining = WorkDuration - _accumulatedWorkTime;
|
if (CurrentState == MonitorState.Working)
|
||||||
WorkProgressChanged?.Invoke(this, remaining);
|
{
|
||||||
}
|
TimeSpan remaining = WorkDuration - _accumulatedWorkTime;
|
||||||
else if (CurrentState == MonitorState.Resting)
|
WorkProgressChanged?.Invoke(this, remaining);
|
||||||
{
|
}
|
||||||
// 使用计数器计算剩余时间,保持一致性
|
else if (CurrentState == MonitorState.Resting)
|
||||||
int totalRestSeconds = (int)RestDuration.TotalSeconds;
|
{
|
||||||
int remainingSeconds = totalRestSeconds - _restElapsedSeconds;
|
int totalRestSeconds = (int)RestDuration.TotalSeconds;
|
||||||
if (remainingSeconds < 0) remainingSeconds = 0;
|
int remainingSeconds = totalRestSeconds - _restElapsedSeconds;
|
||||||
TimeSpan remaining = TimeSpan.FromSeconds(remainingSeconds);
|
if (remainingSeconds < 0) remainingSeconds = 0;
|
||||||
RestProgressChanged?.Invoke(this, remaining);
|
TimeSpan remaining = TimeSpan.FromSeconds(remainingSeconds);
|
||||||
}
|
RestProgressChanged?.Invoke(this, remaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Restart()
|
public void Restart()
|
||||||
{
|
{
|
||||||
_accumulatedWorkTime = TimeSpan.Zero;
|
lock (_lock)
|
||||||
_lastWorkStartTime = DateTime.Now;
|
{
|
||||||
|
_accumulatedWorkTime = TimeSpan.Zero;
|
||||||
|
_isPaused = false;
|
||||||
|
|
||||||
// Ensure timer is running
|
// Force state to Working since user manually restarted
|
||||||
if (!_timer.Enabled) _timer.Start();
|
ChangeState(MonitorState.Working);
|
||||||
|
|
||||||
// Force state to Working since user manually restarted
|
// Immediately refresh UI to show full duration
|
||||||
ChangeState(MonitorState.Working);
|
RefreshStatus();
|
||||||
|
}
|
||||||
// Immediately refresh UI to show full duration
|
|
||||||
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 RestMinutes { get; set; } = 1;
|
||||||
public int IdleThresholdSeconds { get; set; } = 30;
|
public int IdleThresholdSeconds { get; set; } = 30;
|
||||||
public bool IsDarkMode { get; set; } = true;
|
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()
|
public static AppSettings Load()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (File.Exists(ConfigPath))
|
string path;
|
||||||
|
if (PortableMode.IsPortable)
|
||||||
{
|
{
|
||||||
string json = File.ReadAllText(ConfigPath);
|
path = LegacyConfigPath;
|
||||||
return JsonSerializer.Deserialize<AppSettings>(json);
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
path = File.Exists(ConfigPath) ? ConfigPath : LegacyConfigPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
string json = File.ReadAllText(path);
|
||||||
|
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -34,8 +56,12 @@ namespace TimerApp
|
|||||||
{
|
{
|
||||||
try
|
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 });
|
string json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(ConfigPath, json);
|
File.WriteAllText(path, json);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
173
IconGenerator.cs
173
IconGenerator.cs
@@ -1,66 +1,149 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace TimerApp
|
namespace TimerApp
|
||||||
{
|
{
|
||||||
public static class IconGenerator
|
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 (Bitmap bmp = new Bitmap(size, size))
|
||||||
using (Graphics g = Graphics.FromImage(bmp))
|
using (Graphics g = Graphics.FromImage(bmp))
|
||||||
{
|
{
|
||||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
g.Clear(Color.Transparent);
|
g.Clear(Color.Transparent);
|
||||||
|
|
||||||
// 绘制闹钟主体
|
DrawClock(g, size);
|
||||||
int margin = 4;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 两个耳朵 (闹钟铃)
|
|
||||||
using (Brush earBrush = new SolidBrush(Color.White))
|
|
||||||
{
|
|
||||||
// 左耳
|
|
||||||
g.FillPie(earBrush, 0, 0, size/2, size/2, 200, 50);
|
|
||||||
// 右耳
|
|
||||||
g.FillPie(earBrush, size/2, 0, size/2, size/2, 290, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重绘外圈盖住耳朵连接处
|
|
||||||
using (Brush bgBrush = new SolidBrush(Color.FromArgb(30, 30, 30))) // 深色背景
|
|
||||||
{
|
|
||||||
g.FillEllipse(bgBrush, rect);
|
|
||||||
}
|
|
||||||
using (Pen pen = new Pen(Color.White, 3))
|
|
||||||
{
|
|
||||||
g.DrawEllipse(pen, rect);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 指针
|
|
||||||
Point center = new Point(size / 2, size / 2);
|
|
||||||
using (Pen handPen = new Pen(Color.LightGreen, 3))
|
|
||||||
{
|
|
||||||
handPen.EndCap = LineCap.Round;
|
|
||||||
// 时针
|
|
||||||
g.DrawLine(handPen, center, new Point(center.X + 10, center.Y - 10));
|
|
||||||
// 分针
|
|
||||||
g.DrawLine(handPen, center, new Point(center.X, center.Y - 18));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为图标
|
|
||||||
// 注意:GetHicon 需要释放
|
|
||||||
IntPtr hIcon = bmp.GetHicon();
|
IntPtr hIcon = bmp.GetHicon();
|
||||||
return Icon.FromHandle(hIcon);
|
try
|
||||||
|
{
|
||||||
|
using Icon icon = Icon.FromHandle(hIcon);
|
||||||
|
return (Icon)icon.Clone();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DestroyIcon(hIcon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteClockIco(string filePath, int size = 256)
|
||||||
|
{
|
||||||
|
byte[] icoBytes = CreateClockIcoBytes(size);
|
||||||
|
File.WriteAllBytes(filePath, icoBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] CreateClockIcoBytes(int size)
|
||||||
|
{
|
||||||
|
if (size <= 0)
|
||||||
|
size = 256;
|
||||||
|
if (size > 256)
|
||||||
|
size = 256;
|
||||||
|
if (size < 16)
|
||||||
|
size = 16;
|
||||||
|
|
||||||
|
byte[] pngBytes;
|
||||||
|
using (Bitmap bmp = new Bitmap(size, size))
|
||||||
|
using (Graphics g = Graphics.FromImage(bmp))
|
||||||
|
{
|
||||||
|
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||||
|
g.Clear(Color.Transparent);
|
||||||
|
DrawClock(g, size);
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
bmp.Save(ms, ImageFormat.Png);
|
||||||
|
pngBytes = ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var icoStream = new MemoryStream(6 + 16 + pngBytes.Length);
|
||||||
|
using (var bw = new BinaryWriter(icoStream, System.Text.Encoding.UTF8, leaveOpen: true))
|
||||||
|
{
|
||||||
|
bw.Write((ushort)0);
|
||||||
|
bw.Write((ushort)1);
|
||||||
|
bw.Write((ushort)1);
|
||||||
|
|
||||||
|
bw.Write((byte)(size == 256 ? 0 : size));
|
||||||
|
bw.Write((byte)(size == 256 ? 0 : size));
|
||||||
|
bw.Write((byte)0);
|
||||||
|
bw.Write((byte)0);
|
||||||
|
bw.Write((ushort)1);
|
||||||
|
bw.Write((ushort)32);
|
||||||
|
bw.Write((uint)pngBytes.Length);
|
||||||
|
bw.Write((uint)(6 + 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
icoStream.Write(pngBytes, 0, pngBytes.Length);
|
||||||
|
return icoStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawClock(Graphics g, int size)
|
||||||
|
{
|
||||||
|
int margin = Math.Max(2, size / 16);
|
||||||
|
int clockSize = size - margin * 2;
|
||||||
|
Rectangle rect = new Rectangle(margin, margin, clockSize, clockSize);
|
||||||
|
|
||||||
|
// 定义轮廓颜色和宽度
|
||||||
|
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.DrawPie(outlinePen, 0, 0, size / 2, size / 2, 200, 50);
|
||||||
|
g.FillPie(earBrush, 0, 0, size / 2, size / 2, 200, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
g.DrawEllipse(outlinePen, rect);
|
||||||
|
g.DrawEllipse(pen, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 绘制指针 (先画轮廓)
|
||||||
|
Point center = new Point(size / 2, size / 2);
|
||||||
|
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));
|
||||||
|
|
||||||
|
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>
|
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing && (components != null))
|
if (disposing)
|
||||||
{
|
{
|
||||||
components.Dispose();
|
_monitor?.Dispose();
|
||||||
|
_restForm?.Dispose();
|
||||||
|
components?.Dispose();
|
||||||
}
|
}
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,7 @@ namespace TimerApp
|
|||||||
|
|
||||||
this.btnStartStop = new System.Windows.Forms.Button();
|
this.btnStartStop = new System.Windows.Forms.Button();
|
||||||
this.btnReset = 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.lblStatus = new System.Windows.Forms.Label();
|
||||||
this.lblTimeLeft = new System.Windows.Forms.Label();
|
this.lblTimeLeft = new System.Windows.Forms.Label();
|
||||||
this.notifyIcon1 = new System.Windows.Forms.NotifyIcon(this.components);
|
this.notifyIcon1 = new System.Windows.Forms.NotifyIcon(this.components);
|
||||||
@@ -169,6 +172,7 @@ namespace TimerApp
|
|||||||
// pnlSettings
|
// pnlSettings
|
||||||
//
|
//
|
||||||
this.pnlSettings.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(30)))), ((int)(((byte)(30)))), ((int)(((byte)(30)))));
|
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.btnReset);
|
||||||
this.pnlSettings.Controls.Add(this.btnStartStop);
|
this.pnlSettings.Controls.Add(this.btnStartStop);
|
||||||
this.pnlSettings.Controls.Add(this.btnRestPlus);
|
this.pnlSettings.Controls.Add(this.btnRestPlus);
|
||||||
@@ -305,11 +309,11 @@ namespace TimerApp
|
|||||||
this.btnStartStop.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
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.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.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.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.TabIndex = 4;
|
||||||
this.btnStartStop.Text = "应用设置";
|
this.btnStartStop.Text = "应用";
|
||||||
this.btnStartStop.UseVisualStyleBackColor = false;
|
this.btnStartStop.UseVisualStyleBackColor = false;
|
||||||
this.btnStartStop.Click += new System.EventHandler(this.btnStartStop_Click);
|
this.btnStartStop.Click += new System.EventHandler(this.btnStartStop_Click);
|
||||||
|
|
||||||
@@ -323,12 +327,28 @@ namespace TimerApp
|
|||||||
this.btnReset.ForeColor = System.Drawing.Color.White;
|
this.btnReset.ForeColor = System.Drawing.Color.White;
|
||||||
this.btnReset.Location = new System.Drawing.Point(40, 110);
|
this.btnReset.Location = new System.Drawing.Point(40, 110);
|
||||||
this.btnReset.Name = "btnReset";
|
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.TabIndex = 8;
|
||||||
this.btnReset.Text = "重置计时";
|
this.btnReset.Text = "重置";
|
||||||
this.btnReset.UseVisualStyleBackColor = false;
|
this.btnReset.UseVisualStyleBackColor = false;
|
||||||
this.btnReset.Click += new System.EventHandler(this.btnReset_Click);
|
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
|
// btnHide
|
||||||
//
|
//
|
||||||
@@ -419,6 +439,7 @@ namespace TimerApp
|
|||||||
private System.Windows.Forms.Button btnRestPlus;
|
private System.Windows.Forms.Button btnRestPlus;
|
||||||
private System.Windows.Forms.Button btnStartStop;
|
private System.Windows.Forms.Button btnStartStop;
|
||||||
private System.Windows.Forms.Button btnReset;
|
private System.Windows.Forms.Button btnReset;
|
||||||
|
private System.Windows.Forms.Button btnPause;
|
||||||
private System.Windows.Forms.Label lblStatus;
|
private System.Windows.Forms.Label lblStatus;
|
||||||
private System.Windows.Forms.Label lblTimeLeft;
|
private System.Windows.Forms.Label lblTimeLeft;
|
||||||
private System.Windows.Forms.NotifyIcon notifyIcon1;
|
private System.Windows.Forms.NotifyIcon notifyIcon1;
|
||||||
|
|||||||
159
MainForm.cs
159
MainForm.cs
@@ -2,14 +2,15 @@ using System;
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace TimerApp
|
namespace TimerApp
|
||||||
{
|
{
|
||||||
public partial class MainForm : Form
|
public partial class MainForm : Form
|
||||||
{
|
{
|
||||||
private ActivityMonitor _monitor;
|
private ActivityMonitor _monitor = null!;
|
||||||
private AppSettings _settings;
|
private AppSettings _settings;
|
||||||
private RestForm _restForm;
|
private RestForm? _restForm;
|
||||||
|
|
||||||
// Colors
|
// Colors
|
||||||
private Color _darkBg = Color.FromArgb(30, 30, 30);
|
private Color _darkBg = Color.FromArgb(30, 30, 30);
|
||||||
@@ -24,10 +25,6 @@ namespace TimerApp
|
|||||||
public static extern bool ReleaseCapture();
|
public static extern bool ReleaseCapture();
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
public static extern int SendMessage(IntPtr hWnd, int Msg, int wParam, int lParam);
|
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 WM_NCLBUTTONDOWN = 0xA1;
|
||||||
private const int HT_CAPTION = 0x2;
|
private const int HT_CAPTION = 0x2;
|
||||||
@@ -61,6 +58,7 @@ namespace TimerApp
|
|||||||
btnRestPlus.BackColor = panelColor;
|
btnRestPlus.BackColor = panelColor;
|
||||||
btnStartStop.BackColor = dark ? Color.FromArgb(63, 63, 70) : Color.White;
|
btnStartStop.BackColor = dark ? Color.FromArgb(63, 63, 70) : Color.White;
|
||||||
btnReset.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 |
|
this.SetStyle(ControlStyles.AllPaintingInWmPaint |
|
||||||
@@ -105,8 +103,8 @@ namespace TimerApp
|
|||||||
// Manual input validation
|
// Manual input validation
|
||||||
txtWork.KeyPress += ValidateDigitInput;
|
txtWork.KeyPress += ValidateDigitInput;
|
||||||
txtRest.KeyPress += ValidateDigitInput;
|
txtRest.KeyPress += ValidateDigitInput;
|
||||||
txtWork.Leave += (s, ev) => ValidateRange((TextBox)s, 1, 120);
|
txtWork.Leave += (s, ev) => ValidateRange((TextBox)s!, 1, 120);
|
||||||
txtRest.Leave += (s, ev) => ValidateRange((TextBox)s, 1, 30);
|
txtRest.Leave += (s, ev) => ValidateRange((TextBox)s!, 1, 30);
|
||||||
|
|
||||||
// Focus handling (remove custom caret)
|
// Focus handling (remove custom caret)
|
||||||
txtWork.KeyDown += TextBox_KeyDown;
|
txtWork.KeyDown += TextBox_KeyDown;
|
||||||
@@ -132,13 +130,20 @@ namespace TimerApp
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Generate and set custom icon
|
// Generate and set custom icon
|
||||||
Icon icon = IconGenerator.GenerateClockIcon();
|
Icon icon = IconGenerator.GenerateClockIcon(64);
|
||||||
this.Icon = icon;
|
this.Icon = icon;
|
||||||
notifyIcon1.Icon = icon;
|
notifyIcon1.Icon = icon;
|
||||||
|
|
||||||
|
contextMenuStrip1.ShowImageMargin = true;
|
||||||
|
toolStripMenuItemShow.Image = null;
|
||||||
|
toolStripMenuItemExit.Image = null;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
notifyIcon1.Icon = SystemIcons.Application;
|
notifyIcon1.Icon = SystemIcons.Application;
|
||||||
|
contextMenuStrip1.ShowImageMargin = true;
|
||||||
|
toolStripMenuItemShow.Image = null;
|
||||||
|
toolStripMenuItemExit.Image = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateStatusUI();
|
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
|
// Allow control keys (backspace, etc.) and digits
|
||||||
if (!char.IsControl(e.KeyChar) && !char.IsDigit(e.KeyChar))
|
if (!char.IsControl(e.KeyChar) && !char.IsDigit(e.KeyChar))
|
||||||
@@ -208,7 +213,7 @@ namespace TimerApp
|
|||||||
|
|
||||||
// CustomCaret removed to use system caret with centered text
|
// 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)
|
if (e.KeyCode == Keys.Enter)
|
||||||
{
|
{
|
||||||
@@ -256,6 +261,7 @@ namespace TimerApp
|
|||||||
// Update buttons
|
// Update buttons
|
||||||
UpdateButtonStyle(btnStartStop, dark);
|
UpdateButtonStyle(btnStartStop, dark);
|
||||||
UpdateButtonStyle(btnReset, dark);
|
UpdateButtonStyle(btnReset, dark);
|
||||||
|
UpdateButtonStyle(btnPause, dark);
|
||||||
UpdateButtonStyle(btnHide, dark);
|
UpdateButtonStyle(btnHide, dark);
|
||||||
|
|
||||||
// Numeric buttons and text
|
// Numeric buttons and text
|
||||||
@@ -270,6 +276,22 @@ namespace TimerApp
|
|||||||
btnClose.ForeColor = text;
|
btnClose.ForeColor = text;
|
||||||
btnMin.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
|
// Theme button with Segoe MDL2 Assets
|
||||||
btnTheme.ForeColor = text;
|
btnTheme.ForeColor = text;
|
||||||
btnTheme.Font = new Font("Segoe MDL2 Assets", 10F, FontStyle.Regular, GraphicsUnit.Point);
|
btnTheme.Font = new Font("Segoe MDL2 Assets", 10F, FontStyle.Regular, GraphicsUnit.Point);
|
||||||
@@ -356,6 +378,20 @@ namespace TimerApp
|
|||||||
_monitor.Restart();
|
_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()
|
private void ApplySettings()
|
||||||
{
|
{
|
||||||
int workMin = 20;
|
int workMin = 20;
|
||||||
@@ -368,11 +404,11 @@ namespace TimerApp
|
|||||||
_monitor.IdleThreshold = TimeSpan.FromSeconds(_settings.IdleThresholdSeconds);
|
_monitor.IdleThreshold = TimeSpan.FromSeconds(_settings.IdleThresholdSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Monitor_StateChanged(object sender, EventArgs e)
|
private void Monitor_StateChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (InvokeRequired)
|
if (InvokeRequired)
|
||||||
{
|
{
|
||||||
Invoke(new Action<object, EventArgs>(Monitor_StateChanged), sender, e);
|
BeginInvoke(new Action<object?, EventArgs>(Monitor_StateChanged), sender, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UpdateStatusUI();
|
UpdateStatusUI();
|
||||||
@@ -384,6 +420,18 @@ namespace TimerApp
|
|||||||
|
|
||||||
bool dark = _settings.IsDarkMode;
|
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)
|
switch (_monitor.CurrentState)
|
||||||
{
|
{
|
||||||
case MonitorState.Idle:
|
case MonitorState.Idle:
|
||||||
@@ -393,43 +441,49 @@ namespace TimerApp
|
|||||||
lblTimeLeft.ForeColor = Color.Gray;
|
lblTimeLeft.ForeColor = Color.Gray;
|
||||||
break;
|
break;
|
||||||
case MonitorState.Working:
|
case MonitorState.Working:
|
||||||
lblStatus.Text = "状态: 工作中";
|
lblStatus.Text = _monitor.IsPaused ? "状态: 工作中 (已暂停)" : "状态: 工作中";
|
||||||
lblStatus.ForeColor = dark ? Color.LightGreen : Color.Green;
|
lblStatus.ForeColor = dark ? Color.LightGreen : Color.Green;
|
||||||
lblTimeLeft.ForeColor = dark ? Color.White : Color.Black;
|
lblTimeLeft.ForeColor = dark ? Color.White : Color.Black;
|
||||||
break;
|
break;
|
||||||
case MonitorState.Resting:
|
case MonitorState.Resting:
|
||||||
lblStatus.Text = "状态: 休息中";
|
lblStatus.Text = _monitor.IsPaused ? "状态: 休息中 (已暂停)" : "状态: 休息中";
|
||||||
lblStatus.ForeColor = dark ? Color.LightSkyBlue : Color.Blue;
|
lblStatus.ForeColor = dark ? Color.LightSkyBlue : Color.Blue;
|
||||||
lblTimeLeft.ForeColor = dark ? Color.LightSkyBlue : Color.Blue;
|
lblTimeLeft.ForeColor = dark ? Color.LightSkyBlue : Color.Blue;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Monitor_WorkProgressChanged(object sender, TimeSpan remaining)
|
private void Monitor_WorkProgressChanged(object? sender, TimeSpan remaining)
|
||||||
{
|
{
|
||||||
if (InvokeRequired)
|
if (InvokeRequired)
|
||||||
{
|
{
|
||||||
Invoke(new Action<object, TimeSpan>(Monitor_WorkProgressChanged), sender, remaining);
|
BeginInvoke(new Action<object?, TimeSpan>(Monitor_WorkProgressChanged), sender, remaining);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lblTimeLeft.Text = $"{remaining.Minutes:D2}:{remaining.Seconds:D2}";
|
lblTimeLeft.Text = $"{remaining.Minutes:D2}:{remaining.Seconds:D2}";
|
||||||
|
|
||||||
// Update tray tooltip
|
// Update tray tooltip
|
||||||
|
string newText;
|
||||||
if (remaining.TotalMinutes < 1)
|
if (remaining.TotalMinutes < 1)
|
||||||
{
|
{
|
||||||
notifyIcon1.Text = $"即将休息: {remaining.Seconds}秒";
|
newText = $"即将休息: {remaining.Seconds}秒";
|
||||||
}
|
}
|
||||||
else
|
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)
|
if (InvokeRequired)
|
||||||
{
|
{
|
||||||
Invoke(new Action<object, EventArgs>(Monitor_RestStarted), sender, e);
|
BeginInvoke(new Action<object?, EventArgs>(Monitor_RestStarted), sender, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,28 +494,36 @@ namespace TimerApp
|
|||||||
_restForm.SkipRequested += RestForm_SkipRequested;
|
_restForm.SkipRequested += RestForm_SkipRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化显示时间,避免显示默认值
|
||||||
|
_restForm.UpdateTime(_monitor.RestDuration);
|
||||||
_restForm.Show();
|
_restForm.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RestForm_SkipRequested(object sender, EventArgs e)
|
private void RestForm_SkipRequested(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
_monitor.Stop();
|
_monitor.Stop();
|
||||||
_monitor.Start();
|
_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)
|
if (_restForm != null && !_restForm.IsDisposed && _restForm.Visible)
|
||||||
{
|
{
|
||||||
_restForm.UpdateTime(remaining);
|
_restForm.UpdateTime(remaining);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Monitor_RestEnded(object sender, EventArgs e)
|
private void Monitor_RestEnded(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (InvokeRequired)
|
if (InvokeRequired)
|
||||||
{
|
{
|
||||||
Invoke(new Action<object, EventArgs>(Monitor_RestEnded), sender, e);
|
BeginInvoke(new Action<object?, EventArgs>(Monitor_RestEnded), sender, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,15 +535,14 @@ namespace TimerApp
|
|||||||
notifyIcon1.ShowBalloonTip(3000, "休息结束", "继续加油工作吧!", ToolTipIcon.Info);
|
notifyIcon1.ShowBalloonTip(3000, "休息结束", "继续加油工作吧!", ToolTipIcon.Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool _hasShownMinimizeTip = false;
|
|
||||||
|
|
||||||
private void btnHide_Click(object sender, EventArgs e)
|
private void btnHide_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
this.Hide();
|
this.Hide();
|
||||||
if (!_hasShownMinimizeTip)
|
if (!_settings.HasShownMinimizeTip)
|
||||||
{
|
{
|
||||||
notifyIcon1.ShowBalloonTip(2000, "已隐藏", "程序仍在后台运行,双击托盘图标恢复。", ToolTipIcon.Info);
|
notifyIcon1.ShowBalloonTip(2000, "已隐藏", "程序仍在后台运行,双击托盘图标恢复。", ToolTipIcon.Info);
|
||||||
_hasShownMinimizeTip = true;
|
_settings.HasShownMinimizeTip = true;
|
||||||
|
_settings.Save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,36 +552,68 @@ namespace TimerApp
|
|||||||
{
|
{
|
||||||
e.Cancel = true;
|
e.Cancel = true;
|
||||||
this.Hide();
|
this.Hide();
|
||||||
if (!_hasShownMinimizeTip)
|
if (!_settings.HasShownMinimizeTip)
|
||||||
{
|
{
|
||||||
notifyIcon1.ShowBalloonTip(2000, "已隐藏", "程序仍在后台运行,双击托盘图标恢复。", ToolTipIcon.Info);
|
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();
|
ShowForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void toolStripMenuItemShow_Click(object sender, EventArgs e)
|
private void toolStripMenuItemShow_Click(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
ShowForm();
|
ShowForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void 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.Show();
|
||||||
this.WindowState = FormWindowState.Normal;
|
this.WindowState = FormWindowState.Normal;
|
||||||
|
this.ShowInTaskbar = true;
|
||||||
|
this.BringToFront();
|
||||||
this.Activate();
|
this.Activate();
|
||||||
|
this.TopMost = true;
|
||||||
|
this.TopMost = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void toolStripMenuItemExit_Click(object sender, EventArgs e)
|
private void toolStripMenuItemExit_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
_monitor.Stop();
|
_monitor.Stop();
|
||||||
|
_monitor.Dispose();
|
||||||
notifyIcon1.Visible = false;
|
notifyIcon1.Visible = false;
|
||||||
|
notifyIcon1.Dispose();
|
||||||
Application.Exit();
|
Application.Exit();
|
||||||
|
Environment.Exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pnlTitle_MouseDown(object sender, MouseEventArgs e)
|
private void pnlTitle_MouseDown(object sender, MouseEventArgs e)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace TimerApp
|
namespace TimerApp
|
||||||
{
|
{
|
||||||
@@ -19,34 +21,37 @@ namespace TimerApp
|
|||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
static extern uint GetTickCount();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取系统空闲时间(毫秒)
|
/// 获取系统空闲时间(毫秒)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static long GetIdleTime()
|
public static long GetIdleTime()
|
||||||
{
|
{
|
||||||
LASTINPUTINFO lastInputInfo = new LASTINPUTINFO();
|
LASTINPUTINFO lastInputInfo = new LASTINPUTINFO
|
||||||
lastInputInfo.cbSize = (uint)Marshal.SizeOf(lastInputInfo);
|
{
|
||||||
lastInputInfo.dwTime = 0;
|
cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>(),
|
||||||
|
dwTime = 0
|
||||||
|
};
|
||||||
|
|
||||||
if (GetLastInputInfo(ref lastInputInfo))
|
if (GetLastInputInfo(ref lastInputInfo))
|
||||||
{
|
{
|
||||||
// Environment.TickCount 可能会在大约 24.9 天后翻转为负数,
|
uint tickCount = GetTickCount();
|
||||||
// 但 GetLastInputInfo 返回的也是 uint (DWORD),所以我们统一转为 long 处理差值
|
uint idleMs = unchecked(tickCount - lastInputInfo.dwTime);
|
||||||
// 或者直接使用 unchecked 减法处理溢出
|
return idleMs;
|
||||||
// 更稳健的做法是使用 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[DllImport("kernel32.dll")]
|
static NativeMethods()
|
||||||
static extern uint GetTickCount();
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Program.cs
55
Program.cs
@@ -5,12 +5,51 @@ static class Program
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The main entry point for the application.
|
/// The main entry point for the application.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[STAThread]
|
[STAThread]
|
||||||
static void Main()
|
static void Main()
|
||||||
{
|
{
|
||||||
// To customize application configuration such as set high DPI settings or default font,
|
// 设置全局异常处理
|
||||||
// see https://aka.ms/applicationconfiguration.
|
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
|
||||||
ApplicationConfiguration.Initialize();
|
Application.ThreadException += Application_ThreadException;
|
||||||
Application.Run(new MainForm());
|
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
|
public class RestForm : Form
|
||||||
{
|
{
|
||||||
private Label lblMessage;
|
private Label lblMessage = null!;
|
||||||
private Label lblTimer;
|
private Label lblTimer = null!;
|
||||||
private Button btnSkip;
|
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()
|
public RestForm()
|
||||||
{
|
{
|
||||||
@@ -29,10 +41,11 @@ namespace TimerApp
|
|||||||
// Form 设置
|
// Form 设置
|
||||||
//
|
//
|
||||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.None;
|
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.TopMost = true;
|
||||||
this.BackColor = System.Drawing.Color.Black;
|
this.BackColor = System.Drawing.Color.Black;
|
||||||
this.Opacity = 0.90; // 90% 不透明度
|
this.Opacity = 0; // 初始隐藏,避免闪烁
|
||||||
this.ShowInTaskbar = false;
|
this.ShowInTaskbar = false;
|
||||||
this.Name = "RestForm";
|
this.Name = "RestForm";
|
||||||
this.Text = "Rest Now";
|
this.Text = "Rest Now";
|
||||||
@@ -63,7 +76,7 @@ namespace TimerApp
|
|||||||
// 初始大小,会在CenterControls中动态调整
|
// 初始大小,会在CenterControls中动态调整
|
||||||
this.lblTimer.Size = new System.Drawing.Size(400, 180);
|
this.lblTimer.Size = new System.Drawing.Size(400, 180);
|
||||||
this.lblTimer.TabIndex = 1;
|
this.lblTimer.TabIndex = 1;
|
||||||
this.lblTimer.Text = "01:00";
|
this.lblTimer.Text = "--:--";
|
||||||
this.lblTimer.TextAlign = ContentAlignment.MiddleCenter;
|
this.lblTimer.TextAlign = ContentAlignment.MiddleCenter;
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -99,12 +112,34 @@ namespace TimerApp
|
|||||||
this.PerformLayout();
|
this.PerformLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RestForm_Load(object sender, EventArgs e)
|
private void RestForm_Load(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
CenterControls();
|
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();
|
CenterControls();
|
||||||
}
|
}
|
||||||
@@ -143,7 +178,7 @@ namespace TimerApp
|
|||||||
{
|
{
|
||||||
if (lblTimer.InvokeRequired)
|
if (lblTimer.InvokeRequired)
|
||||||
{
|
{
|
||||||
lblTimer.Invoke(new Action<TimeSpan>(UpdateTime), remaining);
|
lblTimer.BeginInvoke(new Action<TimeSpan>(UpdateTime), remaining);
|
||||||
}
|
}
|
||||||
else
|
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);
|
SkipRequested?.Invoke(this, EventArgs.Empty);
|
||||||
this.Close();
|
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>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationIcon>Properties\app.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</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