mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-08-20 04:27:29 +00:00
feat: game options support!
This commit is contained in:
parent
f7c3502b61
commit
167e1b8066
260
schemas/args.json
Normal file
260
schemas/args.json
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://emerald-legacy-launcher/schemas/args.json",
|
||||
"title": "ArgsSchema",
|
||||
"description": "Schema describing launcher command-line argument options, groups, and conditional dependencies.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["$schema", "groups", "options", "dependencies"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "URI of the JSON schema this file conforms to."
|
||||
},
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1,
|
||||
"description": "Version of the schema format. Currently always 1."
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Arbitrary metadata about this schema."
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"description": "Groups used to organize options.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/schemaGroup"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "The command-line options.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/schemaOption"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"type": "array",
|
||||
"description": "Conditional dependencies that disable or hide options.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/schemaDependency"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"schemaGroup": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the group."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title of the group."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description of the group."
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemaOption": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title", "type", "arg"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the option."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title of the option."
|
||||
},
|
||||
"type": {
|
||||
"enum": ["boolean", "int", "number", "string", "choice"],
|
||||
"description": "The kind of value the option accepts."
|
||||
},
|
||||
"arg": {
|
||||
"type": "string",
|
||||
"description": "The command-line argument string emitted for this option."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional human-readable description."
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"description": "Identifier of the group this option belongs to."
|
||||
},
|
||||
"default": {
|
||||
"description": "Default value for the option. Must match the option's type."
|
||||
},
|
||||
"min": {
|
||||
"type": "number",
|
||||
"description": "Minimum value for int/number options."
|
||||
},
|
||||
"max": {
|
||||
"type": "number",
|
||||
"description": "Maximum value for int/number options."
|
||||
},
|
||||
"step": {
|
||||
"type": "number",
|
||||
"description": "Stepping increment for int/number options."
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string",
|
||||
"description": "Placeholder text for string options."
|
||||
},
|
||||
"choices": {
|
||||
"type": "array",
|
||||
"description": "Allowed choices. Required and must be non-empty when type is 'choice'.",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/schemaChoice"
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": { "const": "choice" }
|
||||
},
|
||||
"required": ["type"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["choices"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": { "const": "boolean" }
|
||||
},
|
||||
"required": ["type"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"default": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"type": { "const": "string" }
|
||||
},
|
||||
"required": ["type"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"default": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"schemaChoice": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["value"],
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The value stored/emitted for this choice."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Optional display label. Defaults to the value."
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemaDependency": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["target", "when"],
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Identifier of the option affected by this dependency."
|
||||
},
|
||||
"when": {
|
||||
"$ref": "#/$defs/condition",
|
||||
"description": "Condition that, when true, applies the effect."
|
||||
},
|
||||
"effect": {
|
||||
"enum": ["disable", "hide"],
|
||||
"description": "Whether the target option is disabled or hidden. Defaults to 'disable'."
|
||||
}
|
||||
}
|
||||
},
|
||||
"condition": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["option"],
|
||||
"properties": {
|
||||
"option": {
|
||||
"type": "string",
|
||||
"description": "Identifier of the option to inspect."
|
||||
},
|
||||
"equals": {
|
||||
"description": "True when the option value strictly equals this."
|
||||
},
|
||||
"in": {
|
||||
"type": "array",
|
||||
"description": "True when the option value is one of these.",
|
||||
"items": true
|
||||
},
|
||||
"not": {
|
||||
"description": "True when the option value is not strictly equal to this."
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean",
|
||||
"description": "True when the option value is present (or absent when false)."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["all"],
|
||||
"properties": {
|
||||
"all": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/condition" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["any"],
|
||||
"properties": {
|
||||
"any": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/condition" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["not"],
|
||||
"properties": {
|
||||
"not": { "$ref": "#/$defs/condition" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,8 +28,9 @@ pub async fn launch_game(
|
|||
state: State<'_, GameState>,
|
||||
instance_id: String,
|
||||
servers: Vec<McServer>,
|
||||
extra_args: Vec<String>,
|
||||
mut extra_args: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
extra_args.extend(load_instance_args(&app, &instance_id));
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = state;
|
||||
|
|
@ -407,6 +408,21 @@ pub fn get_instance_path(app: AppHandle, instance_id: String) -> String {
|
|||
.to_string()
|
||||
}
|
||||
|
||||
fn load_instance_args(app: &AppHandle, instance_id: &str) -> Vec<String> {
|
||||
let config_val = config::load_config_raw(app.clone());
|
||||
config_val
|
||||
.instance_launch_args
|
||||
.and_then(|m| m.get(instance_id).cloned())
|
||||
.map(|entry| entry.args)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_instance_args_schema(app: AppHandle, instance_id: String) -> Option<String> {
|
||||
let dir = util::get_instance_working_dir(&app, &instance_id);
|
||||
fs::read_to_string(dir.join("Arguments.Schema.json")).ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_playtime(app: AppHandle, instance_id: String) -> PlaytimeResponse {
|
||||
playtime::get_playtime(&app, &instance_id)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ pub fn load_config_raw(app: AppHandle) -> AppConfig {
|
|||
extra_launch_args: None,
|
||||
launch_prefix: None,
|
||||
launch_env_vars: None,
|
||||
instance_launch_args: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ pub fn run() {
|
|||
game::get_instance_path,
|
||||
game::get_playtime,
|
||||
game::get_playtime_daily,
|
||||
game::get_instance_args_schema,
|
||||
#[cfg(desktop)]
|
||||
game::backup_instance,
|
||||
#[cfg(desktop)]
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ pub struct CustomizationEntry {
|
|||
pub panorama: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceLaunchArgs {
|
||||
pub values: std::collections::HashMap<String, serde_json::Value>,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppConfig {
|
||||
|
|
@ -59,6 +66,7 @@ pub struct AppConfig {
|
|||
pub extra_launch_args: Option<Vec<String>>,
|
||||
pub launch_prefix: Option<String>,
|
||||
pub launch_env_vars: Option<std::collections::HashMap<String, String>>,
|
||||
pub instance_launch_args: Option<std::collections::HashMap<String, InstanceLaunchArgs>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
|
|
|||
511
src/components/modals/OptionsModal.tsx
Normal file
511
src/components/modals/OptionsModal.tsx
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
import { useState, useEffect, useMemo, useRef, type RefObject } from "react";
|
||||
import { TauriService } from "../../services/TauriService";
|
||||
import {
|
||||
parseSchema,
|
||||
mergeValues,
|
||||
defaultValues,
|
||||
computeEffects,
|
||||
buildArgs,
|
||||
type ArgsSchema,
|
||||
type SchemaOption,
|
||||
} from "../../utils/argsSchema";
|
||||
|
||||
export default function OptionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
instanceId,
|
||||
instanceName,
|
||||
savedValues,
|
||||
onSave,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
playPressSound: (s?: string) => void;
|
||||
playBackSound: (s?: string) => void;
|
||||
instanceId: string;
|
||||
instanceName: string;
|
||||
savedValues?: Record<string, unknown>;
|
||||
onSave: (
|
||||
instanceId: string,
|
||||
values: Record<string, unknown>,
|
||||
args: string[],
|
||||
) => void;
|
||||
}) {
|
||||
const [schema, setSchema] = useState<ArgsSchema | null>(null);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const rowRefs = useRef<(HTMLElement | null)[]>([]);
|
||||
const inputRefs = useRef<(HTMLElement | null)[]>([]);
|
||||
const resetRef = useRef<HTMLButtonElement | null>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement | null>(null);
|
||||
const saveRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSchema(null);
|
||||
setValues({});
|
||||
setFocusIndex(0);
|
||||
TauriService.getInstanceArgsSchema(instanceId)
|
||||
.then((raw) => {
|
||||
if (cancelled) return;
|
||||
if (!raw) {
|
||||
setError("This instance does not provide a launch options schema.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const parsed = parseSchema(raw);
|
||||
if (!parsed) {
|
||||
setError("The launch options schema is invalid or unsupported.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setSchema(parsed);
|
||||
setValues(mergeValues(parsed, savedValues));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, instanceId, savedValues]);
|
||||
|
||||
const effects = useMemo(
|
||||
() => (schema ? computeEffects(schema, values) : {}),
|
||||
[schema, values],
|
||||
);
|
||||
|
||||
const visibleOptions = useMemo(
|
||||
() =>
|
||||
schema
|
||||
? schema.options.filter((o) => !effects[o.id]?.hidden)
|
||||
: ([] as SchemaOption[]),
|
||||
[schema, effects],
|
||||
);
|
||||
|
||||
const optionOrder = useMemo(
|
||||
() => visibleOptions.map((o) => o.id),
|
||||
[visibleOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusIndex((prev) => Math.min(prev, optionOrder.length + 2));
|
||||
}, [optionOrder.length]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
if (!schema)
|
||||
return {
|
||||
sections: [] as {
|
||||
title: string;
|
||||
description?: string;
|
||||
options: SchemaOption[];
|
||||
}[],
|
||||
general: [] as SchemaOption[],
|
||||
};
|
||||
const byGroup = new Map<string, SchemaOption[]>();
|
||||
const general: SchemaOption[] = [];
|
||||
const declared = new Set(schema.groups.map((g) => g.id));
|
||||
for (const option of visibleOptions) {
|
||||
if (option.group && declared.has(option.group)) {
|
||||
const list = byGroup.get(option.group);
|
||||
if (list) list.push(option);
|
||||
else byGroup.set(option.group, [option]);
|
||||
} else {
|
||||
general.push(option);
|
||||
}
|
||||
}
|
||||
const sections = schema.groups
|
||||
.map((group) => ({
|
||||
title: group.title,
|
||||
description: group.description,
|
||||
options: byGroup.get(group.id) ?? ([] as SchemaOption[]),
|
||||
}))
|
||||
.filter((s) => s.options.length > 0);
|
||||
return { sections, general };
|
||||
}, [schema, visibleOptions]);
|
||||
|
||||
const handleReset = () => {
|
||||
playPressSound();
|
||||
if (!schema) return;
|
||||
setValues(defaultValues(schema));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!schema) return;
|
||||
playPressSound("save_click.wav");
|
||||
const finalValues = { ...values };
|
||||
onSave(
|
||||
instanceId,
|
||||
finalValues,
|
||||
buildArgs(schema, finalValues, computeEffects(schema, finalValues)),
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
const activeTag = document.activeElement?.tagName;
|
||||
if (
|
||||
activeTag === "INPUT" ||
|
||||
activeTag === "SELECT" ||
|
||||
activeTag === "TEXTAREA"
|
||||
) {
|
||||
if (e.key === "Escape") {
|
||||
playBackSound();
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const total = optionOrder.length + 3;
|
||||
if (e.key === "Escape") {
|
||||
playBackSound();
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev + 1) % total);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => (prev - 1 + total) % total);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (focusIndex < optionOrder.length) {
|
||||
const id = optionOrder[focusIndex];
|
||||
const option = schema?.options.find((o) => o.id === id);
|
||||
if (!option) return;
|
||||
const effect = effects[id];
|
||||
if (option.type === "boolean") {
|
||||
if (!effect?.disabled) {
|
||||
setValues((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
}
|
||||
} else {
|
||||
const input = inputRefs.current[focusIndex];
|
||||
if (input) input.focus();
|
||||
}
|
||||
} else if (focusIndex === optionOrder.length) {
|
||||
handleReset();
|
||||
} else if (focusIndex === optionOrder.length + 1) {
|
||||
playBackSound();
|
||||
onClose();
|
||||
} else {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [
|
||||
isOpen,
|
||||
optionOrder,
|
||||
focusIndex,
|
||||
schema,
|
||||
values,
|
||||
effects,
|
||||
playPressSound,
|
||||
playBackSound,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (focusIndex < optionOrder.length) {
|
||||
rowRefs.current[focusIndex]?.focus();
|
||||
} else if (focusIndex === optionOrder.length) {
|
||||
resetRef.current?.focus();
|
||||
} else if (focusIndex === optionOrder.length + 1) {
|
||||
cancelRef.current?.focus();
|
||||
} else {
|
||||
saveRef.current?.focus();
|
||||
}
|
||||
}, [isOpen, focusIndex, optionOrder]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const flatIndex = (optionId: string) => optionOrder.indexOf(optionId);
|
||||
const titleDesc = (option: SchemaOption) => (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-[#222222] mc-text-shadow truncate">
|
||||
{option.title}
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className="text-[11px] text-[#666666] leading-tight">
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const control = (option: SchemaOption, index: number, disabled: boolean) => {
|
||||
const value = values[option.id];
|
||||
switch (option.type) {
|
||||
case "boolean":
|
||||
return null;
|
||||
case "int":
|
||||
case "number":
|
||||
return (
|
||||
<input
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type="number"
|
||||
disabled={disabled}
|
||||
min={option.min}
|
||||
max={option.max}
|
||||
step={option.step ?? (option.type === "int" ? 1 : "any")}
|
||||
value={typeof value === "number" ? value : ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[option.id]: raw === "" ? "" : Number(raw),
|
||||
}));
|
||||
}}
|
||||
onFocus={() => setFocusIndex(index)}
|
||||
className={`w-24 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none text-center font-['Mojangles'] focus:border-[#FFFF55] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
|
||||
disabled ? "opacity-40 cursor-not-allowed" : ""
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
);
|
||||
case "string":
|
||||
return (
|
||||
<input
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
placeholder={option.placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => {
|
||||
setValues((prev) => ({ ...prev, [option.id]: e.target.value }));
|
||||
}}
|
||||
onFocus={() => setFocusIndex(index)}
|
||||
className={`w-44 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${
|
||||
disabled ? "opacity-40 cursor-not-allowed" : ""
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
);
|
||||
case "choice":
|
||||
return (
|
||||
<select
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
disabled={disabled}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => {
|
||||
setValues((prev) => ({ ...prev, [option.id]: e.target.value }));
|
||||
}}
|
||||
onFocus={() => setFocusIndex(index)}
|
||||
className={`w-44 h-8 bg-white border-2 border-[#373737] text-black text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${
|
||||
disabled ? "opacity-40 cursor-not-allowed" : ""
|
||||
}`}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
>
|
||||
{option.choices?.map((choice) => (
|
||||
<option key={choice.value} value={choice.value}>
|
||||
{choice.label ?? choice.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderOption = (option: SchemaOption) => {
|
||||
const index = flatIndex(option.id);
|
||||
const effect = effects[option.id];
|
||||
const disabled = !!effect?.disabled;
|
||||
const isFocused = focusIndex === index;
|
||||
if (option.type === "boolean") {
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
inputRefs.current[index] = null;
|
||||
}}
|
||||
onFocus={() => setFocusIndex(index)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
playPressSound();
|
||||
setValues((prev) => ({ ...prev, [option.id]: !prev[option.id] }));
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left outline-none border-2 ${
|
||||
isFocused ? "border-[#FFFF55] bg-black/10" : "border-transparent"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
<div className="relative w-6 h-6 flex-shrink-0 flex items-center justify-center">
|
||||
<img
|
||||
src={
|
||||
isFocused
|
||||
? "/images/checkbox_highlighted.png"
|
||||
: "/images/checkbox.png"
|
||||
}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
{values[option.id] === true && (
|
||||
<img
|
||||
src="/images/check.png"
|
||||
alt=""
|
||||
className="relative z-10 w-6 h-6 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{titleDesc(option)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
}}
|
||||
tabIndex={-1}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 outline-none border-2 ${
|
||||
isFocused ? "border-[#FFFF55] bg-black/10" : "border-transparent"
|
||||
} ${disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-3">
|
||||
{control(option, index, disabled)}
|
||||
{titleDesc(option)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const actionButton = (
|
||||
ref: RefObject<HTMLButtonElement | null>,
|
||||
index: number,
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
danger?: boolean,
|
||||
) => (
|
||||
<button
|
||||
ref={ref}
|
||||
onMouseEnter={() => setFocusIndex(index)}
|
||||
onClick={onClick}
|
||||
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none bg-transparent ${
|
||||
focusIndex === index
|
||||
? "text-[#FFFF55]"
|
||||
: danger
|
||||
? "text-red-500"
|
||||
: "text-white"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
focusIndex === index
|
||||
? "url('/images/button_highlighted.png')"
|
||||
: "url('/images/Button_Background.png')",
|
||||
backgroundSize: "100% 100%",
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 outline-none border-none"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
playBackSound();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative w-[620px] max-w-[95vw] max-h-[88vh] p-5 flex flex-col items-center font-['Mojangles'] mc-options-bg">
|
||||
<h2 className="text-xl text-black mc-text-shadow mb-1 text-center">
|
||||
Options
|
||||
</h2>
|
||||
<p className="text-[#333333] text-sm mb-4 text-center truncate max-w-full">
|
||||
{instanceName}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center gap-4 py-10">
|
||||
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-black text-lg mc-text-shadow">
|
||||
Loading options...
|
||||
</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<p className="text-red-600 text-sm mc-text-shadow text-center max-w-md">
|
||||
{error}
|
||||
</p>
|
||||
<div className="flex gap-4 mt-2 w-full">
|
||||
{actionButton(cancelRef, optionOrder.length + 1, "OK", () => {
|
||||
playBackSound();
|
||||
onClose();
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : schema ? (
|
||||
<>
|
||||
<div className="w-full flex-1 min-h-0 max-h-[52vh] overflow-y-auto custom-scrollbar mb-4">
|
||||
{sections.sections.map((section) => (
|
||||
<div key={section.title} className="mb-3">
|
||||
<h3 className="text-[#333333] mc-text-shadow uppercase tracking-widest text-sm px-3 pt-2 pb-1">
|
||||
{section.title}
|
||||
</h3>
|
||||
{section.description && (
|
||||
<p className="text-[#666666] text-xs px-3 pb-1">
|
||||
{section.description}
|
||||
</p>
|
||||
)}
|
||||
{section.options.map(renderOption)}
|
||||
</div>
|
||||
))}
|
||||
{sections.general.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h3 className="text-[#333333] mc-text-shadow uppercase tracking-widest text-sm px-3 pt-2 pb-1">
|
||||
General
|
||||
</h3>
|
||||
{sections.general.map(renderOption)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 w-full flex-shrink-0">
|
||||
{actionButton(
|
||||
resetRef,
|
||||
optionOrder.length,
|
||||
"Reset",
|
||||
handleReset,
|
||||
true,
|
||||
)}
|
||||
{actionButton(cancelRef, optionOrder.length + 1, "Cancel", () => {
|
||||
playBackSound();
|
||||
onClose();
|
||||
})}
|
||||
{actionButton(
|
||||
saveRef,
|
||||
optionOrder.length + 2,
|
||||
"Save",
|
||||
handleSave,
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import ImportWorldModal from "../modals/ImportWorldModal";
|
|||
import PlaytimeModal from "../modals/PlaytimeModal";
|
||||
import CustomizeModal from "../modals/CustomizeModal";
|
||||
import DownloadDlcModal from "../modals/DownloadDlcModal";
|
||||
import OptionsModal from "../modals/OptionsModal";
|
||||
import { parseSchema } from "../../utils/argsSchema";
|
||||
import {
|
||||
useUI,
|
||||
useConfig,
|
||||
|
|
@ -64,6 +66,8 @@ const VersionsView = memo(function VersionsView() {
|
|||
profile: selectedProfile,
|
||||
setProfile: setSelectedProfile,
|
||||
animationsEnabled,
|
||||
instanceLaunchArgs,
|
||||
setInstanceLaunchArgs,
|
||||
} = useConfig();
|
||||
const { playPressSound, playBackSound } = useAudio();
|
||||
const {
|
||||
|
|
@ -119,12 +123,30 @@ const VersionsView = memo(function VersionsView() {
|
|||
const [dlcTargetEdition, setDlcTargetEdition] = useState<Edition | null>(
|
||||
null,
|
||||
);
|
||||
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
|
||||
const [optionsTarget, setOptionsTarget] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [argsSchemas, setArgsSchemas] = useState<Record<string, boolean>>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const ITEM_COUNT = editions.length + 3;
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.activeElement?.tagName === "INPUT") return;
|
||||
if (
|
||||
isImportModalOpen ||
|
||||
isSetUidModalOpen ||
|
||||
isImportWorldModalOpen ||
|
||||
isPlaytimeModalOpen ||
|
||||
isCustomizeModalOpen ||
|
||||
isDlcModalOpen ||
|
||||
isOptionsModalOpen ||
|
||||
deleteConfirmEdition
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
playBackSound();
|
||||
|
|
@ -213,6 +235,14 @@ const VersionsView = memo(function VersionsView() {
|
|||
handleCancelDownload,
|
||||
addToSteam,
|
||||
isDayTime,
|
||||
isImportModalOpen,
|
||||
isSetUidModalOpen,
|
||||
isImportWorldModalOpen,
|
||||
isPlaytimeModalOpen,
|
||||
isCustomizeModalOpen,
|
||||
isDlcModalOpen,
|
||||
isOptionsModalOpen,
|
||||
deleteConfirmEdition,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -243,6 +273,29 @@ const VersionsView = memo(function VersionsView() {
|
|||
fetchPlaytimes();
|
||||
}, [installedVersions]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const checkSchemas = async () => {
|
||||
const map: Record<string, boolean> = {};
|
||||
await Promise.all(
|
||||
installedVersions.map(async (id) => {
|
||||
try {
|
||||
const schema = await TauriService.getInstanceArgsSchema(id);
|
||||
map[id] = !!schema && parseSchema(schema) !== null;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
map[id] = false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cancelled) setArgsSchemas(map);
|
||||
};
|
||||
checkSchemas();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [installedVersions]);
|
||||
|
||||
const handleEditionClick = (edition: Edition, index: number) => {
|
||||
const isInstalled = installedVersions.includes(edition.instanceId);
|
||||
if (isInstalled) {
|
||||
|
|
@ -614,6 +667,42 @@ const VersionsView = memo(function VersionsView() {
|
|||
Download DLC
|
||||
</button>
|
||||
) : null}
|
||||
{argsSchemas[edition.instanceId] && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
playPressSound();
|
||||
setOptionsTarget({
|
||||
id: edition.instanceId,
|
||||
name: edition.name,
|
||||
});
|
||||
setIsOptionsModalOpen(true);
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="w-3.5 h-3.5"
|
||||
>
|
||||
<line x1="4" y1="21" x2="4" y2="14" />
|
||||
<line x1="4" y1="10" x2="4" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="3" />
|
||||
<line x1="20" y1="21" x2="20" y2="16" />
|
||||
<line x1="20" y1="12" x2="20" y2="3" />
|
||||
<line x1="1" y1="14" x2="7" y2="14" />
|
||||
<line x1="9" y1="8" x2="15" y2="8" />
|
||||
<line x1="17" y1="16" x2="23" y2="16" />
|
||||
</svg>
|
||||
Options
|
||||
</button>
|
||||
)}
|
||||
{Array.isArray(edition.branches) &&
|
||||
edition.branches.length > 0 && (
|
||||
<button
|
||||
|
|
@ -1070,6 +1159,29 @@ const VersionsView = memo(function VersionsView() {
|
|||
officialDLC={dlcTargetEdition?.officialDLC ?? ""}
|
||||
/>
|
||||
|
||||
<OptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onClose={() => {
|
||||
setIsOptionsModalOpen(false);
|
||||
setOptionsTarget(null);
|
||||
}}
|
||||
playPressSound={playPressSound}
|
||||
playBackSound={playBackSound}
|
||||
instanceId={optionsTarget?.id ?? ""}
|
||||
instanceName={optionsTarget?.name ?? ""}
|
||||
savedValues={
|
||||
optionsTarget
|
||||
? instanceLaunchArgs[optionsTarget.id]?.values
|
||||
: undefined
|
||||
}
|
||||
onSave={(instanceId, values, args) => {
|
||||
setInstanceLaunchArgs((prev) => ({
|
||||
...prev,
|
||||
[instanceId]: { values, args },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
{deleteConfirmEdition && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
|||
configRaw.customizations,
|
||||
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled,
|
||||
configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars, configRaw.startFullscreen,
|
||||
configRaw.skipIntro,
|
||||
configRaw.skipIntro, configRaw.instanceLaunchArgs,
|
||||
]);
|
||||
|
||||
const game = useMemo(() => gameRaw, [
|
||||
|
|
@ -145,6 +145,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
|||
launchEnvVars: config.launchEnvVars,
|
||||
startFullscreen: config.startFullscreen,
|
||||
skipIntro: config.skipIntro,
|
||||
instanceLaunchArgs: config.instanceLaunchArgs,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [
|
||||
|
|
@ -155,7 +156,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
|
|||
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
|
||||
config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix,
|
||||
config.launchEnvVars, config.isLoaded, config.startFullscreen,
|
||||
config.skipIntro,
|
||||
config.skipIntro, config.instanceLaunchArgs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export function useAppConfig() {
|
|||
const [launchPrefix, setLaunchPrefix] = useState<string | undefined>();
|
||||
const [launchEnvVars, setLaunchEnvVars] = useState<Record<string, string> | undefined>();
|
||||
const [skipIntro, setSkipIntro] = useLocalStorage("lce-skip-intro", false);
|
||||
const [instanceLaunchArgs, setInstanceLaunchArgs] = useState<
|
||||
Record<string, { values: Record<string, unknown>; args: string[] }>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
TauriService.loadConfig().then((config) => {
|
||||
if (config.username) setUsername(config.username);
|
||||
|
|
@ -49,6 +52,7 @@ export function useAppConfig() {
|
|||
if (config.launchPrefix) setLaunchPrefix(config.launchPrefix);
|
||||
if (config.launchEnvVars) setLaunchEnvVars(config.launchEnvVars);
|
||||
if (config.skipIntro !== undefined) setSkipIntro(config.skipIntro);
|
||||
if (config.instanceLaunchArgs) setInstanceLaunchArgs(config.instanceLaunchArgs);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -76,9 +80,10 @@ export function useAppConfig() {
|
|||
launchPrefix,
|
||||
launchEnvVars,
|
||||
skipIntro,
|
||||
instanceLaunchArgs,
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded]);
|
||||
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded, instanceLaunchArgs]);
|
||||
|
||||
return {
|
||||
username,
|
||||
|
|
@ -128,5 +133,7 @@ export function useAppConfig() {
|
|||
setLaunchEnvVars,
|
||||
skipIntro,
|
||||
setSkipIntro,
|
||||
instanceLaunchArgs,
|
||||
setInstanceLaunchArgs,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ export interface AppConfig {
|
|||
customizations?: Record<string, { titleImage?: string; panorama?: string }>;
|
||||
customPaths?: Record<string, string>;
|
||||
skipIntro?: boolean;
|
||||
instanceLaunchArgs?: Record<
|
||||
string,
|
||||
{ values: Record<string, unknown>; args: string[] }
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ThemePalette {
|
||||
|
|
@ -387,6 +391,10 @@ export class TauriService {
|
|||
return invoke("get_instance_path", { instanceId });
|
||||
}
|
||||
|
||||
static async getInstanceArgsSchema(instanceId: string): Promise<string | null> {
|
||||
return invoke("get_instance_args_schema", { instanceId });
|
||||
}
|
||||
|
||||
static async readScreenshotAsDataUrl(path: string): Promise<string> {
|
||||
return invoke("read_screenshot_as_data_url", { path });
|
||||
}
|
||||
|
|
|
|||
360
src/utils/argsSchema.ts
Normal file
360
src/utils/argsSchema.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
export type OptionType = "boolean" | "int" | "number" | "string" | "choice";
|
||||
export interface SchemaChoice {
|
||||
value: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface SchemaOption {
|
||||
id: string;
|
||||
title: string;
|
||||
type: OptionType;
|
||||
arg: string;
|
||||
description?: string;
|
||||
group?: string;
|
||||
default?: unknown;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
choices?: SchemaChoice[];
|
||||
}
|
||||
|
||||
export interface SchemaGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type Condition =
|
||||
| { all: Condition[] }
|
||||
| { any: Condition[] }
|
||||
| { not: Condition }
|
||||
| {
|
||||
option: string;
|
||||
equals?: unknown;
|
||||
in?: unknown[];
|
||||
not?: unknown;
|
||||
exists?: boolean;
|
||||
};
|
||||
|
||||
export interface SchemaDependency {
|
||||
target: string;
|
||||
when: Condition;
|
||||
effect?: "disable" | "hide";
|
||||
}
|
||||
|
||||
export interface ArgsSchema {
|
||||
$schema: string;
|
||||
schemaVersion?: number;
|
||||
meta?: Record<string, unknown>;
|
||||
groups: SchemaGroup[];
|
||||
options: SchemaOption[];
|
||||
dependencies: SchemaDependency[];
|
||||
}
|
||||
|
||||
export interface OptionEffects {
|
||||
hidden: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const VALID_TYPES: OptionType[] = [
|
||||
"boolean",
|
||||
"int",
|
||||
"number",
|
||||
"string",
|
||||
"choice",
|
||||
];
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isCondition(value: unknown): value is Condition {
|
||||
if (!isObject(value)) return false;
|
||||
if ("option" in value) return typeof value.option === "string";
|
||||
if ("all" in value)
|
||||
return Array.isArray(value.all) && value.all.every(isCondition);
|
||||
if ("any" in value)
|
||||
return Array.isArray(value.any) && value.any.every(isCondition);
|
||||
if ("not" in value) return isCondition(value.not);
|
||||
return false;
|
||||
}
|
||||
export function parseSchema(raw: string): ArgsSchema | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isObject(parsed)) return null;
|
||||
if (parsed.$schema === undefined) return null; //neo: cheap. i know.
|
||||
if (!Array.isArray(parsed.options)) return null;
|
||||
const optionKeys = new Set<string>();
|
||||
const options: SchemaOption[] = [];
|
||||
for (const entry of parsed.options) {
|
||||
if (!isObject(entry)) continue;
|
||||
const id = typeof entry.id === "string" ? entry.id : "";
|
||||
const title = typeof entry.title === "string" ? entry.title : "";
|
||||
const type = entry.type;
|
||||
const arg = typeof entry.arg === "string" ? entry.arg : "";
|
||||
if (!id || !title || !arg || optionKeys.has(id)) continue;
|
||||
if (typeof type !== "string" || !VALID_TYPES.includes(type as OptionType))
|
||||
continue;
|
||||
if (type === "choice") {
|
||||
if (!Array.isArray(entry.choices) || entry.choices.length === 0) continue;
|
||||
const choices: SchemaChoice[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const c of entry.choices) {
|
||||
if (!isObject(c) || typeof c.value !== "string" || seen.has(c.value))
|
||||
continue;
|
||||
seen.add(c.value);
|
||||
choices.push({
|
||||
value: c.value,
|
||||
label: typeof c.label === "string" ? c.label : c.value,
|
||||
});
|
||||
}
|
||||
if (choices.length === 0) continue;
|
||||
options.push({
|
||||
id,
|
||||
title,
|
||||
type: "choice",
|
||||
arg,
|
||||
description:
|
||||
typeof entry.description === "string" ? entry.description : undefined,
|
||||
group: typeof entry.group === "string" ? entry.group : undefined,
|
||||
default: typeof entry.default === "string" ? entry.default : undefined,
|
||||
choices,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const option: SchemaOption = {
|
||||
id,
|
||||
title,
|
||||
type: type as OptionType,
|
||||
arg,
|
||||
description:
|
||||
typeof entry.description === "string" ? entry.description : undefined,
|
||||
group: typeof entry.group === "string" ? entry.group : undefined,
|
||||
};
|
||||
if (entry.default !== undefined) option.default = entry.default;
|
||||
if (isFiniteNumber(entry.min)) option.min = entry.min;
|
||||
if (isFiniteNumber(entry.max)) option.max = entry.max;
|
||||
if (isFiniteNumber(entry.step)) option.step = entry.step;
|
||||
if (typeof entry.placeholder === "string")
|
||||
option.placeholder = entry.placeholder;
|
||||
options.push(option);
|
||||
}
|
||||
|
||||
const groups: SchemaGroup[] = [];
|
||||
if (Array.isArray(parsed.groups)) {
|
||||
const seen = new Set<string>();
|
||||
for (const g of parsed.groups) {
|
||||
if (!isObject(g) || typeof g.id !== "string" || seen.has(g.id)) continue;
|
||||
seen.add(g.id);
|
||||
groups.push({
|
||||
id: g.id,
|
||||
title: typeof g.title === "string" ? g.title : g.id,
|
||||
description:
|
||||
typeof g.description === "string" ? g.description : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dependencies: SchemaDependency[] = [];
|
||||
if (Array.isArray(parsed.dependencies)) {
|
||||
for (const d of parsed.dependencies) {
|
||||
if (!isObject(d) || typeof d.target !== "string") continue;
|
||||
if (!isCondition(d.when)) continue;
|
||||
dependencies.push({
|
||||
target: d.target,
|
||||
when: d.when,
|
||||
effect:
|
||||
d.effect === "hide" || d.effect === "disable" ? d.effect : "disable",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.length === 0) return null;
|
||||
return {
|
||||
$schema: parsed.$schema!.toString(),
|
||||
schemaVersion: 1,
|
||||
meta: isObject(parsed.meta) ? parsed.meta : undefined,
|
||||
groups,
|
||||
options,
|
||||
dependencies,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultValues(schema: ArgsSchema): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const option of schema.options) {
|
||||
values[option.id] = optionDefault(option);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function optionDefault(option: SchemaOption): unknown {
|
||||
switch (option.type) {
|
||||
case "boolean":
|
||||
return typeof option.default === "boolean" ? option.default : false;
|
||||
case "int":
|
||||
case "number":
|
||||
return isFiniteNumber(option.default) ? option.default : 0;
|
||||
case "string":
|
||||
return typeof option.default === "string" ? option.default : "";
|
||||
case "choice": {
|
||||
if (typeof option.default === "string") {
|
||||
const match = option.choices?.find((c) => c.value === option.default);
|
||||
if (match) return match.value;
|
||||
}
|
||||
return option.choices?.[0]?.value ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeValue(option: SchemaOption, value: unknown): unknown {
|
||||
switch (option.type) {
|
||||
case "boolean":
|
||||
return typeof value === "boolean" ? value : optionDefault(option);
|
||||
case "int":
|
||||
return isFiniteNumber(value) ? Math.trunc(value) : optionDefault(option);
|
||||
case "number":
|
||||
return isFiniteNumber(value) ? value : optionDefault(option);
|
||||
case "string":
|
||||
return typeof value === "string" ? value : optionDefault(option);
|
||||
case "choice": {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
option.choices?.some((c) => c.value === value)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return optionDefault(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeValues(
|
||||
schema: ArgsSchema,
|
||||
saved: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const values = defaultValues(schema);
|
||||
if (!saved) return values;
|
||||
for (const option of schema.options) {
|
||||
if (saved[option.id] !== undefined) {
|
||||
values[option.id] = sanitizeValue(option, saved[option.id]);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function evaluateLeaf(
|
||||
condition: Record<string, unknown>,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
const optionId = typeof condition.option === "string" ? condition.option : "";
|
||||
const value = optionId in values ? values[optionId] : undefined;
|
||||
if (condition.equals !== undefined) return value === condition.equals;
|
||||
if (Array.isArray(condition.in)) {
|
||||
return condition.in.some((candidate) => candidate === value);
|
||||
}
|
||||
if (condition.not !== undefined) return value !== condition.not;
|
||||
if (condition.exists !== undefined) {
|
||||
return condition.exists
|
||||
? value !== undefined && value !== null && value !== "" && value !== false
|
||||
: value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
value === false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function evaluateCondition(
|
||||
condition: Condition,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
if ("option" in condition) return evaluateLeaf(condition, values);
|
||||
if ("all" in condition)
|
||||
return condition.all.every((c) => evaluateCondition(c, values));
|
||||
if ("any" in condition)
|
||||
return condition.any.some((c) => evaluateCondition(c, values));
|
||||
if ("not" in condition) return !evaluateCondition(condition.not, values);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function computeEffects(
|
||||
schema: ArgsSchema,
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, OptionEffects> {
|
||||
const effects: Record<string, OptionEffects> = {};
|
||||
for (const option of schema.options) {
|
||||
effects[option.id] = { hidden: false, disabled: false };
|
||||
}
|
||||
for (const dep of schema.dependencies) {
|
||||
if (!(dep.target in effects)) continue;
|
||||
if (!evaluateCondition(dep.when, values)) continue;
|
||||
const effect = effects[dep.target];
|
||||
if (dep.effect === "hide") {
|
||||
effect.hidden = true;
|
||||
effect.disabled = true;
|
||||
} else {
|
||||
effect.disabled = true;
|
||||
}
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
export function buildArgs(
|
||||
schema: ArgsSchema,
|
||||
values: Record<string, unknown>,
|
||||
effects: Record<string, OptionEffects>,
|
||||
): string[] {
|
||||
const args: string[] = [];
|
||||
for (const option of schema.options) {
|
||||
const effect = effects[option.id];
|
||||
if (!effect) continue;
|
||||
if (effect.hidden || effect.disabled) continue;
|
||||
const value = values[option.id];
|
||||
switch (option.type) {
|
||||
case "boolean":
|
||||
if (value === true) args.push(option.arg);
|
||||
break;
|
||||
case "int":
|
||||
case "number":
|
||||
if (isFiniteNumber(value)) args.push(option.arg, String(value));
|
||||
break;
|
||||
case "string":
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
args.push(option.arg, value);
|
||||
}
|
||||
break;
|
||||
case "choice":
|
||||
if (typeof value === "string" && value !== "") {
|
||||
args.push(option.arg, value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function displayValue(option: SchemaOption, value: unknown): string {
|
||||
switch (option.type) {
|
||||
case "boolean":
|
||||
return value === true ? "true" : "false";
|
||||
case "int":
|
||||
case "number":
|
||||
return isFiniteNumber(value) ? String(value) : "";
|
||||
case "string":
|
||||
return typeof value === "string" ? value : "";
|
||||
case "choice": {
|
||||
if (typeof value !== "string") return "";
|
||||
return option.choices?.find((c) => c.value === value)?.label ?? value;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue