351 lines
15 KiB
JavaScript
351 lines
15 KiB
JavaScript
// THE electron-builder configuration — the whole thing, one file. There is
|
|
// no "build" field in package.json: run-electron-builder.mjs always passes
|
|
// --config for this file, so a stray package.json field would be silently
|
|
// ignored anyway, and splitting the config across JSON + this overlay is
|
|
// how the two halves drift.
|
|
//
|
|
// A .cjs module (not JSON) so the variant is decided at require time:
|
|
// HERMES_DESKTOP_VARIANT=light builds "Hermes Light". The whole config
|
|
// derives from that one flag.
|
|
// @ts-check
|
|
'use strict'
|
|
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const feedContract = require('./update-feed.cjs')
|
|
const { createMacSigner } = require('./scripts/mac-sign.mjs')
|
|
|
|
const {
|
|
light,
|
|
store,
|
|
storeMsix,
|
|
displayName,
|
|
appId,
|
|
appNamePascal,
|
|
artifactNamePascal,
|
|
windowsExecutableName,
|
|
channel,
|
|
msixAppIdWithOrg,
|
|
token
|
|
} = require('./product-identity.cjs')
|
|
|
|
// `storeMsix` is optional on the identity type but guaranteed present when
|
|
// `store` is true (product-identity.cjs spreads it only in that branch).
|
|
// The `store` flag is typed `boolean` in the identity, so checkJs cannot
|
|
// correlate the two; `mustStoreMsix` is the single assertion point and the
|
|
// invariant lives in product-identity.cjs:33-34/58-68.
|
|
/** @type {NonNullable<typeof storeMsix> | undefined} */
|
|
const storeMsixWhenStore = storeMsix
|
|
const releaseBuild = Boolean(process.env.HERMES_PAYLOAD_TAG)
|
|
|
|
/**
|
|
* The store MSIX packaging identity. Callers must only invoke this when
|
|
* `store` is true (see the invariant note above).
|
|
* @param {NonNullable<typeof storeMsix> | undefined} value
|
|
* @returns {NonNullable<typeof storeMsix>}
|
|
*/
|
|
function mustStoreMsix(value) {
|
|
return /** @type {NonNullable<typeof storeMsix>} */ (value)
|
|
}
|
|
|
|
// The out-of-store MSIX publisher (ATS cert subject) — single source, shared
|
|
// with the .appinstaller generator so the manifest and the App Installer can
|
|
// never drift (see scripts/msix-shared.mjs).
|
|
const { OUT_OF_STORE_PUBLISHER, channelBuildRequest, stageChannelManifest } = require('../../scripts/msix-shared.mjs')
|
|
const { macIconResource } = require('./scripts/mac-icon.cjs')
|
|
const channelRequest = channelBuildRequest()
|
|
|
|
/** @typedef {import("app-builder-lib").Configuration} Configuration */
|
|
|
|
const [owner, repo] = (process.env.GITHUB_REPOSITORY || 'NousResearch/hermes-agent').split('/')
|
|
if (!owner || !repo) {
|
|
throw new Error(`invalid GITHUB_REPOSITORY ${process.env.GITHUB_REPOSITORY}`)
|
|
}
|
|
const electronVersion = require('./package.json').devDependencies.electron
|
|
if (!/^\d+\.\d+\.\d+$/.test(electronVersion)) {
|
|
throw new Error(`invalid electron version ${electronVersion} in package.json`)
|
|
}
|
|
|
|
const macFeed = channelRequest ? null : feedContract.darwinFeed(channel === 'canary' || channel === 'light-canary' ? 'canary' : 'stable', light)
|
|
const publicUrl = feedContract.feedBaseUrl(process.env.CLOUDFLARE_R2_PUBLIC_URL)
|
|
|
|
/** @satisfies {Configuration} */
|
|
module.exports = {
|
|
electronVersion,
|
|
appId,
|
|
productName: displayName,
|
|
executableName: displayName,
|
|
protocols: [
|
|
{
|
|
name: `${displayName} Protocol`,
|
|
schemes: ['hermes']
|
|
}
|
|
],
|
|
// A store build is archived, never served to a feed — prefix its artifact
|
|
// so it can't collide with the out-of-store MSIX of the same tag/arch, and
|
|
// the release pipeline can keep the two apart.
|
|
artifactName: `${store ? 'Store-' : ''}${artifactNamePascal}-\${version}-\${os}-\${arch}.\${ext}`,
|
|
icon: 'assets/icon',
|
|
// The electron-updater feed. CI builds set CLOUDFLARE_R2_PUBLIC_URL (the R2
|
|
// public bucket / custom domain) and publish there — the feed yml, blockmaps
|
|
// and installers all live in the same flat R2 bucket, and electron-updater
|
|
// resolves the yml's relative artifact paths against it. Builds without the
|
|
// var (local, or a fork without the R2 vars) keep the github provider, which
|
|
// is exactly today's behavior. The store build has no feed at all (the Store
|
|
// owns its distribution and updates).
|
|
publish: channelRequest ? null : !channel
|
|
? null
|
|
: [
|
|
publicUrl
|
|
? { provider: 'generic', url: publicUrl, channel }
|
|
: { provider: 'github', owner, repo, channel }
|
|
],
|
|
extraMetadata: {
|
|
name: appNamePascal,
|
|
// Electron bootstrap reads package.productName before main.ts. Keep the
|
|
// shipped stable default, but isolate nonstable userData from first access.
|
|
// A stable-branded channel has no token and keeps stable's userData.
|
|
...((channelRequest && token) || appNamePascal !== artifactNamePascal ? { productName: displayName } : {}),
|
|
desktopName: appId
|
|
},
|
|
directories: {
|
|
output: 'release'
|
|
},
|
|
files: ['dist/**', 'assets/**', 'public/**', 'package.json'],
|
|
beforeBuild: channelRequest ? async () => {
|
|
await require(path.join(__dirname, 'scripts/before-build.mjs')).default()
|
|
stageChannelManifest(__dirname, channelRequest)
|
|
return false
|
|
} : 'scripts/before-build.mjs',
|
|
beforePack: 'scripts/before-pack.mjs',
|
|
// The exe identity stamp runs here, on the pristine electron.exe, because
|
|
// ASAR integrity rewrites the PE later and rcedit cannot commit to that
|
|
// rewritten file (#105629). afterPack keeps the signing/payload work.
|
|
afterExtract: 'scripts/after-extract.mjs',
|
|
afterPack: 'scripts/after-pack.mjs',
|
|
...(process.platform === 'darwin' ? { afterSign: 'scripts/notarize.mjs' } : {}),
|
|
extraResources: [
|
|
{
|
|
from: 'build/install-stamp.json',
|
|
to: 'install-stamp.json'
|
|
},
|
|
...(['bundled', 'store'].includes(process.env.HERMES_DESKTOP_VARIANT || '')
|
|
? [{ from: 'build/agent-payload', to: 'agent-payload' }]
|
|
: []),
|
|
{
|
|
from: 'assets/icon.ico',
|
|
to: 'icon.ico'
|
|
}
|
|
],
|
|
asar: {
|
|
unpack: ['**/*.node', '**/prebuilds/**', 'dist/**']
|
|
},
|
|
mac: {
|
|
// macOS 26 masks every icon into its own squircle: the layered Icon
|
|
// Composer package lets the system do that with the ring following the
|
|
// outline, while `assets/icon.icns` stays the artwork for macOS <= 15.
|
|
// electron-builder compiles `.icon` with actool >= 26 only, so hosts
|
|
// without Xcode 26 fall back to the .icns alone (see scripts/mac-icon.cjs);
|
|
// after-pack.mjs restores our full-resolution .icns either way.
|
|
icon: macIconResource(__dirname),
|
|
// The afterSign hook owns notarization, including keychain-profile builds.
|
|
notarize: false,
|
|
// The packaged client reads this generated app-update.yml by default.
|
|
publish: channelRequest?.receiverCandidate
|
|
? [{ provider: 'generic', url: `${channelRequest.publicBase}/releases/darwin/stable/`, channel: 'stable' }]
|
|
: channelRequest
|
|
? [{ provider: 'generic', url: `${channelRequest.publicBase}/releases/channel-builds/${channelRequest.buildId}/darwin/`, channel: 'latest' }]
|
|
: publicUrl && channel && macFeed
|
|
? [{ provider: 'generic', url: `${publicUrl}/${macFeed.directory}/`, channel: macFeed.channel }]
|
|
: null,
|
|
category: 'public.app-category.developer-tools',
|
|
extendInfo: {
|
|
CFBundleDisplayName: displayName,
|
|
CFBundleExecutable: displayName,
|
|
CFBundleName: displayName,
|
|
LSRequiresNativeExecution: true,
|
|
NSAudioCaptureUsageDescription: `${displayName} uses audio capture for voice conversations.`,
|
|
NSCameraUsageDescription: `${displayName} uses the camera when a plugin or feature you enable requests it.`,
|
|
NSMicrophoneUsageDescription: `${displayName} uses the microphone for voice input and voice conversations.`,
|
|
NSCalendarsUsageDescription: `${displayName} needs access to Calendar to provide requested meeting and scheduling support.`,
|
|
NSCalendarsFullAccessUsageDescription: `${displayName} needs full access to Calendar to read and manage events when explicitly requested.`,
|
|
NSRemindersUsageDescription: `${displayName} needs access to Reminders to provide requested personal-assistant and scheduling support.`,
|
|
NSRemindersFullAccessUsageDescription: `${displayName} needs full access to Reminders to read and manage reminders when explicitly requested.`,
|
|
NSScreenCaptureUsageDescription: `${displayName} captures the screen when you ask the agent to screenshot or record it.`,
|
|
NSLocalNetworkUsageDescription: `${displayName} connects to devices on your local network when a plugin or feature you enable requests it.`,
|
|
NSAppleMusicUsageDescription: `${displayName} accesses your music library when a plugin or feature you enable requests it.`,
|
|
NSContactsUsageDescription: `${displayName} uses Contacts access when you ask it to read or update your address book.`,
|
|
NSAppleEventsUsageDescription: `${displayName} uses Apple Events to automate apps you explicitly ask it to control.`
|
|
},
|
|
target: ['dmg', 'zip'],
|
|
sign: createMacSigner({
|
|
entitlements: path.join(__dirname, 'electron/entitlements.mac.plist'),
|
|
entitlementsInherit: path.join(__dirname, 'electron/entitlements.mac.inherit.plist'),
|
|
hardenedRuntime: true,
|
|
ignore: (/** @type {string} */ file) => {
|
|
try {
|
|
if (fs.lstatSync(file).isDirectory()) {
|
|
return false
|
|
}
|
|
return !isMachO(file)
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
})
|
|
},
|
|
dmg: {
|
|
// Avoid the failing optional APFS shrink pass; keep compressed conversion.
|
|
shrink: false,
|
|
// The volume icon defaults to the packager's icns, which is actool's 256px
|
|
// fallback whenever `mac.icon` is the Icon Composer package. Ship our own
|
|
// drive-with-the-girl artwork instead (dmgbuild's badge option can only
|
|
// paste onto the stock removable-drive icon). It lives in packaging/ with
|
|
// the background so the `files` whitelist keeps it out of the app bundle.
|
|
icon: 'packaging/dmg-volume.icns',
|
|
title: 'Hermes Agent Installer',
|
|
// A prebuilt .tiff on purpose, not a PNG plus a @2x sibling: dmg-builder's
|
|
// PNG path runs `tiffutil -cathidpicheck`, which on macOS 26 rewrites both
|
|
// frames to 72 dpi and silently drops the 2x representation. A .tiff is
|
|
// handed to dmgbuild untouched (dmg-builder/dist/dmgUtil.js), and living
|
|
// outside assets/ keeps it out of the app bundle via the `files` whitelist.
|
|
background: 'packaging/nous-dmg-2b.tiff',
|
|
iconSize: 96,
|
|
iconTextSize: 11,
|
|
window: {
|
|
width: 660,
|
|
height: 400
|
|
},
|
|
contents: [
|
|
{
|
|
x: 253,
|
|
y: 238,
|
|
type: 'file'
|
|
},
|
|
{
|
|
x: 512,
|
|
y: 235,
|
|
type: 'link',
|
|
path: '/Applications'
|
|
}
|
|
]
|
|
},
|
|
win: {
|
|
executableName: windowsExecutableName,
|
|
legalTrademarks: displayName,
|
|
target: ['msix'],
|
|
// The updaters' relaunch waiter is PowerShell run outside the package. The
|
|
// sealed payload's snapshot omits scripts/, so it ships as a resource
|
|
// (RELAUNCH_WAITER_SCRIPT in electron/updater/relaunch-waiter.ts).
|
|
extraResources: [{ from: 'scripts/update-relaunch-waiter.ps1', to: 'update-relaunch-waiter.ps1' }],
|
|
...windowsSigning()
|
|
},
|
|
msix: {
|
|
// A store build uses the Partner Center packaging identity (the Store
|
|
// re-signs + rewrites the publisher on submission); everything else uses
|
|
// the out-of-store ATS-cert identity.
|
|
identityName: store ? mustStoreMsix(storeMsixWhenStore).identityName : msixAppIdWithOrg,
|
|
applicationId: appNamePascal,
|
|
displayName,
|
|
publisher: store ? mustStoreMsix(storeMsixWhenStore).publisher : OUT_OF_STORE_PUBLISHER,
|
|
publisherDisplayName: store ? mustStoreMsix(storeMsixWhenStore).publisherDisplayName : 'Nous Research',
|
|
// The native quad is the build time (scripts/msix-shared.mjs::nativeQuad),
|
|
// baked into the manifest template, so the builder's own build-number
|
|
// override would stamp a second, conflicting version.
|
|
setBuildNumber: false,
|
|
// Store versions are baked into a build-time template. App semver and
|
|
// artifact filenames stay unchanged; the Store reserves revision zero.
|
|
// Floor Windows 11 22H2. Below build 18307 the manifest schema caps
|
|
// AppExtension Name at 39 chars and Microsoft's own
|
|
// "com.microsoft.windows.copilotkeyprovider" is 40 (makeappx
|
|
// 0x80080204 — A/B-verified against the 26100 kit; 18307 exactly
|
|
// still failed on it, 22621 passes), and 22621 is the documented
|
|
// Copilot hardware key floor anyway.
|
|
minVersion: '10.0.22621.0',
|
|
maxVersionTested: '10.0.26100.0',
|
|
// Static path: the file itself is written by scripts/before-build.mjs at
|
|
// build time (see the comment on the hook) — never at config require
|
|
// time, so typecheck/test imports don't touch the filesystem.
|
|
customExtensionsPath: 'build/msix-extensions.xml',
|
|
customManifestPath: store ? 'build/store-msix-manifest.xml'
|
|
: releaseBuild || channelRequest || appNamePascal !== artifactNamePascal
|
|
? 'build/msix-manifest.xml' : 'assets/msix-manifest.xml',
|
|
// Hermes state is deliberately shared with unpackaged CLI/gateway
|
|
// processes. Pair the manifest's disabled virtualization properties with
|
|
// the restricted capability that permits unvirtualized AppData/HKCU writes.
|
|
capabilities: ['unvirtualizedResources'],
|
|
showNameOnTiles: true
|
|
},
|
|
linux: {
|
|
category: 'Development',
|
|
maintainer: 'Nous Research <support@nousresearch.com>',
|
|
synopsis: light
|
|
? 'Remote-only desktop client for Hermes Agent.'
|
|
: 'Native desktop shell for Hermes Agent.',
|
|
target: ['AppImage']
|
|
}
|
|
}
|
|
|
|
if (channelRequest) {
|
|
Object.assign(module.exports, { buildVersion: channelRequest.version })
|
|
Object.assign(module.exports.extraMetadata, {
|
|
version: channelRequest.version,
|
|
shortVersion: channelRequest.windowsVersion,
|
|
shortVersionWindows: channelRequest.windowsVersion
|
|
})
|
|
Object.assign(module.exports.mac, {
|
|
bundleVersion: channelRequest.version,
|
|
bundleShortVersion: channelRequest.version
|
|
})
|
|
}
|
|
|
|
// MSIX build-time staging (build/appx icons + build/msix-extensions.xml)
|
|
// lives in scripts/before-build.mjs — an electron-builder lifecycle hook —
|
|
// NOT here at require time, so importing this config for typecheck/tests
|
|
// never writes to the filesystem.
|
|
|
|
// Azure Trusted Signing. The hook signs the .msix package itself and the
|
|
// product exe (after electron-builder's rcedit — the batch in afterPack runs
|
|
// too early for the exe); every other payload binary is batch-signed in
|
|
// afterPack via scripts/batch-sign-binaries.mjs, which this hook acknowledges
|
|
// with `true` so electron-builder never re-signs one-by-one. The package
|
|
// signature is all Windows install validation checks; inner Authenticode
|
|
// covers SmartScreen/WDAC tree scans. See batch-sign-binaries.mjs for the
|
|
// ordering contract.
|
|
const MACHO_MAGICS = new Set([
|
|
0xfeedface,
|
|
0xcefaedfe,
|
|
0xfeedfacf,
|
|
0xcffaedfe,
|
|
0xcafebabe,
|
|
0xbebafeca
|
|
])
|
|
|
|
/** @param {string} file */
|
|
function isMachO(file) {
|
|
const buf = Buffer.alloc(4)
|
|
const fd = fs.openSync(file, 'r')
|
|
try {
|
|
if (fs.readSync(fd, buf, 0, 4, 0) !== 4) {
|
|
return false
|
|
}
|
|
} finally {
|
|
fs.closeSync(fd)
|
|
}
|
|
return MACHO_MAGICS.has(buf.readUInt32BE(0))
|
|
}
|
|
|
|
function windowsSigning() {
|
|
if (!process.env.AZURE_SIGN_ENDPOINT || !process.env.AZURE_CLIENT_ID) {
|
|
return {}
|
|
}
|
|
return {
|
|
sign: {
|
|
type: 'signtool',
|
|
sign: './scripts/batch-sign-binaries.mjs',
|
|
signingHashAlgorithms: ['sha256'],
|
|
publisherName: process.env.AZURE_SIGN_PUBLISHER
|
|
}
|
|
}
|
|
}
|