Skip to content

Commit fd3cf0c

Browse files
authored
Codex release 1 4 0 break flow (#5)
* feat: add idle-aware break skip challenge * fix: add cross-target preflight checks * fix: resolve windows clippy issues * fix: block countdown reload menu
1 parent c48f9d2 commit fd3cf0c

15 files changed

Lines changed: 912 additions & 141 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "restgap"
3-
version = "1.3.0"
3+
version = "1.4.1"
44
edition = "2024"
55
authors = ["iwangjie <345127857@qq.com>"]
66
description = "息间(RestGap)— 跨平台休息提醒应用(macOS/Windows/Linux)"
@@ -84,7 +84,9 @@ windows = { version = "0.58", features = [
8484
"Win32_Foundation",
8585
"Win32_Globalization",
8686
"Win32_System_LibraryLoader",
87+
"Win32_System_SystemInformation",
8788
"Win32_System_Threading",
89+
"Win32_UI_Input_KeyboardAndMouse",
8890
"Win32_UI_WindowsAndMessaging",
8991
"Win32_UI_Shell",
9092
"Win32_Graphics_Gdi",

scripts/release-preflight.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,38 @@ if ! command -v cargo >/dev/null 2>&1; then
1313
exit 1
1414
fi
1515

16+
if ! command -v rustup >/dev/null 2>&1; then
17+
echo "rustup is required but not found in PATH."
18+
exit 1
19+
fi
20+
21+
host_target="$(rustc -vV | awk '/host:/ {print $2}')"
22+
23+
resolve_extra_targets() {
24+
if [[ -n "${RESTGAP_EXTRA_TARGETS:-}" ]]; then
25+
printf '%s\n' ${RESTGAP_EXTRA_TARGETS}
26+
return
27+
fi
28+
29+
rustup target list --installed | while read -r target; do
30+
if [[ "${target}" != "${host_target}" ]]; then
31+
printf '%s\n' "${target}"
32+
fi
33+
done
34+
}
35+
1636
echo "Running release preflight..."
1737
cargo fmt --check
1838
cargo clippy --all-targets --all-features -- -D warnings
39+
40+
while read -r target; do
41+
if [[ -z "${target}" ]]; then
42+
continue
43+
fi
44+
echo "Running cross-target checks for ${target}..."
45+
cargo clippy --target "${target}" --all-targets --all-features -- -D warnings
46+
cargo check --target "${target}" --all-targets
47+
done < <(resolve_extra_targets)
48+
1949
cargo test
2050
cargo build --release

src/i18n.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,41 @@ impl Texts {
338338
Language::Zh => "放松眼睛,伸展身体",
339339
}
340340
}
341+
342+
pub const fn countdown_skip_title(&self) -> &'static str {
343+
match self.lang {
344+
Language::En => "Type this sentence to skip",
345+
Language::Zh => "输入这句话才可跳过",
346+
}
347+
}
348+
349+
pub fn countdown_skip_progress(&self, matched: usize, total: usize) -> String {
350+
match self.lang {
351+
Language::En => format!("Matched {matched}/{total} · each character must be within 2s"),
352+
Language::Zh => format!("已匹配 {matched}/{total} · 相邻字符间隔不能超过 2 秒"),
353+
}
354+
}
355+
356+
pub const fn countdown_skip_timeout(&self) -> &'static str {
357+
match self.lang {
358+
Language::En => "Timed out. Restart from the beginning.",
359+
Language::Zh => "输入超时,请从头开始。",
360+
}
361+
}
362+
363+
pub const fn countdown_skip_mismatch(&self) -> &'static str {
364+
match self.lang {
365+
Language::En => "Mismatch. Restart from the beginning.",
366+
Language::Zh => "输入不匹配,请从头开始。",
367+
}
368+
}
369+
370+
pub const fn countdown_skip_success(&self) -> &'static str {
371+
match self.lang {
372+
Language::En => "Matched. Skipping this break...",
373+
Language::Zh => "匹配完成,正在跳过本次休息……",
374+
}
375+
}
341376
}
342377

343378
pub fn detect_system_language() -> Language {

src/idle.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//! 低功耗闲置跳过判定。
2+
//!
3+
//! 仅在工作阶段结束时查询一次系统空闲时长,不做持续轮询。
4+
5+
use std::time::Duration;
6+
7+
const MAX_ALLOWED_ACTIVE_TIME: Duration = Duration::from_secs(8);
8+
9+
pub fn should_skip_break(cycle_elapsed: Duration) -> bool {
10+
let Some(idle_duration) = current_idle_duration() else {
11+
return false;
12+
};
13+
should_skip_break_with_idle(cycle_elapsed, idle_duration)
14+
}
15+
16+
fn should_skip_break_with_idle(cycle_elapsed: Duration, idle_duration: Duration) -> bool {
17+
if cycle_elapsed <= MAX_ALLOWED_ACTIVE_TIME {
18+
return false;
19+
}
20+
21+
idle_duration + MAX_ALLOWED_ACTIVE_TIME >= cycle_elapsed
22+
}
23+
24+
#[cfg(target_os = "macos")]
25+
#[allow(unsafe_code)]
26+
fn current_idle_duration() -> Option<Duration> {
27+
use std::time::Duration;
28+
29+
type CGEventSourceStateID = u32;
30+
type CGEventType = u32;
31+
32+
const K_CG_EVENT_SOURCE_STATE_COMBINED_SESSION_STATE: CGEventSourceStateID = 0;
33+
const K_CG_ANY_INPUT_EVENT_TYPE: CGEventType = !0;
34+
35+
#[link(name = "ApplicationServices", kind = "framework")]
36+
unsafe extern "C" {
37+
fn CGEventSourceSecondsSinceLastEventType(
38+
source: CGEventSourceStateID,
39+
event_type: CGEventType,
40+
) -> f64;
41+
}
42+
43+
let seconds = unsafe {
44+
CGEventSourceSecondsSinceLastEventType(
45+
K_CG_EVENT_SOURCE_STATE_COMBINED_SESSION_STATE,
46+
K_CG_ANY_INPUT_EVENT_TYPE,
47+
)
48+
};
49+
50+
if !seconds.is_finite() || seconds.is_sign_negative() {
51+
return None;
52+
}
53+
54+
Some(Duration::from_secs_f64(seconds))
55+
}
56+
57+
#[cfg(target_os = "windows")]
58+
#[allow(unsafe_code)]
59+
fn current_idle_duration() -> Option<Duration> {
60+
use windows::Win32::System::SystemInformation::GetTickCount;
61+
use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
62+
63+
let mut last_input = LASTINPUTINFO {
64+
cbSize: u32::try_from(std::mem::size_of::<LASTINPUTINFO>()).ok()?,
65+
..Default::default()
66+
};
67+
68+
if !unsafe { GetLastInputInfo(&raw mut last_input) }.as_bool() {
69+
return None;
70+
}
71+
72+
let now = unsafe { GetTickCount() };
73+
let idle_ms = now.wrapping_sub(last_input.dwTime);
74+
Some(Duration::from_millis(u64::from(idle_ms)))
75+
}
76+
77+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
78+
const fn current_idle_duration() -> Option<Duration> {
79+
None
80+
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use super::*;
85+
86+
#[test]
87+
fn skips_only_when_idle_almost_covers_cycle() {
88+
let cycle = Duration::from_secs(30 * 60);
89+
let almost_all_idle = cycle.checked_sub(Duration::from_secs(5)).unwrap();
90+
assert!(should_skip_break_with_idle(cycle, almost_all_idle));
91+
assert!(!should_skip_break_with_idle(
92+
cycle,
93+
cycle.checked_sub(Duration::from_secs(20)).unwrap()
94+
));
95+
}
96+
}

src/linux/mod.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,29 @@ pub fn run() {
106106
} else {
107107
let remaining = state.time_until_break();
108108
if remaining == Duration::ZERO {
109-
// Time for a break!
110-
state.is_breaking = true;
111-
state.break_start = Some(Instant::now());
112-
let break_secs = state.config.break_seconds;
113-
match lang {
114-
Language::Zh => println!("\n休息时间!请休息 {break_secs} 秒\n"),
115-
Language::En => {
116-
println!("\nBreak time! Please rest for {break_secs} seconds\n");
109+
if crate::idle::should_skip_break(state.work_start.elapsed()) {
110+
state.work_start = Instant::now();
111+
match lang {
112+
Language::Zh => println!("\n本轮几乎无操作,已跳过本次休息。\n"),
113+
Language::En => {
114+
println!("\nThis cycle was nearly idle, so the break was skipped.\n");
115+
}
116+
}
117+
} else {
118+
// Time for a break!
119+
state.is_breaking = true;
120+
state.break_start = Some(Instant::now());
121+
let break_secs = state.config.break_seconds;
122+
match lang {
123+
Language::Zh => println!("\n休息时间!请休息 {break_secs} 秒\n"),
124+
Language::En => {
125+
println!("\nBreak time! Please rest for {break_secs} seconds\n");
126+
}
117127
}
118-
}
119128

120-
// In a full implementation, this would show a fullscreen window
121-
// For now, we just print to console
129+
// In a full implementation, this would show a fullscreen window
130+
// For now, we just print to console
131+
}
122132
} else if remaining.as_secs() % 60 == 0 && remaining.as_secs() > 0 {
123133
// Print update every minute
124134
let minutes = remaining.as_secs() / 60;

src/macos/state.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use objc2_foundation::NSTimer;
1212
use objc2_web_kit::WKWebView;
1313

1414
use super::config::Config;
15+
use crate::skip_challenge::SkipChallenge;
1516

1617
/// 工作阶段
1718
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -32,6 +33,7 @@ pub enum NotifyEvent {
3233
pub struct AppState {
3334
pub config: Config,
3435
pub phase: Phase,
36+
pub phase_started_at_mono: Option<Instant>,
3537
pub phase_deadline_mono: Option<Instant>,
3638
pub phase_deadline_wall: Option<SystemTime>,
3739
pub timer: Option<Retained<NSTimer>>,
@@ -51,10 +53,9 @@ pub struct AppState {
5153
pub countdown_webviews: Vec<Retained<WKWebView>>,
5254
pub countdown_timer: Option<Retained<NSTimer>>,
5355
pub countdown_end_time: Option<Instant>,
54-
// Hidden skip phrase state (only used during breaks)
56+
// 跳过挑战状态(仅在休息时使用)
5557
pub countdown_key_monitor: Option<Retained<AnyObject>>,
56-
pub countdown_skip_smart_idx: usize,
57-
pub countdown_skip_ascii_idx: usize,
58+
pub countdown_skip_challenge: Option<SkipChallenge>,
5859
pub countdown_skip_requested: bool,
5960
}
6061

@@ -63,6 +64,7 @@ impl AppState {
6364
Self {
6465
config,
6566
phase: Phase::Working,
67+
phase_started_at_mono: None,
6668
phase_deadline_mono: None,
6769
phase_deadline_wall: None,
6870
timer: None,
@@ -82,8 +84,7 @@ impl AppState {
8284
countdown_timer: None,
8385
countdown_end_time: None,
8486
countdown_key_monitor: None,
85-
countdown_skip_smart_idx: 0,
86-
countdown_skip_ascii_idx: 0,
87+
countdown_skip_challenge: None,
8788
countdown_skip_requested: false,
8889
}
8990
}

src/macos/timer.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub fn schedule_phase(delegate: &RestGapDelegate, phase: Phase) {
2323
}
2424

2525
state.phase = phase;
26+
let started_at = Instant::now();
2627
let (duration, tolerance) = match phase {
2728
Phase::Working => (state.config.work_interval(), state.config.work_tolerance()),
2829
Phase::Breaking => (
@@ -31,7 +32,8 @@ pub fn schedule_phase(delegate: &RestGapDelegate, phase: Phase) {
3132
),
3233
};
3334

34-
state.phase_deadline_mono = Some(Instant::now() + duration);
35+
state.phase_started_at_mono = Some(started_at);
36+
state.phase_deadline_mono = Some(started_at + duration);
3537
state.phase_deadline_wall = Some(SystemTime::now() + duration);
3638

3739
(duration.as_secs_f64(), tolerance.as_secs_f64())
@@ -74,16 +76,28 @@ fn notify(event: NotifyEvent, config: &Config, delegate: &RestGapDelegate) {
7476

7577
/// 定时器触发时的阶段转换
7678
pub fn transition_on_timer(delegate: &RestGapDelegate) {
77-
let (next_phase, event, config) = with_state(|state| {
79+
let transition = with_state(|state| {
7880
state.timer.take();
7981
let config = state.config.clone();
8082
match state.phase {
81-
Phase::Working => (Phase::Breaking, NotifyEvent::BreakStart, config),
82-
Phase::Breaking => (Phase::Working, NotifyEvent::BreakEnd, config),
83+
Phase::Working => {
84+
let should_skip = state
85+
.phase_started_at_mono
86+
.is_some_and(|started_at| crate::idle::should_skip_break(started_at.elapsed()));
87+
if should_skip {
88+
(Phase::Working, None, config)
89+
} else {
90+
(Phase::Breaking, Some(NotifyEvent::BreakStart), config)
91+
}
92+
}
93+
Phase::Breaking => (Phase::Working, Some(NotifyEvent::BreakEnd), config),
8394
}
8495
});
8596

86-
notify(event, &config, delegate);
97+
let (next_phase, event, config) = transition;
98+
if let Some(event) = event {
99+
notify(event, &config, delegate);
100+
}
87101
schedule_phase(delegate, next_phase);
88102
}
89103

0 commit comments

Comments
 (0)