UNPKG

node-msix-packager

Version:

A utility to create MSIX packages from Node.js applications with MCP server support, Node.js Single Executable Application (SEA) bundling using @vercel/ncc and postject, and enhanced build options

311 lines (277 loc) 11.5 kB
const path = require('path'); const fs = require('fs-extra'); const { CONSTANTS, ValidationError } = require('./constants'); /** * Generates a GUID for PhoneProductId * @returns {string} A UUID v4 string */ function generateGuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * Generates the AppxManifest.xml content * @param {Object} config - Configuration object * @param {Object} packageJson - package.json content * @returns {string} XML manifest content */ function generateManifest(config, packageJson) { // Validate required fields for manifest generation validateManifestConfig(config); const manifest = `<?xml version="1.0" encoding="utf-8"?> <Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3" xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap uap3 uap5 desktop rescap"> <Identity Name="${config.packageName}" Publisher="${config.publisher}" Version="${config.version}" /> <mp:PhoneIdentity PhoneProductId="${generateGuid()}" PhonePublisherId="00000000-0000-0000-0000-000000000000"/> <Properties> <DisplayName>${config.displayName}</DisplayName> <PublisherDisplayName>${getPublisherDisplayName(config.publisher)}</PublisherDisplayName> <Logo>Assets\\StoreLogo.png</Logo> </Properties> <Dependencies> <TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /> <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /> </Dependencies> <Resources> <Resource Language="en-US"/> </Resources> <Applications> <Application Id="App" Executable="${config.executable}" EntryPoint="Windows.FullTrustApplication"> <uap:VisualElements DisplayName="${config.displayName}" Description="${config.description || config.displayName}" BackgroundColor="${config.backgroundColor || 'transparent'}" Square150x150Logo="Assets\\Square150x150Logo.png" Square44x44Logo="Assets\\Square44x44Logo.png"> <uap:DefaultTile Wide310x150Logo="Assets\\Wide310x150Logo.png" /> <uap:SplashScreen Image="Assets\\SplashScreen.png" /> </uap:VisualElements> <Extensions> <uap3:Extension Category="windows.appExtension"> <uap3:AppExtension Name="com.microsoft.windows.ai.mcpServer" Id="MCPServer" DisplayName="${config.displayName}" PublicFolder="Assets"> <uap3:Properties> <Registration>mcpServerConfig.json</Registration> </uap3:Properties> </uap3:AppExtension> </uap3:Extension> <uap5:Extension Category="windows.appExecutionAlias"> <uap5:AppExecutionAlias> <uap5:ExecutionAlias Alias="${config.alias}"/> </uap5:AppExecutionAlias> </uap5:Extension> ${generateDesktopExtension(config)} </Extensions> </Application> </Applications> <Capabilities> ${generateCapabilities(config.capabilities)} </Capabilities> </Package>`; return manifest; } /** * Validates configuration for manifest generation * @param {Object} config - Configuration object * @throws {ValidationError} When validation fails */ function validateManifestConfig(config) { const requiredFields = ['packageName', 'publisher', 'version', 'displayName', 'description', 'architecture', 'executable']; const missing = requiredFields.filter(field => !config[field]); if (missing.length > 0) { throw new ValidationError('manifest config', 'all required fields', `Missing fields: ${missing.join(', ')}`); } } /** * Extracts publisher display name from publisher string * @param {string} publisher - Publisher string (e.g., "CN=MyCompany, O=MyOrg") * @returns {string} Display name */ function getPublisherDisplayName(publisher) { // Extract CN (Common Name) from publisher string const cnMatch = publisher.match(/CN=([^,]+)/); return cnMatch ? cnMatch[1].trim() : publisher; } /** * Generates capabilities XML section * @param {Array} capabilities - Array of capabilities * @returns {string} Capabilities XML */ function generateCapabilities(capabilities = ['internetClient']) { // Always ensure runFullTrust capability is included for Node.js apps const allCapabilities = [...capabilities]; if (!allCapabilities.includes('runFullTrust')) { allCapabilities.push('runFullTrust'); } // Always include internetClient if not specified if (!allCapabilities.includes('internetClient')) { allCapabilities.unshift('internetClient'); } return allCapabilities.map(capability => { // Handle different capability types if (capability === 'runFullTrust') { return ' <rescap:Capability Name="runFullTrust" />'; } else if (capability.startsWith('rescap:')) { return ` <rescap:Capability Name="${capability.replace('rescap:', '')}" />`; } else if (capability.startsWith('uap:')) { return ` <uap:Capability Name="${capability.replace('uap:', '')}" />`; } else { return ` <Capability Name="${capability}" />`; } }).join('\n'); } /** * Generates desktop extension XML section for full trust process * @param {Object} config - Configuration object * @returns {string} Desktop extension XML */ function generateDesktopExtension(config) { // Only generate desktop extension if we have arguments and executable is node.exe if (config.arguments && config.executable === 'node.exe') { return ` <desktop:Extension Category="windows.fullTrustProcess" Executable="${config.executable}"> <desktop:FullTrustProcess> <desktop:ParameterGroup GroupId="NodeJsGroup" Parameters="${config.arguments}" /> </desktop:FullTrustProcess> </desktop:Extension>`; } return ''; } /** * Creates default assets for the package * @param {string} assetsDir - Directory to create assets in * @param {string} iconPath - Path to source icon file (optional) */ async function createDefaultAssets(assetsDir, iconPath = null) { await fs.ensureDir(assetsDir); // Asset sizes required by MSIX const requiredAssets = [ { name: 'Square44x44Logo.png', size: 44 }, { name: 'Square150x150Logo.png', size: 150 }, { name: 'Wide310x150Logo.png', width: 310, height: 150 }, { name: 'StoreLogo.png', size: 50 }, { name: 'SplashScreen.png', width: 620, height: 300 } ]; if (iconPath && await fs.pathExists(iconPath)) { // If source icon is provided, try to use it await copyAndResizeIcon(iconPath, assetsDir, requiredAssets); } else { // Create placeholder assets await createPlaceholderAssets(assetsDir, requiredAssets); } } /** * Copies and resizes icon for different asset sizes * @param {string} iconPath - Source icon path * @param {string} assetsDir - Assets directory * @param {Array} requiredAssets - Required asset configurations */ async function copyAndResizeIcon(iconPath, assetsDir, requiredAssets) { try { // Check if we have ImageMagick available for resizing const { executeCommand } = require('./utils'); for (const asset of requiredAssets) { const outputPath = path.join(assetsDir, asset.name); try { if (asset.size) { // Square asset executeCommand(`magick "${iconPath}" -resize ${asset.size}x${asset.size} "${outputPath}"`, { silent: true }); } else { // Rectangular asset executeCommand(`magick "${iconPath}" -resize ${asset.width}x${asset.height} "${outputPath}"`, { silent: true }); } console.log(`Created asset: ${asset.name}`); } catch (error) { // Fallback to copying original icon await fs.copy(iconPath, outputPath); console.log(`Copied original icon as: ${asset.name} (resize failed)`); } } } catch (error) { console.warn('ImageMagick not available, creating placeholder assets'); await createPlaceholderAssets(assetsDir, requiredAssets); } } /** * Creates placeholder PNG assets * @param {string} assetsDir - Assets directory * @param {Array} requiredAssets - Required asset configurations */ async function createPlaceholderAssets(assetsDir, requiredAssets) { // Create simple placeholder PNGs programmatically for (const asset of requiredAssets) { const outputPath = path.join(assetsDir, asset.name); const width = asset.width || asset.size; const height = asset.height || asset.size; // Create a simple PNG placeholder await createSimplePNG(outputPath, width, height); console.log(`Created placeholder asset: ${asset.name} (${width}x${height})`); } } /** * Creates a simple PNG file programmatically * @param {string} outputPath - Output file path * @param {number} width - Image width * @param {number} height - Image height */ async function createSimplePNG(outputPath, width, height) { // Create a minimal PNG file // This is a very basic 1x1 transparent PNG that MSIX will accept const minimalPNG = Buffer.from([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, // IHDR chunk size 0x49, 0x48, 0x44, 0x52, // IHDR 0x00, 0x00, 0x00, 0x01, // width: 1 0x00, 0x00, 0x00, 0x01, // height: 1 0x08, 0x06, 0x00, 0x00, 0x00, // bit depth: 8, color type: 6 (RGBA), compression: 0, filter: 0, interlace: 0 0x1F, 0x15, 0xC4, 0x89, // IHDR CRC 0x00, 0x00, 0x00, 0x0A, // IDAT chunk size 0x49, 0x44, 0x41, 0x54, // IDAT 0x78, 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, // compressed data (transparent pixel) 0xE2, 0x21, 0xBC, 0x33, // IDAT CRC 0x00, 0x00, 0x00, 0x00, // IEND chunk size 0x49, 0x45, 0x4E, 0x44, // IEND 0xAE, 0x42, 0x60, 0x82 // IEND CRC ]); await fs.writeFile(outputPath, minimalPNG); } /** * Generates a resource configuration file * @param {Object} config - Configuration object * @returns {string} Resource configuration content */ function generateResourceConfig(config) { const resourceConfig = { packageName: config.packageName, displayName: config.displayName, description: config.description, version: config.version, architecture: config.architecture, capabilities: config.capabilities, generated: new Date().toISOString() }; return JSON.stringify(resourceConfig, null, 2); } module.exports = { generateManifest, createDefaultAssets, generateResourceConfig, validateManifestConfig };