Zum Inhalt

src/settings/form-state.ts

// Pure form-state model for the native settings dialog (settings.html).
//
// The Win32 host opens settings.html with the current registry values as
// query params (same serialisation as screensaver.html) and expects a
// "save:key=value&..." string message back — parsed by ParseSaveMessage in
// native/windows/host/src/options.cpp. Key order and on/off encoding are
// pinned by tests/settings-form.test.ts and options_test.cpp on both sides.
//
// v0.10: numerics travel as RAW validated strings (registry → query → form →
// save message, never reformatted) so byte-parity with the host is trivial.

export interface SettingsFormState {
  scene: string;
  preset: string;
  speed: string;
  audio: boolean;
  bloom: boolean;
  trails: boolean;
  scan: boolean;
  crt: boolean;
  matrix: boolean;
  terminal: boolean;
  radar: boolean;
  crosshair: boolean;
  // v0.10 — keys 13..33 (contract order, see buildSaveMessage)
  look: string;          // look slug or '' = custom ("Eigene"), dialog-only key
  altitude: string;      // low|mid|high
  fog: string;           // auto|clear|dense
  weather: string;       // light-fog|heavy-fog|storm|dust|clear
  bank: string;          // num 0..2
  reactive: boolean;
  autocycle: boolean;
  cyclemin: string;      // num 0.5..10
  termlayout: string;    // strip|window
  boot: boolean;
  bootspeed: string;     // fast|normal|cinematic
  daynight: boolean;
  crtintensity: string;  // num 0..1
  curvature: string;     // num 0..0.25
  aperture: string;      // num 0..0.5
  bloomstrength: string; // num 0..3
  trailsamount: string;  // num 0.5..0.95 (= trails.damp)
  ntsc: string;          // num 0..1
  halation: string;      // num 0..0.6
  scale: string;         // num 0.25..1
  perfadapt: boolean;
}

/** One monitor as enumerated by the host (`monitors=`-JSON query param). */
export interface MonitorEntry {
  id: string;
  name: string;
  w: number;
  h: number;
  portrait: boolean;
  primary: boolean;
  // Existing per-monitor saver config, carried along by the host so the
  // dialog starts from it (data-loss guard: an untouched save must reproduce
  // these instead of resetting every monitor to neutral). Absent fields mean
  // "no registry subkey yet" → mode 'on', scene/preset ''.
  mode?: string;   // on|off|random|scene
  scene?: string;  // slug or '' (= global scene)
  preset?: string; // slug or '' (= global preset)
  // The wallpaper's own per-monitor trio (registry WMode/WScene/WPreset). wmode
  // arrives ALREADY RESOLVED by the host (EffectiveWallpaperMode): its registry
  // sentinel — the empty string — means "never set", which in turn means
  // "primary on, others off", NOT off. Rendering that absence as 'off' and
  // letting the user save it is what turned the wallpaper permanently black in
  // v0.10, so the rule lives host-side and this side never re-derives it.
  wmode?: string;   // on|off|random|scene, always concrete
  wscene?: string;  // slug or '' (= global scene)
  wpreset?: string; // slug or '' (= global preset)
}

/**
 * Per-monitor form state, serialised as mNid/mNmode/mNscene/mNpreset plus the
 * wallpaper's mNwmode/mNwscene/mNwpreset.
 */
export interface MonitorFormState {
  id: string;
  mode: string;   // on|off|random|scene
  scene: string;  // slug or '' (= global scene)
  preset: string; // slug or '' (= global preset)
  wmode: string;   // on|off|random|scene
  wscene: string;  // slug or '' (= global scene)
  wpreset: string; // slug or '' (= global preset)
  // Did the user touch this card IN THE WALLPAPER TAB? Only then does the trio
  // travel. The wmode the host sends is already resolved (EffectiveWallpaperMode
  // turns the "never set" sentinel into on/off for display), so the value alone
  // cannot answer "has anyone ever decided this?" — and a save that answers it
  // wrongly writes WMode for every monitor, killing the "unset primary = on"
  // rule forever. The touch is the only honest signal, so it is carried, not
  // re-derived.
  wtouched: boolean;
}

/** Mirrors the engine defaults (defaults.ts) and the host's SaverOptions defaults. */
export const FORM_DEFAULTS: SettingsFormState = {
  scene: 'random',
  preset: 'toxic-haze',
  speed: 'norm',
  audio: false,
  bloom: true,
  trails: false,
  scan: true,
  crt: true,
  matrix: false,
  terminal: true,
  radar: true,
  crosshair: true,
  look: '',
  altitude: 'low',
  fog: 'auto',
  weather: 'light-fog',
  bank: '1',
  reactive: true,
  autocycle: true,
  cyclemin: '5',
  termlayout: 'strip',
  boot: true,
  bootspeed: 'normal',
  daynight: true,
  crtintensity: '0.35',
  curvature: '0.012',
  aperture: '0.22',
  bloomstrength: '1.4',
  trailsamount: '0.84',
  ntsc: '0',
  halation: '0.15',
  scale: '1',
  perfadapt: true,
};

/**
 * The wallpaper's own defaults — deliberately NOT the saver's (spec §4.1).
 * Sound all day is unusable, and full device resolution through the bloom+CRT
 * chain is what made the wallpaper stutter (v0.8 finding). Mirrors
 * WallpaperDefaults() in native/windows/host/src/wallpaper_options.cpp.
 */
export const WALLPAPER_FORM_DEFAULTS: SettingsFormState = {
  ...FORM_DEFAULTS,
  audio: false,
  scale: '0.66',
};

/** All 33 query keys in the pinned contract order (host BuildQueryString). */
const KEY_ORDER: readonly (keyof SettingsFormState)[] = [
  'scene', 'preset', 'speed', 'audio', 'bloom', 'trails', 'scan', 'crt', 'matrix',
  'terminal', 'radar', 'crosshair', 'look', 'altitude', 'fog', 'weather', 'bank',
  'reactive', 'autocycle', 'cyclemin', 'termlayout', 'boot', 'bootspeed', 'daynight',
  'crtintensity', 'curvature', 'aperture', 'bloomstrength', 'trailsamount', 'ntsc',
  'halation', 'scale', 'perfadapt',
];

/**
 * The wallpaper half's query/save prefix. `wp`, not `w`: `w` alone borders the
 * per-monitor vocabulary that is already taken (WMode/WScene/WPreset in the
 * registry) — a `wscene` next to a `WScene` that means something else is a trap
 * for the next reader.
 */
const WALLPAPER_PREFIX = 'wp';

export type Tab = 'saver' | 'wallpaper';

export type BoolKey = {
  [K in keyof SettingsFormState]: SettingsFormState[K] extends boolean ? K : never;
}[keyof SettingsFormState];

export type StringKey = Exclude<keyof SettingsFormState, BoolKey>;

/** Save-format contract cap — ParseSaveMessage accepts monitor indices 0..7 only. */
export const MONITOR_CAP = 8;

export interface NumericRange { min: number; max: number; step: number }

/**
 * Slider ranges for the 10 raw numeric fields — the single source for the
 * dialog's range-input attributes (main.ts) AND readInitial's garbage guard,
 * so UI and validation cannot diverge. min/max mirror the host's
 * IsValidNumber calls in ParseSaveMessage (options.cpp).
 */
export const NUMERIC_RANGES = {
  bank:          { min: 0,    max: 2,    step: 0.1 },
  cyclemin:      { min: 0.5,  max: 10,   step: 0.5 },
  crtintensity:  { min: 0,    max: 1,    step: 0.01 },
  curvature:     { min: 0,    max: 0.25, step: 0.001 },
  aperture:      { min: 0,    max: 0.5,  step: 0.01 },
  bloomstrength: { min: 0,    max: 3,    step: 0.1 },
  trailsamount:  { min: 0.5,  max: 0.95, step: 0.01 },
  ntsc:          { min: 0,    max: 1,    step: 0.01 },
  halation:      { min: 0,    max: 0.6,  step: 0.01 },
  scale:         { min: 0.25, max: 1,    step: 0.01 },
} as const satisfies Partial<Record<StringKey, NumericRange>>;

export type NumericKey = keyof typeof NUMERIC_RANGES;

/**
 * The closed value sets of the enum fields — the single source for the dialog's
 * option lists (main.ts) AND readInitial's garbage guard, same contract as
 * NUMERIC_RANGES. Mirrors the whitelists in ParseOptionField (options.cpp),
 * which rejects anything else and fails the whole save with it.
 *
 * scene/preset/look are absent on purpose: they are open slug sets (the host
 * only checks the charset), and their option lists come from the engine data.
 */
export const ENUM_VALUES = {
  speed: ['slow', 'norm', 'fast'],
  altitude: ['low', 'mid', 'high'],
  fog: ['auto', 'clear', 'dense'],
  weather: ['light-fog', 'heavy-fog', 'storm', 'dust', 'clear'],
  termlayout: ['strip', 'window'],
  bootspeed: ['fast', 'normal', 'cinematic'],
} as const satisfies Partial<Record<StringKey, readonly string[]>>;

// Same shape the host accepts (IsValidNumber → wcstod): plain unsigned
// decimals. Anything else ("1,5", "abc", "1#") would poison the save loop.
const NUMERIC_RE = /^[0-9]+(\.[0-9]+)?$/;

/**
 * Whether a raw registry value may enter the form. Numerics must be in range,
 * enums must be in their whitelist — the selects normalise only what they
 * render, and the inactive tab renders nothing, so this side (which sees both
 * halves) is the only place that can defend both.
 */
function isAcceptableRaw(key: StringKey, v: string): boolean {
  const values = (ENUM_VALUES as Partial<Record<StringKey, readonly string[]>>)[key];
  if (values) return values.includes(v);
  const range = (NUMERIC_RANGES as Partial<Record<StringKey, NumericRange>>)[key];
  if (!range) return true; // open slug sets (scene/preset/look) — host checks the charset
  if (!NUMERIC_RE.test(v)) return false;
  const n = parseFloat(v);
  return n >= range.min && n <= range.max;
}

const BOOL_KEYS: readonly BoolKey[] = [
  'audio', 'bloom', 'trails', 'scan', 'crt', 'matrix', 'terminal', 'radar', 'crosshair',
  'reactive', 'autocycle', 'boot', 'daynight', 'perfadapt',
];

// v0.10 string keys pass through raw ('' is meaningful for look); the three
// legacy keys keep their non-empty guard from v0.9.
const RAW_STRING_KEYS: readonly StringKey[] = [
  'look', 'altitude', 'fog', 'weather', 'bank', 'cyclemin', 'termlayout', 'bootspeed',
  'crtintensity', 'curvature', 'aperture', 'bloomstrength', 'trailsamount', 'ntsc',
  'halation', 'scale',
];

/**
 * Reads one option set out of the query. `prefix`/`defaults` exist for the
 * wallpaper half (readWallpaperInitial) — the saver half keeps calling this
 * with neither, so its 33 keys stay exactly where the format contract pins them.
 */
export function readInitial(
  params: URLSearchParams,
  prefix = '',
  defaults: SettingsFormState = FORM_DEFAULTS,
): SettingsFormState {
  const s: SettingsFormState = { ...defaults };
  const get = (key: string): string | null => params.get(prefix + key);
  for (const key of ['scene', 'preset', 'speed'] as const) {
    const v = get(key);
    if (v && isAcceptableRaw(key, v)) s[key] = v;
  }
  for (const key of RAW_STRING_KEYS) {
    const v = get(key);
    // Numeric and enum fields reject registry garbage back to the defaults —
    // otherwise the host rejects EVERY save (both tabs travel in one
    // all-or-nothing message) until the user happens to touch exactly the
    // poisoned control, which on the inactive tab they cannot even see.
    if (v !== null && isAcceptableRaw(key, v)) s[key] = v;
  }
  for (const key of BOOL_KEYS) {
    const v = get(key);
    if (v === 'on') s[key] = true;
    else if (v === 'off') s[key] = false;
  }
  return s;
}

/**
 * Reads the wp-prefixed half — same field names as the saver's, one prefix
 * deeper (mirrors BuildWallpaperQuerySuffix in options.cpp).
 *
 * `audio` is forced off rather than read: the host renders the wallpaper with
 * sound off unconditionally (BuildWallpaperPage), so a dialog that showed or
 * saved anything else would be lying about what the machine does.
 */
export function readWallpaperInitial(params: URLSearchParams): SettingsFormState {
  const s = readInitial(params, WALLPAPER_PREFIX, WALLPAPER_FORM_DEFAULTS);
  s.audio = false;
  return s;
}

/** Run status for the wallpaper tab's status block: "2/3", or '' when nothing runs. */
export function readWallpaperRunning(params: URLSearchParams): string {
  return params.get(WALLPAPER_PREFIX + 'running') ?? '';
}

/** Initially active tab. Anything unrecognised opens the saver — the /c default. */
export function readTab(params: URLSearchParams): Tab {
  return params.get('tab') === 'wallpaper' ? 'wallpaper' : 'saver';
}

/** Whether the wallpaper's HKCU Run key is set (host-owned, hence its own key). */
export function readAutostart(params: URLSearchParams): boolean {
  return params.get(WALLPAPER_PREFIX + 'autostart') === 'on';
}

/** Whether the tray currently holds the wallpaper paused (host-owned, like autostart). */
export function readWallpaperPaused(params: URLSearchParams): boolean {
  return params.get(WALLPAPER_PREFIX + 'paused') === 'on';
}

/** Parses the host's monitor list (`monitors=`-JSON param); [] when absent/broken. */
export function readMonitors(params: URLSearchParams): MonitorEntry[] {
  const raw = params.get('monitors');
  if (!raw) return [];
  try {
    const parsed: unknown = JSON.parse(raw);
    return Array.isArray(parsed) ? (parsed as MonitorEntry[]) : [];
  } catch {
    return [];
  }
}

/**
 * Initial per-monitor form state from the host's monitor entries: starts from
 * the existing config the host embedded in the `monitors=`-JSON, so a save
 * without touching the cards round-trips the loaded values byte-identically.
 */
export function initialMonitorStates(entries: readonly MonitorEntry[]): MonitorFormState[] {
  return entries.map((m) => ({
    id: m.id,
    mode: m.mode ?? 'on',
    scene: m.scene ?? '',
    preset: m.preset ?? '',
    // '' only when a host too old to send the trio is on the other end — the
    // save then omits the key and the host keeps its sentinel, rather than this
    // side inventing a value for an absence it cannot see (see MonitorEntry).
    wmode: m.wmode ?? '',
    wscene: m.wscene ?? '',
    wpreset: m.wpreset ?? '',
    wtouched: false, // loading is not deciding
  }));
}

/** The 33 keys of one option set, in the pinned contract order. */
function serializeSet(s: SettingsFormState, prefix = ''): string {
  return KEY_ORDER.map((key) => {
    const v = s[key];
    return `${prefix}${key}=${typeof v === 'boolean' ? (v ? 'on' : 'off') : v}`;
  }).join('&');
}

/**
 * Serialises in the same key order as the host's BuildQueryString, then the
 * optional `monitormode` (only when non-empty), then the mN monitor keys
 * (index order, gapless — ParseSaveMessage rejects gaps). At most the first
 * MONITOR_CAP entries go out — ParseSaveMessage only accepts m0..m7 and would
 * reject the whole save on an m8 key.
 *
 * One save carries both tabs: `wallpaper` appends the same 33 keys wp-prefixed,
 * which settings_window.cpp splits off and writes to its own registry subkey.
 * Everything past `perfadapt` is additive — omit the wallpaper half and the
 * message is byte-identical to the v0.10 format. Numerics stay raw strings on
 * both halves; the host compares them byte for byte.
 */
export function buildSaveMessage(
  s: SettingsFormState,
  monitors: readonly MonitorFormState[] = [],
  monitorMode?: string,
  wallpaper?: SettingsFormState,
  wallpaperMonitorMode?: string,
): string {
  let msg = 'save:' + serializeSet(s);
  if (monitorMode) msg += `&monitormode=${monitorMode}`;
  monitors.slice(0, MONITOR_CAP).forEach((m, i) => {
    msg += `&m${i}id=${m.id}&m${i}mode=${m.mode}&m${i}scene=${m.scene}&m${i}preset=${m.preset}`;
    // The wallpaper trio only for a card the user actually touched in the
    // wallpaper tab (main.ts sets wtouched there, and only from a real change
    // event). Every save carries a wallpaper half — the dialog serves both tabs
    // — so `wallpaper` alone would send the trio for every monitor on every
    // save, materialising WMode where the host's "never set" sentinel must
    // stay. An absent key is the ONLY way to say "nobody decided this yet", and
    // `wmode` is belt and braces: the empty sentinel is never spelled out.
    if (wallpaper && m.wtouched && m.wmode) {
      msg += `&m${i}wmode=${m.wmode}&m${i}wscene=${m.wscene}&m${i}wpreset=${m.wpreset}`;
    }
  });
  if (wallpaper) {
    msg += '&' + serializeSet(wallpaper, WALLPAPER_PREFIX);
    // Same empty-means-not-sent rule as monitormode: the host can then tell
    // "the dialog had no choice to offer" from "the user picked per".
    if (wallpaperMonitorMode) msg += `&${WALLPAPER_PREFIX}monitormode=${wallpaperMonitorMode}`;
  }
  return msg;
}