Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5a8291c
feat: add event emission abstraction for scheduler testing
pilgrimlyieu Nov 9, 2025
453fdb3
feat: add mini break counter to `SchedulerStatus` for tracking long b…
pilgrimlyieu Nov 9, 2025
c84e2ce
build: add test utils & update `build.rs` for Windows testing
pilgrimlyieu Nov 9, 2025
3a0170c
feat: add runtime and event emitter generics for dependency Injection
pilgrimlyieu Nov 9, 2025
bf65e1b
feat(test): add test helpers and utilities for scheduler testing
pilgrimlyieu Nov 10, 2025
1ec2e23
refactor: refactor and extract methods for better testing
pilgrimlyieu Nov 10, 2025
60c1ddb
test: add comprehensive tests for pause reasons and session management
pilgrimlyieu Nov 10, 2025
4b76581
test: add unit tests for attention timer and enhance test helpers
pilgrimlyieu Nov 10, 2025
162ab2b
refactor(test): add `naive_time` helper for improved readability in t…
pilgrimlyieu Nov 10, 2025
ea181a9
test: add unit tests for break scheduler
pilgrimlyieu Nov 10, 2025
8d465a5
refactor: rename break windows to prompt windows
pilgrimlyieu Nov 10, 2025
a127819
feat: implement session management for all monitors
pilgrimlyieu Nov 10, 2025
884a9ba
refactor: export utility function from `SchedulerManager` for testing
pilgrimlyieu Nov 11, 2025
bd932c0
test: add attention timer tests & update test helpers
pilgrimlyieu Nov 11, 2025
b6922f8
test: add break scheduler tests
pilgrimlyieu Nov 11, 2025
fbbe278
test: add manager integration tests
pilgrimlyieu Nov 11, 2025
13f4405
test: add monitor integration tests
pilgrimlyieu Nov 11, 2025
ada883e
test: add stress tests
pilgrimlyieu Nov 11, 2025
39baf21
refactor: avoid duplicating schedule lookup logic
pilgrimlyieu Nov 11, 2025
5f9c398
refactor: improve `build.rs` comments
pilgrimlyieu Nov 11, 2025
a655cb2
build: remove sccache build configuration
pilgrimlyieu Nov 11, 2025
497a887
refactor: reuse event emitter in test environment
pilgrimlyieu Nov 11, 2025
af8512e
build: try to fix CI errors
pilgrimlyieu Nov 11, 2025
510a4b0
refactor: optimize code readability
pilgrimlyieu Nov 11, 2025
9d2647c
build: fix CI complaining
pilgrimlyieu Nov 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
# Use lld linker on Linux and Windows for faster linking
# macOS doesn't support lld, it uses ld64
# macOS doesn't support lld.
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

[target.aarch64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
linker = "rust-lld"

[target.aarch64-pc-windows-msvc]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
linker = "rust-lld"

[env]
TS_RS_EXPORT_DIR = { value = "src/types/generated", relative = true }
# workaround needed to prevent `STATUS_ENTRYPOINT_NOT_FOUND` error in tests
# see https://github.com/tauri-apps/tauri/pull/4383#issuecomment-1212221864
__TAURI_WORKSPACE__ = "true"
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ alias fif := fix-front
alias fib := fix-back

alias ta := test-all
alias tl := test-lib
alias tfa := test-front-all
alias tf := test-front
alias tba := test-back-all
Expand Down Expand Up @@ -214,6 +215,13 @@ alias adb := add-dep-back
cargo test --manifest-path {{ RUST_DIR }}/Cargo.toml --workspace
echo "✅ Tests complete!"

# Run library tests only
[group: "test"]
@test-lib *tests:
echo "🧪 Running library tests..."
cargo test --manifest-path {{ RUST_DIR }}/Cargo.toml --workspace --lib {{ tests }}
echo "✅ Library tests complete!"

# Run all front-end tests
[group: "test"]
@test-front-all:
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ sysinfo = "0.37.2"

[dev-dependencies]
tempfile = "3.23.0"
tauri = { version = "2", features = ["test"] }
tokio = { version = "*", features = ["test-util"] }

[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
Expand Down
34 changes: 33 additions & 1 deletion src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
// https://github.com/tauri-apps/tauri/issues/13419#issuecomment-3398457618
// Fix `STATUS_ENTRYPOINT_NOT_FOUND` error on Windows when testing.
fn main() {
tauri_build::build()
#[cfg(windows)]
{
let mut attributes = tauri_build::Attributes::new();
attributes = attributes
.windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest());
add_manifest();
tauri_build::try_build(attributes).unwrap();
}
#[cfg(not(windows))]
{
tauri_build::build();
}
}

#[cfg(windows)]
fn add_manifest() {
static WINDOWS_MANIFEST_FILE: &str = "windows-app-manifest.xml";

let manifest = std::env::current_dir()
.expect("Failed to get current directory during build")
.join(WINDOWS_MANIFEST_FILE);

println!("cargo:rerun-if-changed={}", manifest.display());
// Embed the Windows application manifest file.
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!(
"cargo:rustc-link-arg=/MANIFESTINPUT:{}",
manifest.to_str().unwrap()
);
// Turn linker warnings into errors.
println!("cargo:rustc-link-arg=/WX");
}
2 changes: 1 addition & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for all windows",
"windows": ["main", "settings", "break-*"],
"windows": ["main", "settings", "break-*", "attention-*"],
"permissions": [
"core:default",
"core:event:allow-listen",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ pub use scheduler::{
};
pub use suggestions::{get_suggestions, get_suggestions_for_language, save_suggestions};
pub use system::{open_config_directory, open_log_directory};
pub use window::{close_all_break_windows, open_settings_window};
pub use window::{close_all_prompt_windows, open_settings_window};
8 changes: 4 additions & 4 deletions src-tauri/src/cmd/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ pub async fn open_settings_window<R: Runtime>(app: AppHandle<R>) -> Result<(), S
create_settings_window(&app)
}

/// Close all break windows with the given payload ID prefix
/// Close all prompt windows with the given payload ID prefix
#[allow(clippy::needless_pass_by_value)]
#[tauri::command]
pub fn close_all_break_windows<R: Runtime>(
pub fn close_all_prompt_windows<R: Runtime>(
app: AppHandle<R>,
payload_id: &str,
) -> Result<(), String> {
tracing::debug!("Closing all break windows for payload: {payload_id}");
tracing::debug!("Closing all prompt windows for payload: {payload_id}");

// Get all windows
let windows = app.webview_windows();

// Close all windows that start with the payload_id
for (label, window) in windows {
if label.starts_with(payload_id) {
tracing::debug!("Closing break window: {label}");
tracing::debug!("Closing prompt window: {label}");
window.close().unwrap_or_else(|e| {
tracing::warn!("Failed to close window {label}: {e}");
});
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/config/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub struct AppConfig {
pub theme_mode: String,
/// Shortcut to postpone breaks, e.g., "Ctrl+Shift+X"
pub postpone_shortcut: String,
/// Break window size percentage (0.1 to 1.0, where 1.0 is fullscreen)
/// Prompt window size percentage (0.1 to 1.0, where 1.0 is fullscreen)
pub window_size: f32,
/// List of schedules
pub schedules: Vec<ScheduleSettings>,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/core/suggestions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::platform::i18n::LANGUAGE_FALLBACK;

/// Settings for displaying suggestions during breaks
///
/// This controls whether suggestions are shown to the user during break windows.
/// This controls whether suggestions are shown to the user during prompt windows.
/// The actual suggestion content is managed separately in the [`SuggestionsConfig`].
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
#[serde(rename_all = "camelCase")]
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/core/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ impl Display for HexColor {
}
}

/// Resolved background for break window
/// Resolved background for prompt window
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
Expand Down Expand Up @@ -248,12 +248,12 @@ impl BackgroundSource {
}
}

/// Theme settings for break windows
/// Theme settings for prompt windows
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct ThemeSettings {
/// Background source for the break window (solid color, image path, or image folder)
/// Background source for the prompt window (solid color, image path, or image folder)
pub background: BackgroundSource,
/// Text color in hex format
pub text_color: HexColor,
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ pub fn run() {
// Add DND monitor if enabled
if app_config.monitor_dnd {
tracing::info!("DND monitoring enabled");
let dnd_monitor = monitors::DndMonitor::new(shared_state.clone());
let dnd_monitor = monitors::DndMonitor::new();
monitors.push(Box::new(dnd_monitor));
} else {
tracing::info!("DND monitoring disabled");
Expand Down Expand Up @@ -219,7 +219,7 @@ pub fn run() {
cmd::suggestions::save_suggestions,
cmd::system::open_config_directory,
cmd::system::open_log_directory,
cmd::window::close_all_break_windows,
cmd::window::close_all_prompt_windows,
cmd::window::open_settings_window,
])
.build(tauri::generate_context!())
Expand Down
66 changes: 66 additions & 0 deletions src-tauri/src/monitors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! - **Concrete monitors**: `IdleMonitor`, `DndMonitor`, `AppWhitelistMonitor`
//! - **Orchestrator**: Runs all monitors in a single task, checking at configured intervals
//! - **Action conversion**: Converts `MonitorAction` to `Command` for the scheduler
//! - **Session protection**: Unified session checking to prevent self-interference
//!
//! # Monitor Lifecycle
//!
Expand All @@ -19,12 +20,26 @@
//! 3. **Monitoring Loop**: `check()` is called periodically at the monitor's interval
//! 4. **Shutdown**: `on_stop()` is called when the monitor stops (currently unused)
//!
//! # Session Protection
//!
//! During active sessions (break or attention prompts), monitors are typically skipped
//! to avoid self-interference. For example:
//!
//! - A break window may trigger system DND mode
//! - Without session protection, `DndMonitor` would detect this and pause the scheduler
//! - This would interrupt the break, causing unexpected behavior
//!
//! **Implementation**: The orchestrator checks `SharedState::in_any_session()` before
//! calling each monitor's `check()` method. Monitors with `skip_during_session() == true`
//! (the default) are automatically skipped during sessions.
//!
//! # Design Patterns
//!
//! - **Trait-based polymorphism**: All monitors implement the same interface
//! - **Event-driven where possible**: `DndMonitor` uses OS events instead of polling
//! - **Graceful degradation**: Monitors that fail to initialize return `Unavailable`
//! - **Non-blocking**: All checks must be fast and non-blocking
//! - **Unified session protection**: Orchestrator handles session checking, not monitors
//!
//! # Example
//!
Expand Down Expand Up @@ -292,6 +307,57 @@ pub trait Monitor: Send + Sync {
fn on_stop(&mut self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async {})
}

/// Whether to skip this monitor's checks during active sessions
///
/// # Session Protection
///
/// During an active session (break window or attention prompt), the scheduler
/// is already interacting with the user. Environment changes detected by monitors
/// are often **side effects** of the session itself:
///
/// - **Break windows** may trigger system DND mode
/// - **User leaving computer** during break is expected (goal of break)
/// - **Excluded apps** may still be running during break
///
/// If monitors send Pause commands during sessions, it can cause:
/// - Self-interruption (break window triggers DND → pauses break)
/// - Unexpected behavior (user expects break to continue)
/// - State machine confusion (pausing an already-active session)
///
/// # Default Behavior
///
/// Returns `true` by default (skip checks during sessions).
/// Most monitors should keep this default.
///
/// # When to Override
///
/// Only override to return `false` if the monitor needs to detect
/// critical system conditions that should interrupt sessions
/// (e.g., low battery, system shutdown).
///
/// # Implementation Note
///
/// Session checking is enforced at the orchestrator level, not in
/// individual monitor implementations. This ensures consistent behavior
/// and reduces code duplication.
///
/// # Examples
///
/// ```rust,ignore
/// // Default: skip during sessions (most monitors)
/// fn skip_during_session(&self) -> bool {
/// true
/// }
///
/// // Override: continue during sessions (rare cases)
/// fn skip_during_session(&self) -> bool {
/// false
/// }
/// ```
fn skip_during_session(&self) -> bool {
true
}
}

/// Convert a `MonitorAction` to a `Command`
Expand Down
25 changes: 7 additions & 18 deletions src-tauri/src/monitors/dnd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ use tokio::sync::{Mutex as AsyncMutex, mpsc};
use super::{Monitor, MonitorAction, MonitorError, MonitorResult};
use crate::platform::dnd::{DndEvent, DndMonitor as PlatformDndMonitor, INTERVAL_SECS};
use crate::scheduler::models::PauseReason;
use crate::scheduler::shared_state::SharedState;

/// Debounce delay in seconds
///
Expand All @@ -39,22 +38,25 @@ pub struct DndMonitor {
reported_dnd_state: bool,
/// When the DND state last changed (for debouncing)
state_change_time: Option<Instant>,
/// Shared scheduler state (for session checking)
shared_state: SharedState,
}

impl Default for DndMonitor {
fn default() -> Self {
Self::new()
}
}

impl DndMonitor {
/// Create a new DND monitor
#[must_use]
pub fn new(shared_state: SharedState) -> Self {
pub fn new() -> Self {
Self {
platform_monitor: None,
event_rx: Arc::new(AsyncMutex::new(None)),
available: true, // Assume available, will check on start
current_dnd_state: false,
reported_dnd_state: false,
state_change_time: None,
shared_state,
}
}

Expand Down Expand Up @@ -110,19 +112,6 @@ impl Monitor for DndMonitor {
return Err(MonitorError::Unavailable);
}

// Check if in any session (break or attention)
// During sessions, we ignore DND changes to prevent self-triggering
if self.shared_state.read().in_any_session() {
// In session, ignore DND events but drain the channel
let mut event_rx_guard = self.event_rx.lock().await;
if let Some(rx) = event_rx_guard.as_mut() {
while rx.try_recv().is_ok() {
// Drain events without processing
}
}
return Ok(MonitorAction::None);
}

// Try to receive DND events (non-blocking)
let mut event_rx_guard = self.event_rx.lock().await;
if let Some(rx) = event_rx_guard.as_mut() {
Expand Down
Loading
Loading