Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/icons/ui/Add.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/icons/ui/Checkmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/icons/ui/ChevronRight.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/icons/ui/Dismiss.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/icons/ui/PuzzlePiece.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions docs/extension_host_profile_v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,44 @@ checks the copies offline. To verify the source as well, run
Updating the protocol requires updating the reviewed pin and matching copies;
an upstream draft change does not silently change the Host's accepted subset.

## Package icons

The Host accepts both `amadeus.extension/v0` and `amadeus.extension/v1`.
Existing v0 packages need no migration; v0 remains unchanged and does not allow
`icon`. A package opting into icons includes these fields alongside the required
metadata in `amadeus-extension.json`:

```json
{ "schema": "amadeus.extension/v1", "icon": "assets/icon.png" }
```

Include that file in the wheel's package data. Icons must be package-relative
PNG/JPEG files with matching signatures, at most 1 MiB and 1024×1024 pixels.
A square 256×256 image is recommended. Installation checks package containment,
file type and native decoding; no network URL or executable image format is accepted.
The UI displays the image in a 36 px tile without cropping. Existing packages
need no change: absent or unavailable images use the first displayed name
grapheme, uppercase for Latin names. A damaged image can fall back without hiding
the package's name or actions. Updates and uninstall resolve through the current
Host-owned installation; the renderer never supplies a path or reads package files.

This optional metadata does not change runtime permissions, configuration or the
Host API version. A v0-only Host rejects v1, even when the icon is omitted.
Existing Hosts validate the schema before `engines` and may report an invalid
manifest rather than an incompatible package; raising the Host API requirement
does not alter that old validation order. Authors must identify a v1-capable
minimum application version in distribution notes once this update is released.
The store's independently pinned checker must be requalified for v1 before
icon-bearing packages enter its catalog. The canonical contract is proposed in
[Amadeus-Extensions #4](https://github.com/Code-Amadeus/Amadeus-Extensions/pull/4)
with [design issue #3](https://github.com/Code-Amadeus/Amadeus-Extensions/issues/3).
Review and merge that protocol change before the dependent Host change; if its
accepted commit changes, update the Host source pin and recheck provenance.
After the protocol PR merges, fetch the canonical repository and verify that
`git merge-base --is-ancestor <pinned-commit> origin/main` succeeds, then run
`python tools/check_extension_protocol.py --source <canonical-checkout>`.
The offline hash check alone does not prove that a pin has reached canonical main.

## Evidence and explicit limits

Windows local source builds are the tested runtime target. MiMo English live acceptance
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/uiux-plugins/before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 6 additions & 7 deletions electron/scripts/smoke-startup-settings.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ app.whenReady().then(async () => {
console.log('PASS shared subtitle language persists offline and waits for live application without restart')
await win.webContents.executeJavaScript(`(() => {
const section = document.getElementById('settings-vision');
section.querySelector('button[aria-pressed]').click(); return true;
section.querySelector('button[role="switch"]').click(); return true;
})()`)
await until(`(async () => (await window.amadeus.getDesktopSettings()).values.AMADEUS_VISION_ENABLED === 'true')()`)
assert.equal(store.snapshot(environment).values.AMADEUS_VISION_MODE, 'watching')
Expand All @@ -116,7 +116,7 @@ app.whenReady().then(async () => {
['AEC_REALTIME_BARGE_IN', 'Allow microphone interruption'],
]) {
const expected = store.backendEnvironment(environment)[key] === 'true'
assert.equal(await win.webContents.executeJavaScript(`document.querySelector('select[aria-label="${label}"]').value`), String(expected))
assert.equal(await win.webContents.executeJavaScript(`document.querySelector('button[role="switch"][aria-label="${label}"]').getAttribute('aria-checked')`), String(expected))
}
assert.equal(store.backendEnvironment(environment).AEC_REALTIME_DELAY_MS, undefined)
console.log('PASS offline Wake/AEC controls agree with desktop launch defaults and preserve automatic delay selection')
Expand Down Expand Up @@ -168,23 +168,22 @@ app.whenReady().then(async () => {
assert.equal(new DesktopSettingsStore(file, '').backendEnvironment({})[key], value)
}
await win.webContents.executeJavaScript(`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Graphics').click(); true`)
await until(`document.querySelector('select[aria-label="Sample animation textures"]')?.value === 'true'`)
await until(`document.querySelector('button[role="switch"][aria-label="Sample animation textures"]')?.getAttribute('aria-checked') === 'true'`)
await win.webContents.executeJavaScript(`(() => {
const select = document.querySelector('select[aria-label="Graphics profile"]'); select.value = 'power_saving';
select.dispatchEvent(new Event('change', { bubbles: true })); return true;
})()`)
await until(`document.querySelector('select[aria-label="Sample animation textures"]')?.value === 'false'`)
await until(`document.querySelector('button[role="switch"][aria-label="Sample animation textures"]')?.getAttribute('aria-checked') === 'false'`)
await win.webContents.executeJavaScript(`(() => {
const select = document.querySelector('select[aria-label="Sample animation textures"]'); select.value = 'true';
select.dispatchEvent(new Event('change', { bubbles: true })); return true;
document.querySelector('button[role="switch"][aria-label="Sample animation textures"]').click(); return true;
})()`)
await until(`(async () => (await window.amadeus.getDesktopSettings()).values.RENDER_TEXTURE_SAMPLING === 'true')()`)
await win.webContents.executeJavaScript(`(() => {
const select = document.querySelector('select[aria-label="Graphics profile"]'); select.value = 'custom';
select.dispatchEvent(new Event('change', { bubbles: true })); return true;
})()`)
await until(`Boolean(document.querySelector('input[aria-label="Frame-rate limit"]'))`)
assert.equal(await win.webContents.executeJavaScript(`document.querySelector('select[aria-label="Sample animation textures"]').value`), 'true')
assert.equal(await win.webContents.executeJavaScript(`document.querySelector('button[role="switch"][aria-label="Sample animation textures"]').getAttribute('aria-checked')`), 'true')
console.log('PASS packaged MiMo/OpenAI controls and graphics automatic/explicit settings work offline')
environment.FISH_TTS_MODEL = 'locked-environment-model'
await win.webContents.executeJavaScript(`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim().endsWith('Chat')).click(); true`)
Expand Down
49 changes: 49 additions & 0 deletions electron/src/main/extensionImages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import fs from 'node:fs'
import path from 'node:path'
import { nativeImage } from 'electron'
import { assertExtensionPath } from './extensionPaths.js'

/** Bound the declared canvas before asking the native decoder to allocate it. */
export function imageDimensions(bytes: Buffer, mime: string, label = 'Extension image'): { width: number; height: number } {
if (mime === 'image/png' && bytes.length >= 24 && bytes.toString('ascii', 12, 16) === 'IHDR') {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }
}
if (mime === 'image/jpeg') {
let offset = 2
while (offset + 4 <= bytes.length && bytes[offset] === 255) {
while (bytes[offset] === 255) offset++
const marker = bytes[offset++]
if (marker === 217 || marker === 218) break
if (marker === 1 || marker >= 208 && marker <= 215) continue
if (offset + 2 > bytes.length) break
const length = bytes.readUInt16BE(offset)
if (length < 2 || offset + length > bytes.length) break
if ([192,193,194,195,197,198,199,201,202,203,205,206,207].includes(marker) && length >= 8) {
return { width: bytes.readUInt16BE(offset + 5), height: bytes.readUInt16BE(offset + 3) }
}
offset += length
}
}
throw new Error(`${label}: invalid image header`)
}

/** Read only a bounded, decoded package image; renderer callers never supply a filesystem path. */
export function readExtensionImage(packagePath: string, relative: string,
limits: { label: string; maxMiB: number; maxDimension: number }): { mime: string; base64: string } {
const { label, maxMiB, maxDimension } = limits
if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*(\/[A-Za-z0-9_][A-Za-z0-9._-]*)*$/.test(relative)) throw new Error(`${label}: invalid package-relative path`)
const imagePath = path.join(packagePath, relative)
assertExtensionPath(packagePath, imagePath)
const stat = fs.statSync(imagePath)
if (!stat.isFile() || stat.size > maxMiB * 1024 * 1024) throw new Error(`${label}: image exceeds ${maxMiB} MiB or is not a regular file`)
const bytes = fs.readFileSync(imagePath)
const extension = path.extname(imagePath).toLowerCase()
const mime = extension === '.png' && bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) ? 'image/png'
: ['.jpg', '.jpeg'].includes(extension) && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 ? 'image/jpeg' : ''
if (!mime) throw new Error(`${label}: use a PNG or JPEG image with matching bytes`)
const declared = imageDimensions(bytes, mime, label)
if (declared.width < 1 || declared.height < 1 || declared.width > maxDimension || declared.height > maxDimension) throw new Error(`${label}: dimensions must be 1–${maxDimension} pixels`)
const decoded = nativeImage.createFromBuffer(bytes), size = decoded.getSize()
if (decoded.isEmpty() || size.width < 1 || size.height < 1 || size.width > maxDimension || size.height > maxDimension) throw new Error(`${label}: decoded dimensions must be 1–${maxDimension} pixels`)
return { mime, base64: bytes.toString('base64') }
}
46 changes: 4 additions & 42 deletions electron/src/main/extensionThemes.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,9 @@
import fs from 'node:fs'
import path from 'node:path'
import { nativeImage } from 'electron'
import { assertExtensionPath } from './extensionPaths.js'
import { readExtensionImage } from './extensionImages.js'
import { validateThemePalette, type ThemePalette, type AppliedTheme } from '../shared/extensionTheme.js'
import type { ExtensionManifest } from '../shared/extensions.js'

export type PreparedTheme = ThemePalette & { image?: AppliedTheme['image'] }

/** Bound the declared canvas before asking the native decoder to allocate it. */
export function imageDimensions(bytes: Buffer, mime: string): { width: number; height: number } {
if (mime === 'image/png' && bytes.length >= 24 && bytes.toString('ascii', 12, 16) === 'IHDR') {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }
}
if (mime === 'image/jpeg') {
let offset = 2
while (offset + 4 <= bytes.length && bytes[offset] === 255) {
while (bytes[offset] === 255) offset++
const marker = bytes[offset++]
if (marker === 217 || marker === 218) break
if (marker === 1 || marker >= 208 && marker <= 215) continue
if (offset + 2 > bytes.length) break
const length = bytes.readUInt16BE(offset)
if (length < 2 || offset + length > bytes.length) break
if ([192,193,194,195,197,198,199,201,202,203,205,206,207].includes(marker) && length >= 8) {
return { width: bytes.readUInt16BE(offset + 5), height: bytes.readUInt16BE(offset + 3) }
}
offset += length
}
}
throw new Error('Theme background.image: invalid image header')
}

export class ExtensionThemeResources {
constructor(private readonly installedFonts: () => Promise<string[]> = async () => []) {}

Expand All @@ -42,20 +15,9 @@ export class ExtensionThemeResources {
for (const theme of themes) {
const prepared: PreparedTheme = validateThemePalette(theme, fonts)
if (packagePath && prepared.background) {
const imagePath = path.join(packagePath, prepared.background.image)
assertExtensionPath(packagePath, imagePath)
const stat = fs.statSync(imagePath)
if (!stat.isFile() || stat.size > 8 * 1024 * 1024) throw new Error('Theme background.image: image exceeds 8 MiB or is not a regular file')
const bytes = fs.readFileSync(imagePath)
const extension = path.extname(imagePath).toLowerCase()
const mime = extension === '.png' && bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) ? 'image/png'
: ['.jpg', '.jpeg'].includes(extension) && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 ? 'image/jpeg' : ''
if (!mime) throw new Error('Theme background.image: use a PNG or JPEG image with matching bytes')
const declared = imageDimensions(bytes, mime)
if (declared.width < 1 || declared.height < 1 || declared.width > 4096 || declared.height > 4096) throw new Error('Theme background.image: dimensions must be 1–4096 pixels')
const decoded = nativeImage.createFromBuffer(bytes), size = decoded.getSize()
if (decoded.isEmpty() || size.width < 1 || size.height < 1 || size.width > 4096 || size.height > 4096) throw new Error('Theme background.image: decoded dimensions must be 1–4096 pixels')
if (selectedId !== undefined) prepared.image = { mime, base64: bytes.toString('base64') }
const image = readExtensionImage(packagePath, prepared.background.image,
{ label: 'Theme background.image', maxMiB: 8, maxDimension: 4096 })
if (selectedId !== undefined) prepared.image = image
}
result[theme.id] = prepared
}
Expand Down
16 changes: 16 additions & 0 deletions electron/src/main/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { bundledExtensionSdk } from './extensionSdk.js'
import { DesktopSettingsStore, EXTENSIONS_ENV, type StoredExtension } from './desktopSettings.js'
import type { ExtensionConfigValue, ExtensionManifest, ExtensionRemovalOptions, ExtensionUpdate, ExtensionConfirmation } from '../shared/extensions.js'
import { ExtensionThemeResources } from './extensionThemes.js'
import { readExtensionImage } from './extensionImages.js'
import { startupValue } from '../shared/startupSettings.js'
import type { DesktopThemeState } from '../shared/extensionTheme.js'
import { extensionNeedsAgent, extensionRequiresRestart } from '../shared/extensionProfile.js'
Expand Down Expand Up @@ -316,6 +317,7 @@ export class ExtensionInstaller {
if (JSON.stringify(installedManifest) !== JSON.stringify(manifest)) throw new Error('The installed extension manifest differs from the inspected wheel')
await this.helper.call('validate-installed', [packagePath], { manifest })
await this.themes.validate(manifest, packagePath)
if (manifest.icon) readExtensionImage(packagePath, manifest.icon, { label: 'Package icon', maxMiB: 1, maxDimension: 1024 })
// A replacement is prepared beside the running version. Only a complete
// validated installation replaces desired state; old code is collected
// after confirmed backend stop, using the existing ownership markers.
Expand Down Expand Up @@ -505,6 +507,20 @@ export class ExtensionInstaller {
})
}

icon(id: string): string | null {
const record = this.store.extensionRecords().find(item => item.id === id)
if (!record?.manifest.icon) return null
try {
const image = readExtensionImage(this.packagePath(record), record.manifest.icon,
{ label: 'Package icon', maxMiB: 1, maxDimension: 1024 })
return `data:${image.mime};base64,${image.base64}`
} catch {
// Cosmetic resources do not grant authority or block the package card.
console.warn('[extensions] Package icon unavailable:', record.id)
return null
}
}

private packagePath(record: StoredExtension): string {
const directory = path.join(this.root, record.installId)
const packagePath = record.sitePackages === '' ? path.join(directory, 'package', record.packageRoot)
Expand Down
4 changes: 4 additions & 0 deletions electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2218,6 +2218,10 @@ ipcMain.handle('mcp-connections.remove', (event, connectionId: string) => {
}
}
})
ipcMain.handle('extensions.icon', (event, id: string) => {
if (!isTrustedBackendRenderer(event.sender) || typeof id !== 'string') return null
return extensionInstaller.icon(id)
})
ipcMain.handle('extensions.install', async (event) => {
if (!isTrustedBackendRenderer(event.sender)) return { ok: false, error: 'Untrusted extension requester.' }
const options: Electron.OpenDialogOptions = { title: 'Install Amadeus extension', properties: ['openFile'], filters: [{ name: 'Python wheel', extensions: ['whl'] }] }
Expand Down
1 change: 1 addition & 0 deletions electron/src/preload/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ contextBridge.exposeInMainWorld('amadeus', {
clearEnvironment?: boolean
}): Promise<{ ok: boolean; error?: string; settings?: Record<string, unknown> }> => ipcRenderer.invoke('mcp-connections.upsert', update),
removeMcpConnection: (connectionId: string): Promise<{ ok: boolean; error?: string; settings?: Record<string, unknown> }> => ipcRenderer.invoke('mcp-connections.remove', connectionId),
getExtensionIcon: (id: string): Promise<string | null> => ipcRenderer.invoke('extensions.icon', id),
installExtension: (): Promise<ExtensionMutationResult> => ipcRenderer.invoke('extensions.install'),
updateExtension: (update: ExtensionUpdate): Promise<ExtensionMutationResult> => ipcRenderer.invoke('extensions.update', update),
removeExtension: (id: string, options?: ExtensionRemovalOptions): Promise<ExtensionMutationResult> => ipcRenderer.invoke('extensions.remove', id, options),
Expand Down
Loading
Loading