diff --git a/.gitignore b/.gitignore index cd474ded99..4fcea76f57 100644 --- a/.gitignore +++ b/.gitignore @@ -327,6 +327,7 @@ apps/desktop/assets/appx/*.png apps/desktop/public/apple-touch-icon.png apps/desktop/public/nous-girl.png apps/desktop/public/nous-girl-dark.png +apps/desktop/.dist-build* apps/bootstrap-installer/src-tauri/icons/*.png apps/bootstrap-installer/src-tauri/icons/icon.ico apps/bootstrap-installer/src-tauri/icons/icon.icns diff --git a/apps/desktop/electron/app-installer-file.test.ts b/apps/desktop/electron/app-installer-file.test.ts index 6f267aab2c..dc103cb625 100644 --- a/apps/desktop/electron/app-installer-file.test.ts +++ b/apps/desktop/electron/app-installer-file.test.ts @@ -27,6 +27,7 @@ test('stages an actual HTTP descriptor and leaves a valid file intact on downloa if (!address || typeof address === 'string') { throw new Error('missing server address') } + const base = `http://127.0.0.1:${address.port}` try { diff --git a/apps/desktop/electron/app-installer-file.ts b/apps/desktop/electron/app-installer-file.ts index c703658b2f..2b6eb8af20 100644 --- a/apps/desktop/electron/app-installer-file.ts +++ b/apps/desktop/electron/app-installer-file.ts @@ -24,6 +24,7 @@ export async function stageAppInstallerFile( if (done) { break } + size += value.byteLength if (size > 1024 * 1024) { @@ -39,6 +40,7 @@ export async function stageAppInstallerFile( if (size === 0) { throw new Error('App Installer descriptor is empty') } + await fs.mkdir(directory, { recursive: true }) const target = path.join(directory, 'update.appinstaller') const temporary = `${target}.tmp` diff --git a/apps/desktop/electron/appinstaller-checker.test.ts b/apps/desktop/electron/appinstaller-checker.test.ts index 367afbf461..5e790da69a 100644 --- a/apps/desktop/electron/appinstaller-checker.test.ts +++ b/apps/desktop/electron/appinstaller-checker.test.ts @@ -85,9 +85,11 @@ describe('runAppInstallerChecker', () => { try { let callback: Callback | undefined + const { impl } = stubExecFile(call => { callback = call.callback }) + let settled = false const pending = runAppInstallerChecker('python.exe', 'store.py', { diff --git a/apps/desktop/electron/backend-probes-runtime.test.ts b/apps/desktop/electron/backend-probes-runtime.test.ts index 3f140bafd1..f34b19903f 100644 --- a/apps/desktop/electron/backend-probes-runtime.test.ts +++ b/apps/desktop/electron/backend-probes-runtime.test.ts @@ -9,6 +9,7 @@ import { test } from 'vitest' import { canImportHermesCli } from './backend-probes' const REPO: string = path.resolve(import.meta.dirname, '../../..') + const PYTHON: string = process.env.HERMES_PYTHON || process.env.UV_PYTHON || (process.platform === 'win32' ? 'python' : 'python3') diff --git a/apps/desktop/electron/channel-build-version.test.ts b/apps/desktop/electron/channel-build-version.test.ts index 997c01b27a..6343423384 100644 --- a/apps/desktop/electron/channel-build-version.test.ts +++ b/apps/desktop/electron/channel-build-version.test.ts @@ -78,6 +78,7 @@ afterEach((): void => { test('channel packaging reuses admitted identity and rejects unsupported or unsafe identities', (): void => { const first: ChannelBuildRequest = request() const a: ReturnType = load(first) + const b: ReturnType = load({ ...first, ...request(65537), @@ -188,16 +189,19 @@ test('channel stamps verify the real checkout and retain source version and nati ], { cwd: dir } ) + const build: ChannelBuildRequest = { ...request(), commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim() } + const env: NodeJS.ProcessEnv = { ...process.env, HERMES_DESKTOP_VARIANT: 'bundled', _HERMES_CHANNEL_REQUEST_JSON: JSON.stringify(build), GITHUB_SHA: 'd'.repeat(40) } + const provenance: InstallStamp = resolveStamp({ env, repoRoot: dir }) const payload: StampPayload = { runtime: { repoDir: 'repo', commands: { hermes: 'bin/hermes' } } } const built: InstallStamp = buildStampPayload(provenance, env, 'darwin', payload) @@ -237,12 +241,14 @@ test('channel stamps verify the real checkout and retain source version and nati } const receiverEnv: NodeJS.ProcessEnv = { ...env, _HERMES_CHANNEL_REQUEST_JSON: JSON.stringify(receiver) } + const stable: InstallStamp = buildStampPayload( resolveStamp({ env: receiverEnv, repoRoot: dir }), receiverEnv, 'darwin', payload ) + assert.equal(stable.channelBuild, undefined) assert.equal(stable.source, 'build') assert.equal(stable.tag, receiver.releaseTag) diff --git a/apps/desktop/electron/cli-provision.ts b/apps/desktop/electron/cli-provision.ts index 749251ca29..b01601aab4 100644 --- a/apps/desktop/electron/cli-provision.ts +++ b/apps/desktop/electron/cli-provision.ts @@ -12,12 +12,14 @@ export function removeBundleCliLinks(payloadRoot: string, binDir: string): void if (!fs.existsSync(binDir)) { return } + const payloadBin: string = path.resolve(payloadRoot, 'bin') for (const entry of fs.readdirSync(binDir, { withFileTypes: true })) { if (!entry.isSymbolicLink()) { continue } + const link: string = path.join(binDir, entry.name) const destination: string = fs.readlinkSync(link) diff --git a/apps/desktop/electron/desktop-boot-preference.ts b/apps/desktop/electron/desktop-boot-preference.ts index 768e8ccad8..b85a186a56 100644 --- a/apps/desktop/electron/desktop-boot-preference.ts +++ b/apps/desktop/electron/desktop-boot-preference.ts @@ -21,6 +21,7 @@ export function readDesktopBootPreference(file: string): DesktopBootPreference | if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return null } + throw error } @@ -85,6 +86,7 @@ function updatePreference( } finally { closeSync(handle) } + renameSync(temporary, file) if (process.platform !== 'win32') { diff --git a/apps/desktop/electron/desktop-uninstall.test.ts b/apps/desktop/electron/desktop-uninstall.test.ts index 351520286d..4dabf4077f 100644 --- a/apps/desktop/electron/desktop-uninstall.test.ts +++ b/apps/desktop/electron/desktop-uninstall.test.ts @@ -214,6 +214,7 @@ test.skipIf(process.platform === 'win32').each(['gui', 'lite', 'full'] as const) child.kill() await once(child, 'close') } + fs.rmSync(root, { recursive: true, force: true }) } } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 6e82356d74..53682a63ec 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -3307,6 +3307,7 @@ function resolveDesktopFeedBaseUrl(): string { if (configured) { return configured } + const env: string | undefined = process.env.HERMES_DESKTOP_FEED_BASE_URL if (env) { @@ -4524,6 +4525,7 @@ async function resolveHermesBackend(backendArgs: string[]): Promise { spawnRequest.cancel() } + localBackendLifecycle.signal.addEventListener('abort', cancelRequest, { once: true }) try { @@ -17916,6 +17919,7 @@ app.on('before-quit', event => { const sshNeedsWait = sshConnections.size > 0 || sshBootstrapCoordinator.promises().length > 0 || sshTeardowns.hasPending() + const teardownTasks: QuitTeardownTask[] = [ { run: (): Promise => backendShutdown.run(), waitForCompletion: backendNeedsWait } ] diff --git a/apps/desktop/electron/package-process-reap.ts b/apps/desktop/electron/package-process-reap.ts index e8283f795b..817abff794 100644 --- a/apps/desktop/electron/package-process-reap.ts +++ b/apps/desktop/electron/package-process-reap.ts @@ -153,6 +153,7 @@ export function isUnderInstallRoot( .replace(/[\\/]+$/, '') .replace(/\//g, '\\') .toLowerCase() + const normalizedPath = normalize(imagePath) const candidates = typeof roots === 'string' || roots == null ? [roots] : roots diff --git a/apps/desktop/electron/payload-backend.test.ts b/apps/desktop/electron/payload-backend.test.ts index 764d67b6d9..5d35484dff 100644 --- a/apps/desktop/electron/payload-backend.test.ts +++ b/apps/desktop/electron/payload-backend.test.ts @@ -40,6 +40,7 @@ test('bundled launch paths come from build metadata without filesystem access', const probe = vi.spyOn(fs, 'existsSync').mockImplementation(() => { throw new Error('runtime probe') }) + const read = vi.spyOn(fs, 'readFileSync').mockImplementation(() => { throw new Error('runtime read') }) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index e457a07b56..4e1a2fb7d8 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -14,6 +14,7 @@ import { customWindowControlsEnabled } from './window-controls' const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support') const hudWindowing = ipcRenderer.sendSync('hermes:hud:windowing') const hudNativeDrag = hudWindowing?.nativeDrag === true + const launchFlags: { localModels?: boolean; guestOnboarding?: boolean; skipIntro?: boolean } | undefined = ipcRenderer.sendSync('hermes:feature-flags') diff --git a/apps/desktop/electron/product-identity.ts b/apps/desktop/electron/product-identity.ts index 131694e058..8ca5f6cf12 100644 --- a/apps/desktop/electron/product-identity.ts +++ b/apps/desktop/electron/product-identity.ts @@ -38,6 +38,7 @@ export function applyDesktopIdentity( if (!identity.token && identity.appNamePascal === identity.artifactNamePascal) { return null } + const userData: string = path.join(app.getPath('appData'), identity.appNamePascal) mkdirSync(userData, { recursive: true }) app.setPath('userData', userData) diff --git a/apps/desktop/electron/updater/app-installer.ts b/apps/desktop/electron/updater/app-installer.ts index 249a1b54cd..31028b96cb 100644 --- a/apps/desktop/electron/updater/app-installer.ts +++ b/apps/desktop/electron/updater/app-installer.ts @@ -200,6 +200,7 @@ function newerWindowsVersion(target: string, current: string): boolean { if (!/^\d+\.\d+\.\d+\.\d+$/.test(version)) { throw new Error('Windows channel updates require native numeric versions') } + const parts = version.split('.').map(Number) if (parts.some((part: number): boolean => part > 65535)) { diff --git a/apps/desktop/electron/updater/artifact.ts b/apps/desktop/electron/updater/artifact.ts index 0db6417169..94c648b72d 100644 --- a/apps/desktop/electron/updater/artifact.ts +++ b/apps/desktop/electron/updater/artifact.ts @@ -54,6 +54,7 @@ export async function downloadPinnedArtifact(directory: string, artifact: Pinned if (!response.ok || !response.body) { throw new Error(`Pinned artifact download failed (${response.status})`) } + const handle: FileHandle = await open(temporary, 'wx', 0o600) let size: number = 0 @@ -64,6 +65,7 @@ export async function downloadPinnedArtifact(directory: string, artifact: Pinned if (size > artifact.size) { throw new Error('Pinned artifact download exceeds pinned size') } + await handle.writeFile(chunk) } diff --git a/apps/desktop/electron/updater/channel-artifact-lifetime.test.ts b/apps/desktop/electron/updater/channel-artifact-lifetime.test.ts index 59fc38be99..4566e5e54e 100644 --- a/apps/desktop/electron/updater/channel-artifact-lifetime.test.ts +++ b/apps/desktop/electron/updater/channel-artifact-lifetime.test.ts @@ -23,9 +23,11 @@ test('ordinary Windows preparation owns temporary bytes while a resumable downlo const directory: string = await mkdtemp(path.join(os.tmpdir(), 'hermes-artifact-lifetime-')) directories.push(directory) const bytes: Buffer = Buffer.from('digest-bound download') + const server = createServer((_request, response): void => { response.end(bytes) }) + server.listen(0, '127.0.0.1') await once(server, 'listening') const address = server.address() @@ -33,6 +35,7 @@ test('ordinary Windows preparation owns temporary bytes while a resumable downlo if (!address || !(address instanceof Object)) { throw new Error('Expected TCP server') } + const url: string = `http://127.0.0.1:${address.port}/stable.msixbundle` const identity = { diff --git a/apps/desktop/electron/updater/channel-native.test.ts b/apps/desktop/electron/updater/channel-native.test.ts index a37c533f3b..0bb859f35a 100644 --- a/apps/desktop/electron/updater/channel-native.test.ts +++ b/apps/desktop/electron/updater/channel-native.test.ts @@ -14,11 +14,13 @@ test('download verification binds native bytes to the manifest size and digest b try { const content = Buffer.from('real local archive fixture bytes') await writeFile(file, content) + const artifact = { key: 'releases/fixture.zip', size: content.length, sha256: createHash('sha256').update(content).digest('hex') } + await verifyChannelDownload([file], artifact) await writeFile(file, Buffer.alloc(content.length)) await expect(verifyChannelDownload([file], artifact)).rejects.toThrow('digest') diff --git a/apps/desktop/electron/updater/channel-native.ts b/apps/desktop/electron/updater/channel-native.ts index e2aecd3b7a..ca5cceff71 100644 --- a/apps/desktop/electron/updater/channel-native.ts +++ b/apps/desktop/electron/updater/channel-native.ts @@ -10,6 +10,7 @@ export async function verifyChannelDownload(files: string[], artifact: ChannelPa if (files.length !== 1) { throw new Error('Expected exactly one channel artifact') } + const file = await open(files[0], 'r') try { @@ -18,6 +19,7 @@ export async function verifyChannelDownload(files: string[], artifact: ChannelPa if (!stat.isFile() || stat.size !== artifact.size) { throw new Error('Channel artifact size mismatch') } + const digest = createHash('sha256') for await (const chunk of file.createReadStream({ autoClose: false })) { diff --git a/apps/desktop/electron/updater/channel-protocol.ts b/apps/desktop/electron/updater/channel-protocol.ts index bbbd11f0e3..db11666503 100644 --- a/apps/desktop/electron/updater/channel-protocol.ts +++ b/apps/desktop/electron/updater/channel-protocol.ts @@ -112,12 +112,14 @@ function parseChannelJson(body: string): unknown { if (!token.endsWith(':')) { continue } + const key: string = JSON.parse(token.slice(0, -1)) const keys = objects[objects.length - 1] if (keys.has(key)) { throw new Error('Duplicate channel JSON member') } + keys.add(key) } @@ -131,6 +133,7 @@ class Fields { if (!input || typeof input !== 'object' || Array.isArray(input)) { throw new Error('Expected channel object') } + this.input = input } get(key: string): unknown { @@ -258,6 +261,7 @@ function head(value: unknown): ChannelHead | null { if (value === null) { return null } + const fields = new Fields(value) const buildId = fields.text('buildId', BUILD_ID) const manifestKey = fields.text('manifestKey') @@ -307,6 +311,7 @@ export function decodeChannelRecord(body: string): ChannelRecord { if (common.head && common.head.sequence >= common.nextSequence) { throw new Error('Channel head exceeds allocation') } + const state = fields.text('state') if (state === 'active') { @@ -316,6 +321,7 @@ export function decodeChannelRecord(body: string): ChannelRecord { if (state !== 'retired') { throw new Error('Invalid channel state') } + const lastHead = head(fields.get('lastHead')) if (JSON.stringify(lastHead) !== JSON.stringify(common.head)) { @@ -325,6 +331,7 @@ export function decodeChannelRecord(body: string): ChannelRecord { if (fields.get('receiverProtocol') !== 1) { throw new Error('Unsupported retirement receiver protocol') } + const receiverFields = fields.object('receiver') const kind = receiverFields.text('kind') @@ -353,11 +360,13 @@ function request(fields: Fields): ChannelRequest { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { throw new Error('Invalid bundle environment name') } + const value: unknown = environment.get(key) if (value !== null && (typeof value !== 'string' || value.includes('\0'))) { throw new Error('Invalid bundle environment value') } + Object.defineProperty(bundleEnv, key, { value, enumerable: true }) } @@ -366,7 +375,9 @@ function request(fields: Fields): ChannelRequest { if (channelPublicBase(publicBase) !== publicBase) { throw new Error('Noncanonical request publicBase') } + const releaseTag = fields.optional('releaseTag', /^v\d+\.\d+\.\d+(?:-canary\.\d+)?$/) + const windowsVersion = fields.text( 'windowsVersion', releaseTag?.includes('-canary.') ? /^\d+\.\d+\.\d+\.\d+$/ : /^\d+\.\d+\.\d+\.0$/ @@ -435,12 +446,14 @@ export function decodeChannelManifest(body: string): ChannelManifest { if (!Array.isArray(entries) || !entries.length || entries.length > 4) { throw new Error('Invalid channel packages') } + const packages: ChannelPackage[] = entries.map(packageEntry) const keys = new Set(packages.map((entry: ChannelPackage): string => `${entry.platform}/${entry.arch}`)) if (keys.size !== packages.length) { throw new Error('Duplicate channel package') } + const manifest: ChannelManifest = { schema: 1, request: request(fields.object('request')), packages } if (fields.get('receiverProtocol') !== undefined) { diff --git a/apps/desktop/electron/updater/channel-strategy.ts b/apps/desktop/electron/updater/channel-strategy.ts index a5fa1af75e..bc6e56e81e 100644 --- a/apps/desktop/electron/updater/channel-strategy.ts +++ b/apps/desktop/electron/updater/channel-strategy.ts @@ -41,6 +41,7 @@ export class ChannelStrategy implements UpdaterStrategy { if (this.busy) { throw new Error('An update operation is already in progress.') } + this.busy = true } @@ -119,6 +120,7 @@ export class ChannelStrategy implements UpdaterStrategy { if (status.error || status.updateAvailable === undefined) { throw new Error(status.error || 'Native update availability unknown') } + this.selection = { kind: 'native', strategy, available: status.updateAvailable } return { @@ -136,6 +138,7 @@ export class ChannelStrategy implements UpdaterStrategy { if (!this.selection) { await this.select() } + const selected = this.selection if (selected?.kind === 'retirement') { diff --git a/apps/desktop/electron/updater/channel-windows-host.ts b/apps/desktop/electron/updater/channel-windows-host.ts index f2b451b960..537e6b068a 100644 --- a/apps/desktop/electron/updater/channel-windows-host.ts +++ b/apps/desktop/electron/updater/channel-windows-host.ts @@ -16,6 +16,7 @@ export async function verifyPreparedChannelInstaller( if (pkg.platform !== 'win32' || !pkg.publisher) { throw new Error('Expected a publisher-bound Windows package') } + const directory: string = await mkdtemp(path.join(path.dirname(file), '.channel-artifact-')) try { diff --git a/apps/desktop/electron/updater/channel.test.ts b/apps/desktop/electron/updater/channel.test.ts index bd27b16865..6a4d6d0169 100644 --- a/apps/desktop/electron/updater/channel.test.ts +++ b/apps/desktop/electron/updater/channel.test.ts @@ -113,6 +113,7 @@ async function fixture(): Promise<{ if (record.state !== 'active' || !record.head) { throw new Error('Fixture requires active head') } + record.head.sha256 = createHash('sha256').update(body).digest('hex') objects.set(`/${record.head.manifestKey}`, body) objects.set(`/releases/channels/${record.name}.json`, JSON.stringify(record)) @@ -253,6 +254,7 @@ test('offers a digest-bound retirement without a second proof document', async ( if (result.kind !== 'retirement') { throw new Error('Expected retirement') } + expect(result.retirement.target.channel.name).toBe('stable') expect(result.retirement.target.manifestSha256).toBe(retired.destinationHead.sha256) expect(requests).toEqual([ @@ -269,9 +271,11 @@ test('pins the checked target during apply even after R2 advances, and never aut let enteredResolve: () => void = (): void => {} let releaseResolve: () => void = (): void => {} + const entered = new Promise((resolve): void => { enteredResolve = resolve }) + const release = new Promise((resolve): void => { releaseResolve = resolve }) @@ -323,6 +327,7 @@ test('in-place retirement resolves with the receiver kind and routes through the if (result.kind !== 'retirement') { throw new Error('Expected retirement') } + expect(result.retirement.receiverKind).toBe('in-place') const strategy = new ChannelStrategy({ @@ -368,6 +373,7 @@ test('discontinued retirement surfaces the notice and never downloads or applies if (result.kind !== 'retirement') { throw new Error('Expected retirement') } + expect(result.retirement.receiverKind).toBe('discontinued') const strategy = new ChannelStrategy({ @@ -437,6 +443,7 @@ test.each(['hash', 'identity', 'repository', 'signer', 'escape', 'schema', 'vers if (fault === 'version') { f.manifest.request.version = '1.0.0' } + f.publish() if (fault === 'hash') { @@ -446,6 +453,7 @@ test.each(['hash', 'identity', 'repository', 'signer', 'escape', 'schema', 'vers if (fault === 'schema') { f.objects.set(`/releases/channels/${f.record.name}.json`, '{"schema":2}') } + await expect( new ChannelResolver({ build: f.build, platform: 'darwin', arch: 'arm64', signer: 'ABCDE12345' }).resolve() ).rejects.toThrow() @@ -476,6 +484,7 @@ test.each(['floor', 'cycle', 'protocol', 'receiver', 'digest', 'missing', 'unpro if (!f.record.head) { throw new Error('Expected published stable') } + f.retired.destinationHead = structuredClone(f.record.head) } @@ -490,6 +499,7 @@ test.each(['floor', 'cycle', 'protocol', 'receiver', 'digest', 'missing', 'unpro if (fault === 'missing') { f.objects.delete(`/${f.retired.destinationHead.manifestKey}`) } + f.objects.set(`/releases/channels/${f.retired.name}.json`, JSON.stringify(f.retired)) if (fault === 'protocol') { @@ -553,17 +563,20 @@ test('Windows resolves its numeric native version, publisher and immutable descr } ] f.publish() + const result = await new ChannelResolver({ build: f.build, platform: 'win32', arch: 'x64', signer: 'CN=Nous Research' }).resolve() + expect(result.kind).toBe('active') if (result.kind !== 'active') { throw new Error('Expected active') } + expect(result.target.package.version).toBe('0.0.2.0') expect(result.target.feedUrl).toContain('/win32/stable.appinstaller') }) @@ -623,17 +636,20 @@ test('long-offline previews retain the qualified migration target after stable a feed: { key: `releases/channel-builds/${f.manifest.request.buildId}/darwin/stable-mac.yml`, channel: 'stable' } } f.publish() + const result = await new ChannelResolver({ build: f.build, platform: 'darwin', arch: 'arm64', signer: 'ABCDE12345' }).resolve() + expect(result.kind).toBe('retirement') if (result.kind !== 'retirement') { throw new Error('Expected retirement') } + expect(result.retirement.target.manifest.request.buildId).toBe(qualifiedBuild) expect(result.retirement.target.manifest.request.channel).toBe('stable') }) @@ -655,6 +671,7 @@ test('resolves an arbitrary R2 name through a real HTTP channel and digest-bound if (result.kind !== 'active') { throw new Error('Expected active build') } + expect(result.target.manifest.request.sequence).toBe(2) expect(result.target.feedUrl).toBe( `${build.publicBase}/releases/channel-builds/${'c'.repeat(32)}/darwin/stable-mac.yml` diff --git a/apps/desktop/electron/updater/channel.ts b/apps/desktop/electron/updater/channel.ts index f4217cadfa..0e9b1d86bc 100644 --- a/apps/desktop/electron/updater/channel.ts +++ b/apps/desktop/electron/updater/channel.ts @@ -88,12 +88,14 @@ export class ChannelResolver { if (!response.ok || response.url !== url) { throw new Error(`Channel read unavailable: HTTP ${response.status}`) } + const maximum = 4 * 1024 * 1024 const reader = response.body?.getReader() if (!reader) { throw new Error('Channel metadata has no body') } + const chunks: Uint8Array[] = [] let size = 0 @@ -104,11 +106,13 @@ export class ChannelResolver { if (done) { break } + size += value.byteLength if (size > maximum) { throw new Error('Channel metadata exceeds size limit') } + chunks.push(value) } } finally { @@ -141,6 +145,7 @@ export class ChannelResolver { if (retired.destination === retired.name) { throw new Error('Channel retirement cycle') } + record = decodeChannelRecord(await this.read(`releases/channels/${retired.destination}.json`)) this.assertRecord(record, retired.destination) @@ -158,6 +163,7 @@ export class ChannelResolver { if (target.manifest.receiverProtocol !== retired.receiverProtocol) { throw new Error('Stable build has no supported retirement receiver') } + assertVersionFloor(target.manifest.request.sourceVersion, retired.minimumVersion) return { kind: 'retirement', retirement: { source: this.deps.build, target, receiverKind: retired.receiver.kind } } @@ -177,6 +183,7 @@ export class ChannelResolver { if (!channel.head) { throw new Error('No destination build published') } + const manifest = decodeChannelManifest(await this.read(channel.head.manifestKey, channel.head.sha256)) const request = manifest.request @@ -200,6 +207,7 @@ export class ChannelResolver { } this.assertPackages(channel, manifest) + const entry = manifest.packages.find( (item: ChannelPackage): boolean => item.platform === this.deps.platform && item.arch === this.deps.arch ) @@ -207,6 +215,7 @@ export class ChannelResolver { if (!entry) { throw new Error('Channel has no package for this platform and architecture') } + const signer = entry.platform === 'darwin' ? entry.teamId : entry.publisher if (!this.deps.signer || signer !== this.deps.signer) { @@ -231,6 +240,7 @@ export class ChannelResolver { if (!request.releaseTag || request.version !== request.releaseTag.slice(1)) { throw new Error('Protected release version mismatch') } + prefixes.push(`releases/tag/${request.releaseTag}/`) } diff --git a/apps/desktop/electron/updater/checkout.ts b/apps/desktop/electron/updater/checkout.ts index 533ed1b01e..10bcb90ae6 100644 --- a/apps/desktop/electron/updater/checkout.ts +++ b/apps/desktop/electron/updater/checkout.ts @@ -110,9 +110,11 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat const branch: string = status.branch ?? deps.defaultUpdateBranch const targetArgs: string[] = status.channel ? ['--channel', status.channel] : ['--branch', branch] const targetLabel: string = status.channel ?? branch + const manualCommand: string = status.channel ? `hermes update --channel ${status.channel}` : buildManualUpdateCommand(branch) + const updater: string | null = deps.resolveUpdaterBinary() const root: string = deps.resolveUpdateRoot() @@ -386,6 +388,7 @@ export function createCheckoutStrategy(deps: CheckoutStrategyDeps): UpdaterStrat '--desktop-pid', String(process.pid) ] + const updateStartedAt = Math.floor(Date.now() / 1000) // Relaunch target: the running .app bundle on mac (script swaps the diff --git a/apps/desktop/electron/updater/mac-client.ts b/apps/desktop/electron/updater/mac-client.ts index 3358db125d..349c5be5e1 100644 --- a/apps/desktop/electron/updater/mac-client.ts +++ b/apps/desktop/electron/updater/mac-client.ts @@ -55,6 +55,7 @@ export function createMacStrategy(deps: MacClientDeps): MacStrategy { if (!/^[a-z][a-z0-9-]*$/.test(channel) || !url.pathname.endsWith(`/${channel}-mac.yml`)) { throw new Error('Invalid macOS feed descriptor') } + updater.setFeedURL({ provider: 'generic', url: new URL('./', url).href, channel }) } else if (deps.feedBaseUrl) { const base = channelPublicBase(deps.feedBaseUrl) diff --git a/apps/desktop/electron/updater/mac.test.ts b/apps/desktop/electron/updater/mac.test.ts index cb90807ed3..4e70839349 100644 --- a/apps/desktop/electron/updater/mac.test.ts +++ b/apps/desktop/electron/updater/mac.test.ts @@ -90,6 +90,7 @@ describe('macOS strategy', () => { deps.verifyDownload = async (): Promise => { throw new Error('artifact digest mismatch') } + await expect(new MacStrategy(deps).apply()).rejects.toThrow('artifact digest') expect(events).not.toContain('verify') expect(events).not.toContain('stop') diff --git a/apps/desktop/electron/updater/operation.ts b/apps/desktop/electron/updater/operation.ts index 4a3f705601..b68519086f 100644 --- a/apps/desktop/electron/updater/operation.ts +++ b/apps/desktop/electron/updater/operation.ts @@ -20,6 +20,7 @@ export class UpdateOperation { if (this.applying) { throw new Error('An update is already in progress.') } + this.applying = true let handedOff: boolean = false diff --git a/apps/desktop/electron/updater/relaunch-isolation.test.ts b/apps/desktop/electron/updater/relaunch-isolation.test.ts index 825c167ee8..b78574ee13 100644 --- a/apps/desktop/electron/updater/relaunch-isolation.test.ts +++ b/apps/desktop/electron/updater/relaunch-isolation.test.ts @@ -102,11 +102,13 @@ test('cancellation and failed registration remove only their own installation ma for (const automatic of [true, false]) { let resolveReady!: (handle: RelaunchWaiterHandle | undefined) => void + const ready: Promise = new Promise( (resolve: (handle: RelaunchWaiterHandle | undefined) => void): void => { resolveReady = resolve } ) + let registered: boolean = false const pending: Promise = registerUpdateRelaunch(canary, 'canary-old', { diff --git a/apps/desktop/electron/updater/relaunch-waiter-lifecycle.test.ts b/apps/desktop/electron/updater/relaunch-waiter-lifecycle.test.ts index 55f20f8a53..fdd8398ddc 100644 --- a/apps/desktop/electron/updater/relaunch-waiter-lifecycle.test.ts +++ b/apps/desktop/electron/updater/relaunch-waiter-lifecycle.test.ts @@ -94,6 +94,7 @@ for (const mode of [ if (mode.refuseKill) { child.kill = () => false } + child.once('close', () => { closed = true }) @@ -163,6 +164,7 @@ for (const mode of [ if (child && originalKill) { child.kill = originalKill } + await stop(child) if (stage) { diff --git a/apps/desktop/electron/updater/relaunch-waiter.ts b/apps/desktop/electron/updater/relaunch-waiter.ts index a2c357b5f6..6d2b754749 100644 --- a/apps/desktop/electron/updater/relaunch-waiter.ts +++ b/apps/desktop/electron/updater/relaunch-waiter.ts @@ -191,6 +191,7 @@ export async function startRelaunchWaiter( if (settled) { return } + settled = true clearTimeout(timer) resolve(ready) diff --git a/apps/desktop/electron/updater/store-client.test.ts b/apps/desktop/electron/updater/store-client.test.ts index fcd0d17cfa..731cee3806 100644 --- a/apps/desktop/electron/updater/store-client.test.ts +++ b/apps/desktop/electron/updater/store-client.test.ts @@ -10,6 +10,7 @@ it('the production runner passes modes, full HWND and isolated payload imports', timeout?: number waitForExit?: boolean }[] = [] + const handle = Buffer.alloc(8) handle.writeBigUInt64LE(0x1234567887654321n) diff --git a/apps/desktop/scripts/prepared-native-deps.mjs b/apps/desktop/scripts/prepared-native-deps.mjs index f0648b0b8c..1fed773a9a 100644 --- a/apps/desktop/scripts/prepared-native-deps.mjs +++ b/apps/desktop/scripts/prepared-native-deps.mjs @@ -13,8 +13,8 @@ function nativeIdentity(source) { fileDigest(path.join(import.meta.dirname, 'prepared-native-deps.mjs')), ...['build-command-screenshot-monitor.mjs', 'build-hud-modifier-monitor.mjs'] .map(name => fileDigest(path.join(import.meta.dirname, name))), - ...['command-screenshot-monitor.m', 'hud-modifier-gesture.h', 'hud-modifier-monitor.m', - 'hud-modifier-monitor-win.c', 'hud-modifier-monitor-x11.c'] + ...['command-screenshot-monitor.m', 'hud-modifier-gesture.h', 'hud-modifier-gesture.cs', + 'hud-modifier-monitor.m', 'hud-modifier-monitor-win.cs', 'hud-modifier-monitor-x11.c'] .map(name => fileDigest(path.join(source, 'apps/desktop/electron/native', name))), ])).digest('hex') } diff --git a/apps/desktop/src/app/retirement-view.test.tsx b/apps/desktop/src/app/retirement-view.test.tsx index 5964768040..4097e9f517 100644 --- a/apps/desktop/src/app/retirement-view.test.tsx +++ b/apps/desktop/src/app/retirement-view.test.tsx @@ -19,10 +19,12 @@ afterEach((): void => { test('discontinued retirement shows the uninstall notice and persists dismissal per revision', async (): Promise => { const dismissed: string[] = [] const stored = new Map() + const original = { getItem: window.localStorage.getItem.bind(window.localStorage), setItem: window.localStorage.setItem.bind(window.localStorage) } + vi.spyOn(window.localStorage, 'getItem').mockImplementation((key: string) => stored.get(key) ?? original.getItem(key)) vi.spyOn(window.localStorage, 'setItem').mockImplementation((key: string, value: string) => { stored.set(key, value) diff --git a/apps/desktop/src/components/desktop-install-overlay.test.tsx b/apps/desktop/src/components/desktop-install-overlay.test.tsx index 6e2c2a2795..44fa21ff30 100644 --- a/apps/desktop/src/components/desktop-install-overlay.test.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.test.tsx @@ -567,6 +567,7 @@ it.each([ if (local === undefined && state.setupChoice) { Reflect.deleteProperty(state.setupChoice, 'local') } + installDesktopMock(state) render() expect(await screen.findByText(title)).toBeTruthy() diff --git a/apps/desktop/src/components/onboarding-chat/gate.test.tsx b/apps/desktop/src/components/onboarding-chat/gate.test.tsx index 5c47b8b193..5301987bf5 100644 --- a/apps/desktop/src/components/onboarding-chat/gate.test.tsx +++ b/apps/desktop/src/components/onboarding-chat/gate.test.tsx @@ -19,9 +19,11 @@ it('starts the skipped-film splash before the backend connects and removes it on $desktopOnboarding.set({ ...$desktopOnboarding.get(), firstRunSkipped: false }) let complete = (_ready: boolean) => {} + const pending = new Promise(resolve => { complete = resolve }) + const kickoff = vi.fn(() => pending) const request = async () => { @@ -68,6 +70,7 @@ it.each(['refused', 'rejected'])('restores the ordinary app after %s startup', a const request = async () => { throw new Error('No provider notice available') } + render( diff --git a/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx b/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx index 261f7b5dcf..84d37f365f 100644 --- a/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx +++ b/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx @@ -93,6 +93,7 @@ it.each(['first-run', 'settings', 'registry'])( 'stale probes and credential tests cannot authorize %s', async (host: RemoteSetupHost): Promise => { const probe: ReturnType> = deferred() + const tested: ReturnType>>> = deferred() @@ -102,11 +103,13 @@ it.each(['first-run', 'settings', 'registry'])( } satisfies Pick Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: bridge }) + const { result }: RenderHookResult, void> = renderHook((): ReturnType => useRemoteSetup({ host }) ) + act((): void => { result.current.setAuthMode('oauth') result.current.setUrl('https://a.example') diff --git a/apps/desktop/src/components/sync-status-card.test.tsx b/apps/desktop/src/components/sync-status-card.test.tsx index dd3889fa79..bb61f7f474 100644 --- a/apps/desktop/src/components/sync-status-card.test.tsx +++ b/apps/desktop/src/components/sync-status-card.test.tsx @@ -110,6 +110,7 @@ it.each(cases)( if (receipt?.plugin_checks?.some(row => row.needs_fixing)) { expect(screen.getByText('bad-url: update_url points at a fork')).toBeTruthy() } + expect(screen.getByText('grower: 1.0.0 → 1.1.0')).toBeTruthy() expect(screen.queryByText('fine')).toBeNull() expect(screen.queryByText('kept')).toBeNull() diff --git a/apps/desktop/src/components/version-details.test.tsx b/apps/desktop/src/components/version-details.test.tsx index 15b14347cf..de857f12ff 100644 --- a/apps/desktop/src/components/version-details.test.tsx +++ b/apps/desktop/src/components/version-details.test.tsx @@ -96,6 +96,7 @@ describe('VersionDetails', () => { const openExternal: Mock = vi .fn() .mockResolvedValue(undefined) + vi.stubGlobal('hermesDesktop', { openExternal } satisfies Pick) render( diff --git a/apps/desktop/src/store/local-models-surfaces.test.tsx b/apps/desktop/src/store/local-models-surfaces.test.tsx index 56f4652cff..60af35fe4e 100644 --- a/apps/desktop/src/store/local-models-surfaces.test.tsx +++ b/apps/desktop/src/store/local-models-surfaces.test.tsx @@ -419,6 +419,7 @@ it.each(['model-download', 'quickstart', 'runtime-install'] as const)( if (kind === 'quickstart') { expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull() } + fireEvent.click(screen.getByRole('button', { name: /resume/i })) await tick() expect(jobs[0].status).toBe('running') diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index bc1c6d80cd..87950e4e23 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -251,6 +251,7 @@ export function notify(input: NotificationInput): string { function logErrorToDesktopLog(error: unknown, fallback: string): void { try { const label: string = new URLSearchParams(window.location.search).get('win') ?? 'main' + const raw: string = error instanceof Error ? (error.stack ?? error.message) : typeof error === 'string' ? error : fallback diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 39251f5d7f..b94eebc3a8 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -498,6 +498,7 @@ export async function checkUpdates({ force = false }: UpdateCheckOptions = {}): if (status.retirement) { maybeNotifyDiscontinued(status.retirement) } + maybeNotifyUpdateAvailable(status, 'client') void refreshDesktopVersion() @@ -603,6 +604,7 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis if (result.updateAvailable === false) { return result } + notify({ durationMs: 8000, id: UPDATE_TOAST_ID, diff --git a/scripts/ci/desktop_build_cache.py b/scripts/ci/desktop_build_cache.py index c71f0a1521..4427c96f4d 100644 --- a/scripts/ci/desktop_build_cache.py +++ b/scripts/ci/desktop_build_cache.py @@ -35,8 +35,9 @@ _INPUT_FILES = ( "apps/desktop/scripts/build-hud-modifier-monitor.mjs", "apps/desktop/electron/native/command-screenshot-monitor.m", "apps/desktop/electron/native/hud-modifier-gesture.h", + "apps/desktop/electron/native/hud-modifier-gesture.cs", "apps/desktop/electron/native/hud-modifier-monitor.m", - "apps/desktop/electron/native/hud-modifier-monitor-win.c", + "apps/desktop/electron/native/hud-modifier-monitor-win.cs", "apps/desktop/electron/native/hud-modifier-monitor-x11.c", "apps/desktop/scripts/windows-bundle-tools.mjs", "apps/desktop/scripts/prepared-native-deps.mjs", "apps/desktop/scripts/prepared-packaging.mjs", "apps/desktop/scripts/prepare-dmgbuild.mjs",