Compare commits
1 Commits
PhotoSaver
...
HealthDaem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b52398f22f |
2648
HuajisheHealthDaemon/v0.3/Cargo.lock
generated
Normal file
2648
HuajisheHealthDaemon/v0.3/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
HuajisheHealthDaemon/v0.3/Cargo.toml
Normal file
17
HuajisheHealthDaemon/v0.3/Cargo.toml
Normal 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"] } # 音频播放
|
||||||
132
HuajisheHealthDaemon/v0.3/Readme.md
Normal file
132
HuajisheHealthDaemon/v0.3/Readme.md
Normal 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
|
||||||
|
# 确保已安装 Rust(https://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 |
|
||||||
77
HuajisheHealthDaemon/v0.3/config.toml
Normal file
77
HuajisheHealthDaemon/v0.3/config.toml
Normal 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
|
||||||
89
HuajisheHealthDaemon/v0.3/release/config.toml
Normal file
89
HuajisheHealthDaemon/v0.3/release/config.toml
Normal 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
|
||||||
BIN
HuajisheHealthDaemon/v0.3/release/health-guardian.exe
Normal file
BIN
HuajisheHealthDaemon/v0.3/release/health-guardian.exe
Normal file
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
════════════════════════════════════════════════════════════════════
|
||||||
|
🚀 会话开始 2026-02-21 08:52:23
|
||||||
|
════════════════════════════════════════════════════════════════════
|
||||||
502
HuajisheHealthDaemon/v0.3/src/main.rs
Normal file
502
HuajisheHealthDaemon/v0.3/src/main.rs
Normal 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
3485
HuajisheHealthDaemon/v1.0/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
HuajisheHealthDaemon/v1.0/Cargo.toml
Normal file
39
HuajisheHealthDaemon/v1.0/Cargo.toml
Normal 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
|
||||||
209
HuajisheHealthDaemon/v1.0/README.md
Normal file
209
HuajisheHealthDaemon/v1.0/README.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
### CLI(hguard)
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
#### launchd(macOS)
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 托盘 UI(hguard-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` | 完全静音 |
|
||||||
111
HuajisheHealthDaemon/v1.0/config.toml
Normal file
111
HuajisheHealthDaemon/v1.0/config.toml
Normal 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
|
||||||
19
HuajisheHealthDaemon/v1.0/health-cli/Cargo.toml
Normal file
19
HuajisheHealthDaemon/v1.0/health-cli/Cargo.toml
Normal 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 }
|
||||||
218
HuajisheHealthDaemon/v1.0/health-cli/src/display.rs
Normal file
218
HuajisheHealthDaemon/v1.0/health-cli/src/display.rs
Normal 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!();
|
||||||
|
}
|
||||||
221
HuajisheHealthDaemon/v1.0/health-cli/src/main.rs
Normal file
221
HuajisheHealthDaemon/v1.0/health-cli/src/main.rs
Normal 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;
|
||||||
16
HuajisheHealthDaemon/v1.0/health-core/Cargo.toml
Normal file
16
HuajisheHealthDaemon/v1.0/health-core/Cargo.toml
Normal 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 }
|
||||||
104
HuajisheHealthDaemon/v1.0/health-core/src/config/mod.rs
Normal file
104
HuajisheHealthDaemon/v1.0/health-core/src/config/mod.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
HuajisheHealthDaemon/v1.0/health-core/src/lib.rs
Normal file
23
HuajisheHealthDaemon/v1.0/health-core/src/lib.rs
Normal 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};
|
||||||
|
}
|
||||||
150
HuajisheHealthDaemon/v1.0/health-core/src/notifier/mod.rs
Normal file
150
HuajisheHealthDaemon/v1.0/health-core/src/notifier/mod.rs
Normal 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 }
|
||||||
|
}
|
||||||
136
HuajisheHealthDaemon/v1.0/health-core/src/scheduler/mod.rs
Normal file
136
HuajisheHealthDaemon/v1.0/health-core/src/scheduler/mod.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
HuajisheHealthDaemon/v1.0/health-core/src/state/mod.rs
Normal file
71
HuajisheHealthDaemon/v1.0/health-core/src/state/mod.rs
Normal 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() }
|
||||||
|
}
|
||||||
101
HuajisheHealthDaemon/v1.0/health-core/src/task/mod.rs
Normal file
101
HuajisheHealthDaemon/v1.0/health-core/src/task/mod.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
HuajisheHealthDaemon/v1.0/health-daemon/Cargo.toml
Normal file
24
HuajisheHealthDaemon/v1.0/health-daemon/Cargo.toml
Normal 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"
|
||||||
225
HuajisheHealthDaemon/v1.0/health-daemon/src/main.rs
Normal file
225
HuajisheHealthDaemon/v1.0/health-daemon/src/main.rs
Normal 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;
|
||||||
22
HuajisheHealthDaemon/v1.0/health-tray/Cargo.toml
Normal file
22
HuajisheHealthDaemon/v1.0/health-tray/Cargo.toml
Normal 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" }
|
||||||
BIN
HuajisheHealthDaemon/v1.0/health-tray/assets/favicon.ico
Normal file
BIN
HuajisheHealthDaemon/v1.0/health-tray/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
8
HuajisheHealthDaemon/v1.0/health-tray/build.rs
Normal file
8
HuajisheHealthDaemon/v1.0/health-tray/build.rs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
HuajisheHealthDaemon/v1.0/health-tray/resources.rc
Normal file
2
HuajisheHealthDaemon/v1.0/health-tray/resources.rc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// ID_NAME RESOURCE_TYPE PATH
|
||||||
|
main-icon ICON "assets/favicon.ico"
|
||||||
183
HuajisheHealthDaemon/v1.0/health-tray/src/main.rs
Normal file
183
HuajisheHealthDaemon/v1.0/health-tray/src/main.rs
Normal 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));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 主线程运行托盘 UI(macOS 要求必须在主线程)
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
111
HuajisheHealthDaemon/v1.0/release/config.toml
Normal file
111
HuajisheHealthDaemon/v1.0/release/config.toml
Normal 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
|
||||||
BIN
HuajisheHealthDaemon/v1.0/release/hguard-daemon.exe
Normal file
BIN
HuajisheHealthDaemon/v1.0/release/hguard-daemon.exe
Normal file
Binary file not shown.
BIN
HuajisheHealthDaemon/v1.0/release/hguard-tray.exe
Normal file
BIN
HuajisheHealthDaemon/v1.0/release/hguard-tray.exe
Normal file
Binary file not shown.
BIN
HuajisheHealthDaemon/v1.0/release/hguard.exe
Normal file
BIN
HuajisheHealthDaemon/v1.0/release/hguard.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user