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
107 changes: 97 additions & 10 deletions crates/benshi/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::fmt;
use std::io::{self, Write};
use std::process::ExitCode;
#[cfg(target_os = "linux")]
use std::sync::Arc;
use std::sync::{Arc, PoisonError};
#[cfg(target_os = "linux")]
use std::task::Poll;
#[cfg(target_os = "linux")]
Expand Down Expand Up @@ -51,6 +51,10 @@ const STOPPED: u8 = 0;
#[cfg(target_os = "linux")]
const FAILED: u8 = 1;

/// Where a token for AniList may be given from the environment.
#[cfg(target_os = "linux")]
const TOKEN: &str = "BENSHI_TOKEN_ANILIST";

/// What one invocation of this binary is for.
///
/// Decided here rather than by the argument parser, because the two halves
Expand Down Expand Up @@ -293,10 +297,11 @@ async fn serve(voice: &Arc<Voice>) -> u8 {
use benshi_core::clock::SystemClock;
use benshi_daemon::accounts::OnAniList;
use benshi_daemon::config;
use benshi_daemon::listing::{self, FromAniList, Listing};
use benshi_daemon::paths::{DATABASE, Role, directory};
use benshi_daemon::recognition::Recogniser;
use benshi_detect::mpris::{MprisWatcher, POLL_INTERVAL, SOURCE_DEADLINE};
use benshi_store::Store;
use benshi_sync::anilist::transport::ANILIST;

// Taken ahead of the socket and the database, so that a signal sent
// while one is bound and the other opened stops the daemon once its
Expand All @@ -308,9 +313,8 @@ async fn serve(voice: &Arc<Voice>) -> u8 {
Err(code) => return code,
};
// Read ahead of the socket and the database, so that a file that cannot
// be used ends the daemon having touched neither. No task reads a
// setting.
let (_settings, origin) = match config::load(directory(Role::Config)) {
// be used ends the daemon having touched neither.
let (settings, origin) = match config::load(directory(Role::Config)) {
Ok(loaded) => loaded,
Err(unusable) => {
voice.say(format_args!("{unusable}"));
Expand Down Expand Up @@ -354,22 +358,42 @@ async fn serve(voice: &Arc<Voice>) -> u8 {
return FAILED;
}
};
// What the store holds of the list is what the first round decides
// against, so a daemon recognises before it reads the list again.
let recogniser = match listing::kept(&store.lock().expect("nothing has used the store yet")) {
Ok(recogniser) => recogniser,
Err(unread) => {
voice.say(format_args!(
"{} cannot be used: {unread}",
database.display()
));
return FAILED;
}
};
let given = given();
let words = Arc::new(UpToTheLast::default());
voice.say(format_args!("{origin}"));
voice.say(format_args!("listening on {}", socket.display()));
voice.say(format_args!("recording in {}", database.display()));
if given.is_some() {
voice.say(format_args!("AniList token from {TOKEN}"));
}

// A factory rather than a watcher: a restart has to reconnect to the
// session bus, which is the failure it exists to recover from.
let running = benshi_daemon::run(
|| MprisWatcher::connect(SystemClock::new(), SOURCE_DEADLINE),
listener,
// No list to match a name against, so every file is refused and
// `benshi why` says so.
Recogniser::empty(),
recogniser,
store,
SystemTime::now,
POLL_INTERVAL,
Arc::new(OnAniList::new(benshi_sync::anilist::transport::ANILIST)),
Arc::new(OnAniList::new(ANILIST)),
Listing {
source: FromAniList::new(ANILIST, given),
reading: settings.list,
say: saying(voice, &words),
},
// Said as it happens: the socket goes on answering once detection
// has stopped, and the process does not end to say so. The
// supervisor deals with no task that ended until this returns, so it
Expand All @@ -393,11 +417,59 @@ async fn serve(voice: &Arc<Voice>) -> u8 {
// task as it is dropped. A task that is aborted ends at an await: the
// one it waits at, or the next it reaches. The runtime waits for that
// as it is dropped, and for a call into the store that is under way.
voice.say(format_args!("stopped by {signal}"));
words.last(|| voice.say(format_args!("stopped by {signal}")));

STOPPED
}

/// The token for AniList given from the environment, where one is: for tests
/// and development, and said in no help text. A token given here is read in
/// place of the one a login keeps, for as long as the daemon runs.
#[cfg(target_os = "linux")]
fn given() -> Option<benshi_core::token::Token> {
std::env::var(TOKEN)
.ok()
.map(|token| token.trim().to_owned())
.filter(|token| !token.is_empty())
.map(benshi_core::token::Token::new)
}

/// Lines said up to the daemon's last words, and none after them.
///
/// A task that is stopped runs on to the next await it reaches. A call it
/// waits for can fail as the runtime goes, and the task would say so after
/// the line that says why the daemon stopped.
#[cfg(target_os = "linux")]
#[derive(Debug, Default)]
struct UpToTheLast(std::sync::Mutex<bool>);

#[cfg(target_os = "linux")]
impl UpToTheLast {
/// Say a line through `say`, where the last words are not said yet.
fn say(&self, say: impl FnOnce()) {
let said_last = self.0.lock().unwrap_or_else(PoisonError::into_inner);
if !*said_last {
say();
}
}

/// Say the last words through `say`. Nothing is said after them.
fn last(&self, say: impl FnOnce()) {
let mut said_last = self.0.lock().unwrap_or_else(PoisonError::into_inner);
*said_last = true;
say();
}
}

/// A line a task says, said by `voice` up to the last words of `words`.
#[cfg(target_os = "linux")]
fn saying(voice: &Arc<Voice>, words: &Arc<UpToTheLast>) -> benshi_daemon::listing::Say {
let voice = Arc::clone(voice);
let words = Arc::clone(words);

Arc::new(move |line: &str| words.say(|| voice.say(format_args!("{line}"))))
}

/// There is no adapter for this platform, so there is no daemon to run.
///
/// Not async, and no runtime is built. A runtime with nothing to watch would
Expand All @@ -418,6 +490,21 @@ mod tests {
use std::io;

use super::Invocation;
#[cfg(target_os = "linux")]
use super::UpToTheLast;

#[cfg(target_os = "linux")]
#[test]
fn nothing_is_said_after_the_last_words() {
let words = UpToTheLast::default();
let said = RefCell::new(Vec::new());

words.say(|| said.borrow_mut().push("a line"));
words.last(|| said.borrow_mut().push("the last words"));
words.say(|| said.borrow_mut().push("a line after them"));

assert_eq!(said.into_inner(), ["a line", "the last words"]);
}

#[cfg(target_os = "linux")]
#[test]
Expand Down
105 changes: 105 additions & 0 deletions crates/benshi/tests/the_token_is_given.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! What the daemon says of a token for AniList given from the environment.
//!
//! The settings of these tests read the list at no time, so that no daemon
//! here asks AniList anything.
//!
//! The binary itself is run, in a session the test chose, because what is
//! under test is what a person reads.

#![cfg(target_os = "linux")]

mod common;

use std::ffi::OsStr;
use std::fs;
use std::path::Path;

use common::{Daemon, Directory};

/// The settings of a daemon that reads the list at no time.
const READING_NOTHING: &str = "[list]\nrefresh = \"off\"\nat_start = false\non_miss = \"off\"\n";

/// A session of its own in `temporary`, with settings that read the list at
/// no time.
fn a_session(temporary: &Path) -> Vec<(&'static str, std::ffi::OsString)> {
let config = temporary.join("config");
fs::create_dir_all(config.join("benshi")).expect("a configuration directory");
fs::write(config.join("benshi").join("config.toml"), READING_NOTHING)
.expect("a file of settings");

vec![
("XDG_RUNTIME_DIR", temporary.join("run").into_os_string()),
("XDG_CONFIG_HOME", config.into_os_string()),
("BENSHI_DATA_DIR", temporary.join("data").into_os_string()),
]
}

/// What a daemon in this session says next once it has said where it
/// records.
fn said_after_starting(session: &[(&str, &OsStr)]) -> Option<String> {
let daemon = Daemon::start(session);
let started = daemon.says();
assert!(
started
.last()
.is_some_and(|line| line.starts_with("benshi: recording in ")),
"{started:?}"
);

daemon.says_next()
}

#[test]
fn a_daemon_given_a_token_from_the_environment_says_so_as_it_starts() {
let temporary = Directory::of("k-given");
let mut session = a_session(temporary.path());
session.push(("BENSHI_TOKEN_ANILIST", "a token".into()));
let session: Vec<(&str, &OsStr)> = session
.iter()
.map(|(name, value)| (*name, value.as_os_str()))
.collect();

let next = said_after_starting(&session);

assert_eq!(
next.as_deref(),
Some("benshi: AniList token from BENSHI_TOKEN_ANILIST")
);
}

#[test]
fn a_daemon_given_an_empty_token_says_nothing_of_one_as_it_starts() {
let temporary = Directory::of("k-empty");
let mut session = a_session(temporary.path());
session.push(("BENSHI_TOKEN_ANILIST", " ".into()));
let session: Vec<(&str, &OsStr)> = session
.iter()
.map(|(name, value)| (*name, value.as_os_str()))
.collect();

let next = said_after_starting(&session);

assert!(
next.as_deref()
.is_none_or(|line| !line.starts_with("benshi: AniList token")),
"{next:?}"
);
}

#[test]
fn a_daemon_given_no_token_says_nothing_of_one_as_it_starts() {
let temporary = Directory::of("k-none");
let session = a_session(temporary.path());
let session: Vec<(&str, &OsStr)> = session
.iter()
.map(|(name, value)| (*name, value.as_os_str()))
.collect();

let next = said_after_starting(&session);

assert!(
next.as_deref()
.is_none_or(|line| !line.starts_with("benshi: AniList token")),
"{next:?}"
);
}
2 changes: 2 additions & 0 deletions crates/cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ mod tests {
use benshi_daemon::bus::{BusEvent, EventBus};
use benshi_daemon::detection::Seen;
use benshi_daemon::ipc::{Server, bind};
use benshi_daemon::listing::Wakes;
use benshi_daemon::protocol::{Request, Response};
use benshi_daemon::recognition::{Decided, Decision, Recogniser};
use benshi_daemon::secrets;
Expand Down Expand Up @@ -815,6 +816,7 @@ mod tests {
seen,
decided.clone(),
Arc::clone(&accounts) as Arc<dyn Accounts>,
Wakes::new(),
));

Self {
Expand Down
7 changes: 5 additions & 2 deletions crates/cli/tests/the_client_sees_what_the_daemon_does.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ use benshi_daemon::accounts::{Accounts, Done, LoggedIn};
use benshi_daemon::bus::{BusEvent, EventBus};
use benshi_daemon::detection::{Detection, Seen, Wiring};
use benshi_daemon::ipc::{Server, bind};
use benshi_daemon::recognition::{Decided, Recogniser};
use benshi_daemon::listing::Wakes;
use benshi_daemon::recognition::{Current, Decided, Recogniser};
use benshi_detect::{PlayerWatcher, PollOutcome, SourceInfo, WatchError};
use benshi_store::Store;
use tempfile::TempDir;
Expand Down Expand Up @@ -153,7 +154,8 @@ impl Daemon {
bus: Arc::clone(&bus),
policy: Arc::clone(&policy),
seen: seen.clone(),
recogniser: Arc::new(Recogniser::empty()),
recogniser: Current::new(Recogniser::empty()),
wakes: Wakes::new(),
decided: decided.clone(),
store: Arc::new(Mutex::new(store)),
now: SystemTime::now,
Expand All @@ -170,6 +172,7 @@ impl Daemon {
seen,
decided,
Arc::new(NoLogins),
Wakes::new(),
));
drop(tokio::spawn(server.listen(Arc::new(listener))));

Expand Down
Loading
Loading