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 () {
});
-
-
-