feat(electron): added plugin specific tools to ui

This commit is contained in:
2025-08-11 15:56:33 -04:00
parent a7ab2d4079
commit b370eff4f3
17 changed files with 1834 additions and 405 deletions

View File

@@ -107,6 +107,7 @@ py_installation.install_sources(
meson.project_source_root() + '/fourdst/core/platform.py', meson.project_source_root() + '/fourdst/core/platform.py',
meson.project_source_root() + '/fourdst/core/utils.py', meson.project_source_root() + '/fourdst/core/utils.py',
meson.project_source_root() + '/fourdst/core/keys.py', meson.project_source_root() + '/fourdst/core/keys.py',
meson.project_source_root() + '/fourdst/core/plugin.py',
), ),
subdir: 'fourdst/core' subdir: 'fourdst/core'
) )

View File

@@ -34,7 +34,7 @@ class FourdstEncoder(json.JSONEncoder):
project_root = Path(__file__).resolve().parent.parent project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root))
from fourdst.core import bundle, keys from fourdst.core import bundle, keys, plugin
def main(): def main():
# Use stderr for all logging to avoid interfering with JSON output on stdout # Use stderr for all logging to avoid interfering with JSON output on stdout
@@ -74,9 +74,18 @@ def main():
'sync_remotes', 'get_remote_sources', 'add_remote_source', 'remove_remote_source' 'sync_remotes', 'get_remote_sources', 'add_remote_source', 'remove_remote_source'
] ]
plugin_commands = [
'parse_cpp_interface', 'generate_plugin_project', 'validate_bundle_directory',
'pack_bundle_directory', 'extract_plugin_from_bundle', 'compare_plugin_sources',
'validate_plugin_project'
]
if command in key_commands: if command in key_commands:
func = getattr(keys, command) func = getattr(keys, command)
module_name = "keys" module_name = "keys"
elif command in plugin_commands:
func = getattr(plugin, command)
module_name = "plugin"
else: else:
func = getattr(bundle, command) func = getattr(bundle, command)
module_name = "bundle" module_name = "bundle"

View File

@@ -63,6 +63,16 @@
<button id="open-bundle-btn" class="nav-button active">Open Bundle</button> <button id="open-bundle-btn" class="nav-button active">Open Bundle</button>
<button id="create-bundle-btn" class="nav-button">Create Bundle</button> <button id="create-bundle-btn" class="nav-button">Create Bundle</button>
</nav> </nav>
<div class="sidebar-header" style="margin-top: 30px;">
<h3>Plugin Tools</h3>
</div>
<nav class="sidebar-nav">
<button id="init-plugin-btn" class="nav-button">Initialize Plugin</button>
<button id="validate-plugin-btn" class="nav-button">Validate Plugin</button>
<button id="extract-plugin-btn" class="nav-button">Extract Plugin</button>
<button id="diff-plugin-btn" class="nav-button">Compare Plugins</button>
</nav>
</div> </div>
<!-- libconstants content (empty for now) --> <!-- libconstants content (empty for now) -->
@@ -480,6 +490,150 @@
</div> </div>
</div> </div>
<!-- Plugin Management Views -->
<div id="plugin-view" class="hidden">
<!-- Plugin Initialize View -->
<div id="plugin-init-view" class="plugin-management-view">
<div class="plugin-header">
<h3>Initialize New Plugin</h3>
<p>Create a new Meson-based C++ plugin project from an interface header.</p>
</div>
<div class="plugin-form">
<div class="form-group">
<label for="plugin-project-name">Project Name:</label>
<input type="text" id="plugin-project-name" placeholder="my_plugin" required>
</div>
<div class="form-group">
<label for="plugin-header-file">Interface Header File:</label>
<div class="file-input-group">
<input type="file" id="plugin-header-file" accept=".h,.hpp" style="display: none;">
<button id="plugin-header-browse-btn" class="browse-button">Browse...</button>
<span id="plugin-header-filename" class="filename-display">No file selected</span>
</div>
</div>
<div class="form-group" id="plugin-interface-selection" style="display: none;">
<label for="plugin-interface-select">Select Interface to Implement:</label>
<select id="plugin-interface-select" class="form-select">
<option value="">-- Select an Interface --</option>
</select>
<div id="plugin-interface-methods" class="interface-methods-preview" style="display: none;">
<h5>Methods to implement:</h5>
<ul id="plugin-methods-list"></ul>
</div>
</div>
<div class="form-group">
<label for="plugin-directory">Output Directory:</label>
<div class="file-input-group">
<input type="text" id="plugin-directory" placeholder="." readonly>
<button id="plugin-directory-browse-btn" class="browse-button">Browse...</button>
</div>
</div>
<div class="form-group">
<label for="plugin-version">Version:</label>
<input type="text" id="plugin-version" placeholder="0.1.0" value="0.1.0">
</div>
<div class="form-group">
<label for="plugin-libplugin-rev">libplugin Revision:</label>
<input type="text" id="plugin-libplugin-rev" placeholder="main" value="main">
</div>
<div class="form-actions">
<button id="plugin-init-execute-btn" class="action-button primary" disabled>Initialize Plugin</button>
</div>
</div>
<div id="plugin-init-results" class="plugin-results hidden"></div>
</div>
<!-- Plugin Validate View -->
<div id="plugin-validate-view" class="plugin-management-view hidden">
<div class="plugin-header">
<h3>Validate Plugin Project</h3>
<p>Check a plugin project's structure and meson.build file for correctness.</p>
</div>
<div class="plugin-form">
<div class="form-group">
<label for="validate-plugin-path">Plugin Directory:</label>
<div class="file-input-group">
<input type="text" id="validate-plugin-path" placeholder="." readonly>
<button id="validate-plugin-browse-btn" class="browse-button">Browse...</button>
</div>
</div>
<div class="form-actions">
<button id="plugin-validate-execute-btn" class="action-button primary">Validate Plugin</button>
</div>
</div>
<div id="plugin-validate-results" class="plugin-results hidden"></div>
</div>
<!-- Plugin Extract View -->
<div id="plugin-extract-view" class="plugin-management-view hidden">
<div class="plugin-header">
<h3>Extract Plugin from Bundle</h3>
<p>Extract a plugin's source code from a bundle.</p>
</div>
<div class="plugin-form">
<div class="form-group">
<label for="extract-plugin-name">Plugin Name:</label>
<input type="text" id="extract-plugin-name" placeholder="plugin_name" required>
</div>
<div class="form-group">
<label for="extract-bundle-file">Bundle File:</label>
<div class="file-input-group">
<input type="file" id="extract-bundle-file" accept=".fbundle" style="display: none;">
<button id="extract-bundle-browse-btn" class="browse-button">Browse...</button>
<span id="extract-bundle-filename" class="filename-display">No file selected</span>
</div>
</div>
<div class="form-group">
<label for="extract-output-dir">Output Directory:</label>
<div class="file-input-group">
<input type="text" id="extract-output-dir" placeholder="." readonly>
<button id="extract-output-browse-btn" class="browse-button">Browse...</button>
</div>
</div>
<div class="form-actions">
<button id="plugin-extract-execute-btn" class="action-button primary" disabled>Extract Plugin</button>
</div>
</div>
<div id="plugin-extract-results" class="plugin-results hidden"></div>
</div>
<!-- Plugin Diff View -->
<div id="plugin-diff-view" class="plugin-management-view hidden">
<div class="plugin-header">
<h3>Compare Plugin Sources</h3>
<p>Compare the source code of a plugin between two different bundles.</p>
</div>
<div class="plugin-form">
<div class="form-group">
<label for="diff-plugin-name">Plugin Name:</label>
<input type="text" id="diff-plugin-name" placeholder="plugin_name" required>
</div>
<div class="form-group">
<label for="diff-bundle-a">First Bundle:</label>
<div class="file-input-group">
<input type="file" id="diff-bundle-a" accept=".fbundle" style="display: none;">
<button id="diff-bundle-a-browse-btn" class="browse-button">Browse...</button>
<span id="diff-bundle-a-filename" class="filename-display">No file selected</span>
</div>
</div>
<div class="form-group">
<label for="diff-bundle-b">Second Bundle:</label>
<div class="file-input-group">
<input type="file" id="diff-bundle-b" accept=".fbundle" style="display: none;">
<button id="diff-bundle-b-browse-btn" class="browse-button">Browse...</button>
<span id="diff-bundle-b-filename" class="filename-display">No file selected</span>
</div>
</div>
<div class="form-actions">
<button id="plugin-diff-execute-btn" class="action-button primary" disabled>Compare Plugins</button>
</div>
</div>
<div id="plugin-diff-results" class="plugin-results hidden"></div>
</div>
</div>
<!-- Key Management Views --> <!-- Key Management Views -->
<div id="keys-view" class="hidden"> <div id="keys-view" class="hidden">
<!-- Trusted Keys View --> <!-- Trusted Keys View -->

View File

@@ -5,7 +5,7 @@
// Import modular components // Import modular components
const { setupAppEventHandlers, setupThemeHandlers } = require('./main/app-lifecycle'); const { setupAppEventHandlers, setupThemeHandlers } = require('./main/app-lifecycle');
const { setupFileDialogHandlers } = require('./main/file-dialogs'); const { setupFileDialogHandlers } = require('./main/file-dialogs');
const { setupBundleIPCHandlers, setupKeyIPCHandlers } = require('./main/ipc-handlers'); const { setupBundleIPCHandlers, setupKeyIPCHandlers, setupPluginIPCHandlers } = require('./main/ipc-handlers');
// Initialize all modules in the correct order // Initialize all modules in the correct order
function initializeMainProcess() { function initializeMainProcess() {
@@ -24,6 +24,9 @@ function initializeMainProcess() {
// Setup key management IPC handlers // Setup key management IPC handlers
setupKeyIPCHandlers(); setupKeyIPCHandlers();
// Setup plugin management IPC handlers
setupPluginIPCHandlers();
console.log('[MAIN_PROCESS] All modules initialized successfully'); console.log('[MAIN_PROCESS] All modules initialized successfully');
} }

View File

@@ -231,9 +231,92 @@ const setupBundleIPCHandlers = () => {
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
};
const setupPluginIPCHandlers = () => {
// Parse C++ interface handler
ipcMain.handle('parse-cpp-interface', async (event, { headerContent, fileName }) => {
try {
// Write header content to a temporary file since parse_cpp_interface expects a file path
const os = require('os');
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, fileName || 'temp_interface.h');
await fs.writeFile(tempFilePath, headerContent);
const kwargs = {
header_path: tempFilePath
};
const result = await runPythonCommand('parse_cpp_interface', kwargs, event);
// Clean up temporary file
try {
await fs.unlink(tempFilePath);
} catch (cleanupError) {
console.warn('[IPC_HANDLER] Failed to clean up temp file:', cleanupError);
}
return result;
} catch (error) {
console.error('[IPC_HANDLER] Error in parse-cpp-interface:', error);
return { success: false, error: error.message };
}
});
// Generate plugin project handler
ipcMain.handle('generate-plugin-project', async (event, projectConfig) => {
// The function expects a single 'config' parameter containing all the configuration
// Note: directory and header_path need to be converted to Path objects in Python
const kwargs = {
config: {
project_name: projectConfig.project_name,
chosen_interface: projectConfig.chosen_interface,
interfaces: projectConfig.interfaces,
directory: projectConfig.output_directory, // Will be converted to Path in Python
version: projectConfig.version,
libplugin_rev: projectConfig.libplugin_revision,
header_path: projectConfig.header_path // Will be converted to Path in Python
}
};
return runPythonCommand('generate_plugin_project', kwargs, event);
});
// Validate plugin project handler
ipcMain.handle('validate-plugin-project', async (event, { plugin_directory }) => {
const kwargs = {
project_path: plugin_directory // Function expects 'project_path', not 'plugin_directory'
};
return runPythonCommand('validate_plugin_project', kwargs, event);
});
// Extract plugin from bundle handler
ipcMain.handle('extract-plugin-from-bundle', async (event, { plugin_name, bundle_path, output_directory }) => {
const kwargs = {
plugin_name: plugin_name,
bundle_path: bundle_path,
output_directory: output_directory
};
return runPythonCommand('extract_plugin_from_bundle', kwargs, event);
});
// Compare plugin sources handler
ipcMain.handle('compare-plugin-sources', async (event, { plugin_name, bundle_a_path, bundle_b_path }) => {
const kwargs = {
plugin_name: plugin_name,
bundle_a_path: bundle_a_path,
bundle_b_path: bundle_b_path
};
return runPythonCommand('compare_plugin_sources', kwargs, event);
});
}; };
module.exports = { module.exports = {
setupBundleIPCHandlers, setupBundleIPCHandlers,
setupKeyIPCHandlers setupKeyIPCHandlers,
setupPluginIPCHandlers
}; };

View File

@@ -9,6 +9,9 @@ const stateManager = require('./renderer/state-manager');
const domManager = require('./renderer/dom-manager'); const domManager = require('./renderer/dom-manager');
const bundleOperations = require('./renderer/bundle-operations'); const bundleOperations = require('./renderer/bundle-operations');
const keyOperations = require('./renderer/key-operations'); const keyOperations = require('./renderer/key-operations');
console.log('[RENDERER] Loading plugin operations module...');
const pluginOperations = require('./renderer/plugin-operations');
console.log('[RENDERER] Plugin operations module loaded:', !!pluginOperations);
const uiComponents = require('./renderer/ui-components'); const uiComponents = require('./renderer/ui-components');
const eventHandlers = require('./renderer/event-handlers'); const eventHandlers = require('./renderer/event-handlers');
const opatHandler = require('./renderer/opat-handler'); const opatHandler = require('./renderer/opat-handler');
@@ -23,6 +26,7 @@ function initializeModules() {
domManager, domManager,
bundleOperations, bundleOperations,
keyOperations, keyOperations,
pluginOperations,
uiComponents, uiComponents,
eventHandlers, eventHandlers,
opatHandler, opatHandler,
@@ -33,6 +37,7 @@ function initializeModules() {
// Initialize each module with its dependencies // Initialize each module with its dependencies
bundleOperations.initializeDependencies(deps); bundleOperations.initializeDependencies(deps);
keyOperations.initializeDependencies(deps); keyOperations.initializeDependencies(deps);
pluginOperations.initializeDependencies(deps);
uiComponents.initializeDependencies(deps); uiComponents.initializeDependencies(deps);
eventHandlers.initializeDependencies(deps); eventHandlers.initializeDependencies(deps);
opatHandler.initializeDependencies(deps); opatHandler.initializeDependencies(deps);
@@ -87,6 +92,7 @@ window.stateManager = stateManager;
window.domManager = domManager; window.domManager = domManager;
window.bundleOperations = bundleOperations; window.bundleOperations = bundleOperations;
window.keyOperations = keyOperations; window.keyOperations = keyOperations;
window.pluginOperations = pluginOperations;
window.uiComponents = uiComponents; window.uiComponents = uiComponents;
window.eventHandlers = eventHandlers; window.eventHandlers = eventHandlers;
window.opatHandler = opatHandler; window.opatHandler = opatHandler;

View File

@@ -2,8 +2,9 @@
// Extracted from renderer.js to centralize DOM element handling and view management // Extracted from renderer.js to centralize DOM element handling and view management
// --- DOM ELEMENTS (will be initialized in initializeDOMElements) --- // --- DOM ELEMENTS (will be initialized in initializeDOMElements) ---
let welcomeScreen, bundleView, keysView, createBundleForm; let welcomeScreen, bundleView, keysView, createBundleForm, pluginView;
let openBundleBtn, createBundleBtn; let openBundleBtn, createBundleBtn;
let pluginInitBtn, pluginValidateBtn, pluginPackBtn, pluginExtractBtn, pluginDiffBtn;
let signBundleBtn, validateBundleBtn, clearBundleBtn, saveMetadataBtn; let signBundleBtn, validateBundleBtn, clearBundleBtn, saveMetadataBtn;
let saveOptionsModal, overwriteBundleBtn, saveAsNewBtn; let saveOptionsModal, overwriteBundleBtn, saveAsNewBtn;
let signatureWarningModal, signatureWarningCancel, signatureWarningContinue; let signatureWarningModal, signatureWarningCancel, signatureWarningContinue;
@@ -45,11 +46,18 @@ function initializeDOMElements() {
bundleView = document.getElementById('bundle-view'); bundleView = document.getElementById('bundle-view');
keysView = document.getElementById('keys-view'); keysView = document.getElementById('keys-view');
createBundleForm = document.getElementById('create-bundle-form'); createBundleForm = document.getElementById('create-bundle-form');
pluginView = document.getElementById('plugin-view');
// Sidebar buttons // Sidebar buttons
openBundleBtn = document.getElementById('open-bundle-btn'); openBundleBtn = document.getElementById('open-bundle-btn');
createBundleBtn = document.getElementById('create-bundle-btn'); createBundleBtn = document.getElementById('create-bundle-btn');
// Plugin management buttons
initPluginBtn = document.getElementById('init-plugin-btn');
validatePluginBtn = document.getElementById('validate-plugin-btn');
extractPluginBtn = document.getElementById('extract-plugin-btn');
diffPluginBtn = document.getElementById('diff-plugin-btn');
// Bundle action buttons // Bundle action buttons
signBundleBtn = document.getElementById('sign-bundle-btn'); signBundleBtn = document.getElementById('sign-bundle-btn');
validateBundleBtn = document.getElementById('validate-bundle-btn'); validateBundleBtn = document.getElementById('validate-bundle-btn');
@@ -90,10 +98,16 @@ function showView(viewId) {
const opatView = document.getElementById('opat-view'); const opatView = document.getElementById('opat-view');
// Hide main content views // Hide main content views
[welcomeScreen, bundleView, keysView, createBundleForm].forEach(view => { [welcomeScreen, bundleView, keysView, createBundleForm, pluginView].forEach(view => {
view.classList.toggle('hidden', view.id !== viewId); view.classList.toggle('hidden', view.id !== viewId);
}); });
// When switching away from plugin view, hide all plugin management sub-views
if (viewId !== 'plugin-view') {
const pluginManagementViews = document.querySelectorAll('.plugin-management-view');
pluginManagementViews.forEach(view => view.classList.add('hidden'));
}
// Handle OPAT view separately since it's not in the main views array // Handle OPAT view separately since it's not in the main views array
if (opatView) { if (opatView) {
opatView.classList.toggle('hidden', viewId !== 'opat-view'); opatView.classList.toggle('hidden', viewId !== 'opat-view');
@@ -117,6 +131,15 @@ function showView(viewId) {
if (libpluginView) { if (libpluginView) {
libpluginView.classList.remove('hidden'); libpluginView.classList.remove('hidden');
} }
} else if (viewId === 'plugin-view') {
// When switching to plugin view, show the default plugin sub-view (init view)
const pluginManagementViews = document.querySelectorAll('.plugin-management-view');
pluginManagementViews.forEach(view => view.classList.add('hidden'));
const defaultPluginView = document.getElementById('plugin-init-view');
if (defaultPluginView) {
defaultPluginView.classList.remove('hidden');
}
} else if (viewId === 'opat-view') { } else if (viewId === 'opat-view') {
// Ensure OPAT view is visible and properly initialized // Ensure OPAT view is visible and properly initialized
if (opatView) { if (opatView) {
@@ -135,6 +158,12 @@ function switchTab(tabId) {
tabLinks.forEach(link => { tabLinks.forEach(link => {
link.classList.toggle('active', link.dataset.tab === tabId); link.classList.toggle('active', link.dataset.tab === tabId);
}); });
// When switching away from libplugin tab, hide all plugin management views
if (tabId !== 'libplugin') {
const pluginManagementViews = document.querySelectorAll('.plugin-management-view');
pluginManagementViews.forEach(view => view.classList.add('hidden'));
}
} }
function showSpinner() { function showSpinner() {
@@ -174,8 +203,13 @@ module.exports = {
bundleView, bundleView,
keysView, keysView,
createBundleForm, createBundleForm,
pluginView,
openBundleBtn, openBundleBtn,
createBundleBtn, createBundleBtn,
initPluginBtn,
validatePluginBtn,
extractPluginBtn,
diffPluginBtn,
signBundleBtn, signBundleBtn,
validateBundleBtn, validateBundleBtn,
clearBundleBtn, clearBundleBtn,

View File

@@ -4,7 +4,7 @@
const { ipcRenderer } = require('electron'); const { ipcRenderer } = require('electron');
// Import dependencies (these will be injected when integrated) // Import dependencies (these will be injected when integrated)
let stateManager, domManager, bundleOperations, keyOperations, fillWorkflow, uiComponents, opatHandler; let stateManager, domManager, bundleOperations, keyOperations, pluginOperations, fillWorkflow, uiComponents, opatHandler;
// --- EVENT LISTENERS SETUP --- // --- EVENT LISTENERS SETUP ---
function setupEventListeners() { function setupEventListeners() {
@@ -66,6 +66,9 @@ function setupEventListeners() {
domManager.showModal('Not Implemented', 'The create bundle form will be moved to a modal dialog.'); domManager.showModal('Not Implemented', 'The create bundle form will be moved to a modal dialog.');
}); });
// Plugin management navigation is handled by plugin-operations.js
// No duplicate event listeners needed here
// Tab navigation // Tab navigation
elements.tabLinks.forEach(link => { elements.tabLinks.forEach(link => {
link.addEventListener('click', () => domManager.switchTab(link.dataset.tab)); link.addEventListener('click', () => domManager.switchTab(link.dataset.tab));
@@ -89,6 +92,17 @@ function setupEventListeners() {
// Key Management event listeners // Key Management event listeners
setupKeyManagementEventListeners(); setupKeyManagementEventListeners();
// Plugin Management event listeners
console.log('[EVENT_HANDLERS] Setting up plugin event listeners...');
console.log('[EVENT_HANDLERS] pluginOperations available:', !!pluginOperations);
console.log('[EVENT_HANDLERS] setupPluginEventListeners method available:', !!(pluginOperations && pluginOperations.setupPluginEventListeners));
if (pluginOperations && pluginOperations.setupPluginEventListeners) {
pluginOperations.setupPluginEventListeners();
} else {
console.warn('[EVENT_HANDLERS] Plugin operations not available for event listener setup');
}
// Signature warning modal event listeners // Signature warning modal event listeners
elements.signatureWarningCancel.addEventListener('click', () => { elements.signatureWarningCancel.addEventListener('click', () => {
elements.signatureWarningModal.classList.add('hidden'); elements.signatureWarningModal.classList.add('hidden');
@@ -172,6 +186,14 @@ function setupCategoryNavigation() {
// Show category home screen // Show category home screen
showCategoryHomeScreen(category); showCategoryHomeScreen(category);
// Set up plugin button listeners when libplugin category is shown
if (category === 'libplugin' && pluginOperations) {
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
pluginOperations.ensurePluginButtonListeners();
}, 100);
}
} }
// Update welcome screen // Update welcome screen
@@ -210,7 +232,7 @@ function showCategoryHomeScreen(category) {
const views = [ const views = [
'welcome-screen', 'libplugin-home', 'opat-home', 'welcome-screen', 'libplugin-home', 'opat-home',
'libconstants-home', 'serif-home', 'opat-view', 'libplugin-view', 'libconstants-home', 'serif-home', 'opat-view', 'libplugin-view',
'bundle-view', 'keys-view', 'create-bundle-form' 'bundle-view', 'keys-view', 'create-bundle-form', 'plugin-view'
]; ];
// Hide all views // Hide all views
@@ -512,6 +534,7 @@ function initializeDependencies(deps) {
domManager = deps.domManager; domManager = deps.domManager;
bundleOperations = deps.bundleOperations; bundleOperations = deps.bundleOperations;
keyOperations = deps.keyOperations; keyOperations = deps.keyOperations;
pluginOperations = deps.pluginOperations;
fillWorkflow = deps.fillWorkflow; fillWorkflow = deps.fillWorkflow;
uiComponents = deps.uiComponents; uiComponents = deps.uiComponents;
opatHandler = deps.opatHandler; opatHandler = deps.opatHandler;

View File

@@ -0,0 +1,509 @@
// Plugin Operations Module
// Handles all plugin-related functionality in the GUI
const { ipcRenderer } = require('electron');
class PluginOperations {
constructor() {
this.parsedInterfaces = null;
this.headerFilePath = null;
this.deps = null;
this.currentView = 'plugin-init-view';
}
initializeDependencies(deps) {
this.deps = deps;
console.log('[PLUGIN_OPERATIONS] Dependencies initialized');
console.log('[PLUGIN_OPERATIONS] Available deps:', Object.keys(deps));
}
// Show specific plugin view
showPluginView(viewId) {
// Show the main plugin view container first
this.deps.domManager.showView('plugin-view');
// Ensure plugin button event listeners are set up now that buttons are visible
this.ensurePluginButtonListeners();
// Hide all plugin views
const pluginViews = document.querySelectorAll('.plugin-management-view');
pluginViews.forEach(view => view.classList.add('hidden'));
// Show the requested view
const targetView = document.getElementById(viewId);
if (targetView) {
targetView.classList.remove('hidden');
this.currentView = viewId;
}
}
// Parse interface header file and populate interface selection
async parseInterfaceHeader(headerFile) {
try {
this.showPluginLoading('Parsing interface header file...');
// Read header file content
const headerContent = await this.readFileAsText(headerFile);
// Call backend to parse interfaces using specific IPC handler
const result = await ipcRenderer.invoke('parse-cpp-interface', {
headerContent: headerContent,
fileName: headerFile.name
});
if (result.success) {
this.populateInterfaceSelection(result.data);
this.headerFilePath = headerFile.path; // Store the header file path for later use
this.hidePluginResults();
} else {
this.showPluginError(`Failed to parse header file: ${result.error}`);
}
} catch (error) {
this.showPluginError(`Failed to parse header file: ${error.message}`);
}
}
// Populate interface selection dropdown
populateInterfaceSelection(interfaces) {
const interfaceSelection = document.getElementById('plugin-interface-selection');
const interfaceSelect = document.getElementById('plugin-interface-select');
const methodsPreview = document.getElementById('plugin-interface-methods');
const methodsList = document.getElementById('plugin-methods-list');
// Clear existing options
interfaceSelect.innerHTML = '<option value="">-- Select an Interface --</option>';
// Add interfaces to dropdown
Object.keys(interfaces).forEach(interfaceName => {
const option = document.createElement('option');
option.value = interfaceName;
option.textContent = interfaceName;
interfaceSelect.appendChild(option);
});
// Show interface selection
interfaceSelection.style.display = 'block';
// Store interfaces data for later use
this.parsedInterfaces = interfaces;
// Setup interface selection change handler
interfaceSelect.onchange = () => {
const selectedInterface = interfaceSelect.value;
if (selectedInterface && interfaces[selectedInterface]) {
// Show methods preview
methodsList.innerHTML = '';
interfaces[selectedInterface].forEach(method => {
const li = document.createElement('li');
li.textContent = method.signature;
methodsList.appendChild(li);
});
methodsPreview.style.display = 'block';
} else {
methodsPreview.style.display = 'none';
}
this.updateInitButtonState();
};
this.updateInitButtonState();
}
// Initialize Plugin Project
async initializePlugin() {
const projectName = document.getElementById('plugin-project-name').value.trim();
const headerFile = document.getElementById('plugin-header-file').files[0];
const selectedInterface = document.getElementById('plugin-interface-select').value;
const outputDir = document.getElementById('plugin-directory').value.trim();
const version = document.getElementById('plugin-version').value.trim();
const libpluginRev = document.getElementById('plugin-libplugin-rev').value.trim();
if (!projectName || !headerFile || !selectedInterface || !outputDir) {
this.showPluginError('Please fill in all required fields and select an interface.');
return;
}
try {
this.showPluginLoading('Initializing plugin project...');
// Call backend to initialize plugin using specific IPC handler
const result = await ipcRenderer.invoke('generate-plugin-project', {
project_name: projectName,
chosen_interface: selectedInterface,
interfaces: this.parsedInterfaces,
output_directory: outputDir,
version: version,
libplugin_revision: libpluginRev,
header_path: this.headerFilePath
});
this.handlePluginResult(result, 'Plugin project initialized successfully!');
} catch (error) {
this.showPluginError(`Failed to initialize plugin: ${error.message}`);
}
}
// Validate Plugin Project
async validatePlugin() {
const pluginPath = document.getElementById('validate-plugin-path').value.trim();
if (!pluginPath) {
this.showPluginError('Please select a plugin directory.');
return;
}
try {
this.showPluginLoading('Validating plugin project...');
const result = await ipcRenderer.invoke('validate-plugin-project', {
plugin_directory: pluginPath
});
this.handlePluginResult(result, 'Plugin validation completed!');
} catch (error) {
this.showPluginError(`Failed to validate plugin: ${error.message}`);
}
}
// Extract Plugin from Bundle
async extractPlugin() {
const pluginName = document.getElementById('extract-plugin-name').value.trim();
const bundleFile = document.getElementById('extract-bundle-file').files[0];
const outputDir = document.getElementById('extract-output-dir').value.trim();
if (!pluginName || !bundleFile || !outputDir) {
this.showPluginError('Please fill in all required fields.');
return;
}
try {
this.showPluginLoading('Extracting plugin from bundle...');
const result = await ipcRenderer.invoke('extract-plugin-from-bundle', {
plugin_name: pluginName,
bundle_path: bundleFile.path,
output_directory: outputDir
});
this.handlePluginResult(result, 'Plugin extracted successfully!');
} catch (error) {
this.showPluginError(`Failed to extract plugin: ${error.message}`);
}
}
// Compare Plugin Sources
async comparePlugins() {
const pluginName = document.getElementById('diff-plugin-name').value.trim();
const bundleA = document.getElementById('diff-bundle-a').files[0];
const bundleB = document.getElementById('diff-bundle-b').files[0];
if (!pluginName || !bundleA || !bundleB) {
this.showPluginError('Please fill in all required fields.');
return;
}
try {
this.showPluginLoading('Comparing plugin sources...');
const result = await ipcRenderer.invoke('compare-plugin-sources', {
plugin_name: pluginName,
bundle_a_path: bundleA.path,
bundle_b_path: bundleB.path
});
this.handlePluginResult(result, 'Plugin comparison completed!');
} catch (error) {
this.showPluginError(`Failed to compare plugins: ${error.message}`);
}
}
// Helper Methods
async readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = e => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
showPluginLoading(message) {
const resultsDiv = document.querySelector(`#${this.currentView} .plugin-results`);
if (resultsDiv) {
resultsDiv.classList.remove('hidden');
resultsDiv.innerHTML = `
<div class="loading-state">
<div class="loading-spinner"></div>
<p>${message}</p>
</div>
`;
}
}
showPluginError(message) {
const resultsDiv = document.querySelector(`#${this.currentView} .plugin-results`);
if (resultsDiv) {
resultsDiv.classList.remove('hidden');
resultsDiv.innerHTML = `
<div class="error-state">
<h4>Error</h4>
<p>${message}</p>
</div>
`;
}
}
hidePluginResults() {
const resultsDiv = document.querySelector(`#${this.currentView} .plugin-results`);
if (resultsDiv) {
resultsDiv.classList.add('hidden');
}
}
handlePluginResult(result, successMessage) {
const resultsDiv = document.querySelector(`#${this.currentView} .plugin-results`);
if (!resultsDiv) return;
resultsDiv.classList.remove('hidden');
if (result.success) {
let content = `<div class="success-state"><h4>${successMessage}</h4>`;
if (result.data) {
if (result.data.output) {
content += `<pre class="command-output">${result.data.output}</pre>`;
}
if (result.data.diff) {
content += `<pre class="diff-output">${result.data.diff}</pre>`;
}
if (result.data.validation_results) {
content += `<div class="validation-results">`;
result.data.validation_results.forEach(item => {
content += `<p><strong>${item.check}:</strong> ${item.status}</p>`;
});
content += `</div>`;
}
}
content += `</div>`;
resultsDiv.innerHTML = content;
} else {
resultsDiv.innerHTML = `
<div class="error-state">
<h4>Operation Failed</h4>
<p>${result.error}</p>
</div>
`;
}
}
// Setup event listeners for plugin operations (called when plugin buttons are visible)
setupPluginEventListeners() {
// Don't set up listeners during initial app startup - wait until plugin buttons are visible
// This method will be called from showPluginView() when needed
}
// Setup plugin button event listeners when they're actually in the DOM
ensurePluginButtonListeners() {
if (this.listenersSetup) return; // Avoid duplicate setup
// Plugin navigation buttons with active state management
const pluginButtons = [
{ id: 'init-plugin-btn', view: 'plugin-init-view' },
{ id: 'validate-plugin-btn', view: 'plugin-validate-view' },
{ id: 'extract-plugin-btn', view: 'plugin-extract-view' },
{ id: 'diff-plugin-btn', view: 'plugin-diff-view' }
];
let allButtonsFound = true;
pluginButtons.forEach(({ id, view }) => {
const button = document.getElementById(id);
if (button) {
button.addEventListener('click', () => {
// Remove active class from all plugin buttons
pluginButtons.forEach(({ id: btnId }) => {
document.getElementById(btnId)?.classList.remove('active');
});
// Add active class to clicked button
button.classList.add('active');
// Show the plugin view
this.showPluginView(view);
});
} else {
allButtonsFound = false;
}
});
if (allButtonsFound) {
this.listenersSetup = true;
}
// Plugin Initialize form
document.getElementById('plugin-header-browse-btn')?.addEventListener('click', () => {
document.getElementById('plugin-header-file').click();
});
document.getElementById('plugin-header-file')?.addEventListener('change', async (e) => {
const file = e.target.files[0];
const filename = file?.name || 'No file selected';
document.getElementById('plugin-header-filename').textContent = filename;
// Hide interface selection and reset state when new file is selected
const interfaceSelection = document.getElementById('plugin-interface-selection');
const interfaceSelect = document.getElementById('plugin-interface-select');
const methodsPreview = document.getElementById('plugin-interface-methods');
if (interfaceSelection) interfaceSelection.style.display = 'none';
if (interfaceSelect) interfaceSelect.value = '';
if (methodsPreview) methodsPreview.style.display = 'none';
this.parsedInterfaces = null;
this.updateInitButtonState();
// Parse interface file if one was selected
if (file) {
await this.parseInterfaceHeader(file);
}
});
document.getElementById('plugin-directory-browse-btn')?.addEventListener('click', async () => {
const result = await ipcRenderer.invoke('select-directory');
if (result) {
document.getElementById('plugin-directory').value = result;
this.updateInitButtonState();
}
});
document.getElementById('plugin-project-name')?.addEventListener('input', () => {
this.updateInitButtonState();
});
document.getElementById('plugin-init-execute-btn')?.addEventListener('click', () => {
this.initializePlugin();
});
// Plugin Validate form
document.getElementById('validate-plugin-browse-btn')?.addEventListener('click', async () => {
const result = await ipcRenderer.invoke('select-directory');
if (result) {
document.getElementById('validate-plugin-path').value = result;
}
});
document.getElementById('plugin-validate-execute-btn')?.addEventListener('click', () => {
this.validatePlugin();
});
// Plugin Pack form
document.getElementById('pack-plugin-browse-btn')?.addEventListener('click', async () => {
const result = await ipcRenderer.invoke('select-directory');
if (result) {
document.getElementById('pack-plugin-path').value = result;
}
});
document.getElementById('plugin-pack-execute-btn')?.addEventListener('click', () => {
this.packPlugin();
});
// Plugin Extract form
document.getElementById('extract-bundle-browse-btn')?.addEventListener('click', () => {
document.getElementById('extract-bundle-file').click();
});
document.getElementById('extract-bundle-file')?.addEventListener('change', (e) => {
const filename = e.target.files[0]?.name || 'No file selected';
document.getElementById('extract-bundle-filename').textContent = filename;
this.updateExtractButtonState();
});
document.getElementById('extract-output-browse-btn')?.addEventListener('click', async () => {
const result = await ipcRenderer.invoke('select-directory');
if (result) {
document.getElementById('extract-output-dir').value = result;
this.updateExtractButtonState();
}
});
document.getElementById('extract-plugin-name')?.addEventListener('input', () => {
this.updateExtractButtonState();
});
document.getElementById('plugin-extract-execute-btn')?.addEventListener('click', () => {
this.extractPlugin();
});
// Plugin Diff form
document.getElementById('diff-bundle-a-browse-btn')?.addEventListener('click', () => {
document.getElementById('diff-bundle-a').click();
});
document.getElementById('diff-bundle-a')?.addEventListener('change', (e) => {
const filename = e.target.files[0]?.name || 'No file selected';
document.getElementById('diff-bundle-a-filename').textContent = filename;
this.updateDiffButtonState();
});
document.getElementById('diff-bundle-b-browse-btn')?.addEventListener('click', () => {
document.getElementById('diff-bundle-b').click();
});
document.getElementById('diff-bundle-b')?.addEventListener('change', (e) => {
const filename = e.target.files[0]?.name || 'No file selected';
document.getElementById('diff-bundle-b-filename').textContent = filename;
this.updateDiffButtonState();
});
document.getElementById('diff-plugin-name')?.addEventListener('input', () => {
this.updateDiffButtonState();
});
document.getElementById('plugin-diff-execute-btn')?.addEventListener('click', () => {
this.comparePlugins();
});
console.log('[PLUGIN_OPERATIONS] Event listeners setup complete');
}
// Button state management
updateInitButtonState() {
const projectName = document.getElementById('plugin-project-name').value.trim();
const headerFile = document.getElementById('plugin-header-file').files[0];
const selectedInterface = document.getElementById('plugin-interface-select').value;
const outputDir = document.getElementById('plugin-directory').value.trim();
const button = document.getElementById('plugin-init-execute-btn');
if (button) {
// Button is enabled only when all required fields are filled AND an interface is selected
button.disabled = !projectName || !headerFile || !selectedInterface || !outputDir;
}
}
updateExtractButtonState() {
const pluginName = document.getElementById('extract-plugin-name').value.trim();
const bundleFile = document.getElementById('extract-bundle-file').files[0];
const outputDir = document.getElementById('extract-output-dir').value.trim();
const button = document.getElementById('plugin-extract-execute-btn');
if (button) {
button.disabled = !pluginName || !bundleFile || !outputDir;
}
}
updateDiffButtonState() {
const pluginName = document.getElementById('diff-plugin-name').value.trim();
const bundleA = document.getElementById('diff-bundle-a').files[0];
const bundleB = document.getElementById('diff-bundle-b').files[0];
const button = document.getElementById('plugin-diff-execute-btn');
if (button) {
button.disabled = !pluginName || !bundleA || !bundleB;
}
}
}
module.exports = new PluginOperations();

View File

@@ -298,6 +298,79 @@ body.dark-mode .app-title {
flex-shrink: 0; flex-shrink: 0;
transition: all 0.2s ease; transition: all 0.2s ease;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.05); box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.05);
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
scroll-behavior: smooth;
}
/* Custom scrollbar for secondary sidebar */
.secondary-sidebar::-webkit-scrollbar {
width: 6px;
}
.secondary-sidebar::-webkit-scrollbar-track {
background: transparent;
}
.secondary-sidebar::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
transition: background 0.2s ease;
}
.secondary-sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
/* Responsive sidebar content spacing */
.sidebar-content {
padding: 1rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* Allow content to shrink */
}
/* Responsive button spacing based on available height */
@media (max-height: 700px) {
.sidebar-nav {
gap: 0.5rem;
}
.nav-button {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.sidebar-header {
margin-top: 1rem !important;
margin-bottom: 0.5rem;
}
.sidebar-header h3 {
font-size: 1rem;
}
}
@media (max-height: 600px) {
.sidebar-nav {
gap: 0.25rem;
}
.nav-button {
padding: 0.375rem 0.5rem;
font-size: 0.8rem;
}
.sidebar-header {
margin-top: 0.75rem !important;
margin-bottom: 0.25rem;
}
.sidebar-header h3 {
font-size: 0.9rem;
}
} }
body.dark-mode .secondary-sidebar { body.dark-mode .secondary-sidebar {
@@ -306,6 +379,15 @@ body.dark-mode .secondary-sidebar {
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.05); box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.05);
} }
/* Dark mode scrollbar */
body.dark-mode .secondary-sidebar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
body.dark-mode .secondary-sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Responsive secondary sidebar */ /* Responsive secondary sidebar */
@media (min-width: 1280px) { @media (min-width: 1280px) {
.secondary-sidebar { .secondary-sidebar {
@@ -914,7 +996,16 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
background: var(--background-color); background: var(--background-color);
} }
/* Individual Key Management Views */ /* Main Content Area */
.main-content {
flex: 1;
overflow-y: auto;
padding: 2rem;
background: var(--background-color);
max-height: calc(100vh - 60px); /* Fix scrolling by setting max height */
}
/* Individual Key Management Styles */
.key-management-view { .key-management-view {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -922,6 +1013,98 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
background: var(--background-color); background: var(--background-color);
} }
/* Plugin Management Styles */
.plugin-management-view {
flex: 1;
overflow-y: auto;
padding: 20px;
background: var(--background-color);
max-height: calc(100vh - 120px); /* Fix scrolling by setting max height */
}
/* File Input Group Styling */
.file-input-group {
display: flex;
align-items: stretch;
gap: 0;
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
background: white;
transition: all 0.2s ease;
}
.file-input-group:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.file-input-group input {
flex: 1;
border: none !important;
border-radius: 0 !important;
margin: 0;
box-shadow: none !important;
background: transparent;
}
.file-input-group input:focus {
box-shadow: none !important;
border: none !important;
}
/* Modern Browse Button Styling */
.browse-button {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
border: none;
padding: 0.75rem 1.25rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 0;
white-space: nowrap;
position: relative;
overflow: hidden;
}
.browse-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s ease;
}
.browse-button:hover {
background: linear-gradient(135deg, var(--secondary-color) 0%, #1d4ed8 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.browse-button:hover::before {
left: 100%;
}
.browse-button:active {
transform: translateY(0);
}
/* Filename Display Styling */
.filename-display {
color: var(--text-light);
font-size: 0.9rem;
font-style: italic;
margin-left: 0.5rem;
padding: 0.75rem 0;
}
/* Key Management Headers */ /* Key Management Headers */
.keys-header, .keys-header,
.generate-key-header, .generate-key-header,
@@ -937,6 +1120,13 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
gap: 1rem; gap: 1rem;
} }
/* Plugin Management Headers */
.plugin-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.keys-header .keys-actions { .keys-header .keys-actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -965,7 +1155,8 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
/* Form Styling */ /* Form Styling */
.generate-key-form, .generate-key-form,
.add-key-form { .add-key-form,
.plugin-form {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 2rem;
@@ -1549,6 +1740,29 @@ body.dark-mode .info-banner svg {
color: #60a5fa; color: #60a5fa;
} }
/* Dark Mode Support for File Input Groups and Browse Buttons */
body.dark-mode .file-input-group {
background: #1e293b;
border-color: #334155;
}
body.dark-mode .file-input-group input {
background: transparent;
color: #e2e8f0;
}
body.dark-mode .browse-button {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
}
body.dark-mode .browse-button:hover {
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
}
body.dark-mode .filename-display {
color: #94a3b8;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.key-management-view { .key-management-view {

View File

@@ -1,47 +1,14 @@
# fourdst/cli/plugin/diff.py # fourdst/cli/plugin/diff.py
import typer import typer
import yaml
import zipfile
from pathlib import Path from pathlib import Path
import tempfile
import shutil
import difflib
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text
from fourdst.core.plugin import compare_plugin_sources
console = Console() console = Console()
def _extract_sdist(bundle_path: Path, plugin_name: str, temp_dir: Path):
"""Extracts a specific plugin's sdist from a bundle to a directory."""
sdist_extract_path = temp_dir / f"{plugin_name}_src"
with tempfile.TemporaryDirectory() as bundle_unpack_dir_str:
bundle_unpack_dir = Path(bundle_unpack_dir_str)
with zipfile.ZipFile(bundle_path, 'r') as bundle_zip:
bundle_zip.extractall(bundle_unpack_dir)
manifest_path = bundle_unpack_dir / "manifest.yaml"
if not manifest_path.exists():
raise FileNotFoundError("manifest.yaml not found in bundle.")
with open(manifest_path, 'r') as f:
manifest = yaml.safe_load(f)
plugin_data = manifest.get('bundlePlugins', {}).get(plugin_name)
if not plugin_data or 'sdist' not in plugin_data:
raise FileNotFoundError(f"Plugin '{plugin_name}' or its sdist not found in {bundle_path.name}.")
sdist_path_in_bundle = bundle_unpack_dir / plugin_data['sdist']['path']
if not sdist_path_in_bundle.exists():
raise FileNotFoundError(f"sdist archive '{plugin_data['sdist']['path']}' not found in bundle.")
with zipfile.ZipFile(sdist_path_in_bundle, 'r') as sdist_zip:
sdist_zip.extractall(sdist_extract_path)
return sdist_extract_path
def plugin_diff( def plugin_diff(
plugin_name: str = typer.Argument(..., help="The name of the plugin to compare."), plugin_name: str = typer.Argument(..., help="The name of the plugin to compare."),
bundle_a_path: Path = typer.Argument(..., help="The first bundle to compare.", exists=True, readable=True), bundle_a_path: Path = typer.Argument(..., help="The first bundle to compare.", exists=True, readable=True),
@@ -52,54 +19,41 @@ def plugin_diff(
""" """
console.print(Panel(f"Comparing source for plugin [bold blue]{plugin_name}[/bold blue] between bundles")) console.print(Panel(f"Comparing source for plugin [bold blue]{plugin_name}[/bold blue] between bundles"))
with tempfile.TemporaryDirectory() as temp_a_str, tempfile.TemporaryDirectory() as temp_b_str: # Compare using core function
try: compare_result = compare_plugin_sources(bundle_a_path, bundle_b_path, plugin_name)
src_a_path = _extract_sdist(bundle_a_path, plugin_name, Path(temp_a_str)) if not compare_result['success']:
src_b_path = _extract_sdist(bundle_b_path, plugin_name, Path(temp_b_str)) console.print(f"[red]Error: {compare_result['error']}[/red]")
except FileNotFoundError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1) raise typer.Exit(code=1)
files_a = {p.relative_to(src_a_path) for p in src_a_path.rglob('*') if p.is_file()} # Display results
files_b = {p.relative_to(src_b_path) for p in src_b_path.rglob('*') if p.is_file()} compare_data = compare_result['data']
has_changes = compare_data['has_changes']
added_files = files_b - files_a added_files = compare_data['added_files']
removed_files = files_a - files_b removed_files = compare_data['removed_files']
common_files = files_a & files_b modified_files = compare_data['modified_files']
has_changes = False
if added_files: if added_files:
has_changes = True console.print(Panel("\n".join(f"[green]+ {f}[/green]" for f in added_files), title="[bold]Added Files[/bold]"))
console.print(Panel("\n".join(f"[green]+ {f}[/green]" for f in sorted(list(added_files))), title="[bold]Added Files[/bold]"))
if removed_files: if removed_files:
has_changes = True console.print(Panel("\n".join(f"[red]- {f}[/red]" for f in removed_files), title="[bold]Removed Files[/bold]"))
console.print(Panel("\n".join(f"[red]- {f}[/red]" for f in sorted(list(removed_files))), title="[bold]Removed Files[/bold]"))
modified_files_count = 0 for modified_file in modified_files:
for file_rel_path in sorted(list(common_files)): file_path = modified_file['file_path']
content_a = (src_a_path / file_rel_path).read_text() diff_content = modified_file['diff']
content_b = (src_b_path / file_rel_path).read_text()
if content_a != content_b:
has_changes = True
modified_files_count += 1
diff = difflib.unified_diff(
content_a.splitlines(keepends=True),
content_b.splitlines(keepends=True),
fromfile=f"a/{file_rel_path}",
tofile=f"b/{file_rel_path}",
)
diff_text = Text() diff_text = Text()
for line in diff: for line in diff_content.splitlines(keepends=True):
if line.startswith('+'): diff_text.append(line, style="green") if line.startswith('+'):
elif line.startswith('-'): diff_text.append(line, style="red") diff_text.append(line, style="green")
else: diff_text.append(line) elif line.startswith('-'):
diff_text.append(line, style="red")
else:
diff_text.append(line)
console.print(Panel(diff_text, title=f"[bold yellow]Modified: {file_rel_path}[/bold yellow]", border_style="yellow", expand=False)) console.print(Panel(diff_text, title=f"[bold yellow]Modified: {file_path}[/bold yellow]", border_style="yellow", expand=False))
if not has_changes: if not has_changes:
console.print(Panel("[green]No source code changes detected for this plugin.[/green]", title="Result")) console.print(Panel("[green]No source code changes detected for this plugin.[/green]", title="Result"))
else: else:
console.print(f"\nFound changes in {modified_files_count} file(s).") console.print(f"\nFound changes in {len(modified_files)} file(s).")

View File

@@ -1,10 +1,8 @@
# fourdst/cli/plugin/extract.py # fourdst/cli/plugin/extract.py
import typer import typer
import yaml
import zipfile
from pathlib import Path from pathlib import Path
import tempfile
import shutil from fourdst.core.plugin import extract_plugin_from_bundle
def plugin_extract( def plugin_extract(
plugin_name: str = typer.Argument(..., help="The name of the plugin to extract."), plugin_name: str = typer.Argument(..., help="The name of the plugin to extract."),
@@ -22,61 +20,20 @@ def plugin_extract(
""" """
Extracts a plugin's source code from a bundle. Extracts a plugin's source code from a bundle.
""" """
output_dir.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory() as temp_dir_str:
temp_dir = Path(temp_dir_str)
# 1. Unpack the main bundle
typer.echo(f"Opening bundle: {bundle_path.name}") typer.echo(f"Opening bundle: {bundle_path.name}")
with zipfile.ZipFile(bundle_path, 'r') as bundle_zip:
bundle_zip.extractall(temp_dir)
# 2. Read the manifest # Extract using core function
manifest_path = temp_dir / "manifest.yaml" extract_result = extract_plugin_from_bundle(bundle_path, plugin_name, output_dir)
if not manifest_path.exists(): if not extract_result['success']:
typer.secho("Error: Bundle is invalid. Missing manifest.yaml.", fg=typer.colors.RED) typer.secho(f"Error: {extract_result['error']}", fg=typer.colors.RED)
raise typer.Exit(code=1) raise typer.Exit(code=1)
with open(manifest_path, 'r') as f: # Display results
manifest = yaml.safe_load(f) extract_data = extract_result['data']
final_destination = Path(extract_data['output_path'])
# 3. Find the plugin and its sdist
plugin_data = manifest.get('bundlePlugins', {}).get(plugin_name)
if not plugin_data:
typer.secho(f"Error: Plugin '{plugin_name}' not found in the bundle.", fg=typer.colors.RED)
available_plugins = list(manifest.get('bundlePlugins', {}).keys())
if available_plugins:
typer.echo("Available plugins are: " + ", ".join(available_plugins))
raise typer.Exit(code=1)
sdist_info = plugin_data.get('sdist')
if not sdist_info or 'path' not in sdist_info:
typer.secho(f"Error: Source distribution (sdist) not found for plugin '{plugin_name}'.", fg=typer.colors.RED)
raise typer.Exit(code=1)
sdist_path_in_bundle = temp_dir / sdist_info['path']
if not sdist_path_in_bundle.is_file():
typer.secho(f"Error: sdist file '{sdist_info['path']}' is missing from the bundle archive.", fg=typer.colors.RED)
raise typer.Exit(code=1)
# 4. Extract the sdist to the final output directory
final_destination = output_dir / plugin_name
if final_destination.exists(): if final_destination.exists():
typer.secho(f"Warning: Output directory '{final_destination}' already exists. Files may be overwritten.", fg=typer.colors.YELLOW) typer.secho(f"Warning: Output directory '{final_destination}' already existed. Files may have been overwritten.", fg=typer.colors.YELLOW)
else:
final_destination.mkdir(parents=True)
typer.echo(f"Extracting '{plugin_name}' source to '{final_destination.resolve()}'...")
with zipfile.ZipFile(sdist_path_in_bundle, 'r') as sdist_zip:
sdist_zip.extractall(final_destination)
typer.echo(f"Extracting '{plugin_name}' source to '{final_destination}'...")
typer.secho(f"\n✅ Plugin '{plugin_name}' extracted successfully.", fg=typer.colors.GREEN) typer.secho(f"\n✅ Plugin '{plugin_name}' extracted successfully.", fg=typer.colors.GREEN)
except zipfile.BadZipFile:
typer.secho(f"Error: '{bundle_path}' is not a valid bundle (zip) file.", fg=typer.colors.RED)
raise typer.Exit(code=1)
except Exception as e:
typer.secho(f"An unexpected error occurred: {e}", fg=typer.colors.RED)
raise typer.Exit(code=1)

View File

@@ -2,12 +2,10 @@
import typer import typer
import sys import sys
import shutil
from pathlib import Path from pathlib import Path
import questionary import questionary
from fourdst.cli.common.utils import run_command, get_template_content from fourdst.core.plugin import parse_cpp_interface, generate_plugin_project
from fourdst.cli.common.templates import GITIGNORE_CONTENT
plugin_app = typer.Typer() plugin_app = typer.Typer()
@@ -23,13 +21,25 @@ def plugin_init(
Initializes a new Meson-based C++ plugin project from an interface header. Initializes a new Meson-based C++ plugin project from an interface header.
""" """
print(f"Parsing interface header: {header.name}") print(f"Parsing interface header: {header.name}")
interfaces = parse_cpp_header(header)
# Parse the C++ header using core function
parse_result = parse_cpp_interface(header)
if not parse_result['success']:
print(f"Error: {parse_result['error']}", file=sys.stderr)
raise typer.Exit(code=1)
interfaces = parse_result['data']
if not interfaces: if not interfaces:
print(f"Error: No suitable interfaces (classes with pure virtual methods) found in {header}", file=sys.stderr) print(f"Error: No suitable interfaces (classes with pure virtual methods) found in {header}", file=sys.stderr)
raise typer.Exit(code=1) raise typer.Exit(code=1)
# --- Interactive Selection --- # Display found interfaces
for interface_name, methods in interfaces.items():
print(f"Found interface: '{interface_name}'")
for method in methods:
print(f" -> Found pure virtual method: {method['signature']}")
# Interactive Selection
chosen_interface = questionary.select( chosen_interface = questionary.select(
"Which interface would you like to implement?", "Which interface would you like to implement?",
choices=list(interfaces.keys()) choices=list(interfaces.keys())
@@ -40,152 +50,29 @@ def plugin_init(
print(f"Initializing plugin '{project_name}' implementing interface '{chosen_interface}'...") print(f"Initializing plugin '{project_name}' implementing interface '{chosen_interface}'...")
# --- Code Generation --- # Generate the project using core function
method_stubs = "\n".join( config = {
f" {method['signature']} override {{\n{method['body']}\n }}" 'project_name': project_name,
for method in interfaces[chosen_interface] 'header_path': header,
) 'directory': directory,
'version': version,
'libplugin_rev': libplugin_rev,
'chosen_interface': chosen_interface,
'interfaces': interfaces
}
class_name = ''.join(filter(str.isalnum, project_name.replace('_', ' ').title().replace(' ', ''))) + "Plugin" generation_result = generate_plugin_project(config)
root_path = directory / project_name if not generation_result['success']:
src_path = root_path / "src" print(f"Error creating project structure: {generation_result['error']}", file=sys.stderr)
include_path = src_path / "include"
subprojects_path = root_path / "subprojects"
try:
src_path.mkdir(parents=True, exist_ok=True)
include_path.mkdir(exist_ok=True)
subprojects_path.mkdir(exist_ok=True)
# --- Copy interface header to make project self-contained ---
local_header_path = include_path / header.name
shutil.copy(header, local_header_path)
print(f" -> Copied interface header to {local_header_path.relative_to(root_path)}")
# --- Create libplugin.wrap file ---
libplugin_wrap_content = f"""[wrap-git]
url = https://github.com/4D-STAR/libplugin
revision = {libplugin_rev}
depth = 1
"""
(subprojects_path / "libplugin.wrap").write_text(libplugin_wrap_content)
print(f" -> Created {subprojects_path / 'libplugin.wrap'}")
# --- Create meson.build from template ---
meson_template = get_template_content("meson.build.in")
meson_content = meson_template.format(
project_name=project_name,
version=version
)
(root_path / "meson.build").write_text(meson_content)
print(f" -> Created {root_path / 'meson.build'}")
# --- Create C++ source file from template ---
cpp_template = get_template_content("plugin.cpp.in")
cpp_content = cpp_template.format(
class_name=class_name,
project_name=project_name,
interface=chosen_interface,
interface_header_path=header.name, # Use just the filename
method_stubs=method_stubs
)
(src_path / f"{project_name}.cpp").write_text(cpp_content)
print(f" -> Created {src_path / f'{project_name}.cpp'}")
# --- Create .gitignore ---
(root_path / ".gitignore").write_text(GITIGNORE_CONTENT)
print(f" -> Created .gitignore")
# --- Initialize Git Repository ---
print(" -> Initializing Git repository...")
run_command(["git", "init"], cwd=root_path)
run_command(["git", "add", "."], cwd=root_path)
commit_message = f"Initial commit: Scaffold fourdst plugin '{project_name}'"
run_command(["git", "commit", "-m", commit_message], cwd=root_path)
except OSError as e:
print(f"Error creating project structure: {e}", file=sys.stderr)
raise typer.Exit(code=1) raise typer.Exit(code=1)
# Display results
project_data = generation_result['data']
for file_path in project_data['files_created']:
print(f" -> Created {file_path}")
print("\n✅ Project initialized successfully and committed to Git!") print("\n✅ Project initialized successfully and committed to Git!")
print("To build your new plugin:") print("To build your new plugin:")
print(f" cd {root_path}") print(f" cd {project_data['project_path']}")
print(" meson setup builddir") print(" meson setup builddir")
print(" meson compile -C builddir") print(" meson compile -C builddir")
def parse_cpp_header(header_path: Path):
"""
Parses a C++ header file using libclang to find classes and their pure virtual methods.
"""
# This function requires python-clang-16
try:
from clang import cindex
except ImportError:
print("Error: The 'init' command requires 'libclang'. Please install it.", file=sys.stderr)
print("Run: pip install python-clang-16", file=sys.stderr)
# Also ensure the libclang.so/dylib is in your system's library path.
raise typer.Exit(code=1)
if not cindex.Config.loaded:
try:
# Attempt to find libclang automatically. This may need to be configured by the user.
# On systems like macOS, you might need to point to the specific version, e.g.:
# cindex.Config.set_library_path('/opt/homebrew/opt/llvm/lib')
cindex.Config.set_library_file(cindex.conf.get_filename())
except cindex.LibclangError as e:
print(f"Error: libclang library not found. Please ensure it's installed and in your system's path.", file=sys.stderr)
print(f"Details: {e}", file=sys.stderr)
raise typer.Exit(code=1)
index = cindex.Index.create()
# Pass standard C++ arguments to the parser. This improves reliability.
args = ['-x', 'c++', '-std=c++17']
translation_unit = index.parse(str(header_path), args=args)
if not translation_unit:
print(f"Error: Unable to parse the translation unit {header_path}", file=sys.stderr)
raise typer.Exit(code=1)
interfaces = {}
# --- Recursive function to walk the AST ---
def walk_ast(node):
# We are looking for class definitions, not just declarations.
if node.kind == cindex.CursorKind.CLASS_DECL and node.is_definition():
# Collect pure virtual methods within this class
pv_methods = [m for m in node.get_children()
if m.kind == cindex.CursorKind.CXX_METHOD and m.is_pure_virtual_method()]
# If it has pure virtual methods, it's an interface we care about
if pv_methods:
interface_name = node.spelling
methods = []
print(f"Found interface: '{interface_name}'")
for method in pv_methods:
# Get the string representation of all argument types
args_str = ', '.join([arg.type.spelling for arg in method.get_arguments()])
# Reconstruct the signature from its parts. This is much more reliable.
sig = f"{method.result_type.spelling} {method.spelling}({args_str})"
# Append 'const' if the method is a const method
if method.is_const_method():
sig += " const"
methods.append({"signature": sig, "body": " // TODO: Implement this method"})
print(f" -> Found pure virtual method: {sig}")
interfaces[interface_name] = methods
interfaces[interface_name] = methods
# --- The recursive step ---
# Recurse for children of this node
for child in node.get_children():
walk_ast(child)
# Start the traversal from the root of the AST
walk_ast(translation_unit.cursor)
return interfaces

View File

@@ -1,52 +1,8 @@
# fourdst/cli/plugin/pack.py # fourdst/cli/plugin/pack.py
import typer import typer
import sys
import yaml
import zipfile
from pathlib import Path from pathlib import Path
from fourdst.cli.common.utils import calculate_sha256 from fourdst.core.plugin import validate_bundle_directory, pack_bundle_directory
def _validate_bundle_directory(directory: Path) -> list[str]:
"""
Validates that a directory has the structure of a valid bundle.
Returns a list of error strings. An empty list means success.
"""
errors = []
manifest_path = directory / "manifest.yaml"
if not manifest_path.is_file():
return ["Error: Missing 'manifest.yaml' in the root of the directory."]
try:
with open(manifest_path, 'r') as f:
manifest = yaml.safe_load(f)
except yaml.YAMLError as e:
return [f"Error: Invalid YAML in manifest.yaml: {e}"]
# 1. Check that all files referenced in the manifest exist
for plugin_name, plugin_data in manifest.get('bundlePlugins', {}).items():
sdist_info = plugin_data.get('sdist', {})
if sdist_info:
sdist_path = sdist_info.get('path')
if sdist_path and not (directory / sdist_path).is_file():
errors.append(f"Missing sdist file for '{plugin_name}': {sdist_path}")
for binary in plugin_data.get('binaries', []):
binary_path = binary.get('path')
if binary_path and not (directory / binary_path).is_file():
errors.append(f"Missing binary file for '{plugin_name}': {binary_path}")
# 2. If checksums exist, validate them
expected_checksum = binary.get('checksum')
if binary_path and expected_checksum:
file_to_check = directory / binary_path
if file_to_check.is_file():
actual_checksum = "sha256:" + calculate_sha256(file_to_check)
if actual_checksum != expected_checksum:
errors.append(f"Checksum mismatch for '{binary_path}'")
return errors
def plugin_pack( def plugin_pack(
@@ -58,8 +14,13 @@ def plugin_pack(
""" """
typer.echo(f"--- Validating Bundle Directory: {folder_path.resolve()} ---") typer.echo(f"--- Validating Bundle Directory: {folder_path.resolve()} ---")
validation_errors = _validate_bundle_directory(folder_path) # Validate using core function
validation_result = validate_bundle_directory(folder_path)
if not validation_result['success']:
typer.secho(f"Error during validation: {validation_result['error']}", fg=typer.colors.RED)
raise typer.Exit(code=1)
validation_errors = validation_result['data']['errors']
if validation_errors: if validation_errors:
typer.secho("Validation Failed. The following issues were found:", fg=typer.colors.RED, bold=True) typer.secho("Validation Failed. The following issues were found:", fg=typer.colors.RED, bold=True)
for error in validation_errors: for error in validation_errors:
@@ -70,31 +31,27 @@ def plugin_pack(
typer.echo("\n--- Packing Bundle ---") typer.echo("\n--- Packing Bundle ---")
output_name = name if name else folder_path.name output_name = name if name else folder_path.name
output_path = folder_path.parent / f"{output_name}.fbundle" if folder_path.parent.exists():
typer.secho(f"Warning: Output file {folder_path.parent / f'{output_name}.fbundle'} will be created/overwritten.", fg=typer.colors.YELLOW)
if output_path.exists(): # Pack using core function
typer.secho(f"Warning: Output file {output_path} already exists and will be overwritten.", fg=typer.colors.YELLOW) output_config = {
'name': output_name,
'output_dir': folder_path.parent
}
try: pack_result = pack_bundle_directory(folder_path, output_config)
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as bundle_zip: if not pack_result['success']:
for file_to_add in folder_path.rglob('*'): typer.secho(f"An unexpected error occurred during packing: {pack_result['error']}", fg=typer.colors.RED)
if file_to_add.is_file(): raise typer.Exit(code=1)
arcname = file_to_add.relative_to(folder_path)
bundle_zip.write(file_to_add, arcname)
typer.echo(f" Adding: {arcname}")
typer.secho(f"\n✅ Successfully created bundle: {output_path.resolve()}", fg=typer.colors.GREEN, bold=True) # Display results
pack_data = pack_result['data']
typer.echo(f" Added {pack_data['files_packed']} files to bundle")
typer.secho(f"\n✅ Successfully created bundle: {pack_data['output_path']}", fg=typer.colors.GREEN, bold=True)
# Final status report # Final status report
with open(folder_path / "manifest.yaml", 'r') as f: if pack_data['is_signed']:
manifest = yaml.safe_load(f)
is_signed = 'bundleAuthorKeyFingerprint' in manifest and (folder_path / "manifest.sig").exists()
if is_signed:
typer.secho("Bundle Status: ✅ SIGNED", fg=typer.colors.GREEN) typer.secho("Bundle Status: ✅ SIGNED", fg=typer.colors.GREEN)
else: else:
typer.secho("Bundle Status: 🟡 UNSIGNED", fg=typer.colors.YELLOW) typer.secho("Bundle Status: 🟡 UNSIGNED", fg=typer.colors.YELLOW)
except Exception as e:
typer.secho(f"An unexpected error occurred during packing: {e}", fg=typer.colors.RED)
raise typer.Exit(code=1)

View File

@@ -5,6 +5,8 @@ from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text
from fourdst.core.plugin import validate_plugin_project
console = Console() console = Console()
def plugin_validate( def plugin_validate(
@@ -22,52 +24,39 @@ def plugin_validate(
""" """
console.print(Panel(f"Validating Plugin: [bold]{plugin_path.name}[/bold]", border_style="blue")) console.print(Panel(f"Validating Plugin: [bold]{plugin_path.name}[/bold]", border_style="blue"))
errors = 0 # Validate using core function
warnings = 0 validate_result = validate_plugin_project(plugin_path)
if not validate_result['success']:
console.print(f"[red]Error during validation: {validate_result['error']}[/red]")
raise typer.Exit(code=1)
def check(condition, success_msg, error_msg, is_warning=False): # Display results
nonlocal errors, warnings validate_data = validate_result['data']
if condition: errors = validate_data['errors']
console.print(Text(f"{success_msg}", style="green")) warnings = validate_data['warnings']
return True checks = validate_data['checks']
# Display each check result
for check in checks:
if check['passed']:
console.print(Text(f"{check['message']}", style="green"))
else: else:
if is_warning: if check['is_warning']:
console.print(Text(f"⚠️ {error_msg}", style="yellow")) console.print(Text(f"⚠️ {check['message']}", style="yellow"))
warnings += 1
else: else:
console.print(Text(f"{error_msg}", style="red")) console.print(Text(f"{check['message']}", style="red"))
errors += 1
return False
# 1. Check for meson.build
meson_file = plugin_path / "meson.build"
if check(meson_file.exists(), "Found meson.build file.", "Missing meson.build file."):
meson_content = meson_file.read_text()
# 2. Check for project() definition
check("project(" in meson_content, "Contains project() definition.", "meson.build is missing a project() definition.", is_warning=True)
# 3. Check for shared_library()
check("shared_library(" in meson_content, "Contains shared_library() definition.", "meson.build does not appear to define a shared_library().")
# 4. Check for source files
has_cpp = any(plugin_path.rglob("*.cpp"))
has_h = any(plugin_path.rglob("*.h")) or any(plugin_path.rglob("*.hpp"))
check(has_cpp, "Found C++ source files (.cpp).", "No .cpp source files found in the directory.", is_warning=True)
check(has_h, "Found C++ header files (.h/.hpp).", "No .h or .hpp header files found in the directory.", is_warning=True)
# 5. Check for test definition (optional)
check("test(" in meson_content, "Contains test() definitions.", "No test() definitions found in meson.build. Consider adding tests.", is_warning=True)
# Final summary # Final summary
console.print("-" * 40) console.print("-" * 40)
if errors == 0: if not errors:
console.print(Panel( console.print(Panel(
f"[bold green]Validation Passed[/bold green]\nWarnings: {warnings}", f"[bold green]Validation Passed[/bold green]\nWarnings: {len(warnings)}",
title="Result", title="Result",
border_style="green" border_style="green"
)) ))
else: else:
console.print(Panel( console.print(Panel(
f"[bold red]Validation Failed[/bold red]\nErrors: {errors}\nWarnings: {warnings}", f"[bold red]Validation Failed[/bold red]\nErrors: {len(errors)}\nWarnings: {len(warnings)}",
title="Result", title="Result",
border_style="red" border_style="red"
)) ))

649
fourdst/core/plugin.py Normal file
View File

@@ -0,0 +1,649 @@
# fourdst/core/plugin.py
import yaml
import zipfile
import shutil
import tempfile
import difflib
from pathlib import Path
from typing import Dict, List, Any, Optional, Tuple
import logging
from fourdst.cli.common.utils import calculate_sha256, run_command, get_template_content
from fourdst.cli.common.templates import GITIGNORE_CONTENT
def parse_cpp_interface(header_path: Path) -> Dict[str, Any]:
"""
Parses a C++ header file using libclang to find classes and their pure virtual methods.
Returns:
Dict with structure:
{
"success": bool,
"data": {
"interface_name": [
{"signature": str, "body": str},
...
]
},
"error": str (if success=False)
}
"""
try:
# Import libclang
try:
from clang import cindex
except ImportError:
return {
'success': False,
'error': "The 'init' command requires 'libclang'. Please install it with: pip install python-clang-16"
}
if not cindex.Config.loaded:
try:
cindex.Config.set_library_file(cindex.conf.get_filename())
except cindex.LibclangError as e:
return {
'success': False,
'error': f"libclang library not found. Please ensure it's installed and in your system's path. Details: {e}"
}
index = cindex.Index.create()
args = ['-x', 'c++', '-std=c++17']
translation_unit = index.parse(str(header_path), args=args)
if not translation_unit:
return {
'success': False,
'error': f"Unable to parse the translation unit {header_path}"
}
interfaces = {}
def walk_ast(node):
if node.kind == cindex.CursorKind.CLASS_DECL and node.is_definition():
pv_methods = [m for m in node.get_children()
if m.kind == cindex.CursorKind.CXX_METHOD and m.is_pure_virtual_method()]
if pv_methods:
interface_name = node.spelling
methods = []
for method in pv_methods:
args_str = ', '.join([arg.type.spelling for arg in method.get_arguments()])
sig = f"{method.result_type.spelling} {method.spelling}({args_str})"
if method.is_const_method():
sig += " const"
methods.append({
"signature": sig,
"body": " // TODO: Implement this method"
})
interfaces[interface_name] = methods
for child in node.get_children():
walk_ast(child)
walk_ast(translation_unit.cursor)
return {
'success': True,
'data': interfaces
}
except Exception as e:
logging.exception(f"Unexpected error parsing C++ header {header_path}")
return {
'success': False,
'error': f"Unexpected error: {str(e)}"
}
def generate_plugin_project(config: Dict[str, Any]) -> Dict[str, Any]:
"""
Generates a new plugin project from configuration.
Args:
config: {
"project_name": str,
"header_path": Path,
"directory": Path,
"version": str,
"libplugin_rev": str,
"chosen_interface": str,
"interfaces": dict # from parse_cpp_interface
}
Returns:
Dict with structure:
{
"success": bool,
"data": {
"project_path": str,
"files_created": [str, ...]
},
"error": str (if success=False)
}
"""
try:
project_name = config['project_name']
header_path = Path(config['header_path']) # Convert string to Path object
directory = Path(config['directory']) # Convert string to Path object
version = config['version']
libplugin_rev = config['libplugin_rev']
chosen_interface = config['chosen_interface']
interfaces = config['interfaces']
# Generate method stubs
method_stubs = "\n".join(
f" {method['signature']} override {{\n{method['body']}\n }}"
for method in interfaces[chosen_interface]
)
class_name = ''.join(filter(str.isalnum, project_name.replace('_', ' ').title().replace(' ', ''))) + "Plugin"
root_path = directory / project_name
src_path = root_path / "src"
include_path = src_path / "include"
subprojects_path = root_path / "subprojects"
files_created = []
# Create directory structure
src_path.mkdir(parents=True, exist_ok=True)
include_path.mkdir(exist_ok=True)
subprojects_path.mkdir(exist_ok=True)
# Copy interface header
local_header_path = include_path / header_path.name
shutil.copy(header_path, local_header_path)
files_created.append(str(local_header_path.relative_to(root_path)))
# Create libplugin.wrap file
libplugin_wrap_content = f"""[wrap-git]
url = https://github.com/4D-STAR/libplugin
revision = {libplugin_rev}
depth = 1
"""
wrap_file = subprojects_path / "libplugin.wrap"
wrap_file.write_text(libplugin_wrap_content)
files_created.append(str(wrap_file.relative_to(root_path)))
# Create meson.build from template
meson_template = get_template_content("meson.build.in")
meson_content = meson_template.format(
project_name=project_name,
version=version
)
meson_file = root_path / "meson.build"
meson_file.write_text(meson_content)
files_created.append(str(meson_file.relative_to(root_path)))
# Create C++ source file from template
cpp_template = get_template_content("plugin.cpp.in")
cpp_content = cpp_template.format(
class_name=class_name,
project_name=project_name,
interface=chosen_interface,
interface_header_path=header_path.name,
method_stubs=method_stubs
)
cpp_file = src_path / f"{project_name}.cpp"
cpp_file.write_text(cpp_content)
files_created.append(str(cpp_file.relative_to(root_path)))
# Create .gitignore
gitignore_file = root_path / ".gitignore"
gitignore_file.write_text(GITIGNORE_CONTENT)
files_created.append(str(gitignore_file.relative_to(root_path)))
# Initialize Git Repository
run_command(["git", "init"], cwd=root_path)
run_command(["git", "add", "."], cwd=root_path)
commit_message = f"Initial commit: Scaffold fourdst plugin '{project_name}'"
run_command(["git", "commit", "-m", commit_message], cwd=root_path)
return {
'success': True,
'data': {
'project_path': str(root_path),
'files_created': files_created
}
}
except Exception as e:
logging.exception(f"Unexpected error generating plugin project")
return {
'success': False,
'error': f"Unexpected error: {str(e)}"
}
def validate_bundle_directory(directory: Path) -> Dict[str, Any]:
"""
Validates that a directory has the structure of a valid bundle.
Returns:
Dict with structure:
{
"success": bool,
"data": {
"errors": [str, ...],
"is_signed": bool
},
"error": str (if success=False)
}
"""
try:
errors = []
manifest_path = directory / "manifest.yaml"
if not manifest_path.is_file():
errors.append("Missing 'manifest.yaml' in the root of the directory.")
return {
'success': True,
'data': {
'errors': errors,
'is_signed': False
}
}
try:
with open(manifest_path, 'r') as f:
manifest = yaml.safe_load(f)
except yaml.YAMLError as e:
errors.append(f"Invalid YAML in manifest.yaml: {e}")
return {
'success': True,
'data': {
'errors': errors,
'is_signed': False
}
}
# Check that all files referenced in the manifest exist
for plugin_name, plugin_data in manifest.get('bundlePlugins', {}).items():
sdist_info = plugin_data.get('sdist', {})
if sdist_info:
sdist_path = sdist_info.get('path')
if sdist_path and not (directory / sdist_path).is_file():
errors.append(f"Missing sdist file for '{plugin_name}': {sdist_path}")
for binary in plugin_data.get('binaries', []):
binary_path = binary.get('path')
if binary_path and not (directory / binary_path).is_file():
errors.append(f"Missing binary file for '{plugin_name}': {binary_path}")
# If checksums exist, validate them
expected_checksum = binary.get('checksum')
if binary_path and expected_checksum:
file_to_check = directory / binary_path
if file_to_check.is_file():
actual_checksum = "sha256:" + calculate_sha256(file_to_check)
if actual_checksum != expected_checksum:
errors.append(f"Checksum mismatch for '{binary_path}'")
# Check if bundle is signed
is_signed = ('bundleAuthorKeyFingerprint' in manifest and
(directory / "manifest.sig").exists())
return {
'success': True,
'data': {
'errors': errors,
'is_signed': is_signed
}
}
except Exception as e:
logging.exception(f"Unexpected error validating bundle directory {directory}")
return {
'success': False,
'error': f"Unexpected error: {str(e)}"
}
def pack_bundle_directory(directory: Path, output_config: Dict[str, Any]) -> Dict[str, Any]:
"""
Packs a directory into a .fbundle archive.
Args:
directory: Path to directory to pack
output_config: {
"name": str (optional, defaults to directory name),
"output_dir": Path (optional, defaults to directory.parent)
}
Returns:
Dict with structure:
{
"success": bool,
"data": {
"output_path": str,
"is_signed": bool,
"files_packed": int
},
"error": str (if success=False)
}
"""
try:
# First validate the directory
validation_result = validate_bundle_directory(directory)
if not validation_result['success']:
return validation_result
if validation_result['data']['errors']:
return {
'success': False,
'error': f"Validation failed: {'; '.join(validation_result['data']['errors'])}"
}
output_name = output_config.get('name', directory.name)
output_dir = output_config.get('output_dir', directory.parent)
output_path = output_dir / f"{output_name}.fbundle"
files_packed = 0
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as bundle_zip:
for file_to_add in directory.rglob('*'):
if file_to_add.is_file():
arcname = file_to_add.relative_to(directory)
bundle_zip.write(file_to_add, arcname)
files_packed += 1
return {
'success': True,
'data': {
'output_path': str(output_path.resolve()),
'is_signed': validation_result['data']['is_signed'],
'files_packed': files_packed
}
}
except Exception as e:
logging.exception(f"Unexpected error packing bundle directory {directory}")
return {
'success': False,
'error': f"Unexpected error: {str(e)}"
}
def extract_plugin_from_bundle(bundle_path: Path, plugin_name: str, output_path: Path) -> Dict[str, Any]:
"""
Extracts a plugin's source code from a bundle.
Returns:
Dict with structure:
{
"success": bool,
"data": {
"output_path": str,
"plugin_info": dict
},
"error": str (if success=False)
}
"""
try:
output_path.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as temp_dir_str:
temp_dir = Path(temp_dir_str)
# Unpack the main bundle
with zipfile.ZipFile(bundle_path, 'r') as bundle_zip:
bundle_zip.extractall(temp_dir)
# Read the manifest
manifest_path = temp_dir / "manifest.yaml"
if not manifest_path.exists():
return {
'success': False,
'error': "Bundle is invalid. Missing manifest.yaml."
}
with open(manifest_path, 'r') as f:
manifest = yaml.safe_load(f)
# Find the plugin and its sdist
plugin_data = manifest.get('bundlePlugins', {}).get(plugin_name)
if not plugin_data:
available_plugins = list(manifest.get('bundlePlugins', {}).keys())
return {
'success': False,
'error': f"Plugin '{plugin_name}' not found in the bundle. Available plugins: {', '.join(available_plugins) if available_plugins else 'none'}"
}
sdist_info = plugin_data.get('sdist')
if not sdist_info or 'path' not in sdist_info:
return {
'success': False,
'error': f"Source distribution (sdist) not found for plugin '{plugin_name}'."
}
sdist_path_in_bundle = temp_dir / sdist_info['path']
if not sdist_path_in_bundle.is_file():
return {
'success': False,
'error': f"sdist file '{sdist_info['path']}' is missing from the bundle archive."
}
# Extract the sdist to the final output directory
final_destination = output_path / plugin_name
final_destination.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(sdist_path_in_bundle, 'r') as sdist_zip:
sdist_zip.extractall(final_destination)
return {
'success': True,
'data': {
'output_path': str(final_destination.resolve()),
'plugin_info': plugin_data
}
}
except zipfile.BadZipFile:
return {
'success': False,
'error': f"'{bundle_path}' is not a valid bundle (zip) file."
}
except Exception as e:
logging.exception(f"Unexpected error extracting plugin {plugin_name} from {bundle_path}")
return {
'success': False,
'error': f"Unexpected error: {str(e)}"
}
def compare_plugin_sources(bundle_a_path: Path, bundle_b_path: Path, plugin_name: str) -> Dict[str, Any]:
"""
Compares the source code of a specific plugin between two different bundles.
Returns:
Dict with structure:
{
"success": bool,
"data": {
"has_changes": bool,
"added_files": [str, ...],
"removed_files": [str, ...],
"modified_files": [
{
"file_path": str,
"diff": str
},
...
]
},
"error": str (if success=False)
}
"""
try:
def extract_sdist(bundle_path: Path, plugin_name: str, temp_dir: Path):
"""Helper function to extract sdist from bundle."""
sdist_extract_path = temp_dir / f"{plugin_name}_src"
with tempfile.TemporaryDirectory() as bundle_unpack_dir_str:
bundle_unpack_dir = Path(bundle_unpack_dir_str)
with zipfile.ZipFile(bundle_path, 'r') as bundle_zip:
bundle_zip.extractall(bundle_unpack_dir)
manifest_path = bundle_unpack_dir / "manifest.yaml"
if not manifest_path.exists():
raise FileNotFoundError("manifest.yaml not found in bundle.")
with open(manifest_path, 'r') as f:
manifest = yaml.safe_load(f)
plugin_data = manifest.get('bundlePlugins', {}).get(plugin_name)
if not plugin_data or 'sdist' not in plugin_data:
raise FileNotFoundError(f"Plugin '{plugin_name}' or its sdist not found in {bundle_path.name}.")
sdist_path_in_bundle = bundle_unpack_dir / plugin_data['sdist']['path']
if not sdist_path_in_bundle.exists():
raise FileNotFoundError(f"sdist archive '{plugin_data['sdist']['path']}' not found in bundle.")
with zipfile.ZipFile(sdist_path_in_bundle, 'r') as sdist_zip:
sdist_zip.extractall(sdist_extract_path)
return sdist_extract_path
with tempfile.TemporaryDirectory() as temp_a_str, tempfile.TemporaryDirectory() as temp_b_str:
try:
src_a_path = extract_sdist(bundle_a_path, plugin_name, Path(temp_a_str))
src_b_path = extract_sdist(bundle_b_path, plugin_name, Path(temp_b_str))
except FileNotFoundError as e:
return {
'success': False,
'error': str(e)
}
files_a = {p.relative_to(src_a_path) for p in src_a_path.rglob('*') if p.is_file()}
files_b = {p.relative_to(src_b_path) for p in src_b_path.rglob('*') if p.is_file()}
added_files = list(sorted(files_b - files_a))
removed_files = list(sorted(files_a - files_b))
common_files = files_a & files_b
modified_files = []
for file_rel_path in sorted(list(common_files)):
content_a = (src_a_path / file_rel_path).read_text()
content_b = (src_b_path / file_rel_path).read_text()
if content_a != content_b:
diff = ''.join(difflib.unified_diff(
content_a.splitlines(keepends=True),
content_b.splitlines(keepends=True),
fromfile=f"a/{file_rel_path}",
tofile=f"b/{file_rel_path}",
))
modified_files.append({
'file_path': str(file_rel_path),
'diff': diff
})
has_changes = bool(added_files or removed_files or modified_files)
return {
'success': True,
'data': {
'has_changes': has_changes,
'added_files': [str(f) for f in added_files],
'removed_files': [str(f) for f in removed_files],
'modified_files': modified_files
}
}
except Exception as e:
logging.exception(f"Unexpected error comparing plugin {plugin_name} between bundles")
return {
'success': False,
'error': f"Unexpected error: {str(e)}"
}
def validate_plugin_project(project_path: Path) -> Dict[str, Any]:
"""
Validates a plugin's structure and meson.build file.
Returns:
Dict with structure:
{
"success": bool,
"data": {
"errors": [str, ...],
"warnings": [str, ...],
"checks": [
{
"name": str,
"passed": bool,
"is_warning": bool,
"message": str
},
...
]
},
"error": str (if success=False)
}
"""
try:
# Convert string path to Path object if needed
if isinstance(project_path, str):
project_path = Path(project_path)
errors = []
warnings = []
checks = []
def check(condition, name, success_msg, error_msg, is_warning=False):
passed = bool(condition)
checks.append({
'name': name,
'passed': passed,
'is_warning': is_warning,
'message': success_msg if passed else error_msg
})
if not passed:
if is_warning:
warnings.append(error_msg)
else:
errors.append(error_msg)
return passed
# Check for meson.build
meson_file = project_path / "meson.build"
meson_content = ""
if check(meson_file.exists(), "meson_build_exists", "Found meson.build file.", "Missing meson.build file."):
meson_content = meson_file.read_text()
# Check for project() definition
check("project(" in meson_content, "has_project_definition", "Contains project() definition.", "meson.build is missing a project() definition.", is_warning=True)
# Check for shared_library()
check("shared_library(" in meson_content, "has_shared_library", "Contains shared_library() definition.", "meson.build does not appear to define a shared_library().")
# Check for source files
has_cpp = any(project_path.rglob("*.cpp"))
has_h = any(project_path.rglob("*.h")) or any(project_path.rglob("*.hpp"))
check(has_cpp, "has_cpp_files", "Found C++ source files (.cpp).", "No .cpp source files found in the directory.", is_warning=True)
check(has_h, "has_header_files", "Found C++ header files (.h/.hpp).", "No .h or .hpp header files found in the directory.", is_warning=True)
# Check for test definition (optional)
check("test(" in meson_content, "has_tests", "Contains test() definitions.", "No test() definitions found in meson.build. Consider adding tests.", is_warning=True)
return {
'success': True,
'data': {
'errors': errors,
'warnings': warnings,
'checks': checks
}
}
except Exception as e:
logging.exception(f"Unexpected error validating plugin project {project_path}")
return {
'success': False,
'error': f"Unexpected error: {str(e)}"
}

View File

@@ -1,4 +1,4 @@
[wrap-git] [wrap-git]
url = https://github.com/4D-STAR/libplugin.git url = https://github.com/4D-STAR/libplugin.git
revision = v0.3.3 revision = v0.3.4
depth = 1 depth = 1