diff --git a/UI-DESIGN.md b/UI-DESIGN.md index e657976e..c84b588b 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -298,6 +298,13 @@ 失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时 显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠 颜色或动效表达状态。 +- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只 + 影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品 + 身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。 +- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅 + 接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不 + 超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时 + 单行截断,不能撑宽或挤压侧栏。 - 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。 ### 6.9 助手工作栏 diff --git a/package-lock.json b/package-lock.json index 80dc98f8..50c0ca1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.11.5", + "version": "0.11.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.11.5", + "version": "0.11.6", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index cead8102..66444a8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.11.5", + "version": "0.11.6", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/src/main/file-media-type.ts b/src/main/file-media-type.ts index ad76e0c0..c66a8f91 100644 --- a/src/main/file-media-type.ts +++ b/src/main/file-media-type.ts @@ -1,4 +1,5 @@ import { extname } from 'node:path' +export { detectSupportedImage } from '../shared/image-media-type' const mimeTypes: Readonly> = { '.c': 'text/x-c', @@ -40,33 +41,3 @@ export function mimeTypeFromFileName( ): string { return mimeTypes[extname(name).toLocaleLowerCase()] ?? fallback } - -export function detectSupportedImage(data: Buffer): { - extension: 'jpg' | 'png' | 'webp' - mimeType: 'image/jpeg' | 'image/png' | 'image/webp' -} { - if ( - data.byteLength >= 3 && - data[0] === 0xff && - data[1] === 0xd8 && - data[2] === 0xff - ) { - return { extension: 'jpg', mimeType: 'image/jpeg' } - } - if ( - data.byteLength >= 8 && - data.subarray(0, 8).equals( - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - ) - ) { - return { extension: 'png', mimeType: 'image/png' } - } - if ( - data.byteLength >= 12 && - data.subarray(0, 4).toString('ascii') === 'RIFF' && - data.subarray(8, 12).toString('ascii') === 'WEBP' - ) { - return { extension: 'webp', mimeType: 'image/webp' } - } - throw new Error('图片格式不受支持') -} diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 492dbb56..f408eb3c 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -18,6 +18,10 @@ import type { } from '../../shared/contracts' import type { ApplicationSettings } from '../../shared/application-settings-contracts' import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut' +import { + loadBrandingPreferences, + saveBrandingPreferences +} from './branding' import type { AssistantProject, AssistantSchedule, @@ -1343,6 +1347,33 @@ describe('App', () => { ).not.toBeInTheDocument() }) + it('loads customized branding into the primary sidebar', async () => { + expect( + saveBrandingPreferences({ + name: '研发伙伴', + subtitleZhCN: '团队工作台', + subtitleEnUS: 'Team Workspace', + logoDataUrl: 'data:image/png;base64,iVBORw0KGgo=' + }) + ).toBe(true) + + render() + + await screen.findByRole('heading', { + name: '今天想一起完成什么?' + }) + const brand = document.querySelector('.brand') + expect(brand).not.toBeNull() + expect(within(brand as HTMLElement).getByText('研发伙伴')) + .toBeInTheDocument() + expect(within(brand as HTMLElement).getByText('团队工作台')) + .toBeInTheDocument() + expect(brand?.querySelector('img')).toHaveAttribute( + 'src', + 'data:image/png;base64,iVBORw0KGgo=' + ) + }) + it('renders the unified user and Scheduled Task queue above the Composer', async () => { const conversationId = '00000000-0000-4000-8000-000000000951' @@ -1751,6 +1782,41 @@ describe('App', () => { }) }) + it('saves branding from Appearance and updates the sidebar immediately', async () => { + render() + + fireEvent.click( + await screen.findByRole('button', { + name: /本地工作区/u + }) + ) + fireEvent.click( + await screen.findByRole('tab', { name: '外观' }) + ) + fireEvent.change(screen.getByLabelText('品牌名称'), { + target: { value: '研发伙伴' } + }) + fireEvent.change(screen.getByLabelText('中文副标题'), { + target: { value: '团队工作台' } + }) + fireEvent.click( + screen.getByRole('button', { name: '保存品牌设置' }) + ) + + expect(await screen.findByText('品牌设置已保存')) + .toBeInTheDocument() + const brand = document.querySelector('.brand') + expect(brand).not.toBeNull() + expect(within(brand as HTMLElement).getByText('研发伙伴')) + .toBeInTheDocument() + expect(within(brand as HTMLElement).getByText('团队工作台')) + .toBeInTheDocument() + expect(loadBrandingPreferences()).toMatchObject({ + name: '研发伙伴', + subtitleZhCN: '团队工作台' + }) + }) + it('preloads a heavy route once across repeated pointer and focus intent', async () => { render() const activity = await screen.findByRole('button', { @@ -8710,7 +8776,7 @@ describe('App', () => { expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0) expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute( 'placeholder', - '描述你想生成的图片…' + '描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本' ) await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 660d283d..ca14ced5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -180,6 +180,11 @@ import type { SettingsLeaveRequester } from './SettingsPanel' import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut' import goodbuddyDarkIcon from './assets/goodbuddy-dark.png' import goodbuddyLightIcon from './assets/goodbuddy-light.png' +import { + loadBrandingPreferences, + saveBrandingPreferences +} from './branding' +import { BrandLockup } from './BrandLockup' import { applyAppearanceTheme, loadAppearanceTheme, @@ -2084,6 +2089,9 @@ function App(): React.JSX.Element { const [runtimeSwitching, setRuntimeSwitching] = useState(false) const [appearanceTheme, setAppearanceTheme] = useState(loadAppearanceTheme) + const [brandingPreferences, setBrandingPreferences] = useState( + loadBrandingPreferences + ) const [systemPrefersDark, setSystemPrefersDark] = useState( () => typeof window.matchMedia === 'function' && @@ -2093,6 +2101,10 @@ function App(): React.JSX.Element { appearanceTheme, systemPrefersDark ) + const brandingSubtitle = + locale === 'en-US' + ? brandingPreferences.subtitleEnUS + : brandingPreferences.subtitleZhCN const toggleAppearanceTheme = useCallback((): void => { setAppearanceTheme( resolvedAppearanceTheme === 'dark' ? 'light' : 'dark' @@ -7902,23 +7914,19 @@ function App(): React.JSX.Element { ref={sidebarRef} role={narrowWindow && sidebarOpen ? 'dialog' : undefined} > -
-
- -
-
- GoodBuddy - {t('brand.subtitle')} -
-
+ { + if (!saveBrandingPreferences(preferences)) { + return false + } + setBrandingPreferences(preferences) + notify({ + tone: 'success', + message: t('notices.brandingSaved'), + dedupeKey: 'branding-saved' + }) + return true + }} onClearLocalData={clearLocalData} onClose={() => { setSettingsInitialCategory(undefined) diff --git a/src/renderer/src/BrandLockup.tsx b/src/renderer/src/BrandLockup.tsx new file mode 100644 index 00000000..34c9e740 --- /dev/null +++ b/src/renderer/src/BrandLockup.tsx @@ -0,0 +1,33 @@ +type BrandLockupProps = { + ariaLabel?: string + className: string + copyClassName: string + logo?: string + markClassName: string + name: string + role?: 'img' + subtitle?: string +} + +export function BrandLockup({ + ariaLabel, + className, + copyClassName, + logo, + markClassName, + name, + role, + subtitle +}: BrandLockupProps): React.JSX.Element { + return ( +
+
+ {logo && } +
+
+ {name} + {subtitle && {subtitle}} +
+
+ ) +} diff --git a/src/renderer/src/BrandingSettingsSection.tsx b/src/renderer/src/BrandingSettingsSection.tsx new file mode 100644 index 00000000..ffe7983c --- /dev/null +++ b/src/renderer/src/BrandingSettingsSection.tsx @@ -0,0 +1,268 @@ +import { ImagePlus, RotateCcw, Save, Trash2 } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + brandingLogoMaximumBytes, + brandingNameMaximumLength, + brandingPreferencesEqual, + brandingSubtitleMaximumLength, + defaultBrandingPreferences, + isValidBrandingLogoDataUrl, + normalizeBrandingPreferences, + type BrandingPreferences +} from './branding' +import { BrandLockup } from './BrandLockup' +import { + loadImageDimensions, + readFileAsDataUrl +} from './file-data-url' + +type BrandingSettingsSectionProps = { + preferences: BrandingPreferences + fallbackLogo?: string + onChange: (preferences: BrandingPreferences) => boolean + onDirtyChange?: (dirty: boolean) => void +} + +export function BrandingSettingsSection({ + preferences, + fallbackLogo, + onChange, + onDirtyChange +}: BrandingSettingsSectionProps): React.JSX.Element { + const { i18n, t } = useTranslation('settings') + const [draft, setDraft] = useState(preferences) + const [error, setError] = useState() + const fileInputRef = useRef(null) + const logoReadRef = useRef(null) + const dirty = !brandingPreferencesEqual(draft, preferences) + + useEffect(() => { + onDirtyChange?.(dirty) + }, [dirty, onDirtyChange]) + + useEffect( + () => () => { + logoReadRef.current?.abort() + }, + [] + ) + + const save = (): void => { + const normalized = normalizeBrandingPreferences(draft) + if (!onChange(normalized)) { + setError(t('appearance.branding.errors.saveFailed')) + return + } + setDraft(normalized) + setError(undefined) + } + + const selectLogo = async (file: File | undefined): Promise => { + logoReadRef.current?.abort() + if (!file) { + return + } + if ( + !['image/png', 'image/jpeg', 'image/webp'].includes(file.type) + ) { + setError(t('appearance.branding.errors.unsupportedLogo')) + return + } + if (file.size === 0 || file.size > brandingLogoMaximumBytes) { + setError(t('appearance.branding.errors.logoTooLarge')) + return + } + const controller = new AbortController() + logoReadRef.current = controller + try { + const dataUrl = await readFileAsDataUrl( + file, + file.type, + controller.signal + ) + if (!isValidBrandingLogoDataUrl(dataUrl)) { + setError(t('appearance.branding.errors.invalidLogo')) + return + } + const { width, height } = await loadImageDimensions( + dataUrl, + controller.signal + ) + if ( + width <= 0 || + height <= 0 || + width > 4_096 || + height > 4_096 || + width * height > 16_000_000 + ) { + setError(t('appearance.branding.errors.invalidLogo')) + return + } + if (logoReadRef.current !== controller) { + return + } + setDraft((current) => ({ + ...current, + logoDataUrl: dataUrl + })) + setError(undefined) + } catch { + if (!controller.signal.aborted) { + setError(t('appearance.branding.errors.readLogoFailed')) + } + } finally { + if (logoReadRef.current === controller) { + logoReadRef.current = null + } + } + } + + const previewSubtitle = + (i18n.resolvedLanguage === 'en-US' + ? draft.subtitleEnUS + : draft.subtitleZhCN) || undefined + const previewLogo = draft.logoDataUrl ?? fallbackLogo + + return ( +
+
+
+ + + +
+ + + +
+ +
+ { + void selectLogo(event.target.files?.[0]) + event.target.value = '' + }} + ref={fileInputRef} + type="file" + /> + + {draft.logoDataUrl && ( + + )} + {t('appearance.branding.logo.help')} +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ ) +} diff --git a/src/renderer/src/FeedbackDialog.tsx b/src/renderer/src/FeedbackDialog.tsx index ca6e021b..9001ead1 100644 --- a/src/renderer/src/FeedbackDialog.tsx +++ b/src/renderer/src/FeedbackDialog.tsx @@ -24,6 +24,7 @@ import { type FeedbackSubmissionErrorCode } from '../../shared/feedback-contracts' import { activateModalFocus, trapTabFocus } from './dialog-focus' +import { loadImageDimensions } from './file-data-url' type FeedbackDialogProps = { appInfo: AppInfo @@ -49,21 +50,6 @@ function formatScreenshotBytes(bytes: number): string { : `${Math.max(1, Math.ceil(bytes / 1_024))} KB` } -function imageDimensions( - objectUrl: string -): Promise<{ width: number; height: number }> { - return new Promise((resolve, reject) => { - const image = new Image() - image.onload = () => - resolve({ - width: image.naturalWidth, - height: image.naturalHeight - }) - image.onerror = () => reject(new Error('Image could not be decoded')) - image.src = objectUrl - }) -} - export function FeedbackDialog({ appInfo, onClose @@ -174,7 +160,7 @@ export function FeedbackDialog({ } objectUrl = URL.createObjectURL(file) pendingScreenshotUrlsRef.current.add(objectUrl) - const { width, height } = await imageDimensions(objectUrl) + const { width, height } = await loadImageDimensions(objectUrl) if (selection !== screenshotSelectionRef.current) { pendingScreenshotUrlsRef.current.delete(objectUrl) URL.revokeObjectURL(objectUrl) diff --git a/src/renderer/src/MagicNoteEditor.tsx b/src/renderer/src/MagicNoteEditor.tsx index 74553c87..2eda96b1 100644 --- a/src/renderer/src/MagicNoteEditor.tsx +++ b/src/renderer/src/MagicNoteEditor.tsx @@ -22,6 +22,7 @@ import { magicNoteDataBytes, type MagicNoteRichContent } from '../../shared/magic-notes-contracts' +import { readFileAsDataUrl } from './file-data-url' const supportedImageTypes = new Set([ 'image/jpeg', @@ -41,32 +42,6 @@ export type MagicNoteEditorProps = { onParagraphCommit?: (content: MagicNoteRichContent) => void } -function readFileAsDataUrl( - file: File, - mimeType: string, - readFailedMessage: string -): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - if (typeof reader.result !== 'string') { - reject(new Error(readFailedMessage)) - return - } - const separatorIndex = reader.result.indexOf(',') - if (separatorIndex < 0) { - reject(new Error(readFailedMessage)) - return - } - resolve( - `data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}` - ) - } - reader.onerror = () => reject(new Error(readFailedMessage)) - reader.readAsDataURL(file) - }) -} - function safeEmbeddedFileName(file: File): string { const fallback = file.type.startsWith('video/') ? 'video' @@ -273,9 +248,6 @@ export function MagicNoteEditor({ return } try { - const readFailedMessage = translateRef.current( - 'editor.fileReadFailed' - ) const embeds = await Promise.all( classified.map(async ({ file, kind, mimeType }) => ({ kind, @@ -283,11 +255,7 @@ export function MagicNoteEditor({ name: safeEmbeddedFileName(file), mimeType, size: file.size, - dataUrl: await readFileAsDataUrl( - file, - mimeType, - readFailedMessage - ) + dataUrl: await readFileAsDataUrl(file, mimeType) } })) ) @@ -302,12 +270,8 @@ export function MagicNoteEditor({ index += 2 } quill.setSelection(index, 0, 'silent') - } catch (error) { - onErrorRef.current( - error instanceof Error - ? error.message - : translateRef.current('editor.fileReadFailed') - ) + } catch { + onErrorRef.current(translateRef.current('editor.fileReadFailed')) } } diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index c9bd956f..6ad94cbc 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -48,8 +48,21 @@ import { } from './RuntimeCustomizationSection' import { changeUiLocale } from './i18n' import { UiLocaleProvider } from './i18n/UiLocaleProvider' +import type { BrandingPreferences } from './branding' const modelProfileId = '00000000-0000-4000-8000-000000000001' +const nativeImage = globalThis.Image + +class DecodableBrandImage { + naturalWidth = 64 + naturalHeight = 64 + onload: (() => void) | null = null + onerror: (() => void) | null = null + + set src(_value: string) { + queueMicrotask(() => this.onload?.()) + } +} const browserProfileId = '00000000-0000-4000-8000-000000000201' const runtimeSettings: RuntimeSettings = { provider: 'auto', @@ -934,6 +947,10 @@ describe('SettingsPanel runtime files', () => { afterEach(() => { cleanup() + Object.defineProperty(globalThis, 'Image', { + configurable: true, + value: nativeImage + }) }) it('offers system, light, and dark appearance modes', async () => { @@ -963,6 +980,99 @@ describe('SettingsPanel runtime files', () => { expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark') }) + it('customizes and restores the sidebar brand from Appearance', async () => { + const onBrandingPreferencesChange = vi.fn< + (preferences: BrandingPreferences) => boolean + >(() => true) + + function BrandingHarness(): React.JSX.Element { + const [preferences, setPreferences] = useState({ + name: 'GoodBuddy', + subtitleZhCN: '智能工作台', + subtitleEnUS: 'Intelligent Workspace' + }) + return ( + { + onBrandingPreferencesChange(next) + setPreferences(next) + return true + }} + open + onClearLocalData={vi.fn(async () => {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + } + + render() + fireEvent.click(screen.getByRole('tab', { name: '外观' })) + + expect(screen.getByRole('img', { name: '品牌效果预览' })) + .toHaveTextContent('GoodBuddy') + expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy') + + fireEvent.change(screen.getByLabelText('品牌名称'), { + target: { value: '研发伙伴' } + }) + fireEvent.change(screen.getByLabelText('中文副标题'), { + target: { value: '团队工作台' } + }) + fireEvent.change(screen.getByLabelText('英文副标题'), { + target: { value: 'Team Workspace' } + }) + expect(screen.getByRole('img', { name: '品牌效果预览' })) + .toHaveTextContent('研发伙伴') + + Object.defineProperty(globalThis, 'Image', { + configurable: true, + value: DecodableBrandImage + }) + fireEvent.change(screen.getByLabelText('选择 Logo'), { + target: { + files: [ + new File( + [ + Uint8Array.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a + ]) + ], + 'brand.png', + { type: 'image/png' } + ) + ] + } + }) + await screen.findByRole('button', { name: '使用默认 Logo' }) + + fireEvent.click( + screen.getByRole('button', { name: '保存品牌设置' }) + ) + expect(onBrandingPreferencesChange).toHaveBeenCalledWith({ + name: '研发伙伴', + subtitleZhCN: '团队工作台', + subtitleEnUS: 'Team Workspace', + logoDataUrl: 'data:image/png;base64,iVBORw0KGgo=' + }) + + fireEvent.click(screen.getByRole('button', { name: '恢复默认' })) + expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy') + expect(screen.getByLabelText('中文副标题')).toHaveValue( + '智能工作台' + ) + }) + it('configures direct model context compression with explicit token budgets', async () => { render( Promise appearanceTheme?: AppearanceTheme onAppearanceThemeChange?: (theme: AppearanceTheme) => void + brandingPreferences?: BrandingPreferences + brandingFallbackLogo?: string + onBrandingPreferencesChange?: ( + preferences: BrandingPreferences + ) => boolean magicNotesEnabled?: boolean remoteProjectsEnabled?: boolean onMagicNotesEnabledChange?: (enabled: boolean) => void @@ -584,6 +594,9 @@ export function SettingsPanel({ onExpertsChanged = () => {}, appearanceTheme = 'system', onAppearanceThemeChange = () => {}, + brandingPreferences = defaultBrandingPreferences, + brandingFallbackLogo, + onBrandingPreferencesChange = () => false, magicNotesEnabled = false, remoteProjectsEnabled = false, onMagicNotesEnabledChange = () => {}, @@ -598,6 +611,7 @@ export function SettingsPanel({ setPreference: setUiLocalePreference } = useUiLocale() const [settings, setSettings] = useState() + const [brandingDirty, setBrandingDirty] = useState(false) const [provider, setProvider] = useState( defaultRuntimeSettings.provider @@ -927,11 +941,13 @@ export function SettingsPanel({ runtimeDraftDirty || speechModelSelectionDirty || runtimeCustomizationDirty || + brandingDirty || platformFeaturesDirty || documentParsingDirty || sshHostsDirty const navigationWouldLoseDraft = runtimeCustomizationDirty || + brandingDirty || platformFeaturesDirty || documentParsingDirty || sshHostsDirty @@ -939,6 +955,7 @@ export function SettingsPanel({ runtimeCustomizationDirty && !runtimeDraftDirty && !speechModelSelectionDirty && + !brandingDirty && !platformFeaturesDirty && !documentParsingDirty && !sshHostsDirty @@ -1188,6 +1205,7 @@ export function SettingsPanel({ setSpeechModelDraftId(undefined) setPersistedSpeechModelId(undefined) setSpeechModelSelectionDirty(false) + setBrandingDirty(false) setPlatformFeaturesDirty(false) setDocumentParsingDirty(false) setSshHostsDirty(false) @@ -1213,6 +1231,7 @@ export function SettingsPanel({ setSpeechModelSelectionDirty(false) runtimeCustomizationRef.current?.discard() setRuntimeCustomizationDirty(false) + setBrandingDirty(false) setPlatformFeaturesDirty(false) setDocumentParsingDirty(false) setSshHostsDirty(false) @@ -2110,6 +2129,12 @@ export function SettingsPanel({ ))} + )} {activeTab === 'platform-features' && ( diff --git a/src/renderer/src/branding.test.ts b/src/renderer/src/branding.test.ts new file mode 100644 index 00000000..40537e85 --- /dev/null +++ b/src/renderer/src/branding.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + brandingLogoMaximumBytes, + brandingNameMaximumLength, + defaultBrandingPreferences, + loadBrandingPreferences, + normalizeBrandingPreferences, + saveBrandingPreferences +} from './branding' + +afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() +}) + +describe('branding preferences', () => { + it('uses defaults when no valid preference is stored', () => { + expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences) + + localStorage.setItem('goodbuddy.branding.v1', '{"name":true}') + expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences) + }) + + it('normalizes text and accepts only bounded inline image data', () => { + expect( + normalizeBrandingPreferences({ + name: ` ${'B'.repeat(brandingNameMaximumLength + 4)} `, + subtitleZhCN: ' 中文品牌 ', + subtitleEnUS: ' English brand ', + logoDataUrl: 'https://example.com/logo.png' + }) + ).toEqual({ + name: 'B'.repeat(brandingNameMaximumLength), + subtitleZhCN: '中文品牌', + subtitleEnUS: 'English brand' + }) + + expect( + normalizeBrandingPreferences({ + name: ' ', + subtitleZhCN: '', + subtitleEnUS: '', + logoDataUrl: 'data:image/png;base64,iVBORw0KGgo=' + }) + ).toEqual({ + name: 'GoodBuddy', + subtitleZhCN: '', + subtitleEnUS: '', + logoDataUrl: 'data:image/png;base64,iVBORw0KGgo=' + }) + }) + + it('persists and reloads valid preferences', () => { + const preferences = { + name: '团队助手', + subtitleZhCN: '研发工作台', + subtitleEnUS: 'Engineering Workspace', + logoDataUrl: 'data:image/png;base64,iVBORw0KGgo=' + } + + expect(saveBrandingPreferences(preferences)).toBe(true) + expect(loadBrandingPreferences()).toEqual(preferences) + }) + + it('rejects a logo whose bytes do not match its declared type', () => { + expect( + normalizeBrandingPreferences({ + ...defaultBrandingPreferences, + logoDataUrl: 'data:image/jpeg;base64,iVBORw0KGgo=' + }) + ).toEqual(defaultBrandingPreferences) + }) + + it('rejects oversized encoded logos before decoding them', () => { + const decode = vi.spyOn(globalThis, 'atob') + + expect( + normalizeBrandingPreferences({ + ...defaultBrandingPreferences, + logoDataUrl: `data:image/png;base64,${'A'.repeat( + Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 8 + )}` + }) + ).toEqual(defaultBrandingPreferences) + expect(decode).not.toHaveBeenCalled() + }) + + it('reports unavailable local storage without throwing', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked') + }) + + expect( + saveBrandingPreferences(defaultBrandingPreferences) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/branding.ts b/src/renderer/src/branding.ts new file mode 100644 index 00000000..22ae9867 --- /dev/null +++ b/src/renderer/src/branding.ts @@ -0,0 +1,140 @@ +import { detectSupportedImage } from '../../shared/image-media-type' + +export type BrandingPreferences = { + name: string + subtitleZhCN: string + subtitleEnUS: string + logoDataUrl?: string +} + +export const defaultBrandingPreferences: BrandingPreferences = { + name: 'GoodBuddy', + subtitleZhCN: '智能工作台', + subtitleEnUS: 'Intelligent Workspace' +} + +export const brandingNameMaximumLength = 32 +export const brandingSubtitleMaximumLength = 48 +export const brandingLogoMaximumBytes = 512 * 1024 + +const storageKey = 'goodbuddy.branding.v1' +const supportedLogoDataUrlPattern = + /^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/]+={0,2})$/iu + +export function isValidBrandingLogoDataUrl(value: string): boolean { + const match = supportedLogoDataUrlPattern.exec(value) + if (!match) { + return false + } + try { + const encoded = match[2]! + if ( + encoded.length > + Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 4 + ) { + return false + } + const decoded = atob(encoded) + if ( + decoded.length === 0 || + decoded.length > brandingLogoMaximumBytes + ) { + return false + } + const header = Uint8Array.from( + decoded + .slice(0, 12) + .split('') + .map((character) => character.charCodeAt(0)) + ) + return ( + detectSupportedImage(header).mimeType === match[1]!.toLowerCase() + ) + } catch { + return false + } +} + +export function normalizeBrandingPreferences( + value: BrandingPreferences +): BrandingPreferences { + const name = value.name.trim() + const subtitleZhCN = value.subtitleZhCN.trim() + const subtitleEnUS = value.subtitleEnUS.trim() + return { + name: + name.length > 0 + ? name.slice(0, brandingNameMaximumLength) + : defaultBrandingPreferences.name, + subtitleZhCN: subtitleZhCN.slice( + 0, + brandingSubtitleMaximumLength + ), + subtitleEnUS: subtitleEnUS.slice( + 0, + brandingSubtitleMaximumLength + ), + ...(value.logoDataUrl && + isValidBrandingLogoDataUrl(value.logoDataUrl) + ? { logoDataUrl: value.logoDataUrl } + : {}) + } +} + +export function loadBrandingPreferences(): BrandingPreferences { + try { + const value = localStorage.getItem(storageKey) + if (!value) { + return { ...defaultBrandingPreferences } + } + const parsed = JSON.parse(value) as unknown + if ( + typeof parsed !== 'object' || + parsed === null || + typeof (parsed as Record).name !== 'string' || + typeof (parsed as Record).subtitleZhCN !== + 'string' || + typeof (parsed as Record).subtitleEnUS !== + 'string' + ) { + return { ...defaultBrandingPreferences } + } + const candidate = parsed as BrandingPreferences + return normalizeBrandingPreferences({ + name: candidate.name, + subtitleZhCN: candidate.subtitleZhCN, + subtitleEnUS: candidate.subtitleEnUS, + ...(typeof candidate.logoDataUrl === 'string' + ? { logoDataUrl: candidate.logoDataUrl } + : {}) + }) + } catch { + return { ...defaultBrandingPreferences } + } +} + +export function saveBrandingPreferences( + value: BrandingPreferences +): boolean { + try { + localStorage.setItem( + storageKey, + JSON.stringify(normalizeBrandingPreferences(value)) + ) + return true + } catch { + return false + } +} + +export function brandingPreferencesEqual( + left: BrandingPreferences, + right: BrandingPreferences +): boolean { + return ( + left.name === right.name && + left.subtitleZhCN === right.subtitleZhCN && + left.subtitleEnUS === right.subtitleEnUS && + left.logoDataUrl === right.logoDataUrl + ) +} diff --git a/src/renderer/src/file-data-url.ts b/src/renderer/src/file-data-url.ts new file mode 100644 index 00000000..53e0321f --- /dev/null +++ b/src/renderer/src/file-data-url.ts @@ -0,0 +1,82 @@ +export function readFileAsDataUrl( + file: Blob, + mimeType: string, + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + const cleanup = (): void => { + signal?.removeEventListener('abort', abort) + reader.onload = null + reader.onerror = null + reader.onabort = null + } + const fail = (): void => { + cleanup() + reject(new Error('File could not be read')) + } + const abort = (): void => { + reader.abort() + } + + if (signal?.aborted) { + fail() + return + } + signal?.addEventListener('abort', abort, { once: true }) + reader.onload = () => { + if (typeof reader.result !== 'string') { + fail() + return + } + const separatorIndex = reader.result.indexOf(',') + if (separatorIndex < 0) { + fail() + return + } + const dataUrl = + `data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}` + cleanup() + resolve(dataUrl) + } + reader.onerror = fail + reader.onabort = fail + reader.readAsDataURL(file) + }) +} + +export function loadImageDimensions( + source: string, + signal?: AbortSignal +): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const image = new Image() + const cleanup = (): void => { + signal?.removeEventListener('abort', abort) + image.onload = null + image.onerror = null + } + const abort = (): void => { + cleanup() + image.src = '' + reject(new Error('Image loading was cancelled')) + } + if (signal?.aborted) { + abort() + return + } + signal?.addEventListener('abort', abort, { once: true }) + image.onload = () => { + cleanup() + resolve({ + width: image.naturalWidth, + height: image.naturalHeight + }) + } + image.onerror = () => { + cleanup() + reject(new Error('Image could not be decoded')) + } + image.src = source + }) +} diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index ea8d40de..25e6c018 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -2,9 +2,6 @@ import type { TranslationShape } from '../../resource-types' import type { app as chineseApp } from '../zh-CN/app' export const app = { - brand: { - subtitle: 'Intelligent Workspace' - }, notifications: { success: 'Success', error: 'Error', @@ -567,6 +564,7 @@ export const app = { } }, notices: { + brandingSaved: 'Branding saved', updateAvailable: 'GoodBuddy {{version}} is available. View it in About & Updates.', remoteProjectActivated: diff --git a/src/renderer/src/i18n/locales/en-US/settings.ts b/src/renderer/src/i18n/locales/en-US/settings.ts index dbbb1c24..d13b5178 100644 --- a/src/renderer/src/i18n/locales/en-US/settings.ts +++ b/src/renderer/src/i18n/locales/en-US/settings.ts @@ -16,8 +16,8 @@ export const settings = { categories: { appearance: { label: 'Appearance', - navigationDescription: 'Theme and interface language', - description: 'Theme and interface language' + navigationDescription: 'Theme, language, and branding', + description: 'Theme, language, and brand customization' }, platformFeatures: { label: 'Platform features', @@ -879,6 +879,36 @@ export const settings = { description: 'Use the English interface' } } + }, + branding: { + title: 'Brand customization', + description: + 'Customize the brand name, subtitle, and logo in the top-left corner', + previewLabel: 'Brand preview', + fields: { + name: 'Brand name', + subtitleZhCN: 'Chinese subtitle', + subtitleEnUS: 'English subtitle' + }, + logo: { + select: 'Choose logo', + useDefault: 'Use default logo', + help: + 'Supports PNG, JPEG, or WebP up to 4096 × 4096 and 512 KB' + }, + actions: { + restore: 'Restore defaults', + save: 'Save branding' + }, + errors: { + unsupportedLogo: 'Choose a PNG, JPEG, or WebP image', + logoTooLarge: 'The logo file cannot exceed 512 KB', + invalidLogo: + 'The logo is invalid, has the wrong format, or is too large', + readLogoFailed: 'The logo could not be read. Choose it again.', + saveFailed: + 'Branding could not be saved. Check local storage and try again.' + } } } } satisfies TranslationShape diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 86aba833..8e0686ff 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -1,7 +1,4 @@ export const app = { - brand: { - subtitle: '智能工作台' - }, notifications: { success: '成功', error: '错误', @@ -533,6 +530,7 @@ export const app = { } }, notices: { + brandingSaved: '品牌设置已保存', updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看', remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪', remoteProjectActivationFailed: diff --git a/src/renderer/src/i18n/locales/zh-CN/settings.ts b/src/renderer/src/i18n/locales/zh-CN/settings.ts index a53ea42c..ee6dffce 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settings.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settings.ts @@ -12,8 +12,8 @@ export const settings = { categories: { appearance: { label: '外观', - navigationDescription: '主题与界面语言', - description: '主题与界面语言' + navigationDescription: '主题、语言与品牌', + description: '主题、语言与品牌定制' }, platformFeatures: { label: '平台功能', @@ -809,6 +809,33 @@ export const settings = { description: 'Use the English interface' } } + }, + branding: { + title: '品牌定制', + description: '定制左上角品牌名称、副标题与 Logo', + previewLabel: '品牌效果预览', + fields: { + name: '品牌名称', + subtitleZhCN: '中文副标题', + subtitleEnUS: '英文副标题' + }, + logo: { + select: '选择 Logo', + useDefault: '使用默认 Logo', + help: + '支持 PNG、JPEG 或 WebP,尺寸不超过 4096 × 4096,文件不能超过 512 KB' + }, + actions: { + restore: '恢复默认', + save: '保存品牌设置' + }, + errors: { + unsupportedLogo: '请选择 PNG、JPEG 或 WebP 图片', + logoTooLarge: 'Logo 文件不能超过 512 KB', + invalidLogo: 'Logo 图片无效、格式不符或尺寸过大', + readLogoFailed: '读取 Logo 失败,请重新选择', + saveFailed: '保存品牌设置失败,请检查本地存储后重试' + } } } } as const diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 1af137cb..d0957862 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -2359,10 +2359,18 @@ textarea:focus-visible { .brand__copy { display: flex; + min-width: 0; flex-direction: column; gap: 2px; } +.brand__copy strong, +.brand__copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .brand__copy strong { font-size: 15px; font-weight: 600; @@ -13736,6 +13744,84 @@ details.settings-section > :not(summary) + :not(summary) { gap: 14px; } +.branding-settings__preview { + display: flex; + align-items: center; + min-height: 76px; + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + background: var(--surface-raised); + gap: var(--space-3); +} + +.branding-settings__preview-mark { + display: grid; + width: 44px; + height: 44px; + flex: 0 0 44px; + overflow: hidden; + border: 1px solid var(--accent-selected); + border-radius: 12px; + background: var(--accent-subtle); + place-items: center; +} + +.branding-settings__preview-mark img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.branding-settings__preview-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-1); +} + +.branding-settings__preview-copy strong, +.branding-settings__preview-copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.branding-settings__preview-copy strong { + color: var(--text-primary); + font-size: var(--font-section-title); +} + +.branding-settings__preview-copy span { + color: var(--text-muted); + font-size: var(--font-caption); +} + +.branding-settings__fields { + display: grid; + gap: var(--space-3); + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.branding-settings__logo { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} + +.branding-settings__logo button { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.branding-settings__logo small { + flex-basis: 100%; + color: var(--text-muted); + font-size: var(--font-caption); +} + .appearance-options { display: grid; gap: 10px; @@ -14495,6 +14581,10 @@ details.settings-section > :not(summary) + :not(summary) { grid-template-columns: 1fr; } + .branding-settings__fields { + grid-template-columns: 1fr; + } + .workspace-picker { flex-wrap: wrap; } diff --git a/src/shared/image-media-type.test.ts b/src/shared/image-media-type.test.ts new file mode 100644 index 00000000..e4d90423 --- /dev/null +++ b/src/shared/image-media-type.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { detectSupportedImage } from './image-media-type' + +describe('supported image detection', () => { + it.each([ + { + bytes: [0xff, 0xd8, 0xff], + extension: 'jpg', + mimeType: 'image/jpeg' + }, + { + bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + extension: 'png', + mimeType: 'image/png' + }, + { + bytes: [ + 0x52, + 0x49, + 0x46, + 0x46, + 0, + 0, + 0, + 0, + 0x57, + 0x45, + 0x42, + 0x50 + ], + extension: 'webp', + mimeType: 'image/webp' + } + ])('detects $mimeType bytes', ({ bytes, extension, mimeType }) => { + expect(detectSupportedImage(Uint8Array.from(bytes))).toEqual({ + extension, + mimeType + }) + }) + + it('rejects unsupported bytes', () => { + expect(() => + detectSupportedImage(Uint8Array.from([1, 2, 3])) + ).toThrow('图片格式不受支持') + }) +}) diff --git a/src/shared/image-media-type.ts b/src/shared/image-media-type.ts new file mode 100644 index 00000000..68b732f8 --- /dev/null +++ b/src/shared/image-media-type.ts @@ -0,0 +1,44 @@ +export type SupportedImageFormat = { + extension: 'jpg' | 'png' | 'webp' + mimeType: 'image/jpeg' | 'image/png' | 'image/webp' +} + +function matchesAscii( + data: Uint8Array, + offset: number, + value: string +): boolean { + return [...value].every( + (character, index) => + data[offset + index] === character.charCodeAt(0) + ) +} + +export function detectSupportedImage( + data: Uint8Array +): SupportedImageFormat { + if ( + data.byteLength >= 3 && + data[0] === 0xff && + data[1] === 0xd8 && + data[2] === 0xff + ) { + return { extension: 'jpg', mimeType: 'image/jpeg' } + } + if ( + data.byteLength >= 8 && + [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every( + (value, index) => data[index] === value + ) + ) { + return { extension: 'png', mimeType: 'image/png' } + } + if ( + data.byteLength >= 12 && + matchesAscii(data, 0, 'RIFF') && + matchesAscii(data, 8, 'WEBP') + ) { + return { extension: 'webp', mimeType: 'image/webp' } + } + throw new Error('图片格式不受支持') +}