diff --git a/Cargo.lock b/Cargo.lock index 30b913d..33f6af9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,6 +807,7 @@ dependencies = [ name = "editor" version = "0.1.0" dependencies = [ + "base64", "blake3", "chrono", "directories", @@ -820,6 +821,7 @@ dependencies = [ "runtime", "sdl2-sys", "serde", + "toml_edit 0.25.4+spec-1.1.0", "vectarine-plugin-sdk", "which", "winresource", @@ -2590,7 +2592,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -3473,7 +3475,7 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", @@ -3488,6 +3490,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.23.10+spec-1.0.0" @@ -3495,8 +3506,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", + "toml_writer", "winnow", ] diff --git a/TODO.md b/TODO.md index 3ce6c3a..d76d805 100644 --- a/TODO.md +++ b/TODO.md @@ -18,18 +18,24 @@ - [x] Add a way to pick the prefered text editor (with vscode as default) - [x] Write the bundle.py script to watch the specs defined in plugins.md - [x] Manage files that the editor needs to work that are not inside the editor binary in a cross-platform way. - - [ ] Automatically manage the `luau-api` folder (create it when the project is created, verify its integrity on load, etc...) - - [ ] Show plugins in the editor + - [x] Show plugins in the editor + - [x] Automatically manage the `luau-api` folder (create it when the project is created, verify its integrity on load, etc...) + - [x] Automatically unpack dynamic libs in the plugins folder. + - [ ] Find a way to avoid crashes caused by dylibs plugins (use abi-stable instead of libloading for safe ffi) + - [ ] Check the version of the plugin and only allow loading if the version of the plugin matches the vectarine version (maybe the plugin can have a version range?) - [ ] Add all editor hooks (debug menu) - [ ] Reload plugins in the editor - - [ ] Show supported platforms in the editor - - [ ] Add ability to load/unload plugins from the editor from the filesystem + - [x] Automatically manage the "plugins" field in the game.vecta file of projects opened in the editor + - [x] Show supported platforms in the editor + - [x] Add ability to load/unload plugins from the editor from the filesystem - [ ] Add ability to download plugins from the editor - [ ] Add documentation on how to create and use plugins - [ ] Test that it works on Linux - [ ] Test that it works on Windows - [ ] Emscripten compatibility using JS as a bridge - [ ] Move generic resource trait to the SDK so that plugins can define custom resources. (the plugin will need access to the resources object.) +- [ ] For fastlist, add a concept of a 'gap' value which is empty and is used to make filtering and slicing operations simpler. A gap is a Vec2 with NaNs. You can merge fastlist to discard the gaps. +- [ ] Inside 'Help' add an 'Issues' button that opens to a window where errors related to the editor (bad installation, missing permissions, etc...) are shown instead of discarding all results all of the time. - [ ] Screens (from the screen API) should be called layers. You should have a stack of layers. - [ ] Screenshot API - [ ] Lua function to screenshot (probably just turn a canvas into a PNG) diff --git a/docs/engine/plugins.md b/docs/engine/plugins.md index 17f2fff..649dcaa 100644 --- a/docs/engine/plugins.md +++ b/docs/engine/plugins.md @@ -57,6 +57,15 @@ to the trusted plugins folder. When the list of plugins of the game changes, the game is restarted to have a coherent state. +## Dynamic loading + +On some OS, a dynamic library needs to be associated with a file and cannot come from memory. To solve this, the plugin folder contains both the `.dll` / `.so` / etc... and the `.vectaplugin` files. +If the dynamic library file does not exist, we generate it automatically. + +Before execution we check the hash of the dynamic library against the hash inside the `.vectaplugin` to prevent anything fishy. + +This type of verification only exists in the editor. The runtime trusts the game's plugins. + ## In the game When the game is distributed, only the relevant native code is shipped in the `.vecta` package. As start-up, the game is able to extract the .dlls/.so/.dynlib from the zip to execute them. diff --git a/docs/user-manual.md b/docs/user-manual.md index 25dee20..eaff6e6 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -64,7 +64,7 @@ end ``` As you save, the game updates instantly. -See the `luau-api` folder for a list of available functions lua. +See the `luau-api` folder for a list of available functions lua. This folder is automatically generated by vectarine and its content should not be modified manually. With a Luau extension / plugin installed, your text editor be able to autocomplete your code using the functions in `luau-api`. `luau-api` is a great source of documentation and can be though of as a companion to this manual. @@ -839,13 +839,29 @@ It can also mean that you forgot to put `runtime.js` and `runtime.wasm` in the s You put these files in a zip and upload it to [itch.io](https://itch.io) if you want. +# 🧩 Extending Vectarine with Plugins + +Plugins are a way to add features to the vectarine editor and to your game. Plugins can add new interfaces in the editor, new luau functions, and do basically anything. +Because of this, using a plugin in a game requires that you trust the author. + +Plugins exist in the form of files with the `.vectaplugin` extension. Vectarine stores a list of trusted plugins that you can use in your games to a `plugins` folder. +You can open it using the "Open plugins folder" button in the "Plugin manager". When you download a plugin, add it to this folder and refresh the list for it to appear. + +Games have their own list of plugins in their `plugins` folder which should be next to the `game.vecta` file. For security reasons, +**the vectarine editor will only load plugins from a game if they are also in the `plugins` folder of the editor** and are thus trusted plugins! + +If you want to make your own plugins, check the [README](https://github.com/vanyle/vectarine/tree/main/vectarine-plugin-template) of the vectarine plugin template on Github. + +When you export a game to a specific platform, the parts of the plugin needed for that platform are obtained from the `.vectabundle` and are stored inside the output file. + # 👥 Collaborating on a project Working on a game with other people is more fun! ## Vectarine and Git -Vectarine works well with version control systems like [Git](https://git-scm.com/). If you already now Git, use it! +Vectarine works well with version control systems like [Git](https://git-scm.com/). If you already now Git, use it! You can add "luau-api" to your .gitignore +as it is automatically generated by Vectarine when the project is loaded. If you don't know Git, do not use it, it is complex to learn. diff --git a/editor/Cargo.toml b/editor/Cargo.toml index 6304685..8a91513 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -19,6 +19,8 @@ which = "8.0.0" zip = "6.0.0" directories = "6.0.0" blake3 = "1.8.3" +base64 = "0.22.1" +toml_edit = "0.25.4" [[bin]] name = "vecta" diff --git a/editor/src/editorconfig.rs b/editor/src/editorconfig.rs index 21178ff..daf3571 100644 --- a/editor/src/editorconfig.rs +++ b/editor/src/editorconfig.rs @@ -51,6 +51,8 @@ pub struct EditorConfig { pub is_profiler_window_shown: bool, pub is_plugins_window_shown: bool, pub is_export_window_shown: bool, + // The preference window should be closed when opening Vectarine + #[serde(skip_serializing, skip_deserializing)] pub is_preferences_window_shown: bool, pub is_always_on_top: bool, pub is_editor_always_on_top: bool, diff --git a/editor/src/editorinterface.rs b/editor/src/editorinterface.rs index 7a589bb..1621bda 100644 --- a/editor/src/editorinterface.rs +++ b/editor/src/editorinterface.rs @@ -33,6 +33,7 @@ use crate::{ }, egui_sdl2_platform, export::exportinterface::draw_editor_export, + pluginsystem::trustedplugin::{self, PluginEntry, TrustedPlugin}, projectstate::ProjectState, }; use editorconsole::draw_editor_console; @@ -66,6 +67,8 @@ pub struct EditorState { pub editor_batch_draw: BatchDraw2d, debouncer: Rc>>, + pub plugins: Vec, + pub game_error: Option, } @@ -98,6 +101,7 @@ impl EditorState { let video = self.video.clone(); let window = self.window.clone(); let debouncer = self.debouncer.clone(); + let trusted_plugins = self.get_trusted_plugins(); LocalFileSystem.read_file( geteditorpaths::get_editor_config_path() @@ -135,6 +139,7 @@ impl EditorState { gl, video, window, + &trusted_plugins, |loaded_project| { if let Ok(loaded_project) = loaded_project { project.replace(Some(loaded_project)); @@ -179,6 +184,7 @@ impl EditorState { .expect("Failed to create debouncer"), )), game_error: None, + plugins: trustedplugin::load_plugins(), } } @@ -196,9 +202,12 @@ impl EditorState { self.gl.clone(), self.video.clone(), self.window.clone(), + &self.get_trusted_plugins(), |project| { match project { - Ok(p) => self.project.borrow_mut().replace(p), + Ok(p) => { + self.project.borrow_mut().replace(p); + } Err(e) => { callback(Err(e)); return; @@ -254,6 +263,11 @@ impl EditorState { let ctx = platform.context(); draw_editor_menu(self, &ctx); + + if self.project.borrow().is_none() { + draw_empty_screen(self, &ctx); + } + draw_editor_console(self, &ctx); draw_editor_resources(self, painter, &ctx); draw_editor_watcher(self, &ctx); @@ -262,10 +276,6 @@ impl EditorState { draw_editor_plugin_manager(self, &ctx); draw_editor_preferences(self, &ctx); - if self.project.borrow().is_none() { - draw_empty_screen(self, &ctx); - } - // Stop drawing the egui frame and get the full output let full_output = platform.end_frame(&mut self.video.borrow_mut()); match full_output { @@ -298,6 +308,16 @@ impl EditorState { platform.handle_event(event, sdl, &self.video.borrow()); } } + + pub fn get_trusted_plugins(&self) -> Vec { + self.plugins + .iter() + .filter_map(|entry| match entry { + PluginEntry::Trusted(trusted_plugin) => Some(trusted_plugin.clone()), + PluginEntry::Malformed(_) => None, + }) + .collect::>() + } } pub fn handle_close_events(latest_events: &[sdl2::event::Event]) { diff --git a/editor/src/editorinterface/editorplugins.rs b/editor/src/editorinterface/editorplugins.rs index c0efb4e..05f1315 100644 --- a/editor/src/editorinterface/editorplugins.rs +++ b/editor/src/editorinterface/editorplugins.rs @@ -1,21 +1,36 @@ -use std::fs; +use std::{borrow::Cow, fs, path::PathBuf}; -use runtime::egui; +use egui_extras::{Column, TableBody, TableBuilder}; +use runtime::egui::{self, Label}; -use crate::editorinterface::{EditorState, extra::geteditorpaths::get_editor_plugins_path}; +use crate::{ + editorinterface::{ + EditorState, + extra::geteditorpaths::{get_editor_plugins_path, get_end_of_path}, + }, + pluginsystem::trustedplugin::{ + self, PluginEntry, TrustedPlugin, get_available_filename_for_trusted_plugin, + }, + projectstate::ProjectState, +}; pub fn draw_editor_plugin_manager(editor: &mut EditorState, ctx: &egui::Context) { let mut is_shown = editor.config.borrow_mut().is_plugins_window_shown; if editor.config.borrow().is_plugins_window_shown { let window = egui::Window::new("Plugin manager") - .default_height(200.0) - .default_width(300.0) + .resizable(true) + .default_height(300.0) + .default_width(700.0) .open(&mut is_shown) .collapsible(false) .vscroll(false); let response = window.show(ctx, |ui| { - draw_editor_plugin_manager_content(editor, ui); + egui::ScrollArea::both() + .auto_shrink([true; 2]) + .show(ui, |ui| { + draw_editor_plugin_manager_content(editor, ui); + }); }); if let Some(response) = response { let on_top = Some(response.response.layer_id) == ctx.top_layer_id(); @@ -28,15 +43,388 @@ pub fn draw_editor_plugin_manager(editor: &mut EditorState, ctx: &egui::Context) editor.config.borrow_mut().is_plugins_window_shown = is_shown; } -fn draw_editor_plugin_manager_content(_editor: &mut EditorState, ui: &mut egui::Ui) { - ui.label("No plugins found").on_hover_text("Plugins are programs that extend Vectarine's functionality. They are files ending with '.vecta.plugin'. You can download plugins or create them using the template provided by Vectarine GitHub repository."); - if ui.button("Open plugin folder") - .on_hover_text("Open the folder where plugins are stored. You can add plugins there and they will appear in the list of available plugins.") - .clicked(){ - let plugin_library_path = get_editor_plugins_path(); - if !plugin_library_path.exists() { - let _ = fs::create_dir_all(&plugin_library_path); +fn draw_editor_plugin_manager_content(editor: &mut EditorState, ui: &mut egui::Ui) { + // Both refresh buttons do the same as there is no case where you want to refresh one list without refreshing the other. + // There are 2 buttons in the UI to drive away the point that there are 2 different concepts: game plugins and trusted plugins. + let mut should_refresh_plugins = false; + + ui.horizontal(|ui|{ + if ui.button("Open trusted plugins folder") + .on_hover_text("Open the folder where trusted plugins are stored. You can add plugins there and they will appear in the list of trusted plugins.") + .clicked(){ + let plugin_library_path = get_editor_plugins_path(); + if !plugin_library_path.exists() { + let _ = fs::create_dir_all(&plugin_library_path); + } + let _ = open::that(plugin_library_path); } - let _ = open::that(plugin_library_path); + + if ui.button("Refresh trusted plugin list").clicked() { + should_refresh_plugins = true; } + }); + + if editor.plugins.is_empty() { + ui.label("No plugins found").on_hover_text("Plugins are programs that extend Vectarine's functionality. They are files ending with '.vecta.plugin'. You can download plugins or create them using the template provided by Vectarine GitHub repository."); + } else { + ui.heading("Trusted plugins").on_hover_text("Trusted plugins are the list of plugins known to the editor. Only plugins of a game that are also inside the trusted list are executed."); + + draw_table_header_for_plugin(ui, "trusted", |body| { + for plugin in editor.plugins.iter_mut() { + let row_height = 20.0; + match plugin { + PluginEntry::Trusted(trusted_plugin) => { + let game_project = editor.project.borrow(); + draw_trusted_plugin_row( + body, + trusted_plugin, + game_project.as_ref(), + &mut should_refresh_plugins, + ); + } + PluginEntry::Malformed(malformed) => { + let filename = malformed + .path + .file_name() + .map(|s| s.to_string_lossy()) + .unwrap_or_else(|| Cow::from("???")); + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label(filename); + }); + row.col(|ui| { + let path_shown = &malformed + .path + .file_name() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| get_end_of_path(&malformed.path)); + ui.label(path_shown); + }); + row.col(|ui| { + ui.label(&malformed.error); + }); + row.col(|ui| { + ui.label("N/A"); + }); + row.col(|ui| { + ui.label("N/A"); + }); + row.col(|ui| { + ui.label("N/A"); + }); + }); + } + } + } + }); + } + + ui.heading("Game plugins") + .on_hover_text("Game plugins are the list of plugins belonging to the current game. Only plugins that are also trusted are executed."); + + { + let project = editor.project.borrow(); + if let Some(project) = project.as_ref() { + ui.horizontal(|ui| { + #[allow(clippy::collapsible_if)] + if ui + .button("Open game plugin folder") + .on_hover_text("Open the folder with the plugins specific to your project") + .clicked() + { + if let Some(folder) = project.project_plugins_folder() { + if !folder.exists() { + let _ = fs::create_dir_all(&folder); + } + let _ = open::that(&folder); + } + } + + if ui.button("Refresh game plugins list").clicked() { + should_refresh_plugins = true; + } + }); + + { + let game_plugins = project.plugins.borrow(); + if game_plugins.is_empty() { + ui.label("Because you use no native plugins, all platforms are supported."); + } else { + let is_untrusted_plugin_in_list = + game_plugins.iter().any(|p| p.trusted_plugin.is_none()); + if is_untrusted_plugin_in_list { + ui.label("Because some plugins are not trusted, the list of supported platform is unknown"); + } else { + let supported_platforms = game_plugins + .iter() + .filter_map(|p| p.trusted_plugin.as_ref()) + .map(|trusted_plugin| trusted_plugin.supported_platforms.clone()) + .reduce(|a, b| &a & &b); + if let Some(supported_platforms) = supported_platforms { + ui.label(format!( + "Your game only supports the following platforms: {}", + supported_platforms + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + )).on_hover_text("For a platform to be supported, it needs to be supported by all the plugins you use."); + } else { + ui.label("Something went wrong while checking the supported platform. Try refreshing the list of plugins."); + } + } + } + } + + let mut plugin_to_trust: Option = None; + + draw_table_header_for_game_plugin(ui, "game", |body| { + let game_plugins = project.plugins.borrow(); + for plugin in game_plugins.iter() { + let row_height = 20.0; + let display_filename = plugin + .path + .file_name() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| get_end_of_path(&plugin.path)); + + match plugin.trusted_plugin.as_ref() { + Some(trusted_plugin) => { + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label(&trusted_plugin.name); + }); + row.col(|ui| { + ui.label(display_filename); + }); + row.col(|ui| { + ui.label("This plugin is trusted"); + }); + }); + } + None => { + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label("⚠ Untrusted").on_hover_text("This plugin is not trusted and won't be executed. You can add it to the list of trusted plugins to allow its execution."); + }); + row.col(|ui| { + ui.label(display_filename); + }); + row.col(|ui| { + if ui.button("Trust").clicked() { + plugin_to_trust = Some(plugin.path.clone()); + should_refresh_plugins = true; + } + }); + }); + } + } + } + }); + + // We perform actions on the plugin list outside of drawing code to avoid mutating the list of plugins while iterating on it. + // Trusting a plugin means copying it the trusted folder. We refresh the list afterwards + if let Some(plugin_to_trust) = plugin_to_trust { + let editor_plugin_folder = get_editor_plugins_path(); + let plugin_filename = plugin_to_trust + .file_name() + .map(|fname| fname.display().to_string()) + .unwrap_or_else(|| "plugin.vectaplugin".to_string()); + let _ = std::fs::create_dir_all(&editor_plugin_folder); + let destination = editor_plugin_folder + .join(get_available_filename_for_trusted_plugin(&plugin_filename)); + let _ = std::fs::copy(&plugin_to_trust, destination); + } + } else { + ui.label("No project loaded") + .on_hover_text("Load a project to see its plugins."); + } + } + + if should_refresh_plugins { + editor.plugins = trustedplugin::load_plugins(); + let mut project = editor.project.borrow_mut(); + if let Some(project) = project.as_mut() { + project.refresh_plugin_list(&editor.get_trusted_plugins()); + project.update_plugins_in_project_info(); + } + } +} + +fn draw_trusted_plugin_row( + body: &mut TableBody, + plugin: &mut TrustedPlugin, + game_project: Option<&ProjectState>, + should_refresh_plugins: &mut bool, +) { + let ui = body.ui_mut(); + let font_id = egui::TextStyle::Body.resolve(ui.style()); + + let description_width = body.widths()[2]; + + let ui = body.ui_mut(); + let galley = ui.fonts(|f| { + f.layout_job(egui::text::LayoutJob::simple( + plugin.description.clone(), + font_id, + ui.visuals().text_color(), + description_width, + )) + }); + + let row_height = galley.size().y + 8.0; + + body.row(row_height, |mut row| { + row.col(|ui| { + if ui.link(&plugin.name).on_hover_text(&plugin.url).clicked() { + // For safety reasons, we're not opening a file + if plugin.url.starts_with("http") { + let _ = open::that(&plugin.url); + } + } + }); + row.col(|ui| { + let path_shown = &plugin + .path + .file_name() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| get_end_of_path(&plugin.path)); + ui.label(path_shown); + }); + row.col(|ui| { + let label = egui::Label::new(&plugin.description).wrap(); + ui.add(label); + }); + row.col(|ui| { + let supported_platforms = plugin + .supported_platforms + .iter() + .map(|platform| format!("{}", platform)) + .collect::>() + .join(", "); + ui.label(supported_platforms); + }); + row.col(|ui| { + let label = Label::new(plugin.hash.to_string()).wrap(); + if ui.add(label).on_hover_text("Click to copy").clicked() { + ui.ctx().copy_text(plugin.hash.to_string()); + } + }); + row.col(|ui| { + if let Some(game_project) = game_project { + // First, check if the plugin is already added + let game_plugins = game_project.plugins.borrow(); + let is_added = game_plugins.iter().any(|p| { + p.trusted_plugin.as_ref().map(|plugin| plugin.hash) == Some(plugin.hash) + }); + if is_added { + if ui + .button("Stop trusting") + .on_hover_text( + "Deletes the trusted plugin file from the editor plugin folder", + ) + .clicked() + { + let _ = fs::remove_file(&plugin.path); + *should_refresh_plugins = true; + } + } else if ui.button("Add to game").clicked() { + game_project.add_plugin(plugin.clone()); + *should_refresh_plugins = true; + } + } + }); + }); +} + +/// Draws a table header for a plugin list. +/// This table has 6 columns: +/// - Name +/// - Filename +/// - About +/// - Supported platforms +/// - Hash +/// - Actions +fn draw_table_header_for_plugin( + ui: &mut egui::Ui, + salt: &str, + body_renderer: impl FnOnce(&mut TableBody), +) { + ui.push_id(salt, |ui| { + let available_height = ui.available_height(); + let table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .auto_shrink(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto()) // Name + .column(Column::auto().at_most(200.0).clip(true)) // Path + .column(Column::auto().resizable(true)) // About (description, version, url, errors, supported platforms, ...) + .column(Column::auto()) // Supported platforms + .column(Column::auto()) // Hash + .column(Column::auto()) // Actions + .min_scrolled_height(0.0) + .max_scroll_height(available_height); + + let table = table.header(20.0, |mut header| { + header.col(|ui| { + ui.label("Name"); + }); + header.col(|ui| { + ui.label("Filename"); + }); + header.col(|ui| { + ui.label("Description"); + }); + header.col(|ui| { + ui.label("Supported platforms").on_hover_text("Your game will only be available on the platforms that are supported by all of the plugins your game uses."); + }); + header.col(|ui| { + ui.label("Hash").on_hover_text("You can compare this hash with the one on the plugin's website if it exists to make sure the plugin is not corrupted or malicious."); + }); + header.col(|ui| { + ui.label("Actions"); + }); + }); + table.body(|mut body| { + body_renderer(&mut body); + }); + }); +} + +// Draw a table header for game plugins +// This table has 3 columns +// Name, Filename and Actions +fn draw_table_header_for_game_plugin( + ui: &mut egui::Ui, + salt: &str, + body_renderer: impl FnOnce(&mut TableBody), +) { + ui.push_id(salt, |ui| { + let available_height = ui.available_height(); + let table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .auto_shrink(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().at_least(100.0)) // Name + .column(Column::auto().at_least(200.0).clip(true)) // Filename + .column(Column::auto()) // Actions + .min_scrolled_height(0.0) + .max_scroll_height(available_height); + let table = table.header(20.0, |mut header| { + header.col(|ui| { + ui.label("Trusted Name"); + }); + header.col(|ui| { + ui.label("Filename"); + }); + header.col(|ui| { + ui.label("Actions"); + }); + }); + table.body(|mut body| { + body_renderer(&mut body); + }); + }); } diff --git a/editor/src/editorinterface/emptyscreen.rs b/editor/src/editorinterface/emptyscreen.rs index 9dfa005..487f11e 100644 --- a/editor/src/editorinterface/emptyscreen.rs +++ b/editor/src/editorinterface/emptyscreen.rs @@ -14,7 +14,7 @@ use runtime::{ use crate::editorinterface::{ EditorState, emptyscreen::createproject::create_game_and_open_it, - geteditorpaths::get_gallery_path, + extra::geteditorpaths::get_end_of_path, geteditorpaths::get_gallery_path, }; pub mod createproject; @@ -103,17 +103,6 @@ pub fn draw_empty_screen_window_content( }); } -pub fn get_end_of_path(path: &Path) -> String { - // Show last 5 components of the path. - let components = path.components().collect::>(); - let end_of_path = &components - [std::cmp::max(0, components.len().saturating_sub(5))..components.len()] - .iter() - .map(|c| PathBuf::from(c.as_os_str())) - .fold(PathBuf::new(), |a, b| a.join(b)); - format!("{}", end_of_path.display()) -} - pub fn draw_new_game_window_content( state: &mut EditorState, ui: &mut egui::Ui, diff --git a/editor/src/editorinterface/extra/geteditorpaths.rs b/editor/src/editorinterface/extra/geteditorpaths.rs index 747cdf6..7b68416 100644 --- a/editor/src/editorinterface/extra/geteditorpaths.rs +++ b/editor/src/editorinterface/extra/geteditorpaths.rs @@ -119,3 +119,21 @@ pub fn get_editor_plugins_path() -> PathBuf { let base_dirs = get_base_dir(); base_dirs.data_dir().join("plugins") } + +pub static PLUGIN_FILE_EXTENSION: &str = ".vectaplugin"; + +pub fn does_path_end_with(path: &Path, suffix: &str) -> bool { + path.to_string_lossy().ends_with(suffix) +} + +/// Returns the last components of a path. Ideal for displaying long paths. +pub fn get_end_of_path(path: &Path) -> String { + // Show last 5 components of the path. + let components = path.components().collect::>(); + let end_of_path = &components + [std::cmp::max(0, components.len().saturating_sub(5))..components.len()] + .iter() + .map(|c| PathBuf::from(c.as_os_str())) + .fold(PathBuf::new(), |a, b| a.join(b)); + format!("{}", end_of_path.display()) +} diff --git a/editor/src/export/exportinterface.rs b/editor/src/export/exportinterface.rs index e5a7519..f284909 100644 --- a/editor/src/export/exportinterface.rs +++ b/editor/src/export/exportinterface.rs @@ -64,10 +64,9 @@ Read the manual section about obfuscation for more details. ui_title(ui, "Export platform"); ui.horizontal_wrapped(|ui| { TARGET_PLATFORM.with_borrow_mut(|target_platform| { - ui.selectable_value(target_platform, ExportPlatform::Windows, "Windows"); - ui.selectable_value(target_platform, ExportPlatform::Linux, "Linux"); - ui.selectable_value(target_platform, ExportPlatform::MacOS, "macOS"); - ui.selectable_value(target_platform, ExportPlatform::Web, "Web"); + for platform in ExportPlatform::all() { + ui.selectable_value(target_platform, platform, format!("{}", platform)); + } }); }); diff --git a/editor/src/export/exportproject.rs b/editor/src/export/exportproject.rs index 170d1ef..e63a66c 100644 --- a/editor/src/export/exportproject.rs +++ b/editor/src/export/exportproject.rs @@ -21,6 +21,29 @@ pub enum ExportPlatform { Web, } +impl ExportPlatform { + pub fn all() -> impl Iterator { + [ + ExportPlatform::Windows, + ExportPlatform::Linux, + ExportPlatform::MacOS, + ExportPlatform::Web, + ] + .into_iter() + } +} + +impl std::fmt::Display for ExportPlatform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExportPlatform::Windows => write!(f, "Windows"), + ExportPlatform::Linux => write!(f, "Linux"), + ExportPlatform::MacOS => write!(f, "macOS"), + ExportPlatform::Web => write!(f, "Web"), + } + } +} + pub fn export_project( project_path: &Path, project_info: &ProjectInfo, @@ -211,12 +234,12 @@ fn add_file_content_to_zip( fn get_export_filename(project_info: &ProjectInfo, platform: ExportPlatform) -> String { let project_name = &project_info.title.replace(" ", "_"); - match platform { - ExportPlatform::Windows => format!("{}_windows.zip", project_name), - ExportPlatform::Linux => format!("{}_linux.zip", project_name), - ExportPlatform::MacOS => format!("{}_macos.zip", project_name), - ExportPlatform::Web => format!("{}_web.zip", project_name), - } + // Example: my_snake_windows.zip + format!( + "{}_{}.zip", + project_name, + format!("{}", platform).to_lowercase() + ) } fn get_files_in_folder(folder_path: &Path, zip_base_path: &str) -> Vec<(PathBuf, String)> { diff --git a/editor/src/pluginsystem.rs b/editor/src/pluginsystem.rs index 2a62d35..42bb9b1 100644 --- a/editor/src/pluginsystem.rs +++ b/editor/src/pluginsystem.rs @@ -1,2 +1,3 @@ +pub mod gameplugin; pub mod hash; pub mod trustedplugin; diff --git a/editor/src/pluginsystem/gameplugin.rs b/editor/src/pluginsystem/gameplugin.rs new file mode 100644 index 0000000..8e2fc58 --- /dev/null +++ b/editor/src/pluginsystem/gameplugin.rs @@ -0,0 +1,65 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use runtime::anyhow::{self, bail}; + +use crate::pluginsystem::hash::Hash; +use crate::pluginsystem::trustedplugin::{ + TrustedPlugin, get_hash_of_file_in_zip, get_platform_file_for_me, +}; + +/// Represents a plugin loaded into a game. It can be trusted or not. +#[derive(Debug)] +pub struct GamePlugin { + pub path: PathBuf, + pub hash: Hash, + pub trusted_plugin: Option, + pub dynamic_library_path: PathBuf, + pub dynamic_library_hash: Option, +} + +impl GamePlugin { + pub fn from_path(path: &Path, trusted_plugins: &[TrustedPlugin]) -> Option { + let hash = Hash::from_path(path)?; + let trusted_plugin = trusted_plugins.iter().find(|plugin| plugin.hash == hash); + let dynamic_library_path = get_associated_dynamic_library_path(path); + let path_in_zip = get_platform_file_for_me(); + let dynamic_library_hash = get_hash_of_file_in_zip(path, path_in_zip); + + Some(Self { + path: path.to_path_buf(), + hash, + trusted_plugin: trusted_plugin.cloned(), + dynamic_library_path, + dynamic_library_hash, + }) + } + + pub fn create_dynamic_library_file_if_needed(&self) -> anyhow::Result<()> { + let Some(expected_hash) = &self.dynamic_library_hash else { + bail!("The plugin does not support the current platform"); + }; + + if self.dynamic_library_path.exists() { + if let Some(hash) = Hash::from_path(&self.dynamic_library_path) { + // Hashes are matching, everything is OK, no need to recreate the file. + if hash == *expected_hash { + return Ok(()); + } + } + // File is corrupted, or the plugin was updated or the project loaded was malicious. + let _ = fs::remove_file(&self.dynamic_library_path); + } + + let Some(trusted_plugin) = &self.trusted_plugin else { + bail!("The plugin is not trusted"); + }; + trusted_plugin.try_copy_dynamic_library(&self.dynamic_library_path) + } +} + +fn get_associated_dynamic_library_path(path: &Path) -> PathBuf { + let mut dynamic_library_path = path.to_path_buf(); + dynamic_library_path.set_extension(runtime::native_plugin::get_dynamic_lib_suffix()); + dynamic_library_path +} diff --git a/editor/src/pluginsystem/hash.rs b/editor/src/pluginsystem/hash.rs index c6ff315..5a89c0b 100644 --- a/editor/src/pluginsystem/hash.rs +++ b/editor/src/pluginsystem/hash.rs @@ -1,9 +1,45 @@ +use base64::{Engine, prelude::BASE64_STANDARD}; +use std::{ + fs, + io::{self, Read}, + path::Path, +}; + /// Represents the hash of a plugin -#[derive(Debug, Hash, Eq, PartialEq, Clone)] +#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy)] pub struct Hash([u8; 32]); +impl std::fmt::Display for Hash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let base64 = BASE64_STANDARD.encode(self.0); + write!(f, "{}", base64) + } +} + impl Hash { pub fn from(bytes: blake3::Hash) -> Self { Self(*bytes.as_bytes()) } + + pub fn from_file(file_reader: &mut io::BufReader) -> Option { + let mut hasher = blake3::Hasher::new(); + + // A buffer to hold chunks of the file + let mut buffer = [0; 8192]; + loop { + let count = file_reader.read(&mut buffer).ok()?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + + Some(Self::from(hasher.finalize())) + } + + pub fn from_path(path: &Path) -> Option { + let file = fs::File::open(path).ok()?; + let mut reader = io::BufReader::new(file); + Self::from_file(&mut reader) + } } diff --git a/editor/src/pluginsystem/trustedplugin.rs b/editor/src/pluginsystem/trustedplugin.rs index 1d253f7..0a8562a 100644 --- a/editor/src/pluginsystem/trustedplugin.rs +++ b/editor/src/pluginsystem/trustedplugin.rs @@ -1,8 +1,23 @@ -use std::{collections::HashSet, fs}; +use std::{ + collections::HashSet, + fs, + io::{self, Read}, + path::Path, +}; + +use runtime::{ + anyhow::{self, bail}, + native_plugin::DYNAMIC_LIB_SUFFIXES, + toml, +}; +use serde::Deserialize; use crate::{ - editorinterface::extra::geteditorpaths::get_editor_plugins_path, - export::exportproject::ExportPlatform, pluginsystem::hash::Hash, + editorinterface::extra::geteditorpaths::{ + PLUGIN_FILE_EXTENSION, does_path_end_with, get_editor_plugins_path, + }, + export::exportproject::ExportPlatform, + pluginsystem::hash::Hash, }; /// A trusted plugin is a plugin in the list of plugins that the editor knows about. @@ -10,18 +25,228 @@ use crate::{ #[derive(Debug, Eq, PartialEq, Clone)] pub struct TrustedPlugin { pub name: String, - pub version: String, + pub version: u64, pub path: std::path::PathBuf, - pub lua_api_path: Option, + has_lua_api: bool, pub supported_platforms: HashSet, pub hash: Hash, + pub url: String, + pub description: String, +} + +pub struct MalformedPlugin { + pub path: std::path::PathBuf, + pub error: String, +} + +// The UI will display plugin entries +pub enum PluginEntry { + Trusted(TrustedPlugin), + Malformed(MalformedPlugin), +} + +impl TrustedPlugin { + /// Tries to copy the lua api of the plugin to the given destination. + /// If the destination path already exists, it will be overwritten. + /// If the plugin does not have a lua api, it will do nothing. + pub fn try_copy_lua_api(&self, dest: &Path) -> anyhow::Result<()> { + copy_file_from_vectaplugin(&self.path, "plugin.luau", dest) + } + + pub fn try_copy_dynamic_library(&self, dest: &Path) -> anyhow::Result<()> { + let platform_file = get_platform_file_for_me(); + copy_file_from_vectaplugin(&self.path, platform_file, dest) + } + + /// Checks if the file containing the plugin is still valid. + pub fn is_still_valid(&self) -> bool { + let Ok(file) = fs::File::open(&self.path) else { + return false; + }; + let mut file_reader = io::BufReader::new(file); + let hash = Hash::from_file(&mut file_reader); + match hash { + Some(hash) => self.hash == hash, + None => false, + } + } +} + +#[derive(Debug, Deserialize)] +struct PluginTomlManifest { + name: String, + version: u64, + url: String, + description: String, + // Maybe one day, this will contain some kind of signature to verify authorship. For now, url + hash is enough. + // The editor can display the hash, the url can display the hash and the human can check that they match } -pub fn load_trusted_plugins() -> Vec { +pub fn load_plugins() -> Vec { let plugin_library_path = get_editor_plugins_path(); if !plugin_library_path.exists() { let _ = fs::create_dir_all(&plugin_library_path); return vec![]; } - vec![] + let Ok(entries) = fs::read_dir(&plugin_library_path) else { + return vec![]; + }; + + entries + .filter_map(|entry| { + let entry = entry.ok()?; + if !does_path_end_with(&entry.path(), PLUGIN_FILE_EXTENSION) { + return None; + } + Some(entry) + }) + .map(|entry| match load_trusted_plugin(&entry.path()) { + Ok(plugin) => PluginEntry::Trusted(plugin), + Err(err) => PluginEntry::Malformed(MalformedPlugin { + path: entry.path(), + error: err.to_string(), + }), + }) + .collect::>() +} + +static PLATFORM_FILES: [(ExportPlatform, &str); 4] = [ + (ExportPlatform::Windows, "windows/plugin.dll"), + (ExportPlatform::Linux, "linux/plugin.so"), + (ExportPlatform::MacOS, "macos/plugin.dylib"), + (ExportPlatform::Web, "web/plugin.wasm"), +]; + +pub fn get_platform_file_for_me() -> &'static str { + #[cfg(target_os = "windows")] + { + "windows/plugin.dll" + } + #[cfg(target_os = "linux")] + { + "linux/plugin.so" + } + #[cfg(target_os = "macos")] + { + "macos/plugin.dylib" + } + #[cfg(target_arch = "wasm32")] + { + "web/plugin.wasm" + } +} + +fn copy_file_from_vectaplugin(zip_path: &Path, file_name: &str, dest: &Path) -> anyhow::Result<()> { + let Ok(file) = fs::File::open(zip_path) else { + bail!("The vectaplugin file cannot be read"); + }; + let Ok(mut zip_archive) = zip::ZipArchive::new(file) else { + bail!("It is not a valid {} file", PLUGIN_FILE_EXTENSION); + }; + let Ok(mut file) = zip_archive.by_name(file_name) else { + bail!("It does not contain a {} file", file_name); + }; + let mut dest_writer = fs::File::create(dest)?; + io::copy(&mut file, &mut dest_writer)?; + Ok(()) +} + +fn load_trusted_plugin(path: &Path) -> anyhow::Result { + if !does_path_end_with(path, PLUGIN_FILE_EXTENSION) { + bail!("The file does not end with {}", PLUGIN_FILE_EXTENSION); + } + let Ok(file) = fs::File::open(path) else { + bail!("The file cannot be read"); + }; + let mut file_reader = io::BufReader::new(file); + let hash = + Hash::from_file(&mut file_reader).ok_or(anyhow::anyhow!("Failed to compute hash"))?; + let Ok(file) = fs::File::open(path) else { + bail!("The file cannot be read, again?"); // Indicate some weird race condition or somebody messing with us. + }; + let Ok(mut zip_archive) = zip::ZipArchive::new(file) else { + bail!( + "It is not a valid {} file. The file might be corrupted", + PLUGIN_FILE_EXTENSION + ); + }; + + let supported_platforms = PLATFORM_FILES + .iter() + .filter(|(_, file)| zip_archive.by_name(file).is_ok()) + .map(|(platform, _)| *platform) + .collect::>(); + + let has_lua_api = zip_archive.by_name("plugin.luau").is_ok(); + + let Ok(mut manifest) = zip_archive.by_name("manifest.toml") else { + bail!("It does not contain a plugin manifest"); + }; + let mut buf = String::new(); + manifest.read_to_string(&mut buf)?; + let manifest = match toml::from_str::(&buf) { + Ok(toml) => toml, + Err(err) => bail!("It does not contain a valid plugin manifest, {}", err), + }; + + Ok(TrustedPlugin { + hash, + name: manifest.name, + version: manifest.version, + path: path.to_path_buf(), + has_lua_api, + supported_platforms, + url: manifest.url, + description: manifest.description, + }) +} + +pub fn get_hash_of_file_in_zip(zip_path: &Path, file_name: &str) -> Option { + let Ok(file) = fs::File::open(zip_path) else { + return None; + }; + let Ok(mut zip_archive) = zip::ZipArchive::new(file) else { + return None; + }; + let Ok(file) = zip_archive.by_name(file_name) else { + return None; + }; + let mut file_reader = io::BufReader::new(file); + Hash::from_file(&mut file_reader) +} + +pub fn is_dynamic_library_file(path: &Path) -> bool { + let Some(ext) = path.extension() else { + return false; + }; + DYNAMIC_LIB_SUFFIXES.contains(&ext.to_string_lossy().as_ref()) +} + +pub fn is_trusted_plugin_name_is_available(name: &str) -> bool { + let editor_plugin_path = get_editor_plugins_path(); + !editor_plugin_path.join(name).exists() +} + +/// Returns a name for a trusted plugin that does not exist yet, using the preferred name if possible. +pub fn get_available_filename_for_trusted_plugin(preferred_name: &str) -> String { + let (name, extension) = preferred_name + .split_once('.') + .unwrap_or((preferred_name, "")); + let mut considered_index = 0; + loop { + let name = format!( + "{}{}.{}", + name, + if considered_index == 0 { + String::new() + } else { + format!("_{}", considered_index) + }, + extension + ); + if is_trusted_plugin_name_is_available(&name) { + return name; + } + considered_index += 1; + } } diff --git a/editor/src/projectstate.rs b/editor/src/projectstate.rs index 326a359..571039f 100644 --- a/editor/src/projectstate.rs +++ b/editor/src/projectstate.rs @@ -1,5 +1,6 @@ use std::{ cell::RefCell, + collections::HashSet, fs, path::{Path, PathBuf}, rc::Rc, @@ -11,13 +12,24 @@ use runtime::{ anyhow::{self}, game::Game, io::fs::ReadOnlyFileSystem, + lua_env::BUILT_IN_MODULES, projectinfo::{ProjectInfo, get_project_info}, }; use runtime::{io::localfs::LocalFileSystem, sdl2}; -use crate::luau; +use crate::{ + editorinterface::extra::geteditorpaths::{ + PLUGIN_FILE_EXTENSION, does_path_end_with, get_luau_api_path, + }, + luau, + pluginsystem::{ + gameplugin::GamePlugin, + trustedplugin::{TrustedPlugin, is_dynamic_library_file}, + }, +}; pub struct ProjectState { + /// Path to the .vecta file (the manifest) of the project pub project_path: PathBuf, pub project_info: ProjectInfo, pub game: Game, @@ -25,6 +37,7 @@ pub struct ProjectState { pub window: Rc>, pub hook_timing: Rc>>, pub hook_error: Rc>>, + pub plugins: Rc>>, } impl ProjectState { @@ -56,6 +69,7 @@ impl ProjectState { gl: Arc, video: Rc>, window: Rc>, + trusted_plugins: &[TrustedPlugin], callback: F, ) where F: FnOnce(anyhow::Result), @@ -92,7 +106,7 @@ impl ProjectState { return; }; let (hook_timing, hook_error) = luau::setup_luau_hooks(&game.lua_env.lua); - callback(Ok(Self { + let result = Self { project_path: project_path.to_path_buf(), project_info, game, @@ -100,8 +114,192 @@ impl ProjectState { window, hook_timing, hook_error, - })); + plugins: Rc::new(RefCell::new(Vec::new())), + }; + result.refresh_plugin_list(trusted_plugins); + callback(Ok(result)); }, ); } + + pub fn project_folder(&self) -> Option<&Path> { + self.project_path.parent() + } + + pub fn project_plugins_folder(&self) -> Option { + self.project_folder().map(|folder| folder.join("plugins")) + } + + pub fn refresh_plugin_list(&self, trusted_plugins: &[TrustedPlugin]) { + self.plugins.borrow_mut().clear(); + let Some(project_folder) = self.project_folder() else { + return; + }; + let project_plugins_folder = project_folder.join("plugins"); + let luau_api_folder = project_folder.join("luau-api"); + + // Read the files in the folder + let Ok(files) = fs::read_dir(&project_plugins_folder) else { + return; + }; + + let plugin_files = files.filter_map(|file| { + let Ok(file) = file else { + return None; + }; + let path = file.path(); + if !does_path_end_with(&path, PLUGIN_FILE_EXTENSION) { + return None; + } + Some(path) + }); + + let game_plugins = plugin_files + .filter_map(|path| GamePlugin::from_path(&path, trusted_plugins)) + .collect::>(); + + // Filter out untrusted plugins + let trusted_dynamic_library_paths = game_plugins + .iter() + .filter(|plugin| plugin.trusted_plugin.is_some()) // take only trusted plugins + .map(|plugin| plugin.dynamic_library_path.clone()) + .collect::>(); + + let Ok(files) = fs::read_dir(&project_plugins_folder) else { + return; + }; + for file in files { + let Ok(file) = file else { + continue; + }; + let path = file.path(); + if !is_dynamic_library_file(&path) { + continue; + } + // Only keep trusted dynamic libraries + if !trusted_dynamic_library_paths.contains(&path) { + let _ = fs::remove_file(&path); + } + } + + // Extract dynamic libraries of trusted plugins + for plugin in &game_plugins { + let Some(trusted_plugin) = &plugin.trusted_plugin else { + continue; + }; + let _ = trusted_plugin.try_copy_dynamic_library(&plugin.dynamic_library_path); + } + + // Sync the Lua API folder + if !luau_api_folder.is_dir() && luau_api_folder.exists() { + let _ = fs::remove_file(&luau_api_folder); + } + if !luau_api_folder.exists() { + let _ = fs::create_dir(&luau_api_folder); + } + let mut known_luau_files = BUILT_IN_MODULES + .iter() + .map(|module| format!("{}.luau", module)) + .collect::>(); + + // Add the Lua API files of the plugins + for game_plugin in &game_plugins { + let Some(trusted_plugin) = &game_plugin.trusted_plugin else { + continue; + }; + let name = format!("{}.luau", trusted_plugin.name.clone()); + let dest = luau_api_folder.join(name.clone()); + known_luau_files.insert(name); + let _ = trusted_plugin.try_copy_lua_api(&dest); + } + + // Remove unknown Lua API files + if let Ok(files) = fs::read_dir(&luau_api_folder) { + for file in files { + let Ok(file) = file else { + continue; + }; + let filename = file + .path() + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if !known_luau_files.contains(&filename) { + let _ = fs::remove_file(file.path()); + } + } + } + + // Add the built-in modules + let luau_editor_path = get_luau_api_path(); + BUILT_IN_MODULES.iter().for_each(|module_name| { + let src = luau_editor_path.join(format!("{}.luau", module_name)); + let dest = luau_api_folder.join(format!("{}.luau", module_name)); + if !src.exists() { + // avoid unnecessary file writing. + let _ = fs::copy(src, dest); + } + }); + + // Build the Vec + self.plugins.replace(game_plugins); + self.save_project_info(); + } + + /// Add a plugin to the project. + /// A refresh of the plugin list is needed after that. + pub fn add_plugin(&self, plugin: TrustedPlugin) { + let Some(project_folder) = self.project_folder() else { + return; + }; + let project_plugins_folder = project_folder.join("plugins"); + let Some(plugin_name) = plugin.path.file_name() else { + return; + }; + let _ = fs::create_dir_all(&project_plugins_folder); + let _ = fs::copy(&plugin.path, project_plugins_folder.join(plugin_name)); + } + + pub fn update_plugins_in_project_info(&mut self) { + self.project_info.plugins = self + .plugins + .borrow() + .iter() + .filter_map(|plugin| { + plugin.trusted_plugin.as_ref()?; // only keep trusted plugins. + let filename = plugin.dynamic_library_path.file_prefix()?; + Some(filename.to_string_lossy().to_string()) + }) + .collect(); + } + + /// Save the project info to the project manifest while trying to preserve comments general order of keys. + pub fn save_project_info(&self) { + let Ok(current_project_info) = fs::read_to_string(&self.project_path) else { + self.save_project_info_by_overwriting(); + return; + }; + let Ok(mut document) = current_project_info.parse::() else { + self.save_project_info_by_overwriting(); + return; + }; + let toml_string = vectarine_plugin_sdk::toml::to_string(&self.project_info) + .expect("Unable to serialize the ProjectInfo type to toml"); + let target_document = toml_string + .parse::() + .expect("Unable to parse the toml string generated by toml"); + for (key, value) in target_document.iter() { + document[key] = value.clone(); + } + let toml_string = document.to_string(); + let _ = fs::write(&self.project_path, toml_string); + } + + /// Save the project info while erasing the existing file and its fields. + fn save_project_info_by_overwriting(&self) { + let toml_string = vectarine_plugin_sdk::toml::to_string(&self.project_info) + .expect("Unable to serialize the ProjectInfo type to toml"); + let _ = fs::write(&self.project_path, toml_string); + } } diff --git a/gallery/Learn Vectarine/.gitignore b/gallery/Learn Vectarine/.gitignore new file mode 100644 index 0000000..9d00a36 --- /dev/null +++ b/gallery/Learn Vectarine/.gitignore @@ -0,0 +1,2 @@ +# Luau-api is generated automatically. +luau-api diff --git a/gallery/Learn Vectarine/game.vecta b/gallery/Learn Vectarine/game.vecta index 415be9c..d8bc602 100644 --- a/gallery/Learn Vectarine/game.vecta +++ b/gallery/Learn Vectarine/game.vecta @@ -1,9 +1,10 @@ #!/usr/bin/env vecta title = "Learn Vectarine!" main_script_path = "scripts/game.luau" -logo = "textures/logo.png" tags = ["tutorial", "prototype"] -description = """Open this project to discover how vectarine works!""" +description = "Open this project to discover how vectarine works!" loading_animation = "pixel" -screen_width = 800 -screen_height = 600 +plugins = ["plugin_template"] +logo_path = "assets/logo.png" +default_screen_width = 800 +default_screen_height = 600 diff --git a/mise.toml b/mise.toml index 0f8e1c6..9c79a8d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,7 @@ [tools] uv = "0.9.0" emsdk = "4.0.13" -rust = "1.90.0" +rust = "1.94.0" git-lfs = "latest" gh = "2.83.1" diff --git a/runtime/src/game.rs b/runtime/src/game.rs index 4246559..436ba3e 100644 --- a/runtime/src/game.rs +++ b/runtime/src/game.rs @@ -7,7 +7,9 @@ use vectarine_plugin_sdk::plugininterface::PluginInterface; use crate::{ console::print_warn, - game_resource::{Resource, ResourceId, Status, script_resource::ScriptResource}, + game_resource::{ + Resource, ResourceId, ResourceManager, Status, script_resource::ScriptResource, + }, graphics::batchdraw::BatchDraw2d, io::{fs::ReadOnlyFileSystem, process_events}, lua_env::{LuaEnvironment, lua_screen, print_lua_error_from_error}, @@ -27,6 +29,8 @@ pub struct Game { pub main_script_path: String, pub metrics_holder: Rc>, + + pub plugin_env: PluginEnvironment, } impl Game { @@ -58,17 +62,28 @@ impl Game { project_info.default_screen_height, ); + // Create all the things we need for a game let batch = BatchDraw2d::new(&gl).expect("Failed to create batch 2d"); let metrics = Rc::new(RefCell::new(MetricsHolder::new())); - let lua_env = LuaEnvironment::new(batch, file_system, project_dir, metrics.clone()); - let mut game = Game::from_lua(&gl, lua_env, project_info.main_script_path.clone(), metrics); - game.load(video, window); + let resources = Rc::new(ResourceManager::new(file_system, project_dir)); + let plugin_env = PluginEnvironment::load_plugins(&project_info.plugins, &resources); + let lua_env = LuaEnvironment::new(batch, metrics.clone(), resources); - let plugin_env = PluginEnvironment::load_plugins(&project_info.plugins); - plugin_env.init(PluginInterface { + // Make the game! + let mut game = Game::from_lua( + &gl, + lua_env, + project_info.main_script_path.clone(), + metrics, + plugin_env, + ); + + game.load(video, window); + game.plugin_env.init(PluginInterface { lua: &game.lua_env.lua, }); + // Load the starting script let path = Path::new(&game.main_script_path); game.lua_env.resources.load_resource::( path, @@ -76,8 +91,10 @@ impl Game { game.lua_env.lua.clone(), game.lua_env.default_events.resource_loaded_event.clone(), ); - // New game means new sounds, so we discard the previous ones. + + // New game means new sounds, so we discard the previous ones (this is useful only for the editor). sound::flush_all_samples(); + callback(Ok(game)); } @@ -86,6 +103,7 @@ impl Game { lua_env: LuaEnvironment, main_script_path: String, metrics_holder: Rc>, + plugin_env: PluginEnvironment, ) -> Self { Game { gl: gl.clone(), @@ -93,6 +111,7 @@ impl Game { was_main_script_executed: false, main_script_path, metrics_holder, + plugin_env, } } @@ -237,6 +256,11 @@ impl Game { gl.enable(glow::MULTISAMPLE); } + let plugin_interface = PluginInterface { + lua: &self.lua_env.lua, + }; + self.plugin_env.pre_lua_hook(plugin_interface); + // Update screen transitions lua_screen::update_screen_transition(&self.lua_env.lua, delta_time.as_secs_f32()); @@ -265,6 +289,11 @@ impl Game { .draw(&self.lua_env.resources, true); } + let plugin_interface = PluginInterface { + lua: &self.lua_env.lua, + }; + self.plugin_env.post_lua_hook(plugin_interface); + // Default Duration metrics self.metrics_holder .borrow_mut() diff --git a/runtime/src/game_resource.rs b/runtime/src/game_resource.rs index 0696bc3..82c834d 100644 --- a/runtime/src/game_resource.rs +++ b/runtime/src/game_resource.rs @@ -446,6 +446,9 @@ impl ResourceManager { pub fn get_absolute_path(&self, resource_path: &Path) -> String { get_absolute_path(&self.base_path, resource_path) } + pub fn get_resource_path(&self) -> PathBuf { + self.base_path.clone() + } } /// Represents a resource, a dependency on external data that can be loaded and used by the game. diff --git a/runtime/src/lua_env.rs b/runtime/src/lua_env.rs index 0bf044f..29d63cd 100644 --- a/runtime/src/lua_env.rs +++ b/runtime/src/lua_env.rs @@ -27,10 +27,15 @@ use crate::console::{print_lua_error, print_warn}; use crate::game_resource::ResourceManager; use crate::graphics::batchdraw::BatchDraw2d; use crate::io::IoEnvState; -use crate::io::fs::ReadOnlyFileSystem; use crate::metrics::MetricsHolder; +pub const BUILT_IN_MODULES: &[&str] = &[ + "vec", "vec4", "event", "fastlist", "camera", "audio", "tile", "loader", "image", "text", + "graphics", "screen", "io", "debug", "persist", "resource", "physics", "color", "coord", + "canvas", +]; + pub struct LuaEnvironment { pub lua: Rc, pub env_state: Rc>, @@ -47,9 +52,8 @@ impl LuaEnvironment { #[allow(clippy::unwrap_used)] pub fn new( batch: BatchDraw2d, - file_system: Box, - base_path: &Path, metrics: Rc>, + resources: Rc, ) -> Self { let batch = Rc::new(RefCell::new(batch)); let lua_options = vectarine_plugin_sdk::mlua::LuaOptions::default(); @@ -77,84 +81,69 @@ impl LuaEnvironment { .raw_set(UNSAFE_INTERNALS_KEY, lua.create_table().unwrap()) .unwrap(); - let resources = Rc::new(ResourceManager::new(file_system, base_path)); let env_state = Rc::new(RefCell::new(IoEnvState::default())); let persist_module = lua_persist::setup_persist_api(&lua).unwrap(); - lua.register_module("@vectarine/persist", persist_module) - .unwrap(); + register_vectarine_module(&lua, "persist", persist_module); let vec2_module = lua_vec2::setup_vec_api(&lua).unwrap(); - lua.register_module("@vectarine/vec", vec2_module).unwrap(); + register_vectarine_module(&lua, "vec", vec2_module); let vec4_module = lua_vec4::setup_vec_api(&lua).unwrap(); - lua.register_module("@vectarine/vec4", vec4_module).unwrap(); + register_vectarine_module(&lua, "vec4", vec4_module); let fastlist_module = lua_fastlist::setup_fastlist_api(&lua, &batch, &resources).unwrap(); - lua.register_module("@vectarine/fastlist", fastlist_module) - .unwrap(); + register_vectarine_module(&lua, "fastlist", fastlist_module); let color_module = lua.create_table().unwrap(); - lua.register_module("@vectarine/color", color_module) - .unwrap(); + register_vectarine_module(&lua, "color", color_module); let coords_module = lua_coord::setup_coords_api(&lua, &gl).unwrap(); - lua.register_module("@vectarine/coord", coords_module) - .unwrap(); + register_vectarine_module(&lua, "coord", coords_module); let (event_module, default_events, _event_manager) = lua_event::setup_event_api(&lua).unwrap(); - lua.register_module("@vectarine/event", event_module) - .unwrap(); + register_vectarine_module(&lua, "event", event_module); let canvas_module = lua_canvas::setup_canvas_api(&lua, &batch, &env_state, &resources).unwrap(); - lua.register_module("@vectarine/canvas", canvas_module) - .unwrap(); + register_vectarine_module(&lua, "canvas", canvas_module); let image_module = lua_image::setup_image_api(&lua, &batch, &env_state, &resources).unwrap(); - lua.register_module("@vectarine/image", image_module) - .unwrap(); + register_vectarine_module(&lua, "image", image_module); let text_module = lua_text::setup_text_api(&lua, &batch, &env_state, &resources).unwrap(); - lua.register_module("@vectarine/text", text_module).unwrap(); + register_vectarine_module(&lua, "text", text_module); let graphics_module = lua_graphics::setup_graphics_api(&lua, &batch, &env_state, &resources).unwrap(); - lua.register_module("@vectarine/graphics", graphics_module) - .unwrap(); + register_vectarine_module(&lua, "graphics", graphics_module); let screen_module = lua_screen::setup_screen_api(&lua, &batch, &env_state, &resources).unwrap(); - lua.register_module("@vectarine/screen", screen_module) - .unwrap(); + register_vectarine_module(&lua, "screen", screen_module); let io_module = lua_io::setup_io_api(&lua, &env_state).unwrap(); - lua.register_module("@vectarine/io", io_module).unwrap(); + register_vectarine_module(&lua, "io", io_module); let camera_module = lua_camera::setup_camera_api(&lua, &env_state).unwrap(); - lua.register_module("@vectarine/camera", camera_module) - .unwrap(); + register_vectarine_module(&lua, "camera", camera_module); let debug_module = lua_debug::setup_debug_api(&lua, &metrics).unwrap(); - lua.register_module("@vectarine/debug", debug_module) - .unwrap(); + register_vectarine_module(&lua, "debug", debug_module); let audio_module = lua_audio::setup_audio_api(&lua, &env_state, &resources).unwrap(); - lua.register_module("@vectarine/audio", audio_module) - .unwrap(); + register_vectarine_module(&lua, "audio", audio_module); let physics_module = lua_physics::setup_physics_api(&lua).unwrap(); - lua.register_module("@vectarine/physics", physics_module) - .unwrap(); + register_vectarine_module(&lua, "physics", physics_module); let tile_module = lua_tile::setup_tile_api(&lua, &resources).unwrap(); - lua.register_module("@vectarine/tile", tile_module).unwrap(); + register_vectarine_module(&lua, "tile", tile_module); let loader_module = lua_loader::setup_loader_api(&lua, &resources).unwrap(); - lua.register_module("@vectarine/loader", loader_module) - .unwrap(); + register_vectarine_module(&lua, "loader", loader_module); let original_require = lua .globals() @@ -272,6 +261,21 @@ pub fn run_file_and_display_error_from_lua_handle( } } +pub fn register_vectarine_module( + lua: &vectarine_plugin_sdk::mlua::Lua, + name: &'static str, + module: vectarine_plugin_sdk::mlua::Table, +) { + if !BUILT_IN_MODULES.contains(&name) { + panic!( + "You need to add {} to the BUILT_IN_MODULES list in runtime/src/lua_env.rs to be allowed to register it.", + name + ); + } + lua.register_module(&format!("@vectarine/{}", name), module) + .expect("Failed to register vectarine module"); +} + pub fn stringify_lua_value(value: &vectarine_plugin_sdk::mlua::Value) -> String { let mut seen = Vec::new(); stringify_lua_value_helper(value, &mut seen) diff --git a/runtime/src/native_plugin.rs b/runtime/src/native_plugin.rs index 144ad80..ba54ec0 100644 --- a/runtime/src/native_plugin.rs +++ b/runtime/src/native_plugin.rs @@ -4,6 +4,8 @@ use std::rc::Rc; use vectarine_plugin_sdk::plugininterface::PluginInterface; +use crate::game_resource::ResourceManager; + #[cfg(target_os = "emscripten")] use super::native_plugin::native_plugin_impl::emscripten as imp; @@ -23,6 +25,7 @@ pub struct NativePlugin { } impl NativePlugin { + /// Load a native vectarine plugin from a path. pub fn load(name: &str) -> vectarine_plugin_sdk::anyhow::Result { let native_handle = unsafe { imp::NativePlugin::load(name) }?; Ok(Self { @@ -57,19 +60,23 @@ pub struct PluginEnvironment { } impl PluginEnvironment { - pub fn load_plugins(plugin_names: &[String]) -> Self { + /// Are plugins resources? Great question! No. But we still need a resource_manager to resolve their path. + pub fn load_plugins(plugin_names: &[String], resource_manager: &ResourceManager) -> Self { // TODO: load plugins from a directory in a cross-platform way - let suffix = get_dynlib_suffix(); + let suffix = get_dynamic_lib_suffix(); let native_plugins = plugin_names .iter() .flat_map(|name| { - let full_name = format!("{}{}", name, suffix); - + let full_name = format!("{}.{}", name, suffix); // We look at the plugin at multiple locations before giving up - let plugin = match NativePlugin::load(&full_name) { + let plugin_path = resource_manager + .get_resource_path() + .join("plugins") + .join(&full_name); + let plugin = match NativePlugin::load(plugin_path.to_string_lossy().as_ref()) { Ok(plugin) => plugin, Err(e) => { - println!("Failed to load plugin {}, {}", full_name, e); + println!("Failed to load plugin {}: {}", full_name, e); return None; } }; @@ -86,28 +93,50 @@ impl PluginEnvironment { self.loaded_plugins.iter() } + /// Call the initialization hook of all the loaded plugins pub fn init(&self, plugin_interface: PluginInterface) { for plugin in &self.loaded_plugins { - plugin.call_init_hook(plugin_interface); + plugin.call_init_hook(plugin_interface); // might trigger a crash I guess? + } + } + + pub fn pre_lua_hook(&self, plugin_interface: PluginInterface) { + for plugin in &self.loaded_plugins { + plugin.call_pre_lua_hook(plugin_interface); + } + } + + pub fn post_lua_hook(&self, plugin_interface: PluginInterface) { + for plugin in &self.loaded_plugins { + plugin.call_post_lua_hook(plugin_interface); + } + } + + /// Call the release hook of all the loaded plugins + pub fn release_hook(&self, plugin_interface: PluginInterface) { + for plugin in &self.loaded_plugins { + plugin.call_release_hook(plugin_interface); } } } -fn get_dynlib_suffix() -> &'static str { +pub static DYNAMIC_LIB_SUFFIXES: [&str; 4] = ["so", "dll", "dylib", "wasm"]; + +pub fn get_dynamic_lib_suffix() -> &'static str { #[cfg(target_os = "linux")] { - ".so" + "so" } #[cfg(target_os = "windows")] { - ".dll" + "dll" } #[cfg(target_os = "macos")] { - ".dylib" + "dylib" } #[cfg(target_os = "emscripten")] { - ".wasm" + "wasm" } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 5ffac48..f6ee2a2 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.90.0" +channel = "1.94.0" profile = "default" # targets = ["wasm32-unknown-emscripten", "aarch64-apple-darwin", "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu"] diff --git a/vectarine-plugin-template/README.md b/vectarine-plugin-template/README.md index 9d25c2a..73c615b 100644 --- a/vectarine-plugin-template/README.md +++ b/vectarine-plugin-template/README.md @@ -11,24 +11,25 @@ Run it using `uv run bundle.py`. You'll need to have [uv](https://github.com/astral-sh/uv) installed. -## Using your plugin +This will produce a `plugin.vectaplugin` file that you can copy to the `plugins` folder of the editor to use it. -Vectarine comes bundled with a runtime that is precompiled for all the major platforms. -For your plugins, you will need to manually compile them for the platforms you want to support. +## Distributing your plugin -If you don't have a Mac, you won't be able to support MacOS users for example. +To share your plugin, simply share the `your_plugin_name.vectaplugin` file. -## Distributing your plugin +## Platform support + +Vectarine comes bundled with a runtime that is precompiled for all the major platforms. +However, as plugins contain native code, you will need to manually compile them for the platforms you want to support. + +If you don't compile a Mac version of your plugin, games using it won't be able to run on Mac. -Vectarine can load plugins in 2 ways: +## Lua API -- From a local file -- From the plugin registry +If your plugin extends the Lua, you should provide inside `plugin.luau` a list of the function you define, with documentation comments and proper types. -Plugins can have 2 formats: +## Plugin capabilities -- Unpackage plugins are the main format for plugin developpers, it is just the path to the shared library with the plugin -- Package plugins are the format for end users, a zip file with the shared libraries with the different platforms an documentation. +As vectarine plugins are written in Rust, they can do pretty much anything, as long as the platform they are compiled for supports it. -If your plugin extends the Lua, you should provide a `luau-api` folder to document the APIs you provide as well as a `README.md` file -for a description of your plugin, an **examples** on how to use it. +Moreover, plugins use the SDK as a dependency to be able to access and create Luau functions for users. diff --git a/vectarine-plugin-template/manifest.toml b/vectarine-plugin-template/manifest.toml index f4d5ef0..bd380de 100644 --- a/vectarine-plugin-template/manifest.toml +++ b/vectarine-plugin-template/manifest.toml @@ -1,4 +1,4 @@ name = "Plugin template" description = """A demo plugin that squares a number.""" version = 1 -url = "https://github.com/vanyle/vectarine/" +url = "https://github.com/vanyle/vectarine/tree/main/vectarine-plugin-template" diff --git a/vectarine-plugin-template/plugin.luau b/vectarine-plugin-template/plugin.luau index b536bc4..4b01d23 100644 --- a/vectarine-plugin-template/plugin.luau +++ b/vectarine-plugin-template/plugin.luau @@ -1,5 +1,8 @@ local module = {} +--- Returns the square of a number +--- @param n number +--- @return number function module.square(n: number): number return n * n end