#![allow(
clippy::needless_pass_by_value,
reason = "Bevy system and mlua callback signatures are by-value by contract"
)]
use std::sync::mpsc::{Receiver, TryRecvError};
use clap::{Parser, Subcommand};
use tracing::{error, warn};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
mod accessibility_prompt;
mod client;
mod commands;
mod config;
mod ecs;
mod errors;
mod events;
#[cfg(feature = "lua")]
mod lua;
mod manager;
mod menubar;
mod overlay;
mod pasteboard;
mod platform;
mod reader;
mod util;
pub use paneru::types;
#[cfg(test)]
mod tests;
embed_plist::embed_info_plist!("../assets/Info.plist");
use events::{Event, EventSender};
use client::ClientCommand;
use ecs::state::StateQueryKind;
use errors::Result;
use platform::service;
use reader::CommandReader;
use types::script_state::ScriptStateWrite;
use types::script_value::ScriptValue;
use types::wire::ScriptStateRequest;
use crate::ecs::setup_bevy_app;
use crate::manager::{check_ax_privilege, request_ax_privilege};
use crate::menubar::MenuBarManager;
use crate::platform::PlatformCallbacks;
use accessibility_prompt::{AccessibilitySetupAction, show_accessibility_setup};
#[cfg(feature = "lua")]
pub const VERSION_STRING: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("PANERU_LUA_VERSION"),
")"
);
#[cfg(not(feature = "lua"))]
pub const VERSION_STRING: &str = concat!(env!("CARGO_PKG_VERSION"));
#[derive(Clone, Debug, Default, Parser)]
#[command(
version = VERSION_STRING,
author = clap::crate_authors!(),
about = clap::crate_description!(),
)]
pub struct Paneru {
#[clap(subcommand)]
subcmd: Option<SubCmd>,
}
#[derive(Clone, Debug, Default, Subcommand)]
pub enum SubCmd {
#[default]
Launch,
Install,
Uninstall,
Reinstall,
InstallApp,
UninstallApp,
Start,
Stop,
Restart,
SendCmd {
#[arg(trailing_var_arg = true)]
cmd: Vec<String>,
},
Query {
#[clap(subcommand)]
query: QueryCmd,
},
Subscribe {
#[arg(long)]
json: bool,
},
State {
#[clap(subcommand)]
state: StateCmd,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum StateCmd {
Get { key: String },
Set { key: String, value: String },
Remove { key: String },
Cas {
key: String,
expected: String,
value: String,
},
}
#[derive(Clone, Debug, Subcommand)]
pub enum QueryCmd {
State {
#[arg(long)]
json: bool,
},
VirtualWorkspaces {
#[arg(long)]
json: bool,
},
Active {
#[arg(long)]
json: bool,
},
OnScreen {
#[arg(long)]
json: bool,
},
}
fn main() -> Result<()> {
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(
fmt::layer()
.with_level(true)
.with_line_number(true)
.with_file(true)
.with_target(true)
.with_thread_ids(false)
.with_writer(std::io::stderr)
.compact(),
)
.init();
let service = || service::Service::try_new(service::ID);
let subcmd = Paneru::parse().subcmd.unwrap_or_default();
maybe_warn_deprecated_options_for_service(&subcmd);
match subcmd {
SubCmd::Launch => {
let (sender, receiver) = EventSender::new();
let sender_c = sender.clone();
ctrlc::set_handler(move || {
let _ = sender_c.send(events::Event::Exit); })
.expect("setting Ctrl-C handler should succeed");
CommandReader::new(sender.clone()).start()?;
if !check_ax_privilege() && !wait_for_accessibility(sender.clone(), &receiver) {
return Ok(());
}
match objc2::rc::autoreleasepool(|_| setup_bevy_app(sender, receiver)) {
Ok(mut app) => {
app.run();
}
Err(err) => {
error!(
"Error launching Paneru: {err}.\nStopping the service for now. You can restart it again with 'paneru restart'."
);
service()?.stop()?;
}
}
}
SubCmd::Install => service()?.install()?,
SubCmd::Uninstall => service()?.uninstall()?,
SubCmd::Reinstall => service()?.reinstall()?,
SubCmd::InstallApp => platform::app_launcher::AppLauncher::try_new()?.install()?,
SubCmd::UninstallApp => platform::app_launcher::AppLauncher::try_new()?.uninstall()?,
SubCmd::Start => service()?.start()?,
SubCmd::Stop => service()?.stop()?,
SubCmd::Restart => service()?.restart()?,
SubCmd::SendCmd { cmd } => client::run(ClientCommand::Send(cmd))?,
SubCmd::Query { query } => client::run(ClientCommand::Query(query.kind()))?,
SubCmd::Subscribe { json: _ } => client::run(ClientCommand::Subscribe)?,
SubCmd::State { state } => client::run(ClientCommand::ScriptState(state.request()?))?,
}
Ok(())
}
fn wait_for_accessibility(sender: EventSender, receiver: &Receiver<Event>) -> bool {
let mut platform_callbacks = PlatformCallbacks::new(sender.clone());
let _menu_bar =
MenuBarManager::new_accessibility_required(platform_callbacks.main_thread_marker, sender);
if show_accessibility_setup(platform_callbacks.main_thread_marker)
== AccessibilitySetupAction::Continue
{
request_ax_privilege();
}
warn!(
"Accessibility access is required. Paneru will remain in the menu bar and start automatically once access is granted."
);
loop {
platform_callbacks.pump_cocoa_event_loop(1.0);
if check_ax_privilege() {
return true;
}
match receiver.try_recv() {
Ok(
Event::Exit
| Event::Command {
command: commands::Command::Quit,
},
)
| Err(TryRecvError::Disconnected) => return false,
Ok(event) => warn!(
?event,
"ignoring event while waiting for Accessibility access"
),
Err(TryRecvError::Empty) => {}
}
}
}
impl QueryCmd {
fn kind(&self) -> StateQueryKind {
match self {
QueryCmd::State { json: _ } => StateQueryKind::State,
QueryCmd::VirtualWorkspaces { json: _ } => StateQueryKind::VirtualWorkspaces,
QueryCmd::Active { json: _ } => StateQueryKind::Active,
QueryCmd::OnScreen { json: _ } => StateQueryKind::OnScreen,
}
}
}
impl StateCmd {
fn request(&self) -> errors::Result<ScriptStateRequest> {
const ABSENT: &str = "-";
let parse = |raw: &str| -> errors::Result<ScriptValue> {
serde_json::from_str::<serde_json::Value>(raw)
.map(ScriptValue::from)
.map_err(|err| errors::Error::InvalidInput(format!("{raw:?} is not JSON: {err}")))
};
let maybe = |raw: &str| -> errors::Result<Option<ScriptValue>> {
if raw == ABSENT {
Ok(None)
} else {
parse(raw).map(Some)
}
};
Ok(match self {
StateCmd::Get { key } => ScriptStateRequest::Get { key: key.clone() },
StateCmd::Set { key, value } => {
ScriptStateRequest::Write(ScriptStateWrite::set(key.clone(), parse(value)?))
}
StateCmd::Remove { key } => {
ScriptStateRequest::Write(ScriptStateWrite::remove(key.clone()))
}
StateCmd::Cas {
key,
expected,
value,
} => ScriptStateRequest::Write(ScriptStateWrite::compare_and_set(
key.clone(),
maybe(expected)?,
maybe(value)?,
)),
})
}
}
fn should_check_deprecated_options(subcmd: &SubCmd) -> bool {
matches!(
subcmd,
SubCmd::Install | SubCmd::Uninstall | SubCmd::Start | SubCmd::Stop | SubCmd::Restart
)
}
fn maybe_warn_deprecated_options_for_service(subcmd: &SubCmd) {
if !should_check_deprecated_options(subcmd) {
return;
}
#[cfg(feature = "lua")]
if config::discover_lua_file().is_some() {
return;
}
let Some(path) = config::discover_configuration_file() else {
return;
};
match config::deprecated_options_in_file(&path) {
Ok(keys) if !keys.is_empty() => {
warn!(
"detected deprecated [options] keys in `{}` while running a service command: {}. \
Please migrate to `[padding]`, `[swipe]`, and `[decorations.*]`.",
path.display(),
keys.join(", ")
);
}
Ok(_) => {}
Err(err) => {
warn!(
"could not inspect `{}` for deprecated options: {err}",
path.display()
);
}
}
}