mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 23:04:52 +09:00
Support mod portal downloads
This commit is contained in:
1 parent
2936973a75
commit
de8a7ef884
6 files changed
+184
-55
No files matched your search
@@ -118,6 +118,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.UserSetWhitelistedRequest, this.handleUserSetWhitelistedRequest.bind(this));
|
||||
this.handle(lib.UserDeleteRequest, this.handleUserDeleteRequest.bind(this));
|
||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
|
||||
}
|
||||
|
||||
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
|
||||
@@ -537,9 +538,66 @@ export default class ControlConnection extends BaseConnection {
|
||||
// The Response class is defined inline within the Request class
|
||||
return new lib.ModPortalGetAllRequest.Response(mods);
|
||||
} catch (error: any) {
|
||||
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error.message}`);
|
||||
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`);
|
||||
// Propagate a user-friendly error back to the client
|
||||
throw new lib.RequestError(`Portal mod fetch failed: ${error.message}`);
|
||||
throw new lib.RequestError(`Portal mod fetch failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle request to download a mod from the Factorio Mod Portal to the controller.
|
||||
* @param request - Request object with mod name, version, and factorioVersion.
|
||||
*/
|
||||
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise<lib.ModInfo> {
|
||||
const { name, version, factorioVersion } = request;
|
||||
const filename = lib.ModInfo.filename(name, version);
|
||||
|
||||
// Check if mod already exists
|
||||
if (this._controller.modStore.files.has(filename)) {
|
||||
logger.verbose(`Mod ${filename} already exists, skipping download.`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return this._controller.modStore.files.get(filename)!;
|
||||
}
|
||||
|
||||
// Get Factorio credentials from config
|
||||
const username = this._controller.config.get("controller.factorio_username");
|
||||
const token = this._controller.config.get("controller.factorio_token");
|
||||
|
||||
if (!username || !token) {
|
||||
// Attempt download without credentials - might fail for some mods
|
||||
logger.warn(`Factorio username or token not configured. Attempting anonymous download for ${filename}.`);
|
||||
// Note: The Factorio Mod Portal API might reject downloads without credentials.
|
||||
// Consider throwing an error here if credentials are required by your setup.
|
||||
// throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`);
|
||||
const modMap = new Map([[name, version]]);
|
||||
await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion);
|
||||
|
||||
// Verify download and return ModInfo
|
||||
const downloadedMod = this._controller.modStore.files.get(filename);
|
||||
if (!downloadedMod) {
|
||||
throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`);
|
||||
}
|
||||
logger.info(`Successfully downloaded mod ${filename}.`);
|
||||
return downloadedMod;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Error downloading mod ${filename} from portal: ${error.message}`);
|
||||
// Improve error message clarity
|
||||
let errorMessage = `Mod portal download failed for ${name} v${version}`;
|
||||
if (error instanceof lib.RequestError) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
|
||||
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
errorMessage += ": Mod version not found on the portal.";
|
||||
} else {
|
||||
errorMessage += `: ${error.message}`;
|
||||
}
|
||||
throw new lib.RequestError(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ interface ModPortalResponse {
|
||||
}
|
||||
|
||||
const MOD_PORTAL_API_URL = "https://mods.factorio.com/api/mods";
|
||||
const MOD_PORTAL_MAX_PAGE_SIZE = 10000; // Or largest allowed by API
|
||||
const MOD_PORTAL_MAX_PAGE_SIZE = 1000; // Or largest allowed by API
|
||||
|
||||
export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, ModStoreEvents> {
|
||||
constructor(
|
||||
@@ -266,8 +266,6 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
) {
|
||||
if (releases.length === 0) { }
|
||||
|
||||
// releases.find() // work on this
|
||||
|
||||
const latestRelease = releases.find(release => release.version === version);
|
||||
|
||||
if (latestRelease === undefined) {
|
||||
@@ -280,7 +278,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
const fileName = `${this.modsDirectory}/${latestRelease.file_name}.tmp`;
|
||||
await ModStore.downloadFile(url, fileName);
|
||||
await fs.rename(fileName, `${this.modsDirectory}/${latestRelease.file_name}`);
|
||||
this.addMod(await this.loadFile(`${this.modsDirectory}/${latestRelease.file_name}`));
|
||||
this.addMod(await this.loadFile(latestRelease.file_name));
|
||||
}
|
||||
|
||||
private async downloadModsChunk(
|
||||
@@ -473,43 +471,38 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
|
||||
let allMods: ModDetails[] = [];
|
||||
let hasMorePages = true;
|
||||
|
||||
try {
|
||||
while (hasMorePages) {
|
||||
const url = new URL(MOD_PORTAL_API_URL);
|
||||
url.searchParams.set("page_size", String(MOD_PORTAL_MAX_PAGE_SIZE));
|
||||
url.searchParams.set("version", factorioVersion);
|
||||
url.searchParams.set("page", String(currentPage));
|
||||
url.searchParams.set("hide_deprecated", String(hide_deprecated));
|
||||
while (hasMorePages) {
|
||||
const url = new URL(MOD_PORTAL_API_URL);
|
||||
url.searchParams.set("page_size", String(MOD_PORTAL_MAX_PAGE_SIZE));
|
||||
url.searchParams.set("version", factorioVersion);
|
||||
url.searchParams.set("page", String(currentPage));
|
||||
url.searchParams.set("hide_deprecated", String(hide_deprecated));
|
||||
|
||||
logger.verbose(`Fetching mod portal page ${currentPage}: ${url.toString()}`);
|
||||
const response = await fetch(url.toString());
|
||||
logger.verbose(`Fetching mod portal page ${currentPage}: ${url.toString()}`);
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = "";
|
||||
try {
|
||||
errorDetail = await response.text();
|
||||
} catch (bodyError) {
|
||||
logger.warn("Failed to read response body for failed portal request");
|
||||
}
|
||||
let errorMessage = `Mod portal fetch page ${currentPage} failed: `;
|
||||
errorMessage += `${response.status} ${response.statusText}`;
|
||||
if (errorDetail) {
|
||||
errorMessage += ` - ${errorDetail}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
if (!response.ok) {
|
||||
let errorDetail = "";
|
||||
try {
|
||||
errorDetail = await response.text();
|
||||
} catch (bodyError) {
|
||||
logger.warn("Failed to read response body for failed portal request");
|
||||
}
|
||||
|
||||
const data = await response.json() as ModPortalResponse;
|
||||
allMods = allMods.concat(data.results);
|
||||
|
||||
hasMorePages = currentPage < data.pagination.page_count;
|
||||
currentPage += 1;
|
||||
let errorMessage = `Mod portal fetch page ${currentPage} failed: `;
|
||||
errorMessage += `${response.status} ${response.statusText}`;
|
||||
if (errorDetail) {
|
||||
errorMessage += ` - ${errorDetail}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
|
||||
return allMods;
|
||||
} catch (err: any) {
|
||||
logger.error(err);
|
||||
throw new Error(err);
|
||||
|
||||
const data = await response.json() as ModPortalResponse;
|
||||
allMods = allMods.concat(data.results);
|
||||
|
||||
hasMorePages = currentPage < data.pagination.page_count;
|
||||
currentPage += 1;
|
||||
}
|
||||
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
|
||||
return allMods;
|
||||
}
|
||||
}
|
||||
@@ -355,3 +355,39 @@ export class ModUpdatesEvent {
|
||||
return new this(json.updates.map(update => ModInfo.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request mods to be downloaded from the Factorio mod portal to the controller.
|
||||
*
|
||||
* The controller handles the actual download process asynchronously.
|
||||
* Requires Factorio credentials to be configured on the controller if the portal
|
||||
* requires authentication for downloads.
|
||||
*
|
||||
* @param name - Name of the mod to download.
|
||||
* @param version - Version of the mod to download.
|
||||
* @param factorioVersion - Factorio version context for the download.
|
||||
*/
|
||||
export class ModPortalDownloadRequest {
|
||||
declare ["constructor"]: typeof ModPortalDownloadRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.mod.download_from_portal" as const;
|
||||
static Response = ModInfo;
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public factorioVersion: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": Type.String(),
|
||||
"factorioVersion": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.name, json.version, json.factorioVersion);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
mod.ModDeleteRequest,
|
||||
mod.ModPackUpdatesEvent,
|
||||
mod.ModUpdatesEvent,
|
||||
mod.ModPortalDownloadRequest,
|
||||
|
||||
user.PermissionListRequest,
|
||||
user.RoleListRequest,
|
||||
|
||||
@@ -387,7 +387,7 @@ definePermission({
|
||||
});
|
||||
definePermission({
|
||||
name: "core.mod.upload",
|
||||
title: "Upload mod",
|
||||
title: "Upload mods",
|
||||
description: "Upload mods to the controller.",
|
||||
});
|
||||
definePermission({
|
||||
@@ -401,6 +401,12 @@ definePermission({
|
||||
title: "Delete mod",
|
||||
description: "Delete mods stored on the controller.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.mod.download_from_portal",
|
||||
title: "Download mods from portal",
|
||||
description: "Allow downloading mods directly from the Factorio mod portal to the controller.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.permission.list",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { Fragment, useState, useContext, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip } from "antd";
|
||||
import { ImportOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, Upload, Alert, Select, Tooltip, notification,
|
||||
} from "antd";
|
||||
import {
|
||||
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -471,19 +475,50 @@ function SearchModsButton() {
|
||||
key: "actions",
|
||||
align: "center",
|
||||
render: (_, record) => (
|
||||
<Tooltip title="Open in Factorio Mod Portal">
|
||||
<Typography.Link
|
||||
href={`https://mods.factorio.com/mod/${record.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://mods.factorio.com/static/favicon.ico"
|
||||
alt="Factorio Mod Portal"
|
||||
style={{ height: "1em", verticalAlign: "middle" }}
|
||||
/>
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
<Space>
|
||||
<Tooltip title="Open in Factorio Mod Portal">
|
||||
<Typography.Link
|
||||
href={`https://mods.factorio.com/mod/${record.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://mods.factorio.com/static/favicon.ico"
|
||||
alt="Factorio Mod Portal"
|
||||
style={{ height: "1em", verticalAlign: "middle" }}
|
||||
/>
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
{account.hasPermission("core.mod.download") && record.latest_release && (
|
||||
<Tooltip title="Download to Controller">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DownloadOutlined />}
|
||||
disabled={!record.latest_release?.version}
|
||||
onClick={() => {
|
||||
if (record.latest_release?.version) {
|
||||
control.send(
|
||||
new lib.ModPortalDownloadRequest(
|
||||
record.name,
|
||||
record.latest_release.version,
|
||||
factorioVersion
|
||||
)
|
||||
).then(() => {
|
||||
notification.success({
|
||||
message: "Download started",
|
||||
description: `${record.title || record.name} v${
|
||||
record.latest_release?.version
|
||||
} is being downloaded to the controller.`,
|
||||
});
|
||||
}).catch(
|
||||
notifyErrorHandler("Error starting mod download")
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
Reference in new issue
Block a user