Compare commits

...

1 Commits

Author SHA1 Message Date
ac9e94c9b0 api for plugins 2023-03-03 21:23:11 +04:00
2 changed files with 258 additions and 0 deletions

249
plugin_loader.gd Normal file
View File

@ -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)

9
plugin_v1.gd Normal file
View File

@ -0,0 +1,9 @@
class_name Plugin_V1
func plugin_start() -> void:
pass
func plugin_stop() -> void:
pass