use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Serialize;
use crate::progress::human_duration;
pub const SINK_ENV: &str = "TURNOUT_TOASTS";
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
pub struct Toast {
pub title: String,
pub body: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub open: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub buttons: Vec<Button>,
pub failure: bool,
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
pub struct Button {
pub label: String,
pub open: String,
}
impl Button {
fn new(label: &str, open: impl Into<String>) -> Self {
Self {
label: label.to_string(),
open: open.into(),
}
}
}
pub fn show(toast: &Toast) -> Result<()> {
if let Some(path) = std::env::var_os(SINK_ENV).filter(|path| !path.is_empty()) {
return record(Path::new(&path), toast);
}
imp::show(toast)
}
pub fn settle() {
if std::env::var_os(SINK_ENV).is_some_and(|path| !path.is_empty()) {
return;
}
imp::settle();
}
fn record(path: &Path, toast: &Toast) -> Result<()> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("cannot write {}", path.display()))?;
let line = format!("{}\n", serde_json::to_string(toast)?);
file.write_all(line.as_bytes()).with_context(|| format!("cannot write {}", path.display()))
}
pub struct Places<'a> {
pub log: Option<&'a Path>,
pub dir: &'a Path,
}
impl Places<'_> {
fn log_url(&self) -> Option<String> {
self.log.map(file_url)
}
fn log_button(&self) -> Option<Button> {
self.log_url().map(|url| Button::new("Show log", url))
}
fn folder_button(&self) -> Button {
Button::new("Show folder", file_url(self.dir))
}
}
fn subject(app: &str, command: &str) -> String {
if command == "dev" { app.to_string() } else { format!("{app} {command}") }
}
pub fn ready(app: &str, command: &str, address: Option<&str>, elapsed: Duration, places: &Places<'_>) -> Toast {
let started = format!("started in {}", human_duration(elapsed));
let mut buttons = Vec::new();
let (body, open) = match address {
Some(url) => {
buttons.push(Button::new("Open in browser", url));
(format!("{url} - {started}"), Some(url.to_string()))
}
None => (started, places.log_url()),
};
buttons.extend(places.log_button());
Toast {
title: format!("{} is ready", subject(app, command)),
body,
note: None,
open,
buttons,
failure: false,
}
}
pub fn finished(app: &str, command: &str, elapsed: Duration, link: Option<&str>, places: &Places<'_>) -> Toast {
let took = format!("in {}", human_duration(elapsed));
let title = if command == "deploy" {
format!("{app} deployed")
} else {
format!("{} finished", subject(app, command))
};
match link {
Some(url) => Toast {
title,
body: format!("{url} - {took}"),
note: None,
open: Some(url.to_string()),
buttons: [Some(Button::new("Open in browser", url)), places.log_button()].into_iter().flatten().collect(),
failure: false,
},
None => Toast {
title,
body: took,
note: None,
open: places.log_url(),
buttons: places.log_button().into_iter().chain([places.folder_button()]).collect(),
failure: false,
},
}
}
pub fn stopped(app: &str, command: &str, span: &str, places: &Places<'_>) -> Toast {
Toast {
title: format!("{} stopped", subject(app, command)),
body: format!("after {span}"),
note: None,
open: places.log_url(),
buttons: places.log_button().into_iter().collect(),
failure: false,
}
}
pub fn failed(app: &str, command: &str, how: &str, reason: Option<&str>, places: &Places<'_>) -> Toast {
let logs = if command == "dev" {
format!("turnout logs {app} dev --failed")
} else {
format!("turnout logs {app} {command} --failed")
};
Toast {
title: format!("{} failed", subject(app, command)),
body: reason.map_or_else(|| how.to_string(), str::to_string),
note: Some(format!("{how} - {logs}")),
open: places.log_url(),
buttons: places.log_button().into_iter().chain([places.folder_button()]).collect(),
failure: true,
}
}
pub fn session_expired(app: &str, server: &str, login: &str) -> Toast {
Toast {
title: format!("{app}: the {server} session expired"),
body: "The stand refused the gateway's cookies. Log in again, or hand the gateway a cookie from the browser.".to_string(),
note: Some(format!("turnout cookie set {app}")),
open: Some(login.to_string()),
buttons: vec![Button::new("Log in", login)],
failure: true,
}
}
pub fn stand_down(server: &str, status: Option<u16>, app: &str, url: &str) -> Toast {
Toast {
title: match status {
Some(status) => format!("{server} answers {status}"),
None => format!("{server} does not answer"),
},
body: format!("{app}'s requests fail at the stand, not in the app."),
note: Some("turnout status".to_string()),
open: Some(url.to_string()),
buttons: vec![Button::new("Open the stand", url)],
failure: true,
}
}
const REASON_CHARS: usize = 160;
pub fn reason(tail: &[String]) -> Option<String> {
let lines: Vec<String> = tail
.iter()
.map(|line| crate::job::strip_ansi(line).trim().to_string())
.filter(|line| !line.is_empty())
.collect();
let own: Vec<&String> = lines.iter().filter(|line| !is_wrapper(line)).collect();
let line = own
.iter()
.rev()
.find(|line| line.to_ascii_lowercase().contains("error"))
.or_else(|| own.last())
.copied()
.or_else(|| lines.last())?;
Some(cut(line, REASON_CHARS))
}
fn is_wrapper(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
[
"npm err",
"npm warn",
"elifecycle",
"error command failed",
"info visit",
"yarn run v",
"done in",
]
.iter()
.any(|prefix| lower.starts_with(prefix))
}
fn cut(text: &str, chars: usize) -> String {
if text.chars().count() <= chars {
return text.to_string();
}
let kept: String = text.chars().take(chars.saturating_sub(3)).collect();
format!("{}...", kept.trim_end())
}
pub fn file_url(path: &Path) -> String {
let raw = path.to_string_lossy();
let raw = raw
.strip_prefix(r"\\?\UNC\")
.map(|share| format!(r"\\{share}"))
.unwrap_or_else(|| raw.strip_prefix(r"\\?\").unwrap_or(&raw).to_string());
let text = raw.replace('\\', "/");
let mut url = String::from("file:");
if text.starts_with("//") {
} else if text.starts_with('/') {
url.push_str("//");
} else {
url.push_str("///");
}
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b'~' | b':') {
url.push(byte as char);
} else {
url.push_str(&format!("%{byte:02X}"));
}
}
url
}
#[cfg(not(target_os = "macos"))]
fn icon() -> Option<std::path::PathBuf> {
const ICON: &[u8] = include_bytes!("../assets/toast-icon.png");
let path = crate::paths::data_dir().ok()?.join("toast-icon.png");
if std::fs::metadata(&path).map(|meta| meta.len() != ICON.len() as u64).unwrap_or(true) {
std::fs::write(&path, ICON).ok()?;
}
Some(path)
}
#[cfg(any(windows, test))]
fn windows_xml(toast: &Toast) -> String {
let mut xml = String::from("<toast");
if let Some(open) = &toast.open {
xml.push_str(&format!(r#" activationType="protocol" launch="{}""#, escape_xml(open)));
}
if toast.failure {
xml.push_str(r#" duration="long""#);
}
xml.push_str(r#"><visual><binding template="ToastGeneric">"#);
xml.push_str(&format!("<text>{}</text>", escape_xml(&toast.title)));
if !toast.body.is_empty() {
xml.push_str(&format!("<text>{}</text>", escape_xml(&toast.body)));
}
if let Some(note) = &toast.note {
xml.push_str(&format!(r#"<text placement="attribution">{}</text>"#, escape_xml(note)));
}
xml.push_str("</binding></visual>");
if !toast.buttons.is_empty() {
xml.push_str("<actions>");
for button in &toast.buttons {
xml.push_str(&format!(
r#"<action content="{}" activationType="protocol" arguments="{}"/>"#,
escape_xml(&button.label),
escape_xml(&button.open)
));
}
xml.push_str("</actions>");
}
xml.push_str("</toast>");
xml
}
#[cfg(any(windows, test))]
fn escape_xml(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c if (c as u32) < 0x20 && !matches!(c, '\t' | '\n' | '\r') => {}
c => out.push(c),
}
}
out
}
#[cfg(windows)]
mod imp {
use anyhow::{Context, Result, bail};
use windows::Data::Xml::Dom::XmlDocument;
use windows::UI::Notifications::{ToastNotification, ToastNotificationManager};
use windows::core::HSTRING;
use super::Toast;
const APP_ID: &str = "lacodda.turnout";
pub fn show(toast: &Toast) -> Result<()> {
let registered = register();
let xml = XmlDocument::new().context("cannot prepare the notification")?;
xml.LoadXml(&HSTRING::from(super::windows_xml(toast)))
.context("cannot prepare the notification")?;
let notification = ToastNotification::CreateToastNotification(&xml).context("cannot prepare the notification")?;
let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(APP_ID)).context("cannot reach the notification centre")?;
notifier.Show(¬ification).context("cannot show the notification")?;
registered.context("the notification went out without turnout's name and icon")
}
pub fn settle() {}
fn register() -> Result<()> {
use windows_sys::Win32::System::Registry::{HKEY, HKEY_CURRENT_USER, KEY_SET_VALUE, REG_OPTION_NON_VOLATILE, RegCloseKey, RegCreateKeyExW};
let subkey = wide(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"));
let mut key: HKEY = std::ptr::null_mut();
let status = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
subkey.as_ptr(),
0,
std::ptr::null(),
REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
if status != 0 {
bail!("cannot register turnout as a notification sender (error {status})");
}
let mut result = set_string(key, "DisplayName", "turnout");
if result.is_ok()
&& let Some(icon) = super::icon()
{
result = set_string(key, "IconUri", &icon.to_string_lossy());
}
unsafe { RegCloseKey(key) };
result
}
fn set_string(key: windows_sys::Win32::System::Registry::HKEY, name: &str, value: &str) -> Result<()> {
use windows_sys::Win32::System::Registry::{REG_SZ, RegSetValueExW};
let name_w = wide(name);
let data = wide(value);
let status = unsafe { RegSetValueExW(key, name_w.as_ptr(), 0, REG_SZ, data.as_ptr().cast(), (data.len() * 2) as u32) };
if status != 0 {
bail!("cannot write {name} for the notification sender (error {status})");
}
Ok(())
}
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(std::iter::once(0)).collect()
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod imp {
use std::sync::Mutex;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use super::Toast;
const LINGER: Duration = Duration::from_secs(15 * 60);
static WAITING: Mutex<Vec<JoinHandle<()>>> = Mutex::new(Vec::new());
pub fn show(toast: &Toast) -> Result<()> {
let mut notification = notify_rust::Notification::new();
notification.appname("turnout").summary(&toast.title).body(&body(toast));
if let Some(icon) = super::icon() {
notification.icon(&icon.to_string_lossy());
}
let mut targets: Vec<(String, String)> = Vec::new();
if let Some(open) = &toast.open {
notification.action("default", "Open");
targets.push(("default".to_string(), open.clone()));
}
for (index, button) in toast.buttons.iter().enumerate() {
let id = format!("button-{index}");
notification.action(&id, &button.label);
targets.push((id, button.open.clone()));
}
let handle = notification.show().context("cannot show the notification")?;
if targets.is_empty() {
return Ok(());
}
let waiter = std::thread::spawn(move || {
handle.wait_for_action(|action| {
if let Some((_, url)) = targets.iter().find(|(id, _)| id == action) {
let _ = crate::front::open_in_browser(url);
}
});
});
WAITING.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(waiter);
Ok(())
}
pub fn settle() {
let deadline = Instant::now() + LINGER;
loop {
let done = WAITING
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.iter()
.all(JoinHandle::is_finished);
if done || Instant::now() >= deadline {
return;
}
std::thread::sleep(Duration::from_secs(1));
}
}
fn body(toast: &Toast) -> String {
let text = match &toast.note {
Some(note) => format!("{}\n{note}", toast.body),
None => toast.body.clone(),
};
text.replace('&', "&").replace('<', "<").replace('>', ">")
}
}
#[cfg(target_os = "macos")]
mod imp {
use anyhow::{Context, Result};
use super::Toast;
pub fn show(toast: &Toast) -> Result<()> {
let body = match &toast.note {
Some(note) => format!("{}\n{note}", toast.body),
None => toast.body.clone(),
};
notify_rust::Notification::new()
.summary(&toast.title)
.body(&body)
.show()
.context("cannot show the notification")?;
Ok(())
}
pub fn settle() {}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn places(log: Option<&Path>) -> Places<'_> {
Places {
log,
dir: Path::new("/work/myapp"),
}
}
#[test]
fn a_path_becomes_a_url_an_opener_reads_back() {
assert_eq!(
file_url(Path::new("/home/me/.local/share/lacodda/turnout/logs/myapp.test%3Ae2e.log")),
"file:///home/me/.local/share/lacodda/turnout/logs/myapp.test%253Ae2e.log"
);
assert_eq!(
file_url(Path::new(r"C:\Users\Анна Ли\x.log")),
"file:///C:/Users/%D0%90%D0%BD%D0%BD%D0%B0%20%D0%9B%D0%B8/x.log"
);
assert_eq!(file_url(Path::new(r"\\?\C:\work\myapp")), "file:///C:/work/myapp");
assert_eq!(file_url(Path::new(r"\\?\UNC\server\share\app")), "file://server/share/app");
assert_eq!(file_url(Path::new("/tmp/a#b?c")), "file:///tmp/a%23b%3Fc");
}
#[test]
fn a_ready_toast_leads_to_the_server() {
let log = PathBuf::from("/logs/myapp.dev.log");
let toast = ready("myapp", "dev", Some("http://myapp.localhost"), Duration::from_millis(800), &places(Some(&log)));
assert_eq!(toast.title, "myapp is ready");
assert_eq!(toast.body, "http://myapp.localhost - started in 0.8s");
assert_eq!(toast.open.as_deref(), Some("http://myapp.localhost"));
assert_eq!(toast.buttons[0], Button::new("Open in browser", "http://myapp.localhost"));
assert_eq!(toast.buttons[1], Button::new("Show log", "file:///logs/myapp.dev.log"));
assert!(!toast.failure);
let quiet = ready("myapp", "storybook", None, Duration::from_secs(3), &places(Some(&log)));
assert_eq!(quiet.title, "myapp storybook is ready");
assert_eq!(quiet.open.as_deref(), Some("file:///logs/myapp.dev.log"));
}
#[test]
fn a_finished_toast_leads_to_the_result() {
let log = PathBuf::from("/logs/myapp.deploy.log");
let deployed = finished(
"myapp",
"deploy",
Duration::from_secs(34),
Some("https://staging.example.com"),
&places(Some(&log)),
);
assert_eq!(deployed.title, "myapp deployed");
assert_eq!(deployed.body, "https://staging.example.com - in 34s");
assert_eq!(deployed.open.as_deref(), Some("https://staging.example.com"));
let built = finished("myapp", "build", Duration::from_secs(72), None, &places(Some(&log)));
assert_eq!(built.title, "myapp build finished");
assert_eq!(built.body, "in 1m 12s");
assert_eq!(built.open.as_deref(), Some("file:///logs/myapp.deploy.log"));
assert_eq!(built.buttons.last().unwrap(), &Button::new("Show folder", "file:///work/myapp"));
}
#[test]
fn a_failure_toast_says_why_and_where() {
let kept = PathBuf::from("/logs/failed/myapp.build.log");
let toast = failed("myapp", "build", "exit 2 after 34s", Some("error TS2322: nope"), &places(Some(&kept)));
assert_eq!(toast.title, "myapp build failed");
assert_eq!(toast.body, "error TS2322: nope");
assert_eq!(toast.note.as_deref(), Some("exit 2 after 34s - turnout logs myapp build --failed"));
assert_eq!(toast.open.as_deref(), Some("file:///logs/failed/myapp.build.log"));
assert!(toast.failure);
assert_eq!(failed("myapp", "dev", "exit 1 after 2s", None, &places(None)).body, "exit 1 after 2s");
}
#[test]
fn the_reason_is_the_last_line_that_names_an_error() {
let tail = |lines: &[&str]| lines.iter().map(|line| line.to_string()).collect::<Vec<_>>();
let compiler = "src/main.ts(3,7): \x1b[31merror\x1b[0m TS2322: Type 'string' is not assignable";
let expected = Some("src/main.ts(3,7): error TS2322: Type 'string' is not assignable".to_string());
assert_eq!(
reason(&tail(&[
compiler,
"npm error Lifecycle script `build` failed with error:",
"npm error code 2",
"npm error A complete log of this run can be found in: x"
])),
expected
);
assert_eq!(reason(&tail(&[compiler, "npm ERR! code ELIFECYCLE", "npm ERR! errno 2"])), expected);
assert_eq!(
reason(&tail(&[
"yarn run v1.22.22",
"$ tsc",
compiler,
"error Command failed with exit code 2.",
"info Visit https://yarnpkg.com/en/docs/cli/run"
])),
expected
);
assert_eq!(reason(&tail(&[compiler, " ELIFECYCLE Command failed with exit code 2."])), expected);
assert_eq!(reason(&tail(&["npm error code 2"])), Some("npm error code 2".to_string()));
assert_eq!(
reason(&tail(&["error: expected `;`", "", " 1 problem", "Done in 2s"])),
Some("error: expected `;`".to_string())
);
assert_eq!(reason(&tail(&["compiling", "exit"])), Some("exit".to_string()));
assert_eq!(reason(&tail(&["", " "])), None);
let long = "x".repeat(400);
assert_eq!(reason(&tail(&[&long])).unwrap().chars().count(), REASON_CHARS);
}
#[test]
fn the_windows_toast_is_protocol_activated_and_escaped() {
let toast = Toast {
title: "myapp build failed".into(),
body: "error: <T> & \"x\"\u{1b}[0m".into(),
note: Some("exit 2".into()),
open: Some("file:///C:/logs/a%253Ab.log".into()),
buttons: vec![Button::new("Show folder", "file:///C:/work/myapp")],
failure: true,
};
let xml = windows_xml(&toast);
assert!(
xml.starts_with(r#"<toast activationType="protocol" launch="file:///C:/logs/a%253Ab.log" duration="long">"#),
"{xml}"
);
assert!(xml.contains("<text>error: <T> & "x"[0m</text>"), "{xml}");
assert!(xml.contains(r#"<text placement="attribution">exit 2</text>"#), "{xml}");
assert!(
xml.contains(r#"<action content="Show folder" activationType="protocol" arguments="file:///C:/work/myapp"/>"#),
"{xml}"
);
let plain = windows_xml(&Toast {
buttons: Vec::new(),
failure: false,
..toast
});
assert!(!plain.contains("duration") && !plain.contains("<actions>"), "{plain}");
}
}