Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions crates/botloftd/src/desktop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
mod activity;
mod away;
pub mod notice;
pub mod outline;
mod screen;

pub use activity::{Activity, STOPPED};
Expand Down
217 changes: 217 additions & 0 deletions crates/botloftd/src/desktop/outline.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//! The outline in the bot's color around the window it is using (spec
//! 24.9): it shows when a bot reads or acts in a window and follows the
//! window while the bot's turn goes on, until the bot leaves the desktop
//! alone for a minute, its turn ends or the owner stops it. One window at
//! a time: the one used last.

use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};

use botloft_core::ids::BotId;
use botloft_core::protocol::{BotState, DesktopState};
use tokio::sync::broadcast::error::RecvError;

use super::notice::color;
use crate::platform::desktop;
use crate::state::{Daemon, Event};

/// How often the outline catches up with its window.
const FOLLOW_EVERY: Duration = Duration::from_millis(250);
/// How long it stays after the bot's turn ended.
const AFTER_TURN: Duration = Duration::from_secs(3);
/// How long it stays with the bot working but away from the desktop.
const IDLE_FOR: Duration = Duration::from_secs(60);

/// The window outlined now.
#[derive(Debug, Clone, PartialEq)]
struct Outlined {
bot: BotId,
window: u64,
color: (u8, u8, u8),
used: Instant,
/// When the bot's turn ended, if it did.
turn_over: Option<Instant>,
}

/// What the outline does, apart from the screen.
#[derive(Debug, Default)]
struct Follow {
outlined: Option<Outlined>,
/// Each bot's last use, by the state's `at`: a state with the same one
/// is not a use.
last: HashMap<BotId, i64>,
}

impl Follow {
/// A bot read or acted in a window, or the owner stopped it.
fn changed(
&mut self,
state: &DesktopState,
color: impl FnOnce() -> (u8, u8, u8),
now: Instant,
) {
let mine = self
.outlined
.as_ref()
.is_some_and(|outlined| outlined.bot == state.bot_id);
if state.stopped {
if mine {
self.outlined = None;
}
return;
}
let (Some(window), Some(at)) = (&state.window, state.at) else {
return;
};
// Letting it go on is not a use.
if self.last.insert(state.bot_id.clone(), at) == Some(at) {
return;
}
let color = match &self.outlined {
Some(outlined) if mine => outlined.color,
_ => color(),
};
self.outlined = Some(Outlined {
bot: state.bot_id.clone(),
window: window.id,
color,
used: now,
turn_over: None,
});
}

/// The bot's turn began or ended.
fn turn(&mut self, bot: &BotId, working: bool, now: Instant) {
if let Some(outlined) = self
.outlined
.as_mut()
.filter(|outlined| outlined.bot == *bot)
{
outlined.turn_over = (!working).then_some(now);
}
}

/// The window to outline now, if any.
fn now(&mut self, now: Instant) -> Option<(u64, (u8, u8, u8))> {
let outlined = self.outlined.as_ref()?;
let over = outlined
.turn_over
.is_some_and(|ended| now.duration_since(ended) >= AFTER_TURN)
|| now.duration_since(outlined.used) >= IDLE_FOR;
if over {
self.outlined = None;
return None;
}
Some((outlined.window, outlined.color))
}
}

fn working(state: BotState) -> bool {
matches!(state, BotState::Busy | BotState::NeedsApproval)
}

/// Follows what the bots do on the desktop and keeps the outline there.
pub async fn run(daemon: Arc<Daemon>) {
let mut tick = tokio::time::interval(FOLLOW_EVERY);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut events = daemon.subscribe();
let mut follow = Follow::default();
let mut shown = false;
loop {
tokio::select! {
_ = tick.tick() => match follow.now(Instant::now()) {
Some((window, color)) => {
desktop::outline_show(window, color);
shown = true;
}
None if shown => {
desktop::outline_hide();
shown = false;
}
None => {}
},
event = events.recv() => match event {
Ok(Event::DesktopChanged(state)) => {
let hex = || {
let bot = daemon.store().bot(&state.bot_id).ok().flatten();
color(&bot.map(|bot| bot.color).unwrap_or_default())
};
follow.changed(&state, hex, Instant::now());
}
Ok(Event::BotState(change)) => {
follow.turn(&change.bot_id, working(change.state), Instant::now());
}
Ok(_) | Err(RecvError::Lagged(_)) => {}
Err(RecvError::Closed) => return,
},
}
}
}

#[cfg(test)]
mod tests {
use botloft_core::protocol::DesktopWindow;

use super::*;

fn used(bot: &BotId, window: u64, at: i64) -> DesktopState {
DesktopState {
bot_id: bot.clone(),
window: Some(DesktopWindow {
id: window,
title: "Notes".to_owned(),
app: "Notepad".to_owned(),
}),
action: None,
at: Some(at),
stopped: false,
}
}

const BLUE: (u8, u8, u8) = (0x5E, 0xC8, 0xFF);

#[test]
fn the_outline_follows_the_window_in_use_until_the_turn_ends() {
let mut follow = Follow::default();
let (scout, start) = (BotId::generate(), Instant::now());
assert_eq!(follow.now(start), None);
follow.changed(&used(&scout, 7, 1), || BLUE, start);
assert_eq!(follow.now(start), Some((7, BLUE)));

// Its turn ends: a moment more, then gone.
follow.turn(&scout, false, start);
assert_eq!(follow.now(start + Duration::from_secs(1)), Some((7, BLUE)));
assert_eq!(follow.now(start + AFTER_TURN), None);
}

#[test]
fn a_minute_away_from_the_desktop_or_a_stop_takes_it_away() {
let mut follow = Follow::default();
let (scout, start) = (BotId::generate(), Instant::now());
follow.changed(&used(&scout, 7, 1), || BLUE, start);
assert_eq!(follow.now(start + IDLE_FOR), None);

follow.changed(&used(&scout, 7, 2), || BLUE, start);
let mut stopped = used(&scout, 7, 2);
stopped.stopped = true;
follow.changed(&stopped, || BLUE, start);
assert_eq!(follow.now(start), None);
// Letting it go on is not a use.
stopped.stopped = false;
follow.changed(&stopped, || BLUE, start);
assert_eq!(follow.now(start), None);
}

#[test]
fn the_window_used_last_is_the_one_outlined() {
let mut follow = Follow::default();
let (scout, writer, start) = (BotId::generate(), BotId::generate(), Instant::now());
follow.changed(&used(&scout, 7, 1), || BLUE, start);
follow.changed(&used(&writer, 9, 2), || (1, 2, 3), start);
assert_eq!(follow.now(start), Some((9, (1, 2, 3))));
// Another bot's turn ending leaves it.
follow.turn(&scout, false, start);
assert_eq!(follow.now(start + AFTER_TURN), Some((9, (1, 2, 3))));
}
}
2 changes: 2 additions & 0 deletions crates/botloftd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a
tokio::spawn(courier::run(Arc::clone(&daemon)));
tokio::spawn(routines::run(Arc::clone(&daemon)));
tokio::spawn(browser::run(Arc::clone(&daemon)));
// The outline around the window a bot uses (spec 24.9).
tokio::spawn(botloftd::desktop::outline::run(Arc::clone(&daemon)));
tokio::spawn(keep_awake::run(
daemon.supervisor.busy_bots(),
daemon.settings.keep_awake(),
Expand Down
18 changes: 2 additions & 16 deletions crates/botloftd/src/platform/desktop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

mod blocked;
pub mod keys;
mod overlay;
mod read;
// Only Windows takes pictures; the tests run everywhere.
#[cfg_attr(not(windows), allow(dead_code))]
Expand All @@ -15,6 +16,7 @@ mod windows;
use std::path::PathBuf;

pub use blocked::{Never, never};
pub use overlay::{notice_hide, notice_show, outline_hide, outline_show};
pub use read::{Control, LINES_MAX, by_reference, reference, render};

/// The largest picture of a window the bot gets, in logical pixels (24.4).
Expand Down Expand Up @@ -260,22 +262,6 @@ pub fn frame(id: u64) -> Result<[i32; 4], DesktopError> {
}
}

/// Shows the notice that a bot uses the real mouse and keyboard: `text`
/// in a pill, a border in `color` around the monitor of window `over`
/// (spec 24.7).
pub fn notice_show(text: &str, color: (u8, u8, u8), over: u64) {
#[cfg(windows)]
windows::notice_show(text, color, over);
#[cfg(not(windows))]
let _ = (text, color, over);
}

/// Takes the notice away.
pub fn notice_hide() {
#[cfg(windows)]
windows::notice_hide();
}

/// A picture of the window `id`, only of it, even behind other windows, in
/// logical pixels up to [`PICTURE_MAX`] (spec 24.4).
pub fn picture(id: u64) -> Result<Picture, DesktopError> {
Expand Down
37 changes: 37 additions & 0 deletions crates/botloftd/src/platform/desktop/overlay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//! What the daemon draws on the owner's screen while a bot uses it: the
//! notice of the real mouse and keyboard (spec 24.7) and the outline around
//! the window the bot uses (spec 24.9).

#[cfg(windows)]
use super::windows;

/// Shows the notice that a bot uses the real mouse and keyboard: `text`
/// in a pill, a border in `color` around the monitor of window `over`
/// (spec 24.7).
pub fn notice_show(text: &str, color: (u8, u8, u8), over: u64) {
#[cfg(windows)]
windows::notice_show(text, color, over);
#[cfg(not(windows))]
let _ = (text, color, over);
}

/// Takes the notice away.
pub fn notice_hide() {
#[cfg(windows)]
windows::notice_hide();
}

/// Outlines window `over` in `color`, or moves the outline to where the
/// window is now; hidden while the window is minimized or gone.
pub fn outline_show(over: u64, color: (u8, u8, u8)) {
#[cfg(windows)]
windows::outline_show(over, color);
#[cfg(not(windows))]
let _ = (over, color);
}

/// Takes the outline away.
pub fn outline_hide() {
#[cfg(windows)]
windows::outline_hide();
}
4 changes: 4 additions & 0 deletions crates/botloftd/src/platform/desktop/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ mod act_tests;
mod capture;
mod input;
mod notice;
mod outline;
#[cfg(test)]
mod outline_tests;
mod owner;
mod process;
mod real;
Expand Down Expand Up @@ -38,6 +41,7 @@ pub use act::act;
pub use capture::{capture, frame};
pub use input::owner_idle;
pub use notice::{hide as notice_hide, show as notice_show};
pub use outline::{hide as outline_hide, show as outline_show};
pub use real::{click as real_click, press as real_press, type_text as real_type};
pub use stop_key::on_stop_key;
pub use uia::read;
Expand Down
Loading
Loading