From 80473f52da01b919934f11bda04d7d6cd45d6e01 Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Tue, 7 Apr 2026 14:14:08 +0300 Subject: [PATCH 01/19] Switched to the new upload API --- etc/Dependency Report.md | 3 + etc/vortex.api.md | 32 +- extensions/collections/package.json | 1 + .../collections/src/collectionExport.ts | 29 ++ packages/nexus-api-v3/.gitignore | 2 + packages/nexus-api-v3/package.json | 34 ++ packages/nexus-api-v3/src/client.test.ts | 261 ++++++++++++ packages/nexus-api-v3/src/client.ts | 120 ++++++ packages/nexus-api-v3/src/errors.ts | 22 + packages/nexus-api-v3/src/index.ts | 7 + packages/nexus-api-v3/tsconfig.json | 15 + packages/nexus-api-v3/tsdown.config.ts | 13 + packages/nexus-api-v3/vitest.config.ts | 8 + pnpm-lock.yaml | 269 ++++++++++-- pnpm-workspace.yaml | 2 + src/renderer/package.json | 1 + .../extensions/nexus_integration/constants.ts | 5 + .../nexus_integration/eventHandlers.ts | 56 +-- .../extensions/nexus_integration/index.tsx | 4 +- .../util_v3/manifestMapping.test.ts | 208 ++++++++++ .../util_v3/manifestMapping.ts | 65 +++ .../util_v3/submitCollectionV3.test.ts | 384 ++++++++++++++++++ .../util_v3/submitCollectionV3.ts | 152 +++++++ .../util_v3/uploadV3.test.ts | 204 ++++++++++ .../nexus_integration/util_v3/uploadV3.ts | 104 +++++ src/renderer/src/util/network.ts | 65 ++- src/renderer/webpack.config.cjs | 2 +- 27 files changed, 1974 insertions(+), 94 deletions(-) create mode 100644 packages/nexus-api-v3/.gitignore create mode 100644 packages/nexus-api-v3/package.json create mode 100644 packages/nexus-api-v3/src/client.test.ts create mode 100644 packages/nexus-api-v3/src/client.ts create mode 100644 packages/nexus-api-v3/src/errors.ts create mode 100644 packages/nexus-api-v3/src/index.ts create mode 100644 packages/nexus-api-v3/tsconfig.json create mode 100644 packages/nexus-api-v3/tsdown.config.ts create mode 100644 packages/nexus-api-v3/vitest.config.ts create mode 100644 src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts create mode 100644 src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts create mode 100644 src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts create mode 100644 src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts create mode 100644 src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts create mode 100644 src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts diff --git a/etc/Dependency Report.md b/etc/Dependency Report.md index 65d696f2f0..a6f33814a9 100644 --- a/etc/Dependency Report.md +++ b/etc/Dependency Report.md @@ -45,6 +45,7 @@ This is a list of all modules leaked by Vortex to extensions. Any module listed | electron-context-menu | 3.6.1 | | electron-redux | 1.4.9-sync | | electron-updater | 4.6.5 | +| encoding-down | 6.3.0 | | exe-version | 2.3.0 | | feedparser | 2.3.0 | | fs-extra | 9.1.0 | @@ -61,6 +62,8 @@ This is a list of all modules leaked by Vortex to extensions. Any module listed | is-admin | 3.0.0 | | json-socket | 0.3.0 | | jsonwebtoken | 9.0.3 | +| leveldown | 5.6.0 | +| levelup | 4.4.0 | | limiter | 3.0.0 | | lodash | 4.17.23 | | memoize-one | 5.2.1 | diff --git a/etc/vortex.api.md b/etc/vortex.api.md index 19a378d034..a07e6a4c41 100644 --- a/etc/vortex.api.md +++ b/etc/vortex.api.md @@ -49,6 +49,7 @@ import type { IModFileContentSearchFilter } from '@nexusmods/nexus-api'; import type { IModInfo } from 'modmeta-db'; import type { IModInfo as IModInfo_2 } from '@nexusmods/nexus-api'; import type { IModRequirements } from '@nexusmods/nexus-api'; +import type { IncomingHttpHeaders } from 'http'; import type { IncomingMessage } from 'http'; import type { IPreference } from '@nexusmods/nexus-api'; import type { IPreferenceQuery } from '@nexusmods/nexus-api'; @@ -3509,6 +3510,9 @@ interface ISettingsInterface { order?: { [gameId: string]: string[]; }; + pinned?: { + [gameId: string]: string[]; + }; }; // (undocumented) usage: { @@ -3654,6 +3658,9 @@ interface IState { needToDeploy: { [gameId: string]: boolean; }; + deploymentCounter: { + [gameId: string]: number; + }; }; transactions: IStateTransactions; history: IHistoryPersistent; @@ -4048,6 +4055,16 @@ interface IUnavailableReason { solution?: (t: TFunction) => string; } +// @public (undocumented) +interface IUploadResult { + // (undocumented) + body: Buffer; + // (undocumented) + headers: IncomingHttpHeaders; + // (undocumented) + statusCode: number; +} + // @public interface IUser { // (undocumented) @@ -6014,6 +6031,9 @@ type UpdateType = "drag-n-drop" | "props-update" | "refresh"; // @public (undocumented) function upload(targetUrl: string, dataStream: Readable, dataSize: number): Promise; +// @public (undocumented) +function uploadWithHeaders(targetUrl: string, dataStream: Readable, dataSize: number, extraHeaders?: Record): Promise; + // Warning: (ae-forgotten-export) The symbol "IUsageProps" needs to be exported by the entry point api.d.ts // // @public (undocumented) @@ -6187,8 +6207,10 @@ declare namespace util { jsonRequest, request, upload, + uploadWithHeaders, IRequestOptions, - Method + Method, + IUploadResult } } export { util } @@ -6293,10 +6315,10 @@ export class ZoomableImage extends React_2.Component", }); throw new util.ProcessCanceled("collection rejected"); + } else if (err instanceof V3ApiError) { + const message = err.detail || err.message; + const validationErrors = err.validationErrors ?? []; + api.sendNotification({ + type: "error", + message: "The server rejected this collection", + actions: [ + { + title: "More", + action: () => { + api.showDialog( + "error", + "The server rejected this collection", + { + text: + validationErrors.length === 0 + ? message + : validationErrors + .map((ve) => `${ve.pointer}: ${ve.detail}`) + .join("\n"), + }, + [{ label: "Close" }], + ); + }, + }, + ], + }); + throw new util.ProcessCanceled("collection rejected"); } else if (err.constructor.name === "GraphError") { const message: string = err.message; const details: IGraphErrorDetail[] = err["details"] ?? []; diff --git a/packages/nexus-api-v3/.gitignore b/packages/nexus-api-v3/.gitignore new file mode 100644 index 0000000000..1ea6743a06 --- /dev/null +++ b/packages/nexus-api-v3/.gitignore @@ -0,0 +1,2 @@ +dist/ +src/generated/ diff --git a/packages/nexus-api-v3/package.json b/packages/nexus-api-v3/package.json new file mode 100644 index 0000000000..9e3edec898 --- /dev/null +++ b/packages/nexus-api-v3/package.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "name": "@vortex/nexus-api-v3", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "codegen": "openapi-typescript https://api.nexusmods.com/openapi.yaml -o src/generated/nexus-api-v3.d.ts", + "build": "pnpm codegen && pnpm tsdown", + "dist": "pnpm tsdown", + "test": "vitest run", + "typecheck": "pnpm tsc --noEmit -p tsconfig.json", + "postinstall": "pnpm codegen" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "dependencies": { + "openapi-fetch": "catalog:" + }, + "devDependencies": { + "openapi-typescript": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/nexus-api-v3/src/client.test.ts b/packages/nexus-api-v3/src/client.test.ts new file mode 100644 index 0000000000..3761f2eae1 --- /dev/null +++ b/packages/nexus-api-v3/src/client.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createNexusV3Client } from "./client"; +import { V3ApiError } from "./errors"; + +// Mock openapi-fetch's createClient +vi.mock("openapi-fetch", () => ({ + default: vi.fn(() => ({ + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), + })), +})); + +import createClient from "openapi-fetch"; + +const mockCreateClient = vi.mocked(createClient); + +describe("createNexusV3Client", () => { + let mockClient: { + GET: ReturnType; + POST: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = { + GET: vi.fn(), + POST: vi.fn(), + }; + mockCreateClient.mockReturnValue(mockClient as any); + }); + + describe("auth headers", () => { + it("sets apikey header when apiKey is provided", () => { + createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "my-api-key", + }); + + expect(mockCreateClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + apikey: "my-api-key", + }), + }), + ); + }); + + it("sets Authorization header when bearerToken is provided", () => { + createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + bearerToken: "my-jwt-token", + }); + + expect(mockCreateClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer my-jwt-token", + }), + }), + ); + }); + + it("prefers bearerToken over apiKey", () => { + createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "my-api-key", + bearerToken: "my-jwt-token", + }); + + const headers = mockCreateClient.mock.calls[0][0].headers as Record< + string, + string + >; + expect(headers["Authorization"]).toBe("Bearer my-jwt-token"); + expect(headers["apikey"]).toBeUndefined(); + }); + }); + + describe("createUpload", () => { + it("calls POST /uploads and returns data", async () => { + mockClient.POST.mockResolvedValue({ + data: { data: { id: "upload-1", presigned_url: "https://s3/upload" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + const result = await client.createUpload(1024, "file.zip"); + + expect(mockClient.POST).toHaveBeenCalledWith("/uploads", { + body: { size_bytes: 1024, filename: "file.zip" }, + }); + expect(result.id).toBe("upload-1"); + expect(result.presigned_url).toBe("https://s3/upload"); + }); + + it("throws V3ApiError on error response", async () => { + mockClient.POST.mockResolvedValue({ + data: undefined, + error: { + type: "about:blank", + title: "Bad Request", + status: 400, + detail: "Invalid size", + instance: "/uploads", + }, + response: { status: 400, url: "https://api.nexusmods.com/v3/uploads" }, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + + await expect(client.createUpload(-1, "file.zip")).rejects.toThrow( + V3ApiError, + ); + }); + }); + + describe("finaliseUpload", () => { + it("calls POST /uploads/{id}/finalise", async () => { + mockClient.POST.mockResolvedValue({ + data: { data: { id: "upload-1", state: "created" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + await client.finaliseUpload("upload-1"); + + expect(mockClient.POST).toHaveBeenCalledWith("/uploads/{id}/finalise", { + params: { path: { id: "upload-1" } }, + }); + }); + }); + + describe("getUpload", () => { + it("calls GET /uploads/{id} and returns state", async () => { + mockClient.GET.mockResolvedValue({ + data: { data: { id: "upload-1", state: "available" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + const result = await client.getUpload("upload-1"); + + expect(mockClient.GET).toHaveBeenCalledWith("/uploads/{id}", { + params: { path: { id: "upload-1" } }, + }); + expect(result.state).toBe("available"); + }); + }); + + describe("createCollection", () => { + it("calls POST /collections with upload_id and payload", async () => { + mockClient.POST.mockResolvedValue({ + data: { data: { id: "col-1", revision_id: "rev-1" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + const payload = { + adult_content: false, + collection_manifest: { info: {}, mods: [] }, + collection_schema_id: 1, + }; + const result = await client.createCollection("upload-1", payload as any); + + expect(mockClient.POST).toHaveBeenCalledWith("/collections", { + body: { upload_id: "upload-1", collection_data: payload }, + }); + expect(result.id).toBe("col-1"); + expect(result.revision_id).toBe("rev-1"); + }); + + it("throws V3ApiError with validation errors on 422", async () => { + mockClient.POST.mockResolvedValue({ + data: undefined, + error: { + type: "about:blank", + title: "Unprocessable Entity", + status: 422, + detail: "Validation failed", + instance: "/collections", + errors: [ + { + detail: "mod_id is required", + pointer: "/collection_data/collection_manifest/mods/0/source/mod_id", + }, + ], + }, + response: { + status: 422, + url: "https://api.nexusmods.com/v3/collections", + }, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + + try { + await client.createCollection("upload-1", {} as any); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(V3ApiError); + const v3err = err as V3ApiError; + expect(v3err.status).toBe(422); + expect(v3err.validationErrors).toHaveLength(1); + expect(v3err.validationErrors![0].pointer).toContain("mod_id"); + } + }); + }); + + describe("createCollectionRevision", () => { + it("calls POST /collections/{id}/revisions", async () => { + mockClient.POST.mockResolvedValue({ + data: { data: { id: "rev-2", collection_id: "col-1" } }, + error: undefined, + response: {}, + }); + + const client = createNexusV3Client({ + baseUrl: "https://api.nexusmods.com/v3", + apiKey: "key", + }); + const result = await client.createCollectionRevision( + "col-1", + "upload-1", + {} as any, + ); + + expect(mockClient.POST).toHaveBeenCalledWith( + "/collections/{id}/revisions", + { + params: { path: { id: "col-1" } }, + body: { upload_id: "upload-1", collection_data: {} }, + }, + ); + expect(result.id).toBe("rev-2"); + expect(result.collection_id).toBe("col-1"); + }); + }); +}); diff --git a/packages/nexus-api-v3/src/client.ts b/packages/nexus-api-v3/src/client.ts new file mode 100644 index 0000000000..4eacd009cd --- /dev/null +++ b/packages/nexus-api-v3/src/client.ts @@ -0,0 +1,120 @@ +import createClient from "openapi-fetch"; + +import type { paths } from "./generated/nexus-api-v3"; +import { V3ApiError } from "./errors"; + +export interface NexusV3ClientOptions { + baseUrl: string; + apiKey?: string; + bearerToken?: string; +} + +export type NexusV3Client = ReturnType; + +export function createNexusV3Client(options: NexusV3ClientOptions) { + const headers: Record = { + "User-Agent": "Vortex", + }; + + if (options.bearerToken) { + headers["Authorization"] = `Bearer ${options.bearerToken}`; + } else if (options.apiKey) { + headers["apikey"] = options.apiKey; + } + + const client = createClient({ + baseUrl: options.baseUrl, + headers, + }); + + // Wrap the client methods to throw V3ApiError on error responses + return { + ...client, + + async createUpload(sizeBytes: number, filename: string) { + const { data, error, response } = await client.POST("/uploads", { + body: { size_bytes: sizeBytes, filename }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async createMultipartUpload(sizeBytes: number, filename: string) { + const { data, error, response } = await client.POST( + "/uploads/multipart", + { + body: { size_bytes: sizeBytes, filename }, + }, + ); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async finaliseUpload(uploadId: string) { + const { data, error, response } = await client.POST( + "/uploads/{id}/finalise", + { + params: { path: { id: uploadId } }, + }, + ); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async getUpload(uploadId: string) { + const { data, error, response } = await client.GET("/uploads/{id}", { + params: { path: { id: uploadId } }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async createCollection( + uploadId: string, + collectionData: paths["/collections"]["post"]["requestBody"]["content"]["application/json"]["collection_data"], + ) { + const { data, error, response } = await client.POST("/collections", { + body: { upload_id: uploadId, collection_data: collectionData }, + }); + if (error) throw toV3Error(error, response); + return data.data; + }, + + async createCollectionRevision( + collectionId: string, + uploadId: string, + collectionData: paths["/collections/{id}/revisions"]["post"]["requestBody"]["content"]["application/json"]["collection_data"], + ) { + const { data, error, response } = await client.POST( + "/collections/{id}/revisions", + { + params: { path: { id: collectionId } }, + body: { upload_id: uploadId, collection_data: collectionData }, + }, + ); + if (error) throw toV3Error(error, response); + return data.data; + }, + }; +} + +function toV3Error(error: unknown, response: Response): V3ApiError { + // openapi-fetch returns the parsed error body. For problem+json responses, + // this will be a ProblemDetails or ValidationProblem object. + if ( + error && + typeof error === "object" && + "title" in error && + "status" in error + ) { + return new V3ApiError(error as any); + } + // Fallback for unexpected error shapes + return new V3ApiError({ + type: "about:blank", + title: "Request failed", + status: response.status, + detail: String(error), + instance: response.url, + }); +} diff --git a/packages/nexus-api-v3/src/errors.ts b/packages/nexus-api-v3/src/errors.ts new file mode 100644 index 0000000000..c920c7449d --- /dev/null +++ b/packages/nexus-api-v3/src/errors.ts @@ -0,0 +1,22 @@ +import type { components } from "./generated/nexus-api-v3"; + +type ProblemDetails = components["schemas"]["ProblemDetails"]; +type ValidationProblemItem = components["schemas"]["ValidationProblemItem"]; + +export class V3ApiError extends Error { + public readonly status: number; + public readonly problemType: string; + public readonly detail: string; + public readonly instance: string; + public readonly validationErrors?: ValidationProblemItem[]; + + constructor(problem: ProblemDetails & { errors?: ValidationProblemItem[] }) { + super(problem.title); + this.name = "V3ApiError"; + this.status = problem.status; + this.problemType = problem.type; + this.detail = problem.detail; + this.instance = problem.instance; + this.validationErrors = problem.errors; + } +} diff --git a/packages/nexus-api-v3/src/index.ts b/packages/nexus-api-v3/src/index.ts new file mode 100644 index 0000000000..1202ea0ae6 --- /dev/null +++ b/packages/nexus-api-v3/src/index.ts @@ -0,0 +1,7 @@ +export type { paths, components, operations } from "./generated/nexus-api-v3"; +export { + createNexusV3Client, + type NexusV3Client, + type NexusV3ClientOptions, +} from "./client"; +export { V3ApiError } from "./errors"; diff --git a/packages/nexus-api-v3/tsconfig.json b/packages/nexus-api-v3/tsconfig.json new file mode 100644 index 0000000000..92150e6908 --- /dev/null +++ b/packages/nexus-api-v3/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://www.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"], + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "allowJs": false, + "composite": true, + "noEmit": true, + "types": ["node"], + "lib": ["ESNext"] + } +} diff --git a/packages/nexus-api-v3/tsdown.config.ts b/packages/nexus-api-v3/tsdown.config.ts new file mode 100644 index 0000000000..be11a350bb --- /dev/null +++ b/packages/nexus-api-v3/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "./src/index.ts", + }, + format: ["esm", "commonjs"], + dts: { + sourcemap: true, + }, + exports: true, + platform: "neutral", +}); diff --git a/packages/nexus-api-v3/vitest.config.ts b/packages/nexus-api-v3/vitest.config.ts new file mode 100644 index 0000000000..c1433e6ef3 --- /dev/null +++ b/packages/nexus-api-v3/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5126cd7eab..badcec8b3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -493,6 +493,12 @@ catalogs: jsonwebtoken: specifier: ^9.0.0 version: 9.0.3 + leveldown: + specifier: 5.6.0 + version: 5.6.0 + levelup: + specifier: 4.4.0 + version: 4.4.0 limiter: specifier: ^3.0.0 version: 3.0.0 @@ -538,6 +544,12 @@ catalogs: numeral: specifier: ^2.0.6 version: 2.0.6 + openapi-fetch: + specifier: ^0.14.0 + version: 0.14.1 + openapi-typescript: + specifier: ^7.8.0 + version: 7.13.0 oxfmt: specifier: ^0.41.0 version: 0.41.0 @@ -979,6 +991,9 @@ importers: '@types/redux': specifier: 'catalog:' version: 3.6.0 + '@vortex/nexus-api-v3': + specifier: workspace:* + version: link:../../packages/nexus-api-v3 bluebird: specifier: 'catalog:' version: 3.7.2 @@ -3793,6 +3808,25 @@ importers: specifier: 'catalog:' version: 1.58.2 + packages/nexus-api-v3: + dependencies: + openapi-fetch: + specifier: 'catalog:' + version: 0.14.1 + devDependencies: + openapi-typescript: + specifier: 'catalog:' + version: 7.13.0(typescript@5.9.3) + tsdown: + specifier: 'catalog:' + version: 0.21.0-beta.1(synckit@0.11.12)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(jsdom@20.0.3)(vite@8.0.3) + packages/paths: dependencies: zod: @@ -4607,6 +4641,9 @@ importers: '@vitejs/plugin-react': specifier: 'catalog:' version: 6.0.1(vite@8.0.3) + '@vortex/nexus-api-v3': + specifier: workspace:* + version: link:../../packages/nexus-api-v3 '@vortex/shared': specifier: workspace:* version: link:../shared @@ -6205,6 +6242,16 @@ packages: '@react-dnd/shallowequal@2.0.0': resolution: {integrity: sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==} + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.11': + resolution: {integrity: sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/binding-android-arm64@1.0.0-rc.11': resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7682,6 +7729,10 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@2.1.1: resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} engines: {node: '>=0.10.0'} @@ -8084,6 +8135,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities-legacy@1.1.4: resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} @@ -8175,6 +8229,9 @@ packages: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -9682,6 +9739,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + infer-owner@1.0.4: resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} @@ -9972,6 +10033,10 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -10786,6 +10851,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -10878,6 +10955,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -10983,6 +11064,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + popper.js@1.16.1: resolution: {integrity: sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==} deprecated: You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1 @@ -11957,6 +12042,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -12398,6 +12487,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -12755,6 +12847,9 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -12821,7 +12916,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12882,7 +12977,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) lodash.debounce: 4.0.8 resolve: 1.22.11 transitivePeerDependencies: @@ -13551,7 +13646,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -13613,7 +13708,7 @@ snapshots: '@electron/get@2.0.3': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -13627,7 +13722,7 @@ snapshots: '@electron/notarize@2.2.1': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 promise-retry: 2.0.1 transitivePeerDependencies: @@ -13636,7 +13731,7 @@ snapshots: '@electron/osx-sign@1.0.5': dependencies: compare-version: 0.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 @@ -13652,7 +13747,7 @@ snapshots: dependencies: '@electron/asar': 3.4.1 '@malept/cross-spawn-promise': 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dir-compare: 3.3.0 fs-extra: 9.1.0 minimatch: 3.1.5 @@ -13761,7 +13856,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -13782,7 +13877,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -13874,7 +13969,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -13886,7 +13981,7 @@ snapshots: '@malept/flatpak-bundler@0.4.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 lodash: 4.17.23 tmp-promise: 3.0.3 @@ -14042,7 +14137,7 @@ snapshots: dependencies: agent-base: 7.1.4 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) lru-cache: 10.4.3 socks-proxy-agent: 8.0.5 transitivePeerDependencies: @@ -14300,6 +14395,29 @@ snapshots: '@react-dnd/shallowequal@2.0.0': {} + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.11(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/binding-android-arm64@1.0.0-rc.11': optional: true @@ -15242,7 +15360,7 @@ snapshots: '@typescript-eslint/types': 8.47.0 '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.47.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -15252,7 +15370,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) '@typescript-eslint/types': 8.47.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -15261,7 +15379,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) '@typescript-eslint/types': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -15289,7 +15407,7 @@ snapshots: '@typescript-eslint/types': 8.47.0 '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) '@typescript-eslint/utils': 8.47.0(eslint@9.39.1)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -15301,7 +15419,7 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/utils': 8.57.2(eslint@9.39.1)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -15318,7 +15436,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) '@typescript-eslint/types': 8.47.0 '@typescript-eslint/visitor-keys': 8.47.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.9 @@ -15334,7 +15452,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) '@typescript-eslint/types': 8.57.2 '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -15676,7 +15794,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -15739,6 +15857,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-colors@4.1.3: {} + ansi-regex@2.1.1: {} ansi-regex@5.0.1: {} @@ -15774,7 +15894,7 @@ snapshots: builder-util: 24.13.1 builder-util-runtime: 9.2.4 chromium-pickle-js: 0.2.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3) ejs: 3.1.10 electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3) @@ -16148,14 +16268,14 @@ snapshots: builder-util-runtime@8.9.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) sax: 1.6.0 transitivePeerDependencies: - supports-color builder-util-runtime@9.2.4: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) sax: 1.6.0 transitivePeerDependencies: - supports-color @@ -16169,7 +16289,7 @@ snapshots: builder-util-runtime: 9.2.4 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 @@ -16285,6 +16405,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + character-entities-legacy@1.1.4: {} character-entities@1.2.4: {} @@ -16379,6 +16501,8 @@ snapshots: color-support@1.1.3: {} + colorette@1.4.0: {} + colorette@2.0.20: {} colors@1.0.3: {} @@ -16755,9 +16879,11 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js-light@2.5.1: {} @@ -17349,7 +17475,7 @@ snapshots: eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.1(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) get-tsconfig: 4.13.7 @@ -17553,7 +17679,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -17693,7 +17819,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -18254,14 +18380,14 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -18284,14 +18410,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -18355,6 +18481,8 @@ snapshots: indent-string@4.0.0: {} + index-to-position@1.2.0: {} + infer-owner@1.0.4: {} inflight@1.0.6: @@ -18625,6 +18753,8 @@ snapshots: jju@1.4.0: {} + js-levenshtein@1.1.6: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -19093,7 +19223,7 @@ snapshots: micromark@2.11.4: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -19460,6 +19590,22 @@ snapshots: dependencies: wrappy: 1.0.2 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.11(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -19579,6 +19725,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -19664,6 +19816,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pluralize@8.0.0: {} + popper.js@1.16.1: {} possible-typed-array-names@1.1.0: {} @@ -20735,7 +20889,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -20762,7 +20916,7 @@ snapshots: socks-proxy-agent@7.0.0: dependencies: agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) socks: 2.8.7 transitivePeerDependencies: - supports-color @@ -20770,7 +20924,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) socks: 2.8.7 transitivePeerDependencies: - supports-color @@ -20940,10 +21094,12 @@ snapshots: sumchecker@3.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color + supports-color@10.2.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -21365,7 +21521,7 @@ snapshots: universal-analytics@0.4.23: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) request: 2.88.2 uuid: 3.4.0 transitivePeerDependencies: @@ -21429,6 +21585,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -21537,6 +21695,37 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(@vitest/ui@4.1.0)(happy-dom@20.8.8)(jsdom@20.0.3)(vite@8.0.3): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@8.0.3) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.3(@types/node@25.5.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.5.0 + '@vitest/ui': 4.1.0(vitest@4.1.2) + happy-dom: 20.8.8 + jsdom: 20.0.3 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vortex-parse-ini@https://codeload.github.com/Nexus-Mods/vortex-parse-ini/tar.gz/2425af99d1cff2331ccf3aacfa892c314e99e18d: @@ -21807,6 +21996,8 @@ snapshots: yallist@4.0.0: {} + yaml-ast-parser@0.0.43: {} + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cdc769002b..9918de45a5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -288,6 +288,8 @@ catalog: xml2js: ^0.5.0 xxhash-addon: ^2.0.3 zod: ^4.3.5 + openapi-fetch: ^0.14.0 + openapi-typescript: ^7.8.0 catalogMode: strict cleanupUnusedCatalogs: true diff --git a/src/renderer/package.json b/src/renderer/package.json index a0264ae635..66ee4f8a4f 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -25,6 +25,7 @@ "@microsoft/api-extractor": "catalog:", "@msgpack/msgpack": "catalog:", "@nexusmods/nexus-api": "catalog:", + "@vortex/nexus-api-v3": "workspace:*", "@opentelemetry/api": "catalog:", "@opentelemetry/context-async-hooks": "catalog:", "@opentelemetry/resources": "catalog:", diff --git a/src/renderer/src/extensions/nexus_integration/constants.ts b/src/renderer/src/extensions/nexus_integration/constants.ts index 0602d9012c..c5137f328a 100644 --- a/src/renderer/src/extensions/nexus_integration/constants.ts +++ b/src/renderer/src/extensions/nexus_integration/constants.ts @@ -11,6 +11,11 @@ export const NEXUS_BASE_URL = `https://${NEXUS_FLAMEWORK_SUBDOMAIN}.${NEXUS_DOMAIN}`; export const NEXUS_GAMES_URL = process.env["NEXUS_GAMES_URL"] || `https://${NEXUS_DOMAIN}/games`; +export const NEXUS_V3_BASE_URL = + process.env["NEXUS_V3_BASE_URL"] || + `https://${NEXUS_API_SUBDOMAIN}.${NEXUS_DOMAIN}/v3`; +export const MULTIPART_THRESHOLD = 100 * 1024 * 1024; // 100 MiB + export const NEXUS_PROTOCOL = "https:"; export const PREMIUM_PATH = ["account", "billing", "premium"]; export const FALLBACK_AVATAR = "assets/images/noavatar.png"; diff --git a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts index b146efbeb4..cecbc0db50 100644 --- a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts +++ b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts @@ -14,6 +14,7 @@ import * as fs from "../../util/fs"; import { log } from "../../util/log"; import { calcDuration, showError } from "../../util/message"; import { upload } from "../../util/network"; +import { submitCollectionV3 } from "./util_v3/submitCollectionV3"; import opn from "../../util/opn"; import { activeGameId, @@ -1335,58 +1336,21 @@ export function onSubmitFeedback(nexus: Nexus) { }; } -function sendCollection( - nexus: Nexus, - collectionInfo: ICollectionManifest, - collectionId: number, - uuid: string, -) { - if (collectionId === undefined) { - return nexus.createCollection( - { - adultContent: false, - collectionManifest: collectionInfo, - collectionSchemaId: 1, - }, - uuid, - ); - } else { - return nexus - .editCollection(collectionId as any, collectionInfo.info.name) - .then(() => - nexus.createOrUpdateRevision( - { - adultContent: false, - collectionManifest: collectionInfo, - collectionSchemaId: 1, - }, - uuid, - collectionId, - ), - ); - } -} - -export function onSubmitCollection(nexus: Nexus) { +export function onSubmitCollection(api: IExtensionApi, nexus: Nexus) { return ( collectionInfo: ICollectionManifest, assetFilePath: string, collectionId: number, callback: (err: Error, response?: any) => void, ) => { - nexus - .getRevisionUploadUrl() - .then(({ url, uuid }) => { - return fs - .statAsync(assetFilePath) - .then((stat) => - upload(url, fs.createReadStream(assetFilePath), stat.size), - ) - .then(() => uuid); - }) - .then((uuid: string) => - sendCollection(nexus, collectionInfo, collectionId, uuid), - ) + const state = api.getState(); + submitCollectionV3( + state, + nexus, + collectionInfo, + assetFilePath, + collectionId || undefined, + ) .then((response) => callback(null, response)) .catch((err) => callback(unknownToError(err))); }; diff --git a/src/renderer/src/extensions/nexus_integration/index.tsx b/src/renderer/src/extensions/nexus_integration/index.tsx index 514cff6b31..3b6c21e3b6 100644 --- a/src/renderer/src/extensions/nexus_integration/index.tsx +++ b/src/renderer/src/extensions/nexus_integration/index.tsx @@ -1197,7 +1197,7 @@ function extendAPI(api: IExtensionApi, nexus: NexusT): INexusAPIExtension { nexusGetTrendingMods: eh.onGetTrendingMods(api, nexus), nexusEndorseMod: eh.onEndorseMod(api, nexus), nexusSubmitFeedback: eh.onSubmitFeedback(nexus), - nexusSubmitCollection: eh.onSubmitCollection(nexus), + nexusSubmitCollection: eh.onSubmitCollection(api, nexus), nexusModUpdate: eh.onModUpdate(api, nexus), nexusOpenCollectionPage: eh.onOpenCollectionPage(api), nexusOpenModPage: eh.onOpenModPage(api), @@ -1376,7 +1376,7 @@ function once(api: IExtensionApi, callbacks: Array<(nexus: NexusT) => void>) { api.events.on("refresh-user-info", eh.onRefreshUserInfo(nexus, api)); api.events.on("endorse-mod", eh.onEndorseMod(api, nexus)); api.events.on("submit-feedback", eh.onSubmitFeedback(nexus)); - api.events.on("submit-collection", eh.onSubmitCollection(nexus)); + api.events.on("submit-collection", eh.onSubmitCollection(api, nexus)); api.events.on("mods-update", eh.onModsUpdate(api, nexus)); api.events.on("mod-update", eh.onModUpdate(api, nexus)); api.events.on("open-collection-page", eh.onOpenCollectionPage(api)); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts new file mode 100644 index 0000000000..f142db30f6 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts @@ -0,0 +1,208 @@ +import type { ICollectionManifest } from "@nexusmods/nexus-api"; + +import { describe, it, expect } from "vitest"; + +import { toV3CollectionPayload } from "./manifestMapping"; + +const makeManifest = ( + overrides: Partial = {}, +): ICollectionManifest => ({ + info: overrides.info ?? { + author: "TestAuthor", + authorUrl: "https://nexusmods.com/users/123", + name: "Test Collection", + description: "A test collection", + summary: "Test summary", + domainName: "skyrimspecialedition", + gameVersions: ["1.6.640"], + }, + mods: overrides.mods ?? [ + { + name: "Test Mod", + version: "1.0.0", + optional: false, + domainName: "skyrimspecialedition", + source: { + type: "nexus", + modId: 12345, + fileId: 67890, + md5: "abc123", + fileSize: 1024, + updatePolicy: "exact", + logicalFilename: "testmod", + fileExpression: "testmod-{version}.zip", + }, + author: "ModAuthor", + }, + ], +}); + +describe("toV3CollectionPayload", () => { + it("converts info fields from camelCase to snake_case", () => { + const manifest = makeManifest(); + const payload = toV3CollectionPayload(manifest); + + expect(payload.collection_manifest.info).toEqual({ + author: "TestAuthor", + author_url: "https://nexusmods.com/users/123", + name: "Test Collection", + description: "A test collection", + summary: "Test summary", + domain_name: "skyrimspecialedition", + game_versions: ["1.6.640"], + }); + }); + + it("converts mod fields from camelCase to snake_case", () => { + const manifest = makeManifest(); + const payload = toV3CollectionPayload(manifest); + + expect(payload.collection_manifest.mods).toHaveLength(1); + expect(payload.collection_manifest.mods[0]).toEqual({ + name: "Test Mod", + version: "1.0.0", + optional: false, + domain_name: "skyrimspecialedition", + source: { + type: "nexus", + mod_id: "12345", + file_id: "67890", + md5: "abc123", + file_size: 1024, + update_policy: "exact", + logical_filename: "testmod", + file_expression: "testmod-{version}.zip", + url: null, + adult_content: null, + }, + author: "ModAuthor", + }); + }); + + it("converts mod_id and file_id to strings", () => { + const manifest = makeManifest(); + const payload = toV3CollectionPayload(manifest); + const source = payload.collection_manifest.mods[0].source; + + expect(typeof source.mod_id).toBe("string"); + expect(typeof source.file_id).toBe("string"); + }); + + it("defaults adult_content to false", () => { + const manifest = makeManifest(); + expect(toV3CollectionPayload(manifest).adult_content).toBe(false); + }); + + it("passes through adult_content when specified", () => { + const manifest = makeManifest(); + expect(toV3CollectionPayload(manifest, true).adult_content).toBe(true); + expect(toV3CollectionPayload(manifest, false).adult_content).toBe(false); + }); + + it("sets collection_schema_id to 1", () => { + const manifest = makeManifest(); + const payload = toV3CollectionPayload(manifest); + + expect(payload.collection_schema_id).toBe(1); + }); + + it("handles missing optional fields with null", () => { + const manifest = makeManifest({ + info: { + author: "Author", + name: "Name", + domainName: "skyrim", + }, + mods: [ + { + name: "Mod", + version: "1.0", + optional: true, + domainName: "skyrim", + source: { + type: "direct", + url: "https://example.com/mod.zip", + }, + }, + ], + }); + + const payload = toV3CollectionPayload(manifest); + const info = payload.collection_manifest.info; + + expect(info.author_url).toBeNull(); + expect(info.description).toBeNull(); + expect(info.summary).toBeNull(); + expect(info.game_versions).toBeNull(); + + const source = payload.collection_manifest.mods[0].source; + expect(source.mod_id).toBeUndefined(); + expect(source.file_id).toBeUndefined(); + expect(source.md5).toBeNull(); + expect(source.file_size).toBeNull(); + }); + + it("handles all source types", () => { + const sourceTypes = ["nexus", "direct", "browse", "manual"] as const; + + for (const type of sourceTypes) { + const manifest = makeManifest({ + mods: [ + { + name: "Mod", + version: "1.0", + optional: false, + domainName: "skyrim", + source: { type }, + }, + ], + }); + const payload = toV3CollectionPayload(manifest); + expect(payload.collection_manifest.mods[0].source.type).toBe(type); + } + }); + + it("handles multiple mods", () => { + const manifest = makeManifest({ + mods: [ + { + name: "Mod A", + version: "1.0", + optional: false, + domainName: "skyrim", + source: { type: "nexus", modId: 1, fileId: 2 }, + }, + { + name: "Mod B", + version: "2.0", + optional: true, + domainName: "fallout4", + source: { type: "direct", url: "https://example.com" }, + }, + ], + }); + + const payload = toV3CollectionPayload(manifest); + expect(payload.collection_manifest.mods).toHaveLength(2); + expect(payload.collection_manifest.mods[0].name).toBe("Mod A"); + expect(payload.collection_manifest.mods[1].name).toBe("Mod B"); + expect(payload.collection_manifest.mods[1].domain_name).toBe("fallout4"); + }); + + it("handles mod with no author", () => { + const manifest = makeManifest({ + mods: [ + { + name: "Mod", + version: "1.0", + optional: false, + domainName: "skyrim", + source: { type: "nexus" }, + }, + ], + }); + + const payload = toV3CollectionPayload(manifest); + expect(payload.collection_manifest.mods[0].author).toBeNull(); + }); +}); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts new file mode 100644 index 0000000000..bbec796a33 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts @@ -0,0 +1,65 @@ +import type { ICollectionManifest } from "@nexusmods/nexus-api"; +import type { components } from "@vortex/nexus-api-v3"; + +type V3CollectionPayload = components["schemas"]["CollectionPayload"]; +type V3CollectionManifest = components["schemas"]["CollectionManifest"]; +type V3CollectionManifestMod = components["schemas"]["CollectionManifestMod"]; +type V3CollectionManifestModSource = + components["schemas"]["CollectionManifestModSource"]; + +function toV3ManifestModSource( + source: ICollectionManifest["mods"][number]["source"], +): V3CollectionManifestModSource { + return { + type: source.type satisfies V3CollectionManifestModSource["type"], + mod_id: source.modId?.toString(), + file_id: source.fileId?.toString(), + md5: source.md5 ?? null, + file_size: source.fileSize ?? null, + update_policy: + source.updatePolicy satisfies V3CollectionManifestModSource["update_policy"], + logical_filename: source.logicalFilename ?? null, + file_expression: source.fileExpression ?? null, + url: source.url ?? null, + adult_content: source.adultContent ?? null, + }; +} + +function toV3ManifestMod( + mod: ICollectionManifest["mods"][number], +): V3CollectionManifestMod { + return { + name: mod.name, + version: mod.version, + optional: mod.optional, + domain_name: mod.domainName, + source: toV3ManifestModSource(mod.source), + author: mod.author ?? null, + }; +} + +function toV3Manifest(manifest: ICollectionManifest): V3CollectionManifest { + return { + info: { + author: manifest.info.author, + author_url: manifest.info.authorUrl ?? null, + name: manifest.info.name, + description: manifest.info.description ?? null, + summary: manifest.info.summary ?? null, + domain_name: manifest.info.domainName, + game_versions: manifest.info.gameVersions ?? null, + }, + mods: manifest.mods.map(toV3ManifestMod), + }; +} + +export function toV3CollectionPayload( + manifest: ICollectionManifest, + adultContent: boolean = false, +): V3CollectionPayload { + return { + adult_content: adultContent, + collection_manifest: toV3Manifest(manifest), + collection_schema_id: 1, + }; +} diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts new file mode 100644 index 0000000000..20be7e9b3d --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts @@ -0,0 +1,384 @@ +import type Nexus from "@nexusmods/nexus-api"; +import type { + ICollection, + ICollectionManifest, +} from "@nexusmods/nexus-api"; +import type { NexusV3Client } from "@vortex/nexus-api-v3"; + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../util/log", () => ({ + log: vi.fn(), +})); + +vi.mock("@vortex/nexus-api-v3", () => ({ + createNexusV3Client: vi.fn(), +})); + +vi.mock("fs-extra", () => ({ + default: { stat: vi.fn() }, + stat: vi.fn(), +})); + +vi.mock("./uploadV3", () => ({ + pollUploadAvailable: vi.fn().mockResolvedValue(undefined), + uploadSinglePart: vi.fn().mockResolvedValue(undefined), + uploadMultipart: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./manifestMapping", () => ({ + toV3CollectionPayload: vi.fn(() => ({ + adult_content: false, + collection_manifest: {}, + collection_schema_id: 1, + })), +})); + +vi.mock("../guards", () => ({ + hasConfidentialWithNexus: vi.fn(() => true), +})); + +vi.mock("../selectors", () => ({ + apiKey: vi.fn(() => "test-api-key"), +})); + +import { createNexusV3Client } from "@vortex/nexus-api-v3"; +import { stat } from "fs-extra"; + +import type { IState } from "../../../types/IState"; + +import { hasConfidentialWithNexus } from "../guards"; +import { submitCollectionV3 } from "./submitCollectionV3"; +import { uploadMultipart, uploadSinglePart } from "./uploadV3"; + +const mockCreateClient = vi.mocked(createNexusV3Client); +const mockStat = stat as unknown as ReturnType; + +const SMALL_FILE_SIZE = 1024; // < 100 MiB +const LARGE_FILE_SIZE = 200 * 1024 * 1024; // > 100 MiB + +function makeState(): IState { + return { + confidential: { + account: { + nexus: { + APIKey: "test-key", + OAuthCredentials: { token: "test-token" }, + }, + }, + }, + } as unknown as IState; +} + +function makeManifest(): ICollectionManifest { + return { + info: { + author: "Author", + name: "Test", + domainName: "skyrim", + }, + mods: [], + }; +} + +interface MockCollection { + id: number; + slug: string; + currentRevision?: { id: number; revisionNumber: number; status: string }; +} + +function makeGetMyCollections(collections: MockCollection[]) { + return vi + .fn() + .mockResolvedValue( + collections as unknown as Array>, + ); +} + +function makeNexus(collections: MockCollection[] = []): Nexus { + return { + getMyCollections: makeGetMyCollections(collections), + } as unknown as Nexus; +} + +function makeMockClient(): Pick< + NexusV3Client, + | "createUpload" + | "createMultipartUpload" + | "finaliseUpload" + | "getUpload" + | "createCollection" + | "createCollectionRevision" +> { + return { + createUpload: vi + .fn() + .mockResolvedValue({ + id: "upload-123", + presigned_url: "https://s3.example.com/upload", + user_id: "1", + state: "created", + }), + createMultipartUpload: vi + .fn() + .mockResolvedValue({ + id: "upload-456", + parts_size: 100 * 1024 * 1024, + parts_presigned_url: [ + "https://s3.example.com/part1", + "https://s3.example.com/part2", + ], + complete_presigned_url: "https://s3.example.com/complete", + user_id: "1", + state: "created", + }), + finaliseUpload: vi + .fn() + .mockResolvedValue({ id: "upload-123", user_id: "1", state: "created" }), + getUpload: vi + .fn() + .mockResolvedValue({ + id: "upload-123", + user_id: "1", + state: "available", + }), + createCollection: vi + .fn() + .mockResolvedValue({ id: "999", revision_id: "rev-1" }), + createCollectionRevision: vi + .fn() + .mockResolvedValue({ id: "rev-2", collection_id: "888" }), + }; +} + +describe("submitCollectionV3", () => { + let mockClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = makeMockClient(); + mockCreateClient.mockReturnValue(mockClient as NexusV3Client); + mockStat.mockResolvedValue({ size: SMALL_FILE_SIZE }); + }); + + describe("upload path selection", () => { + it("uses single-part upload for small files", async () => { + mockStat.mockResolvedValue({ size: SMALL_FILE_SIZE }); + const nexus = makeNexus([ + { + id: 999, + slug: "test", + currentRevision: { id: 1, revisionNumber: 0, status: "draft" }, + }, + ]); + + await submitCollectionV3( + makeState(), + nexus, + makeManifest(), + "/tmp/small.zip", + undefined, + ); + + expect(mockClient.createUpload).toHaveBeenCalledWith( + SMALL_FILE_SIZE, + "small.zip", + ); + expect(vi.mocked(uploadSinglePart)).toHaveBeenCalled(); + expect(mockClient.createMultipartUpload).not.toHaveBeenCalled(); + expect(vi.mocked(uploadMultipart)).not.toHaveBeenCalled(); + }); + + it("uses multipart upload for large files", async () => { + mockStat.mockResolvedValue({ size: LARGE_FILE_SIZE }); + const nexus = makeNexus([ + { + id: 999, + slug: "test", + currentRevision: { id: 1, revisionNumber: 0, status: "draft" }, + }, + ]); + + await submitCollectionV3( + makeState(), + nexus, + makeManifest(), + "/tmp/large.zip", + undefined, + ); + + expect(mockClient.createMultipartUpload).toHaveBeenCalledWith( + LARGE_FILE_SIZE, + "large.zip", + ); + expect(vi.mocked(uploadMultipart)).toHaveBeenCalled(); + expect(mockClient.createUpload).not.toHaveBeenCalled(); + expect(vi.mocked(uploadSinglePart)).not.toHaveBeenCalled(); + }); + }); + + describe("collection creation", () => { + it("creates a new collection when collectionId is undefined", async () => { + const nexus = makeNexus([ + { + id: 999, + slug: "test-slug", + currentRevision: { id: 1, revisionNumber: 0, status: "draft" }, + }, + ]); + + const result = await submitCollectionV3( + makeState(), + nexus, + makeManifest(), + "/tmp/file.zip", + undefined, + ); + + expect(mockClient.createCollection).toHaveBeenCalledWith( + "upload-123", + expect.any(Object), + ); + expect(mockClient.createCollectionRevision).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.collection?.id).toBe(999); + expect(result.collection?.slug).toBe("test-slug"); + }); + + it("creates a revision when collectionId is provided", async () => { + const nexus = makeNexus([ + { + id: 888, + slug: "existing-slug", + currentRevision: { + id: 2, + revisionNumber: 1, + status: "under_moderation", + }, + }, + ]); + + const result = await submitCollectionV3( + makeState(), + nexus, + makeManifest(), + "/tmp/file.zip", + 888, + ); + + expect(mockClient.createCollectionRevision).toHaveBeenCalledWith( + "888", + "upload-123", + expect.any(Object), + ); + expect(mockClient.createCollection).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.collection?.id).toBe(888); + expect(result.collection?.slug).toBe("existing-slug"); + }); + }); + + describe("fetchCollectionDetails", () => { + it("returns slug and revision details from GraphQL", async () => { + const collections = [ + { + id: 999, + slug: "my-collection", + currentRevision: { + id: 42, + revisionNumber: 3, + status: "published", + }, + }, + ]; + const getMyCollections = makeGetMyCollections(collections); + const nexus = { getMyCollections } as unknown as Nexus; + + const result = await submitCollectionV3( + makeState(), + nexus, + makeManifest(), + "/tmp/file.zip", + undefined, + ); + + expect(getMyCollections).toHaveBeenCalled(); + expect(result.collection?.slug).toBe("my-collection"); + expect(result.revision?.id).toBe(42); + expect(result.revision?.revisionNumber).toBe(3); + expect(result.revision?.revisionStatus).toBe("published"); + }); + + it("returns partial result when collection not found in GraphQL", async () => { + const nexus = makeNexus([]); // empty — collection not found + + const result = await submitCollectionV3( + makeState(), + nexus, + makeManifest(), + "/tmp/file.zip", + undefined, + ); + + expect(result.success).toBe(true); + expect(result.collection?.id).toBe(999); + expect(result.collection?.slug).toBeUndefined(); + expect(result.revision).toBeUndefined(); + }); + + it("handles collection with no currentRevision", async () => { + const nexus = makeNexus([ + { id: 999, slug: "test", currentRevision: undefined }, + ]); + + const result = await submitCollectionV3( + makeState(), + nexus, + makeManifest(), + "/tmp/file.zip", + undefined, + ); + + expect(result.collection?.slug).toBe("test"); + expect(result.revision).toBeUndefined(); + }); + }); + + describe("upload lifecycle", () => { + it("calls finalise and poll after upload", async () => { + const nexus = makeNexus([ + { + id: 999, + slug: "test", + currentRevision: { id: 1, revisionNumber: 0, status: "draft" }, + }, + ]); + + await submitCollectionV3( + makeState(), + nexus, + makeManifest(), + "/tmp/file.zip", + undefined, + ); + + expect(mockClient.finaliseUpload).toHaveBeenCalledWith("upload-123"); + }); + }); + + describe("auth", () => { + it("throws when not logged in", async () => { + vi.mocked(hasConfidentialWithNexus).mockReturnValueOnce(false); + + await expect( + submitCollectionV3( + makeState(), + makeNexus(), + makeManifest(), + "/tmp/file.zip", + undefined, + ), + ).rejects.toThrow("Not logged in"); + }); + }); +}); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts new file mode 100644 index 0000000000..15ec8684f2 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts @@ -0,0 +1,152 @@ +import type Nexus from "@nexusmods/nexus-api"; +import type { + ICollectionManifest, + ICreateCollectionResult, +} from "@nexusmods/nexus-api"; + +import { createNexusV3Client } from "@vortex/nexus-api-v3"; +import * as fs from "fs-extra"; + +import type { IState } from "../../../types/IState"; + +import { log } from "../../../util/log"; +import { MULTIPART_THRESHOLD, NEXUS_V3_BASE_URL } from "../constants"; +import { hasConfidentialWithNexus } from "../guards"; +import { apiKey as apiKeySelector } from "../selectors"; +import { toV3CollectionPayload } from "./manifestMapping"; +import { + pollUploadAvailable, + uploadMultipart, + uploadSinglePart, +} from "./uploadV3"; + +function createClientFromState(state: IState) { + if (!hasConfidentialWithNexus(state.confidential)) { + throw new Error("Not logged in to Nexus Mods"); + } + + const nexusAccount = state.confidential.account.nexus; + const apiKey = apiKeySelector(state); + const oauthCredentials = nexusAccount.OAuthCredentials as + | { token?: string } + | undefined; + const oauthToken = oauthCredentials?.token; + + return createNexusV3Client({ + baseUrl: NEXUS_V3_BASE_URL, + apiKey, + bearerToken: oauthToken, + }); +} + +async function fetchCollectionDetails( + nexus: Nexus, + collectionId: number, +): Promise { + log("debug", "calling getMyCollections", { collectionId }); + const collections = await nexus.getMyCollections( + { + id: true, + slug: true, + currentRevision: { + id: true, + revisionNumber: true, + status: true, + }, + }, + undefined, + 100, + 0, + ); + log("debug", "getMyCollections returned", { + count: collections.length, + ids: collections.map((c) => c.id), + }); + + const collection = collections.find((c) => c.id === collectionId); + if (collection) { + return { + collection: { id: collection.id, slug: collection.slug }, + revision: collection.currentRevision + ? { + id: collection.currentRevision.id, + revisionNumber: collection.currentRevision.revisionNumber, + revisionStatus: collection.currentRevision.status, + } + : undefined, + success: true, + }; + } + + log("warn", "collection not found in getMyCollections", { collectionId }); + return { + collection: { id: collectionId }, + revision: undefined, + success: true, + }; +} + +export async function submitCollectionV3( + state: IState, + nexus: Nexus, + collectionInfo: ICollectionManifest, + assetFilePath: string, + collectionId: number | undefined, +): Promise { + const client = createClientFromState(state); + const stat = await fs.stat(assetFilePath); + const fileSize = stat.size; + const filename = assetFilePath.split(/[\\/]/).pop() ?? "collection.zip"; + + log("info", "submitting collection via V3 API", { + fileSize, + isMultipart: fileSize > MULTIPART_THRESHOLD, + isNewCollection: collectionId === undefined, + }); + + // Step 1: Create upload session + let uploadId: string; + + if (fileSize <= MULTIPART_THRESHOLD) { + const upload = await client.createUpload(fileSize, filename); + uploadId = upload.id; + await uploadSinglePart(upload.presigned_url, assetFilePath, fileSize); + } else { + const multipart = await client.createMultipartUpload(fileSize, filename); + uploadId = multipart.id; + await uploadMultipart(multipart, assetFilePath, fileSize); + } + + // Step 2: Finalise upload + log("debug", "finalising upload", { uploadId }); + await client.finaliseUpload(uploadId); + + // Step 3: Poll until available + log("debug", "polling upload availability", { uploadId }); + await pollUploadAvailable(client, uploadId); + log("debug", "upload available", { uploadId }); + + // Step 4: Create collection or revision + const payload = toV3CollectionPayload(collectionInfo); + + let createdCollectionId: number; + + if (collectionId === undefined) { + log("debug", "creating new collection", { uploadId }); + const result = await client.createCollection(uploadId, payload); + createdCollectionId = Number(result.id); + log("info", "collection created", { collectionId: createdCollectionId }); + } else { + log("debug", "creating new revision", { uploadId, collectionId }); + await client.createCollectionRevision( + String(collectionId), + uploadId, + payload, + ); + createdCollectionId = collectionId; + log("info", "revision created", { collectionId: createdCollectionId }); + } + + // Step 5: Fetch full details via GraphQL (slug, revisionNumber, etc.) + return fetchCollectionDetails(nexus, createdCollectionId); +} diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts new file mode 100644 index 0000000000..b642c008b4 --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts @@ -0,0 +1,204 @@ +import { Readable } from "stream"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../util/log", () => ({ + log: vi.fn(), +})); + +vi.mock("../../../util/network", () => ({ + uploadWithHeaders: vi.fn(), +})); + +vi.mock("fs", () => ({ + default: { createReadStream: vi.fn(() => Readable.from(Buffer.alloc(0))) }, + createReadStream: vi.fn(() => Readable.from(Buffer.alloc(0))), +})); + +import type { NexusV3Client } from "@vortex/nexus-api-v3"; + +import { uploadWithHeaders } from "../../../util/network"; +import { + uploadMultipart, + uploadSinglePart, + pollUploadAvailable, +} from "./uploadV3"; + +const mockUploadWithHeaders = vi.mocked(uploadWithHeaders); + +function makeClient(overrides: Partial = {}): NexusV3Client { + return { + getUpload: vi.fn(), + ...overrides, + } as unknown as NexusV3Client; +} + +describe("pollUploadAvailable", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + it("resolves immediately when state is available", async () => { + const getUpload = vi.fn().mockResolvedValue({ state: "available" }); + const client = makeClient({ getUpload }); + + await pollUploadAvailable(client, "upload-123"); + + expect(getUpload).toHaveBeenCalledOnce(); + expect(getUpload).toHaveBeenCalledWith("upload-123"); + }); + + it("polls until state becomes available", async () => { + const getUpload = vi + .fn() + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "available" }); + + const client = makeClient({ getUpload }); + + const promise = pollUploadAvailable(client, "upload-123"); + + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(2000); + + await promise; + + expect(getUpload).toHaveBeenCalledTimes(3); + }); +}); + +describe("uploadSinglePart", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls uploadWithHeaders with correct URL and size", async () => { + mockUploadWithHeaders.mockResolvedValue({ + body: Buffer.alloc(0), + headers: {}, + statusCode: 200, + }); + + await uploadSinglePart( + "https://s3.example.com/upload", + "/tmp/file.zip", + 1024, + ); + + expect(mockUploadWithHeaders).toHaveBeenCalledOnce(); + const [url, , size] = mockUploadWithHeaders.mock.calls[0]; + expect(url).toBe("https://s3.example.com/upload"); + expect(size).toBe(1024); + }); +}); + +describe("uploadMultipart", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it("uploads all parts and completes with correct XML", async () => { + mockUploadWithHeaders + .mockResolvedValueOnce({ + body: Buffer.alloc(0), + headers: { etag: '"etag-part-1"' }, + statusCode: 200, + }) + .mockResolvedValueOnce({ + body: Buffer.alloc(0), + headers: { etag: '"etag-part-2"' }, + statusCode: 200, + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(""), + } as unknown as Response); + + await uploadMultipart( + { + parts_size: 100, + parts_presigned_url: [ + "https://s3.example.com/part1", + "https://s3.example.com/part2", + ], + complete_presigned_url: "https://s3.example.com/complete", + }, + "/tmp/bigfile.zip", + 180, + ); + + // Verify both parts were uploaded + expect(mockUploadWithHeaders).toHaveBeenCalledTimes(2); + + const [url1, , size1] = mockUploadWithHeaders.mock.calls[0]; + expect(url1).toBe("https://s3.example.com/part1"); + expect(size1).toBe(100); + + const [url2, , size2] = mockUploadWithHeaders.mock.calls[1]; + expect(url2).toBe("https://s3.example.com/part2"); + expect(size2).toBe(80); // last part is smaller + + // Verify completion POST with correct XML + expect(fetchSpy).toHaveBeenCalledOnce(); + const [completeUrl, options] = fetchSpy.mock.calls[0]; + expect(completeUrl).toBe("https://s3.example.com/complete"); + expect(options.method).toBe("POST"); + expect(options.headers).toEqual({ "Content-Type": "application/xml" }); + + const xml = options.body as string; + expect(xml).toContain(""); + expect(xml).toContain("1"); + expect(xml).toContain('"etag-part-1"'); + expect(xml).toContain("2"); + expect(xml).toContain('"etag-part-2"'); + }); + + it("throws if a part upload has no ETag", async () => { + mockUploadWithHeaders.mockResolvedValueOnce({ + body: Buffer.alloc(0), + headers: {}, // no etag + statusCode: 200, + }); + + await expect( + uploadMultipart( + { + parts_size: 100, + parts_presigned_url: ["https://s3.example.com/part1"], + complete_presigned_url: "https://s3.example.com/complete", + }, + "/tmp/file.zip", + 50, + ), + ).rejects.toThrow("ETag"); + }); + + it("throws if multipart completion fails", async () => { + mockUploadWithHeaders.mockResolvedValueOnce({ + body: Buffer.alloc(0), + headers: { etag: '"etag-1"' }, + statusCode: 200, + }); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue("Internal Server Error"), + } as unknown as Response); + + await expect( + uploadMultipart( + { + parts_size: 100, + parts_presigned_url: ["https://s3.example.com/part1"], + complete_presigned_url: "https://s3.example.com/complete", + }, + "/tmp/file.zip", + 50, + ), + ).rejects.toThrow("Failed to complete multipart upload"); + }); +}); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts new file mode 100644 index 0000000000..f797aef15c --- /dev/null +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts @@ -0,0 +1,104 @@ +import type { NexusV3Client } from "@vortex/nexus-api-v3"; + +import { createReadStream } from "fs"; + +import { log } from "../../../util/log"; +import { uploadWithHeaders } from "../../../util/network"; + +const POLL_INTERVAL_MS = 2000; +const POLL_MAX_ATTEMPTS = 150; // 5 minutes + +export async function pollUploadAvailable( + client: NexusV3Client, + uploadId: string, +): Promise { + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + const upload = await client.getUpload(uploadId); + if (upload.state === "available") { + return; + } + log("debug", "polling upload status", { + uploadId, + state: upload.state, + attempt, + }); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + throw new Error( + `Upload ${uploadId} did not become available within ${(POLL_INTERVAL_MS * POLL_MAX_ATTEMPTS) / 1000}s`, + ); +} + +export async function uploadSinglePart( + presignedUrl: string, + filePath: string, + fileSize: number, +): Promise { + await uploadWithHeaders(presignedUrl, createReadStream(filePath), fileSize); +} + +function buildCompleteMultipartXml( + etags: Array<{ partNumber: number; etag: string }>, +): string { + const parts = etags + .map( + ({ partNumber, etag }) => + ` \n ${partNumber}\n ${etag}\n `, + ) + .join("\n"); + return `\n${parts}\n`; +} + +export async function uploadMultipart( + multipart: { + parts_size: number; + parts_presigned_url: string[]; + complete_presigned_url: string; + }, + filePath: string, + fileSize: number, +): Promise { + const { parts_size, parts_presigned_url, complete_presigned_url } = multipart; + const etags: Array<{ partNumber: number; etag: string }> = []; + + for (let i = 0; i < parts_presigned_url.length; i++) { + const start = i * parts_size; + const end = Math.min(start + parts_size, fileSize); + const chunkSize = end - start; + + const stream = createReadStream(filePath, { start, end: end - 1 }); + const result = await uploadWithHeaders( + parts_presigned_url[i], + stream, + chunkSize, + ); + + const etag = result.headers["etag"]; + if (!etag) { + throw new Error( + `S3 did not return an ETag for part ${i + 1} of multipart upload`, + ); + } + + etags.push({ partNumber: i + 1, etag }); + log("debug", "multipart part uploaded", { + part: i + 1, + total: parts_presigned_url.length, + etag, + }); + } + + // Complete the multipart upload by POSTing the ETags XML to S3 + const xml = buildCompleteMultipartXml(etags); + const response = await fetch(complete_presigned_url, { + method: "POST", + headers: { "Content-Type": "application/xml" }, + body: xml, + }); + if (!response.ok) { + const body = await response.text(); + throw new Error( + `Failed to complete multipart upload: ${response.status} ${body}`, + ); + } +} diff --git a/src/renderer/src/util/network.ts b/src/renderer/src/util/network.ts index 88242795de..628979c5e4 100644 --- a/src/renderer/src/util/network.ts +++ b/src/renderer/src/util/network.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage, ClientRequest } from "http"; +import type { IncomingMessage, IncomingHttpHeaders, ClientRequest } from "http"; import { get as getHTTP, request as requestHTTP } from "http"; import { get as getHTTPS, request as requestHTTPS } from "https"; import type { Readable } from "stream"; @@ -169,3 +169,66 @@ export function upload( }); }); } + +export interface IUploadResult { + body: Buffer; + headers: IncomingHttpHeaders; + statusCode: number; +} + +export function uploadWithHeaders( + targetUrl: string, + dataStream: Readable, + dataSize: number, + extraHeaders?: Record, +): Promise { + return new Promise((resolve, reject) => { + log("debug", "uploading file (with headers)", { targetUrl, dataSize }); + const started = Date.now(); + const req = request( + "PUT", + targetUrl, + { + "Content-Type": "application/octet-stream", + "Content-Length": dataSize.toString(), + ...extraHeaders, + }, + (res) => { + const { statusCode } = res; + log("debug", "upload complete", { + targetUrl, + dataSize, + statusCode, + elapsed: Date.now() - started, + }); + + let rawData: Buffer = Buffer.alloc(0); + res + .on("data", (chunk) => { + rawData = Buffer.concat([rawData, chunk]); + }) + .on("end", () => { + if (statusCode !== 200) { + return reject( + new Error(`Upload failed. Status Code: ${statusCode}`), + ); + } + resolve({ + body: rawData, + headers: res.headers, + statusCode, + }); + }) + .on("error", (reqErr: Error) => { + return reject(reqErr); + }); + }, + ); + req.on("error", (err) => { + return reject(err); + }); + dataStream.pipe(req, { + end: true, + }); + }); +} diff --git a/src/renderer/webpack.config.cjs b/src/renderer/webpack.config.cjs index 9a593cb92c..bd3d2ddb26 100644 --- a/src/renderer/webpack.config.cjs +++ b/src/renderer/webpack.config.cjs @@ -82,7 +82,7 @@ const config = { devtool: "source-map", externals: [ nodeExternals({ - allowlist: [/@vortex\/shared/], + allowlist: [/@vortex\/shared/, /@vortex\/nexus-api-v3/], }), ], }; From 179fd809f5b5f1c989b536fbf5022c1e381df03d Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Mon, 13 Apr 2026 14:59:41 +0300 Subject: [PATCH 02/19] Fixed tests --- .../nexus_integration/util_v3/submitCollectionV3.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts index 20be7e9b3d..056fd20edb 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts @@ -144,7 +144,13 @@ function makeMockClient(): Pick< }), createCollection: vi .fn() - .mockResolvedValue({ id: "999", revision_id: "rev-1" }), + .mockResolvedValue({ + id: "999", + slug: "test-slug", + revision_id: "rev-1", + revision_number: 1, + revision_status: "draft", + }), createCollectionRevision: vi .fn() .mockResolvedValue({ id: "rev-2", collection_id: "888" }), From b637825cb74ea9010c9f899c192a5ec492c1919f Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 22 Apr 2026 12:20:35 +0300 Subject: [PATCH 03/19] Removed GraphQL, using new API --- .../collections/src/collectionExport.ts | 12 +- packages/nexus-api-v3/src/client.ts | 7 +- packages/nexus-api-v3/src/index.ts | 1 + .../nexus_integration/eventHandlers.ts | 5 +- .../extensions/nexus_integration/index.tsx | 4 +- .../util_v3/manifestMapping.ts | 13 +- .../util_v3/submitCollectionV3.test.ts | 178 +++--------------- .../util_v3/submitCollectionV3.ts | 102 +++------- .../util_v3/uploadV3.test.ts | 14 +- .../nexus_integration/util_v3/uploadV3.ts | 19 +- 10 files changed, 100 insertions(+), 255 deletions(-) diff --git a/extensions/collections/src/collectionExport.ts b/extensions/collections/src/collectionExport.ts index ad221f6076..1e563113ce 100644 --- a/extensions/collections/src/collectionExport.ts +++ b/extensions/collections/src/collectionExport.ts @@ -339,7 +339,10 @@ export async function doExportToAPI( ), ); collectionId = result.collection.id; - collectionSlug = result.collection.slug; + // V3 revision endpoint omits slug (it never changes), so fall back to + // the previously stored slug on the mod. + collectionSlug = + result.collection.slug ?? mod.attributes?.collectionSlug; api.store.dispatch( actions.setModAttribute(gameId, modId, "collectionId", collectionId), ); @@ -348,15 +351,14 @@ export async function doExportToAPI( gameId, modId, "collectionSlug", - result.collection.slug, + collectionSlug, ), ); api.store.dispatch( actions.setModAttribute(gameId, modId, "source", "nexus"), ); - const revisionId = result.revision?.id ?? result["revisionId"]; - revisionNumber = - result.revision?.revisionNumber ?? result["revisionNumber"]; + const revisionId = result.revision?.id; + revisionNumber = result.revision?.revisionNumber; api.store.dispatch( actions.setModAttribute(gameId, modId, "revisionId", revisionId), ); diff --git a/packages/nexus-api-v3/src/client.ts b/packages/nexus-api-v3/src/client.ts index 4eacd009cd..ff67ea63db 100644 --- a/packages/nexus-api-v3/src/client.ts +++ b/packages/nexus-api-v3/src/client.ts @@ -1,4 +1,4 @@ -import createClient from "openapi-fetch"; +import createClient, { type Middleware } from "openapi-fetch"; import type { paths } from "./generated/nexus-api-v3"; import { V3ApiError } from "./errors"; @@ -7,6 +7,7 @@ export interface NexusV3ClientOptions { baseUrl: string; apiKey?: string; bearerToken?: string; + middleware?: Middleware[]; } export type NexusV3Client = ReturnType; @@ -27,6 +28,10 @@ export function createNexusV3Client(options: NexusV3ClientOptions) { headers, }); + for (const mw of options.middleware ?? []) { + client.use(mw); + } + // Wrap the client methods to throw V3ApiError on error responses return { ...client, diff --git a/packages/nexus-api-v3/src/index.ts b/packages/nexus-api-v3/src/index.ts index 1202ea0ae6..1b60c70dac 100644 --- a/packages/nexus-api-v3/src/index.ts +++ b/packages/nexus-api-v3/src/index.ts @@ -5,3 +5,4 @@ export { type NexusV3ClientOptions, } from "./client"; export { V3ApiError } from "./errors"; +export type { Middleware as NexusV3Middleware } from "openapi-fetch"; diff --git a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts index bdcd6c1fb4..9cf7516a40 100644 --- a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts +++ b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts @@ -88,6 +88,7 @@ import { updateKey, updateToken, } from "./util"; +import { submitCollectionV3 } from "./util_v3/submitCollectionV3"; import { findLatestUpdate, retrieveModInfo } from "./util/checkModsVersion"; import { nexusGameId, @@ -103,7 +104,6 @@ import { } from "./util/graphQueries"; import submitFeedback from "./util/submitFeedback"; import { makeModUID } from "./util/UIDs"; -import { submitCollectionV3 } from "./util_v3/submitCollectionV3"; export function onChangeDownloads(api: IExtensionApi, nexus: Nexus) { const state: IState = api.store.getState(); @@ -1344,9 +1344,8 @@ export function onSubmitCollection(api: IExtensionApi) { collectionId: number, callback: (err: Error, response?: any) => void, ) => { - const state = api.getState(); submitCollectionV3( - state, + api.getState(), collectionInfo, assetFilePath, collectionId || undefined, diff --git a/src/renderer/src/extensions/nexus_integration/index.tsx b/src/renderer/src/extensions/nexus_integration/index.tsx index 18c6ea7848..cccc2f1ec5 100644 --- a/src/renderer/src/extensions/nexus_integration/index.tsx +++ b/src/renderer/src/extensions/nexus_integration/index.tsx @@ -1191,7 +1191,7 @@ function extendAPI(api: IExtensionApi, nexus: NexusT): INexusAPIExtension { nexusGetTrendingMods: eh.onGetTrendingMods(api, nexus), nexusEndorseMod: eh.onEndorseMod(api, nexus), nexusSubmitFeedback: eh.onSubmitFeedback(nexus), - nexusSubmitCollection: eh.onSubmitCollection(api, nexus), + nexusSubmitCollection: eh.onSubmitCollection(api), nexusModUpdate: eh.onModUpdate(api, nexus), nexusOpenCollectionPage: eh.onOpenCollectionPage(api), nexusOpenModPage: eh.onOpenModPage(api), @@ -1370,7 +1370,7 @@ function once(api: IExtensionApi, callbacks: Array<(nexus: NexusT) => void>) { api.events.on("refresh-user-info", eh.onRefreshUserInfo(nexus, api)); api.events.on("endorse-mod", eh.onEndorseMod(api, nexus)); api.events.on("submit-feedback", eh.onSubmitFeedback(nexus)); - api.events.on("submit-collection", eh.onSubmitCollection(api, nexus)); + api.events.on("submit-collection", eh.onSubmitCollection(api)); api.events.on("mods-update", eh.onModsUpdate(api, nexus)); api.events.on("mod-update", eh.onModUpdate(api, nexus)); api.events.on("open-collection-page", eh.onOpenCollectionPage(api)); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts index bbec796a33..8810836dac 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts @@ -38,14 +38,21 @@ function toV3ManifestMod( }; } +function emptyToNull(value: string | undefined | null): string | null { + if (value === undefined || value === null || value === "") { + return null; + } + return value; +} + function toV3Manifest(manifest: ICollectionManifest): V3CollectionManifest { return { info: { author: manifest.info.author, - author_url: manifest.info.authorUrl ?? null, + author_url: emptyToNull(manifest.info.authorUrl), name: manifest.info.name, - description: manifest.info.description ?? null, - summary: manifest.info.summary ?? null, + description: emptyToNull(manifest.info.description), + summary: emptyToNull(manifest.info.summary), domain_name: manifest.info.domainName, game_versions: manifest.info.gameVersions ?? null, }, diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts index 056fd20edb..9fd7bb1101 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts @@ -1,13 +1,9 @@ -import type Nexus from "@nexusmods/nexus-api"; -import type { - ICollection, - ICollectionManifest, -} from "@nexusmods/nexus-api"; +import type { ICollectionManifest } from "@nexusmods/nexus-api"; import type { NexusV3Client } from "@vortex/nexus-api-v3"; import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("../../../util/log", () => ({ +vi.mock("../../../logging", () => ({ log: vi.fn(), })); @@ -81,26 +77,6 @@ function makeManifest(): ICollectionManifest { }; } -interface MockCollection { - id: number; - slug: string; - currentRevision?: { id: number; revisionNumber: number; status: string }; -} - -function makeGetMyCollections(collections: MockCollection[]) { - return vi - .fn() - .mockResolvedValue( - collections as unknown as Array>, - ); -} - -function makeNexus(collections: MockCollection[] = []): Nexus { - return { - getMyCollections: makeGetMyCollections(collections), - } as unknown as Nexus; -} - function makeMockClient(): Pick< NexusV3Client, | "createUpload" @@ -116,30 +92,30 @@ function makeMockClient(): Pick< .mockResolvedValue({ id: "upload-123", presigned_url: "https://s3.example.com/upload", - user_id: "1", + user: { id: "1" }, state: "created", }), createMultipartUpload: vi .fn() .mockResolvedValue({ id: "upload-456", - parts_size: 100 * 1024 * 1024, - parts_presigned_url: [ + part_size_bytes: 100 * 1024 * 1024, + part_presigned_urls: [ "https://s3.example.com/part1", "https://s3.example.com/part2", ], complete_presigned_url: "https://s3.example.com/complete", - user_id: "1", + user: { id: "1" }, state: "created", }), finaliseUpload: vi .fn() - .mockResolvedValue({ id: "upload-123", user_id: "1", state: "created" }), + .mockResolvedValue({ id: "upload-123", user: { id: "1" }, state: "created" }), getUpload: vi .fn() .mockResolvedValue({ id: "upload-123", - user_id: "1", + user: { id: "1" }, state: "available", }), createCollection: vi @@ -147,13 +123,18 @@ function makeMockClient(): Pick< .mockResolvedValue({ id: "999", slug: "test-slug", - revision_id: "rev-1", + revision_id: "1", revision_number: 1, revision_status: "draft", }), createCollectionRevision: vi .fn() - .mockResolvedValue({ id: "rev-2", collection_id: "888" }), + .mockResolvedValue({ + id: "42", + collection_id: "888", + revision_number: 2, + revision_status: "draft", + }), }; } @@ -170,17 +151,9 @@ describe("submitCollectionV3", () => { describe("upload path selection", () => { it("uses single-part upload for small files", async () => { mockStat.mockResolvedValue({ size: SMALL_FILE_SIZE }); - const nexus = makeNexus([ - { - id: 999, - slug: "test", - currentRevision: { id: 1, revisionNumber: 0, status: "draft" }, - }, - ]); await submitCollectionV3( makeState(), - nexus, makeManifest(), "/tmp/small.zip", undefined, @@ -197,17 +170,9 @@ describe("submitCollectionV3", () => { it("uses multipart upload for large files", async () => { mockStat.mockResolvedValue({ size: LARGE_FILE_SIZE }); - const nexus = makeNexus([ - { - id: 999, - slug: "test", - currentRevision: { id: 1, revisionNumber: 0, status: "draft" }, - }, - ]); await submitCollectionV3( makeState(), - nexus, makeManifest(), "/tmp/large.zip", undefined, @@ -223,19 +188,10 @@ describe("submitCollectionV3", () => { }); }); - describe("collection creation", () => { - it("creates a new collection when collectionId is undefined", async () => { - const nexus = makeNexus([ - { - id: 999, - slug: "test-slug", - currentRevision: { id: 1, revisionNumber: 0, status: "draft" }, - }, - ]); - + describe("new collection", () => { + it("returns id, slug, revisionNumber, and revisionStatus from V3 response", async () => { const result = await submitCollectionV3( makeState(), - nexus, makeManifest(), "/tmp/file.zip", undefined, @@ -249,24 +205,16 @@ describe("submitCollectionV3", () => { expect(result.success).toBe(true); expect(result.collection?.id).toBe(999); expect(result.collection?.slug).toBe("test-slug"); + expect(result.revision?.id).toBe(1); + expect(result.revision?.revisionNumber).toBe(1); + expect(result.revision?.revisionStatus).toBe("draft"); }); + }); - it("creates a revision when collectionId is provided", async () => { - const nexus = makeNexus([ - { - id: 888, - slug: "existing-slug", - currentRevision: { - id: 2, - revisionNumber: 1, - status: "under_moderation", - }, - }, - ]); - + describe("revision update", () => { + it("returns id, revisionNumber, and revisionStatus from V3 response (slug unchanged)", async () => { const result = await submitCollectionV3( makeState(), - nexus, makeManifest(), "/tmp/file.zip", 888, @@ -280,89 +228,18 @@ describe("submitCollectionV3", () => { expect(mockClient.createCollection).not.toHaveBeenCalled(); expect(result.success).toBe(true); expect(result.collection?.id).toBe(888); - expect(result.collection?.slug).toBe("existing-slug"); - }); - }); - - describe("fetchCollectionDetails", () => { - it("returns slug and revision details from GraphQL", async () => { - const collections = [ - { - id: 999, - slug: "my-collection", - currentRevision: { - id: 42, - revisionNumber: 3, - status: "published", - }, - }, - ]; - const getMyCollections = makeGetMyCollections(collections); - const nexus = { getMyCollections } as unknown as Nexus; - - const result = await submitCollectionV3( - makeState(), - nexus, - makeManifest(), - "/tmp/file.zip", - undefined, - ); - - expect(getMyCollections).toHaveBeenCalled(); - expect(result.collection?.slug).toBe("my-collection"); - expect(result.revision?.id).toBe(42); - expect(result.revision?.revisionNumber).toBe(3); - expect(result.revision?.revisionStatus).toBe("published"); - }); - - it("returns partial result when collection not found in GraphQL", async () => { - const nexus = makeNexus([]); // empty — collection not found - - const result = await submitCollectionV3( - makeState(), - nexus, - makeManifest(), - "/tmp/file.zip", - undefined, - ); - - expect(result.success).toBe(true); - expect(result.collection?.id).toBe(999); + // slug intentionally not returned — caller keeps previously stored value expect(result.collection?.slug).toBeUndefined(); - expect(result.revision).toBeUndefined(); - }); - - it("handles collection with no currentRevision", async () => { - const nexus = makeNexus([ - { id: 999, slug: "test", currentRevision: undefined }, - ]); - - const result = await submitCollectionV3( - makeState(), - nexus, - makeManifest(), - "/tmp/file.zip", - undefined, - ); - - expect(result.collection?.slug).toBe("test"); - expect(result.revision).toBeUndefined(); + expect(result.revision?.id).toBe(42); + expect(result.revision?.revisionNumber).toBe(2); + expect(result.revision?.revisionStatus).toBe("draft"); }); }); describe("upload lifecycle", () => { it("calls finalise and poll after upload", async () => { - const nexus = makeNexus([ - { - id: 999, - slug: "test", - currentRevision: { id: 1, revisionNumber: 0, status: "draft" }, - }, - ]); - await submitCollectionV3( makeState(), - nexus, makeManifest(), "/tmp/file.zip", undefined, @@ -379,7 +256,6 @@ describe("submitCollectionV3", () => { await expect( submitCollectionV3( makeState(), - makeNexus(), makeManifest(), "/tmp/file.zip", undefined, diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts index 15ec8684f2..2ce5ce7540 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts @@ -1,4 +1,3 @@ -import type Nexus from "@nexusmods/nexus-api"; import type { ICollectionManifest, ICreateCollectionResult, @@ -9,7 +8,7 @@ import * as fs from "fs-extra"; import type { IState } from "../../../types/IState"; -import { log } from "../../../util/log"; +import { log } from "../../../logging"; import { MULTIPART_THRESHOLD, NEXUS_V3_BASE_URL } from "../constants"; import { hasConfidentialWithNexus } from "../guards"; import { apiKey as apiKeySelector } from "../selectors"; @@ -26,7 +25,7 @@ function createClientFromState(state: IState) { } const nexusAccount = state.confidential.account.nexus; - const apiKey = apiKeySelector(state); + const apiKey: string | undefined = apiKeySelector(state); const oauthCredentials = nexusAccount.OAuthCredentials as | { token?: string } | undefined; @@ -39,56 +38,8 @@ function createClientFromState(state: IState) { }); } -async function fetchCollectionDetails( - nexus: Nexus, - collectionId: number, -): Promise { - log("debug", "calling getMyCollections", { collectionId }); - const collections = await nexus.getMyCollections( - { - id: true, - slug: true, - currentRevision: { - id: true, - revisionNumber: true, - status: true, - }, - }, - undefined, - 100, - 0, - ); - log("debug", "getMyCollections returned", { - count: collections.length, - ids: collections.map((c) => c.id), - }); - - const collection = collections.find((c) => c.id === collectionId); - if (collection) { - return { - collection: { id: collection.id, slug: collection.slug }, - revision: collection.currentRevision - ? { - id: collection.currentRevision.id, - revisionNumber: collection.currentRevision.revisionNumber, - revisionStatus: collection.currentRevision.status, - } - : undefined, - success: true, - }; - } - - log("warn", "collection not found in getMyCollections", { collectionId }); - return { - collection: { id: collectionId }, - revision: undefined, - success: true, - }; -} - export async function submitCollectionV3( state: IState, - nexus: Nexus, collectionInfo: ICollectionManifest, assetFilePath: string, collectionId: number | undefined, @@ -117,36 +68,39 @@ export async function submitCollectionV3( await uploadMultipart(multipart, assetFilePath, fileSize); } - // Step 2: Finalise upload - log("debug", "finalising upload", { uploadId }); + // Step 2: Finalise and wait for availability await client.finaliseUpload(uploadId); - - // Step 3: Poll until available - log("debug", "polling upload availability", { uploadId }); await pollUploadAvailable(client, uploadId); - log("debug", "upload available", { uploadId }); - // Step 4: Create collection or revision + // Step 3: Create collection or revision const payload = toV3CollectionPayload(collectionInfo); - let createdCollectionId: number; - if (collectionId === undefined) { - log("debug", "creating new collection", { uploadId }); const result = await client.createCollection(uploadId, payload); - createdCollectionId = Number(result.id); - log("info", "collection created", { collectionId: createdCollectionId }); - } else { - log("debug", "creating new revision", { uploadId, collectionId }); - await client.createCollectionRevision( - String(collectionId), - uploadId, - payload, - ); - createdCollectionId = collectionId; - log("info", "revision created", { collectionId: createdCollectionId }); + return { + collection: { id: Number(result.id), slug: result.slug }, + revision: { + id: Number(result.revision_id), + revisionNumber: result.revision_number, + revisionStatus: result.revision_status, + }, + success: true, + }; } - // Step 5: Fetch full details via GraphQL (slug, revisionNumber, etc.) - return fetchCollectionDetails(nexus, createdCollectionId); + const revisionResult = await client.createCollectionRevision( + String(collectionId), + uploadId, + payload, + ); + // Slug is unchanged from the previous upload (caller already has it stored). + return { + collection: { id: collectionId }, + revision: { + id: Number(revisionResult.id), + revisionNumber: revisionResult.revision_number, + revisionStatus: revisionResult.revision_status, + }, + success: true, + }; } diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts index b642c008b4..47d6322754 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts @@ -1,7 +1,7 @@ import { Readable } from "stream"; import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("../../../util/log", () => ({ +vi.mock("../../../logging", () => ({ log: vi.fn(), })); @@ -119,8 +119,8 @@ describe("uploadMultipart", () => { await uploadMultipart( { - parts_size: 100, - parts_presigned_url: [ + part_size_bytes: 100, + part_presigned_urls: [ "https://s3.example.com/part1", "https://s3.example.com/part2", ], @@ -166,8 +166,8 @@ describe("uploadMultipart", () => { await expect( uploadMultipart( { - parts_size: 100, - parts_presigned_url: ["https://s3.example.com/part1"], + part_size_bytes: 100, + part_presigned_urls: ["https://s3.example.com/part1"], complete_presigned_url: "https://s3.example.com/complete", }, "/tmp/file.zip", @@ -192,8 +192,8 @@ describe("uploadMultipart", () => { await expect( uploadMultipart( { - parts_size: 100, - parts_presigned_url: ["https://s3.example.com/part1"], + part_size_bytes: 100, + part_presigned_urls: ["https://s3.example.com/part1"], complete_presigned_url: "https://s3.example.com/complete", }, "/tmp/file.zip", diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts index f797aef15c..74b8ad8278 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts @@ -2,7 +2,7 @@ import type { NexusV3Client } from "@vortex/nexus-api-v3"; import { createReadStream } from "fs"; -import { log } from "../../../util/log"; +import { log } from "../../../logging"; import { uploadWithHeaders } from "../../../util/network"; const POLL_INTERVAL_MS = 2000; @@ -51,24 +51,25 @@ function buildCompleteMultipartXml( export async function uploadMultipart( multipart: { - parts_size: number; - parts_presigned_url: string[]; + part_size_bytes: number; + part_presigned_urls: string[]; complete_presigned_url: string; }, filePath: string, fileSize: number, ): Promise { - const { parts_size, parts_presigned_url, complete_presigned_url } = multipart; + const { part_size_bytes, part_presigned_urls, complete_presigned_url } = + multipart; const etags: Array<{ partNumber: number; etag: string }> = []; - for (let i = 0; i < parts_presigned_url.length; i++) { - const start = i * parts_size; - const end = Math.min(start + parts_size, fileSize); + for (let i = 0; i < part_presigned_urls.length; i++) { + const start = i * part_size_bytes; + const end = Math.min(start + part_size_bytes, fileSize); const chunkSize = end - start; const stream = createReadStream(filePath, { start, end: end - 1 }); const result = await uploadWithHeaders( - parts_presigned_url[i], + part_presigned_urls[i], stream, chunkSize, ); @@ -83,7 +84,7 @@ export async function uploadMultipart( etags.push({ partNumber: i + 1, etag }); log("debug", "multipart part uploaded", { part: i + 1, - total: parts_presigned_url.length, + total: part_presigned_urls.length, etag, }); } From 3c53140eb6616352e73f8a021cb078964c12941d Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 22 Apr 2026 13:47:30 +0300 Subject: [PATCH 04/19] Multipart is parallel now better retry, error handling Added back editCollection --- packages/nexus-api-v3/src/client.ts | 39 ++-- packages/nexus-api-v3/src/errors.ts | 14 +- .../nexus_integration/eventHandlers.ts | 3 +- .../extensions/nexus_integration/index.tsx | 4 +- .../util_v3/manifestMapping.test.ts | 4 +- .../util_v3/manifestMapping.ts | 4 +- .../util_v3/submitCollectionV3.test.ts | 53 +++++- .../util_v3/submitCollectionV3.ts | 24 ++- .../util_v3/uploadV3.test.ts | 10 + .../nexus_integration/util_v3/uploadV3.ts | 172 ++++++++++++++---- 10 files changed, 247 insertions(+), 80 deletions(-) diff --git a/packages/nexus-api-v3/src/client.ts b/packages/nexus-api-v3/src/client.ts index ff67ea63db..c2f2865f4a 100644 --- a/packages/nexus-api-v3/src/client.ts +++ b/packages/nexus-api-v3/src/client.ts @@ -105,21 +105,30 @@ export function createNexusV3Client(options: NexusV3ClientOptions) { function toV3Error(error: unknown, response: Response): V3ApiError { // openapi-fetch returns the parsed error body. For problem+json responses, - // this will be a ProblemDetails or ValidationProblem object. - if ( - error && - typeof error === "object" && - "title" in error && - "status" in error - ) { - return new V3ApiError(error as any); - } - // Fallback for unexpected error shapes + // this will be a ProblemDetails or ValidationProblem object — but proxies, + // 502 pages, and transport errors can all produce other shapes, so we fall + // back to the HTTP response whenever a field is missing. + const problem = + error && typeof error === "object" ? (error as Record) : {}; + return new V3ApiError({ - type: "about:blank", - title: "Request failed", - status: response.status, - detail: String(error), - instance: response.url, + type: typeof problem.type === "string" ? problem.type : "about:blank", + title: + typeof problem.title === "string" && problem.title.length > 0 + ? problem.title + : `HTTP ${response.status}`, + status: + typeof problem.status === "number" ? problem.status : response.status, + detail: + typeof problem.detail === "string" + ? problem.detail + : error instanceof Error + ? error.message + : "", + instance: + typeof problem.instance === "string" ? problem.instance : response.url, + errors: Array.isArray(problem.errors) + ? (problem.errors as V3ApiError["validationErrors"]) + : undefined, }); } diff --git a/packages/nexus-api-v3/src/errors.ts b/packages/nexus-api-v3/src/errors.ts index c920c7449d..c961ff787c 100644 --- a/packages/nexus-api-v3/src/errors.ts +++ b/packages/nexus-api-v3/src/errors.ts @@ -10,13 +10,15 @@ export class V3ApiError extends Error { public readonly instance: string; public readonly validationErrors?: ValidationProblemItem[]; - constructor(problem: ProblemDetails & { errors?: ValidationProblemItem[] }) { - super(problem.title); + constructor( + problem: Partial & { errors?: ValidationProblemItem[] }, + ) { + super(problem.title || "Request failed"); this.name = "V3ApiError"; - this.status = problem.status; - this.problemType = problem.type; - this.detail = problem.detail; - this.instance = problem.instance; + this.status = problem.status ?? 0; + this.problemType = problem.type || "about:blank"; + this.detail = problem.detail || ""; + this.instance = problem.instance || ""; this.validationErrors = problem.errors; } } diff --git a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts index 9cf7516a40..ceb47d4910 100644 --- a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts +++ b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts @@ -1337,7 +1337,7 @@ export function onSubmitFeedback(nexus: Nexus) { }; } -export function onSubmitCollection(api: IExtensionApi) { +export function onSubmitCollection(api: IExtensionApi, nexus: Nexus) { return ( collectionInfo: ICollectionManifest, assetFilePath: string, @@ -1346,6 +1346,7 @@ export function onSubmitCollection(api: IExtensionApi) { ) => { submitCollectionV3( api.getState(), + nexus, collectionInfo, assetFilePath, collectionId || undefined, diff --git a/src/renderer/src/extensions/nexus_integration/index.tsx b/src/renderer/src/extensions/nexus_integration/index.tsx index cccc2f1ec5..18c6ea7848 100644 --- a/src/renderer/src/extensions/nexus_integration/index.tsx +++ b/src/renderer/src/extensions/nexus_integration/index.tsx @@ -1191,7 +1191,7 @@ function extendAPI(api: IExtensionApi, nexus: NexusT): INexusAPIExtension { nexusGetTrendingMods: eh.onGetTrendingMods(api, nexus), nexusEndorseMod: eh.onEndorseMod(api, nexus), nexusSubmitFeedback: eh.onSubmitFeedback(nexus), - nexusSubmitCollection: eh.onSubmitCollection(api), + nexusSubmitCollection: eh.onSubmitCollection(api, nexus), nexusModUpdate: eh.onModUpdate(api, nexus), nexusOpenCollectionPage: eh.onOpenCollectionPage(api), nexusOpenModPage: eh.onOpenModPage(api), @@ -1370,7 +1370,7 @@ function once(api: IExtensionApi, callbacks: Array<(nexus: NexusT) => void>) { api.events.on("refresh-user-info", eh.onRefreshUserInfo(nexus, api)); api.events.on("endorse-mod", eh.onEndorseMod(api, nexus)); api.events.on("submit-feedback", eh.onSubmitFeedback(nexus)); - api.events.on("submit-collection", eh.onSubmitCollection(api)); + api.events.on("submit-collection", eh.onSubmitCollection(api, nexus)); api.events.on("mods-update", eh.onModsUpdate(api, nexus)); api.events.on("mod-update", eh.onModUpdate(api, nexus)); api.events.on("open-collection-page", eh.onOpenCollectionPage(api)); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts index f142db30f6..94469ece42 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts @@ -136,8 +136,8 @@ describe("toV3CollectionPayload", () => { expect(info.game_versions).toBeNull(); const source = payload.collection_manifest.mods[0].source; - expect(source.mod_id).toBeUndefined(); - expect(source.file_id).toBeUndefined(); + expect(source.mod_id).toBeNull(); + expect(source.file_id).toBeNull(); expect(source.md5).toBeNull(); expect(source.file_size).toBeNull(); }); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts index 8810836dac..60109157cc 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts @@ -12,8 +12,8 @@ function toV3ManifestModSource( ): V3CollectionManifestModSource { return { type: source.type satisfies V3CollectionManifestModSource["type"], - mod_id: source.modId?.toString(), - file_id: source.fileId?.toString(), + mod_id: source.modId?.toString() ?? null, + file_id: source.fileId?.toString() ?? null, md5: source.md5 ?? null, file_size: source.fileSize ?? null, update_policy: diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts index 9fd7bb1101..106100c51c 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts @@ -1,4 +1,4 @@ -import type { ICollectionManifest } from "@nexusmods/nexus-api"; +import type { ICollectionManifest, default as Nexus } from "@nexusmods/nexus-api"; import type { NexusV3Client } from "@vortex/nexus-api-v3"; import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -30,12 +30,9 @@ vi.mock("./manifestMapping", () => ({ })), })); -vi.mock("../guards", () => ({ - hasConfidentialWithNexus: vi.fn(() => true), -})); - vi.mock("../selectors", () => ({ apiKey: vi.fn(() => "test-api-key"), + isLoggedIn: vi.fn(() => true), })); import { createNexusV3Client } from "@vortex/nexus-api-v3"; @@ -43,7 +40,7 @@ import { stat } from "fs-extra"; import type { IState } from "../../../types/IState"; -import { hasConfidentialWithNexus } from "../guards"; +import { isLoggedIn } from "../selectors"; import { submitCollectionV3 } from "./submitCollectionV3"; import { uploadMultipart, uploadSinglePart } from "./uploadV3"; @@ -77,6 +74,12 @@ function makeManifest(): ICollectionManifest { }; } +function makeNexus(): Nexus { + return { + editCollection: vi.fn().mockResolvedValue(true), + } as unknown as Nexus; +} + function makeMockClient(): Pick< NexusV3Client, | "createUpload" @@ -140,10 +143,12 @@ function makeMockClient(): Pick< describe("submitCollectionV3", () => { let mockClient: ReturnType; + let mockNexus: Nexus; beforeEach(() => { vi.clearAllMocks(); mockClient = makeMockClient(); + mockNexus = makeNexus(); mockCreateClient.mockReturnValue(mockClient as NexusV3Client); mockStat.mockResolvedValue({ size: SMALL_FILE_SIZE }); }); @@ -154,6 +159,7 @@ describe("submitCollectionV3", () => { await submitCollectionV3( makeState(), + mockNexus, makeManifest(), "/tmp/small.zip", undefined, @@ -173,6 +179,7 @@ describe("submitCollectionV3", () => { await submitCollectionV3( makeState(), + mockNexus, makeManifest(), "/tmp/large.zip", undefined, @@ -192,6 +199,7 @@ describe("submitCollectionV3", () => { it("returns id, slug, revisionNumber, and revisionStatus from V3 response", async () => { const result = await submitCollectionV3( makeState(), + mockNexus, makeManifest(), "/tmp/file.zip", undefined, @@ -215,6 +223,7 @@ describe("submitCollectionV3", () => { it("returns id, revisionNumber, and revisionStatus from V3 response (slug unchanged)", async () => { const result = await submitCollectionV3( makeState(), + mockNexus, makeManifest(), "/tmp/file.zip", 888, @@ -234,12 +243,42 @@ describe("submitCollectionV3", () => { expect(result.revision?.revisionNumber).toBe(2); expect(result.revision?.revisionStatus).toBe("draft"); }); + + it("calls nexus.editCollection with the current name before the revision upload", async () => { + await submitCollectionV3( + makeState(), + mockNexus, + makeManifest(), + "/tmp/file.zip", + 888, + ); + + expect(mockNexus.editCollection).toHaveBeenCalledWith(888, "Test"); + const editOrder = vi.mocked(mockNexus.editCollection).mock + .invocationCallOrder[0]; + const revisionOrder = vi.mocked(mockClient.createCollectionRevision).mock + .invocationCallOrder[0]; + expect(editOrder).toBeLessThan(revisionOrder); + }); + + it("does not call nexus.editCollection when creating a new collection", async () => { + await submitCollectionV3( + makeState(), + mockNexus, + makeManifest(), + "/tmp/file.zip", + undefined, + ); + + expect(mockNexus.editCollection).not.toHaveBeenCalled(); + }); }); describe("upload lifecycle", () => { it("calls finalise and poll after upload", async () => { await submitCollectionV3( makeState(), + mockNexus, makeManifest(), "/tmp/file.zip", undefined, @@ -251,7 +290,7 @@ describe("submitCollectionV3", () => { describe("auth", () => { it("throws when not logged in", async () => { - vi.mocked(hasConfidentialWithNexus).mockReturnValueOnce(false); + vi.mocked(isLoggedIn).mockReturnValueOnce(false); await expect( submitCollectionV3( diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts index 2ce5ce7540..c99bd2c37f 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts @@ -1,6 +1,8 @@ import type { ICollectionManifest, ICreateCollectionResult, + IOAuthCredentials, + default as Nexus, } from "@nexusmods/nexus-api"; import { createNexusV3Client } from "@vortex/nexus-api-v3"; @@ -10,8 +12,7 @@ import type { IState } from "../../../types/IState"; import { log } from "../../../logging"; import { MULTIPART_THRESHOLD, NEXUS_V3_BASE_URL } from "../constants"; -import { hasConfidentialWithNexus } from "../guards"; -import { apiKey as apiKeySelector } from "../selectors"; +import { apiKey as apiKeySelector, isLoggedIn } from "../selectors"; import { toV3CollectionPayload } from "./manifestMapping"; import { pollUploadAvailable, @@ -20,16 +21,14 @@ import { } from "./uploadV3"; function createClientFromState(state: IState) { - if (!hasConfidentialWithNexus(state.confidential)) { + if (!isLoggedIn(state)) { throw new Error("Not logged in to Nexus Mods"); } - const nexusAccount = state.confidential.account.nexus; - const apiKey: string | undefined = apiKeySelector(state); - const oauthCredentials = nexusAccount.OAuthCredentials as - | { token?: string } - | undefined; - const oauthToken = oauthCredentials?.token; + const apiKey = apiKeySelector(state); + const oauthCred: IOAuthCredentials = + state.confidential.account?.["nexus"]?.["OAuthCredentials"]; + const oauthToken = oauthCred?.token; return createNexusV3Client({ baseUrl: NEXUS_V3_BASE_URL, @@ -40,6 +39,7 @@ function createClientFromState(state: IState) { export async function submitCollectionV3( state: IState, + nexus: Nexus, collectionInfo: ICollectionManifest, assetFilePath: string, collectionId: number | undefined, @@ -88,6 +88,12 @@ export async function submitCollectionV3( }; } + // V3 revision creation doesn't propagate collection-level metadata (name) + // to the parent collection. Preserve the legacy behaviour by calling + // GraphQL editCollection first, mirroring the pre-v3 flow which ran + // editCollection unconditionally before every revision upload. + await nexus.editCollection(collectionId, collectionInfo.info.name); + const revisionResult = await client.createCollectionRevision( String(collectionId), uploadId, diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts index 47d6322754..659f2649ad 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts @@ -66,6 +66,16 @@ describe("pollUploadAvailable", () => { expect(getUpload).toHaveBeenCalledTimes(3); }); + + it("bails out on a terminal failure state", async () => { + const getUpload = vi.fn().mockResolvedValue({ state: "failed" }); + const client = makeClient({ getUpload }); + + await expect( + pollUploadAvailable(client, "upload-123"), + ).rejects.toThrow(/terminal failure state/); + expect(getUpload).toHaveBeenCalledOnce(); + }); }); describe("uploadSinglePart", () => { diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts index 74b8ad8278..54eff06ce4 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts @@ -3,11 +3,64 @@ import type { NexusV3Client } from "@vortex/nexus-api-v3"; import { createReadStream } from "fs"; import { log } from "../../../logging"; -import { uploadWithHeaders } from "../../../util/network"; +import { uploadWithHeaders, type IUploadResult } from "../../../util/network"; + +function describeError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + try { + return JSON.stringify(err); + } catch { + return ""; + } +} const POLL_INTERVAL_MS = 2000; const POLL_MAX_ATTEMPTS = 150; // 5 minutes +const RETRY_ATTEMPTS = 3; +const RETRY_BASE_DELAY_MS = 1000; + +const MULTIPART_CONCURRENCY = 4; + +// States considered terminal-non-success. Kept as a runtime check because +// the OpenAPI schema currently only declares "created" | "available", but +// the server may introduce failure states before the spec catches up. +const TERMINAL_FAILURE_STATES = new Set([ + "failed", + "rejected", + "cancelled", + "canceled", + "expired", + "errored", +]); + +async function withRetry( + fn: () => Promise, + label: string, + attempts = RETRY_ATTEMPTS, +): Promise { + let lastErr: unknown; + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (attempt === attempts) break; + const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); + log("warn", "upload attempt failed, retrying", { + label, + attempt, + attempts, + delayMs: delay, + error: describeError(err), + }); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw lastErr; +} + export async function pollUploadAvailable( client: NexusV3Client, uploadId: string, @@ -17,9 +70,15 @@ export async function pollUploadAvailable( if (upload.state === "available") { return; } + const state = upload.state as string; + if (TERMINAL_FAILURE_STATES.has(state)) { + throw new Error( + `Upload ${uploadId} entered terminal failure state: ${state}`, + ); + } log("debug", "polling upload status", { uploadId, - state: upload.state, + state, attempt, }); await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); @@ -34,7 +93,11 @@ export async function uploadSinglePart( filePath: string, fileSize: number, ): Promise { - await uploadWithHeaders(presignedUrl, createReadStream(filePath), fileSize); + await withRetry( + () => + uploadWithHeaders(presignedUrl, createReadStream(filePath), fileSize), + "single-part upload", + ); } function buildCompleteMultipartXml( @@ -49,6 +112,39 @@ function buildCompleteMultipartXml( return `\n${parts}\n`; } +async function uploadPart( + url: string, + filePath: string, + start: number, + end: number, + partNumber: number, + totalParts: number, +): Promise<{ partNumber: number; etag: string }> { + const chunkSize = end - start; + const result = await withRetry( + () => { + const stream = createReadStream(filePath, { start, end: end - 1 }); + return uploadWithHeaders(url, stream, chunkSize); + }, + `multipart part ${partNumber}/${totalParts}`, + ); + + const rawEtag = result.headers["etag"]; + const etag = Array.isArray(rawEtag) ? rawEtag[0] : rawEtag; + if (!etag) { + throw new Error( + `S3 did not return an ETag for part ${partNumber} of multipart upload`, + ); + } + + log("debug", "multipart part uploaded", { + part: partNumber, + total: totalParts, + etag, + }); + return { partNumber, etag }; +} + export async function uploadMultipart( multipart: { part_size_bytes: number; @@ -60,42 +156,46 @@ export async function uploadMultipart( ): Promise { const { part_size_bytes, part_presigned_urls, complete_presigned_url } = multipart; - const etags: Array<{ partNumber: number; etag: string }> = []; - - for (let i = 0; i < part_presigned_urls.length; i++) { - const start = i * part_size_bytes; - const end = Math.min(start + part_size_bytes, fileSize); - const chunkSize = end - start; - - const stream = createReadStream(filePath, { start, end: end - 1 }); - const result = await uploadWithHeaders( - part_presigned_urls[i], - stream, - chunkSize, - ); - - const etag = result.headers["etag"]; - if (!etag) { - throw new Error( - `S3 did not return an ETag for part ${i + 1} of multipart upload`, - ); - } + const totalParts = part_presigned_urls.length; + const etags: Array<{ partNumber: number; etag: string }> = new Array( + totalParts, + ); - etags.push({ partNumber: i + 1, etag }); - log("debug", "multipart part uploaded", { - part: i + 1, - total: part_presigned_urls.length, - etag, - }); - } + // Worker-pool pattern: N workers drain a shared index counter. Preserves + // insertion order in `etags` regardless of completion order. + let next = 0; + const workers = Array.from( + { length: Math.min(MULTIPART_CONCURRENCY, totalParts) }, + async () => { + while (true) { + const i = next++; + if (i >= totalParts) return; + const start = i * part_size_bytes; + const end = Math.min(start + part_size_bytes, fileSize); + etags[i] = await uploadPart( + part_presigned_urls[i], + filePath, + start, + end, + i + 1, + totalParts, + ); + } + }, + ); + await Promise.all(workers); - // Complete the multipart upload by POSTing the ETags XML to S3 + // Complete the multipart upload by POSTing the ETags XML to S3. const xml = buildCompleteMultipartXml(etags); - const response = await fetch(complete_presigned_url, { - method: "POST", - headers: { "Content-Type": "application/xml" }, - body: xml, - }); + const response = await withRetry( + () => + fetch(complete_presigned_url, { + method: "POST", + headers: { "Content-Type": "application/xml" }, + body: xml, + }), + "multipart completion", + ); if (!response.ok) { const body = await response.text(); throw new Error( From 579a40df4aacc0b3b1768b09420707acd464667d Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 22 Apr 2026 14:10:01 +0300 Subject: [PATCH 05/19] Fix --- .../util_v3/submitCollectionV3.test.ts | 1 + .../util_v3/uploadV3.test.ts | 34 ++++++++++++++++--- .../nexus_integration/util_v3/uploadV3.ts | 30 ++++++++++------ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts index 106100c51c..68a31cdaa2 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.test.ts @@ -295,6 +295,7 @@ describe("submitCollectionV3", () => { await expect( submitCollectionV3( makeState(), + mockNexus, makeManifest(), "/tmp/file.zip", undefined, diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts index 659f2649ad..8ec2fd2b97 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts @@ -186,29 +186,55 @@ describe("uploadMultipart", () => { ).rejects.toThrow("ETag"); }); - it("throws if multipart completion fails", async () => { + it("retries and then throws if multipart completion keeps failing", async () => { mockUploadWithHeaders.mockResolvedValueOnce({ body: Buffer.alloc(0), headers: { etag: '"etag-1"' }, statusCode: 200, }); - vi.spyOn(globalThis, "fetch").mockResolvedValue({ + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 500, text: vi.fn().mockResolvedValue("Internal Server Error"), } as unknown as Response); + vi.useFakeTimers(); + const promise = uploadMultipart( + { + part_size_bytes: 100, + part_presigned_urls: ["https://s3.example.com/part1"], + complete_presigned_url: "https://s3.example.com/complete", + }, + "/tmp/file.zip", + 50, + ); + // Swallow the eventual rejection so the assertion below owns it. + const settled = promise.catch((err: unknown) => err); + await vi.runAllTimersAsync(); + vi.useRealTimers(); + + const err = await settled; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch( + /Failed to complete multipart upload/, + ); + // Completion POST is retried up to RETRY_ATTEMPTS (3) times. + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("throws with mismatched part layout", async () => { await expect( uploadMultipart( { part_size_bytes: 100, + // One URL provided, but 250 bytes at 100 bytes/part needs 3 parts. part_presigned_urls: ["https://s3.example.com/part1"], complete_presigned_url: "https://s3.example.com/complete", }, "/tmp/file.zip", - 50, + 250, ), - ).rejects.toThrow("Failed to complete multipart upload"); + ).rejects.toThrow(/Multipart layout mismatch/); }); }); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts index 54eff06ce4..58fbff261d 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts @@ -157,6 +157,13 @@ export async function uploadMultipart( const { part_size_bytes, part_presigned_urls, complete_presigned_url } = multipart; const totalParts = part_presigned_urls.length; + const expectedParts = Math.ceil(fileSize / part_size_bytes); + if (expectedParts !== totalParts) { + throw new Error( + `Multipart layout mismatch: server returned ${totalParts} presigned URLs ` + + `but ${fileSize} bytes at ${part_size_bytes} bytes/part needs ${expectedParts}`, + ); + } const etags: Array<{ partNumber: number; etag: string }> = new Array( totalParts, ); @@ -187,19 +194,22 @@ export async function uploadMultipart( // Complete the multipart upload by POSTing the ETags XML to S3. const xml = buildCompleteMultipartXml(etags); - const response = await withRetry( - () => - fetch(complete_presigned_url, { + await withRetry( + async () => { + const response = await fetch(complete_presigned_url, { method: "POST", headers: { "Content-Type": "application/xml" }, body: xml, - }), + }); + if (!response.ok) { + // Throw inside withRetry so transient 5xx responses are retried. + const body = await response.text(); + throw new Error( + `Failed to complete multipart upload: ${response.status} ${body}`, + ); + } + return response; + }, "multipart completion", ); - if (!response.ok) { - const body = await response.text(); - throw new Error( - `Failed to complete multipart upload: ${response.status} ${body}`, - ); - } } From ed6c3f028e4a60a6453699c2ccd585a42b3a0c41 Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 22 Apr 2026 14:40:33 +0300 Subject: [PATCH 06/19] Better error handling, added abort handling --- .../util_v3/submitCollectionV3.ts | 12 +- .../util_v3/uploadV3.test.ts | 60 ++++++- .../nexus_integration/util_v3/uploadV3.ts | 161 ++++++++++++++---- src/renderer/src/util/network.ts | 38 ++++- 4 files changed, 232 insertions(+), 39 deletions(-) diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts index c99bd2c37f..9e6f4c3482 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/submitCollectionV3.ts @@ -43,6 +43,7 @@ export async function submitCollectionV3( collectionInfo: ICollectionManifest, assetFilePath: string, collectionId: number | undefined, + signal?: AbortSignal, ): Promise { const client = createClientFromState(state); const stat = await fs.stat(assetFilePath); @@ -61,16 +62,21 @@ export async function submitCollectionV3( if (fileSize <= MULTIPART_THRESHOLD) { const upload = await client.createUpload(fileSize, filename); uploadId = upload.id; - await uploadSinglePart(upload.presigned_url, assetFilePath, fileSize); + await uploadSinglePart( + upload.presigned_url, + assetFilePath, + fileSize, + signal, + ); } else { const multipart = await client.createMultipartUpload(fileSize, filename); uploadId = multipart.id; - await uploadMultipart(multipart, assetFilePath, fileSize); + await uploadMultipart(multipart, assetFilePath, fileSize, signal); } // Step 2: Finalise and wait for availability await client.finaliseUpload(uploadId); - await pollUploadAvailable(client, uploadId); + await pollUploadAvailable(client, uploadId, signal); // Step 3: Create collection or revision const payload = toV3CollectionPayload(collectionInfo); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts index 8ec2fd2b97..9ab2f005f8 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.test.ts @@ -67,13 +67,67 @@ describe("pollUploadAvailable", () => { expect(getUpload).toHaveBeenCalledTimes(3); }); - it("bails out on a terminal failure state", async () => { + it("bails after tolerating unknown states a few times", async () => { const getUpload = vi.fn().mockResolvedValue({ state: "failed" }); const client = makeClient({ getUpload }); + const promise = pollUploadAvailable(client, "upload-123"); + const settled = promise.catch((err: unknown) => err); + await vi.runAllTimersAsync(); + const err = await settled; + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/unknown state/); + // One initial observation + tolerance-1 more before bailing (3 total). + expect(getUpload).toHaveBeenCalledTimes(3); + }); + + it("keeps polling through a single unknown-state blip", async () => { + const getUpload = vi + .fn() + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "mystery-new-state" }) + .mockResolvedValueOnce({ state: "created" }) + .mockResolvedValueOnce({ state: "available" }); + const client = makeClient({ getUpload }); + + const promise = pollUploadAvailable(client, "upload-123"); + await vi.runAllTimersAsync(); + await promise; + + expect(getUpload).toHaveBeenCalledTimes(4); + }); + + it("throws promptly when signal is already aborted", async () => { + const getUpload = vi.fn().mockResolvedValue({ state: "created" }); + const client = makeClient({ getUpload }); + const controller = new AbortController(); + controller.abort(); + await expect( - pollUploadAvailable(client, "upload-123"), - ).rejects.toThrow(/terminal failure state/); + pollUploadAvailable(client, "upload-123", controller.signal), + ).rejects.toThrow(); + expect(getUpload).not.toHaveBeenCalled(); + }); + + it("aborts between polls when signal fires", async () => { + const getUpload = vi.fn().mockResolvedValue({ state: "created" }); + const client = makeClient({ getUpload }); + const controller = new AbortController(); + + const promise = pollUploadAvailable( + client, + "upload-123", + controller.signal, + ); + const settled = promise.catch((err: unknown) => err); + // Let the first poll run and enter the sleep. + await vi.advanceTimersByTimeAsync(0); + controller.abort(); + await vi.runAllTimersAsync(); + + const err = await settled; + expect(err).toBeInstanceOf(Error); expect(getUpload).toHaveBeenCalledOnce(); }); }); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts index 58fbff261d..e665c36914 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts @@ -15,6 +15,13 @@ function describeError(err: unknown): string { } } +function abortError(signal: AbortSignal | undefined): Error { + const reason: unknown = signal?.reason; + if (reason instanceof Error) return reason; + if (typeof reason === "string") return new Error(reason); + return new Error("Aborted"); +} + const POLL_INTERVAL_MS = 2000; const POLL_MAX_ATTEMPTS = 150; // 5 minutes @@ -23,30 +30,86 @@ const RETRY_BASE_DELAY_MS = 1000; const MULTIPART_CONCURRENCY = 4; -// States considered terminal-non-success. Kept as a runtime check because -// the OpenAPI schema currently only declares "created" | "available", but -// the server may introduce failure states before the spec catches up. -const TERMINAL_FAILURE_STATES = new Set([ - "failed", - "rejected", - "cancelled", - "canceled", - "expired", - "errored", -]); +// States declared by the OpenAPI schema. A successful upload transitions +// created → available; anything else is an unknown state we did not opt into. +const KNOWN_INPROGRESS_STATES = new Set(["created"]); +const SUCCESS_STATE = "available"; +// How many consecutive unknown-state observations we tolerate before bailing. +// One transient observation can be legitimate if the server introduces a new +// intermediate state before the schema catches up; three in a row is not. +const UNKNOWN_STATE_TOLERANCE = 3; + +function statusCodeOf(err: unknown): number | undefined { + if (err !== null && typeof err === "object" && "statusCode" in err) { + const sc = (err as { statusCode?: unknown }).statusCode; + if (typeof sc === "number") return sc; + } + if (err !== null && typeof err === "object" && "status" in err) { + const s = (err as { status?: unknown }).status; + if (typeof s === "number") return s; + } + return undefined; +} + +function isRetryableError(err: unknown): boolean { + const sc = statusCodeOf(err); + if (sc === undefined) { + // No status — treat as transport error, retry. + return true; + } + // 4xx are client errors and generally not worth retrying. 408 (timeout) + // and 429 (rate-limit) are the conventional exceptions. + if (sc >= 400 && sc < 500 && sc !== 408 && sc !== 429) { + return false; + } + return true; +} + +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(abortError(signal)); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(abortError(signal)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +interface IRetryOptions { + attempts?: number; + signal?: AbortSignal; +} async function withRetry( fn: () => Promise, label: string, - attempts = RETRY_ATTEMPTS, + options: IRetryOptions = {}, ): Promise { + const { attempts = RETRY_ATTEMPTS, signal } = options; let lastErr: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { + if (signal?.aborted) throw abortError(signal); try { return await fn(); } catch (err) { lastErr = err; - if (attempt === attempts) break; + if (attempt === attempts || !isRetryableError(err)) { + if (!isRetryableError(err)) { + log("debug", "upload attempt failed, not retrying", { + label, + statusCode: statusCodeOf(err), + error: describeError(err), + }); + } + break; + } const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1); log("warn", "upload attempt failed, retrying", { label, @@ -55,7 +118,7 @@ async function withRetry( delayMs: delay, error: describeError(err), }); - await new Promise((resolve) => setTimeout(resolve, delay)); + await abortableSleep(delay, signal); } } throw lastErr; @@ -64,24 +127,40 @@ async function withRetry( export async function pollUploadAvailable( client: NexusV3Client, uploadId: string, + signal?: AbortSignal, ): Promise { + let unknownStreak = 0; for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + if (signal?.aborted) throw abortError(signal); + const upload = await client.getUpload(uploadId); - if (upload.state === "available") { + const state = upload.state as string; + if (state === SUCCESS_STATE) { return; } - const state = upload.state as string; - if (TERMINAL_FAILURE_STATES.has(state)) { - throw new Error( - `Upload ${uploadId} entered terminal failure state: ${state}`, - ); + if (KNOWN_INPROGRESS_STATES.has(state)) { + unknownStreak = 0; + log("debug", "polling upload status", { + uploadId, + state, + attempt, + }); + } else { + unknownStreak += 1; + log("warn", "upload reported unknown state", { + uploadId, + state, + attempt, + unknownStreak, + tolerance: UNKNOWN_STATE_TOLERANCE, + }); + if (unknownStreak >= UNKNOWN_STATE_TOLERANCE) { + throw new Error( + `Upload ${uploadId} reported unknown state "${state}" ${unknownStreak} times; bailing`, + ); + } } - log("debug", "polling upload status", { - uploadId, - state, - attempt, - }); - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + await abortableSleep(POLL_INTERVAL_MS, signal); } throw new Error( `Upload ${uploadId} did not become available within ${(POLL_INTERVAL_MS * POLL_MAX_ATTEMPTS) / 1000}s`, @@ -92,11 +171,19 @@ export async function uploadSinglePart( presignedUrl: string, filePath: string, fileSize: number, + signal?: AbortSignal, ): Promise { await withRetry( () => - uploadWithHeaders(presignedUrl, createReadStream(filePath), fileSize), + uploadWithHeaders( + presignedUrl, + createReadStream(filePath), + fileSize, + undefined, + signal, + ), "single-part upload", + { signal }, ); } @@ -119,14 +206,16 @@ async function uploadPart( end: number, partNumber: number, totalParts: number, + signal?: AbortSignal, ): Promise<{ partNumber: number; etag: string }> { const chunkSize = end - start; const result = await withRetry( () => { const stream = createReadStream(filePath, { start, end: end - 1 }); - return uploadWithHeaders(url, stream, chunkSize); + return uploadWithHeaders(url, stream, chunkSize, undefined, signal); }, `multipart part ${partNumber}/${totalParts}`, + { signal }, ); const rawEtag = result.headers["etag"]; @@ -153,6 +242,7 @@ export async function uploadMultipart( }, filePath: string, fileSize: number, + signal?: AbortSignal, ): Promise { const { part_size_bytes, part_presigned_urls, complete_presigned_url } = multipart; @@ -160,8 +250,8 @@ export async function uploadMultipart( const expectedParts = Math.ceil(fileSize / part_size_bytes); if (expectedParts !== totalParts) { throw new Error( - `Multipart layout mismatch: server returned ${totalParts} presigned URLs ` - + `but ${fileSize} bytes at ${part_size_bytes} bytes/part needs ${expectedParts}`, + `Multipart layout mismatch: server returned ${totalParts} presigned URLs ` + + `but ${fileSize} bytes at ${part_size_bytes} bytes/part needs ${expectedParts}`, ); } const etags: Array<{ partNumber: number; etag: string }> = new Array( @@ -175,6 +265,7 @@ export async function uploadMultipart( { length: Math.min(MULTIPART_CONCURRENCY, totalParts) }, async () => { while (true) { + if (signal?.aborted) throw abortError(signal); const i = next++; if (i >= totalParts) return; const start = i * part_size_bytes; @@ -186,6 +277,7 @@ export async function uploadMultipart( end, i + 1, totalParts, + signal, ); } }, @@ -200,16 +292,21 @@ export async function uploadMultipart( method: "POST", headers: { "Content-Type": "application/xml" }, body: xml, + signal, }); if (!response.ok) { // Throw inside withRetry so transient 5xx responses are retried. + // Status is attached so isRetryableError can skip 4xx responses. const body = await response.text(); - throw new Error( + const err = new Error( `Failed to complete multipart upload: ${response.status} ${body}`, - ); + ) as Error & { statusCode: number }; + err.statusCode = response.status; + throw err; } return response; }, "multipart completion", + { signal }, ); } diff --git a/src/renderer/src/util/network.ts b/src/renderer/src/util/network.ts index 628979c5e4..16eae4bd5a 100644 --- a/src/renderer/src/util/network.ts +++ b/src/renderer/src/util/network.ts @@ -176,15 +176,37 @@ export interface IUploadResult { statusCode: number; } +export class HttpUploadError extends Error { + public readonly statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "HttpUploadError"; + this.statusCode = statusCode; + } +} + +function toAbortError(signal: AbortSignal | undefined): Error { + const reason: unknown = signal?.reason; + if (reason instanceof Error) return reason; + if (typeof reason === "string") return new Error(reason); + return new Error("Upload aborted"); +} + export function uploadWithHeaders( targetUrl: string, dataStream: Readable, dataSize: number, extraHeaders?: Record, + signal?: AbortSignal, ): Promise { return new Promise((resolve, reject) => { log("debug", "uploading file (with headers)", { targetUrl, dataSize }); const started = Date.now(); + + if (signal?.aborted) { + return reject(toAbortError(signal)); + } + const req = request( "PUT", targetUrl, @@ -210,7 +232,10 @@ export function uploadWithHeaders( .on("end", () => { if (statusCode !== 200) { return reject( - new Error(`Upload failed. Status Code: ${statusCode}`), + new HttpUploadError( + `Upload failed. Status Code: ${statusCode}`, + statusCode ?? 0, + ), ); } resolve({ @@ -227,6 +252,17 @@ export function uploadWithHeaders( req.on("error", (err) => { return reject(err); }); + + const onAbort = () => { + const reason = toAbortError(signal); + req.destroy(reason); + dataStream.destroy(reason); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + req.on("close", () => { + signal?.removeEventListener("abort", onAbort); + }); + dataStream.pipe(req, { end: true, }); From 8cebdd9e775bf012d30809964faf0c432d6658a1 Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 22 Apr 2026 17:06:16 +0300 Subject: [PATCH 07/19] Fixed bundle upload --- .../collections/src/collectionExport.ts | 8 +++++- extensions/collections/src/util/util.ts | 26 +++++++++++++++++-- .../nexus_integration/eventHandlers.ts | 2 ++ .../types/INexusAPIExtension.ts | 1 + .../util_v3/manifestMapping.test.ts | 6 +++-- .../util_v3/manifestMapping.ts | 9 +++++-- 6 files changed, 45 insertions(+), 7 deletions(-) diff --git a/extensions/collections/src/collectionExport.ts b/extensions/collections/src/collectionExport.ts index 1e563113ce..6207ddc6ed 100644 --- a/extensions/collections/src/collectionExport.ts +++ b/extensions/collections/src/collectionExport.ts @@ -281,7 +281,9 @@ export async function doExportToAPI( const state: types.IState = api.store.getState(); const mod = state.persistent.mods[gameId][modId]; - const { progress, progressEnd } = makeProgressFunction(api); + const { progress, progressEnd, signal } = makeProgressFunction(api, { + cancellable: true, + }); const errors: Array<{ message: string; replace: any }> = []; @@ -335,6 +337,7 @@ export async function doExportToAPI( filterInfo(info), filePath, collectionId, + signal, cb, ), ); @@ -403,6 +406,9 @@ export async function doExportToAPI( progressEnd(); } catch (err) { progressEnd(); + if (err instanceof util.UserCanceled) { + throw err; + } if (err.name === "ModFileNotFound") { const file = info.mods.find((iter) => iter.source.fileId === err.fileId); api.sendNotification({ diff --git a/extensions/collections/src/util/util.ts b/extensions/collections/src/util/util.ts index ae93cbbdfa..b1f6141c09 100644 --- a/extensions/collections/src/util/util.ts +++ b/extensions/collections/src/util/util.ts @@ -27,12 +27,33 @@ export function hasEditPermissions( return allPermissions.includes("collection:edit"); } -export function makeProgressFunction(api: types.IExtensionApi) { +interface IProgressOptions { + cancellable?: boolean; +} + +export function makeProgressFunction( + api: types.IExtensionApi, + options: IProgressOptions = {}, +) { + const controller = new AbortController(); + + const cancelAction = options.cancellable + ? [ + { + title: "Cancel", + action: () => { + controller.abort(new util.UserCanceled()); + }, + }, + ] + : undefined; + const notificationId = api.sendNotification({ type: "activity", title: "Building Collection", message: "", progress: 0, + actions: cancelAction, }); let notiPerc = 0; @@ -74,6 +95,7 @@ export function makeProgressFunction(api: types.IExtensionApi) { title: "Building Collection", progress: notiPerc, message: notiText, + actions: cancelAction, }); } }; @@ -82,7 +104,7 @@ export function makeProgressFunction(api: types.IExtensionApi) { api.dismissNotification(notificationId); }; - return { progress, progressEnd }; + return { progress, progressEnd, signal: controller.signal }; } export function bbProm( diff --git a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts index ceb47d4910..4e902fc7b9 100644 --- a/src/renderer/src/extensions/nexus_integration/eventHandlers.ts +++ b/src/renderer/src/extensions/nexus_integration/eventHandlers.ts @@ -1342,6 +1342,7 @@ export function onSubmitCollection(api: IExtensionApi, nexus: Nexus) { collectionInfo: ICollectionManifest, assetFilePath: string, collectionId: number, + signal: AbortSignal | undefined, callback: (err: Error, response?: any) => void, ) => { submitCollectionV3( @@ -1350,6 +1351,7 @@ export function onSubmitCollection(api: IExtensionApi, nexus: Nexus) { collectionInfo, assetFilePath, collectionId || undefined, + signal, ) .then((response) => callback(null, response)) .catch((err) => callback(unknownToError(err))); diff --git a/src/renderer/src/extensions/nexus_integration/types/INexusAPIExtension.ts b/src/renderer/src/extensions/nexus_integration/types/INexusAPIExtension.ts index 57ed108b77..5da2d9b22d 100644 --- a/src/renderer/src/extensions/nexus_integration/types/INexusAPIExtension.ts +++ b/src/renderer/src/extensions/nexus_integration/types/INexusAPIExtension.ts @@ -77,6 +77,7 @@ export interface INexusAPIExtension { collectionInfo: ICollectionManifest, assetFilePath: string, collectionId: number, + signal: AbortSignal | undefined, callback: (err: Error, response?: any) => void, ) => void; nexusModUpdate?: ( diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts index 94469ece42..48aa9ad2fd 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.test.ts @@ -136,8 +136,10 @@ describe("toV3CollectionPayload", () => { expect(info.game_versions).toBeNull(); const source = payload.collection_manifest.mods[0].source; - expect(source.mod_id).toBeNull(); - expect(source.file_id).toBeNull(); + // mod_id / file_id fall back to empty strings — V3 backend rejects + // both null and missing fields, requires a String + expect(source.mod_id).toBe(""); + expect(source.file_id).toBe(""); expect(source.md5).toBeNull(); expect(source.file_size).toBeNull(); }); diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts index 60109157cc..6b62749dfe 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/manifestMapping.ts @@ -10,10 +10,15 @@ type V3CollectionManifestModSource = function toV3ManifestModSource( source: ICollectionManifest["mods"][number]["source"], ): V3CollectionManifestModSource { + // NOTE: The V3 OpenAPI spec marks mod_id/file_id as optional (mod_id?: string), + // but the backend's Sorbet runtime treats them as required non-nilable strings. + // Omitting the field raises "Can't set mod_id to nil - need a String" and + // sending null raises "does not allow null values". Send empty strings as a + // workaround for non-nexus sources until the backend spec is fixed. return { type: source.type satisfies V3CollectionManifestModSource["type"], - mod_id: source.modId?.toString() ?? null, - file_id: source.fileId?.toString() ?? null, + mod_id: source.modId?.toString() ?? "", + file_id: source.fileId?.toString() ?? "", md5: source.md5 ?? null, file_size: source.fileSize ?? null, update_policy: From 8867ea78e05f241c1146df71d45b762fd701eb1a Mon Sep 17 00:00:00 2001 From: Vitalii Mikhailov Date: Wed, 22 Apr 2026 17:40:24 +0300 Subject: [PATCH 08/19] No more parallel uploads --- .../nexus_integration/util_v3/uploadV3.ts | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts index e665c36914..8c722a7589 100644 --- a/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts +++ b/src/renderer/src/extensions/nexus_integration/util_v3/uploadV3.ts @@ -28,8 +28,6 @@ const POLL_MAX_ATTEMPTS = 150; // 5 minutes const RETRY_ATTEMPTS = 3; const RETRY_BASE_DELAY_MS = 1000; -const MULTIPART_CONCURRENCY = 4; - // States declared by the OpenAPI schema. A successful upload transitions // created → available; anything else is an unknown state we did not opt into. const KNOWN_INPROGRESS_STATES = new Set(["created"]); @@ -254,35 +252,23 @@ export async function uploadMultipart( `but ${fileSize} bytes at ${part_size_bytes} bytes/part needs ${expectedParts}`, ); } - const etags: Array<{ partNumber: number; etag: string }> = new Array( - totalParts, - ); - - // Worker-pool pattern: N workers drain a shared index counter. Preserves - // insertion order in `etags` regardless of completion order. - let next = 0; - const workers = Array.from( - { length: Math.min(MULTIPART_CONCURRENCY, totalParts) }, - async () => { - while (true) { - if (signal?.aborted) throw abortError(signal); - const i = next++; - if (i >= totalParts) return; - const start = i * part_size_bytes; - const end = Math.min(start + part_size_bytes, fileSize); - etags[i] = await uploadPart( - part_presigned_urls[i], - filePath, - start, - end, - i + 1, - totalParts, - signal, - ); - } - }, - ); - await Promise.all(workers); + const etags: Array<{ partNumber: number; etag: string }> = []; + for (let i = 0; i < totalParts; i++) { + if (signal?.aborted) throw abortError(signal); + const start = i * part_size_bytes; + const end = Math.min(start + part_size_bytes, fileSize); + etags.push( + await uploadPart( + part_presigned_urls[i], + filePath, + start, + end, + i + 1, + totalParts, + signal, + ), + ); + } // Complete the multipart upload by POSTing the ETags XML to S3. const xml = buildCompleteMultipartXml(etags); From fd201fc37829a10725a8c5bd558da6341920cabb Mon Sep 17 00:00:00 2001 From: IDCs Date: Wed, 13 May 2026 08:58:25 +0100 Subject: [PATCH 09/19] updating cherry-pick workflow to follow new schema --- .github/workflows/cherry-pick.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cherry-pick.yml b/.github/workflows/cherry-pick.yml index 63882fdf82..706543cdf9 100644 --- a/.github/workflows/cherry-pick.yml +++ b/.github/workflows/cherry-pick.yml @@ -2,7 +2,7 @@ name: Cherry Pick on: pull_request_target: - branches: [v2*] + branches: [v2*, release/*] types: [closed] permissions: From 10941c83a7970f8e35d7711a931b55643052baa7 Mon Sep 17 00:00:00 2001 From: IDCs Date: Wed, 13 May 2026 09:04:40 +0100 Subject: [PATCH 10/19] another workflow that needs modifying --- .github/workflows/fingerprint-fixed.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/fingerprint-fixed.yml b/.github/workflows/fingerprint-fixed.yml index 9ee989e5aa..1543629547 100644 --- a/.github/workflows/fingerprint-fixed.yml +++ b/.github/workflows/fingerprint-fixed.yml @@ -5,6 +5,7 @@ on: branches: - master - "v*" + - "release/*" permissions: contents: read From 4cf0c77d861e2043536596e7d90296663463c361 Mon Sep 17 00:00:00 2001 From: IDCs Date: Wed, 13 May 2026 09:42:58 +0100 Subject: [PATCH 11/19] fixed notification suppress action icon showing a "cog/gear" suppressible notifications will currently show the suppress action as a cog/gear - this is misleading users as the globally accepted meaning of a cog/gear means to show settings, not suppress. fixes APP-454 --- .../components/Header/Notifications/NotificationControls.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/views/components/Header/Notifications/NotificationControls.tsx b/src/renderer/src/views/components/Header/Notifications/NotificationControls.tsx index af8b38ccd8..3d0422bd65 100644 --- a/src/renderer/src/views/components/Header/Notifications/NotificationControls.tsx +++ b/src/renderer/src/views/components/Header/Notifications/NotificationControls.tsx @@ -1,4 +1,4 @@ -import { mdiCogOutline, mdiClose } from "@mdi/js"; +import { mdiEyeOff, mdiClose } from "@mdi/js"; import React, { type FC, type MouseEvent } from "react"; import { useTranslation } from "react-i18next"; @@ -30,7 +30,7 @@ export const NotificationControls: FC = ({ {allowSuppress && (