use crate::error::{err_number, VBError, VBResult};
use super::appactivate::AppActivateRequest;
use super::backend::InteractionBackend;
use super::inputbox::InputBoxRequest;
use super::msgbox::{MsgBoxButton, MsgBoxRequest};
use super::sendkeys::SendKeysRequest;
use super::shell::ShellRequest;
pub struct NativeBackend;
impl NativeBackend {
pub fn new() -> Self {
Self
}
}
impl Default for NativeBackend {
fn default() -> Self {
Self::new()
}
}
impl InteractionBackend for NativeBackend {
fn command_args(&self) -> Vec<String> {
std::env::args().skip(1).collect()
}
fn do_events(&self) -> i16 {
std::thread::yield_now();
0
}
fn beep(&self) {
use std::io::Write;
let _ = std::io::stderr().write_all(b"\x07");
}
fn stop(&self) {
}
fn msg_box(&self, request: &MsgBoxRequest) -> VBResult<MsgBoxButton> {
show_dialog(request)
}
fn input_box(&self, request: &InputBoxRequest) -> VBResult<String> {
show_input_dialog(request)
}
fn app_activate(&self, request: &AppActivateRequest) -> VBResult<()> {
activate_window(request)
}
fn send_keys(&self, request: &SendKeysRequest) -> VBResult<()> {
deliver_keystrokes(request)
}
fn shell(&self, request: &ShellRequest) -> VBResult<f64> {
launch(request).map_err(|err| {
let mapped = VBError::from(err);
VBError::with_description(
mapped.number,
format!("\"{}\": {}", request.pathname, mapped.description),
)
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
fn show_dialog(request: &MsgBoxRequest) -> VBResult<MsgBoxButton> {
#[cfg(target_os = "windows")]
{
Ok(windows::message_box(request))
}
#[cfg(target_os = "macos")]
{
Ok(macos::display_dialog(request).unwrap_or_else(|| fallback(request)))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Ok(linux::zenity_dialog(request).unwrap_or_else(|| fallback(request)))
}
#[cfg(target_arch = "wasm32")]
{
Ok(wasm::display_dialog(request))
}
#[cfg(not(any(windows, unix, target_arch = "wasm32")))]
{
let _ = request;
Ok(fallback(request))
}
}
fn show_input_dialog(request: &InputBoxRequest) -> VBResult<String> {
#[cfg(target_os = "windows")]
{
Ok(windows::input_dialog(request).unwrap_or_else(|| input_fallback(request)))
}
#[cfg(target_os = "macos")]
{
Ok(macos::input_dialog(request).unwrap_or_else(|| input_fallback(request)))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
Ok(linux::entry_dialog(request).unwrap_or_else(|| input_fallback(request)))
}
#[cfg(target_arch = "wasm32")]
{
Ok(wasm::prompt_dialog(request).unwrap_or_default())
}
#[cfg(not(any(windows, unix, target_arch = "wasm32")))]
{
let _ = request;
Ok(input_fallback(request))
}
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn fallback(request: &MsgBoxRequest) -> MsgBoxButton {
let title = request.title.as_deref().unwrap_or("MsgBox");
let buttons = request
.offered_buttons()
.iter()
.map(|b| b.name())
.collect::<Vec<_>>()
.join("|");
eprintln!("[MsgBox] {title}: {} [{buttons}]", request.prompt);
request.default_button_value()
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn input_fallback(request: &InputBoxRequest) -> String {
let title = request.title.as_deref().unwrap_or("InputBox");
eprintln!(
"[InputBox] {title}: {} [{}]",
request.prompt, request.default_response
);
request.default_response.clone()
}
fn activate_window(request: &AppActivateRequest) -> VBResult<()> {
#[cfg(target_os = "windows")]
{
if windows::activate_window(request) {
Ok(())
} else {
Err(no_such_window(&request.title))
}
}
#[cfg(target_os = "macos")]
{
match macos::activate_window(request) {
Some(true) => Ok(()),
Some(false) => Err(no_such_window(&request.title)),
None => {
activate_fallback(request);
Ok(())
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
match linux::activate_window(request) {
Some(true) => Ok(()),
Some(false) => Err(no_such_window(&request.title)),
None => {
activate_fallback(request);
Ok(())
}
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = request;
activate_fallback(request);
Ok(())
}
}
#[cfg_attr(
not(any(windows, unix)),
allow(dead_code) // exercised by tests; consumed by the platform backends
)]
fn no_such_window(title: &str) -> VBError {
VBError::with_description(
err_number::INVALID_PROCEDURE_CALL,
format!(
"Invalid procedure call or argument: AppActivate found no window titled \
\"{title}\""
),
)
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn activate_fallback(request: &AppActivateRequest) {
if request.wait {
eprintln!("[AppActivate] {} [wait]", request.title);
} else {
eprintln!("[AppActivate] {}", request.title);
}
}
fn deliver_keystrokes(request: &SendKeysRequest) -> VBResult<()> {
#[cfg(target_os = "windows")]
{
windows::send_keys(request);
}
#[cfg(target_os = "macos")]
{
if !macos::send_keys(request) {
sendkeys_fallback(request);
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if !linux::send_keys(request) {
sendkeys_fallback(request);
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = request;
sendkeys_fallback(request);
}
Ok(())
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn sendkeys_fallback(request: &SendKeysRequest) {
if request.wait {
eprintln!("[SendKeys] {} [wait]", request.keys);
} else {
eprintln!("[SendKeys] {}", request.keys);
}
}
#[cfg(not(any(windows, unix)))]
static SYNTHETIC_TASK_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(not(any(windows, unix)))]
fn next_synthetic_task_id() -> f64 {
use std::sync::atomic::Ordering;
1.0 + SYNTHETIC_TASK_IDS.fetch_add(1, Ordering::Relaxed) as f64
}
fn launch(request: &ShellRequest) -> std::io::Result<f64> {
#[cfg(target_os = "windows")]
{
windows::spawn_process(request)
}
#[cfg(unix)]
{
posix::spawn_process(request)
}
#[cfg(not(any(windows, unix)))]
{
let _ = request;
eprintln!("[Shell] {}", request.pathname);
Ok(next_synthetic_task_id())
}
}
#[cfg(target_os = "windows")]
mod windows {
use std::cell::RefCell;
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
use windows_sys::Win32::UI::WindowsAndMessaging::{
DialogBoxIndirectParamW, EndDialog, GetDialogBaseUnits, GetDlgItemTextW, MessageBoxW,
SetDlgItemTextW, BS_DEFPUSHBUTTON, BS_PUSHBUTTON, DS_CENTER, DS_MODALFRAME, ES_AUTOHSCROLL,
IDCANCEL, IDOK, MB_ABORTRETRYIGNORE, MB_DEFBUTTON1, MB_DEFBUTTON2, MB_DEFBUTTON3,
MB_DEFBUTTON4, MB_HELP, MB_ICONERROR, MB_ICONINFORMATION, MB_ICONQUESTION, MB_ICONWARNING,
MB_OK, MB_OKCANCEL, MB_RETRYCANCEL, MB_RIGHT, MB_RTLREADING, MB_SETFOREGROUND,
MB_SYSTEMMODAL, MB_YESNO, MB_YESNOCANCEL, WM_COMMAND, WM_INITDIALOG, WS_BORDER, WS_CAPTION,
WS_CHILD, WS_GROUP, WS_POPUP, WS_SYSMENU, WS_TABSTOP, WS_VISIBLE,
};
use super::super::appactivate::AppActivateRequest;
use super::super::inputbox::InputBoxRequest;
use super::super::msgbox::{
MsgBoxButton, MsgBoxButtonSet, MsgBoxIcon, MsgBoxModality, MsgBoxRequest,
};
fn wide(s: &str) -> Vec<u16> {
use std::iter::once;
std::ffi::OsStr::new(s)
.encode_wide()
.chain(once(0))
.collect()
}
pub(super) fn message_box(request: &MsgBoxRequest) -> MsgBoxButton {
let text = wide(&request.prompt);
let caption = wide(request.title.as_deref().unwrap_or(""));
let mut flags = match request.button_set {
MsgBoxButtonSet::OkOnly => MB_OK,
MsgBoxButtonSet::OkCancel => MB_OKCANCEL,
MsgBoxButtonSet::AbortRetryIgnore => MB_ABORTRETRYIGNORE,
MsgBoxButtonSet::YesNoCancel => MB_YESNOCANCEL,
MsgBoxButtonSet::YesNo => MB_YESNO,
MsgBoxButtonSet::RetryCancel => MB_RETRYCANCEL,
} | match request.icon {
MsgBoxIcon::None => 0,
MsgBoxIcon::Critical => MB_ICONERROR,
MsgBoxIcon::Question => MB_ICONQUESTION,
MsgBoxIcon::Exclamation => MB_ICONWARNING,
MsgBoxIcon::Information => MB_ICONINFORMATION,
} | match request.default_button {
1 => MB_DEFBUTTON1,
2 => MB_DEFBUTTON2,
3 => MB_DEFBUTTON3,
_ => MB_DEFBUTTON4,
};
flags |= match request.modality {
MsgBoxModality::Application => 0,
MsgBoxModality::System => MB_SYSTEMMODAL,
};
if request.help_button {
flags |= MB_HELP;
}
if request.set_foreground {
flags |= MB_SETFOREGROUND;
}
if request.right_aligned {
flags |= MB_RIGHT;
}
if request.rtl_reading {
flags |= MB_RTLREADING;
}
let hwnd: *mut core::ffi::c_void = std::ptr::null_mut();
let result = unsafe { MessageBoxW(hwnd, text.as_ptr(), caption.as_ptr(), flags) };
MsgBoxButton::from_id(result as i16).unwrap_or_else(|| request.default_button_value())
}
struct WindowInfo {
hwnd: isize,
title: String,
pid: u32,
}
pub(super) fn activate_window(request: &AppActivateRequest) -> bool {
use windows_sys::Win32::UI::WindowsAndMessaging::{
EnumWindows, IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
};
unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> windows_sys::core::BOOL {
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible,
};
let windows = &mut *(lparam as *mut Vec<WindowInfo>);
if IsWindowVisible(hwnd) != 0 {
let mut title = String::new();
let len = GetWindowTextLengthW(hwnd);
if len > 0 {
let mut buffer = vec![0u16; len as usize + 1];
let copied =
GetWindowTextW(hwnd, buffer.as_mut_ptr(), buffer.len() as i32) as usize;
buffer.truncate(copied);
title = String::from_utf16_lossy(&buffer);
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, &mut pid);
windows.push(WindowInfo {
hwnd: hwnd as isize,
title,
pid,
});
}
1 }
let mut windows: Vec<WindowInfo> = Vec::new();
unsafe {
EnumWindows(Some(enum_proc), &mut windows as *mut _ as LPARAM);
}
let needle = request.title.to_lowercase();
let target = if let Some(task_id) = request.as_task_id() {
windows
.iter()
.find(|w| w.pid == task_id as u32)
.map(|w| w.hwnd)
.or_else(|| find_string_match(&windows, &needle))
} else {
find_string_match(&windows, &needle)
};
let Some(hwnd) = target else {
return false;
};
unsafe {
let hwnd = hwnd as HWND;
if IsIconic(hwnd) != 0 {
ShowWindow(hwnd, SW_RESTORE);
}
SetForegroundWindow(hwnd);
}
true
}
fn find_string_match(windows: &[WindowInfo], needle: &str) -> Option<isize> {
let folded: Vec<(isize, String)> = windows
.iter()
.map(|w| (w.hwnd, w.title.to_lowercase()))
.collect();
folded
.iter()
.find(|(_, title)| title == needle)
.or_else(|| folded.iter().find(|(_, title)| title.starts_with(needle)))
.or_else(|| folded.iter().find(|(_, title)| title.ends_with(needle)))
.map(|(hwnd, _)| *hwnd)
}
pub(super) fn send_keys(request: &SendKeysRequest) {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
KEYEVENTF_KEYUP, VK_CONTROL, VK_MENU, VK_SHIFT,
};
let mut inputs: Vec<windows_sys::Win32::UI::Input::KeyboardAndMouse::INPUT> = Vec::new();
for stroke in &request.strokes {
let mut held: Vec<u16> = Vec::new();
if stroke.shift {
held.push(VK_SHIFT);
}
if stroke.ctrl {
held.push(VK_CONTROL);
}
if stroke.alt {
held.push(VK_MENU);
}
let key_event = match stroke.key {
SendKey::Char(c) => {
match layout_key(c) {
Some((vk_code, layout_shift)) => {
if layout_shift && !stroke.shift {
held.push(VK_SHIFT);
}
Event::Virtual(vk_code)
}
None => Event::Unicode(c),
}
}
named => Event::Virtual(virtual_key_code(named)),
};
for vk in &held {
inputs.push(key_input(*vk, 0, 0));
}
match key_event {
Event::Virtual(vk) => {
inputs.push(key_input(vk, 0, 0));
inputs.push(key_input(vk, 0, KEYEVENTF_KEYUP));
}
Event::Unicode(c) => push_unicode(&mut inputs, c),
}
for vk in held.iter().rev() {
inputs.push(key_input(*vk, 0, KEYEVENTF_KEYUP));
}
}
if inputs.is_empty() {
return;
}
let sent = unsafe {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::SendInput;
SendInput(
inputs.len() as u32,
inputs.as_mut_ptr(),
std::mem::size_of::<windows_sys::Win32::UI::Input::KeyboardAndMouse::INPUT>()
as i32,
)
};
if sent != inputs.len() as u32 {
eprintln!(
"[SendKeys] SendInput delivered {sent} of {} events",
inputs.len()
);
}
}
enum Event {
Virtual(u16),
Unicode(char),
}
fn layout_key(c: char) -> Option<(u16, bool)> {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::VkKeyScanW;
let scanned = unsafe { VkKeyScanW(c as u16) };
if scanned == -1 {
return None;
}
let vk_code = (scanned & 0xFF) as u16;
if vk_code == 0xFF {
return None;
}
let shift_state = ((scanned >> 8) & 0xFF) as u8;
Some((vk_code, shift_state & 1 != 0))
}
fn push_unicode(
inputs: &mut Vec<windows_sys::Win32::UI::Input::KeyboardAndMouse::INPUT>,
c: char,
) {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{KEYEVENTF_KEYUP, KEYEVENTF_UNICODE};
let mut units = [0u16; 2];
let count = c.encode_utf16(&mut units).len();
for unit in &units[..count] {
inputs.push(key_input(0, *unit, KEYEVENTF_UNICODE));
inputs.push(key_input(0, *unit, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP));
}
}
fn key_input(
wvk: u16,
scan: u16,
flags: u32,
) -> windows_sys::Win32::UI::Input::KeyboardAndMouse::INPUT {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT,
};
INPUT {
r#type: INPUT_KEYBOARD,
Anonymous: INPUT_0 {
ki: KEYBDINPUT {
wVk: wvk,
wScan: scan,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
}
}
fn virtual_key_code(key: SendKey) -> u16 {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
VK_BACK, VK_CANCEL, VK_CAPITAL, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HELP,
VK_HOME, VK_INSERT, VK_LEFT, VK_NEXT, VK_NUMLOCK, VK_PRIOR, VK_RETURN, VK_RIGHT,
VK_SCROLL, VK_SNAPSHOT, VK_TAB, VK_UP,
};
match key {
SendKey::Backspace => VK_BACK,
SendKey::Break => VK_CANCEL,
SendKey::CapsLock => VK_CAPITAL,
SendKey::Delete => VK_DELETE,
SendKey::Down => VK_DOWN,
SendKey::End => VK_END,
SendKey::Enter => VK_RETURN,
SendKey::Esc => VK_ESCAPE,
SendKey::Help => VK_HELP,
SendKey::Home => VK_HOME,
SendKey::Insert => VK_INSERT,
SendKey::Left => VK_LEFT,
SendKey::NumLock => VK_NUMLOCK,
SendKey::PageDown => VK_NEXT,
SendKey::PageUp => VK_PRIOR,
SendKey::PrintScreen => VK_SNAPSHOT,
SendKey::Right => VK_RIGHT,
SendKey::ScrollLock => VK_SCROLL,
SendKey::Tab => VK_TAB,
SendKey::Up => VK_UP,
SendKey::Function(n) => VK_F1 + (n.max(1).min(24) as u16 - 1),
SendKey::Char(_) => unreachable!("character keys are handled by the layout mapping"),
}
}
thread_local! {
static INPUT_ANSWER: RefCell<Option<String>> = const { RefCell::new(None) };
}
const ID_EDIT: i32 = 1001;
const CLASS_BUTTON: u16 = 0x0080;
const CLASS_EDIT: u16 = 0x0081;
const CLASS_STATIC: u16 = 0x0082;
pub(super) fn input_dialog(request: &InputBoxRequest) -> Option<String> {
let template = build_template(request);
INPUT_ANSWER.with(|slot| *slot.borrow_mut() = None);
let failed = unsafe {
DialogBoxIndirectParamW(
std::ptr::null_mut(), template.as_ptr().cast(),
std::ptr::null_mut(),
Some(input_dialog_proc),
request as *const InputBoxRequest as isize,
) == -1
};
if failed {
return None;
}
INPUT_ANSWER.with(|slot| slot.borrow_mut().take())
}
fn build_template(request: &InputBoxRequest) -> Vec<u16> {
let mut style =
WS_POPUP | WS_CAPTION | WS_SYSMENU | DS_MODALFRAME as u32 | DS_CENTER as u32;
let (mut x, mut y) = (0i16, 0i16);
if let (Some(xpos), Some(ypos)) = (request.xpos, request.ypos) {
(x, y) = position_in_dialog_units(xpos, ypos);
style &= !(DS_CENTER as u32);
}
let mut b = TemplateBuilder::dialog(style, x, y, 210, 94, request.title.as_deref());
b.item(
WS_CHILD | WS_VISIBLE | WS_GROUP,
10,
8,
190,
44,
0,
CLASS_STATIC,
&request.prompt,
);
b.item(
WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL as u32,
10,
56,
190,
13,
ID_EDIT as u16,
CLASS_EDIT,
"",
);
b.item(
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON as u32,
92,
76,
50,
14,
IDOK as u16,
CLASS_BUTTON,
"OK",
);
b.item(
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON as u32,
148,
76,
50,
14,
IDCANCEL as u16,
CLASS_BUTTON,
"Cancel",
);
b.finish()
}
fn position_in_dialog_units(xpos: i32, ypos: i32) -> (i16, i16) {
let base = unsafe { GetDialogBaseUnits() };
let base_x = (base & 0xFFFF).max(1) as i32;
let base_y = ((base >> 16) & 0xFFFF).max(1) as i32;
let dlu_x = (xpos / 15) * 4 / base_x;
let dlu_y = (ypos / 15) * 8 / base_y;
(
dlu_x.clamp(i16::MIN as i32, i16::MAX as i32) as i16,
dlu_y.clamp(i16::MIN as i32, i16::MAX as i32) as i16,
)
}
unsafe extern "system" fn input_dialog_proc(
hwnd: HWND,
message: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match message {
WM_INITDIALOG => {
let request = &*(lparam as *const InputBoxRequest);
let default_text = wide(&request.default_response);
SetDlgItemTextW(hwnd, ID_EDIT, default_text.as_ptr());
0 }
WM_COMMAND => match wparam & 0xFFFF {
id if id == IDOK as usize => {
let text = read_edit_text(hwnd);
INPUT_ANSWER.with(|slot| *slot.borrow_mut() = Some(text));
EndDialog(hwnd, 1);
1
}
id if id == IDCANCEL as usize => {
EndDialog(hwnd, 0);
0
}
_ => 0,
},
_ => 0,
}
}
unsafe fn read_edit_text(hwnd: HWND) -> String {
let mut capacity = 260usize;
loop {
let mut buffer = vec![0u16; capacity];
let copied =
GetDlgItemTextW(hwnd, ID_EDIT, buffer.as_mut_ptr(), capacity as i32) as usize;
if copied + 1 < capacity {
buffer.truncate(copied);
return String::from_utf16_lossy(&buffer);
}
capacity *= 2;
}
}
struct TemplateBuilder {
words: Vec<u16>,
}
impl TemplateBuilder {
fn dialog(style: u32, x: i16, y: i16, cx: i16, cy: i16, title: Option<&str>) -> Self {
let mut b = Self { words: Vec::new() };
b.dword(style);
b.dword(0); b.word(0); b.word(x as u16);
b.word(y as u16);
b.word(cx as u16);
b.word(cy as u16);
b.word(0); b.word(0); b.text(&title.unwrap_or("Input"));
b
}
#[allow(clippy::too_many_arguments)]
fn item(
&mut self,
style: u32,
x: i16,
y: i16,
cx: i16,
cy: i16,
id: u16,
class_atom: u16,
text: &str,
) {
self.align_dword();
self.dword(style);
self.dword(0); self.word(x as u16);
self.word(y as u16);
self.word(cx as u16);
self.word(cy as u16);
self.word(id);
self.word(0xFFFF);
self.word(class_atom);
self.text(text);
self.word(0); }
fn word(&mut self, value: u16) {
self.words.push(value);
}
fn dword(&mut self, value: u32) {
self.words.push(value as u16);
self.words.push((value >> 16) as u16);
}
fn align_dword(&mut self) {
if self.words.len() % 2 != 0 {
self.word(0);
}
}
fn text(&mut self, value: &str) {
self.words.extend(OsStr::new(value).encode_wide());
self.word(0);
}
fn finish(mut self) -> Vec<u16> {
self.align_dword();
self.words[6] = 4; self.words
}
}
pub(super) fn spawn_process(request: &ShellRequest) -> std::io::Result<f64> {
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
CreateProcessW, CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTF_USESHOWWINDOW,
STARTUPINFOW,
};
let mut command_line: Vec<u16> = std::ffi::OsStr::new(&request.pathname)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut startup: STARTUPINFOW = unsafe { std::mem::zeroed() };
startup.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
startup.dwFlags = STARTF_USESHOWWINDOW;
startup.wShowWindow = show_window_flag(request.window_style);
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
let started = unsafe {
CreateProcessW(
std::ptr::null(), command_line.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
0, CREATE_UNICODE_ENVIRONMENT,
std::ptr::null(), std::ptr::null(), &startup,
&mut process,
)
};
if started == 0 {
return Err(std::io::Error::last_os_error());
}
unsafe {
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
Ok(f64::from(process.dwProcessId))
}
fn show_window_flag(style: super::super::shell::WindowStyle) -> u16 {
use super::super::shell::WindowStyle;
match style {
WindowStyle::Hide => 0, WindowStyle::NormalFocus => 1, WindowStyle::MinimizedFocus => 2, WindowStyle::MaximizedFocus => 3, WindowStyle::NormalNoFocus => 4, WindowStyle::MinimizedNoFocus => 7, }
}
}
#[cfg(target_os = "macos")]
mod macos {
use std::process::Command;
use super::super::appactivate::AppActivateRequest;
use super::super::msgbox::{MsgBoxButton, MsgBoxIcon, MsgBoxRequest};
fn escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub(super) fn display_dialog(request: &MsgBoxRequest) -> Option<MsgBoxButton> {
let offered = request.offered_buttons();
let labels = offered
.iter()
.map(|b| format!("\"{}\"", escape(b.name())))
.collect::<Vec<_>>()
.join(", ");
let default_label = request.default_button_value().name();
let mut script = format!(
"display dialog \"{}\" buttons {{{labels}}} default button \"{}\"",
escape(&request.prompt),
escape(default_label),
);
if let Some(title) = &request.title {
script.push_str(&format!(" with title \"{}\"", escape(title)));
}
script.push_str(match request.icon {
MsgBoxIcon::Critical => " with icon stop",
MsgBoxIcon::Question | MsgBoxIcon::Exclamation => " with icon caution",
MsgBoxIcon::Information => " with icon note",
MsgBoxIcon::None => "",
});
let output = Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.ok()?;
if !output.status.success() {
if offered.contains(&MsgBoxButton::Cancel) {
return Some(MsgBoxButton::Cancel);
}
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.trim()
.strip_prefix("button returned:")
.and_then(MsgBoxButton::from_name)
.or(Some(request.default_button_value()))
}
pub(super) fn input_dialog(request: &InputBoxRequest) -> Option<String> {
let mut script = format!(
"display dialog \"{}\" default answer \"{}\" \
buttons {{\"OK\", \"Cancel\"}} default button \"OK\"",
escape(&request.prompt),
escape(&request.default_response),
);
if let Some(title) = &request.title {
script.push_str(&format!(" with title \"{}\"", escape(title)));
}
let output = Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.ok()?;
if !output.status.success() {
return Some(String::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let (_, value) = stdout.rsplit_once("text returned:")?;
Some(value.strip_suffix('\n').unwrap_or(value).to_string())
}
pub(super) fn activate_window(request: &AppActivateRequest) -> Option<bool> {
let title = escape(&request.title);
for comparison in ["begins with", "ends with"] {
let script = format!(
"tell application \"System Events\"\n\
\x20 repeat with p in (every application process whose visible is true)\n\
\x20 if name of p {comparison} \"{title}\" then\n\
\x20 set frontmost of p to true\n\
\x20 return \"activated\"\n\
\x20 end if\n\
\x20 try\n\
\x20 repeat with w in (every window of p)\n\
\x20 if name of w {comparison} \"{title}\" then\n\
\x20 perform action \"AXRaise\" of w\n\
\x20 set frontmost of p to true\n\
\x20 return \"activated\"\n\
\x20 end if\n\
\x20 end repeat\n\
\x20 end try\n\
\x20 end repeat\n\
end tell\n\
return \"missing\""
);
let output = Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.ok()?;
if !output.status.success() {
return None;
}
if String::from_utf8_lossy(&output.stdout).trim() == "activated" {
return Some(true);
}
}
Some(false)
}
fn macos_key_code(key: SendKey) -> Option<i32> {
let code = match key {
SendKey::Backspace => 51,
SendKey::Delete => 117, SendKey::Tab => 48,
SendKey::Enter => 36,
SendKey::Esc => 53,
SendKey::Home => 115,
SendKey::End => 119,
SendKey::PageUp => 116,
SendKey::PageDown => 121,
SendKey::Up => 126,
SendKey::Down => 125,
SendKey::Left => 123,
SendKey::Right => 124,
SendKey::Help => 114,
SendKey::CapsLock => 57,
SendKey::NumLock => 71, SendKey::Function(n) => match n {
1 => 122,
2 => 120,
3 => 99,
4 => 118,
5 => 96,
6 => 97,
7 => 98,
8 => 100,
9 => 101,
10 => 109,
11 => 103,
12 => 111,
13 => 105,
14 => 107,
15 => 113,
_ => 106, },
SendKey::Break | SendKey::PrintScreen | SendKey::ScrollLock | SendKey::Insert => {
return None
}
SendKey::Char(_) => return None, };
Some(code)
}
fn modifier_clause(stroke: &super::super::sendkeys::Keystroke) -> String {
let mut names = Vec::new();
if stroke.shift {
names.push("shift down");
}
if stroke.ctrl {
names.push("control down");
}
if stroke.alt {
names.push("option down");
}
if names.is_empty() {
String::new()
} else {
format!(" using {{{}}}", names.join(", "))
}
}
pub(super) fn send_keys(request: &SendKeysRequest) -> bool {
use super::super::sendkeys::SendKey;
let mut lines: Vec<String> = Vec::new();
let mut text_run = String::new();
for stroke in &request.strokes {
match stroke.key {
SendKey::Char(c) if !stroke.shift && !stroke.ctrl && !stroke.alt => {
text_run.push(c);
}
SendKey::Char(c) => {
if !text_run.is_empty() {
lines.push(format!("keystroke \"{}\"", escape(&text_run)));
text_run.clear();
}
lines.push(format!(
"keystroke \"{}\"{}",
escape(&c.to_string()),
modifier_clause(stroke)
));
}
named => {
if !text_run.is_empty() {
lines.push(format!("keystroke \"{}\"", escape(&text_run)));
text_run.clear();
}
match macos_key_code(named) {
Some(code) => {
lines.push(format!("key code {code}{}", modifier_clause(stroke)))
}
None => eprintln!(
"[SendKeys] {} has no macOS equivalent; skipped",
named.name()
),
}
}
}
}
if !text_run.is_empty() {
lines.push(format!("keystroke \"{}\"", escape(&text_run)));
}
if lines.is_empty() {
return true;
}
let script = format!(
"tell application \"System Events\"\n{}\nend tell",
lines.join("\n")
);
Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod linux {
use std::process::Command;
use super::super::appactivate::AppActivateRequest;
use super::super::inputbox::InputBoxRequest;
use super::super::msgbox::{MsgBoxButton, MsgBoxIcon, MsgBoxRequest};
use super::super::sendkeys::{Keystroke, SendKey, SendKeysRequest};
fn headless() -> bool {
std::env::var_os("DISPLAY").is_none_or(|v| v.is_empty())
&& std::env::var_os("WAYLAND_DISPLAY").is_none_or(|v| v.is_empty())
}
pub(super) fn zenity_dialog(request: &MsgBoxRequest) -> Option<MsgBoxButton> {
if headless() {
return None;
}
let offered = request.offered_buttons();
let mut command = Command::new("zenity");
command.arg(match request.icon {
MsgBoxIcon::Critical => "--error",
MsgBoxIcon::Question => "--question",
MsgBoxIcon::Exclamation => "--warning",
MsgBoxIcon::Information | MsgBoxIcon::None => "--info",
});
command.arg("--no-wrap");
command.arg("--text").arg(&request.prompt);
if let Some(title) = &request.title {
command.arg("--title").arg(title);
}
let first = offered[0];
let second = offered.get(1).copied();
let third = offered.get(2).copied();
command.arg("--ok-label").arg(first.name());
if let Some(second) = second {
command.arg("--cancel-label").arg(second.name());
}
if let Some(third) = third {
command.arg("--extra-button").arg(third.name());
}
let output = command.output().ok()?;
if let Some(third) = third {
let label = String::from_utf8_lossy(&output.stdout);
if label.trim().eq_ignore_ascii_case(third.name()) {
return Some(third);
}
}
if output.status.success() {
Some(first)
} else {
second
}
}
pub(super) fn entry_dialog(request: &InputBoxRequest) -> Option<String> {
if headless() {
return None;
}
let mut command = Command::new("zenity");
command.arg("--entry");
command.arg("--text").arg(&request.prompt);
command.arg("--entry-text").arg(&request.default_response);
if let Some(title) = &request.title {
command.arg("--title").arg(title);
}
let output = command.output().ok()?;
if !output.status.success() {
return Some(String::new());
}
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
if text.ends_with('\n') {
text.pop();
}
Some(text)
}
pub(super) fn activate_window(request: &AppActivateRequest) -> Option<bool> {
let listing = Command::new("wmctrl").arg("-l").output().ok()?;
if !listing.status.success() {
return None;
}
let needle = request.title.to_lowercase();
let windows: Vec<(String, String)> = String::from_utf8_lossy(&listing.stdout)
.lines()
.filter_map(|line| {
let mut parts = line.splitn(4, char::is_whitespace);
let id = parts.next()?.to_string();
parts.next()?; parts.next()?; Some((id, parts.next()?.to_lowercase()))
})
.collect();
let find = |predicate: &dyn Fn(&str) -> bool| -> Option<String> {
windows
.iter()
.find(|(_, title)| predicate(title))
.map(|(id, _)| id.clone())
};
let target = find(&|t| t == needle)
.or_else(|| find(&|t| t.starts_with(&needle)))
.or_else(|| find(&|t| t.ends_with(&needle)))?;
Command::new("wmctrl")
.args(["-i", "-a"])
.arg(target)
.status()
.ok()
.map(|status| status.success())
}
pub(super) fn send_keys(request: &SendKeysRequest) -> bool {
fn flush_type(run: &mut String, ran_any: &mut bool) {
if run.is_empty() {
return;
}
let ok = Command::new("xdotool")
.arg("type")
.arg(std::mem::take(run))
.status()
.map(|status| status.success())
.unwrap_or(false);
*ran_any |= ok;
}
fn press(stroke: &Keystroke, ran_any: &mut bool) {
let mut parts: Vec<String> = Vec::new();
if stroke.shift {
parts.push("shift".into());
}
if stroke.ctrl {
parts.push("ctrl".into());
}
if stroke.alt {
parts.push("alt".into());
}
parts.push(xdotool_key_name(stroke.key));
let ok = Command::new("xdotool")
.arg("key")
.arg("--clearmodifiers")
.arg(parts.join("+"))
.status()
.map(|status| status.success())
.unwrap_or(false);
*ran_any |= ok;
}
if headless() {
return false;
}
let mut ran_any = false;
let mut text_run = String::new();
for stroke in &request.strokes {
if let SendKey::Char(c) = stroke.key {
if !stroke.shift && !stroke.ctrl && !stroke.alt {
text_run.push(c);
continue;
}
}
flush_type(&mut text_run, &mut ran_any);
press(stroke, &mut ran_any);
}
flush_type(&mut text_run, &mut ran_any);
ran_any
}
fn xdotool_key_name(key: SendKey) -> String {
match key {
SendKey::Char(' ') => "space".into(),
SendKey::Char('\t') => "Tab".into(),
SendKey::Char('\n' | '\r') => "Return".into(),
SendKey::Char(c) if c.is_ascii_graphic() => c.to_string(),
SendKey::Char(c) => format!("U{:04x}", c as u32),
SendKey::Backspace => "BackSpace".into(),
SendKey::Break => "Break".into(),
SendKey::CapsLock => "Caps_Lock".into(),
SendKey::Delete => "Delete".into(),
SendKey::Down => "Down".into(),
SendKey::End => "End".into(),
SendKey::Enter => "Return".into(),
SendKey::Esc => "Escape".into(),
SendKey::Help => "Help".into(),
SendKey::Home => "Home".into(),
SendKey::Insert => "Insert".into(),
SendKey::Left => "Left".into(),
SendKey::NumLock => "Num_Lock".into(),
SendKey::PageDown => "Next".into(),
SendKey::PageUp => "Prior".into(),
SendKey::PrintScreen => "Print".into(),
SendKey::Right => "Right".into(),
SendKey::ScrollLock => "Scroll_Lock".into(),
SendKey::Tab => "Tab".into(),
SendKey::Up => "Up".into(),
SendKey::Function(n) => format!("F{n}"),
}
}
}
#[cfg(unix)]
mod posix {
use std::process::{Command, Stdio};
use super::super::shell::ShellRequest;
pub(super) fn spawn_process(request: &ShellRequest) -> std::io::Result<f64> {
let (program, arguments) = split_command_line(&request.pathname);
let mut command = Command::new(program);
command.args(arguments);
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
use std::os::unix::process::CommandExt;
command.process_group(0);
let mut child = command.spawn()?;
let pid = child.id();
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(f64::from(pid))
}
pub(super) fn split_command_line(line: &str) -> (String, Vec<String>) {
let mut tokens: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut token_started = false;
for ch in line.chars() {
match ch {
'"' => {
in_quotes = !in_quotes;
token_started = true;
}
c if c.is_whitespace() && !in_quotes => {
if token_started {
tokens.push(std::mem::take(&mut current));
token_started = false;
}
}
c => {
current.push(c);
token_started = true;
}
}
}
if token_started {
tokens.push(current);
}
if tokens.is_empty() {
return (String::new(), Vec::new());
}
let program = tokens.drain(..1).next().unwrap_or_default();
(program, tokens)
}
}
#[cfg_attr(
not(target_arch = "wasm32"),
allow(dead_code) // exercised by tests; consumed by the wasm32 backend
)]
fn browser_message(title: Option<&str>, prompt: &str) -> String {
match title {
Some(title) => format!("{title}\n\n{prompt}"),
None => prompt.to_string(),
}
}
#[cfg_attr(
not(target_arch = "wasm32"),
allow(dead_code) // exercised by tests; consumed by the wasm32 backend
)]
fn browser_secondary_message(
title: Option<&str>,
prompt: &str,
second: &str,
third: &str,
) -> String {
format!(
"{}\n\n(OK = {second}, Cancel = {third})",
browser_message(title, prompt)
)
}
#[cfg(target_arch = "wasm32")]
mod wasm {
use wasm_bindgen::prelude::*;
use super::super::inputbox::InputBoxRequest;
use super::super::msgbox::{MsgBoxButton, MsgBoxRequest};
use super::{browser_message, browser_secondary_message};
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = window)]
fn alert(message: &str);
#[wasm_bindgen(js_namespace = window, js_name = confirm)]
fn window_confirm(message: &str) -> bool;
#[wasm_bindgen(js_namespace = window, js_name = prompt)]
fn window_prompt(message: &str, default_value: &str) -> Option<String>;
}
pub(super) fn display_dialog(request: &MsgBoxRequest) -> MsgBoxButton {
let title = request.title.as_deref();
let prompt = request.prompt.as_str();
let offered = request.offered_buttons();
match offered {
[only] => {
alert(&browser_message(title, prompt));
*only
}
[first, second] => {
if window_confirm(&browser_message(title, prompt)) {
*first
} else {
*second
}
}
[first, second, third] => {
if window_confirm(&browser_message(title, prompt)) {
*first
} else if window_confirm(&browser_secondary_message(
title,
prompt,
second.name(),
third.name(),
)) {
*second
} else {
*third
}
}
_ => request.default_button_value(),
}
}
pub(super) fn prompt_dialog(request: &InputBoxRequest) -> Option<String> {
let message = browser_message(request.title.as_deref(), &request.prompt);
window_prompt(&message, &request.default_response)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_args_skips_program_name() {
let backend = NativeBackend::new();
let _args = backend.command_args();
}
#[test]
fn do_events_returns_zero() {
let backend = NativeBackend::new();
assert_eq!(backend.do_events(), 0);
}
#[test]
fn fallback_answers_default_button() {
let request = MsgBoxRequest::parse("headless?", 4 + 32 + 256).unwrap();
assert_eq!(fallback(&request), MsgBoxButton::No);
}
#[test]
fn input_fallback_answers_default_response() {
let request = InputBoxRequest::new("headless?").with_default("42");
assert_eq!(input_fallback(&request), "42");
}
#[test]
fn no_such_window_is_error_5_describing_the_title() {
let err = no_such_window("Ghost Window");
assert_eq!(err.number, err_number::INVALID_PROCEDURE_CALL);
assert!(
err.description.contains("Ghost Window"),
"{}",
err.description
);
}
#[test]
fn app_activate_does_not_panic() {
let backend = NativeBackend::new();
let _ = backend.app_activate(&AppActivateRequest::new(
"definitely-not-a-real-window-title-42",
));
}
#[test]
fn send_keys_does_not_panic() {
let backend = NativeBackend::new();
backend
.send_keys(
&SendKeysRequest::parse("definitely-not-typed-anywhere-42{TAB}^c%{F4}", true)
.unwrap(),
)
.unwrap();
}
#[test]
#[cfg(unix)]
fn shell_spawns_a_program_and_reports_its_task_id() {
let task_id = launch(&ShellRequest::new("true")).unwrap();
assert!(task_id > 0.0);
}
#[test]
#[cfg(unix)]
fn shell_missing_program_is_file_not_found() {
let err = NativeBackend::new()
.shell(&ShellRequest::new("definitely-not-a-program-42"))
.unwrap_err();
assert_eq!(err.number, err_number::FILE_NOT_FOUND);
assert!(err.description.contains("definitely-not-a-program-42"));
}
#[test]
#[cfg(unix)]
fn command_line_splitting_honors_double_quotes() {
let (program, args) = posix::split_command_line(
r#""C:\Program Files\App.exe" /flag "my file.txt" trailing"#,
);
assert_eq!(program, r"C:\Program Files\App.exe");
assert_eq!(args, vec!["/flag", "my file.txt", "trailing"]);
}
#[test]
#[cfg(unix)]
fn empty_command_lines_split_to_an_empty_program() {
let (program, args) = posix::split_command_line(" ");
assert_eq!(program, "");
assert!(args.is_empty());
}
#[test]
fn browser_message_prepends_the_title() {
assert_eq!(browser_message(None, "hi"), "hi");
assert_eq!(browser_message(Some("App"), "hi"), "App\n\nhi");
}
#[test]
fn browser_secondary_message_labels_both_choices() {
let message = browser_secondary_message(Some("App"), "Overwrite?", "No", "Cancel");
assert!(message.contains("App\n\nOverwrite?"));
assert!(message.contains("(OK = No, Cancel = Cancel)"));
}
}