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
JavaScript
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
};