crates/fig_desktop/src/install.rs (982 lines of code) (raw):

use std::sync::Arc; #[cfg(not(target_os = "linux"))] use fig_install::check_for_updates; use fig_integrations::Integration; use fig_integrations::ssh::SshIntegration; use fig_os_shim::Context; #[cfg(target_os = "macos")] use fig_util::directories::fig_data_dir; #[cfg(target_os = "macos")] use macos_utils::bundle::get_bundle_path_for_executable; use semver::Version; use tracing::{ error, info, }; #[allow(unused_imports)] use crate::utils::is_cargo_debug_build; const PREVIOUS_VERSION_KEY: &str = "desktop.versionAtPreviousLaunch"; #[cfg(target_os = "macos")] const MIGRATED_KEY: &str = "desktop.migratedFromFig"; #[cfg(target_os = "macos")] pub async fn migrate_data_dir() { // Migrate the user data dir if let (Ok(old), Ok(new)) = (fig_util::directories::old_fig_data_dir(), fig_data_dir()) { if !old.is_symlink() && old.is_dir() && !new.is_dir() { match tokio::fs::rename(&old, &new).await { Ok(()) => { if let Err(err) = symlink(&new, &old).await { error!(%err, "Failed to symlink old user data dir"); } }, Err(err) => { error!(%err, "Failed to migrate user data dir"); }, } } } } #[cfg(target_os = "macos")] fn run_input_method_migration() { use fig_integrations::input_method::InputMethod; use tokio::time::{ Duration, sleep, }; use tracing::warn; let input_method = InputMethod::default(); match input_method.target_bundle_path() { Ok(target_bundle_path) if target_bundle_path.exists() => { tokio::spawn(async move { input_method.terminate().ok(); if let Err(err) = input_method.migrate().await { warn!(%err, "Failed to migrate input method"); } sleep(Duration::from_secs(1)).await; input_method.launch(); }); }, Ok(_) => warn!("Input method bundle path does not exist"), Err(err) => warn!(%err, "Failed to get input method bundle path"), } } /// Run items at launch #[allow(unused_variables)] pub async fn run_install(ctx: Arc<Context>, ignore_immediate_update: bool) { #[cfg(target_os = "macos")] { initialize_fig_dir(&fig_os_shim::Env::new()).await.ok(); if fig_util::directories::home_dir() .map(|home| home.join("Library/Application Support/fig/credentials.json")) .is_ok_and(|path| path.exists()) && !fig_settings::state::get_bool_or(MIGRATED_KEY, false) { let set = fig_settings::state::set_value(MIGRATED_KEY, true); if set.is_ok() { fig_telemetry::send_fig_user_migrated().await; } } } #[cfg(target_os = "macos")] // Add any items that are only once per version if should_run_install_script() { run_input_method_migration(); } #[cfg(target_os = "linux")] run_linux_install( Arc::clone(&ctx), Arc::new(fig_settings::Settings::new()), Arc::new(fig_settings::State::new()), ) .await; if let Err(err) = set_previous_version(current_version()) { error!(%err, "Failed to set previous version"); } #[cfg(not(target_os = "linux"))] { // Update if there's a newer version if !ignore_immediate_update && !is_cargo_debug_build() { use std::time::Duration; use tokio::time::timeout; // Check for updates but timeout after 3 seconds to avoid making the user wait too long // todo: don't download the index file twice match timeout(Duration::from_secs(3), check_for_updates(true)).await { Ok(Ok(Some(_))) => { crate::update::check_for_update(true, true).await; }, Ok(Ok(None)) => error!("No update found"), Ok(Err(err)) => error!(%err, "Failed to check for updates"), Err(err) => error!(%err, "Update check timed out"), } } tokio::spawn(async { let seconds = fig_settings::settings::get_int_or("app.autoupdate.check-period", 60 * 60 * 3); if seconds < 0 { return; } let mut interval = tokio::time::interval(std::time::Duration::from_secs(seconds as u64)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); interval.tick().await; loop { interval.tick().await; // TODO: we need to determine if the dashboard is open here and pass that as the second bool crate::update::check_for_update(false, false).await; } }); // remove the updater if it exists #[cfg(target_os = "windows")] std::fs::remove_file(fig_util::directories::fig_dir().unwrap().join("fig_installer.exe")).ok(); } // install vscode integration #[cfg(target_os = "macos")] for variant in fig_integrations::vscode::variants_installed() { let integration = fig_integrations::vscode::VSCodeIntegration { variant }; if integration.is_installed().await.is_err() { info!( "Attempting to install vscode integration for variant {}", integration.variant.application_name ); if let Err(err) = integration.install().await { error!(%err, "Failed installing vscode integration for variant {}", integration.variant.application_name); } } } // install intellij integration #[cfg(target_os = "macos")] match fig_integrations::intellij::variants_installed().await { Ok(variants) => { for integration in variants { if integration.is_installed().await.is_err() { info!( "Attempting to install intellij integration for variant {}", integration.variant.application_name() ); if let Err(err) = integration.install().await { error!(%err, "Failed installing intellij integration for variant {}", integration.variant.application_name()); } } } }, Err(err) => error!(%err, "Failed getting installed intellij variants"), } // update ssh integration if let Ok(ssh_integration) = SshIntegration::new() { if let Err(err) = ssh_integration.reinstall().await { error!(%err, "Failed updating ssh integration"); } } } /// Symlink, and overwrite if it already exists and is invalid or not a symlink #[cfg(target_os = "macos")] async fn symlink(src: impl AsRef<std::path::Path>, dst: impl AsRef<std::path::Path>) -> Result<(), std::io::Error> { use std::io::ErrorKind; let src = src.as_ref(); let dst = dst.as_ref(); // Check if the link already exists match tokio::fs::symlink_metadata(dst).await { Ok(metadata) => { // If it's a symlink, check if it points to the right place if metadata.file_type().is_symlink() { if let Ok(read_link) = tokio::fs::read_link(dst).await { if read_link == src { return Ok(()); } } } // If it's not a symlink or it points to the wrong place, delete it tokio::fs::remove_file(dst).await?; }, Err(err) if err.kind() == ErrorKind::NotFound => {}, Err(err) => return Err(err), } // Create the symlink tokio::fs::symlink(src, dst).await } #[cfg(target_os = "macos")] pub async fn initialize_fig_dir(env: &fig_os_shim::Env) -> anyhow::Result<()> { use std::fs; use fig_integrations::shell::ShellExt; use fig_util::consts::{ APP_BUNDLE_ID, APP_PROCESS_NAME, CLI_BINARY_NAME, PTY_BINARY_NAME, }; use fig_util::directories::home_dir; use fig_util::launchd_plist::{ LaunchdPlist, create_launch_agent, }; use fig_util::{ OLD_CLI_BINARY_NAMES, OLD_PTY_BINARY_NAMES, Shell, }; use macos_utils::bundle::get_bundle_path; use tracing::warn; let local_bin = fig_util::directories::home_local_bin()?; if let Err(err) = fs::create_dir_all(&local_bin) { error!(%err, "Failed to create {local_bin:?}"); } // Install figterm to ~/.local/bin match get_bundle_path_for_executable(PTY_BINARY_NAME) { Some(pty_path) => { let link = local_bin.join(PTY_BINARY_NAME); if let Err(err) = symlink(&pty_path, link).await { error!(%err, "Failed to symlink for {PTY_BINARY_NAME}: {pty_path:?}"); } for old_pty_binary_name in OLD_PTY_BINARY_NAMES { let old_pty_binary_path = local_bin.join(old_pty_binary_name); if old_pty_binary_path.exists() { if let Err(err) = tokio::fs::remove_file(&old_pty_binary_path).await { warn!(%err, "Failed to remove {old_pty_binary_name}: {old_pty_binary_path:?}"); } } } for shell in Shell::all() { let pty_shell_cpy = local_bin.join(format!("{shell} ({PTY_BINARY_NAME})")); let pty_path = pty_path.clone(); tokio::spawn(async move { // Check version if copy already exists, this is because everytime a copy is made the first start is // kinda slow and we want to avoid that if pty_shell_cpy.exists() { let output = tokio::process::Command::new(&pty_shell_cpy) .arg("--version") .output() .await .ok(); let version = output .as_ref() .and_then(|output| std::str::from_utf8(&output.stdout).ok()) .map(|s| { match s.strip_prefix(PTY_BINARY_NAME) { Some(s) => s, None => s, } .trim() }); if version == Some(env!("CARGO_PKG_VERSION")) { return; } } if let Err(err) = tokio::fs::remove_file(&pty_shell_cpy).await { error!(%err, "Failed to remove {PTY_BINARY_NAME} shell {shell:?} copy"); } if let Err(err) = tokio::fs::copy(&pty_path, &pty_shell_cpy).await { error!(%err, "Failed to copy {PTY_BINARY_NAME} to {}", pty_shell_cpy.display()); } }); for old_pty_binary_name in OLD_PTY_BINARY_NAMES { // Remove legacy pty shell copies let old_pty_binary_path = local_bin.join(format!("{shell} ({old_pty_binary_name})")); if old_pty_binary_path.exists() { if let Err(err) = tokio::fs::remove_file(&old_pty_binary_path).await { warn!(%err, "Failed to remove legacy pty: {old_pty_binary_path:?}"); } } } } }, None => error!("Failed to find {PTY_BINARY_NAME} in bundle"), } // install the cli to ~/.local/bin match get_bundle_path_for_executable(CLI_BINARY_NAME) { Some(q_cli_path) => { let dest = local_bin.join(CLI_BINARY_NAME); if let Err(err) = symlink(&q_cli_path, dest).await { error!(%err, "Failed to symlink {CLI_BINARY_NAME}"); } for old_cli_binary_name in OLD_CLI_BINARY_NAMES { let old_cli_binary_path = local_bin.join(old_cli_binary_name); if old_cli_binary_path.is_symlink() { if let Err(err) = symlink(&q_cli_path, &old_cli_binary_path).await { warn!(%err, "Failed to symlink legacy CLI: {old_cli_binary_path:?}"); } } } }, None => error!("Failed to find {CLI_BINARY_NAME} in bundle"), } if let Some(bundle_path) = get_bundle_path() { let exe = bundle_path.join("Contents").join("MacOS").join(APP_PROCESS_NAME); let startup_launch_agent = LaunchdPlist::new("com.amazon.codewhisperer.launcher") .program_arguments([&exe.to_string_lossy(), "--is-startup", "--no-dashboard"]) .associated_bundle_identifiers([APP_BUNDLE_ID]) .run_at_load(true); create_launch_agent(&startup_launch_agent)?; let path = startup_launch_agent.get_file_path()?; std::process::Command::new("launchctl") .arg("load") .arg(&path) .status() .ok(); } if let Ok(home) = home_dir() { let iterm_integration_path = home .join("Library") .join("Application Support") .join("iTerm2") .join("Scripts") .join("AutoLaunch") .join("fig-iterm-integration.scpt"); if iterm_integration_path.exists() { std::fs::remove_file(&iterm_integration_path).ok(); } } // Init the shell directory std::fs::create_dir(fig_data_dir()?.join("shell")).ok(); for shell in fig_util::Shell::all().iter() { for script_integration in shell.get_script_integrations().unwrap_or_default() { if let Err(err) = script_integration.install().await { error!(%err, "Failed installing shell integration {}", script_integration.describe()); } } for shell_integration in shell.get_shell_integrations(env).unwrap_or_default() { if let Err(err) = shell_integration.migrate().await { error!(%err, "Failed installing shell integration {}", shell_integration.describe()); } } } Ok(()) } #[cfg(target_os = "linux")] async fn run_linux_install(ctx: Arc<Context>, settings: Arc<fig_settings::Settings>, state: Arc<fig_settings::State>) { use dbus::gnome_shell::ShellExtensions; use fig_settings::State; use fig_util::system_info::linux::get_display_server; // install binaries under home local bin if ctx.env().in_appimage() { let ctx_clone = Arc::clone(&ctx); tokio::spawn(async move { install_appimage_binaries(&ctx_clone) .await .map_err(|err| error!(?err, "Unable to install binaries under the local bin directory")) .ok(); }); } // Important we log an error if we cannot detect the display server in use. // If this isn't wayland or x11, the user will probably just see a blank screen. match get_display_server(&ctx) { Ok(_) => (), Err(fig_util::Error::UnknownDisplayServer(server)) => { error!( "Unknown value set for XDG_SESSION_TYPE: {}. This must be set to x11 or wayland.", server ); }, Err(err) => { error!( "Unknown error occurred when detecting the display server: {:?}. Is XDG_SESSION_TYPE set to x11 or wayland?", err ); }, } // GNOME Shell Extension { let ctx_clone = Arc::clone(&ctx); tokio::spawn(async move { let ctx = ctx_clone; let shell_extensions = ShellExtensions::new(Arc::downgrade(&ctx)); let state = State::new(); install_gnome_shell_extension(&ctx, &shell_extensions, &state) .await .map_err(|err| error!(?err, "Unable to install the GNOME Shell extension")) .ok(); }); } // Desktop entry { let ctx_clone = Arc::clone(&ctx); let settings_clone = Arc::clone(&settings); let state_clone = Arc::clone(&state); tokio::spawn(async move { install_desktop_entry(&ctx_clone, &state_clone) .await .map_err(|err| error!(?err, "Unable to install desktop entry")) .ok(); install_autostart_entry(&ctx_clone, &settings_clone, &state_clone) .await .map_err(|err| error!(?err, "Unable to install autostart entry")) .ok(); }); } // TODO: is this correct? // launch_ibus().await; } /// Installs the correct version of the Amazon Q for CLI GNOME Shell extension, if required. #[cfg(target_os = "linux")] async fn install_gnome_shell_extension<Ctx, ExtensionsCtx>( ctx: &Ctx, shell_extensions: &dbus::gnome_shell::ShellExtensions<ExtensionsCtx>, state: &fig_settings::State, ) -> anyhow::Result<()> where Ctx: fig_os_shim::ContextProvider, ExtensionsCtx: fig_os_shim::ContextProvider, { use dbus::gnome_shell::{ ExtensionInstallationStatus, get_extension_status, }; use fig_os_shim::FsProvider; use fig_util::directories::{ bundled_gnome_extension_version_path, bundled_gnome_extension_zip_path, }; use fig_util::system_info::linux::{ DisplayServer, get_display_server, }; use tracing::debug; let display_server = get_display_server(ctx)?; if display_server != DisplayServer::Wayland { debug!( "Detected non-Wayland display server: `{:?}`. Not installing the extension.", display_server ); return Ok(()); } if !state.get_bool_or("desktop.gnomeExtensionInstallationPermissionGranted", false) { debug!("Permission is not granted to install GNOME extension, doing nothing."); return Ok(()); } let fs = ctx.fs(); let extension_uuid = shell_extensions.extension_uuid().await?; let bundled_version: u32 = fs .read_to_string(bundled_gnome_extension_version_path(ctx, &extension_uuid)?) .await? .parse()?; let bundled_path = bundled_gnome_extension_zip_path(ctx, &extension_uuid)?; match get_extension_status(ctx, shell_extensions, Some(bundled_version)).await? { ExtensionInstallationStatus::GnomeShellNotRunning => { info!("GNOME Shell is not running, not installing the extension."); }, ExtensionInstallationStatus::NotInstalled => { info!("Extension {} not installed, installing now.", extension_uuid); shell_extensions.install_bundled_extension(bundled_path).await?; }, ExtensionInstallationStatus::Errored => { error!( "Extension {} is in an errored state. It must be manually uninstalled, and the current desktop session must be restarted.", extension_uuid ); }, ExtensionInstallationStatus::RequiresReboot => { info!( "Extension {} already installed but not loaded. User must reboot their machine.", extension_uuid ); }, ExtensionInstallationStatus::UnexpectedVersion { installed_version } => { info!( "Installed extension {} has version {} but the bundled extension has version {}. Installing now.", extension_uuid, installed_version, bundled_version ); shell_extensions.install_bundled_extension(bundled_path).await?; }, ExtensionInstallationStatus::NotEnabled => { info!( "Extension {} is installed but not enabled. Enabling now.", extension_uuid ); match shell_extensions.enable_extension().await { Ok(true) => { info!("Extension enabled."); }, Ok(false) => { error!("Something went wrong trying to enable the extension."); }, Err(err) => { error!("Error occurred enabling the extension: {:?}", err); }, } }, ExtensionInstallationStatus::Enabled => { info!("Extension {} is already installed and enabled.", extension_uuid); }, } Ok(()) } /// Installs the desktop entry if required. #[cfg(target_os = "linux")] async fn install_desktop_entry(ctx: &Context, state: &fig_settings::State) -> anyhow::Result<()> { use fig_integrations::desktop_entry::DesktopEntryIntegration; use fig_util::directories::{ appimage_desktop_entry_icon_path, appimage_desktop_entry_path, }; if !state.get_bool_or("appimage.manageDesktopEntry", false) { return Ok(()); } let exec_path = ctx.env().get("APPIMAGE")?; let entry_path = appimage_desktop_entry_path(ctx)?; let icon_path = appimage_desktop_entry_icon_path(ctx)?; DesktopEntryIntegration::new(ctx, Some(entry_path), Some(icon_path), Some(exec_path.into())) .install() .await?; Ok(()) } /// Installs the autostart entry if required. #[cfg(target_os = "linux")] async fn install_autostart_entry( ctx: &Context, settings: &fig_settings::Settings, state: &fig_settings::State, ) -> anyhow::Result<()> { use fig_integrations::desktop_entry::{ AutostartIntegration, should_install_autostart_entry, }; if !should_install_autostart_entry(ctx, settings, state) { return Ok(()); } AutostartIntegration::new(ctx)?.install().await?; Ok(()) } /// Installs the CLI and PTY under the user's local bin directory from the AppImage, if required. #[cfg(target_os = "linux")] async fn install_appimage_binaries(ctx: &Context) -> anyhow::Result<()> { use fig_util::consts::{ CLI_BINARY_NAME, PTY_BINARY_NAME, }; use fig_util::directories::home_local_bin_ctx; use tokio::process::Command; if !home_local_bin_ctx(ctx)?.exists() { ctx.fs().create_dir_all(home_local_bin_ctx(ctx)?).await?; } // Extract and install the CLI + PTY under home local bin, if required. for binary_name in &[CLI_BINARY_NAME, PTY_BINARY_NAME] { let local_binary_path = home_local_bin_ctx(ctx)?.join(binary_name); if local_binary_path.exists() { let output = Command::new(&local_binary_path).arg("--version").output().await.ok(); let installed_version = output .as_ref() .and_then(|output| std::str::from_utf8(&output.stdout).ok()) .map(parse_version); match installed_version { Some(installed_version) => { let app_version = env!("CARGO_PKG_VERSION"); if installed_version != app_version { info!( "Installed version {} for binary {} is different than application version {}", installed_version, local_binary_path.to_string_lossy(), app_version ); copy_binary_from_appimage_mount(ctx, binary_name, local_binary_path).await?; } }, None => error!( "Unable to parse the version of the binary at: {}", local_binary_path.to_string_lossy() ), } } else { copy_binary_from_appimage_mount(ctx, binary_name, local_binary_path).await?; } } Ok(()) } /// The AppImage is executed by mounting to a temporary directory and running the desktop binary. /// The current working directory of the desktop app essentially looks like this: /// - <tempdir>/bin/q /// - <tempdir>/bin/qterm /// /// Thus, we can access and copy the bundled binaries from the AppImage to the provided /// `destination`. #[cfg(target_os = "linux")] async fn copy_binary_from_appimage_mount( ctx: &Context, binary_name: &str, destination: impl AsRef<std::path::Path>, ) -> anyhow::Result<()> { use anyhow::Context; use tracing::debug; let cwd = ctx.env().current_dir()?; let binary_path = cwd.join(format!("bin/{binary_name}")); debug!( "Copying {} to {}", binary_path.to_string_lossy(), destination.as_ref().to_string_lossy() ); ctx.fs() .copy(&binary_path, destination) .await .context(format!("Unable to copy {binary_name}"))?; Ok(()) } /// Parses the semver portion of a string of the form: `"<binary-name> <semver>"`. #[cfg(target_os = "linux")] fn parse_version(output: &str) -> String { output .chars() .skip_while(|c| !c.is_ascii_digit()) .collect::<String>() .trim() .to_string() } #[cfg(target_os = "linux")] #[derive(Debug)] enum SystemdUserService { IBusGeneric, IBusGnome, } #[cfg(target_os = "linux")] impl SystemdUserService { fn service_name(&self) -> &'static str { match self { SystemdUserService::IBusGeneric => "org.freedesktop.IBus.session.generic.service", SystemdUserService::IBusGnome => "org.freedesktop.IBus.session.GNOME.service", } } } #[cfg(target_os = "linux")] impl std::fmt::Display for SystemdUserService { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.service_name()) } } #[cfg(target_os = "linux")] async fn launch_systemd_user_service(service: SystemdUserService) -> anyhow::Result<()> { use tokio::process::Command; let output = Command::new("systemctl") .args(["--user", "restart", service.service_name()]) .output() .await?; if !output.status.success() { anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)) } Ok(()) } #[cfg(target_os = "linux")] #[allow(dead_code)] async fn launch_ibus(ctx: &Context) { use std::ffi::OsString; use sysinfo::{ ProcessRefreshKind, RefreshKind, System, }; use tokio::process::Command; let system = tokio::task::block_in_place(|| { System::new_with_specifics(RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing())) }); let ibus_daemon = OsString::from("ibus-daemon"); if system.processes_by_name(&ibus_daemon).next().is_none() { info!("Launching ibus via systemd"); match Command::new("systemctl") .args(["--user", "is-active", "gnome-session-initialized.target"]) .output() .await { Ok(gnome_session_output) => match std::str::from_utf8(&gnome_session_output.stdout).map(|s| s.trim()) { Ok("active") => match launch_systemd_user_service(SystemdUserService::IBusGnome).await { Ok(_) => info!("Launched '{}", SystemdUserService::IBusGnome), Err(err) => error!(%err, "Failed to launch '{}'", SystemdUserService::IBusGnome), }, Ok("inactive") => match launch_systemd_user_service(SystemdUserService::IBusGeneric).await { Ok(_) => info!("Launched '{}'", SystemdUserService::IBusGeneric), Err(err) => error!(%err, "Failed to launch '{}'", SystemdUserService::IBusGeneric), }, result => error!( ?result, "Failed to determine if gnome-session-initialized.target is running" ), }, Err(err) => error!(%err, "Failed to run 'systemctl --user is-active gnome-session-initialized.target'"), } } // Wait up to 2 sec for ibus activation for _ in 0..10 { if dbus::ibus::connect_to_ibus_daemon(ctx).await.is_ok() { return; } tokio::time::sleep(std::time::Duration::from_millis(200)).await; } error!("Timed out after 2 sec waiting for ibus activation"); } #[cfg(target_os = "macos")] fn should_run_install_script() -> bool { let current_version = current_version(); let previous_version = match previous_version() { Some(previous_version) => previous_version, None => return true, }; !is_cargo_debug_build() && current_version > previous_version } /// The current version of the desktop app fn current_version() -> Version { Version::parse(env!("CARGO_PKG_VERSION")).unwrap() } /// The previous version of the desktop app stored in local state #[cfg(target_os = "macos")] fn previous_version() -> Option<Version> { fig_settings::state::get_string(PREVIOUS_VERSION_KEY) .ok() .flatten() .and_then(|ref v| Version::parse(v).ok()) } fn set_previous_version(version: Version) -> anyhow::Result<()> { fig_settings::state::set_value(PREVIOUS_VERSION_KEY, version.to_string())?; Ok(()) } #[cfg(test)] mod test { use super::*; #[test] fn test_current_version() { current_version(); } #[cfg(target_os = "macos")] #[tokio::test] async fn test_symlink() { use tempfile::tempdir; let tmp_dir = tempdir().unwrap(); let tmp_dir = tmp_dir.path(); // folders let src_dir_1 = tmp_dir.join("dir_1"); let src_dir_2 = tmp_dir.join("dir_2"); let dst_dir = tmp_dir.join("dst"); std::fs::create_dir_all(&src_dir_1).unwrap(); std::fs::create_dir_all(&src_dir_2).unwrap(); // Check that a new symlink is created assert!(!dst_dir.exists()); symlink(&src_dir_1, &dst_dir).await.unwrap(); assert!(dst_dir.exists()); assert_eq!(dst_dir.read_link().unwrap(), src_dir_1); // Check that the symlink is updated symlink(&src_dir_2, &dst_dir).await.unwrap(); assert!(dst_dir.exists()); assert_eq!(dst_dir.read_link().unwrap(), src_dir_2); // files let src_file_1 = src_dir_1.join("file_1"); let src_file_2 = src_dir_2.join("file_2"); let dst_file = dst_dir.join("file"); std::fs::write(&src_file_1, "content 1").unwrap(); std::fs::write(&src_file_2, "content 2").unwrap(); // Check that a new symlink is created assert!(!dst_file.exists()); symlink(&src_file_1, &dst_file).await.unwrap(); assert!(dst_file.exists()); assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "content 1"); // Check that the symlink is updated symlink(&src_file_2, &dst_file).await.unwrap(); assert!(dst_file.exists()); assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "content 2"); } #[cfg(target_os = "linux")] mod linux_appimage_tests { use std::fs::Permissions; use std::os::unix::fs::PermissionsExt; use std::path::Path; use fig_util::directories::home_local_bin_ctx; use fig_util::{ CLI_BINARY_NAME, PTY_BINARY_NAME, }; use tokio::process::Command; use super::*; /// Writes a test script for the CLI/PTY binaries to `directory` that prints /// `"<binaryname> version"`. async fn write_test_binaries(ctx: &Context, version: &str, destination: impl AsRef<Path>) { let fs = ctx.fs(); if !fs.exists(&destination) { fs.create_dir_all(&destination).await.unwrap(); } for binary_name in &[CLI_BINARY_NAME, PTY_BINARY_NAME] { let path = destination.as_ref().join(binary_name); fs.write( &path, format!( r#"#!/usr/bin/env sh echo "{binary_name} {version}" "# ), ) .await .unwrap(); fs.set_permissions(&path, Permissions::from_mode(0o700)).await.unwrap(); } } async fn assert_binaries_installed(ctx: &Context, expected_version: &str) { for binary_name in &[CLI_BINARY_NAME, PTY_BINARY_NAME] { let binary_path = home_local_bin_ctx(ctx).unwrap().join(binary_name); let stdout = Command::new(ctx.fs().chroot_path(binary_path)) .output() .await .unwrap() .stdout; let stdout = std::str::from_utf8(&stdout).unwrap(); assert!(ctx.fs().exists(home_local_bin_ctx(ctx).unwrap().join(binary_name))); assert_eq!(parse_version(stdout), expected_version); } } #[test] fn test_linux_parse_version() { assert_eq!(parse_version("cli 1.2.3"), "1.2.3"); } static INSTALL_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); #[tokio::test] async fn test_linux_appimage_install_on_fresh_system() { let _lock = INSTALL_TEST_LOCK.lock().await; tracing_subscriber::fmt::try_init().ok(); // Given let ctx = Context::builder().with_test_home().await.unwrap().build(); let current_version = current_version().to_string(); write_test_binaries(&ctx, &current_version, "/bin").await; // When install_appimage_binaries(&ctx).await.unwrap(); // Then assert_binaries_installed(&ctx, &current_version).await; } #[tokio::test] async fn test_linux_appimage_install_when_installed_binaries_have_old_version() { let _lock = INSTALL_TEST_LOCK.lock().await; tracing_subscriber::fmt::try_init().ok(); // Given let ctx = Context::builder().with_test_home().await.unwrap().build(); let current_version = current_version().to_string(); let old_version = "0.0.1"; write_test_binaries(&ctx, &current_version, "/bin").await; write_test_binaries(&ctx, old_version, home_local_bin_ctx(&ctx).unwrap()).await; // When install_appimage_binaries(&ctx).await.unwrap(); // Then assert_binaries_installed(&ctx, &current_version).await; } } #[cfg(target_os = "linux")] mod linux_gnome_shell_extension_tests { use dbus::gnome_shell::{ ExtensionInstallationStatus, GNOME_SHELL_PROCESS_NAME, ShellExtensions, get_extension_status, }; use fig_os_shim::Os; use fig_settings::State; use fig_util::directories::{ bundled_gnome_extension_version_path, bundled_gnome_extension_zip_path, }; use super::*; /// Helper function that writes test files to [bundled_gnome_extension_zip_path] and /// [bundled_extension_version_path]. async fn write_extension_bundle(ctx: &Context, uuid: &str, version: u32) { let zip_path = bundled_gnome_extension_zip_path(ctx, uuid).unwrap(); let version_path = bundled_gnome_extension_version_path(ctx, uuid).unwrap(); ctx.fs().create_dir_all(zip_path.parent().unwrap()).await.unwrap(); ctx.fs().write(&zip_path, version.to_string()).await.unwrap(); ctx.fs().write(&version_path, version.to_string()).await.unwrap(); } #[tokio::test] async fn test_extension_is_installed_for_new_user() { let ctx = Context::builder() .with_test_home() .await .unwrap() .with_env_var("APPIMAGE", "1") .with_env_var("XDG_SESSION_TYPE", "wayland") .with_os(Os::Linux) .with_running_processes(&[GNOME_SHELL_PROCESS_NAME]) .build_fake(); let shell_extensions = ShellExtensions::new_fake(Arc::downgrade(&ctx)); let extension_version = 1; write_extension_bundle( &ctx, &shell_extensions.extension_uuid().await.unwrap(), extension_version, ) .await; let state = State::from_slice(&[("desktop.gnomeExtensionInstallationPermissionGranted", true.into())]); // When install_gnome_shell_extension(&ctx, &shell_extensions, &state) .await .unwrap(); // Then let status = get_extension_status(&ctx, &shell_extensions, Some(extension_version)) .await .unwrap(); assert!(matches!(status, ExtensionInstallationStatus::RequiresReboot)); } #[tokio::test] async fn test_extension_not_installed_if_permission_not_granted() { let ctx = Context::builder() .with_test_home() .await .unwrap() .with_env_var("APPIMAGE", "1") .with_os(Os::Linux) .with_running_processes(&[GNOME_SHELL_PROCESS_NAME]) .build_fake(); let shell_extensions = ShellExtensions::new_fake(Arc::downgrade(&ctx)); let extension_version = 1; write_extension_bundle( &ctx, &shell_extensions.extension_uuid().await.unwrap(), extension_version, ) .await; let state = State::new_fake(); // When install_gnome_shell_extension(&ctx, &shell_extensions, &state) .await .unwrap(); // Then let status = get_extension_status(&ctx, &shell_extensions, Some(extension_version)) .await .unwrap(); assert!(matches!(status, ExtensionInstallationStatus::NotInstalled)); } #[tokio::test] async fn test_extension_not_installed_if_not_wayland() { let ctx = Context::builder() .with_test_home() .await .unwrap() .with_env_var("APPIMAGE", "1") .with_os(Os::Linux) .with_running_processes(&[GNOME_SHELL_PROCESS_NAME]) .build_fake(); let shell_extensions = ShellExtensions::new_fake(Arc::downgrade(&ctx)); let extension_version = 1; write_extension_bundle( &ctx, &shell_extensions.extension_uuid().await.unwrap(), extension_version, ) .await; let state = State::from_slice(&[("desktop.gnomeExtensionInstallationPermissionGranted", true.into())]); // When install_gnome_shell_extension(&ctx, &shell_extensions, &state) .await .unwrap(); // Then let status = get_extension_status(&ctx, &shell_extensions, Some(extension_version)) .await .unwrap(); assert!(matches!(status, ExtensionInstallationStatus::NotInstalled)); } } #[cfg(target_os = "linux")] mod linux_desktop_entry_tests { use fig_integrations::desktop_entry::{ AutostartIntegration, local_entry_path, local_icon_path, }; use fig_settings::{ Settings, State, }; use fig_util::directories::{ appimage_desktop_entry_icon_path, appimage_desktop_entry_path, }; use super::*; #[tokio::test] async fn test_desktop_entry_is_installed() { let ctx = Context::builder() .with_test_home() .await .unwrap() .with_env_var("APPIMAGE", "/test.appimage") .build_fake(); let fs = ctx.fs(); let entry_path = appimage_desktop_entry_path(&ctx).unwrap(); let icon_path = appimage_desktop_entry_icon_path(&ctx).unwrap(); fs.create_dir_all(&entry_path.parent().unwrap()).await.unwrap(); fs.write(&entry_path, "[Desktop Entry]\nExec=q-desktop").await.unwrap(); fs.create_dir_all(icon_path.parent().unwrap()).await.unwrap(); fs.write(&icon_path, "image").await.unwrap(); let state = State::from_slice(&[("appimage.manageDesktopEntry", true.into())]); // When install_desktop_entry(&ctx, &state).await.unwrap(); // Then assert!(fs.exists(local_entry_path(&ctx).unwrap())); assert!(fs.exists(local_icon_path(&ctx).unwrap())); } #[tokio::test] async fn test_desktop_entry_not_installed_if_not_managed() { let ctx = Context::builder() .with_test_home() .await .unwrap() .with_env_var("APPIMAGE", "/test.appimage") .build_fake(); let fs = ctx.fs(); let entry_path = appimage_desktop_entry_path(&ctx).unwrap(); let icon_path = appimage_desktop_entry_icon_path(&ctx).unwrap(); fs.create_dir_all(&entry_path.parent().unwrap()).await.unwrap(); fs.write(&entry_path, "[Desktop Entry]\nExec=q-desktop").await.unwrap(); fs.create_dir_all(icon_path.parent().unwrap()).await.unwrap(); fs.write(&icon_path, "image").await.unwrap(); let state = State::new_fake(); // When install_desktop_entry(&ctx, &state).await.unwrap(); // Then assert!(!fs.exists(local_entry_path(&ctx).unwrap())); assert!(!fs.exists(local_icon_path(&ctx).unwrap())); } #[tokio::test] async fn test_autostart_entry_installed_locally_for_appimage() { let ctx = Context::builder() .with_test_home() .await .unwrap() .with_env_var("APPIMAGE", "/test.appimage") .build_fake(); let fs = ctx.fs(); fs.create_dir_all(local_entry_path(&ctx).unwrap().parent().unwrap()) .await .unwrap(); fs.write(local_entry_path(&ctx).unwrap(), "[Desktop Entry]") .await .unwrap(); // When install_autostart_entry( &ctx, &Settings::new_fake(), &State::from_slice(&[("appimage.manageDesktopEntry", true.into())]), ) .await .unwrap(); // Then assert!( AutostartIntegration::to_local(&ctx) .unwrap() .is_installed() .await .is_ok() ); } } }