#![cfg_attr(not(windows), forbid(unsafe_code))]
#![cfg_attr(windows, windows_subsystem = "windows")]
use std::io::IsTerminal as _;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Args, CommandFactory as _, Parser, Subcommand};
use slipcase_open::endpoint;
use slipcase_open::i18n::{fill, t};
use slipcase_open::ipc::{self, Request, Response, Voice};
use slipcase_open::outside::Outside;
use slipcase_open::platform::Host;
use slipcase_open::policy;
use slipcase_open::present::{self, Channel, Report};
use slipcase_open::resident::{self, Resident};
use slipcase_open::{recover, session};
#[derive(Parser)]
#[command(
version,
about,
long_about = None,
// So that a lone container path is not read as a malformed subcommand.
args_conflicts_with_subcommands = true,
after_long_help = "Settings are read from /etc/slipcase/open.toml and from\n\
$XDG_CONFIG_HOME/slipcase-open/policy.toml, neither of which has to exist.\n\
Run `slipcase-open policy` for the paths on this machine, or see\n\
slipcase-open(1)."
)]
struct Cli {
#[command(subcommand)]
verb: Option<Verb>,
container: Option<PathBuf>,
}
#[derive(Subcommand)]
enum Verb {
Open(Open),
Sessions,
Close(Close),
Recover(Recover),
Policy,
}
#[derive(Args)]
struct Open {
container: PathBuf,
}
#[derive(Args)]
struct Close {
id: String,
}
#[derive(Args)]
struct Recover {
id: String,
#[arg(long, conflicts_with = "discard")]
write_back: bool,
#[arg(long)]
discard: bool,
}
type Fallible = Result<(), Box<dyn std::error::Error>>;
#[derive(Debug)]
struct AlreadySaid;
impl std::fmt::Display for AlreadySaid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("reported through the channel")
}
}
impl std::error::Error for AlreadySaid {}
fn main() -> ExitCode {
#[cfg(windows)]
attach_console();
slipcase_open::i18n::activate(&[
("de", include_str!("../po/de.po")),
#[cfg(debug_assertions)]
("en-x-pseudo", include_str!("../po/en-x-pseudo.po")),
]);
let cli = Cli::parse();
let verb = match (cli.verb, cli.container) {
(Some(verb), _) => Some(verb),
(None, Some(container)) => Some(Verb::Open(Open { container })),
(None, None) => {
if cfg!(windows) {
None
} else {
let _ = Cli::command().print_help();
return ExitCode::from(2);
}
}
};
let outcome = || -> Fallible {
let root = session::default_root()?;
let door = endpoint::path()?;
match verb {
Some(Verb::Open(a)) => open(&root, &door, &a),
Some(Verb::Sessions) => sessions(&root, &door),
Some(Verb::Close(a)) => close(&door, &a),
Some(Verb::Recover(a)) => recover_one(&root, &door, &a),
Some(Verb::Policy) => settings(&root, &door),
None => stand_by(&root, &door),
}
}();
match outcome {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
if e.downcast_ref::<AlreadySaid>().is_none() {
eprintln!("slipcase-open: {e}");
}
ExitCode::FAILURE
}
}
}
fn hand_over(door: &Path, request: &Request) -> Result<Option<Response>, ipc::Error> {
#[cfg(windows)]
slipcase_open::platform::hand_the_foreground_on();
match endpoint::connect(door) {
Err(_) => Ok(None),
Ok(mut stream) => ipc::ask(&mut stream, request).map(Some),
}
}
fn say(response: &Response) -> Fallible {
match response {
Response::Ok(lines) => {
for line in lines {
println!("{line}");
}
Ok(())
}
Response::Err(why) => Err(why.clone().into()),
}
}
fn open(root: &Path, door: &Path, a: &Open) -> Fallible {
let container = std::fs::canonicalize(&a.container)
.map_err(|e| format!("{}: {e}", a.container.display()))?;
let voice = if std::io::stderr().is_terminal() {
Voice::Client
} else {
Voice::Instance
};
let request = Request::Open { container, voice };
if let Some(response) = hand_over(door, &request)? {
return say(&response);
}
let listener = match endpoint::bind(door) {
Ok(listener) => listener,
Err(why) => {
return match hand_over(door, &request)? {
Some(response) => say(&response),
None => Err(format!(
"could not reach the instance, and {} could not be bound: {why}",
door.display()
)
.into()),
};
}
};
let channel = channel();
let source = policy::for_this_platform();
let volume = policy::resolve(&source)
.map(|e| e.notify)
.unwrap_or_default();
let outside = Outside::new(&source, &Host, channel.as_ref()).saying(volume);
report_policy(&outside);
let _ = resident::sweep(root, &[]);
let mut instance = Resident::new(root);
let response = instance.handle(request, &outside);
let standing = standing();
if !has_a_reason_to_stay(
instance.is_idle(),
!instance.troubles().is_empty(),
standing.holding(),
) {
drop(listener);
see_out(&outside);
return match &response {
Response::Ok(_) => say(&response),
Response::Err(_) if voice == Voice::Client => say(&response),
Response::Err(_) => Err(AlreadySaid.into()),
};
}
match &response {
Response::Ok(_) => say(&response)?,
Response::Err(why) if voice == Voice::Client => eprintln!("slipcase-open: {why}"),
Response::Err(_) => {}
}
if std::io::stderr().is_terminal() {
eprintln!(
"{}",
t("Watching. Interrupt to leave the sessions recoverable, or:")
);
eprintln!(" slipcase-open close <session>");
}
resident::run(listener, &mut instance, &outside, standing.as_ref())?;
instance.stand_down(&outside);
see_out(&outside);
Ok(())
}
fn see_out(outside: &Outside<'_>) {
if !from_a_command_line() {
outside.channel.stay_until_seen();
}
}
const fn has_a_reason_to_stay(idle: bool, carrying: bool, showing: bool) -> bool {
!idle || (carrying && showing)
}
fn channel() -> Box<dyn Channel> {
#[cfg(target_os = "linux")]
if let Ok(desktop) = present::freedesktop::Desktop::connect() {
return Box::new(desktop);
}
#[cfg(windows)]
if let Ok(toast) = present::toast::Toast::connect() {
return Box::new(toast);
}
Box::new(present::terminal::Terminal)
}
fn sessions(root: &Path, door: &Path) -> Fallible {
if let Some(response) = hand_over(door, &Request::List)? {
return say(&response);
}
let found = session::scan(root)?;
if found.is_empty() {
println!("{}", t("No sessions."));
return Ok(());
}
for s in &found {
let state = recover::state(s);
println!(
"{} {} {}",
id_of(s),
slpc::display_name(&s.record().content_name),
state
);
println!(
" {}",
fill(
t("from {container}"),
&[("container", &slpc::display_path(&s.record().container))],
)
);
match state.course() {
recover::Course::Sweep => {}
recover::Course::WriteBack => println!(
" {} slipcase-open recover {} --write-back",
t("goes back when its container is next opened, or:"),
id_of(s)
),
recover::Course::Ask => println!(
" slipcase-open recover {} --write-back|--discard",
id_of(s)
),
}
}
Ok(())
}
fn close(door: &Path, a: &Close) -> Fallible {
match hand_over(door, &Request::Close(a.id.clone()))? {
Some(response) => say(&response),
None => Err("no instance is running, so nothing is open to close".into()),
}
}
fn recover_one(root: &Path, door: &Path, a: &Recover) -> Fallible {
if let Some(Response::Ok(lines)) = hand_over(door, &Request::List)? {
if lines
.iter()
.any(|l| l.starts_with(&a.id) && l.contains("open,"))
{
return Err(format!(
"{} is open, not left behind. Use `slipcase-open close {}`.",
a.id, a.id
)
.into());
}
}
let mut s = session::find(root, &a.id)?;
if a.discard {
s.remove()?;
println!("{}", t("Discarded."));
return Ok(());
}
if !a.write_back {
println!("{}: {}", a.id, recover::state(&s));
println!("{}", t("Pass --write-back or --discard to act on it."));
return Ok(());
}
slipcase_open::writeback::write_back(&mut s)?;
println!(
"{}",
fill(
t("Written back to {container}."),
&[("container", &slpc::display_path(&s.record().container))],
)
);
s.remove()?;
Ok(())
}
fn report_policy(outside: &Outside<'_>) {
let Ok(effective) = policy::resolve(outside.policy) else {
return;
};
if effective.managed {
let mut report = Report::ordinary("Settings on this machine are administered.");
if effective.configuration_suppressed {
report = report.and("Your own configuration is not being consulted.");
}
outside.report(&report);
}
for entry in &effective.uncomparable_entries {
outside.report(&Report::ordinary(format!(
"Ignored: `{entry}` in a policy list cannot match any content file."
)));
}
}
fn settings(root: &Path, door: &Path) -> Fallible {
let source = policy::for_this_platform();
let resolved = policy::resolve(&source);
let layers = source.locations();
if layers.is_empty() {
println!("No settings on this platform yet. The built-in set is what decides.");
} else {
println!("Where settings are read, in order of authority:");
println!();
for (origin, where_it_is) in layers {
println!(
" {:<14} {} ({})",
origin,
where_it_is,
layer_state(&source, origin, resolved.as_ref().ok())
);
}
println!();
}
let effective = resolved?;
println!("What they add up to:");
println!();
let allowed: Vec<&str> = effective.allowed().collect();
let denied: Vec<&str> = effective.denied().collect();
labelled("allowed", &allowed);
labelled("denied", &denied);
println!(
" {:<14} {}",
"notify",
match effective.notify {
policy::Notify::Everything => "everything",
policy::Notify::Important => "important",
}
);
println!(
" {:<14} {}",
"write-back",
if effective.confirm_each_write_back {
"confirmed each time"
} else {
"as the content file is saved"
}
);
println!();
if effective.managed {
println!("Settings on this machine are administered.");
if effective.configuration_suppressed {
println!("Your own configuration is not being consulted.");
}
println!();
}
for entry in &effective.uncomparable_entries {
println!("Ignored: `{entry}` in a policy list cannot match any content file.");
}
if !effective.uncomparable_entries.is_empty() {
println!();
}
println!("Where the tool keeps its own state:");
println!();
println!(" {:<14} {}", "sessions", slpc::display_path(root));
println!(" {:<14} {}", "front door", slpc::display_path(door));
let (speaks, refused) = how_it_speaks();
println!(" {:<14} {speaks}", "notifications");
if let Some(why) = refused {
println!(" {:<14} {why}", "");
}
Ok(())
}
fn standing() -> Box<dyn present::Standing> {
if from_a_command_line() {
return Box::new(present::Nowhere);
}
#[cfg(windows)]
if let Ok(tray) = present::tray::Tray::show_up() {
return Box::new(tray);
}
Box::new(present::Nowhere)
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn from_a_command_line() -> bool {
use windows::Win32::System::Console::{GetStdHandle, STD_ERROR_HANDLE};
unsafe { GetStdHandle(STD_ERROR_HANDLE).is_ok_and(|h| !h.is_invalid()) }
}
#[cfg(not(windows))]
fn from_a_command_line() -> bool {
std::io::stderr().is_terminal()
}
fn stand_by(root: &Path, door: &Path) -> Fallible {
#[cfg(windows)]
{
if hand_over(door, &Request::Ping)?.is_some() {
return Ok(());
}
let Ok(listener) = endpoint::bind(door) else {
return Ok(());
};
let channel = channel();
let source = policy::for_this_platform();
let volume = policy::resolve(&source)
.map(|e| e.notify)
.unwrap_or_default();
let outside = Outside::new(&source, &Host, channel.as_ref()).saying(volume);
let _ = resident::sweep(root, &[]);
let mut instance = Resident::new(root);
let standing = standing();
resident::run(listener, &mut instance, &outside, standing.as_ref())?;
instance.stand_down(&outside);
see_out(&outside);
Ok(())
}
#[cfg(not(windows))]
{
let _ = (root, door);
Err("no standing list on this platform".into())
}
}
#[cfg(windows)]
#[allow(unsafe_code)]
fn attach_console() {
use windows::Win32::System::Console::{
AttachConsole, GetStdHandle, ATTACH_PARENT_PROCESS, STD_OUTPUT_HANDLE,
};
unsafe {
if GetStdHandle(STD_OUTPUT_HANDLE).is_ok_and(|h| !h.is_invalid()) {
return;
}
let _ = AttachConsole(ATTACH_PARENT_PROCESS);
}
}
fn how_it_speaks() -> (String, Option<String>) {
#[cfg(target_os = "linux")]
{
match present::freedesktop::Desktop::connect() {
Ok(_) => ("desktop notifications".to_owned(), None),
Err(why) => ("the terminal".to_owned(), Some(why.to_string())),
}
}
#[cfg(windows)]
{
match present::toast::Toast::connect() {
Ok(_) => ("toast notifications".to_owned(), None),
Err(why) => ("the terminal".to_owned(), Some(why.to_string())),
}
}
#[cfg(not(any(target_os = "linux", windows)))]
{
("the terminal".to_owned(), None)
}
}
fn layer_state(
source: &policy::Settings,
origin: policy::Origin,
effective: Option<&policy::Effective>,
) -> String {
if origin == policy::Origin::Configuration
&& effective.is_some_and(|e| e.configuration_suppressed)
{
return "not consulted; policy has suppressed it".to_string();
}
match policy::Source::layer(source, origin) {
Ok(None) => "not there".to_string(),
Ok(Some(layer)) if layer.says_nothing() => "there, and sets nothing".to_string(),
Ok(Some(_)) => "in force".to_string(),
Err(_) => "cannot be read".to_string(),
}
}
fn labelled(label: &str, items: &[&str]) {
const INDENT: usize = 18;
const WIDTH: usize = 78;
if items.is_empty() {
println!(" {label:<14} nothing");
return;
}
let mut line = String::new();
let mut first = true;
for item in items {
let piece = if line.is_empty() {
(*item).to_string()
} else {
format!(", {item}")
};
if !line.is_empty() && INDENT + line.len() + piece.len() > WIDTH {
println!("{}{line},", head(label, first));
first = false;
line = (*item).to_string();
} else {
line.push_str(&piece);
}
}
println!("{}{line}", head(label, first));
}
fn head(label: &str, first: bool) -> String {
if first {
format!(" {label:<14} ")
} else {
" ".repeat(18)
}
}
fn id_of(s: &session::Session) -> String {
s.dir()
.file_name()
.map_or_else(|| "?".to_string(), |n| n.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::has_a_reason_to_stay;
#[test]
fn a_refusal_stays_where_there_is_an_icon_to_carry_it() {
assert!(has_a_reason_to_stay(true, true, true));
}
#[test]
fn the_same_refusal_at_a_prompt_returns() {
assert!(!has_a_reason_to_stay(true, true, false));
}
#[test]
fn an_open_session_stays_whether_or_not_anything_is_wrong() {
for carrying in [false, true] {
for showing in [false, true] {
assert!(
has_a_reason_to_stay(false, carrying, showing),
"left with a session open ({carrying}, {showing})"
);
}
}
}
#[test]
fn nothing_held_and_nothing_wrong_returns_even_with_an_icon() {
assert!(!has_a_reason_to_stay(true, false, true));
assert!(!has_a_reason_to_stay(true, false, false));
}
}