diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceada5c7..f0795530 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,10 @@ jobs: if: matrix.platform == 'web' run: npm run lint + - name: Run TypeScript type checking (Web) + if: matrix.platform == 'web' + run: npm run typecheck + - name: Build Frontend (Web) if: matrix.platform == 'web' env: diff --git a/eslint.config.mjs b/eslint.config.mjs index 9342f6a3..82f91f18 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,11 @@ export default tseslint.config({ ignores: ["src/livesplit-core/**", "src/type-definitions/**"], extends: tseslint.configs.recommendedTypeChecked, rules: { + "@typescript-eslint/consistent-type-exports": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/no-misused-promises": "off", diff --git a/livesplit-core b/livesplit-core index d896beb7..0c4fdbb6 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit d896beb71fac38c7e18457de1a1c0e69481c72f0 +Subproject commit 0c4fdbb6753892b66ae53bbf881184edc9be67a1 diff --git a/package.json b/package.json index 67c0cb2e..7a4fe453 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build:core:release": "node buildCore.js --release", "build:core:deploy": "node buildCore.js --max-opt --nightly", "lint": "eslint src", + "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", "publish": "vite build", "serve": "vite", "tauri:icons": "tauri icon src/assets/icon.png", diff --git a/src/api/GameList.ts b/src/api/GameList.ts index 8813930a..92b7e44a 100644 --- a/src/api/GameList.ts +++ b/src/api/GameList.ts @@ -1,16 +1,16 @@ import { getGameHeaders, getCategories as apiGetCategories, - Category, + type Category, getRuns as apiGetRuns, getPlatforms as apiGetPlatforms, getRegions as apiGetRegions, getGame as apiGetGame, - Game, - Run, - PlayersEmbedded, + type Game, + type Run, + type PlayersEmbedded, } from "./SpeedrunCom"; -import { Option } from "../util/OptionUtil"; +import { type Option } from "../util/OptionUtil"; import { FuzzyList } from "../livesplit-core"; const gameList: string[] = []; @@ -197,11 +197,10 @@ export async function downloadLeaderboard( if (categories === undefined) { return; } - const index = categories.map((c) => c.name).indexOf(categoryName); - if (index < 0) { + const category = categories.find((c) => c.name === categoryName); + if (category === undefined) { return; } - const category = categories[index]; const runPages = await apiGetRuns( true, category.id, diff --git a/src/api/LiveSplitServer.ts b/src/api/LiveSplitServer.ts index 96231786..a786b23c 100644 --- a/src/api/LiveSplitServer.ts +++ b/src/api/LiveSplitServer.ts @@ -1,7 +1,7 @@ import { toast } from "react-toastify"; -import { LSOCommandSink } from "../util/LSOCommandSink"; +import { type LSOCommandSink } from "../util/LSOCommandSink"; import { ServerProtocol } from "../livesplit-core/livesplit_core"; -import { Event } from "../livesplit-core"; +import { type Event } from "../livesplit-core"; export class LiveSplitServer { private connection: WebSocket; diff --git a/src/api/SpeedrunCom.ts b/src/api/SpeedrunCom.ts index d66c2a42..d6f62d14 100644 --- a/src/api/SpeedrunCom.ts +++ b/src/api/SpeedrunCom.ts @@ -1,4 +1,4 @@ -import { Option, map } from "../util/OptionUtil"; +import { type Option, map } from "../util/OptionUtil"; const BASE_URI = "https://www.speedrun.com/api/v1/"; diff --git a/src/index.tsx b/src/index.tsx index 52ecb191..87ebd34f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -58,12 +58,12 @@ try { const root = createRoot(container!); root.render( { let response: Response | undefined; + const options = signal === undefined ? {} : { signal }; if (window.__TAURI__ != null) { - response = await window.__TAURI__.http.fetch(url, { signal }); + response = await window.__TAURI__.http.fetch(url, options); } else { - response = await fetch(url, { signal }); + response = await fetch(url, options); } return response.arrayBuffer(); } diff --git a/src/platform/Hotkeys.ts b/src/platform/Hotkeys.ts index b8a3351b..8bb5c43b 100644 --- a/src/platform/Hotkeys.ts +++ b/src/platform/Hotkeys.ts @@ -1,4 +1,4 @@ -import { CommandSinkRef, HotkeyConfig, HotkeySystem, Language } from "../livesplit-core"; +import { type CommandSinkRef, HotkeyConfig, HotkeySystem, type Language } from "../livesplit-core"; import { expect } from "../util/OptionUtil"; export interface HotkeyImplementation { diff --git a/src/storage/index.ts b/src/storage/index.ts index 670ec95e..f3ed38a8 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,8 +1,8 @@ -import { openDB, IDBPDatabase } from "idb"; -import { Option, assert } from "../util/OptionUtil"; -import { RunRef, Run, TimingMethod, Language } from "../livesplit-core"; +import { openDB, type IDBPDatabase } from "idb"; +import { type Option, assert } from "../util/OptionUtil"; +import { type RunRef, Run, TimingMethod, type Language } from "../livesplit-core"; import { - GeneralSettings, + type GeneralSettings, MANUAL_GAME_TIME_SETTINGS_DEFAULT, THEME_MODE_AUTOMATIC, } from "../ui/views/MainSettings"; @@ -35,8 +35,8 @@ function getSplitsInfo(run: RunRef): SplitsInfo { return { game: run.gameName(), category: run.extendedCategoryName(true, true, true), - realTime, - gameTime, + ...(realTime === undefined ? {} : { realTime }), + ...(gameTime === undefined ? {} : { gameTime }), }; } diff --git a/src/ui/Embed.tsx b/src/ui/Embed.tsx index a436c05b..6c4f5b96 100644 --- a/src/ui/Embed.tsx +++ b/src/ui/Embed.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Option } from "../util/OptionUtil"; +import { type Option } from "../util/OptionUtil"; import runEditorClasses from "../css/RunEditor.module.css"; @@ -29,7 +29,10 @@ function tryYoutubeFromUri(uri: string): Option { result = youtubeBase2.exec(uri); } if (result !== null) { - return resolveYoutube(result[1]); + const videoId = result[1]; + if (videoId !== undefined) { + return resolveYoutube(videoId); + } } return null; } @@ -42,7 +45,10 @@ function tryTwitchFromUri(uri: string): Option { result = twitchBase2.exec(uri); } if (result !== null) { - return resolveTwitch(result[3]); + const videoId = result[3]; + if (videoId !== undefined) { + return resolveTwitch(videoId); + } } return null; } diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index eb87b8e4..0442c4e3 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -7,14 +7,14 @@ import { RunEditor, Segment, Timer, - HotkeyConfig, + type HotkeyConfig, LayoutState, - LayoutStateJson, - TimingMethod, - TimerPhase, + type LayoutStateJson, + type TimingMethod, + type TimerPhase, Event, - LayoutRefMut, - Language, + type LayoutRefMut, + type Language, } from "../livesplit-core"; import { Layout as ShowLayout } from "./components/Layout"; import { @@ -25,7 +25,7 @@ import { openFileAsString, } from "../util/FileUtil"; import { - Option, + type Option, assertNull, expect, maybeDisposeAndThen, @@ -34,14 +34,14 @@ import { import { LayoutEditor as LayoutEditorComponent } from "./views/LayoutEditor"; import { RunEditor as RunEditorComponent } from "./views/RunEditor"; import { - GeneralSettings, + type GeneralSettings, MainSettings as SettingsEditorComponent, THEME_MODE_AUTOMATIC, THEME_MODE_DARK, } from "./views/MainSettings"; import { TimerView } from "./views/TimerView"; import { About } from "./views/About"; -import { SplitsSelection, EditingInfo } from "./views/SplitsSelection"; +import { SplitsSelection, type EditingInfo } from "./views/SplitsSelection"; import { LayoutView } from "./views/LayoutView"; import { ToastContainer, toast } from "react-toastify"; import * as Storage from "../storage"; @@ -52,10 +52,10 @@ import { TheRunClient, WebRenderer, } from "../livesplit-core/livesplit_core"; -import { LiveSplitServer } from "../api/LiveSplitServer"; +import { type LiveSplitServer } from "../api/LiveSplitServer"; import { LSOCommandSink } from "../util/LSOCommandSink"; import { DialogContainer } from "./components/Dialog"; -import { createHotkeys, HotkeyImplementation } from "../platform/Hotkeys"; +import { createHotkeys, type HotkeyImplementation } from "../platform/Hotkeys"; import { Menu } from "lucide-react"; import { createRoot } from "react-dom/client"; @@ -118,7 +118,11 @@ function isMenuLocked(menuKind: MenuKind) { type Menu = | { kind: MenuKind.Timer } | { kind: MenuKind.Splits } - | { kind: MenuKind.RunEditor; editor: RunEditor; splitsKey?: number } + | { + kind: MenuKind.RunEditor; + editor: RunEditor; + splitsKey: number | undefined; + } | { kind: MenuKind.Layout } | { kind: MenuKind.LayoutEditor; editor: LayoutEditor } | { kind: MenuKind.MainSettings; config: HotkeyConfig } @@ -147,7 +151,7 @@ export interface State { layoutWidth: number; layoutHeight: number; menu: Menu; - openedSplitsKey?: number; + openedSplitsKey: number | undefined; sidebarOpen: boolean; sidebarTransitionsEnabled: boolean; storedLayoutWidth: number; @@ -323,7 +327,7 @@ export class LiveSplit extends React.Component { } } - public componentDidMount() { + public override componentDidMount() { this.scrollEvent = { handleEvent: (e: WheelEvent) => this.onScroll(e) }; window.addEventListener("wheel", this.scrollEvent); this.rightClickEvent = { @@ -363,11 +367,11 @@ export class LiveSplit extends React.Component { } } - public componentDidUpdate() { + public override componentDidUpdate() { this.handleAutomaticResize(); } - public componentWillUnmount() { + public override componentWillUnmount() { window.removeEventListener( "wheel", expect( @@ -416,7 +420,7 @@ export class LiveSplit extends React.Component { } } - public render() { + public override render() { let view: React.JSX.Element | undefined; if (this.state.menu.kind === MenuKind.RunEditor) { diff --git a/src/ui/components/ColorPicker.tsx b/src/ui/components/ColorPicker.tsx index 07c879c1..900523f8 100644 --- a/src/ui/components/ColorPicker.tsx +++ b/src/ui/components/ColorPicker.tsx @@ -1,6 +1,6 @@ -import React, { ChangeEvent, MouseEvent } from "react"; +import React, { type ChangeEvent, type MouseEvent } from "react"; import { useEffect, useState } from "react"; -import { Color } from "../../livesplit-core"; +import { type Color } from "../../livesplit-core"; import { Pipette } from "lucide-react"; import classes from "../../css/ColorPicker.module.css"; @@ -417,10 +417,10 @@ function Hex({ .toString(16) .padStart(2, "0")}`.toUpperCase() } - parse={(value) => { + parse={(value): Color | undefined => { const parsed = parseHex(value); if (parsed) { - return [...parsed, a]; + return [parsed[0], parsed[1], parsed[2], a]; } return undefined; }} @@ -541,8 +541,14 @@ function ColorComponent({ ); } -function PredefinedColors({ setColor }: { setColor: (color: Color) => void }) { - const predefinedColors = [ +function PredefinedColors({ + setColor, +}: { + setColor: (color: Color) => void; +}) { + const predefinedColors: ReadonlyArray< + ReadonlyArray + > = [ [ ["Red", 244, 67, 54], ["Pink", 233, 30, 99], @@ -570,7 +576,7 @@ function PredefinedColors({ setColor }: { setColor: (color: Color) => void }) { ["Black", 0, 0, 0], ["White", 255, 255, 255], ], - ] as unknown as [[[string, number, number, number]]]; + ]; return (
@@ -593,7 +599,7 @@ function PredefinedColor({ color: [title, r, g, b], setColor, }: { - color: [string, number, number, number]; + color: readonly [string, number, number, number]; setColor: (color: Color) => void; }) { return ( @@ -606,7 +612,7 @@ function PredefinedColor({ ); } -function hsvToRgb(h: number, s: number, v: number) { +function hsvToRgb(h: number, s: number, v: number): [number, number, number] { const xDivC = 1 - Math.abs(((h / 60) % 2) - 1); const [rc, rx, gc, gx, bc, bx] = @@ -633,7 +639,7 @@ function hsvToRgb(h: number, s: number, v: number) { return [r, g, b]; } -function rgbToHsv(r: number, g: number, b: number) { +function rgbToHsv(r: number, g: number, b: number): [number, number, number] { const max = Math.max(r, g, b); const min = Math.min(r, g, b); const delta = max - min; diff --git a/src/ui/components/ContextMenu.tsx b/src/ui/components/ContextMenu.tsx index 7469ae46..5c9415c1 100644 --- a/src/ui/components/ContextMenu.tsx +++ b/src/ui/components/ContextMenu.tsx @@ -1,8 +1,8 @@ -import React, { createContext, useContext, ReactNode } from "react"; +import React, { createContext, useContext, type ReactNode } from "react"; import { expect } from "../../util/OptionUtil"; import classes from "../../css/ContextMenu.module.css"; -import { Language } from "../../livesplit-core"; +import { type Language } from "../../livesplit-core"; export interface Position { x: number; diff --git a/src/ui/components/Layout.tsx b/src/ui/components/Layout.tsx index 53edd662..1bdd73c4 100644 --- a/src/ui/components/Layout.tsx +++ b/src/ui/components/Layout.tsx @@ -1,10 +1,10 @@ import * as React from "react"; import { ResizableBox } from "react-resizable"; -import { LayoutStateRef } from "../../livesplit-core"; -import { WebRenderer } from "../../livesplit-core/livesplit_core"; +import { type LayoutStateRef } from "../../livesplit-core"; +import { type WebRenderer } from "../../livesplit-core/livesplit_core"; import AutoRefresh from "../../util/AutoRefresh"; -import { UrlCache } from "../../util/UrlCache"; -import { GeneralSettings } from "../views/MainSettings"; +import { type UrlCache } from "../../util/UrlCache"; +import { type GeneralSettings } from "../views/MainSettings"; import classes from "../../css/Layout.module.css"; @@ -42,7 +42,14 @@ export function Layout({ layoutUrlCache.imageCache.ptr, ); if (newDims != null) { - onResize(newDims[0], newDims[1]); + const [newWidth, newHeight] = newDims; + if (newWidth === undefined || newHeight === undefined) { + // The renderer contract returns exactly two dimensions. Treat + // malformed binding data as an error instead of forwarding + // undefined dimensions into the layout state. + throw new Error("Renderer returned incomplete dimensions."); + } + onResize(newWidth, newHeight); } }; diff --git a/src/ui/components/Leaderboard.tsx b/src/ui/components/Leaderboard.tsx index 3e49da11..f89c73c8 100644 --- a/src/ui/components/Leaderboard.tsx +++ b/src/ui/components/Leaderboard.tsx @@ -1,11 +1,11 @@ import * as React from "react"; -import { expect, map, Option } from "../../util/OptionUtil"; +import { expect, map, type Option } from "../../util/OptionUtil"; import { - Category, - Game, - PlayersEmbedded, - Run, - Variable, + type Category, + type Game, + type PlayersEmbedded, + type Run, + type Variable, } from "../../api/SpeedrunCom"; import { getGameInfo, getPlatforms, getRegions } from "../../api/GameList"; import { resolveEmbed } from "../Embed"; @@ -17,7 +17,7 @@ import classes from "../../css/Leaderboard.module.css"; import runEditorClasses from "../../css/RunEditor.module.css"; import tableClasses from "../../css/Table.module.css"; import markdownClasses from "../../css/Markdown.module.css"; -import { Language } from "../../livesplit-core"; +import { type Language } from "../../livesplit-core"; export interface Filters { region?: string; @@ -195,10 +195,10 @@ export function Leaderboard({ run.videos.links != null && run.videos.links.length > 0 ) { - const videoUri = - run.videos.links[run.videos.links.length - 1] - .uri; - embed = resolveEmbed(videoUri); + const video = run.videos.links.at(-1); + if (video !== undefined) { + embed = resolveEmbed(video.uri); + } } const comment = run.comment ?? ""; @@ -340,10 +340,15 @@ export function Leaderboard({ let name = p.name; let flag; if (possibleMatch !== null) { - flag = replaceFlag( - possibleMatch[1], - ); - name = possibleMatch[2]; + const [, countryCode, matchedName] = + possibleMatch; + if ( + countryCode !== undefined && + matchedName !== undefined + ) { + flag = replaceFlag(countryCode); + name = matchedName; + } } return [ i !== 0 ? ", " : null, @@ -515,7 +520,7 @@ export function LeaderboardButtons({ } else if (value === "no") { filters.isEmulated = false; } else { - filters.isEmulated = undefined; + delete filters.isEmulated; } updateFilters(); }} @@ -553,12 +558,15 @@ export function LeaderboardButtons({ } else { const defaultValueId = variable.values.default; if (defaultValueId != null) { - currentFilterValue = - variable.values.values[defaultValueId].label; - filters.variables.set( - variable.name, - currentFilterValue, - ); + const defaultValue = + variable.values.values[defaultValueId]; + if (defaultValue !== undefined) { + currentFilterValue = defaultValue.label; + filters.variables.set( + variable.name, + currentFilterValue, + ); + } } } } diff --git a/src/ui/components/Markdown.tsx b/src/ui/components/Markdown.tsx index 10cdea6a..e759ed46 100644 --- a/src/ui/components/Markdown.tsx +++ b/src/ui/components/Markdown.tsx @@ -9,34 +9,46 @@ const SAFE = markdownit({ html: false, breaks: true, linkify: true }); let isSpeedrunCom = false; const unsafeDefault = - UNSAFE.renderer.rules.link_open || + UNSAFE.renderer.rules["link_open"] || function (tokens, idx, options, _env, self) { return self.renderToken(tokens, idx, options); }; -UNSAFE.renderer.rules.link_open = function (tokens, idx, options, env, self) { - tokens[idx].attrSet("target", "_blank"); +UNSAFE.renderer.rules["link_open"] = function (tokens, idx, options, env, self) { + const token = tokens[idx]; + if (token === undefined) { + throw new Error("Markdown renderer provided an invalid token index."); + } + token.attrSet("target", "_blank"); return unsafeDefault(tokens, idx, options, env, self); }; const safeLinkDefault = - SAFE.renderer.rules.link_open || + SAFE.renderer.rules["link_open"] || function (tokens, idx, options, _env, self) { return self.renderToken(tokens, idx, options); }; -SAFE.renderer.rules.link_open = function (tokens, idx, options, env, self) { - tokens[idx].attrSet("target", "_blank"); +SAFE.renderer.rules["link_open"] = function (tokens, idx, options, env, self) { + const token = tokens[idx]; + if (token === undefined) { + throw new Error("Markdown renderer provided an invalid token index."); + } + token.attrSet("target", "_blank"); return safeLinkDefault(tokens, idx, options, env, self); }; const safeImageDefault = - SAFE.renderer.rules.image || + SAFE.renderer.rules["image"] || function (tokens, idx, options, _env, self) { return self.renderToken(tokens, idx, options); }; -SAFE.renderer.rules.image = function (tokens, idx, options, env, self) { +SAFE.renderer.rules["image"] = function (tokens, idx, options, env, self) { + const token = tokens[idx]; + if (token === undefined) { + throw new Error("Markdown renderer provided an invalid token index."); + } if (isSpeedrunCom) { - const src = tokens[idx].attrGet("src"); + const src = token.attrGet("src"); if (src?.startsWith("/")) { - tokens[idx].attrSet("src", `https://www.speedrun.com${src}`); + token.attrSet("src", `https://www.speedrun.com${src}`); } } return safeImageDefault(tokens, idx, options, env, self); diff --git a/src/ui/components/Settings/Accuracy.tsx b/src/ui/components/Settings/Accuracy.tsx index a15d8026..51d0379c 100644 --- a/src/ui/components/Settings/Accuracy.tsx +++ b/src/ui/components/Settings/Accuracy.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; -import { AccuracyJson, Language } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type AccuracyJson, type Language } from "../../../livesplit-core"; +import { type SettingValueFactory } from "."; import { Label, resolve } from "../../../localization"; import tableClasses from "../../../css/Table.module.css"; diff --git a/src/ui/components/Settings/Alignment.tsx b/src/ui/components/Settings/Alignment.tsx index 4e2e70e7..9b1c878d 100644 --- a/src/ui/components/Settings/Alignment.tsx +++ b/src/ui/components/Settings/Alignment.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; import type { Alignment, Language } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type SettingValueFactory } from "."; import { Label, resolve } from "../../../localization"; import tableClasses from "../../../css/Table.module.css"; diff --git a/src/ui/components/Settings/Color.tsx b/src/ui/components/Settings/Color.tsx index 30387a30..db36f2aa 100644 --- a/src/ui/components/Settings/Color.tsx +++ b/src/ui/components/Settings/Color.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import type { Color } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type SettingValueFactory } from "."; import { ColorPicker } from "../ColorPicker"; import { Switch } from "../Switch"; diff --git a/src/ui/components/Settings/Column.tsx b/src/ui/components/Settings/Column.tsx index 84370b6d..eef257aa 100644 --- a/src/ui/components/Settings/Column.tsx +++ b/src/ui/components/Settings/Column.tsx @@ -7,7 +7,7 @@ import type { ColumnUpdateTrigger, Language, } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type SettingValueFactory } from "."; import { Label, resolve } from "../../../localization"; import tableClasses from "../../../css/Table.module.css"; diff --git a/src/ui/components/Settings/DigitsFormat.tsx b/src/ui/components/Settings/DigitsFormat.tsx index 6ac396d5..411a90af 100644 --- a/src/ui/components/Settings/DigitsFormat.tsx +++ b/src/ui/components/Settings/DigitsFormat.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; -import { DigitsFormatJson, Language } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type DigitsFormatJson, type Language } from "../../../livesplit-core"; +import { type SettingValueFactory } from "."; import tableClasses from "../../../css/Table.module.css"; diff --git a/src/ui/components/Settings/Font.tsx b/src/ui/components/Settings/Font.tsx index d1bb9011..49ffe855 100644 --- a/src/ui/components/Settings/Font.tsx +++ b/src/ui/components/Settings/Font.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; import type { Font, Language } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type SettingValueFactory } from "."; import { Switch } from "../Switch"; import * as FontList from "../../../util/FontList"; import { Label, resolve } from "../../../localization"; diff --git a/src/ui/components/Settings/Gradient.tsx b/src/ui/components/Settings/Gradient.tsx index 687c99ac..c5097c50 100644 --- a/src/ui/components/Settings/Gradient.tsx +++ b/src/ui/components/Settings/Gradient.tsx @@ -6,13 +6,21 @@ import type { Language, ListGradient, } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; -import { assertNever, expect, Option } from "../../../util/OptionUtil"; +import { type SettingValueFactory } from "."; +import { assertNever, expect, type Option } from "../../../util/OptionUtil"; import { ColorPicker } from "../ColorPicker"; import { Label, resolve } from "../../../localization"; import tableClasses from "../../../css/Table.module.css"; +function firstGradientKey(value: object): string { + const key = Object.keys(value)[0]; + if (key === undefined) { + throw new Error("Expected a gradient variant."); + } + return key; +} + export function Gradient({ value, setValue, @@ -29,7 +37,7 @@ export function Gradient({ let color2: Color | undefined; if (value !== "Transparent") { - type = Object.keys(value)[0]; + type = firstGradientKey(value); if ("Plain" in value) { color1 = value.Plain; } else if ("Vertical" in value) { @@ -48,39 +56,39 @@ export function Gradient({ color1: Option, color2: Option, ) => { - color1 = color1 ?? [0, 0, 0, 0]; - color2 = color2 ?? color1; + const rgba1: Color = color1 ?? [0, 0, 0, 0]; + const rgba2: Color = color2 ?? rgba1; switch (type) { case "Transparent": return factory.fromTransparentGradient(); case "Plain": return factory.fromColor( - color1[0], - color1[1], - color1[2], - color1[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], ); case "Vertical": return factory.fromVerticalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); case "Horizontal": return factory.fromHorizontalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); default: throw new Error("Unexpected Gradient Type"); @@ -165,7 +173,7 @@ export function DeltaGradient({ let color2: Color | undefined; if (typeof value !== "string") { - [type] = Object.keys(value); + type = firstGradientKey(value); if ("Plain" in value) { color1 = value.Plain; } else if ("Vertical" in value) { @@ -184,39 +192,39 @@ export function DeltaGradient({ color1: Option, color2: Option, ) => { - color1 = color1 ?? [0, 0, 0, 0]; - color2 = color2 ?? color1; + const rgba1: Color = color1 ?? [0, 0, 0, 0]; + const rgba2: Color = color2 ?? rgba1; switch (type) { case "Transparent": return factory.fromTransparentGradient(); case "Plain": return factory.fromColor( - color1[0], - color1[1], - color1[2], - color1[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], ); case "Vertical": return factory.fromVerticalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); case "Horizontal": return factory.fromHorizontalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); default: return expect( @@ -315,12 +323,12 @@ export function ListGradient({ let color2: Color | undefined; if ("Alternating" in value) { - type = Object.keys(value)[0]; + type = firstGradientKey(value); [color1, color2] = value.Alternating; } else { const gradient = value.Same; if (gradient !== "Transparent") { - type = Object.keys(gradient)[0]; + type = firstGradientKey(gradient); if ("Plain" in gradient) { color1 = gradient.Plain; } else if ("Vertical" in gradient) { @@ -340,50 +348,50 @@ export function ListGradient({ color1: Option, color2: Option, ) => { - color1 = color1 ?? [0, 0, 0, 0]; - color2 = color2 ?? color1; + const rgba1: Color = color1 ?? [0, 0, 0, 0]; + const rgba2: Color = color2 ?? rgba1; switch (type) { case "Transparent": return factory.fromTransparentGradient(); case "Plain": return factory.fromColor( - color1[0], - color1[1], - color1[2], - color1[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], ); case "Vertical": return factory.fromVerticalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); case "Horizontal": return factory.fromHorizontalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); case "Alternating": return factory.fromAlternatingGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); default: throw new Error("Unexpected Gradient Type"); diff --git a/src/ui/components/Settings/HotkeyButton.tsx b/src/ui/components/Settings/HotkeyButton.tsx index cb11d042..f91f0ff0 100644 --- a/src/ui/components/Settings/HotkeyButton.tsx +++ b/src/ui/components/Settings/HotkeyButton.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useEffect, useState } from "react"; -import { Option, expect } from "../../../util/OptionUtil"; +import { type Option, expect } from "../../../util/OptionUtil"; import { hotkeySystem } from "../../LiveSplit"; import { Circle, Trash } from "lucide-react"; import { Label, resolve } from "../../../localization"; -import { Language } from "../../../livesplit-core"; +import { type Language } from "../../../livesplit-core"; import classes from "../../../css/HotkeyButton.module.css"; import tooltipClasses from "../../../css/Tooltip.module.css"; @@ -38,10 +38,15 @@ export function HotkeyButton({ if (value != null) { const matches = value.match(/(.+)\+\s*(.+)$/); if (matches != null) { - resolvedKey = `${matches[1]}+ ${await resolveKey( - matches[2], - lang, - )}`; + const [, modifiers, keyCode] = matches; + if (modifiers !== undefined && keyCode !== undefined) { + resolvedKey = `${modifiers}+ ${await resolveKey( + keyCode, + lang, + )}`; + } else { + resolvedKey = await resolveKey(value, lang); + } } else { resolvedKey = await resolveKey(value, lang); } @@ -110,22 +115,19 @@ export function HotkeyButton({ let gamepadIdx = 0; for (const gamepad of gamepads) { - if (gamepadIdx >= oldButtonState.length) { - oldButtonState[gamepadIdx] = []; - } + const gamepadState = oldButtonState[gamepadIdx] ?? []; + oldButtonState[gamepadIdx] = gamepadState; if (gamepad != null) { let buttonIdx = 0; for (const button of gamepad.buttons) { const oldState = - oldButtonState[gamepadIdx]?.[buttonIdx] ?? - false; + gamepadState[buttonIdx] ?? false; if (button.pressed && !oldState) { setValue(`Gamepad${buttonIdx}`); } - oldButtonState[gamepadIdx][buttonIdx] = - button.pressed; + gamepadState[buttonIdx] = button.pressed; buttonIdx++; } diff --git a/src/ui/components/Settings/LayoutBackground.tsx b/src/ui/components/Settings/LayoutBackground.tsx index 3f1a6ff9..d7906f7c 100644 --- a/src/ui/components/Settings/LayoutBackground.tsx +++ b/src/ui/components/Settings/LayoutBackground.tsx @@ -1,13 +1,13 @@ import * as React from "react"; -import { Option, assertNever, expect } from "../../../util/OptionUtil"; +import { type Option, assertNever, expect } from "../../../util/OptionUtil"; import type { Color, Language, LayoutBackground, } from "../../../livesplit-core"; import { ColorPicker } from "../ColorPicker"; -import { SettingValueFactory } from "."; -import { UrlCache } from "../../../util/UrlCache"; +import { type SettingValueFactory } from "."; +import { type UrlCache } from "../../../util/UrlCache"; import { toast } from "react-toastify"; import { FILE_EXT_IMAGES, openFileAsArrayBuffer } from "../../../util/FileUtil"; import { Label, resolve } from "../../../localization"; @@ -15,6 +15,14 @@ import { Label, resolve } from "../../../localization"; import colorPickerClasses from "../../../css/ColorPicker.module.css"; import tableClasses from "../../../css/Table.module.css"; +function firstBackgroundKey(value: object): string { + const key = Object.keys(value)[0]; + if (key === undefined) { + throw new Error("Expected a layout background variant."); + } + return key; +} + export function LayoutBackground({ value, setValue, @@ -44,39 +52,39 @@ export function LayoutBackground({ color1: Option, color2: Option, ) => { - color1 = color1 ?? [0, 0, 0, 0]; - color2 = color2 ?? color1; + const rgba1: Color = color1 ?? [0, 0, 0, 0]; + const rgba2: Color = color2 ?? rgba1; switch (type) { case "Transparent": return factory.fromTransparentGradient(); case "Plain": return factory.fromColor( - color1[0], - color1[1], - color1[2], - color1[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], ); case "Vertical": return factory.fromVerticalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); case "Horizontal": return factory.fromHorizontalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], + rgba1[0], + rgba1[1], + rgba1[2], + rgba1[3], + rgba2[0], + rgba2[1], + rgba2[2], + rgba2[3], ); default: return expect( @@ -93,7 +101,7 @@ export function LayoutBackground({ }; if (typeof value !== "string") { - [type] = Object.keys(value); + type = firstBackgroundKey(value); if ("Plain" in value) { color1 = value.Plain; } else if ("Vertical" in value) { diff --git a/src/ui/components/Settings/LayoutDirection.tsx b/src/ui/components/Settings/LayoutDirection.tsx index d3ad869f..b7567816 100644 --- a/src/ui/components/Settings/LayoutDirection.tsx +++ b/src/ui/components/Settings/LayoutDirection.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; import type { Language, LayoutDirection } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type SettingValueFactory } from "."; import { Label, resolve } from "../../../localization"; import tableClasses from "../../../css/Table.module.css"; diff --git a/src/ui/components/Settings/ServerConnectionButton.tsx b/src/ui/components/Settings/ServerConnectionButton.tsx index 911835d3..222ba2d3 100644 --- a/src/ui/components/Settings/ServerConnectionButton.tsx +++ b/src/ui/components/Settings/ServerConnectionButton.tsx @@ -1,8 +1,8 @@ import * as React from "react"; -import { Option } from "../../../util/OptionUtil"; -import { LiveSplitServer } from "../../../api/LiveSplitServer"; +import { type Option } from "../../../util/OptionUtil"; +import { type LiveSplitServer } from "../../../api/LiveSplitServer"; import { Label, resolve } from "../../../localization"; -import { Language } from "../../../livesplit-core"; +import { type Language } from "../../../livesplit-core"; import classes from "../../../css/LiveSplitServerButton.module.css"; import tableClasses from "../../../css/Table.module.css"; diff --git a/src/ui/components/Settings/String.tsx b/src/ui/components/Settings/String.tsx index 0be66570..ce116443 100644 --- a/src/ui/components/Settings/String.tsx +++ b/src/ui/components/Settings/String.tsx @@ -1,10 +1,10 @@ import * as React from "react"; import { useState } from "react"; -import { SettingValueFactory } from "."; +import { type SettingValueFactory } from "."; import { Eye, EyeOff, Trash } from "lucide-react"; import { Switch } from "../Switch"; import { Label, resolve } from "../../../localization"; -import { Language } from "../../../livesplit-core"; +import { type Language } from "../../../livesplit-core"; import tableClasses from "../../../css/Table.module.css"; import tooltipClasses from "../../../css/Tooltip.module.css"; diff --git a/src/ui/components/Settings/SubsplitDisplayMode.tsx b/src/ui/components/Settings/SubsplitDisplayMode.tsx index e7f7feac..d9ca228d 100644 --- a/src/ui/components/Settings/SubsplitDisplayMode.tsx +++ b/src/ui/components/Settings/SubsplitDisplayMode.tsx @@ -1,10 +1,10 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; import { - SubsplitDisplayMode as SubsplitDisplayModeType, - Language, + type SubsplitDisplayMode as SubsplitDisplayModeType, + type Language, } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type SettingValueFactory } from "."; import { Label, resolve } from "../../../localization"; import tableClasses from "../../../css/Table.module.css"; diff --git a/src/ui/components/Settings/TimingMethod.tsx b/src/ui/components/Settings/TimingMethod.tsx index 45c0cb14..f3525822 100644 --- a/src/ui/components/Settings/TimingMethod.tsx +++ b/src/ui/components/Settings/TimingMethod.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { expect } from "../../../util/OptionUtil"; -import { Language, TimingMethodJson } from "../../../livesplit-core"; -import { SettingValueFactory } from "."; +import { type Language, type TimingMethodJson } from "../../../livesplit-core"; +import { type SettingValueFactory } from "."; import { Switch } from "../Switch"; import { Label, resolve } from "../../../localization"; diff --git a/src/ui/components/Settings/index.tsx b/src/ui/components/Settings/index.tsx index 37ed0b03..6138f648 100644 --- a/src/ui/components/Settings/index.tsx +++ b/src/ui/components/Settings/index.tsx @@ -1,12 +1,12 @@ import * as React from "react"; import { - Language, - SettingsDescriptionValueJson, + type Language, + type SettingsDescriptionValueJson, } from "../../../livesplit-core"; -import { assertNever, Option } from "../../../util/OptionUtil"; +import { assertNever, type Option } from "../../../util/OptionUtil"; import { HotkeyButton } from "./HotkeyButton"; -import { UrlCache } from "../../../util/UrlCache"; -import { LiveSplitServer } from "../../../api/LiveSplitServer"; +import { type UrlCache } from "../../../util/UrlCache"; +import { type LiveSplitServer } from "../../../api/LiveSplitServer"; import { showDialog } from "../Dialog"; import { Switch } from "../Switch"; import { ServerConnectionButton } from "./ServerConnectionButton"; @@ -277,7 +277,7 @@ export class JsonSettingValueFactory implements SettingValueFactory extends React.Component> { - public render() { + public override render() { const settingsRows: React.JSX.Element[] = []; const { factory } = this.props; @@ -743,7 +743,7 @@ export class SettingsComponent extends React.Component> { this.props.lang, ), textInput: true, - defaultText: serverUrl, + ...(serverUrl === undefined ? {} : { defaultText: serverUrl }), buttons: [ resolve(Label.Connect, this.props.lang), resolve(Label.Cancel, this.props.lang), diff --git a/src/ui/views/About.tsx b/src/ui/views/About.tsx index 4d7bd492..779b7b91 100644 --- a/src/ui/views/About.tsx +++ b/src/ui/views/About.tsx @@ -3,11 +3,11 @@ import * as React from "react"; import LiveSplitIcon from "../../assets/icon.svg"; import { Markdown } from "../components/Markdown"; import { ArrowLeft } from "lucide-react"; -import { GeneralSettings } from "./MainSettings"; +import { type GeneralSettings } from "./MainSettings"; import { formatDate, getLocale, Label, resolve } from "../../localization"; import classes from "../../css/About.module.css"; -import { Language, Lang } from "../../livesplit-core"; +import { type Language, Lang } from "../../livesplit-core"; interface Callbacks { renderViewWithSidebar( @@ -131,12 +131,14 @@ function resolveChangelogMessage( } const base = locale.split("-")[0]; - const baseMatch = change.messages[base]; - if (baseMatch) { - return baseMatch; + if (base !== undefined) { + const baseMatch = change.messages[base]; + if (baseMatch) { + return baseMatch; + } } - return change.messages.en ?? change.message; + return change.messages["en"] ?? change.message; } function SideBar({ diff --git a/src/ui/views/LayoutEditor.tsx b/src/ui/views/LayoutEditor.tsx index 6f632956..a2037176 100644 --- a/src/ui/views/LayoutEditor.tsx +++ b/src/ui/views/LayoutEditor.tsx @@ -1,16 +1,16 @@ import React, { useState } from "react"; import * as LiveSplit from "../../livesplit-core"; import { SettingsComponent } from "../components/Settings"; -import { UrlCache } from "../../util/UrlCache"; +import { type UrlCache } from "../../util/UrlCache"; import { Layout } from "../components/Layout"; -import { WebRenderer } from "../../livesplit-core/livesplit_core"; -import { GeneralSettings } from "./MainSettings"; -import { LSOCommandSink } from "../../util/LSOCommandSink"; +import { type WebRenderer } from "../../livesplit-core/livesplit_core"; +import { type GeneralSettings } from "./MainSettings"; +import { type LSOCommandSink } from "../../util/LSOCommandSink"; import { ContextMenu, MenuItem, Separator, - Position, + type Position, } from "../components/ContextMenu"; import { ArrowDown, ArrowUp, Check, Copy, Plus, Trash, X } from "lucide-react"; import { Label, orAutoLang, resolve } from "../../localization"; @@ -136,6 +136,12 @@ export function View({ } const indent = state.indent_levels[i]; const isPlaceholder = state.is_placeholder[i]; + if (indent === undefined) { + // Components and indentation levels are parallel arrays produced + // by livesplit-core. Fail at the binding boundary if that invariant + // is broken instead of rendering with an undefined offset. + throw new Error("Layout editor state has no component indentation."); + } return ( @@ -346,7 +352,7 @@ function AddComponentButton({ addVariable: (name: string) => void; addComponent: (componentClass: ComponentClass) => void; layoutDirection: LiveSplit.LayoutDirection; - lang?: LiveSplit.Language; + lang: LiveSplit.Language | undefined; }) { const [position, setPosition] = useState(null); diff --git a/src/ui/views/LayoutView.tsx b/src/ui/views/LayoutView.tsx index b88f21c5..d3407217 100644 --- a/src/ui/views/LayoutView.tsx +++ b/src/ui/views/LayoutView.tsx @@ -1,18 +1,18 @@ import * as React from "react"; import { - Language, - Layout, - LayoutStateRefMut, - TimerPhase, - TimingMethod, + type Language, + type Layout, + type LayoutStateRefMut, + type TimerPhase, + type TimingMethod, } from "../../livesplit-core"; import { TimerView } from "./TimerView"; -import { UrlCache } from "../../util/UrlCache"; -import { WebRenderer } from "../../livesplit-core/livesplit_core"; -import { GeneralSettings } from "./MainSettings"; -import { LiveSplitServer } from "../../api/LiveSplitServer"; -import { Option } from "../../util/OptionUtil"; -import { LSOCommandSink } from "../../util/LSOCommandSink"; +import { type UrlCache } from "../../util/UrlCache"; +import { type WebRenderer } from "../../livesplit-core/livesplit_core"; +import { type GeneralSettings } from "./MainSettings"; +import { type LiveSplitServer } from "../../api/LiveSplitServer"; +import { type Option } from "../../util/OptionUtil"; +import { type LSOCommandSink } from "../../util/LSOCommandSink"; import { ArrowLeft, Circle, diff --git a/src/ui/views/MainSettings.tsx b/src/ui/views/MainSettings.tsx index be97778d..1f0f9493 100644 --- a/src/ui/views/MainSettings.tsx +++ b/src/ui/views/MainSettings.tsx @@ -5,22 +5,22 @@ import { SettingsComponent, } from "../components/Settings"; import { - SettingsDescriptionJson, + type SettingsDescriptionJson, SettingValue, - HotkeyConfig, + type HotkeyConfig, Lang, Language, } from "../../livesplit-core"; import { toast } from "react-toastify"; -import { UrlCache } from "../../util/UrlCache"; +import { type UrlCache } from "../../util/UrlCache"; import { FRAME_RATE_AUTOMATIC as FRAME_RATE_BATTERY_AWARE, FRAME_RATE_MATCH_SCREEN as FRAME_RATE_MATCH_SCREEN, - FrameRateSetting, + type FrameRateSetting, } from "../../util/FrameRate"; import { LiveSplitServer } from "../../api/LiveSplitServer"; -import { Option } from "../../util/OptionUtil"; -import { LSOCommandSink } from "../../util/LSOCommandSink"; +import { type Option } from "../../util/OptionUtil"; +import { type LSOCommandSink } from "../../util/LSOCommandSink"; import { Check, ExternalLink, FlaskConical, X } from "lucide-react"; import buttonGroupClasses from "../../css/ButtonGroup.module.css"; @@ -33,9 +33,9 @@ export interface GeneralSettings { showManualGameTime: ManualGameTimeSettings | false; saveOnReset: boolean; speedrunComIntegration: boolean; - serverUrl?: string; - theRunGgIntegration?: TheRunGgSettings; - alwaysOnTop?: boolean; + serverUrl: string | undefined; + theRunGgIntegration: TheRunGgSettings | undefined; + alwaysOnTop: boolean | undefined; lang: Language | undefined; } @@ -161,32 +161,62 @@ export function View({ : "Auto", list: [ ["Auto", resolve(Label.LanguageAuto, lang)], - [`${Language.English}`, Lang.name(Language.English)], - [`${Language.Dutch}`, Lang.name(Language.Dutch)], - [`${Language.French}`, Lang.name(Language.French)], - [`${Language.German}`, Lang.name(Language.German)], - [`${Language.Italian}`, Lang.name(Language.Italian)], + [ + `${Language.English}`, + Lang.displayName(Language.English), + ], + [ + `${Language.Dutch}`, + Lang.displayName(Language.Dutch), + ], + [ + `${Language.French}`, + Lang.displayName(Language.French), + ], + [ + `${Language.German}`, + Lang.displayName(Language.German), + ], + [ + `${Language.Italian}`, + Lang.displayName(Language.Italian), + ], [ `${Language.Portuguese}`, - Lang.name(Language.Portuguese), + Lang.displayName(Language.Portuguese), + ], + [ + `${Language.Polish}`, + Lang.displayName(Language.Polish), + ], + [ + `${Language.Russian}`, + Lang.displayName(Language.Russian), + ], + [ + `${Language.Spanish}`, + Lang.displayName(Language.Spanish), ], - [`${Language.Polish}`, Lang.name(Language.Polish)], - [`${Language.Russian}`, Lang.name(Language.Russian)], - [`${Language.Spanish}`, Lang.name(Language.Spanish)], [ `${Language.BrazilianPortuguese}`, - Lang.name(Language.BrazilianPortuguese), + Lang.displayName(Language.BrazilianPortuguese), ], [ `${Language.ChineseSimplified}`, - Lang.name(Language.ChineseSimplified), + Lang.displayName(Language.ChineseSimplified), ], [ `${Language.ChineseTraditional}`, - Lang.name(Language.ChineseTraditional), + Lang.displayName(Language.ChineseTraditional), + ], + [ + `${Language.Japanese}`, + Lang.displayName(Language.Japanese), + ], + [ + `${Language.Korean}`, + Lang.displayName(Language.Korean), ], - [`${Language.Japanese}`, Lang.name(Language.Japanese)], - [`${Language.Korean}`, Lang.name(Language.Korean)], ] as [string, string][], mandatory: true, }, @@ -377,10 +407,7 @@ export function View({ : value.String === FRAME_RATE_BATTERY_AWARE ? FRAME_RATE_BATTERY_AWARE - : parseInt( - value.String.split(" ")[0], - 10, - ), + : parseInt(value.String, 10), }); } break; diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index d665e320..7d0840c6 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -26,23 +26,23 @@ import { platformListLength, regionListLength, } from "../../api/GameList"; -import { Category, Run, getRun } from "../../api/SpeedrunCom"; -import { Option, expect, map } from "../../util/OptionUtil"; +import { type Category, type Run, getRun } from "../../api/SpeedrunCom"; +import { type Option, expect, map } from "../../util/OptionUtil"; import { SettingsComponent, JsonSettingValueFactory, - ExtendedSettingsDescriptionFieldJson, - ExtendedSettingsDescriptionValueJson, + type ExtendedSettingsDescriptionFieldJson, + type ExtendedSettingsDescriptionValueJson, } from "../components/Settings"; import { Markdown } from "../components/Markdown"; -import { UrlCache } from "../../util/UrlCache"; -import { GeneralSettings } from "./MainSettings"; +import { type UrlCache } from "../../util/UrlCache"; +import { type GeneralSettings } from "./MainSettings"; import { showDialog } from "../components/Dialog"; import { corsBustingFetch } from "../../platform/CORS"; -import { ContextMenu, MenuItem, Position } from "../components/ContextMenu"; +import { ContextMenu, MenuItem, type Position } from "../components/ContextMenu"; import { Check, X } from "lucide-react"; import { - Filters, + type Filters, isVariableValidForCategory, Leaderboard, LeaderboardButtons, @@ -1053,18 +1053,34 @@ function VariablesTab({ editor.setEmulatorUsage(emulatorUsage); } else if (index < customVariablesOffset) { const stringValue = unwrapString(value); - const key = speedrunComVariables[ + const variable = speedrunComVariables[ index - speedrunComVariablesOffset - ].text as string; + ]; + if ( + variable === undefined || + typeof variable.text !== "string" + ) { + throw new Error( + "Expected a speedrun.com variable setting.", + ); + } + const key = variable.text; if (stringValue !== "") { editor.setSpeedrunComVariable(key, stringValue); } else { editor.removeSpeedrunComVariable(key); } } else { - const key = customVariables[ + const variable = customVariables[ index - customVariablesOffset - ].text as string; + ]; + if ( + variable === undefined || + typeof variable.text !== "string" + ) { + throw new Error("Expected a custom variable setting."); + } + const key = variable.text; const stringValue = unwrapRemovableString(value); if (stringValue !== null) { editor.setCustomVariable(key, stringValue); @@ -2044,6 +2060,10 @@ async function interactiveAssociateRunOrOpenPage( return; } const runId = matches[1]; + if (runId === undefined) { + toast.error(resolve(Label.InvalidSpeedrunUrl, lang)); + return; + } try { const run = await getRun(runId); const gameInfo = await downloadGameInfoByGameId(run.game); diff --git a/src/ui/views/RunEditor/SegmentTableCells.tsx b/src/ui/views/RunEditor/SegmentTableCells.tsx index ec4949a0..64ade1d4 100644 --- a/src/ui/views/RunEditor/SegmentTableCells.tsx +++ b/src/ui/views/RunEditor/SegmentTableCells.tsx @@ -1,10 +1,10 @@ import React from "react"; import { toast } from "react-toastify"; -import * as LiveSplit from "../../../livesplit-core"; +import type * as LiveSplit from "../../../livesplit-core"; import { FILE_EXT_IMAGES, openFileAsArrayBuffer } from "../../../util/FileUtil"; import { Label, resolve } from "../../../localization"; -import { ContextMenu, MenuItem, Position } from "../../components/ContextMenu"; +import { ContextMenu, MenuItem, type Position } from "../../components/ContextMenu"; import classes from "../../../css/RunEditor.module.css"; import tooltipClasses from "../../../css/Tooltip.module.css"; diff --git a/src/ui/views/RunEditor/SegmentsTable.tsx b/src/ui/views/RunEditor/SegmentsTable.tsx index 5bd78d17..6af36991 100644 --- a/src/ui/views/RunEditor/SegmentsTable.tsx +++ b/src/ui/views/RunEditor/SegmentsTable.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState } from "react"; -import * as LiveSplit from "../../../livesplit-core"; +import type * as LiveSplit from "../../../livesplit-core"; import { Label, orAutoLang, resolve } from "../../../localization"; -import { UrlCache } from "../../../util/UrlCache"; +import { type UrlCache } from "../../../util/UrlCache"; import { changeSegmentGroupIcon, changeSegmentIcon, @@ -900,6 +900,12 @@ function handleComparisonTimeBlur( if (rowState.comparisonTimesChanged[comparisonIndex]) { const comparisonName = editorState.comparison_names[comparisonIndex]; const comparisonTime = rowState.comparisonTimes[comparisonIndex]; + if (comparisonName === undefined || comparisonTime === undefined) { + // The comparison names and row-local edit values are parallel + // arrays. A missing entry means the editor state is inconsistent, + // so fail before sending an invalid comparison to livesplit-core. + throw new Error("Missing comparison time editor state."); + } editor.activeParseAndSetComparisonTime( comparisonName, comparisonTime, diff --git a/src/ui/views/SplitsSelection.tsx b/src/ui/views/SplitsSelection.tsx index de2437e6..a6b29023 100644 --- a/src/ui/views/SplitsSelection.tsx +++ b/src/ui/views/SplitsSelection.tsx @@ -1,14 +1,14 @@ import React, { useState, useEffect } from "react"; import { getSplitsInfos, - SplitsInfo, + type SplitsInfo, deleteSplits as storageDeleteSplits, copySplits as storageCopySplits, loadSplits, storeRunWithoutDisposing, storeSplitsKey, } from "../../storage"; -import { Language, Run, Segment, TimerPhase } from "../../livesplit-core"; +import { type Language, Run, Segment, TimerPhase } from "../../livesplit-core"; import { toast } from "react-toastify"; import { openFileAsArrayBuffer, @@ -16,10 +16,10 @@ import { convertFileToArrayBuffer, FILE_EXT_SPLITS, } from "../../util/FileUtil"; -import { Option, bug, maybeDisposeAndThen } from "../../util/OptionUtil"; +import { type Option, bug, maybeDisposeAndThen } from "../../util/OptionUtil"; import { DragUpload } from "../components/DragUpload"; -import { GeneralSettings } from "./MainSettings"; -import { LSOCommandSink } from "../../util/LSOCommandSink"; +import { type GeneralSettings } from "./MainSettings"; +import { type LSOCommandSink } from "../../util/LSOCommandSink"; import { showDialog } from "../components/Dialog"; import { Label, resolve } from "../../localization"; import { @@ -45,7 +45,7 @@ export interface EditingInfo { export interface Props { commandSink: LSOCommandSink; - openedSplitsKey?: number; + openedSplitsKey: number | undefined; callbacks: Callbacks; generalSettings: GeneralSettings; splitsModified: boolean; @@ -128,9 +128,9 @@ function View({ lang, }: { commandSink: LSOCommandSink; - openedSplitsKey?: number; + openedSplitsKey: number | undefined; callbacks: Callbacks; - splitsInfos?: Array<[number, SplitsInfo]>; + splitsInfos: Array<[number, SplitsInfo]> | undefined; refreshDb: () => Promise; lang: Language | undefined; }) { @@ -345,7 +345,7 @@ function SavedSplitsRow({ deleteSplits, lang, }: { - openedSplitsKey?: number; + openedSplitsKey: number | undefined; splitsKey: number; info: SplitsInfo; openSplits: (key: number) => void; diff --git a/src/ui/views/TimerView.tsx b/src/ui/views/TimerView.tsx index 2c982a30..3442fcb2 100644 --- a/src/ui/views/TimerView.tsx +++ b/src/ui/views/TimerView.tsx @@ -2,21 +2,21 @@ import React, { useState } from "react"; import { TimingMethod, TimeSpan, - LayoutStateRefMut, + type LayoutStateRefMut, TimerPhase, } from "../../livesplit-core"; import * as LiveSplit from "../../livesplit-core"; -import { Option, expect } from "../../util/OptionUtil"; +import { type Option, expect } from "../../util/OptionUtil"; import { DragUpload } from "../components/DragUpload"; import { Layout } from "../components/Layout"; -import { UrlCache } from "../../util/UrlCache"; -import { WebRenderer } from "../../livesplit-core/livesplit_core"; +import { type UrlCache } from "../../util/UrlCache"; +import { type WebRenderer } from "../../livesplit-core/livesplit_core"; import { - GeneralSettings, + type GeneralSettings, MANUAL_GAME_TIME_MODE_SEGMENT_TIMES, } from "./MainSettings"; -import { LiveSplitServer } from "../../api/LiveSplitServer"; -import { LSOCommandSink } from "../../util/LSOCommandSink"; +import { type LiveSplitServer } from "../../api/LiveSplitServer"; +import { type LSOCommandSink } from "../../util/LSOCommandSink"; import { ArrowDown, ArrowUp, diff --git a/src/util/AutoRefresh.tsx b/src/util/AutoRefresh.tsx index 750e77cd..e3a9cbbc 100644 --- a/src/util/AutoRefresh.tsx +++ b/src/util/AutoRefresh.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { FRAME_RATE_AUTOMATIC, - FrameRateSetting, + type FrameRateSetting, batteryAwareFrameRate, } from "./FrameRate"; diff --git a/src/util/FileUtil.ts b/src/util/FileUtil.ts index 5e0189dc..17658603 100644 --- a/src/util/FileUtil.ts +++ b/src/util/FileUtil.ts @@ -1,4 +1,4 @@ -import { Option } from "./OptionUtil"; +import { type Option } from "./OptionUtil"; // Workaround for Chrome sometimes garbage collecting the input element while it // is being used, preventing the onchange event from triggering. diff --git a/src/util/FontList.ts b/src/util/FontList.ts index 73dd5376..b5991d40 100644 --- a/src/util/FontList.ts +++ b/src/util/FontList.ts @@ -6,7 +6,9 @@ export const knownStyles = new Map>(); // 1. What to look for in the style specifier of the loaded font list. // 2. The value to tell livesplit-core. // 3. The label to display in the UI. -export const FONT_WEIGHTS = [ +export const FONT_WEIGHTS: ReadonlyArray< + readonly [keyword: string, value: string, label: string] +> = [ ["thin", "thin", "Thin"], ["extralight", "extra-light", "Extra Light"], ["light", "light", "Light"], @@ -20,7 +22,9 @@ export const FONT_WEIGHTS = [ ["extrablack", "extra-black", "Extra Black"], ]; -export const FONT_STRETCHES = [ +export const FONT_STRETCHES: ReadonlyArray< + readonly [keyword: string, value: string, label: string] +> = [ ["ultracondensed", "ultra-condensed", "Ultra Condensed"], ["extracondensed", "extra-condensed", "Extra Condensed"], ["condensed", "condensed", "Condensed"], diff --git a/src/util/LSOCommandSink.ts b/src/util/LSOCommandSink.ts index f9a47afe..6b6e1e27 100644 --- a/src/util/LSOCommandSink.ts +++ b/src/util/LSOCommandSink.ts @@ -1,21 +1,21 @@ import { CommandError, - CommandResult, + type CommandResult, CommandSink, - CommandSinkRef, + type CommandSinkRef, Event, - ImageCacheRefMut, - Language, - LayoutEditorRefMut, - LayoutRefMut, - LayoutStateRefMut, - Run, - RunRef, - TimeRef, + type ImageCacheRefMut, + type Language, + type LayoutEditorRefMut, + type LayoutRefMut, + type LayoutStateRefMut, + type Run, + type RunRef, + type TimeRef, TimeSpanRef, - Timer, - TimerPhase, - TimingMethod, + type Timer, + type TimerPhase, + type TimingMethod, isEvent, } from "../livesplit-core"; import { WebCommandSink } from "../livesplit-core/livesplit_core"; diff --git a/src/util/OptionUtil.tsx b/src/util/OptionUtil.tsx index 9f91853a..82c592e8 100644 --- a/src/util/OptionUtil.tsx +++ b/src/util/OptionUtil.tsx @@ -3,7 +3,7 @@ import { toast } from "react-toastify"; import toastClasses from "../css/Toast.module.css"; import { Label, resolve } from "../localization"; -import { Language } from "../livesplit-core"; +import { type Language } from "../livesplit-core"; export type Option = T | null | undefined; diff --git a/src/util/TimeUtil.ts b/src/util/TimeUtil.ts index c6dd6fa4..7b676919 100644 --- a/src/util/TimeUtil.ts +++ b/src/util/TimeUtil.ts @@ -1,4 +1,4 @@ -import { Language } from "../livesplit-core"; +import { type Language } from "../livesplit-core"; import { getLocale } from "../localization"; export function formatLeaderboardTime( diff --git a/src/util/UrlCache.ts b/src/util/UrlCache.ts index 0e8b1316..8f9d31e0 100644 --- a/src/util/UrlCache.ts +++ b/src/util/UrlCache.ts @@ -1,4 +1,4 @@ -import { ImageCache, ImageId } from "../livesplit-core"; +import { ImageCache, type ImageId } from "../livesplit-core"; export class UrlCache { private urls: Map = new Map(); diff --git a/tsconfig.json b/tsconfig.json index ebe73006..423f9389 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,14 +6,25 @@ "target": "ES2024", "jsx": "react", "strictNullChecks": true, + "exactOptionalPropertyTypes": true, "noImplicitReturns": true, "noImplicitThis": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, "alwaysStrict": true, "strictFunctionTypes": true, "strict": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": true, + "verbatimModuleSyntax": true, "allowSyntheticDefaultImports": true, "moduleResolution": "Bundler", "lib": [ diff --git a/tsconfig.node.json b/tsconfig.node.json index 541c7586..9e2f326c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,7 +5,24 @@ "moduleResolution": "Bundler", "strict": true, "noImplicitAny": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": true, + // DOM, WebWorker, and Workbox declarations currently conflict when + // TypeScript checks their third-party declaration files together. "skipLibCheck": true, + "verbatimModuleSyntax": true, "types": ["node"], "lib": ["ES2024", "DOM", "WebWorker"] }, diff --git a/vite.config.ts b/vite.config.ts index 899b057e..b305b9c9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -117,7 +117,7 @@ function parseChangelog() { messages[entry.lang] = entry.message; } const message = - messages.en ?? + messages["en"] ?? messages["en-US"] ?? Object.values(messages)[0] ?? ""; @@ -142,7 +142,7 @@ function parseChangelogEntries(commit: string) { entries.push(current); } const lang = match[1] || "en"; - current = { lang, message: match[2] }; + current = { lang, message: match[2] ?? "" }; continue; } if (current && line.startsWith(" ")) { @@ -257,8 +257,8 @@ export default defineConfig(async ({ mode }) => { const changelog = parseChangelog(); try { const [lsoContributorsList, coreContributorsList] = await Promise.all([ - getContributorsForRepo("LiveSplitOne", process.env.GITHUB_TOKEN), - getContributorsForRepo("livesplit-core", process.env.GITHUB_TOKEN), + getContributorsForRepo("LiveSplitOne", process.env["GITHUB_TOKEN"]), + getContributorsForRepo("livesplit-core", process.env["GITHUB_TOKEN"]), ]); const coreContributorsMap: Record = {};