diff --git a/plugin_loader.gd b/plugin_loader.gd new file mode 100644 index 0000000..8e188f2 --- /dev/null +++ b/plugin_loader.gd @@ -0,0 +1,249 @@ +## This resource works in two stages: +## 1. loads resource packs from a specified location +## 2. verifies each resource pack is conform to a spec +## 3. list all plugins +## 4. loads all plugins marked as "enabled" +## +## Notes: Enabling a plugin does _not_ run it. It's expected that the app would +## be restarted for the loading to happen. If you want to immediately load and +## unload, use the signals +## +## ## Expected Flow: +## +## 1. use `PluginLoader.restore().load_plugins()` to load all plugins +## 2. use `PluginLoader.restore().provide_interface()` to get a bunch of +## checkboxes to use in an UI +## 3. that's it! You can also provide an initial set of plugins (expected to be +## found with the app's files +class_name PluginLoader extends Resource + +################################################################################ +# +# GENERAL SINGLETON BEHAVIOR +# + +## Default resource path. +const _RESOURCE_PATH := "user://plugins.tres" + + +## Loads the plugin loader resource. +## Always returns a PluginLoader instance; if the path is wrong, a new instance will +## be created. +static func restore(path := _RESOURCE_PATH) -> PluginLoader: + var file: PluginLoader = ResourceLoader.load(path, "PluginLoader") + if file == null: + return PluginLoader.new() + file.refresh_from_disk() + return file + + +## Saves the current plugins status. +## This is called automatically when changing a plugin status. You shouldn't have to +## call it manually. +func save() -> void: + ResourceSaver.save(self, _RESOURCE_PATH) + + +################################################################################ +# +# PLUGIN PACKS LOADING +# + +enum STATUS{ + AVAILABLE, + LOADED, + ERROR, + NOT_FOUND +} + +## Defines where plugin packs should be fetched from +@export var plugins_packs_directory := "user://" +## An object of [plugin_name: String, status: STATUS] +@export var plugins_status := {} +## These represent plugins that come with the application +@export var native_plugins := {} + +## Lists all plugin packs from their designated location. Refreshes the "plugins +## status" array +## Returns a Dictionary with keys "added" and "removed", each respectively listing +## all added or removed packs as strings +func read_available_plugins() -> Dictionary: + var dir := DirAccess.open(plugins_packs_directory) + if dir == null: + return {} + dir.list_dir_begin() + + var file_name = dir.get_next() + + var old_plugins := plugins_status.duplicate() + plugins_status = {} + var removed: Array[String] = [] + var added: Array[String] = [] + + # read the resource packs directory + while file_name != "": + if not dir.current_is_dir(): + if file_name.get_extension().to_lower() == "pck": + var plugin_name := file_name.get_basename() + var path := plugins_packs_directory.path_join(file_name) + var success := ProjectSettings.load_resource_pack(path, false) + if not success: + printerr("Could not load resource pack %s"%[file_name]) + else: + if old_plugins.has(plugin_name): + plugins_status[plugin_name] = old_plugins[plugin_name] + else: + added.append(plugin_name) + plugins_status[plugin_name] = STATUS.AVAILABLE + file_name = dir.get_next() + + # read the native plugins list + for plugin_name in native_plugins: + if not old_plugins.has(plugin_name): + plugins_status[plugin_name] = old_plugins[plugin_name] + else: + plugins_status[plugin_name] = native_plugins[plugin_name] + + # check if anything was removed + for plugin_name in old_plugins: + if not plugins_status.has(plugin_name): + removed.append(plugin_name) + + save() + + return { + added = added, + removed = removed, + } + + + +################################################################################ +# +# PLUGINS STATES +# +# Changing a plugin state does not mean anything before calling `load_plugins()` +# + + +signal plugin_status_changed(plugin_name: String, status: STATUS) + + +## Sets a plugin on or off. Does not immediately load it. +func set_plugin_status(plugin_name: String, status: STATUS) -> void: + if not plugins_status.has(plugin_name): + printerr("Plugin %s does not exist"%[plugin_name]) + return + if plugins_status[plugin_name] == status: + return + plugins_status[plugin_name] = status + plugin_status_changed.emit(plugin_name, status) + save() + + + +## Returns a plugin status. +func get_plugin_status(plugin_name: String) -> STATUS: + if not plugins_status.has(plugin_name): + printerr("Plugin %s does not exist"%[plugin_name]) + return STATUS.NOT_FOUND + return (plugins_status[plugin_name]) as STATUS + + +################################################################################ +# +# PLUGINS INSTANCING +# + +## Defines where plugins are expected to be found after a plugin pack is loaded +## this is also where native plugins are located +@export var plugins_local_directory := "res://mods" +signal plugin_error(plugin_name: String) +signal plugin_started(plugin_name: String) + + +func start_plugin_if_enabled(plugin_name: String) -> bool: + var status := get_plugin_status(plugin_name) + match status: + STATUS.LOADED: + var instance := instance_plugin_if_valid(plugin_name) + if instance == null: + plugins_status[plugin_name] = STATUS.ERROR + plugin_error.emit(plugin_name) + else: + instance.plugin_start() + plugin_started.emit(plugin_name) + return true + return false + + +## This loads a local plugin and instances it. If the plugin comes from a resource pack, +## the resource pack needs to have been loaded before +func instance_plugin_if_valid(plugin_name: String) -> Plugin_V1: + var entry_point_path := plugins_local_directory.path_join(plugin_name).path_join("plugin.gd") + var entry_point_file := FileAccess.open(entry_point_path, FileAccess.READ) + if entry_point_file == null: + printerr("The entry point does not exist or is not readable") + return null + var entry_point_text := entry_point_file.get_as_text() + if not entry_point_text.match("extends"): + printerr("The entry point is not text, which is not allowed for security reasons") + return null + if entry_point_text.match("func _init():"): + printerr("The entry point contains _init(), which is a security issue, mod will not load") + return null + var entry_point_script = load(entry_point_path) + if entry_point_script == null: + printerr("Tried loading %s but did not find the expected entry point"%[entry_point_path]) + return null + var instance: Plugin_V1 = (entry_point_script as Plugin_V1).new() + if not (instance is Plugin_V1): + printerr("Tried loading %s but it is not the right format"%[entry_point_path]) + return null + return instance + + +################################################################################ +# +# UTILITIES +# + + +## Utility function to provide a list of checkboxes that turn plugins on or off. +func provide_interface() -> Array[CheckBox]: + var checkboxes: Array[CheckBox] = [] + for plugin in plugins_status: + var plugin_name: String = plugin as String + var checkbox := CheckBox.new() + checkbox.text = plugin_name.get_basename() + checkbox.button_pressed = get_plugin_status(plugin_name) == STATUS.LOADED + checkbox.toggled.connect( + func checkbox_toggled(is_on: bool) -> void: + set_plugin_status(plugin_name, STATUS.LOADED if is_on else STATUS.AVAILABLE) + ) + checkboxes.append(checkbox) + return checkboxes + + +################################################################################ +# +# BOOSTRAPPING +# + +## For each plugin that's on, load it. +## This function makes no assumptions and no checks. Not loading plugins twice +## is on you +func load_plugins() -> void: + var report := read_available_plugins() + var removed := report.removed as Array[String] + var added := report.added as Array[String] + if removed.size(): + print_rich("Removed plugins %s"%[", ".join(removed)]) + if added.size(): + print_rich("Added plugins %s"%[", ".join(removed)]) + for plugin_name in plugins_status: + start_plugin_if_enabled(plugin_name) + + + + diff --git a/plugin_v1.gd b/plugin_v1.gd new file mode 100644 index 0000000..b100469 --- /dev/null +++ b/plugin_v1.gd @@ -0,0 +1,9 @@ +class_name Plugin_V1 + + +func plugin_start() -> void: + pass + + +func plugin_stop() -> void: + pass