diff --git a/crates/adapter-ipc-grpc/src/v2.rs b/crates/adapter-ipc-grpc/src/v2.rs index 9e816bc..07eede5 100644 --- a/crates/adapter-ipc-grpc/src/v2.rs +++ b/crates/adapter-ipc-grpc/src/v2.rs @@ -3,8 +3,8 @@ use std::{path::PathBuf, time::Duration}; use app_services::{ SharedControlPlaneApp, commands as app_commands, queries::{ - ConsoleSnapshot, StatusSnapshot, TransportEventSnapshot, UiDiscoveredPeer, UiPairedPeer, - UiPendingRequest, UiSnapshot, + AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, StatusSnapshot, + TransportEventSnapshot, UiDiscoveredPeer, UiPairedPeer, UiPendingRequest, UiSnapshot, }, }; use tokio::{sync::mpsc, time}; @@ -12,17 +12,18 @@ use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; use ipc_api::boundless::v1::{ - ConsoleSnapshotReply, DiagnosticsDumpReply, DiagnosticsDumpRequest, DiscoveredPeerInfo, Empty, - FeatureListReply, FeatureSetRequest, HotkeySetRequest, HotkeyTriggerRequest, - ImportTrustBundleRequest, InputCaptureTargetReply, InputCaptureTargetRequest, InputOwnerReply, - InputOwnerRequest, LayoutReply, LayoutSetRequest, NearbyJoinStartRequest, - NearbyJoinStatusReply, NearbyJoinStatusRequest, NearbyPairingCompletionReply, - NearbyPairingDecisionRequest, NearbyPairingRequestInfo, NearbyRequestCodeStartReply, - NearbyRequestCodeStartRequest, NearbySubmitCodeRequest, OperationReply, PairCreateCodeReply, - PairCreateCodeRequest, PairJoinReply, PairJoinRequest, PeerInfo, PeerListReply, - RemovePeerRequest, SafeResetRequest, SendClipboardImageRequest, SendClipboardTextRequest, - SendFileRequest, SendInputKeyRequest, SendInputMoveRequest, StatusReply, StatusRequest, - TransportEvent, TransportEventsReply, TrustBundleReply, UiSnapshotReply, + AntiIdleConfigReply, AntiIdleSetRequest, AntiIdleStatusReply, ConsoleSnapshotReply, + DiagnosticsDumpReply, DiagnosticsDumpRequest, DiscoveredPeerInfo, Empty, FeatureListReply, + FeatureSetRequest, HotkeySetRequest, HotkeyTriggerRequest, ImportTrustBundleRequest, + InputCaptureTargetReply, InputCaptureTargetRequest, InputOwnerReply, InputOwnerRequest, + LayoutReply, LayoutSetRequest, NearbyJoinStartRequest, NearbyJoinStatusReply, + NearbyJoinStatusRequest, NearbyPairingCompletionReply, NearbyPairingDecisionRequest, + NearbyPairingRequestInfo, NearbyRequestCodeStartReply, NearbyRequestCodeStartRequest, + NearbySubmitCodeRequest, OperationReply, PairCreateCodeReply, PairCreateCodeRequest, + PairJoinReply, PairJoinRequest, PeerInfo, PeerListReply, RemovePeerRequest, SafeResetRequest, + SendClipboardImageRequest, SendClipboardTextRequest, SendFileRequest, SendInputKeyRequest, + SendInputMoveRequest, StatusReply, StatusRequest, TransportEvent, TransportEventsReply, + TrustBundleReply, UiSnapshotReply, control_plane_service_server::{ControlPlaneService, ControlPlaneServiceServer}, }; @@ -240,6 +241,51 @@ impl ControlPlaneService for ControlPlaneApi { })) } + async fn get_anti_idle_config( + &self, + _request: Request, + ) -> Result, Status> { + let snapshot = self + .app + .anti_idle_config() + .await + .map_err(|error| Status::internal(format!("build anti-idle config: {error:#}")))?; + Ok(Response::new(map_anti_idle_config(snapshot))) + } + + async fn get_anti_idle_status( + &self, + _request: Request, + ) -> Result, Status> { + let snapshot = self + .app + .anti_idle_status() + .await + .map_err(|error| Status::internal(format!("build anti-idle status: {error:#}")))?; + Ok(Response::new(map_anti_idle_status(snapshot))) + } + + async fn set_anti_idle_config( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let reply = self + .app + .set_anti_idle_config(app_commands::SetAntiIdleConfigCommand { + enabled: request.enabled, + recent_activity_window_secs: request.recent_activity_window_secs, + allow_on_battery: request.allow_on_battery, + keep_display_on: request.keep_display_on, + }) + .await + .map_err(|error| Status::invalid_argument(error.to_string()))?; + Ok(Response::new(OperationReply { + ok: reply.ok, + message: reply.message, + })) + } + async fn set_hotkey( &self, request: Request, @@ -790,6 +836,8 @@ fn map_ui_snapshot(snapshot: UiSnapshot) -> UiSnapshotReply { .into_iter() .map(map_pending_request) .collect(), + anti_idle_config: Some(map_anti_idle_config(snapshot.anti_idle_config)), + anti_idle_status: Some(map_anti_idle_status(snapshot.anti_idle_status)), } } @@ -812,6 +860,8 @@ fn map_console_snapshot(snapshot: ConsoleSnapshot) -> ConsoleSnapshotReply { input_capture_target_peer_id: snapshot.input_capture_target_peer_id.unwrap_or_default(), mdns_active: snapshot.mdns_active, local_display_name: snapshot.local_display_name, + anti_idle_config: Some(map_anti_idle_config(snapshot.anti_idle_config)), + anti_idle_status: Some(map_anti_idle_status(snapshot.anti_idle_status)), } } @@ -828,6 +878,29 @@ fn map_status_snapshot(snapshot: StatusSnapshot) -> StatusReply { input_locked: snapshot.input_locked, input_lock_supported: snapshot.input_lock_supported, capture_target_peer_id: snapshot.capture_target_peer_id.unwrap_or_default(), + anti_idle_supported: snapshot.anti_idle_supported, + anti_idle_enabled: snapshot.anti_idle_enabled, + anti_idle_active: snapshot.anti_idle_active, + anti_idle_display_required: snapshot.anti_idle_display_required, + } +} + +fn map_anti_idle_config(snapshot: AntiIdleConfigSnapshot) -> AntiIdleConfigReply { + AntiIdleConfigReply { + enabled: snapshot.enabled, + recent_activity_window_secs: snapshot.recent_activity_window_secs, + allow_on_battery: snapshot.allow_on_battery, + keep_display_on: snapshot.keep_display_on, + } +} + +fn map_anti_idle_status(snapshot: AntiIdleStatusSnapshot) -> AntiIdleStatusReply { + AntiIdleStatusReply { + supported: snapshot.supported, + enabled: snapshot.enabled, + active: snapshot.active, + display_required: snapshot.display_required, + reason: snapshot.reason, } } diff --git a/crates/app-services/src/app.rs b/crates/app-services/src/app.rs index f3ea1bf..dc94ede 100644 --- a/crates/app-services/src/app.rs +++ b/crates/app-services/src/app.rs @@ -12,12 +12,12 @@ use crate::{ NearbyRequestCodeCommand, NearbySubmitCodeCommand, OperationReply, PairJoinCommand, PairJoinReply, PairingCodeReply, PairingCodeRequest, RemovePeerCommand, SafeResetCommand, SendClipboardImageCommand, SendClipboardTextCommand, SendFileCommand, SendInputKeyCommand, - SendInputMoveCommand, + SendInputMoveCommand, SetAntiIdleConfigCommand, }, queries::{ - ConsoleSnapshot, NearbyJoinStatusSnapshot, NearbyPairingCompletionSnapshot, - NearbyRequestCodeStartSnapshot, StatusSnapshot, TransportEventSnapshot, - TrustBundleSnapshot, UiSnapshot, + AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, NearbyJoinStatusSnapshot, + NearbyPairingCompletionSnapshot, NearbyRequestCodeStartSnapshot, StatusSnapshot, + TransportEventSnapshot, TrustBundleSnapshot, UiSnapshot, }, }; @@ -34,6 +34,12 @@ pub trait ControlPlaneApp: Send + Sync { async fn layout(&self) -> Result; async fn features(&self) -> Result>; async fn set_feature(&self, command: FeatureSetCommand) -> Result; + async fn anti_idle_config(&self) -> Result; + async fn anti_idle_status(&self) -> Result; + async fn set_anti_idle_config( + &self, + command: SetAntiIdleConfigCommand, + ) -> Result; async fn set_hotkey(&self, command: HotkeySetCommand) -> Result; async fn trigger_hotkey_action(&self, command: HotkeyTriggerCommand) -> Result; async fn export_trust_bundle(&self) -> Result; diff --git a/crates/app-services/src/commands.rs b/crates/app-services/src/commands.rs index 33f7b43..d4b6848 100644 --- a/crates/app-services/src/commands.rs +++ b/crates/app-services/src/commands.rs @@ -30,6 +30,14 @@ pub struct FeatureSetCommand { pub enabled: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetAntiIdleConfigCommand { + pub enabled: bool, + pub recent_activity_window_secs: u32, + pub allow_on_battery: bool, + pub keep_display_on: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HotkeySetCommand { pub action: String, diff --git a/crates/app-services/src/queries.rs b/crates/app-services/src/queries.rs index 3b2a328..7b1a943 100644 --- a/crates/app-services/src/queries.rs +++ b/crates/app-services/src/queries.rs @@ -1,6 +1,23 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AntiIdleConfigSnapshot { + pub enabled: bool, + pub recent_activity_window_secs: u32, + pub allow_on_battery: bool, + pub keep_display_on: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AntiIdleStatusSnapshot { + pub supported: bool, + pub enabled: bool, + pub active: bool, + pub display_required: bool, + pub reason: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatusSnapshot { pub daemon_version: String, @@ -13,6 +30,10 @@ pub struct StatusSnapshot { pub input_locked: bool, pub input_lock_supported: bool, pub capture_target_peer_id: Option, + pub anti_idle_supported: bool, + pub anti_idle_enabled: bool, + pub anti_idle_active: bool, + pub anti_idle_display_required: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,6 +71,8 @@ pub struct UiSnapshot { pub discovered_peers: Vec, pub paired_peers: Vec, pub pending_requests: Vec, + pub anti_idle_config: AntiIdleConfigSnapshot, + pub anti_idle_status: AntiIdleStatusSnapshot, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -65,6 +88,8 @@ pub struct ConsoleSnapshot { pub input_capture_target_peer_id: Option, pub mdns_active: bool, pub local_display_name: String, + pub anti_idle_config: AntiIdleConfigSnapshot, + pub anti_idle_status: AntiIdleStatusSnapshot, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index aa6cea3..3237492 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -42,7 +42,7 @@ pub(super) async fn daemon_status(endpoint: &str) -> Result<()> { let mut client = ControlPlaneServiceClient::new(channel(endpoint).await?); let status = client.get_status(StatusRequest {}).await?.into_inner(); println!( - "running={} machine_id={} peers={} protocol={} api_transport={} api_bind={} api_pipe_name={} input_locked={} input_lock_supported={} active_capture_target={}", + "running={} machine_id={} peers={} protocol={} api_transport={} api_bind={} api_pipe_name={} input_locked={} input_lock_supported={} active_capture_target={} anti_idle_supported={} anti_idle_enabled={} anti_idle_active={} anti_idle_display_required={}", status.running, status.machine_id, status.peer_count, @@ -56,7 +56,11 @@ pub(super) async fn daemon_status(endpoint: &str) -> Result<()> { "none" } else { status.capture_target_peer_id.as_str() - } + }, + status.anti_idle_supported, + status.anti_idle_enabled, + status.anti_idle_active, + status.anti_idle_display_required ); Ok(()) } @@ -879,6 +883,48 @@ pub(super) async fn feature_set(endpoint: &str, name: String, value: ToggleValue Ok(()) } +pub(super) async fn anti_idle_show(endpoint: &str) -> Result<()> { + let mut client = ControlPlaneServiceClient::new(channel(endpoint).await?); + let config = client.get_anti_idle_config(Empty {}).await?.into_inner(); + let status = client.get_anti_idle_status(Empty {}).await?.into_inner(); + + println!( + "enabled={} recent_activity_window_secs={} allow_on_battery={} keep_display_on={} supported={} active={} display_required={} reason={}", + config.enabled, + config.recent_activity_window_secs, + config.allow_on_battery, + config.keep_display_on, + status.supported, + status.active, + status.display_required, + status.reason + ); + Ok(()) +} + +pub(super) async fn anti_idle_set( + endpoint: &str, + enabled: bool, + window_minutes: u32, + allow_on_battery: bool, + keep_display_on: bool, +) -> Result<()> { + let recent_activity_window_secs = window_minutes.saturating_mul(60); + let mut client = ControlPlaneServiceClient::new(channel(endpoint).await?); + let response = client + .set_anti_idle_config(AntiIdleSetRequest { + enabled, + recent_activity_window_secs, + allow_on_battery, + keep_display_on, + }) + .await? + .into_inner(); + + println!("ok={} message={}", response.ok, response.message); + Ok(()) +} + pub(super) async fn hotkey_set(endpoint: &str, action: String, combo: String) -> Result<()> { let mut client = ControlPlaneServiceClient::new(channel(endpoint).await?); let response = client @@ -1168,6 +1214,8 @@ struct UiSnapshot { discovered_peers: Vec, paired_peers: Vec, pending_requests: Vec, + anti_idle_config: UiAntiIdleConfig, + anti_idle_status: UiAntiIdleStatus, } #[derive(Debug, Clone, Serialize)] @@ -1196,6 +1244,23 @@ struct UiPendingRequest { requires_verification_code: bool, } +#[derive(Debug, Clone, Serialize)] +struct UiAntiIdleConfig { + enabled: bool, + recent_activity_window_secs: u32, + allow_on_battery: bool, + keep_display_on: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct UiAntiIdleStatus { + supported: bool, + enabled: bool, + active: bool, + display_required: bool, + reason: String, +} + pub(super) async fn ui_snapshot(endpoint: &str, start_daemon: bool) -> Result<()> { if start_daemon { ensure_daemon_available(endpoint, true).await?; @@ -1240,6 +1305,36 @@ pub(super) async fn ui_snapshot(endpoint: &str, start_daemon: bool) -> Result<() requires_verification_code: request.requires_verification_code, }) .collect(), + anti_idle_config: snapshot + .anti_idle_config + .map(|config| UiAntiIdleConfig { + enabled: config.enabled, + recent_activity_window_secs: config.recent_activity_window_secs, + allow_on_battery: config.allow_on_battery, + keep_display_on: config.keep_display_on, + }) + .unwrap_or(UiAntiIdleConfig { + enabled: false, + recent_activity_window_secs: 0, + allow_on_battery: false, + keep_display_on: false, + }), + anti_idle_status: snapshot + .anti_idle_status + .map(|status| UiAntiIdleStatus { + supported: status.supported, + enabled: status.enabled, + active: status.active, + display_required: status.display_required, + reason: status.reason, + }) + .unwrap_or(UiAntiIdleStatus { + supported: false, + enabled: false, + active: false, + display_required: false, + reason: "none".to_string(), + }), }; println!( diff --git a/crates/cli/src/console.rs b/crates/cli/src/console.rs index a313594..06d207a 100644 --- a/crates/cli/src/console.rs +++ b/crates/cli/src/console.rs @@ -32,6 +32,7 @@ pub(super) struct ConsoleSnapshot { pub(super) input_owner: Option, pub(super) capture_target: Option, pub(super) mdns_active: bool, + pub(super) anti_idle_reason: String, } impl ConsoleSnapshot { @@ -171,6 +172,11 @@ async fn fetch_console_snapshot(endpoint: &str) -> Result { input_owner, capture_target, mdns_active: snapshot.mdns_active, + anti_idle_reason: snapshot + .anti_idle_status + .as_ref() + .map(|status| status.reason.clone()) + .unwrap_or_else(|| "none".to_string()), }) } @@ -178,7 +184,7 @@ fn print_console_snapshot(endpoint: &str, snapshot: &ConsoleSnapshot) { println!(); println!("=== Boundless Status ==="); println!( - "daemon=running endpoint={} machine_id={} protocol={} input_locked={} input_lock_supported={} active_capture_target={}", + "daemon=running endpoint={} machine_id={} protocol={} input_locked={} input_lock_supported={} active_capture_target={} anti_idle_supported={} anti_idle_enabled={} anti_idle_active={} anti_idle_display_required={} anti_idle_reason={}", endpoint, snapshot.status.machine_id, snapshot.status.protocol_version, @@ -188,7 +194,12 @@ fn print_console_snapshot(endpoint: &str, snapshot: &ConsoleSnapshot) { "none" } else { snapshot.status.capture_target_peer_id.as_str() - } + }, + snapshot.status.anti_idle_supported, + snapshot.status.anti_idle_enabled, + snapshot.status.anti_idle_active, + snapshot.status.anti_idle_display_required, + snapshot.anti_idle_reason ); println!( "api_transport={} api_bind={} api_pipe_name={}", diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a8fb067..086a2e1 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -23,13 +23,14 @@ use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient}; use tonic::{codegen::Service, transport::Uri}; use ipc_api::boundless::v1::{ - DiagnosticsDumpRequest, Empty, FeatureSetRequest, HotkeySetRequest, HotkeyTriggerRequest, - ImportTrustBundleRequest, InputCaptureTargetRequest, InputOwnerRequest, LayoutSetRequest, - NearbyJoinStartRequest, NearbyJoinStatusRequest, NearbyPairingDecisionRequest, - NearbyRequestCodeStartRequest, NearbySubmitCodeRequest, PairCreateCodeRequest, PairJoinRequest, - RemovePeerRequest, SafeResetRequest, SendClipboardImageRequest, SendClipboardTextRequest, - SendFileRequest, SendInputKeyRequest, SendInputMoveRequest, StatusReply, StatusRequest, - UiSnapshotReply, control_plane_service_client::ControlPlaneServiceClient, + AntiIdleSetRequest, DiagnosticsDumpRequest, Empty, FeatureSetRequest, HotkeySetRequest, + HotkeyTriggerRequest, ImportTrustBundleRequest, InputCaptureTargetRequest, InputOwnerRequest, + LayoutSetRequest, NearbyJoinStartRequest, NearbyJoinStatusRequest, + NearbyPairingDecisionRequest, NearbyRequestCodeStartRequest, NearbySubmitCodeRequest, + PairCreateCodeRequest, PairJoinRequest, RemovePeerRequest, SafeResetRequest, + SendClipboardImageRequest, SendClipboardTextRequest, SendFileRequest, SendInputKeyRequest, + SendInputMoveRequest, StatusReply, StatusRequest, UiSnapshotReply, + control_plane_service_client::ControlPlaneServiceClient, }; mod cli_helpers; @@ -96,6 +97,10 @@ enum Command { #[command(subcommand)] command: FeatureCommand, }, + AntiIdle { + #[command(subcommand)] + command: AntiIdleCommand, + }, Transport { #[command(subcommand)] command: TransportCommand, @@ -224,6 +229,21 @@ enum FeatureCommand { Set { name: String, value: ToggleValue }, } +#[derive(Debug, Subcommand)] +enum AntiIdleCommand { + Show, + Set { + #[arg(long)] + enabled: bool, + #[arg(long, value_parser = clap::value_parser!(u32).range(1..=30))] + window_minutes: u32, + #[arg(long)] + allow_on_battery: bool, + #[arg(long)] + keep_display_on: bool, + }, +} + #[derive(Debug, Subcommand)] enum TransportCommand { SendText { @@ -401,6 +421,24 @@ async fn main() -> Result<()> { FeatureCommand::List => feature_list(&cli.endpoint).await, FeatureCommand::Set { name, value } => feature_set(&cli.endpoint, name, value).await, }, + Command::AntiIdle { command } => match command { + AntiIdleCommand::Show => anti_idle_show(&cli.endpoint).await, + AntiIdleCommand::Set { + enabled, + window_minutes, + allow_on_battery, + keep_display_on, + } => { + anti_idle_set( + &cli.endpoint, + enabled, + window_minutes, + allow_on_battery, + keep_display_on, + ) + .await + } + }, Command::Transport { command } => match command { TransportCommand::SendText { peer_id, text } => { transport_send_text(&cli.endpoint, peer_id, text).await @@ -571,6 +609,7 @@ mod tests { input_owner: None, capture_target: None, mdns_active: true, + anti_idle_reason: "none".to_string(), }; let by_index = resolve_discovered_peer(&snapshot, "2").expect("index"); diff --git a/crates/core-protocol/src/lib.rs b/crates/core-protocol/src/lib.rs index ed1ac13..781aec1 100644 --- a/crates/core-protocol/src/lib.rs +++ b/crates/core-protocol/src/lib.rs @@ -5,7 +5,7 @@ use thiserror::Error; pub const PROTOCOL_NAME: &str = "boundless"; pub const PROTOCOL_CURRENT: ProtocolVersion = ProtocolVersion { - major: 3, + major: 4, minor: 0, patch: 0, }; @@ -66,6 +66,9 @@ pub enum WireMessage { machine_id: String, timestamp_unix_ms: i64, }, + AntiIdlePulse { + keep_display_on: bool, + }, ClipboardText { machine_id: String, text: String, diff --git a/crates/daemon/src/anti_idle.rs b/crates/daemon/src/anti_idle.rs new file mode 100644 index 0000000..becfb78 --- /dev/null +++ b/crates/daemon/src/anti_idle.rs @@ -0,0 +1,45 @@ +use std::time::Duration; + +use anyhow::Result; +use tokio::time; +use tracing::warn; + +use crate::state::AppState; + +const ANTI_IDLE_SAFETY_TICK: Duration = Duration::from_secs(1); + +pub fn start(state: AppState) { + tokio::spawn(async move { + if let Err(error) = run(state).await { + warn!(error = ?error, "anti-idle runtime stopped"); + } + }); +} + +async fn run(state: AppState) -> Result<()> { + let mut worker = platform_windows::runtime::spawn_anti_idle_power_worker()?; + let wake = state.anti_idle_wake_signal(); + let mut ticker = time::interval(ANTI_IDLE_SAFETY_TICK); + ticker.set_missed_tick_behavior(time::MissedTickBehavior::Skip); + + let mut last_flags = u32::MAX; + loop { + let wake_notified = wake.notified(); + tokio::pin!(wake_notified); + + if !wake.take_pending() { + tokio::select! { + _ = &mut wake_notified => { + let _ = wake.take_pending(); + } + _ = ticker.tick() => {} + } + } + + let runtime = state.reconcile_anti_idle_runtime().await; + if runtime.desired_execution_state_flags != last_flags { + worker.apply(runtime.desired_execution_state_flags)?; + last_flags = runtime.desired_execution_state_flags; + } + } +} diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index 86c026a..698f133 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -12,7 +12,9 @@ use uuid::Uuid; use core_protocol::PROTOCOL_CURRENT; const DEFAULT_LAYOUT_MATRIX: &str = "self"; -const RUNTIME_CONFIG_VERSION: &str = "2"; +const RUNTIME_CONFIG_VERSION: &str = "4"; +const DEFAULT_ANTI_IDLE_RECENT_ACTIVITY_WINDOW_SECS: u32 = 300; +const DEFAULT_ANTI_IDLE_PULSE_INTERVAL_SECS: u32 = 30; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PeerConfig { @@ -69,11 +71,39 @@ pub struct RuntimeConfig { pub auto_start: bool, pub network_port: u16, pub features: BTreeMap, + #[serde(default)] + pub anti_idle: AntiIdleConfig, pub hotkeys: BTreeMap, pub peers: Vec, pub updated_at: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AntiIdleConfig { + #[serde(default = "default_anti_idle_enabled")] + pub enabled: bool, + #[serde(default = "default_recent_activity_window_secs")] + pub recent_activity_window_secs: u32, + #[serde(default = "default_allow_on_battery")] + pub allow_on_battery: bool, + #[serde(default = "default_keep_display_on")] + pub keep_display_on: bool, + #[serde(default = "default_pulse_interval_secs")] + pub pulse_interval_secs: u32, +} + +impl Default for AntiIdleConfig { + fn default() -> Self { + Self { + enabled: default_anti_idle_enabled(), + recent_activity_window_secs: default_recent_activity_window_secs(), + allow_on_battery: default_allow_on_battery(), + keep_display_on: default_keep_display_on(), + pulse_interval_secs: default_pulse_interval_secs(), + } + } +} + impl Default for RuntimeConfig { fn default() -> Self { let now = Utc::now(); @@ -112,6 +142,7 @@ impl Default for RuntimeConfig { auto_start: true, network_port: 15100, features, + anti_idle: AntiIdleConfig::default(), hotkeys, peers: Vec::new(), updated_at: now, @@ -135,6 +166,26 @@ fn default_api_pipe_name() -> String { "boundlessd-api".to_string() } +fn default_anti_idle_enabled() -> bool { + true +} + +fn default_recent_activity_window_secs() -> u32 { + DEFAULT_ANTI_IDLE_RECENT_ACTIVITY_WINDOW_SECS +} + +fn default_allow_on_battery() -> bool { + false +} + +fn default_keep_display_on() -> bool { + false +} + +fn default_pulse_interval_secs() -> u32 { + DEFAULT_ANTI_IDLE_PULSE_INTERVAL_SECS +} + pub fn config_path() -> PathBuf { if let Ok(path) = std::env::var("BOUNDLESS_CONFIG_PATH") { return PathBuf::from(path); @@ -158,9 +209,14 @@ pub fn load_or_create_config_at(path: &Path) -> Result { } let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let config: RuntimeConfig = + let mut value: serde_json::Value = serde_json::from_str(&data).with_context(|| format!("parse {}", path.display()))?; + migrate_config_value(path, &mut value)?; + + let config: RuntimeConfig = + serde_json::from_value(value).with_context(|| format!("parse {}", path.display()))?; + if config.config_version != RUNTIME_CONFIG_VERSION { bail!( "unsupported config version `{}`; expected `{}`. remove `{}` to regenerate config for this build", @@ -186,6 +242,20 @@ pub fn load_or_create_config_at(path: &Path) -> Result { ); } + if config.anti_idle.recent_activity_window_secs == 0 { + bail!( + "invalid config: anti_idle.recent_activity_window_secs must be greater than zero in `{}`", + path.display() + ); + } + + if config.anti_idle.pulse_interval_secs == 0 { + bail!( + "invalid config: anti_idle.pulse_interval_secs must be greater than zero in `{}`", + path.display() + ); + } + Ok(config) } @@ -208,9 +278,71 @@ fn hostname() -> String { .unwrap_or_else(|_| "boundless-host".to_string()) } +fn migrate_config_value(path: &Path, value: &mut serde_json::Value) -> Result<()> { + let Some(object) = value.as_object_mut() else { + bail!( + "invalid config: root must be an object in `{}`", + path.display() + ); + }; + + let Some(config_version) = object + .get("config_version") + .and_then(|entry| entry.as_str()) + else { + bail!( + "invalid config: config_version is required in `{}`", + path.display() + ); + }; + + match config_version { + RUNTIME_CONFIG_VERSION => { + if !object.contains_key("anti_idle") { + object.insert( + "anti_idle".to_string(), + serde_json::to_value(AntiIdleConfig::default()) + .context("serialize anti_idle default")?, + ); + } + Ok(()) + } + "2" | "3" => { + object.insert( + "config_version".to_string(), + serde_json::Value::String(RUNTIME_CONFIG_VERSION.to_string()), + ); + object.insert( + "protocol_version".to_string(), + serde_json::Value::String(PROTOCOL_CURRENT.to_string()), + ); + object.insert( + "anti_idle".to_string(), + serde_json::to_value(AntiIdleConfig::default()) + .context("serialize anti_idle default")?, + ); + + let migrated: RuntimeConfig = + serde_json::from_value(serde_json::Value::Object(object.clone())) + .with_context(|| format!("parse migrated {}", path.display()))?; + save_config_at(path, &migrated)?; + Ok(()) + } + other => bail!( + "unsupported config version `{}`; expected `{}`. remove `{}` to regenerate config for this build", + other, + RUNTIME_CONFIG_VERSION, + path.display() + ), + } +} + #[cfg(test)] mod tests { - use super::{ApiTransport, RuntimeConfig, load_or_create_config_at, save_config_at}; + use super::{ + AntiIdleConfig, ApiTransport, RuntimeConfig, default_pulse_interval_secs, + default_recent_activity_window_secs, load_or_create_config_at, save_config_at, + }; use core_protocol::PROTOCOL_CURRENT; #[test] @@ -333,4 +465,72 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + + #[test] + fn anti_idle_defaults_match_balanced_policy() { + let config = RuntimeConfig::default(); + + assert!(config.anti_idle.enabled); + assert_eq!( + config.anti_idle.recent_activity_window_secs, + default_recent_activity_window_secs() + ); + assert!(!config.anti_idle.allow_on_battery); + assert!(!config.anti_idle.keep_display_on); + assert_eq!( + config.anti_idle.pulse_interval_secs, + default_pulse_interval_secs() + ); + } + + #[test] + fn load_or_create_migrates_v2_config_with_default_anti_idle() { + let root = + std::env::temp_dir().join(format!("boundless-config-migrate-{}", uuid::Uuid::new_v4())); + let path = root.join("config.json"); + std::fs::create_dir_all(&root).expect("create root"); + std::fs::write( + &path, + format!( + r#"{{ + "config_version": "2", + "machine_id": "m1", + "device_name": "node", + "api_bind": "127.0.0.1:50051", + "api_transport": "tcp", + "api_pipe_name": "boundlessd-api", + "protocol_version": "{}", + "layout_matrix": "self", + "auto_start": true, + "network_port": 15100, + "features": {{ + "share_clipboard": true, + "transfer_file": true, + "share_input": true, + "easy_mouse": true, + "wrap_mouse": true + }}, + "hotkeys": {{ + "toggle_easy_mouse": "Ctrl+Alt+Shift+E", + "lock_machine": "Ctrl+Alt+Shift+L", + "switch_all": "Disabled", + "reconnect": "Ctrl+Alt+Shift+R" + }}, + "peers": [], + "updated_at": "2026-04-13T00:00:00Z" +}}"#, + PROTOCOL_CURRENT + ), + ) + .expect("seed config"); + + let config = load_or_create_config_at(&path).expect("migrate config"); + assert_eq!(config.config_version, "4"); + assert_eq!(config.anti_idle, AntiIdleConfig::default()); + + let saved = std::fs::read_to_string(&path).expect("read migrated"); + assert!(saved.contains("\"anti_idle\"")); + + let _ = std::fs::remove_dir_all(root); + } } diff --git a/crates/daemon/src/control_plane_app.rs b/crates/daemon/src/control_plane_app.rs index cfafbcc..0cd751c 100644 --- a/crates/daemon/src/control_plane_app.rs +++ b/crates/daemon/src/control_plane_app.rs @@ -11,12 +11,13 @@ use app_services::{ NearbyRequestCodeCommand, NearbySubmitCodeCommand, OperationReply, PairJoinCommand, PairJoinReply, PairingCodeReply, PairingCodeRequest, RemovePeerCommand, SafeResetCommand, SendClipboardImageCommand, SendClipboardTextCommand, SendFileCommand, SendInputKeyCommand, - SendInputMoveCommand, + SendInputMoveCommand, SetAntiIdleConfigCommand, }, queries::{ - ConsoleSnapshot, NearbyJoinStatusSnapshot, NearbyPairingCompletionSnapshot, - NearbyRequestCodeStartSnapshot, StatusSnapshot, TransportEventSnapshot, - TrustBundleSnapshot, UiDiscoveredPeer, UiPairedPeer, UiPendingRequest, UiSnapshot, + AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, NearbyJoinStatusSnapshot, + NearbyPairingCompletionSnapshot, NearbyRequestCodeStartSnapshot, StatusSnapshot, + TransportEventSnapshot, TrustBundleSnapshot, UiDiscoveredPeer, UiPairedPeer, + UiPendingRequest, UiSnapshot, }, }; use async_trait::async_trait; @@ -122,6 +123,42 @@ impl ControlPlaneApp for DaemonControlPlaneApp { }) } + async fn anti_idle_config(&self) -> Result { + Ok(build_anti_idle_config_snapshot( + self.state.anti_idle_config().await, + )) + } + + async fn anti_idle_status(&self) -> Result { + Ok(build_anti_idle_status_snapshot( + self.state.anti_idle_runtime_state().await, + )) + } + + async fn set_anti_idle_config( + &self, + command: SetAntiIdleConfigCommand, + ) -> Result { + self.state + .set_anti_idle_config_values( + command.enabled, + command.recent_activity_window_secs, + command.allow_on_battery, + command.keep_display_on, + ) + .await?; + Ok(OperationReply { + ok: true, + message: format!( + "anti_idle enabled={} recent_activity_window_secs={} allow_on_battery={} keep_display_on={}", + command.enabled, + command.recent_activity_window_secs, + command.allow_on_battery, + command.keep_display_on + ), + }) + } + async fn set_hotkey(&self, command: HotkeySetCommand) -> Result { self.state .set_hotkey(command.action.clone(), command.combo.clone()) @@ -493,6 +530,10 @@ fn build_status_snapshot_from_bundle( input_locked: bundle.input_locked, input_lock_supported: bundle.input_lock_supported, capture_target_peer_id: bundle.active_input_capture_target_peer_id, + anti_idle_supported: bundle.anti_idle_runtime.supported, + anti_idle_enabled: bundle.anti_idle_runtime.enabled, + anti_idle_active: bundle.anti_idle_runtime.active, + anti_idle_display_required: bundle.anti_idle_runtime.display_required, } } @@ -510,6 +551,8 @@ async fn build_ui_snapshot(state: &AppState) -> Result { discovered_peers, paired_peers, pending_requests, + anti_idle_config: build_anti_idle_config_snapshot(bundle.anti_idle_config), + anti_idle_status: build_anti_idle_status_snapshot(bundle.anti_idle_runtime), }) } @@ -550,6 +593,31 @@ fn build_console_snapshot_from_bundle( input_capture_target_peer_id: bundle.input_capture_target_peer_id, mdns_active: bundle.mdns_active, local_display_name: bundle.config.device_name, + anti_idle_config: build_anti_idle_config_snapshot(bundle.anti_idle_config), + anti_idle_status: build_anti_idle_status_snapshot(bundle.anti_idle_runtime), + } +} + +fn build_anti_idle_config_snapshot( + config: crate::config::AntiIdleConfig, +) -> AntiIdleConfigSnapshot { + AntiIdleConfigSnapshot { + enabled: config.enabled, + recent_activity_window_secs: config.recent_activity_window_secs, + allow_on_battery: config.allow_on_battery, + keep_display_on: config.keep_display_on, + } +} + +fn build_anti_idle_status_snapshot( + runtime: crate::state::AntiIdleRuntimeState, +) -> AntiIdleStatusSnapshot { + AntiIdleStatusSnapshot { + supported: runtime.supported, + enabled: runtime.enabled, + active: runtime.active, + display_required: runtime.display_required, + reason: runtime.reason.as_str().to_string(), } } diff --git a/crates/daemon/src/host.rs b/crates/daemon/src/host.rs index 9db30fe..242a354 100644 --- a/crates/daemon/src/host.rs +++ b/crates/daemon/src/host.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use tracing::{info, warn}; use crate::{ - clipboard, config::ApiTransport, discovery, hotkeys, input, network, pairing_wire, + anti_idle, clipboard, config::ApiTransport, discovery, hotkeys, input, network, pairing_wire, state::AppState, }; @@ -28,6 +28,7 @@ where { let state = AppState::load_or_create().context("load app state")?; apply_overrides(&state, overrides).await?; + let _ = state.reconcile_anti_idle_runtime().await; let transport_listener = network::prepare_listener(&state).await; let snapshot = state.snapshot().await; @@ -35,6 +36,7 @@ where clipboard::start(state.clone()); discovery::start(state.clone()); input::start(state.clone()); + anti_idle::start(state.clone()); hotkeys::start(state.clone()); pairing_wire::start(state.clone()); network::start(state.clone(), transport_listener); diff --git a/crates/daemon/src/input/runtime.rs b/crates/daemon/src/input/runtime.rs index 74fc9b9..116a0b4 100644 --- a/crates/daemon/src/input/runtime.rs +++ b/crates/daemon/src/input/runtime.rs @@ -317,6 +317,9 @@ pub(super) async fn capture_and_queue_outgoing_frames( ) .await; } + if !events.is_empty() { + state.note_real_local_input_activity().await; + } let cursor_position = backend.cursor_position(); let screen_bounds = local_virtual_screen_bounds(); diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index caa1620..2ea35fc 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -1,3 +1,4 @@ +pub mod anti_idle; pub mod clipboard; pub mod config; pub mod control_plane_app; diff --git a/crates/daemon/src/network/control.rs b/crates/daemon/src/network/control.rs index 77140c3..96efa0c 100644 --- a/crates/daemon/src/network/control.rs +++ b/crates/daemon/src/network/control.rs @@ -146,6 +146,18 @@ pub(super) async fn handle_heartbeat_message(state: &AppState, remote_peer_id: O } } +pub(super) async fn handle_anti_idle_pulse_message( + state: &AppState, + remote_peer_id: Option<&str>, + keep_display_on: bool, +) { + if let Some(peer_id) = remote_peer_id { + state + .note_remote_anti_idle_pulse(peer_id, keep_display_on) + .await; + } +} + async fn flush_pending_after_control_frame( state: &AppState, local_machine_id: &str, diff --git a/crates/daemon/src/network/session.rs b/crates/daemon/src/network/session.rs index a41dc47..42c251c 100644 --- a/crates/daemon/src/network/session.rs +++ b/crates/daemon/src/network/session.rs @@ -10,7 +10,8 @@ use crate::state::TransportEventRecord; use super::codec::now_millis; use super::control::{ - HelloHandling, handle_heartbeat_message, handle_hello_ack_message, handle_hello_message, + HelloHandling, handle_anti_idle_pulse_message, handle_heartbeat_message, + handle_hello_ack_message, handle_hello_message, }; use super::inbound::{ discard_inbound_clipboard_image_transfer, discard_inbound_transfer, @@ -142,6 +143,7 @@ where let mut inbound_clipboard_image_transfers: HashMap = HashMap::new(); let mut outbound_transfer_flow: OutboundTransferFlows = HashMap::new(); + let mut last_anti_idle_pulse_sent_at: Option = None; loop { tokio::select! { @@ -165,6 +167,28 @@ where timestamp_unix_ms: now_millis(), }; send_message(&mut writer, &heartbeat, &mut write_frame_buffer).await?; + if let Some(pulse) = state.anti_idle_outbound_pulse().await + && last_anti_idle_pulse_sent_at + .is_none_or(|last| last.elapsed() >= pulse.interval) + { + send_message( + &mut writer, + &WireMessage::AntiIdlePulse { + keep_display_on: pulse.keep_display_on, + }, + &mut write_frame_buffer, + ) + .await?; + state.record_transport_event(TransportEventRecord { + timestamp: Utc::now(), + direction: "outgoing".to_string(), + kind: "anti_idle_pulse_sent".to_string(), + peer_id: authenticated_peer_id.clone(), + detail: format!("keep_display_on={}", pulse.keep_display_on), + size_bytes: 0, + }); + last_anti_idle_pulse_sent_at = Some(std::time::Instant::now()); + } if let Some(remote_protocol) = remote_protocol { flush_outgoing_input_payloads_with_buffer( &state, @@ -375,6 +399,14 @@ where WireMessage::Heartbeat { .. } => { handle_heartbeat_message(&state, remote_peer_id.as_deref()).await; } + WireMessage::AntiIdlePulse { keep_display_on } => { + handle_anti_idle_pulse_message( + &state, + remote_peer_id.as_deref(), + keep_display_on, + ) + .await; + } WireMessage::ClipboardText { machine_id, text } => { handle_clipboard_text_message( &state, diff --git a/crates/daemon/src/state.rs b/crates/daemon/src/state.rs index 21c883d..22d0661 100644 --- a/crates/daemon/src/state.rs +++ b/crates/daemon/src/state.rs @@ -34,7 +34,8 @@ use core_security::{ use core_transfer::{resolve_conflict_path, validate_transfer_size}; use crate::config::{ - ApiTransport, PeerConfig, RuntimeConfig, config_path, load_or_create_config_at, save_config_at, + AntiIdleConfig, ApiTransport, PeerConfig, RuntimeConfig, config_path, load_or_create_config_at, + save_config_at, }; const MAX_PENDING_REMOTE_CLIPBOARD_ITEMS: usize = 64; @@ -51,6 +52,8 @@ const NEARBY_PAIRING_CODE_SUBMISSION_MAX_FAILURES: usize = 8; const NEARBY_PAIRING_CODE_SUBMISSION_LOCKOUT_SECONDS: i64 = 600; pub(crate) const FILE_TRANSFER_CHUNK_BYTES: usize = 48 * 1024; +mod anti_idle_ops; +mod anti_idle_state; mod clipboard_ops; mod clipboard_state; mod config_ops; @@ -69,6 +72,8 @@ mod transport_ops; mod transport_state; mod validation; +pub(crate) use anti_idle_state::AntiIdleRuntimeState; +use anti_idle_state::AntiIdleState; pub(crate) use clipboard_state::PendingRemoteClipboardPayload; use clipboard_state::{ClipboardReplayState, ClipboardState, ClipboardSyncState}; pub(crate) use discovery_state::DiscoveredPeerEndpoint; @@ -131,6 +136,8 @@ pub(crate) struct ControlPlaneSnapshotBundle { pub(crate) input_locked: bool, pub(crate) input_lock_supported: bool, pub(crate) mdns_active: bool, + pub(crate) anti_idle_config: AntiIdleConfig, + pub(crate) anti_idle_runtime: AntiIdleRuntimeState, } impl PendingInjectInputFrame { @@ -149,6 +156,29 @@ pub enum CaptureHandoffTarget { Peer(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AntiIdleAssertionReason { + None, + LocalRecentInput, + RemoteRecentInput, +} + +#[derive(Debug, Clone, Copy)] +pub struct AntiIdleOutboundPulse { + pub keep_display_on: bool, + pub interval: Duration, +} + +impl AntiIdleAssertionReason { + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::LocalRecentInput => "local_recent_input", + Self::RemoteRecentInput => "remote_recent_input", + } + } +} + #[derive(Debug, Clone)] struct ParsedLayoutMatrixCache { spec: String, @@ -164,6 +194,7 @@ pub struct AppState { transport: Arc, discovery: Arc, input: Arc, + anti_idle: Arc, security_paths: Arc, identity: Arc, device_fingerprint: Arc, @@ -171,6 +202,7 @@ pub struct AppState { parsed_layout_matrix_cache: Arc>>, input_capture_wake: Arc, input_inject_wake: Arc, + anti_idle_wake: Arc, } #[cfg(test)] diff --git a/crates/daemon/src/state/anti_idle_ops.rs b/crates/daemon/src/state/anti_idle_ops.rs new file mode 100644 index 0000000..151e8c4 --- /dev/null +++ b/crates/daemon/src/state/anti_idle_ops.rs @@ -0,0 +1,347 @@ +use super::*; +use anyhow::bail; + +const ALLOWED_ANTI_IDLE_WINDOW_SECS: &[u32] = &[60, 300, 600, 900, 1_800]; + +impl AppState { + pub async fn anti_idle_config(&self) -> AntiIdleConfig { + self.config.read().await.anti_idle.clone() + } + + pub(crate) async fn anti_idle_runtime_state(&self) -> AntiIdleRuntimeState { + *self.anti_idle.runtime.read().await + } + + pub async fn set_anti_idle_config_values( + &self, + enabled: bool, + recent_activity_window_secs: u32, + allow_on_battery: bool, + keep_display_on: bool, + ) -> Result<()> { + validate_recent_activity_window_secs(recent_activity_window_secs)?; + + let mut config = self.config.write().await; + config.anti_idle.enabled = enabled; + config.anti_idle.recent_activity_window_secs = recent_activity_window_secs; + config.anti_idle.allow_on_battery = allow_on_battery; + config.anti_idle.keep_display_on = keep_display_on; + save_config_at(&self.config_path, &config)?; + drop(config); + + self.notify_anti_idle_wake("anti_idle_config_changed"); + Ok(()) + } + + pub async fn note_real_local_input_activity(&self) { + *self.anti_idle.last_real_local_input_at.write().await = Some(Instant::now()); + self.notify_anti_idle_wake("real_local_input"); + } + + pub async fn anti_idle_outbound_pulse(&self) -> Option { + let config = self.anti_idle_config().await; + if !config.enabled || !platform_windows::runtime::anti_idle_power_supported() { + return None; + } + + let now = Instant::now(); + let last_activity = (*self.anti_idle.last_real_local_input_at.read().await)?; + if now.duration_since(last_activity) + > Duration::from_secs(u64::from(config.recent_activity_window_secs)) + { + return None; + } + if !config.allow_on_battery + && !platform_windows::runtime::anti_idle_system_on_ac_power().unwrap_or(true) + { + return None; + } + + Some(AntiIdleOutboundPulse { + keep_display_on: config.keep_display_on, + interval: Duration::from_secs(u64::from(config.pulse_interval_secs)), + }) + } + + pub async fn note_remote_anti_idle_pulse(&self, peer_id: &str, keep_display_on: bool) { + let config = self.anti_idle_config().await; + let lease = Duration::from_secs(u64::from(config.pulse_interval_secs).saturating_mul(3)); + self.anti_idle + .remote_activity_until_by_peer + .write() + .await + .insert( + peer_id.to_string(), + anti_idle_state::RemoteAntiIdleLease { + until: Instant::now() + lease, + keep_display_on, + }, + ); + self.record_transport_event(TransportEventRecord { + timestamp: Utc::now(), + direction: "incoming".to_string(), + kind: "anti_idle_pulse_received".to_string(), + peer_id: peer_id.to_string(), + detail: format!( + "keep_display_on={keep_display_on} lease_secs={}", + lease.as_secs() + ), + size_bytes: 0, + }); + self.notify_anti_idle_wake("anti_idle_pulse_received"); + } + + pub async fn clear_remote_anti_idle_peer(&self, peer_id: &str) -> bool { + let removed = self + .anti_idle + .remote_activity_until_by_peer + .write() + .await + .remove(peer_id) + .is_some(); + if removed { + self.notify_anti_idle_wake("anti_idle_peer_cleared"); + } + removed + } + + pub(crate) async fn reconcile_anti_idle_runtime(&self) -> AntiIdleRuntimeState { + let config = self.anti_idle_config().await; + let now = Instant::now(); + let supported = platform_windows::runtime::anti_idle_power_supported(); + + let local_recent_input_active = self + .anti_idle + .last_real_local_input_at + .read() + .await + .is_some_and(|last| { + now.duration_since(last) + <= Duration::from_secs(u64::from(config.recent_activity_window_secs)) + }); + + let (remote_recent_input_active, remote_display_required) = { + let mut leases = self.anti_idle.remote_activity_until_by_peer.write().await; + leases.retain(|_, lease| lease.until > now); + let remote_recent_input_active = !leases.is_empty(); + let remote_display_required = leases.values().any(|lease| lease.keep_display_on); + (remote_recent_input_active, remote_display_required) + }; + + let pending_reason = if local_recent_input_active { + AntiIdleAssertionReason::LocalRecentInput + } else if remote_recent_input_active { + AntiIdleAssertionReason::RemoteRecentInput + } else { + AntiIdleAssertionReason::None + }; + + let on_ac_power = if supported && !config.allow_on_battery { + platform_windows::runtime::anti_idle_system_on_ac_power().unwrap_or(true) + } else { + true + }; + let battery_suppressed = supported + && config.enabled + && pending_reason != AntiIdleAssertionReason::None + && !config.allow_on_battery + && !on_ac_power; + let active = supported + && config.enabled + && pending_reason != AntiIdleAssertionReason::None + && !battery_suppressed; + let display_required = active + && ((local_recent_input_active && config.keep_display_on) || remote_display_required); + let reason = if active { + pending_reason + } else { + AntiIdleAssertionReason::None + }; + let desired_execution_state_flags = + platform_windows::runtime::anti_idle_execution_state_flags(active, display_required); + let next = AntiIdleRuntimeState { + supported, + enabled: config.enabled, + active, + display_required, + battery_suppressed, + reason, + desired_execution_state_flags, + }; + + let mut runtime = self.anti_idle.runtime.write().await; + let previous = *runtime; + if previous != next { + self.record_transport_event(TransportEventRecord { + timestamp: Utc::now(), + direction: "local".to_string(), + kind: "anti_idle_state_changed".to_string(), + peer_id: "self".to_string(), + detail: format!( + "supported={} enabled={} active={} display_required={} battery_suppressed={} reason={}", + next.supported, + next.enabled, + next.active, + next.display_required, + next.battery_suppressed, + next.reason.as_str() + ), + size_bytes: 0, + }); + if !previous.active && next.active { + self.record_transport_event(TransportEventRecord { + timestamp: Utc::now(), + direction: "local".to_string(), + kind: "anti_idle_assertion_acquired".to_string(), + peer_id: "self".to_string(), + detail: format!( + "display_required={} reason={}", + next.display_required, + next.reason.as_str() + ), + size_bytes: u64::from(next.desired_execution_state_flags), + }); + } + if previous.active && !next.active { + self.record_transport_event(TransportEventRecord { + timestamp: Utc::now(), + direction: "local".to_string(), + kind: "anti_idle_assertion_released".to_string(), + peer_id: "self".to_string(), + detail: format!("previous_reason={}", previous.reason.as_str()), + size_bytes: u64::from(previous.desired_execution_state_flags), + }); + } + if !previous.battery_suppressed && next.battery_suppressed { + self.record_transport_event(TransportEventRecord { + timestamp: Utc::now(), + direction: "local".to_string(), + kind: "anti_idle_skipped_on_battery".to_string(), + peer_id: "self".to_string(), + detail: format!("reason={}", pending_reason.as_str()), + size_bytes: 0, + }); + } + *runtime = next; + } + + next + } + + pub(crate) fn notify_anti_idle_wake(&self, source: &str) { + if self.anti_idle_wake.trigger() { + self.record_runtime_wake("anti_idle", source); + self.anti_idle_wake.notify_one(); + } + } + + pub(crate) fn anti_idle_wake_signal(&self) -> Arc { + self.anti_idle_wake.clone() + } +} + +fn validate_recent_activity_window_secs(value: u32) -> Result<()> { + if ALLOWED_ANTI_IDLE_WINDOW_SECS.contains(&value) { + return Ok(()); + } + + bail!( + "recent activity window must be one of {} seconds", + ALLOWED_ANTI_IDLE_WINDOW_SECS + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn test_state() -> (AppState, std::path::PathBuf) { + let root = + std::env::temp_dir().join(format!("boundless-anti-idle-test-{}", uuid::Uuid::new_v4())); + let config_path = root.join("config.json"); + let security_root = root.join("security"); + let state = + AppState::load_or_create_with_paths(config_path, security_root).expect("load state"); + (state, root) + } + + async fn test_state_with_peer() -> (AppState, String, std::path::PathBuf) { + let (state, root) = test_state().await; + let (code, _) = state.create_pairing_code(120).await; + let peer_id = state + .join_peer( + code, + "127.0.0.1:15100".to_string(), + Some("peer".to_string()), + ) + .await + .expect("join peer"); + (state, peer_id, root) + } + + #[tokio::test] + async fn anti_idle_setting_rejects_invalid_window_values() { + let (state, root) = test_state().await; + let err = state + .set_anti_idle_config_values(true, 120, false, false) + .await + .expect_err("invalid window must be rejected"); + assert!(err.to_string().contains("recent activity window")); + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn real_local_input_opens_outbound_pulse_window() { + let (state, root) = test_state().await; + + assert!(state.anti_idle_outbound_pulse().await.is_none()); + state.note_real_local_input_activity().await; + + let pulse = state.anti_idle_outbound_pulse().await; + if platform_windows::runtime::anti_idle_power_supported() { + let pulse = pulse.expect("recent local input should produce pulse"); + assert_eq!(pulse.interval, Duration::from_secs(30)); + assert!(!pulse.keep_display_on); + } else { + assert!(pulse.is_none()); + } + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn remote_pulse_activates_remote_reason_and_disconnect_clears_it() { + let (state, peer_id, root) = test_state_with_peer().await; + state + .set_peer_connected(&peer_id, true) + .await + .expect("connect peer"); + + state.note_remote_anti_idle_pulse(&peer_id, true).await; + let runtime = state.reconcile_anti_idle_runtime().await; + if runtime.supported { + assert_eq!(runtime.reason, AntiIdleAssertionReason::RemoteRecentInput); + assert!(runtime.active || runtime.battery_suppressed); + assert_eq!(runtime.display_required, runtime.active); + } else { + assert_eq!(runtime.reason, AntiIdleAssertionReason::None); + assert!(!runtime.active); + assert!(!runtime.display_required); + assert!(!runtime.battery_suppressed); + } + + state + .set_peer_connected(&peer_id, false) + .await + .expect("disconnect peer"); + let runtime = state.reconcile_anti_idle_runtime().await; + assert_eq!(runtime.reason, AntiIdleAssertionReason::None); + assert!(!runtime.active); + + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/crates/daemon/src/state/anti_idle_state.rs b/crates/daemon/src/state/anti_idle_state.rs new file mode 100644 index 0000000..3b27a8c --- /dev/null +++ b/crates/daemon/src/state/anti_idle_state.rs @@ -0,0 +1,49 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AntiIdleRuntimeState { + pub(crate) supported: bool, + pub(crate) enabled: bool, + pub(crate) active: bool, + pub(crate) display_required: bool, + pub(crate) battery_suppressed: bool, + pub(crate) reason: AntiIdleAssertionReason, + pub(crate) desired_execution_state_flags: u32, +} + +impl Default for AntiIdleRuntimeState { + fn default() -> Self { + Self { + supported: cfg!(windows), + enabled: false, + active: false, + display_required: false, + battery_suppressed: false, + reason: AntiIdleAssertionReason::None, + desired_execution_state_flags: 0, + } + } +} + +#[derive(Debug)] +pub(super) struct AntiIdleState { + pub(super) last_real_local_input_at: RwLock>, + pub(super) remote_activity_until_by_peer: RwLock>, + pub(super) runtime: RwLock, +} + +impl Default for AntiIdleState { + fn default() -> Self { + Self { + last_real_local_input_at: RwLock::new(None), + remote_activity_until_by_peer: RwLock::new(HashMap::new()), + runtime: RwLock::new(AntiIdleRuntimeState::default()), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct RemoteAntiIdleLease { + pub(super) until: Instant, + pub(super) keep_display_on: bool, +} diff --git a/crates/daemon/src/state/control_plane_snapshot_ops.rs b/crates/daemon/src/state/control_plane_snapshot_ops.rs index c49f3bf..251d32a 100644 --- a/crates/daemon/src/state/control_plane_snapshot_ops.rs +++ b/crates/daemon/src/state/control_plane_snapshot_ops.rs @@ -7,6 +7,7 @@ impl AppState { let peers = config.peers.clone(); let layout_matrix = config.layout_matrix.clone(); let features = config.features.clone(); + let anti_idle_config = config.anti_idle.clone(); let ( discovered_endpoints, @@ -16,6 +17,7 @@ impl AppState { input_capture_target_peer_id, input_lock_runtime, mdns_active, + anti_idle_runtime, ) = tokio::join!( self.discovered_endpoints(), self.list_pending_nearby_pairing_requests(), @@ -24,6 +26,7 @@ impl AppState { self.input_capture_target(), self.input_lock_runtime(), self.mdns_active(), + self.async_anti_idle_runtime_state(), ); let (input_locked, input_lock_supported) = input_lock_runtime; @@ -45,8 +48,14 @@ impl AppState { input_locked, input_lock_supported, mdns_active, + anti_idle_config, + anti_idle_runtime, } } + + async fn async_anti_idle_runtime_state(&self) -> AntiIdleRuntimeState { + self.anti_idle_runtime_state().await + } } #[cfg(test)] diff --git a/crates/daemon/src/state/core_ops.rs b/crates/daemon/src/state/core_ops.rs index 676e214..cbae272 100644 --- a/crates/daemon/src/state/core_ops.rs +++ b/crates/daemon/src/state/core_ops.rs @@ -61,6 +61,7 @@ impl AppState { transport: Arc::new(TransportState::default()), discovery: Arc::new(DiscoveryState::default()), input: Arc::new(InputState::new(input_enabled)), + anti_idle: Arc::new(AntiIdleState::default()), security_paths: Arc::new(paths), identity: Arc::new(identity), device_fingerprint: Arc::new(fingerprint), @@ -68,6 +69,7 @@ impl AppState { parsed_layout_matrix_cache: Arc::new(RwLock::new(None)), input_capture_wake: Arc::new(RuntimeWakeSignal::default()), input_inject_wake: Arc::new(RuntimeWakeSignal::default()), + anti_idle_wake: Arc::new(RuntimeWakeSignal::default()), }) } @@ -178,7 +180,7 @@ impl AppState { self.transport.notify_outgoing_flush_signal(); } - fn record_runtime_wake(&self, channel: &str, source: &str) { + pub(crate) fn record_runtime_wake(&self, channel: &str, source: &str) { self.record_transport_event(TransportEventRecord { timestamp: Utc::now(), direction: "local".to_string(), diff --git a/crates/daemon/src/state/peer_ops.rs b/crates/daemon/src/state/peer_ops.rs index f0e1b48..935d54f 100644 --- a/crates/daemon/src/state/peer_ops.rs +++ b/crates/daemon/src/state/peer_ops.rs @@ -74,6 +74,7 @@ impl AppState { self.clear_pending_clipboard_replay_for_peer(peer_id).await; self.clear_obsolete_inflight_clipboard_replays_for_peer(peer_id) .await; + self.clear_remote_anti_idle_peer(peer_id).await; self.transport .reconnect_generation_by_peer .write() @@ -136,6 +137,7 @@ impl AppState { self.clear_pending_clipboard_replay_for_peer(peer_id).await; self.clear_obsolete_inflight_clipboard_replays_for_peer(peer_id) .await; + self.clear_remote_anti_idle_peer(peer_id).await; self.notify_input_capture_wake("peer_disconnected"); } else if transitioned_to_connected && !self diff --git a/crates/daemon/src/state/transport_ops.rs b/crates/daemon/src/state/transport_ops.rs index b7974e8..d333c32 100644 --- a/crates/daemon/src/state/transport_ops.rs +++ b/crates/daemon/src/state/transport_ops.rs @@ -140,6 +140,7 @@ impl AppState { for peer_id in &disconnected_peer_ids { self.clear_pending_inject_input_frames_for_peer(peer_id) .await; + self.clear_remote_anti_idle_peer(peer_id).await; } let mut capture_target = self.input.control.capture_target_peer_id.write().await; diff --git a/crates/ipc-api/proto/boundless.proto b/crates/ipc-api/proto/boundless.proto index 0303402..ef1ec6b 100644 --- a/crates/ipc-api/proto/boundless.proto +++ b/crates/ipc-api/proto/boundless.proto @@ -17,6 +17,10 @@ message StatusReply { bool input_locked = 9; bool input_lock_supported = 10; string capture_target_peer_id = 11; + bool anti_idle_supported = 12; + bool anti_idle_enabled = 13; + bool anti_idle_active = 14; + bool anti_idle_display_required = 15; } message PairCreateCodeRequest { @@ -106,6 +110,28 @@ message FeatureListReply { map features = 1; } +message AntiIdleConfigReply { + bool enabled = 1; + uint32 recent_activity_window_secs = 2; + bool allow_on_battery = 3; + bool keep_display_on = 4; +} + +message AntiIdleStatusReply { + bool supported = 1; + bool enabled = 2; + bool active = 3; + bool display_required = 4; + string reason = 5; +} + +message AntiIdleSetRequest { + bool enabled = 1; + uint32 recent_activity_window_secs = 2; + bool allow_on_battery = 3; + bool keep_display_on = 4; +} + message HotkeySetRequest { string action = 1; string combo = 2; @@ -207,6 +233,8 @@ message UiSnapshotReply { repeated DiscoveredPeerInfo discovered_peers = 5; repeated PeerInfo paired_peers = 6; repeated NearbyPairingRequestInfo pending_requests = 7; + AntiIdleConfigReply anti_idle_config = 8; + AntiIdleStatusReply anti_idle_status = 9; } message ConsoleSnapshotReply { @@ -219,6 +247,8 @@ message ConsoleSnapshotReply { string input_capture_target_peer_id = 7; bool mdns_active = 8; string local_display_name = 9; + AntiIdleConfigReply anti_idle_config = 10; + AntiIdleStatusReply anti_idle_status = 11; } message NearbyRequestCodeStartRequest { @@ -290,6 +320,9 @@ service ControlPlaneService { rpc LayoutSet(LayoutSetRequest) returns (OperationReply); rpc ListFeatures(Empty) returns (FeatureListReply); rpc SetFeature(FeatureSetRequest) returns (OperationReply); + rpc GetAntiIdleConfig(Empty) returns (AntiIdleConfigReply); + rpc GetAntiIdleStatus(Empty) returns (AntiIdleStatusReply); + rpc SetAntiIdleConfig(AntiIdleSetRequest) returns (OperationReply); rpc SetHotkey(HotkeySetRequest) returns (OperationReply); rpc TriggerHotkeyAction(HotkeyTriggerRequest) returns (OperationReply); rpc ExportTrustBundle(Empty) returns (TrustBundleReply); diff --git a/crates/platform-windows/Cargo.toml b/crates/platform-windows/Cargo.toml index c4ed26b..41b6101 100644 --- a/crates/platform-windows/Cargo.toml +++ b/crates/platform-windows/Cargo.toml @@ -11,7 +11,7 @@ core-input = { path = "../core-input" } tokio.workspace = true tonic.workspace = true tracing.workspace = true -windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_LibraryLoader", "Win32_System_Shutdown", "Win32_System_Threading", "Win32_UI_Input", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_LibraryLoader", "Win32_System_Power", "Win32_System_Shutdown", "Win32_System_Threading", "Win32_UI_Input", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } [target.'cfg(windows)'.dependencies] clipboard-win = "5.4.1" diff --git a/crates/platform-windows/src/runtime.rs b/crates/platform-windows/src/runtime.rs index 2dd96fc..5f387b6 100644 --- a/crates/platform-windows/src/runtime.rs +++ b/crates/platform-windows/src/runtime.rs @@ -2,7 +2,9 @@ use std::{ io, pin::Pin, + sync::mpsc::{self as std_mpsc, SyncSender}, task::{Context as TaskContext, Poll}, + thread::{self, JoinHandle}, }; #[cfg(windows)] @@ -18,7 +20,13 @@ use tokio::{ #[cfg(windows)] use tonic::{codegen::tokio_stream::Stream, transport::server::Connected}; #[cfg(windows)] -use windows_sys::Win32::System::Shutdown::LockWorkStation; +use windows_sys::Win32::System::{ + Power::{ + ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, GetSystemPowerStatus, + SYSTEM_POWER_STATUS, SetThreadExecutionState, + }, + Shutdown::LockWorkStation, +}; #[cfg(windows)] #[derive(Debug)] @@ -105,6 +113,135 @@ pub fn lock_workstation() -> Result<()> { anyhow::bail!("lock_machine is only supported on Windows"); } +#[cfg(windows)] +#[derive(Debug)] +pub struct AntiIdlePowerWorker { + sender: SyncSender, + thread: Option>, +} + +#[cfg(windows)] +#[derive(Debug)] +enum PowerWorkerCommand { + Apply(u32), + Shutdown, +} + +#[cfg(not(windows))] +#[derive(Debug, Default)] +pub struct AntiIdlePowerWorker; + +#[cfg(windows)] +impl AntiIdlePowerWorker { + pub fn apply(&mut self, flags: u32) -> Result<()> { + self.sender + .send(PowerWorkerCommand::Apply(flags)) + .context("send anti-idle worker command") + } +} + +#[cfg(not(windows))] +impl AntiIdlePowerWorker { + pub fn apply(&mut self, _flags: u32) -> Result<()> { + Ok(()) + } +} + +#[cfg(windows)] +impl Drop for AntiIdlePowerWorker { + fn drop(&mut self) { + let _ = self.sender.send(PowerWorkerCommand::Shutdown); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +#[cfg(windows)] +pub fn anti_idle_power_supported() -> bool { + true +} + +#[cfg(not(windows))] +pub fn anti_idle_power_supported() -> bool { + false +} + +#[cfg(windows)] +pub fn anti_idle_execution_state_flags(active: bool, display_required: bool) -> u32 { + if !active { + return ES_CONTINUOUS; + } + + let mut flags = ES_CONTINUOUS | ES_SYSTEM_REQUIRED; + if display_required { + flags |= ES_DISPLAY_REQUIRED; + } + flags +} + +#[cfg(not(windows))] +pub fn anti_idle_execution_state_flags(_active: bool, _display_required: bool) -> u32 { + 0 +} + +#[cfg(windows)] +pub fn anti_idle_system_on_ac_power() -> Result { + let mut status = SYSTEM_POWER_STATUS::default(); + let ok = unsafe { GetSystemPowerStatus(&mut status as *mut SYSTEM_POWER_STATUS) }; + if ok == 0 { + return Err(std::io::Error::last_os_error()).context("GetSystemPowerStatus"); + } + + Ok(status.ACLineStatus == 1) +} + +#[cfg(not(windows))] +pub fn anti_idle_system_on_ac_power() -> Result { + Ok(true) +} + +#[cfg(windows)] +pub fn spawn_anti_idle_power_worker() -> Result { + let (sender, receiver) = std_mpsc::sync_channel::(8); + let thread = thread::spawn(move || { + let mut last_flags = u32::MAX; + while let Ok(command) = receiver.recv() { + match command { + PowerWorkerCommand::Apply(flags) => { + if flags == last_flags { + continue; + } + let result = unsafe { SetThreadExecutionState(flags) }; + if result == 0 { + tracing::warn!( + flags, + error = %std::io::Error::last_os_error(), + "SetThreadExecutionState failed" + ); + continue; + } + last_flags = flags; + } + PowerWorkerCommand::Shutdown => { + let _ = unsafe { SetThreadExecutionState(ES_CONTINUOUS) }; + break; + } + } + } + }); + + Ok(AntiIdlePowerWorker { + sender, + thread: Some(thread), + }) +} + +#[cfg(not(windows))] +pub fn spawn_anti_idle_power_worker() -> Result { + Ok(AntiIdlePowerWorker) +} + pub fn validate_pipe_name(pipe_name: &str) -> Result<()> { let trimmed = pipe_name.trim(); if trimmed.is_empty() { @@ -162,3 +299,31 @@ fn pipe_path_for_name(pipe_name: &str) -> io::Result { Ok(format!(r"\\.\pipe\{trimmed}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anti_idle_flags_clear_to_continuous_when_inactive() { + #[cfg(windows)] + assert_eq!(anti_idle_execution_state_flags(false, false), ES_CONTINUOUS); + + #[cfg(not(windows))] + assert_eq!(anti_idle_execution_state_flags(false, false), 0); + } + + #[test] + fn anti_idle_flags_include_display_when_requested() { + #[cfg(windows)] + { + let flags = anti_idle_execution_state_flags(true, true); + assert_eq!(flags & ES_SYSTEM_REQUIRED, ES_SYSTEM_REQUIRED); + assert_eq!(flags & ES_DISPLAY_REQUIRED, ES_DISPLAY_REQUIRED); + assert_eq!(flags & ES_CONTINUOUS, ES_CONTINUOUS); + } + + #[cfg(not(windows))] + assert_eq!(anti_idle_execution_state_flags(true, true), 0); + } +} diff --git a/crates/tray/src/dashboard/task_runner.rs b/crates/tray/src/dashboard/task_runner.rs index 0ff22ec..f3fc49b 100644 --- a/crates/tray/src/dashboard/task_runner.rs +++ b/crates/tray/src/dashboard/task_runner.rs @@ -205,6 +205,33 @@ impl DashboardTaskRunner { }); } + pub(super) fn set_anti_idle_config( + &self, + tx: Sender, + endpoint: String, + enabled: bool, + recent_activity_window_secs: u32, + allow_on_battery: bool, + keep_display_on: bool, + ) { + Self::spawn(move || { + match set_anti_idle_config_blocking( + &endpoint, + enabled, + recent_activity_window_secs, + allow_on_battery, + keep_display_on, + ) { + Ok(msg) => { + let _ = tx.send(AppMsg::ActionComplete(msg)); + } + Err(error) => { + let _ = tx.send(AppMsg::ActionFailed(error.to_string())); + } + } + }); + } + pub(super) fn reconnect_all_peers(&self, tx: Sender, endpoint: String) { Self::spawn(move || match trigger_hotkey_action_blocking(&endpoint, "reconnect") { Ok(msg) => { diff --git a/crates/tray/src/dashboard/workflow.rs b/crates/tray/src/dashboard/workflow.rs index 4d6b5a9..7646696 100644 --- a/crates/tray/src/dashboard/workflow.rs +++ b/crates/tray/src/dashboard/workflow.rs @@ -390,6 +390,119 @@ impl DashboardApp { ui.add_space(16.0); ui.separator(); + // ── Peer Availability ───────────────────────────────────── + ui.add_space(8.0); + ui.heading("Peer Availability"); + ui.add_space(4.0); + let anti_idle_config = self.snapshot.anti_idle_config.clone(); + let anti_idle_status = self.snapshot.anti_idle_status.clone(); + let anti_idle_status_text = if !anti_idle_status.supported { + "Unsupported on this platform" + } else if anti_idle_status.active { + "Active now" + } else { + "Inactive" + }; + ui.label( + egui::RichText::new(format!( + "Status: {}{}", + anti_idle_status_text, + if anti_idle_status.reason == "none" { + if anti_idle_status.supported { + format!( + " (enabled={} display_required={})", + anti_idle_status.enabled, anti_idle_status.display_required + ) + } else { + String::new() + } + } else { + format!( + " ({}; enabled={} display_required={})", + anti_idle_status.reason.replace('_', " "), + anti_idle_status.enabled, + anti_idle_status.display_required + ) + } + )) + .weak(), + ); + if anti_idle_status.supported { + let mut anti_idle_enabled = anti_idle_config.enabled; + if ui + .checkbox( + &mut anti_idle_enabled, + "Keep connected peers awake", + ) + .clicked() + { + self.task_runner().set_anti_idle_config( + self.tx.clone(), + self.ctx.endpoint.clone(), + !anti_idle_config.enabled, + anti_idle_config.recent_activity_window_secs, + anti_idle_config.allow_on_battery, + anti_idle_config.keep_display_on, + ); + } + + ui.add_space(8.0); + ui.label("Recent activity window"); + ui.horizontal_wrapped(|ui| { + for minutes in [1_u32, 5, 10, 15, 30] { + let selected = anti_idle_config.recent_activity_window_secs == minutes * 60; + if ui.selectable_label(selected, format!("{minutes} min")).clicked() { + self.task_runner().set_anti_idle_config( + self.tx.clone(), + self.ctx.endpoint.clone(), + anti_idle_config.enabled, + minutes * 60, + anti_idle_config.allow_on_battery, + anti_idle_config.keep_display_on, + ); + } + } + }); + + let mut allow_on_battery = anti_idle_config.allow_on_battery; + if ui + .checkbox( + &mut allow_on_battery, + "Allow on battery", + ) + .clicked() + { + self.task_runner().set_anti_idle_config( + self.tx.clone(), + self.ctx.endpoint.clone(), + anti_idle_config.enabled, + anti_idle_config.recent_activity_window_secs, + !anti_idle_config.allow_on_battery, + anti_idle_config.keep_display_on, + ); + } + let mut keep_display_on = anti_idle_config.keep_display_on; + if ui + .checkbox( + &mut keep_display_on, + "Keep display on", + ) + .clicked() + { + self.task_runner().set_anti_idle_config( + self.tx.clone(), + self.ctx.endpoint.clone(), + anti_idle_config.enabled, + anti_idle_config.recent_activity_window_secs, + anti_idle_config.allow_on_battery, + !anti_idle_config.keep_display_on, + ); + } + } + + ui.add_space(16.0); + ui.separator(); + // ── Actions ──────────────────────────────────────────────── ui.add_space(8.0); ui.heading("Actions"); diff --git a/crates/tray/src/dashboard_test_support.rs b/crates/tray/src/dashboard_test_support.rs index bde1bfc..54c7e33 100644 --- a/crates/tray/src/dashboard_test_support.rs +++ b/crates/tray/src/dashboard_test_support.rs @@ -86,6 +86,19 @@ pub(super) fn sample_first_run_snapshot() -> UiSnapshot { discovered_peers: Vec::new(), paired_peers: Vec::new(), pending_requests: Vec::new(), + anti_idle_config: UiAntiIdleConfig { + enabled: true, + recent_activity_window_secs: 300, + allow_on_battery: false, + keep_display_on: false, + }, + anti_idle_status: UiAntiIdleStatus { + supported: true, + enabled: true, + active: false, + display_required: false, + reason: "none".to_string(), + }, } } diff --git a/crates/tray/src/main.rs b/crates/tray/src/main.rs index 52115c9..c0b651d 100644 --- a/crates/tray/src/main.rs +++ b/crates/tray/src/main.rs @@ -24,8 +24,8 @@ mod windows_app { use hyper_util::rt::TokioIo; use image::ImageFormat; use ipc_api::boundless::v1::{ - Empty, HotkeyTriggerRequest, LayoutSetRequest, NearbyPairingDecisionRequest, - NearbyRequestCodeStartRequest, NearbySubmitCodeRequest, + AntiIdleSetRequest, Empty, HotkeyTriggerRequest, LayoutSetRequest, + NearbyPairingDecisionRequest, NearbyRequestCodeStartRequest, NearbySubmitCodeRequest, control_plane_service_client::ControlPlaneServiceClient, }; use serde::Deserialize; @@ -83,6 +83,25 @@ mod windows_app { discovered_peers: Vec, paired_peers: Vec, pending_requests: Vec, + anti_idle_config: UiAntiIdleConfig, + anti_idle_status: UiAntiIdleStatus, + } + + #[derive(Debug, Clone, Deserialize, Default)] + struct UiAntiIdleConfig { + enabled: bool, + recent_activity_window_secs: u32, + allow_on_battery: bool, + keep_display_on: bool, + } + + #[derive(Debug, Clone, Deserialize, Default)] + struct UiAntiIdleStatus { + supported: bool, + enabled: bool, + active: bool, + display_required: bool, + reason: String, } #[derive(Debug, Clone, Deserialize)] @@ -238,6 +257,25 @@ mod windows_app { requires_verification_code: request.requires_verification_code, }) .collect(), + anti_idle_config: snapshot + .anti_idle_config + .map(|config| UiAntiIdleConfig { + enabled: config.enabled, + recent_activity_window_secs: config.recent_activity_window_secs, + allow_on_battery: config.allow_on_battery, + keep_display_on: config.keep_display_on, + }) + .unwrap_or_default(), + anti_idle_status: snapshot + .anti_idle_status + .map(|status| UiAntiIdleStatus { + supported: status.supported, + enabled: status.enabled, + active: status.active, + display_required: status.display_required, + reason: status.reason, + }) + .unwrap_or_default(), })?; } Ok(()) @@ -294,6 +332,22 @@ mod windows_app { block_on_result(layout_set(endpoint, matrix_spec)) } + fn set_anti_idle_config_blocking( + endpoint: &str, + enabled: bool, + recent_activity_window_secs: u32, + allow_on_battery: bool, + keep_display_on: bool, + ) -> Result { + block_on_result(set_anti_idle_config( + endpoint, + enabled, + recent_activity_window_secs, + allow_on_battery, + keep_display_on, + )) + } + fn ensure_daemon_available_blocking(ctx: &AppContext) -> Result> { block_on_result(ensure_daemon_available( &ctx.endpoint, @@ -331,6 +385,29 @@ mod windows_app { Ok(response.message) } + async fn set_anti_idle_config( + endpoint: &str, + enabled: bool, + recent_activity_window_secs: u32, + allow_on_battery: bool, + keep_display_on: bool, + ) -> Result { + let mut client = ControlPlaneServiceClient::new(channel(endpoint).await?); + let response = client + .set_anti_idle_config(AntiIdleSetRequest { + enabled, + recent_activity_window_secs, + allow_on_battery, + keep_display_on, + }) + .await? + .into_inner(); + if !response.ok { + bail!(response.message); + } + Ok(response.message) + } + async fn ensure_daemon_available( endpoint: &str, start_daemon: bool,