diff --git a/docs/_static/msi_viewer.js b/docs/_static/msi_viewer.js index 484d53f..614ef0e 100644 --- a/docs/_static/msi_viewer.js +++ b/docs/_static/msi_viewer.js @@ -1,16 +1,35 @@ // MSI Viewer JavaScript Implementation +// +// This application uses Web Workers to handle MSI processing in a background thread, +// preventing the main UI thread from blocking during long operations like: +// - Loading and initializing Pyodide +// - Parsing large MSI files +// - Extracting files with LZX compression +// +// Architecture: +// - Main thread: Handles UI updates, user interactions, and rendering +// - Worker thread: Handles Pyodide and all MSI processing operations +// - Communication: postMessage API for bidirectional messaging // Main class for the MSI Viewer application class MSIViewer { constructor() { - this.pyodide = null; - this.pymsi = null; - this.currentPackage = null; - this.currentMsi = null; + // Configuration constants + this.WORKER_PATH = '_static/msi_viewer_worker.js'; + this.WORKER_INIT_MAX_RETRIES = 10; + this.WORKER_INIT_BASE_DELAY = 500; + this.WORKER_INIT_MAX_DELAY = 5000; + this.WORKER_INIT_BACKOFF_FACTOR = 1.5; + + this.worker = null; this.currentFileName = null; + this.tablesData = []; + this.isWorkerReady = false; + this._loadRetries = 0; + this._loadRetryTimeout = null; this.initElements(); this.initEventListeners(); - this.loadPyodide(); + this.initWorker(); } // Initialize DOM element references @@ -61,141 +80,133 @@ class MSIViewer { }); } - // Load Pyodide and pymsi - async loadPyodide() { + // Initialize the web worker + initWorker() { this.loadingIndicator.style.display = 'block'; - this.loadingIndicator.textContent = 'Loading Pyodide...'; + this.loadingIndicator.textContent = 'Initializing...'; + this.loadingIndicator.classList.remove('error'); try { - // Pyodide should already be loaded from the script in the HTML - if (typeof loadPyodide === 'undefined') { - throw new Error('Pyodide is not loaded. Please check your internet connection.'); - } + // Create worker - path is relative to the HTML page location + // This works correctly on ReadTheDocs where the HTML is at /en/latest/msi_viewer.html + // and the worker is at /en/latest/_static/msi_viewer_worker.js + this.worker = new Worker(this.WORKER_PATH); + + // Handle messages from the worker + this.worker.addEventListener('message', (event) => { + this.handleWorkerMessage(event.data); + }); - this.pyodide = await loadPyodide(); - if (!this.pyodide) { - throw new Error('loadPyodide() failed.'); - } + // Handle worker errors + this.worker.addEventListener('error', (error) => { + console.error('Worker error:', error); + this.loadingIndicator.classList.add('error'); + this.loadingIndicator.textContent = `Worker error: ${error.message}`; + }); - this.loadingIndicator.textContent = 'Loading pymsi...'; - - // Install pymsi using micropip - await this.pyodide.loadPackagesFromImports('import micropip'); - const micropip = this.pyodide.pyimport('micropip'); - // The name of the package is 'python-msi' on PyPI - await micropip.install('python-msi'); - - // Import pymsi - await this.pyodide.runPythonAsync(` - import pymsi - import json - import io - import zipfile - from js import Uint8Array, Object, File, Blob, URL - from pyodide.ffi import to_js - `); - - this.pymsi = this.pyodide.pyimport('pymsi'); - this.loadingIndicator.style.display = 'none'; - console.log('pymsi loaded successfully'); + // Initialize Pyodide in the worker + this.worker.postMessage({ type: 'init' }); } catch (error) { - this.loadingIndicator.textContent = `Error loading Pyodide or pymsi: ${error.message}`; - console.error('Error initializing:', error); + console.error('Error creating worker:', error); + this.loadingIndicator.classList.add('error'); + this.loadingIndicator.textContent = `Error creating worker: ${error.message}`; } } - // Load MSI file from ArrayBuffer (used for file input, example, and URL) - async loadMsiFileFromArrayBuffer(arrayBuffer, fileName = 'uploaded.msi') { - this.currentFileName = fileName; - this.loadingIndicator.style.display = 'block'; - this.loadingIndicator.textContent = 'Reading MSI file...'; - - try { - // Read the file as an ArrayBuffer - const msiBinaryData = new Uint8Array(arrayBuffer); - - // Write the file to Pyodide's virtual file system - this.pyodide.FS.writeFile('/uploaded.msi', msiBinaryData); - - // Create Package and Msi objects using the file path - await this.pyodide.runPythonAsync(` - from pathlib import Path - current_package = pymsi.Package(Path('/uploaded.msi')) - current_msi = pymsi.Msi(current_package, True) - `); - - this.currentPackage = await this.pyodide.globals.get('current_package'); - this.currentMsi = await this.pyodide.globals.get('current_msi'); - console.log('Successfully created MSI object:', this.currentMsi); - console.log('Successfully created Package object:', this.currentPackage); - - // Load and display the MSI contents - await this.loadFilesList(); - console.log('Files list loaded successfully'); - await this.loadTablesList(); - console.log('Tables list loaded successfully'); - await this.loadSummaryInfo(); - console.log('Summary information loaded successfully'); - await this.loadStreams(); - console.log('Streams loaded successfully'); - - // Enable the extract button and show current file - this.extractButton.disabled = false; - this.currentFileDisplay.textContent = `Currently loaded: ${this.currentFileName}`; - this.currentFileDisplay.style.display = 'block'; - - this.loadingIndicator.style.display = 'none'; - } catch (error) { - this.loadingIndicator.textContent = `Error processing MSI file: ${error.message}`; - console.error('Error processing MSI:', error); + // Handle messages from the worker + handleWorkerMessage(message) { + const { type, success, error, data, message: progressMsg, isError } = message; + + switch (type) { + case 'progress': + this.loadingIndicator.style.display = 'block'; + this.loadingIndicator.textContent = progressMsg; + if (isError) { + this.loadingIndicator.classList.add('error'); + console.error('Worker progress error:', progressMsg); + } else { + this.loadingIndicator.classList.remove('error'); + } + break; + + case 'initialized': + if (success) { + this.isWorkerReady = true; + this.loadingIndicator.style.display = 'none'; + this.loadingIndicator.classList.remove('error'); + console.log('Worker initialized successfully'); + } else { + this.loadingIndicator.classList.add('error'); + this.loadingIndicator.textContent = `Initialization error: ${error}`; + console.error('Worker initialization failed:', error); + } + break; + + case 'msi-loaded': + this.loadingIndicator.classList.remove('error'); + if (success) { + this.displayMsiData(data, message.fileName); + } else { + this.loadingIndicator.classList.add('error'); + this.loadingIndicator.textContent = `Error loading MSI: ${error}`; + console.error('MSI loading failed:', error); + } + break; + + case 'table-data': + this.loadingIndicator.classList.remove('error'); + if (success) { + this.displayTableData(data); + } else { + this.loadingIndicator.classList.add('error'); + this.loadingIndicator.textContent = `Error loading table: ${error}`; + console.error('Table loading failed:', error); + } + break; + + case 'extract-complete': + this.loadingIndicator.classList.remove('error'); + if (success) { + this.createZipFromExtractedFiles(message.files, message.baseFileName); + } else { + this.loadingIndicator.classList.add('error'); + this.loadingIndicator.textContent = `Extraction error: ${error}`; + console.error('Extraction failed:', error); + } + break; + + default: + console.warn('Unknown message type from worker:', type); } } - // Handle file selection - async handleFileSelect(event) { - if (!this.fileInput.files || this.fileInput.files.length === 0) return; + // Display MSI data after loading + displayMsiData(data, fileName) { + this.currentFileName = fileName; + this.tablesData = data.tables; - const file = this.fileInput.files[0]; - const arrayBuffer = await file.arrayBuffer(); - await this.loadMsiFileFromArrayBuffer(arrayBuffer, file.name); - } + // Display files + this.displayFilesList(data.files); - // Handle loading the example file from the server - async handleLoadExampleFile() { - const exampleUrl = '_static/example.msi'; - this.loadingIndicator.style.display = 'block'; - this.loadingIndicator.textContent = 'Fetching example file...'; - try { - const response = await fetch(exampleUrl); - if (!response.ok) throw new Error(`Failed to fetch example file (${response.status})`); - const arrayBuffer = await response.arrayBuffer(); - await this.loadMsiFileFromArrayBuffer(arrayBuffer, 'example.msi'); - } catch (error) { - this.loadingIndicator.textContent = `Error loading example file: ${error.message}`; - console.error('Error loading example file:', error); - } - } + // Display tables list + this.displayTablesList(data.tables); + + // Display summary + this.displaySummary(data.summary); + + // Display streams + this.displayStreams(data.streams); - // Load files list from MSI - async loadFilesList() { - const filesData = await this.pyodide.runPythonAsync(` - files = [] - try: - for file in current_msi.files.values(): - files.append({ - 'name': file.name, - 'directory': file.component.directory.name, - 'size': file.size, - 'component': file.component.id, - 'version': file.version - }) - except Exception as e: - print(f"Error getting files: {e}") - files = [] - to_js(files) - `); - console.log('Files data loaded:', filesData); + // Enable the extract button and show current file + this.extractButton.disabled = false; + this.currentFileDisplay.textContent = `Currently loaded: ${this.currentFileName}`; + this.currentFileDisplay.style.display = 'block'; + this.loadingIndicator.style.display = 'none'; + } + + // Display files list + displayFilesList(filesData) { this.filesList.innerHTML = ''; if (filesData.length === 0) { @@ -206,28 +217,18 @@ class MSIViewer { for (const file of filesData) { const row = document.createElement('tr'); row.innerHTML = ` - ${file.get("name") || ''} - ${file.get("directory") || ''} - ${file.get("size") || ''} - ${file.get("component") || ''} - ${file.get("version") || ''} + ${file.name} + ${file.directory} + ${file.size} + ${file.component} + ${file.version} `; this.filesList.appendChild(row); } } - // Load tables list - async loadTablesList() { - const tables = await this.pyodide.runPythonAsync(` - tables = [] - for k in current_package.ole.root.kids: - name, is_table = pymsi.streamname.decode_unicode(k.name) - if is_table: - tables.append(name) - to_js(tables) - `); - console.log('Tables found:', tables); - + // Display tables list + displayTablesList(tables) { this.tableSelector.innerHTML = ''; if (tables.length === 0) { @@ -248,96 +249,18 @@ class MSIViewer { } } - // Load table data when a table is selected - async loadTableData() { - const selectedTable = this.tableSelector.value; - if (!selectedTable) return; - - const tableData = await this.pyodide.runPythonAsync(` - result = {'columns': [], 'rows': []} - try: - table = current_package.get('${selectedTable}') - result['columns'] = [column.name for column in table.columns] - result['rows'] = [row for row in table.rows] - except Exception as e: - print(f"Error getting table data: {e}") - to_js(result) - `); - console.log('Table data loaded:', tableData); - - // Display table columns - this.tableHeader.innerHTML = ''; - const headerRow = document.createElement('tr'); - - for (const column of tableData.get("columns")) { - const th = document.createElement('th'); - th.textContent = column; - headerRow.appendChild(th); - } - - this.tableHeader.appendChild(headerRow); - - // Display table rows - this.tableContent.innerHTML = ''; - - if (tableData.get("rows").length === 0) { - const emptyRow = document.createElement('tr'); - emptyRow.innerHTML = `No data`; - this.tableContent.appendChild(emptyRow); - return; - } - - for (const rowData of tableData.get("rows")) { - const row = document.createElement('tr'); - - // Iterate through columns to maintain the correct order - for (const column of tableData.get("columns")) { - const td = document.createElement('td'); - const value = rowData.get(column); - td.textContent = (value !== null && value !== undefined) ? String(value) : ''; - row.appendChild(td); - } - - this.tableContent.appendChild(row); - } - } - - // Load summary information - async loadSummaryInfo() { - const summaryData = await this.pyodide.runPythonAsync(` - result = {} - summary = current_package.summary - - # Helper function to safely convert values to string - def safe_str(value): - return "" if value is None else str(value) - - # Add each property if it exists - result["arch"] = safe_str(summary.arch()) - result["author"] = safe_str(summary.author()) - result["comments"] = safe_str(summary.comments()) - result["creating_application"] = safe_str(summary.creating_application()) - result["creation_time"] = safe_str(summary.creation_time()) - result["languages"] = safe_str(summary.languages()) - result["subject"] = safe_str(summary.subject()) - result["title"] = safe_str(summary.title()) - result["uuid"] = safe_str(summary.uuid()) - result["word_count"] = safe_str(summary.word_count()) - - to_js(result) - `); - console.log('Summary data loaded:', summaryData); - + // Display summary information + displaySummary(summaryData) { this.summaryContent.innerHTML = ''; - if (summaryData.size === 0) { + if (Object.keys(summaryData).length === 0) { this.summaryContent.innerHTML = '

No summary information available

'; return; } const table = document.createElement('table'); - for (const [key, value] of summaryData) { + for (const [key, value] of Object.entries(summaryData)) { const row = document.createElement('tr'); const keyCell = document.createElement('td'); const valueCell = document.createElement('td'); @@ -353,21 +276,11 @@ class MSIViewer { this.summaryContent.appendChild(table); } - // Load streams information - async loadStreams() { - const streamsData = await this.pyodide.runPythonAsync(` - streams = [] - for k in current_package.ole.root.kids: - name, is_table = pymsi.streamname.decode_unicode(k.name) - if not is_table: - streams.append(name) - to_js(streams) - `); - console.log('Streams data loaded:', streamsData); - + // Display streams + displayStreams(streams) { this.streamsContent.innerHTML = ''; - if (streamsData.length === 0) { + if (streams.length === 0) { this.streamsContent.innerHTML = '

No streams available

'; return; } @@ -377,64 +290,63 @@ class MSIViewer { headerRow.innerHTML = 'Name'; table.appendChild(headerRow); - for (const stream of streamsData) { + for (const stream of streams) { const row = document.createElement('tr'); - row.innerHTML = ` - ${stream} - `; + row.innerHTML = `${stream}`; table.appendChild(row); } this.streamsContent.appendChild(table); } - // Extract files and create a ZIP for download - async extractFiles() { - this.loadingIndicator.style.display = 'block'; - this.loadingIndicator.textContent = 'Extracting files...'; + // Display table data + displayTableData(data) { + // Display table columns + this.tableHeader.innerHTML = ''; + const headerRow = document.createElement('tr'); - try { - // Import and use the extract_root function from __main__.py - await this.pyodide.runPythonAsync(` - import shutil - from pathlib import Path - from pymsi.__main__ import extract_root - - # Clean up and recreate temp directory - temp_dir = Path('/tmp/extracted') - if temp_dir.exists(): - shutil.rmtree(temp_dir) - temp_dir.mkdir(parents=True, exist_ok=True) - - # Extract files using the same logic as the CLI - extract_root(current_msi.root, temp_dir) - `); - - this.loadingIndicator.textContent = 'Creating ZIP archive...'; - - // Get list of all extracted files - const fileList = await this.pyodide.runPythonAsync(` - import os - files = [] - temp_dir = Path('/tmp/extracted') - for root, dirs, filenames in os.walk(temp_dir): - for filename in filenames: - full_path = os.path.join(root, filename) - rel_path = os.path.relpath(full_path, temp_dir) - files.append(rel_path) - to_js(files) - `); - - if (fileList.length === 0) { - this.loadingIndicator.textContent = 'No files extracted'; - setTimeout(() => { - this.loadingIndicator.style.display = 'none'; - }, 2000); - return; + for (const column of data.columns) { + const th = document.createElement('th'); + th.textContent = column; + headerRow.appendChild(th); + } + + this.tableHeader.appendChild(headerRow); + + // Display table rows + this.tableContent.innerHTML = ''; + + if (data.rows.length === 0) { + const emptyRow = document.createElement('tr'); + emptyRow.innerHTML = `No data`; + this.tableContent.appendChild(emptyRow); + return; + } + + for (const rowData of data.rows) { + const row = document.createElement('tr'); + + // Iterate through columns to maintain the correct order + for (const column of data.columns) { + const td = document.createElement('td'); + const value = rowData[column]; + td.textContent = value !== null && value !== undefined ? String(value) : ''; + row.appendChild(td); } - // Create ZIP file in JavaScript using JSZip library - // We need to make sure JSZip is loaded + this.tableContent.appendChild(row); + } + + this.loadingIndicator.style.display = 'none'; + } + + // Create ZIP from extracted files + createZipFromExtractedFiles(files, baseFileName) { + this.loadingIndicator.style.display = 'block'; + this.loadingIndicator.textContent = 'Creating ZIP archive...'; + + try { + // Make sure JSZip is loaded if (typeof JSZip === 'undefined') { throw new Error('JSZip failed to load.'); } @@ -442,38 +354,144 @@ class MSIViewer { const zip = new JSZip(); // Add each file to the ZIP - for (const filePath of fileList) { - const fileData = this.pyodide.FS.readFile(`/tmp/extracted/${filePath}`); - zip.file(filePath, fileData); + for (const file of files) { + zip.file(file.path, file.data); } // Generate ZIP blob - const zipBlob = await zip.generateAsync({ type: 'blob' }); - - // Create filename based on MSI name - const baseFileName = this.currentFileName.replace(/\.msi$/i, ''); - const zipFileName = `${baseFileName}_extracted.zip`; - - // Trigger download - const url = URL.createObjectURL(zipBlob); - const a = document.createElement('a'); - a.href = url; - a.download = zipFileName; - document.body.appendChild(a); - a.click(); - - // Clean up - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(url); - }, 0); - - this.loadingIndicator.style.display = 'none'; + zip.generateAsync({ type: 'blob' }).then((zipBlob) => { + // Create filename + const zipFileName = `${baseFileName}_extracted.zip`; + + // Trigger download + const url = URL.createObjectURL(zipBlob); + const a = document.createElement('a'); + a.href = url; + a.download = zipFileName; + document.body.appendChild(a); + a.click(); + + // Clean up after allowing the download to start + // Use requestAnimationFrame to ensure the click event completes + requestAnimationFrame(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }); + + this.loadingIndicator.style.display = 'none'; + }).catch((error) => { + this.loadingIndicator.textContent = `Error creating ZIP: ${error.message}`; + console.error('Error creating ZIP:', error); + }); } catch (error) { - this.loadingIndicator.textContent = `Error extracting files: ${error.message}`; - console.error('Error extracting files:', error); + this.loadingIndicator.textContent = `Error creating ZIP: ${error.message}`; + console.error('Error creating ZIP:', error); } } + + // Load MSI file from ArrayBuffer (used for file input, example, and URL) + async loadMsiFileFromArrayBuffer(arrayBuffer, fileName = 'uploaded.msi') { + if (!this.isWorkerReady) { + this.loadingIndicator.style.display = 'block'; + this.loadingIndicator.textContent = 'Waiting for worker to initialize...'; + // Wait for worker to be ready with exponential backoff + if (this._loadRetries < this.WORKER_INIT_MAX_RETRIES) { + this._loadRetries++; + const delay = Math.min( + this.WORKER_INIT_BASE_DELAY * Math.pow(this.WORKER_INIT_BACKOFF_FACTOR, this._loadRetries - 1), + this.WORKER_INIT_MAX_DELAY + ); + // Clear any existing timeout to prevent memory buildup + if (this._loadRetryTimeout) { + clearTimeout(this._loadRetryTimeout); + } + this._loadRetryTimeout = setTimeout(() => { + this._loadRetryTimeout = null; + this.loadMsiFileFromArrayBuffer(arrayBuffer, fileName); + }, delay); + } else { + this.loadingIndicator.classList.add('error'); + this.loadingIndicator.textContent = 'Worker initialization timeout. Please refresh the page.'; + this._loadRetries = 0; + this._loadRetryTimeout = null; + } + return; + } + + // Reset retry counter and timeout on success + this._loadRetries = 0; + if (this._loadRetryTimeout) { + clearTimeout(this._loadRetryTimeout); + this._loadRetryTimeout = null; + } + + // Send the file to the worker for processing + this.worker.postMessage({ + type: 'load-msi', + data: { + arrayBuffer: arrayBuffer, + fileName: fileName + } + }); + } + + // Handle file selection + async handleFileSelect(event) { + if (!this.fileInput.files || this.fileInput.files.length === 0) return; + + const file = this.fileInput.files[0]; + const arrayBuffer = await file.arrayBuffer(); + await this.loadMsiFileFromArrayBuffer(arrayBuffer, file.name); + } + + // Handle loading the example file from the server + async handleLoadExampleFile() { + const exampleUrl = '_static/example.msi'; + this.loadingIndicator.style.display = 'block'; + this.loadingIndicator.textContent = 'Fetching example file...'; + try { + const response = await fetch(exampleUrl); + if (!response.ok) throw new Error(`Failed to fetch example file (${response.status})`); + const arrayBuffer = await response.arrayBuffer(); + await this.loadMsiFileFromArrayBuffer(arrayBuffer, 'example.msi'); + } catch (error) { + this.loadingIndicator.textContent = `Error loading example file: ${error.message}`; + console.error('Error loading example file:', error); + } + } + + // Load table data when a table is selected + loadTableData() { + const selectedTable = this.tableSelector.value; + if (!selectedTable || !this.isWorkerReady) return; + + this.loadingIndicator.style.display = 'block'; + this.loadingIndicator.textContent = 'Loading table data...'; + + // Request table data from worker + this.worker.postMessage({ + type: 'load-table', + data: { + tableName: selectedTable + } + }); + } + + // Extract files and create a ZIP for download + extractFiles() { + if (!this.isWorkerReady || !this.currentFileName) return; + + this.loadingIndicator.style.display = 'block'; + this.loadingIndicator.textContent = 'Starting extraction...'; + + // Request file extraction from worker + this.worker.postMessage({ + type: 'extract-files', + data: { + fileName: this.currentFileName + } + }); + } } // Initialize the MSI Viewer when the DOM is loaded diff --git a/docs/_static/msi_viewer_worker.js b/docs/_static/msi_viewer_worker.js new file mode 100644 index 0000000..e29415b --- /dev/null +++ b/docs/_static/msi_viewer_worker.js @@ -0,0 +1,397 @@ +// MSI Viewer Web Worker - Handles Pyodide operations in a separate thread +// +// This web worker runs Pyodide (Python runtime) in a background thread to prevent +// blocking the main UI thread. This is especially important for: +// - Large MSI files that take time to parse +// - MSI files using LZX compression which requires significant CPU +// - File extraction operations that can take several seconds +// +// The worker communicates with the main thread via postMessage, sending: +// - Progress updates during long operations +// - Error messages if something goes wrong +// - Processed data (files, tables, summary info) back to the main thread +// +// This architecture ensures the browser UI remains responsive and prevents +// "page unresponsive" warnings during MSI processing. + +let pyodide = null; +let pymsi = null; +let currentPackage = null; +let currentMsi = null; + +// Send progress updates to the main thread +function sendProgress(message, isError = false) { + self.postMessage({ + type: 'progress', + message: message, + isError: isError + }); +} + +// Initialize Pyodide +async function initPyodide() { + try { + sendProgress('Loading Pyodide...'); + + // Import Pyodide from CDN + importScripts('https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js'); + + pyodide = await loadPyodide(); + if (!pyodide) { + throw new Error('loadPyodide() failed.'); + } + + sendProgress('Loading pymsi...'); + + // Install pymsi using micropip + await pyodide.loadPackagesFromImports('import micropip'); + const micropip = pyodide.pyimport('micropip'); + await micropip.install('python-msi'); + + // Import required modules + await pyodide.runPythonAsync(` + import pymsi + import json + import io + import zipfile + import shutil + import os + from pathlib import Path + from pyodide.ffi import to_js + `); + + pymsi = pyodide.pyimport('pymsi'); + + sendProgress('Ready', false); + + self.postMessage({ + type: 'initialized', + success: true + }); + } catch (error) { + sendProgress(`Error loading Pyodide or pymsi: ${error.message}`, true); + self.postMessage({ + type: 'initialized', + success: false, + error: error.message + }); + } +} + +// Load MSI file from ArrayBuffer +async function loadMsiFile(arrayBuffer, fileName) { + try { + sendProgress('Reading MSI file...'); + + // Read the file as a Uint8Array + const msiBinaryData = new Uint8Array(arrayBuffer); + + // Write the file to Pyodide's virtual file system + pyodide.FS.writeFile('/uploaded.msi', msiBinaryData); + + sendProgress('Parsing MSI structure...'); + + // Create Package and Msi objects using the file path + await pyodide.runPythonAsync(` + from pathlib import Path + current_package = pymsi.Package(Path('/uploaded.msi')) + current_msi = pymsi.Msi(current_package, True) + `); + + currentPackage = await pyodide.globals.get('current_package'); + currentMsi = await pyodide.globals.get('current_msi'); + + sendProgress('Loading file information...'); + const filesData = await getFilesData(); + const tablesData = await getTablesData(); + const summaryData = await getSummaryData(); + const streamsData = await getStreamsData(); + + sendProgress('MSI file loaded successfully'); + + self.postMessage({ + type: 'msi-loaded', + success: true, + fileName: fileName, + data: { + files: filesData, + tables: tablesData, + summary: summaryData, + streams: streamsData + } + }); + } catch (error) { + sendProgress(`Error processing MSI file: ${error.message}`, true); + self.postMessage({ + type: 'msi-loaded', + success: false, + error: error.message + }); + } +} + +// Get files data +async function getFilesData() { + const filesData = await pyodide.runPythonAsync(` + files = [] + try: + for file in current_msi.files.values(): + files.append({ + 'name': file.name, + 'directory': file.component.directory.name, + 'size': file.size, + 'component': file.component.id, + 'version': file.version + }) + except Exception as e: + print(f"Error getting files: {e}") + files = [] + to_js(files) + `); + + // Convert to plain JS array + const result = []; + for (const file of filesData) { + result.push({ + name: file.get("name") || '', + directory: file.get("directory") || '', + size: file.get("size") || '', + component: file.get("component") || '', + version: file.get("version") || '' + }); + } + return result; +} + +// Get tables data +async function getTablesData() { + const tables = await pyodide.runPythonAsync(` + tables = [] + for k in current_package.ole.root.kids: + name, is_table = pymsi.streamname.decode_unicode(k.name) + if is_table: + tables.append(name) + to_js(tables) + `); + + // Convert to plain JS array + const result = []; + for (const table of tables) { + result.push(table); + } + return result; +} + +// Get summary data +async function getSummaryData() { + const summaryData = await pyodide.runPythonAsync(` + result = {} + summary = current_package.summary + + # Helper function to safely convert values to string + def safe_str(value): + return "" if value is None else str(value) + + # Add each property if it exists + result["arch"] = safe_str(summary.arch()) + result["author"] = safe_str(summary.author()) + result["comments"] = safe_str(summary.comments()) + result["creating_application"] = safe_str(summary.creating_application()) + result["creation_time"] = safe_str(summary.creation_time()) + result["languages"] = safe_str(summary.languages()) + result["subject"] = safe_str(summary.subject()) + result["title"] = safe_str(summary.title()) + result["uuid"] = safe_str(summary.uuid()) + result["word_count"] = safe_str(summary.word_count()) + + to_js(result) + `); + + // Convert to plain JS object + const result = {}; + for (const [key, value] of summaryData) { + result[key] = value !== null ? String(value) : ''; + } + return result; +} + +// Get streams data +async function getStreamsData() { + const streamsData = await pyodide.runPythonAsync(` + streams = [] + for k in current_package.ole.root.kids: + name, is_table = pymsi.streamname.decode_unicode(k.name) + if not is_table: + streams.append(name) + to_js(streams) + `); + + // Convert to plain JS array + const result = []; + for (const stream of streamsData) { + result.push(stream); + } + return result; +} + +// Load table data for a specific table +async function loadTableData(tableName) { + try { + // Validate table name to prevent control characters and path traversal + // MSI table names can include alphanumeric, underscores, dots, spaces, and hyphens + // But should not include control characters, quotes, or path separators + if (/[\x00-\x1F\x7F'"`\\\/]/.test(tableName)) { + throw new Error('Invalid table name: contains unsupported characters'); + } + + // Set the table name as a variable in Python scope to avoid code injection + pyodide.globals.set('selected_table_name', tableName); + + const tableData = await pyodide.runPythonAsync(` + result = {'columns': [], 'rows': []} + try: + table = current_package.get(selected_table_name) + result['columns'] = [column.name for column in table.columns] + result['rows'] = [row for row in table.rows] + except Exception as e: + print(f"Error getting table data: {e}") + to_js(result) + `); + + // Convert columns + const columns = []; + for (const column of tableData.get("columns")) { + columns.push(column); + } + + // Convert rows + const rows = []; + for (const rowData of tableData.get("rows")) { + const row = {}; + for (const column of columns) { + const value = rowData.get(column); + row[column] = (value !== null && value !== undefined) ? String(value) : ''; + } + rows.push(row); + } + + self.postMessage({ + type: 'table-data', + success: true, + tableName: tableName, + data: { + columns: columns, + rows: rows + } + }); + } catch (error) { + self.postMessage({ + type: 'table-data', + success: false, + tableName: tableName, + error: error.message + }); + } +} + +// Extract files and create a ZIP +async function extractFiles(fileName) { + try { + sendProgress('Extracting files...'); + + // Import and use the extract_root function from __main__.py + await pyodide.runPythonAsync(` + import shutil + from pathlib import Path + from pymsi.__main__ import extract_root + + # Clean up and recreate temp directory + temp_dir = Path('/tmp/extracted') + if temp_dir.exists(): + shutil.rmtree(temp_dir) + temp_dir.mkdir(parents=True, exist_ok=True) + + # Extract files using the same logic as the CLI + extract_root(current_msi.root, temp_dir) + `); + + sendProgress('Collecting extracted files...'); + + // Get list of all extracted files + const fileList = await pyodide.runPythonAsync(` + import os + files = [] + temp_dir = Path('/tmp/extracted') + for root, dirs, filenames in os.walk(temp_dir): + for filename in filenames: + full_path = os.path.join(root, filename) + rel_path = os.path.relpath(full_path, temp_dir) + files.append(rel_path) + to_js(files) + `); + + if (fileList.length === 0) { + sendProgress('No files extracted', true); + self.postMessage({ + type: 'extract-complete', + success: false, + error: 'No files extracted' + }); + return; + } + + sendProgress('Reading extracted files...'); + + // Read all files and send them back to the main thread + const extractedFiles = []; + for (const filePath of fileList) { + const fileData = pyodide.FS.readFile(`/tmp/extracted/${filePath}`); + extractedFiles.push({ + path: filePath, + data: fileData + }); + } + + sendProgress('Files extracted successfully'); + + self.postMessage({ + type: 'extract-complete', + success: true, + files: extractedFiles, + baseFileName: fileName.replace(/\.msi$/i, '') + }); + } catch (error) { + sendProgress(`Error extracting files: ${error.message}`, true); + self.postMessage({ + type: 'extract-complete', + success: false, + error: error.message + }); + } +} + +// Handle messages from the main thread +self.addEventListener('message', async (event) => { + const { type, data } = event.data; + + switch (type) { + case 'init': + await initPyodide(); + break; + + case 'load-msi': + await loadMsiFile(data.arrayBuffer, data.fileName); + break; + + case 'load-table': + await loadTableData(data.tableName); + break; + + case 'extract-files': + await extractFiles(data.fileName); + break; + + default: + console.warn('Unknown message type:', type); + } +}); diff --git a/docs/msi_viewer.md b/docs/msi_viewer.md index cf98c53..1bf7af8 100644 --- a/docs/msi_viewer.md +++ b/docs/msi_viewer.md @@ -151,12 +151,43 @@ Behind the scenes, it is running [pymsi](https://github.com/nightlark/pymsi/) us #loading-indicator { margin-top: 1rem; - padding: 0.5rem; + padding: 0.75rem 1rem; background: #e3f2fd; border: 1px solid #90caf9; border-radius: 4px; color: #1565c0; font-weight: 500; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + } + + #loading-indicator::before { + content: ""; + display: inline-block; + width: 16px; + height: 16px; + margin-right: 8px; + border: 2px solid #1565c0; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + #loading-indicator.error { + background: #ffebee; + border-color: #ef5350; + color: #c62828; + } + + #loading-indicator.error::before { + display: none; + } + + @keyframes spin { + to { transform: rotate(360deg); } } #current-file-display { @@ -294,9 +325,6 @@ document.addEventListener('DOMContentLoaded', function () { }); - - -