Files
hermes-agent/apps/desktop/scripts/before-build.mjs
2026-09-22 02:19:38 -04:00

183 lines
7.9 KiB
JavaScript

/**
* Desktop bundles ship precompiled renderer assets. Returning false here tells
* electron-builder to skip the node_modules collector/install step, which
* avoids workspace dependency graph explosions and keeps packaging
* deterministic across environments.
*
* Payload assembly belongs to scripts/bundles. This hook never creates it.
*
* Also stages the MSIX build-time assets (the build/appx icon set and the
* build/msix-extensions.xml fragment the config points customExtensionsPath
* at). These MUST happen here, in the hook electron-builder calls at build
* time — not in electron-builder.config.cjs at require time, or every
* typecheck/test import of the config would write files.
*/
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { appIdentity, nativeManifestTemplate, storeManifestTemplate } from '../../../scripts/msix-shared.mjs'
const require = createRequire(import.meta.url)
const {
light,
store,
displayName,
appNamePascal,
artifactNamePascal
} = require('../product-identity.cjs')
export default async function beforeBuild() {
stageMsixAssets()
writeMsixExtensions()
if (store) stageStoreManifest(path.join(import.meta.dirname, '..'), process.env.HERMES_PAYLOAD_TAG)
else if (process.env.HERMES_PAYLOAD_TAG) {
stageReleaseManifest(path.join(import.meta.dirname, '..'), process.env.HERMES_PAYLOAD_TAG)
}
return false
}
function stageMsixAssets() {
const desktop = path.join(import.meta.dirname, '..')
const sourceDir = path.join(desktop, 'assets', 'appx')
const stageDir = path.join(desktop, 'build', 'appx')
const names = [
'Square44x44Logo.png',
'Square150x150Logo.png',
'StoreLogo.png',
'Wide310x150Logo.png'
]
fs.mkdirSync(stageDir, { recursive: true })
for (const name of names) {
const source = path.join(sourceDir, name)
if (!fs.existsSync(source)) {
throw new Error(`missing MSIX asset ${source}`)
}
fs.copyFileSync(source, path.join(stageDir, name))
}
}
export function stageStoreManifest(desktop, tag) {
const template = fs.readFileSync(path.join(desktop, 'assets/msix-manifest.xml'), 'utf8')
const { version } = appIdentity(desktop, tag)
const output = path.join(desktop, 'build/store-msix-manifest.xml')
fs.mkdirSync(path.dirname(output), { recursive: true })
fs.writeFileSync(output, storeManifestTemplate(template, version), 'utf8')
return output
}
export function stageReleaseManifest(desktop, tag) {
const generated = path.join(desktop, 'build/msix-manifest.xml')
const { identity, version } = appIdentity(desktop, tag)
const source = identity.appNamePascal === identity.artifactNamePascal || !fs.existsSync(generated)
? path.join(desktop, 'assets/msix-manifest.xml')
: generated
const template = fs.readFileSync(source, 'utf8')
fs.mkdirSync(path.dirname(generated), { recursive: true })
fs.writeFileSync(generated, nativeManifestTemplate(template, version), 'utf8')
return generated
}
function writeMsixExtensions() {
const desktop = path.join(import.meta.dirname, '..')
const output = path.join('build', 'msix-extensions.xml')
const file = path.join(desktop, output)
const manifest = path.join(desktop, 'build', 'agent-payload', 'manifest.json')
const launchers = ['bundled', 'store'].includes(process.env.HERMES_DESKTOP_VARIANT || '')
? JSON.parse(fs.readFileSync(manifest, 'utf8')).launchers : []
if (!Array.isArray(launchers)) throw new Error('Bundled payload has no declared launchers')
const nonstable = appNamePascal !== artifactNamePascal
const aliases = nonstable ? '' : appExecutionAliasExtensions(launchers)
if (nonstable) {
const template = fs.readFileSync(path.join(desktop, 'assets/msix-manifest.xml'), 'utf8')
const applications = appExecutionAliasApplications(launchers, { appNamePascal, displayName })
fs.mkdirSync(path.join(desktop, 'build'), { recursive: true })
fs.writeFileSync(path.join(desktop, 'build/msix-manifest.xml'),
template.replace('</Applications>', `${applications}\n </Applications>`))
}
// The uap3:AppExtension fragment that registers the app as a Windows
// Copilot hardware key provider. The press activates hermes://copilot-key/start.
//
// Content rules (violations are an opaque makeappx 0x80080204):
// * xmlns:uap3 rides on the fragment root — the stock manifest template
// declares no uap3 prefix. A/B-verified fine.
// * children of uap3:Properties are UNPREFIXED (xs:any content, per
// Microsoft's copilot-key-state sample).
// * Id caps at 39 chars (opaque makeappx maxLength violation otherwise) —
// it only discriminates within the package, so it stays a fixed short
// literal and DisplayName carries the variant name.
const copilot = light
? ''
: `<uap3:Extension
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
Category="windows.appExtension">
<uap3:AppExtension
Name="com.microsoft.windows.copilotkeyprovider"
Id="CopilotKeyProvider"
DisplayName="${xmlAttribute(displayName)}"
Description="Launch ${xmlAttribute(displayName)} with the Copilot key"
PublicFolder="Public">
<uap3:Properties>
<SingleTap>hermes://copilot-key/start?state=Tap</SingleTap>
<PressAndHoldStart>hermes://copilot-key/start?state=Down</PressAndHoldStart>
<PressAndHoldStop>hermes://copilot-key/stop?state=Up</PressAndHoldStop>
</uap3:Properties>
</uap3:AppExtension>
</uap3:Extension>
${aliases}`
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, copilot)
}
// The manifest fragments above are string templates; a display name or a
// payload-declared launcher stem carrying `&`, `<` or `"` would otherwise
// corrupt the XML makeappx reads (an opaque 0x80080204 at best, a different
// alias at worst).
/** @param {string} value */
export function xmlAttribute(value) {
return String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
}
/**
* One uap5:Extension carries the aliases declared by the payload.
* Exported pure for tests.
* @param {string[]} launchers exe stems under bin/
*/
export function appExecutionAliasApplications(launchers, identity) {
// Each distlib launcher has a different embedded Python entrypoint. A single
// multi-alias extension executes only its first launcher (including for ACP).
// Give each CLI its own hidden application, with one extension per app.
return launchers.map((name, index) => {
const executable = ['app', 'resources', 'agent-payload', 'bin', `${name}.exe`].join(String.fromCharCode(92))
return `<Application Id="${xmlAttribute(identity.appNamePascal)}Cli${index}" Executable="${xmlAttribute(executable)}" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements DisplayName="${xmlAttribute(identity.displayName)}" Description="${xmlAttribute(identity.displayName)} CLI"
Square150x150Logo="assets\\Square150x150Logo.png" Square44x44Logo="assets\\Square44x44Logo.png"
BackgroundColor="transparent" AppListEntry="none" />
<Extensions>${appExecutionAliasExtensions([name])}</Extensions>
</Application>`
}).join('\n')
}
export function appExecutionAliasExtensions(launchers) {
if (launchers.length === 0) return ''
const bs = String.fromCharCode(92)
const executable = (name) => ['app', 'resources', 'agent-payload', 'bin', `${name}.exe`].join(bs)
// One windows.appExecutionAlias extension per application. The Executable
// attribute serves every alias in that extension. Separate distlib entrypoints
// use separate hidden applications (validated with Windows SDK 26100).
return `<uap5:Extension
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
Category="windows.appExecutionAlias"
Executable="${xmlAttribute(executable(launchers[0]))}"
EntryPoint="Windows.FullTrustApplication">
<uap5:AppExecutionAlias>
${launchers
.map((name) => ` <uap5:ExecutionAlias Alias="${xmlAttribute(name)}.exe" />`)
.join('\n')}
</uap5:AppExecutionAlias>
</uap5:Extension>`
}