wip fixin stuff

This commit is contained in:
ethernet
2026-09-21 18:19:03 -04:00
parent fc4b1958dc
commit f999b5ecd2
43 changed files with 111 additions and 3 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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 {

View File

@@ -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`

View File

@@ -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', {

View File

@@ -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')

View File

@@ -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<typeof load> = load(first)
const b: ReturnType<typeof load> = 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)

View File

@@ -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)

View File

@@ -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') {

View File

@@ -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 })
}
}

View File

@@ -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<ResolvedHerm
const activeBackend: SourceBackend | null = await resolveSourceInstallationBackend(ACTIVE_HERMES_ROOT, backendArgs, {
hermesHome: HERMES_HOME
})
const activeRuntime: ActiveRuntimeState = activeRuntimeState(activeBackend)
if (activeBackend && !bootstrapRepairRequested) {
@@ -11495,6 +11497,7 @@ async function spawnPoolBackend(
const cancelRequest = (): void => {
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<void> => backendShutdown.run(), waitForCompletion: backendNeedsWait }
]

View File

@@ -153,6 +153,7 @@ export function isUnderInstallRoot(
.replace(/[\\/]+$/, '')
.replace(/\//g, '\\')
.toLowerCase()
const normalizedPath = normalize(imagePath)
const candidates = typeof roots === 'string' || roots == null ? [roots] : roots

View File

@@ -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')
})

View File

@@ -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')

View File

@@ -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)

View File

@@ -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)) {

View File

@@ -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)
}

View File

@@ -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 = {

View File

@@ -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')

View File

@@ -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 })) {

View File

@@ -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) {

View File

@@ -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') {

View File

@@ -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 {

View File

@@ -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<void>((resolve): void => {
enteredResolve = resolve
})
const release = new Promise<void>((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`

View File

@@ -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}/`)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -90,6 +90,7 @@ describe('macOS strategy', () => {
deps.verifyDownload = async (): Promise<void> => {
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')

View File

@@ -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

View File

@@ -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<RelaunchWaiterHandle | undefined> = new Promise(
(resolve: (handle: RelaunchWaiterHandle | undefined) => void): void => {
resolveReady = resolve
}
)
let registered: boolean = false
const pending: Promise<RelaunchRegistration> = registerUpdateRelaunch(canary, 'canary-old', {

View File

@@ -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) {

View File

@@ -191,6 +191,7 @@ export async function startRelaunchWaiter(
if (settled) {
return
}
settled = true
clearTimeout(timer)
resolve(ready)

View File

@@ -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)

View File

@@ -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')
}

View File

@@ -19,10 +19,12 @@ afterEach((): void => {
test('discontinued retirement shows the uninstall notice and persists dismissal per revision', async (): Promise<void> => {
const dismissed: string[] = []
const stored = new Map<string, string>()
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)

View File

@@ -567,6 +567,7 @@ it.each([
if (local === undefined && state.setupChoice) {
Reflect.deleteProperty(state.setupChoice, 'local')
}
installDesktopMock(state)
render(<DesktopInstallOverlay />)
expect(await screen.findByText(title)).toBeTruthy()

View File

@@ -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<boolean>(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(
<I18nProvider>
<OnboardingChatGate enabled onKickoff={kickoff} requestGateway={request} />

View File

@@ -93,6 +93,7 @@ it.each<RemoteSetupHost>(['first-run', 'settings', 'registry'])(
'stale probes and credential tests cannot authorize %s',
async (host: RemoteSetupHost): Promise<void> => {
const probe: ReturnType<typeof deferred<DesktopConnectionProbeResult>> = deferred<DesktopConnectionProbeResult>()
const tested: ReturnType<typeof deferred<Awaited<ReturnType<Window['hermesDesktop']['testConnectionConfig']>>>> =
deferred()
@@ -102,11 +103,13 @@ it.each<RemoteSetupHost>(['first-run', 'settings', 'registry'])(
} satisfies Pick<Window['hermesDesktop'], 'probeConnectionConfig' | 'testConnectionConfig'>
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: bridge })
const {
result
}: RenderHookResult<ReturnType<typeof useRemoteSetup>, void> = renderHook((): ReturnType<typeof useRemoteSetup> =>
useRemoteSetup({ host })
)
act((): void => {
result.current.setAuthMode('oauth')
result.current.setUrl('https://a.example')

View File

@@ -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()

View File

@@ -96,6 +96,7 @@ describe('VersionDetails', () => {
const openExternal: Mock<Window['hermesDesktop']['openExternal']> = vi
.fn<Window['hermesDesktop']['openExternal']>()
.mockResolvedValue(undefined)
vi.stubGlobal('hermesDesktop', { openExternal } satisfies Pick<Window['hermesDesktop'], 'openExternal'>)
render(

View File

@@ -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')

View File

@@ -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

View File

@@ -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,

View File

@@ -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",