Files
hermes-agent/apps/desktop/scripts/before-build.mjs
ethernet 386e31a56f fix(desktop): ship exact-size, theme-aware MSIX icons instead of one 44px tile
The MSIX package carried a single Square44x44Logo.png, so every taskbar and
Start slot on a HiDPI Windows 11 machine was Windows upscaling a 44px bitmap,
and with no altform variants Windows plated the icon itself. The generator now
emits the qualified catalog Windows resolves by itself: <Logo>.scale-{125,150,
200,400} for all four manifest logos at Microsoft's pixel sizes, and
Square44x44Logo.targetsize-{16..256} in the three required theme forms —
unqualified and altform-lightunplated carry the light tile, altform-unplated
(dark theme) the dark tile, the same split macOS dark mode gets. before-build
stages the whole assets/appx directory; app-builder-lib runs makepri and
`makeappx /l` as soon as it sees a qualified asset, so nothing else changes.
The hand-listed *-dark.png logos, generated but never staged, are gone.

Exposed along the way: the commit badge sat outside the portrait's silhouette
clip, and at 16–30px its anti-aliased edge stacked coverage on the plate's
corner pixels and changed the alpha channel between build flavors. The badge
now shares the clip, is plain black, and is left off direct renders below
32px where no SHA is legible anyway.
2026-10-02 12:51:02 -04:00

183 lines
8.3 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')
// Every generated logo goes in: the qualified siblings (*.scale-N,
// *.targetsize-N_altform-*) are what make Windows draw an exact bitmap per
// taskbar/Start slot instead of upscaling the 44px base, and app-builder-lib
// only runs makepri when it sees them staged.
const names = fs.readdirSync(sourceDir).filter((name) => name.endsWith('.png'))
for (const required of ['Square44x44Logo.png', 'Square150x150Logo.png', 'StoreLogo.png', 'Wide310x150Logo.png']) {
if (!names.includes(required)) {
throw new Error(`missing MSIX asset ${path.join(sourceDir, required)}`)
}
}
fs.mkdirSync(stageDir, { recursive: true })
for (const name of names) {
fs.copyFileSync(path.join(sourceDir, name), 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>`
}