diff --git a/desktop/src/main/http-relay.ts b/desktop/src/main/http-relay.ts new file mode 100644 index 00000000..d2b3677c --- /dev/null +++ b/desktop/src/main/http-relay.ts @@ -0,0 +1,92 @@ +import { ipcMain, net } from 'electron' + +// A small HTTP relay so the renderer can reach external HTTPS endpoints from +// the main process (the file:// renderer origin is otherwise blocked by CORS). +// It's deliberately generic and carries no product-specific knowledge; any +// optional extension can use it. Requests are limited to https to avoid it +// becoming an open local proxy. + +export interface RelayRequest { + url: string + method?: string + headers?: Record + // Stringified body (callers serialize JSON/form themselves). + body?: string +} + +export interface RelayResponse { + ok: boolean + status: number + headers: Record + body: string +} + +const MAX_BODY_BYTES = 8 * 1024 * 1024 + +function relay(req: RelayRequest): Promise { + return new Promise((resolve, reject) => { + let parsed: URL + try { + parsed = new URL(req.url) + } catch { + reject(new Error('invalid url')) + return + } + if (parsed.protocol !== 'https:') { + reject(new Error('only https is allowed')) + return + } + + const request = net.request({ + method: req.method || 'GET', + url: req.url, + }) + if (req.headers) { + for (const [k, v] of Object.entries(req.headers)) request.setHeader(k, v) + } + + request.on('response', (response) => { + const chunks: Buffer[] = [] + let size = 0 + let aborted = false + response.on('data', (chunk: Buffer) => { + if (aborted) return + size += chunk.length + if (size > MAX_BODY_BYTES) { + aborted = true + reject(new Error('response too large')) + return + } + chunks.push(chunk) + }) + response.on('end', () => { + if (aborted) return + const headers: Record = {} + for (const [k, v] of Object.entries(response.headers)) { + headers[k] = Array.isArray(v) ? v.join(', ') : String(v) + } + const status = response.statusCode || 0 + resolve({ + ok: status >= 200 && status < 300, + status, + headers, + body: Buffer.concat(chunks).toString('utf8'), + }) + }) + }) + request.on('error', (err) => reject(err)) + + if (req.body != null) request.write(req.body) + request.end() + }) +} + +export function setupHttpRelayIPC() { + ipcMain.handle('http-relay', async (_event, req: RelayRequest) => { + try { + return await relay(req) + } catch (e) { + return { ok: false, status: 0, headers: {}, body: String((e as Error).message) } + } + }) +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 586dff2f..6f6c945b 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -6,11 +6,13 @@ import { PythonBackend } from './python-manager' import { buildAppMenu } from './menu' import { createTray, destroyTray } from './tray' import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater' -import { setupThemeIPC } from './themes' +import { setupThemeIPC, loadAppConfig } from './themes' +import { setupHttpRelayIPC } from './http-relay' -// Force the product name so the Dock/menu shows "CowAgent" even in dev mode, -// where the default Electron binary would otherwise report "Electron". -app.setName('CowAgent') +// Force the product name so the Dock/menu shows the app name even in dev mode, +// where the default Electron binary would otherwise report "Electron". The name +// can be overridden by the bundled app-config (appName); defaults to CowAgent. +app.setName(loadAppConfig()?.appName || 'CowAgent') let mainWindow: BrowserWindow | null = null let pythonBackend: PythonBackend | null = null @@ -308,6 +310,7 @@ app.whenReady().then(async () => { setupIPC() setupThemeIPC() + setupHttpRelayIPC() createWindow() buildAppMenu(() => mainWindow) // No menu-bar tray on macOS — the Dock + window controls are enough there. diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts index 2eb22201..4f2619d8 100644 --- a/desktop/src/main/preload.ts +++ b/desktop/src/main/preload.ts @@ -51,6 +51,21 @@ contextBridge.exposeInMainWorld('electronAPI', { getAppConfig: () => ipcRenderer.invoke('app-config-get') as Promise<{ defaultTheme?: string; appName?: string } | null>, + // Generic HTTPS relay via the main process (bypasses the renderer's CORS + // restrictions for external endpoints). Optional extensions may use it. + httpRelay: (req: { + url: string + method?: string + headers?: Record + body?: string + }) => + ipcRenderer.invoke('http-relay', req) as Promise<{ + ok: boolean + status: number + headers: Record + body: string + }>, + // Auto-update: trigger checks/download/install and subscribe to status. The // optional lang routes installer downloads to the China CDN mirror (zh) or R2. checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang), diff --git a/desktop/src/main/themes.ts b/desktop/src/main/themes.ts index 8d0d1c9e..c2ff2222 100644 --- a/desktop/src/main/themes.ts +++ b/desktop/src/main/themes.ts @@ -59,6 +59,9 @@ function bundledThemesDir(): string | null { export interface AppConfig { defaultTheme?: string appName?: string + // Optional override for the auto-update feed base URL. When set, the updater + // uses it as-is instead of the default build's feed. + updateFeedUrl?: string } function appConfigPath(): string { diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index 7164ef77..5a2ddd83 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -7,6 +7,7 @@ import path from 'path' // import compiles to `electron_updater_1.autoUpdater` and resolves correctly, // whereas `import pkg from 'electron-updater'` yields undefined. import { autoUpdater } from 'electron-updater' +import { loadAppConfig } from './themes' // Status payloads pushed to the renderer over the 'update-status' channel. // The renderer drives the NavRail badge + update panel from these. @@ -33,6 +34,11 @@ function isLegacyWindows(): boolean { return Number.isFinite(major) && major < 10 } +// A bundled app-config may point the updater at a different feed origin. When +// set, that single URL is used as-is (no China/R2 dual-origin switching, which +// is specific to the default build's infrastructure). Absent -> default feed. +const CONFIGURED_FEED = (loadAppConfig()?.updateFeedUrl || '').trim() + // The update feed. Both entries hit the same Pages Function // (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer // downloads to the China CDN mirror instead of R2. The feed metadata is @@ -40,7 +46,10 @@ function isLegacyWindows(): boolean { // to fall back from one download origin to the other. Legacy Windows appends a // /legacy/ segment so it gets the win-legacy release instead of the standard. const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '') -const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE) +const feedUrlFor = (china: boolean) => { + if (CONFIGURED_FEED) return CONFIGURED_FEED + return china ? `${FEED_BASE}?lang=zh` : FEED_BASE +} // Which origin the current session prefers, derived from the app UI language // (zh -> China mirror). Downloads that fail on the preferred origin retry once diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index 004c4e60..d9b00ec0 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -23,6 +23,7 @@ import MemoryPage from './pages/MemoryPage' import ChannelsPage from './pages/ChannelsPage' import TasksPage from './pages/TasksPage' import LogsPage from './pages/LogsPage' +import { product } from '@product' const App: React.FC = () => { const backend = useBackend() @@ -37,6 +38,11 @@ const App: React.FC = () => { // whether login is needed; 'need_login' shows the password screen; 'ok' lets // the main UI render. const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking') + const [productAuthed, setProductAuthed] = useState(false) + // Optional gate provided by '@product'. `product.auth` is constant for the + // whole build, so calling its hook conditionally is stable across renders. + // eslint-disable-next-line react-hooks/rules-of-hooks + const productRequiresAuth = product.auth ? product.auth.useRequiresAuth() : false useEffect(() => { if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl) @@ -72,6 +78,8 @@ const App: React.FC = () => { // configured (and not dismissed earlier this session); no persisted flag. useEffect(() => { if (backend.status !== 'ready' || authState !== 'ok') return + // An extension may opt out of the built-in setup wizard. + if (product.onboarding?.enabled === false) return let cancelled = false apiClient .getModels() @@ -130,8 +138,14 @@ const App: React.FC = () => { return setAuthState('ok')} /> } + // Optional gate from '@product', shown after the local auth check passes. + // Rendered inside the layout (nav rail stays visible) so the app's features + // are on display while the login card sits in the content area. + const ProductGate = product.auth?.Gate + const showProductGate = !!(ProductGate && productRequiresAuth && !productAuthed) + const isChat = location.pathname === '/' - const showSessions = isChat && !sessionsCollapsed + const showSessions = isChat && !sessionsCollapsed && !showProductGate return (
@@ -156,11 +170,19 @@ const App: React.FC = () => { )}
+ {product.slots?.HeaderRight && ( +
+ +
+ )} {isWin && } {/* Content */}
+ {showProductGate && ProductGate ? ( + setProductAuthed(true)} /> + ) : ( } /> } /> @@ -172,7 +194,11 @@ const App: React.FC = () => { {/* Legacy /models route now lives as a tab inside settings */} } /> } /> + {product.routes?.map((r) => ( + + ))} + )}
diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx index 0add57ed..226b5d78 100644 --- a/desktop/src/renderer/src/layout/NavRail.tsx +++ b/desktop/src/renderer/src/layout/NavRail.tsx @@ -34,6 +34,7 @@ import { useTheme } from '../hooks/useTheme' import { usePlatform } from '../hooks/usePlatform' import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore' import UpdateBanner from '../components/UpdateBanner' +import { product } from '@product' // Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep // in sync with desktop/package.json "version"; the packaged app overrides this @@ -220,7 +221,9 @@ const NavRail: React.FC = ({ onLangChange }) => { {/* Footer actions: a single "more" entry (with version + update dot) that - opens an upward popover, plus the always-visible collapse toggle. */} + opens an upward popover, plus the always-visible collapse toggle. An + optional '@product' slot sits on the left (e.g. an account avatar), + taking the spot the "more" entry would otherwise occupy. */}
{menuOpen && ( = ({ onLangChange }) => { )}
- {/* Single clickable entry: version label (left) + the three dots - (right) form one button; the whole block opens the popover. The - version is the packaged app version, also what auto-update - compares against. Collapsed: dots only, version hidden. */} - + {/* Left side: either the built-in "more" entry (version + dots) or, + when an extension provides one and hides the built-in menu, its + footer slot (e.g. an account avatar). */} + {product.slots?.NavRailFooter && product.nav?.hideFooterMenu ? ( +
+ +
+ ) : ( + !product.nav?.hideFooterMenu && ( + + ) + )} - {!collapsed &&
} + {!collapsed && !(product.slots?.NavRailFooter && product.nav?.hideFooterMenu) && ( +
+ )} {collapsed ? : } @@ -335,17 +347,21 @@ const FooterMenu: React.FC<{ : t('update_check') return (
- {/* External destinations first (skill hub, docs, website) */} - } label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} /> - } label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} /> - } label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} /> - } - label={t('menu_feedback')} - onClick={() => onOpenLink(FEEDBACK_URL)} - /> - -
+ {/* External destinations (skill hub, docs, website, feedback). An + extension may hide this group to keep the menu to app actions only. */} + {!product.nav?.hideExternalLinks && ( + <> + } label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} /> + } label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} /> + } label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} /> + } + label={t('menu_feedback')} + onClick={() => onOpenLink(FEEDBACK_URL)} + /> +
+ + )} {/* App actions below: update, theme, language, logs */} = ({ baseUrl, onLangChange }) => { const location = useLocation() + const modelsTabHidden = product.models?.hideModelsTab === true // Allow deep-linking to the models tab via /settings?tab=models. - const initial: Tab = new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic' + const initial: Tab = + !modelsTabHidden && new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic' const [tab, setTab] = useState(initial) const tabs: { key: Tab; label: string }[] = [ { key: 'basic', label: t('settings_tab_basic') }, - { key: 'models', label: t('settings_tab_models') }, + ...(modelsTabHidden ? [] : [{ key: 'models' as Tab, label: t('settings_tab_models') }]), ] return ( @@ -47,8 +50,12 @@ const SettingsPage: React.FC = ({ baseUrl, onLangChange }) => ))}
- {tab === 'basic' ? ( - setTab('models')} /> + {tab === 'basic' || modelsTabHidden ? ( + setTab('models')} + /> ) : ( )} diff --git a/desktop/src/renderer/src/pages/settings/BasicSettings.tsx b/desktop/src/renderer/src/pages/settings/BasicSettings.tsx index 3395ceff..77157c2c 100644 --- a/desktop/src/renderer/src/pages/settings/BasicSettings.tsx +++ b/desktop/src/renderer/src/pages/settings/BasicSettings.tsx @@ -2,9 +2,14 @@ import React, { useState, useEffect } from 'react' import { Cpu, Bot, ShieldCheck, Languages, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react' import { t, getLang, setLang, localizedLabel, type Lang } from '../../i18n' import apiClient from '../../api/client' +import { product } from '@product' import type { ConfigData, ProviderMeta } from '../../types' import { Card, Field, Dropdown, Toggle, TextInput, SaveRow, MASK_RE } from './primitives' +const CustomModelPicker = product.models?.ModelPicker +const hideProviderSelect = product.models?.hideProviderSelect === true +const showManagedApiKey = product.models?.showManagedApiKey === true + interface BasicSettingsProps { baseUrl: string onLangChange?: () => void @@ -22,6 +27,11 @@ const BasicSettings: React.FC = ({ baseUrl, onLangChange, on const [showCustom, setShowCustom] = useState(false) const [modelStatus, setModelStatus] = useState('') + // managed API key (shown only when the standalone models tab is hidden) + const [apiKey, setApiKey] = useState('') + const [apiKeyDirty, setApiKeyDirty] = useState(false) + const [apiKeyVisible, setApiKeyVisible] = useState(false) + // agent card const [maxTokens, setMaxTokens] = useState(100000) const [maxTurns, setMaxTurns] = useState(20) @@ -64,6 +74,10 @@ const BasicSettings: React.FC = ({ baseUrl, onLangChange, on const current = data.use_linkai ? 'linkai' : data.bot_type || ids[0] || '' setProvider(current) const meta = data.providers?.[current] as ProviderMeta | undefined + // Managed key: show the masked value for the current provider's key field. + const keyField = meta?.api_key_field + setApiKey((keyField && data.api_keys?.[keyField]) || '') + setApiKeyDirty(false) const presets = meta?.models || [] if (data.model && presets.length && !presets.includes(data.model)) { setShowCustom(true) @@ -99,8 +113,10 @@ const BasicSettings: React.FC = ({ baseUrl, onLangChange, on } const saveModelConfig = async () => { - const finalModel = showCustom ? customModel.trim() : model - const isLinkai = provider === 'linkai' + const finalModel = CustomModelPicker ? model : showCustom ? customModel.trim() : model + // With a managed model source the provider selector is hidden; route through + // the managed provider so credentials resolve consistently. + const isLinkai = CustomModelPicker ? true : provider === 'linkai' try { await apiClient.updateConfig({ model: finalModel, @@ -116,6 +132,27 @@ const BasicSettings: React.FC = ({ baseUrl, onLangChange, on setTimeout(() => setModelStatus(''), 2000) } + const currentKeyField = (config?.providers?.[provider] as ProviderMeta | undefined)?.api_key_field + + const saveApiKey = async () => { + if (!apiKeyDirty || !currentKeyField) return + // Never save a masked value back as the real key. + if (MASK_RE.test(apiKey)) return + try { + await apiClient.updateConfig({ [currentKeyField]: apiKey }) + setModelStatus(t('config_saved')) + setApiKeyDirty(false) + const fresh = await apiClient.getConfig() + setConfig(fresh) + const meta = fresh.providers?.[provider] as ProviderMeta | undefined + const keyField = meta?.api_key_field + setApiKey((keyField && fresh.api_keys?.[keyField]) || '') + } catch { + setModelStatus(t('config_save_error')) + } + setTimeout(() => setModelStatus(''), 2000) + } + const saveAgentConfig = async () => { try { await apiClient.updateConfig({ @@ -200,21 +237,60 @@ const BasicSettings: React.FC = ({ baseUrl, onLangChange, on {/* Model — provider/model selection only; credentials live in Models tab */} } title={t('config_model')}>
- - - + {!hideProviderSelect && ( + + + + )} - - {showCustom && ( - setCustomModel(e.target.value)} - placeholder={t('config_custom_model_hint')} - /> + {CustomModelPicker ? ( + + ) : ( + <> + + {showCustom && ( + setCustomModel(e.target.value)} + placeholder={t('config_custom_model_hint')} + /> + )} + )} + {/* Managed API key: hidden by default, click the eye to reveal the + partially-masked value (e.g. sk-1****9aL7). Editable in place; if + left untouched (still contains a mask char) it is not overwritten. */} + {showManagedApiKey && currentKeyField && ( + +
+ { + setApiKey(e.target.value.trim()) + setApiKeyDirty(true) + }} + /> + +
+
+ )} + {/* Guide users to the Models tab for API key / base config. When the selected provider has no credentials, surface a warning. */} {onOpenModels && ( @@ -240,7 +316,13 @@ const BasicSettings: React.FC = ({ baseUrl, onLangChange, on )} - + { + await saveModelConfig() + if (showManagedApiKey && apiKeyDirty) await saveApiKey() + }} + />
diff --git a/desktop/src/renderer/src/pages/settings/ModelsTab.tsx b/desktop/src/renderer/src/pages/settings/ModelsTab.tsx index 7ad8ac9d..08f91552 100644 --- a/desktop/src/renderer/src/pages/settings/ModelsTab.tsx +++ b/desktop/src/renderer/src/pages/settings/ModelsTab.tsx @@ -20,6 +20,10 @@ import type { CapabilityState, ModelsData, ModelProvider, SearchCapabilityState import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives' import CapabilityCard from './CapabilityCard' import { normEntries, providerLabel, resolveVoices, CUSTOM_OPTION } from './modelsHelpers' +import { product } from '@product' + +// Whether the "add custom provider" entry is available. Defaults to true. +const allowCustomProviders = product.models?.allowCustomProviders !== false interface ModelsTabProps { baseUrl: string @@ -330,7 +334,9 @@ const VendorModal: React.FC<{ label: localizedLabel(p.label), hint: p.configured ? t('models_configured') : undefined, })), - { value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') }, + ...(allowCustomProviders + ? [{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') }] + : []), ] const onPick = (val: string) => { diff --git a/desktop/src/renderer/src/product/default/index.ts b/desktop/src/renderer/src/product/default/index.ts new file mode 100644 index 00000000..b7aff3eb --- /dev/null +++ b/desktop/src/renderer/src/product/default/index.ts @@ -0,0 +1,6 @@ +import type { ProductExtension } from '../types' + +// Default: no extensions. All behavior stays as-is. An alternate build can +// override the '@product' alias to point at its own module (see +// vite.config.ts) exporting a populated ProductExtension. +export const product: ProductExtension = {} diff --git a/desktop/src/renderer/src/product/types.ts b/desktop/src/renderer/src/product/types.ts new file mode 100644 index 00000000..73b5dfa0 --- /dev/null +++ b/desktop/src/renderer/src/product/types.ts @@ -0,0 +1,77 @@ +// ============================================================ +// Optional extension contract. +// +// The core imports a single `product` object from '@product'. By default +// that alias resolves to product/default (an empty object → no change). +// An alternate build can point the alias at another module (see +// vite.config.ts COW_PRODUCT_DIR) and fill in the fields below. Every +// field is optional; absent means "keep the default behavior". The core +// must degrade gracefully when a field is missing. +// ============================================================ +import type React from 'react' + +// Optional gate rendered before the main UI. When present, the core shows +// until the extension reports the session no longer needs it. +export interface ProductAuth { + Gate: React.FC<{ onAuthenticated: () => void }> + // Whether the gate is currently required. Implementations may use their + // own hooks/state internally. + useRequiresAuth: () => boolean +} + +// Optional UI mount points the core renders if provided. +export interface ProductSlots { + // Rendered at the bottom of the nav rail. + NavRailFooter?: React.FC + // Rendered on the right side of the top titlebar strip. + HeaderRight?: React.FC +} + +// Extra routes appended to the core . Path is a HashRouter path. +export interface ProductRoute { + path: string + element: React.ReactNode +} + +export interface ProductOnboarding { + // Set false to disable the built-in setup wizard. Defaults to enabled. + enabled?: boolean +} + +export interface ProductModels { + // Set false to hide the "add custom provider" entry. Defaults to allowed. + allowCustomProviders?: boolean + // Set true to hide the standalone "models" settings tab. Defaults to shown. + hideModelsTab?: boolean + // Set true to hide the provider dropdown in basic settings (e.g. when the + // model list comes from a single managed source). Defaults to shown. + hideProviderSelect?: boolean + // Optional replacement for the model selection control in basic settings. + // Controlled: receives the current model id and reports changes. When set, + // the core renders this instead of its built-in model dropdown. + ModelPicker?: React.FC<{ value: string; onChange: (model: string) => void }> + // Set true to show a masked+editable API key field for the current provider + // inside basic settings, useful when the standalone models tab is hidden. + showManagedApiKey?: boolean +} + +// Optional nav-rail customization. Lets a build tailor the footer menu's +// external destinations without touching core code. +export interface ProductNav { + // Set true to hide the built-in external links group (skill hub, docs, + // website, feedback). Defaults to shown. + hideExternalLinks?: boolean + // Set true to hide the built-in footer "more" entry (version label + menu), + // e.g. when an extension provides its own footer menu. The collapse toggle + // stays. Defaults to shown. + hideFooterMenu?: boolean +} + +export interface ProductExtension { + auth?: ProductAuth + slots?: ProductSlots + routes?: ProductRoute[] + onboarding?: ProductOnboarding + models?: ProductModels + nav?: ProductNav +} diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 21a1fcef..5282d30a 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -27,6 +27,13 @@ export interface ElectronAPI { // Optional app config: first-run default theme + display name. Null when // the build ships no app config (standard build). getAppConfig?: () => Promise<{ defaultTheme?: string; appName?: string } | null> + // Generic HTTPS relay via the main process (bypasses renderer CORS). + httpRelay?: (req: { + url: string + method?: string + headers?: Record + body?: string + }) => Promise<{ ok: boolean; status: number; headers: Record; body: string }> // Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN. checkForUpdate?: (lang?: string) => Promise downloadUpdate?: (lang?: string) => Promise diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index dc51e5b6..f17560ab 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -1,6 +1,14 @@ +const path = require('path') + +// When '@product' points outside this project (COW_PRODUCT_DIR), scan that +// directory too so classes used only there still get generated. +const productContent = process.env.COW_PRODUCT_DIR + ? [path.join(process.env.COW_PRODUCT_DIR, '**/*.{tsx,ts}')] + : [] + /** @type {import('tailwindcss').Config} */ module.exports = { - content: ['./src/renderer/**/*.{html,tsx,ts}'], + content: ['./src/renderer/**/*.{html,tsx,ts}', ...productContent], darkMode: 'class', theme: { extend: { diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index d6d5250a..80ba32df 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -16,7 +16,12 @@ "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, "resolveJsonModule": true, - "esModuleInterop": true + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/renderer/src/*"], + "@product": ["src/renderer/src/product/default"] + } }, "include": ["src/renderer"] } diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 942bd13c..217604d9 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -2,6 +2,25 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' +// '@product' resolves to the built-in default (empty). An alternate build +// can set COW_PRODUCT_DIR to point at another module instead. +const productDir = + process.env.COW_PRODUCT_DIR || path.resolve(__dirname, 'src/renderer/src/product/default') + +// When '@product' points outside this project, its files can't resolve shared +// deps from their own tree. Alias the shared runtime deps to this project's +// node_modules so an out-of-tree product module imports the same instances. +const nodeModules = path.resolve(__dirname, 'node_modules') +const sharedDepAliases = process.env.COW_PRODUCT_DIR + ? { + react: path.join(nodeModules, 'react'), + 'react-dom': path.join(nodeModules, 'react-dom'), + 'react/jsx-runtime': path.join(nodeModules, 'react/jsx-runtime'), + 'react-router-dom': path.join(nodeModules, 'react-router-dom'), + 'lucide-react': path.join(nodeModules, 'lucide-react'), + } + : {} + export default defineConfig({ plugins: [react()], root: path.resolve(__dirname, 'src/renderer'), @@ -17,6 +36,8 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, 'src/renderer/src'), + '@product': productDir, + ...sharedDepAliases, }, }, })