fix: Fixed tests
feat: Added RClone integration feat: Implemented plugin settings feat: Updated minimal store version test: Fixed tests feat: Moved store and igdb and es-de to their own plugins
This commit is contained in:
parent
444d8c4c27
commit
c09fbd3dc8
115 changed files with 4139 additions and 1502 deletions
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "com.simeonradivoev.gameflow.rclone",
|
||||
"displayName": "Rclone Integration",
|
||||
"version": "0.0.1",
|
||||
"description": "Rclone integration for syncing saves",
|
||||
"main": "./rclone.ts",
|
||||
"icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png",
|
||||
"category": "saves",
|
||||
"keywords": [
|
||||
"integration",
|
||||
"rclone"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||
import desc from './package.json';
|
||||
import { config, events } from "@/bun/api/app";
|
||||
import path, { dirname } from 'node:path';
|
||||
import unzip from 'unzip-stream';
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import fs from 'node:fs/promises';
|
||||
import { randomUUIDv7, sleep } from "bun";
|
||||
import z from "zod";
|
||||
import { createInterface } from "node:readline";
|
||||
import { redirect } from "elysia";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { id } from "zod/v4/locales";
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
runWebGui: z.boolean()
|
||||
.default(false)
|
||||
.describe("Run the Web GUI that can be accessed at http://localhost:5572")
|
||||
.meta({ title: "Run Web GUI" }),
|
||||
globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"),
|
||||
webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"),
|
||||
remoteName: z.string().default(""),
|
||||
verboseLog: z.boolean()
|
||||
.default(false)
|
||||
.describe("Show detailed log of operation for debugging")
|
||||
.meta({ $comment: JSON.stringify({ category: "debug" }) })
|
||||
});
|
||||
|
||||
type SettingsType = z.infer<typeof SettingsSchema>;
|
||||
const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm;
|
||||
|
||||
export default class RcloneIntegration implements PluginType<SettingsType>
|
||||
{
|
||||
settingsSchema = SettingsSchema;
|
||||
rclonePath: string | undefined;
|
||||
server: Bun.Subprocess | undefined;
|
||||
password: string;
|
||||
user = "gameflow";
|
||||
loginUrl: string | undefined = undefined;
|
||||
eventsNames = [{
|
||||
id: "open-web-gui",
|
||||
title: "Open Web GUI",
|
||||
description: "Open Web GUI",
|
||||
action: "Open"
|
||||
}, {
|
||||
id: "refresh",
|
||||
title: "Refresh Sources",
|
||||
action: "Refresh"
|
||||
}];
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.password = randomUUIDv7();
|
||||
}
|
||||
|
||||
async onEvent (id: string)
|
||||
{
|
||||
switch (id)
|
||||
{
|
||||
case "open-web-gui":
|
||||
return { openTab: this.loginUrl };
|
||||
break;
|
||||
case "refresh":
|
||||
await this.refresh();
|
||||
return { reload: true };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async setup (ctx: PluginLoadingContextType<SettingsType>)
|
||||
{
|
||||
ctx.zodRegistry.add(SettingsSchema.shape.runWebGui, { requiresRestart: true });
|
||||
ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true });
|
||||
|
||||
const toolsPath = path.join(config.get('downloadPath'), "tools");
|
||||
const existingRclones = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath }));
|
||||
if (existingRclones[0])
|
||||
{
|
||||
this.rclonePath = path.join(toolsPath, existingRclones[0]);
|
||||
await this.startServer(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64')))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.setProgress(0.5, "Downloading RClone");
|
||||
const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`);
|
||||
|
||||
await ensureDir(toolsPath);
|
||||
await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath }));
|
||||
const dests = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath }));
|
||||
if (dests[0])
|
||||
{
|
||||
this.rclonePath = path.join(toolsPath, dests[0]);
|
||||
await this.startServer(ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async refresh ()
|
||||
{
|
||||
const data = await this.request('/config/listremotes', {});
|
||||
z.globalRegistry.add(SettingsSchema.shape.remoteName, { examples: data.remotes, description: "The name of the remote to sync with" });
|
||||
}
|
||||
|
||||
async startServer (ctx: PluginLoadingContextType<SettingsType>)
|
||||
{
|
||||
const args: string[] = [];
|
||||
if (ctx.config.get('runWebGui'))
|
||||
{
|
||||
args.push("--rc-web-gui");
|
||||
args.push("--rc-web-gui-no-open-browser");
|
||||
}
|
||||
if (ctx.config.get(''))
|
||||
{
|
||||
args.push('-vv');
|
||||
}
|
||||
let env: Record<string, string> | undefined = undefined;
|
||||
if (!ctx.config.get('globalConfig'))
|
||||
{
|
||||
env = { RCLONE_CONFIG: path.join(config.get('downloadPath'), 'tools', 'config', 'rclone', 'rclone.conf') };
|
||||
}
|
||||
ctx.config.set('webGuiPassword', this.password);
|
||||
this.server = Bun.spawn([this.rclonePath!, "rcd", '--use-json-log', `--rc-user=${this.user}`, ...args, `--rc-pass=${this.password}`, "--rc-addr", "localhost:5572"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env
|
||||
});
|
||||
const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) });
|
||||
rl.on('line', e =>
|
||||
{
|
||||
const data = JSON.parse(e);
|
||||
|
||||
if (data.level === 'error')
|
||||
{
|
||||
console.error(data.msg);
|
||||
} else
|
||||
{
|
||||
console.log(e);
|
||||
if (loginTokenUrlRegex.test(data.msg))
|
||||
{
|
||||
this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
await new Promise((resolve) =>
|
||||
{
|
||||
const handleResolve = (line: string) =>
|
||||
{
|
||||
const data = JSON.parse(line);
|
||||
if (!loginTokenUrlRegex.test(data.msg)) return;
|
||||
rl.off('line', handleResolve);
|
||||
resolve(data);
|
||||
};
|
||||
rl.on('line', handleResolve);
|
||||
});
|
||||
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async request (path: string, body: any)
|
||||
{
|
||||
const response = await fetch(`http://localhost:5572${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${Buffer.from(`${this.user}:${this.password}`).toString('base64')}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok)
|
||||
{
|
||||
return data;
|
||||
} else
|
||||
{
|
||||
throw new Error(response.statusText, { cause: data });
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup ()
|
||||
{
|
||||
await this.request('/core/quit', {}).catch(e =>
|
||||
{
|
||||
this.server?.kill("SIGKILL");
|
||||
});
|
||||
|
||||
await this.server?.exited;
|
||||
}
|
||||
|
||||
async load (ctx: PluginLoadingContextType<SettingsType>)
|
||||
{
|
||||
await this.setup(ctx);
|
||||
|
||||
ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) =>
|
||||
{
|
||||
if (source !== 'store' || !this.rclonePath || !saveFolderSlots) return;
|
||||
|
||||
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
|
||||
{
|
||||
|
||||
let src: string;
|
||||
if (ctx.config.get('remoteName'))
|
||||
{
|
||||
src = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`;
|
||||
|
||||
const exists = await this.request('/operations/stat', {
|
||||
fs: `${ctx.config.get('remoteName')}:`,
|
||||
remote: `gameflow/saves/${source}/${id}/${slot}`
|
||||
}).catch(e => undefined);
|
||||
if (!exists || !exists.item) return;
|
||||
|
||||
} else
|
||||
{
|
||||
src = path.join(config.get('downloadPath'), 'saves', source, id, slot);
|
||||
if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', source, id, slot))) return;
|
||||
}
|
||||
|
||||
setProgress(0.5, "RClone: Syncing Saves");
|
||||
|
||||
const data = await this.request('/sync/copy', {
|
||||
srcFs: src,
|
||||
dstFs: cwd,
|
||||
createEmptySrcDirs: true,
|
||||
_config: {
|
||||
UseJSONLog: true,
|
||||
LogLevel: "DEBUG",
|
||||
HumanReadable: true,
|
||||
Progress: true,
|
||||
DryRun: true
|
||||
}
|
||||
});
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) =>
|
||||
{
|
||||
if (source !== 'store' || !this.rclonePath) return;
|
||||
console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(","));
|
||||
|
||||
await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) =>
|
||||
{
|
||||
let dest: string;
|
||||
if (ctx.config.get('remoteName'))
|
||||
{
|
||||
dest = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`;
|
||||
} else
|
||||
{
|
||||
dest = path.join(config.get('downloadPath'), 'saves', source, id, slot);
|
||||
}
|
||||
|
||||
const data = await this.request('/sync/sync', {
|
||||
srcFs: change.cwd,
|
||||
dstFs: dest,
|
||||
createEmptySrcDirs: true,
|
||||
_config: {
|
||||
UseJSONLog: true,
|
||||
LogLevel: "DEBUG",
|
||||
HumanReadable: true,
|
||||
Progress: true
|
||||
},
|
||||
_filter: {
|
||||
IncludeRule: Array.isArray(change.subPath) ? change.subPath.map(s =>
|
||||
{
|
||||
if (change.isGlob) return s;
|
||||
else s.replaceAll('\\', '/');
|
||||
}) : change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/')
|
||||
}
|
||||
}).catch(e =>
|
||||
{
|
||||
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (data)
|
||||
{
|
||||
events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' });
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue