1143 lines
37 KiB
TypeScript
1143 lines
37 KiB
TypeScript
import { isThemeClockVariable } from './themeClock';
|
||
import type { ThemeV2ScriptScreen, ThemeV2ScriptValue } from './themeV2';
|
||
import { parse as parseAcorn } from 'acorn';
|
||
import type {
|
||
AssignmentExpression,
|
||
BinaryExpression,
|
||
BlockStatement,
|
||
CallExpression,
|
||
ConditionalExpression,
|
||
Expression,
|
||
ExpressionStatement,
|
||
ForStatement,
|
||
Identifier,
|
||
IfStatement,
|
||
Literal,
|
||
LogicalExpression,
|
||
Node as AcornNode,
|
||
ParenthesizedExpression,
|
||
Program as AcornProgram,
|
||
TemplateLiteral,
|
||
UnaryExpression,
|
||
UpdateExpression,
|
||
VariableDeclaration,
|
||
WhileStatement,
|
||
} from 'acorn';
|
||
|
||
export type ThemeScriptDiagnostic = {
|
||
message: string;
|
||
line: number;
|
||
column: number;
|
||
};
|
||
|
||
export type ThemeScriptExpression =
|
||
| { type: 'literal'; value: ThemeV2ScriptValue }
|
||
| { type: 'variable'; name: string }
|
||
| { type: 'unary'; operator: '!' | '-'; operand: ThemeScriptExpression }
|
||
| {
|
||
type: 'binary';
|
||
operator: '||' | '&&' | '==' | '!=' | '<' | '<=' | '>' | '>=' | '+' | '-' | '*' | '/' | '%';
|
||
left: ThemeScriptExpression;
|
||
right: ThemeScriptExpression;
|
||
}
|
||
| { type: 'call'; name: string; args: ThemeScriptExpression[] }
|
||
| {
|
||
type: 'conditional';
|
||
test: ThemeScriptExpression;
|
||
consequent: ThemeScriptExpression;
|
||
alternate: ThemeScriptExpression;
|
||
};
|
||
|
||
export type ThemeScriptStatement =
|
||
| { type: 'set'; name: string; expression: ThemeScriptExpression }
|
||
| { type: 'toggleVariable'; name: string }
|
||
| {
|
||
type: 'visibility';
|
||
target: 'element' | 'group';
|
||
id: string;
|
||
mode: 'show' | 'hide' | 'toggle';
|
||
}
|
||
| { type: 'navigate'; screen: ThemeV2ScriptScreen }
|
||
| { type: 'openUrl'; url: string }
|
||
| { type: 'launch' }
|
||
| { type: 'stop' }
|
||
| {
|
||
type: 'if';
|
||
condition: ThemeScriptExpression;
|
||
then: ThemeScriptStatement[];
|
||
else?: ThemeScriptStatement[];
|
||
}
|
||
| { type: 'repeat'; count: ThemeScriptExpression; body: ThemeScriptStatement[] }
|
||
| {
|
||
type: 'for';
|
||
init: ThemeScriptStatement[];
|
||
test?: ThemeScriptExpression;
|
||
update: ThemeScriptStatement[];
|
||
body: ThemeScriptStatement[];
|
||
}
|
||
| { type: 'while'; test: ThemeScriptExpression; body: ThemeScriptStatement[] };
|
||
|
||
export type ThemeScriptProgram = ThemeScriptStatement[];
|
||
|
||
export type ThemeScriptParseResult = {
|
||
program: ThemeScriptProgram;
|
||
errors: ThemeScriptDiagnostic[];
|
||
};
|
||
|
||
export type ThemeScriptExpressionContext = {
|
||
variables: Record<string, ThemeV2ScriptValue>;
|
||
screen: ThemeV2ScriptScreen;
|
||
isVisible: (elementId: string) => boolean;
|
||
isGroupVisible: (groupId: string) => boolean;
|
||
random: () => number;
|
||
};
|
||
|
||
export type ThemeScriptRuntime = ThemeScriptExpressionContext & {
|
||
setElementVisible?: (elementId: string, visible: boolean) => void;
|
||
setGroupVisible?: (groupId: string, visible: boolean) => void;
|
||
navigate?: (screen: ThemeV2ScriptScreen) => void;
|
||
openUrl?: (url: string) => void;
|
||
launch?: () => void;
|
||
/** External actions are only allowed from an explicit click trigger. */
|
||
allowExternal?: boolean;
|
||
};
|
||
|
||
const SCRIPT_SCREENS = ['home', 'themes', 'settings', 'login'] as const;
|
||
const MAX_NESTING = 32;
|
||
const BLOCKED_VARIABLES = new Set(['__proto__', 'prototype', 'constructor']);
|
||
const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
|
||
min: { min: 1, max: Number.MAX_SAFE_INTEGER },
|
||
max: { min: 1, max: Number.MAX_SAFE_INTEGER },
|
||
clamp: { min: 3, max: 3 },
|
||
round: { min: 1, max: 1 },
|
||
floor: { min: 1, max: 1 },
|
||
ceil: { min: 1, max: 1 },
|
||
abs: { min: 1, max: 1 },
|
||
random: { min: 0, max: 0 },
|
||
chance: { min: 1, max: 1 },
|
||
visible: { min: 1, max: 1 },
|
||
groupVisible: { min: 1, max: 1 },
|
||
contains: { min: 2, max: 2 },
|
||
length: { min: 1, max: 1 },
|
||
number: { min: 1, max: 1 },
|
||
string: { min: 1, max: 1 },
|
||
boolean: { min: 1, max: 1 },
|
||
};
|
||
|
||
class ThemeScriptParseError extends Error {
|
||
line: number;
|
||
column: number;
|
||
|
||
constructor(message: string, line: number, column: number) {
|
||
super(message);
|
||
this.line = line;
|
||
this.column = column;
|
||
}
|
||
}
|
||
|
||
const ACTION_FUNCTIONS = new Set([
|
||
'showLayer',
|
||
'hideLayer',
|
||
'toggleLayer',
|
||
'showGroup',
|
||
'hideGroup',
|
||
'toggleGroup',
|
||
'goTo',
|
||
'navigate',
|
||
'openUrl',
|
||
'launch',
|
||
'stop',
|
||
]);
|
||
|
||
const FUNCTION_ALIASES: Record<string, string> = {
|
||
Number: 'number',
|
||
String: 'string',
|
||
Boolean: 'boolean',
|
||
'Math.min': 'min',
|
||
'Math.max': 'max',
|
||
'Math.clamp': 'clamp',
|
||
'Math.round': 'round',
|
||
'Math.floor': 'floor',
|
||
'Math.ceil': 'ceil',
|
||
'Math.abs': 'abs',
|
||
'Math.random': 'random',
|
||
};
|
||
|
||
const BLOCKED_GLOBALS = new Set([
|
||
'document',
|
||
'window',
|
||
'globalThis',
|
||
'global',
|
||
'self',
|
||
'top',
|
||
'parent',
|
||
'frames',
|
||
'process',
|
||
'require',
|
||
'module',
|
||
'exports',
|
||
'eval',
|
||
'Function',
|
||
'AsyncFunction',
|
||
'GeneratorFunction',
|
||
'Reflect',
|
||
'Proxy',
|
||
'WebAssembly',
|
||
'fetch',
|
||
'XMLHttpRequest',
|
||
'localStorage',
|
||
'sessionStorage',
|
||
'navigator',
|
||
'location',
|
||
'history',
|
||
'Math',
|
||
'undefined',
|
||
'NaN',
|
||
'Infinity',
|
||
]);
|
||
|
||
const acornLocation = (node: AcornNode | undefined) => ({
|
||
line: node?.loc?.start.line ?? 1,
|
||
column: (node?.loc?.start.column ?? 0) + 1,
|
||
});
|
||
|
||
const compileError = (message: string, node?: AcornNode) => {
|
||
const location = acornLocation(node);
|
||
return new ThemeScriptParseError(message, location.line, location.column);
|
||
};
|
||
|
||
class JavaScriptThemeScriptCompiler {
|
||
private depth = 0;
|
||
|
||
compileProgram(program: AcornProgram): ThemeScriptProgram {
|
||
return this.compileStatements(program.body);
|
||
}
|
||
|
||
private nested<T>(node: AcornNode, compile: () => T): T {
|
||
if (this.depth >= MAX_NESTING) {
|
||
throw compileError(`Scripts can nest at most ${MAX_NESTING} blocks deep.`, node);
|
||
}
|
||
this.depth += 1;
|
||
try {
|
||
return compile();
|
||
} finally {
|
||
this.depth -= 1;
|
||
}
|
||
}
|
||
|
||
private compileStatements(nodes: AcornNode[]): ThemeScriptStatement[] {
|
||
return nodes.flatMap((node) => this.compileStatement(node));
|
||
}
|
||
|
||
private compileStatement(node: AcornNode): ThemeScriptStatement[] {
|
||
switch (node.type) {
|
||
case 'EmptyStatement':
|
||
return [];
|
||
case 'BlockStatement':
|
||
return this.nested(node, () => this.compileStatements((node as BlockStatement).body));
|
||
case 'VariableDeclaration':
|
||
return this.compileVariableDeclaration(node as VariableDeclaration);
|
||
case 'ExpressionStatement':
|
||
return this.compileExpressionStatement((node as ExpressionStatement).expression);
|
||
case 'IfStatement':
|
||
return this.compileIf(node as IfStatement);
|
||
case 'ForStatement':
|
||
return this.compileFor(node as ForStatement);
|
||
case 'WhileStatement':
|
||
return this.compileWhile(node as WhileStatement);
|
||
case 'ReturnStatement':
|
||
throw compileError('Use stop() instead of return to end a theme script.', node);
|
||
case 'BreakStatement':
|
||
case 'ContinueStatement':
|
||
throw compileError('break and continue are not supported yet.', node);
|
||
case 'FunctionDeclaration':
|
||
case 'FunctionExpression':
|
||
case 'ArrowFunctionExpression':
|
||
throw compileError('Functions are not supported in theme scripts.', node);
|
||
case 'ForInStatement':
|
||
case 'ForOfStatement':
|
||
throw compileError('Only classic for loops are supported.', node);
|
||
case 'DoWhileStatement':
|
||
throw compileError('do...while is not supported. Use while instead.', node);
|
||
case 'SwitchStatement':
|
||
case 'TryStatement':
|
||
case 'ThrowStatement':
|
||
case 'WithStatement':
|
||
case 'DebuggerStatement':
|
||
case 'LabeledStatement':
|
||
throw compileError(`${node.type} is not supported in theme scripts.`, node);
|
||
case 'ImportDeclaration':
|
||
case 'ExportNamedDeclaration':
|
||
case 'ExportDefaultDeclaration':
|
||
case 'ExportAllDeclaration':
|
||
throw compileError('Imports and exports are not available in theme scripts.', node);
|
||
default:
|
||
throw compileError(`Unsupported JavaScript statement "${node.type}".`, node);
|
||
}
|
||
}
|
||
|
||
private compileVariableDeclaration(node: VariableDeclaration): ThemeScriptStatement[] {
|
||
if (node.kind !== 'var' && node.kind !== 'let' && node.kind !== 'const') {
|
||
throw compileError('Only var, let and const declarations are supported.', node);
|
||
}
|
||
|
||
return node.declarations.map((declaration) => {
|
||
if (declaration.id.type !== 'Identifier') {
|
||
throw compileError(
|
||
'Destructuring is not supported. Use a simple variable name.',
|
||
declaration.id
|
||
);
|
||
}
|
||
return {
|
||
type: 'set',
|
||
name: this.variableName(declaration.id.name, declaration.id),
|
||
expression: declaration.init
|
||
? this.compileExpression(declaration.init)
|
||
: { type: 'literal', value: 0 },
|
||
};
|
||
});
|
||
}
|
||
|
||
private compileExpressionStatement(expression: Expression): ThemeScriptStatement[] {
|
||
if (expression.type === 'AssignmentExpression') {
|
||
return this.compileAssignment(expression as AssignmentExpression);
|
||
}
|
||
if (expression.type === 'UpdateExpression') {
|
||
return this.compileUpdate(expression as UpdateExpression);
|
||
}
|
||
if (expression.type === 'CallExpression') {
|
||
return [this.compileActionCall(expression as CallExpression)];
|
||
}
|
||
if (expression.type === 'SequenceExpression') {
|
||
return expression.expressions.flatMap((item) => this.compileExpressionStatement(item));
|
||
}
|
||
throw compileError(
|
||
'Only assignments, ++/-- and theme action calls can be used as statements.',
|
||
expression
|
||
);
|
||
}
|
||
|
||
private compileAssignment(node: AssignmentExpression): ThemeScriptStatement[] {
|
||
if (node.left.type !== 'Identifier') {
|
||
throw compileError('Only simple variable names can be assigned.', node.left);
|
||
}
|
||
const name = this.variableName(node.left.name, node.left);
|
||
const right = this.compileExpression(node.right);
|
||
switch (node.operator) {
|
||
case '=':
|
||
return [{ type: 'set', name, expression: right }];
|
||
case '+=':
|
||
case '-=':
|
||
case '*=':
|
||
case '/=':
|
||
case '%=':
|
||
return [
|
||
{
|
||
type: 'set',
|
||
name,
|
||
expression: {
|
||
type: 'binary',
|
||
operator: node.operator.slice(0, -1) as '+' | '-' | '*' | '/' | '%',
|
||
left: { type: 'variable', name },
|
||
right,
|
||
},
|
||
},
|
||
];
|
||
default:
|
||
throw compileError(`Assignment operator "${node.operator}" is not supported.`, node);
|
||
}
|
||
}
|
||
|
||
private compileUpdate(node: UpdateExpression): ThemeScriptStatement[] {
|
||
if (node.argument.type !== 'Identifier') {
|
||
throw compileError('Only simple variable names can be incremented.', node.argument);
|
||
}
|
||
return [
|
||
{
|
||
type: 'set',
|
||
name: this.variableName(node.argument.name, node.argument),
|
||
expression: {
|
||
type: 'binary',
|
||
operator: node.operator === '++' ? '+' : '-',
|
||
left: { type: 'variable', name: node.argument.name },
|
||
right: { type: 'literal', value: 1 },
|
||
},
|
||
},
|
||
];
|
||
}
|
||
|
||
private compileIf(node: IfStatement): ThemeScriptStatement[] {
|
||
return this.nested(node, () => {
|
||
const condition = this.compileExpression(node.test);
|
||
const then = this.compileStatement(node.consequent);
|
||
const otherwise = node.alternate ? this.compileStatement(node.alternate) : undefined;
|
||
return [
|
||
{
|
||
type: 'if',
|
||
condition,
|
||
then,
|
||
...(otherwise ? { else: otherwise } : {}),
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
private compileFor(node: ForStatement): ThemeScriptStatement[] {
|
||
return this.nested(node, () => {
|
||
const init = node.init
|
||
? node.init.type === 'VariableDeclaration'
|
||
? this.compileVariableDeclaration(node.init)
|
||
: this.compileExpressionStatement(node.init)
|
||
: [];
|
||
const test = node.test ? this.compileExpression(node.test) : undefined;
|
||
const update = node.update ? this.compileExpressionStatement(node.update) : [];
|
||
return [
|
||
{
|
||
type: 'for',
|
||
init,
|
||
...(test ? { test } : {}),
|
||
update,
|
||
body: this.compileStatement(node.body),
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
private compileWhile(node: WhileStatement): ThemeScriptStatement[] {
|
||
return this.nested(node, () => [
|
||
{
|
||
type: 'while',
|
||
test: this.compileExpression(node.test),
|
||
body: this.compileStatement(node.body),
|
||
},
|
||
]);
|
||
}
|
||
|
||
private compileExpression(node: AcornNode): ThemeScriptExpression {
|
||
switch (node.type) {
|
||
case 'Literal': {
|
||
const literal = node as Literal & { regex?: unknown; bigint?: unknown };
|
||
if (literal.regex || literal.bigint) {
|
||
throw compileError('Regular expressions and BigInt are not supported.', node);
|
||
}
|
||
if (literal.value === null || literal.value === undefined) {
|
||
throw compileError(
|
||
'null and undefined are not supported. Use 0 or an empty string.',
|
||
node
|
||
);
|
||
}
|
||
if (
|
||
typeof literal.value !== 'string' &&
|
||
typeof literal.value !== 'number' &&
|
||
typeof literal.value !== 'boolean'
|
||
) {
|
||
throw compileError('Only numbers, strings and booleans are supported.', node);
|
||
}
|
||
return { type: 'literal', value: literal.value };
|
||
}
|
||
case 'Identifier': {
|
||
const name = (node as Identifier).name;
|
||
if (BLOCKED_GLOBALS.has(name)) {
|
||
throw compileError(`The global "${name}" is not available in theme scripts.`, node);
|
||
}
|
||
return { type: 'variable', name };
|
||
}
|
||
case 'TemplateLiteral':
|
||
return this.compileTemplate(node as TemplateLiteral);
|
||
case 'UnaryExpression': {
|
||
const unary = node as UnaryExpression;
|
||
if (unary.operator !== '!' && unary.operator !== '-' && unary.operator !== '+') {
|
||
throw compileError(`Unary operator "${unary.operator}" is not supported.`, node);
|
||
}
|
||
const operand = this.compileExpression(unary.argument);
|
||
return unary.operator === '+'
|
||
? operand
|
||
: { type: 'unary', operator: unary.operator, operand };
|
||
}
|
||
case 'BinaryExpression': {
|
||
const binary = node as BinaryExpression;
|
||
const operator =
|
||
binary.operator === '===' ? '==' : binary.operator === '!==' ? '!=' : binary.operator;
|
||
if (!['==', '!=', '<', '<=', '>', '>=', '+', '-', '*', '/', '%'].includes(operator)) {
|
||
throw compileError(`Operator "${binary.operator}" is not supported.`, node);
|
||
}
|
||
return {
|
||
type: 'binary',
|
||
operator: operator as '==' | '!=' | '<' | '<=' | '>' | '>=' | '+' | '-' | '*' | '/' | '%',
|
||
left: this.compileExpression(binary.left as AcornNode),
|
||
right: this.compileExpression(binary.right),
|
||
};
|
||
}
|
||
case 'LogicalExpression': {
|
||
const logical = node as LogicalExpression;
|
||
if (logical.operator !== '&&' && logical.operator !== '||') {
|
||
throw compileError(`Operator "${logical.operator}" is not supported.`, node);
|
||
}
|
||
return {
|
||
type: 'binary',
|
||
operator: logical.operator,
|
||
left: this.compileExpression(logical.left),
|
||
right: this.compileExpression(logical.right),
|
||
};
|
||
}
|
||
case 'ConditionalExpression': {
|
||
const conditional = node as ConditionalExpression;
|
||
return {
|
||
type: 'conditional',
|
||
test: this.compileExpression(conditional.test),
|
||
consequent: this.compileExpression(conditional.consequent),
|
||
alternate: this.compileExpression(conditional.alternate),
|
||
};
|
||
}
|
||
case 'CallExpression':
|
||
return this.compilePureCall(node as CallExpression);
|
||
case 'ParenthesizedExpression':
|
||
return this.compileExpression((node as ParenthesizedExpression).expression);
|
||
case 'AssignmentExpression':
|
||
case 'UpdateExpression':
|
||
throw compileError('Assignments are statements; put them on their own line.', node);
|
||
case 'MemberExpression':
|
||
case 'ChainExpression':
|
||
throw compileError('Only Math.* functions are available inside expressions.', node);
|
||
case 'ThisExpression':
|
||
throw compileError('this is not available in theme scripts.', node);
|
||
case 'ArrayExpression':
|
||
case 'ObjectExpression':
|
||
throw compileError('Arrays and objects are not supported. Use theme variables.', node);
|
||
case 'FunctionExpression':
|
||
case 'ArrowFunctionExpression':
|
||
case 'ClassExpression':
|
||
throw compileError('Functions and classes are not supported in theme scripts.', node);
|
||
default:
|
||
throw compileError(`Unsupported JavaScript expression "${node.type}".`, node);
|
||
}
|
||
}
|
||
|
||
private compileTemplate(node: TemplateLiteral): ThemeScriptExpression {
|
||
let result: ThemeScriptExpression = {
|
||
type: 'literal',
|
||
value: node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? '',
|
||
};
|
||
for (let index = 0; index < node.expressions.length; index += 1) {
|
||
result = {
|
||
type: 'binary',
|
||
operator: '+',
|
||
left: result,
|
||
right: this.compileExpression(node.expressions[index]),
|
||
};
|
||
const next = node.quasis[index + 1]?.value.cooked ?? node.quasis[index + 1]?.value.raw ?? '';
|
||
if (next) {
|
||
result = {
|
||
type: 'binary',
|
||
operator: '+',
|
||
left: result,
|
||
right: { type: 'literal', value: next },
|
||
};
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private callName(node: CallExpression): string {
|
||
if (node.callee.type === 'Identifier') return node.callee.name;
|
||
if (
|
||
node.callee.type === 'MemberExpression' &&
|
||
!node.callee.computed &&
|
||
node.callee.object.type === 'Identifier' &&
|
||
node.callee.property.type === 'Identifier'
|
||
) {
|
||
return `${node.callee.object.name}.${node.callee.property.name}`;
|
||
}
|
||
throw compileError('Only direct function calls are supported.', node.callee as AcornNode);
|
||
}
|
||
|
||
private compilePureCall(node: CallExpression): ThemeScriptExpression {
|
||
const rawName = this.callName(node);
|
||
if (ACTION_FUNCTIONS.has(rawName)) {
|
||
throw compileError(`${rawName}() is an action and must be its own statement.`, node);
|
||
}
|
||
const name = FUNCTION_ALIASES[rawName] ?? rawName;
|
||
const spec = FUNCTION_ARITY[name];
|
||
if (!spec) throw compileError(`Unknown function "${rawName}".`, node);
|
||
if (node.arguments.some((argument) => argument.type === 'SpreadElement')) {
|
||
throw compileError('Spread arguments are not supported.', node);
|
||
}
|
||
if (node.arguments.length < spec.min || node.arguments.length > spec.max) {
|
||
const expected =
|
||
spec.min === spec.max
|
||
? `${spec.min}`
|
||
: `${spec.min}–${spec.max === Number.MAX_SAFE_INTEGER ? 'many' : spec.max}`;
|
||
throw compileError(
|
||
`Function "${rawName}" expects ${expected} argument${spec.min === 1 && spec.max === 1 ? '' : 's'}.`,
|
||
node
|
||
);
|
||
}
|
||
return {
|
||
type: 'call',
|
||
name,
|
||
args: node.arguments.map((argument) => this.compileExpression(argument as AcornNode)),
|
||
};
|
||
}
|
||
|
||
private compileActionCall(node: CallExpression): ThemeScriptStatement {
|
||
const name = this.callName(node);
|
||
if (!ACTION_FUNCTIONS.has(name)) {
|
||
if (FUNCTION_ARITY[name] || FUNCTION_ALIASES[name]) {
|
||
throw compileError(
|
||
`${name}() is a value function and cannot be used as a statement.`,
|
||
node
|
||
);
|
||
}
|
||
throw compileError(`Unknown action "${name}()".`, node);
|
||
}
|
||
if (node.arguments.some((argument) => argument.type === 'SpreadElement')) {
|
||
throw compileError('Spread arguments are not supported.', node);
|
||
}
|
||
|
||
const stringArgument = (label: string): string => {
|
||
const argument = node.arguments[0];
|
||
if (!argument || argument.type !== 'Literal' || typeof argument.value !== 'string') {
|
||
throw compileError(`${name}() expects ${label} as a quoted string.`, argument ?? node);
|
||
}
|
||
return argument.value;
|
||
};
|
||
const requireCount = (count: number) => {
|
||
if (node.arguments.length !== count) {
|
||
throw compileError(`${name}() expects ${count} argument${count === 1 ? '' : 's'}.`, node);
|
||
}
|
||
};
|
||
const idArgument = (label: string) => {
|
||
const value = stringArgument(label).trim();
|
||
if (!value || value.length > 64) {
|
||
throw compileError(`${label} must be between 1 and 64 characters.`, node);
|
||
}
|
||
return value;
|
||
};
|
||
|
||
switch (name) {
|
||
case 'showLayer':
|
||
case 'hideLayer':
|
||
case 'toggleLayer': {
|
||
requireCount(1);
|
||
return {
|
||
type: 'visibility',
|
||
target: 'element',
|
||
id: idArgument('a layer id'),
|
||
mode: name === 'showLayer' ? 'show' : name === 'hideLayer' ? 'hide' : 'toggle',
|
||
};
|
||
}
|
||
case 'showGroup':
|
||
case 'hideGroup':
|
||
case 'toggleGroup': {
|
||
requireCount(1);
|
||
return {
|
||
type: 'visibility',
|
||
target: 'group',
|
||
id: idArgument('a group id'),
|
||
mode: name === 'showGroup' ? 'show' : name === 'hideGroup' ? 'hide' : 'toggle',
|
||
};
|
||
}
|
||
case 'goTo':
|
||
case 'navigate': {
|
||
requireCount(1);
|
||
const screen = stringArgument('a screen name');
|
||
if (!SCRIPT_SCREENS.includes(screen as (typeof SCRIPT_SCREENS)[number])) {
|
||
throw compileError(
|
||
`Unknown screen "${screen}". Use home, themes, settings or login.`,
|
||
node
|
||
);
|
||
}
|
||
return { type: 'navigate', screen: screen as ThemeV2ScriptScreen };
|
||
}
|
||
case 'openUrl': {
|
||
requireCount(1);
|
||
const url = stringArgument('a URL');
|
||
if (!/^https?:\/\//i.test(url) || url.length > 2000) {
|
||
throw compileError(
|
||
'URLs must start with http:// or https:// and be at most 2000 characters.',
|
||
node
|
||
);
|
||
}
|
||
return { type: 'openUrl', url };
|
||
}
|
||
case 'launch':
|
||
requireCount(0);
|
||
return { type: 'launch' };
|
||
case 'stop':
|
||
requireCount(0);
|
||
return { type: 'stop' };
|
||
default:
|
||
throw compileError(`Unknown action "${name}()".`, node);
|
||
}
|
||
}
|
||
|
||
private variableName(name: string, node: AcornNode): string {
|
||
if (isThemeClockVariable(name)) {
|
||
throw compileError(`"${name}" is a built-in clock value and is read-only.`, node);
|
||
}
|
||
if (BLOCKED_VARIABLES.has(name) || BLOCKED_GLOBALS.has(name)) {
|
||
throw compileError(`"${name}" cannot be used as a theme variable.`, node);
|
||
}
|
||
if (name === 'screen') {
|
||
throw compileError('"screen" is read-only in theme scripts.', node);
|
||
}
|
||
return name;
|
||
}
|
||
}
|
||
|
||
const parseJavaScriptThemeScript = (source: string): ThemeScriptParseResult => {
|
||
let program: AcornProgram;
|
||
try {
|
||
program = parseAcorn(source, {
|
||
ecmaVersion: 'latest',
|
||
sourceType: 'script',
|
||
locations: true,
|
||
}) as AcornProgram;
|
||
} catch (error) {
|
||
const loc = (error as { loc?: { line: number; column: number } }).loc;
|
||
return {
|
||
program: [],
|
||
errors: [
|
||
{
|
||
message: (error instanceof Error
|
||
? error.message
|
||
: 'Could not parse this JavaScript.'
|
||
).replace(/\s*\(\d+:\d+\)\s*$/, ''),
|
||
line: loc?.line ?? 1,
|
||
column: (loc?.column ?? 0) + 1,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
try {
|
||
return { program: new JavaScriptThemeScriptCompiler().compileProgram(program), errors: [] };
|
||
} catch (error) {
|
||
if (error instanceof ThemeScriptParseError) {
|
||
return {
|
||
program: [],
|
||
errors: [{ message: error.message, line: error.line, column: error.column }],
|
||
};
|
||
}
|
||
return {
|
||
program: [],
|
||
errors: [
|
||
{
|
||
message: error instanceof Error ? error.message : 'Could not compile this script.',
|
||
line: 1,
|
||
column: 1,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
};
|
||
|
||
/** Theme scripts use plain, sandboxed JavaScript syntax. */
|
||
export const parseThemeScript = (source: string): ThemeScriptParseResult => {
|
||
if (!source.trim()) return { program: [], errors: [] };
|
||
return parseJavaScriptThemeScript(source);
|
||
};
|
||
|
||
export const formatThemeScriptError = (error: ThemeScriptDiagnostic) =>
|
||
`${error.message} (line ${error.line}, column ${error.column})`;
|
||
|
||
const numberValue = (value: ThemeV2ScriptValue): number => {
|
||
const number = typeof value === 'number' ? value : Number(value);
|
||
return Number.isFinite(number) ? number : 0;
|
||
};
|
||
|
||
const booleanValue = (value: ThemeV2ScriptValue): boolean => Boolean(value);
|
||
|
||
const equalValues = (left: ThemeV2ScriptValue, right: ThemeV2ScriptValue) => {
|
||
if (typeof left === 'number' || typeof right === 'number') {
|
||
return numberValue(left) === numberValue(right);
|
||
}
|
||
if (typeof left === 'boolean' || typeof right === 'boolean') {
|
||
return booleanValue(left) === booleanValue(right);
|
||
}
|
||
return String(left) === String(right);
|
||
};
|
||
|
||
const compareValues = (left: ThemeV2ScriptValue, right: ThemeV2ScriptValue) => {
|
||
if (typeof left === 'number' || typeof right === 'number') {
|
||
return numberValue(left) - numberValue(right);
|
||
}
|
||
const leftText = String(left);
|
||
const rightText = String(right);
|
||
return leftText < rightText ? -1 : leftText > rightText ? 1 : 0;
|
||
};
|
||
|
||
const callFunction = (
|
||
name: string,
|
||
args: ThemeV2ScriptValue[],
|
||
context: ThemeScriptExpressionContext
|
||
): ThemeV2ScriptValue => {
|
||
switch (name) {
|
||
case 'min':
|
||
return Math.min(...args.map(numberValue));
|
||
case 'max':
|
||
return Math.max(...args.map(numberValue));
|
||
case 'clamp': {
|
||
const value = numberValue(args[0]);
|
||
const min = numberValue(args[1]);
|
||
const max = numberValue(args[2]);
|
||
return Math.min(Math.max(value, Math.min(min, max)), Math.max(min, max));
|
||
}
|
||
case 'round':
|
||
return Math.round(numberValue(args[0]));
|
||
case 'floor':
|
||
return Math.floor(numberValue(args[0]));
|
||
case 'ceil':
|
||
return Math.ceil(numberValue(args[0]));
|
||
case 'abs':
|
||
return Math.abs(numberValue(args[0]));
|
||
case 'random':
|
||
return context.random();
|
||
case 'chance':
|
||
return context.random() * 100 < numberValue(args[0]);
|
||
case 'visible':
|
||
return context.isVisible(String(args[0] ?? ''));
|
||
case 'groupVisible':
|
||
return context.isGroupVisible(String(args[0] ?? ''));
|
||
case 'contains':
|
||
return String(args[0] ?? '').includes(String(args[1] ?? ''));
|
||
case 'length':
|
||
return String(args[0] ?? '').length;
|
||
case 'number':
|
||
return numberValue(args[0]);
|
||
case 'string':
|
||
return String(args[0] ?? '');
|
||
case 'boolean':
|
||
return booleanValue(args[0]);
|
||
default:
|
||
return 0;
|
||
}
|
||
};
|
||
|
||
export const evaluateThemeScriptExpression = (
|
||
expression: ThemeScriptExpression,
|
||
context: ThemeScriptExpressionContext
|
||
): ThemeV2ScriptValue => {
|
||
switch (expression.type) {
|
||
case 'literal':
|
||
return expression.value;
|
||
case 'variable':
|
||
return expression.name === 'screen'
|
||
? context.screen
|
||
: (context.variables[expression.name] ?? 0);
|
||
case 'unary': {
|
||
if (expression.operator === '!')
|
||
return !booleanValue(evaluateThemeScriptExpression(expression.operand, context));
|
||
return -numberValue(evaluateThemeScriptExpression(expression.operand, context));
|
||
}
|
||
case 'call':
|
||
return callFunction(
|
||
expression.name,
|
||
expression.args.map((argument) => evaluateThemeScriptExpression(argument, context)),
|
||
context
|
||
);
|
||
case 'conditional':
|
||
return booleanValue(evaluateThemeScriptExpression(expression.test, context))
|
||
? evaluateThemeScriptExpression(expression.consequent, context)
|
||
: evaluateThemeScriptExpression(expression.alternate, context);
|
||
case 'binary': {
|
||
if (expression.operator === '&&') {
|
||
return (
|
||
booleanValue(evaluateThemeScriptExpression(expression.left, context)) &&
|
||
booleanValue(evaluateThemeScriptExpression(expression.right, context))
|
||
);
|
||
}
|
||
if (expression.operator === '||') {
|
||
return (
|
||
booleanValue(evaluateThemeScriptExpression(expression.left, context)) ||
|
||
booleanValue(evaluateThemeScriptExpression(expression.right, context))
|
||
);
|
||
}
|
||
|
||
const left = evaluateThemeScriptExpression(expression.left, context);
|
||
const right = evaluateThemeScriptExpression(expression.right, context);
|
||
switch (expression.operator) {
|
||
case '==':
|
||
return equalValues(left, right);
|
||
case '!=':
|
||
return !equalValues(left, right);
|
||
case '<':
|
||
return compareValues(left, right) < 0;
|
||
case '<=':
|
||
return compareValues(left, right) <= 0;
|
||
case '>':
|
||
return compareValues(left, right) > 0;
|
||
case '>=':
|
||
return compareValues(left, right) >= 0;
|
||
case '+':
|
||
return typeof left === 'string' || typeof right === 'string'
|
||
? String(left) + String(right)
|
||
: numberValue(left) + numberValue(right);
|
||
case '-':
|
||
return numberValue(left) - numberValue(right);
|
||
case '*':
|
||
return numberValue(left) * numberValue(right);
|
||
case '/': {
|
||
const divisor = numberValue(right);
|
||
return divisor === 0 ? 0 : numberValue(left) / divisor;
|
||
}
|
||
case '%': {
|
||
const divisor = numberValue(right);
|
||
return divisor === 0 ? 0 : numberValue(left) % divisor;
|
||
}
|
||
default:
|
||
return 0;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const walkExpression = (
|
||
expression: ThemeScriptExpression,
|
||
visit: (expression: ThemeScriptExpression) => void
|
||
) => {
|
||
visit(expression);
|
||
switch (expression.type) {
|
||
case 'unary':
|
||
walkExpression(expression.operand, visit);
|
||
break;
|
||
case 'binary':
|
||
walkExpression(expression.left, visit);
|
||
walkExpression(expression.right, visit);
|
||
break;
|
||
case 'call':
|
||
for (const argument of expression.args) walkExpression(argument, visit);
|
||
break;
|
||
case 'conditional':
|
||
walkExpression(expression.test, visit);
|
||
walkExpression(expression.consequent, visit);
|
||
walkExpression(expression.alternate, visit);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
};
|
||
|
||
const walkStatements = (
|
||
statements: ThemeScriptStatement[],
|
||
visit: (statement: ThemeScriptStatement) => void
|
||
) => {
|
||
for (const statement of statements) {
|
||
visit(statement);
|
||
if (statement.type === 'if') {
|
||
walkStatements(statement.then, visit);
|
||
if (statement.else) walkStatements(statement.else, visit);
|
||
} else if (statement.type === 'repeat') {
|
||
walkStatements(statement.body, visit);
|
||
} else if (statement.type === 'for') {
|
||
walkStatements(statement.init, visit);
|
||
walkStatements(statement.update, visit);
|
||
walkStatements(statement.body, visit);
|
||
} else if (statement.type === 'while') {
|
||
walkStatements(statement.body, visit);
|
||
}
|
||
}
|
||
};
|
||
|
||
/** Variable names read or written by a compiled script; `screen` is a builtin. */
|
||
export const themeScriptProgramVariables = (program: ThemeScriptProgram): string[] => {
|
||
const names = new Set<string>();
|
||
walkStatements(program, (statement) => {
|
||
if (statement.type === 'set') names.add(statement.name);
|
||
if (statement.type === 'toggleVariable') names.add(statement.name);
|
||
if (statement.type === 'if')
|
||
walkExpression(statement.condition, (expression) => {
|
||
if (expression.type === 'variable' && expression.name !== 'screen')
|
||
names.add(expression.name);
|
||
});
|
||
if (statement.type === 'repeat')
|
||
walkExpression(statement.count, (expression) => {
|
||
if (expression.type === 'variable' && expression.name !== 'screen')
|
||
names.add(expression.name);
|
||
});
|
||
if (statement.type === 'for' && statement.test)
|
||
walkExpression(statement.test, (expression) => {
|
||
if (expression.type === 'variable' && expression.name !== 'screen')
|
||
names.add(expression.name);
|
||
});
|
||
if (statement.type === 'while')
|
||
walkExpression(statement.test, (expression) => {
|
||
if (expression.type === 'variable' && expression.name !== 'screen')
|
||
names.add(expression.name);
|
||
});
|
||
if (statement.type === 'set')
|
||
walkExpression(statement.expression, (expression) => {
|
||
if (expression.type === 'variable' && expression.name !== 'screen')
|
||
names.add(expression.name);
|
||
});
|
||
});
|
||
return [...names].filter((name) => !isThemeClockVariable(name));
|
||
};
|
||
|
||
export const themeScriptSourceVariables = (source: string): string[] => {
|
||
const { program } = parseThemeScript(source);
|
||
return program.length > 0 ? themeScriptProgramVariables(program) : [];
|
||
};
|
||
|
||
/** Whether a compiled script contains an action that leaves the launcher. */
|
||
export const themeScriptProgramCanLeaveLauncher = (program: ThemeScriptProgram): boolean => {
|
||
let canLeave = false;
|
||
walkStatements(program, (statement) => {
|
||
if (statement.type === 'launch' || statement.type === 'openUrl') canLeave = true;
|
||
});
|
||
return canLeave;
|
||
};
|
||
|
||
export type ThemeScriptReferences = {
|
||
elementIds: string[];
|
||
groupIds: string[];
|
||
};
|
||
|
||
/** Layer and group ids referenced directly by a compiled script. */
|
||
export const themeScriptProgramReferences = (
|
||
program: ThemeScriptProgram
|
||
): ThemeScriptReferences => {
|
||
const elementIds = new Set<string>();
|
||
const groupIds = new Set<string>();
|
||
|
||
const collectExpression = (expression: ThemeScriptExpression) => {
|
||
walkExpression(expression, (node) => {
|
||
if (node.type !== 'call' || node.args.length === 0) return;
|
||
const argument = node.args[0];
|
||
if (argument.type !== 'literal' || typeof argument.value !== 'string') return;
|
||
if (node.name === 'visible') elementIds.add(argument.value);
|
||
if (node.name === 'groupVisible') groupIds.add(argument.value);
|
||
});
|
||
};
|
||
|
||
walkStatements(program, (statement) => {
|
||
if (statement.type === 'visibility') {
|
||
if (statement.target === 'element') elementIds.add(statement.id);
|
||
else groupIds.add(statement.id);
|
||
}
|
||
if (statement.type === 'if') collectExpression(statement.condition);
|
||
if (statement.type === 'repeat') collectExpression(statement.count);
|
||
if (statement.type === 'for' && statement.test) collectExpression(statement.test);
|
||
if (statement.type === 'while') collectExpression(statement.test);
|
||
if (statement.type === 'set') collectExpression(statement.expression);
|
||
});
|
||
|
||
return { elementIds: [...elementIds], groupIds: [...groupIds] };
|
||
};
|
||
|
||
const MAX_STEPS = 10000;
|
||
const MAX_REPEAT = 1000;
|
||
|
||
/** Execute a compiled script against theme state. Never evaluates JavaScript. */
|
||
export const executeThemeScript = (program: ThemeScriptProgram, runtime: ThemeScriptRuntime) => {
|
||
let steps = 0;
|
||
let stopped = false;
|
||
let exhausted = false;
|
||
|
||
const evaluate = (expression: ThemeScriptExpression) =>
|
||
evaluateThemeScriptExpression(expression, runtime);
|
||
|
||
const run = (statements: ThemeScriptStatement[]): boolean => {
|
||
for (const statement of statements) {
|
||
if (stopped) return true;
|
||
steps += 1;
|
||
if (steps > MAX_STEPS) {
|
||
exhausted = true;
|
||
return true;
|
||
}
|
||
|
||
switch (statement.type) {
|
||
case 'set':
|
||
runtime.variables[statement.name] = evaluate(statement.expression);
|
||
break;
|
||
case 'toggleVariable':
|
||
runtime.variables[statement.name] = !booleanValue(
|
||
runtime.variables[statement.name] ?? false
|
||
);
|
||
break;
|
||
case 'visibility': {
|
||
const visible =
|
||
statement.mode === 'show'
|
||
? true
|
||
: statement.mode === 'hide'
|
||
? false
|
||
: statement.target === 'group'
|
||
? !runtime.isGroupVisible(statement.id)
|
||
: !runtime.isVisible(statement.id);
|
||
if (statement.target === 'group') runtime.setGroupVisible?.(statement.id, visible);
|
||
else runtime.setElementVisible?.(statement.id, visible);
|
||
break;
|
||
}
|
||
case 'navigate':
|
||
runtime.navigate?.(statement.screen);
|
||
break;
|
||
case 'openUrl':
|
||
if (runtime.allowExternal === true && /^https?:\/\//i.test(statement.url)) {
|
||
runtime.openUrl?.(statement.url);
|
||
}
|
||
break;
|
||
case 'launch':
|
||
if (runtime.allowExternal === true) runtime.launch?.();
|
||
break;
|
||
case 'stop':
|
||
stopped = true;
|
||
return true;
|
||
case 'if': {
|
||
const branch = booleanValue(evaluate(statement.condition))
|
||
? statement.then
|
||
: (statement.else ?? []);
|
||
if (run(branch)) return true;
|
||
break;
|
||
}
|
||
case 'repeat': {
|
||
const count = Math.min(
|
||
MAX_REPEAT,
|
||
Math.max(0, Math.floor(numberValue(evaluate(statement.count))))
|
||
);
|
||
for (let index = 0; index < count; index += 1) {
|
||
steps += 1;
|
||
if (steps > MAX_STEPS) {
|
||
exhausted = true;
|
||
return true;
|
||
}
|
||
if (run(statement.body)) return true;
|
||
}
|
||
break;
|
||
}
|
||
case 'for': {
|
||
if (run(statement.init)) return true;
|
||
let iterations = 0;
|
||
while (!statement.test || booleanValue(evaluate(statement.test))) {
|
||
steps += 1;
|
||
iterations += 1;
|
||
if (steps > MAX_STEPS || iterations > MAX_REPEAT) {
|
||
exhausted = true;
|
||
return true;
|
||
}
|
||
if (run(statement.body)) return true;
|
||
if (run(statement.update)) return true;
|
||
}
|
||
break;
|
||
}
|
||
case 'while': {
|
||
let iterations = 0;
|
||
while (booleanValue(evaluate(statement.test))) {
|
||
steps += 1;
|
||
iterations += 1;
|
||
if (steps > MAX_STEPS || iterations > MAX_REPEAT) {
|
||
exhausted = true;
|
||
return true;
|
||
}
|
||
if (run(statement.body)) return true;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
run(program);
|
||
return { stopped, exhausted };
|
||
};
|