wip fixin stuff
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
|
||||
@@ -153,6 +153,7 @@ export function isUnderInstallRoot(
|
||||
.replace(/[\\/]+$/, '')
|
||||
.replace(/\//g, '\\')
|
||||
.toLowerCase()
|
||||
|
||||
const normalizedPath = normalize(imagePath)
|
||||
const candidates = typeof roots === 'string' || roots == null ? [roots] : roots
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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}/`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -191,6 +191,7 @@ export async function startRelaunchWaiter(
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(ready)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user