diff --git a/assets/icons/ui/Add.svg b/assets/icons/ui/Add.svg new file mode 100644 index 00000000..3dc1703a --- /dev/null +++ b/assets/icons/ui/Add.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ui/Checkmark.svg b/assets/icons/ui/Checkmark.svg new file mode 100644 index 00000000..e3008e68 --- /dev/null +++ b/assets/icons/ui/Checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ui/ChevronRight.svg b/assets/icons/ui/ChevronRight.svg new file mode 100644 index 00000000..5361cf55 --- /dev/null +++ b/assets/icons/ui/ChevronRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ui/Dismiss.svg b/assets/icons/ui/Dismiss.svg new file mode 100644 index 00000000..03062cf7 --- /dev/null +++ b/assets/icons/ui/Dismiss.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ui/PuzzlePiece.svg b/assets/icons/ui/PuzzlePiece.svg new file mode 100644 index 00000000..de9bdbb4 --- /dev/null +++ b/assets/icons/ui/PuzzlePiece.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/extension_host_profile_v0.1.md b/docs/extension_host_profile_v0.1.md index 0a359215..c85c162a 100644 --- a/docs/extension_host_profile_v0.1.md +++ b/docs/extension_host_profile_v0.1.md @@ -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 origin/main` succeeds, then run +`python tools/check_extension_protocol.py --source `. +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 diff --git a/docs/screenshots/uiux-plugins/after-dark-zh-narrow.png b/docs/screenshots/uiux-plugins/after-dark-zh-narrow.png new file mode 100644 index 00000000..af03fd84 Binary files /dev/null and b/docs/screenshots/uiux-plugins/after-dark-zh-narrow.png differ diff --git a/docs/screenshots/uiux-plugins/after-light-en.png b/docs/screenshots/uiux-plugins/after-light-en.png new file mode 100644 index 00000000..e307fc4e Binary files /dev/null and b/docs/screenshots/uiux-plugins/after-light-en.png differ diff --git a/docs/screenshots/uiux-plugins/before.png b/docs/screenshots/uiux-plugins/before.png new file mode 100644 index 00000000..6d94cca9 Binary files /dev/null and b/docs/screenshots/uiux-plugins/before.png differ diff --git a/electron/scripts/smoke-startup-settings.cjs b/electron/scripts/smoke-startup-settings.cjs index af43a79e..f8ff47cc 100644 --- a/electron/scripts/smoke-startup-settings.cjs +++ b/electron/scripts/smoke-startup-settings.cjs @@ -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') @@ -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') @@ -168,15 +168,14 @@ 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(`(() => { @@ -184,7 +183,7 @@ app.whenReady().then(async () => { 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`) diff --git a/electron/src/main/extensionImages.ts b/electron/src/main/extensionImages.ts new file mode 100644 index 00000000..9f0c2988 --- /dev/null +++ b/electron/src/main/extensionImages.ts @@ -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') } +} diff --git a/electron/src/main/extensionThemes.ts b/electron/src/main/extensionThemes.ts index 53682117..4f3bd66a 100644 --- a/electron/src/main/extensionThemes.ts +++ b/electron/src/main/extensionThemes.ts @@ -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 = async () => []) {} @@ -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 } diff --git a/electron/src/main/extensions.ts b/electron/src/main/extensions.ts index dd57c8c9..d29b43c3 100644 --- a/electron/src/main/extensions.ts +++ b/electron/src/main/extensions.ts @@ -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' @@ -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. @@ -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) diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 221931e8..2cace70e 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -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'] }] } diff --git a/electron/src/preload/index.mts b/electron/src/preload/index.mts index d3110e33..1a120ae8 100644 --- a/electron/src/preload/index.mts +++ b/electron/src/preload/index.mts @@ -51,6 +51,7 @@ contextBridge.exposeInMainWorld('amadeus', { clearEnvironment?: boolean }): Promise<{ ok: boolean; error?: string; settings?: Record }> => ipcRenderer.invoke('mcp-connections.upsert', update), removeMcpConnection: (connectionId: string): Promise<{ ok: boolean; error?: string; settings?: Record }> => ipcRenderer.invoke('mcp-connections.remove', connectionId), + getExtensionIcon: (id: string): Promise => ipcRenderer.invoke('extensions.icon', id), installExtension: (): Promise => ipcRenderer.invoke('extensions.install'), updateExtension: (update: ExtensionUpdate): Promise => ipcRenderer.invoke('extensions.update', update), removeExtension: (id: string, options?: ExtensionRemovalOptions): Promise => ipcRenderer.invoke('extensions.remove', id, options), diff --git a/electron/src/renderer/App.tsx b/electron/src/renderer/App.tsx index bb81b604..c0774855 100644 --- a/electron/src/renderer/App.tsx +++ b/electron/src/renderer/App.tsx @@ -12,7 +12,7 @@ import { stopElectronSliceHost, syncElectronSliceHost } from './wallpaperSlice' import { createProjectionController } from './projectionController' import appIconUrl from '@assets/icons/app/app_icon.png' -export type Page = 'chat' | 'vn' | 'backend' | 'settings' | 'plugins' +export type Page = 'chat' | 'vn' | 'settings' | 'plugins' const WORK_FOCUS_RUN_KEY = 'amadeus.work.focusRunId' const WORK_FOCUS_ACTION_KEY = 'amadeus.work.focusAction' @@ -21,7 +21,7 @@ const WORK_FOCUS_CWD_KEY = 'amadeus.work.focusCwd' function initialPage(): Page { const page = new URLSearchParams(window.location.search).get('page') - if (page === 'vn' || page === 'backend' || page === 'settings' || page === 'plugins') { + if (page === 'vn' || page === 'settings' || page === 'plugins') { return page } return 'chat' @@ -101,16 +101,6 @@ function AmadeusApp() { void projections.toggle('wallpaper').catch(error => console.error('[wallpaper]', error)) }, [projections]) - // Listen for render projection changes from the model bar. - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail - if (detail === 'toggle-render') handleToggleRender() - } - window.addEventListener('navigate', handler) - return () => window.removeEventListener('navigate', handler) - }, [handleToggleRender]) - useEffect(() => { if (desktopProjection) return const unsubReady = subscribe('wallpaper.ready', projections.ready) @@ -244,14 +234,15 @@ function AmadeusApp() {
- {page === 'chat' && } + {page === 'chat' && } {page === 'vn' && } - {page === 'backend' && } {(page === 'settings' || page === 'plugins') && } send={send} subscribe={subscribe} connected={connected} reconnectBackend={reconnect} onNavigationGuardChange={registerSettingsGuard} onOpenPlugins={() => handleNavigate('plugins')} onOpenSettings={section => { if (settingsNavigationGuard.current && !settingsNavigationGuard.current()) return diff --git a/electron/src/renderer/activeCharacter.tsx b/electron/src/renderer/activeCharacter.tsx index 125751eb..4abe518f 100644 --- a/electron/src/renderer/activeCharacter.tsx +++ b/electron/src/renderer/activeCharacter.tsx @@ -40,3 +40,8 @@ export function useActiveCharacterSnapshot(connected: boolean, send: Send) { export function useActiveCharacter() { return useContext(ActiveCharacterContext) } + +/** Avatar fallback for the assistant: the active role's first character, or A for Amadeus. */ +export function characterInitial(character: ActiveCharacter | null): string { + return Array.from(character?.short_name || '')[0] || 'A' +} diff --git a/electron/src/renderer/components/AcpProviders.tsx b/electron/src/renderer/components/AcpProviders.tsx index 9692065f..a266e81b 100644 --- a/electron/src/renderer/components/AcpProviders.tsx +++ b/electron/src/renderer/components/AcpProviders.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useI18n } from '../i18n' import FluentIcon from './FluentIcon' import { SettingsButton, SettingsField, StatusPill } from './SettingsPrimitives' +import Switch from './Switch' import { canLeaveSettingsEditors, DISCARD_CONNECTION_CHANGES, type SettingsEditorState } from './settingsDraft' type Profile = { @@ -118,7 +119,7 @@ export default function AcpProviders({ encoded, locked, electronUnavailable = fa