Skip to content

feat: add plugin system for extensibility#1

Merged
christianromeni merged 2 commits into
mainfrom
feat/plugin-system
Jan 17, 2026
Merged

feat: add plugin system for extensibility#1
christianromeni merged 2 commits into
mainfrom
feat/plugin-system

Conversation

@christianromeni

Copy link
Copy Markdown
Contributor

Summary

Adds a plugin system to allow extending Booky's functionality without modifying the core application.

Plugin Types

  • IPreConversionPlugin - Process files before conversion (e.g., DRM removal, normalization)
  • IPostConversionPlugin - Process files after conversion (e.g., metadata enhancement)
  • IFormatPlugin - Add support for new input formats (e.g., AZW3, KFX)

Changes

  • Add Plugins/IBookyPlugin.cs - Plugin interfaces
  • Add Services/PluginService.cs - Plugin loader and manager
  • Integrate plugin hooks into ConversionService
  • Update MainWindow to use plugin service
  • Add example plugin templates in Plugins/Examples/
  • Add plugin documentation in Plugins/README.md

Usage

  1. Create a .NET class library implementing one of the plugin interfaces
  2. Build and copy the DLL to the Plugins folder
  3. Restart Booky - plugins are loaded automatically

- Add IBookyPlugin interface with pre/post conversion and format plugins
- Add PluginService to load plugins from Plugins folder
- Integrate plugin hooks into conversion pipeline
- Add example plugin templates and documentation

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a comprehensive plugin system to enable extending Booky's ebook conversion functionality without modifying core code. The system supports three plugin types: pre-conversion processors (e.g., DRM removal), post-conversion processors (e.g., metadata enhancement), and format converters (e.g., AZW3, KFX support).

Changes:

  • Introduced plugin interfaces (IBookyPlugin, IPreConversionPlugin, IPostConversionPlugin, IFormatPlugin) with PluginResult class for standardized plugin responses
  • Implemented PluginService for loading, managing, and executing plugins from a Plugins directory
  • Integrated plugin hooks into ConversionService workflow (pre-conversion, format plugin conversion, post-conversion)

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 17 comments.

Show a summary per file
File Description
Plugins/IBookyPlugin.cs Defines plugin interfaces and result types for the three plugin categories
Services/PluginService.cs Implements plugin discovery, loading, lifecycle management, and execution orchestration
Services/ConversionService.cs Integrates plugin execution into the conversion pipeline at appropriate stages
MainWindow.xaml.cs Instantiates PluginService, enables plugin-based file type detection and descriptions
Plugins/README.md Provides comprehensive documentation on plugin types, creation, and usage guidelines
Plugins/Examples/ExamplePreConversionPlugin.cs Template demonstrating pre-conversion plugin implementation
Plugins/Examples/ExampleFormatPlugin.cs Template demonstrating format converter plugin implementation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread MainWindow.xaml.cs
Comment on lines +42 to +43
_pluginService = new PluginService();
_pluginService.LoadPlugins();

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin service is never unloaded when the application closes. This means plugin Shutdown methods are never called, potentially leaving resources unreleased. Consider adding a handler for window closing event in MainWindow that calls _pluginService.UnloadPlugins() to ensure proper cleanup.

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +149
public async Task ConvertAsync(ConversionOptions options, PluginService? pluginService = null)
{
var ext = Path.GetExtension(options.InputPath).ToLowerInvariant();
var inputPath = options.InputPath;
var ext = Path.GetExtension(inputPath).ToLowerInvariant();

// Run pre-conversion plugins (e.g., DRM removal)
if (pluginService != null)
{
inputPath = await pluginService.RunPreConversionPluginsAsync(inputPath);
}

// Try format plugins first
if (pluginService != null)
{
var pluginResult = await pluginService.ConvertWithPluginAsync(inputPath, options.OutputPath, options.Title, options.Author);
if (pluginResult != null)
{
if (!pluginResult.Success)
throw new Exception(pluginResult.ErrorMessage ?? "Plugin conversion failed");

// Run post-conversion plugins
await pluginService.RunPostConversionPluginsAsync(options.OutputPath);
return;
}
}

// Built-in conversion
if (ext != ".mobi")
throw new NotSupportedException($"Unsupported format: {ext}. Only MOBI files are supported.");

await ConvertMobiToEpubAsync(options);
var modifiedOptions = new ConversionOptions
{
InputPath = inputPath,
OutputPath = options.OutputPath,
Title = options.Title,
Author = options.Author
};

await ConvertMobiToEpubAsync(modifiedOptions);

// Run post-conversion plugins
if (pluginService != null)
{
await pluginService.RunPostConversionPluginsAsync(options.OutputPath);
}

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When inputPath is modified by pre-conversion plugins and differs from options.InputPath, the temporary file is never cleaned up after the conversion completes. The ConversionService should track if inputPath was modified and ensure cleanup of the temporary file in a finally block or after processing completes.

Copilot uses AI. Check for mistakes.
Comment thread Services/PluginService.cs
Comment on lines +54 to +68
foreach (var type in pluginTypes)
{
try
{
if (Activator.CreateInstance(type) is IBookyPlugin plugin)
{
plugin.Initialize();
_plugins.Add(plugin);
System.Diagnostics.Debug.WriteLine($"Loaded plugin: {plugin.Name} v{plugin.Version}");
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to initialize plugin {type.Name}: {ex.Message}");
}

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a plugin's Initialize method throws an exception during plugin loading, the plugin instance may be created but not added to the _plugins list, yet its resources are not cleaned up. Consider calling Shutdown on the plugin instance if it was successfully created but Initialize failed, or wrap the entire creation and initialization in a try-catch that disposes properly.

Copilot uses AI. Check for mistakes.
Comment thread Services/PluginService.cs Outdated
Comment on lines +104 to +105
var formatPlugin = FormatPlugins.FirstOrDefault(p => p.SupportedExtensions.Contains(extension.ToLowerInvariant()));
return formatPlugin?.GetFileTypeDescription(extension);

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extension comparison in GetPluginFileTypeDescription passes the extension parameter without normalizing it to lowercase before the comparison, but the Contains check uses extension.ToLowerInvariant(). This is inconsistent - the extension should be normalized once at the start of the method. Additionally, the normalized extension should be passed to GetFileTypeDescription for consistency.

Suggested change
var formatPlugin = FormatPlugins.FirstOrDefault(p => p.SupportedExtensions.Contains(extension.ToLowerInvariant()));
return formatPlugin?.GetFileTypeDescription(extension);
var normalizedExtension = extension.ToLowerInvariant();
var formatPlugin = FormatPlugins.FirstOrDefault(p => p.SupportedExtensions.Contains(normalizedExtension));
return formatPlugin?.GetFileTypeDescription(normalizedExtension);

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +52
public async Task<PluginResult> ProcessAsync(string inputPath, string outputPath)
{
// This is where you would implement your processing logic
//
// For example:
// 1. Read the input file
// 2. Process it (decrypt, normalize, etc.)
// 3. Write the result to outputPath
// 4. Return PluginResult.Ok(outputPath) on success
//
// If you can't process this file, return PluginResult.Skip()
// If there's an error, return PluginResult.Fail("error message")

await Task.CompletedTask; // Placeholder for async operations

// Default: skip processing (pass through unchanged)
return PluginResult.Skip();

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example shows returning PluginResult.Skip() as the default behavior, but this could be confusing for plugin developers. When a plugin returns Skip with no OutputPath, the pre-conversion logic in PluginService doesn't modify currentPath, effectively passing through the original file. However, the tempOutput file that was pre-created is never deleted, causing a resource leak. Consider updating the example to show proper cleanup or changing the PluginService to not create tempOutput until the plugin confirms it will process the file.

Copilot uses AI. Check for mistakes.
Comment thread Plugins/README.md
## Plugin Guidelines

- Plugins should fail gracefully and not crash Booky
- Return `PluginResult.Skip()` if the plugin can't process a file

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that plugins return PluginResult.Skip() if they can't process a file, but the actual behavior in RunPreConversionPluginsAsync treats Skip() the same as a failure - the plugin just doesn't modify the file. The documentation should clarify that Skip() means "plugin chose not to process this file, continue with the original file" rather than implying it's an error condition.

Suggested change
- Return `PluginResult.Skip()` if the plugin can't process a file
- Return `PluginResult.Skip()` when the plugin chooses not to process a file (Booky will continue using the original, unmodified file)

Copilot uses AI. Check for mistakes.
Comment thread Services/PluginService.cs
Comment on lines +24 to +44
public void LoadPlugins()
{
if (!Directory.Exists(_pluginsPath))
{
Directory.CreateDirectory(_pluginsPath);
return;
}

var dllFiles = Directory.GetFiles(_pluginsPath, "*.dll");

foreach (var dllPath in dllFiles)
{
try
{
LoadPluginFromDll(dllPath);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to load plugin {dllPath}: {ex.Message}");
}
}

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading all DLL files from the Plugins directory without validation could be a security risk. Malicious DLLs could be placed in this directory and executed with the application's privileges. Consider implementing plugin signature verification, a whitelist of trusted plugins, or at minimum, logging loaded plugin information to help users identify unexpected plugins.

Copilot uses AI. Check for mistakes.
Comment thread Services/PluginService.cs
Comment on lines +133 to +156
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Plugin {plugin.Name} failed: {ex.Message}");
}
}

return currentPath;
}

/// <summary>
/// Run post-conversion plugins on an EPUB file
/// </summary>
public async Task RunPostConversionPluginsAsync(string epubPath)
{
foreach (var plugin in PostConversionPlugins)
{
try
{
await plugin.ProcessAsync(epubPath);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Plugin {plugin.Name} failed: {ex.Message}");
}

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Debug.WriteLine means plugin processing errors are invisible in production. Users won't know why their plugins aren't working. Consider using a proper logging mechanism or at minimum providing user-visible feedback when plugins fail, especially since plugins are a user-facing feature where troubleshooting information is valuable.

Copilot uses AI. Check for mistakes.
Comment thread Plugins/IBookyPlugin.cs Outdated
/// </summary>
/// <param name="inputPath">Path to the input file</param>
/// <param name="outputPath">Path where processed file should be written</param>
/// <returns>True if processing succeeded, false to skip this plugin</returns>

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation comment for ProcessAsync says it returns "True if processing succeeded, false to skip this plugin" but the actual return type is Task<PluginResult>, not a boolean. This outdated documentation is misleading. Update the comment to describe the PluginResult return value and its Success, ErrorMessage, and OutputPath properties.

Suggested change
/// <returns>True if processing succeeded, false to skip this plugin</returns>
/// <returns>
/// A <see cref="PluginResult"/> indicating the outcome of processing.
/// <list type="bullet">
/// <item><description><see cref="PluginResult.Success"/> is true if processing completed successfully or the plugin chose to skip processing.</description></item>
/// <item><description><see cref="PluginResult.ErrorMessage"/> contains details if processing failed.</description></item>
/// <item><description><see cref="PluginResult.OutputPath"/> is the path to the processed file, if different from the input.</description></item>
/// </list>
/// </returns>

Copilot uses AI. Check for mistakes.
Comment thread Services/PluginService.cs
{
var assembly = Assembly.LoadFrom(dllPath);

var pluginTypes = assembly.GetTypes()

Copilot AI Jan 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin loading iterates through all types in every DLL assembly, checking each type against IBookyPlugin. For large plugin DLLs with many types, this could be slow. Consider documenting that plugins should be kept small, or implement a more efficient discovery mechanism such as requiring plugins to have a specific attribute or export a known type.

Suggested change
var pluginTypes = assembly.GetTypes()
var pluginTypes = assembly.GetExportedTypes()

Copilot uses AI. Check for mistakes.
@christianromeni

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review! Here's the response to the feedback:

Fixed:

  • ✅ Plugin cleanup on app close - added Closing event handler to call UnloadPlugins()
  • ✅ Temp file cleanup - ConvertAsync now tracks and cleans up pre-conversion plugin temp files in a finally block
  • ✅ Extension normalization - normalized once at method start
  • ✅ Empty catch block - added Debug.WriteLine for shutdown errors
  • ✅ Outdated ProcessAsync comment - updated to describe PluginResult return type

Won't fix (over-engineering for this use case):

  • AssemblyLoadContext for hot-reloading: Users can restart the app. Adding separate load contexts adds significant complexity for a feature nobody needs.
  • Security/plugin validation: This is a free desktop tool where users manually install plugins. If someone puts a malicious DLL in their own Plugins folder, they already have full access to their own machine.
  • Proper logging framework: For a small desktop app, Debug.WriteLine is sufficient. Adding a logging framework is overkill.
  • GetExportedTypes() optimization: Premature optimization. Realistically there will be 2-3 plugins max.
  • Stale extension after pre-conversion: Pre-conversion plugins (like DRM removal) output the same format. They don't change .mobi to .azw3.
  • Skip() temp file leak: The temp file path is created but the file doesn't exist until the plugin writes to it. If plugin skips, there's no file to leak.
  • Initialize cleanup: If Initialize fails, the plugin isn't added to the list, so Shutdown won't be called - this is intentional.

- Add plugin cleanup on window close (UnloadPlugins)
- Add temp file cleanup in ConvertAsync finally block
- Fix extension normalization consistency in GetPluginFileTypeDescription
- Add Debug.WriteLine for plugin shutdown errors
- Update ProcessAsync comment to describe PluginResult return type
@christianromeni christianromeni merged commit f7f8bc5 into main Jan 17, 2026
2 checks passed
@christianromeni christianromeni deleted the feat/plugin-system branch January 17, 2026 01:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants