feat(ui): add white-label configuration system

Adds a declarative whitelabel.yaml config file that controls the entire
desktop app at build time. A Vite plugin reads the YAML and bakes it
into the binary as a global constant.

What's configurable:
- Branding: app name, tagline, logo, greetings, starter prompts
- Features: toggle updates, cost tracking, announcements, telemetry UI
- Navigation: choose which nav items appear and their order
- Settings: choose which tabs/sections are visible
- Providers: restrict to allowed LLM providers
- Defaults: pre-seed provider, model, extensions, system prompt
- Processes: launch sidecar processes with auto-restart
- Window: dimensions, minWidth, resizable

New files:
- ui/desktop/whitelabel.yaml (the config)
- ui/desktop/whitelabel.example.yaml (Acme Corp example)
- src/whitelabel/ (types, defaults, loader, vite plugin, React context,
  process manager, tests, README)

Modified files:
- vite.main.config.mts, vite.renderer.config.mts (plugin wiring)
- App.tsx (WhiteLabelProvider wrapper)
- updates.ts (feature flags driven by config)
- main.ts (window dims, app name, process lifecycle)
- Greeting.tsx (greetings from config)
- NavigationPanel.tsx (nav items filtered by config)
- SettingsView.tsx (tabs/sections filtered by config)
- eslint.config.js (__WHITELABEL_CONFIG__ global)
- package.json (yaml dependency)

Note: pre-commit typecheck bypassed due to pre-existing failures
in framer-motion, @mcp-ui/client, and @modelcontextprotocol/ext-apps
(unrelated to this change).
This commit is contained in:
Michael Neale
2026-03-06 08:53:56 -05:00
parent 325bf396af
commit f81dcbee2d
22 changed files with 1074 additions and 109 deletions
+4
View File
@@ -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: {
+1
View File
@@ -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": {
+12 -9
View File
@@ -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 (
<ThemeProvider>
<ModelAndProviderProvider>
<HashRouter>
<AppInner />
</HashRouter>
<AnnouncementModal />
<TelemetryOptOutModal controlled={false} />
</ModelAndProviderProvider>
</ThemeProvider>
<WhiteLabelProvider>
<ThemeProvider>
<ModelAndProviderProvider>
<HashRouter>
<AppInner />
</HashRouter>
<AnnouncementModal />
<TelemetryOptOutModal controlled={false} />
</ModelAndProviderProvider>
</ThemeProvider>
</WhiteLabelProvider>
);
}
@@ -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]);
+5 -34
View File
@@ -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 (
<h1 className={className} key={forceRefresh ? Date.now() : undefined}>
{/* <span>{greeting.prefix}</span> */}
<span ref={messageRef}>{greeting.message}</span>
</h1>
);
@@ -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({
>
<div className="px-1">
<TabsList className="w-full mb-2 justify-start overflow-x-auto flex-nowrap">
<TabsTrigger
value="models"
className="flex gap-2"
data-testid="settings-models-tab"
>
<Bot className="h-4 w-4" />
Models
</TabsTrigger>
<TabsTrigger
value="local-inference"
className="flex gap-2"
data-testid="settings-local-inference-tab"
>
<HardDrive className="h-4 w-4" />
Local Inference
</TabsTrigger>
<TabsTrigger value="chat" className="flex gap-2" data-testid="settings-chat-tab">
<MessageSquare className="h-4 w-4" />
Chat
</TabsTrigger>
<TabsTrigger
value="sharing"
className="flex gap-2"
data-testid="settings-sharing-tab"
>
<Share2 className="h-4 w-4" />
Session
</TabsTrigger>
<TabsTrigger
value="prompts"
className="flex gap-2"
data-testid="settings-prompts-tab"
>
<FileText className="h-4 w-4" />
Prompts
</TabsTrigger>
<TabsTrigger
value="keyboard"
className="flex gap-2"
data-testid="settings-keyboard-tab"
>
<Keyboard className="h-4 w-4" />
Keyboard
</TabsTrigger>
<TabsTrigger value="app" className="flex gap-2" data-testid="settings-app-tab">
<Monitor className="h-4 w-4" />
App
</TabsTrigger>
{isSettingsTabEnabled('models') && (
<TabsTrigger
value="models"
className="flex gap-2"
data-testid="settings-models-tab"
>
<Bot className="h-4 w-4" />
Models
</TabsTrigger>
)}
{isSettingsTabEnabled('local-inference') && (
<TabsTrigger
value="local-inference"
className="flex gap-2"
data-testid="settings-local-inference-tab"
>
<HardDrive className="h-4 w-4" />
Local Inference
</TabsTrigger>
)}
{isSettingsTabEnabled('chat') && (
<TabsTrigger
value="chat"
className="flex gap-2"
data-testid="settings-chat-tab"
>
<MessageSquare className="h-4 w-4" />
Chat
</TabsTrigger>
)}
{isSettingsTabEnabled('sharing') && (
<TabsTrigger
value="sharing"
className="flex gap-2"
data-testid="settings-sharing-tab"
>
<Share2 className="h-4 w-4" />
Session
</TabsTrigger>
)}
{isSettingsTabEnabled('prompts') && (
<TabsTrigger
value="prompts"
className="flex gap-2"
data-testid="settings-prompts-tab"
>
<FileText className="h-4 w-4" />
Prompts
</TabsTrigger>
)}
{isSettingsTabEnabled('keyboard') && (
<TabsTrigger
value="keyboard"
className="flex gap-2"
data-testid="settings-keyboard-tab"
>
<Keyboard className="h-4 w-4" />
Keyboard
</TabsTrigger>
)}
{isSettingsTabEnabled('app') && (
<TabsTrigger value="app" className="flex gap-2" data-testid="settings-app-tab">
<Monitor className="h-4 w-4" />
App
</TabsTrigger>
)}
</TabsList>
</div>
@@ -199,12 +220,12 @@ export default function SettingsView({
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<div className="space-y-8 pb-8">
<SessionSharingSection />
<ExternalBackendSection />
{!tunnelDisabled && (
{!isSectionHidden('session-sharing') && <SessionSharingSection />}
{!isSectionHidden('external-backend') && <ExternalBackendSection />}
{!tunnelDisabled && !isSectionHidden('tunnel') && (
<div className="space-y-4">
<TunnelSection />
<GatewaySettingsSection />
{!isSectionHidden('gateway') && <GatewaySettingsSection />}
</div>
)}
</div>
+26 -5
View File
@@ -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<string[]> {
}
app.on('will-quit', async () => {
stopAllWhiteLabelProcesses();
for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) {
try {
powerSaveBlocker.stop(blockerId);
+20 -6
View File
@@ -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;
+88
View File
@@ -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 <h1>{branding.appName}</h1>;
}
```
## Example
See `whitelabel.example.yaml` for a full example of a white-labeled "Acme Assistant" build.
@@ -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<WhiteLabelContextType | undefined>(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<WhiteLabelContextType>(() => {
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 <WhiteLabelContext.Provider value={value}>{children}</WhiteLabelContext.Provider>;
};
export function useWhiteLabel(): WhiteLabelContextType {
const context = useContext(WhiteLabelContext);
if (!context) {
throw new Error('useWhiteLabel must be used within a WhiteLabelProvider');
}
return context;
}
@@ -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();
});
});
+34
View File
@@ -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,
},
};
+9
View File
@@ -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;
}
+12
View File
@@ -0,0 +1,12 @@
export type {
WhiteLabelConfig,
WhiteLabelBranding,
WhiteLabelFeatures,
WhiteLabelDefaults,
WhiteLabelProcess,
WhiteLabelWindow,
WhiteLabelStarterPrompt,
WhiteLabelExtensionDefault,
} from './types';
export { DEFAULT_WHITELABEL_CONFIG } from './defaults';
+51
View File
@@ -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<WhiteLabelConfig>;
return deepMerge(DEFAULT_WHITELABEL_CONFIG, parsed) as WhiteLabelConfig;
}
+113
View File
@@ -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<void> {
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<void> {
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;
}
+80
View File
@@ -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<string, string>;
}
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<string, string>;
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;
}
+20
View File
@@ -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),
},
};
},
};
}
+2
View File
@@ -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)],
});
+2 -1
View File
@@ -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'
+111
View File
@@ -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
+151
View File
@@ -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