use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
fn exe() -> Result<PathBuf> {
let exe = std::env::current_exe().context("locating the running kagi binary")?;
Ok(std::fs::canonicalize(&exe).unwrap_or(exe))
}
fn home() -> Result<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.context("cannot locate a home directory")
}
fn run(program: &str, args: &[&str]) -> Result<String> {
let out = Command::new(program)
.args(args)
.output()
.with_context(|| format!("running `{program}`"))?;
if !out.status.success() {
bail!(
"`{program} {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn run_quiet(program: &str, args: &[&str]) {
let _ = Command::new(program).args(args).output();
}
fn write_file(path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
}
fn run_args(config: Option<&Path>) -> Vec<String> {
match config {
Some(c) => vec!["--config".into(), c.display().to_string(), "run".into()],
None => vec!["run".into()],
}
}
#[cfg(target_os = "macos")]
mod imp {
use super::*;
const LABEL: &str = "com.yukimemi.kagi";
fn plist_path() -> Result<PathBuf> {
Ok(home()?
.join("Library")
.join("LaunchAgents")
.join(format!("{LABEL}.plist")))
}
fn domain() -> Result<String> {
let uid = run("id", &["-u"])?;
Ok(format!("gui/{}", uid.trim()))
}
fn target() -> Result<String> {
Ok(format!("{}/{LABEL}", domain()?))
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn plist(exe: &Path, config: Option<&Path>) -> String {
let mut args = String::new();
args.push_str(&format!(
" <string>{}</string>\n",
xml_escape(&exe.display().to_string())
));
for a in run_args(config) {
args.push_str(&format!(" <string>{}</string>\n", xml_escape(&a)));
}
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Generated by `kagi service install`. Re-run it to regenerate. -->
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
{args} </array>
<key>EnvironmentVariables</key>
<dict>
<!-- macOS keys Accessibility and Input Monitoring to this binary. A
background self-update swaps it out, and the agent then runs unable
to see any key until the grants are renewed, with no prompt because
nothing is in the foreground. Update deliberately: `kagi update`. -->
<key>KAGI_NO_AUTOUPDATE</key>
<string>1</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<!-- Until the Privacy grants are ticked, kagi exits immediately and launchd
respawns it. The 10s default turns that into a stream of restarts while
the user is still in System Settings. -->
<key>ThrottleInterval</key>
<integer>60</integer>
<!-- Keyboard handling must not be throttled behind background QoS. -->
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
</dict>
</plist>
"#,
log = xml_escape(&log_path().display().to_string()),
)
}
pub fn log_path() -> PathBuf {
match home() {
Ok(h) => h.join("Library").join("Logs").join("kagi.log"),
Err(_) => PathBuf::from("/tmp/kagi.log"),
}
}
pub fn install(config: Option<&Path>) -> Result<()> {
let path = plist_path()?;
write_file(&path, &plist(&exe()?, config))?;
println!("wrote {}", path.display());
run_quiet("launchctl", &["bootout", &target()?]);
run(
"launchctl",
&["bootstrap", &domain()?, &path.display().to_string()],
)?;
println!("loaded {}", target()?);
Ok(())
}
pub fn uninstall() -> Result<()> {
run_quiet("launchctl", &["bootout", &target()?]);
let path = plist_path()?;
if path.exists() {
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
println!("removed {}", path.display());
}
println!("unloaded {}", target()?);
Ok(())
}
pub fn start() -> Result<()> {
run("launchctl", &["kickstart", "-k", &target()?])?;
println!("started {}", target()?);
Ok(())
}
pub fn stop() -> Result<()> {
run("launchctl", &["kill", "SIGTERM", &target()?])?;
println!("signalled {}", target()?);
Ok(())
}
pub fn status() -> Result<()> {
let path = plist_path()?;
println!("plist: {} ({})", path.display(), exists(&path));
match Command::new("launchctl")
.args(["print", &target()?])
.output()
{
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let t = line.trim();
if t.starts_with("state =")
|| t.starts_with("pid =")
|| t.starts_with("last exit code =")
{
println!("{t}");
}
}
}
_ => println!("state = not loaded"),
}
println!("log: {}", log_path().display());
Ok(())
}
pub fn after_install() -> Result<()> {
println!(
"\nmacOS grants Accessibility and Input Monitoring per binary, and the\n\
agent is not the terminal you just used. The first run therefore fails\n\
with `CGEventTapCreate failed` until you add\n {}\n\
under System Settings > Privacy & Security > Accessibility, and again\n\
under Input Monitoring. Then:\n launchctl kickstart -k {}\n\
Check progress with `kagi service status` and {}.",
exe()?.display(),
target()?,
log_path().display()
);
Ok(())
}
}
#[cfg(target_os = "linux")]
mod imp {
use super::*;
const UNIT: &str = "kagi.service";
fn unit_path() -> Result<PathBuf> {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(x) => PathBuf::from(x),
None => home()?.join(".config"),
};
Ok(base.join("systemd").join("user").join(UNIT))
}
fn unit(exe: &Path, config: Option<&Path>) -> String {
let args = run_args(config).join(" ");
format!(
"# Generated by `kagi service install`. Re-run it to regenerate.\n\
[Unit]\n\
Description=kagi — cross-platform key mapper with first-class IME control\n\
Documentation=https://github.com/yukimemi/kagi\n\
After=graphical-session.target\n\
PartOf=graphical-session.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={exe} {args}\n\
Restart=on-failure\n\
RestartSec=2\n\
# A key mapper that loses to the scheduler feels like broken hardware.\n\
Nice=-5\n\
\n\
[Install]\n\
WantedBy=graphical-session.target\n",
exe = exe.display(),
)
}
pub fn install(config: Option<&Path>) -> Result<()> {
let path = unit_path()?;
write_file(&path, &unit(&exe()?, config))?;
println!("wrote {}", path.display());
run("systemctl", &["--user", "daemon-reload"])?;
run("systemctl", &["--user", "enable", "--now", UNIT])?;
println!("enabled {UNIT}");
Ok(())
}
pub fn uninstall() -> Result<()> {
run_quiet("systemctl", &["--user", "disable", "--now", UNIT]);
let path = unit_path()?;
if path.exists() {
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
println!("removed {}", path.display());
}
run_quiet("systemctl", &["--user", "daemon-reload"]);
println!("disabled {UNIT}");
Ok(())
}
pub fn start() -> Result<()> {
run("systemctl", &["--user", "restart", UNIT])?;
println!("started {UNIT}");
Ok(())
}
pub fn stop() -> Result<()> {
run("systemctl", &["--user", "stop", UNIT])?;
println!("stopped {UNIT}");
Ok(())
}
pub fn status() -> Result<()> {
let path = unit_path()?;
println!("unit: {} ({})", path.display(), exists(&path));
let out = Command::new("systemctl")
.args(["--user", "--no-pager", "status", UNIT])
.output();
match out {
Ok(o) => print!("{}", String::from_utf8_lossy(&o.stdout)),
Err(e) => println!("systemctl unavailable: {e}"),
}
Ok(())
}
pub fn after_install() -> Result<()> {
println!(
"\nkagi reads /dev/input/event* and writes /dev/uinput. If the service\n\
fails with a permission error:\n \
sudo usermod -aG input $USER # then log out and back in\n \
echo 'KERNEL==\"uinput\", GROUP=\"input\", MODE=\"0660\"' \\\n \
| sudo tee /etc/udev/rules.d/99-kagi-uinput.rules\n \
sudo modprobe uinput && sudo udevadm control --reload-rules\n\
Logs: journalctl --user -u kagi -f"
);
Ok(())
}
}
#[cfg(target_os = "windows")]
mod imp {
use super::*;
const TASK: &str = "kagi";
fn shim_path() -> Result<PathBuf> {
let base = match std::env::var_os("LOCALAPPDATA") {
Some(x) => PathBuf::from(x),
None => home()?.join("AppData").join("Local"),
};
Ok(base.join("kagi").join("kagi-hidden.vbs"))
}
fn shim(exe: &Path, config: Option<&Path>) -> String {
let mut cmd = format!("\"\"{}\"\"", exe.display());
for a in run_args(config) {
cmd.push_str(&format!(" \"\"{a}\"\""));
}
format!(
"' Generated by `kagi service install`. Re-run it to regenerate.\r\n\
Set sh = CreateObject(\"WScript.Shell\")\r\n\
sh.Run \"{cmd}\", 0, False\r\n"
)
}
pub fn install(config: Option<&Path>) -> Result<()> {
let shim_file = shim_path()?;
write_file(&shim_file, &shim(&exe()?, config))?;
println!("wrote {}", shim_file.display());
let action = format!("wscript.exe \"{}\"", shim_file.display());
run(
"schtasks",
&[
"/Create", "/TN", TASK, "/TR", &action, "/SC", "ONLOGON", "/RL", "LIMITED", "/F",
],
)?;
println!("registered logon task `{TASK}`");
start()
}
pub fn uninstall() -> Result<()> {
run_quiet("schtasks", &["/End", "/TN", TASK]);
run_quiet("schtasks", &["/Delete", "/TN", TASK, "/F"]);
let shim_file = shim_path()?;
if shim_file.exists() {
std::fs::remove_file(&shim_file)
.with_context(|| format!("removing {}", shim_file.display()))?;
println!("removed {}", shim_file.display());
}
println!("removed logon task `{TASK}`");
Ok(())
}
pub fn start() -> Result<()> {
run("schtasks", &["/Run", "/TN", TASK])?;
println!("started `{TASK}`");
Ok(())
}
pub fn stop() -> Result<()> {
run("schtasks", &["/End", "/TN", TASK])?;
println!("stopped `{TASK}`");
Ok(())
}
pub fn status() -> Result<()> {
let shim_file = shim_path()?;
println!("shim: {} ({})", shim_file.display(), exists(&shim_file));
match Command::new("schtasks")
.args(["/Query", "/TN", TASK, "/FO", "LIST"])
.output()
{
Ok(o) if o.status.success() => print!("{}", String::from_utf8_lossy(&o.stdout)),
_ => println!("state = not registered"),
}
Ok(())
}
pub fn after_install() -> Result<()> {
println!(
"\nA low-level keyboard hook cannot see input destined for a window\n\
running at a higher integrity level. If remapping stops working in an\n\
elevated app, re-register with an elevated shell and `/RL HIGHEST`."
);
Ok(())
}
}
fn exists(path: &Path) -> &'static str {
if path.exists() { "present" } else { "absent" }
}
pub fn install(config: Option<&Path>) -> Result<()> {
imp::install(config)?;
imp::after_install()
}
pub fn uninstall() -> Result<()> {
imp::uninstall()
}
pub fn start() -> Result<()> {
imp::start()
}
pub fn stop() -> Result<()> {
imp::stop()
}
pub fn status() -> Result<()> {
imp::status()
}