diff --git a/CHANGELOG.md b/CHANGELOG.md index 7844f35f..39386312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ - Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins. - Added centralized logging for the cluster. All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried. -- Added remote save management with the ability to list, create, upload, download, delete and load saves for instances. +- Added remote save management with the ability to list, create, rename, copy, upload, download, transfer, delete and load saves for instances. ### Features diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index b4cc3e5d..41454c3d 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -533,8 +533,11 @@ async function loadMapSettings(args) { }; } -instanceCommands.add(new libCommand.Command({ - definition: ["list-saves ", "list saves on an instance", (yargs) => { +const instanceSaveCommands = new libCommand.CommandTree({ + name: "save", alias: ["s"], description: "Instance save management", +}); +instanceSaveCommands.add(new libCommand.Command({ + definition: ["list ", "list saves on an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to list saves on", type: "string" }); }], handler: async function(args, control) { @@ -548,8 +551,8 @@ instanceCommands.add(new libCommand.Command({ }, })); -instanceCommands.add(new libCommand.Command({ - definition: ["create-save [name]", "Create a new save on an instance", (yargs) => { +instanceSaveCommands.add(new libCommand.Command({ + definition: ["create [name]", "Create a new save on an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to create on", type: "string" }); yargs.positional("name", { describe: "Name of save to create.", type: "string", default: "world.zip" }); yargs.options({ @@ -573,8 +576,40 @@ instanceCommands.add(new libCommand.Command({ }, })); -instanceCommands.add(new libCommand.Command({ - definition: ["upload-save ", "Upload a save to an instance", (yargs) => { +instanceSaveCommands.add(new libCommand.Command({ + definition: ["rename ", "Rename a save on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to rename save on", type: "string" }); + yargs.positional("old-name", { describe: "Old name of save.", type: "string" }); + yargs.positional("new-name", { describe: "New name of save.", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await libLink.messages.renameSave.send(control, { + instance_id: instanceId, + old_name: args.oldName, + new_name: args.newName, + }); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["copy ", "Copy a save on an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to copy save on", type: "string" }); + yargs.positional("source", { describe: "Save to copy.", type: "string" }); + yargs.positional("destination", { describe: "Name of copy.", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await libLink.messages.copySave.send(control, { + instance_id: instanceId, + source: args.source, + destination: args.destination, + }); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["upload ", "Upload a save to an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to upload to", type: "string" }); yargs.positional("filepath", { describe: "Path to save to upload", type: "string" }); yargs.options({ @@ -624,8 +659,36 @@ instanceCommands.add(new libCommand.Command({ }, })); -instanceCommands.add(new libCommand.Command({ - definition: ["download-save ", "Download a save from an instance", (yargs) => { +instanceSaveCommands.add(new libCommand.Command({ + definition: [ + "transfer [target-save]", + "Transfer a save between instances", + (yargs) => { + yargs.positional("source-instance", { describe: "Instance to transfer save from", type: "string" }); + yargs.positional("source-save", { describe: "Save to transfer.", type: "string" }); + yargs.positional("target-instance", { describe: "Instance to transfer to", type: "string" }); + yargs.positional("target-save", { describe: "Name to give transferred save.", type: "string" }); + yargs.options({ + "copy": { describe: "Copy instead of moving the save", type: "boolean", default: false }, + }); + }, + ], + handler: async function(args, control) { + let sourceInstanceId = await libCommand.resolveInstance(control, args.sourceInstance); + let targetInstanceId = await libCommand.resolveInstance(control, args.targetInstance); + let result = await libLink.messages.transferSave.send(control, { + instance_id: sourceInstanceId, + source_save: args.sourceSave, + target_instance_id: targetInstanceId, + target_save: args.targetSave || args.sourceSave, + copy: args.copy, + }); + print(`Transferred as ${result.save} to ${args.targetInstance}.`); + }, +})); + +instanceSaveCommands.add(new libCommand.Command({ + definition: ["download ", "Download a save from an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to download save from", type: "string" }); yargs.positional("save", { describe: "Save to download", type: "string" }); }], @@ -669,8 +732,8 @@ instanceCommands.add(new libCommand.Command({ }, })); -instanceCommands.add(new libCommand.Command({ - definition: ["delete-save ", "Delete a save from an instance", (yargs) => { +instanceSaveCommands.add(new libCommand.Command({ + definition: ["delete ", "Delete a save from an instance", (yargs) => { yargs.positional("instance", { describe: "Instance to delete save from", type: "string" }); yargs.positional("save", { describe: "Save to delete", type: "string" }); }], @@ -682,6 +745,7 @@ instanceCommands.add(new libCommand.Command({ }); }, })); +instanceCommands.add(instanceSaveCommands); instanceCommands.add(new libCommand.Command({ definition: ["export-data ", "Export item icons and locale from instance", (yargs) => { diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 06135ff4..dd187266 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -550,6 +550,28 @@ messages.createSave = new Request({ }, }); +messages.renameSave = new Request({ + type: "rename_save", + links: ["control-master", "master-slave"], + permission: "core.instance.save.rename", + forwardTo: "instance", + requestProperties: { + "old_name": { type: "string" }, + "new_name": { type: "string" }, + }, +}); + +messages.copySave = new Request({ + type: "copy_save", + links: ["control-master", "master-slave"], + permission: "core.instance.save.copy", + forwardTo: "instance", + requestProperties: { + "source": { type: "string" }, + "destination": { type: "string" }, + }, +}); + messages.deleteSave = new Request({ type: "delete_save", links: ["control-master", "master-slave"], @@ -573,6 +595,22 @@ messages.downloadSave = new Request({ }, }); +messages.transferSave = new Request({ + type: "transfer_save", + links: ["control-master", "master-slave"], + permission: "core.instance.save.transfer", + requestProperties: { + "instance_id": { type: "integer" }, + "source_save": { type: "string" }, + "target_instance_id": { type: "integer" }, + "target_save": { type: "string" }, + "copy": { type: "boolean" }, + }, + responseProperties: { + "save": { type: "string" }, + }, +}); + messages.pullSave = new Request({ type: "pull_save", links: ["master-slave"], diff --git a/packages/lib/users.js b/packages/lib/users.js index f5f59f1a..8432191b 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -379,7 +379,7 @@ definePermission({ }); definePermission({ name: "core.instance.save.list", - title: "List saves on instance", + title: "List saves", description: "List the saves currently on the instance.", }); definePermission({ @@ -389,22 +389,37 @@ definePermission({ }); definePermission({ name: "core.instance.save.create", - title: "Create new instance save", + title: "Create new save", description: "Create new savegames on instances.", }); +definePermission({ + name: "core.instance.save.rename", + title: "Rename save", + description: "Rename savegames on instances.", +}); +definePermission({ + name: "core.instance.save.copy", + title: "Copy save", + description: "Create copies of savegames on instances.", +}); definePermission({ name: "core.instance.save.delete", - title: "Delete instance save", + title: "Delete save", description: "Delete savegames on instances.", }); definePermission({ name: "core.instance.save.upload", - title: "Upload save to instance", + title: "Upload save", description: "Upload savegames to instances.", }); +definePermission({ + name: "core.instance.save.transfer", + title: "Transfer save", + description: "Transfer savegames between instances.", +}); definePermission({ name: "core.instance.save.download", - title: "Download save from instance", + title: "Download save", description: "Download savegames from instances.", }); definePermission({ diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index 068076ee..05a31320 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -1,5 +1,6 @@ "use strict"; const jwt = require("jsonwebtoken"); +const events = require("events"); const libConfig = require("@clusterio/lib/config"); const libErrors = require("@clusterio/lib/errors"); @@ -159,10 +160,7 @@ class ControlConnection extends BaseConnection { async getInstanceRequestHandler(message) { let id = message.data.id; - let instance = this._master.instances.get(id); - if (!instance) { - throw new libErrors.RequestError(`Instance with ID ${id} does not exist`); - } + let instance = this._master.getRequestInstance(id); return { id, @@ -249,11 +247,7 @@ class ControlConnection extends BaseConnection { } async deleteInstanceRequestHandler(message, request) { - let instance = this._master.instances.get(message.data.instance_id); - if (!instance) { - throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`); - } - + let instance = this._master.getRequestInstance(message.data.instance_id); if (instance.config.get("instance.assigned_slave") !== null) { await this.forwardRequestToInstance(message, request); } @@ -266,11 +260,7 @@ class ControlConnection extends BaseConnection { } async getInstanceConfigRequestHandler(message) { - let instance = this._master.instances.get(message.data.instance_id); - if (!instance) { - throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`); - } - + let instance = this._master.getRequestInstance(message.data.instance_id); return { serialized_config: instance.config.serialize("control"), }; @@ -290,11 +280,7 @@ class ControlConnection extends BaseConnection { } async setInstanceConfigFieldRequestHandler(message) { - let instance = this._master.instances.get(message.data.instance_id); - if (!instance) { - throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`); - } - + let instance = this._master.getRequestInstance(message.data.instance_id); if (message.data.field === "instance.assigned_slave") { throw new libErrors.RequestError("instance.assigned_slave must be set through the assign-slave interface"); } @@ -309,11 +295,7 @@ class ControlConnection extends BaseConnection { } async setInstanceConfigPropRequestHandler(message) { - let instance = this._master.instances.get(message.data.instance_id); - if (!instance) { - throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`); - } - + let instance = this._master.getRequestInstance(message.data.instance_id); let { field, prop, value } = message.data; instance.config.setProp(field, prop, value, "control"); await this.updateInstanceConfig(instance); @@ -321,10 +303,7 @@ class ControlConnection extends BaseConnection { async assignInstanceCommandRequestHandler(message, request) { let { slave_id, instance_id } = message.data; - let instance = this._master.instances.get(instance_id); - if (!instance) { - throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`); - } + let instance = this._master.getRequestInstance(instance_id); // Check if target slave is connected let newSlaveConnection; @@ -395,6 +374,85 @@ class ControlConnection extends BaseConnection { return { stream_id: stream.id }; } + async transferSaveRequestHandler(message, request) { + let { source_save, target_save, copy, instance_id, target_instance_id } = message.data; + if (copy) { + this.user.checkPermission("core.instance.save.copy"); + } else if (source_save !== target_save) { + this.user.checkPermission("core.instance.save.rename"); + } + + if (instance_id === target_instance_id) { + throw new libErrors.RequestError("Source and target instance may not be the same"); + } + let sourceInstance = this._master.getRequestInstance(instance_id); + let targetInstance = this._master.getRequestInstance(target_instance_id); + let sourceSlaveId = sourceInstance.config.get("instance.assigned_slave"); + let targetSlaveId = targetInstance.config.get("instance.assigned_slave"); + if (sourceSlaveId === null) { + throw new libErrors.RequestError("Source instance is not assigned a slave"); + } + if (targetSlaveId === null) { + throw new libErrors.RequestError("Target instance is not assigned a slave"); + } + + // Let slave handle request if source and target is on the same slave. + if (sourceSlaveId === targetSlaveId) { + return await this.forwardRequestToInstance(message, request); + } + + // Check connectivity + let sourceSlaveConnection = this._master.wsServer.slaveConnections.get(sourceSlaveId); + if (!sourceSlaveConnection || sourceSlaveConnection.closing) { + throw new libErrors.RequestError("Source slave is not connected to the master server"); + } + + let targetSlaveConnection = this._master.wsServer.slaveConnections.get(targetSlaveId); + if (!targetSlaveConnection || targetSlaveConnection.closing) { + throw new libErrors.RequestError("Target slave is not connected to the master server"); + } + + // Create stream to proxy from target to source + let stream = await routes.createProxyStream(this._master.app); + stream.events.on("timeout", () => { + if (stream.source) { + stream.source.destroy(); + } + stream.events.emit("error", new libErrors.RequestError("Timed out establishing transfer stream")); + }); + + // Ignore errors if not listening for them to avoid crash. + stream.events.on("error", () => { /* ignore */ }); + + // Establish push from source slave to stream, this is done first to + // ensure the file size is known prior to the target slave pull. + await Promise.all([ + this._master.forwardRequestToInstance(libLink.messages.pushSave, { + instance_id, + stream_id: stream.id, + save: source_save, + }), + events.once(stream.events, "source"), + ]); + + // Establish pull from target slave to stream and wait for completion. + let result = await this._master.forwardRequestToInstance(libLink.messages.pullSave, { + instance_id: target_instance_id, + stream_id: stream.id, + filename: target_save, + }); + + // Delete source save if this is not a copy + if (!copy) { + await this._master.forwardRequestToInstance(libLink.messages.deleteSave, { + instance_id, + save: source_save, + }); + } + + return { save: result.save }; + } + async setLogSubscriptionsRequestHandler(message) { this.logSubscriptions = message.data; this.updateLogSubscriptions(); diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index 3d83e849..863bf48a 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -424,6 +424,21 @@ class Master { } } + /** + * Get instance by ID for a request + * + * @param {number} instanceId - ID of instance to get. + * @returns {object} instance + * @throws {module:lib/errors.RequestError} if the instance does not exist. + */ + getRequestInstance(instanceId) { + let instance = this.instances.get(instanceId); + if (!instance) { + throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`); + } + return instance; + } + addInstanceHooks(instance) { instance.config.on("fieldChanged", (group, field, prev) => { if (group.name === "instance" && field === "name") { @@ -608,11 +623,7 @@ class Master { * @returns {object} data returned from the request. */ async forwardRequestToInstance(request, data) { - let instance = this.instances.get(data.instance_id); - if (!instance) { - throw new libErrors.RequestError(`Instance with ID ${data.instance_id} does not exist`); - } - + let instance = this.getRequestInstance(data.instance_id); let slaveId = instance.config.get("instance.assigned_slave"); if (slaveId === null) { throw new libErrors.RequestError("Instance is not assigned to a slave"); diff --git a/packages/slave/slave.js b/packages/slave/slave.js index f4916ff8..db3d0281 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -1077,6 +1077,15 @@ function checkFilename(name) { } } +function checkRequestSaveName(name) { + try { + checkFilename(name); + } catch (err) { + throw new libErrors.RequestError(`Save name ${err.message}`); + } +} + + /** * Handles running the slave * @@ -1351,6 +1360,14 @@ class Slave extends libLink.Link { } } + getRequestInstanceInfo(instanceId) { + let instanceInfo = this.instanceInfos.get(instanceId); + if (!instanceInfo) { + throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`); + } + return instanceInfo; + } + /** * Initialize and connect an unloaded instance * @@ -1358,11 +1375,7 @@ class Slave extends libLink.Link { * @returns {module:slave/slave~InstanceConnection} connection to instance. */ async _connectInstance(instanceId) { - let instanceInfo = this.instanceInfos.get(instanceId); - if (!instanceInfo) { - throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`); - } - + let instanceInfo = this.getRequestInstanceInfo(instanceId); if (this.instanceConnections.has(instanceId)) { throw new libErrors.RequestError(`Instance with ID ${instanceId} is running`); } @@ -1424,11 +1437,7 @@ class Slave extends libLink.Link { return await request.send(instanceConnection, message.data); } - let instanceInfo = this.instanceInfos.get(instanceId); - if (!instanceInfo) { - throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`); - } - + let instanceInfo = this.getRequestInstanceInfo(instanceId); return { list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null), }; @@ -1440,6 +1449,85 @@ class Slave extends libLink.Link { await request.send(instanceConnection, message.data); } + async renameSaveRequestHandler(message) { + let { instance_id, old_name, new_name } = message.data; + checkRequestSaveName(old_name); + checkRequestSaveName(new_name); + let instanceInfo = this.getRequestInstanceInfo(instance_id); + try { + await fs.move( + path.join(instanceInfo.path, "saves", old_name), + path.join(instanceInfo.path, "saves", new_name), + { overwrite: false }, + ); + } catch (err) { + if (err.code === "ENOENT") { + throw new libErrors.RequestError(`${old_name} does not exist`); + } + throw err; + } + await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); + } + + async copySaveRequestHandler(message) { + let { instance_id, source, destination } = message.data; + checkRequestSaveName(source); + checkRequestSaveName(destination); + let instanceInfo = this.getRequestInstanceInfo(instance_id); + try { + await fs.copy( + path.join(instanceInfo.path, "saves", source), + path.join(instanceInfo.path, "saves", destination), + { overwrite: false, errorOnExist: true }, + ); + } catch (err) { + if (err.code === "ENOENT") { + throw new libErrors.RequestError(`${old_name} does not exist`); + } + throw err; + } + await this.sendSaveListUpdate(instance_id, path.join(instanceInfo.path, "saves")); + } + + async transferSaveRequestHandler(message) { + let { source_save, target_save, copy, instance_id, target_instance_id } = message.data; + checkRequestSaveName(source_save); + checkRequestSaveName(target_save); + let sourceInstanceInfo = this.getRequestInstanceInfo(instance_id); + let targetInstanceInfo = this.getRequestInstanceInfo(target_instance_id); + + // For consistency with remote transfer initiated through pullSave the + // target is renamed if it already exists. + target_save = await libFileOps.findUnusedName( + path.join(targetInstanceInfo.path, "saves"), target_save, ".zip" + ); + + try { + if (copy) { + await fs.copy( + path.join(sourceInstanceInfo.path, "saves", source_save), + path.join(targetInstanceInfo.path, "saves", target_save), + { overwrite: true }, + ); + } else { + await fs.move( + path.join(sourceInstanceInfo.path, "saves", source_save), + path.join(targetInstanceInfo.path, "saves", target_save), + { overwrite: true }, + ); + } + } catch (err) { + if (err.code === "ENOENT") { + throw new libErrors.RequestError(`${source_save} does not exist`); + } + throw err; + } + await this.sendSaveListUpdate(instance_id, path.join(sourceInstanceInfo.path, "saves")); + await this.sendSaveListUpdate(target_instance_id, path.join(targetInstanceInfo.path, "saves")); + + return { save: target_save }; + } + async sendSaveListUpdate(instance_id, savesDir) { let instanceConnection = this.instanceConnections.get(instance_id); let saveList; @@ -1454,15 +1542,8 @@ class Slave extends libLink.Link { async deleteSaveRequestHandler(message) { let { instance_id, save } = message.data; - try { - checkFilename(save); - } catch (err) { - throw new libErrors.RequestError(`Save name ${err.message}`); - } - let instanceInfo = this.instanceInfos.get(instance_id); - if (!instanceInfo) { - throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`); - } + checkRequestSaveName(save); + let instanceInfo = this.getRequestInstanceInfo(instance_id); try { await fs.unlink(path.join(instanceInfo.path, "saves", save)); @@ -1477,15 +1558,8 @@ class Slave extends libLink.Link { async pullSaveRequestHandler(message) { let { instance_id, stream_id, filename } = message.data; - try { - checkFilename(filename); - } catch (err) { - throw new libErrors.RequestError(`Save name ${err.message}`); - } - let instanceInfo = this.instanceInfos.get(instance_id); - if (!instanceInfo) { - throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`); - } + checkRequestSaveName(filename); + let instanceInfo = this.getRequestInstanceInfo(instance_id); let url = new URL(this.config.get("slave.master_url")); url.pathname += `api/stream/${stream_id}`; @@ -1528,15 +1602,8 @@ class Slave extends libLink.Link { async pushSaveRequestHandler(message) { let { instance_id, stream_id, save } = message.data; - try { - checkFilename(save); - } catch (err) { - throw new libErrors.RequestError(`Save name ${err.message}`); - } - let instanceInfo = this.instanceInfos.get(instance_id); - if (!instanceInfo) { - throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`); - } + checkRequestSaveName(save); + let instanceInfo = this.getRequestInstanceInfo(instance_id); let content; try { diff --git a/packages/web_ui/src/components/SavesList.jsx b/packages/web_ui/src/components/SavesList.jsx index 72c0554a..133cffac 100644 --- a/packages/web_ui/src/components/SavesList.jsx +++ b/packages/web_ui/src/components/SavesList.jsx @@ -1,5 +1,8 @@ import React, { useContext, useState } from "react"; -import { Button, List, Popconfirm, Progress, Space, Table, Tooltip, Upload } from "antd"; +import { + message, Button, Checkbox, Form, Input, List, Modal, + Popconfirm, Progress, Select, Space, Table, Tooltip, Upload, +} from "antd"; import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined"; import LeftOutlined from "@ant-design/icons/LeftOutlined"; @@ -9,10 +12,173 @@ import { useAccount } from "../model/account"; import ControlContext from "./ControlContext"; import CreateSaveModal from "./CreateSaveModal"; import SectionHeader from "./SectionHeader"; +import { useInstanceList } from "../model/instance"; import { useSaves } from "../model/saves"; import { notifyErrorHandler } from "../util/notify"; +function RenameModal(props) { + let control = useContext(ControlContext); + let [visible, setVisible] = useState(false); + let [form] = Form.useForm(); + + return <> + + form.submit()} + onCancel={() => setVisible(false)} + > +
{ + libLink.messages.renameSave.send(control, + { instance_id: props.instanceId, old_name: props.save.name, new_name: values.newName } + ).then(() => { + setVisible(false); + form.resetFields(); + }).catch(notifyErrorHandler("Error renaming save")); + }} + > + + + +
+
+ ; +} + +function CopyModal(props) { + let control = useContext(ControlContext); + let [visible, setVisible] = useState(false); + let [form] = Form.useForm(); + + return <> + + form.submit()} + onCancel={() => setVisible(false)} + > +
{ + libLink.messages.copySave.send(control, + { instance_id: props.instanceId, source: props.save.name, destination: values.newName } + ).then(() => { + setVisible(false); + form.resetFields(); + }).catch(notifyErrorHandler("Error copying save")); + }} + > + + + +
+
+ ; +} + +function TransferModal(props) { + let account = useAccount(); + let control = useContext(ControlContext); + let [visible, setVisible] = useState(false); + let [form] = Form.useForm(); + let [instanceList] = useInstanceList(); + + return <> + + form.submit()} + onCancel={() => setVisible(false)} + destroyOnClose + > +
{ + setVisible(false); + let hide = message.loading("Transferring save...", 0); + libLink.messages.transferSave.send(control, { + instance_id: props.instanceId, + source_save: props.save.name, + target_instance_id: values.targetInstanceId, + target_save: values.transferredName || props.save.name, + copy: values.copy, + }).then(() => { + message.success("Transfer complete"); + form.resetFields(); + }).catch( + notifyErrorHandler("Error transfering save") + ).finally(() => hide()); + }} + > + + + + + + + + + +
+
+ ; +} + function formatBytes(bytes) { if (bytes === 0) { return "0 Bytes"; @@ -81,6 +247,12 @@ export default function SavesList(props) { ); }} >Load save} + {account.hasPermission("core.instance.save.rename") && } + {account.hasPermission("core.instance.save.copy") && } {account.hasPermission("core.instance.save.download") && } + {account.hasPermission("core.instance.save.transfer") && } {account.hasPermission("core.instance.save.delete") &&