Skip to content

Commit

Permalink
Hooks: introduce bi-directional communication
Browse files Browse the repository at this point in the history
*Plato* can now responds to fetcher events by writing on the fetcher's
standard input. We've added a new fetcher event, *search*, that
illustrate this new mechanism.
  • Loading branch information
baskerville committed Jan 3, 2021
1 parent a8eee70 commit b151712
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 121 deletions.
22 changes: 0 additions & 22 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 1 addition & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,11 @@ version = "0.4.19"
version = "0.34.3"
optional = true

[dependencies.crossbeam-channel]
version = "0.5.0"
optional = true

[dependencies.signal-hook]
version = "0.3.1"
optional = true

[features]
importer = ["getopts"]
emulator = ["sdl2"]
fetcher = ["reqwest", "crossbeam-channel", "signal-hook"]
fetcher = ["reqwest", "signal-hook"]
39 changes: 25 additions & 14 deletions doc/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,27 @@ Here's an example hook, that launches the default article fetcher included in
path = "Articles"
program = "bin/article_fetcher/article_fetcher"
sort-method = "added"
first-column = "title-and-author"
second-column = "progress"
```

The above chunk needs to be added after one of the `[[libraries]]` section.

The `path` key is the path of the directory that will trigger the hook. The
`sort-method` and `second-column` keys are optional.
`path` is the path of the directory that will trigger the hook. `program` is
the path to the executable associated with this hook. The `sort-method`,
`first-column` and `second-column` keys are optional.

The *Toogle Select* sub-menu of the library menu can be used to trigger a hook when the
corresponding directory doesn't exit yet. Otherwise, you can just tap
the directory label in the navigation bar. When the hook is triggered, the
associated `program` is spawned. It will receive the directory path, wifi and
online statuses (*true* or *false*) as arguments.
The *Toogle Select* sub-menu of the library menu can be used to trigger a hook
when there's no imported documents in `path`. Otherwise, you can just tap the
directory in the navigation bar. When the hook is triggered, the associated
`program` is spawned. It will receive the directory path, wifi and online
statuses (*true* or *false*) as arguments.

A fetcher can send events to *Plato* through its standard output.
Each event is a JSON object with a required `type` key:
A fetcher can use its standard output (resp. standard input) to send events to
(resp. receive events from) *Plato*. An event is a JSON object with a required
`type` key. Events are read and written line by line, one per line.

The events that can be written to standard output are:

```
// Display a notification message.
Expand All @@ -32,16 +37,22 @@ Each event is a JSON object with a required `type` key:
{"type": "addDocument", "info": OBJECT}
// Enable or disable the WiFi.
{"type": "setWifi", "enable": BOOL}
// Search for books matching the given query.
{"type": "search", "query": STRING}
// Import new entries and update existing entries in the current library.
{"type": "import"}
// Remove entries with dangling paths from the current library.
{"type": "cleanUp"}
```

On *Plato*'s side, the events are read line by line, one event per line.
The events that can be read from standard input are:

When the network becomes operational, *Plato* will send the `SIGUSR1` signal to
all the fetchers.
```
// Sent in response to `search`. `results` is an array of *Info* objects.
{"type": "search": "results": ARRAY}
// Sent to all the fetchers when the network becomes available.
{"type": "network", "status": "up"}
```

When the associated directory is deselected, *Plato* will send the `SIGTERM`
signal to the corresponding fetcher.
When a directory is deselected, *Plato* will send the `SIGTERM` signal to all
the matching fetchers.
59 changes: 24 additions & 35 deletions src/fetcher.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
mod helpers;

use std::io;
use std::env;
use std::thread;
use std::fs::{self, File};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use reqwest::blocking::Client;
use serde_json::json;
use chrono::{Duration, Utc, Local, DateTime};
Expand All @@ -16,11 +18,6 @@ const SETTINGS_PATH: &str = "Settings.toml";
const SESSION_PATH: &str = ".session.json";
// Nearly RFC 3339
const DATE_FORMAT: &str = "%FT%T%z";
const LISTENED_SIGNALS: &[libc::c_int] = &[
signal_hook::consts::SIGINT, signal_hook::consts::SIGHUP,
signal_hook::consts::SIGQUIT, signal_hook::consts::SIGTERM,
signal_hook::consts::SIGUSR1, signal_hook::consts::SIGUSR2,
];

#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
Expand Down Expand Up @@ -64,19 +61,6 @@ impl Default for Session {
}
}

fn signal_receiver(signals: &[libc::c_int]) -> Result<crossbeam_channel::Receiver<libc::c_int>, Error> {
let (s, r) = crossbeam_channel::bounded(4);
let mut signals = signal_hook::iterator::Signals::new(signals)?;
thread::spawn(move || {
for signal in signals.forever() {
if s.send(signal).is_err() {
break;
}
}
});
Ok(r)
}

fn update_token(client: &Client, session: &mut Session, settings: &Settings) -> Result<(), Error> {
let query = json!({
"grant_type": "password",
Expand Down Expand Up @@ -120,15 +104,17 @@ fn main() -> Result<(), Error> {
.with_context(|| format!("Can't load settings from {}", SETTINGS_PATH))?;
let mut session = load_json::<Session, _>(SESSION_PATH)
.unwrap_or_default();
let signals = signal_receiver(LISTENED_SIGNALS)?;

if !online {
let event = json!({
"type": "setWifi",
"enable": true,
});
println!("{}", event);
signals.recv()?;
if !wifi {
let event = json!({
"type": "setWifi",
"enable": true,
});
println!("{}", event);
}
let mut line = String::new();
io::stdin().read_line(&mut line)?;
}

if !save_path.exists() {
Expand All @@ -147,6 +133,9 @@ fn main() -> Result<(), Error> {
let since = session.since;
let url = format!("{}/api/entries", &settings.base_url);

let sigterm = Arc::new(AtomicBool::new(false));
signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&sigterm))?;

'outer: loop {
let query = json!({
"since": since,
Expand Down Expand Up @@ -191,10 +180,8 @@ fn main() -> Result<(), Error> {

if let Some(items) = entries.pointer("/_embedded/items").and_then(|v| v.as_array()) {
for element in items {
if let Ok(sig) = signals.try_recv() {
if sig != signal_hook::consts::SIGUSR1 {
break 'outer;
}
if sigterm.load(Ordering::Relaxed) {
break 'outer
}

let id = element.get("id")
Expand Down Expand Up @@ -309,11 +296,13 @@ fn main() -> Result<(), Error> {
println!("{}", event);
}

let event = json!({
"type": "setWifi",
"enable": wifi,
});
println!("{}", event);
if !wifi {
let event = json!({
"type": "setWifi",
"enable": false,
});
println!("{}", event);
}

save_json(&session, SESSION_PATH).context("Can't save session.")?;
Ok(())
Expand Down
4 changes: 2 additions & 2 deletions src/settings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ pub enum SecondColumn {
#[serde(default, rename_all = "kebab-case")]
pub struct Hook {
pub path: PathBuf,
pub program: Option<PathBuf>,
pub program: PathBuf,
pub sort_method: Option<SortMethod>,
pub first_column: Option<FirstColumn>,
pub second_column: Option<SecondColumn>,
Expand All @@ -240,7 +240,7 @@ impl Default for Hook {
fn default() -> Self {
Hook {
path: PathBuf::default(),
program: None,
program: PathBuf::default(),
sort_method: None,
first_column: None,
second_column: None,
Expand Down
Loading

0 comments on commit b151712

Please sign in to comment.