CODEBASE: Validate theme, editor theme, and styles (#1789)

This commit is contained in:
catloversg
2025-01-09 10:20:05 +07:00
committed by GitHub
parent 320c852386
commit 0f9144a059
27 changed files with 969 additions and 213 deletions

View File

@@ -1,10 +1,4 @@
import { OrderType, PositionType } from "@enums";
import { getKeyList } from "../../utils/helpers/getKeyList";
import { Stock } from "../../StockMarket/Stock";
import { Order } from "../../StockMarket/Order";
const stockObjectProperties = getKeyList(Stock);
const orderObjectProperties = getKeyList(Order);
/**
* It's intentional to not use JSONSchemaType here. The data structure of StockMarket is not suitable for the usage of
@@ -98,7 +92,26 @@ export const StockMarketSchema = {
type: "number",
},
},
required: [...stockObjectProperties],
required: [
"b",
"cap",
"lastPrice",
"maxShares",
"mv",
"name",
"otlkMag",
"otlkMagForecast",
"playerAvgPx",
"playerAvgShortPx",
"playerShares",
"playerShortShares",
"price",
"shareTxForMovement",
"shareTxUntilMovement",
"spreadPerc",
"symbol",
"totalShares",
],
},
},
properties: {
@@ -129,7 +142,7 @@ export const StockMarketSchema = {
enum: [OrderType.LimitBuy, OrderType.LimitSell, OrderType.StopBuy, OrderType.StopSell],
},
},
required: [...orderObjectProperties],
required: ["pos", "price", "shares", "stockSymbol", "type"],
},
},
},

View File

@@ -0,0 +1,18 @@
export const StylesSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
lineHeight: {
type: "number",
},
fontSize: {
type: "number",
},
tailFontSize: {
type: "number",
},
fontFamily: {
type: "string",
},
},
};

View File

@@ -0,0 +1,286 @@
import { validEditorThemeBases } from "../../ScriptEditor/ui/themes";
import type { ITheme } from "../../Themes/Themes";
import { getRecordKeys } from "../../Types/Record";
/**
* VS code has a regex for checking hex colors at: https://github.com/microsoft/vscode/blob/1dd8c77ac79508a047235ceee0cba7ba7f049425/src/vs/editor/common/languages/supports/tokenization.ts#L153.
*
* We have to tweak it:
* - "#" must be the first character.
* - Allow 3-character hex colors (e.g., #fff).
*
* Explanation:
^ asserts position at start of the string
# matches the character # with index 35 (base 10) literally (case sensitive)
1st Capturing Group ((([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?)|([0-9A-Fa-f]{3}))
1st Alternative (([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?)
2nd Capturing Group (([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?)
3rd Capturing Group ([0-9A-Fa-f]{6})
Match a single character present in the list below [0-9A-Fa-f]
{6} matches the previous token exactly 6 times
0-9 matches a single character in the range between 0 (index 48) and 9 (index 57) (case sensitive)
A-F matches a single character in the range between A (index 65) and F (index 70) (case sensitive)
a-f matches a single character in the range between a (index 97) and f (index 102) (case sensitive)
4th Capturing Group ([0-9A-Fa-f]{2})?
? matches the previous token between zero and one times, as many times as possible, giving back as needed (greedy)
Match a single character present in the list below [0-9A-Fa-f]
{2} matches the previous token exactly 2 times
0-9 matches a single character in the range between 0 (index 48) and 9 (index 57) (case sensitive)
A-F matches a single character in the range between A (index 65) and F (index 70) (case sensitive)
a-f matches a single character in the range between a (index 97) and f (index 102) (case sensitive)
2nd Alternative ([0-9A-Fa-f]{3})
5th Capturing Group ([0-9A-Fa-f]{3})
Match a single character present in the list below [0-9A-Fa-f]
{3} matches the previous token exactly 3 times
0-9 matches a single character in the range between 0 (index 48) and 9 (index 57) (case sensitive)
A-F matches a single character in the range between A (index 65) and F (index 70) (case sensitive)
a-f matches a single character in the range between a (index 97) and f (index 102) (case sensitive)
$ asserts position at the end of the string
*/
export const themeHexColorRegex = /^#((([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?)|([0-9A-Fa-f]{3}))$/;
/**
* This regex is based on themeHexColorRegex. It removes the part of "#". When processing data of editor themes, we
* always add "#" to the hex value, so valid hex values cannot include "#" character.
*/
export const editorThemeHexColorRegex = /^((([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?)|([0-9A-Fa-f]{3}))$/;
function getThemeSchemaProperties() {
const result: Record<keyof ITheme, { type: string; pattern?: string }> = {
primarylight: {
type: "string",
},
primary: {
type: "string",
},
primarydark: {
type: "string",
},
successlight: {
type: "string",
},
success: {
type: "string",
},
successdark: {
type: "string",
},
errorlight: {
type: "string",
},
error: {
type: "string",
},
errordark: {
type: "string",
},
secondarylight: {
type: "string",
},
secondary: {
type: "string",
},
secondarydark: {
type: "string",
},
warninglight: {
type: "string",
},
warning: {
type: "string",
},
warningdark: {
type: "string",
},
infolight: {
type: "string",
},
info: {
type: "string",
},
infodark: {
type: "string",
},
welllight: {
type: "string",
},
well: {
type: "string",
},
white: {
type: "string",
},
black: {
type: "string",
},
hp: {
type: "string",
},
money: {
type: "string",
},
hack: {
type: "string",
},
combat: {
type: "string",
},
cha: {
type: "string",
},
int: {
type: "string",
},
rep: {
type: "string",
},
disabled: {
type: "string",
},
backgroundprimary: {
type: "string",
},
backgroundsecondary: {
type: "string",
},
button: {
type: "string",
},
maplocation: {
type: "string",
},
bnlvl0: {
type: "string",
},
bnlvl1: {
type: "string",
},
bnlvl2: {
type: "string",
},
bnlvl3: {
type: "string",
},
};
for (const key of getRecordKeys(result)) {
result[key].pattern = themeHexColorRegex.source;
}
return result;
}
export const MainThemeSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: getThemeSchemaProperties(),
};
export const EditorThemeSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
common: {
type: "object",
properties: {
accent: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
bg: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
fg: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
},
},
syntax: {
type: "object",
properties: {
tag: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
entity: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
string: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
regexp: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
markup: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
keyword: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
comment: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
constant: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
error: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
},
},
ui: {
type: "object",
properties: {
line: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
panel: {
type: "object",
properties: {
bg: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
selected: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
border: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
},
},
selection: {
type: "object",
properties: {
bg: {
type: "string",
pattern: editorThemeHexColorRegex.source,
},
},
},
},
},
base: {
type: "string",
/**
* Monaco checks the base theme at runtime. If the value is invalid, monaco will throw an error ("Error: Illegal
* theme base!") and crash the game.
*/
enum: validEditorThemeBases,
},
inherit: {
type: "boolean",
},
},
};

View File

@@ -0,0 +1,34 @@
import type { ValidateFunction } from "ajv/dist/types";
import type { IStyleSettings } from "../ScriptEditor/NetscriptDefinitions";
import type { IScriptEditorTheme } from "../ScriptEditor/ui/themes";
import type { ITheme } from "../Themes/Themes";
import { JsonSchemaValidator } from "./JsonSchemaValidator";
function assertAndSanitize(data: unknown, validate: ValidateFunction<unknown>): void {
if (!validate(data)) {
console.error("validate.errors:", validate.errors);
// validate.errors is an array of objects, so we need to use JSON.stringify.
throw new Error(JSON.stringify(validate.errors));
}
}
/**
* This function validates the unknown data and removes properties not defined in MainThemeSchema.
*/
export function assertAndSanitizeMainTheme(data: unknown): asserts data is ITheme {
assertAndSanitize(data, JsonSchemaValidator.MainTheme);
}
/**
* This function validates the unknown data and removes properties not defined in EditorThemeSchema.
*/
export function assertAndSanitizeEditorTheme(data: unknown): asserts data is IScriptEditorTheme {
assertAndSanitize(data, JsonSchemaValidator.EditorTheme);
}
/**
* This function validates the unknown data and removes properties not defined in StylesSchema.
*/
export function assertAndSanitizeStyles(data: unknown): asserts data is IStyleSettings {
assertAndSanitize(data, JsonSchemaValidator.Styles);
}

View File

@@ -1,10 +1,16 @@
import Ajv from "ajv";
import { AllGangsSchema } from "./Data/AllGangsSchema";
import { StockMarketSchema } from "./Data/StockMarketSchema";
import { StylesSchema } from "./Data/StylesSchema";
import { EditorThemeSchema, MainThemeSchema } from "./Data/ThemeSchema";
const ajv = new Ajv();
const ajvWithRemoveAdditionalOption = new Ajv({ removeAdditional: "all" });
export const JsonSchemaValidator = {
AllGangs: ajv.compile(AllGangsSchema),
StockMarket: ajv.compile(StockMarketSchema),
MainTheme: ajvWithRemoveAdditionalOption.compile(MainThemeSchema),
EditorTheme: ajvWithRemoveAdditionalOption.compile(EditorThemeSchema),
Styles: ajvWithRemoveAdditionalOption.compile(StylesSchema),
};

View File

@@ -5,40 +5,10 @@ import { defaultTheme } from "../Themes/Themes";
import { defaultStyles } from "../Themes/Styles";
import { CONSTANTS } from "../Constants";
import { commitHash } from "../utils/helpers/commitHash";
import { InternalAPI, NetscriptContext } from "../Netscript/APIWrapper";
import { InternalAPI } from "../Netscript/APIWrapper";
import { Terminal } from "../../src/Terminal";
import { helpers } from "../Netscript/NetscriptHelpers";
import { errorMessage } from "../Netscript/ErrorMessages";
/** Will probably remove the below function in favor of a different approach to object type assertion.
* This method cannot be used to handle optional properties. */
export function assertObjectType<T extends object>(
ctx: NetscriptContext,
name: string,
obj: unknown,
desiredObject: T,
): asserts obj is T {
if (typeof obj !== "object" || obj === null) {
throw errorMessage(
ctx,
`Type ${obj === null ? "null" : typeof obj} provided for ${name}. Must be an object.`,
"TYPE",
);
}
for (const [key, val] of Object.entries(desiredObject)) {
if (!Object.hasOwn(obj, key)) {
throw errorMessage(ctx, `Object provided for argument ${name} is missing required property ${key}.`, "TYPE");
}
const objVal = (obj as Record<string, unknown>)[key];
if (typeof val !== typeof objVal) {
throw errorMessage(
ctx,
`Incorrect type ${typeof objVal} provided for property ${key} on ${name} argument. Should be type ${typeof val}.`,
"TYPE",
);
}
}
}
import { assertAndSanitizeMainTheme, assertAndSanitizeStyles } from "../JsonSchema/JSONSchemaAssertion";
export function NetscriptUserInterface(): InternalAPI<IUserInterface> {
return {
@@ -54,52 +24,37 @@ export function NetscriptUserInterface(): InternalAPI<IUserInterface> {
},
setTheme: (ctx) => (newTheme) => {
const themeValidator: Record<string, string | undefined> = {};
assertObjectType(ctx, "newTheme", newTheme, themeValidator);
const hex = /^(#)((?:[A-Fa-f0-9]{2}){3,4}|(?:[A-Fa-f0-9]{3}))$/;
const currentTheme = { ...Settings.theme };
const errors: string[] = [];
for (const key of Object.keys(newTheme)) {
if (!currentTheme[key]) {
// Invalid key
errors.push(`Invalid key "${key}"`);
} else if (!hex.test(newTheme[key] ?? "")) {
errors.push(`Invalid color "${key}": ${newTheme[key]}`);
} else {
currentTheme[key] = newTheme[key];
}
}
if (errors.length === 0) {
Object.assign(Settings.theme, currentTheme);
ThemeEvents.emit();
helpers.log(ctx, () => `Successfully set theme`);
} else {
helpers.log(ctx, () => `Failed to set theme. Errors: ${errors.join(", ")}`);
let newData: unknown;
try {
/**
* assertAndSanitizeMainTheme may mutate its parameter, so we have to clone the user-provided data here.
*/
newData = structuredClone(newTheme);
assertAndSanitizeMainTheme(newData);
} catch (error) {
helpers.log(ctx, () => `Failed to set theme. Errors: ${error}`);
return;
}
Object.assign(Settings.theme, newData);
ThemeEvents.emit();
helpers.log(ctx, () => `Successfully set theme`);
},
setStyles: (ctx) => (newStyles) => {
const styleValidator: Record<string, string | number | undefined> = {};
assertObjectType(ctx, "newStyles", newStyles, styleValidator);
const currentStyles: Record<string, unknown> = { ...Settings.styles };
const errors: string[] = [];
for (const key of Object.keys(newStyles)) {
if (!currentStyles[key]) {
// Invalid key
errors.push(`Invalid key "${key}"`);
} else {
currentStyles[key] = newStyles[key];
}
}
if (errors.length === 0) {
Object.assign(Settings.styles, currentStyles);
ThemeEvents.emit();
helpers.log(ctx, () => `Successfully set styles`);
} else {
helpers.log(ctx, () => `Failed to set styles. Errors: ${errors.join(", ")}`);
let newData: unknown;
try {
/**
* assertAndSanitizeStyles may mutate its parameter, so we have to clone the user-provided data here.
*/
newData = structuredClone(newStyles);
assertAndSanitizeStyles(newData);
} catch (error) {
helpers.log(ctx, () => `Failed to set styles. Errors: ${error}`);
return;
}
Object.assign(Settings.styles, newData);
ThemeEvents.emit();
helpers.log(ctx, () => `Successfully set styles`);
},
resetTheme: (ctx) => () => {

View File

@@ -9619,7 +9619,6 @@ interface InvestmentOffer {
* @public
*/
interface UserInterfaceTheme {
[key: string]: string | undefined;
primarylight: string;
primary: string;
primarydark: string;
@@ -9653,6 +9652,11 @@ interface UserInterfaceTheme {
backgroundprimary: string;
backgroundsecondary: string;
button: string;
maplocation: string;
bnlvl0: string;
bnlvl1: string;
bnlvl2: string;
bnlvl3: string;
}
/**

View File

@@ -2,7 +2,7 @@ import type { ContentFilePath } from "../Paths/ContentFile";
import { EventEmitter } from "../utils/EventEmitter";
import * as monaco from "monaco-editor";
import { loadThemes, makeTheme, sanitizeTheme } from "./ui/themes";
import { loadThemes, makeTheme } from "./ui/themes";
import { Settings } from "../Settings/Settings";
import { NetscriptExtra } from "../NetscriptFunctions/Extra";
import * as enums from "../Enums";
@@ -132,7 +132,6 @@ export class ScriptEditor {
});
// Load themes
loadThemes(monaco.editor.defineTheme);
sanitizeTheme(Settings.EditorTheme);
monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
}
}

View File

@@ -12,6 +12,7 @@ import { OptionSwitch } from "../../ui/React/OptionSwitch";
import { defaultMonacoTheme } from "./themes";
import { dialogBoxCreate } from "../../ui/React/DialogBox";
import { assertAndSanitizeEditorTheme } from "../../JsonSchema/JSONSchemaAssertion";
type ColorEditorProps = {
label: string;
@@ -74,21 +75,18 @@ export function ThemeEditorModal(props: ThemeEditorProps): React.ReactElement {
}
function onThemeChange(event: React.ChangeEvent<HTMLInputElement>): void {
let themeData: unknown;
try {
const importedTheme = JSON.parse(event.target.value) as typeof Settings.EditorTheme;
if (importedTheme == null) {
throw new Error("Theme data must not be null or undefined.");
}
if (typeof importedTheme !== "object") {
throw new Error(`Theme data is invalid.`);
}
Settings.EditorTheme = importedTheme;
props.onChange();
themeData = JSON.parse(event.target.value);
assertAndSanitizeEditorTheme(themeData);
} catch (error) {
console.error(`Theme data is invalid. Data: ${event.target.value}.`);
console.error(error);
dialogBoxCreate(`Invalid theme. ${error}`);
console.error("Theme data is invalid. Data:", event.target.value);
dialogBoxCreate(`Invalid theme. Errors: ${error}.`);
return;
}
Object.assign(Settings.EditorTheme, themeData);
props.onChange();
}
const onResetToDefault = () => {

View File

@@ -12,7 +12,7 @@ import Typography from "@mui/material/Typography";
import SettingsIcon from "@mui/icons-material/Settings";
import { makeTheme, sanitizeTheme } from "./themes";
import { makeTheme } from "./themes";
import { Modal } from "../../ui/React/Modal";
import { Page } from "../../ui/Router";
@@ -54,7 +54,6 @@ export function Toolbar({ editor, onSave }: IProps) {
};
const onThemeChange = () => {
sanitizeTheme(Settings.EditorTheme);
monaco.editor.defineTheme("customTheme", makeTheme(Settings.EditorTheme));
};

View File

@@ -1,10 +1,13 @@
import type { editor } from "monaco-editor";
import { getRecordKeys } from "../../Types/Record";
import { Settings } from "../../Settings/Settings";
type DefineThemeFn = typeof editor.defineTheme;
export const validEditorThemeBases = ["vs", "vs-dark", "hc-black", "hc-light"] as const;
/**
* If we change this interface, we must change EditorThemeSchema.
*/
export interface IScriptEditorTheme {
base: "vs" | "vs-dark" | "hc-black";
base: (typeof validEditorThemeBases)[number];
inherit: boolean;
common: {
accent: string;
@@ -67,45 +70,6 @@ export const defaultMonacoTheme: IScriptEditorTheme = {
},
};
// Regex used for token color validation
// https://github.com/microsoft/vscode/blob/973684056e67153952f495fce93bf50d0ec0b892/src/vs/editor/common/languages/supports/tokenization.ts#L153
const colorRegExp = /^#?([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$/;
// Recursively sanitize the theme data to prevent errors
// Invalid data will be replaced with FF0000 (bright red)
export const sanitizeTheme = (theme: IScriptEditorTheme): void => {
if (typeof theme !== "object") {
Settings.EditorTheme = structuredClone(defaultMonacoTheme);
return;
}
for (const themeKey of getRecordKeys(theme)) {
if (typeof theme[themeKey] !== "object") {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete theme[themeKey];
}
switch (themeKey) {
case "base":
if (!["vs-dark", "vs"].includes(theme.base)) theme.base = "vs-dark";
continue;
case "inherit":
if (typeof theme.inherit !== "boolean") theme.inherit = true;
continue;
}
const block = theme[themeKey];
const repairBlock = <T extends Record<string, unknown>>(block: T) => {
for (const [blockKey, blockValue] of Object.entries(block) as [keyof T, unknown][]) {
if (!blockValue || (typeof blockValue !== "string" && typeof blockValue !== "object"))
(block[blockKey] as string) = "FF0000";
else if (typeof blockValue === "object") repairBlock(blockValue as Record<string, unknown>);
else if (!blockValue.match(colorRegExp)) (block[blockKey] as string) = "FF0000";
}
};
// Type assertion is to something less specific.
repairBlock(block);
}
};
export function makeTheme(theme: IScriptEditorTheme): editor.IStandaloneThemeData {
const themeRules = [
{

View File

@@ -4,10 +4,16 @@ import { defaultStyles } from "../Themes/Styles";
import { CursorStyle, CursorBlinking, WordWrapOptions } from "../ScriptEditor/ui/Options";
import { defaultMonacoTheme } from "../ScriptEditor/ui/themes";
import { objectAssert } from "../utils/helpers/typeAssertion";
import {
assertAndSanitizeEditorTheme,
assertAndSanitizeMainTheme,
assertAndSanitizeStyles,
} from "../JsonSchema/JSONSchemaAssertion";
/**
* This function won't be able to catch **all** invalid hostnames, and it's still fine. In order to validate a hostname
* properly, we need to import a good validation library or write one by ourselves. I think that it's unnecessary.
* This function won't be able to catch **all** invalid hostnames. In order to validate a hostname properly, we need to
* import a good validation library or write one by ourselves. Considering that we only need to catch common mistakes,
* it's not worth the effort.
*
* Some invalid hostnames that we don't catch:
* - Invalid/missing TLD: "abc".
@@ -160,15 +166,33 @@ export const Settings = {
load(saveString: string) {
const save: unknown = JSON.parse(saveString);
objectAssert(save);
save.theme && Object.assign(Settings.theme, save.theme);
save.styles && Object.assign(Settings.styles, save.styles);
save.overview && Object.assign(Settings.overview, save.overview);
save.EditorTheme && Object.assign(Settings.EditorTheme, save.EditorTheme);
try {
// Sanitize theme data. Invalid theme data may crash the game or make it stuck in the loading page.
assertAndSanitizeMainTheme(save.theme);
Object.assign(Settings.theme, save.theme);
} catch (error) {
console.error(error);
}
try {
// Sanitize editor theme data. Invalid editor theme data may crash the game when the player opens the script editor.
assertAndSanitizeEditorTheme(save.EditorTheme);
Object.assign(Settings.EditorTheme, save.EditorTheme);
} catch (error) {
console.error(error);
}
try {
// Sanitize styles.
assertAndSanitizeStyles(save.styles);
Object.assign(Settings.styles, save.styles);
} catch (error) {
console.error(error);
}
Object.assign(Settings, save, {
theme: Settings.theme,
styles: Settings.styles,
overview: Settings.overview,
theme: Settings.theme,
EditorTheme: Settings.EditorTheme,
styles: Settings.styles,
});
/**
* The hostname and port of RFA have not been validated properly, so the save data may contain invalid data. In that

View File

@@ -1,7 +1,9 @@
import * as predefined from "./data";
/**
* If we change this interface, we must change MainThemeSchema and UserInterfaceTheme.
*/
export interface ITheme {
[key: string]: string | undefined;
primarylight: string;
primary: string;
primarydark: string;

View File

@@ -13,12 +13,13 @@ import HistoryIcon from "@mui/icons-material/History";
import { Color, ColorPicker } from "material-ui-color";
import { ThemeEvents } from "./Theme";
import { Settings } from "../../Settings/Settings";
import { defaultTheme } from "../Themes";
import { defaultTheme, type ITheme } from "../Themes";
import { UserInterfaceTheme } from "@nsdefs";
import { Router } from "../../ui/GameRoot";
import { Page } from "../../ui/Router";
import { ThemeCollaborate } from "./ThemeCollaborate";
import { dialogBoxCreate } from "../../ui/React/DialogBox";
import { assertAndSanitizeMainTheme } from "../../JsonSchema/JSONSchemaAssertion";
interface IProps {
open: boolean;
@@ -26,9 +27,9 @@ interface IProps {
}
interface IColorEditorProps {
name: string;
name: keyof ITheme;
color: string | undefined;
onColorChange: (name: string, value: string) => void;
onColorChange: (name: keyof ITheme, value: string) => void;
defaultColor: string;
}
@@ -70,7 +71,7 @@ function ColorEditor({ name, onColorChange, color, defaultColor }: IColorEditorP
}
export function ThemeEditorModal(props: IProps): React.ReactElement {
const [customTheme, setCustomTheme] = useState<Record<string, string | undefined>>({
const [customTheme, setCustomTheme] = useState<Record<keyof ITheme, string | undefined>>({
...Settings.theme,
});
@@ -81,27 +82,22 @@ export function ThemeEditorModal(props: IProps): React.ReactElement {
}
function onThemeChange(event: React.ChangeEvent<HTMLInputElement>): void {
let themeData: unknown;
try {
const importedTheme = JSON.parse(event.target.value) as typeof Settings.theme;
if (importedTheme == null) {
throw new Error("Theme data must not be null or undefined.");
}
if (typeof importedTheme !== "object") {
throw new Error(`Theme data is invalid.`);
}
setCustomTheme(importedTheme);
for (const key of Object.keys(importedTheme)) {
Settings.theme[key] = importedTheme[key];
}
ThemeEvents.emit();
themeData = JSON.parse(event.target.value);
assertAndSanitizeMainTheme(themeData);
} catch (error) {
console.error(`Theme data is invalid. Data: ${event.target.value}.`);
console.error(error);
dialogBoxCreate(`Invalid theme. ${error}`);
console.error("Theme data is invalid. Data:", event.target.value);
dialogBoxCreate(`Invalid theme. Errors: ${error}.`);
return;
}
Object.assign(Settings.theme, themeData);
ThemeEvents.emit();
setCustomTheme(Settings.theme);
}
function onColorChange(name: string, value: string): void {
function onColorChange(name: keyof ITheme, value: string): void {
setCustomTheme((old: Record<string, string | undefined>) => {
old[name] = value;
return old;