Modernize feresectl and add bounded IPC event streaming
This commit is contained in:
1 parent
cfdd2a148a
commit
97026a0db7
39 files changed
+1671
-254
No files matched your search
@@ -6,7 +6,12 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
ferese-config = { path = "../crates/ferese-config" }
|
||||
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
libc.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,285 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Control a running Ferese session.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Print query results as JSON.
|
||||
#[arg(short = 'j', long, global = true)]
|
||||
pub json: bool,
|
||||
/// Connect to this IPC socket instead of FERESE_SOCKET or the runtime default.
|
||||
#[arg(long, global = true)]
|
||||
pub socket: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Show the active window.
|
||||
#[command(alias = "get-focused-window")]
|
||||
FocusedWindow,
|
||||
/// List windows.
|
||||
#[command(alias = "get-windows")]
|
||||
Windows,
|
||||
/// Show idle inhibitors.
|
||||
#[command(alias = "get-idle-inhibition")]
|
||||
IdleInhibition,
|
||||
/// List workspaces and their output ownership.
|
||||
#[command(alias = "get-workspaces")]
|
||||
Workspaces,
|
||||
/// List connected outputs and their applied configuration.
|
||||
#[command(alias = "get-outputs")]
|
||||
Outputs,
|
||||
/// List configured keybindings.
|
||||
#[command(alias = "get-keybindings")]
|
||||
Keybindings,
|
||||
/// Show session and inhibition state.
|
||||
#[command(alias = "get-session-state")]
|
||||
SessionState,
|
||||
/// Toggle floating for the focused window.
|
||||
ToggleFloating,
|
||||
/// Toggle fullscreen for the focused window.
|
||||
ToggleFullscreen,
|
||||
/// Toggle maximization for the focused window.
|
||||
ToggleMaximized,
|
||||
/// Switch between scrolling and tree layout.
|
||||
ToggleLayout,
|
||||
/// Show or hide the workspace overview.
|
||||
ToggleOverview,
|
||||
/// Show or hide the keybinding guide.
|
||||
ToggleKeybindingGuide,
|
||||
/// Cycle the focused column width.
|
||||
CycleColumnWidth,
|
||||
/// Center the focused column.
|
||||
CenterColumn,
|
||||
/// Pull a window into the focused column.
|
||||
Consume,
|
||||
/// Move a window out of the focused column.
|
||||
Expel,
|
||||
/// Close the focused window.
|
||||
Close,
|
||||
/// Cycle the display layout.
|
||||
ToggleDisplayMode,
|
||||
/// List available output profiles.
|
||||
OutputProfiles,
|
||||
/// Confirm the pending output configuration.
|
||||
OutputConfirm,
|
||||
/// Revert the pending output configuration.
|
||||
OutputRevert,
|
||||
/// Reload the compositor configuration.
|
||||
ReloadConfig,
|
||||
/// Exit the compositor.
|
||||
Exit,
|
||||
/// Open the logout confirmation.
|
||||
RequestLogout,
|
||||
/// Return to the previous workspace.
|
||||
WorkspaceBackAndForth,
|
||||
/// Focus the previously focused window.
|
||||
FocusLastWindow,
|
||||
/// Focus the next recently used window.
|
||||
FocusMruNext,
|
||||
/// Focus the previous recently used window.
|
||||
FocusMruPrevious,
|
||||
/// Focus a window in a direction.
|
||||
Focus { direction: Direction },
|
||||
/// Move the focused window in a direction.
|
||||
Move { direction: Direction },
|
||||
/// Resize the focused window in a direction.
|
||||
Resize { direction: Direction },
|
||||
/// Activate a workspace by its one-based index.
|
||||
Workspace {
|
||||
#[arg(value_parser = clap::value_parser!(u32).range(1..))]
|
||||
index: u32,
|
||||
},
|
||||
/// Move the focused window to a workspace.
|
||||
MoveToWorkspace {
|
||||
#[arg(value_parser = clap::value_parser!(u32).range(1..))]
|
||||
index: u32,
|
||||
},
|
||||
/// Apply a display layout.
|
||||
OutputLayout { layout: DisplayLayout },
|
||||
/// Select an output profile, or auto.
|
||||
OutputProfile { name: String },
|
||||
/// Enable or disable the internal display.
|
||||
OutputInternal { state: Switch },
|
||||
/// Write a screenshot PNG to stdout.
|
||||
Screenshot {
|
||||
#[arg(short = 'g', long)]
|
||||
geometry: Option<String>,
|
||||
},
|
||||
/// Write a window screenshot PNG to stdout.
|
||||
ScreenshotWindow { window: u64 },
|
||||
/// Launch desktop autostart entries.
|
||||
Autostart,
|
||||
/// Inspect or change the theme.
|
||||
Theme {
|
||||
#[command(subcommand)]
|
||||
action: ThemeAction,
|
||||
},
|
||||
/// Inspect or retry the XWayland server.
|
||||
Xwayland {
|
||||
#[command(subcommand)]
|
||||
action: XwaylandAction,
|
||||
},
|
||||
/// Inspect media players or control playback.
|
||||
Media {
|
||||
#[command(subcommand)]
|
||||
action: Option<MediaAction>,
|
||||
},
|
||||
/// Follow compositor changes as newline-delimited JSON.
|
||||
EventStream,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum Direction {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum DisplayLayout {
|
||||
InternalOnly,
|
||||
ExternalOnly,
|
||||
Extend,
|
||||
Mirror,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum Switch {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum ThemeMode {
|
||||
Light,
|
||||
Dark,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ThemeAction {
|
||||
/// Show the current theme.
|
||||
#[command(alias = "status")]
|
||||
Get,
|
||||
/// Follow theme snapshots as newline-delimited JSON.
|
||||
Subscribe,
|
||||
/// Set the appearance mode.
|
||||
Mode { mode: ThemeMode },
|
||||
/// Resolve a configuration file without applying it.
|
||||
Preview { path: PathBuf },
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum XwaylandAction {
|
||||
/// Show XWayland status.
|
||||
Status,
|
||||
/// Retry starting XWayland after a failure.
|
||||
Retry,
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum MediaAction {
|
||||
/// Show players and the active player.
|
||||
Get,
|
||||
/// Toggle playback.
|
||||
PlayPause,
|
||||
/// Skip to the next track.
|
||||
Next,
|
||||
/// Return to the previous track.
|
||||
Previous,
|
||||
/// Raise the active player's window.
|
||||
Raise,
|
||||
/// Select the active player automatically.
|
||||
Auto,
|
||||
/// Prefer a particular player.
|
||||
Pin { player: String },
|
||||
/// Exclude a player from automatic selection.
|
||||
Ignore { player: String },
|
||||
/// Include a previously ignored player.
|
||||
Unignore { player: String },
|
||||
}
|
||||
|
||||
fn value_name(value: &impl ValueEnum) -> String {
|
||||
value
|
||||
.to_possible_value()
|
||||
.expect("visible enum value")
|
||||
.get_name()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn ipc(&self) -> Option<(&'static str, Value)> {
|
||||
let (command, args) = match self {
|
||||
Self::FocusedWindow => ("get-focused-window", json!({})),
|
||||
Self::Windows => ("get-windows", json!({})),
|
||||
Self::IdleInhibition => ("get-idle-inhibition", json!({})),
|
||||
Self::Workspaces => ("get-workspaces", json!({})),
|
||||
Self::Outputs => ("outputs", json!({})),
|
||||
Self::Keybindings => ("get-keybindings", json!({})),
|
||||
Self::SessionState => ("get-session-state", json!({})),
|
||||
Self::ToggleFloating => ("toggle-floating", json!({})),
|
||||
Self::ToggleFullscreen => ("toggle-fullscreen", json!({})),
|
||||
Self::ToggleMaximized => ("toggle-maximized", json!({})),
|
||||
Self::ToggleLayout => ("toggle-layout", json!({})),
|
||||
Self::ToggleOverview => ("toggle-overview", json!({})),
|
||||
Self::ToggleKeybindingGuide => ("toggle-keybinding-guide", json!({})),
|
||||
Self::CycleColumnWidth => ("cycle-column-width", json!({})),
|
||||
Self::CenterColumn => ("center-column", json!({})),
|
||||
Self::Consume => ("consume", json!({})),
|
||||
Self::Expel => ("expel", json!({})),
|
||||
Self::Close => ("close", json!({})),
|
||||
Self::ToggleDisplayMode => ("toggle-display-mode", json!({})),
|
||||
Self::OutputProfiles => ("output-profiles", json!({})),
|
||||
Self::OutputConfirm => ("output-confirm", json!({})),
|
||||
Self::OutputRevert => ("output-revert", json!({})),
|
||||
Self::ReloadConfig => ("reload-config", json!({})),
|
||||
Self::Exit => ("exit", json!({})),
|
||||
Self::RequestLogout => ("request-logout", json!({})),
|
||||
Self::WorkspaceBackAndForth => ("workspace-back-and-forth", json!({})),
|
||||
Self::FocusLastWindow => ("focus-last-window", json!({})),
|
||||
Self::FocusMruNext => ("focus-mru-next", json!({})),
|
||||
Self::FocusMruPrevious => ("focus-mru-previous", json!({})),
|
||||
Self::Focus { direction } => ("focus", json!({"direction": value_name(direction)})),
|
||||
Self::Move { direction } => ("move", json!({"direction": value_name(direction)})),
|
||||
Self::Resize { direction } => ("resize", json!({"direction": value_name(direction)})),
|
||||
Self::Workspace { index } => ("workspace", json!({"index": index})),
|
||||
Self::MoveToWorkspace { index } => ("move-to-workspace", json!({"index": index})),
|
||||
Self::OutputLayout { layout } => ("output-layout", json!({"layout": value_name(layout)})),
|
||||
Self::OutputProfile { name } => ("output-profile", json!({"name": name})),
|
||||
Self::OutputInternal { state } => ("output-internal", json!({"enabled": matches!(state, Switch::On)})),
|
||||
Self::Screenshot {
|
||||
geometry: Some(geometry),
|
||||
} => ("screenshot", json!({"geometry": geometry})),
|
||||
Self::Screenshot { geometry: None } => ("screenshot", json!({})),
|
||||
Self::ScreenshotWindow { window } => ("screenshot-window", json!({"window": window})),
|
||||
Self::Xwayland { action } => (
|
||||
match action {
|
||||
XwaylandAction::Status => ferese_ipc::xwayland::STATUS_COMMAND,
|
||||
XwaylandAction::Retry => ferese_ipc::xwayland::RETRY_COMMAND,
|
||||
},
|
||||
json!({}),
|
||||
),
|
||||
Self::Media {
|
||||
action: None | Some(MediaAction::Get),
|
||||
} => ("media-get", json!({})),
|
||||
Self::Media { action: Some(action) } => {
|
||||
let args = match action {
|
||||
MediaAction::Pin { player } => json!({"action": "pin", "player": player}),
|
||||
MediaAction::Ignore { player } => json!({"action": "ignore", "player": player, "ignored": true}),
|
||||
MediaAction::Unignore { player } => json!({"action": "ignore", "player": player, "ignored": false}),
|
||||
MediaAction::PlayPause => json!({"action": "play-pause"}),
|
||||
MediaAction::Next => json!({"action": "next"}),
|
||||
MediaAction::Previous => json!({"action": "previous"}),
|
||||
MediaAction::Raise => json!({"action": "raise"}),
|
||||
MediaAction::Auto => json!({"action": "auto"}),
|
||||
MediaAction::Get => unreachable!(),
|
||||
};
|
||||
("media-action", args)
|
||||
}
|
||||
Self::Autostart | Self::Theme { .. } | Self::EventStream => return None,
|
||||
};
|
||||
Some((command, args))
|
||||
}
|
||||
}
|
||||
+102
-226
@@ -1,127 +1,130 @@
|
||||
mod autostart;
|
||||
mod cli;
|
||||
mod output;
|
||||
|
||||
use std::error::Error;
|
||||
use std::io::Write;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
use std::{env, fs, io};
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use cli::{Cli, Command, ThemeAction};
|
||||
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
if env::args().nth(1).as_deref() == Some("autostart") {
|
||||
if env::args().len() != 2 {
|
||||
return Err("usage: feresectl autostart".into());
|
||||
}
|
||||
let cli = Cli::parse();
|
||||
if matches!(cli.command, Command::Autostart) {
|
||||
return autostart::run();
|
||||
}
|
||||
if env::args().nth(1).as_deref() == Some("theme") {
|
||||
return theme_command(env::args().skip(2).collect());
|
||||
}
|
||||
if env::args().nth(1).as_deref() == Some("xwayland") {
|
||||
return xwayland_command(env::args().skip(2).collect());
|
||||
}
|
||||
let (command, args) = parse_args(env::args().skip(1))?;
|
||||
let request = Request {
|
||||
version: VERSION,
|
||||
id: 1,
|
||||
kind: "command".to_owned(),
|
||||
command: command.clone(),
|
||||
args,
|
||||
};
|
||||
let mut stream = UnixStream::connect(socket_path()?)?;
|
||||
|
||||
write_frame(&mut stream, &request)?;
|
||||
let response: Response = read_frame(&mut stream)?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(format!("{}: {}", error.code, error.message).into());
|
||||
}
|
||||
|
||||
if matches!(command.as_str(), "screenshot" | "screenshot-window") {
|
||||
write_png(&response)
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))?
|
||||
for alias in env::args().skip(1).filter(|arg| {
|
||||
matches!(
|
||||
arg.as_str(),
|
||||
"get-outputs"
|
||||
| "get-windows"
|
||||
| "get-workspaces"
|
||||
| "get-focused-window"
|
||||
| "get-idle-inhibition"
|
||||
| "get-session-state"
|
||||
| "get-keybindings"
|
||||
)
|
||||
}) {
|
||||
eprintln!(
|
||||
"feresectl: {alias} is deprecated; use {}",
|
||||
alias.trim_start_matches("get-")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
let socket = ferese_ipc::socket::resolve(cli.socket.as_deref())?;
|
||||
match &cli.command {
|
||||
Command::Theme { action } => theme_command(action, &socket, cli.json),
|
||||
Command::EventStream => event_stream(&socket),
|
||||
command => {
|
||||
let (command, args) = command.ipc().expect("IPC command");
|
||||
let mut stream = UnixStream::connect(socket)?;
|
||||
let result = request(&mut stream, command, args)?;
|
||||
if matches!(command, "screenshot" | "screenshot-window") {
|
||||
write_png(&Response::success(1, result))
|
||||
} else {
|
||||
output::print(&result, cli.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
let mut connection = ferese_ipc::theme::Connection::connect()?;
|
||||
match args.as_slice() {
|
||||
[command] if command == "get" || command == "status" => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&connection.get(ferese_config::families::builtins)?)?
|
||||
);
|
||||
}
|
||||
[command] if command == "subscribe" => {
|
||||
fn request(stream: &mut UnixStream, command: &str, args: Value) -> Result<Value, Box<dyn Error>> {
|
||||
write_frame(
|
||||
stream,
|
||||
&Request {
|
||||
version: VERSION,
|
||||
id: 1,
|
||||
kind: "command".into(),
|
||||
command: command.into(),
|
||||
args,
|
||||
},
|
||||
)?;
|
||||
let response: Response = read_frame(stream)?;
|
||||
if response.version != VERSION || response.id != 1 {
|
||||
return Err("Unexpected IPC response".into());
|
||||
}
|
||||
if let Some(error) = response.error {
|
||||
return Err(format!("{}: {}", error.code, error.message).into());
|
||||
}
|
||||
Ok(response.result.unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
fn theme_command(action: &ThemeAction, socket: &Path, json_output: bool) -> Result<(), Box<dyn Error>> {
|
||||
let mut connection = ferese_ipc::theme::Connection::connect_to(socket)?;
|
||||
let result = match action {
|
||||
ThemeAction::Get => serde_json::to_value(connection.get(ferese_config::families::builtins)?)?,
|
||||
ThemeAction::Subscribe => {
|
||||
let mut snapshot = connection.get(ferese_config::families::builtins)?;
|
||||
let mut stdout = io::stdout().lock();
|
||||
loop {
|
||||
println!("{}", serde_json::to_string(&snapshot)?);
|
||||
if !output::write_json_line(&mut stdout, &snapshot)? {
|
||||
return Ok(());
|
||||
}
|
||||
snapshot = connection.watch(snapshot.revision, ferese_config::families::builtins)?;
|
||||
}
|
||||
}
|
||||
[command, mode] if command == "mode" && matches!(mode.as_str(), "light" | "dark" | "auto") => {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&connection.call("theme-set-mode", json!({"mode": mode}))?)?
|
||||
);
|
||||
}
|
||||
[command, path] if command == "preview" => {
|
||||
let source = fs::read_to_string(path)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&connection.call(
|
||||
"theme-preview",
|
||||
json!({"source": source, "directory": std::path::Path::new(path).canonicalize()?.parent()})
|
||||
)?)?
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"usage: feresectl theme <get|status|subscribe|mode light|mode dark|mode auto|preview PATH>".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
ThemeAction::Mode { mode } => connection.call(
|
||||
"theme-set-mode",
|
||||
json!({"mode": mode.to_possible_value().unwrap().get_name()}),
|
||||
)?,
|
||||
ThemeAction::Preview { path } => connection.call(
|
||||
"theme-preview",
|
||||
json!({
|
||||
"source": fs::read_to_string(path)?, "directory": path.canonicalize()?.parent(),
|
||||
}),
|
||||
)?,
|
||||
};
|
||||
output::print(&result, json_output)
|
||||
}
|
||||
|
||||
fn xwayland_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
use ferese_ipc::xwayland::{RETRY_COMMAND, STATUS_COMMAND, Status};
|
||||
|
||||
let command = match args.as_slice() {
|
||||
[command] if command == "status" => STATUS_COMMAND,
|
||||
[command] if command == "retry" => RETRY_COMMAND,
|
||||
_ => return Err("usage: feresectl xwayland <status|retry>".into()),
|
||||
};
|
||||
|
||||
let request = Request {
|
||||
version: VERSION,
|
||||
id: 1,
|
||||
kind: "command".to_owned(),
|
||||
command: command.to_owned(),
|
||||
args: json!({}),
|
||||
};
|
||||
let mut stream = UnixStream::connect(socket_path()?)?;
|
||||
write_frame(&mut stream, &request)?;
|
||||
let response: Response = read_frame(&mut stream)?;
|
||||
if let Some(error) = response.error {
|
||||
return Err(format!("{}: {}", error.code, error.message).into());
|
||||
fn event_stream(socket: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let mut stream = UnixStream::connect(socket)?;
|
||||
request(
|
||||
&mut stream,
|
||||
"event-stream",
|
||||
json!({"version": ferese_ipc::events::VERSION}),
|
||||
)?;
|
||||
let mut stdout = io::stdout().lock();
|
||||
loop {
|
||||
let event: ferese_ipc::events::Event = read_frame(&mut stream)?;
|
||||
event.validate()?;
|
||||
if !output::write_json_line(&mut stdout, &event)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = Status::from_value(response.result.unwrap_or(Value::Null))?;
|
||||
if command == RETRY_COMMAND {
|
||||
println!(
|
||||
"X11 retry requested; current state: {}",
|
||||
serde_json::to_string(&status.state)?
|
||||
);
|
||||
}
|
||||
println!("{}", serde_json::to_string_pretty(&status)?);
|
||||
Ok(())
|
||||
#[cfg(test)]
|
||||
fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value), String> {
|
||||
let cli = Cli::try_parse_from(std::iter::once("feresectl".to_owned()).chain(args)).map_err(|e| e.to_string())?;
|
||||
cli.command
|
||||
.ipc()
|
||||
.map(|(command, args)| (command.into(), args))
|
||||
.ok_or_else(|| "not an IPC command".into())
|
||||
}
|
||||
|
||||
// The compositor stages the PNG in a private directory and replies with its
|
||||
@@ -146,133 +149,6 @@ fn write_png(response: &Response) -> Result<(), Box<dyn Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value), String> {
|
||||
let mut args = args.into_iter();
|
||||
let command = args.next().ok_or_else(usage)?;
|
||||
let positional = args.collect::<Vec<_>>();
|
||||
let payload = match command.as_str() {
|
||||
"output-layout" => {
|
||||
exactly_one(&command, &positional, "internal-only, external-only, extend or mirror")?;
|
||||
if !["internal-only", "external-only", "extend", "mirror"].contains(&positional[0].as_str()) {
|
||||
return Err("unknown display layout".into());
|
||||
}
|
||||
json!({ "layout": positional[0] })
|
||||
}
|
||||
"output-profile" => {
|
||||
exactly_one(&command, &positional, "profile name or auto")?;
|
||||
json!({ "name": positional[0] })
|
||||
}
|
||||
"output-internal" => {
|
||||
exactly_one(&command, &positional, "on or off")?;
|
||||
let enabled = match positional[0].as_str() {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Err("output-internal requires on or off".into()),
|
||||
};
|
||||
json!({ "enabled": enabled })
|
||||
}
|
||||
"screenshot-window" => {
|
||||
exactly_one(&command, &positional, "window ID")?;
|
||||
let window = positional[0]
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "Expected an unsigned window ID")?;
|
||||
json!({"window":window})
|
||||
}
|
||||
"focus" | "move" | "resize" => {
|
||||
exactly_one(&command, &positional, "direction")?;
|
||||
json!({ "direction": positional[0] })
|
||||
}
|
||||
"workspace" | "move-to-workspace" => {
|
||||
exactly_one(&command, &positional, "index")?;
|
||||
let index = positional[0]
|
||||
.parse::<u32>()
|
||||
.map_err(|_| format!("{} requires a positive workspace index", command))?;
|
||||
json!({ "index": index })
|
||||
}
|
||||
"media" => {
|
||||
return match positional.as_slice() {
|
||||
[] => Ok(("media-get".into(), json!({}))),
|
||||
[mode] if mode == "get" => Ok(("media-get".into(), json!({}))),
|
||||
[action] if ["play-pause", "next", "previous", "raise", "auto"].contains(&action.as_str()) => {
|
||||
Ok(("media-action".into(), json!({"action": action})))
|
||||
}
|
||||
[action, player] if ["pin", "ignore", "unignore"].contains(&action.as_str()) => {
|
||||
Ok(("media-action".into(), json!({"action":if action == "pin" { "pin" } else { "ignore" },"player":player,"ignored":action == "ignore"})))
|
||||
}
|
||||
_ => Err("usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]".into()),
|
||||
};
|
||||
}
|
||||
"screenshot" => match positional.as_slice() {
|
||||
[] => json!({}),
|
||||
[flag, geometry] if flag == "--geometry" || flag == "-g" => {
|
||||
json!({ "geometry": geometry })
|
||||
}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"{command} accepts an optional --geometry \"x,y WxH\" ({})",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
},
|
||||
"toggle-floating"
|
||||
| "toggle-fullscreen"
|
||||
| "toggle-maximized"
|
||||
| "toggle-layout"
|
||||
| "toggle-overview"
|
||||
| "toggle-keybinding-guide"
|
||||
| "cycle-column-width"
|
||||
| "center-column"
|
||||
| "consume"
|
||||
| "expel"
|
||||
| "close"
|
||||
| "get-focused-window"
|
||||
| "get-windows"
|
||||
| "get-idle-inhibition"
|
||||
| "get-workspaces"
|
||||
| "get-outputs"
|
||||
| "outputs"
|
||||
| "toggle-display-mode"
|
||||
| "output-profiles"
|
||||
| "output-confirm"
|
||||
| "output-revert"
|
||||
| "reload-config"
|
||||
| "exit"
|
||||
| "request-logout"
|
||||
| "workspace-back-and-forth"
|
||||
| "focus-last-window"
|
||||
| "focus-mru-next"
|
||||
| "focus-mru-previous" => {
|
||||
if !positional.is_empty() {
|
||||
return Err(format!("{command} does not accept arguments"));
|
||||
}
|
||||
json!({})
|
||||
}
|
||||
_ => return Err(format!("unknown command {command:?}\n{}", usage())),
|
||||
};
|
||||
|
||||
Ok((command, payload))
|
||||
}
|
||||
|
||||
fn exactly_one(command: &str, args: &[String], name: &str) -> Result<(), String> {
|
||||
if args.len() == 1 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{command} requires exactly one {name}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_path() -> Result<PathBuf, io::Error> {
|
||||
env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map(|directory| directory.join("ferese/control.sock"))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl xwayland <status|retry>\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|get-idle-inhibition|reload-config>".to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -282,7 +158,7 @@ mod tests {
|
||||
assert_eq!(ferese_ipc::xwayland::STATUS_COMMAND, "xwayland-status");
|
||||
assert_eq!(ferese_ipc::xwayland::RETRY_COMMAND, "xwayland-retry");
|
||||
|
||||
assert!(usage().contains("feresectl xwayland <status|retry>"));
|
||||
assert!(Cli::try_parse_from(["feresectl", "xwayland", "status"]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use std::error::Error;
|
||||
use std::io::{self, Write};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn print(value: &Value, json: bool) -> Result<(), Box<dyn Error>> {
|
||||
let mut stdout = io::stdout().lock();
|
||||
if json {
|
||||
writeln!(stdout, "{}", serde_json::to_string_pretty(value)?)?;
|
||||
} else {
|
||||
write_plain(&mut stdout, value, 0)?;
|
||||
}
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Result<()> {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
for (key, value) in fields {
|
||||
write!(writer, "{:indent$}{key}:", "")?;
|
||||
if value.is_object() || value.is_array() {
|
||||
writeln!(writer)?;
|
||||
write_plain(writer, value, indent + 2)?;
|
||||
} else {
|
||||
writeln!(writer, " {}", scalar(value))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
if index > 0 && item.is_object() {
|
||||
writeln!(writer)?;
|
||||
}
|
||||
write_plain(writer, item, indent)?;
|
||||
}
|
||||
}
|
||||
_ => writeln!(writer, "{:indent$}{}", "", scalar(value))?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scalar(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "none".into(),
|
||||
Value::String(text) => text.clone(),
|
||||
value => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return false when a downstream consumer closes stdout.
|
||||
pub fn write_json_line(writer: &mut impl Write, value: &impl serde::Serialize) -> Result<bool, Box<dyn Error>> {
|
||||
let line = serde_json::to_vec(value)?;
|
||||
match writer
|
||||
.write_all(&line)
|
||||
.and_then(|()| writer.write_all(b"\n"))
|
||||
.and_then(|()| writer.flush())
|
||||
{
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn plain_output_has_readable_fields_without_json_quotes() {
|
||||
let mut bytes = Vec::new();
|
||||
write_plain(
|
||||
&mut bytes,
|
||||
&serde_json::json!([{"id": 7, "title": "Terminal", "focused": true}]),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8(bytes).unwrap(),
|
||||
"focused: true\nid: 7\ntitle: Terminal\n"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn event_output_is_one_json_value_per_line() {
|
||||
let mut bytes = Vec::new();
|
||||
for value in [
|
||||
serde_json::json!({"title": "a\nb"}),
|
||||
serde_json::json!({"locked": true}),
|
||||
] {
|
||||
assert!(write_json_line(&mut bytes, &value).unwrap());
|
||||
}
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert_eq!(text.lines().count(), 2);
|
||||
for line in text.lines() {
|
||||
serde_json::from_str::<Value>(line).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
use std::thread;
|
||||
|
||||
use ferese_ipc::{Request, Response, read_frame, write_frame};
|
||||
use serde_json::json;
|
||||
|
||||
fn ctl(args: &[&str]) -> Command {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_feresectl"));
|
||||
command
|
||||
.args(args)
|
||||
.env_remove("FERESE_SOCKET")
|
||||
.env_remove("XDG_RUNTIME_DIR");
|
||||
command
|
||||
}
|
||||
|
||||
fn serve(path: &Path, expected: &'static str, value: serde_json::Value) -> thread::JoinHandle<()> {
|
||||
let listener = UnixListener::bind(path).unwrap();
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let request: Request = read_frame(&mut stream).unwrap();
|
||||
assert_eq!(request.command, expected);
|
||||
write_frame(&mut stream, &Response::success(request.id, value)).unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
fn success(output: Output) -> String {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
String::from_utf8(output.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_exits_successfully_without_a_session_and_has_hidden_legacy_aliases() {
|
||||
for args in [
|
||||
&["--help"][..],
|
||||
&["theme", "--help"],
|
||||
&["media", "--help"],
|
||||
&["xwayland", "--help"],
|
||||
&["screenshot", "--help"],
|
||||
] {
|
||||
let text = success(ctl(args).output().unwrap());
|
||||
assert!(text.contains("Usage:"));
|
||||
assert!(!text.contains("get-outputs"));
|
||||
}
|
||||
let output = ctl(&["widows"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
let error = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(error.contains("windows"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_socket_targets_the_nested_instance_for_queries_and_theme_commands() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let nested = directory.path().join("nested.sock");
|
||||
let host = directory.path().join("host.sock");
|
||||
let host_listener = UnixListener::bind(&host).unwrap();
|
||||
host_listener.set_nonblocking(true).unwrap();
|
||||
for args in [
|
||||
vec!["--socket", nested.to_str().unwrap(), "-j", "windows"],
|
||||
vec!["theme", "mode", "dark", "--socket", nested.to_str().unwrap(), "--json"],
|
||||
] {
|
||||
let worker = serve(
|
||||
&nested,
|
||||
if args[0] == "theme" {
|
||||
"theme-set-mode"
|
||||
} else {
|
||||
"get-windows"
|
||||
},
|
||||
json!({"instance": "nested"}),
|
||||
);
|
||||
let output = success(ctl(&args).env("FERESE_SOCKET", &host).output().unwrap());
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&output).unwrap(),
|
||||
json!({"instance": "nested"})
|
||||
);
|
||||
worker.join().unwrap();
|
||||
std::fs::remove_file(&nested).unwrap();
|
||||
assert_eq!(
|
||||
host_listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_environment_and_runtime_fallback_use_the_same_query_path() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(directory.path().join("ferese")).unwrap();
|
||||
let socket = directory.path().join("ferese/control.sock");
|
||||
for environment in ["FERESE_SOCKET", "XDG_RUNTIME_DIR"] {
|
||||
let worker = serve(&socket, "outputs", json!([{"name": "nested", "enabled": true}]));
|
||||
let target = if environment == "FERESE_SOCKET" {
|
||||
socket.as_path()
|
||||
} else {
|
||||
directory.path()
|
||||
};
|
||||
let output = success(ctl(&["outputs"]).env(environment, target).output().unwrap());
|
||||
assert!(output.contains("name: nested"));
|
||||
assert!(!output.contains('"'));
|
||||
worker.join().unwrap();
|
||||
std::fs::remove_file(&socket).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_queries_are_hidden_aliases_with_an_explicit_json_flag() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let socket = directory.path().join("control.sock");
|
||||
let worker = serve(&socket, "get-workspaces", json!([]));
|
||||
let output = ctl(&["--socket", socket.to_str().unwrap(), "get-workspaces", "-j"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("deprecated; use workspaces"));
|
||||
assert_eq!(success(output).trim(), "[]");
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_stream_uses_the_selected_socket_and_prints_complete_json_lines() {
|
||||
use ferese_ipc::events::{Change, Event, VERSION};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let socket = directory.path().join("events.sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let event = Event {
|
||||
version: VERSION,
|
||||
generation: 7,
|
||||
last: true,
|
||||
change: Change::LockChanged {
|
||||
lock: ferese_ipc::events::LockState {
|
||||
phase: "locked".into(),
|
||||
sleeping: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
let expected = event.clone();
|
||||
let (stop, stopped) = std::sync::mpsc::channel();
|
||||
let worker = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let request: Request = read_frame(&mut stream).unwrap();
|
||||
assert_eq!(request.command, "event-stream");
|
||||
assert_eq!(request.args["version"], VERSION);
|
||||
write_frame(&mut stream, &Response::success(request.id, json!({"version": VERSION}))).unwrap();
|
||||
write_frame(&mut stream, &event).unwrap();
|
||||
stopped.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
});
|
||||
let mut child = ctl(&["event-stream", "--socket", socket.to_str().unwrap()])
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap())
|
||||
.read_line(&mut line)
|
||||
.unwrap();
|
||||
assert_eq!(serde_json::from_str::<Event>(&line).unwrap(), expected);
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
stop.send(()).unwrap();
|
||||
worker.join().unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user