Ctrl K
ComunidadGuías…

native/src/autostart_control.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Native autostart control for the CLI manager.
//!
//! Windows uses the per-user Run key directly. Unix delegates normal maintenance
//! to the installed resident runtime. Uninstall has an exact Linux fallback for
//! partial installs; macOS requires the installed app bundle so SMAppService can
//! be unregistered before that bundle is removed.

use std::path::Path;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AutoStartStatus {
    Disabled,
    Enabled,
    #[cfg(not(target_os = "windows"))]
    RequiresApproval,
    Mismatched,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AutoStartCleanup {
    pub removed: bool,
    pub detail: String,
}

#[cfg(any(unix, test))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MissingRuntimeCleanupPolicy {
    DirectPlatformCleanup,
    RequireRuntimeForPlatformCleanup,
}

#[cfg(target_os = "windows")]
mod imp {
    use super::AutoStartStatus;
    use std::os::windows::ffi::OsStrExt;
    use std::path::Path;
    use windows::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SUCCESS, WIN32_ERROR};
    use windows::Win32::System::Registry::{
        HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_SZ, RegCloseKey, RegDeleteValueW,
        RegOpenKeyExW, RegQueryValueExW, RegSetValueExW,
    };
    use windows::core::{Error as WindowsError, HRESULT, PCWSTR};

    const RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run";
    const VALUE_NAME: &str = "Origin Speak";
    const LEGACY_VALUE_NAME: &str = "ListenOS";

    struct RegistryKey(HKEY);

    impl Drop for RegistryKey {
        fn drop(&mut self) {
            unsafe {
                let _ = RegCloseKey(self.0);
            }
        }
    }

    pub fn status(runtime: &Path) -> Result<AutoStartStatus, String> {
        let key = open_run_key(KEY_READ)?;
        let value_name = wide(VALUE_NAME);
        let mut value_type = REG_SZ;
        let mut byte_len = 0u32;
        let query = unsafe {
            RegQueryValueExW(
                key.0,
                PCWSTR(value_name.as_ptr()),
                None,
                Some(&mut value_type),
                None,
                Some(&mut byte_len),
            )
        };
        if query == ERROR_FILE_NOT_FOUND {
            return Ok(AutoStartStatus::Disabled);
        }
        check(query, "query Origin Speak login registration")?;
        if value_type != REG_SZ || byte_len < 2 {
            return Ok(AutoStartStatus::Mismatched);
        }

        let mut bytes = vec![0_u8; byte_len as usize];
        let read = unsafe {
            RegQueryValueExW(
                key.0,
                PCWSTR(value_name.as_ptr()),
                None,
                Some(&mut value_type),
                Some(bytes.as_mut_ptr()),
                Some(&mut byte_len),
            )
        };
        check(read, "read Origin Speak login registration")?;
        let units = bytes[..byte_len as usize]
            .chunks_exact(2)
            .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
            .take_while(|unit| *unit != 0)
            .collect::<Vec<_>>();
        let current = String::from_utf16(&units)
            .map_err(|error| format!("decode Origin Speak login registration: {error}"))?;
        let expected = command_for_runtime(runtime)?;
        Ok(if current == expected {
            AutoStartStatus::Enabled
        } else {
            AutoStartStatus::Mismatched
        })
    }

    pub fn set(runtime: &Path, enabled: bool) -> Result<AutoStartStatus, String> {
        let key = open_run_key(KEY_WRITE)?;
        let value_name = wide(VALUE_NAME);
        if enabled {
            let command = command_for_runtime(runtime)?;
            let wide_command = wide(&command);
            let bytes = wide_command
                .iter()
                .flat_map(|unit| unit.to_le_bytes())
                .collect::<Vec<_>>();
            let status = unsafe {
                RegSetValueExW(
                    key.0,
                    PCWSTR(value_name.as_ptr()),
                    None,
                    REG_SZ,
                    Some(&bytes),
                )
            };
            check(status, "register Origin Speak runtime to launch at login")?;
        } else {
            let status = unsafe { RegDeleteValueW(key.0, PCWSTR(value_name.as_ptr())) };
            if status != ERROR_FILE_NOT_FOUND {
                check(status, "remove Origin Speak login registration")?;
            }
        }
        status(runtime)
    }

    fn command_for_runtime(runtime: &Path) -> Result<String, String> {
        let runtime = runtime
            .to_str()
            .ok_or_else(|| "Origin Speak runtime path is not valid Unicode".to_string())?;
        if runtime.contains('"') {
            return Err(
                "Origin Speak runtime path contains an unsupported quote character".to_string(),
            );
        }
        Ok(format!("\"{runtime}\""))
    }

    fn open_run_key(
        access: windows::Win32::System::Registry::REG_SAM_FLAGS,
    ) -> Result<RegistryKey, String> {
        let subkey = wide(RUN_KEY);
        let mut key = HKEY::default();
        let status = unsafe {
            RegOpenKeyExW(
                HKEY_CURRENT_USER,
                PCWSTR(subkey.as_ptr()),
                None,
                access,
                &mut key,
            )
        };
        check(status, "open current-user login registry key")?;
        Ok(RegistryKey(key))
    }

    fn wide(value: &str) -> Vec<u16> {
        std::ffi::OsStr::new(value)
            .encode_wide()
            .chain(std::iter::once(0))
            .collect()
    }

    fn check(status: WIN32_ERROR, operation: &str) -> Result<(), String> {
        if status == ERROR_SUCCESS {
            return Ok(());
        }
        let error = WindowsError::from_hresult(HRESULT::from_win32(status.0));
        Err(format!("{operation}: {error}"))
    }

    pub fn cleanup_legacy_registration() -> Result<bool, String> {
        let key = open_run_key(KEY_WRITE)?;
        let value_name = wide(LEGACY_VALUE_NAME);
        let status = unsafe { RegDeleteValueW(key.0, PCWSTR(value_name.as_ptr())) };
        if status == ERROR_FILE_NOT_FOUND {
            return Ok(false);
        }
        check(status, "remove legacy ListenOS login registration")?;
        Ok(true)
    }
}

#[cfg(unix)]
mod imp {
    use super::AutoStartStatus;
    use std::path::Path;
    use std::process::{Command, Stdio};

    pub fn status(runtime: &Path) -> Result<AutoStartStatus, String> {
        maintenance(runtime, "--maintenance-autostart-status")
    }

    pub fn set(runtime: &Path, enabled: bool) -> Result<AutoStartStatus, String> {
        maintenance(
            runtime,
            if enabled {
                "--maintenance-autostart-enable"
            } else {
                "--maintenance-autostart-disable"
            },
        )
    }

    fn maintenance(runtime: &Path, argument: &str) -> Result<AutoStartStatus, String> {
        if !runtime.is_file() {
            return Err(format!(
                "Origin Speak runtime is not installed at {}",
                runtime.display()
            ));
        }
        #[cfg(target_os = "macos")]
        crate::macos_bundle::verify_runtime_executable(runtime)?;
        let status = Command::new(runtime)
            .arg(argument)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map_err(|error| format!("run Origin Speak autostart maintenance: {error}"))?;
        match status.code() {
            Some(0) => Ok(AutoStartStatus::Enabled),
            Some(10) => Ok(AutoStartStatus::Disabled),
            Some(11) => Ok(AutoStartStatus::RequiresApproval),
            Some(12) => Ok(AutoStartStatus::Mismatched),
            Some(code) => Err(format!(
                "Origin Speak autostart maintenance failed with exit code {code}"
            )),
            None => Err("Origin Speak autostart maintenance terminated unexpectedly".to_string()),
        }
    }
}

#[cfg(not(any(target_os = "windows", unix)))]
mod imp {
    use super::AutoStartStatus;
    use std::path::Path;

    pub fn status(_: &Path) -> Result<AutoStartStatus, String> {
        Err("CLI-managed native autostart is unsupported on this platform".to_string())
    }

    pub fn set(_: &Path, _: bool) -> Result<AutoStartStatus, String> {
        Err("CLI-managed native autostart is unsupported on this platform".to_string())
    }
}

pub fn status(runtime: &Path) -> Result<AutoStartStatus, String> {
    imp::status(runtime)
}

pub fn set(runtime: &Path, enabled: bool) -> Result<AutoStartStatus, String> {
    imp::set(runtime, enabled)
}

#[cfg(target_os = "windows")]
pub fn cleanup_legacy_registration() -> Result<bool, String> {
    imp::cleanup_legacy_registration()
}

pub fn disable_for_uninstall(runtime: &Path) -> Result<AutoStartCleanup, String> {
    #[cfg(target_os = "windows")]
    {
        let previous = status(runtime).unwrap_or(AutoStartStatus::Mismatched);
        let _ = set(runtime, false)?;
        return Ok(AutoStartCleanup {
            removed: !matches!(previous, AutoStartStatus::Disabled),
            detail: "Windows login Run-key registration removed or already absent".to_string(),
        });
    }

    #[cfg(unix)]
    {
        if runtime.is_file() {
            match set(runtime, false) {
                Ok(_) => {
                    return Ok(AutoStartCleanup {
                        removed: true,
                        detail: "resident runtime autostart maintenance completed".to_string(),
                    });
                }
                Err(maintenance_error) => {
                    return cleanup_after_unix_maintenance_failure(maintenance_error);
                }
            }
        }

        return match missing_runtime_cleanup_policy(std::env::consts::OS) {
            MissingRuntimeCleanupPolicy::DirectPlatformCleanup => {
                #[cfg(target_os = "linux")]
                {
                    let removed = remove_linux_autostart_entry()?;
                    Ok(AutoStartCleanup {
                        removed,
                        detail: if removed {
                            "removed exact XDG Origin Speak autostart entry after missing runtime"
                                .to_string()
                        } else {
                            "runtime and XDG Origin Speak autostart entry were already absent"
                                .to_string()
                        },
                    })
                }
                #[cfg(not(target_os = "linux"))]
                {
                    Ok(AutoStartCleanup {
                        removed: false,
                        detail:
                            "runtime missing; no direct platform autostart entry remained to remove"
                                .to_string(),
                    })
                }
            }
            MissingRuntimeCleanupPolicy::RequireRuntimeForPlatformCleanup => Err(format!(
                "cannot unregister macOS SMAppService because the installed Origin Speak runtime bundle is missing at {}; uninstall stopped before app-owned files were removed. Run 'origin setup' to restore the signed bundle, then rerun uninstall so the login item can be unregistered",
                runtime.display()
            )),
        };
    }

    #[cfg(not(any(target_os = "windows", unix)))]
    {
        let _ = runtime;
        Ok(AutoStartCleanup {
            removed: false,
            detail: "autostart cleanup is unsupported on this platform; uninstall continued"
                .to_string(),
        })
    }
}

#[cfg(unix)]
fn cleanup_after_unix_maintenance_failure(
    maintenance_error: String,
) -> Result<AutoStartCleanup, String> {
    #[cfg(target_os = "linux")]
    {
        let removed = remove_linux_autostart_entry()?;
        return Ok(AutoStartCleanup {
            removed,
            detail: if removed {
                format!(
                    "runtime autostart maintenance failed ({maintenance_error}); removed the exact owned XDG autostart entry directly"
                )
            } else {
                format!(
                    "runtime autostart maintenance failed ({maintenance_error}); no owned XDG autostart entry remained"
                )
            },
        });
    }
    #[cfg(target_os = "macos")]
    {
        Err(format!(
            "macOS SMAppService unregister failed ({maintenance_error}); uninstall stopped and the installed Origin Speak.app bundle was kept so autostart cleanup can be retried"
        ))
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
    {
        Ok(AutoStartCleanup {
            removed: false,
            detail: format!(
                "runtime autostart maintenance failed ({maintenance_error}); uninstall continued"
            ),
        })
    }
}

#[cfg(any(unix, test))]
fn missing_runtime_cleanup_policy(os: &str) -> MissingRuntimeCleanupPolicy {
    if os == "linux" {
        MissingRuntimeCleanupPolicy::DirectPlatformCleanup
    } else {
        MissingRuntimeCleanupPolicy::RequireRuntimeForPlatformCleanup
    }
}

#[cfg(target_os = "linux")]
fn remove_linux_autostart_entry() -> Result<bool, String> {
    let config_home = match std::env::var_os("XDG_CONFIG_HOME") {
        Some(value) if Path::new(&value).is_absolute() => std::path::PathBuf::from(value),
        _ => {
            let home = std::env::var_os("HOME")
                .ok_or_else(|| "HOME is unavailable for XDG autostart cleanup".to_string())?;
            std::path::PathBuf::from(home).join(".config")
        }
    };
    let path = config_home.join("autostart").join("origin-speak.desktop");
    let metadata = match std::fs::symlink_metadata(&path) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
        Err(error) => {
            return Err(format!(
                "inspect Origin Speak XDG autostart entry {}: {error}",
                path.display()
            ));
        }
    };
    if metadata.is_dir() && !metadata.file_type().is_symlink() {
        return Err(format!(
            "refusing to remove non-file XDG autostart path: {}",
            path.display()
        ));
    }
    if metadata.file_type().is_symlink() {
        return Err(format!(
            "refusing to remove symlink at Origin Speak XDG autostart path: {}",
            path.display()
        ));
    }
    let contents = std::fs::read_to_string(&path)
        .map_err(|error| format!("read XDG autostart entry {}: {error}", path.display()))?;
    if !is_owned_linux_autostart_entry(&contents) {
        return Err(format!(
            "refusing to remove unrecognized XDG autostart entry at {}",
            path.display()
        ));
    }
    std::fs::remove_file(&path)
        .map_err(|error| format!("remove XDG autostart entry {}: {error}", path.display()))?;
    Ok(true)
}

#[cfg(any(target_os = "linux", test))]
fn is_owned_linux_autostart_entry(contents: &str) -> bool {
    contents.lines().any(|line| line == "Name=Origin Speak")
        && contents.lines().any(|line| line == "Type=Application")
        && contents
            .lines()
            .any(|line| line.starts_with("Exec=") && line.contains("origin-runtime"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_runtime_cleanup_requires_macos_bundle_for_smappservice_unregister() {
        assert_eq!(
            missing_runtime_cleanup_policy("macos"),
            MissingRuntimeCleanupPolicy::RequireRuntimeForPlatformCleanup
        );
        assert_eq!(
            missing_runtime_cleanup_policy("linux"),
            MissingRuntimeCleanupPolicy::DirectPlatformCleanup
        );
    }

    #[test]
    fn linux_direct_cleanup_requires_recognizable_owned_entry() {
        assert!(is_owned_linux_autostart_entry(
            "[Desktop Entry]\nType=Application\nName=Origin Speak\nExec=\"/home/me/.local/lib/origin-speak/origin-runtime\"\n"
        ));
        assert!(!is_owned_linux_autostart_entry(
            "[Desktop Entry]\nType=Application\nName=Origin Speak\nExec=\"/home/me/bin/something-else\"\n"
        ));
    }
}