diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a39cd273..a5aa2942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,15 @@ jobs: cache: npm - run: npm ci - run: npm run test:coverage + # sonarcloud reads this instead of regenerating the same coverage a + # few minutes later: one leg's report is enough for one Sonar scan, + # and ubuntu-latest is picked because sonarcloud itself runs on it. + - if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/lcov.info + retention-days: 1 # `dev` branch protection requires a context literally named `test`, and a # matrixed job can't produce one: it reports `test (ubuntu-latest)` and @@ -75,8 +84,20 @@ jobs: # Informational quality scan: never blocks a merge, so a SonarCloud outage # or configuration change can't turn the whole run red. # Dependabot events carry no secrets so the scan cannot authenticate. + # + # needs test-matrix rather than running in parallel with it: the coverage + # this reads comes from that job's ubuntu-latest leg (uploaded above), on + # this same commit, so there is nothing to scan with until it has actually + # produced one. A failed test-matrix leaves no artifact to download, and + # continue-on-error below is what keeps that from failing the run. + # always() is required here: a plain `if:` combined with `needs` defaults + # to success() (same rule the `test` job's comment above documents), so + # without it a single red or cancelled test-matrix leg (e.g. a + # Windows-only flake) would SKIP this job outright instead of letting it + # attempt the download and fail gracefully via continue-on-error. sonarcloud: - if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'pull_request' || github.event.pull_request.user.login != 'dependabot[bot]') + needs: [test-matrix] + if: always() && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && (github.event_name != 'pull_request' || github.event.pull_request.user.login != 'dependabot[bot]') continue-on-error: true runs-on: ubuntu-latest steps: @@ -88,7 +109,10 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm run test:coverage + - uses: actions/download-artifact@v4 + with: + name: coverage-report + path: coverage - uses: SonarSource/sonarqube-scan-action@v8 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -108,7 +132,14 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm run build:unpack + # npm run build:unpack is `npm run build && electron-builder --dir`, + # and build is `npm run typecheck && electron-vite build`: run as an + # npm script here, that re-runs the full typecheck the dedicated + # typecheck job already ran on its own. Calling electron-vite and + # electron-builder directly skips the repeat; npm run build:unpack + # itself is untouched for a local build outside CI. + - shell: bash + run: ./node_modules/.bin/electron-vite build && ./node_modules/.bin/electron-builder --dir # macos-latest bills Actions minutes at a 10x multiplier and the launcher # can't launch the game on macOS yet, so this build is manual-only. diff --git a/src/renderer/src/components/layout/MainMenu.tsx b/src/renderer/src/components/layout/MainMenu.tsx index 607d1e45..6b52f691 100644 --- a/src/renderer/src/components/layout/MainMenu.tsx +++ b/src/renderer/src/components/layout/MainMenu.tsx @@ -1,34 +1,19 @@ -import { ReactNode, useEffect, useRef, useState } from "react" +import { ReactNode, useEffect, useState } from "react" import { useTranslation } from "react-i18next" -import { Link, useLocation, useNavigate } from "react-router-dom" -import { - PiBoxArrowDownDuotone, - PiFolderOpenDuotone, - PiGearDuotone, - PiWrenchDuotone, - PiGitForkDuotone, - PiHouseLineDuotone, - PiPencilDuotone, - PiPlusCircleDuotone, - PiInfoDuotone, - PiXCircleDuotone, - PiPlayCircleDuotone -} from "react-icons/pi" +import { Link, useLocation } from "react-router-dom" +import { PiBoxArrowDownDuotone, PiFolderOpenDuotone, PiGearDuotone, PiWrenchDuotone, PiGitForkDuotone, PiHouseLineDuotone, PiPencilDuotone, PiPlusCircleDuotone, PiInfoDuotone } from "react-icons/pi" import clsx from "clsx" -import { useInstallations, useGameVersions, useSettingsConfig, useConfigDispatch, CONFIG_ACTIONS } from "@renderer/features/config/contexts/ConfigContext" +import { useInstallations, useSettingsConfig } from "@renderer/features/config/contexts/ConfigContext" import { useNotificationsContext } from "@renderer/contexts/NotificationsContext" -import { useExternalLinks } from "@renderer/hooks/useExternalLinks" import { useMakeInstallationBackup } from "@renderer/features/installations/hooks/useMakeInstallationBackup" -import { pickPlayOutcomeNotification } from "@renderer/utils/playOutcomeNotifications" -import { useAppInfo } from "@renderer/features/info/hooks/useAppInfo" -import { checkInstallationPathExists, logLaunch, preventAppClose, runGame } from "@renderer/features/launch/adapters/launch" -import { getInstallationVersionStatus } from "@domain/installations/versionReference" +import { useLaunchGame } from "@renderer/features/launch/hooks/useLaunchGame" +import { checkInstallationPathExists } from "@renderer/features/launch/adapters/launch" import InstallationsDropdownMenu from "@renderer/features/installations/components/InstallationsDropdownMenu" import ActivityCenter from "@renderer/components/ui/ActivityCenter" -import PopupDialogPanel from "@renderer/components/ui/PopupDialogPanel" +import LaunchBackupPrompt from "@renderer/features/launch/components/LaunchBackupPrompt" import { NormalButton } from "@renderer/components/ui/Buttons" import { FormButton, FormLinkButton } from "@renderer/components/ui/FormComponents" import SessionButton from "../ui/SessionButton" @@ -43,57 +28,19 @@ interface MainMenuLinkProps { function MainMenu(): JSX.Element { const { t } = useTranslation() const installations = useInstallations() - const gameVersions = useGameVersions() const { lastUsedInstallation } = useSettingsConfig() - const configDispatch = useConfigDispatch() const { addNotification } = useNotificationsContext() - const { openOnBrowser: openExternalLink } = useExternalLinks() - const goTo = useNavigate() - const { os } = useAppInfo() const makeInstallationBackup = useMakeInstallationBackup() + const { launchGame, skipBackupPromptOpen, answerSkipBackupPrompt } = useLaunchGame() const [selectedInstallation, setSelectedInstallation] = useState(undefined) - // #338's question is awaited from inside PlayHandler rather than driven from a - // click handler, so the launch stays one linear function: the finally block - // still owns clearing _playing and releasing the close guard on every path. - // Both stay held while the question is on screen, which is what a launch - // waiting on an answer is. - const [skipBackupPromptOpen, setSkipBackupPromptOpen] = useState(false) - const skipBackupAnswerRef = useRef<((launchAnyway: boolean) => void) | null>(null) - - /** Closes the prompt and hands the answer to the PlayHandler call waiting on it. */ - function answerSkipBackupPrompt(launchAnyway: boolean): void { - setSkipBackupPromptOpen(false) - skipBackupAnswerRef.current?.(launchAnyway) - skipBackupAnswerRef.current = null - } - - /** Asks whether to launch without a backup. Cancel, Escape and a click outside all answer no. */ - function askToLaunchWithoutBackup(): Promise { - return new Promise((resolve) => { - skipBackupAnswerRef.current = resolve - setSkipBackupPromptOpen(true) - }) - } - useEffect(() => { const si = installations.find((i) => i.id === lastUsedInstallation) setSelectedInstallation(si) }, [lastUsedInstallation, installations]) - // App.tsx keeps MainMenu mounted for as long as the launcher runs, so this - // only fires on teardown. It answers "do not launch" rather than leaving - // PlayHandler parked on a promise nobody can resolve, which would hold the - // close guard until the process exits. - useEffect(() => { - return (): void => { - skipBackupAnswerRef.current?.(false) - skipBackupAnswerRef.current = null - } - }, []) - const GROUP_1: MainMenuLinkProps[] = [ { icon: , text: t("components.mainMenu.homeTitle"), desc: t("components.mainMenu.homeDesc"), to: "/" }, { icon: , text: t("components.mainMenu.installationsTitle"), desc: t("components.mainMenu.installationsDesc"), to: "/installations" }, @@ -103,110 +50,6 @@ function MainMenu(): JSX.Element { { icon: , text: t("components.mainMenu.infoAndHelpTitle"), desc: t("components.mainMenu.infoAndHelpDesc"), to: "/info-and-help" } ] - async function PlayHandler(): Promise { - const id = crypto.randomUUID() - preventAppClose("add", id, "Started playing Vintage Story.") - - // Only set once _playing has actually been flipped to true below, so the - // finally block never clears a flag this call did not set itself (the - // early "already playing" guard reads someone else's _playing, and must - // not stomp on it if this call unwinds before ever taking it over). - let playingInstallationId: string | undefined - let playingGameVersionId: string | undefined - - try { - if (!selectedInstallation) return addNotification(t("features.installations.noInstallationSelected"), "error") - if (selectedInstallation._playing) return addNotification(t("features.installations.gameAlreadyRunning"), "error") - // Update all deletes each old archive before downloading its replacement, so a game started - // mid-run would load a Mods folder with some of its Mods missing. - if (selectedInstallation._updatingMods) return addNotification(t("features.mods.cantPlayWhileUpdatingMods"), "error") - - const gameVersionToRun = selectedInstallation.version ? gameVersions.find((gv) => gv.id === selectedInstallation.gameVersionId) : undefined - if (!gameVersionToRun) { - // An Installation with no version at all reaches here too (configManager normalizes a - // missing version to ""), and interpolating that into versionNotInstalled reads as - // "VS Version not installed." with a blank name (#118). - const status = getInstallationVersionStatus(selectedInstallation, gameVersions) - const message = - status === "unset" - ? t("features.versions.noVersionSet") - : status === "unlinked" - ? t("features.versions.versionUnlinked") - : t("features.versions.versionNotInstalled", { version: selectedInstallation.version }) - return addNotification(message, "error") - } - if (gameVersionToRun._installing) return addNotification(t("features.versions.versionInstalling", { version: selectedInstallation.version }), "error") - if (gameVersionToRun._deleting) return addNotification(t("features.versions.versionDeleting", { version: selectedInstallation.version }), "error") - if (gameVersionToRun._playing) return addNotification(t("features.versions.versionPlaying", { version: selectedInstallation.version }), "error") - - playingInstallationId = selectedInstallation.id - playingGameVersionId = gameVersionToRun.id - - configDispatch({ type: CONFIG_ACTIONS.EDIT_INSTALLATION, payload: { id: selectedInstallation.id, updates: { _playing: true } } }) - configDispatch({ type: CONFIG_ACTIONS.EDIT_GAME_VERSION, payload: { id: gameVersionToRun.id, updates: { _playing: true } } }) - - if (selectedInstallation.backupsAuto) { - const backupOutcome = await makeInstallationBackup(selectedInstallation.id) - - // Only an archive compression or pruning failure is recoverable: the - // installation still exists and the player can knowingly launch - // without this backup (#338). Busy, playing, restoring, and missing - // installation states are hard stops and must not offer an override. - if (!backupOutcome.ok) { - const canLaunchWithoutBackup = backupOutcome.reason === "compress-failed" || backupOutcome.reason === "prune-failed" - if (!canLaunchWithoutBackup) return - - const launchAnyway = await askToLaunchWithoutBackup() - if (!launchAnyway) return - } - } - - const startedPlaying = Date.now() - const result = await runGame(gameVersionToRun, selectedInstallation) - - // Playtime is only recorded once the game actually ran: a launch that - // never started played for 0 seconds, and crediting it with the sliver - // of time between the two Date.now() calls would misreport "just - // played" for a session that never happened. - if (result.ok) { - const finishedPlaying = Date.now() - const ttp = finishedPlaying - startedPlaying + selectedInstallation.totalTimePlayed - configDispatch({ type: CONFIG_ACTIONS.EDIT_INSTALLATION, payload: { id: selectedInstallation.id, updates: { lastTimePlayed: finishedPlaying, totalTimePlayed: ttp } } }) - } - - const outcomeNotification = pickPlayOutcomeNotification(result, os) - if (outcomeNotification) { - const { link, report } = outcomeNotification - const actions = [ - ...(link ? [{ id: "open-guide", label: t(link.labelKey), onClick: (): void => openExternalLink(link.url) }] : []), - ...(report - ? [ - { - id: "see-report", - label: t(report.labelKey), - onClick: (): void => { - void goTo(`/installations/report/${selectedInstallation.id}`) - } - } - ] - : []) - ] - addNotification(t(outcomeNotification.key), "error", actions.length > 0 ? { actions } : undefined) - } - } catch (err) { - logLaunch("error", "[front] [layout] [components/layout/MainMenu.tsx] [MainMenu > PlayHandler] Error executing the game.") - logLaunch("debug", `[front] [layout] [components/layout/MainMenu.tsx] [MainMenu > PlayHandler] Error executing the game: ${err}`) - addNotification(t("notifications.body.errorExecutingGame"), "error") - } finally { - // Runs on every outcome, the two early-return backup and error paths - // included, so a failed launch never leaves the installation and game - // version stuck at _playing: true until the app restarts (issue #40). - if (playingInstallationId) configDispatch({ type: CONFIG_ACTIONS.EDIT_INSTALLATION, payload: { id: playingInstallationId, updates: { _playing: false } } }) - if (playingGameVersionId) configDispatch({ type: CONFIG_ACTIONS.EDIT_GAME_VERSION, payload: { id: playingGameVersionId, updates: { _playing: false } } }) - preventAppClose("remove", id, "Finished playing vintage Story.") - } - } - return (
@@ -226,7 +69,7 @@ function MainMenu(): JSX.Element {
- + launchGame(selectedInstallation)} variant="primary" size="lg" className="h-14 w-full text-2xl">

{t("generic.play")}

@@ -257,23 +100,7 @@ function MainMenu(): JSX.Element {
- {/* Cancel comes first in the DOM because HeadlessUI's focus trap focuses - the first focusable child, so Enter on a freshly opened prompt keeps - the launch stopped. The restore and delete confirms order themselves - the same way for the same reason. */} - answerSkipBackupPrompt(false)}> - <> -

{t("features.backups.backupFailedSkipLaunch")}

-
- answerSkipBackupPrompt(false)} variant="secondary"> - - - answerSkipBackupPrompt(true)} variant="destructive"> - - -
- -
+
) } diff --git a/src/renderer/src/contexts/TaskManagerContext.tsx b/src/renderer/src/contexts/TaskManagerContext.tsx index a6024b95..1ee4327a 100644 --- a/src/renderer/src/contexts/TaskManagerContext.tsx +++ b/src/renderer/src/contexts/TaskManagerContext.tsx @@ -287,45 +287,89 @@ export const TaskProvider = ({ children }: { children: React.ReactNode }): JSX.E } }, []) - async function startDownload( - name: string, - desc: string, - notifications: TaskNotificationPolicy, - url: string, - outputPath: string, - fileName: string, - onFinish: (status: boolean, path: string, error: Error | null) => void + /** The noun each task's prevent-close reason and its "Adding ___ to [PATH]" log use; irregular enough (extraction, installation, compression) that concatenating `type` does not produce them. */ + const TASK_NOUNS: Record<"download" | "extract" | "install" | "compress", string> = { download: "download", extract: "extraction", install: "installation", compress: "compression" } + + /** + * The scaffold startDownload, startExtract, startInstall and startCompress used to each carry + * on their own: a uuid, the prevent-close token, the two info logs, ADD_TASK, the awaited + * operation, the COMPLETED dispatch and optional toast on success, classifyFailure and the + * failed dispatch and optional toast on catch, and releasing the prevent-close token either way. + * + * The download resolving (or extracting, or installing, or compressing) is what completes the + * task, not the progress events a separate listener feeds into the reducer above: a source whose + * last tick lands under 100 would otherwise leave the task showing as still running forever, and + * dispatching COMPLETED after a 100 tick already did costs nothing (the reducer is idempotent). + * + * `operation` is the one thing each of the four actually differs on: the host call itself, plus + * whatever it alone needs after it (extract's post-await chmod, install's throw-on-!result.ok, + * compress's optional compressionLevel). `onFinish` here stays the two-argument (status, error) + * shape every non-download caller already has; download's own three-argument contract (status, + * path, error) is adapted in its own wrapper, over data (the downloaded file's path) only that + * wrapper's closure holds. The raw caught value is handed back as-is, not normalized to an + * Error, so a wrapper's own `${err}` in its error message matches exactly what it produced + * before this fold. + * + * Two of the four info logs are worded a little more generically than their old per-function + * copies (a shared "runTask" tag instead of "startDownload"/"startExtract"/etc., and compress's + * "Adding" log no longer names its source path, only its destination): neither reaches a player, + * neither is asserted by a test, and log-provenance.test.ts still passes since nothing here + * interpolates a path, a name or any other risky identifier. + */ + async function runTask( + config: { type: keyof typeof TASK_NOUNS; name: string; desc: string; notifications: TaskNotificationPolicy; messageKeys: { successKey: string; failureKey: string } }, + operation: (id: string) => Promise, + onFinish: (status: boolean, error: unknown) => void ): Promise { + const { type, name, desc, notifications, messageKeys } = config const id = crypto.randomUUID() + const noun = TASK_NOUNS[type] + const nameParam = `${type}Name` + const LOG_TAG = `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > runTask] [${id}] [${type}]` try { - window.api.utils.setPreventAppClose("add", id, "Started download.") - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startDownload] [${id}] [download] Adding download to [PATH].`) - tasksDispatch({ type: ACTIONS.ADD_TASK, payload: { id, name, desc, type: "download", progress: 0, status: "pending" } }) - - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startDownload] [${id}] [download] Downloading...`) - const downloadedFile = await window.api.pathsManager.downloadOnPath(id, url, outputPath, fileName) - - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startDownload] [${id}] [download] Downloaded.`) - // The download resolving is what completes the task, not the progress - // events: a source whose last tick lands at 97 would otherwise leave the - // task showing as still running forever. See the reducer above for why - // dispatching this after a 100 tick already did costs nothing. + window.api.utils.setPreventAppClose("add", id, `Started ${noun}.`) + window.api.utils.logMessage("info", `${LOG_TAG} Adding ${noun} to [PATH].`) + tasksDispatch({ type: ACTIONS.ADD_TASK, payload: { id, name, desc, type, progress: 0, status: "pending" } }) + + window.api.utils.logMessage("info", `${LOG_TAG} ${type}ing...`) + await operation(id) + + window.api.utils.logMessage("info", `${LOG_TAG} ${type}ed.`) tasksDispatch({ type: ACTIONS.UPDATE_TASK, payload: { id, updates: COMPLETED } }) - if (notifications.completion === "toast") addNotification(t("notifications.body.downloaded", { downloadName: name }), "success", { presentation: "toast" }) - onFinish(true, downloadedFile, null) + if (notifications.completion === "toast") addNotification(t(messageKeys.successKey, { [nameParam]: name }), "success", { presentation: "toast" }) + onFinish(true, null) } catch (err) { - window.api.utils.logMessage("error", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startDownload] [${id}] [download] Error downloading.`) - window.api.utils.logMessage("debug", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startDownload] [${id}] [download] Error downloading: ${err}`) + window.api.utils.logMessage("error", `${LOG_TAG} Error ${type}ing.`) + window.api.utils.logMessage("debug", `${LOG_TAG} Error ${type}ing: ${err}`) const reason = classifyFailure(err) tasksDispatch({ type: ACTIONS.UPDATE_TASK, payload: { id, updates: { status: "failed", reason } } }) - if (notifications.failure === "generic") addNotification(t("notifications.body.downloadError", { downloadName: name }), "error", { reason }) - onFinish(false, "", new Error(`Error downloading ${url}: ${err}`)) + if (notifications.failure === "generic") addNotification(t(messageKeys.failureKey, { [nameParam]: name }), "error", { reason }) + onFinish(false, err) } finally { - window.api.utils.setPreventAppClose("remove", id, "Finished download.") + window.api.utils.setPreventAppClose("remove", id, `Finished ${noun}.`) } } + async function startDownload( + name: string, + desc: string, + notifications: TaskNotificationPolicy, + url: string, + outputPath: string, + fileName: string, + onFinish: (status: boolean, path: string, error: Error | null) => void + ): Promise { + let downloadedFile = "" + await runTask( + { type: "download", name, desc, notifications, messageKeys: { successKey: "notifications.body.downloaded", failureKey: "notifications.body.downloadError" } }, + async (id) => { + downloadedFile = await window.api.pathsManager.downloadOnPath(id, url, outputPath, fileName) + }, + (status, err) => onFinish(status, downloadedFile, status ? null : new Error(`Error downloading ${url}: ${err}`)) + ) + } + async function startExtract( name: string, desc: string, @@ -336,40 +380,20 @@ export const TaskProvider = ({ children }: { children: React.ReactNode }): JSX.E onFinish: (status: boolean, error: Error | null) => void, unwrapSingleRootFolder = false ): Promise { - const id = crypto.randomUUID() - - try { - window.api.utils.setPreventAppClose("add", id, "Started extraction.") - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startExtract] [${id}] [extract] Adding extraction to [PATH].`) - tasksDispatch({ type: ACTIONS.ADD_TASK, payload: { id, name, desc, type: "extract", progress: 0, status: "pending" } }) - - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startExtract] [${id}] [extract] Extracting...`) - const result = await window.api.pathsManager.extractOnPath(id, filePath, outputPath, deleteZip, unwrapSingleRootFolder) - - if (!result) throw new Error("Extraction failed") - - // Awaited so a rejected chmod is caught below instead of becoming an unhandled - // rejection. A failed chmod fails the task on purpose: an unexecutable game is a - // failed install on Linux, not a harmless side note. (On non-Linux platforms the - // call resolves `false` without throwing, which is the normal, expected outcome.) - await window.api.pathsManager.changePerms([outputPath], 0o755) - - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startExtract] [${id}] [extract] Extracted.`) - // Completed once the extraction and the chmod are both through, so a - // last progress tick under 100 cannot strand the task as running. - tasksDispatch({ type: ACTIONS.UPDATE_TASK, payload: { id, updates: COMPLETED } }) - if (notifications.completion === "toast") addNotification(t("notifications.body.extracted", { extractName: name }), "success", { presentation: "toast" }) - onFinish(true, null) - } catch (err) { - window.api.utils.logMessage("error", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startExtract] [${id}] [extract] Error extracting.`) - window.api.utils.logMessage("debug", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startExtract] [${id}] [extract] Error extracting: ${err}`) - const reason = classifyFailure(err) - tasksDispatch({ type: ACTIONS.UPDATE_TASK, payload: { id, updates: { status: "failed", reason } } }) - if (notifications.failure === "generic") addNotification(t("notifications.body.extractError", { extractName: name }), "error", { reason }) - onFinish(false, new Error(`Error extracting ${filePath}: ${err}`)) - } finally { - window.api.utils.setPreventAppClose("remove", id, "Finished extraction.") - } + await runTask( + { type: "extract", name, desc, notifications, messageKeys: { successKey: "notifications.body.extracted", failureKey: "notifications.body.extractError" } }, + async (id) => { + const result = await window.api.pathsManager.extractOnPath(id, filePath, outputPath, deleteZip, unwrapSingleRootFolder) + if (!result) throw new Error("Extraction failed") + + // Awaited so a rejected chmod is caught below instead of becoming an unhandled + // rejection. A failed chmod fails the task on purpose: an unexecutable game is a + // failed install on Linux, not a harmless side note. (On non-Linux platforms the + // call resolves `false` without throwing, which is the normal, expected outcome.) + await window.api.pathsManager.changePerms([outputPath], 0o755) + }, + (status, err) => onFinish(status, status ? null : new Error(`Error extracting ${filePath}: ${err}`)) + ) } async function startInstall( @@ -381,39 +405,19 @@ export const TaskProvider = ({ children }: { children: React.ReactNode }): JSX.E deleteInstaller: boolean, onFinish: (status: boolean, error: Error | null) => void ): Promise { - const id = crypto.randomUUID() - - try { - window.api.utils.setPreventAppClose("add", id, "Started installation.") - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startInstall] [${id}] [install] Adding installation to [PATH].`) - tasksDispatch({ type: ACTIONS.ADD_TASK, payload: { id, name, desc, type: "install", progress: 0, status: "pending" } }) - - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startInstall] [${id}] [install] Installing...`) - const result = await window.api.pathsManager.runInstaller(id, filePath, outputPath, deleteInstaller) - - // The wire tells apart why the installer never landed the game (see - // InstallerRunResult in global.d.ts), but onFinish here stays the - // boolean shape every caller already expects: the reason still rides - // along on the thrown Error's message for the log line below. - if (!result.ok) throw new Error(`Installation failed: ${result.reason}`) - - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startInstall] [${id}] [install] Installed.`) - // The worst of the four for this: an installer that ran to the end sends - // a 100 tick, but one whose payload was read out instead reports whatever - // the reader last counted, and neither is what says the task is done. - tasksDispatch({ type: ACTIONS.UPDATE_TASK, payload: { id, updates: COMPLETED } }) - if (notifications.completion === "toast") addNotification(t("notifications.body.extracted", { extractName: name }), "success", { presentation: "toast" }) - onFinish(true, null) - } catch (err) { - window.api.utils.logMessage("error", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startInstall] [${id}] [install] Error installing.`) - window.api.utils.logMessage("debug", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startInstall] [${id}] [install] Error installing: ${err}`) - const reason = classifyFailure(err) - tasksDispatch({ type: ACTIONS.UPDATE_TASK, payload: { id, updates: { status: "failed", reason } } }) - if (notifications.failure === "generic") addNotification(t("notifications.body.extractError", { extractName: name }), "error", { reason }) - onFinish(false, new Error(`Error installing ${filePath}: ${err}`)) - } finally { - window.api.utils.setPreventAppClose("remove", id, "Finished installation.") - } + await runTask( + { type: "install", name, desc, notifications, messageKeys: { successKey: "notifications.body.installed", failureKey: "notifications.body.installError" } }, + async (id) => { + const result = await window.api.pathsManager.runInstaller(id, filePath, outputPath, deleteInstaller) + + // The wire tells apart why the installer never landed the game (see + // InstallerRunResult in global.d.ts), but onFinish here stays the + // boolean shape every caller already expects: the reason still rides + // along on the thrown Error's message for the log line below. + if (!result.ok) throw new Error(`Installation failed: ${result.reason}`) + }, + (status, err) => onFinish(status, status ? null : new Error(`Error installing ${filePath}: ${err}`)) + ) } async function startCompress( @@ -426,33 +430,14 @@ export const TaskProvider = ({ children }: { children: React.ReactNode }): JSX.E onFinish: (status: boolean, error: Error | null) => void, compressionLevel?: number ): Promise { - const id = crypto.randomUUID() - - try { - window.api.utils.setPreventAppClose("add", id, "Started compression.") - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startCompress] [${id}] [compress] Adding compression of [PATH] to [PATH].`) - tasksDispatch({ type: ACTIONS.ADD_TASK, payload: { id, name, desc, type: "compress", progress: 0, status: "pending" } }) - - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startCompress] [${id}] [compress] Compressing...`) - const result = await window.api.pathsManager.compressOnPath(id, inputPath, outputPath, fileName, compressionLevel) - - if (!result) throw new Error("Compression failed") - - window.api.utils.logMessage("info", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startCompress] [${id}] [compress] Compressed.`) - // Same as the other three: the resolved call is the completion signal. - tasksDispatch({ type: ACTIONS.UPDATE_TASK, payload: { id, updates: COMPLETED } }) - if (notifications.completion === "toast") addNotification(t("notifications.body.compressed", { compressName: name }), "success", { presentation: "toast" }) - onFinish(true, null) - } catch (err) { - window.api.utils.logMessage("error", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startCompress] [${id}] [compress] Error compressing.`) - window.api.utils.logMessage("debug", `[front] [tasks] [contexts/TaskManagercontext.tsx] [TaskProvider > startCompress] [${id}] [compress] Error compressing: ${err}`) - const reason = classifyFailure(err) - tasksDispatch({ type: ACTIONS.UPDATE_TASK, payload: { id, updates: { status: "failed", reason } } }) - if (notifications.failure === "generic") addNotification(t("notifications.body.compressError", { compressName: name }), "error", { reason }) - onFinish(false, new Error(`Error compressing: ${err}`)) - } finally { - window.api.utils.setPreventAppClose("remove", id, "Finished compression.") - } + await runTask( + { type: "compress", name, desc, notifications, messageKeys: { successKey: "notifications.body.compressed", failureKey: "notifications.body.compressError" } }, + async (id) => { + const result = await window.api.pathsManager.compressOnPath(id, inputPath, outputPath, fileName, compressionLevel) + if (!result) throw new Error("Compression failed") + }, + (status, err) => onFinish(status, status ? null : new Error(`Error compressing: ${err}`)) + ) } async function startOptimumPatch( diff --git a/src/renderer/src/features/config/hooks/useConfigFolderPicker.ts b/src/renderer/src/features/config/hooks/useConfigFolderPicker.ts index a1c2d56b..2f9be2d2 100644 --- a/src/renderer/src/features/config/hooks/useConfigFolderPicker.ts +++ b/src/renderer/src/features/config/hooks/useConfigFolderPicker.ts @@ -1,31 +1,28 @@ -import { useTranslation } from "react-i18next" - -import { useNotificationsContext } from "@renderer/contexts/NotificationsContext" import { CONFIG_ACTIONS, useConfigDispatch } from "@renderer/features/config/contexts/ConfigContext" +import { usePickEmptyFolder } from "@renderer/features/installations/hooks/usePathActions" type FolderSettingActionType = CONFIG_ACTIONS.SET_DEFAULT_INSTALLATIONS_FOLDER | CONFIG_ACTIONS.SET_DEFAULT_VERSIONS_FOLDER | CONFIG_ACTIONS.SET_DEFAULT_BACKUPS_FOLDER /** - * Minimal local hook behind ConfigPage's three folder pickers (installations, versions, backups). - * - * The installations feature carries the same browse-and-warn-if-not-empty pattern. Sharing one - * implementation between the two is a natural follow-up, kept separate here so this hook does not - * depend on that file's shape. + * Minimal local hook behind ConfigPage's three folder pickers (installations, versions, backups): + * usePickEmptyFolder's pick-and-warn, dispatched into config once a folder comes back. * * Lives outside features/config/pages on purpose: neither ConfigPage.tsx nor anything else under - * that directory may mention the preload bridge directly. + * that directory may mention the preload bridge directly. Reaching into features/installations + * for usePickEmptyFolder is already normal here (useInstallationFolder and + * useVersionInstallFolder both cross feature lines the same way for the same hook). + * + * usePickEmptyFolder warns without blocking the pick, so dispatching has to happen whether or + * not it warned, never only on the clean path. */ export function useConfigFolderPicker(actionType: FolderSettingActionType): () => Promise { - const { t } = useTranslation() - const { addNotification } = useNotificationsContext() const configDispatch = useConfigDispatch() + const pickEmptyFolder = usePickEmptyFolder() return async function pickFolder(): Promise { - const path = await window.api.utils.selectFolderDialog() - const selectedPath = path[0] - if (!selectedPath || selectedPath.length < 1) return + const selectedPath = await pickEmptyFolder() + if (!selectedPath) return - if (!(await window.api.pathsManager.checkPathEmpty(selectedPath))) addNotification(t("notifications.body.folderNotEmpty"), "warning") configDispatch({ type: actionType, payload: selectedPath }) } } diff --git a/src/renderer/src/features/launch/hooks/useLaunchGame.ts b/src/renderer/src/features/launch/hooks/useLaunchGame.ts index 95d902ed..7cb14ff3 100644 --- a/src/renderer/src/features/launch/hooks/useLaunchGame.ts +++ b/src/renderer/src/features/launch/hooks/useLaunchGame.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" import { useGameVersions, useConfigDispatch, CONFIG_ACTIONS } from "@renderer/features/config/contexts/ConfigContext" import { useNotificationsContext } from "@renderer/contexts/NotificationsContext" @@ -46,6 +47,7 @@ export function useLaunchGame(): LaunchGame { const { addNotification } = useNotificationsContext() const { openOnBrowser: openExternalLink } = useExternalLinks() const { os } = useAppInfo() + const goTo = useNavigate() const makeInstallationBackup = useMakeInstallationBackup() @@ -158,9 +160,22 @@ export function useLaunchGame(): LaunchGame { const outcomeNotification = pickPlayOutcomeNotification(result, os) if (outcomeNotification) { - const link = outcomeNotification.link - const options = link ? { actions: [{ id: "open-guide", label: t(link.labelKey), onClick: (): void => openExternalLink(link.url) }] } : undefined - addNotification(t(outcomeNotification.key), "error", options) + const { link, report } = outcomeNotification + const actions = [ + ...(link ? [{ id: "open-guide", label: t(link.labelKey), onClick: (): void => openExternalLink(link.url) }] : []), + ...(report + ? [ + { + id: "see-report", + label: t(report.labelKey), + onClick: (): void => { + void goTo(`/installations/report/${installation.id}`) + } + } + ] + : []) + ] + addNotification(t(outcomeNotification.key), "error", actions.length > 0 ? { actions } : undefined) } } catch (err) { logLaunch("error", `${LOG_TAG} Error executing the game.`) diff --git a/src/renderer/src/features/mods/components/ModsFilterBar.tsx b/src/renderer/src/features/mods/components/ModsFilterBar.tsx index 4bff22c0..634ba4b3 100644 --- a/src/renderer/src/features/mods/components/ModsFilterBar.tsx +++ b/src/renderer/src/features/mods/components/ModsFilterBar.tsx @@ -1,8 +1,10 @@ -import { Dispatch, SetStateAction } from "react" +import { SetStateAction } from "react" import { useTranslation } from "react-i18next" import { PiStarDuotone, PiStarFill, PiEraserDuotone } from "react-icons/pi" import clsx from "clsx" +import type { ModsFilters } from "@renderer/features/mods/modsBrowseState" + import { FormButton, FormInputText } from "@renderer/components/ui/FormComponents" import { StickyMenuGroupWrapper, StickyMenuGroup } from "@renderer/components/ui/StickyMenu" import AuthorFilter from "@renderer/features/mods/components/AuthorFilter" @@ -14,44 +16,12 @@ import InstalledFilter from "@renderer/features/mods/components/InstalledFilter" /** Every ListMods filter control: text/author/version/tag/side/installed, favorites-only, order, and clear. */ function ModsFilterBar({ - textFilter, - setTextFilter, - authorFilter, - setAuthorFilter, - versionsFilter, - setVersionsFilter, - tagsFilter, - setTagsFilter, - sideFilter, - setSideFilter, - installedFilter, - setInstalledFilter, - onlyFav, - setOnlyFav, - orderBy, - setOrderBy, - orderByOrder, - setOrderByOrder, + filters, + setFilter, onClearFilters }: Readonly<{ - textFilter: string - setTextFilter: Dispatch> - authorFilter: DownloadableModAuthorType - setAuthorFilter: Dispatch> - versionsFilter: DownloadableModGameVersionType[] - setVersionsFilter: Dispatch> - tagsFilter: DownloadableModTagType[] - setTagsFilter: Dispatch> - sideFilter: string - setSideFilter: Dispatch> - installedFilter: string - setInstalledFilter: Dispatch> - onlyFav: boolean - setOnlyFav: Dispatch> - orderBy: string - setOrderBy: Dispatch> - orderByOrder: string - setOrderByOrder: Dispatch> + filters: ModsFilters + setFilter: (key: K, value: SetStateAction) => void onClearFilters: () => void }>): JSX.Element { const { t } = useTranslation() @@ -59,17 +29,17 @@ function ModsFilterBar({ return ( - setTextFilter(e.target.value)} className="w-40 h-8" /> + setFilter("textFilter", e.target.value)} className="w-40 h-8" /> - + setFilter("authorFilter", value)} size="w-40 h-8" /> - + setFilter("versionsFilter", value)} size="w-40 h-8" /> - + setFilter("tagsFilter", value)} size="w-40 h-8" /> - + setFilter("sideFilter", value)} size="w-40 h-8" /> - + setFilter("installedFilter", value)} size="w-40 h-8" /> {/* * The active hue goes on the icon, never on the FormButton: the ghost variant carries @@ -80,15 +50,15 @@ function ModsFilterBar({ */} setOnlyFav((prev) => !prev)} - className={clsx("w-8 h-8 text-lg", onlyFav && "border-vsl")} + onClick={() => setFilter("onlyFav", !filters.onlyFav)} + className={clsx("w-8 h-8 text-lg", filters.onlyFav && "border-vsl")} variant="ghost" - ariaPressed={onlyFav} + ariaPressed={filters.onlyFav} > - {onlyFav ? : } + {filters.onlyFav ? : } - + setFilter("orderBy", value)} orderByOrder={filters.orderByOrder} setOrderByOrder={(value) => setFilter("orderByOrder", value)} /> onClearFilters()} className="w-8 h-8 text-lg" variant="ghost"> diff --git a/src/renderer/src/features/mods/modsBrowseState.ts b/src/renderer/src/features/mods/modsBrowseState.ts index 30cb8606..cbd6e302 100644 --- a/src/renderer/src/features/mods/modsBrowseState.ts +++ b/src/renderer/src/features/mods/modsBrowseState.ts @@ -2,7 +2,8 @@ import type { ModPick } from "@domain/mods/modSelection" export const DEFAULT_LOADED_MODS = 45 -export type ModsBrowseState = { +/** The nine fields ListMods' filter bar reads and writes, grouped as the one object it now keeps in state. */ +export type ModsFilters = { textFilter: string authorFilter: DownloadableModAuthorType versionsFilter: DownloadableModGameVersionType[] @@ -12,6 +13,9 @@ export type ModsBrowseState = { onlyFav: boolean orderBy: string orderByOrder: string +} + +export type ModsBrowseState = ModsFilters & { visibleMods: number scrollTop: number /** Selection mode on the browse grid, and the Mods picked in it. Renderer memory only, never config. */ diff --git a/src/renderer/src/features/mods/pages/ListMods.tsx b/src/renderer/src/features/mods/pages/ListMods.tsx index e2357200..a251c55f 100644 --- a/src/renderer/src/features/mods/pages/ListMods.tsx +++ b/src/renderer/src/features/mods/pages/ListMods.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback, useRef, useLayoutEffect, type Dispatch, type SetStateAction } from "react" +import { useState, useEffect, useMemo, useCallback, useRef, useLayoutEffect, type SetStateAction } from "react" import { Trans, useTranslation } from "react-i18next" import { useNavigate } from "react-router-dom" import { PiCheckSquareDuotone, PiCheckSquareFill } from "react-icons/pi" @@ -26,7 +26,7 @@ import ImportModpackPopup from "@renderer/features/mods/components/ImportModpack import ModSelectionBar from "@renderer/features/mods/components/ModSelectionBar" import type { ModCardAction } from "@renderer/features/mods/components/ModListCard" import { FormButton } from "@renderer/components/ui/FormComponents" -import { DEFAULT_LOADED_MODS, getModsBrowseState, updateModsBrowseState, type ModsBrowseState } from "@renderer/features/mods/modsBrowseState" +import { DEFAULT_LOADED_MODS, getModsBrowseState, updateModsBrowseState, type ModsBrowseState, type ModsFilters } from "@renderer/features/mods/modsBrowseState" import { installedCopiesOf } from "@domain/mods/installedFilters" import { findModUpdate } from "@domain/mods/compatibility" import { addPicks, modSelectionEntries, togglePick, type ModPick } from "@domain/mods/modSelection" @@ -90,15 +90,13 @@ function ListMods(): JSX.Element { const [modDetails, setModDetails] = useState>(() => new Map()) const requestedModDetails = useRef(new Set()) - const [onlyFav, setOnlyFavState] = useState(browseState.onlyFav) - const [textFilter, setTextFilterState] = useState(browseState.textFilter) - const [authorFilter, setAuthorFilterState] = useState(browseState.authorFilter) - const [versionsFilter, setVersionsFilterState] = useState(browseState.versionsFilter) - const [tagsFilter, setTagsFilterState] = useState(browseState.tagsFilter) - const [sideFilter, setSideFilterState] = useState(browseState.sideFilter) - const [installedFilter, setInstalledFilterState] = useState(browseState.installedFilter) - const [orderBy, setOrderByState] = useState(browseState.orderBy) - const [orderByOrder, setOrderByOrderState] = useState(browseState.orderByOrder) + const [filters, setFiltersState] = useState(browseState) + // Mirrors `filters` synchronously within one event, so a handler that fires setFilter more than + // once (OrderFilter's changeOrder sets both orderBy and orderByOrder) resolves its second call + // against the first call's result rather than the stale value this render started with. Kept in + // step with the committed state on every render; `filters` itself stays the one React reads. + const filtersRef = useRef(filters) + filtersRef.current = filters const [searching, setSearching] = useState(true) @@ -127,27 +125,21 @@ function ListMods(): JSX.Element { * * The next value is resolved against the rendered one here, in the event, rather than inside a * state updater: an updater runs during render (twice under StrictMode) and is no place for a - * scroll or a store write. Every caller sets a given filter at most once per event, so the - * rendered value is the one the change applies to. + * scroll or a store write. Resolved against `filtersRef`, not `filters`, so a handler that calls + * this twice in one event (OrderFilter's changeOrder sets both orderBy and orderByOrder) has its + * second call see the first call's change instead of clobbering it with the stale render value. */ - function updateFilter(current: T, setter: Dispatch>, value: SetStateAction, update: (next: T) => Partial): void { - const next = typeof value === "function" ? (value as (previous: T) => T)(current) : value + function setFilter(key: K, value: SetStateAction): void { + const current = filtersRef.current[key] + const next = typeof value === "function" ? (value as (previous: ModsFilters[K]) => ModsFilters[K])(current) : value if (next === current) return resetBrowsePosition() - updateModsBrowseState(update(next)) - setter(next) + const updated = { ...filtersRef.current, [key]: next } + filtersRef.current = updated + updateModsBrowseState(updated) + setFiltersState(updated) } - const setTextFilter: Dispatch> = (value) => updateFilter(textFilter, setTextFilterState, value, (next) => ({ textFilter: next })) - const setAuthorFilter: Dispatch> = (value) => updateFilter(authorFilter, setAuthorFilterState, value, (next) => ({ authorFilter: next })) - const setVersionsFilter: Dispatch> = (value) => updateFilter(versionsFilter, setVersionsFilterState, value, (next) => ({ versionsFilter: next })) - const setTagsFilter: Dispatch> = (value) => updateFilter(tagsFilter, setTagsFilterState, value, (next) => ({ tagsFilter: next })) - const setSideFilter: Dispatch> = (value) => updateFilter(sideFilter, setSideFilterState, value, (next) => ({ sideFilter: next })) - const setInstalledFilter: Dispatch> = (value) => updateFilter(installedFilter, setInstalledFilterState, value, (next) => ({ installedFilter: next })) - const setOnlyFav: Dispatch> = (value) => updateFilter(onlyFav, setOnlyFavState, value, (next) => ({ onlyFav: next })) - const setOrderBy: Dispatch> = (value) => updateFilter(orderBy, setOrderByState, value, (next) => ({ orderBy: next })) - const setOrderByOrder: Dispatch> = (value) => updateFilter(orderByOrder, setOrderByOrderState, value, (next) => ({ orderByOrder: next })) - const handleScroll = (): void => { if (!scrollRef.current) return const { scrollTop, clientHeight, scrollHeight } = scrollRef.current @@ -186,11 +178,13 @@ function ListMods(): JSX.Element { } // triggerQueryMods is a plain function redeclared every render, not a useCallback: it // always closes over this render's own filter values, so calling it from here already - // reads the current textFilter/authorFilter/etc. Listing it as a dependency would only - // make this effect refire on ListMods' own re-renders, not on anything it doesn't - // already refire on through the filters below. + // reads the current filters. Listing it as a dependency would only make this effect + // refire on ListMods' own re-renders, not on anything it doesn't already refire on + // through `filters` below. `filters` itself is a single dependency now, and setFilter + // only ever gives it a new reference on an actual change, so this compares correctly by + // identity. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [textFilter, authorFilter, versionsFilter, tagsFilter, sideFilter, installedFilter, onlyFav, orderBy, orderByOrder]) + }, [filters]) // Keyed on id/path, not on `installation` itself: triggerGetInstalledMods calls // syncModsCount, which writes _modsCount back onto this same installation and @@ -243,13 +237,14 @@ function ListMods(): JSX.Element { useEffect(() => { if (installationInstalledMods === undefined) return - if (!installationModsLoadedRef.current || installedFilter !== "all") triggerQueryMods() + if (!installationModsLoadedRef.current || filters.installedFilter !== "all") triggerQueryMods() installationModsLoadedRef.current = true // installedFilter changing on its own is already covered by the debounced-query effect - // above (it lists installedFilter in its own deps); this effect exists only to redo an - // "installed"/"not-installed" filter once a fresh installationInstalledMods scan comes - // in, so listing installedFilter here too would just fire triggerQueryMods twice for - // the same change. triggerQueryMods is excluded for the same reason as the effect above. + // above (it lists `filters`, installedFilter included, in its own deps); this effect + // exists only to redo an "installed"/"not-installed" filter once a fresh + // installationInstalledMods scan comes in, so listing filters here too would just fire + // triggerQueryMods twice for the same change. triggerQueryMods is excluded for the same + // reason as the effect above. // eslint-disable-next-line react-hooks/exhaustive-deps }, [installationInstalledMods]) @@ -301,22 +296,22 @@ function ListMods(): JSX.Element { const queryToken = ++queryTokenRef.current let mods = await queryMods({ - textFilter, - authorFilter, - versionsFilter, - tagsFilter, - orderBy, - orderByOrder + textFilter: filters.textFilter, + authorFilter: filters.authorFilter, + versionsFilter: filters.versionsFilter, + tagsFilter: filters.tagsFilter, + orderBy: filters.orderBy, + orderByOrder: filters.orderByOrder }) if (queryToken !== queryTokenRef.current) return - if (sideFilter !== "any") mods = mods.filter((mod) => mod.side === sideFilter) + if (filters.sideFilter !== "any") mods = mods.filter((mod) => mod.side === filters.sideFilter) - if (installedFilter === "installed") mods = mods.filter((mod) => installedCopiesOf(mod.modidstrs, installationInstalledMods).length > 0) - if (installedFilter === "not-installed") mods = mods.filter((mod) => installedCopiesOf(mod.modidstrs, installationInstalledMods).length < 1) + if (filters.installedFilter === "installed") mods = mods.filter((mod) => installedCopiesOf(mod.modidstrs, installationInstalledMods).length > 0) + if (filters.installedFilter === "not-installed") mods = mods.filter((mod) => installedCopiesOf(mod.modidstrs, installationInstalledMods).length < 1) - if (onlyFav) mods = mods.filter((mod) => favMods.includes(mod.modid)) + if (filters.onlyFav) mods = mods.filter((mod) => favMods.includes(mod.modid)) setModsList(mods) setSearching(false) @@ -445,13 +440,13 @@ function ListMods(): JSX.Element { ) function clearFilters(): void { - setTextFilter("") - setAuthorFilter({ userid: "", name: "" }) - setVersionsFilter([]) - setTagsFilter([]) - setSideFilter("any") - setInstalledFilter("all") - setOnlyFav(false) + setFilter("textFilter", "") + setFilter("authorFilter", { userid: "", name: "" }) + setFilter("versionsFilter", []) + setFilter("tagsFilter", []) + setFilter("sideFilter", "any") + setFilter("installedFilter", "all") + setFilter("onlyFav", false) } return ( @@ -485,27 +480,7 @@ function ListMods(): JSX.Element { - + {selecting && ( ("") const [folderByUser, setFolderByUser] = useState(false) @@ -37,11 +35,8 @@ export function useVersionInstallFolder(version: DownloadableGameVersionTypeType }, [version]) async function browseFolder(): Promise { - const path = await window.api.utils.selectFolderDialog() - const selectedPath = path[0] - if (!selectedPath || selectedPath.length === 0) return - - if (!(await window.api.pathsManager.checkPathEmpty(selectedPath))) addNotification(t("notifications.body.folderNotEmpty"), "warning") + const selectedPath = await pickEmptyFolder() + if (!selectedPath) return setFolder(selectedPath) setFolderByUser(true) diff --git a/src/renderer/src/locales/be-BY.json b/src/renderer/src/locales/be-BY.json index 36f537cb..a1f1d23f 100644 --- a/src/renderer/src/locales/be-BY.json +++ b/src/renderer/src/locales/be-BY.json @@ -900,6 +900,8 @@ "downloadError": "Не ўдалося спампаваць {{downloadName}}. Праверце злучэнне і паспрабуйце зноў.", "extracted": "Распакавана: {{extractName}}.", "extractError": "Не ўдалося распакаваць {{extractName}}. Праверце, што архіў не пашкоджаны і што на дыску ёсць месца.", + "installed": "Завершана ўстаноўка: {{installName}}!", + "installError": "Памылка ўстаноўкі: {{installName}}!", "gameExitedWithErrors": "Vintage Story завяршылася з памылкамі. Падрабязнасці ёсць у журнале.", "errorExecutingGame": "Нешта пайшло не так пры запуску гульні. Падрабязнасці ёсць у журнале.", "gameLaunchUnsupportedPlatform": "Vintage Story пакуль не можа працаваць на гэтай платформе. Паспрабуйце з Windows ці Linux.", diff --git a/src/renderer/src/locales/de-DE.json b/src/renderer/src/locales/de-DE.json index a6ca6bb9..06b15021 100644 --- a/src/renderer/src/locales/de-DE.json +++ b/src/renderer/src/locales/de-DE.json @@ -900,6 +900,8 @@ "downloadError": "{{downloadName}} konnte nicht heruntergeladen werden. Prüfe deine Verbindung und versuche es erneut.", "extracted": "{{extractName}} entpackt.", "extractError": "{{extractName}} konnte nicht entpackt werden. Prüfe, ob das Archiv unbeschädigt ist und ob auf dem Laufwerk Platz ist.", + "installed": "Installation abgeschlossen: {{installName}}!", + "installError": "Fehler bei der Installation: {{installName}}!", "gameExitedWithErrors": "Vintage Story wurde mit Fehlern beendet. Die Details stehen im Protokoll.", "errorExecutingGame": "Beim Starten des Spiels ist etwas schiefgegangen. Die Details stehen im Protokoll.", "gameLaunchUnsupportedPlatform": "Vintage Story läuft auf dieser Plattform noch nicht. Versuche es unter Windows oder Linux.", diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 7d22143b..db9818fa 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -939,6 +939,8 @@ "downloadError": "Couldn't download {{downloadName}}. Check your connection and try again.", "extracted": "Unpacked {{extractName}}.", "extractError": "Couldn't unpack {{extractName}}. Check that the archive is not damaged and that there is room on the drive.", + "installed": "Installed {{installName}}.", + "installError": "Couldn't install {{installName}}. The log has the details.", "gameExitedWithErrors": "Vintage Story exited with errors. The log has the details.", "errorExecutingGame": "Something went wrong starting the game. The log has the details.", "gameLaunchUnsupportedPlatform": "Vintage Story can't run on this platform yet. Try it from Windows or Linux.", diff --git a/src/renderer/src/locales/es-ES.json b/src/renderer/src/locales/es-ES.json index ae31856c..31ba2e1e 100644 --- a/src/renderer/src/locales/es-ES.json +++ b/src/renderer/src/locales/es-ES.json @@ -900,6 +900,8 @@ "downloadError": "¡Error al descargar: {{downloadName}}!", "extracted": "¡Extracción finalizada: {{extractName}}!", "extractError": "¡Error al extraer: {{extractName}}!", + "installed": "¡Instalación finalizada: {{installName}}!", + "installError": "¡Error al instalar: {{installName}}!", "gameExitedWithErrors": "¡Vintage Story se cerró con errores!", "errorExecutingGame": "¡Ha ocurrido un error al ejecutar el juego!", "gameLaunchUnsupportedPlatform": "Vintage Story todavía no puede ejecutarse en esta plataforma. Pruébalo desde Windows o Linux.", diff --git a/src/renderer/src/locales/fr-FR.json b/src/renderer/src/locales/fr-FR.json index 2791b41a..ade9da35 100644 --- a/src/renderer/src/locales/fr-FR.json +++ b/src/renderer/src/locales/fr-FR.json @@ -939,6 +939,8 @@ "downloadError": "Impossible de télécharger {{downloadName}}. Vérifiez votre connexion et réessayez.", "extracted": "Décompression terminée : {{extractName}}.", "extractError": "Impossible de décompresser {{extractName}}. Vérifiez que l'archive n'est pas endommagée et qu'il reste de la place sur le disque.", + "installed": "Installation terminée : {{installName}}.", + "installError": "Impossible d'installer {{installName}}. Consultez le journal pour plus de détails.", "gameExitedWithErrors": "Vintage Story s'est arrêté avec des erreurs. Les détails sont dans le journal.", "errorExecutingGame": "Un problème est survenu au démarrage du jeu. Les détails sont dans le journal.", "gameLaunchUnsupportedPlatform": "Vintage Story ne peut pas encore tourner sur cette plateforme. Essayez depuis Windows ou Linux.", diff --git a/src/renderer/src/locales/hu-HU.json b/src/renderer/src/locales/hu-HU.json index 3200ea1a..0916d764 100644 --- a/src/renderer/src/locales/hu-HU.json +++ b/src/renderer/src/locales/hu-HU.json @@ -900,6 +900,8 @@ "downloadError": "Letöltési hiba: {{downloadName}}!", "extracted": "A kicsomagolás befejeződött: {{extractName}}!", "extractError": "Hiba a kicsomagolás közben: {{extractName}}!", + "installed": "A telepítés befejeződött: {{installName}}!", + "installError": "Hiba a telepítés közben: {{installName}}!", "gameExitedWithErrors": "A Vintage Story, hibák következtében kilépett!", "errorExecutingGame": "A játék indításakor hibák léptek fel!", "gameLaunchUnsupportedPlatform": "A Vintage Story ezen a platformon még nem fut. Próbáld Windowsról vagy Linuxról.", diff --git a/src/renderer/src/locales/it-IT.json b/src/renderer/src/locales/it-IT.json index 38eea3ff..b9580045 100644 --- a/src/renderer/src/locales/it-IT.json +++ b/src/renderer/src/locales/it-IT.json @@ -900,6 +900,8 @@ "downloadError": "Errore scaricando: {{downloadName}}!", "extracted": "Finita estrazione: {{extractName}}!", "extractError": "Errore estraendo: {{extractName}}!", + "installed": "Installazione completata: {{installName}}!", + "installError": "Errore durante l'installazione: {{installName}}!", "gameExitedWithErrors": "Vintage Story si è chiuso con errori!", "errorExecutingGame": "C'è stato un errore nell'esecuzione del gioco!", "gameLaunchUnsupportedPlatform": "Vintage Story non può ancora funzionare su questa piattaforma. Provalo da Windows o Linux.", diff --git a/src/renderer/src/locales/nl-NL.json b/src/renderer/src/locales/nl-NL.json index a220f723..84c18ca1 100644 --- a/src/renderer/src/locales/nl-NL.json +++ b/src/renderer/src/locales/nl-NL.json @@ -900,6 +900,8 @@ "downloadError": "Fout downloaden: {{downloadName}}!", "extracted": "Uitpakken voltooid: {{extractName}}!", "extractError": "Fout uitpakken: {{extractName}}!", + "installed": "Installatie voltooid: {{installName}}!", + "installError": "Fout bij installeren: {{installName}}!", "gameExitedWithErrors": "Vintage Story gesloten met fouten!", "errorExecutingGame": "Er is een fout opgetreden terwijl het spel gestart werd", "gameLaunchUnsupportedPlatform": "Vintage Story kan nog niet op dit platform draaien. Probeer het vanaf Windows of Linux.", diff --git a/src/renderer/src/locales/pl-PL.json b/src/renderer/src/locales/pl-PL.json index bb347f7c..cd468b89 100644 --- a/src/renderer/src/locales/pl-PL.json +++ b/src/renderer/src/locales/pl-PL.json @@ -900,6 +900,8 @@ "downloadError": "Błąd pobierania: {{downloadName}}!", "extracted": "Ukończono wypakowywanie: {{extractName}}!", "extractError": "Błąd wypakowywania: {{extractName}}!", + "installed": "Ukończono instalację: {{installName}}!", + "installError": "Błąd instalacji: {{installName}}!", "gameExitedWithErrors": "Vintage Story zostało zamknięte z błędami!", "errorExecutingGame": "Podczas uruchamiania gry wystąpił błąd!", "gameLaunchUnsupportedPlatform": "Vintage Story nie działa jeszcze na tej platformie. Spróbuj z systemu Windows lub Linux.", diff --git a/src/renderer/src/locales/pt-BR.json b/src/renderer/src/locales/pt-BR.json index 3278f86a..45cd1133 100644 --- a/src/renderer/src/locales/pt-BR.json +++ b/src/renderer/src/locales/pt-BR.json @@ -900,6 +900,8 @@ "downloadError": "Erro ao baixar: {{downloadName}}!", "extracted": "Extração concluída: {{extractName}}!", "extractError": "Erro na extração: {{extractName}}!", + "installed": "Instalação concluída: {{installName}}!", + "installError": "Erro na instalação: {{installName}}!", "gameExitedWithErrors": "Vintage Story parou com erros!", "errorExecutingGame": "Ocorreu um erro ao executar o jogo!", "gameLaunchUnsupportedPlatform": "O Vintage Story ainda não pode rodar nesta plataforma. Tente a partir do Windows ou Linux!", diff --git a/src/renderer/src/locales/pt-PT.json b/src/renderer/src/locales/pt-PT.json index 1b130805..f4955926 100644 --- a/src/renderer/src/locales/pt-PT.json +++ b/src/renderer/src/locales/pt-PT.json @@ -900,6 +900,8 @@ "downloadError": "Erro ao descarregar: {{downloadName}}!", "extracted": "Extração concluída: {{extractName}}!", "extractError": "Erro na extração: {{extractName}}!", + "installed": "Instalação concluída: {{installName}}!", + "installError": "Erro na instalação: {{installName}}!", "gameExitedWithErrors": "Vintage Story parou com erros!", "errorExecutingGame": "Ocorreu um erro ao executar o jogo!", "gameLaunchUnsupportedPlatform": "O Vintage Story ainda não corre nesta plataforma. Experimente a partir do Windows ou do Linux.", diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index a37a62bf..776fd01f 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -939,6 +939,8 @@ "downloadError": "Не удалось загрузить {{downloadName}}. Проверьте подключение и попробуйте снова.", "extracted": "Извлечение завершено: {{extractName}}.", "extractError": "Не удалось распаковать {{extractName}}. Проверьте, что архив не повреждён и что на диске есть место.", + "installed": "Установка завершена: {{installName}}!", + "installError": "Ошибка установки: {{installName}}!", "gameExitedWithErrors": "Vintage Story завершилась с ошибками. Подробности есть в журнале.", "errorExecutingGame": "При запуске игры что-то пошло не так. Подробности есть в журнале.", "gameLaunchUnsupportedPlatform": "Vintage Story пока не работает на этой платформе. Попробуйте из Windows или Linux.", diff --git a/src/renderer/src/locales/uk-UA.json b/src/renderer/src/locales/uk-UA.json index ee3f7c10..9cd665da 100644 --- a/src/renderer/src/locales/uk-UA.json +++ b/src/renderer/src/locales/uk-UA.json @@ -900,6 +900,8 @@ "downloadError": "Не вдалося завантажити {{downloadName}}. Перевірте підключення та спробуйте ще раз.", "extracted": "Видобуток завершено: {{extractName}}!", "extractError": "Не вдалося розпакувати {{extractName}}. Перевірте, що архів не пошкоджений і що на диску є місце.", + "installed": "Встановлення завершено: {{installName}}!", + "installError": "Помилка під час встановлення: {{installName}}!", "gameExitedWithErrors": "Vintage Story завершився з помилками. Подробиці є в журналі.", "errorExecutingGame": "Під час запуску гри щось пішло не так. Подробиці є в журналі.", "gameLaunchUnsupportedPlatform": "Vintage Story поки що не може працювати на цій платформі. Спробуйте з Windows або Linux.", diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index 57b95526..ad4c2f29 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -900,6 +900,8 @@ "downloadError": "下载出现错误: {{downloadName}}!", "extracted": "解压完成: {{extractName}}!", "extractError": "解压出现错误: {{extractName}}!", + "installed": "安装完成: {{installName}}!", + "installError": "安装出现错误: {{installName}}!", "gameExitedWithErrors": "《复古物语》因出现错误退出!", "errorExecutingGame": "执行游戏时发生错误!", "gameLaunchUnsupportedPlatform": "Vintage Story 还不能在这个平台上运行。请在 Windows 或 Linux 上试试。", diff --git a/tests/config/ci-sonarcloud-runs-on-failure.test.ts b/tests/config/ci-sonarcloud-runs-on-failure.test.ts new file mode 100644 index 00000000..25647109 --- /dev/null +++ b/tests/config/ci-sonarcloud-runs-on-failure.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { describe, it } from "vitest" + +/** + * Guards against sonarcloud silently vanishing from the checks list. + * + * sonarcloud gained `needs: [test-matrix]` so it can download that job's + * coverage artifact instead of regenerating it. But GitHub Actions applies + * an implicit `success()` to any `if:` that names none of + * success()/always()/failure()/cancelled() when the job also has `needs` + * (see the `test` job's own comment two jobs above, which relies on the + * same rule). Without `always()`, a single failed or cancelled test-matrix + * leg (say, a Windows-only flake) makes the whole sonarcloud job SKIP + * outright, even though the ubuntu-latest leg already uploaded a usable + * coverage-report artifact. On dev, sonarcloud had no `needs` and always + * attempted its own independent test:coverage run regardless of anything + * else in the workflow. + */ +describe("ci sonarcloud job", () => { + const workflow = readFileSync(resolve(__dirname, "../../.github/workflows/ci.yml"), "utf8") + const sonarcloudBlock = /^ {2}sonarcloud:$([\s\S]*?)(?=^ {2}\S+:$)/m.exec(workflow)?.[1] + + it("still has a coverage artifact to read from test-matrix", () => { + assert.ok(sonarcloudBlock, "sonarcloud job not found in ci.yml") + assert.match(sonarcloudBlock!, /needs:\s*\[test-matrix\]/) + }) + + it("keeps attempting to run even when a test-matrix leg fails or is cancelled", () => { + // A plain `if:` with `needs` defaults to success(): only always() (or an + // explicit failure()/cancelled()) stops a red test-matrix leg from + // skipping this job outright instead of letting continue-on-error do its + // job of turning a real failure into an informational miss. + const ifLine = /^ {4}if:\s*(.+)$/m.exec(sonarcloudBlock!)?.[1] + assert.ok(ifLine, "sonarcloud job has no if: condition") + assert.match(ifLine!, /always\(\)/) + }) +}) diff --git a/tests/renderer-dom/installationsAddFolderPick.test.tsx b/tests/renderer-dom/installationsAddFolderPick.test.tsx index 900d5d04..ca1fc430 100644 --- a/tests/renderer-dom/installationsAddFolderPick.test.tsx +++ b/tests/renderer-dom/installationsAddFolderPick.test.tsx @@ -3,6 +3,7 @@ import { screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import AddInstallation from "@renderer/features/installations/pages/AddInstallation" +import NotificationsOverlay from "@renderer/components/layout/NotificationsOverlay" import { installMockWindowApi } from "./helpers/windowApi" import { renderWithProviders } from "./helpers/render" @@ -41,6 +42,33 @@ describe("AddInstallation", () => { await waitFor(() => expect(pathInput.value).toBe("/picked/not-empty")) }) + /** + * usePickEmptyFolder (#490 item 5) is the survivor of a fold that collapsed three copies of + * pick-a-folder-and-warn-if-not-empty (config, installations, versions) onto this one hook. + * Nothing exercised the warning notification itself before this fold, only that the pick was + * not blocked by it (the test above); this is that missing case, for the hook every caller + * now shares. + */ + it("shows the not-empty warning notification, not just an unblocked pick", async () => { + const user = userEvent.setup() + installMockWindowApi({ + utils: { selectFolderDialog: vi.fn(async () => ["/picked/not-empty"]) }, + pathsManager: { checkPathEmpty: vi.fn(async () => false) } + }) + + renderWithProviders( + <> + + + , + { route: "/installations/add" } + ) + + await user.click(screen.getByTitle("Browse")) + + expect(await screen.findByText("The folder you've selected is not empty. Make sure there is nothing important in it.")).toBeTruthy() + }) + it("cancelling the dialog leaves the field untouched", async () => { const user = userEvent.setup() installMockWindowApi({ utils: { selectFolderDialog: vi.fn(async () => []) } }) diff --git a/tests/renderer-dom/manageInstallationServers.test.tsx b/tests/renderer-dom/manageInstallationServers.test.tsx index 20ff4f47..fe5132f1 100644 --- a/tests/renderer-dom/manageInstallationServers.test.tsx +++ b/tests/renderer-dom/manageInstallationServers.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest" import { screen, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import { Route, Routes } from "react-router-dom" +import { Route, Routes, useLocation } from "react-router-dom" import ManageInstallationServers from "@renderer/features/servers/pages/ManageInstallationServers" import ImportServersDialog from "@renderer/features/servers/components/ImportServersDialog" @@ -38,6 +38,11 @@ function anInstallation(servers?: ServerBookmarkType[]): InstallationType { } } +/** Where the page's own navigations land, the session report notice's action among them. */ +function WhereProbe(): JSX.Element { + return {useLocation().pathname} +} + function renderServersPage(servers?: ServerBookmarkType[], overrides: WindowApiOverrides = {}): ReturnType { const api = installMockWindowApi({ configManager: { @@ -52,17 +57,20 @@ function renderServersPage(servers?: ServerBookmarkType[], overrides: WindowApiO }) renderWithProviders( - - - - - - } - /> - , + <> + + + + + + } + /> + + + , { route: `/installations/servers/${INSTALLATION_ID}` } ) @@ -244,6 +252,24 @@ describe("ManageInstallationServers", () => { expect(saved?.installations[0]?.servers?.map((server) => server.name)).toEqual(["Stratum", "Testing"]) }) + /** + * MainMenu's Play button and this row both go through useLaunchGame (#490 item 1): before that + * fold, the report action lived only in MainMenu's own copy, so a crash after joining a server + * offered no report link. Mirrors launchPlayGame.test.tsx's "offers the session report" case. + */ + it("offers the session report from the exited-with-errors notice and lands on that Installation's page", async () => { + const user = userEvent.setup() + const executeGame = vi.fn(async () => ({ ok: true, exitCode: 1 }) as GameExecutionResult) + renderServersPage([{ id: "s-1", name: "Stratum", host: "play.example.com", port: 42_420, lastLaunched: -1 }], { gameManager: { executeGame } }) + + await user.click(await screen.findByRole("button", { name: "Join" })) + + await screen.findByText("Vintage Story exited with errors. The log has the details.") + await user.click(await screen.findByRole("button", { name: "See what went wrong" })) + + await vi.waitFor(() => expect(screen.getByTestId("where").textContent).toBe(`/installations/report/${INSTALLATION_ID}`)) + }) + /** * i18next escapes what it interpolates, so a date handed to t() reaches the page as * "4/9/2025" and React renders those entities literally. Every launched row read that diff --git a/tests/renderer-dom/taskManagerStartInstall.test.tsx b/tests/renderer-dom/taskManagerStartInstall.test.tsx index 9c87a3cc..78ae6fef 100644 --- a/tests/renderer-dom/taskManagerStartInstall.test.tsx +++ b/tests/renderer-dom/taskManagerStartInstall.test.tsx @@ -112,5 +112,12 @@ describe("TaskManagerContext.startInstall", () => { await waitFor(() => expect(onFinish).toHaveBeenCalled()) expect(result.current.notifications.notifications.map((n) => n.type)).toEqual(["error"]) + // startInstall was copied from startExtract and kept its archive wording + // (issue #490 item 4): an installer failure is not an archive failure, and + // `reason` already carries the specific cause to the Activity Center row, + // so the toast should point at the log rather than blame "the archive". + const body = result.current.notifications.notifications[0]?.body ?? "" + expect(body).toContain("log") + expect(body).not.toContain("archive") }) })