mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Add custom themes installation feature (#6612)
* Add custom themes feature with GitHub installation support - Add ThemeInstaller component for installing themes from GitHub - Implement custom theme validation and CSS parsing - Add support for installed custom themes in preferences - Add CustomThemeStyle component with CSS validation - Update theme system to support custom overlay backgrounds - Add comprehensive tests for theme installation and validation - Add documentation and release notes for custom themes feature * Update custom theme catalog: remove several themes and add 'Miami Beach' from a new repository. * Enhance CSS validation in custom themes - Refactor `validatePropertyValue` to implement an allowlist approach for CSS property values, rejecting complex constructs and functions except for rgb/rgba/hsl/hsla. - Add comprehensive tests for various invalid CSS scenarios, including function calls and at-rules. - Improve error messages for better clarity on validation failures. - Ensure property name validation checks for format and allowed characters. * Update custom theme catalog: rename theme from 'Miami Beach' to 'Shades of Coffee'. * Remove 'forceReload' feature flag and related code from the application settings and feature flag definitions. * Enhance ThemeInstaller component to support installed themes - Add `installedTheme` prop to `ThemeInstaller` for managing custom themes. - Implement logic to populate the text box with installed custom theme CSS when reopening. - Update tests to verify behavior for installed themes with and without repositories. - Improve CSS validation to allow additional CSS keywords and ensure proper structure.
This commit is contained in:
committed by
GitHub
parent
bb70074f35
commit
d01d0eacb8
@@ -41,6 +41,7 @@ import { installPolyfills } from '@desktop-client/polyfills';
|
||||
import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice';
|
||||
import { useDispatch, useSelector, useStore } from '@desktop-client/redux';
|
||||
import {
|
||||
CustomThemeStyle,
|
||||
hasHiddenScrollbars,
|
||||
ThemeStyle,
|
||||
useTheme,
|
||||
@@ -240,6 +241,7 @@ export function App() {
|
||||
<AppInner />
|
||||
</ErrorBoundary>
|
||||
<ThemeStyle />
|
||||
<CustomThemeStyle />
|
||||
<ErrorBoundary FallbackComponent={FatalError}>
|
||||
<Modals />
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -43,12 +43,14 @@ type ExternalLinkProps = {
|
||||
children?: ReactNode;
|
||||
to?: string;
|
||||
linkColor?: keyof typeof externalLinkColors;
|
||||
onClick?: MouseEventHandler;
|
||||
};
|
||||
|
||||
const ExternalLink = ({
|
||||
children,
|
||||
to,
|
||||
linkColor = 'blue',
|
||||
onClick,
|
||||
}: ExternalLinkProps) => {
|
||||
return (
|
||||
// we can't use <ExternalLink /> here for obvious reasons
|
||||
@@ -57,6 +59,7 @@ const ExternalLink = ({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: externalLinkColors[linkColor] }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -202,6 +202,12 @@ export function ExperimentalFeatures() {
|
||||
>
|
||||
<Trans>Crossover Report</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle
|
||||
flag="customThemes"
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/6607"
|
||||
>
|
||||
<Trans>Custom themes</Trans>
|
||||
</FeatureToggle>
|
||||
{showServerPrefs && (
|
||||
<ServerFeatureToggle
|
||||
prefName="flags.plugins"
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { ThemeInstaller } from './ThemeInstaller';
|
||||
|
||||
import {
|
||||
fetchThemeCss,
|
||||
validateThemeCss,
|
||||
} from '@desktop-client/style/customThemes';
|
||||
|
||||
vi.mock('@desktop-client/style/customThemes', async () => {
|
||||
const actual = await vi.importActual('@desktop-client/style/customThemes');
|
||||
return {
|
||||
...actual,
|
||||
fetchThemeCss: vi.fn(),
|
||||
validateThemeCss: vi.fn(),
|
||||
normalizeGitHubRepo: vi.fn((repo: string) =>
|
||||
repo.startsWith('http') ? repo : `https://github.com/${repo}`,
|
||||
),
|
||||
getThemeScreenshotUrl: vi.fn(
|
||||
(repo: string) =>
|
||||
`https://raw.githubusercontent.com/${repo}/refs/heads/main/screenshot.png`,
|
||||
),
|
||||
};
|
||||
});
|
||||
vi.mock('@desktop-client/data/customThemeCatalog.json', () => ({
|
||||
default: [
|
||||
{
|
||||
name: 'Demo Theme',
|
||||
repo: 'actualbudget/demo-theme',
|
||||
},
|
||||
{
|
||||
name: 'Ocean Blue',
|
||||
repo: 'actualbudget/ocean-theme',
|
||||
},
|
||||
{
|
||||
name: 'Forest Green',
|
||||
repo: 'actualbudget/forest-theme',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
describe('ThemeInstaller', () => {
|
||||
const mockOnInstall = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
const mockValidCss = `:root {
|
||||
--color-primary: #007bff;
|
||||
--color-secondary: #6c757d;
|
||||
}`;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnInstall.mockClear();
|
||||
mockOnClose.mockClear();
|
||||
vi.mocked(fetchThemeCss).mockResolvedValue(mockValidCss);
|
||||
vi.mocked(validateThemeCss).mockImplementation(css => css.trim());
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the component with title and close button', () => {
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Install Custom Theme')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders catalog themes', () => {
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Demo Theme' })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Ocean Blue' })).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Forest Green' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render error message initially', () => {
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
// Error messages have specific styling, check they're not present
|
||||
const errorElements = screen.queryAllByText(/Failed/i);
|
||||
expect(errorElements.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog theme selection', () => {
|
||||
it('calls onInstall when a catalog theme is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Demo Theme',
|
||||
repo: 'https://github.com/actualbudget/demo-theme',
|
||||
cssContent: mockValidCss,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears pasted CSS when a catalog theme is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
const cssText = ':root { --color-primary: #ff0000; }';
|
||||
await user.click(textArea);
|
||||
await user.paste(cssText);
|
||||
|
||||
expect(textArea).toHaveValue(cssText);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
expect(textArea).toHaveValue('');
|
||||
});
|
||||
|
||||
it('clears error when a catalog theme is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
// First, create an error by pasting invalid CSS
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
await user.click(textArea);
|
||||
await user.paste('invalid css');
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
|
||||
expect(screen.queryByText(/Failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling for catalog themes', () => {
|
||||
it('displays error when fetchThemeCss fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const errorMessage = 'Network error';
|
||||
const testOnInstall = vi.fn();
|
||||
|
||||
// Override the mock to reject directly BEFORE rendering
|
||||
vi.mocked(fetchThemeCss).mockImplementationOnce(() =>
|
||||
Promise.reject(new Error(errorMessage)),
|
||||
);
|
||||
|
||||
render(
|
||||
<ThemeInstaller onInstall={testOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
|
||||
// Wait for the error to be displayed - this confirms the rejection worked
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
// Verify fetchThemeCss was called with correct argument
|
||||
expect(fetchThemeCss).toHaveBeenCalledWith('actualbudget/demo-theme');
|
||||
|
||||
// Since error is displayed, onInstall should NOT be called
|
||||
expect(testOnInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays generic error when fetchThemeCss fails with non-Error object', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchThemeCss).mockRejectedValue('String error');
|
||||
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load theme')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockOnInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays error when validateThemeCss throws', async () => {
|
||||
const user = userEvent.setup();
|
||||
const validationError = 'Invalid CSS format';
|
||||
vi.mocked(validateThemeCss).mockImplementation(() => {
|
||||
throw new Error(validationError);
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(validationError)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockOnInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pasted CSS installation', () => {
|
||||
it('calls onInstall when valid CSS is pasted and Apply is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
await user.click(textArea);
|
||||
await user.paste(mockValidCss);
|
||||
|
||||
const applyButton = screen.getByText('Apply');
|
||||
await user.click(applyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(validateThemeCss).toHaveBeenCalledWith(mockValidCss);
|
||||
expect(mockOnInstall).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Custom Theme',
|
||||
repo: '',
|
||||
cssContent: mockValidCss,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace from pasted CSS before validation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
const cssWithWhitespace = ` ${mockValidCss} `;
|
||||
await user.click(textArea);
|
||||
await user.paste(cssWithWhitespace);
|
||||
|
||||
const applyButton = screen.getByText('Apply');
|
||||
await user.click(applyButton);
|
||||
|
||||
expect(validateThemeCss).toHaveBeenCalledWith(cssWithWhitespace.trim());
|
||||
});
|
||||
|
||||
it('does not call onInstall when Apply is clicked with empty CSS', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const applyButton = screen.getByText('Apply');
|
||||
expect(applyButton).toBeDisabled();
|
||||
|
||||
await user.click(applyButton);
|
||||
|
||||
expect(mockOnInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onInstall when Apply is clicked with whitespace-only CSS', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
await user.click(textArea);
|
||||
await user.paste(' ');
|
||||
|
||||
const applyButton = screen.getByText('Apply');
|
||||
expect(applyButton).toBeDisabled();
|
||||
|
||||
await user.click(applyButton);
|
||||
});
|
||||
|
||||
it('populates text box with installed custom theme CSS when reopening', () => {
|
||||
const installedCustomTheme = {
|
||||
id: 'theme-abc123',
|
||||
name: 'Custom Theme',
|
||||
repo: '',
|
||||
cssContent: mockValidCss,
|
||||
};
|
||||
|
||||
render(
|
||||
<ThemeInstaller
|
||||
onInstall={mockOnInstall}
|
||||
onClose={mockOnClose}
|
||||
installedTheme={installedCustomTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
expect(textArea).toHaveValue(mockValidCss);
|
||||
});
|
||||
|
||||
it('does not populate text box when installed theme has a repo', () => {
|
||||
const installedCatalogTheme = {
|
||||
id: 'theme-xyz789',
|
||||
name: 'Demo Theme',
|
||||
repo: 'https://github.com/actualbudget/demo-theme',
|
||||
cssContent: mockValidCss,
|
||||
};
|
||||
|
||||
render(
|
||||
<ThemeInstaller
|
||||
onInstall={mockOnInstall}
|
||||
onClose={mockOnClose}
|
||||
installedTheme={installedCatalogTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
expect(textArea).toHaveValue('');
|
||||
});
|
||||
|
||||
it('clears selected catalog theme when CSS is pasted', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
// First select a theme (which should be selected/highlighted)
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
await waitFor(() => {
|
||||
expect(fetchThemeCss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Then paste CSS
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
await user.click(textArea);
|
||||
await user.paste(mockValidCss);
|
||||
|
||||
// Selection should be cleared (we can't easily test visual state,
|
||||
// but the handler should clear it)
|
||||
expect(textArea).toHaveValue(mockValidCss);
|
||||
});
|
||||
|
||||
it('clears error when CSS is pasted', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
// First create an error
|
||||
vi.mocked(fetchThemeCss).mockRejectedValueOnce(new Error('Test error'));
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Then paste CSS
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
await user.click(textArea);
|
||||
await user.paste(mockValidCss);
|
||||
|
||||
// Error should be cleared
|
||||
expect(screen.queryByText('Test error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling for pasted CSS', () => {
|
||||
it('displays error when validateThemeCss throws for pasted CSS', async () => {
|
||||
const user = userEvent.setup();
|
||||
const validationError = 'Theme CSS must contain exactly :root';
|
||||
vi.mocked(validateThemeCss).mockImplementation(() => {
|
||||
throw new Error(validationError);
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const textArea = screen.getByRole('textbox', {
|
||||
name: 'Custom Theme CSS',
|
||||
});
|
||||
await user.click(textArea);
|
||||
await user.paste('invalid css');
|
||||
|
||||
const applyButton = screen.getByText('Apply');
|
||||
await user.click(applyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(validationError)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockOnInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('disables Apply button when loading', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchThemeCss).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => resolve(mockValidCss), 100);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
|
||||
const applyButton = screen.getByText('Apply');
|
||||
expect(applyButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Apply button when pasted CSS is empty during loading', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchThemeCss).mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => resolve(mockValidCss), 100);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
// Trigger loading state
|
||||
await user.click(screen.getByRole('button', { name: 'Demo Theme' }));
|
||||
|
||||
const applyButton = screen.getByText('Apply');
|
||||
expect(applyButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('close button', () => {
|
||||
it('calls onClose when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByText('Close');
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog theme display', () => {
|
||||
it('displays theme screenshot URL correctly', () => {
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const images = screen.getAllByRole('img');
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that images have the correct src pattern
|
||||
images.forEach(img => {
|
||||
expect(img).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('raw.githubusercontent.com'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays theme author correctly', () => {
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
// Author should be displayed as "by actualbudget"
|
||||
const authorTexts = screen.getAllByText(/by/i);
|
||||
expect(authorTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('displays source link for themes', () => {
|
||||
render(
|
||||
<ThemeInstaller onInstall={mockOnInstall} onClose={mockOnClose} />,
|
||||
);
|
||||
|
||||
const sourceLinks = screen.getAllByText('Source');
|
||||
expect(sourceLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { TextArea } from 'react-aria-components';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme as themeStyle } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Link } from '@desktop-client/components/common/Link';
|
||||
import { FixedSizeList } from '@desktop-client/components/FixedSizeList';
|
||||
import customThemeCatalog from '@desktop-client/data/customThemeCatalog.json';
|
||||
import {
|
||||
type CatalogTheme,
|
||||
type InstalledTheme,
|
||||
fetchThemeCss,
|
||||
validateThemeCss,
|
||||
generateThemeId,
|
||||
normalizeGitHubRepo,
|
||||
getThemeScreenshotUrl,
|
||||
extractRepoOwner,
|
||||
} from '@desktop-client/style/customThemes';
|
||||
|
||||
// Theme item fixed dimensions
|
||||
const THEME_ITEM_HEIGHT = 140;
|
||||
const THEME_ITEM_WIDTH = 140;
|
||||
const THEME_ITEM_GAP = 12;
|
||||
const CATALOG_MAX_HEIGHT = 300;
|
||||
|
||||
type ThemeInstallerProps = {
|
||||
onInstall: (theme: InstalledTheme) => void;
|
||||
onClose: () => void;
|
||||
installedTheme?: InstalledTheme | null;
|
||||
};
|
||||
|
||||
export function ThemeInstaller({
|
||||
onInstall,
|
||||
onClose,
|
||||
installedTheme,
|
||||
}: ThemeInstallerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedCatalogTheme, setSelectedCatalogTheme] =
|
||||
useState<CatalogTheme | null>(null);
|
||||
const [pastedCss, setPastedCss] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Initialize pastedCss with installed custom theme CSS if it exists
|
||||
useEffect(() => {
|
||||
// If there's an installed theme with empty repo (custom pasted CSS), restore it
|
||||
if (installedTheme && !installedTheme.repo) {
|
||||
setPastedCss(installedTheme.cssContent);
|
||||
}
|
||||
}, [installedTheme]);
|
||||
|
||||
// TODO: inlined for now, but eventually we will fetch this from github directly
|
||||
const catalog = customThemeCatalog as CatalogTheme[];
|
||||
|
||||
// Calculate items per row based on container width
|
||||
const getItemsPerRow = useCallback((containerWidth: number) => {
|
||||
const padding = 8; // 4px on each side
|
||||
const availableWidth = containerWidth - padding;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(availableWidth + THEME_ITEM_GAP) / (THEME_ITEM_WIDTH + THEME_ITEM_GAP),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const installTheme = useCallback(
|
||||
async (options: {
|
||||
css: string | Promise<string>;
|
||||
name: string;
|
||||
repo: string;
|
||||
id: string;
|
||||
errorMessage: string;
|
||||
}) => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const css =
|
||||
typeof options.css === 'string' ? options.css : await options.css;
|
||||
const validatedCss = validateThemeCss(css);
|
||||
|
||||
const installedTheme: InstalledTheme = {
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
repo: options.repo,
|
||||
cssContent: validatedCss,
|
||||
};
|
||||
onInstall(installedTheme);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : options.errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[onInstall],
|
||||
);
|
||||
|
||||
const handleCatalogThemeClick = useCallback(
|
||||
async (theme: CatalogTheme) => {
|
||||
setSelectedCatalogTheme(theme);
|
||||
setPastedCss('');
|
||||
|
||||
const normalizedRepo = normalizeGitHubRepo(theme.repo);
|
||||
await installTheme({
|
||||
css: fetchThemeCss(theme.repo),
|
||||
name: theme.name,
|
||||
repo: normalizedRepo,
|
||||
id: generateThemeId(normalizedRepo),
|
||||
errorMessage: t('Failed to load theme'),
|
||||
});
|
||||
},
|
||||
[installTheme, t],
|
||||
);
|
||||
|
||||
const handlePastedCssChange = useCallback((value: string) => {
|
||||
setPastedCss(value);
|
||||
setSelectedCatalogTheme(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleInstallPastedCss = useCallback(() => {
|
||||
if (!pastedCss.trim()) return;
|
||||
|
||||
installTheme({
|
||||
css: pastedCss.trim(),
|
||||
name: t('Custom Theme'),
|
||||
repo: '',
|
||||
id: generateThemeId(`pasted-${Date.now()}`),
|
||||
errorMessage: t('Failed to validate theme CSS'),
|
||||
});
|
||||
}, [pastedCss, installTheme, t]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: 16,
|
||||
backgroundColor: themeStyle.tableBackground,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${themeStyle.tableBorder}`,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
<Trans>Install Custom Theme</Trans>
|
||||
</Text>
|
||||
<Button variant="bare" onPress={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Catalog Virtualized List */}
|
||||
<Text style={{ marginBottom: 8, color: themeStyle.pageTextSubdued }}>
|
||||
<Trans>Choose from catalog:</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
height: CATALOG_MAX_HEIGHT,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
if (width === 0 || height === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemsPerRow = getItemsPerRow(width);
|
||||
const rows: CatalogTheme[][] = [];
|
||||
for (let i = 0; i < catalog.length; i += itemsPerRow) {
|
||||
rows.push(catalog.slice(i, i + itemsPerRow));
|
||||
}
|
||||
|
||||
return (
|
||||
<FixedSizeList
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={rows.length}
|
||||
itemSize={THEME_ITEM_HEIGHT + THEME_ITEM_GAP}
|
||||
itemKey={index => `row-${index}`}
|
||||
renderRow={({ index, style }) => {
|
||||
const rowThemes = rows[index];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
gap: THEME_ITEM_GAP,
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{rowThemes.map((theme, themeIndex) => {
|
||||
const isSelected =
|
||||
selectedCatalogTheme?.name === theme.name &&
|
||||
selectedCatalogTheme?.repo === theme.repo;
|
||||
|
||||
const isLoadingSelected = isLoading && isSelected;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={`${theme.name}-${index}-${themeIndex}`}
|
||||
variant="bare"
|
||||
aria-label={theme.name}
|
||||
onPress={() => handleCatalogThemeClick(theme)}
|
||||
style={{
|
||||
width: THEME_ITEM_WIDTH,
|
||||
height: THEME_ITEM_HEIGHT,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: `2px solid ${
|
||||
isSelected
|
||||
? themeStyle.buttonPrimaryBackground
|
||||
: themeStyle.tableBorder
|
||||
}`,
|
||||
backgroundColor: isSelected
|
||||
? themeStyle.tableRowBackgroundHover
|
||||
: 'transparent',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 6,
|
||||
backgroundColor: themeStyle.overlayBackground,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
opacity: isLoadingSelected ? 1 : 0,
|
||||
pointerEvents: isLoadingSelected
|
||||
? 'auto'
|
||||
: 'none',
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: themeStyle.pageText,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<img
|
||||
src={getThemeScreenshotUrl(theme.repo)}
|
||||
alt={theme.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 60,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{theme.name}
|
||||
</Text>
|
||||
|
||||
<SpaceBetween
|
||||
direction="horizontal"
|
||||
align="center"
|
||||
gap={4}
|
||||
style={{ fontSize: 10 }}
|
||||
>
|
||||
<Text
|
||||
style={{ color: themeStyle.pageTextSubdued }}
|
||||
>
|
||||
{t('by')}{' '}
|
||||
<Text style={{ fontWeight: 'bold' }}>
|
||||
{extractRepoOwner(theme.repo)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Link
|
||||
variant="external"
|
||||
to={normalizeGitHubRepo(theme.repo)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Trans>Source</Trans>
|
||||
</Link>
|
||||
</SpaceBetween>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</View>
|
||||
|
||||
{/* Paste CSS Input */}
|
||||
<View
|
||||
style={{
|
||||
borderTop: `1px solid ${themeStyle.tableBorder}`,
|
||||
paddingTop: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ marginBottom: 8, color: themeStyle.pageTextSubdued }}>
|
||||
<Trans>or paste CSS directly:</Trans>
|
||||
</Text>
|
||||
<TextArea
|
||||
value={pastedCss}
|
||||
onChange={e => handlePastedCssChange(e.target.value)}
|
||||
placeholder={t(':root {\n --color-sidebarItemSelected: #007bff;\n}')}
|
||||
aria-label={t('Custom Theme CSS')}
|
||||
style={{
|
||||
height: 120,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="normal"
|
||||
onPress={handleInstallPastedCss}
|
||||
isDisabled={!pastedCss.trim() || isLoading}
|
||||
>
|
||||
<Trans>Apply</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<Text
|
||||
style={{
|
||||
color: themeStyle.errorText,
|
||||
marginBottom: 12,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Select } from '@actual-app/components/select';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme as themeStyle } from '@actual-app/components/theme';
|
||||
@@ -10,21 +11,106 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { type DarkTheme, type Theme } from 'loot-core/types/prefs';
|
||||
|
||||
import { ThemeInstaller } from './ThemeInstaller';
|
||||
import { Column, Setting } from './UI';
|
||||
|
||||
import { useSidebar } from '@desktop-client/components/sidebar/SidebarProvider';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import {
|
||||
themeOptions,
|
||||
useTheme,
|
||||
usePreferredDarkTheme,
|
||||
darkThemeOptions,
|
||||
} from '@desktop-client/style';
|
||||
import {
|
||||
type InstalledTheme,
|
||||
parseInstalledTheme,
|
||||
serializeInstalledTheme,
|
||||
} from '@desktop-client/style/customThemes';
|
||||
|
||||
const INSTALL_NEW_VALUE = '__install_new__';
|
||||
|
||||
export function ThemeSettings() {
|
||||
const { t } = useTranslation();
|
||||
const sidebar = useSidebar();
|
||||
const [theme, switchTheme] = useTheme();
|
||||
const [darkTheme, switchDarkTheme] = usePreferredDarkTheme();
|
||||
const [showInstaller, setShowInstaller] = useState(false);
|
||||
|
||||
const customThemesEnabled = useFeatureFlag('customThemes');
|
||||
|
||||
// Global prefs for custom themes
|
||||
const [installedThemeJson, setInstalledThemeJson] = useGlobalPref(
|
||||
'installedCustomTheme',
|
||||
);
|
||||
|
||||
const installedTheme = parseInstalledTheme(installedThemeJson);
|
||||
|
||||
// Build the options list
|
||||
const buildOptions = useCallback(() => {
|
||||
const options: Array<readonly [string, string] | typeof Menu.line> = [
|
||||
...themeOptions,
|
||||
];
|
||||
|
||||
// Add custom theme options only if feature flag is enabled
|
||||
if (customThemesEnabled) {
|
||||
// Add installed custom theme if it exists
|
||||
if (installedTheme) {
|
||||
options.push([
|
||||
`custom:${installedTheme.id}`,
|
||||
installedTheme.name,
|
||||
] as const);
|
||||
}
|
||||
|
||||
// Add separator and "Custom theme" option
|
||||
options.push(Menu.line);
|
||||
options.push([INSTALL_NEW_VALUE, t('Custom theme')] as const);
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [installedTheme, customThemesEnabled, t]);
|
||||
|
||||
// Determine current value for the select
|
||||
const getCurrentValue = useCallback(() => {
|
||||
if (customThemesEnabled && installedTheme) {
|
||||
return `custom:${installedTheme.id}`;
|
||||
}
|
||||
return theme;
|
||||
}, [customThemesEnabled, installedTheme, theme]);
|
||||
|
||||
// Handle theme selection
|
||||
const handleThemeChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === INSTALL_NEW_VALUE) {
|
||||
setShowInstaller(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith('custom:')) {
|
||||
// Custom theme is already installed and active, no action needed
|
||||
// (since there's only one theme, selecting it means it's already active)
|
||||
} else {
|
||||
// Built-in theme selected - clear the installed custom theme
|
||||
setInstalledThemeJson(serializeInstalledTheme(null));
|
||||
switchTheme(value as Theme);
|
||||
}
|
||||
},
|
||||
[setInstalledThemeJson, switchTheme],
|
||||
);
|
||||
|
||||
// Handle theme installation
|
||||
const handleInstall = useCallback(
|
||||
(newTheme: InstalledTheme) => {
|
||||
setInstalledThemeJson(serializeInstalledTheme(newTheme));
|
||||
},
|
||||
[setInstalledThemeJson],
|
||||
);
|
||||
|
||||
// Handle installer close
|
||||
const handleInstallerClose = useCallback(() => {
|
||||
setShowInstaller(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Setting
|
||||
@@ -34,44 +120,60 @@ export function ThemeSettings() {
|
||||
flexDirection: 'column',
|
||||
gap: '1em',
|
||||
width: '100%',
|
||||
[`@media (min-width: ${
|
||||
sidebar.floating
|
||||
? tokens.breakpoint_small
|
||||
: tokens.breakpoint_medium
|
||||
})`]: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Column title={t('Theme')}>
|
||||
<Select<Theme>
|
||||
onChange={value => {
|
||||
switchTheme(value);
|
||||
}}
|
||||
value={theme}
|
||||
options={themeOptions}
|
||||
className={css({
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: themeStyle.buttonNormalBackgroundHover,
|
||||
{!showInstaller && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'column',
|
||||
gap: '1em',
|
||||
width: '100%',
|
||||
[`@media (min-width: ${
|
||||
sidebar.floating
|
||||
? tokens.breakpoint_small
|
||||
: tokens.breakpoint_medium
|
||||
})`]: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
}}
|
||||
>
|
||||
<Column title={t('Theme')}>
|
||||
<Select<string>
|
||||
onChange={handleThemeChange}
|
||||
value={getCurrentValue()}
|
||||
options={buildOptions()}
|
||||
className={css({
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: themeStyle.buttonNormalBackgroundHover,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Column>
|
||||
{theme === 'auto' && !installedTheme && (
|
||||
<Column title={t('Dark theme')}>
|
||||
<Select<DarkTheme>
|
||||
onChange={value => {
|
||||
switchDarkTheme(value);
|
||||
}}
|
||||
value={darkTheme}
|
||||
options={darkThemeOptions}
|
||||
className={css({
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: themeStyle.buttonNormalBackgroundHover,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Column>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{customThemesEnabled && showInstaller && (
|
||||
<ThemeInstaller
|
||||
onInstall={handleInstall}
|
||||
onClose={handleInstallerClose}
|
||||
installedTheme={installedTheme}
|
||||
/>
|
||||
</Column>
|
||||
{theme === 'auto' && (
|
||||
<Column title={t('Dark theme')}>
|
||||
<Select<DarkTheme>
|
||||
onChange={value => {
|
||||
switchDarkTheme(value);
|
||||
}}
|
||||
value={darkTheme}
|
||||
options={darkThemeOptions}
|
||||
className={css({
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: themeStyle.buttonNormalBackgroundHover,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Column>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
|
||||
14
packages/desktop-client/src/data/customThemeCatalog.json
Normal file
14
packages/desktop-client/src/data/customThemeCatalog.json
Normal file
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"name": "Demo Theme",
|
||||
"repo": "actualbudget/demo-theme"
|
||||
},
|
||||
{
|
||||
"name": "Shades of Coffee",
|
||||
"repo": "Juulz/shades-of-coffee"
|
||||
},
|
||||
{
|
||||
"name": "Miami Beach",
|
||||
"repo": "Juulz/miami-beach"
|
||||
}
|
||||
]
|
||||
@@ -9,6 +9,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
formulaMode: false,
|
||||
currency: false,
|
||||
crossoverReport: false,
|
||||
customThemes: false,
|
||||
};
|
||||
|
||||
export function useFeatureFlag(name: FeatureFlag): boolean {
|
||||
|
||||
1121
packages/desktop-client/src/style/customThemes.test.ts
Normal file
1121
packages/desktop-client/src/style/customThemes.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
328
packages/desktop-client/src/style/customThemes.ts
Normal file
328
packages/desktop-client/src/style/customThemes.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Custom theme utilities: fetch, validation, and storage helpers.
|
||||
*/
|
||||
|
||||
export type CatalogTheme = {
|
||||
name: string;
|
||||
repo: string;
|
||||
};
|
||||
|
||||
export type InstalledTheme = {
|
||||
id: string;
|
||||
name: string;
|
||||
repo: string;
|
||||
cssContent: string; // CSS content stored when theme is installed (required)
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely extract the owner from a GitHub repo string.
|
||||
* Handles malformed repo strings by returning "Unknown" when owner cannot be determined.
|
||||
*/
|
||||
export function extractRepoOwner(repo: string): string {
|
||||
if (!repo || typeof repo !== 'string' || !repo.includes('/')) {
|
||||
return 'Unknown';
|
||||
}
|
||||
const parts = repo.split('/');
|
||||
const owner = parts[0]?.trim();
|
||||
return owner || 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a GitHub repo identifier to a full GitHub URL.
|
||||
* Accepts "owner/repo" format.
|
||||
* Returns "https://github.com/owner/repo".
|
||||
* @throws {Error} If repo is invalid or missing owner/repo.
|
||||
*/
|
||||
export function normalizeGitHubRepo(repo: string): string {
|
||||
const trimmed = repo.trim();
|
||||
if (!trimmed.includes('/')) {
|
||||
throw new Error('Invalid repo: must be in "owner/repo" format');
|
||||
}
|
||||
|
||||
const parts = trimmed.split('/');
|
||||
const owner = parts[0]?.trim();
|
||||
const repoName = parts[1]?.trim();
|
||||
|
||||
if (!owner || !repoName) {
|
||||
throw new Error('Invalid repo: must include both owner and repo name');
|
||||
}
|
||||
|
||||
return `https://github.com/${owner}/${repoName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the screenshot URL for a theme repo.
|
||||
* Returns a safe fallback URL for malformed repos.
|
||||
*/
|
||||
export function getThemeScreenshotUrl(repo: string): string {
|
||||
if (
|
||||
!repo ||
|
||||
typeof repo !== 'string' ||
|
||||
!repo.trim() ||
|
||||
!repo.includes('/')
|
||||
) {
|
||||
// Return a placeholder or empty data URL for malformed repos
|
||||
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTQwIiBoZWlnaHQ9IjYwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxNDAiIGhlaWdodD0iNjAiIGZpbGw9IiNmNWY1ZjUiLz48L3N2Zz4=';
|
||||
}
|
||||
const trimmed = repo.trim();
|
||||
return `https://raw.githubusercontent.com/${trimmed}/refs/heads/main/screenshot.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try fetching actual.css from main branch.
|
||||
*/
|
||||
export function fetchThemeCss(repo: string): Promise<string> {
|
||||
return fetchDirectCss(
|
||||
`https://raw.githubusercontent.com/${repo}/refs/heads/main/actual.css`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CSS from a direct URL (not a GitHub repo).
|
||||
*/
|
||||
export async function fetchDirectCss(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch CSS from ${url}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a CSS property value only contains allowed content (allowlist approach).
|
||||
* Only simple, safe CSS values are allowed - no functions (except rgb/rgba/hsl/hsla), no URLs, no complex constructs.
|
||||
* Explicitly rejects var() and other function calls to prevent variable references and complex expressions.
|
||||
*/
|
||||
function validatePropertyValue(value: string, property: string): void {
|
||||
if (!value || value.length === 0) {
|
||||
return; // Empty values are allowed
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
// Allowlist: Only allow specific safe CSS value patterns
|
||||
// 1. Hex colors: #RGB, #RRGGBB, or #RRGGBBAA (3, 6, or 8 hex digits)
|
||||
const hexColorPattern = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?([0-9a-fA-F]{2})?$/;
|
||||
|
||||
// 2. RGB/RGBA functions: rgb(...) or rgba(...) with simple numeric/percentage values
|
||||
// Allow optional whitespace and support both integers and decimals
|
||||
const rgbRgbaPattern =
|
||||
/^rgba?\(\s*\d+%?\s*,\s*\d+%?\s*,\s*\d+%?\s*(,\s*[\d.]+)?\s*\)$/;
|
||||
|
||||
// 3. HSL/HSLA functions: hsl(...) or hsla(...) with simple numeric/percentage values
|
||||
const hslHslaPattern =
|
||||
/^hsla?\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*(,\s*[\d.]+)?\s*\)$/;
|
||||
|
||||
// 4. Length values with units: number (including decimals) followed by valid CSS unit
|
||||
const lengthPattern =
|
||||
/^(\d+\.?\d*|\d*\.\d+)(px|em|rem|%|vh|vw|vmin|vmax|cm|mm|in|pt|pc|ex|ch)$/;
|
||||
|
||||
// 5. Unitless numbers (integers or decimals)
|
||||
const numberPattern = /^(\d+\.?\d*|\d*\.\d+)$/;
|
||||
|
||||
// 6. CSS keywords: common safe keywords
|
||||
const keywordPattern =
|
||||
/^(inherit|initial|unset|revert|transparent|none|auto|normal)$/i;
|
||||
|
||||
// Check if value matches any allowed pattern
|
||||
if (
|
||||
hexColorPattern.test(trimmedValue) ||
|
||||
rgbRgbaPattern.test(trimmedValue) ||
|
||||
hslHslaPattern.test(trimmedValue) ||
|
||||
lengthPattern.test(trimmedValue) ||
|
||||
numberPattern.test(trimmedValue) ||
|
||||
keywordPattern.test(trimmedValue)
|
||||
) {
|
||||
return; // Value is allowed
|
||||
}
|
||||
|
||||
// If none of the allowlist patterns match, reject the value
|
||||
throw new Error(
|
||||
`Invalid value "${trimmedValue}" for property "${property}". Only simple CSS values are allowed (colors, lengths, numbers, or keywords). Functions (including var()), URLs, and other complex constructs are not permitted.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that CSS contains only :root { ... } with CSS custom property (variable) declarations.
|
||||
* Must contain exactly :root { ... } and nothing else.
|
||||
* Returns the validated CSS or throws an error.
|
||||
*/
|
||||
export function validateThemeCss(css: string): string {
|
||||
// Strip multi-line comments before validation
|
||||
// Note: Single-line comments (//) are not stripped to avoid corrupting CSS values like URLs
|
||||
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
|
||||
// Must contain exactly :root { ... } and nothing else
|
||||
// Find :root { ... } and extract content, then check there's nothing after
|
||||
const rootMatch = cleaned.match(/^:root\s*\{/);
|
||||
if (!rootMatch) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
// Find the opening brace after :root
|
||||
const rootStart = cleaned.indexOf(':root');
|
||||
const openBrace = cleaned.indexOf('{', rootStart);
|
||||
|
||||
if (openBrace === -1) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
// Find the first closing brace (nested blocks will be caught by the check below)
|
||||
const closeBrace = cleaned.indexOf('}', openBrace + 1);
|
||||
|
||||
if (closeBrace === -1) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
// Extract content inside :root { ... }
|
||||
const rootContent = cleaned.substring(openBrace + 1, closeBrace).trim();
|
||||
|
||||
// Check for forbidden at-rules first (before nested block check, since at-rules with braces would trigger that)
|
||||
// Comprehensive list of CSS at-rules that should not be allowed
|
||||
// This includes @import, @media, @keyframes, @font-face, @supports, @charset,
|
||||
// @namespace, @page, @layer, @container, @scope, and any other at-rules
|
||||
if (/@[a-z-]+/i.test(rootContent)) {
|
||||
throw new Error(
|
||||
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
|
||||
);
|
||||
}
|
||||
|
||||
// Check for nested blocks (additional selectors) - should not have any { after extracting :root content
|
||||
if (/\{/.test(rootContent)) {
|
||||
throw new Error(
|
||||
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
|
||||
);
|
||||
}
|
||||
|
||||
// Check that there's nothing after the closing brace
|
||||
const afterRoot = cleaned.substring(closeBrace + 1).trim();
|
||||
if (afterRoot.length > 0) {
|
||||
throw new Error(
|
||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
// Parse declarations and validate each one
|
||||
const declarations = rootContent
|
||||
.split(';')
|
||||
.map(d => d.trim())
|
||||
.filter(d => d.length > 0);
|
||||
|
||||
for (const decl of declarations) {
|
||||
const colonIndex = decl.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
throw new Error(`Invalid CSS declaration: "${decl}"`);
|
||||
}
|
||||
|
||||
const property = decl.substring(0, colonIndex).trim();
|
||||
|
||||
// Property must start with --
|
||||
if (!property.startsWith('--')) {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Only CSS custom properties (starting with --) are allowed.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate property name format
|
||||
// CSS custom property names must:
|
||||
// - Start with --
|
||||
// - Not be empty (not just --)
|
||||
// - Not end with a dash
|
||||
// - Contain only valid characters (letters, digits, underscore, dash, but not at start/end positions)
|
||||
if (property === '--' || property === '-') {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Property name cannot be empty or contain only dashes.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for invalid characters in property name (no brackets, spaces, special chars except dash/underscore)
|
||||
// Property name after -- should only contain: letters, digits, underscore, and dashes (not consecutive dashes at start/end)
|
||||
const propertyNameAfterDashes = property.substring(2);
|
||||
if (propertyNameAfterDashes.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Property name cannot be empty after "--".`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for invalid characters (no brackets, no special characters except underscore and dash)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(propertyNameAfterDashes)) {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Property name contains invalid characters. Only letters, digits, underscores, and dashes are allowed.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check that property doesn't end with a dash (after the -- prefix)
|
||||
if (property.endsWith('-')) {
|
||||
throw new Error(
|
||||
`Invalid property "${property}". Property name cannot end with a dash.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract and validate the value
|
||||
const value = decl.substring(colonIndex + 1).trim();
|
||||
validatePropertyValue(value, property);
|
||||
}
|
||||
|
||||
// Return the original CSS (with :root wrapper) so it can be injected properly
|
||||
return css.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a theme based on its repo URL or direct CSS URL.
|
||||
*/
|
||||
export function generateThemeId(urlOrRepo: string): string {
|
||||
// Simple hash-like ID from the URL
|
||||
let hash = 0;
|
||||
for (let i = 0; i < urlOrRepo.length; i++) {
|
||||
const char = urlOrRepo.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return `theme-${Math.abs(hash).toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the installed theme JSON from global prefs.
|
||||
* Returns a single InstalledTheme or null if none is installed.
|
||||
*/
|
||||
export function parseInstalledTheme(
|
||||
json: string | undefined,
|
||||
): InstalledTheme | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
typeof parsed.id === 'string' &&
|
||||
typeof parsed.name === 'string' &&
|
||||
typeof parsed.repo === 'string' &&
|
||||
typeof parsed.cssContent === 'string'
|
||||
) {
|
||||
return {
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
repo: parsed.repo,
|
||||
cssContent: parsed.cssContent,
|
||||
} satisfies InstalledTheme;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize installed theme to JSON for global prefs.
|
||||
*/
|
||||
export function serializeInstalledTheme(theme: InstalledTheme | null): string {
|
||||
return JSON.stringify(theme);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isNonProductionEnvironment } from 'loot-core/shared/environment';
|
||||
import type { DarkTheme, Theme } from 'loot-core/types/prefs';
|
||||
|
||||
import { validateThemeCss, parseInstalledTheme } from './customThemes';
|
||||
import * as darkTheme from './themes/dark';
|
||||
import * as developmentTheme from './themes/development';
|
||||
import * as lightTheme from './themes/light';
|
||||
import * as midnightTheme from './themes/midnight';
|
||||
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
|
||||
const themes = {
|
||||
@@ -97,3 +99,38 @@ export function ThemeStyle() {
|
||||
.join('\n');
|
||||
return <style>{`:root {\n${css}}`}</style>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CustomThemeStyle injects CSS from the installed custom theme (if any).
|
||||
* This is rendered after ThemeStyle to allow custom themes to override base theme variables.
|
||||
*/
|
||||
export function CustomThemeStyle() {
|
||||
const customThemesEnabled = useFeatureFlag('customThemes');
|
||||
const [installedThemeJson] = useGlobalPref('installedCustomTheme');
|
||||
|
||||
// Parse installed theme (single theme, not array)
|
||||
const installedTheme = parseInstalledTheme(installedThemeJson);
|
||||
|
||||
// Get CSS content from the theme (cssContent is required)
|
||||
const { cssContent } = installedTheme ?? {};
|
||||
|
||||
// Memoize validated CSS to avoid re-validation on every render
|
||||
const validatedCss = useMemo(() => {
|
||||
if (!customThemesEnabled || !cssContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return validateThemeCss(cssContent);
|
||||
} catch (error) {
|
||||
console.error('Invalid custom theme CSS', { error, cssContent });
|
||||
return null;
|
||||
}
|
||||
}, [customThemesEnabled, cssContent]);
|
||||
|
||||
if (!validatedCss) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <style id="custom-theme-active">{validatedCss}</style>;
|
||||
}
|
||||
|
||||
@@ -217,3 +217,5 @@ export const tooltipBackground = colorPalette.navy800;
|
||||
export const tooltipBorder = colorPalette.navy700;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy900;
|
||||
|
||||
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
|
||||
|
||||
@@ -217,3 +217,5 @@ export const tooltipBackground = colorPalette.navy50;
|
||||
export const tooltipBorder = colorPalette.navy150;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy100;
|
||||
|
||||
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
|
||||
|
||||
@@ -219,3 +219,5 @@ export const tooltipBackground = colorPalette.white;
|
||||
export const tooltipBorder = colorPalette.navy150;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy100;
|
||||
|
||||
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
|
||||
|
||||
@@ -219,3 +219,5 @@ export const tooltipBackground = colorPalette.gray800;
|
||||
export const tooltipBorder = colorPalette.gray600;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy900;
|
||||
|
||||
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
|
||||
|
||||
Reference in New Issue
Block a user