mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-17 14:59:16 +02:00
CODEBASE: Validate theme, editor theme, and styles (#1789)
This commit is contained in:
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
18
src/JsonSchema/Data/StylesSchema.ts
Normal file
18
src/JsonSchema/Data/StylesSchema.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
};
|
||||
286
src/JsonSchema/Data/ThemeSchema.ts
Normal file
286
src/JsonSchema/Data/ThemeSchema.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
};
|
||||
34
src/JsonSchema/JSONSchemaAssertion.ts
Normal file
34
src/JsonSchema/JSONSchemaAssertion.ts
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) => () => {
|
||||
|
||||
6
src/ScriptEditor/NetscriptDefinitions.d.ts
vendored
6
src/ScriptEditor/NetscriptDefinitions.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user