fix(desktop): hot-reload development themes
This commit is contained in:
@@ -2450,6 +2450,13 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
|||||||
if (url.protocol === 'devtools:') return true;
|
if (url.protocol === 'devtools:') return true;
|
||||||
if (url.protocol === `${UI_PROTOCOL}:`) return true;
|
if (url.protocol === `${UI_PROTOCOL}:`) return true;
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||||
|
// In development the renderer is served by Vite while state.localOrigin
|
||||||
|
// remains the separate local API server. Permit same-origin reloads from
|
||||||
|
// the renderer itself so Vite full-reload fallbacks stay in Electron.
|
||||||
|
try {
|
||||||
|
if (new URL(browserWindow.webContents.getURL()).origin === url.origin) return true;
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
if (state.localOrigin) {
|
if (state.localOrigin) {
|
||||||
try {
|
try {
|
||||||
if (new URL(state.localOrigin).origin === url.origin) return true;
|
if (new URL(state.localOrigin).origin === url.origin) return true;
|
||||||
@@ -4681,6 +4688,9 @@ const isLocalSender = (webContents) => {
|
|||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
const url = new URL(raw);
|
const url = new URL(raw);
|
||||||
if (url.protocol === `${UI_PROTOCOL}:` && url.hostname === 'app') return true;
|
if (url.protocol === `${UI_PROTOCOL}:` && url.hostname === 'app') return true;
|
||||||
|
// Electron dev renders from Vite while the local API is served on a
|
||||||
|
// separate port. This exact loopback HMR origin is trusted only in dev.
|
||||||
|
if (isDev && url.origin === `http://127.0.0.1:${process.env.OPENCHAMBER_HMR_UI_PORT || '5173'}`) return true;
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||||
if (state.localOrigin) {
|
if (state.localOrigin) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
DEFAULT_LIGHT_THEME_ID,
|
DEFAULT_LIGHT_THEME_ID,
|
||||||
DEFAULT_DARK_THEME_ID,
|
DEFAULT_DARK_THEME_ID,
|
||||||
} from '@/lib/theme/themes';
|
} from '@/lib/theme/themes';
|
||||||
|
import { withPrColors } from '@/lib/theme/themes/prColors';
|
||||||
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
|
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
|
||||||
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
|
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
@@ -158,6 +159,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
|
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
|
||||||
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getInitialSystemPreference());
|
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getInitialSystemPreference());
|
||||||
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
|
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
|
||||||
|
const [developmentThemes, setDevelopmentThemes] = useState<Theme[]>([]);
|
||||||
const [embeddedBootstrapTheme] = useState<Theme | null>(() => readEmbeddedCurrentTheme());
|
const [embeddedBootstrapTheme] = useState<Theme | null>(() => readEmbeddedCurrentTheme());
|
||||||
const [embeddedSyncedTheme, setEmbeddedSyncedTheme] = useState<Theme | null>(null);
|
const [embeddedSyncedTheme, setEmbeddedSyncedTheme] = useState<Theme | null>(null);
|
||||||
const [customThemesLoading, setCustomThemesLoading] = useState(false);
|
const [customThemesLoading, setCustomThemesLoading] = useState(false);
|
||||||
@@ -204,10 +206,32 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
|
|
||||||
// Custom themes first so they can override built-ins with the same id.
|
// Custom themes first so they can override built-ins with the same id.
|
||||||
customThemes.forEach(add);
|
customThemes.forEach(add);
|
||||||
|
// Vite publishes valid built-in JSON edits through this development-only
|
||||||
|
// runtime channel, avoiding a full page reload for theme work.
|
||||||
|
developmentThemes.forEach(add);
|
||||||
themes.forEach(add);
|
themes.forEach(add);
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}, [customThemes, embeddedBootstrapTheme, embeddedSyncedTheme, isVSCode, vscodeTheme]);
|
}, [customThemes, developmentThemes, embeddedBootstrapTheme, embeddedSyncedTheme, isVSCode, vscodeTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleThemeHmr = (event: Event) => {
|
||||||
|
const theme = (event as CustomEvent<unknown>).detail;
|
||||||
|
if (!isValidTheme(theme)) return;
|
||||||
|
|
||||||
|
const nextTheme = withPrColors(theme);
|
||||||
|
setDevelopmentThemes((previous) => {
|
||||||
|
const index = previous.findIndex((candidate) => candidate.metadata.id === nextTheme.metadata.id);
|
||||||
|
if (index < 0) return [...previous, nextTheme];
|
||||||
|
const next = [...previous];
|
||||||
|
next[index] = nextTheme;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('openchamber:theme-hmr', handleThemeHmr);
|
||||||
|
return () => window.removeEventListener('openchamber:theme-hmr', handleThemeHmr);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getThemeByIdFromAvailable = useCallback(
|
const getThemeByIdFromAvailable = useCallback(
|
||||||
(themeId: string): Theme | undefined => availableThemes.find((theme) => theme.metadata.id === themeId),
|
(themeId: string): Theme | undefined => availableThemes.find((theme) => theme.metadata.id === themeId),
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ const start = async (): Promise<void> => {
|
|||||||
|
|
||||||
void start();
|
void start();
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.on('openchamber:theme-updated', (theme: unknown) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('openchamber:theme-hmr', { detail: theme }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (import.meta.env.PROD) {
|
if (import.meta.env.PROD) {
|
||||||
registerPwaServiceWorker();
|
registerPwaServiceWorker();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,6 +11,31 @@ const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, 'package.jso
|
|||||||
const pwaDevEnabled = process.env.OPENCHAMBER_DISABLE_PWA_DEV !== '1';
|
const pwaDevEnabled = process.env.OPENCHAMBER_DISABLE_PWA_DEV !== '1';
|
||||||
const reactScanToggle = (process.env.VITE_ENABLE_REACT_SCAN ?? '').toLowerCase();
|
const reactScanToggle = (process.env.VITE_ENABLE_REACT_SCAN ?? '').toLowerCase();
|
||||||
const enableReactScan = reactScanToggle === '1' || reactScanToggle === 'true' || reactScanToggle === 'on' || reactScanToggle === 'yes';
|
const enableReactScan = reactScanToggle === '1' || reactScanToggle === 'true' || reactScanToggle === 'on' || reactScanToggle === 'yes';
|
||||||
|
const themeDirectory = path.resolve(__dirname, '../ui/src/lib/theme/themes');
|
||||||
|
|
||||||
|
const themeJsonHmrPlugin = () => ({
|
||||||
|
name: 'openchamber-theme-json-hmr',
|
||||||
|
handleHotUpdate({ file, server }: { file: string; server: { ws: { send: (payload: unknown) => void } } }) {
|
||||||
|
if (!file.startsWith(`${themeDirectory}${path.sep}`) || path.extname(file) !== '.json') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
server.ws.send({
|
||||||
|
type: 'custom',
|
||||||
|
event: 'openchamber:theme-updated',
|
||||||
|
data: JSON.parse(readFileSync(file, 'utf-8')),
|
||||||
|
});
|
||||||
|
// Theme JSON is applied by the runtime event listener. Returning no
|
||||||
|
// modules prevents Vite's otherwise unavoidable page-reload fallback.
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
// Leave the previous valid theme active while an editor writes invalid
|
||||||
|
// or incomplete JSON; the next valid save will replace it.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: path.resolve(__dirname, '.'),
|
root: path.resolve(__dirname, '.'),
|
||||||
@@ -39,6 +64,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
themeStoragePlugin(),
|
themeStoragePlugin(),
|
||||||
|
themeJsonHmrPlugin(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
|
|||||||
Reference in New Issue
Block a user