diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index 458bcb0a58..7c0a5554c4 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -104,6 +104,10 @@ module.exports = [ MediaStream: 'readonly', Blob: 'readonly', FormData: 'readonly', + // Build-time injected globals + __WHITELABEL_CONFIG__: 'readonly', + MAIN_WINDOW_VITE_DEV_SERVER_URL: 'readonly', + MAIN_WINDOW_VITE_NAME: 'readonly', }, }, plugins: { diff --git a/ui/desktop/package.json b/ui/desktop/package.json index c2c3cfd508..ff0c0ad88b 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -95,6 +95,7 @@ "tw-animate-css": "^1.4.0", "unist-util-visit": "^5.1.0", "uuid": "^13.0.0", + "yaml": "^2.8.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 2e9b0655fb..bdece9ab00 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -39,6 +39,7 @@ import 'react-toastify/dist/ReactToastify.css'; import { useConfig } from './components/ConfigContext'; import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; import { ThemeProvider } from './contexts/ThemeContext'; +import { WhiteLabelProvider } from './whitelabel/WhiteLabelContext'; import PermissionSettingsView from './components/settings/permission/PermissionSetting'; import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView'; @@ -701,14 +702,16 @@ export function AppInner() { export default function App() { return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/ui/desktop/src/components/Layout/NavigationPanel.tsx b/ui/desktop/src/components/Layout/NavigationPanel.tsx index f8e1b63012..4766d5085a 100644 --- a/ui/desktop/src/components/Layout/NavigationPanel.tsx +++ b/ui/desktop/src/components/Layout/NavigationPanel.tsx @@ -9,6 +9,7 @@ import { CondensedRenderer } from './CondensedRenderer'; import { ExpandedRenderer } from './ExpandedRenderer'; import { NavigationOverlay } from './navigation'; import type { SessionStatus, DragHandlers } from './navigation/types'; +import { useWhiteLabel } from '../../whitelabel/WhiteLabelContext'; export const Navigation: React.FC<{ className?: string }> = ({ className }) => { const { @@ -26,19 +27,21 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => { const location = useLocation(); const { extensionsList } = useConfig(); + const { isNavItemEnabled } = useWhiteLabel(); const appsExtensionEnabled = !!extensionsList?.find((ext) => ext.name === 'apps')?.enabled; const visibleItems = useMemo(() => { return preferences.itemOrder .filter((id) => preferences.enabledItems.includes(id)) + .filter((id) => isNavItemEnabled(id)) .map((id) => getNavItemById(id)) .filter((item): item is NavItem => item !== undefined) .filter((item) => { if (item.path === '/apps') return appsExtensionEnabled; return true; }); - }, [preferences.itemOrder, preferences.enabledItems, appsExtensionEnabled]); + }, [preferences.itemOrder, preferences.enabledItems, appsExtensionEnabled, isNavItemEnabled]); const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]); diff --git a/ui/desktop/src/components/common/Greeting.tsx b/ui/desktop/src/components/common/Greeting.tsx index 704d2af8aa..fa5efd607a 100644 --- a/ui/desktop/src/components/common/Greeting.tsx +++ b/ui/desktop/src/components/common/Greeting.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useTextAnimator } from '../../hooks/use-text-animator'; +import { useWhiteLabel } from '../../whitelabel/WhiteLabelContext'; interface GreetingProps { className?: string; @@ -10,46 +11,16 @@ export function Greeting({ className = 'mt-1 text-4xl font-light animate-in fade-in duration-300', forceRefresh = false, }: GreetingProps) { - const prefixes = ['Hello!']; - const messages = [ - ' Ready to get started?', - ' What would you like to work on?', - ' Ready to build something amazing?', - ' What would you like to explore?', - " What's on your mind?", - ' What shall we create today?', - ' What project needs attention?', - ' What would you like to tackle?', - ' What would you like to explore?', - ' What needs to be done?', - " What's the plan for today?", - ' Ready to create something great?', - ' What can be built today?', - " What's the next challenge?", - ' What progress can be made?', - ' What would you like to accomplish?', - ' What task awaits?', - " What's the mission today?", - ' What can be achieved?', - ' What project is ready to begin?', - ]; + const { getRandomGreeting } = useWhiteLabel(); - // Using lazy initializer to generate random greeting on each component instance - const greeting = useState(() => { - const randomPrefixIndex = Math.floor(Math.random() * prefixes.length); - const randomMessageIndex = Math.floor(Math.random() * messages.length); - - return { - prefix: prefixes[randomPrefixIndex], - message: messages[randomMessageIndex], - }; - })[0]; + const greeting = useState(() => ({ + message: getRandomGreeting(), + }))[0]; const messageRef = useTextAnimator({ text: greeting.message }); return (

- {/* {greeting.prefix} */} {greeting.message}

); diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index ba533da72f..ea48fc4a78 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -19,6 +19,7 @@ import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection'; import LocalInferenceSection from './localInference/LocalInferenceSection'; import { CONFIGURATION_ENABLED } from '../../updates'; import { trackSettingsTabViewed } from '../../utils/analytics'; +import { useWhiteLabel } from '../../whitelabel/WhiteLabelContext'; export type SettingsViewOptions = { deepLinkConfig?: ExtensionConfig; @@ -35,7 +36,9 @@ export default function SettingsView({ setView: (view: View, viewOptions?: ViewOptions) => void; viewOptions: SettingsViewOptions; }) { - const [activeTab, setActiveTab] = useState('models'); + const { isSettingsTabEnabled, isSectionHidden, features } = useWhiteLabel(); + const firstAvailableTab = features.settingsTabs[0] || 'models'; + const [activeTab, setActiveTab] = useState(firstAvailableTab); const [tunnelDisabled, setTunnelDisabled] = useState(false); const hasTrackedInitialTab = useRef(false); @@ -121,54 +124,72 @@ export default function SettingsView({ >
- - - Models - - - - Local Inference - - - - Chat - - - - Session - - - - Prompts - - - - Keyboard - - - - App - + {isSettingsTabEnabled('models') && ( + + + Models + + )} + {isSettingsTabEnabled('local-inference') && ( + + + Local Inference + + )} + {isSettingsTabEnabled('chat') && ( + + + Chat + + )} + {isSettingsTabEnabled('sharing') && ( + + + Session + + )} + {isSettingsTabEnabled('prompts') && ( + + + Prompts + + )} + {isSettingsTabEnabled('keyboard') && ( + + + Keyboard + + )} + {isSettingsTabEnabled('app') && ( + + + App + + )}
@@ -199,12 +220,12 @@ export default function SettingsView({ className="mt-0 focus-visible:outline-none focus-visible:ring-0" >
- - - {!tunnelDisabled && ( + {!isSectionHidden('session-sharing') && } + {!isSectionHidden('external-backend') && } + {!tunnelDisabled && !isSectionHidden('tunnel') && (
- + {!isSectionHidden('gateway') && }
)}
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index bfba32cb8d..dd64c7c7f2 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -49,8 +49,21 @@ import { UPDATES_ENABLED } from './updates'; import './utils/recipeHash'; import { Client } from './api/client'; import { GooseApp } from './api'; +import type { WhiteLabelConfig } from './whitelabel/types'; +import { DEFAULT_WHITELABEL_CONFIG } from './whitelabel/defaults'; + +function getWhiteLabelConfig(): WhiteLabelConfig { + try { + return __WHITELABEL_CONFIG__; + } catch { + return DEFAULT_WHITELABEL_CONFIG; + } +} + +const whiteLabelConfig = getWhiteLabelConfig(); import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity'; +import { startWhiteLabelProcesses, stopAllWhiteLabelProcesses } from './whitelabel/processManager'; function shouldSetupUpdater(): boolean { // Setup updater if either the flag is enabled OR dev updates are enabled @@ -386,7 +399,7 @@ app.on('open-url', async (_event, url) => { app.on('will-finish-launching', () => { if (process.platform === 'darwin') { app.setAboutPanelOptions({ - applicationName: 'Goose', + applicationName: whiteLabelConfig.branding.appName, applicationVersion: app.getVersion(), }); } @@ -575,8 +588,8 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult; const mainWindowState = windowStateKeeper({ - defaultWidth: 940, - defaultHeight: 800, + defaultWidth: whiteLabelConfig.window.width, + defaultHeight: whiteLabelConfig.window.height, }); const mainWindow = new BrowserWindow({ @@ -588,8 +601,8 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { y: mainWindowState.y, width: mainWindowState.width, height: mainWindowState.height, - minWidth: 450, - resizable: true, + minWidth: whiteLabelConfig.window.minWidth, + resizable: whiteLabelConfig.window.resizable !== false, useContentSize: true, icon: path.join(__dirname, '../images/icon.icns'), webPreferences: { @@ -1731,6 +1744,12 @@ async function appMain() { // Ensure Windows shims are available before any MCP processes are spawned await ensureWinShims(); + // Start any sidecar processes defined in white-label config + if (whiteLabelConfig.processes && whiteLabelConfig.processes.length > 0) { + const resourcesPath = app.isPackaged ? process.resourcesPath : process.cwd(); + await startWhiteLabelProcesses(whiteLabelConfig.processes, resourcesPath); + } + registerUpdateIpcHandlers(); // Handle microphone permission requests @@ -2478,6 +2497,8 @@ async function getAllowList(): Promise { } app.on('will-quit', async () => { + stopAllWhiteLabelProcesses(); + for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) { try { powerSaveBlocker.stop(blockerId); diff --git a/ui/desktop/src/updates.ts b/ui/desktop/src/updates.ts index 88f257c8e3..12e0cc50c2 100644 --- a/ui/desktop/src/updates.ts +++ b/ui/desktop/src/updates.ts @@ -1,6 +1,20 @@ -export const UPDATES_ENABLED = true; -export const COST_TRACKING_ENABLED = true; -export const ANNOUNCEMENTS_ENABLED = false; -export const CONFIGURATION_ENABLED = true; -export const TELEMETRY_UI_ENABLED = true; -export const DICTATION_ALLOWED_PROVIDERS: string[] | null = null; +import type { WhiteLabelConfig } from './whitelabel/types'; +import { DEFAULT_WHITELABEL_CONFIG } from './whitelabel/defaults'; + +function getWLConfig(): WhiteLabelConfig { + try { + return __WHITELABEL_CONFIG__; + } catch { + return DEFAULT_WHITELABEL_CONFIG; + } +} + +const _wl = getWLConfig(); + +export const UPDATES_ENABLED = _wl.features.updatesEnabled; +export const COST_TRACKING_ENABLED = _wl.features.costTrackingEnabled; +export const ANNOUNCEMENTS_ENABLED = _wl.features.announcementsEnabled; +export const CONFIGURATION_ENABLED = _wl.features.configurationEnabled; +export const TELEMETRY_UI_ENABLED = _wl.features.telemetryUiEnabled; +export const DICTATION_ALLOWED_PROVIDERS: string[] | null = + _wl.features.dictationAllowedProviders ?? null; diff --git a/ui/desktop/src/whitelabel/README.md b/ui/desktop/src/whitelabel/README.md new file mode 100644 index 0000000000..6e654d7974 --- /dev/null +++ b/ui/desktop/src/whitelabel/README.md @@ -0,0 +1,88 @@ +# White-Label System + +The white-label system lets you declaratively configure the entire Goose desktop app from a single `whitelabel.yaml` file. When built, the config is baked into the binary — no runtime config files needed. + +## Quick Start + +1. Edit `ui/desktop/whitelabel.yaml` (or create a custom one) +2. Build: `npm run make` +3. The built app uses your config + +To use a custom config file: +```bash +WHITELABEL_CONFIG=/path/to/myconfig.yaml npm run make +``` + +## What You Can Configure + +### Branding +- **App name** — shown in title bar, menus, about dialog +- **Tagline** — welcome/loading screens +- **Logo** — custom SVG/PNG for the app icon +- **Greetings** — random messages on the home screen +- **Starter prompts** — quick-action cards on home screen + +### Features (toggle on/off) +- Updates, cost tracking, announcements, configuration UI, telemetry UI +- **Navigation items** — choose which nav items to show and their order +- **Settings tabs** — choose which settings tabs to show +- **Hidden sections** — hide specific sections within settings tabs +- **Allowed providers** — restrict to specific LLM providers + +### Defaults (pre-seed on first launch) +- Default provider and model +- Pre-installed extensions with config +- Working directory +- System prompt override +- Goosehints content + +### Processes (sidecar management) +- Launch external processes alongside the app +- Auto-restart on crash +- Wait for port readiness before proceeding + +### Window +- Default dimensions and min width +- Always-on-top, resizable + +## Architecture + +``` +whitelabel.yaml ← Your config file + │ + ▼ +vite-plugin.ts ← Reads YAML at build time + │ + ▼ +__WHITELABEL_CONFIG__ ← Baked into JS as a global constant + │ + ├──▶ WhiteLabelContext.tsx ← React context for renderer + ├──▶ main.ts ← Electron main process + └──▶ updates.ts ← Feature flags +``` + +### Files +- `whitelabel.yaml` — the config file +- `types.ts` — TypeScript type definitions +- `defaults.ts` — default values when no config is provided +- `loader.ts` — Node.js YAML loader (build-time) +- `vite-plugin.ts` — Vite plugin that injects config at build time +- `WhiteLabelContext.tsx` — React context + `useWhiteLabel()` hook +- `processManager.ts` — sidecar process lifecycle management +- `global.d.ts` — TypeScript declaration for `__WHITELABEL_CONFIG__` + +## Usage in Components + +```tsx +import { useWhiteLabel } from '../whitelabel/WhiteLabelContext'; + +function MyComponent() { + const { branding, isNavItemEnabled, isSettingsTabEnabled } = useWhiteLabel(); + + return

{branding.appName}

; +} +``` + +## Example + +See `whitelabel.example.yaml` for a full example of a white-labeled "Acme Assistant" build. diff --git a/ui/desktop/src/whitelabel/WhiteLabelContext.tsx b/ui/desktop/src/whitelabel/WhiteLabelContext.tsx new file mode 100644 index 0000000000..05cd9d021f --- /dev/null +++ b/ui/desktop/src/whitelabel/WhiteLabelContext.tsx @@ -0,0 +1,106 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import type { + WhiteLabelConfig, + WhiteLabelBranding, + WhiteLabelFeatures, + WhiteLabelDefaults, + WhiteLabelWindow, +} from './types'; +import { DEFAULT_WHITELABEL_CONFIG } from './defaults'; + +interface WhiteLabelContextType { + config: WhiteLabelConfig; + branding: WhiteLabelBranding; + features: WhiteLabelFeatures; + defaults: WhiteLabelDefaults; + window: WhiteLabelWindow; + isFeatureEnabled: (feature: string) => boolean; + isNavItemEnabled: (itemId: string) => boolean; + isSettingsTabEnabled: (tabId: string) => boolean; + isSectionHidden: (sectionId: string) => boolean; + isProviderAllowed: (providerId: string) => boolean; + getRandomGreeting: () => string; +} + +const WhiteLabelContext = createContext(undefined); + +function getConfig(): WhiteLabelConfig { + try { + return __WHITELABEL_CONFIG__; + } catch { + return DEFAULT_WHITELABEL_CONFIG; + } +} + +export const WhiteLabelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const config = useMemo(() => getConfig(), []); + + const value = useMemo(() => { + const { branding, features, defaults, window: windowConfig } = config; + + const navSet = new Set(features.navigation); + const tabSet = new Set(features.settingsTabs); + const hiddenSections = new Set(features.hiddenSettingSections || []); + const allowedProviders = features.allowedProviders + ? new Set(features.allowedProviders.map((p) => p.toLowerCase())) + : null; + + return { + config, + branding, + features, + defaults, + window: windowConfig, + + isFeatureEnabled(feature: string): boolean { + switch (feature) { + case 'updates': + return features.updatesEnabled; + case 'costTracking': + return features.costTrackingEnabled; + case 'announcements': + return features.announcementsEnabled; + case 'configuration': + return features.configurationEnabled; + case 'telemetryUi': + return features.telemetryUiEnabled; + default: + return true; + } + }, + + isNavItemEnabled(itemId: string): boolean { + return navSet.has(itemId); + }, + + isSettingsTabEnabled(tabId: string): boolean { + return tabSet.has(tabId); + }, + + isSectionHidden(sectionId: string): boolean { + return hiddenSections.has(sectionId); + }, + + isProviderAllowed(providerId: string): boolean { + if (!allowedProviders) return true; + return allowedProviders.has(providerId.toLowerCase()); + }, + + getRandomGreeting(): string { + const greetings = branding.greetings; + if (greetings.length === 0) return "What's on your mind?"; + return greetings[Math.floor(Math.random() * greetings.length)]; + }, + }; + }, [config]); + + return {children}; +}; + +export function useWhiteLabel(): WhiteLabelContextType { + const context = useContext(WhiteLabelContext); + if (!context) { + throw new Error('useWhiteLabel must be used within a WhiteLabelProvider'); + } + return context; +} diff --git a/ui/desktop/src/whitelabel/__tests__/whitelabel.test.ts b/ui/desktop/src/whitelabel/__tests__/whitelabel.test.ts new file mode 100644 index 0000000000..3c3daa6d82 --- /dev/null +++ b/ui/desktop/src/whitelabel/__tests__/whitelabel.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import type { WhiteLabelConfig } from '../types'; +import { DEFAULT_WHITELABEL_CONFIG } from '../defaults'; + +// Mock __WHITELABEL_CONFIG__ for tests +const mockConfig: WhiteLabelConfig = { + ...DEFAULT_WHITELABEL_CONFIG, + branding: { + appName: 'TestApp', + greetings: ['Hello from TestApp!', 'Welcome to TestApp!'], + }, + features: { + ...DEFAULT_WHITELABEL_CONFIG.features, + navigation: ['home', 'chat', 'settings'], + settingsTabs: ['models', 'app'], + hiddenSettingSections: ['tunnel', 'session-sharing'], + allowedProviders: ['openai', 'anthropic'], + updatesEnabled: false, + announcementsEnabled: true, + }, + window: { + width: 1200, + height: 900, + minWidth: 500, + }, +}; + +describe('WhiteLabelConfig defaults', () => { + it('has all required top-level keys', () => { + expect(DEFAULT_WHITELABEL_CONFIG.branding).toBeDefined(); + expect(DEFAULT_WHITELABEL_CONFIG.features).toBeDefined(); + expect(DEFAULT_WHITELABEL_CONFIG.defaults).toBeDefined(); + expect(DEFAULT_WHITELABEL_CONFIG.window).toBeDefined(); + }); + + it('defaults appName to Goose', () => { + expect(DEFAULT_WHITELABEL_CONFIG.branding.appName).toBe('Goose'); + }); + + it('has default greetings', () => { + expect(DEFAULT_WHITELABEL_CONFIG.branding.greetings.length).toBeGreaterThan(0); + }); + + it('has all default navigation items', () => { + const nav = DEFAULT_WHITELABEL_CONFIG.features.navigation; + expect(nav).toContain('home'); + expect(nav).toContain('chat'); + expect(nav).toContain('recipes'); + expect(nav).toContain('apps'); + expect(nav).toContain('scheduler'); + expect(nav).toContain('extensions'); + expect(nav).toContain('settings'); + }); + + it('has all default settings tabs', () => { + const tabs = DEFAULT_WHITELABEL_CONFIG.features.settingsTabs; + expect(tabs).toContain('models'); + expect(tabs).toContain('local-inference'); + expect(tabs).toContain('chat'); + expect(tabs).toContain('sharing'); + expect(tabs).toContain('prompts'); + expect(tabs).toContain('keyboard'); + expect(tabs).toContain('app'); + }); + + it('has sensible default window dimensions', () => { + expect(DEFAULT_WHITELABEL_CONFIG.window.width).toBe(940); + expect(DEFAULT_WHITELABEL_CONFIG.window.height).toBe(800); + expect(DEFAULT_WHITELABEL_CONFIG.window.minWidth).toBe(450); + }); + + it('enables features by default', () => { + expect(DEFAULT_WHITELABEL_CONFIG.features.updatesEnabled).toBe(true); + expect(DEFAULT_WHITELABEL_CONFIG.features.costTrackingEnabled).toBe(true); + expect(DEFAULT_WHITELABEL_CONFIG.features.configurationEnabled).toBe(true); + }); +}); + +describe('WhiteLabelConfig custom config', () => { + it('can override appName', () => { + expect(mockConfig.branding.appName).toBe('TestApp'); + }); + + it('can restrict navigation items', () => { + expect(mockConfig.features.navigation).toEqual(['home', 'chat', 'settings']); + expect(mockConfig.features.navigation).not.toContain('recipes'); + expect(mockConfig.features.navigation).not.toContain('extensions'); + }); + + it('can restrict settings tabs', () => { + expect(mockConfig.features.settingsTabs).toEqual(['models', 'app']); + expect(mockConfig.features.settingsTabs).not.toContain('chat'); + expect(mockConfig.features.settingsTabs).not.toContain('sharing'); + }); + + it('can hide setting sections', () => { + expect(mockConfig.features.hiddenSettingSections).toContain('tunnel'); + expect(mockConfig.features.hiddenSettingSections).toContain('session-sharing'); + }); + + it('can restrict providers', () => { + expect(mockConfig.features.allowedProviders).toContain('openai'); + expect(mockConfig.features.allowedProviders).toContain('anthropic'); + expect(mockConfig.features.allowedProviders).not.toContain('ollama'); + }); + + it('can disable features', () => { + expect(mockConfig.features.updatesEnabled).toBe(false); + expect(mockConfig.features.announcementsEnabled).toBe(true); + }); + + it('can customize window dimensions', () => { + expect(mockConfig.window.width).toBe(1200); + expect(mockConfig.window.height).toBe(900); + expect(mockConfig.window.minWidth).toBe(500); + }); + + it('can customize greetings', () => { + expect(mockConfig.branding.greetings).toHaveLength(2); + expect(mockConfig.branding.greetings[0]).toBe('Hello from TestApp!'); + }); +}); + +describe('WhiteLabelConfig type safety', () => { + it('extension defaults have required fields', () => { + const ext = { + name: 'test-ext', + type: 'sse', + uri: 'https://example.com/sse', + enabled: true, + }; + expect(ext.name).toBeDefined(); + expect(ext.type).toBeDefined(); + expect(ext.enabled).toBeDefined(); + }); + + it('process config has required fields', () => { + const proc = { + name: 'sidecar', + command: 'node', + args: ['server.js'], + restartOnCrash: true, + waitForPort: 9999, + waitTimeoutMs: 10000, + }; + expect(proc.name).toBeDefined(); + expect(proc.command).toBeDefined(); + }); +}); diff --git a/ui/desktop/src/whitelabel/defaults.ts b/ui/desktop/src/whitelabel/defaults.ts new file mode 100644 index 0000000000..fab1ddbbfc --- /dev/null +++ b/ui/desktop/src/whitelabel/defaults.ts @@ -0,0 +1,34 @@ +import type { WhiteLabelConfig } from './types'; + +export const DEFAULT_WHITELABEL_CONFIG: WhiteLabelConfig = { + branding: { + appName: 'Goose', + greetings: [ + 'What would you like to work on?', + 'Ready to build something amazing?', + 'What shall we create today?', + "What's on your mind?", + 'What project needs attention?', + 'What would you like to tackle?', + 'What would you like to explore?', + 'What needs to be done?', + "What's the plan for today?", + 'Ready to create something great?', + ], + }, + features: { + updatesEnabled: true, + costTrackingEnabled: true, + announcementsEnabled: false, + configurationEnabled: true, + telemetryUiEnabled: true, + navigation: ['home', 'chat', 'recipes', 'apps', 'scheduler', 'extensions', 'settings'], + settingsTabs: ['models', 'local-inference', 'chat', 'sharing', 'prompts', 'keyboard', 'app'], + }, + defaults: {}, + window: { + width: 940, + height: 800, + minWidth: 450, + }, +}; diff --git a/ui/desktop/src/whitelabel/global.d.ts b/ui/desktop/src/whitelabel/global.d.ts new file mode 100644 index 0000000000..bff28d7c95 --- /dev/null +++ b/ui/desktop/src/whitelabel/global.d.ts @@ -0,0 +1,9 @@ +import type { WhiteLabelConfig } from './types'; + +declare global { + /** + * Injected at build time by the whitelabel Vite plugin. + * Contains the fully resolved white-label configuration. + */ + const __WHITELABEL_CONFIG__: WhiteLabelConfig; +} diff --git a/ui/desktop/src/whitelabel/index.ts b/ui/desktop/src/whitelabel/index.ts new file mode 100644 index 0000000000..c50414dd56 --- /dev/null +++ b/ui/desktop/src/whitelabel/index.ts @@ -0,0 +1,12 @@ +export type { + WhiteLabelConfig, + WhiteLabelBranding, + WhiteLabelFeatures, + WhiteLabelDefaults, + WhiteLabelProcess, + WhiteLabelWindow, + WhiteLabelStarterPrompt, + WhiteLabelExtensionDefault, +} from './types'; + +export { DEFAULT_WHITELABEL_CONFIG } from './defaults'; diff --git a/ui/desktop/src/whitelabel/loader.ts b/ui/desktop/src/whitelabel/loader.ts new file mode 100644 index 0000000000..dc5b8c6457 --- /dev/null +++ b/ui/desktop/src/whitelabel/loader.ts @@ -0,0 +1,51 @@ +/** + * Build-time loader for whitelabel.yaml. + * Used by the Vite plugin to read and merge config at build time. + * This file runs in Node.js context (not browser). + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as yaml from 'yaml'; +import type { WhiteLabelConfig } from './types'; +import { DEFAULT_WHITELABEL_CONFIG } from './defaults'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function deepMerge(target: any, source: any): any { + if (!source) return target; + const result = { ...target }; + for (const key of Object.keys(source)) { + const sourceVal = source[key]; + const targetVal = target[key]; + if ( + sourceVal !== undefined && + sourceVal !== null && + typeof sourceVal === 'object' && + !Array.isArray(sourceVal) && + typeof targetVal === 'object' && + !Array.isArray(targetVal) && + targetVal !== null + ) { + result[key] = deepMerge(targetVal, sourceVal); + } else if (sourceVal !== undefined) { + result[key] = sourceVal; + } + } + return result; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export function loadWhiteLabelConfig(projectRoot: string): WhiteLabelConfig { + // Allow override via env var + const configPath = process.env.WHITELABEL_CONFIG || path.join(projectRoot, 'whitelabel.yaml'); + + if (!fs.existsSync(configPath)) { + console.log('[WhiteLabel] No whitelabel.yaml found, using defaults'); + return DEFAULT_WHITELABEL_CONFIG; + } + + console.log(`[WhiteLabel] Loading config from ${configPath}`); + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = yaml.parse(raw) as Partial; + + return deepMerge(DEFAULT_WHITELABEL_CONFIG, parsed) as WhiteLabelConfig; +} diff --git a/ui/desktop/src/whitelabel/processManager.ts b/ui/desktop/src/whitelabel/processManager.ts new file mode 100644 index 0000000000..acccb73392 --- /dev/null +++ b/ui/desktop/src/whitelabel/processManager.ts @@ -0,0 +1,113 @@ +/** + * Manages sidecar processes defined in whitelabel.yaml. + * Runs in the Electron main process. + */ +import { spawn, ChildProcess } from 'child_process'; +import * as net from 'node:net'; +import * as path from 'node:path'; +import log from '../utils/logger'; +import type { WhiteLabelProcess } from './types'; + +interface ManagedProcess { + config: WhiteLabelProcess; + child: ChildProcess | null; + stopped: boolean; +} + +const managedProcesses: ManagedProcess[] = []; + +function waitForPort(port: number, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + + const tryConnect = () => { + if (Date.now() - start > timeoutMs) { + reject(new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`)); + return; + } + + const socket = net.createConnection({ port, host: '127.0.0.1' }, () => { + socket.destroy(); + resolve(); + }); + + socket.on('error', () => { + socket.destroy(); + setTimeout(tryConnect, 200); + }); + }; + + tryConnect(); + }); +} + +function startProcess(managed: ManagedProcess, resourcesPath: string): void { + const { config } = managed; + const cwd = config.cwd ? path.resolve(resourcesPath, config.cwd) : resourcesPath; + + log.info(`[WhiteLabel] Starting process: ${config.name} (${config.command})`); + + const child = spawn(config.command, config.args || [], { + cwd, + env: { ...process.env, ...(config.env || {}) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (data) => { + log.info(`[${config.name}] ${data.toString().trim()}`); + }); + + child.stderr?.on('data', (data) => { + log.error(`[${config.name}] ${data.toString().trim()}`); + }); + + child.on('exit', (code, signal) => { + log.info(`[WhiteLabel] Process ${config.name} exited (code=${code}, signal=${signal})`); + managed.child = null; + + if (!managed.stopped && config.restartOnCrash) { + log.info(`[WhiteLabel] Restarting ${config.name} in 2s...`); + setTimeout(() => { + if (!managed.stopped) { + startProcess(managed, resourcesPath); + } + }, 2000); + } + }); + + managed.child = child; +} + +export async function startWhiteLabelProcesses( + processConfigs: WhiteLabelProcess[], + resourcesPath: string +): Promise { + for (const config of processConfigs) { + const managed: ManagedProcess = { config, child: null, stopped: false }; + managedProcesses.push(managed); + + startProcess(managed, resourcesPath); + + if (config.waitForPort) { + const timeout = config.waitTimeoutMs || 10000; + try { + await waitForPort(config.waitForPort, timeout); + log.info(`[WhiteLabel] Process ${config.name} is ready on port ${config.waitForPort}`); + } catch (err) { + log.error(`[WhiteLabel] Process ${config.name} failed to become ready: ${err}`); + } + } + } +} + +export function stopAllWhiteLabelProcesses(): void { + for (const managed of managedProcesses) { + managed.stopped = true; + if (managed.child) { + log.info(`[WhiteLabel] Stopping process: ${managed.config.name}`); + managed.child.kill('SIGTERM'); + managed.child = null; + } + } + managedProcesses.length = 0; +} diff --git a/ui/desktop/src/whitelabel/types.ts b/ui/desktop/src/whitelabel/types.ts new file mode 100644 index 0000000000..57ac3748bc --- /dev/null +++ b/ui/desktop/src/whitelabel/types.ts @@ -0,0 +1,80 @@ +/** + * White-label configuration types. + * These define the shape of whitelabel.yaml and are available at both + * build time (Vite plugin) and runtime (React context). + */ + +export interface WhiteLabelStarterPrompt { + icon: string; + label: string; + prompt: string; +} + +export interface WhiteLabelBranding { + appName: string; + tagline?: string; + logo?: string; + logoSmall?: string; + trayIcon?: string; + greetings: string[]; + starterPrompts?: WhiteLabelStarterPrompt[]; +} + +export interface WhiteLabelFeatures { + updatesEnabled: boolean; + costTrackingEnabled: boolean; + announcementsEnabled: boolean; + configurationEnabled: boolean; + telemetryUiEnabled: boolean; + navigation: string[]; + settingsTabs: string[]; + hiddenSettingSections?: string[]; + allowedProviders?: string[]; + dictationAllowedProviders?: string[] | null; +} + +export interface WhiteLabelExtensionDefault { + name: string; + type: string; + uri?: string; + cmd?: string; + args?: string[]; + enabled: boolean; + envVars?: Record; +} + +export interface WhiteLabelDefaults { + provider?: string; + model?: string; + extensions?: WhiteLabelExtensionDefault[]; + workingDir?: string; + systemPrompt?: string; + goosehints?: string; +} + +export interface WhiteLabelProcess { + name: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record; + restartOnCrash?: boolean; + waitForPort?: number; + waitTimeoutMs?: number; +} + +export interface WhiteLabelWindow { + width: number; + height: number; + minWidth: number; + alwaysOnTop?: boolean; + resizable?: boolean; +} + +export interface WhiteLabelConfig { + branding: WhiteLabelBranding; + features: WhiteLabelFeatures; + defaults: WhiteLabelDefaults; + processes?: WhiteLabelProcess[]; + window: WhiteLabelWindow; +} diff --git a/ui/desktop/src/whitelabel/vite-plugin.ts b/ui/desktop/src/whitelabel/vite-plugin.ts new file mode 100644 index 0000000000..f892e67be6 --- /dev/null +++ b/ui/desktop/src/whitelabel/vite-plugin.ts @@ -0,0 +1,20 @@ +/** + * Vite plugin that loads whitelabel.yaml at build time and injects + * the resolved config as a global constant `__WHITELABEL_CONFIG__`. + */ +import type { Plugin } from 'vite'; +import { loadWhiteLabelConfig } from './loader'; + +export function whiteLabelPlugin(projectRoot: string): Plugin { + return { + name: 'whitelabel-config', + config() { + const config = loadWhiteLabelConfig(projectRoot); + return { + define: { + __WHITELABEL_CONFIG__: JSON.stringify(config), + }, + }; + }, + }; +} diff --git a/ui/desktop/vite.main.config.mts b/ui/desktop/vite.main.config.mts index 7087ae8352..a13c01c61b 100644 --- a/ui/desktop/vite.main.config.mts +++ b/ui/desktop/vite.main.config.mts @@ -1,4 +1,5 @@ import { defineConfig } from 'vite'; +import { whiteLabelPlugin } from './src/whitelabel/vite-plugin'; // https://vitejs.dev/config export default defineConfig({ @@ -7,4 +8,5 @@ export default defineConfig({ 'process.env.GITHUB_REPO': JSON.stringify(process.env.GITHUB_REPO || 'goose'), 'process.env.GOOSE_BUNDLE_NAME': JSON.stringify(process.env.GOOSE_BUNDLE_NAME || 'Goose'), }, + plugins: [whiteLabelPlugin(__dirname)], }); diff --git a/ui/desktop/vite.renderer.config.mts b/ui/desktop/vite.renderer.config.mts index 9a3c558b7c..6fd65a9e53 100644 --- a/ui/desktop/vite.renderer.config.mts +++ b/ui/desktop/vite.renderer.config.mts @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import tailwindcss from '@tailwindcss/vite'; +import { whiteLabelPlugin } from './src/whitelabel/vite-plugin'; // https://vitejs.dev/config export default defineConfig({ @@ -9,7 +10,7 @@ export default defineConfig({ 'process.env.GOOSE_TUNNEL': JSON.stringify(process.env.GOOSE_TUNNEL !== 'no' && process.env.GOOSE_TUNNEL !== 'none'), }, - plugins: [tailwindcss()], + plugins: [tailwindcss(), whiteLabelPlugin(__dirname)], build: { target: 'esnext' diff --git a/ui/desktop/whitelabel.example.yaml b/ui/desktop/whitelabel.example.yaml new file mode 100644 index 0000000000..6d461494f8 --- /dev/null +++ b/ui/desktop/whitelabel.example.yaml @@ -0,0 +1,111 @@ +# ============================================================================= +# Example: "Acme Assistant" — a white-labeled Goose for Acme Corp +# ============================================================================= +# Build with: WHITELABEL_CONFIG=whitelabel.example.yaml npm run make +# ============================================================================= + +branding: + appName: "Acme Assistant" + tagline: "AI-powered development, the Acme way" + + # Custom logo files (place in your brand directory) + # logo: "./brand/acme-logo.svg" + # logoSmall: "./brand/acme-logo-small.svg" + # trayIcon: "./brand/acme-tray.png" + + greetings: + - "Welcome to Acme! What can I help you build?" + - "Ready to ship some code?" + - "What's the next feature?" + - "Let's get productive." + + starterPrompts: + - icon: "code" + label: "Code review" + prompt: "Review the current git diff and suggest improvements" + - icon: "folder-tree" + label: "Project setup" + prompt: "Set up a new project following our Acme standards" + - icon: "message-square" + label: "Write tests" + prompt: "Generate unit tests for the current file" + +features: + updatesEnabled: false # Acme manages updates internally + costTrackingEnabled: true + announcementsEnabled: false # No upstream announcements + configurationEnabled: false # Lock down config editing + telemetryUiEnabled: false # Hide telemetry settings + + # Simplified navigation — no recipes, schedules, or apps + navigation: + - home + - chat + - extensions + - settings + + # Only show relevant settings tabs + settingsTabs: + - models + - chat + - keyboard + - app + + # Hide sections that aren't relevant + hiddenSettingSections: + - external-backend + - tunnel + - session-sharing + - telemetry + - update + - navigation-customization + + # Lock to approved providers only + allowedProviders: + - anthropic + - openai + +defaults: + provider: "anthropic" + model: "claude-sonnet-4-20250514" + + # Pre-install the team's custom MCP extensions + extensions: + - name: "acme-internal-tools" + type: "sse" + uri: "https://mcp.internal.acme.com/sse" + enabled: true + envVars: + ACME_TOKEN: "" + + - name: "code-standards" + type: "stdio" + cmd: "npx" + args: ["-y", "@acme/code-standards-mcp"] + enabled: true + + systemPrompt: | + You are Acme Assistant, an AI coding companion for Acme Corp engineers. + Follow Acme's coding standards. Prefer TypeScript. Always write tests. + Reference internal docs at https://docs.internal.acme.com when relevant. + + goosehints: | + This is an Acme Corp project. Follow internal standards. + Use the acme-internal-tools extension for accessing internal APIs. + +# Start a local tool server alongside the app +processes: + - name: "acme-proxy" + command: "node" + args: ["./tools/proxy.js"] + cwd: "." + env: + PROXY_PORT: "8899" + restartOnCrash: true + waitForPort: 8899 + waitTimeoutMs: 15000 + +window: + width: 1100 + height: 850 + minWidth: 500 diff --git a/ui/desktop/whitelabel.yaml b/ui/desktop/whitelabel.yaml new file mode 100644 index 0000000000..87b8cc9cbc --- /dev/null +++ b/ui/desktop/whitelabel.yaml @@ -0,0 +1,151 @@ +# ============================================================================= +# Goose White-Label Configuration +# ============================================================================= +# This file controls the entire branding, feature set, and behavior of the +# desktop app. When built, these values are baked into the binary. +# +# To create a white-labeled build: +# 1. Copy this file and modify it +# 2. Set WHITELABEL_CONFIG=path/to/your/config.yaml +# 3. Build normally: npm run make +# +# Any field left unset uses the Goose defaults. +# ============================================================================= + +# --------------------------------------------------------------------------- +# Branding +# --------------------------------------------------------------------------- +branding: + # App name shown in title bar, menus, about dialog, etc. + appName: "Goose" + + # Tagline shown on welcome/loading screens (optional) + # tagline: "Your AI-powered assistant" + + # Logo image paths (relative to ui/desktop/ or absolute) + # If not set, the default Goose logo SVG component is used. + # logo: "./brand/logo.svg" + # logoSmall: "./brand/logo-small.svg" + # trayIcon: "./brand/tray-icon.png" + + # Greeting messages shown on the home screen. If empty, defaults are used. + greetings: + - "What would you like to work on?" + - "Ready to build something amazing?" + - "What shall we create today?" + - "What's on your mind?" + + # Starter prompts shown as quick-action cards on the home screen + # starterPrompts: + # - icon: "folder-tree" + # label: "Organize files" + # prompt: "Help me organize my project files" + # - icon: "code" + # label: "Write code" + # prompt: "Help me write a Python script" + +# --------------------------------------------------------------------------- +# Features — toggle major feature areas on/off +# --------------------------------------------------------------------------- +features: + # Top-level feature toggles + updatesEnabled: true + costTrackingEnabled: true + announcementsEnabled: false + configurationEnabled: true + telemetryUiEnabled: true + + # Navigation items to show (order matters). Comment out to hide. + navigation: + - home + - chat + - recipes + - apps + - scheduler + - extensions + - settings + + # Settings tabs to show. Comment out to hide specific tabs. + settingsTabs: + - models + - local-inference + - chat + - sharing + - prompts + - keyboard + - app + + # Hide specific setting sections within tabs + # hiddenSettingSections: + # - "external-backend" + # - "tunnel" + # - "session-sharing" + # - "telemetry" + # - "update" + # - "navigation-customization" + # - "theme" + + # Restrict to specific providers only (empty = all providers available) + # allowedProviders: + # - "openai" + # - "anthropic" + + # Restrict dictation to specific providers (null = all allowed) + # dictationAllowedProviders: + # - "openai" + +# --------------------------------------------------------------------------- +# Defaults — pre-seeded values for first launch +# --------------------------------------------------------------------------- +defaults: + # Default provider and model (sets GOOSE_DEFAULT_PROVIDER / GOOSE_DEFAULT_MODEL) + # provider: "anthropic" + # model: "claude-sonnet-4-20250514" + + # Pre-configured extensions to install on first launch + # extensions: + # - name: "my-custom-tool" + # type: "sse" + # uri: "https://my-tool-server.example.com/sse" + # enabled: true + # envVars: + # API_KEY: "" + # - name: "internal-kb" + # type: "stdio" + # cmd: "npx" + # args: ["-y", "@company/kb-mcp-server"] + # enabled: true + + # Working directory to start in (if not set, uses OS home) + # workingDir: "~/Projects" + + # Default system prompt override + # systemPrompt: "You are a helpful coding assistant for Acme Corp." + + # Default goosehints content (written to .goosehints if not present) + # goosehints: | + # Always follow our coding standards at https://internal.acme.com/standards + +# --------------------------------------------------------------------------- +# Processes — external processes to manage alongside the app +# --------------------------------------------------------------------------- +# processes: +# - name: "my-sidecar" +# command: "node" +# args: ["./sidecar/server.js"] +# cwd: "." # relative to app resources +# env: +# PORT: "9999" +# restartOnCrash: true +# waitForPort: 9999 # app waits for this port before proceeding +# waitTimeoutMs: 10000 + +# --------------------------------------------------------------------------- +# Window — default window appearance +# --------------------------------------------------------------------------- +window: + width: 940 + height: 800 + minWidth: 450 + # alwaysOnTop: false + # resizable: true