diff --git a/Cargo.toml b/Cargo.toml index b49b93c9e..b8bb3b93c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ trybuild = { version = "1.0.101" } typed-path = { version = "0.10.0" } url = { version = "2.5.4" } uuid = { version = "1.11.0", default-features = false } +unicode-normalization = { version = "0.1.24" } walkdir = "2.5.0" windows-sys = { version = "0.59.0", default-features = false } zip = { version = "2.2.2", default-features = false } diff --git a/crates/rattler/Cargo.toml b/crates/rattler/Cargo.toml index 802814dcc..a83b3bd1c 100644 --- a/crates/rattler/Cargo.toml +++ b/crates/rattler/Cargo.toml @@ -32,6 +32,7 @@ memchr = { workspace = true } memmap2 = { workspace = true } once_cell = { workspace = true } parking_lot = { workspace = true } +rattler_menuinst = { path = "../rattler_menuinst", version = "0.1.0", default-features = false } rattler_cache = { path = "../rattler_cache", version = "0.3.0", default-features = false } rattler_conda_types = { path = "../rattler_conda_types", version = "0.29.6", default-features = false } rattler_digest = { path = "../rattler_digest", version = "1.0.4", default-features = false } diff --git a/crates/rattler/src/install/driver.rs b/crates/rattler/src/install/driver.rs index 73c7454a3..698f9e82d 100644 --- a/crates/rattler/src/install/driver.rs +++ b/crates/rattler/src/install/driver.rs @@ -1,13 +1,14 @@ use std::{ borrow::Borrow, collections::{HashMap, HashSet}, + ffi::OsStr, path::{Path, PathBuf}, sync::{Arc, Mutex, MutexGuard}, }; use indexmap::IndexSet; use itertools::Itertools; -use rattler_conda_types::{prefix_record::PathType, PackageRecord, PrefixRecord}; +use rattler_conda_types::{prefix_record::PathType, PackageRecord, Platform, PrefixRecord}; use simple_spawn_blocking::{tokio::run_blocking_task, Cancelled}; use thiserror::Error; use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore}; @@ -155,10 +156,11 @@ impl InstallDriver { transaction: &Transaction, target_prefix: &Path, ) -> Result, PrePostLinkError> { + let mut result = None; if self.execute_link_scripts { match self.run_pre_unlink_scripts(transaction, target_prefix) { Ok(res) => { - return Ok(Some(res)); + result = Some(res); } Err(e) => { tracing::error!("Error running pre-unlink scripts: {:?}", e); @@ -166,7 +168,29 @@ impl InstallDriver { } } - Ok(None) + // For all packages that are removed, we need to remove menuinst entries as well + for record in transaction.removed_packages().map(Borrow::borrow) { + for path in record.paths_data.paths.iter() { + if path.relative_path.starts_with("Menu") + && path.relative_path.extension() == Some(OsStr::new("json")) + { + match rattler_menuinst::remove_menu_items( + &target_prefix.join(&path.relative_path), + target_prefix, + target_prefix, + Platform::current(), + rattler_menuinst::MenuMode::User, + ) { + Ok(_) => {} + Err(e) => { + tracing::warn!("Failed to remove menu item: {}", e); + } + } + } + } + } + + Ok(result) } /// Runs a blocking task that will execute on a separate thread. The task is @@ -222,6 +246,25 @@ impl InstallDriver { None }; + // find all files in `$PREFIX/Menu/*.json` and install them with `menuinst` + if let Ok(read_dir) = target_prefix.join("Menu").read_dir() { + for file in read_dir.flatten() { + let file = file.path(); + if file.is_file() && file.extension().map_or(false, |ext| ext == "json") { + rattler_menuinst::install_menuitems( + &file, + target_prefix, + target_prefix, + Platform::current(), + rattler_menuinst::MenuMode::User, + ) + .unwrap_or_else(|e| { + tracing::warn!("Failed to install menu item: {} (ignored)", e); + }); + } + } + } + Ok(PostProcessResult { post_link_result, clobbered_paths, diff --git a/crates/rattler_conda_types/src/match_spec/mod.rs b/crates/rattler_conda_types/src/match_spec/mod.rs index c50b9d8be..f929e768c 100644 --- a/crates/rattler_conda_types/src/match_spec/mod.rs +++ b/crates/rattler_conda_types/src/match_spec/mod.rs @@ -716,4 +716,9 @@ mod tests { .collect::>() .join("\n")); } + + #[test] + fn test_size_stays_the_same() { + assert_eq!(std::mem::size_of::(), 464); + } } diff --git a/crates/rattler_menuinst/Cargo.toml b/crates/rattler_menuinst/Cargo.toml new file mode 100644 index 000000000..68d4728d3 --- /dev/null +++ b/crates/rattler_menuinst/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "rattler_menuinst" +version = "0.1.0" +edition.workspace = true +authors = ["Wolf Vollprecht "] +description = "Install menu entries for a Conda package" +categories.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true + +[dependencies] +plist = { workspace = true } +dirs = { workspace = true } +serde = { workspace = true, features = ["derive"] } +shlex = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +rattler_conda_types = { path = "../rattler_conda_types", default-features = false } +rattler_shell = { path = "../rattler_shell", default-features = false } +thiserror = { workspace = true } +unicode-normalization = { workspace = true } +regex = { workspace = true } +tempfile = { workspace = true } +fs-err = { workspace = true } +which = "7.0.0" +known-folders = "1.2.0" +quick-xml = "0.37.1" +chrono = { workspace = true, features = ["clock"] } +configparser = { version = "3.1.0", features = ["indexmap"] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58", features = [ + "Win32_System_Com", + "Win32_UI_Shell", + "Win32_System_Com_StructuredStorage", + "Win32_UI_Shell_PropertiesSystem", + "Win32_Storage_EnhancedStorage", + "Win32_Foundation", + "implement" +]} +winreg = "0.52.0" + +[dev-dependencies] +insta = { workspace = true } diff --git a/crates/rattler_menuinst/data/appkit_launcher_arm64 b/crates/rattler_menuinst/data/appkit_launcher_arm64 new file mode 100755 index 000000000..80afa4b37 Binary files /dev/null and b/crates/rattler_menuinst/data/appkit_launcher_arm64 differ diff --git a/crates/rattler_menuinst/data/appkit_launcher_x86_64 b/crates/rattler_menuinst/data/appkit_launcher_x86_64 new file mode 100755 index 000000000..90ea56d55 Binary files /dev/null and b/crates/rattler_menuinst/data/appkit_launcher_x86_64 differ diff --git a/crates/rattler_menuinst/data/menuinst.default.json b/crates/rattler_menuinst/data/menuinst.default.json new file mode 100644 index 000000000..c9acad433 --- /dev/null +++ b/crates/rattler_menuinst/data/menuinst.default.json @@ -0,0 +1,68 @@ +{ + "id_": "https://schemas.conda.io/menuinst-1.schema.json", + "schema_": "https://json-schema.org/draft-07/schema", + "menu_name": "REQUIRED", + "menu_items": [ + { + "name": "REQUIRED", + "description": "REQUIRED", + "command": [ + "REQUIRED" + ], + "icon": null, + "precommand": null, + "precreate": null, + "working_dir": null, + "activate": true, + "terminal": false, + "platforms": { + "linux": { + "Categories": null, + "DBusActivatable": null, + "GenericName": null, + "Hidden": null, + "Implements": null, + "Keywords": null, + "MimeType": null, + "NoDisplay": null, + "NotShowIn": null, + "OnlyShowIn": null, + "PrefersNonDefaultGPU": null, + "StartupNotify": null, + "StartupWMClass": null, + "TryExec": null, + "glob_patterns": null + }, + "osx": { + "CFBundleDisplayName": null, + "CFBundleIdentifier": null, + "CFBundleName": null, + "CFBundleSpokenName": null, + "CFBundleVersion": null, + "CFBundleURLTypes": null, + "CFBundleDocumentTypes": null, + "LSApplicationCategoryType": null, + "LSBackgroundOnly": null, + "LSEnvironment": null, + "LSMinimumSystemVersion": null, + "LSMultipleInstancesProhibited": null, + "LSRequiresNativeExecution": null, + "NSSupportsAutomaticGraphicsSwitching": null, + "UTExportedTypeDeclarations": null, + "UTImportedTypeDeclarations": null, + "entitlements": null, + "link_in_bundle": null, + "event_handler": null + }, + "win": { + "desktop": true, + "quicklaunch": true, + "terminal_profile": null, + "url_protocols": null, + "file_extensions": null, + "app_user_model_id": null + } + } + } + ] +} diff --git a/crates/rattler_menuinst/data/menuinst.schema.json b/crates/rattler_menuinst/data/menuinst.schema.json new file mode 100644 index 000000000..3ce530521 --- /dev/null +++ b/crates/rattler_menuinst/data/menuinst.schema.json @@ -0,0 +1,731 @@ +{ + "title": "MenuInstSchema", + "description": "Metadata required to create menu items across operating systems with ``menuinst``.", + "type": "object", + "properties": { + "$id": { + "title": "$Id", + "description": "Version of the menuinst schema.", + "enum": [ + "https://schemas.conda.io/menuinst-1.schema.json" + ], + "type": "string" + }, + "$schema": { + "title": "$Schema", + "description": "Standard of the JSON schema we adhere to.", + "enum": [ + "https://json-schema.org/draft-07/schema" + ], + "type": "string" + }, + "menu_name": { + "title": "Menu Name", + "minLength": 1, + "type": "string" + }, + "menu_items": { + "title": "Menu Items", + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/definitions/MenuItem" + } + } + }, + "required": [ + "$id", + "$schema", + "menu_name", + "menu_items" + ], + "additionalProperties": false, + "definitions": { + "MenuItemNameDict": { + "title": "MenuItemNameDict", + "description": "Variable menu item name.\nUse this dictionary if the menu item name depends on installation parameters\nsuch as the target environment.", + "type": "object", + "properties": { + "target_environment_is_base": { + "title": "Target Environment Is Base", + "minLength": 1, + "type": "string" + }, + "target_environment_is_not_base": { + "title": "Target Environment Is Not Base", + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "Linux": { + "title": "Linux", + "description": "Linux-specific instructions.\n\nCheck the `Desktop entry specification\n`__\nfor more details.", + "type": "object", + "properties": { + "name": { + "title": "Name", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/definitions/MenuItemNameDict" + } + ] + }, + "description": { + "title": "Description", + "type": "string" + }, + "icon": { + "title": "Icon", + "minLength": 1, + "type": "string" + }, + "command": { + "title": "Command", + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "working_dir": { + "title": "Working Dir", + "minLength": 1, + "type": "string" + }, + "precommand": { + "title": "Precommand", + "minLength": 1, + "type": "string" + }, + "precreate": { + "title": "Precreate", + "minLength": 1, + "type": "string" + }, + "activate": { + "title": "Activate", + "type": "boolean" + }, + "terminal": { + "title": "Terminal", + "type": "boolean" + }, + "Categories": { + "title": "Categories", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string", + "pattern": "^.+;$" + } + ] + }, + "DBusActivatable": { + "title": "Dbusactivatable", + "type": "boolean" + }, + "GenericName": { + "title": "Genericname", + "type": "string" + }, + "Hidden": { + "title": "Hidden", + "type": "boolean" + }, + "Implements": { + "title": "Implements", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string", + "pattern": "^.+;$" + } + ] + }, + "Keywords": { + "title": "Keywords", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string", + "pattern": "^.+;$" + } + ] + }, + "MimeType": { + "title": "Mimetype", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string", + "pattern": "^.+;$" + } + ] + }, + "NoDisplay": { + "title": "Nodisplay", + "type": "boolean" + }, + "NotShowIn": { + "title": "Notshowin", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string", + "pattern": "^.+;$" + } + ] + }, + "OnlyShowIn": { + "title": "Onlyshowin", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string", + "pattern": "^.+;$" + } + ] + }, + "PrefersNonDefaultGPU": { + "title": "Prefersnondefaultgpu", + "type": "boolean" + }, + "StartupNotify": { + "title": "Startupnotify", + "type": "boolean" + }, + "StartupWMClass": { + "title": "Startupwmclass", + "type": "string" + }, + "TryExec": { + "title": "Tryexec", + "type": "string" + }, + "glob_patterns": { + "title": "Glob Patterns", + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": ".*\\*.*" + } + } + }, + "additionalProperties": false + }, + "CFBundleURLTypesModel": { + "title": "CFBundleURLTypesModel", + "description": "Describes a URL scheme associated with the app.", + "type": "object", + "properties": { + "CFBundleTypeRole": { + "title": "Cfbundletyperole", + "enum": [ + "Editor", + "Viewer", + "Shell", + "None" + ], + "type": "string" + }, + "CFBundleURLSchemes": { + "title": "Cfbundleurlschemes", + "type": "array", + "items": { + "type": "string" + } + }, + "CFBundleURLName": { + "title": "Cfbundleurlname", + "type": "string" + }, + "CFBundleURLIconFile": { + "title": "Cfbundleurliconfile", + "type": "string" + } + }, + "required": [ + "CFBundleURLSchemes" + ], + "additionalProperties": false + }, + "CFBundleDocumentTypesModel": { + "title": "CFBundleDocumentTypesModel", + "description": "Describes a document type associated with the app.", + "type": "object", + "properties": { + "CFBundleTypeIconFile": { + "title": "Cfbundletypeiconfile", + "type": "string" + }, + "CFBundleTypeName": { + "title": "Cfbundletypename", + "type": "string" + }, + "CFBundleTypeRole": { + "title": "Cfbundletyperole", + "enum": [ + "Editor", + "Viewer", + "Shell", + "None" + ], + "type": "string" + }, + "LSItemContentTypes": { + "title": "Lsitemcontenttypes", + "type": "array", + "items": { + "type": "string" + } + }, + "LSHandlerRank": { + "title": "Lshandlerrank", + "enum": [ + "Owner", + "Default", + "Alternate" + ], + "type": "string" + } + }, + "required": [ + "CFBundleTypeName", + "LSItemContentTypes", + "LSHandlerRank" + ], + "additionalProperties": false + }, + "UTTypeDeclarationModel": { + "title": "UTTypeDeclarationModel", + "type": "object", + "properties": { + "UTTypeConformsTo": { + "title": "Uttypeconformsto", + "type": "array", + "items": { + "type": "string" + } + }, + "UTTypeDescription": { + "title": "Uttypedescription", + "type": "string" + }, + "UTTypeIconFile": { + "title": "Uttypeiconfile", + "type": "string" + }, + "UTTypeIdentifier": { + "title": "Uttypeidentifier", + "type": "string" + }, + "UTTypeReferenceURL": { + "title": "Uttypereferenceurl", + "type": "string" + }, + "UTTypeTagSpecification": { + "title": "Uttypetagspecification", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "UTTypeConformsTo", + "UTTypeIdentifier", + "UTTypeTagSpecification" + ], + "additionalProperties": false + }, + "MacOS": { + "title": "MacOS", + "description": "Mac-specific instructions. Check these URLs for more info:\n\n- ``CF*`` keys: see `Core Foundation Keys `__\n- ``LS*`` keys: see `Launch Services Keys `__\n- ``entitlements``: see `Entitlements documentation `__", + "type": "object", + "properties": { + "name": { + "title": "Name", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/definitions/MenuItemNameDict" + } + ] + }, + "description": { + "title": "Description", + "type": "string" + }, + "icon": { + "title": "Icon", + "minLength": 1, + "type": "string" + }, + "command": { + "title": "Command", + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "working_dir": { + "title": "Working Dir", + "minLength": 1, + "type": "string" + }, + "precommand": { + "title": "Precommand", + "minLength": 1, + "type": "string" + }, + "precreate": { + "title": "Precreate", + "minLength": 1, + "type": "string" + }, + "activate": { + "title": "Activate", + "type": "boolean" + }, + "terminal": { + "title": "Terminal", + "type": "boolean" + }, + "CFBundleDisplayName": { + "title": "Cfbundledisplayname", + "type": "string" + }, + "CFBundleIdentifier": { + "title": "Cfbundleidentifier", + "pattern": "^[A-z0-9\\-\\.]+$", + "type": "string" + }, + "CFBundleName": { + "title": "Cfbundlename", + "maxLength": 16, + "type": "string" + }, + "CFBundleSpokenName": { + "title": "Cfbundlespokenname", + "type": "string" + }, + "CFBundleVersion": { + "title": "Cfbundleversion", + "pattern": "^\\S+$", + "type": "string" + }, + "CFBundleURLTypes": { + "title": "Cfbundleurltypes", + "type": "array", + "items": { + "$ref": "#/definitions/CFBundleURLTypesModel" + } + }, + "CFBundleDocumentTypes": { + "title": "Cfbundledocumenttypes", + "type": "array", + "items": { + "$ref": "#/definitions/CFBundleDocumentTypesModel" + } + }, + "LSApplicationCategoryType": { + "title": "Lsapplicationcategorytype", + "pattern": "^public\\.app-category\\.\\S+$", + "type": "string" + }, + "LSBackgroundOnly": { + "title": "Lsbackgroundonly", + "type": "boolean" + }, + "LSEnvironment": { + "title": "Lsenvironment", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "LSMinimumSystemVersion": { + "title": "Lsminimumsystemversion", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "type": "string" + }, + "LSMultipleInstancesProhibited": { + "title": "Lsmultipleinstancesprohibited", + "type": "boolean" + }, + "LSRequiresNativeExecution": { + "title": "Lsrequiresnativeexecution", + "type": "boolean" + }, + "NSSupportsAutomaticGraphicsSwitching": { + "title": "Nssupportsautomaticgraphicsswitching", + "type": "boolean" + }, + "UTExportedTypeDeclarations": { + "title": "Utexportedtypedeclarations", + "type": "array", + "items": { + "$ref": "#/definitions/UTTypeDeclarationModel" + } + }, + "UTImportedTypeDeclarations": { + "title": "Utimportedtypedeclarations", + "type": "array", + "items": { + "$ref": "#/definitions/UTTypeDeclarationModel" + } + }, + "entitlements": { + "title": "Entitlements", + "type": "array", + "items": { + "type": "string", + "pattern": "[a-z0-9\\.\\-]+" + } + }, + "link_in_bundle": { + "title": "Link In Bundle", + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^(?!\\/)(?!\\.\\./).*" + } + }, + "event_handler": { + "title": "Event Handler", + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + }, + "Windows": { + "title": "Windows", + "description": "Windows-specific instructions. You can override global keys here if needed", + "type": "object", + "properties": { + "name": { + "title": "Name", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/definitions/MenuItemNameDict" + } + ] + }, + "description": { + "title": "Description", + "type": "string" + }, + "icon": { + "title": "Icon", + "minLength": 1, + "type": "string" + }, + "command": { + "title": "Command", + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "working_dir": { + "title": "Working Dir", + "minLength": 1, + "type": "string" + }, + "precommand": { + "title": "Precommand", + "minLength": 1, + "type": "string" + }, + "precreate": { + "title": "Precreate", + "minLength": 1, + "type": "string" + }, + "activate": { + "title": "Activate", + "type": "boolean" + }, + "terminal": { + "title": "Terminal", + "type": "boolean" + }, + "desktop": { + "title": "Desktop", + "default": true, + "type": "boolean" + }, + "quicklaunch": { + "title": "Quicklaunch", + "default": true, + "type": "boolean" + }, + "terminal_profile": { + "title": "Terminal Profile", + "minLength": 1, + "type": "string" + }, + "url_protocols": { + "title": "Url Protocols", + "type": "array", + "items": { + "type": "string", + "pattern": "\\S+" + } + }, + "file_extensions": { + "title": "File Extensions", + "type": "array", + "items": { + "type": "string", + "pattern": "\\.\\S*" + } + }, + "app_user_model_id": { + "title": "App User Model Id", + "maxLength": 128, + "pattern": "\\S+\\.\\S+", + "type": "string" + } + }, + "additionalProperties": false + }, + "Platforms": { + "title": "Platforms", + "description": "Platform specific options.\n\nNote each of these fields supports the same keys as the top-level :class:`MenuItem`\n(sans ``platforms`` itself), in case overrides are needed.", + "type": "object", + "properties": { + "linux": { + "$ref": "#/definitions/Linux" + }, + "osx": { + "$ref": "#/definitions/MacOS" + }, + "win": { + "$ref": "#/definitions/Windows" + } + }, + "additionalProperties": false + }, + "MenuItem": { + "title": "MenuItem", + "description": "Instructions to create a menu item across operating systems.", + "type": "object", + "properties": { + "name": { + "title": "Name", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/definitions/MenuItemNameDict" + } + ] + }, + "description": { + "title": "Description", + "type": "string" + }, + "command": { + "title": "Command", + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "icon": { + "title": "Icon", + "minLength": 1, + "type": "string" + }, + "precommand": { + "title": "Precommand", + "minLength": 1, + "type": "string" + }, + "precreate": { + "title": "Precreate", + "minLength": 1, + "type": "string" + }, + "working_dir": { + "title": "Working Dir", + "minLength": 1, + "type": "string" + }, + "activate": { + "title": "Activate", + "default": true, + "type": "boolean" + }, + "terminal": { + "title": "Terminal", + "default": false, + "type": "boolean" + }, + "platforms": { + "$ref": "#/definitions/Platforms" + } + }, + "required": [ + "name", + "description", + "command", + "platforms" + ], + "additionalProperties": false + } + } +} diff --git a/crates/rattler_menuinst/data/osx_launcher_arm64 b/crates/rattler_menuinst/data/osx_launcher_arm64 new file mode 100755 index 000000000..5010fced0 Binary files /dev/null and b/crates/rattler_menuinst/data/osx_launcher_arm64 differ diff --git a/crates/rattler_menuinst/data/osx_launcher_x86_64 b/crates/rattler_menuinst/data/osx_launcher_x86_64 new file mode 100755 index 000000000..81fd807ee Binary files /dev/null and b/crates/rattler_menuinst/data/osx_launcher_x86_64 differ diff --git a/crates/rattler_menuinst/src/lib.rs b/crates/rattler_menuinst/src/lib.rs new file mode 100644 index 000000000..e61f6fb4f --- /dev/null +++ b/crates/rattler_menuinst/src/lib.rs @@ -0,0 +1,183 @@ +use std::path::{Path, PathBuf}; + +use rattler_conda_types::Platform; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +mod render; +mod schema; +mod util; +#[cfg(target_os = "windows")] +mod windows; + +pub mod slugify; +pub use slugify::slugify; + +use crate::{render::BaseMenuItemPlaceholders, schema::MenuInstSchema}; + +pub mod utils; + +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub enum MenuMode { + System, + User, +} + +#[derive(thiserror::Error, Debug)] +pub enum MenuInstError { + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + #[error("Deserialization error: {0}")] + SerdeError(#[from] serde_json::Error), + + #[error("Failed to install menu item: {0}")] + InstallError(String), + + #[error("Failed to create plist: {0}")] + PlistError(#[from] plist::Error), + #[error("Failed to sign plist: {0}")] + SigningFailed(String), + + #[error("Failed to install menu item: {0}")] + ActivationError(#[from] rattler_shell::activation::ActivationError), + + #[cfg(target_os = "linux")] + #[error("Failed to install menu item: {0}")] + XmlError(#[from] quick_xml::Error), + + #[cfg(target_os = "windows")] + #[error("Failed to install menu item: {0}")] + WindowsError(#[from] ::windows::core::Error), + + #[cfg(target_os = "linux")] + #[error("Menu config location is not a file: {0:?}")] + MenuConfigNotAFile(PathBuf), +} + +// Install menu items from a given schema file +pub fn install_menuitems( + file: &Path, + prefix: &Path, + base_prefix: &Path, + platform: Platform, + menu_mode: MenuMode, +) -> Result<(), MenuInstError> { + let text = std::fs::read_to_string(file)?; + let menu_inst: MenuInstSchema = serde_json::from_str(&text)?; + let placeholders = BaseMenuItemPlaceholders::new(base_prefix, prefix, platform); + + // if platform.is_linux() { + // #[cfg(target_os = "linux")] + // linux::install_menu(&menu_inst.menu_name, prefix, menu_mode)?; + // } + + for item in menu_inst.menu_items { + if platform.is_linux() { + #[cfg(target_os = "linux")] + if let Some(linux_item) = item.platforms.linux { + let command = item.command.merge(linux_item.base); + linux::install_menu_item( + &menu_inst.menu_name, + prefix, + linux_item.specific, + command, + &placeholders, + menu_mode, + )?; + } + } else if platform.is_osx() { + #[cfg(target_os = "macos")] + if let Some(macos_item) = item.platforms.osx { + let command = item.command.merge(macos_item.base); + macos::install_menu_item( + prefix, + macos_item.specific, + command, + &placeholders, + menu_mode, + )?; + } + } else if platform.is_windows() { + #[cfg(target_os = "windows")] + if let Some(windows_item) = item.platforms.win { + let command = item.command.merge(windows_item.base); + windows::install_menu_item( + prefix, + windows_item.specific, + command, + &placeholders, + menu_mode, + )?; + } + } + } + + Ok(()) +} + +/// Remove menu items from a given schema file +pub fn remove_menu_items( + file: &Path, + prefix: &Path, + base_prefix: &Path, + platform: Platform, + menu_mode: MenuMode, +) -> Result<(), MenuInstError> { + let text = std::fs::read_to_string(file)?; + let menu_inst: MenuInstSchema = serde_json::from_str(&text)?; + let placeholders = BaseMenuItemPlaceholders::new(base_prefix, prefix, platform); + + for item in menu_inst.menu_items { + if platform.is_linux() { + #[cfg(target_os = "linux")] + if let Some(linux_item) = item.platforms.linux { + let command = item.command.merge(linux_item.base); + linux::remove_menu_item( + &menu_inst.menu_name, + prefix, + linux_item.specific, + command, + &placeholders, + menu_mode, + )?; + } + } else if platform.is_osx() { + #[cfg(target_os = "macos")] + if let Some(macos_item) = item.platforms.osx { + let command = item.command.merge(macos_item.base); + macos::remove_menu_item( + prefix, + macos_item.specific, + command, + &placeholders, + menu_mode, + )?; + } + } else if platform.is_windows() { + #[cfg(target_os = "windows")] + if let Some(windows_item) = item.platforms.win { + let command = item.command.merge(windows_item.base); + windows::remove_menu_item( + prefix, + windows_item.specific, + command, + &placeholders, + menu_mode, + )?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +pub mod test { + use std::path::{Path, PathBuf}; + + pub(crate) fn test_data() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("test-data") + } +} diff --git a/crates/rattler_menuinst/src/linux.rs b/crates/rattler_menuinst/src/linux.rs new file mode 100644 index 000000000..239cb3cc3 --- /dev/null +++ b/crates/rattler_menuinst/src/linux.rs @@ -0,0 +1,779 @@ +use fs_err as fs; +use fs_err::File; +use mime_config::MimeConfig; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +mod menu_xml; +mod mime_config; + +use rattler_conda_types::Platform; +use rattler_shell::activation::{ActivationVariables, Activator}; +use rattler_shell::shell; + +use crate::render::{BaseMenuItemPlaceholders, MenuItemPlaceholders, PlaceholderString}; +use crate::slugify; +use crate::util::{log_output, run_pre_create_command}; +use crate::{ + schema::{Linux, MenuItemCommand}, + MenuInstError, MenuMode, +}; + +pub struct LinuxMenu { + prefix: PathBuf, + name: String, + item: Linux, + command: MenuItemCommand, + directories: Directories, + placeholders: MenuItemPlaceholders, + mode: MenuMode, +} + +/// Directories used on Linux for menu items +#[allow(unused)] +#[derive(Debug, Clone)] +pub struct Directories { + /// The name of the (parent) menu (in the json defined as `menu_name`) + pub menu_name: String, + + /// The name of the current menu item + pub name: String, + + /// The data directory for the menu + pub data_directory: PathBuf, + + /// The configuration directory for the menu + pub config_directory: PathBuf, + + /// The location of the menu configuration file This is the file that is + /// used by the system to determine the menu layout It is usually located at + /// `~/.config/menus/applications.menu` + pub menu_config_location: PathBuf, + + /// The location of the system menu configuration file This is the file that + /// is used by the system to determine the menu layout It is usually located + /// at `/etc/xdg/menus/applications.menu` + pub system_menu_config_location: PathBuf, + + /// The location of the desktop entries This is a directory that contains + /// `.desktop` files that describe the applications that are shown in the + /// menu It is usually located at `/usr/share/applications` or + /// `~/.local/share/applications` + pub desktop_entries_location: PathBuf, + + /// The location of the desktop-directories This is a directory that + /// contains `.directory` files that describe the directories that are shown + /// in the menu It is usually located at `/usr/share/desktop-directories` or + /// `~/.local/share/desktop-directories` + pub directory_entry_location: PathBuf, +} + +impl Directories { + fn new(mode: MenuMode, menu_name: &str, name: &str) -> Self { + let system_config_directory = PathBuf::from("/etc/xdg/"); + let system_data_directory = PathBuf::from("/usr/share"); + + let (config_directory, data_directory) = if mode == MenuMode::System { + ( + system_config_directory.clone(), + system_data_directory.clone(), + ) + } else { + ( + dirs::config_dir().expect("Could not get config dir"), + dirs::data_dir().expect("Could not get data dir"), + ) + }; + + Directories { + menu_name: menu_name.to_string(), + name: name.to_string(), + data_directory: data_directory.clone(), + system_menu_config_location: system_config_directory.join("menus/applications.menu"), + menu_config_location: config_directory.join("menus/applications.menu"), + config_directory, + desktop_entries_location: data_directory.join("applications"), + directory_entry_location: data_directory + .join(format!("desktop-directories/{}.directory", slugify(name))), + } + } + + pub fn ensure_directories_exist(&self) -> Result<(), MenuInstError> { + fs::create_dir_all(&self.data_directory)?; + fs::create_dir_all(&self.config_directory)?; + + let paths = vec![ + self.menu_config_location.parent().unwrap().to_path_buf(), + self.desktop_entries_location.clone(), + self.directory_entry_location + .parent() + .unwrap() + .to_path_buf(), + ]; + + for path in paths { + tracing::debug!("Ensuring path {} exists", path.display()); + fs::create_dir_all(path)?; + } + + Ok(()) + } + + pub fn mime_directory(&self) -> PathBuf { + self.data_directory.join("mime") + } + + pub fn desktop_file(&self) -> PathBuf { + self.desktop_entries_location.join(format!( + "{}_{}.desktop", + slugify(&self.menu_name), + slugify(&self.name) + )) + } +} + +impl LinuxMenu { + fn new( + menu_name: &str, + prefix: &Path, + item: Linux, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + mode: MenuMode, + ) -> Self { + Self::new_impl(menu_name, prefix, item, command, placeholders, mode, None) + } + + pub fn new_impl( + menu_name: &str, + prefix: &Path, + item: Linux, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + mode: MenuMode, + directories: Option, + ) -> Self { + let name = command + .name + .resolve(crate::schema::Environment::Base, placeholders); + + let directories = directories.unwrap_or_else(|| Directories::new(mode, menu_name, &name)); + + let refined_placeholders = placeholders.refine(&directories.desktop_file()); + + LinuxMenu { + name, + prefix: prefix.to_path_buf(), + item, + command, + directories, + placeholders: refined_placeholders, + mode, + } + } + + #[cfg(test)] + pub fn new_with_directories( + menu_name: &str, + prefix: &Path, + item: Linux, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + directories: Directories, + ) -> Self { + Self::new_impl( + menu_name, + prefix, + item, + command, + placeholders, + MenuMode::User, + Some(directories), + ) + } + + fn location(&self) -> PathBuf { + self.directories.desktop_file() + } + + /// Logic to run before the shortcut files are created. + fn pre_create(&self) -> Result<(), MenuInstError> { + if let Some(pre_create_command) = &self.command.precreate { + let pre_create_command = pre_create_command.resolve(&self.placeholders); + run_pre_create_command(&pre_create_command)?; + } + + Ok(()) + } + + fn command(&self) -> String { + let mut parts = Vec::new(); + if let Some(pre_command) = &self.command.precommand { + parts.push(pre_command.resolve(&self.placeholders)); + } + + // TODO we should use `env` to set the environment variables in the `.desktop` file + let mut envs = Vec::new(); + if self.command.activate.unwrap_or(false) { + // create a bash activation script and emit it into the script + let activator = + Activator::from_path(&self.prefix, shell::Bash, Platform::current()).unwrap(); + let activation_env = activator + .run_activation(ActivationVariables::default(), None) + .unwrap(); + + for (k, v) in activation_env { + envs.push(format!(r#"{k}="{v}""#)); + } + } + + let command = self + .command + .command + .iter() + .map(|s| s.resolve(&self.placeholders)) + .collect::>() + .join(" "); + + parts.push(command); + + let command = parts.join(" && "); + + format!("bash -c {}", shlex::try_quote(&command).unwrap()) + } + + fn resolve_and_join(&self, items: &[PlaceholderString]) -> String { + let mut res = String::new(); + for item in items { + res.push_str(&item.resolve(&self.placeholders)); + res.push(';'); + } + res + } + + fn create_desktop_entry(&self) -> Result<(), MenuInstError> { + let file = self.location(); + tracing::info!("Creating desktop entry at {}", file.display()); + let writer = File::create(file)?; + let mut writer = std::io::BufWriter::new(writer); + + writeln!(writer, "[Desktop Entry]")?; + writeln!(writer, "Type=Application")?; + writeln!(writer, "Encoding=UTF-8")?; + writeln!(writer, "Name={}", self.name)?; + writeln!(writer, "Exec={}", self.command())?; + writeln!( + writer, + "Terminal={}", + self.command.terminal.unwrap_or(false) + )?; + + if let Some(icon) = &self.command.icon { + let icon = icon.resolve(&self.placeholders); + writeln!(writer, "Icon={icon}")?; + } + + let description = self.command.description.resolve(&self.placeholders); + if !description.is_empty() { + writeln!(writer, "Comment={description}")?; + } + + if let Some(working_dir) = &self.command.working_dir { + let working_dir = working_dir.resolve(&self.placeholders); + writeln!(writer, "Path={working_dir}")?; + } + + // resolve categories and join them with a semicolon + if let Some(categories) = &self.item.categories { + writeln!(writer, "Categories={}", self.resolve_and_join(categories))?; + } + + if let Some(dbus_activatable) = &self.item.dbus_activatable { + writeln!(writer, "DBusActivatable={dbus_activatable}")?; + } + + if let Some(generic_name) = &self.item.generic_name { + writeln!( + writer, + "GenericName={}", + generic_name.resolve(&self.placeholders) + )?; + } + + if let Some(hidden) = &self.item.hidden { + writeln!(writer, "Hidden={hidden}")?; + } + + if let Some(implements) = &self.item.implements { + writeln!(writer, "Implements={}", self.resolve_and_join(implements))?; + } + + if let Some(keywords) = &self.item.keywords { + writeln!(writer, "Keywords={}", self.resolve_and_join(keywords))?; + } + + if let Some(mime_types) = &self.item.mime_type { + writeln!(writer, "MimeType={}", self.resolve_and_join(mime_types))?; + } + + if let Some(no_display) = &self.item.no_display { + writeln!(writer, "NoDisplay={no_display}")?; + } + + if let Some(not_show_in) = &self.item.not_show_in { + writeln!(writer, "NotShowIn={}", self.resolve_and_join(not_show_in))?; + } + + if let Some(only_show_in) = &self.item.only_show_in { + writeln!(writer, "OnlyShowIn={}", self.resolve_and_join(only_show_in))?; + } + + if let Some(single_main_window) = &self.item.single_main_window { + writeln!(writer, "SingleMainWindow={single_main_window}")?; + } + + if let Some(prefers_non_default_gpu) = &self.item.prefers_non_default_gpu { + writeln!(writer, "PrefersNonDefaultGPU={prefers_non_default_gpu}")?; + } + + if let Some(startup_notify) = &self.item.startup_notify { + writeln!(writer, "StartupNotify={startup_notify}")?; + } + + if let Some(startup_wm_class) = &self.item.startup_wm_class { + writeln!( + writer, + "StartupWMClass={}", + startup_wm_class.resolve(&self.placeholders) + )?; + } + + if let Some(try_exec) = &self.item.try_exec { + writeln!(writer, "TryExec={}", try_exec.resolve(&self.placeholders))?; + } + + Ok(()) + } + + fn update_desktop_database() -> Result<(), MenuInstError> { + // We don't care about the output of update-desktop-database + let _ = Command::new("update-desktop-database").output(); + + Ok(()) + } + + fn install(&self) -> Result<(), MenuInstError> { + self.directories.ensure_directories_exist()?; + self.pre_create()?; + self.create_desktop_entry()?; + self.maybe_register_mime_types(true)?; + Self::update_desktop_database()?; + Ok(()) + } + + fn remove(&self) -> Result<(), MenuInstError> { + let paths = self.paths(); + for path in paths { + fs::remove_file(path)?; + } + Ok(()) + } + + fn maybe_register_mime_types(&self, register: bool) -> Result<(), MenuInstError> { + if let Some(mime_types) = self.item.mime_type.as_ref() { + let resolved_mime_types = mime_types + .iter() + .map(|s| s.resolve(&self.placeholders)) + .collect::>(); + self.register_mime_types(&resolved_mime_types, register)?; + } + Ok(()) + } + + fn register_mime_types( + &self, + mime_types: &[String], + register: bool, + ) -> Result<(), MenuInstError> { + tracing::info!("Registering mime types {:?}", mime_types); + let mut resolved_globs = HashMap::::new(); + + if let Some(globs) = &self.item.glob_patterns { + for (k, v) in globs { + resolved_globs.insert(k.resolve(&self.placeholders), v.resolve(&self.placeholders)); + } + } + + for mime_type in mime_types { + if let Some(glob_pattern) = resolved_globs.get(mime_type) { + self.glob_pattern_for_mime_type(mime_type, glob_pattern, register)?; + } + } + + let mimeapps = self.directories.config_directory.join("mimeapps.list"); + + if register { + let mut config = MimeConfig::new(mimeapps); + config.load()?; + for mime_type in mime_types { + tracing::info!("Registering mime type {} for {}", mime_type, &self.name); + config.register_mime_type(mime_type, &self.name); + } + config.save()?; + } else if mimeapps.exists() { + // in this case we remove the mime type from the mimeapps.list file + let mut config = MimeConfig::new(mimeapps); + for mime_type in mime_types { + tracing::info!("Deregistering mime type {} for {}", mime_type, &self.name); + config.deregister_mime_type(mime_type, &self.name); + } + config.save()?; + } + + if let Ok(update_mime_database) = which::which("update-mime-database") { + let mut command = Command::new(update_mime_database); + command.arg("-V").arg(self.directories.mime_directory()); + let output = command.output()?; + if !output.status.success() { + tracing::warn!("Could not update mime database"); + log_output("update-mime-database", output); + } + } + + Ok(()) + } + + fn xml_path_for_mime_type(&self, mime_type: &str) -> Result<(PathBuf, bool), std::io::Error> { + let basename = mime_type.replace("/", "-"); + let mime_directory = self.directories.data_directory.join("mime/packages"); + if !mime_directory.is_dir() { + return Ok((mime_directory.join(format!("{basename}.xml")), false)); + } + + let xml_files: Vec = fs::read_dir(&mime_directory)? + .filter_map(|entry| { + let path = entry.unwrap().path(); + if path + .file_name() + .unwrap() + .to_str() + .unwrap() + .contains(&basename) + { + Some(path) + } else { + None + } + }) + .collect(); + + if !xml_files.is_empty() { + if xml_files.len() > 1 { + tracing::debug!( + "Found multiple files for MIME type {}: {:?}. Returning first.", + mime_type, + xml_files + ); + } + return Ok((xml_files[0].clone(), true)); + } + Ok((mime_directory.join(format!("{basename}.xml")), false)) + } + + fn glob_pattern_for_mime_type( + &self, + mime_type: &str, + glob_pattern: &str, + install: bool, + ) -> Result { + let (xml_path, exists) = self.xml_path_for_mime_type(mime_type).unwrap(); + if exists { + return Ok(xml_path); + } + + // Write the XML that binds our current mime type to the glob pattern + let xmlns = "http://www.freedesktop.org/standards/shared-mime-info"; + let description = format!( + "Custom MIME type {mime_type} for '{glob_pattern}' files (registered by menuinst)" + ); + + let xml = format!( + r#" + + + + {description} + +"# + ); + + let subcommand = if install { "install" } else { "uninstall" }; + // Install the XML file and register it as default for our app + let tmp_dir = TempDir::new()?; + let tmp_path = tmp_dir.path().join(xml_path.file_name().unwrap()); + let mut file = fs::File::create(&tmp_path)?; + file.write_all(xml.as_bytes())?; + + let mut command = Command::new("xdg-mime"); + let mode = match self.mode { + MenuMode::System => "system", + MenuMode::User => "user", + }; + + command + .arg(subcommand) + .arg("--mode") + .arg(mode) + .arg("--novendor") + .arg(tmp_path); + let output = command.output()?; + + if !output.status.success() { + tracing::warn!( + "Could not un/register MIME type {} with xdg-mime. Writing to '{}' as a fallback.", + mime_type, + xml_path.display() + ); + tracing::info!( + "xdg-mime stdout output: {}", + String::from_utf8_lossy(&output.stdout) + ); + tracing::info!( + "xdg-mime stderr output: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let mut file = fs::File::create(&xml_path)?; + file.write_all(xml.as_bytes())?; + } + + Ok(xml_path) + } + + /// All paths that are installed for removal + fn paths(&self) -> Vec { + let mut paths = vec![self.location()]; + + if let Some(mime_types) = &self.item.mime_type { + let resolved = mime_types + .iter() + .map(|s| s.resolve(&self.placeholders)) + .collect::>(); + + for mime in resolved { + let (xml_path, exists) = self.xml_path_for_mime_type(&mime).unwrap(); + if !exists { + continue; + } + + if let Ok(content) = fs::read_to_string(&xml_path) { + if content.contains("registered by menuinst") { + paths.push(xml_path); + } + } + } + } + paths + } +} + +/// Install a menu item on Linux. +pub fn install_menu_item( + menu_name: &str, + prefix: &Path, + item: Linux, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + menu_mode: MenuMode, +) -> Result<(), MenuInstError> { + let menu = LinuxMenu::new(menu_name, prefix, item, command, placeholders, menu_mode); + menu.install() +} + +/// Remove a menu item on Linux. +pub fn remove_menu_item( + menu_name: &str, + prefix: &Path, + item: Linux, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + menu_mode: MenuMode, +) -> Result<(), MenuInstError> { + let menu = LinuxMenu::new(menu_name, prefix, item, command, placeholders, menu_mode); + menu.remove() +} + +#[cfg(test)] +mod tests { + use fs_err as fs; + use std::{ + collections::HashMap, + path::{Path, PathBuf}, + }; + use tempfile::TempDir; + + use crate::{schema::MenuInstSchema, test::test_data}; + + use super::{Directories, LinuxMenu}; + + struct FakeDirectories { + _tmp_dir: TempDir, + directories: Directories, + } + + impl FakeDirectories { + fn new() -> Self { + let tmp_dir = TempDir::new().unwrap(); + let data_directory = tmp_dir.path().join("data"); + let config_directory = tmp_dir.path().join("config"); + + std::env::set_var("XDG_DATA_HOME", &data_directory); + std::env::set_var("XDG_CONFIG_HOME", &config_directory); + + let directories = Directories { + menu_name: "Test".to_string(), + name: "Test".to_string(), + data_directory, + config_directory, + system_menu_config_location: tmp_dir.path().join("system_menu_config_location"), + menu_config_location: tmp_dir.path().join("menu_config_location"), + desktop_entries_location: tmp_dir.path().join("desktop_entries_location"), + directory_entry_location: tmp_dir.path().join("directory_entry_location"), + }; + + directories.ensure_directories_exist().unwrap(); + + Self { + _tmp_dir: tmp_dir, + directories, + } + } + + pub fn directories(&self) -> &Directories { + &self.directories + } + } + + impl Drop for FakeDirectories { + fn drop(&mut self) { + std::env::remove_var("XDG_DATA_HOME"); + std::env::remove_var("XDG_CONFIG_HOME"); + } + } + + struct FakePlaceholders { + placeholders: HashMap, + } + + impl AsRef> for FakePlaceholders { + fn as_ref(&self) -> &HashMap { + &self.placeholders + } + } + + struct FakePrefix { + _tmp_dir: TempDir, + prefix_path: PathBuf, + schema: MenuInstSchema, + } + + impl FakePrefix { + fn new(schema_json: &str) -> Self { + let tmp_dir = TempDir::new().unwrap(); + let prefix_path = tmp_dir.path().join("test-env"); + let schema_json = test_data().join(schema_json); + let menu_folder = prefix_path.join("Menu"); + + fs::create_dir_all(&menu_folder).unwrap(); + fs::copy( + &schema_json, + menu_folder.join(schema_json.file_name().unwrap()), + ) + .unwrap(); + + // Create a icon file for the + let schema = std::fs::read_to_string(schema_json).unwrap(); + let parsed_schema: MenuInstSchema = serde_json::from_str(&schema).unwrap(); + + let mut placeholders = HashMap::::new(); + placeholders.insert( + "MENU_DIR".to_string(), + menu_folder.to_string_lossy().to_string(), + ); + + for item in &parsed_schema.menu_items { + let icon = item.command.icon.as_ref().unwrap(); + for ext in &["icns", "png", "svg"] { + placeholders.insert("ICON_EXT".to_string(), ext.to_string()); + let icon_path = icon.resolve(FakePlaceholders { + placeholders: placeholders.clone(), + }); + fs::write(&icon_path, []).unwrap(); + } + } + + fs::create_dir_all(prefix_path.join("bin")).unwrap(); + fs::write(prefix_path.join("bin/python"), &[]).unwrap(); + + Self { + _tmp_dir: tmp_dir, + prefix_path, + schema: parsed_schema, + } + } + + pub fn prefix(&self) -> &Path { + &self.prefix_path + } + } + + #[test] + fn test_installation() { + let dirs = FakeDirectories::new(); + + let fake_prefix = FakePrefix::new("spyder/menu.json"); + + let item = fake_prefix.schema.menu_items[0].clone(); + let linux = item.platforms.linux.unwrap(); + let command = item.command.merge(linux.base); + + let placeholders = super::BaseMenuItemPlaceholders::new( + fake_prefix.prefix(), + fake_prefix.prefix(), + rattler_conda_types::Platform::current(), + ); + + let linux_menu = LinuxMenu::new_with_directories( + &fake_prefix.schema.menu_name, + fake_prefix.prefix(), + linux.specific, + command, + &placeholders, + dirs.directories().clone(), + ); + + linux_menu.install().unwrap(); + + // check snapshot of desktop file + let desktop_file = dirs.directories().desktop_file(); + let desktop_file_content = fs::read_to_string(&desktop_file).unwrap(); + let desktop_file_content = + desktop_file_content.replace(&fake_prefix.prefix().to_str().unwrap(), ""); + insta::assert_snapshot!(desktop_file_content); + + // check mimeapps.list + let mimeapps_file = dirs.directories().config_directory.join("mimeapps.list"); + let mimeapps_file_content = fs::read_to_string(&mimeapps_file).unwrap(); + insta::assert_snapshot!(mimeapps_file_content); + + let mime_file = dirs + .directories() + .data_directory + .join("mime/packages/text-x-spython.xml"); + let mime_file_content = fs::read_to_string(&mime_file).unwrap(); + insta::assert_snapshot!(mime_file_content); + } +} diff --git a/crates/rattler_menuinst/src/linux/menu_xml.rs b/crates/rattler_menuinst/src/linux/menu_xml.rs new file mode 100644 index 000000000..b31567945 --- /dev/null +++ b/crates/rattler_menuinst/src/linux/menu_xml.rs @@ -0,0 +1,362 @@ +#![allow(dead_code)] +use chrono::Utc; +use fs_err::{self as fs, File}; +use quick_xml::events::Event; +use quick_xml::{Reader, Writer}; +use std::io::Write; +use std::path::PathBuf; + +use crate::{slugify, MenuInstError}; + +pub struct MenuXml { + menu_config_location: PathBuf, + system_menu_config_location: PathBuf, + name: String, + mode: String, +} + +impl MenuXml { + pub fn new( + menu_config_location: PathBuf, + system_menu_config_location: PathBuf, + name: String, + mode: String, + ) -> Result { + Ok(Self { + menu_config_location, + system_menu_config_location, + name, + mode, + }) + } + + fn is_target_menu(&self, mut reader: Reader<&[u8]>) -> Result { + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(e) if e.name().as_ref() == b"Name" => { + if let Event::Text(t) = reader.read_event_into(&mut buf)? { + return Ok(t.unescape()?.into_owned() == self.name); + } + } + Event::End(e) if e.name().as_ref() == b"Menu" => break, + _ => (), + } + } + Ok(false) + } + + fn contents(&self) -> Result { + Ok(fs::read_to_string(&self.menu_config_location)?) + } + + pub fn remove_menu(&self) -> Result<(), MenuInstError> { + tracing::info!( + "Editing {} to remove {} config", + self.menu_config_location.display(), + self.name + ); + + let contents = self.contents()?; + let mut reader = Reader::from_str(&contents); + reader.config_mut().trim_text(true); + + let mut writer = Writer::new_with_indent(Vec::new(), b' ', 2); + let mut buf = Vec::new(); + let mut skip_menu = false; + let mut depth = 0; + + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(e) => { + if e.name().as_ref() == b"Menu" { + depth += 1; + if depth == 1 { + // Always write the root Menu element + writer.write_event(Event::Start(e))?; + } else { + // Check if this is our target menu + + if self.is_target_menu(reader.clone())? { + skip_menu = true; + } else { + writer.write_event(Event::Start(e))?; + } + } + } else if !skip_menu { + writer.write_event(Event::Start(e))?; + } + } + Event::End(e) => { + if skip_menu && e.name().as_ref() == b"Menu" { + skip_menu = false; + } else if !skip_menu { + writer.write_event(Event::End(e))?; + } + } + Event::Eof => break, + e => { + if !skip_menu { + writer.write_event(e)?; + } + } + } + buf.clear(); + } + + self.write_menu_file(&writer.into_inner()) + } + + pub fn has_menu(&self) -> Result { + let contents = self.contents()?; + let mut reader = Reader::from_str(&contents); + let mut buf = Vec::new(); + + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(e) if e.name().as_ref() == b"Menu" => { + if self.is_target_menu(reader.clone())? { + return Ok(true); + } + } + Event::Eof => break, + _ => (), + } + buf.clear(); + } + Ok(false) + } + + pub fn add_menu(&self) -> Result<(), MenuInstError> { + tracing::info!( + "Editing {} to add {} config", + self.menu_config_location.display(), + self.name + ); + + let mut content = fs::read_to_string(&self.menu_config_location)?; + // let insert_pos = content.rfind("").ok_or_else(|| anyhow!("Invalid XML"))?; + let insert_pos = content.rfind("").unwrap(); + + let menu_entry = format!( + r#" + {} + {}.directory + + {} + + +"#, + self.name, + slugify(&self.name), + self.name + ); + + content.insert_str(insert_pos, &menu_entry); + self.write_menu_file(content.as_bytes()) + } + + pub fn is_valid_menu_file(&self) -> bool { + if let Ok(contents) = self.contents() { + let reader = Reader::from_str(&contents); + let mut buf = Vec::new(); + let mut reader = reader; + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + if e.name().as_ref() == b"Menu" { + return true; + } + } + Ok(Event::Eof) => break, + _ => (), + } + } + } + false + } + + fn write_menu_file(&self, content: &[u8]) -> Result<(), MenuInstError> { + tracing::info!("Writing {}", self.menu_config_location.display()); + let mut file = File::create(&self.menu_config_location)?; + file.write_all(content)?; + file.write_all(b"\n")?; + Ok(()) + } + + pub fn ensure_menu_file(&self) -> Result<(), MenuInstError> { + if self.menu_config_location.exists() && !self.menu_config_location.is_file() { + return Err(MenuInstError::MenuConfigNotAFile(self.menu_config_location.clone())); + } + + if self.menu_config_location.is_file() { + let timestamp = Utc::now().format("%Y-%m-%d_%Hh%Mm%S").to_string(); + let backup = format!("{}.{}", self.menu_config_location.display(), timestamp); + fs::copy(&self.menu_config_location, &backup)?; + + if !self.is_valid_menu_file() { + fs::remove_file(&self.menu_config_location)?; + } + } + + if !self.menu_config_location.exists() { + self.new_menu_file()?; + } + Ok(()) + } + + fn new_menu_file(&self) -> Result<(), MenuInstError> { + tracing::info!("Creating {}", self.menu_config_location.display()); + let mut content = String::from("\n"); + content.push_str("\n Applications\n"); + + if self.mode == "user" { + content.push_str(&format!( + " {}\n", + self.system_menu_config_location.display() + )); + } + content.push_str("\n"); + fs::write(&self.menu_config_location, content)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::test::test_data; + + use super::*; + use tempfile::TempDir; + + fn setup_test_dir() -> (TempDir, MenuXml) { + let temp_dir = TempDir::new().unwrap(); + let menu_config = temp_dir.path().join("applications.menu"); + let system_menu_config = temp_dir.path().join("system_applications.menu"); + + let menu_xml = MenuXml::new( + menu_config, + system_menu_config, + "Test Menu".to_string(), + "user".to_string(), + ) + .unwrap(); + + (temp_dir, menu_xml) + } + + #[test] + fn test_new_menu_file() { + let (_temp_dir, menu_xml) = setup_test_dir(); + menu_xml.new_menu_file().unwrap(); + assert!(menu_xml.is_valid_menu_file()); + } + + #[test] + fn test_add_and_remove_menu() { + let (_temp_dir, menu_xml) = setup_test_dir(); + menu_xml.new_menu_file().unwrap(); + + let system_menu_location = menu_xml.system_menu_config_location.display().to_string(); + + let res = fs::read_to_string(&menu_xml.menu_config_location).unwrap(); + let res = res.replace(&system_menu_location, "/path/to/system_menu"); + insta::assert_snapshot!(res); + + // Add menu + menu_xml.add_menu().unwrap(); + assert!(menu_xml.has_menu().unwrap()); + + let res = fs::read_to_string(&menu_xml.menu_config_location).unwrap(); + let res = res.replace(&system_menu_location, "/path/to/system_menu"); + insta::assert_snapshot!(res); + + // Remove menu + menu_xml.remove_menu().unwrap(); + + let res = fs::read_to_string(&menu_xml.menu_config_location).unwrap(); + let res = res.replace(&system_menu_location, "/path/to/system_menu"); + insta::assert_snapshot!(res); + assert!(!menu_xml.has_menu().unwrap()); + } + + #[test] + fn test_invalid_menu_file() { + let (_temp_dir, menu_xml) = setup_test_dir(); + + // Write invalid content + fs::write(&menu_xml.menu_config_location, "XML").unwrap(); + assert!(!menu_xml.is_valid_menu_file()); + } + + #[test] + fn test_ensure_menu_file() { + let (_temp_dir, menu_xml) = setup_test_dir(); + + // Test with non-existent file + menu_xml.ensure_menu_file().unwrap(); + assert!(menu_xml.menu_config_location.exists()); + assert!(menu_xml.is_valid_menu_file()); + + // Test with invalid file + fs::write(&menu_xml.menu_config_location, "XML").unwrap(); + menu_xml.ensure_menu_file().unwrap(); + assert!(menu_xml.is_valid_menu_file()); + } + + #[test] + fn test_remove_menu_xml_structure() { + let (_temp_dir, menu_xml) = setup_test_dir(); + + // Create initial menu file with content + let initial_content = r#" + + Applications + /path/to/system_menu + + Test Menu + test-menu.directory + + Test Menu + + + "#; + + fs::write(&menu_xml.menu_config_location, initial_content).unwrap(); + + // Remove the menu + menu_xml.remove_menu().unwrap(); + + // Read and verify the result + let result = fs::read_to_string(&menu_xml.menu_config_location).unwrap(); + + insta::assert_snapshot!(result); + } + + #[test] + // load file from test data (example.menu) and add a new entry, then remove it + fn test_add_and_remove_menu_xml_structure() { + let (_temp_dir, menu_xml) = setup_test_dir(); + + let test_data = test_data(); + let schema_path = test_data.join("linux-menu/example.menu"); + + // Copy the example.menu file to the menu location + fs::copy(&schema_path, &menu_xml.menu_config_location).unwrap(); + + // Add the menu + menu_xml.add_menu().unwrap(); + + // Read and verify the result + let result = fs::read_to_string(&menu_xml.menu_config_location).unwrap(); + insta::assert_snapshot!(result); + + // Remove the menu + menu_xml.remove_menu().unwrap(); + + // Read and verify the result + let result = fs::read_to_string(&menu_xml.menu_config_location).unwrap(); + insta::assert_snapshot!(result); + } +} diff --git a/crates/rattler_menuinst/src/linux/mime_config.rs b/crates/rattler_menuinst/src/linux/mime_config.rs new file mode 100644 index 000000000..b4178a19e --- /dev/null +++ b/crates/rattler_menuinst/src/linux/mime_config.rs @@ -0,0 +1,303 @@ +use configparser::ini::Ini; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MimeConfigError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug)] +pub struct MimeConfig { + config: Ini, + path: PathBuf, +} + +impl MimeConfig { + pub fn new>(path: P) -> Self { + Self { + // cs == case-sensitive + config: Ini::new_cs(), + path: path.as_ref().to_path_buf(), + } + } + + pub fn load(&mut self) -> Result<(), std::io::Error> { + if self.path.exists() { + self.config + .load(&self.path) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + } + Ok(()) + } + + pub fn save(&self) -> Result<(), std::io::Error> { + self.config.write(&self.path) + } + + #[cfg(test)] + pub fn to_string(&self) -> String { + self.config.writes() + } + + pub fn register_mime_type(&mut self, mime_type: &str, application: &str) { + // Only set default if not already set + if self.config.get("Default Applications", mime_type).is_none() { + self.config.set( + "Default Applications", + mime_type, + Some(application.to_string()), + ); + } + + // Update associations + let existing = self + .config + .get("Added Associations", mime_type) + .unwrap_or_default(); + + let new_value = if !existing.is_empty() && !existing.contains(application) { + format!("{existing};{application}") + } else { + application.to_string() + }; + + self.config + .set("Added Associations", mime_type, Some(new_value)); + } + + pub fn deregister_mime_type(&mut self, mime_type: &str, application: &str) { + for section in &["Default Applications", "Added Associations"] { + if let Some(value) = self.config.get(section, mime_type) { + if value == application { + self.config.remove_key(section, mime_type); + } else if value.contains(application) { + let new_value: String = value + .split(';') + .filter(|&x| x != application) + .collect::>() + .join(";"); + self.config.set(section, mime_type, Some(new_value)); + } + } + + // Remove empty sections + if let Some(section_map) = self.config.get_map_ref().get(*section) { + if section_map.is_empty() { + self.config.remove_section(section); + } + } + } + } + + #[cfg(test)] + pub fn get_default_application(&self, mime_type: &str) -> Option { + self.config.get("Default Applications", mime_type) + } + + #[cfg(test)] + pub fn get_associations(&self, mime_type: &str) -> Vec { + self.config + .get("Added Associations", mime_type) + .map(|s| s.split(';').map(String::from).collect()) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use crate::test::test_data; + + use super::*; + use tempfile::NamedTempFile; + + fn create_temp_config() -> (MimeConfig, NamedTempFile) { + let file = NamedTempFile::new().unwrap(); + let config = MimeConfig::new(file.path()); + (config, file) + } + + #[test] + fn test_register_new_mime_type() { + let (mut config, _file) = create_temp_config(); + + config.register_mime_type("text/plain", "notepad.desktop"); + + assert_eq!( + config.get_default_application("text/plain"), + Some("notepad.desktop".to_string()) + ); + assert_eq!( + config.get_associations("text/plain"), + vec!["notepad.desktop"] + ); + } + + #[test] + fn test_register_multiple_applications() { + let (mut config, _file) = create_temp_config(); + + config.register_mime_type("text/plain", "notepad.desktop"); + config.register_mime_type("text/plain", "gedit.desktop"); + + // First application remains the default + assert_eq!( + config.get_default_application("text/plain"), + Some("notepad.desktop".to_string()) + ); + + // Both applications in associations + let associations = config.get_associations("text/plain"); + assert!(associations.contains(&"notepad.desktop".to_string())); + assert!(associations.contains(&"gedit.desktop".to_string())); + } + + #[test] + fn test_deregister_mime_type() { + let (mut config, _file) = create_temp_config(); + + config.register_mime_type("text/plain", "notepad.desktop"); + config.register_mime_type("text/plain", "gedit.desktop"); + config.deregister_mime_type("text/plain", "notepad.desktop"); + + // notepad should be removed from associations + let associations = config.get_associations("text/plain"); + assert!(!associations.contains(&"notepad.desktop".to_string())); + assert!(associations.contains(&"gedit.desktop".to_string())); + } + + #[test] + fn test_load_and_save() -> Result<(), MimeConfigError> { + let (mut config, file) = create_temp_config(); + + config.register_mime_type("text/plain", "notepad.desktop"); + config.save()?; + + let mut new_config = MimeConfig::new(file.path()); + new_config.load()?; + + assert_eq!( + new_config.get_default_application("text/plain"), + Some("notepad.desktop".to_string()) + ); + Ok(()) + } + + fn get_config_contents(config: &MimeConfig) -> String { + config.save().unwrap(); + std::fs::read_to_string(&config.path).unwrap() + } + + #[test] + fn test_mime_config_snapshots() { + let (mut config, _file) = create_temp_config(); + + // Test progressive changes to the config + config.register_mime_type("text/plain", "notepad.desktop"); + insta::assert_snapshot!(get_config_contents(&config), @r###" + [Default Applications] + text/plain=notepad.desktop + [Added Associations] + text/plain=notepad.desktop + "###); + + config.register_mime_type("text/plain", "gedit.desktop"); + insta::assert_snapshot!(get_config_contents(&config), @r###" + [Default Applications] + text/plain=notepad.desktop + [Added Associations] + text/plain=notepad.desktop;gedit.desktop + "###); + + config.register_mime_type("application/pdf", "pdf-reader.desktop"); + insta::assert_snapshot!(get_config_contents(&config), @r###" + [Default Applications] + text/plain=notepad.desktop + application/pdf=pdf-reader.desktop + [Added Associations] + text/plain=notepad.desktop;gedit.desktop + application/pdf=pdf-reader.desktop + "###); + + config.deregister_mime_type("text/plain", "notepad.desktop"); + insta::assert_snapshot!(get_config_contents(&config), @r###" + [Default Applications] + application/pdf=pdf-reader.desktop + [Added Associations] + text/plain=gedit.desktop + application/pdf=pdf-reader.desktop + "###); + } + + #[test] + fn test_complex_mime_associations_snapshot() { + let (mut config, _file) = create_temp_config(); + + // Add multiple mime types with multiple applications + let test_cases = [ + ( + "text/plain", + vec!["notepad.desktop", "gedit.desktop", "vim.desktop"], + ), + ( + "application/pdf", + vec!["pdf-reader.desktop", "browser.desktop"], + ), + ("image/jpeg", vec!["image-viewer.desktop", "gimp.desktop"]), + ]; + + for (mime_type, apps) in test_cases.iter() { + for app in apps { + config.register_mime_type(mime_type, app); + } + } + + insta::assert_snapshot!(get_config_contents(&config), @r###" + [Default Applications] + text/plain=notepad.desktop + application/pdf=pdf-reader.desktop + image/jpeg=image-viewer.desktop + [Added Associations] + text/plain=notepad.desktop;gedit.desktop;vim.desktop + application/pdf=pdf-reader.desktop;browser.desktop + image/jpeg=image-viewer.desktop;gimp.desktop + "###); + + // Remove some applications + config.deregister_mime_type("text/plain", "gedit.desktop"); + config.deregister_mime_type("application/pdf", "pdf-reader.desktop"); + + insta::assert_snapshot!(get_config_contents(&config), @r###" + [Default Applications] + text/plain=notepad.desktop + image/jpeg=image-viewer.desktop + [Added Associations] + text/plain=notepad.desktop;vim.desktop + application/pdf=browser.desktop + image/jpeg=image-viewer.desktop;gimp.desktop + "###); + } + + #[test] + fn test_existing_mimeapps() { + // load from test-data/linux/mimeapps.list + let path = test_data().join("linux-menu/mimeapps.list"); + let mut mimeapps = MimeConfig::new(path); + mimeapps.load().unwrap(); + + insta::assert_debug_snapshot!(mimeapps.config.get_map()); + + // Test adding a new mime type + mimeapps.register_mime_type("text/pixi", "pixi-app.desktop"); + + insta::assert_debug_snapshot!(mimeapps.config.get_map()); + insta::assert_snapshot!(mimeapps.to_string()); + + // Test removing an application + mimeapps.deregister_mime_type("text/html", "google-chrome.desktop"); + mimeapps.deregister_mime_type("text/pixi", "pixi-app.desktop"); + + insta::assert_debug_snapshot!(mimeapps.config.get_map()); + } +} diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu-2.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu-2.snap new file mode 100644 index 000000000..9dc7a87d5 --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu-2.snap @@ -0,0 +1,16 @@ +--- +source: crates/rattler_menuinst/src/linux/menu_xml.rs +expression: res +--- + + + Applications + /path/to/system_menu + + Test Menu + test-menu.directory + + Test Menu + + + diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu-3.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu-3.snap new file mode 100644 index 000000000..7946a27d6 --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu-3.snap @@ -0,0 +1,9 @@ +--- +source: crates/rattler_menuinst/src/linux/menu_xml.rs +expression: res +--- + + + Applications + /path/to/system_menu + diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu.snap new file mode 100644 index 000000000..7946a27d6 --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu.snap @@ -0,0 +1,9 @@ +--- +source: crates/rattler_menuinst/src/linux/menu_xml.rs +expression: res +--- + + + Applications + /path/to/system_menu + diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu_xml_structure-2.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu_xml_structure-2.snap new file mode 100644 index 000000000..73a62efdf --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu_xml_structure-2.snap @@ -0,0 +1,16 @@ +--- +source: crates/rattler_menuinst/src/linux/menu_xml.rs +expression: result +--- + + + Applications + + WebMirror + shinythings-webmirror.directory + + shinythings-webmirror.desktop + shinythings-webmirror-admin.desktop + + diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu_xml_structure.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu_xml_structure.snap new file mode 100644 index 000000000..7316724e9 --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__add_and_remove_menu_xml_structure.snap @@ -0,0 +1,23 @@ +--- +source: crates/rattler_menuinst/src/linux/menu_xml.rs +expression: result +--- + + +Applications + + WebMirror + shinythings-webmirror.directory + + shinythings-webmirror.desktop + shinythings-webmirror-admin.desktop + + + Test Menu + test-menu.directory + + Test Menu + + + diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__remove_menu_xml_structure.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__remove_menu_xml_structure.snap new file mode 100644 index 000000000..7055eba36 --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__menu_xml__tests__remove_menu_xml_structure.snap @@ -0,0 +1,10 @@ +--- +source: crates/rattler_menuinst/src/linux/menu_xml.rs +expression: result +--- + + + Applications + /path/to/system_menu + diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-2.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-2.snap new file mode 100644 index 000000000..769426fdd --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-2.snap @@ -0,0 +1,33 @@ +--- +source: crates/rattler_menuinst/src/linux/mime_config.rs +expression: mimeapps.config.get_map() +--- +Some( + { + "Default Applications": { + "text/html": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/http": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/https": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/about": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/unknown": Some( + "google-chrome.desktop", + ), + "text/pixi": Some( + "pixi-app.desktop", + ), + }, + "Added Associations": { + "text/pixi": Some( + "pixi-app.desktop", + ), + }, + }, +) diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-3.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-3.snap new file mode 100644 index 000000000..136dc7fba --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-3.snap @@ -0,0 +1,13 @@ +--- +source: crates/rattler_menuinst/src/linux/mime_config.rs +expression: mimeapps.to_string() +--- +[Default Applications] +text/html=google-chrome.desktop +x-scheme-handler/http=google-chrome.desktop +x-scheme-handler/https=google-chrome.desktop +x-scheme-handler/about=google-chrome.desktop +x-scheme-handler/unknown=google-chrome.desktop +text/pixi=pixi-app.desktop +[Added Associations] +text/pixi=pixi-app.desktop diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-4.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-4.snap new file mode 100644 index 000000000..3c8169446 --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps-4.snap @@ -0,0 +1,22 @@ +--- +source: crates/rattler_menuinst/src/linux/mime_config.rs +expression: mimeapps.config.get_map() +--- +Some( + { + "Default Applications": { + "x-scheme-handler/unknown": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/http": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/https": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/about": Some( + "google-chrome.desktop", + ), + }, + }, +) diff --git a/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps.snap b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps.snap new file mode 100644 index 000000000..ae870dcd6 --- /dev/null +++ b/crates/rattler_menuinst/src/linux/snapshots/rattler_menuinst__linux__mime_config__tests__existing_mimeapps.snap @@ -0,0 +1,25 @@ +--- +source: crates/rattler_menuinst/src/linux/mime_config.rs +expression: mimeapps.config.get_map() +--- +Some( + { + "Default Applications": { + "text/html": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/http": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/https": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/about": Some( + "google-chrome.desktop", + ), + "x-scheme-handler/unknown": Some( + "google-chrome.desktop", + ), + }, + }, +) diff --git a/crates/rattler_menuinst/src/macos.rs b/crates/rattler_menuinst/src/macos.rs new file mode 100644 index 000000000..28e6a1e7f --- /dev/null +++ b/crates/rattler_menuinst/src/macos.rs @@ -0,0 +1,919 @@ +use std::{ + collections::HashMap, + io::{BufWriter, Write}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::Command, +}; + +use fs_err as fs; +use fs_err::File; +use plist::Value; +use rattler_conda_types::Platform; +use rattler_shell::{ + activation::{ActivationVariables, Activator}, + shell, +}; + +use crate::{ + render::{resolve, BaseMenuItemPlaceholders, MenuItemPlaceholders}, + schema::{ + CFBundleDocumentTypesModel, CFBundleURLTypesModel, MacOS, MenuItemCommand, + UTTypeDeclarationModel, + }, + slugify, + util::run_pre_create_command, + utils, MenuInstError, MenuMode, +}; + +#[derive(Debug, Clone)] +pub struct MacOSMenu { + name: String, + prefix: PathBuf, + item: MacOS, + command: MenuItemCommand, + directories: Directories, + placeholders: MenuItemPlaceholders, +} + +#[derive(Debug, Clone)] +pub struct Directories { + /// Path to the .app directory defining the menu item + location: PathBuf, + /// Path to the nested .app directory defining the menu item main + /// application + nested_location: PathBuf, +} + +impl Directories { + pub fn new(menu_mode: MenuMode, bundle_name: &str) -> Self { + let base_location = match menu_mode { + MenuMode::System => PathBuf::from("/"), + MenuMode::User => dirs::home_dir().expect("Failed to get home directory"), + }; + + let location = base_location.join("Applications").join(bundle_name); + let nested_location = location.join("Contents/Resources").join(bundle_name); + + Self { + location, + nested_location, + } + } + + fn resources(&self) -> PathBuf { + self.location.join("Contents/Resources") + } + + fn nested_resources(&self) -> PathBuf { + self.nested_location.join("Contents/Resources") + } + + pub fn create_directories(&self, needs_appkit_launcher: bool) -> Result<(), MenuInstError> { + fs::create_dir_all(self.location.join("Contents/Resources"))?; + fs::create_dir_all(self.location.join("Contents/MacOS"))?; + + if needs_appkit_launcher { + fs::create_dir_all(self.nested_location.join("Contents/Resources"))?; + fs::create_dir_all(self.nested_location.join("Contents/MacOS"))?; + } + + Ok(()) + } +} + +impl UTTypeDeclarationModel { + fn to_plist(&self, placeholders: &MenuItemPlaceholders) -> Value { + let mut type_dict = plist::Dictionary::new(); + type_dict.insert( + "UTTypeConformsTo".into(), + Value::Array( + self.ut_type_conforms_to + .iter() + .map(|s| Value::String(s.resolve(placeholders))) + .collect(), + ), + ); + if let Some(desc) = &self.ut_type_description { + type_dict.insert( + "UTTypeDescription".into(), + Value::String(desc.resolve(placeholders)), + ); + } + if let Some(icon) = &self.ut_type_icon_file { + type_dict.insert( + "UTTypeIconFile".into(), + Value::String(icon.resolve(placeholders)), + ); + } + type_dict.insert( + "UTTypeIdentifier".into(), + Value::String(self.ut_type_identifier.resolve(placeholders)), + ); + if let Some(url) = &self.ut_type_reference_url { + type_dict.insert( + "UTTypeReferenceURL".into(), + Value::String(url.resolve(placeholders)), + ); + } + + let mut tag_spec = plist::Dictionary::new(); + for (k, v) in &self.ut_type_tag_specification { + tag_spec.insert( + k.resolve(placeholders), + Value::Array( + v.iter() + .map(|s| Value::String(s.resolve(placeholders))) + .collect(), + ), + ); + } + + type_dict.insert("UTTypeTagSpecification".into(), Value::Dictionary(tag_spec)); + Value::Dictionary(type_dict) + } +} + +impl CFBundleDocumentTypesModel { + fn to_plist(&self, placeholders: &MenuItemPlaceholders) -> Value { + let mut type_dict = plist::Dictionary::new(); + type_dict.insert( + "CFBundleTypeName".into(), + Value::String(self.cf_bundle_type_name.resolve(placeholders)), + ); + + if let Some(icon) = &self.cf_bundle_type_icon_file { + type_dict.insert( + "CFBundleTypeIconFile".into(), + Value::String(icon.resolve(placeholders)), + ); + } + + if let Some(role) = &self.cf_bundle_type_role { + type_dict.insert("CFBundleTypeRole".into(), Value::String(role.clone())); + } + + type_dict.insert( + "LSItemContentTypes".into(), + Value::Array( + self.ls_item_content_types + .iter() + .map(|s| s.resolve(placeholders).into()) + .collect(), + ), + ); + + type_dict.insert( + "LSHandlerRank".into(), + Value::String(self.ls_handler_rank.clone()), + ); + + Value::Dictionary(type_dict) + } +} + +impl CFBundleURLTypesModel { + fn to_plist(&self, placeholders: &MenuItemPlaceholders) -> Value { + let mut type_dict = plist::Dictionary::new(); + + if let Some(role) = self.cf_bundle_type_role.clone() { + type_dict.insert("CFBundleTypeRole".into(), role.into()); + } + + type_dict.insert( + "CFBundleURLSchemes".into(), + Value::Array( + self.cf_bundle_url_schemes + .iter() + .map(|s| s.resolve(placeholders).into()) + .collect(), + ), + ); + + type_dict.insert( + "CFBundleURLName".into(), + Value::String(self.cf_bundle_url_name.resolve(placeholders)), + ); + + if let Some(icon) = &self.cf_bundle_url_icon_file { + type_dict.insert( + "CFBundleURLIconFile".into(), + Value::String(icon.resolve(placeholders)), + ); + } + + Value::Dictionary(type_dict) + } +} + +impl MacOSMenu { + fn new_impl( + prefix: &Path, + item: MacOS, + command: MenuItemCommand, + menu_mode: MenuMode, + placeholders: &BaseMenuItemPlaceholders, + directories: Option, + ) -> Self { + let name = command + .name + .resolve(crate::schema::Environment::Base, placeholders); + + let bundle_name = format!("{name}.app"); + let directories = directories.unwrap_or(Directories::new(menu_mode, &bundle_name)); + tracing::info!("Editing menu item for {bundle_name}"); + + let refined_placeholders = placeholders.refine(&directories.location); + Self { + name, + prefix: prefix.to_path_buf(), + item, + command, + directories, + placeholders: refined_placeholders, + } + } + + #[cfg(test)] + pub fn new_with_directories( + prefix: &Path, + item: MacOS, + command: MenuItemCommand, + menu_mode: MenuMode, + placeholders: &BaseMenuItemPlaceholders, + directories: Directories, + ) -> Self { + Self::new_impl( + prefix, + item, + command, + menu_mode, + placeholders, + Some(directories), + ) + } + + pub fn new( + prefix: &Path, + item: MacOS, + command: MenuItemCommand, + menu_mode: MenuMode, + placeholders: &BaseMenuItemPlaceholders, + ) -> Self { + Self::new_impl(prefix, item, command, menu_mode, placeholders, None) + } + + /// In macOS, file type and URL protocol associations are handled by the + /// Apple Events system. When the user opens on a file or URL, the system + /// will send an Apple Event to the application that was registered as a + /// handler. We need a special launcher to handle these events and pass + /// them to the wrapped application in the shortcut. + /// + /// See: + /// - + /// - The source code at /src/appkit-launcher in this repository + fn needs_appkit_launcher(&self) -> bool { + self.item.event_handler.is_some() + } + + // Run pre-create command + pub fn precreate(&self) -> Result<(), MenuInstError> { + if let Some(precreate) = &self.command.precreate { + let pre_create_command = precreate.resolve(&self.placeholders); + run_pre_create_command(&pre_create_command)?; + } + + for (src, dest) in self + .item + .link_in_bundle + .as_ref() + .unwrap_or(&HashMap::new()) + .iter() + { + let src = src.resolve(&self.placeholders); + let dest = dest.resolve(&self.placeholders); + let rendered_dest = self.directories.location.join(&dest); + if !rendered_dest.starts_with(&self.directories.location) { + return Err(MenuInstError::InstallError(format!( + "'link_in_bundle' destinations MUST be created inside the .app bundle ({}), but it points to '{}'.", + self.directories.location.display(), + rendered_dest.display() + ))); + } + + if let Some(parent) = rendered_dest.parent() { + fs::create_dir_all(parent)?; + } + + fs_err::os::unix::fs::symlink(&src, &rendered_dest)?; + + tracing::info!( + "MenuInst: link finished {src} to {}", + rendered_dest.display() + ); + } + Ok(()) + } + + pub fn install_icon(&self) -> Result<(), MenuInstError> { + if let Some(icon) = self.command.icon.as_ref() { + let icon = PathBuf::from(icon.resolve(&self.placeholders)); + let icon_name = icon.file_name().expect("Failed to get icon name"); + let dest = self.directories.resources().join(icon_name); + fs::copy(&icon, &dest)?; + + tracing::info!("Installed icon to {}", dest.display()); + + if self.needs_appkit_launcher() { + let dest = self.directories.nested_resources().join(icon_name); + fs::copy(&icon, dest)?; + } + } else { + tracing::info!("No icon to install"); + } + + Ok(()) + } + + fn write_pkg_info(&self) -> Result<(), MenuInstError> { + let create_pkg_info = |path: &PathBuf, short_name: &str| -> Result<(), MenuInstError> { + let path = path.join("Contents/PkgInfo"); + tracing::debug!("Writing pkg info to {}", path.display()); + let mut f = fs::File::create(&path)?; + f.write_all(format!("APPL{short_name}").as_bytes())?; + Ok(()) + }; + let short_name = slugify(&self.name.chars().take(8).collect::()); + + create_pkg_info(&self.directories.location, &short_name)?; + if self.needs_appkit_launcher() { + create_pkg_info(&self.directories.nested_location, &short_name)?; + } + + Ok(()) + } + + fn write_plist_info(&self) -> Result<(), MenuInstError> { + let name = self.name.clone(); + let slugname = slugify(&name); + let shortname = if slugname.len() > 16 { + // let hashed = format!("{:x}", Sha256::digest(slugname.as_bytes())); + // TODO + let hashed = "123456"; + format!("{}{}", &slugname[..10], &hashed[..6]) + } else { + slugname.clone() + }; + + let mut pl = plist::Dictionary::new(); + + let bundle_name = resolve(&self.item.cf_bundle_name, &self.placeholders, &shortname); + pl.insert("CFBundleName".into(), Value::String(bundle_name)); + + let display_name = resolve(&self.item.cf_bundle_display_name, &self.placeholders, &name); + pl.insert("CFBundleDisplayName".into(), Value::String(display_name)); + + // This one is _not_ part of the schema, so we just set it + pl.insert("CFBundleExecutable".into(), Value::String(slugname.clone())); + + pl.insert( + "CFBundleIdentifier".into(), + Value::String(format!("com.{slugname}")), + ); + pl.insert("CFBundlePackageType".into(), Value::String("APPL".into())); + + let cf_bundle_version = resolve(&self.item.cf_bundle_version, &self.placeholders, "1.0.0"); + pl.insert( + "CFBundleVersion".into(), + Value::String(cf_bundle_version.clone()), + ); + + pl.insert( + "CFBundleGetInfoString".into(), + Value::String(format!("{slugname}-{cf_bundle_version}")), + ); + + pl.insert( + "CFBundleShortVersionString".into(), + Value::String(cf_bundle_version), + ); + + if let Some(icon) = &self.command.icon { + let resolved_icon = icon.resolve(&self.placeholders); + if let Some(icon_name) = Path::new(&resolved_icon) + .file_name() + .and_then(|name| name.to_str()) + { + pl.insert("CFBundleIconFile".into(), Value::String(icon_name.into())); + } else { + tracing::warn!("Failed to extract icon name from path: {:?}", resolved_icon); + } + } + + if let Some(cf_bundle_types_model) = &self.item.cf_bundle_document_types { + let mut types_array = Vec::new(); + for cf_bundle_type in cf_bundle_types_model { + types_array.push(cf_bundle_type.to_plist(&self.placeholders)); + } + pl.insert("CFBundleDocumentTypes".into(), Value::Array(types_array)); + } + + if let Some(cf_bundle_spoken_names) = &self.item.cf_bundle_spoken_name { + pl.insert( + "CFBundleSpokenName".into(), + Value::String(cf_bundle_spoken_names.resolve(&self.placeholders)), + ); + } + + if self.needs_appkit_launcher() { + tracing::debug!( + "Writing plist to {}", + self.directories + .nested_location + .join("Contents/Info.plist") + .display() + ); + plist::to_file_xml( + self.directories.nested_location.join("Contents/Info.plist"), + &pl, + )?; + pl.insert("LSBackgroundOnly".into(), Value::Boolean(true)); + pl.insert( + "CFBundleIdentifier".into(), + Value::String(format!("com.{slugname}-appkit-launcher")), + ); + } + + if let Some(category) = self.item.ls_application_category_type.as_ref() { + pl.insert( + "LSApplicationCategoryType".into(), + Value::String(category.clone()), + ); + } + + if let Some(background_only) = self.item.ls_background_only { + pl.insert("LSBackgroundOnly".into(), Value::Boolean(background_only)); + } + + if let Some(env) = self.item.ls_environment.as_ref() { + let mut env_dict = plist::Dictionary::new(); + for (k, v) in env { + env_dict.insert(k.into(), Value::String(v.resolve(&self.placeholders))); + } + pl.insert("LSEnvironment".into(), Value::Dictionary(env_dict)); + } + + if let Some(version) = self.item.ls_minimum_system_version.as_ref() { + pl.insert( + "LSMinimumSystemVersion".into(), + Value::String(version.clone()), + ); + } + + if let Some(prohibited) = self.item.ls_multiple_instances_prohibited { + pl.insert( + "LSMultipleInstancesProhibited".into(), + Value::Boolean(prohibited), + ); + } + + if let Some(ns_supports_automatic_graphics_switching) = + self.item.ns_supports_automatic_graphics_switching + { + pl.insert( + "NSSupportsAutomaticGraphicsSwitching".into(), + Value::Boolean(ns_supports_automatic_graphics_switching), + ); + } + + if let Some(requires_native) = self.item.ls_requires_native_execution { + pl.insert( + "LSRequiresNativeExecution".into(), + Value::Boolean(requires_native), + ); + } + + if let Some(ut_exported_type_declarations) = &self.item.ut_exported_type_declarations { + let mut type_array = Vec::new(); + for ut_type in ut_exported_type_declarations { + type_array.push(ut_type.to_plist(&self.placeholders)); + } + pl.insert( + "UTExportedTypeDeclarations".into(), + Value::Array(type_array), + ); + } + + if let Some(ut_imported_type_declarations) = &self.item.ut_imported_type_declarations { + let mut type_array = Vec::new(); + for ut_type in ut_imported_type_declarations { + type_array.push(ut_type.to_plist(&self.placeholders)); + } + pl.insert( + "UTImportedTypeDeclarations".into(), + Value::Array(type_array), + ); + } + + if let Some(cf_bundle_url_types) = &self.item.cf_bundle_url_types { + let mut url_array = Vec::new(); + for url_type in cf_bundle_url_types { + url_array.push(url_type.to_plist(&self.placeholders)); + } + pl.insert("CFBundleURLTypes".into(), Value::Array(url_array)); + } + + tracing::info!( + "Writing plist to {}", + self.directories + .location + .join("Contents/Info.plist") + .display() + ); + plist::to_file_xml(self.directories.location.join("Contents/Info.plist"), &pl)?; + + Ok(()) + } + + fn sign_with_entitlements(&self) -> Result<(), MenuInstError> { + // write a plist file with the entitlements to the filesystem + let mut entitlements = plist::Dictionary::new(); + if let Some(entitlements_list) = &self.item.entitlements { + for e in entitlements_list { + entitlements.insert(e.to_string(), Value::Boolean(true)); + } + } else { + return Ok(()); + } + + let entitlements_file = self + .directories + .location + .join("Contents/Entitlements.plist"); + let writer = BufWriter::new(File::create(&entitlements_file)?); + plist::to_writer_xml(writer, &entitlements)?; + + // sign the .app directory with the entitlements + let _codesign = Command::new("/usr/bin/codesign") + .arg("--verbose") + .arg("--sign") + .arg("-") + .arg("--force") + .arg("--deep") + .arg("--options") + .arg("runtime") + .arg("--prefix") + .arg(format!("com.{}", slugify(&self.name))) + .arg("--entitlements") + .arg(&entitlements_file) + .arg(self.directories.location.to_str().unwrap()) + .output()?; + + Ok(()) + } + + fn command(&self) -> String { + let mut lines = vec!["#!/bin/sh".to_string()]; + + if self.command.terminal.unwrap_or(false) { + lines.extend_from_slice(&[ + r#"if [ "${__CFBundleIdentifier:-}" != "com.apple.Terminal" ]; then"#.to_string(), + r#" open -b com.apple.terminal "$0""#.to_string(), + r#" exit $?"#.to_string(), + "fi".to_string(), + ]); + } + + if let Some(working_dir) = self.command.working_dir.as_ref() { + let working_dir = working_dir.resolve(&self.placeholders); + fs::create_dir_all(&working_dir).expect("Failed to create working directory"); + lines.push(format!("cd \"{working_dir}\"")); + } + + if let Some(precommand) = &self.command.precommand { + lines.push(precommand.resolve(&self.placeholders)); + } + + // Run a cached activation + if self.command.activate.unwrap_or(false) { + // create a bash activation script and emit it into the script + let activator = + Activator::from_path(&self.prefix, shell::Bash, Platform::current()).unwrap(); + let activation_env = activator + .run_activation(ActivationVariables::default(), None) + .unwrap(); + + for (k, v) in activation_env { + lines.push(format!(r#"export {k}="{v}""#)); + } + } + + let command = self + .command + .command + .iter() + .map(|s| s.resolve(&self.placeholders)); + lines.push(utils::quote_args(command).join(" ")); + + lines.join("\n") + } + + fn write_appkit_launcher(&self) -> Result { + // let launcher_path = launcher_path.unwrap_or_else(|| + // self.default_appkit_launcher_path()); + #[cfg(target_arch = "aarch64")] + let launcher_bytes = include_bytes!("../data/appkit_launcher_arm64"); + #[cfg(target_arch = "x86_64")] + let launcher_bytes = include_bytes!("../data/appkit_launcher_x86_64"); + + let launcher_path = self.default_appkit_launcher_path(); + let mut file = File::create(&launcher_path)?; + file.write_all(launcher_bytes)?; + fs::set_permissions(&launcher_path, std::fs::Permissions::from_mode(0o755))?; + + Ok(launcher_path) + } + + fn write_launcher(&self) -> Result { + #[cfg(target_arch = "aarch64")] + let launcher_bytes = include_bytes!("../data/osx_launcher_arm64"); + #[cfg(target_arch = "x86_64")] + let launcher_bytes = include_bytes!("../data/osx_launcher_x86_64"); + + let launcher_path = self.default_launcher_path(); + let mut file = File::create(&launcher_path)?; + file.write_all(launcher_bytes)?; + fs::set_permissions(&launcher_path, std::fs::Permissions::from_mode(0o755))?; + + Ok(launcher_path) + } + + fn write_script(&self, script_path: Option) -> Result { + let script_path = script_path.unwrap_or_else(|| { + PathBuf::from(format!( + "{}-script", + self.default_launcher_path().to_string_lossy() + )) + }); + tracing::info!("Writing script to {}", script_path.display()); + let mut file = File::create(&script_path)?; + file.write_all(self.command().as_bytes())?; + fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + Ok(script_path) + } + + fn write_event_handler( + &self, + script_path: Option, + ) -> Result, MenuInstError> { + if !self.needs_appkit_launcher() { + return Ok(None); + } + + let event_handler_logic = match self.item.event_handler.as_ref() { + Some(logic) => logic.resolve(&self.placeholders), + None => return Ok(None), + }; + + let script_path = script_path.unwrap_or_else(|| { + self.directories + .location + .join("Contents/Resources/handle-event") + }); + + let mut file = File::create(&script_path)?; + file.write_all(format!("#!/bin/bash\n{event_handler_logic}\n").as_bytes())?; + fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + Ok(Some(script_path)) + } + + fn default_appkit_launcher_path(&self) -> PathBuf { + let name = slugify(&self.name); + self.directories.location.join("Contents/MacOS").join(&name) + } + + fn default_launcher_path(&self) -> PathBuf { + let name = slugify(&self.name); + if self.needs_appkit_launcher() { + self.directories + .nested_location + .join("Contents/MacOS") + .join(&name) + } else { + self.directories.location.join("Contents/MacOS").join(&name) + } + } + + fn maybe_register_with_launchservices(&self, register: bool) -> Result<(), MenuInstError> { + if !self.needs_appkit_launcher() { + return Ok(()); + } + + if register { + Self::lsregister(&["-R", self.directories.location.to_str().unwrap()]) + } else { + Self::lsregister(&[ + "-R", + "-u", + "-all", + self.directories.location.to_str().unwrap(), + ]) + } + } + + fn lsregister(args: &[&str]) -> Result<(), MenuInstError> { + let exe = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; + tracing::debug!("Calling lsregister with args: {:?}", args); + let output = Command::new(exe).args(args).output().map_err(|e| { + MenuInstError::InstallError(format!("Failed to execute lsregister: {e}")) + })?; + + if !output.status.success() { + return Err(MenuInstError::InstallError(format!( + "lsregister failed with exit code: {}", + output.status + ))); + } + + Ok(()) + } + + pub fn install(&self) -> Result<(), MenuInstError> { + self.directories + .create_directories(self.needs_appkit_launcher())?; + self.precreate()?; + self.install_icon()?; + self.write_pkg_info()?; + self.write_plist_info()?; + self.write_appkit_launcher()?; + self.write_launcher()?; + self.write_script(None)?; + self.write_event_handler(None)?; + self.maybe_register_with_launchservices(true)?; + self.sign_with_entitlements()?; + Ok(()) + } + + pub fn remove(&self) -> Result, MenuInstError> { + tracing::info!("Removing menu item {}", self.directories.location.display()); + self.maybe_register_with_launchservices(false)?; + if self.directories.location.exists() { + fs_err::remove_dir_all(&self.directories.location).unwrap_or_else(|e| { + tracing::warn!("Failed to remove directory: {e}. Ignoring error."); + }); + Ok(vec![self.directories.location.clone()]) + } else { + Ok(vec![]) + } + } +} + +pub(crate) fn install_menu_item( + prefix: &Path, + macos_item: MacOS, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + menu_mode: MenuMode, +) -> Result<(), MenuInstError> { + let menu = MacOSMenu::new(prefix, macos_item, command, menu_mode, placeholders); + menu.install() +} + +pub(crate) fn remove_menu_item( + prefix: &Path, + macos_item: MacOS, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + menu_mode: MenuMode, +) -> Result, MenuInstError> { + let menu = MacOSMenu::new(prefix, macos_item, command, menu_mode, placeholders); + menu.remove() +} + +#[cfg(test)] +mod tests { + use crate::{schema::MenuInstSchema, test::test_data, MenuMode}; + use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + }; + use tempfile::TempDir; + + impl super::Directories { + pub fn new_test() -> Self { + // Create a temporary directory for testing + Self { + location: tempfile::tempdir().unwrap().into_path(), + nested_location: tempfile::tempdir().unwrap().into_path(), + } + } + } + + #[test] + fn test_directories() { + let dirs = super::Directories::new_test(); + assert!(dirs.location.exists()); + assert!(dirs.nested_location.exists()); + } + + struct FakePlaceholders { + placeholders: HashMap, + } + + impl AsRef> for FakePlaceholders { + fn as_ref(&self) -> &HashMap { + &self.placeholders + } + } + + struct FakePrefix { + _tmp_dir: TempDir, + prefix_path: PathBuf, + schema: MenuInstSchema, + } + + impl FakePrefix { + fn new(schema_json: &Path) -> Self { + let tmp_dir = TempDir::new().unwrap(); + let prefix_path = tmp_dir.path().join("test-env"); + let schema_json = test_data().join(schema_json); + let menu_folder = prefix_path.join("Menu"); + + fs::create_dir_all(&menu_folder).unwrap(); + fs::copy( + &schema_json, + menu_folder.join(schema_json.file_name().unwrap()), + ) + .unwrap(); + + // Create a icon file for the + let schema = std::fs::read_to_string(schema_json).unwrap(); + let parsed_schema: MenuInstSchema = serde_json::from_str(&schema).unwrap(); + + let mut placeholders = HashMap::::new(); + placeholders.insert( + "MENU_DIR".to_string(), + menu_folder.to_string_lossy().to_string(), + ); + + for item in &parsed_schema.menu_items { + let icon = item.command.icon.as_ref().unwrap(); + for ext in &["icns", "png", "svg"] { + placeholders.insert("ICON_EXT".to_string(), ext.to_string()); + let icon_path = icon.resolve(FakePlaceholders { + placeholders: placeholders.clone(), + }); + fs::write(&icon_path, &[]).unwrap(); + } + } + + fs::create_dir_all(prefix_path.join("bin")).unwrap(); + fs::write(prefix_path.join("bin/python"), &[]).unwrap(); + + Self { + _tmp_dir: tmp_dir, + prefix_path, + schema: parsed_schema, + } + } + + pub fn prefix(&self) -> &Path { + &self.prefix_path + } + } + + #[test] + fn test_macos_menu_installation() { + let dirs = super::Directories::new_test(); + let fake_prefix = FakePrefix::new(Path::new("spyder/menu.json")); + + let placeholders = super::BaseMenuItemPlaceholders::new( + &fake_prefix.prefix(), + &fake_prefix.prefix(), + rattler_conda_types::Platform::current(), + ); + + let item = fake_prefix.schema.menu_items[0].clone(); + let macos = item.platforms.osx.unwrap(); + let command = item.command.merge(macos.base); + + let menu = super::MacOSMenu::new_with_directories( + fake_prefix.prefix(), + macos.specific, + command, + MenuMode::User, + &placeholders, + dirs.clone(), + ); + menu.install().unwrap(); + + assert!(dirs.location.exists()); + assert!(dirs.nested_location.exists()); + + // check that the plist file was created + insta::assert_snapshot!( + fs::read_to_string(dirs.location.join("Contents/Info.plist")).unwrap() + ); + } +} diff --git a/crates/rattler_menuinst/src/render.rs b/crates/rattler_menuinst/src/render.rs new file mode 100644 index 000000000..0d07c1719 --- /dev/null +++ b/crates/rattler_menuinst/src/render.rs @@ -0,0 +1,150 @@ +//! This should take a `serde_json` file, render it with all variables and then load it as a `MenuInst` struct + +use rattler_conda_types::Platform; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, path::Path}; + +#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)] +#[serde(transparent)] +pub struct PlaceholderString(pub String); + +impl PlaceholderString { + pub fn resolve(&self, placeholder: impl AsRef>) -> String { + replace_placeholders(self.0.clone(), placeholder.as_ref()) + } +} + +#[allow(dead_code)] +pub fn resolve( + input: &Option, + placeholders: impl AsRef>, + default: &str, +) -> String { + match input { + Some(s) => s.resolve(placeholders), + None => default.to_string(), + } +} + +pub struct BaseMenuItemPlaceholders { + placeholders: HashMap, +} + +impl BaseMenuItemPlaceholders { + pub fn new(base_prefix: &Path, prefix: &Path, platform: Platform) -> Self { + let dist_name = |p: &Path| { + p.file_name() + .map_or_else(|| "empty".to_string(), |s| s.to_string_lossy().to_string()) + }; + + let (python, base_python) = if platform.is_windows() { + (prefix.join("python.exe"), base_prefix.join("python.exe")) + } else { + (prefix.join("bin/python"), base_prefix.join("bin/python")) + }; + + let mut vars = HashMap::from([ + ("BASE_PREFIX", base_prefix.to_path_buf()), + ("PREFIX", prefix.to_path_buf()), + ("PYTHON", python), + ("BASE_PYTHON", base_python), + ("MENU_DIR", prefix.join("Menu")), + ("HOME", dirs::home_dir().unwrap_or_default()), + ]); + + if platform.is_windows() { + vars.insert("BIN_DIR", prefix.join("Library/bin")); + vars.insert("SCRIPTS_DIR", prefix.join("Scripts")); + vars.insert("BASE_PYTHONW", base_prefix.join("pythonw.exe")); + vars.insert("PYTHONW", prefix.join("pythonw.exe")); + } else { + vars.insert("BIN_DIR", prefix.join("bin")); + } + + if platform.is_osx() { + vars.insert("PYTHONAPP", prefix.join("python.app/Contents/MacOS/python")); + } + + // vars.insert("MENU_ITEM_LOCATION", menu_item_location.to_path_buf()); + + let mut vars: HashMap = vars + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string_lossy().to_string())) + .collect(); + + let icon_ext = if platform.is_windows() { + "ico" + } else if platform.is_osx() { + "icns" + } else { + "png" + }; + vars.insert("ICON_EXT".to_string(), icon_ext.to_string()); + + vars.insert("DISTRIBUTION_NAME".to_string(), dist_name(prefix)); + vars.insert("ENV_NAME".to_string(), dist_name(prefix)); + + BaseMenuItemPlaceholders { placeholders: vars } + } + + /// Insert the menu item location into the placeholders + /// + /// - On Linux, this is the path to the `.desktop` file + /// - On Windows, this is the path to the start menu `.lnk` file + /// - On macOS, this is the path to the `.app` bundle + pub fn refine(&self, menu_item_location: &Path) -> MenuItemPlaceholders { + let mut vars = self.placeholders.clone(); + vars.insert( + "MENU_ITEM_LOCATION".to_string(), + menu_item_location.to_string_lossy().to_string(), + ); + MenuItemPlaceholders { placeholders: vars } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MenuItemPlaceholders { + placeholders: HashMap, +} + +impl AsRef> for MenuItemPlaceholders { + fn as_ref(&self) -> &HashMap { + &self.placeholders + } +} + +impl AsRef> for BaseMenuItemPlaceholders { + fn as_ref(&self) -> &HashMap { + &self.placeholders + } +} + +/// Replace placeholders in a string with values from a hashmap +/// This only replaces placeholders in the form of {{ key }} (note: while this looks like a Jinja template, it is not). +fn replace_placeholders(mut text: String, replacements: &HashMap) -> String { + for (key, value) in replacements { + let placeholder = format!("{{{{ {key} }}}}"); + text = text.replace(&placeholder, value); + } + text +} + +#[cfg(test)] +mod test { + // use crate::render::render; + + // #[test] + // fn test_render_gnuradio() { + // let test_data = crate::test::test_data(); + // let schema_path = test_data.join("gnuradio/gnuradio-grc.json"); + // + // let placeholders = crate::render::placeholders( + // Path::new("/home/base_prefix"), + // Path::new("/home/prefix"), + // &rattler_conda_types::Platform::Linux64, + // ); + // + // let schema = render(&schema_path, &placeholders).unwrap(); + // insta::assert_debug_snapshot!(schema); + // } +} diff --git a/crates/rattler_menuinst/src/schema.rs b/crates/rattler_menuinst/src/schema.rs new file mode 100644 index 000000000..8f98a1188 --- /dev/null +++ b/crates/rattler_menuinst/src/schema.rs @@ -0,0 +1,607 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::render::{BaseMenuItemPlaceholders, PlaceholderString}; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct MenuItemNameDict { + target_environment_is_base: Option, + target_environment_is_not_base: Option, +} + +/// A platform-specific menu item configuration. +/// +/// This is equivalent to `MenuItem` but without `platforms` field and all fields are optional. +/// All fields default to `None`. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct BasePlatformSpecific { + /// The name of the menu item. + /// + /// Must be at least 1 character long. + pub name: Option, + + /// A longer description of the menu item. + /// + /// Displayed in popup messages. + pub description: Option, + + /// Path to the file representing or containing the icon. + /// + /// Must be at least 1 character long. + pub icon: Option, + + /// Command to run with the menu item. + /// + /// Represented as a list of strings where each string is an argument. + /// Must contain at least one item. + pub command: Option>, + + /// Working directory for the running process. + /// + /// Defaults to user directory on each platform. + /// Must be at least 1 character long. + pub working_dir: Option, + + /// Logic to run before the command is executed. + /// + /// Runs before the env is activated, if applicable. + /// Should be simple, preferably single-line. + pub precommand: Option, + + /// Logic to run before the shortcut is created. + /// + /// Should be simple, preferably single-line. + pub precreate: Option, + + /// Whether to activate the target environment before running `command`. + pub activate: Option, + + /// Whether to run the program in a terminal/console. + /// + /// ### Platform-specific behavior + /// - `Windows`: Only has an effect if `activate` is true + /// - `MacOS`: The application will ignore command-line arguments + pub terminal: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct Platform { + #[serde(flatten)] + pub base: BasePlatformSpecific, + #[serde(flatten)] + pub specific: T, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum NameField { + Simple(PlaceholderString), + Complex(NameComplex), +} + +impl NameField { + pub fn resolve(&self, env: Environment, placeholders: &BaseMenuItemPlaceholders) -> String { + match self { + NameField::Simple(name) => name.resolve(placeholders), + NameField::Complex(complex_name) => match env { + Environment::Base => complex_name + .target_environment_is_base + .resolve(placeholders), + Environment::NotBase => complex_name + .target_environment_is_not_base + .resolve(placeholders), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct NameComplex { + pub target_environment_is_base: PlaceholderString, + pub target_environment_is_not_base: PlaceholderString, +} + +pub enum Environment { + Base, + #[allow(dead_code)] + NotBase, +} + +/// Windows-specific instructions for menu item configuration. +/// +/// Allows overriding global keys for Windows-specific behavior. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct Windows { + /// Whether to create a desktop icon in addition to the Start Menu item. + /// + /// Defaults to `true` in the original implementation. + pub desktop: Option, + + /// Whether to create a quick launch icon in addition to the Start Menu item. + /// + /// Defaults to `true` in the original implementation. + pub quicklaunch: Option, + + /// Windows Terminal profile configuration. + pub terminal_profile: Option, + + /// URL protocols that will be associated with this program. + /// + /// Each protocol must contain no whitespace characters. + pub url_protocols: Option>, + + /// File extensions that will be associated with this program. + /// + /// Each extension must start with a dot and contain no whitespace. + pub file_extensions: Option>, + + /// Application User Model ID for Windows 7 and above. + /// + /// Used to associate processes, files and windows with a particular application. + /// Required when shortcut produces duplicated icons. + /// + /// # Format + /// - Must contain at least two segments separated by dots + /// - Maximum length of 128 characters + /// - No whitespace allowed + /// + /// # Default + /// If not set, defaults to `Menuinst.` + /// + /// For more information, see [Microsoft's AppUserModelID documentation](https://learn.microsoft.com/en-us/windows/win32/shell/appids#how-to-form-an-application-defined-appusermodelid) + pub app_user_model_id: Option, +} + +/// Linux-specific instructions. +/// Check the `Desktop entry specification ` for more details. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct Linux { + /// Categories in which the entry should be shown in a menu. + /// See 'Registered categories' in the `Menu Spec `. + #[serde(rename = "Categories")] + pub categories: Option>, + + /// A boolean value specifying if D-Bus activation is supported for this application. + #[serde(rename = "DBusActivatable")] + pub dbus_activatable: Option, + + /// Generic name of the application; e.g. if the name is 'conda', + /// this would be 'Package Manager'. + #[serde(rename = "GenericName")] + pub generic_name: Option, + + /// Disable shortcut, signaling a missing resource. + #[serde(rename = "Hidden")] + pub hidden: Option, + + /// List of supported interfaces. See 'Interfaces' in + /// `Desktop Entry Spec `. + #[serde(rename = "Implements")] + pub implements: Option>, + + /// Additional terms to describe this shortcut to aid in searching. + #[serde(rename = "Keywords")] + pub keywords: Option>, + + /// Do not show the 'New Window' option in the app's context menu. + #[serde(rename = "SingleMainWindow")] + pub single_main_window: Option, + + /// The MIME type(s) supported by this application. Note this includes file + /// types and URL protocols. For URL protocols, use + /// `x-scheme-handler/your-protocol-here`. For example, if you want to + /// register `menuinst:`, you would include `x-scheme-handler/menuinst`. + #[serde(rename = "MimeType")] + pub mime_type: Option>, + + /// Do not show this item in the menu. Useful to associate MIME types + /// and other registrations, without having an actual clickable item. + /// Not to be confused with 'Hidden'. + #[serde(rename = "NoDisplay")] + pub no_display: Option, + + /// Desktop environments that should NOT display this item. + /// It'll check against `$XDG_CURRENT_DESKTOP`." + #[serde(rename = "NotShowIn")] + pub not_show_in: Option>, + + /// Desktop environments that should display this item. + /// It'll check against `$XDG_CURRENT_DESKTOP`. + #[serde(rename = "OnlyShowIn")] + pub only_show_in: Option>, + + /// Hint that the app prefers to be run on a more powerful discrete GPU if available. + #[serde(rename = "PrefersNonDefaultGPU")] + pub prefers_non_default_gpu: Option, + + /// Advanced. See `Startup Notification spec `. + #[serde(rename = "StartupNotify")] + pub startup_notify: Option, + + /// Advanced. See `Startup Notification spec `. + #[serde(rename = "StartupWMClass")] + pub startup_wm_class: Option, + + /// Filename or absolute path to an executable file on disk used to + /// determine if the program is actually installed and can be run. If the test + /// fails, the shortcut might be ignored / hidden. + #[serde(rename = "TryExec")] + pub try_exec: Option, + + /// Map of custom MIME types to their corresponding glob patterns (e.g. `*.txt`). + /// Only needed if you define custom MIME types in `MimeType`. + pub glob_patterns: Option>, +} + +/// Describes a URL scheme associated with the app. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct CFBundleURLTypesModel { + /// This key specifies the app's role with respect to the URL. + /// Can be one of `Editor`, `Viewer`, `Shell`, `None` + #[serde(rename = "CFBundleTypeRole")] + pub cf_bundle_type_role: Option, + + /// URL schemes / protocols handled by this type (e.g. 'mailto'). + #[serde(rename = "CFBundleURLSchemes")] + pub cf_bundle_url_schemes: Vec, + + /// Abstract name for this URL type. Uniqueness recommended. + #[serde(rename = "CFBundleURLName")] + pub cf_bundle_url_name: PlaceholderString, + + /// Name of the icon image file (minus the .icns extension). + #[serde(rename = "CFBundleURLIconFile")] + pub cf_bundle_url_icon_file: Option, +} + +/// Describes a document type associated with the app. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct CFBundleDocumentTypesModel { + /// Abstract name for this document type. Uniqueness recommended. + #[serde(rename = "CFBundleTypeName")] + pub cf_bundle_type_name: PlaceholderString, + + /// Name of the icon image file (minus the .icns extension). + #[serde(rename = "CFBundleTypeIconFile")] + pub cf_bundle_type_icon_file: Option, + + /// This key specifies the app's role with respect to the type. + /// Can be one of `Editor`, `Viewer`, `Shell`, `None` + #[serde(rename = "CFBundleTypeRole")] + pub cf_bundle_type_role: Option, + + /// List of UTI (Uniform Type Identifier) strings defining supported file types. + /// + /// # Examples + /// + /// For PNG files, use `public.png` + /// + /// # Details + /// + /// - System-defined UTIs can be found in the [UTI Reference](https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html) + /// - Custom UTIs can be defined via `UTExportedTypeDeclarations` + /// - UTIs from other apps must be imported via `UTImportedTypeDeclarations` + /// + /// For more information, see the [Fun with UTIs](https://www.cocoanetics.com/2012/09/fun-with-uti/) guide. + #[serde(rename = "LSItemContentTypes")] + pub ls_item_content_types: Vec, + + /// Determines how Launch Services ranks this app among the apps + /// that declare themselves editors or viewers of files of this type. + /// Can be one of `Owner`, `Default` or `Alternate` + #[serde(rename = "LSHandlerRank")] + pub ls_handler_rank: String, // TODO implement validation +} + +/// A model representing a Uniform Type Identifier (UTI) declaration. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct UTTypeDeclarationModel { + /// The Uniform Type Identifier types that this type conforms to. + #[serde(rename = "UTTypeConformsTo")] + pub ut_type_conforms_to: Vec, + + /// A description for this type. + #[serde(rename = "UTTypeDescription")] + pub ut_type_description: Option, + + /// The bundle icon resource to associate with this type. + #[serde(rename = "UTTypeIconFile")] + pub ut_type_icon_file: Option, + + /// The Uniform Type Identifier to assign to this type. + #[serde(rename = "UTTypeIdentifier")] + pub ut_type_identifier: PlaceholderString, + + /// The webpage for a reference document that describes this type. + #[serde(rename = "UTTypeReferenceURL")] + pub ut_type_reference_url: Option, + + /// A dictionary defining one or more equivalent type identifiers. + #[serde(rename = "UTTypeTagSpecification")] + pub ut_type_tag_specification: HashMap>, +} + +/// macOS specific fields in the menuinst. For more information on the keys, read the following URLs +/// +/// - `CF*` keys: see `Core Foundation Keys ` +/// - `LS*` keys: see `Launch Services Keys ` +/// - `entitlements`: see `entitlements docs ` +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct MacOS { + /// Display name of the bundle, visible to users and used by Siri. If + /// not provided, 'menuinst' will use the 'name' field. + #[serde(rename = "CFBundleDisplayName")] + pub cf_bundle_display_name: Option, + + /// Unique identifier for the shortcut. Typically uses a reverse-DNS format. + /// If not provided, a identifier will be generated from the 'name' field. + #[serde(rename = "CFBundleIdentifier")] + pub cf_bundle_identifier: Option, + + /// Short name of the bundle. May be used if `CFBundleDisplayName` is + /// absent. If not provided, 'menuinst' will generate one from the 'name' field. + #[serde(rename = "CFBundleName")] + pub cf_bundle_name: Option, + + /// Suitable replacement for text-to-speech operations on the app. + /// For example, "my app one two three" instead of `MyApp123`. + #[serde(rename = "CFBundleSpokenName")] + pub cf_bundle_spoken_name: Option, + + /// Build version number for the bundle. In the context of 'menuinst' + /// this can be used to signal a new version of the menu item for the same + /// application version. + #[serde(rename = "CFBundleVersion")] + pub cf_bundle_version: Option, + + /// URL types supported by this app. Requires setting `event_handler` too. + /// Note this feature requires macOS 10.15+. + #[serde(rename = "CFBundleURLTypes")] + pub cf_bundle_url_types: Option>, + + /// Document types supported by this app. Requires setting `event_handler` too. + /// Requires macOS 10.15+. + #[serde(rename = "CFBundleDocumentTypes")] + pub cf_bundle_document_types: Option>, + + /// The App Store uses this string to determine the appropriate categorization. + #[serde(rename = "LSApplicationCategoryType")] + pub ls_application_category_type: Option, + + /// Specifies whether this app runs only in the background + #[serde(rename = "LSBackgroundOnly")] + pub ls_background_only: Option, + + /// List of key-value pairs used to define environment variables. + #[serde(rename = "LSEnvironment")] + pub ls_environment: Option>, + + /// Minimum version of macOS required for this app to run, as `x.y.z`. + /// For example, for macOS v10.4 and later, use `10.4.0`. (TODO: implement proper parsing) + #[serde(rename = "LSMinimumSystemVersion")] + pub ls_minimum_system_version: Option, + + /// Whether an app is prohibited from running simultaneously in multiple user sessions. + #[serde(rename = "LSMultipleInstancesProhibited")] + pub ls_multiple_instances_prohibited: Option, + + /// If true, prevent a universal binary from being run under + /// Rosetta emulation on an Intel-based Mac. + #[serde(rename = "LSRequiresNativeExecution")] + pub ls_requires_native_execution: Option, + + /// The uniform type identifiers owned and exported by the app. + #[serde(rename = "UTExportedTypeDeclarations")] + pub ut_exported_type_declarations: Option>, + + /// The uniform type identifiers inherently supported, but not owned, by the app. + #[serde(rename = "UTImportedTypeDeclarations")] + pub ut_imported_type_declarations: Option>, + + /// If true, allows an OpenGL app to utilize the integrated GPU. + #[serde(rename = "NSSupportsAutomaticGraphicsSwitching")] + pub ns_supports_automatic_graphics_switching: Option, + + /// List of permissions to request for the launched application. + /// See `the entitlements docs ` + /// for a full list of possible values. + pub entitlements: Option>, + + /// Paths that should be symlinked into the shortcut app bundle. + /// It takes a mapping of source to destination paths. Destination paths must be + /// relative. Placeholder `{{ MENU_ITEM_LOCATION }}` can be useful. + pub link_in_bundle: Option>, + + /// Required shell script logic to handle opened URL payloads. + pub event_handler: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct Platforms { + pub linux: Option>, + pub osx: Option>, + pub win: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct MenuItem { + #[serde(flatten)] + pub command: MenuItemCommand, + pub platforms: Platforms, +} + +/// Instructions to create a menu item across operating systems. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct MenuItemCommand { + /// The name of the menu item. + /// + /// Must be at least 1 character long. + pub name: NameField, + + /// A longer description of the menu item. + /// + /// Displayed in popup messages. + pub description: PlaceholderString, + + /// Command to run with the menu item. + /// + /// Represented as a list of strings where each string is an argument. + /// Must contain at least one item. + pub command: Vec, + + /// Path to the file representing or containing the icon. + /// + /// Must be at least 1 character long when provided. + pub icon: Option, + + /// Logic to run before the command is executed. + /// + /// Should be simple, preferably single-line. + /// Runs before the environment is activated, if applicable. + pub precommand: Option, + + /// Logic to run before the shortcut is created. + /// + /// Should be simple, preferably single-line. + pub precreate: Option, + + /// Working directory for the running process. + /// + /// Defaults to user directory on each platform. + /// Must be at least 1 character long when provided. + pub working_dir: Option, + + /// Whether to activate the target environment before running `command`. + /// + /// Defaults to `true` in the original implementation. + pub activate: Option, + + /// Whether to run the program in a terminal/console. + /// + /// Defaults to `false` in the original implementation. + /// + /// # Platform-specific behavior + /// - `Windows`: Only has an effect if `activate` is true + /// - `MacOS`: The application will ignore command-line arguments + pub terminal: Option, +} + +impl MenuItemCommand { + /// Merge the generic `MenuItemCommand` with a platform-specific `BasePlatformSpecific`. + pub fn merge(&self, platform: BasePlatformSpecific) -> MenuItemCommand { + MenuItemCommand { + name: platform.name.unwrap_or_else(|| self.name.clone()), + description: platform + .description + .unwrap_or_else(|| self.description.clone()), + command: platform.command.unwrap_or_else(|| self.command.clone()), + icon: platform.icon.as_ref().or(self.icon.as_ref()).cloned(), + precommand: platform.precommand.or_else(|| self.precommand.clone()), + precreate: platform.precreate.or_else(|| self.precreate.clone()), + working_dir: platform.working_dir.or_else(|| self.working_dir.clone()), + activate: platform.activate.or(self.activate), + terminal: platform.terminal.or(self.terminal), + } + } +} + +/// Metadata required to create menu items across operating systems with `menuinst` +#[derive(Serialize, Deserialize, Debug)] +pub struct MenuInstSchema { + /// Standard of the JSON schema we adhere to. + #[serde(rename = "$schema")] + pub schema: String, + + /// Name for the category containing the items specified in `menu_items`. + pub menu_name: String, + + /// List of menu entries to create across main desktop systems. + pub menu_items: Vec, +} + +#[cfg(test)] +mod test { + use crate::render::BaseMenuItemPlaceholders; + use rattler_conda_types::Platform; + use std::path::{Path, PathBuf}; + + pub(crate) fn test_data() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("test-data") + } + + #[test] + fn test_deserialize_gnuradio() { + let test_data = test_data(); + let schema_path = test_data.join("gnuradio/gnuradio-grc.json"); + let schema_str = std::fs::read_to_string(schema_path).unwrap(); + let schema: super::MenuInstSchema = serde_json::from_str(&schema_str).unwrap(); + insta::assert_debug_snapshot!(schema); + } + + #[test] + fn test_deserialize_mne() { + let test_data = test_data(); + let schema_path = test_data.join("mne/menu.json"); + let schema_str = std::fs::read_to_string(schema_path).unwrap(); + let schema: super::MenuInstSchema = serde_json::from_str(&schema_str).unwrap(); + insta::assert_debug_snapshot!(schema); + } + + #[test] + fn test_deserialize_grx() { + let test_data = test_data(); + let schema_path = test_data.join("gqrx/gqrx-menu.json"); + let schema_str = std::fs::read_to_string(schema_path).unwrap(); + let schema: super::MenuInstSchema = serde_json::from_str(&schema_str).unwrap(); + insta::assert_debug_snapshot!(schema); + } + + #[test] + fn test_deserialize_spyder() { + let test_data = test_data(); + let schema_path = test_data.join("spyder/menu.json"); + let schema_str = std::fs::read_to_string(schema_path).unwrap(); + let schema: super::MenuInstSchema = serde_json::from_str(&schema_str).unwrap(); + + let item = schema.menu_items[0].clone(); + let macos_item = item.platforms.osx.clone().unwrap(); + let command = item.command.merge(macos_item.base); + let placeholders = BaseMenuItemPlaceholders::new( + Path::new("base_prefix"), + Path::new("prefix"), + Platform::Linux64, + ); + + assert_eq!( + command + .name + .resolve(super::Environment::Base, &placeholders), + "Spyder 6 (prefix)" + ); + + insta::assert_debug_snapshot!(schema); + } + + /// Test against the defaults file from original menuinst + #[test] + fn test_deserialize_defaults() { + let test_data = test_data(); + let schema_path = test_data.join("defaults/defaults.json"); + let schema_str = std::fs::read_to_string(schema_path).unwrap(); + let schema: super::MenuInstSchema = serde_json::from_str(&schema_str).unwrap(); + insta::assert_debug_snapshot!(schema); + } +} diff --git a/crates/rattler_menuinst/src/slugify.rs b/crates/rattler_menuinst/src/slugify.rs new file mode 100644 index 000000000..19f4509ab --- /dev/null +++ b/crates/rattler_menuinst/src/slugify.rs @@ -0,0 +1,53 @@ +use regex::Regex; +use unicode_normalization::UnicodeNormalization; + +pub fn slugify(text: &str) -> String { + // Normalize the text and remove non-ASCII characters + let normalized = text.nfkd().filter(char::is_ascii).collect::(); + + // Remove special characters, convert to lowercase, and trim + let re_special = Regex::new(r"[^\w\s-]").unwrap(); + let without_special = re_special.replace_all(&normalized, "").to_string(); + let trimmed = without_special.trim().to_lowercase(); + + // Replace whitespace and hyphens with a single hyphen + let re_spaces = Regex::new(r"[_\s-]+").unwrap(); + re_spaces.replace_all(&trimmed, "-").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_slugify() { + assert_eq!(slugify("Hello World"), "hello-world"); + assert_eq!(slugify("Hello, World!"), "hello-world"); + } + + #[test] + fn test_special_characters() { + assert_eq!( + slugify("Hello, World! How are you?"), + "hello-world-how-are-you" + ); + } + + #[test] + fn test_multiple_spaces() { + assert_eq!( + slugify("This has many spaces"), + "this-has-many-spaces" + ); + } + + #[test] + fn test_non_ascii_characters() { + assert_eq!(slugify("Héllö Wörld"), "hello-world"); + } + + #[test] + fn test_leading_trailing_spaces() { + assert_eq!(slugify(" Trim me "), "trim-me"); + } +} diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation-2.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation-2.snap new file mode 100644 index 000000000..761d31df4 --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation-2.snap @@ -0,0 +1,8 @@ +--- +source: crates/rattler_menuinst/src/linux.rs +expression: mimeapps_file_content +--- +[Default Applications] +text/x-spython=Spyder 6 (test-env) +[Added Associations] +text/x-spython=Spyder 6 (test-env) diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation-3.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation-3.snap new file mode 100644 index 000000000..83ec13657 --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation-3.snap @@ -0,0 +1,11 @@ +--- +source: crates/rattler_menuinst/src/linux.rs +expression: mime_file_content +--- + + + + + Custom MIME type text/x-spython for '*.spy' files (registered by menuinst) + + diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation.snap new file mode 100644 index 000000000..115e0dc30 --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__linux__tests__installation.snap @@ -0,0 +1,15 @@ +--- +source: crates/rattler_menuinst/src/linux.rs +expression: desktop_file_content +--- +[Desktop Entry] +Type=Application +Encoding=UTF-8 +Name=Spyder 6 (test-env) +Exec=bash -c '/bin/spyder %F' +Terminal=false +Icon=/Menu/spyder.png +Comment=Scientific PYthon Development EnviRonment +Categories=Development;Science; +MimeType=text/x-spython; +StartupWMClass=Spyder-6.test-env diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__macos__tests__macos_menu_installation.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__macos__tests__macos_menu_installation.snap new file mode 100644 index 000000000..35b14479c --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__macos__tests__macos_menu_installation.snap @@ -0,0 +1,141 @@ +--- +source: crates/rattler_menuinst/src/macos.rs +expression: "fs::read_to_string(dirs.location.join(\"Contents/Info.plist\")).unwrap()" +--- + + + + + CFBundleName + Spyder 6 + CFBundleDisplayName + Spyder 6 (test-env) + CFBundleExecutable + spyder-6-test-env + CFBundleIdentifier + com.spyder-6-test-env + CFBundlePackageType + APPL + CFBundleVersion + 6.0.2 + CFBundleGetInfoString + spyder-6-test-env-6.0.2 + CFBundleShortVersionString + 6.0.2 + CFBundleIconFile + spyder.icns + CFBundleDocumentTypes + + + CFBundleTypeName + text document + CFBundleTypeIconFile + spyder.icns + CFBundleTypeRole + Editor + LSItemContentTypes + + com.apple.applescript.text + com.apple.ascii-property-list + com.apple.audio-unit-preset + com.apple.binary-property-list + com.apple.configprofile + com.apple.crashreport + com.apple.dashcode.css + com.apple.dashcode.javascript + com.apple.dashcode.json + com.apple.dashcode.manifest + com.apple.dt.document.ascii-property-list + com.apple.dt.document.script-suite-property-list + com.apple.dt.document.script-terminology-property-list + com.apple.property-list + com.apple.rez-source + com.apple.scripting-definition + com.apple.structured-text + com.apple.traditional-mac-plain-text + com.apple.xcode.ada-source + com.apple.xcode.apinotes + com.apple.xcode.bash-script + com.apple.xcode.configsettings + com.apple.xcode.csh-script + com.apple.xcode.entitlements-property-list + com.apple.xcode.fortran-source + com.apple.xcode.glsl-source + com.apple.xcode.ksh-script + com.apple.xcode.lex-source + com.apple.xcode.make-script + com.apple.xcode.mig-source + com.apple.xcode.pascal-source + com.apple.xcode.strings-text + com.apple.xcode.tcsh-script + com.apple.xcode.yacc-source + com.apple.xcode.zsh-script + com.apple.xml-property-list + com.netscape.javascript-source + com.scenarist.closed-caption + com.sun.java-source + com.sun.java-web-start + net.daringfireball.markdown + org.khronos.glsl-source + org.oasis-open.xliff + public.ada-source + public.assembly-source + public.bash-script + public.c-header + public.c-plus-plus-header + public.c-plus-plus-source + public.c-source + public.case-insensitive-text + public.comma-separated-values-text + public.csh-script + public.css + public.delimited-values-text + public.dylan-source + public.filename-extension + public.fortran-77-source + public.fortran-90-source + public.fortran-95-source + public.fortran-source + public.html + public.json + public.ksh-script + public.lex-source + public.log + public.m3u-playlist + public.make-source + public.mig-source + public.mime-type + public.module-map + public.nasm-assembly-source + public.objective-c-plus-plus-source + public.objective-c-source + public.opencl-source + public.pascal-source + public.patch-file + public.perl-script + public.php-script + public.plain-text + public.python-script + public.rss + public.ruby-script + public.script + public.shell-script + public.source-code + public.tcsh-script + public.text + public.utf16-external-plain-text + public.utf16-plain-text + public.utf8-plain-text + public.utf8-tab-separated-values-text + public.xhtml + public.xml + public.yacc-source + public.yaml + public.zsh-script + + LSHandlerRank + Default + + + + diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__render__test__render_gnuradio.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__render__test__render_gnuradio.snap new file mode 100644 index 000000000..324929628 --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__render__test__render_gnuradio.snap @@ -0,0 +1,639 @@ +--- +source: crates/rattler_menuinst/src/render.rs +expression: schema +--- +MenuInstSchema { + id: "https://schemas.conda.io/menuinst-1.schema.json", + schema: "https://json-schema.org/draft-07/schema", + menu_name: "MNE-Python (__PKG_VERSION__)", + menu_items: [ + MenuItem { + name: "Spyder (MNE)", + description: "The Spyder development environment", + command: [ + "will be overridden in platforms section", + ], + icon: Some( + "/home/prefix/menu/mne_spyder.png", + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + true, + ), + terminal: Some( + false, + ), + platforms: Platforms { + linux: Some( + Linux { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "spyder", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + categories: Some( + [ + "Science", + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + ), + osx: Some( + MacOS { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "spyder", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + cf_bundle_display_name: Some( + "Spyder (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + "Spyder (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + "__PKG_VERSION__", + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ns_supports_automatic_graphics_switching: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + ), + win: Some( + Windows { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "{{ PYTHONW }}", + "{{ SCRIPTS_DIR }}\\spyder-script.py", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + ), + }, + }, + MenuItem { + name: "System Info (MNE)", + description: "Information on the MNE-Python runtime environment", + command: [ + "/home/prefix/bin/python", + "/home/prefix/menu/mne_sys_info.py", + ], + icon: Some( + "/home/prefix/menu/mne_info.png", + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + true, + ), + terminal: Some( + true, + ), + platforms: Platforms { + linux: Some( + Linux { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + categories: Some( + [ + "Science", + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + ), + osx: Some( + MacOS { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + cf_bundle_display_name: Some( + "System Information (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + "System Information (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + "__PKG_VERSION__", + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ns_supports_automatic_graphics_switching: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + ), + win: Some( + Windows { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + ), + }, + }, + MenuItem { + name: "Prompt (MNE)", + description: "MNE-Python console prompt", + command: [ + "will be overridden in platforms section", + ], + icon: Some( + "/home/prefix/menu/mne_console.png", + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + true, + ), + terminal: Some( + true, + ), + platforms: Platforms { + linux: Some( + Linux { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "exec", + "bash", + "--init-file", + "/home/prefix/menu/mne_open_prompt.sh", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + categories: Some( + [ + "Science", + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + ), + osx: Some( + MacOS { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "osascript", + "/home/prefix/menu/mne_open_prompt.applescript", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + cf_bundle_display_name: Some( + "Prompt (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + "Prompt (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + "__PKG_VERSION__", + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ns_supports_automatic_graphics_switching: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + ), + win: Some( + Windows { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "%SystemRoot%\\system32\\cmd.exe", + "/K", + "/home/prefix/menu\\mne_open_prompt.bat", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + ), + }, + }, + MenuItem { + name: "Tutorials (MNE)", + description: "MNE-Python online tutorials", + command: [ + "will be overridden in platforms section", + ], + icon: Some( + "/home/prefix/menu/mne_web.png", + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + false, + ), + terminal: Some( + false, + ), + platforms: Platforms { + linux: Some( + Linux { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "xdg-open", + "https://mne.tools/stable/auto_tutorials/", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + categories: Some( + [ + "Science", + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + ), + osx: Some( + MacOS { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "open", + "https://mne.tools/stable/auto_tutorials/", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + cf_bundle_display_name: Some( + "Tutorials (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + "Tutorials (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + "__PKG_VERSION__", + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ns_supports_automatic_graphics_switching: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + ), + win: Some( + Windows { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "%SystemRoot%\\system32\\WindowsPowerShell\\v1.0\\powershell.exe", + "\"start https://mne.tools/stable/auto_tutorials -WindowStyle hidden\"", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + ), + }, + }, + MenuItem { + name: "User Forum (MNE)", + description: "MNE-Python forum for discussions, problem solving, and information exchange", + command: [ + "will be overridden in platforms section", + ], + icon: Some( + "/home/prefix/menu/mne_forum.png", + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + false, + ), + terminal: Some( + false, + ), + platforms: Platforms { + linux: Some( + Linux { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "xdg-open", + "https://mne.discourse.group", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + categories: Some( + [ + "Science", + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + ), + osx: Some( + MacOS { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "open", + "https://mne.discourse.group", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + cf_bundle_display_name: Some( + "Forum (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + "Forum (MNE-Python __PKG_VERSION__)", + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + "__PKG_VERSION__", + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ns_supports_automatic_graphics_switching: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + ), + win: Some( + Windows { + base: BasePlatformSpecific { + name: "", + description: "", + icon: None, + command: [ + "%SystemRoot%\\system32\\WindowsPowerShell\\v1.0\\powershell.exe", + "\"start https://mne.discourse.group -WindowStyle hidden\"", + ], + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + ), + }, + }, + ], +} diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_defaults.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_defaults.snap new file mode 100644 index 000000000..4e359cc1b --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_defaults.snap @@ -0,0 +1,135 @@ +--- +source: crates/rattler_menuinst/src/schema.rs +expression: schema +--- +MenuInstSchema { + schema: "https://schemas.conda.org/menuinst-1.schema.json", + menu_name: "REQUIRED", + menu_items: [ + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "REQUIRED", + ), + ), + description: PlaceholderString( + "REQUIRED", + ), + command: [ + PlaceholderString( + "REQUIRED", + ), + ], + icon: None, + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + true, + ), + terminal: Some( + false, + ), + }, + platforms: Platforms { + linux: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Linux { + categories: None, + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + single_main_window: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + }, + ), + osx: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: MacOS { + cf_bundle_display_name: None, + cf_bundle_identifier: None, + cf_bundle_name: None, + cf_bundle_spoken_name: None, + cf_bundle_version: None, + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + ns_supports_automatic_graphics_switching: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + }, + ), + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + true, + ), + quicklaunch: Some( + false, + ), + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + ], +} diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_gnuradio.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_gnuradio.snap new file mode 100644 index 000000000..1b7059e78 --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_gnuradio.snap @@ -0,0 +1,68 @@ +--- +source: crates/rattler_menuinst/src/schema.rs +expression: schema +--- +MenuInstSchema { + schema: "https://json-schema.org/draft-07/schema", + menu_name: "{{ DISTRIBUTION_NAME }}", + menu_items: [ + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "GNU Radio Companion", + ), + ), + description: PlaceholderString( + "Flowgraph builder for GNU Radio", + ), + command: [ + PlaceholderString( + "{{ BIN_DIR }}/gnuradio-companion", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/grc.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: None, + terminal: None, + }, + platforms: Platforms { + linux: None, + osx: None, + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + false, + ), + quicklaunch: Some( + false, + ), + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + ], +} diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_grx.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_grx.snap new file mode 100644 index 000000000..44565b58a --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_grx.snap @@ -0,0 +1,135 @@ +--- +source: crates/rattler_menuinst/src/schema.rs +expression: schema +--- +MenuInstSchema { + schema: "https://json-schema.org/draft-07/schema", + menu_name: "{{ DISTRIBUTION_NAME }}", + menu_items: [ + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "gqrx", + ), + ), + description: PlaceholderString( + "Software defined radio", + ), + command: [ + PlaceholderString( + "{{ PREFIX }}/Library/bin/gqrx.exe", + ), + PlaceholderString( + "--edit", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/gqrx.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: None, + terminal: None, + }, + platforms: Platforms { + linux: None, + osx: None, + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + false, + ), + quicklaunch: Some( + false, + ), + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "gqrx [reset]", + ), + ), + description: PlaceholderString( + "Software defined radio [reset settings]", + ), + command: [ + PlaceholderString( + "{{ PREFIX }}/Library/bin/gqrx.exe", + ), + PlaceholderString( + "--reset", + ), + PlaceholderString( + "--edit", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/gqrx.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: None, + terminal: None, + }, + platforms: Platforms { + linux: None, + osx: None, + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + false, + ), + quicklaunch: Some( + false, + ), + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + ], +} diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_mne.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_mne.snap new file mode 100644 index 000000000..171e43acf --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_mne.snap @@ -0,0 +1,849 @@ +--- +source: crates/rattler_menuinst/src/schema.rs +expression: schema +--- +MenuInstSchema { + schema: "https://json-schema.org/draft-07/schema", + menu_name: "MNE-Python (__PKG_VERSION__)", + menu_items: [ + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "Spyder (MNE)", + ), + ), + description: PlaceholderString( + "The Spyder development environment", + ), + command: [ + PlaceholderString( + "will be overridden in platforms section", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/spyder.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + true, + ), + terminal: Some( + false, + ), + }, + platforms: Platforms { + linux: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "spyder", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Linux { + categories: Some( + [ + PlaceholderString( + "Science", + ), + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + single_main_window: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + }, + ), + osx: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "spyder", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: MacOS { + cf_bundle_display_name: Some( + PlaceholderString( + "Spyder (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + PlaceholderString( + "Spyder (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + PlaceholderString( + "__PKG_VERSION__", + ), + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + ns_supports_automatic_graphics_switching: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + }, + ), + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "{{ PYTHONW }}", + ), + PlaceholderString( + "{{ SCRIPTS_DIR }}\\spyder-script.py", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "System Info (MNE)", + ), + ), + description: PlaceholderString( + "Information on the MNE-Python runtime environment", + ), + command: [ + PlaceholderString( + "{{ PYTHON }}", + ), + PlaceholderString( + "{{ MENU_DIR }}/mne_sys_info.py", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/info.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + true, + ), + terminal: Some( + true, + ), + }, + platforms: Platforms { + linux: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Linux { + categories: Some( + [ + PlaceholderString( + "Science", + ), + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + single_main_window: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + }, + ), + osx: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: MacOS { + cf_bundle_display_name: Some( + PlaceholderString( + "System Information (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + PlaceholderString( + "System Information (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + PlaceholderString( + "__PKG_VERSION__", + ), + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + ns_supports_automatic_graphics_switching: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + }, + ), + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: None, + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "Prompt (MNE)", + ), + ), + description: PlaceholderString( + "MNE-Python console prompt", + ), + command: [ + PlaceholderString( + "will be overridden in platforms section", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/console.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + true, + ), + terminal: Some( + true, + ), + }, + platforms: Platforms { + linux: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "exec", + ), + PlaceholderString( + "bash", + ), + PlaceholderString( + "--init-file", + ), + PlaceholderString( + "{{ MENU_DIR }}/mne_open_prompt.sh", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Linux { + categories: Some( + [ + PlaceholderString( + "Science", + ), + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + single_main_window: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + }, + ), + osx: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "osascript", + ), + PlaceholderString( + "{{ MENU_DIR }}/mne_open_prompt.applescript", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: MacOS { + cf_bundle_display_name: Some( + PlaceholderString( + "Prompt (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + PlaceholderString( + "Prompt (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + PlaceholderString( + "__PKG_VERSION__", + ), + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + ns_supports_automatic_graphics_switching: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + }, + ), + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "%SystemRoot%\\system32\\cmd.exe", + ), + PlaceholderString( + "/K", + ), + PlaceholderString( + "{{ MENU_DIR }}\\mne_open_prompt.bat", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "Tutorials (MNE)", + ), + ), + description: PlaceholderString( + "MNE-Python online tutorials", + ), + command: [ + PlaceholderString( + "will be overridden in platforms section", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/web.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + false, + ), + terminal: Some( + false, + ), + }, + platforms: Platforms { + linux: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "xdg-open", + ), + PlaceholderString( + "https://mne.tools/stable/auto_tutorials/", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Linux { + categories: Some( + [ + PlaceholderString( + "Science", + ), + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + single_main_window: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + }, + ), + osx: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "open", + ), + PlaceholderString( + "https://mne.tools/stable/auto_tutorials/", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: MacOS { + cf_bundle_display_name: Some( + PlaceholderString( + "Tutorials (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + PlaceholderString( + "Tutorials (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + PlaceholderString( + "__PKG_VERSION__", + ), + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + ns_supports_automatic_graphics_switching: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + }, + ), + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "%SystemRoot%\\system32\\WindowsPowerShell\\v1.0\\powershell.exe", + ), + PlaceholderString( + "\"start https://mne.tools/stable/auto_tutorials -WindowStyle hidden\"", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + MenuItem { + command: MenuItemCommand { + name: Simple( + PlaceholderString( + "User Forum (MNE)", + ), + ), + description: PlaceholderString( + "MNE-Python forum for discussions, problem solving, and information exchange", + ), + command: [ + PlaceholderString( + "will be overridden in platforms section", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/forum.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + false, + ), + terminal: Some( + false, + ), + }, + platforms: Platforms { + linux: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "xdg-open", + ), + PlaceholderString( + "https://mne.discourse.group", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Linux { + categories: Some( + [ + PlaceholderString( + "Science", + ), + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + single_main_window: None, + mime_type: None, + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: None, + try_exec: None, + glob_patterns: None, + }, + }, + ), + osx: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "open", + ), + PlaceholderString( + "https://mne.discourse.group", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: MacOS { + cf_bundle_display_name: Some( + PlaceholderString( + "Forum (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_identifier: None, + cf_bundle_name: Some( + PlaceholderString( + "Forum (MNE-Python __PKG_VERSION__)", + ), + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + PlaceholderString( + "__PKG_VERSION__", + ), + ), + cf_bundle_url_types: None, + cf_bundle_document_types: None, + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + ns_supports_automatic_graphics_switching: None, + entitlements: None, + link_in_bundle: None, + event_handler: None, + }, + }, + ), + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "%SystemRoot%\\system32\\WindowsPowerShell\\v1.0\\powershell.exe", + ), + PlaceholderString( + "\"start https://mne.discourse.group -WindowStyle hidden\"", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + false, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: None, + app_user_model_id: None, + }, + }, + ), + }, + }, + ], +} diff --git a/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_spyder.snap b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_spyder.snap new file mode 100644 index 000000000..36e3b70b0 --- /dev/null +++ b/crates/rattler_menuinst/src/snapshots/rattler_menuinst__schema__test__deserialize_spyder.snap @@ -0,0 +1,546 @@ +--- +source: crates/rattler_menuinst/src/schema.rs +expression: schema +--- +MenuInstSchema { + schema: "https://json-schema.org/draft-07/schema", + menu_name: "{{ DISTRIBUTION_NAME }} spyder", + menu_items: [ + MenuItem { + command: MenuItemCommand { + name: Complex( + NameComplex { + target_environment_is_base: PlaceholderString( + "Spyder 6 ({{ DISTRIBUTION_NAME }})", + ), + target_environment_is_not_base: PlaceholderString( + "Spyder 6 ({{ ENV_NAME }})", + ), + }, + ), + description: PlaceholderString( + "Scientific PYthon Development EnviRonment", + ), + command: [ + PlaceholderString( + "", + ), + ], + icon: Some( + PlaceholderString( + "{{ MENU_DIR }}/spyder.{{ ICON_EXT }}", + ), + ), + precommand: None, + precreate: None, + working_dir: None, + activate: Some( + false, + ), + terminal: Some( + false, + ), + }, + platforms: Platforms { + linux: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "{{ PREFIX }}/bin/spyder", + ), + PlaceholderString( + "%F", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Linux { + categories: Some( + [ + PlaceholderString( + "Development", + ), + PlaceholderString( + "Science", + ), + ], + ), + dbus_activatable: None, + generic_name: None, + hidden: None, + implements: None, + keywords: None, + single_main_window: None, + mime_type: Some( + [ + PlaceholderString( + "text/x-spython", + ), + ], + ), + no_display: None, + not_show_in: None, + only_show_in: None, + prefers_non_default_gpu: None, + startup_notify: None, + startup_wm_class: Some( + PlaceholderString( + "Spyder-6.{{ ENV_NAME }}", + ), + ), + try_exec: None, + glob_patterns: Some( + { + PlaceholderString( + "text/x-spython", + ): PlaceholderString( + "*.spy", + ), + }, + ), + }, + }, + ), + osx: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "./python", + ), + PlaceholderString( + "{{ PREFIX }}/bin/spyder", + ), + PlaceholderString( + "$@", + ), + ], + ), + working_dir: None, + precommand: Some( + PlaceholderString( + "pushd \"$(dirname \"$0\")\" &>/dev/null", + ), + ), + precreate: None, + activate: None, + terminal: None, + }, + specific: MacOS { + cf_bundle_display_name: None, + cf_bundle_identifier: Some( + PlaceholderString( + "org.spyder-ide.Spyder-6-.prefix", + ), + ), + cf_bundle_name: Some( + PlaceholderString( + "Spyder 6", + ), + ), + cf_bundle_spoken_name: None, + cf_bundle_version: Some( + PlaceholderString( + "6.0.2", + ), + ), + cf_bundle_url_types: None, + cf_bundle_document_types: Some( + [ + CFBundleDocumentTypesModel { + cf_bundle_type_name: PlaceholderString( + "text document", + ), + cf_bundle_type_icon_file: Some( + PlaceholderString( + "spyder.icns", + ), + ), + cf_bundle_type_role: Some( + "Editor", + ), + ls_item_content_types: [ + PlaceholderString( + "com.apple.applescript.text", + ), + PlaceholderString( + "com.apple.ascii-property-list", + ), + PlaceholderString( + "com.apple.audio-unit-preset", + ), + PlaceholderString( + "com.apple.binary-property-list", + ), + PlaceholderString( + "com.apple.configprofile", + ), + PlaceholderString( + "com.apple.crashreport", + ), + PlaceholderString( + "com.apple.dashcode.css", + ), + PlaceholderString( + "com.apple.dashcode.javascript", + ), + PlaceholderString( + "com.apple.dashcode.json", + ), + PlaceholderString( + "com.apple.dashcode.manifest", + ), + PlaceholderString( + "com.apple.dt.document.ascii-property-list", + ), + PlaceholderString( + "com.apple.dt.document.script-suite-property-list", + ), + PlaceholderString( + "com.apple.dt.document.script-terminology-property-list", + ), + PlaceholderString( + "com.apple.property-list", + ), + PlaceholderString( + "com.apple.rez-source", + ), + PlaceholderString( + "com.apple.scripting-definition", + ), + PlaceholderString( + "com.apple.structured-text", + ), + PlaceholderString( + "com.apple.traditional-mac-plain-text", + ), + PlaceholderString( + "com.apple.xcode.ada-source", + ), + PlaceholderString( + "com.apple.xcode.apinotes", + ), + PlaceholderString( + "com.apple.xcode.bash-script", + ), + PlaceholderString( + "com.apple.xcode.configsettings", + ), + PlaceholderString( + "com.apple.xcode.csh-script", + ), + PlaceholderString( + "com.apple.xcode.entitlements-property-list", + ), + PlaceholderString( + "com.apple.xcode.fortran-source", + ), + PlaceholderString( + "com.apple.xcode.glsl-source", + ), + PlaceholderString( + "com.apple.xcode.ksh-script", + ), + PlaceholderString( + "com.apple.xcode.lex-source", + ), + PlaceholderString( + "com.apple.xcode.make-script", + ), + PlaceholderString( + "com.apple.xcode.mig-source", + ), + PlaceholderString( + "com.apple.xcode.pascal-source", + ), + PlaceholderString( + "com.apple.xcode.strings-text", + ), + PlaceholderString( + "com.apple.xcode.tcsh-script", + ), + PlaceholderString( + "com.apple.xcode.yacc-source", + ), + PlaceholderString( + "com.apple.xcode.zsh-script", + ), + PlaceholderString( + "com.apple.xml-property-list", + ), + PlaceholderString( + "com.netscape.javascript-source", + ), + PlaceholderString( + "com.scenarist.closed-caption", + ), + PlaceholderString( + "com.sun.java-source", + ), + PlaceholderString( + "com.sun.java-web-start", + ), + PlaceholderString( + "net.daringfireball.markdown", + ), + PlaceholderString( + "org.khronos.glsl-source", + ), + PlaceholderString( + "org.oasis-open.xliff", + ), + PlaceholderString( + "public.ada-source", + ), + PlaceholderString( + "public.assembly-source", + ), + PlaceholderString( + "public.bash-script", + ), + PlaceholderString( + "public.c-header", + ), + PlaceholderString( + "public.c-plus-plus-header", + ), + PlaceholderString( + "public.c-plus-plus-source", + ), + PlaceholderString( + "public.c-source", + ), + PlaceholderString( + "public.case-insensitive-text", + ), + PlaceholderString( + "public.comma-separated-values-text", + ), + PlaceholderString( + "public.csh-script", + ), + PlaceholderString( + "public.css", + ), + PlaceholderString( + "public.delimited-values-text", + ), + PlaceholderString( + "public.dylan-source", + ), + PlaceholderString( + "public.filename-extension", + ), + PlaceholderString( + "public.fortran-77-source", + ), + PlaceholderString( + "public.fortran-90-source", + ), + PlaceholderString( + "public.fortran-95-source", + ), + PlaceholderString( + "public.fortran-source", + ), + PlaceholderString( + "public.html", + ), + PlaceholderString( + "public.json", + ), + PlaceholderString( + "public.ksh-script", + ), + PlaceholderString( + "public.lex-source", + ), + PlaceholderString( + "public.log", + ), + PlaceholderString( + "public.m3u-playlist", + ), + PlaceholderString( + "public.make-source", + ), + PlaceholderString( + "public.mig-source", + ), + PlaceholderString( + "public.mime-type", + ), + PlaceholderString( + "public.module-map", + ), + PlaceholderString( + "public.nasm-assembly-source", + ), + PlaceholderString( + "public.objective-c-plus-plus-source", + ), + PlaceholderString( + "public.objective-c-source", + ), + PlaceholderString( + "public.opencl-source", + ), + PlaceholderString( + "public.pascal-source", + ), + PlaceholderString( + "public.patch-file", + ), + PlaceholderString( + "public.perl-script", + ), + PlaceholderString( + "public.php-script", + ), + PlaceholderString( + "public.plain-text", + ), + PlaceholderString( + "public.python-script", + ), + PlaceholderString( + "public.rss", + ), + PlaceholderString( + "public.ruby-script", + ), + PlaceholderString( + "public.script", + ), + PlaceholderString( + "public.shell-script", + ), + PlaceholderString( + "public.source-code", + ), + PlaceholderString( + "public.tcsh-script", + ), + PlaceholderString( + "public.text", + ), + PlaceholderString( + "public.utf16-external-plain-text", + ), + PlaceholderString( + "public.utf16-plain-text", + ), + PlaceholderString( + "public.utf8-plain-text", + ), + PlaceholderString( + "public.utf8-tab-separated-values-text", + ), + PlaceholderString( + "public.xhtml", + ), + PlaceholderString( + "public.xml", + ), + PlaceholderString( + "public.yacc-source", + ), + PlaceholderString( + "public.yaml", + ), + PlaceholderString( + "public.zsh-script", + ), + ], + ls_handler_rank: "Default", + }, + ], + ), + ls_application_category_type: None, + ls_background_only: None, + ls_environment: None, + ls_minimum_system_version: None, + ls_multiple_instances_prohibited: None, + ls_requires_native_execution: None, + ut_exported_type_declarations: None, + ut_imported_type_declarations: None, + ns_supports_automatic_graphics_switching: None, + entitlements: None, + link_in_bundle: Some( + { + PlaceholderString( + "{{ PREFIX }}/bin/python", + ): PlaceholderString( + "{{ MENU_ITEM_LOCATION }}/Contents/MacOS/python", + ), + }, + ), + event_handler: None, + }, + }, + ), + win: Some( + Platform { + base: BasePlatformSpecific { + name: None, + description: None, + icon: None, + command: Some( + [ + PlaceholderString( + "{{ PREFIX }}/Scripts/spyder.exe", + ), + PlaceholderString( + "%*", + ), + ], + ), + working_dir: None, + precommand: None, + precreate: None, + activate: None, + terminal: None, + }, + specific: Windows { + desktop: Some( + true, + ), + quicklaunch: None, + terminal_profile: None, + url_protocols: None, + file_extensions: Some( + [ + ".enaml", + ".ipy", + ".py", + ".pyi", + ".pyw", + ".pyx", + ], + ), + app_user_model_id: Some( + PlaceholderString( + "spyder-ide.Spyder-6.{{ ENV_NAME }}", + ), + ), + }, + }, + ), + }, + }, + ], +} diff --git a/crates/rattler_menuinst/src/util.rs b/crates/rattler_menuinst/src/util.rs new file mode 100644 index 000000000..b64a9f86c --- /dev/null +++ b/crates/rattler_menuinst/src/util.rs @@ -0,0 +1,50 @@ +#[allow(dead_code)] +pub fn log_output(cmd_info: &str, output: std::process::Output) { + tracing::info!("{}: status {}", cmd_info, output.status); + tracing::info!( + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(target_family = "unix")] +pub fn run_pre_create_command(pre_create_command: &str) -> Result<(), crate::MenuInstError> { + use std::os::unix::fs::PermissionsExt; + use std::{io::Write, process::Command}; + + use fs_err as fs; + + use crate::MenuInstError; + + let mut temp_file = tempfile::NamedTempFile::with_suffix(".sh")?; + temp_file.write_all(pre_create_command.as_bytes())?; + let temp_path = temp_file.into_temp_path(); + + // Mark the file as executable or run it with bash + let mut cmd = if pre_create_command.starts_with("!#") { + fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o755))?; + Command::new(&temp_path) + } else { + let mut cmd = Command::new("bash"); + cmd.arg(&temp_path); + cmd + }; + + let output = cmd.output()?; + if !output.status.success() { + tracing::error!( + "Failed to run pre-create command (status: {}): \nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + return Err(MenuInstError::InstallError(format!( + "Failed to run pre-create command: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + + Ok(()) +} diff --git a/crates/rattler_menuinst/src/utils/mod.rs b/crates/rattler_menuinst/src/utils/mod.rs new file mode 100644 index 000000000..1ea366d0a --- /dev/null +++ b/crates/rattler_menuinst/src/utils/mod.rs @@ -0,0 +1,9 @@ +pub fn quote_args(args: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter() + .map(|arg| format!(r#""{}""#, arg.as_ref())) + .collect() +} diff --git a/crates/rattler_menuinst/src/windows.rs b/crates/rattler_menuinst/src/windows.rs new file mode 100644 index 000000000..4d6f7891a --- /dev/null +++ b/crates/rattler_menuinst/src/windows.rs @@ -0,0 +1,452 @@ +use fs_err as fs; +use rattler_conda_types::Platform; +use rattler_shell::{ + activation::{ActivationVariables, Activator}, + shell, +}; +use registry::notify_shell_changes; +use std::{ + io::Write as _, + path::{Path, PathBuf}, +}; + +use crate::{ + render::{BaseMenuItemPlaceholders, MenuItemPlaceholders}, + schema::{Environment, MenuItemCommand, Windows}, + slugify, + util::log_output, + MenuInstError, MenuMode, +}; + +mod create_shortcut; +mod knownfolders; +mod lex; +mod registry; + +use knownfolders::UserHandle; + +pub struct Directories { + start_menu: PathBuf, + quick_launch: Option, + desktop: PathBuf, +} + +fn shortcut_filename(name: &str, env_name: Option<&String>, ext: Option<&str>) -> String { + let env = if let Some(env_name) = env_name { + format!(" ({})", env_name) + } else { + "".to_string() + }; + + let ext = ext.unwrap_or_else(|| "lnk"); + format!("{}{}{}", name, env, ext) +} + +/// On Windows we can create shortcuts in several places: +/// - Start Menu +/// - Desktop +/// - Quick launch (only for user installs) +impl Directories { + pub fn create(menu_mode: MenuMode) -> Directories { + let user_handle = match menu_mode { + MenuMode::System => UserHandle::Common, + MenuMode::User => UserHandle::Current, + }; + + let known_folders = knownfolders::Folders::new(); + let start_menu = known_folders.get_folder_path("start", user_handle).unwrap(); + let quick_launch = if menu_mode == MenuMode::User { + known_folders + .get_folder_path("quick_launch", user_handle) + .ok() + } else { + None + }; + let desktop = known_folders + .get_folder_path("desktop", user_handle) + .unwrap(); + + Directories { + start_menu, + quick_launch, + desktop, + } + } +} + +pub struct WindowsMenu { + prefix: PathBuf, + name: String, + item: Windows, + command: MenuItemCommand, + directories: Directories, + placeholders: MenuItemPlaceholders, + menu_mode: MenuMode, +} + +const SHORTCUT_EXTENSION: &str = "lnk"; + +impl WindowsMenu { + pub fn new( + prefix: &Path, + item: Windows, + command: MenuItemCommand, + directories: Directories, + placeholders: &BaseMenuItemPlaceholders, + menu_mode: MenuMode, + ) -> Self { + let name = command.name.resolve(Environment::Base, placeholders); + + let shortcut_name = shortcut_filename( + &name, + placeholders.as_ref().get("ENV_NAME"), + Some(SHORTCUT_EXTENSION), + ); + + let location = directories + .start_menu + .join(&shortcut_name) + .with_extension(SHORTCUT_EXTENSION); + + // self.menu.start_menu_location / self._shortcut_filename() + Self { + prefix: prefix.to_path_buf(), + name, + item, + command, + directories, + placeholders: placeholders.refine(&location), + menu_mode, + } + } + + fn script_content(&self) -> Result { + let mut lines = vec![ + "@echo off".to_string(), + ":: Script generated by conda/menuinst".to_string(), + ]; + + if let Some(pre_command_code) = self.command.precommand.as_ref() { + lines.push(pre_command_code.resolve(&self.placeholders)); + } + + if self.command.activate.unwrap_or_default() { + // create a bash activation script and emit it into the script + let activator = + Activator::from_path(&self.prefix, shell::CmdExe, Platform::current()).unwrap(); + let activation_env = activator.run_activation(ActivationVariables::default(), None)?; + + for (k, v) in activation_env { + lines.push(format!(r#"set "{k}={v}""#)); + } + } + + let args: Vec = self + .command + .command + .iter() + .map(|elem| elem.resolve(&self.placeholders)) + .collect(); + + lines.push(lex::quote_args(&args).join(" ")); + + Ok(lines.join("\n")) + } + + fn shortcut_filename(&self, ext: Option<&str>) -> String { + shortcut_filename(&self.name, self.placeholders.as_ref().get("ENV_NAME"), ext) + } + + fn write_script(&self, path: &Path) -> Result<(), MenuInstError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut file = fs::File::create(path)?; + file.write_all(self.script_content()?.as_bytes())?; + + Ok(()) + } + + fn path_for_script(&self) -> PathBuf { + self.prefix + .join("Menu") + .join(self.shortcut_filename(Some("bat"))) + } + + fn build_command(&self, with_arg1: bool) -> Result, MenuInstError> { + if self.command.activate.unwrap_or(false) { + let script_path = self.path_for_script(); + self.write_script(&script_path)?; + + let system_root = std::env::var("SystemRoot").unwrap_or("C:\\Windows".to_string()); + let system32 = Path::new(&system_root).join("system32"); + let cmd_exe = system32.join("cmd.exe").to_string_lossy().to_string(); + + if self.command.terminal.unwrap_or(false) { + let mut command = [&cmd_exe, "/D", "/K"] + .iter() + .map(|s| s.to_string()) + .collect::>(); + + // add script path with quotes + command.push(format!("\"{}\"", script_path.to_string_lossy())); + + if with_arg1 { + command.push("%1".to_string()); + } + Ok(command) + } else { + let script_path = self.path_for_script(); + self.write_script(&script_path)?; + + let arg1 = if with_arg1 { "%1 " } else { "" }; + let powershell = system32 + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe") + .to_string_lossy() + .to_string(); + + let mut command = [ + &cmd_exe, + "/D", + "/C", + "START", + "/MIN", + "\"\"", + &powershell, + "-WindowStyle", + "hidden", + ] + .iter() + .map(|s| s.to_string()) + .collect::>(); + + command.push(format!( + "\"start '{}' {}-WindowStyle hidden\"", + script_path.to_string_lossy(), + arg1 + )); + + Ok(command) + } + } else { + let mut command = Vec::new(); + for elem in self.command.command.iter() { + command.push(elem.resolve(&self.placeholders)); + } + + if with_arg1 && !command.iter().any(|s| s.contains("%1")) { + command.push("%1".to_string()); + } + + Ok(command) + } + } + + fn precreate(&self) -> Result<(), MenuInstError> { + if let Some(precreate_code) = self.command.precreate.as_ref() { + let precreate_code = precreate_code.resolve(&self.placeholders); + + if precreate_code.is_empty() { + return Ok(()); + } + + let temp_file = tempfile::NamedTempFile::with_suffix(".bat")?; + fs::write(temp_file.path(), precreate_code)?; + + let output = std::process::Command::new("cmd") + .arg("/c") + .arg(temp_file.path()) + .output()?; + + log_output("precreate", output); + } + Ok(()) + } + + fn app_id(&self) -> String { + match self.item.app_user_model_id.as_ref() { + Some(aumi) => aumi.resolve(&self.placeholders), + None => format!( + "Menuinst.{}", + slugify(&self.name) + .replace(".", "") + .chars() + .take(128) + .collect::() + ), + } + } + + fn create_shortcut(&self, args: &[String]) -> Result, MenuInstError> { + let mut created_files = Vec::new(); + let icon = self + .command + .icon + .as_ref() + .map(|s| s.resolve(&self.placeholders)); + + let workdir = if let Some(workdir) = &self.command.working_dir { + workdir.resolve(&self.placeholders) + } else { + "%HOMEPATH%".to_string() + }; + + if workdir != "%HOMEPATH%" { + fs::create_dir_all(&workdir)?; + } + + let app_id = self.app_id(); + + // split args into command and arguments + let (command, args) = args.split_first().unwrap(); + let args = lex::quote_args(args).join(" "); + + let link_name = format!("{}.lnk", self.name); + if self.item.desktop.unwrap_or(true) { + let desktop_link_path = self.directories.desktop.join(&link_name); + create_shortcut::create_shortcut( + &command, + &self.command.description.resolve(&self.placeholders), + &desktop_link_path, + Some(&args), + Some(&workdir), + icon.as_deref(), + Some(0), + Some(&app_id), + )?; + created_files.push(desktop_link_path); + } + + if let Some(quick_launch_dir) = self.directories.quick_launch.as_ref() { + if self.item.quicklaunch.unwrap_or(true) { + let quicklaunch_link_path = quick_launch_dir.join(link_name); + create_shortcut::create_shortcut( + &self.name, + &self.command.description.resolve(&self.placeholders), + &quicklaunch_link_path, + Some(&args), + Some(&workdir), + icon.as_deref(), + Some(0), + Some(&app_id), + )?; + created_files.push(quicklaunch_link_path); + } + } + Ok(created_files) + } + + fn icon(&self) -> Option { + self.command + .icon + .as_ref() + .map(|s| s.resolve(&self.placeholders)) + } + + fn register_file_extensions(&self) -> Result<(), MenuInstError> { + let Some(extensions) = &self.item.file_extensions else { + return Ok(()); + }; + + let icon = self.icon(); + let command = self.build_command(true)?.join(" "); + let name = &self.name; + let app_user_model_id = self.app_id(); + + for extension in extensions { + let identifier = format!("{name}.AssocFile{extension}"); + registry::register_file_extension( + extension, + &identifier, + &command, + icon.as_deref(), + Some(name), + Some(&app_user_model_id), + None, // friendly type name currently not set + self.menu_mode, + )?; + } + + Ok(()) + } + + fn register_url_protocols(&self) -> Result { + let protocols = match &self.item.url_protocols { + Some(protocols) if !protocols.is_empty() => protocols, + _ => return Ok(false), + }; + + let command = self.build_command(true)?.join(" "); + let icon = self.icon(); + let name = &self.name; + let app_user_model_id = format!("{name}.Protocol"); + + for protocol in protocols { + let identifier = format!("{name}.Protocol{protocol}"); + registry::register_url_protocol( + protocol, + &command, + Some(&identifier), + icon.as_deref(), + Some(name), + Some(&app_user_model_id), + self.menu_mode, + )?; + } + + Ok(true) + } + + pub fn install(&self) -> Result<(), MenuInstError> { + let args = self.build_command(false)?; + self.precreate()?; + self.create_shortcut(&args)?; + self.register_file_extensions()?; + self.register_url_protocols()?; + notify_shell_changes(); + Ok(()) + } + + pub fn remove(&self) -> Result<(), MenuInstError> { + todo!() + } +} + +pub(crate) fn install_menu_item( + prefix: &Path, + windows_item: Windows, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + menu_mode: MenuMode, +) -> Result<(), MenuInstError> { + let menu = WindowsMenu::new( + prefix, + windows_item, + command, + Directories::create(menu_mode), + placeholders, + menu_mode, + ); + menu.install() +} + +pub(crate) fn remove_menu_item( + prefix: &Path, + specific: Windows, + command: MenuItemCommand, + placeholders: &BaseMenuItemPlaceholders, + menu_mode: MenuMode, +) -> Result<(), MenuInstError> { + let menu = WindowsMenu::new( + prefix, + specific, + command, + Directories::create(menu_mode), + placeholders, + menu_mode, + ); + menu.remove() +} diff --git a/crates/rattler_menuinst/src/windows/create_shortcut.rs b/crates/rattler_menuinst/src/windows/create_shortcut.rs new file mode 100644 index 000000000..6cd699fb6 --- /dev/null +++ b/crates/rattler_menuinst/src/windows/create_shortcut.rs @@ -0,0 +1,163 @@ +use std::path::Path; + +use windows::{ + core::*, Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID, + Win32::System::Com::StructuredStorage::*, Win32::System::Com::*, Win32::UI::Shell::*, +}; +use PropertiesSystem::IPropertyStore; + +/// Create a Windows `.lnk` shortcut file at the specified path. +pub fn create_shortcut( + path: &str, + description: &str, + filename: &Path, + arguments: Option<&str>, + workdir: Option<&str>, + iconpath: Option<&str>, + iconindex: Option, + app_id: Option<&str>, +) -> Result<()> { + tracing::info!("Creating shortcut: {:?} at {}", filename, path); + + unsafe { + // Initialize COM + let co = CoInitialize(None); + if co.is_err() { + panic!("Failed to initialize COM"); + } + + let shell_link: IShellLinkW = + CoCreateInstance(&ShellLink as *const GUID, None, CLSCTX_INPROC_SERVER)?; + + // Get IPersistFile interface + let persist_file: IPersistFile = shell_link.cast()?; + + // Set required properties + shell_link.SetPath(&HSTRING::from(path))?; + shell_link.SetDescription(&HSTRING::from(description))?; + + // Set optional properties + if let Some(args) = arguments { + shell_link.SetArguments(&HSTRING::from(args))?; + } + + if let Some(work_dir) = workdir { + shell_link.SetWorkingDirectory(&HSTRING::from(work_dir))?; + } + + if let Some(icon_path) = iconpath { + shell_link.SetIconLocation(&HSTRING::from(icon_path), iconindex.unwrap_or(0))?; + } + + // Handle App User Model ID if provided + if let Some(app_id_str) = app_id { + let property_store: IPropertyStore = shell_link.cast()?; + let mut prop_variant = InitPropVariantFromStringAsVector(&HSTRING::from(app_id_str))?; + property_store.SetValue(&PKEY_AppUserModel_ID, &prop_variant)?; + property_store.Commit()?; + PropVariantClear(&mut prop_variant)?; + } + + // Save the shortcut + persist_file.Save(&HSTRING::from(filename), true)?; + + CoUninitialize(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + #[test] + fn test_basic_shortcut_creation() { + let result = create_shortcut( + "C:\\Windows\\notepad.exe", + "Notepad Shortcut", + "test_basic.lnk", + None, + None, + None, + None, + None, + ); + + assert!(result.is_ok()); + assert!(Path::new("test_basic.lnk").exists()); + fs::remove_file("test_basic.lnk").unwrap(); + } + + #[test] + fn test_shortcut_with_arguments() { + let result = create_shortcut( + "C:\\Windows\\notepad.exe", + "Notepad with Args", + "test_args.lnk", + Some("/A test.txt"), + None, + None, + None, + None, + ); + + assert!(result.is_ok()); + assert!(Path::new("test_args.lnk").exists()); + fs::remove_file("test_args.lnk").unwrap(); + } + + #[test] + fn test_shortcut_with_all_options() { + let result = create_shortcut( + "C:\\Windows\\notepad.exe", + "Full Options Shortcut", + "test_full.lnk", + Some("/A"), + Some("C:\\Temp"), + Some("C:\\Windows\\notepad.exe"), + Some(0), + Some("MyApp.TestShortcut"), + ); + + assert!(result.is_ok()); + assert!(Path::new("test_full.lnk").exists()); + fs::remove_file("test_full.lnk").unwrap(); + } + + #[test] + fn test_invalid_path() { + let result = create_shortcut( + "C:\\NonExistent\\fake.exe", + "Invalid Path", + "test_invalid.lnk", + None, + None, + None, + None, + None, + ); + + assert!(result.is_ok()); // Note: Windows API doesn't validate path existence + if Path::new("test_invalid.lnk").exists() { + fs::remove_file("test_invalid.lnk").unwrap(); + } + } + + #[test] + fn test_invalid_save_location() { + let result = create_shortcut( + "C:\\Windows\\notepad.exe", + "Invalid Save", + "C:\\NonExistent\\Directory\\test.lnk", + None, + None, + None, + None, + None, + ); + + assert!(result.is_err()); + } +} diff --git a/crates/rattler_menuinst/src/windows/knownfolders.rs b/crates/rattler_menuinst/src/windows/knownfolders.rs new file mode 100644 index 000000000..5b94ee099 --- /dev/null +++ b/crates/rattler_menuinst/src/windows/knownfolders.rs @@ -0,0 +1,129 @@ +use known_folders::{get_known_folder_path, KnownFolder}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub enum FolderError { + PathNotFound, + PathNotVerifiable, +} + +pub struct Folders { + system_folders: HashMap, + user_folders: HashMap, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum UserHandle { + Current, + Common, +} + +impl Folders { + pub fn new() -> Self { + let mut system_folders = HashMap::new(); + system_folders.insert("desktop".to_string(), KnownFolder::PublicDesktop); + system_folders.insert("start".to_string(), KnownFolder::CommonPrograms); + system_folders.insert("documents".to_string(), KnownFolder::PublicDocuments); + system_folders.insert("profile".to_string(), KnownFolder::ProgramData); + + let mut user_folders = HashMap::new(); + user_folders.insert("desktop".to_string(), KnownFolder::Desktop); + user_folders.insert("start".to_string(), KnownFolder::Programs); + user_folders.insert("documents".to_string(), KnownFolder::Documents); + user_folders.insert("profile".to_string(), KnownFolder::Profile); + user_folders.insert("quick_launch".to_string(), KnownFolder::QuickLaunch); + + Folders { + system_folders, + user_folders, + } + } + + pub fn get_folder_path( + &self, + key: &str, + user_handle: UserHandle, + ) -> Result { + self.folder_path(user_handle, true, key) + } + + fn folder_path( + &self, + preferred_mode: UserHandle, + check_other_mode: bool, + key: &str, + ) -> Result { + let (preferred_folders, other_folders) = match preferred_mode { + UserHandle::Current => (&self.user_folders, &self.system_folders), + UserHandle::Common => (&self.system_folders, &self.user_folders), + }; + + if let Some(folder) = preferred_folders.get(key) { + if let Some(path) = get_known_folder_path(*folder) { + return Ok(path); + } + } + + // Implement fallback for user documents + if preferred_mode == UserHandle::Current && key == "documents" { + if let Some(profile_folder) = preferred_folders.get("profile") { + if let Some(profile_path) = get_known_folder_path(*profile_folder) { + let documents_path = profile_path.join("Documents"); + if documents_path.is_dir() { + return Ok(documents_path); + } + } + } + } + + if check_other_mode { + if let Some(folder) = other_folders.get(key) { + if let Some(path) = get_known_folder_path(*folder) { + return Ok(path); + } + } + } + + Err(FolderError::PathNotFound) + } + + pub fn verify_path>(&self, path: P) -> Result { + let path = path.as_ref(); + if path.exists() && path.is_dir() { + Ok(path.to_path_buf()) + } else { + Err(FolderError::PathNotVerifiable) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_folder_path() { + let folders = Folders::new(); + + let test_folders = vec![ + ("desktop", UserHandle::Current), + ("documents", UserHandle::Current), + ("start", UserHandle::Common), + ("profile", UserHandle::Common), + ]; + + for (folder, handle) in test_folders { + match folders.get_folder_path(folder, handle) { + Ok(path) => { + println!("{} path for {:?}: {:?}", folder, handle, path); + match folders.verify_path(&path) { + Ok(_) => println!(" Path verified successfully"), + Err(e) => println!(" Path verification failed: {:?}", e), + } + } + Err(e) => println!("Error getting {} path for {:?}: {:?}", folder, handle, e), + } + } + } +} diff --git a/crates/rattler_menuinst/src/windows/lex.rs b/crates/rattler_menuinst/src/windows/lex.rs new file mode 100644 index 000000000..09115daec --- /dev/null +++ b/crates/rattler_menuinst/src/windows/lex.rs @@ -0,0 +1,75 @@ +pub fn quote_args(args: &[String]) -> Vec { + if args.len() > 2 + && (args[0].to_uppercase().contains("CMD.EXE") + || args[0].to_uppercase().contains("%COMSPEC%")) + && (args[1].to_uppercase() == "/K" || args[1].to_uppercase() == "/C") + && args[2..].iter().any(|arg| arg.contains(' ')) + { + let cmd = ensure_pad(&args[0], '"'); + let flag = args[1].clone(); + let quoted_args = args[2..] + .iter() + .map(|arg| ensure_pad(arg, '"')) + .collect::>() + .join(" "); + vec![cmd, flag, format!("\"{}\"", quoted_args)] + } else { + args.into_iter().map(|s| quote_string(&s)).collect() + } +} + +pub fn quote_string(s: &str) -> String { + let s = s.trim_matches('"').to_string(); + if s.starts_with('-') || s.starts_with(' ') { + s + } else if s.contains(' ') || s.contains('/') { + format!("\"{}\"", s) + } else { + s + } +} + +pub fn ensure_pad(name: &str, pad: char) -> String { + if name.is_empty() || (name.starts_with(pad) && name.ends_with(pad)) { + name.to_string() + } else { + format!("{}{}{}", pad, name, pad) + } +} + +#[cfg(test)] +mod tests { + use super::{ensure_pad, quote_args, quote_string}; + + #[test] + fn test_quote_args() { + let args = vec![ + "cmd.exe".to_string(), + "/C".to_string(), + "echo".to_string(), + "Hello World".to_string(), + ]; + let expected = vec![ + "\"cmd.exe\"".to_string(), + "/C".to_string(), + "\"\"echo\" \"Hello World\"\"".to_string(), + ]; + assert_eq!(quote_args(&args), expected); + } + + #[test] + fn test_quote_string() { + assert_eq!(quote_string("Hello World"), "\"Hello World\""); + assert_eq!(quote_string("Hello"), "Hello"); + assert_eq!(quote_string("-Hello"), "-Hello"); + assert_eq!(quote_string(" Hello"), " Hello"); + assert_eq!(quote_string("Hello/World"), "\"Hello/World\""); + } + + #[test] + fn test_ensure_pad() { + assert_eq!(ensure_pad("conda", '_'), "_conda_"); + assert_eq!(ensure_pad("_conda_", '_'), "_conda_"); + assert_eq!(ensure_pad("", '_'), ""); + } +} diff --git a/crates/rattler_menuinst/src/windows/registry.rs b/crates/rattler_menuinst/src/windows/registry.rs new file mode 100644 index 000000000..caae48dd5 --- /dev/null +++ b/crates/rattler_menuinst/src/windows/registry.rs @@ -0,0 +1,404 @@ +use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST}; +use winreg::enums::*; +use winreg::RegKey; + +use crate::MenuMode; + +pub fn register_file_extension( + extension: &str, + identifier: &str, + command: &str, + icon: Option<&str>, + app_name: Option<&str>, + app_user_model_id: Option<&str>, + friendly_type_name: Option<&str>, + mode: MenuMode, +) -> Result<(), std::io::Error> { + let hkey = if mode == MenuMode::System { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }; + + let classes = + RegKey::predef(hkey).open_subkey_with_flags("Software\\Classes", KEY_ALL_ACCESS)?; + + // Associate extension with handler + let ext_key = classes.create_subkey(&format!("{}\\OpenWithProgids", extension))?; + ext_key.0.set_value(identifier, &"")?; + + // Register the handler + let handler_desc = format!("{} {} file", extension, identifier); + classes + .create_subkey(identifier)? + .0 + .set_value("", &handler_desc)?; + + // Set the 'open' command + let command_key = classes.create_subkey(&format!("{}\\shell\\open\\command", identifier))?; + command_key.0.set_value("", &command)?; + + // Set app name related values if provided + if let Some(name) = app_name { + let open_key = classes.create_subkey(&format!("{}\\shell\\open", identifier))?; + open_key.0.set_value("", &name)?; + classes + .create_subkey(identifier)? + .0 + .set_value("FriendlyAppName", &name)?; + classes + .create_subkey(&format!("{}\\shell\\open", identifier))? + .0 + .set_value("FriendlyAppName", &name)?; + } + + // Set app user model ID if provided + if let Some(id) = app_user_model_id { + classes + .create_subkey(identifier)? + .0 + .set_value("AppUserModelID", &id)?; + } + + // Set icon if provided + if let Some(icon_path) = icon { + // Set default icon and shell open icon + classes + .create_subkey(identifier)? + .0 + .set_value("DefaultIcon", &icon_path)?; + classes + .create_subkey(&format!("{}\\shell\\open", identifier))? + .0 + .set_value("Icon", &icon_path)?; + } + + // Set friendly type name if provided + // NOTE: Windows <10 requires the string in a PE file, but we just set the raw string + if let Some(friendly_name) = friendly_type_name { + classes + .create_subkey(identifier)? + .0 + .set_value("FriendlyTypeName", &friendly_name)?; + } + + Ok(()) +} + +pub fn unregister_file_extension( + extension: &str, + identifier: &str, + mode: MenuMode, +) -> Result<(), std::io::Error> { + let hkey = if mode == MenuMode::System { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }; + + let classes = + RegKey::predef(hkey).open_subkey_with_flags("Software\\Classes", KEY_ALL_ACCESS)?; + + // Delete the identifier key + classes.delete_subkey_all(identifier)?; + + // Remove the association in OpenWithProgids + let ext_key = + classes.open_subkey_with_flags(&format!("{}\\OpenWithProgids", extension), KEY_ALL_ACCESS); + + match ext_key { + Ok(key) => { + if key.get_value::(identifier).is_err() { + tracing::debug!( + "Handler '{}' is not associated with extension '{}'", + identifier, + extension + ); + } else { + key.delete_value(identifier)?; + } + } + Err(e) => { + tracing::error!("Could not check key '{}' for deletion: {}", extension, e); + return Err(e.into()); + } + } + + Ok(()) +} + +fn title_case(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut capitalize_next = true; + + for c in s.chars() { + if c.is_whitespace() || c == '-' || c == '_' { + capitalize_next = true; + result.push(c); + } else if capitalize_next { + result.extend(c.to_uppercase()); + capitalize_next = false; + } else { + result.extend(c.to_lowercase()); + } + } + + result +} + +pub fn register_url_protocol( + protocol: &str, + command: &str, + identifier: Option<&str>, + icon: Option<&str>, + app_name: Option<&str>, + app_user_model_id: Option<&str>, + mode: MenuMode, +) -> Result<(), std::io::Error> { + let base_path = if mode == MenuMode::System { + format!("Software\\Classes\\{}", protocol) + } else { + format!("Software\\Classes\\{}", protocol) + }; + + let hkey = if mode == MenuMode::System { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }; + + let classes = RegKey::predef(hkey); + let protocol_key = classes.create_subkey(&base_path)?; + + protocol_key + .0 + .set_value("", &format!("URL:{}", title_case(protocol)))?; + protocol_key.0.set_value("URL Protocol", &"")?; + + let command_key = protocol_key.0.create_subkey("shell\\open\\command")?; + command_key.0.set_value("", &command)?; + + if let Some(name) = app_name { + let open_key = protocol_key.0.create_subkey("shell\\open")?; + open_key.0.set_value("", &name)?; + protocol_key.0.set_value("FriendlyAppName", &name)?; + open_key.0.set_value("FriendlyAppName", &name)?; + } + + if let Some(icon_path) = icon { + protocol_key.0.set_value("DefaultIcon", &icon_path)?; + let open_key = protocol_key.0.create_subkey("shell\\open")?; + open_key.0.set_value("Icon", &icon_path)?; + } + + if let Some(aumi) = app_user_model_id { + protocol_key.0.set_value("AppUserModelId", &aumi)?; + } + + if let Some(id) = identifier { + protocol_key.0.set_value("_menuinst", &id)?; + } + + Ok(()) +} + +pub fn unregister_url_protocol( + protocol: &str, + identifier: Option<&str>, + mode: MenuMode, +) -> Result<(), std::io::Error> { + let hkey = if mode == MenuMode::System { + HKEY_LOCAL_MACHINE + } else { + HKEY_CURRENT_USER + }; + + let base_path = format!("Software\\Classes\\{}", protocol); + + if let Ok(key) = RegKey::predef(hkey).open_subkey(&base_path) { + if let Some(id) = identifier { + if let Ok(value) = key.get_value::("_menuinst") { + if value != id { + return Ok(()); + } + } + } + let _ = RegKey::predef(hkey).delete_subkey_all(&base_path); + } + + Ok(()) +} + +pub fn notify_shell_changes() { + unsafe { + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use winreg::RegKey; + + fn cleanup_registry(extension: &str, identifier: &str, mode: MenuMode) { + let _ = unregister_file_extension(extension, identifier, mode); + } + + fn cleanup_protocol(protocol: &str, identifier: Option<&str>, mode: MenuMode) { + let _ = unregister_url_protocol(protocol, identifier, mode); + } + + #[test] + fn test_register_file_extension_user() -> std::io::Result<()> { + let extension = ".rattlertest"; + let identifier = "TestApp.File"; + let command = "\"C:\\Test\\App.exe\" \"%1\""; + let mode = MenuMode::User; + + // Cleanup before test + cleanup_registry(extension, identifier, MenuMode::User); + + // Test registration + register_file_extension(extension, identifier, command, None, None, None, None, mode)?; + + // Verify registration + let classes = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Software\\Classes")?; + + let ext_key = classes.open_subkey(&format!("{}\\OpenWithProgids", extension))?; + assert!(ext_key.get_value::(identifier).is_ok()); + + let cmd_key = classes.open_subkey(&format!("{}\\shell\\open\\command", identifier))?; + let cmd_value: String = cmd_key.get_value("")?; + assert_eq!(cmd_value, command); + + // Cleanup + cleanup_registry(extension, identifier, MenuMode::User); + Ok(()) + } + + #[test] + fn test_register_file_extension_with_icon() -> std::io::Result<()> { + let extension = ".yrattlertest"; + let identifier = "yTestApp.File"; + let command = "\"C:\\Test\\App.exe\" \"%1\""; + let icon = "C:\\Test\\icon.ico"; + let mode = MenuMode::User; + let app_name = Some("Test App"); + let app_user_model_id = Some("TestApp"); + let friendly_type_name = Some("Test App File"); + + // Test registration with icon + register_file_extension( + extension, + identifier, + command, + Some(icon), + app_name, + app_user_model_id, + friendly_type_name, + mode, + )?; + + // Verify icon + let classes = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Software\\Classes")?; + let icon_key = classes.open_subkey(identifier)?; + let icon_value: String = icon_key.get_value("DefaultIcon")?; + assert_eq!(icon_value, icon); + + // Cleanup + cleanup_registry(extension, identifier, mode); + Ok(()) + } + + #[test] + fn test_unregister_file_extension() -> std::io::Result<()> { + let extension = ".xrattlertest"; + let identifier = "xTestApp.File"; + let command = "\"C:\\Test\\App.exe\" \"%1\""; + let mode = MenuMode::User; + + // Setup + register_file_extension(extension, identifier, command, None, None, None, None, mode)?; + + // Test unregistration + unregister_file_extension(extension, identifier, mode)?; + + // Verify removal + let classes = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Software\\Classes")?; + + assert!(classes.open_subkey(identifier).is_err()); + + Ok(()) + } + + #[test] + fn test_register_url_protocol() -> std::io::Result<()> { + let protocol = "rattlertest"; + let command = "\"C:\\Test\\App.exe\" \"%1\""; + let identifier = Some("TestApp"); + let app_name = Some("Test App"); + let icon = Some("C:\\Test\\icon.ico"); + let app_user_model_id = Some("TestApp"); + let mode = MenuMode::User; + + // Cleanup before test + cleanup_protocol(protocol, identifier, mode); + + // Test registration + register_url_protocol( + protocol, + command, + identifier, + icon, + app_name, + app_user_model_id, + mode, + )?; + + // Verify registration + let key = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(&format!("Software\\Classes\\{}", protocol))?; + + let cmd_key = key.open_subkey(r"shell\open\command")?; + let cmd_value: String = cmd_key.get_value("")?; + assert_eq!(cmd_value, command); + + let id_value: String = key.get_value("_menuinst")?; + assert_eq!(id_value, identifier.unwrap()); + + // Cleanup + cleanup_protocol(protocol, identifier, mode); + Ok(()) + } + + #[test] + fn test_unregister_url_protocol() -> std::io::Result<()> { + let protocol = "rattlertest-2"; + let command = "\"C:\\Test\\App.exe\" \"%1\""; + let identifier = Some("xTestApp"); + let app_name = Some("Test App"); + let icon = Some("C:\\Test\\icon.ico"); + let app_user_model_id = Some("TestApp"); + let mode = MenuMode::User; + + // Setup + register_url_protocol( + protocol, + command, + identifier, + icon, + app_name, + app_user_model_id, + mode, + )?; + + // Test unregistration + unregister_url_protocol(protocol, identifier, mode)?; + + // Verify removal + let key = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Software\\Classes")?; + assert!(key.open_subkey(protocol).is_err()); + + Ok(()) + } +} diff --git a/crates/rattler_menuinst/test-data/defaults/defaults.json b/crates/rattler_menuinst/test-data/defaults/defaults.json new file mode 100644 index 000000000..56499b3f7 --- /dev/null +++ b/crates/rattler_menuinst/test-data/defaults/defaults.json @@ -0,0 +1,68 @@ +{ + "menu_name": "REQUIRED", + "menu_items": [ + { + "name": "REQUIRED", + "description": "REQUIRED", + "command": [ + "REQUIRED" + ], + "icon": null, + "precommand": null, + "precreate": null, + "working_dir": null, + "activate": true, + "terminal": false, + "platforms": { + "linux": { + "Categories": null, + "DBusActivatable": null, + "GenericName": null, + "Hidden": null, + "Implements": null, + "Keywords": null, + "MimeType": null, + "NoDisplay": null, + "NotShowIn": null, + "OnlyShowIn": null, + "PrefersNonDefaultGPU": null, + "SingleMainWindow": null, + "StartupNotify": null, + "StartupWMClass": null, + "TryExec": null, + "glob_patterns": null + }, + "osx": { + "CFBundleDisplayName": null, + "CFBundleIdentifier": null, + "CFBundleName": null, + "CFBundleSpokenName": null, + "CFBundleVersion": null, + "CFBundleURLTypes": null, + "CFBundleDocumentTypes": null, + "LSApplicationCategoryType": null, + "LSBackgroundOnly": null, + "LSEnvironment": null, + "LSMinimumSystemVersion": null, + "LSMultipleInstancesProhibited": null, + "LSRequiresNativeExecution": null, + "NSSupportsAutomaticGraphicsSwitching": null, + "UTExportedTypeDeclarations": null, + "UTImportedTypeDeclarations": null, + "entitlements": null, + "link_in_bundle": null, + "event_handler": null + }, + "win": { + "desktop": true, + "quicklaunch": false, + "terminal_profile": null, + "url_protocols": null, + "file_extensions": null, + "app_user_model_id": null + } + } + } + ], + "$schema": "https://schemas.conda.org/menuinst-1.schema.json" + } \ No newline at end of file diff --git a/crates/rattler_menuinst/test-data/gnuradio/gnuradio-grc.json b/crates/rattler_menuinst/test-data/gnuradio/gnuradio-grc.json new file mode 100644 index 000000000..a890200db --- /dev/null +++ b/crates/rattler_menuinst/test-data/gnuradio/gnuradio-grc.json @@ -0,0 +1,21 @@ +{ + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "$schema": "https://json-schema.org/draft-07/schema", + "menu_name": "{{ DISTRIBUTION_NAME }}", + "menu_items": [ + { + "name": "GNU Radio Companion", + "description": "Flowgraph builder for GNU Radio", + "command": [ + "{{ BIN_DIR }}/gnuradio-companion" + ], + "icon": "{{ MENU_DIR }}/grc.{{ ICON_EXT }}", + "platforms": { + "win": { + "desktop": false, + "quicklaunch": false + } + } + } + ] +} \ No newline at end of file diff --git a/crates/rattler_menuinst/test-data/gnuradio/grc.ico b/crates/rattler_menuinst/test-data/gnuradio/grc.ico new file mode 100644 index 000000000..20c8b673b Binary files /dev/null and b/crates/rattler_menuinst/test-data/gnuradio/grc.ico differ diff --git a/crates/rattler_menuinst/test-data/gqrx/gqrx-menu.json b/crates/rattler_menuinst/test-data/gqrx/gqrx-menu.json new file mode 100644 index 000000000..c3785329b --- /dev/null +++ b/crates/rattler_menuinst/test-data/gqrx/gqrx-menu.json @@ -0,0 +1,38 @@ +{ + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "$schema": "https://json-schema.org/draft-07/schema", + "menu_name": "{{ DISTRIBUTION_NAME }}", + "menu_items": [ + { + "name": "gqrx", + "description": "Software defined radio", + "command": [ + "{{ PREFIX }}/Library/bin/gqrx.exe", + "--edit" + ], + "icon": "{{ MENU_DIR }}/gqrx.{{ ICON_EXT }}", + "platforms": { + "win": { + "desktop": false, + "quicklaunch": false + } + } + }, + { + "name": "gqrx [reset]", + "description": "Software defined radio [reset settings]", + "command": [ + "{{ PREFIX }}/Library/bin/gqrx.exe", + "--reset", + "--edit" + ], + "icon": "{{ MENU_DIR }}/gqrx.{{ ICON_EXT }}", + "platforms": { + "win": { + "desktop": false, + "quicklaunch": false + } + } + } + ] +} \ No newline at end of file diff --git a/crates/rattler_menuinst/test-data/linux-menu/example.menu b/crates/rattler_menuinst/test-data/linux-menu/example.menu new file mode 100644 index 000000000..25e296c90 --- /dev/null +++ b/crates/rattler_menuinst/test-data/linux-menu/example.menu @@ -0,0 +1,12 @@ + + +Applications + + WebMirror + shinythings-webmirror.directory + + shinythings-webmirror.desktop + shinythings-webmirror-admin.desktop + + diff --git a/crates/rattler_menuinst/test-data/linux-menu/mimeapps.list b/crates/rattler_menuinst/test-data/linux-menu/mimeapps.list new file mode 100644 index 000000000..fe4f43332 --- /dev/null +++ b/crates/rattler_menuinst/test-data/linux-menu/mimeapps.list @@ -0,0 +1,6 @@ +[Default Applications] +text/html=google-chrome.desktop +x-scheme-handler/http=google-chrome.desktop +x-scheme-handler/https=google-chrome.desktop +x-scheme-handler/about=google-chrome.desktop +x-scheme-handler/unknown=google-chrome.desktop diff --git a/crates/rattler_menuinst/test-data/mne/menu.json b/crates/rattler_menuinst/test-data/mne/menu.json new file mode 100644 index 000000000..4c212c4ce --- /dev/null +++ b/crates/rattler_menuinst/test-data/mne/menu.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "menu_name": "MNE-Python (__PKG_VERSION__)", + "menu_items": [{ + "name": "Spyder (MNE)", + "description": "The Spyder development environment", + "icon": "{{ MENU_DIR }}/spyder.{{ ICON_EXT }}", + "command": ["will be overridden in platforms section"], + "activate": true, + "terminal": false, + "platforms": { + "win": { + "command": [ + "{{ PYTHONW }}", + "{{ SCRIPTS_DIR }}\\spyder-script.py" + ], + "desktop": false + }, + "linux": { + "command": ["spyder"], + "Categories": [ + "Science" + ] + }, + "osx": { + "command": ["spyder"], + "CFBundleName": "Spyder (MNE-Python __PKG_VERSION__)", + "CFBundleDisplayName": "Spyder (MNE-Python __PKG_VERSION__)", + "CFBundleVersion": "__PKG_VERSION__" + } + } + }, + { + "name": "System Info (MNE)", + "description": "Information on the MNE-Python runtime environment", + "icon": "{{ MENU_DIR }}/info.{{ ICON_EXT }}", + "command": [ + "{{ PYTHON }}", + "{{ MENU_DIR }}/mne_sys_info.py" + ], + "activate": true, + "terminal": true, + "platforms": { + "win": { + "desktop": false + }, + "linux": { + "Categories": [ + "Science" + ] + }, + "osx": { + "CFBundleName": "System Information (MNE-Python __PKG_VERSION__)", + "CFBundleDisplayName": "System Information (MNE-Python __PKG_VERSION__)", + "CFBundleVersion": "__PKG_VERSION__" + } + } + }, + { + "name": "Prompt (MNE)", + "description": "MNE-Python console prompt", + "icon": "{{ MENU_DIR }}/console.{{ ICON_EXT }}", + "activate": true, + "terminal": true, + "command": ["will be overridden in platforms section"], + "platforms": { + "win": { + "command": [ + "%SystemRoot%\\system32\\cmd.exe", + "/K", + "{{ MENU_DIR }}\\mne_open_prompt.bat" + ], + "desktop": false + }, + "linux": { + "command": [ + "exec", + "bash", + "--init-file", + "{{ MENU_DIR }}/mne_open_prompt.sh" + ], + "Categories": [ + "Science" + ] + }, + "osx": { + "command": [ + "osascript", + "{{ MENU_DIR }}/mne_open_prompt.applescript" + ], + "CFBundleName": "Prompt (MNE-Python __PKG_VERSION__)", + "CFBundleDisplayName": "Prompt (MNE-Python __PKG_VERSION__)", + "CFBundleVersion": "__PKG_VERSION__" + } + } + }, + { + "name": "Tutorials (MNE)", + "description": "MNE-Python online tutorials", + "icon": "{{ MENU_DIR }}/web.{{ ICON_EXT }}", + "activate": false, + "terminal": false, + "command": ["will be overridden in platforms section"], + "platforms": { + "win": { + "command": [ + "%SystemRoot%\\system32\\WindowsPowerShell\\v1.0\\powershell.exe", + "\"start https://mne.tools/stable/auto_tutorials -WindowStyle hidden\"" + ], + "desktop": false + }, + "linux": { + "command": [ + "xdg-open", + "https://mne.tools/stable/auto_tutorials/" + ], + "Categories": [ + "Science" + ] + }, + "osx": { + "command": [ + "open", + "https://mne.tools/stable/auto_tutorials/" + ], + "CFBundleName": "Tutorials (MNE-Python __PKG_VERSION__)", + "CFBundleDisplayName": "Tutorials (MNE-Python __PKG_VERSION__)", + "CFBundleVersion": "__PKG_VERSION__" + } + } + }, + { + "name": "User Forum (MNE)", + "description": "MNE-Python forum for discussions, problem solving, and information exchange", + "icon": "{{ MENU_DIR }}/forum.{{ ICON_EXT }}", + "activate": false, + "terminal": false, + "command": ["will be overridden in platforms section"], + "platforms": { + "win": { + "command": [ + "%SystemRoot%\\system32\\WindowsPowerShell\\v1.0\\powershell.exe", + "\"start https://mne.discourse.group -WindowStyle hidden\"" + ], + "desktop": false + }, + "linux": { + "command": [ + "xdg-open", + "https://mne.discourse.group" + ], + "Categories": [ + "Science" + ] + }, + "osx": { + "command": [ + "open", + "https://mne.discourse.group" + ], + "CFBundleName": "Forum (MNE-Python __PKG_VERSION__)", + "CFBundleDisplayName": "Forum (MNE-Python __PKG_VERSION__)", + "CFBundleVersion": "__PKG_VERSION__" + } + } + }] + } \ No newline at end of file diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/LICENSE.txt b/crates/rattler_menuinst/test-data/mne_menu/menu/LICENSE.txt new file mode 100644 index 000000000..f92369c54 --- /dev/null +++ b/crates/rattler_menuinst/test-data/mne_menu/menu/LICENSE.txt @@ -0,0 +1,6 @@ +The Spyder icons were taken from the Spyder project +(https://github.com/spyder-ide/spyder) and are therefore placed +under an MIT license. + +The other icons were taken from https://github.com/paomedia/small-n-flat and +are placed in the public domain. diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/console.icns b/crates/rattler_menuinst/test-data/mne_menu/menu/console.icns new file mode 100644 index 000000000..3c7896f60 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/console.icns differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/console.ico b/crates/rattler_menuinst/test-data/mne_menu/menu/console.ico new file mode 100644 index 000000000..cf824a41e Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/console.ico differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/console.png b/crates/rattler_menuinst/test-data/mne_menu/menu/console.png new file mode 100644 index 000000000..836099c53 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/console.png differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/forum.icns b/crates/rattler_menuinst/test-data/mne_menu/menu/forum.icns new file mode 100644 index 000000000..70026f246 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/forum.icns differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/forum.ico b/crates/rattler_menuinst/test-data/mne_menu/menu/forum.ico new file mode 100644 index 000000000..66f945fb5 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/forum.ico differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/forum.png b/crates/rattler_menuinst/test-data/mne_menu/menu/forum.png new file mode 100644 index 000000000..f85b57588 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/forum.png differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/info.icns b/crates/rattler_menuinst/test-data/mne_menu/menu/info.icns new file mode 100644 index 000000000..635cde1b0 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/info.icns differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/info.ico b/crates/rattler_menuinst/test-data/mne_menu/menu/info.ico new file mode 100644 index 000000000..24a411805 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/info.ico differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/info.png b/crates/rattler_menuinst/test-data/mne_menu/menu/info.png new file mode 100644 index 000000000..fc976b6f2 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/info.png differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/menu.json b/crates/rattler_menuinst/test-data/mne_menu/menu/menu.json new file mode 100644 index 000000000..78d3b8834 --- /dev/null +++ b/crates/rattler_menuinst/test-data/mne_menu/menu/menu.json @@ -0,0 +1,168 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "menu_name": "MNE-Python (1.2.3)", + "menu_items": [{ + "name": "Spyder (MNE)", + "description": "The Spyder development environment", + "icon": "{{ MENU_DIR }}/spyder.{{ ICON_EXT }}", + "command": ["will be overridden in platforms section"], + "activate": true, + "terminal": false, + "platforms": { + "win": { + "command": [ + "{{ PYTHONW }}", + "{{ SCRIPTS_DIR }}\\spyder-script.py" + ], + "desktop": false + }, + "linux": { + "command": ["spyder"], + "Categories": [ + "Science" + ] + }, + "osx": { + "command": ["spyder"], + "CFBundleName": "Spyder (MNE-Python 1.2.3)", + "CFBundleDisplayName": "Spyder (MNE-Python 1.2.3)", + "CFBundleVersion": "1.2.3" + } + } + }, + { + "name": "System Info (MNE)", + "description": "Information on the MNE-Python runtime environment", + "icon": "{{ MENU_DIR }}/info.{{ ICON_EXT }}", + "command": [ + "{{ PYTHON }}", + "{{ MENU_DIR }}/mne_sys_info.py" + ], + "activate": true, + "terminal": true, + "platforms": { + "win": { + "desktop": false + }, + "linux": { + "Categories": [ + "Science" + ] + }, + "osx": { + "CFBundleName": "System Information (MNE-Python 1.2.3)", + "CFBundleDisplayName": "System Information (MNE-Python 1.2.3)", + "CFBundleVersion": "1.2.3" + } + } + }, + { + "name": "Prompt (MNE)", + "description": "MNE-Python console prompt", + "icon": "{{ MENU_DIR }}/console.{{ ICON_EXT }}", + "activate": true, + "terminal": true, + "command": ["will be overridden in platforms section"], + "platforms": { + "win": { + "command": [ + "%SystemRoot%\\system32\\cmd.exe", + "/K", + "{{ MENU_DIR }}\\open_prompt.bat" + ], + "desktop": false + }, + "linux": { + "command": [ + "exec", + "bash", + "--init-file", + "{{ MENU_DIR }}/open_prompt.sh" + ], + "Categories": [ + "Science" + ] + }, + "osx": { + "command": [ + "osascript", + "{{ MENU_DIR }}/open_prompt.applescript" + ], + "CFBundleName": "Prompt (MNE-Python 1.2.3)", + "CFBundleDisplayName": "Prompt (MNE-Python 1.2.3)", + "CFBundleVersion": "1.2.3" + } + } + }, + { + "name": "Tutorials (MNE)", + "description": "MNE-Python online tutorials", + "icon": "{{ MENU_DIR }}/web.{{ ICON_EXT }}", + "activate": false, + "terminal": false, + "command": ["will be overridden in platforms section"], + "platforms": { + "win": { + "command": [ + "%SystemRoot%\\system32\\WindowsPowerShell\\v1.0\\powershell.exe", + "\"start https://mne.tools/stable/auto_tutorials -WindowStyle hidden\"" + ], + "desktop": false + }, + "linux": { + "command": [ + "xdg-open", + "https://mne.tools/stable/auto_tutorials/" + ], + "Categories": [ + "Science" + ] + }, + "osx": { + "command": [ + "open", + "https://mne.tools/stable/auto_tutorials/" + ], + "CFBundleName": "Tutorials (MNE-Python 1.2.3)", + "CFBundleDisplayName": "Tutorials (MNE-Python 1.2.3)", + "CFBundleVersion": "1.2.3" + } + } + }, + { + "name": "User Forum (MNE)", + "description": "MNE-Python forum for discussions, problem solving, and information exchange", + "icon": "{{ MENU_DIR }}/forum.{{ ICON_EXT }}", + "activate": false, + "terminal": false, + "command": ["will be overridden in platforms section"], + "platforms": { + "win": { + "command": [ + "%SystemRoot%\\system32\\WindowsPowerShell\\v1.0\\powershell.exe", + "\"start https://mne.discourse.group -WindowStyle hidden\"" + ], + "desktop": false + }, + "linux": { + "command": [ + "xdg-open", + "https://mne.discourse.group" + ], + "Categories": [ + "Science" + ] + }, + "osx": { + "command": [ + "open", + "https://mne.discourse.group" + ], + "CFBundleName": "Forum (MNE-Python 1.2.3)", + "CFBundleDisplayName": "Forum (MNE-Python 1.2.3)", + "CFBundleVersion": "1.2.3" + } + } + }] +} diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/mne.png b/crates/rattler_menuinst/test-data/mne_menu/menu/mne.png new file mode 100644 index 000000000..864f8160b Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/mne.png differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/mne_sys_info.py b/crates/rattler_menuinst/test-data/mne_menu/menu/mne_sys_info.py new file mode 100644 index 000000000..5c0d59a4d --- /dev/null +++ b/crates/rattler_menuinst/test-data/mne_menu/menu/mne_sys_info.py @@ -0,0 +1,25 @@ +# This will be invoked by the System Information menu entry + +import tempfile +import mne + + +def main(): + report = mne.Report(title='MNE System Information') + report.add_sys_info(title='System Information') + + report_file = tempfile.NamedTemporaryFile( + prefix='mne_sys_info', + suffix='.html', + delete=False + ) + report_file.close() # close it so we can open it again for writing + report.save( + fname=report_file.name, + open_browser=True, + overwrite=True + ) + + +if __name__ == '__main__': + main() diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.applescript b/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.applescript new file mode 100644 index 000000000..9944f7b07 --- /dev/null +++ b/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.applescript @@ -0,0 +1,4 @@ +tell application "Terminal" + do script "source __PREFIX__/Menu/mne_open_prompt.sh" + activate +end tell diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.bat b/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.bat new file mode 100755 index 000000000..0b087b0a8 --- /dev/null +++ b/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.bat @@ -0,0 +1,10 @@ +:: This is used to initialize the bash prompt on Windows. +@ECHO OFF + +call %__PREFIX__%\Scripts\Activate.bat +FOR /F "tokens=*" %%g IN ('python --version') do (SET PYVER=%%g) +FOR /F "tokens=*" %%g IN ('where python') do (SET PYPATH=%%g) +FOR /F "tokens=*" %%g IN ('mne --version') do (SET MNEVER=%%g) + +ECHO Using %PYVER% from %PYPATH% +ECHO This is %MNEVER% diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.sh b/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.sh new file mode 100755 index 000000000..075cce969 --- /dev/null +++ b/crates/rattler_menuinst/test-data/mne_menu/menu/open_prompt.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# This is used to initialize the bash prompt on macOS and Linux. + +if [[ -f ~/.bashrc ]] && [[ ${OSTYPE} != 'darwin'* ]]; then + source ~/.bashrc +fi +source __PREFIX__/bin/activate +echo "Using $(python --version) from $(which python)" +echo "This is $(mne --version)" diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.icns b/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.icns new file mode 100644 index 000000000..8bcd28b94 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.icns differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.ico b/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.ico new file mode 100644 index 000000000..4aafb0878 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.ico differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.png b/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.png new file mode 100644 index 000000000..4d7341d51 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/spyder.png differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/web.icns b/crates/rattler_menuinst/test-data/mne_menu/menu/web.icns new file mode 100644 index 000000000..7cecbfcd2 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/web.icns differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/web.ico b/crates/rattler_menuinst/test-data/mne_menu/menu/web.ico new file mode 100644 index 000000000..6f3ddb57a Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/web.ico differ diff --git a/crates/rattler_menuinst/test-data/mne_menu/menu/web.png b/crates/rattler_menuinst/test-data/mne_menu/menu/web.png new file mode 100644 index 000000000..bb5e8ba31 Binary files /dev/null and b/crates/rattler_menuinst/test-data/mne_menu/menu/web.png differ diff --git a/crates/rattler_menuinst/test-data/pixi-editor/menu/menu.json b/crates/rattler_menuinst/test-data/pixi-editor/menu/menu.json new file mode 100644 index 000000000..dc7c1be8e --- /dev/null +++ b/crates/rattler_menuinst/test-data/pixi-editor/menu/menu.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "menu_name": "pixi-editor", + "menu_items": [ + { + "name": { + "target_environment_is_base": "pixi-editor", + "target_environment_is_not_base": "pixi-editor ({{ ENV_NAME }})" + }, + "description": "Scientific PYthon Development EnviRonment", + "icon": "{{ MENU_DIR }}/pixi-icon.{{ ICON_EXT }}", + "activate": false, + "terminal": false, + "command": [""], + "platforms": { + "win": { + "desktop": true, + "app_user_model_id": "dev.prefix.pixi-editor", + "command": ["notepad.exe", "%*"], + "file_extensions": [ + ".pixi" + ] + }, + "linux": { + "Categories": [ + "Development", + "Science" + ], + "command": ["gedit", "%F"], + "MimeType": [ + "text/x-pixi" + ] + }, + "osx": { + "command": ["open", "$@"], + "CFBundleName": "Pixi Editor", + "CFBundleIdentifier": "dev.prefix.pixi-editor", + "CFBundleVersion": "0.1.0", + "CFBundleDocumentTypes": [ + { + "CFBundleTypeName": "text document", + "CFBundleTypeRole": "Editor", + "LSHandlerRank": "Default", + "CFBundleTypeIconFile": "pixi-icon.icns", + "LSItemContentTypes": [ + "public.pixi" + ] + } + ] + } + } + } + ] +} diff --git a/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.icns b/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.icns new file mode 100644 index 000000000..68f610243 Binary files /dev/null and b/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.icns differ diff --git a/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.ico b/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.ico new file mode 100644 index 000000000..6d58d94fd Binary files /dev/null and b/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.ico differ diff --git a/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.png b/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.png new file mode 100644 index 000000000..ef095240d Binary files /dev/null and b/crates/rattler_menuinst/test-data/pixi-editor/menu/pixi-icon.png differ diff --git a/crates/rattler_menuinst/test-data/pixi-editor/recipe.yaml b/crates/rattler_menuinst/test-data/pixi-editor/recipe.yaml new file mode 100644 index 000000000..03767f576 --- /dev/null +++ b/crates/rattler_menuinst/test-data/pixi-editor/recipe.yaml @@ -0,0 +1,17 @@ +package: + name: pixi-editor + version: "0.1.0" + +build: + noarch: generic + script: + - if: unix + then: + - mkdir -p $PREFIX/Menu + - cp $RECIPE_DIR/menu/menu.json $PREFIX/Menu/pixi-editor.json + - cp $RECIPE_DIR/menu/pixi-icon.* $PREFIX/Menu/ + - if: win + then: + - if not exist "%PREFIX%\Menu" mkdir "%PREFIX%\Menu" + - copy "%RECIPE_DIR%\menu\menu.json" "%PREFIX%\Menu\pixi-editor.json" + - copy "%RECIPE_DIR%\menu\pixi-icon.ico" "%PREFIX%\Menu\" diff --git a/crates/rattler_menuinst/test-data/spyder/menu.json b/crates/rattler_menuinst/test-data/spyder/menu.json new file mode 100644 index 000000000..85d6c1ddc --- /dev/null +++ b/crates/rattler_menuinst/test-data/spyder/menu.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://schemas.conda.io/menuinst-1.schema.json", + "menu_name": "{{ DISTRIBUTION_NAME }} spyder", + "menu_items": [ + { + "name": { + "target_environment_is_base": "Spyder 6 ({{ DISTRIBUTION_NAME }})", + "target_environment_is_not_base": "Spyder 6 ({{ ENV_NAME }})" + }, + "description": "Scientific PYthon Development EnviRonment", + "icon": "{{ MENU_DIR }}/spyder.{{ ICON_EXT }}", + "activate": false, + "terminal": false, + "command": [""], + "platforms": { + "win": { + "desktop": true, + "app_user_model_id": "spyder-ide.Spyder-6.{{ ENV_NAME }}", + "command": ["{{ PREFIX }}/Scripts/spyder.exe", "%*"], + "file_extensions": [ + ".enaml", + ".ipy", + ".py", + ".pyi", + ".pyw", + ".pyx" + ] + }, + "linux": { + "Categories": [ + "Development", + "Science" + ], + "command": ["{{ PREFIX }}/bin/spyder", "%F"], + "StartupWMClass": "Spyder-6.{{ ENV_NAME }}", + "MimeType": [ + "text/x-spython" + ], + "glob_patterns": { + "text/x-spython": "*.spy" + } + }, + "osx": { + "precommand": "pushd \"$(dirname \"$0\")\" &>/dev/null", + "command": ["./python", "{{ PREFIX }}/bin/spyder", "$@"], + "link_in_bundle": { + "{{ PREFIX }}/bin/python": "{{ MENU_ITEM_LOCATION }}/Contents/MacOS/python" + }, + "CFBundleName": "Spyder 6", + "CFBundleIdentifier": "org.spyder-ide.Spyder-6-.prefix", + "CFBundleVersion": "6.0.2", + "CFBundleDocumentTypes": [ + { + "CFBundleTypeName": "text document", + "CFBundleTypeRole": "Editor", + "LSHandlerRank": "Default", + "CFBundleTypeIconFile": "spyder.icns", + "LSItemContentTypes": [ + "com.apple.applescript.text", + "com.apple.ascii-property-list", + "com.apple.audio-unit-preset", + "com.apple.binary-property-list", + "com.apple.configprofile", + "com.apple.crashreport", + "com.apple.dashcode.css", + "com.apple.dashcode.javascript", + "com.apple.dashcode.json", + "com.apple.dashcode.manifest", + "com.apple.dt.document.ascii-property-list", + "com.apple.dt.document.script-suite-property-list", + "com.apple.dt.document.script-terminology-property-list", + "com.apple.property-list", + "com.apple.rez-source", + "com.apple.scripting-definition", + "com.apple.structured-text", + "com.apple.traditional-mac-plain-text", + "com.apple.xcode.ada-source", + "com.apple.xcode.apinotes", + "com.apple.xcode.bash-script", + "com.apple.xcode.configsettings", + "com.apple.xcode.csh-script", + "com.apple.xcode.entitlements-property-list", + "com.apple.xcode.fortran-source", + "com.apple.xcode.glsl-source", + "com.apple.xcode.ksh-script", + "com.apple.xcode.lex-source", + "com.apple.xcode.make-script", + "com.apple.xcode.mig-source", + "com.apple.xcode.pascal-source", + "com.apple.xcode.strings-text", + "com.apple.xcode.tcsh-script", + "com.apple.xcode.yacc-source", + "com.apple.xcode.zsh-script", + "com.apple.xml-property-list", + "com.netscape.javascript-source", + "com.scenarist.closed-caption", + "com.sun.java-source", + "com.sun.java-web-start", + "net.daringfireball.markdown", + "org.khronos.glsl-source", + "org.oasis-open.xliff", + "public.ada-source", + "public.assembly-source", + "public.bash-script", + "public.c-header", + "public.c-plus-plus-header", + "public.c-plus-plus-source", + "public.c-source", + "public.case-insensitive-text", + "public.comma-separated-values-text", + "public.csh-script", + "public.css", + "public.delimited-values-text", + "public.dylan-source", + "public.filename-extension", + "public.fortran-77-source", + "public.fortran-90-source", + "public.fortran-95-source", + "public.fortran-source", + "public.html", + "public.json", + "public.ksh-script", + "public.lex-source", + "public.log", + "public.m3u-playlist", + "public.make-source", + "public.mig-source", + "public.mime-type", + "public.module-map", + "public.nasm-assembly-source", + "public.objective-c-plus-plus-source", + "public.objective-c-source", + "public.opencl-source", + "public.pascal-source", + "public.patch-file", + "public.perl-script", + "public.php-script", + "public.plain-text", + "public.python-script", + "public.rss", + "public.ruby-script", + "public.script", + "public.shell-script", + "public.source-code", + "public.tcsh-script", + "public.text", + "public.utf16-external-plain-text", + "public.utf16-plain-text", + "public.utf8-plain-text", + "public.utf8-tab-separated-values-text", + "public.xhtml", + "public.xml", + "public.yacc-source", + "public.yaml", + "public.zsh-script" + ] + } + ] + } + } + } + ] +} diff --git a/crates/rattler_solve/src/lib.rs b/crates/rattler_solve/src/lib.rs index ec21eb093..07167cb0e 100644 --- a/crates/rattler_solve/src/lib.rs +++ b/crates/rattler_solve/src/lib.rs @@ -8,6 +8,7 @@ pub mod libsolv_c; #[cfg(feature = "resolvo")] pub mod resolvo; +// pub mod multi; use std::fmt; diff --git a/crates/rattler_solve/src/multi.rs b/crates/rattler_solve/src/multi.rs new file mode 100644 index 000000000..e69de29bb