1 Commits

Author SHA1 Message Date
e2hang
b52398f22f HuajisheHealthDaemon V1.0 2026-02-21 08:54:28 +08:00
33 changed files with 8947 additions and 0 deletions

2648
HuajisheHealthDaemon/v0.3/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
[package]
name = "health-guardian"
version = "0.2.0"
edition = "2021"
description = "A cross-platform health reminder daemon"
[dependencies]
tokio = { version = "1", features = ["full"] }
notify-rust = "4"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
clap = { version = "4", features = ["derive"] }
dirs = "5"
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] } # 时间处理
colored = "2" # 终端彩色输出
rodio = { version = "0.19", default-features = false, features = ["mp3"] } # 音频播放

View File

@@ -0,0 +1,132 @@
下面是完整的"健康守护助手"项目,包含所有所需文件。
---
## 项目结构
```
health-guardian/
├── Cargo.toml
├── config.toml
└── src/
└── main.rs
```
---
## 编译指南
### 通用准备
```bash
# 安装 Rust所有平台通用
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 克隆 / 进入项目目录
cd health-guardian
```
### macOS
```bash
# 直接编译(原生)
cargo build --release
# 输出位置
./target/release/health-guardian
# 后台运行(终端关闭后仍运行)
nohup ./target/release/health-guardian &
# 或使用 launchd 开机自启(推荐)
# 创建 ~/Library/LaunchAgents/com.health.guardian.plist
# 内容见下方 launchd 配置示例
```
### Linux
```bash
# 安装系统依赖Ubuntu/Debian
sudo apt install -y libdbus-1-dev pkg-config
# 编译
cargo build --release
# 后台静默运行
nohup ./target/release/health-guardian > ~/.health-guardian.log 2>&1 &
# 或注册为 systemd 用户服务(推荐,开机自启)
mkdir -p ~/.config/systemd/user/
cat > ~/.config/systemd/user/health-guardian.service << 'EOF'
[Unit]
Description=Health Guardian - Health Reminder Assistant
[Service]
ExecStart=%h/.local/bin/health-guardian
Restart=on-failure
[Install]
WantedBy=default.target
EOF
cp ./target/release/health-guardian ~/.local/bin/
systemctl --user enable --now health-guardian
```
### Windows
```powershell
# 确保已安装 Rusthttps://rustup.rs
# 直接编译(无需额外依赖)
cargo build --release
# 输出位置
.\target\release\health-guardian.exe
# 后台运行PowerShell隐藏窗口
Start-Process -FilePath ".\target\release\health-guardian.exe" -WindowStyle Hidden
# 开机自启:添加到任务计划程序
schtasks /create /tn "HealthGuardian" /tr "C:\path\to\health-guardian.exe" /sc onlogon /rl HIGHEST /f
```
### 交叉编译(在 macOS/Linux 上编译 Windows 版本)
```bash
# 添加 Windows 目标
rustup target add x86_64-pc-windows-gnu
# 安装交叉编译工具链Ubuntu
sudo apt install gcc-mingw-w64
# 交叉编译
cargo build --release --target x86_64-pc-windows-gnu
```
---
## 架构说明与扩展指引
**添加新任务类型** 只需在 `config.toml` 中追加一个 `[[tasks]]` 块即可,无需修改代码。例如添加"冥想提醒"
```toml
[[tasks]]
name = "正念冥想"
title = "🧘 冥想时间"
body = "停下手中工作,闭眼做 5 分钟正念呼吸,清空杂念。"
initial_delay_minutes = 180
[tasks.interval]
unit = "hours"
value = 3
```
**架构优势总结:**
| 特性 | 实现方式 |
|------|----------|
| 跨平台通知 | `notify-rust` 自动适配 Windows/macOS/Linux |
| 并发计时器 | `tokio::spawn` 每任务独立异步协程,互不阻塞 |
| 极低资源占用 | 全程 `async/await` sleep不轮询 CPU |
| 配置驱动 | TOML + serde零代码添加新任务 |
| 二进制极小 | release + LTO + strip约 2-4MB |

View File

@@ -0,0 +1,77 @@
# ══════════════════════════════════════════════
# 健康守护助手 · 全局配置
# ══════════════════════════════════════════════
[settings]
# 日志文件保存目录(留空则使用系统默认目录)
# macOS: ~/Library/Application Support/health-guardian/
# Linux: ~/.local/share/health-guardian/
# Windows: C:\Users\<你>\AppData\Local\health-guardian\
log_dir = "./logs"
# 提醒音频文件路径(支持 .wav / .mp3
# 留空则播放内置合成蜂鸣声(三声,每声 0.5s
sound_file = ""
# 示例(取消注释以启用):
# log_dir = "/Users/yourname/Documents/health-logs"
# sound_file = "/Users/yourname/sounds/reminder.wav"
# ══════════════════════════════════════════════
# 任务列表
# ══════════════════════════════════════════════
[[tasks]]
name = "起立活动"
title = "🧍 起立时间到!"
body = "你已久坐 1 小时,请起立活动 5 分钟,做做肩颈伸展。"
initial_delay_minutes = 60
sound = true
[tasks.interval]
unit = "hours"
value = 1
[[tasks]]
name = "补水提醒"
title = "💧 喝水时间到!"
body = "请喝一杯温水(约 200ml保持良好的水分摄入。"
initial_delay_minutes = 30
sound = true
[tasks.interval]
unit = "minutes"
value = 30
[[tasks]]
name = "眼部放松"
title = "👁️ 远眺放松眼睛!"
body = "看向 20 英尺外的物体,持续 20 秒,保护视力。"
initial_delay_minutes = 20
sound = true
[tasks.interval]
unit = "minutes"
value = 20
[[tasks]]
name = "深呼吸"
title = "🌬️ 深呼吸练习"
body = "吸气 4 秒 → 屏气 4 秒 → 呼气 6 秒,重复 3 次。"
initial_delay_minutes = 120
sound = true
[tasks.interval]
unit = "hours"
value = 2
[[tasks]]
name = "手部拉伸"
title = "🤲 手部拉伸时间"
body = "握拳展开 10 次,顺逆时针旋转手腕各 10 次。"
initial_delay_minutes = 90
sound = false
[tasks.interval]
unit = "hours"
value = 2

View File

@@ -0,0 +1,89 @@
# ══════════════════════════════════════════════
# 健康守护助手 · 全局配置
# ══════════════════════════════════════════════
[settings]
# 日志文件保存目录(留空则使用系统默认目录)
# macOS: ~/Library/Application Support/health-guardian/
# Linux: ~/.local/share/health-guardian/
# Windows: C:\Users\<你>\AppData\Local\health-guardian\
log_dir = "./logs"
# 提醒音频文件路径(支持 .wav / .mp3
# 留空则播放内置合成蜂鸣声(三声,每声 0.5s
sound_file = ""
# 示例(取消注释以启用):
# log_dir = "/Users/yourname/Documents/health-logs"
# sound_file = "/Users/yourname/sounds/reminder.wav"
# ══════════════════════════════════════════════
# 任务列表
# ══════════════════════════════════════════════
[[tasks]]
name = "起立活动"
title = "🧍 起立时间到!"
body = "你已久坐 1 小时,请起立活动 5 分钟,做做肩颈伸展。"
initial_delay_minutes = 60
sound = true
[tasks.interval]
unit = "hours"
value = 1
[[tasks]]
name = "补水提醒"
title = "💧 喝水时间到!"
body = "请喝一杯温水(约 200ml保持良好的水分摄入。"
initial_delay_minutes = 30
sound = true
[tasks.interval]
unit = "minutes"
value = 30
[[tasks]]
name = "眼部放松"
title = "👁️ 远眺放松眼睛!"
body = "看向 20 英尺外的物体,持续 20 秒,保护视力。"
initial_delay_minutes = 20
sound = true
[tasks.interval]
unit = "minutes"
value = 20
[[tasks]]
name = "深呼吸"
title = "🌬️ 深呼吸练习"
body = "吸气 4 秒 → 屏气 4 秒 → 呼气 6 秒,重复 3 次。"
initial_delay_minutes = 120
sound = true
[tasks.interval]
unit = "hours"
value = 2
[[tasks]]
name = "手部拉伸"
title = "🤲 手部拉伸时间"
body = "握拳展开 10 次,顺逆时针旋转手腕各 10 次。"
initial_delay_minutes = 90
sound = false
[tasks.interval]
unit = "hours"
value = 2
#
#[[tasks]]
#name = "测试"
#title = "To Test"
#body = "曹操"
#initial_delay_minutes = 1
#sound = true
# [tasks.interval]
# unit = "minutes"
# value = 1

Binary file not shown.

View File

@@ -0,0 +1,4 @@
════════════════════════════════════════════════════════════════════
🚀 会话开始 2026-02-21 08:52:23
════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,502 @@
use anyhow::Result;
use clap::Parser;
use colored::*;
use chrono::{DateTime, Local};
use notify_rust::Notification;
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;
// ═══════════════════════════════════════════════════════
// 数据结构
// ═══════════════════════════════════════════════════════
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "unit", content = "value", rename_all = "lowercase")]
pub enum Interval {
Hours(u64),
Minutes(u64),
}
impl Interval {
pub fn to_duration(&self) -> Duration {
match self {
Interval::Hours(h) => Duration::from_secs(h * 3600),
Interval::Minutes(m) => Duration::from_secs(m * 60),
}
}
pub fn display(&self) -> String {
match self {
Interval::Hours(h) => format!("{} 小时", h),
Interval::Minutes(m) => format!("{} 分钟", m),
}
}
pub fn next_time(&self) -> DateTime<Local> {
let secs = match self {
Interval::Hours(h) => *h * 3600,
Interval::Minutes(m) => *m * 60,
} as i64;
Local::now() + chrono::Duration::seconds(secs)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Task {
pub name: String,
pub interval: Interval,
pub title: String,
pub body: String,
#[serde(default)]
pub initial_delay_minutes: u64,
#[serde(default = "default_true")]
pub sound: bool,
}
fn default_true() -> bool { true }
/// 全局设置(从 [settings] 块读取)
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Settings {
/// 日志目录,空字符串 = 系统默认
#[serde(default)]
pub log_dir: String,
/// 音频文件路径,空字符串 = 内置蜂鸣
#[serde(default)]
pub sound_file: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
pub tasks: Vec<Task>,
}
// ═══════════════════════════════════════════════════════
// 声音播放
// ═══════════════════════════════════════════════════════
/// 播放音频文件;失败时静默降级为蜂鸣
fn play_sound_file(path: &str) {
use rodio::{Decoder, OutputStream, Sink};
use std::fs::File;
use std::io::BufReader;
let path = path.to_string();
std::thread::spawn(move || {
let try_play = || -> Result<()> {
let (_stream, handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&handle)?;
let file = File::open(&path)?;
let source = Decoder::new(BufReader::new(file))?;
sink.append(source);
sink.sleep_until_end();
Ok(())
};
if let Err(e) = try_play() {
eprintln!(" {} 音频文件播放失败({}),改用内置蜂鸣。", "".yellow(), e);
play_beep();
}
});
}
/// 内置蜂鸣:三声,每声 0.5 秒,间隔 0.15 秒
fn play_beep() {
use rodio::{OutputStream, Sink, Source};
use rodio::source::SineWave;
std::thread::spawn(|| {
if let Ok((_stream, handle)) = OutputStream::try_default() {
if let Ok(sink) = Sink::try_new(&handle) {
for _ in 0..3 {
let tone = SineWave::new(660.0)
.take_duration(Duration::from_millis(500))
.amplify(0.6);
sink.append(tone);
// 每声之间加入 150ms 静音间隔(通过空源实现)
let silence = SineWave::new(1.0)
.take_duration(Duration::from_millis(150))
.amplify(0.0);
sink.append(silence);
}
sink.sleep_until_end();
}
}
});
}
/// 统一入口:有配置文件路径则播放文件,否则蜂鸣
fn play_sound(sound_file: &str) {
if sound_file.is_empty() {
play_beep();
} else {
play_sound_file(sound_file);
}
}
// ═══════════════════════════════════════════════════════
// 日志管理器
// ═══════════════════════════════════════════════════════
#[derive(Clone)]
pub struct Logger {
log_path: PathBuf,
lock: Arc<Mutex<()>>,
}
impl Logger {
pub fn new(log_dir: &str) -> Self {
// 优先用配置指定目录,否则回退系统默认
let dir = if log_dir.is_empty() {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("health-guardian")
} else {
PathBuf::from(log_dir)
};
let _ = std::fs::create_dir_all(&dir);
let log_path = dir.join("health-guardian.log");
let logger = Self { log_path, lock: Arc::new(Mutex::new(())) };
logger.write_session_header();
logger
}
fn write_session_header(&self) {
let now = Local::now();
let line = format!(
"\n{sep}\n 🚀 会话开始 {ts}\n{sep}\n",
sep = "".repeat(68),
ts = now.format("%Y-%m-%d %H:%M:%S"),
);
self.append(&line);
}
pub fn log_reminder(&self, task: &Task, count: u32) {
let now = Local::now();
// 纯 ASCII 日志,便于 grep / 脚本处理
let line = format!(
"[{ts}] #{count:>4} {name:<12} {title}\n",
ts = now.format("%Y-%m-%d %H:%M:%S"),
count = count,
name = task.name,
title = task.title,
);
self.append(&line);
}
fn append(&self, content: &str) {
let _guard = self.lock.lock().unwrap();
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true).append(true).open(&self.log_path)
{
let _ = f.write_all(content.as_bytes());
}
}
pub fn path(&self) -> &PathBuf { &self.log_path }
}
// ═══════════════════════════════════════════════════════
// 系统通知
// ═══════════════════════════════════════════════════════
fn send_notification(task: &Task) -> Result<()> {
Notification::new()
.summary(&task.title)
.body(&task.body)
.appname("健康守护助手")
.sound_name("default")
.timeout(notify_rust::Timeout::Milliseconds(8000))
.show()?;
Ok(())
}
// ═══════════════════════════════════════════════════════
// 控制台美化输出(对齐版)
// ═══════════════════════════════════════════════════════
// 因为 emoji 在终端占 2 列,这里用纯 ASCII 列宽对齐,
// emoji 前缀单独处理,保证文字列宽正确。
/// 固定列宽:任务名(8)、间隔(10)、首次触发(8)、标题(剩余)
const COL_NAME: usize = 8;
const COL_INTV: usize = 10;
const COL_TIME: usize = 8;
const TABLE_W: usize = 62; // 边框总宽
fn pad_right(s: &str, width: usize) -> String {
// 计算 emoji/CJK 显示宽度(粗略:非 ASCII 字符算 2 列)
let display_w: usize = s.chars().map(|c| {
if c as u32 > 0x2E7F { 2 } else { 1 }
}).sum();
if display_w >= width {
s.to_string()
} else {
format!("{}{}", s, " ".repeat(width - display_w))
}
}
fn print_startup_banner(tasks: &[Task], log_path: &PathBuf, sound_file: &str) {
let now = Local::now();
let border = "".repeat(TABLE_W);
println!();
println!(" {}", format!("{}", border).cyan());
println!(" {}", "║ 🏥 健康守护助手 Health Guardian v0.3 ║".cyan());
println!(" {}", format!("{}", border).cyan());
println!();
// 基本信息
println!(" {} {}", "启动时间 :".dimmed(), now.format("%Y-%m-%d %H:%M:%S").to_string().yellow());
println!(" {} {}", "日志文件 :".dimmed(), log_path.display().to_string().blue().underline());
let sound_display = if sound_file.is_empty() {
"内置蜂鸣(三声 × 0.5s".to_string()
} else {
sound_file.to_string()
};
println!(" {} {}", "提醒音效 :".dimmed(), sound_display.bright_magenta());
println!();
// 表格头
let h_sep = format!("{:─<4}{:─<w1$}{:─<w2$}{:─<w3$}{:─<16}",
"", "", "", "", "",
w1 = COL_NAME + 2, w2 = COL_INTV + 2, w3 = COL_TIME + 2);
let h_hdr = format!("{n:<3}{a:<w1$}{b:<w2$}{c:<w3$}{d:<14}",
n = "#",
a = pad_right("任务名称", COL_NAME),
b = pad_right("间隔", COL_INTV),
c = pad_right("首次触发", COL_TIME),
d = "提醒标题",
w1 = COL_NAME, w2 = COL_INTV, w3 = COL_TIME);
let h_div = format!("{:─<4}{:─<w1$}{:─<w2$}{:─<w3$}{:─<16}",
"", "", "", "", "",
w1 = COL_NAME + 2, w2 = COL_INTV + 2, w3 = COL_TIME + 2);
println!("{}", h_sep.dimmed());
println!("{}", h_hdr.bold());
println!("{}", h_div.dimmed());
for (i, task) in tasks.iter().enumerate() {
let first = Local::now()
+ chrono::Duration::minutes(task.initial_delay_minutes as i64);
let row = format!(
"{n:<3}{a:<w1$}{b:<w2$}{c:<w3$}{d:<14}",
n = format!("{}.", i + 1),
a = pad_right(&task.name, COL_NAME),
b = pad_right(&task.interval.display(), COL_INTV),
c = pad_right(&first.format("%H:%M:%S").to_string(), COL_TIME),
d = &task.title,
w1 = COL_NAME, w2 = COL_INTV, w3 = COL_TIME,
);
// 给每列着色:序号青色,名称绿色,间隔黄色,时间白色,标题亮白
println!("{}", row.normal());
}
let h_bot = format!("{:─<4}{:─<w1$}{:─<w2$}{:─<w3$}{:─<16}",
"", "", "", "", "",
w1 = COL_NAME + 2, w2 = COL_INTV + 2, w3 = COL_TIME + 2);
println!("{}", h_bot.dimmed());
println!();
println!(" {} {}", "".green(), "所有任务已启动,按 Ctrl+C 退出".dimmed());
println!();
}
fn print_reminder(task: &Task, count: u32, next_time: DateTime<Local>) {
let now = Local::now();
let line = "".repeat(TABLE_W);
println!(" {}", format!("{}", line).bright_yellow());
println!(" {} {} {}",
"".bright_yellow(),
task.title.bold().bright_white(),
format!("( 第 {} 次 )", count).dimmed(),
);
println!(" {} {}",
"".bright_yellow(),
task.body.bright_cyan(),
);
println!(" {}", format!("{}", line).bright_yellow());
println!(" {} {} {} {} {}",
"".bright_yellow(),
"⏰ 当前:".dimmed(),
now.format("%H:%M:%S").to_string().white().bold(),
"⏭ 下次:".dimmed(),
next_time.format("%H:%M:%S").to_string().green().bold(),
);
println!(" {}", format!("{}", line).bright_yellow());
println!();
}
// ═══════════════════════════════════════════════════════
// 异步任务循环
// ═══════════════════════════════════════════════════════
async fn run_task(task: Task, logger: Logger, sound_file: String) {
let delay = Duration::from_secs(task.initial_delay_minutes * 60);
if delay.as_secs() > 0 {
sleep(delay).await;
}
let mut count: u32 = 0;
loop {
count += 1;
let next_time = task.interval.next_time();
print_reminder(&task, count, next_time);
logger.log_reminder(&task, count);
if let Err(e) = send_notification(&task) {
eprintln!(" {} 通知失败 [{}]: {}", "".red(), task.name, e);
}
if task.sound {
play_sound(&sound_file);
}
sleep(task.interval.to_duration()).await;
}
}
// ═══════════════════════════════════════════════════════
// 配置加载
// ═══════════════════════════════════════════════════════
fn default_config() -> Config {
Config {
settings: Settings::default(),
tasks: vec![
Task { name: "起立活动".into(), interval: Interval::Hours(1),
title: "🧍 起立时间到!".into(),
body: "你已久坐 1 小时,请起立活动 5 分钟,做做肩颈伸展。".into(),
initial_delay_minutes: 60, sound: true },
Task { name: "补水提醒".into(), interval: Interval::Minutes(30),
title: "💧 喝水时间到!".into(),
body: "请喝一杯温水(约 200ml保持良好的水分摄入。".into(),
initial_delay_minutes: 30, sound: true },
Task { name: "眼部放松".into(), interval: Interval::Minutes(20),
title: "👁️ 远眺放松眼睛!".into(),
body: "看 20 英尺外的物体,持续 20 秒。".into(),
initial_delay_minutes: 20, sound: true },
Task { name: "深呼吸".into(), interval: Interval::Hours(2),
title: "🌬️ 深呼吸练习".into(),
body: "吸气 4 秒 → 屏气 4 秒 → 呼气 6 秒,重复 3 次。".into(),
initial_delay_minutes: 120, sound: true },
],
}
}
fn load_config(path: &PathBuf) -> Result<Config> {
let content = std::fs::read_to_string(path)?;
Ok(toml::from_str(&content)?)
}
fn find_config_file(cli_path: Option<PathBuf>) -> Option<PathBuf> {
if let Some(p) = cli_path { if p.exists() { return Some(p); } }
let local = PathBuf::from("config.toml");
if local.exists() { return Some(local); }
if let Some(dir) = dirs::config_dir() {
let p = dir.join("health-guardian").join("config.toml");
if p.exists() { return Some(p); }
}
None
}
// ═══════════════════════════════════════════════════════
// CLI
// ═══════════════════════════════════════════════════════
#[derive(Parser, Debug)]
#[command(name = "health-guardian", about = "🏥 健康守护助手 - 跨平台健康提醒工具")]
struct Cli {
#[arg(short, long, value_name = "FILE", help = "指定配置文件路径")]
config: Option<PathBuf>,
#[arg(short, long, help = "列出所有任务后退出")]
list: bool,
#[arg(short, long, help = "使用内置默认配置")]
default: bool,
#[arg(short, long, help = "立即触发所有通知(测试用)")]
test: bool,
#[arg(long, help = "显示日志文件路径后退出")]
log_path: bool,
}
// ═══════════════════════════════════════════════════════
// 主入口
// ═══════════════════════════════════════════════════════
#[tokio::main]
async fn main() {
let cli = Cli::parse();
// 加载配置
let config = if cli.default {
default_config()
} else {
match find_config_file(cli.config) {
Some(path) => match load_config(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("{} 配置解析失败: {},使用默认配置。", "".yellow(), e);
default_config()
}
},
None => default_config(),
}
};
let settings = config.settings.clone();
let logger = Logger::new(&settings.log_dir);
let sound_file = settings.sound_file.clone();
// --log-path
if cli.log_path {
println!("{}", logger.path().display());
return;
}
// --list
if cli.list {
println!("\n{}", " 📋 当前健康提醒任务".bold());
for (i, task) in config.tasks.iter().enumerate() {
println!(" {}. {}{}{}",
(i + 1).to_string().cyan(),
task.name.green(),
task.interval.display().yellow(),
task.title,
);
}
return;
}
// --test
if cli.test {
println!("\n{} 测试模式:立即触发所有通知...\n", "🧪".yellow());
for task in &config.tasks {
println!(" {} 发送: {}", "".green(), task.title);
if let Err(e) = send_notification(task) {
eprintln!(" {} 失败: {}", "".red(), e);
}
if task.sound { play_sound(&sound_file); }
tokio::time::sleep(Duration::from_millis(800)).await;
}
println!("\n{} 测试完成。", "".green());
return;
}
// 正常启动
print_startup_banner(&config.tasks, logger.path(), &sound_file);
let mut handles = Vec::new();
for task in config.tasks {
let log = logger.clone();
let sfx = sound_file.clone();
handles.push(tokio::spawn(run_task(task, log, sfx)));
}
for h in handles { let _ = h.await; }
}

3485
HuajisheHealthDaemon/v1.0/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
[workspace]
members = [
"health-core",
"health-cli",
"health-daemon",
"health-tray",
]
resolver = "2"
[workspace.package]
version = "0.4.0"
edition = "2021"
authors = ["Health Guardian Contributors"]
license = "MIT"
# 统一依赖版本,各 crate 通过 workspace = true 引用
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
notify-rust = "4"
rodio = { version = "0.19", default-features = false, features = ["wav", "mp3"] }
dirs = "5"
colored = "2"
clap = { version = "4", features = ["derive"] }
# tray-item 仅在 tray crate 中使用
tray-item = { version = "0.7" }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

View File

@@ -0,0 +1,209 @@
# 🏥 健康守护助手 · Health Guardian v0.4
跨平台健康提醒工具,支持 Windows / macOS / Linux。
## 项目结构
```
health-guardian/
├── Cargo.toml # Workspace 根
├── config.toml # 用户配置文件
├── health-core/ # 纯逻辑引擎lib
│ └── src/
│ ├── lib.rs
│ ├── config/ # 配置加载 & 结构体
│ ├── task/ # Task / Interval / TaskStats
│ ├── state/ # 线程安全运行状态 AppState
│ ├── notifier/ # 系统通知 / 声音 / 日志
│ └── scheduler/ # 异步调度引擎
├── health-cli/ # 命令行入口 → hguard
│ └── src/
│ ├── main.rs # CLI 子命令 & 主流程
│ └── display.rs # 终端美化渲染(与逻辑解耦)
├── health-daemon/ # 后台守护进程 → hguard-daemon
│ └── src/main.rs # Unix double-fork / Windows Service
└── health-tray/ # 系统托盘 UI → hguard-tray
└── src/main.rs # tray-item 跨平台托盘
```
---
## 编译
### 依赖安装
```bash
# 安装 Rust所有平台
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Linux 额外依赖
sudo apt install -y libdbus-1-dev pkg-config libasound2-dev
# AppIndicator 托盘支持Linux
sudo apt install -y libayatana-appindicator3-dev
```
### 编译所有 crate
```bash
cd health-guardian
cargo build --release
```
### 仅编译特定组件
```bash
cargo build --release -p health-cli # hguard
cargo build --release -p health-daemon # hguard-daemon
cargo build --release -p health-tray # hguard-tray
```
### 产物位置
```
target/release/hguard
target/release/hguard-daemon
target/release/hguard-tray
```
---
## 使用
### CLIhguard
```bash
# 正常启动(自动搜索 config.toml
hguard
# 指定配置文件
hguard --config /path/to/config.toml
# 子命令
hguard list # 列出所有任务
hguard test # 立即触发所有通知(测试)
hguard log-path # 显示日志文件路径
hguard status # 显示任务状态快照
hguard --default run # 使用内置默认配置启动
hguard --verbose run # 开启详细日志
```
### 后台守护hguard-daemon
```bash
# 前台运行(调试)
hguard-daemon
# Unix 后台运行double-fork
hguard-daemon --daemonize
# 停止守护进程
kill $(cat ~/.local/share/health-guardian/hguard.pid)
```
#### systemd 用户服务Linux
```bash
mkdir -p ~/.config/systemd/user/
cat > ~/.config/systemd/user/hguard.service << 'EOF'
[Unit]
Description=Health Guardian Daemon
[Service]
ExecStart=/usr/local/bin/hguard-daemon
Restart=on-failure
[Install]
WantedBy=default.target
EOF
systemctl --user enable --now hguard
journalctl --user -u hguard -f
```
#### launchdmacOS
```bash
cat > ~/Library/LaunchAgents/com.hguard.daemon.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.hguard.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/hguard-daemon</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
</dict>
</plist>
EOF
launchctl load ~/Library/LaunchAgents/com.hguard.daemon.plist
```
#### Windows 服务
```powershell
# 以管理员身份运行
sc.exe create hguard binPath="C:\path\to\hguard-daemon.exe" start=auto
sc.exe start hguard
```
### 托盘 UIhguard-tray
```bash
hguard-tray # 启动后出现托盘图标,右键显示菜单
```
---
## 配置文件
配置文件搜索顺序:
1. `--config <path>` 命令行指定
2. `./config.toml`(当前目录)
3. `~/.config/health-guardian/config.toml`
```toml
[settings]
log_dir = "" # 空 = 系统默认;可指定绝对路径
sound_file = "" # 空 = 内置蜂鸣;可指定 .wav/.mp3 路径
[[tasks]]
name = "补水提醒"
title = "💧 喝水时间到!"
body = "请喝一杯温水(约 200ml。"
initial_delay_minutes = 30 # 启动后首次触发延迟(分钟)
sound = true # 是否播放声音
[tasks.interval]
unit = "minutes" # "hours" 或 "minutes"
value = 30
```
---
## 日志格式
```
════════════════════════════════════════════════════════════════════════
🚀 会话开始 2026-02-18 09:00:00
════════════════════════════════════════════════════════════════════════
[2026-02-18 09:20:00] # 1 眼部放松 👁️ 远眺放松眼睛!
[2026-02-18 09:30:00] # 1 补水提醒 💧 喝水时间到!
[2026-02-18 09:40:00] # 2 眼部放松 👁️ 远眺放松眼睛!
```
## 声音说明
| 场景 | 行为 |
|------|------|
| `sound_file = ""` | 内置合成蜂鸣:三声 × 0.5s,间隔 0.15s,频率 660 Hz |
| `sound_file = "/path/to/x.wav"` | 播放指定文件,失败自动降级为蜂鸣 |
| 任务 `sound = false` | 完全静音 |

View File

@@ -0,0 +1,111 @@
# ══════════════════════════════════════════════════════════
# 健康守护助手 · config.toml
# Health Guardian v0.4
# ══════════════════════════════════════════════════════════
# ── 全局设置 ──────────────────────────────────────────────
[settings]
# 日志文件保存目录
# 留空 = 系统默认目录:
# macOS : ~/Library/Application Support/health-guardian/
# Linux : ~/.local/share/health-guardian/
# Windows : C:\Users\<你>\AppData\Local\health-guardian\
#
# 自定义示例:
# log_dir = "/Users/yourname/Documents/health-logs"
# log_dir = "C:\\Users\\yourname\\health-logs"
log_dir = "./logs"
# 提醒音频文件路径(支持 .wav / .mp3
# 留空 = 内置合成蜂鸣(三声,每声 0.5s,频率 660 Hz
#
# 自定义示例:
# sound_file = "/Users/yourname/sounds/ding.wav"
# sound_file = "C:\\Users\\yourname\\sounds\\ding.mp3"
sound_file = ""
# ── 任务列表 ──────────────────────────────────────────────
# 每个 [[tasks]] 块定义一个独立的健康提醒任务。
# interval.unit 可选 "hours" 或 "minutes"。
# initial_delay_minutes程序启动后首次触发前的等待时间分钟
# sound是否为此任务播放声音true / false默认 true
[[tasks]]
name = "起立活动"
title = "🧍 起立时间到!"
body = "你已久坐 1 小时,请起立活动 5 分钟,做做肩颈伸展。"
initial_delay_minutes = 60
sound = true
[tasks.interval]
unit = "hours"
value = 1
[[tasks]]
name = "补水提醒"
title = "💧 喝水时间到!"
body = "请喝一杯温水(约 200ml保持良好的水分摄入。"
initial_delay_minutes = 30
sound = true
[tasks.interval]
unit = "minutes"
value = 30
[[tasks]]
name = "眼部放松"
title = "👁️ 远眺放松眼睛!"
body = "看向 20 英尺外的物体,持续 20 秒,保护视力。"
initial_delay_minutes = 20
sound = true
[tasks.interval]
unit = "minutes"
value = 20
[[tasks]]
name = "深呼吸"
title = "🌬️ 深呼吸练习"
body = "吸气 4 秒 → 屏气 4 秒 → 呼气 6 秒,重复 3 次。"
initial_delay_minutes = 120
sound = true
[tasks.interval]
unit = "hours"
value = 2
[[tasks]]
name = "手部拉伸"
title = "🤲 手部拉伸时间"
body = "握拳展开 10 次,顺逆时针旋转手腕各 10 次。"
initial_delay_minutes = 90
sound = false # 静音任务示例
[tasks.interval]
unit = "hours"
value = 2
# ── 扩展任务示例(默认注释,取消注释即启用) ──────────────
# [[tasks]]
# name = "正念冥想"
# title = "🧘 冥想时间"
# body = "停下手中工作,闭眼做 5 分钟正念呼吸,清空杂念。"
# initial_delay_minutes = 180
# sound = true
#
# [tasks.interval]
# unit = "hours"
# value = 3
# [[tasks]]
# name = "午后活力"
# title = "⚡ 午后提神小运动"
# body = "做 10 个深蹲或原地高抬腿 30 秒,赶走午后困倦!"
# initial_delay_minutes = 240
# sound = true
#
# [tasks.interval]
# unit = "hours"
# value = 4

View File

@@ -0,0 +1,19 @@
[package]
name = "health-cli"
description = "Command-line interface for Health Guardian"
version.workspace = true
edition.workspace = true
[[bin]]
name = "hguard"
path = "src/main.rs"
[dependencies]
health-core = { path = "../health-core" }
tokio = { workspace = true }
clap = { workspace = true }
colored = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,218 @@
//! 终端美化输出模块
//! 所有 console 渲染逻辑集中在此,与调度逻辑完全解耦。
use chrono::{DateTime, Local};
use colored::*;
use health_core::prelude::{Task, TaskStats};
use std::path::PathBuf;
// ─────────────────────────────────────────────
// 列宽常量按显示宽度CJK/emoji = 2 列)
// ─────────────────────────────────────────────
const COL_IDX: usize = 3; // 序号列
const COL_NAME: usize = 9; // 任务名称
const COL_INTV: usize = 11; // 间隔
const COL_TIME: usize = 8; // 首次触发时间
const COL_TTL: usize = 18; // 标题列(可变宽,仅作最小参考)
const BANNER_W: usize = 64; // 整体边框宽度
// ─────────────────────────────────────────────
// 辅助:计算字符串终端显示宽度
// ─────────────────────────────────────────────
fn display_width(s: &str) -> usize {
s.chars().map(|c| {
let cp = c as u32;
// CJK 统一表意文字、全角符号、部分 emoji 范围 → 2 列
if cp > 0x2E7F { 2 } else { 1 }
}).sum()
}
/// 右填充空格,使显示宽度达到 `width`(不足则补空格,超出不截断)
fn pad(s: &str, width: usize) -> String {
let w = display_width(s);
if w >= width { s.to_string() } else { format!("{}{}", s, " ".repeat(width - w)) }
}
// ─────────────────────────────────────────────
// 启动 Banner
// ─────────────────────────────────────────────
pub fn print_startup_banner(
tasks: &[Task],
log_path: &PathBuf,
sound_file: &str,
) {
let now = Local::now();
let border_top = format!("{}", "".repeat(BANNER_W));
let border_bot = format!("{}", "".repeat(BANNER_W));
let title_line = format!(
"{:^width$}",
"🏥 健康守护助手 Health Guardian v0.4",
width = BANNER_W + 2 // emoji 偏移补偿
);
println!();
println!(" {}", border_top.cyan());
println!(" {}", title_line.cyan());
println!(" {}", border_bot.cyan());
println!();
println!(" {} {}", "启动时间 :".dimmed(), now.format("%Y-%m-%d %H:%M:%S").to_string().yellow());
println!(" {} {}", "日志文件 :".dimmed(), log_path.display().to_string().blue().underline());
let sound_label = if sound_file.is_empty() {
"内置蜂鸣(三声 × 0.5s".bright_magenta()
} else {
sound_file.bright_magenta()
};
println!(" {} {}", "提醒音效 :".dimmed(), sound_label);
println!();
// ── 表格 ──────────────────────────────────
print_task_table(tasks);
println!(" {} {}", "".green().bold(), "所有任务已启动,按 Ctrl+C 退出".dimmed());
println!();
}
/// 渲染任务列表表格(启动时 & --list 命令复用)
pub fn print_task_table(tasks: &[Task]) {
// 列宽辅助闭包
let sep = |widths: &[usize], corners: (&str, &str, &str, &str)| {
let (l, m, r, h) = corners;
let cols: Vec<String> = widths.iter().map(|w| h.repeat(w + 2)).collect();
format!(" {}{}{}", l, cols.join(m), r)
};
let widths = [COL_IDX, COL_NAME, COL_INTV, COL_TIME, COL_TTL];
let top = sep(&widths, ("", "", "", ""));
let mid = sep(&widths, ("", "", "", ""));
let bot = sep(&widths, ("", "", "", ""));
println!("{}", top.dimmed());
// 表头
println!(
"{}{}{}{}{}",
pad("#", COL_IDX).dimmed().bold(),
pad("任务名称", COL_NAME).dimmed().bold(),
pad("间隔", COL_INTV).dimmed().bold(),
pad("首次触发", COL_TIME).dimmed().bold(),
pad("提醒标题", COL_TTL).dimmed().bold(),
);
println!("{}", mid.dimmed());
for (i, task) in tasks.iter().enumerate() {
let first = Local::now()
+ chrono::Duration::minutes(task.initial_delay_minutes as i64);
let idx = format!("{}.", i + 1);
let time = first.format("%H:%M:%S").to_string();
println!(
"{}{}{}{}{}",
pad(&idx, COL_IDX).cyan(),
pad(&task.name, COL_NAME).green(),
pad(&task.interval.display(), COL_INTV).yellow(),
pad(&time, COL_TIME).white(),
pad(&task.title, COL_TTL).bright_white(),
);
}
println!("{}", bot.dimmed());
println!();
}
// ─────────────────────────────────────────────
// 任务触发提示框
// ─────────────────────────────────────────────
pub fn print_reminder(task: &Task, count: u32, next_time: DateTime<Local>) {
let now = Local::now();
let line = "".repeat(BANNER_W);
println!();
println!(" {}", format!("{}", line).bright_yellow());
println!(
" {} {} {}",
"".bright_yellow(),
task.title.bold().bright_white(),
format!("( 第 {} 次 )", count).dimmed(),
);
println!(
" {} {}",
"".bright_yellow(),
task.body.bright_cyan(),
);
println!(" {}", format!("{}", line).bright_yellow());
println!(
" {} {} {} {} {}",
"".bright_yellow(),
"⏰ 当前:".dimmed(),
now.format("%H:%M:%S").to_string().white().bold(),
"⏭ 下次:".dimmed(),
next_time.format("%H:%M:%S").to_string().green().bold(),
);
println!(" {}", format!("{}", line).bright_yellow());
println!();
}
// ─────────────────────────────────────────────
// 实时状态摘要(--status 命令)
// ─────────────────────────────────────────────
pub fn print_status_table(stats: &[TaskStats]) {
if stats.is_empty() {
println!(" {}", "暂无运行中的任务。".dimmed());
return;
}
let col_n = 9;
let col_c = 6;
let col_l = 19;
let col_x = 19;
let sep = |corners: (&str, &str, &str)| {
format!(
" {}{}{}{}{}{}{}{}{}",
corners.0,
"".repeat(col_n + 2), corners.1,
"".repeat(col_c + 2), corners.1,
"".repeat(col_l + 2), corners.1,
"".repeat(col_x + 2), corners.2,
)
};
println!();
println!(" {}", "📊 当前任务运行状态".bold());
println!("{}", sep(("","","")).dimmed());
println!(
"{}{}{}{}",
pad("任务名称", col_n).dimmed().bold(),
pad("次数", col_c).dimmed().bold(),
pad("上次触发", col_l).dimmed().bold(),
pad("下次触发", col_x).dimmed().bold(),
);
println!("{}", sep(("","","")).dimmed());
let mut sorted = stats.to_vec();
sorted.sort_by_key(|s| s.task_name.clone());
for s in &sorted {
let last = s.last_fired
.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "".to_string());
let next = s.next_fire.format("%Y-%m-%d %H:%M:%S").to_string();
println!(
"{}{}{}{}",
pad(&s.task_name, col_n).green(),
pad(&s.fire_count.to_string(), col_c).yellow(),
pad(&last, col_l).dimmed(),
pad(&next, col_x).bright_green(),
);
}
println!("{}", sep(("","","")).dimmed());
println!();
}

View File

@@ -0,0 +1,221 @@
mod display;
use anyhow::Result;
use chrono::Local;
use clap::{Parser, Subcommand};
use health_core::prelude::*;
use std::path::PathBuf;
use std::time::Duration;
use tokio::time::sleep;
use tracing::Level;
// ═══════════════════════════════════════════════════════
// CLI 定义
// ═══════════════════════════════════════════════════════
#[derive(Parser)]
#[command(
name = "hguard",
version = "0.4.0",
about = "🏥 健康守护助手 — 跨平台健康提醒工具",
long_about = None,
)]
struct Cli {
/// 指定配置文件路径(默认自动搜索 config.toml
#[arg(short, long, value_name = "FILE", global = true)]
config: Option<PathBuf>,
/// 使用内置默认配置(忽略配置文件)
#[arg(short, long, global = true)]
default: bool,
/// 开启详细日志输出tracing
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// 启动健康守护助手(默认行为)
Run,
/// 列出所有已配置的任务
List,
/// 立即触发所有任务通知一次(测试用)
Test,
/// 显示日志文件路径
LogPath,
/// 显示当前运行状态(需配合 daemon 使用,此处展示静态快照)
Status,
}
// ═══════════════════════════════════════════════════════
// 主入口
// ═══════════════════════════════════════════════════════
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// 初始化 tracing
let log_level = if cli.verbose { Level::DEBUG } else { Level::WARN };
tracing_subscriber::fmt()
.with_max_level(log_level)
.with_target(false)
.init();
// 加载配置
let app_config = load_app_config(cli.default, cli.config.clone());
let command = cli.command.unwrap_or(Commands::Run);
match command {
Commands::List => cmd_list(&app_config),
Commands::LogPath => cmd_log_path(&app_config),
Commands::Test => cmd_test(&app_config).await,
Commands::Status => cmd_status(&app_config),
Commands::Run => cmd_run(app_config).await,
}
}
// ═══════════════════════════════════════════════════════
// 子命令实现
// ═══════════════════════════════════════════════════════
/// list — 展示任务表格
fn cmd_list(config: &AppConfig) -> Result<()> {
println!("\n {}", "📋 已配置的健康提醒任务".bold());
display::print_task_table(&config.tasks);
Ok(())
}
/// log-path — 输出日志路径
fn cmd_log_path(config: &AppConfig) -> Result<()> {
let logger = FileLogger::new(&config.settings.log_dir);
println!("{}", logger.path().display());
Ok(())
}
/// test — 立即触发所有通知一次
async fn cmd_test(config: &AppConfig) -> Result<()> {
use colored::*;
println!("\n{} 测试模式:立即触发所有通知…\n", "🧪".yellow());
let scheduler = build_scheduler(config);
scheduler.fire_all_once().await;
println!("{} 测试完成。", "".green());
Ok(())
}
/// status — 打印调度状态快照
fn cmd_status(config: &AppConfig) -> Result<()> {
// CLI 独立运行时无法获取实时状态,展示首次触发时间静态预测
let now = Local::now();
let stats: Vec<TaskStats> = config.tasks.iter().map(|t| {
TaskStats::new(t)
}).collect();
println!("\n {}", format!("当前时间: {}", now.format("%Y-%m-%d %H:%M:%S")).dimmed());
display::print_status_table(&stats);
Ok(())
}
/// run — 正常启动调度循环
async fn cmd_run(config: AppConfig) -> Result<()> {
let scheduler = build_scheduler(&config);
let log_path = scheduler.log_path().clone();
let sound_file = config.settings.sound_file.clone();
let state = scheduler.state();
// 打印启动 banner
display::print_startup_banner(&config.tasks, &log_path, &sound_file);
// 注入调度钩子:每次触发时打印彩色提醒框
// 通过独立 task 监听 state 变化实现(轻量轮询,间隔 500ms
let state_for_display = state.clone();
let tasks_meta: Vec<(String, String, String)> = config.tasks.iter()
.map(|t| (t.name.clone(), t.title.clone(), t.body.clone()))
.collect();
tokio::spawn(display_watcher(state_for_display, tasks_meta));
// 启动调度器(阻塞)
scheduler.run().await;
Ok(())
}
// ═══════════════════════════════════════════════════════
// 辅助函数
// ═══════════════════════════════════════════════════════
fn load_app_config(use_default: bool, cli_path: Option<PathBuf>) -> AppConfig {
use colored::*;
if use_default {
return AppConfig::default_config();
}
match AppConfig::find_config_file(cli_path) {
Some(path) => {
match AppConfig::from_file(&path) {
Ok(c) => {
println!(" {} {}", "配置文件 :".dimmed(), path.display().to_string().blue());
c
}
Err(e) => {
eprintln!("{} 配置解析失败: {},使用默认配置。", "".yellow(), e);
AppConfig::default_config()
}
}
}
None => AppConfig::default_config(),
}
}
fn build_scheduler(config: &AppConfig) -> Scheduler {
Scheduler::new(config.tasks.clone(), config.settings.clone())
}
/// 后台 display 监听器:轮询 AppState检测 fire_count 变化并渲染提醒框
async fn display_watcher(
state: AppState,
tasks_meta: Vec<(String, String, String)>,
) {
use std::collections::HashMap;
let mut last_counts: HashMap<String, u32> = HashMap::new();
loop {
sleep(Duration::from_millis(300)).await;
let snapshot = state.snapshot();
for s in &snapshot {
let prev = last_counts.get(&s.task_name).copied().unwrap_or(0);
if s.fire_count > prev {
// 找到对应 task 元数据
if let Some((_, title, body)) = tasks_meta.iter()
.find(|(n, _, _)| n == &s.task_name)
{
// 构造临时 Task 用于渲染(避免跨模块持有完整 Task
let fake_task = health_core::task::Task {
name: s.task_name.clone(),
interval: health_core::task::Interval::Minutes(30),
title: title.clone(),
body: body.clone(),
initial_delay_minutes: 0,
sound: false,
};
display::print_reminder(&fake_task, s.fire_count, s.next_fire);
}
last_counts.insert(s.task_name.clone(), s.fire_count);
}
}
}
}
// 让 colored 的 .bold() 等方法对 &str 可用
use colored::Colorize;

View File

@@ -0,0 +1,16 @@
[package]
name = "health-core"
description = "Core logic engine for Health Guardian"
version.workspace = true
edition.workspace = true
[dependencies]
tokio = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
notify-rust = { workspace = true }
rodio = { workspace = true }
dirs = { workspace = true }

View File

@@ -0,0 +1,104 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::task::{Interval, Task};
// ─────────────────────────────────────────────
// 全局设置
// ─────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Settings {
/// 日志目录,空字符串 = 系统默认
#[serde(default)]
pub log_dir: String,
/// 音频文件路径(.wav/.mp3空字符串 = 内置蜂鸣
#[serde(default)]
pub sound_file: String,
}
// ─────────────────────────────────────────────
// 顶层配置
// ─────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppConfig {
#[serde(default)]
pub settings: Settings,
pub tasks: Vec<Task>,
}
impl AppConfig {
/// 从 TOML 文件加载配置
pub fn from_file(path: &PathBuf) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("无法读取配置文件: {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("配置文件格式错误: {}", path.display()))
}
/// 内置默认配置(无配置文件时使用)
pub fn default_config() -> Self {
Self {
settings: Settings::default(),
tasks: vec![
Task {
name: "起立活动".into(),
interval: Interval::Hours(1),
title: "🧍 起立时间到!".into(),
body: "你已久坐 1 小时,请起立活动 5 分钟,做做肩颈伸展。".into(),
initial_delay_minutes: 60,
sound: true,
},
Task {
name: "补水提醒".into(),
interval: Interval::Minutes(30),
title: "💧 喝水时间到!".into(),
body: "请喝一杯温水(约 200ml保持良好的水分摄入。".into(),
initial_delay_minutes: 30,
sound: true,
},
Task {
name: "眼部放松".into(),
interval: Interval::Minutes(20),
title: "👁️ 远眺放松眼睛!".into(),
body: "看向 20 英尺外的物体,持续 20 秒,保护视力。".into(),
initial_delay_minutes: 20,
sound: true,
},
Task {
name: "深呼吸".into(),
interval: Interval::Hours(2),
title: "🌬️ 深呼吸练习".into(),
body: "吸气 4 秒 → 屏气 4 秒 → 呼气 6 秒,重复 3 次。".into(),
initial_delay_minutes: 120,
sound: true,
},
Task {
name: "手部拉伸".into(),
interval: Interval::Hours(2),
title: "🤲 手部拉伸时间".into(),
body: "握拳展开 10 次,顺逆时针旋转手腕各 10 次。".into(),
initial_delay_minutes: 90,
sound: false,
},
],
}
}
/// 自动搜索配置文件路径CLI 指定 → 当前目录 → 用户配置目录)
pub fn find_config_file(cli_path: Option<PathBuf>) -> Option<PathBuf> {
if let Some(p) = cli_path {
if p.exists() { return Some(p); }
}
let local = PathBuf::from("config.toml");
if local.exists() { return Some(local); }
if let Some(dir) = dirs::config_dir() {
let p = dir.join("health-guardian").join("config.toml");
if p.exists() { return Some(p); }
}
None
}
}

View File

@@ -0,0 +1,23 @@
//! # health-core
//!
//! 健康守护助手的纯逻辑引擎,提供:
//! - 任务与间隔定义(`task`
//! - 配置加载与验证(`config`
//! - 全局运行状态(`state`
//! - 通知、声音、日志(`notifier`
//! - 异步调度引擎(`scheduler`
pub mod config;
pub mod notifier;
pub mod scheduler;
pub mod state;
pub mod task;
// 方便外部 crate 直接 use health_core::prelude::*
pub mod prelude {
pub use crate::config::{AppConfig, Settings};
pub use crate::notifier::FileLogger;
pub use crate::scheduler::Scheduler;
pub use crate::state::AppState;
pub use crate::task::{Interval, Task, TaskStats};
}

View File

@@ -0,0 +1,150 @@
use anyhow::Result;
use chrono::Local;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{error, warn};
use crate::task::Task;
// ─────────────────────────────────────────────
// 系统通知
// ─────────────────────────────────────────────
pub fn send_system_notification(task: &Task) -> Result<()> {
notify_rust::Notification::new()
.summary(&task.title)
.body(&task.body)
.appname("健康守护助手")
.sound_name("default")
.timeout(notify_rust::Timeout::Milliseconds(8000))
.show()?;
Ok(())
}
// ─────────────────────────────────────────────
// 声音播放
// ─────────────────────────────────────────────
/// 播放外部音频文件wav / mp3失败时降级为内置蜂鸣
pub fn play_sound_file(path: &str) {
use rodio::{Decoder, OutputStream, Sink};
use std::fs::File;
use std::io::BufReader;
let path = path.to_string();
std::thread::spawn(move || {
let result = (|| -> Result<()> {
let (_stream, handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&handle)?;
let file = File::open(&path)?;
let source = Decoder::new(BufReader::new(file))?;
sink.append(source);
sink.sleep_until_end();
Ok(())
})();
if let Err(e) = result {
warn!("音频文件播放失败({}),降级为内置蜂鸣", e);
play_beep();
}
});
}
/// 内置合成蜂鸣:三声,每声 0.5 秒,间隔 0.15 秒,频率 660 Hz
pub fn play_beep() {
use rodio::{OutputStream, Sink, Source};
use rodio::source::SineWave;
std::thread::spawn(|| {
if let Ok((_stream, handle)) = OutputStream::try_default() {
if let Ok(sink) = Sink::try_new(&handle) {
for _ in 0..3 {
let tone = SineWave::new(660.0)
.take_duration(Duration::from_millis(500))
.amplify(0.6);
let gap = SineWave::new(1.0)
.take_duration(Duration::from_millis(150))
.amplify(0.0);
sink.append(tone);
sink.append(gap);
}
sink.sleep_until_end();
}
}
});
}
/// 统一入口:有路径则播放文件,否则蜂鸣
pub fn play_sound(sound_file: &str) {
if sound_file.is_empty() {
play_beep();
} else {
play_sound_file(sound_file);
}
}
// ─────────────────────────────────────────────
// 文件日志记录器
// ─────────────────────────────────────────────
#[derive(Clone)]
pub struct FileLogger {
log_path: PathBuf,
lock: Arc<Mutex<()>>,
}
impl FileLogger {
/// 根据 log_dir 初始化日志(空字符串 = 系统默认目录)
pub fn new(log_dir: &str) -> Self {
let dir = if log_dir.is_empty() {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("health-guardian")
} else {
PathBuf::from(log_dir)
};
let _ = std::fs::create_dir_all(&dir);
let log_path = dir.join("health-guardian.log");
let logger = Self { log_path, lock: Arc::new(Mutex::new(())) };
logger.write_session_header();
logger
}
fn write_session_header(&self) {
let now = Local::now();
let line = format!(
"\n{sep}\n 🚀 会话开始 {ts}\n{sep}\n",
sep = "".repeat(72),
ts = now.format("%Y-%m-%d %H:%M:%S"),
);
self.append(&line);
}
/// 记录一次任务触发
pub fn log_fire(&self, task: &Task, count: u32) {
let now = Local::now();
let line = format!(
"[{ts}] #{count:>4} {name:<12} {title}\n",
ts = now.format("%Y-%m-%d %H:%M:%S"),
count = count,
name = task.name,
title = task.title,
);
self.append(&line);
}
fn append(&self, content: &str) {
let _g = self.lock.lock().unwrap();
match std::fs::OpenOptions::new()
.create(true).append(true).open(&self.log_path)
{
Ok(mut f) => { let _ = f.write_all(content.as_bytes()); }
Err(e) => error!("日志写入失败: {}", e),
}
}
pub fn path(&self) -> &PathBuf { &self.log_path }
}

View File

@@ -0,0 +1,136 @@
use std::time::Duration;
use tokio::time::sleep;
use tracing::{error, info};
use crate::{
config::Settings,
notifier::{play_sound, send_system_notification, FileLogger},
state::AppState,
task::Task,
};
// ─────────────────────────────────────────────
// 调度事件(可扩展钩子)
// ─────────────────────────────────────────────
/// 每次任务触发时发出的事件,调用方可监听并自定义处理
#[derive(Debug, Clone)]
pub struct FireEvent {
pub task_name: String,
pub task_title: String,
pub task_body: String,
pub fire_count: u32,
}
// ─────────────────────────────────────────────
// 调度引擎
// ─────────────────────────────────────────────
/// 核心调度器:持有所有任务与共享设置
pub struct Scheduler {
tasks: Vec<Task>,
settings: Settings,
state: AppState,
logger: FileLogger,
}
impl Scheduler {
pub fn new(tasks: Vec<Task>, settings: Settings) -> Self {
let logger = FileLogger::new(&settings.log_dir);
let state = AppState::new();
// 注册任务初始状态
for task in &tasks {
state.register_task(crate::task::TaskStats::new(task));
}
Self { tasks, settings, state, logger }
}
/// 返回共享状态引用(供 CLI / tray 查询)
pub fn state(&self) -> AppState { self.state.clone() }
/// 返回日志路径
pub fn log_path(&self) -> &std::path::PathBuf { self.logger.path() }
/// 启动所有任务的异步调度循环(阻塞直到所有任务退出)
pub async fn run(self) {
let mut handles = Vec::new();
let settings = self.settings.clone();
let logger = self.logger.clone();
let state = self.state.clone();
for task in self.tasks {
let s = settings.clone();
let l = logger.clone();
let st = state.clone();
handles.push(tokio::spawn(run_single_task(task, s, l, st)));
}
for h in handles {
if let Err(e) = h.await {
error!("任务崩溃: {:?}", e);
}
}
}
/// 立即触发所有任务一次(用于测试)
pub async fn fire_all_once(&self) {
for task in &self.tasks {
fire_task(task, &self.settings.sound_file, &self.logger, 0).await;
}
}
}
// ─────────────────────────────────────────────
// 单任务循环(在独立 tokio 任务中运行)
// ─────────────────────────────────────────────
async fn run_single_task(
task: Task,
settings: Settings,
logger: FileLogger,
state: AppState,
) {
// 首次延迟
let initial = Duration::from_secs(task.initial_delay_minutes * 60);
if !initial.is_zero() {
info!("[{}] 将在 {} 分钟后首次触发", task.name, task.initial_delay_minutes);
sleep(initial).await;
}
let mut count: u32 = 0;
loop {
count += 1;
let next = task.interval.next_trigger_from_now();
// 更新全局状态
state.record_fire(&task.name, next);
// 触发通知 / 声音 / 日志
fire_task(&task, &settings.sound_file, &logger, count).await;
sleep(task.interval.to_duration()).await;
}
}
/// 单次触发逻辑(抽离以供测试模式复用)
pub async fn fire_task(
task: &Task,
sound_file: &str,
logger: &FileLogger,
count: u32,
) {
info!("[{}] 第 {} 次触发: {}", task.name, count, task.title);
logger.log_fire(task, count);
if let Err(e) = send_system_notification(task) {
error!("[{}] 系统通知失败: {}", task.name, e);
}
if task.sound {
play_sound(sound_file);
}
}

View File

@@ -0,0 +1,71 @@
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use crate::task::TaskStats;
// ─────────────────────────────────────────────
// 应用全局状态(线程安全共享)
// ─────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct AppState {
inner: Arc<RwLock<StateInner>>,
}
#[derive(Debug)]
struct StateInner {
/// key = task.name
stats: HashMap<String, TaskStats>,
running: bool,
}
impl AppState {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(StateInner {
stats: HashMap::new(),
running: true,
})),
}
}
/// 注册一个任务的初始统计
pub fn register_task(&self, stats: TaskStats) {
let mut w = self.inner.write().unwrap();
w.stats.insert(stats.task_name.clone(), stats);
}
/// 更新任务触发记录
pub fn record_fire(&self, task_name: &str, next: chrono::DateTime<chrono::Local>) {
let mut w = self.inner.write().unwrap();
if let Some(s) = w.stats.get_mut(task_name) {
s.record_fire(next);
}
}
/// 读取所有任务统计(克隆快照)
pub fn snapshot(&self) -> Vec<TaskStats> {
let r = self.inner.read().unwrap();
r.stats.values().cloned().collect()
}
/// 读取单个任务统计
pub fn get_stats(&self, task_name: &str) -> Option<TaskStats> {
let r = self.inner.read().unwrap();
r.stats.get(task_name).cloned()
}
/// 是否仍在运行
pub fn is_running(&self) -> bool {
self.inner.read().unwrap().running
}
/// 请求优雅停止
pub fn request_stop(&self) {
self.inner.write().unwrap().running = false;
}
}
impl Default for AppState {
fn default() -> Self { Self::new() }
}

View File

@@ -0,0 +1,101 @@
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
use std::time::Duration;
// ─────────────────────────────────────────────
// 时间间隔
// ─────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "unit", content = "value", rename_all = "lowercase")]
pub enum Interval {
Hours(u64),
Minutes(u64),
}
impl Interval {
/// 转换为标准库 Duration
pub fn to_duration(&self) -> Duration {
match self {
Interval::Hours(h) => Duration::from_secs(h * 3600),
Interval::Minutes(m) => Duration::from_secs(m * 60),
}
}
/// 人类可读展示(中文)
pub fn display(&self) -> String {
match self {
Interval::Hours(h) => format!("{} 小时", h),
Interval::Minutes(m) => format!("{} 分钟", m),
}
}
/// 基于当前时间计算下次触发时刻
pub fn next_trigger_from_now(&self) -> DateTime<Local> {
let secs = match self {
Interval::Hours(h) => *h * 3600,
Interval::Minutes(m) => *m * 60,
} as i64;
Local::now() + chrono::Duration::seconds(secs)
}
}
// ─────────────────────────────────────────────
// 健康任务(核心可扩展结构体)
// ─────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Task {
/// 任务名称,用于展示与日志标识
pub name: String,
/// 提醒间隔
pub interval: Interval,
/// 系统通知标题
pub title: String,
/// 系统通知正文
pub body: String,
/// 首次触发前的延迟分钟0 = 立即触发
#[serde(default)]
pub initial_delay_minutes: u64,
/// 是否播放声音(默认 true
#[serde(default = "default_true")]
pub sound: bool,
}
fn default_true() -> bool { true }
// ─────────────────────────────────────────────
// 任务运行时统计
// ─────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct TaskStats {
pub task_name: String,
pub fire_count: u32,
pub last_fired: Option<DateTime<Local>>,
pub next_fire: DateTime<Local>,
}
impl TaskStats {
pub fn new(task: &Task) -> Self {
let next = Local::now()
+ chrono::Duration::minutes(task.initial_delay_minutes as i64);
Self {
task_name: task.name.clone(),
fire_count: 0,
last_fired: None,
next_fire: next,
}
}
pub fn record_fire(&mut self, next: DateTime<Local>) {
self.fire_count += 1;
self.last_fired = Some(Local::now());
self.next_fire = next;
}
}

View File

@@ -0,0 +1,24 @@
[package]
name = "health-daemon"
description = "Background daemon service for Health Guardian"
version.workspace = true
edition.workspace = true
[[bin]]
name = "hguard-daemon"
path = "src/main.rs"
[dependencies]
health-core = { path = "../health-core" }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dirs = { workspace = true }
clap = { workspace = true }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.27", features = ["process", "signal"] }
[target.'cfg(windows)'.dependencies]
windows-service = "0.6"

View File

@@ -0,0 +1,225 @@
//! # health-daemon
//!
//! 后台守护进程入口。
//!
//! ## Unix / macOS
//! 调用双 fork 使进程脱离终端,写入 PID 文件,随后运行调度器。
//!
//! ## Windows
//! 注册为 Windows Service可通过服务管理器启停。
//!
//! ## 使用
//! ```bash
//! hguard-daemon # 前台运行(调试用)
//! hguard-daemon --daemonize # Unix 后台运行
//! hguard-daemon --config ./cfg.toml # 指定配置
//! ```
use anyhow::Result;
use health_core::prelude::*;
use std::path::PathBuf;
use tracing::{error, info, Level};
// ─────────────────────────────────────────────
// CLI简化版daemon 不需要富文本交互)
// ─────────────────────────────────────────────
use clap::Parser;
#[derive(Parser)]
#[command(name = "hguard-daemon", about = "🏥 健康守护助手 — 后台守护进程")]
struct Cli {
#[arg(short, long, value_name = "FILE")]
config: Option<PathBuf>,
#[arg(short, long)]
default: bool,
/// Unix 平台fork 到后台运行
#[cfg(unix)]
#[arg(long)]
daemonize: bool,
#[arg(short, long)]
verbose: bool,
}
// ─────────────────────────────────────────────
// 主入口
// ─────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// 初始化日志
let level = if cli.verbose { Level::DEBUG } else { Level::INFO };
tracing_subscriber::fmt()
.with_max_level(level)
.with_target(false)
.without_time()
.init();
// Unix后台化
#[cfg(unix)]
if cli.daemonize {
daemonize()?;
}
// Windows非服务模式直接前台运行
// 如需注册为 Windows Service请参考 README 中的 sc.exe 说明
#[cfg(windows)]
{
// windows-service 的 service_dispatcher 需在服务安装后才能使用
// 开发/调试阶段直接走下方的调度器启动流程
}
// 加载配置
let config = load_config(cli.default, cli.config);
info!("加载配置完成,共 {} 个任务", config.tasks.len());
// 写入 PID 文件Unix
#[cfg(unix)]
write_pid_file()?;
// 启动调度器
let scheduler = Scheduler::new(config.tasks, config.settings);
info!("健康守护进程启动,日志路径: {}", scheduler.log_path().display());
scheduler.run().await;
Ok(())
}
// ─────────────────────────────────────────────
// 配置加载
// ─────────────────────────────────────────────
fn load_config(use_default: bool, cli_path: Option<PathBuf>) -> AppConfig {
if use_default { return AppConfig::default_config(); }
match AppConfig::find_config_file(cli_path) {
Some(path) => AppConfig::from_file(&path).unwrap_or_else(|e| {
error!("配置解析失败: {},使用默认配置", e);
AppConfig::default_config()
}),
None => {
info!("未找到配置文件,使用默认配置");
AppConfig::default_config()
}
}
}
// ─────────────────────────────────────────────
// Unix Daemonize双 fork
// ─────────────────────────────────────────────
#[cfg(unix)]
fn daemonize() -> Result<()> {
use nix::unistd::{fork, ForkResult, setsid};
use std::process;
// 第一次 fork
match unsafe { fork()? } {
ForkResult::Parent { .. } => process::exit(0),
ForkResult::Child => {}
}
// 创建新会话,脱离控制终端
setsid()?;
// 第二次 fork防止重新获取终端
match unsafe { fork()? } {
ForkResult::Parent { .. } => process::exit(0),
ForkResult::Child => {}
}
// 重定向标准 I/O 到 /dev/null
use std::fs::OpenOptions;
let dev_null = OpenOptions::new().read(true).write(true).open("/dev/null")?;
let fd = std::os::unix::io::IntoRawFd::into_raw_fd(dev_null);
unsafe {
libc_redirect(fd);
}
info!("进程已切换到后台运行PID = {}", std::process::id());
Ok(())
}
#[cfg(unix)]
unsafe fn libc_redirect(null_fd: i32) {
// 将 stdin / stdout / stderr 重定向到 /dev/null
for target_fd in [0i32, 1, 2] {
libc::dup2(null_fd, target_fd);
}
libc::close(null_fd);
}
#[cfg(unix)]
fn write_pid_file() -> Result<()> {
use std::io::Write;
let pid_dir = dirs::runtime_dir()
.or_else(|| dirs::data_local_dir())
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("health-guardian");
std::fs::create_dir_all(&pid_dir)?;
let pid_path = pid_dir.join("hguard.pid");
let mut f = std::fs::File::create(&pid_path)?;
writeln!(f, "{}", std::process::id())?;
info!("PID 文件写入: {}", pid_path.display());
Ok(())
}
// ─────────────────────────────────────────────
// Windows Service 桩
// ─────────────────────────────────────────────
//
// Windows Service 完整集成说明:
//
// 如需将本程序注册为 Windows Service请执行
// sc.exe create hguard binPath="C:\path\to\hguard-daemon.exe" start=auto
// sc.exe start hguard
//
// Service 内部需要使用 windows-service crate 的 service_dispatcher
// 并在独立线程中启动 tokio runtime 运行调度器。
// 当前版本直接前台/后台运行Windows Service 集成作为后续扩展。
#[cfg(windows)]
mod windows_svc {
use std::ffi::OsString;
use std::time::Duration;
use windows_service::{
define_windows_service,
service::{
ServiceControl, ServiceControlAccept, ServiceExitCode,
ServiceState, ServiceStatus, ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
};
define_windows_service!(ffi_service_main, service_main);
fn service_main(_args: Vec<OsString>) {
let handler = move |control| match control {
ServiceControl::Stop => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
};
let status_handle = service_control_handler::register("hguard", handler).unwrap();
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
}).unwrap();
loop {
std::thread::sleep(Duration::from_secs(60));
}
}
}
#[cfg(unix)]
extern crate libc;

View File

@@ -0,0 +1,22 @@
[package]
name = "health-tray"
description = "System tray UI for Health Guardian"
version.workspace = true
edition.workspace = true
[[bin]]
name = "hguard-tray"
path = "src/main.rs"
[build-dependencies]
embed-resource = "2.4"
[dependencies]
health-core = { path = "../health-core" }
tokio = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tray-item = { version = "0.7" }

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,8 @@
fn main() {
// 检查是否为 Windows 目标
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
if target_os == "windows" {
// 第二个参数传入空的数组引用,表示不定义额外的宏
embed_resource::compile("resources.rc", [] as [&str; 0]);
}
}

View File

@@ -0,0 +1,2 @@
// ID_NAME RESOURCE_TYPE PATH
main-icon ICON "assets/favicon.ico"

View File

@@ -0,0 +1,183 @@
//! # health-tray — 系统托盘 UI
//!
//! tray-item 0.7 API:
//! TrayItem::new(title: &str, icon: &str) -> Result<Self, TIError>
//! Windows icon = .rc 资源名称字符串
//! macOS icon = 资源名称字符串
//! Linux icon = 图标主题名称字符串(如 "applications-system"
use anyhow::Result;
use health_core::prelude::*;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use tracing::{info, Level};
// ─────────────────────────────────────────────
// 消息通道
// ─────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum TrayMessage {
TaskFired { name: String, title: String, count: u32 },
Quit,
}
// ─────────────────────────────────────────────
// 主入口
// ─────────────────────────────────────────────
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(Level::INFO)
.with_target(false)
.init();
let config = load_config();
let quit_flag = Arc::new(AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel::<TrayMessage>();
let tx_sched = tx.clone();
let qf = quit_flag.clone();
let tasks = config.tasks.clone();
let settings = config.settings.clone();
// 后台线程运行调度器tokio runtime
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("tokio runtime 创建失败");
rt.block_on(run_scheduler(tasks, settings, tx_sched, qf));
});
// 主线程运行托盘 UImacOS 要求必须在主线程)
run_tray_ui(rx, tx, &config)
}
// ─────────────────────────────────────────────
// 调度器(带事件钩子)
// ─────────────────────────────────────────────
async fn run_scheduler(
tasks: Vec<Task>,
settings: Settings,
tx: std::sync::mpsc::Sender<TrayMessage>,
quit: Arc<AtomicBool>,
) {
use health_core::notifier::{play_sound, send_system_notification, FileLogger};
use std::time::Duration;
use tokio::time::sleep;
let logger = FileLogger::new(&settings.log_dir);
let sfx = settings.sound_file.clone();
let mut handles = Vec::new();
for task in tasks {
let (tx2, logger, sfx, quit) =
(tx.clone(), logger.clone(), sfx.clone(), quit.clone());
handles.push(tokio::spawn(async move {
let delay = Duration::from_secs(task.initial_delay_minutes * 60);
if !delay.is_zero() { sleep(delay).await; }
let mut count = 0u32;
loop {
if quit.load(Ordering::Relaxed) { break; }
count += 1;
logger.log_fire(&task, count);
let _ = send_system_notification(&task);
if task.sound { play_sound(&sfx); }
let _ = tx2.send(TrayMessage::TaskFired {
name: task.name.clone(),
title: task.title.clone(),
count,
});
sleep(task.interval.to_duration()).await;
}
}));
}
for h in handles { let _ = h.await; }
}
// ─────────────────────────────────────────────
// 托盘 UI — tray-item 0.7 正确 API
// ─────────────────────────────────────────────
fn run_tray_ui(
rx: std::sync::mpsc::Receiver<TrayMessage>,
tx: std::sync::mpsc::Sender<TrayMessage>,
config: &AppConfig,
) -> Result<()> {
use tray_item::TrayItem;
// 1. 初始化托盘
let mut tray = TrayItem::new("健康守护助手", "main-icon")?;
// 设置全局 Tooltip鼠标悬停在托盘图标上显示的内容
// 注意:某些版本的 tray-item 可能需要通过内部实现调用,如果报错可忽略此行
// tray.set_tooltip("健康守护:运行中")?;
tray.add_menu_item("🏥 [任务列表 - 点击手动触发]", move || {})?;
tray.inner_mut().add_separator()?;
// 2. 将 label 改为 menu_item
for task in &config.tasks {
let label = format!("{} ({})", task.name, task.interval.display());
let tx_trigger = tx.clone();
let task_name = task.name.clone();
let task_title = task.title.clone();
// 使用 add_menu_item 代替 add_label
tray.add_menu_item(&label, move || {
info!("手动触发任务: {}", task_name);
// 发送消息回主循环,模拟一次触发
let _ = tx_trigger.send(TrayMessage::TaskFired {
name: task_name.clone(),
title: task_title.clone(),
count: 999, // 标识为手动触发
});
})?;
}
tray.inner_mut().add_separator()?; // 增加分割线(取决于库版本支持情况)
// 退出按钮
let tx_quit = tx.clone();
tray.add_menu_item("🚪 退出程序", move || {
let _ = tx_quit.send(TrayMessage::Quit);
})?;
info!("托盘菜单已生成,任务项现在是可点击状态");
// 事件循环
loop {
match rx.recv() {
Ok(TrayMessage::Quit) => {
info!("收到退出信号");
break;
}
Ok(TrayMessage::TaskFired { name, title, count }) => {
// 这里可以调用系统通知,告诉用户“点击已生效”
let display_count = if count == 999 { "手动".to_string() } else { count.to_string() };
info!("[{}] 触发成功: {}", name, title);
// 提示如果你想更新菜单文字tray-item 0.7 并不直接支持。
// 建议点击后通过 send_system_notification 弹窗提示“休息开始”。
}
Err(_) => break,
}
}
Ok(())
}
// ─────────────────────────────────────────────
// 配置加载
// ─────────────────────────────────────────────
fn load_config() -> AppConfig {
match AppConfig::find_config_file(None) {
Some(p) => AppConfig::from_file(&p).unwrap_or_else(|_| AppConfig::default_config()),
None => AppConfig::default_config(),
}
}

View File

@@ -0,0 +1,111 @@
# ══════════════════════════════════════════════════════════
# 健康守护助手 · config.toml
# Health Guardian v0.4
# ══════════════════════════════════════════════════════════
# ── 全局设置 ──────────────────────────────────────────────
[settings]
# 日志文件保存目录
# 留空 = 系统默认目录:
# macOS : ~/Library/Application Support/health-guardian/
# Linux : ~/.local/share/health-guardian/
# Windows : C:\Users\<你>\AppData\Local\health-guardian\
#
# 自定义示例:
# log_dir = "/Users/yourname/Documents/health-logs"
# log_dir = "C:\\Users\\yourname\\health-logs"
log_dir = "./logs"
# 提醒音频文件路径(支持 .wav / .mp3
# 留空 = 内置合成蜂鸣(三声,每声 0.5s,频率 660 Hz
#
# 自定义示例:
# sound_file = "/Users/yourname/sounds/ding.wav"
# sound_file = "C:\\Users\\yourname\\sounds\\ding.mp3"
sound_file = ""
# ── 任务列表 ──────────────────────────────────────────────
# 每个 [[tasks]] 块定义一个独立的健康提醒任务。
# interval.unit 可选 "hours" 或 "minutes"。
# initial_delay_minutes程序启动后首次触发前的等待时间分钟
# sound是否为此任务播放声音true / false默认 true
[[tasks]]
name = "起立活动"
title = "🧍 起立时间到!"
body = "你已久坐 1 小时,请起立活动 5 分钟,做做肩颈伸展。"
initial_delay_minutes = 60
sound = true
[tasks.interval]
unit = "hours"
value = 1
[[tasks]]
name = "补水提醒"
title = "💧 喝水时间到!"
body = "请喝一杯温水(约 200ml保持良好的水分摄入。"
initial_delay_minutes = 30
sound = true
[tasks.interval]
unit = "minutes"
value = 30
[[tasks]]
name = "眼部放松"
title = "👁️ 远眺放松眼睛!"
body = "看向 20 英尺外的物体,持续 20 秒,保护视力。"
initial_delay_minutes = 20
sound = true
[tasks.interval]
unit = "minutes"
value = 20
[[tasks]]
name = "深呼吸"
title = "🌬️ 深呼吸练习"
body = "吸气 4 秒 → 屏气 4 秒 → 呼气 6 秒,重复 3 次。"
initial_delay_minutes = 120
sound = true
[tasks.interval]
unit = "hours"
value = 2
[[tasks]]
name = "手部拉伸"
title = "🤲 手部拉伸时间"
body = "握拳展开 10 次,顺逆时针旋转手腕各 10 次。"
initial_delay_minutes = 90
sound = false # 静音任务示例
[tasks.interval]
unit = "hours"
value = 2
# ── 扩展任务示例(默认注释,取消注释即启用) ──────────────
# [[tasks]]
# name = "正念冥想"
# title = "🧘 冥想时间"
# body = "停下手中工作,闭眼做 5 分钟正念呼吸,清空杂念。"
# initial_delay_minutes = 180
# sound = true
#
# [tasks.interval]
# unit = "hours"
# value = 3
# [[tasks]]
# name = "午后活力"
# title = "⚡ 午后提神小运动"
# body = "做 10 个深蹲或原地高抬腿 30 秒,赶走午后困倦!"
# initial_delay_minutes = 240
# sound = true
#
# [tasks.interval]
# unit = "hours"
# value = 4

Binary file not shown.

Binary file not shown.

Binary file not shown.