feat: Bundled NW.js with appimages
feat: Implemented self update feat: Added rclone saves for emulators fix: Fixed auto focus in builds feat: Added helper cards on empty library
This commit is contained in:
parent
587956c792
commit
813785f4f3
59 changed files with 1210 additions and 480 deletions
|
|
@ -18,7 +18,6 @@ import EventEmitter from "node:events";
|
|||
import { appPath } from "../utils";
|
||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { getStoreFolder } from "./store/services/gamesService";
|
||||
import { PluginManager } from "./plugins/plugin-manager";
|
||||
import registerPlugins from "./plugins/register-plugins";
|
||||
import controls from './controls/controls';
|
||||
|
|
@ -43,6 +42,8 @@ export let events: EventEmitter<AppEventMap>;
|
|||
let controlsHandle: { cleanup: () => void; };
|
||||
let api: { cleanup: () => Promise<void>; };
|
||||
let bunServer: { cleanup: () => Promise<void>; } | undefined;
|
||||
let cleannedUp = false;
|
||||
let cleaningUp = false;
|
||||
|
||||
export async function load ()
|
||||
{
|
||||
|
|
@ -56,6 +57,7 @@ export async function load ()
|
|||
windowSize: { width: 1280, height: 800 }
|
||||
}),
|
||||
});
|
||||
|
||||
customEmulators = new Conf<Record<string, string>>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
|
|
@ -96,6 +98,9 @@ export async function load ()
|
|||
|
||||
export async function cleanup ()
|
||||
{
|
||||
if (cleaningUp) throw new Error("Already Cleaning Up");
|
||||
cleaningUp = true;
|
||||
if (cleannedUp) throw new Error("Already Cleaned Up. Skipping");
|
||||
console.log("Cleaning Up");
|
||||
await bunServer?.cleanup();
|
||||
await api.cleanup();
|
||||
|
|
@ -108,6 +113,7 @@ export async function cleanup ()
|
|||
config._closeWatcher();
|
||||
customEmulators._closeWatcher();
|
||||
console.log("Finished Cleaning Up");
|
||||
cleannedUp = true;
|
||||
}
|
||||
|
||||
export async function reloadDatabase ()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { cache } from "./app";
|
|||
import cacheSchema from "@schema/cache";
|
||||
import { GithubReleaseSchema } from "@/shared/constants";
|
||||
import PQueue from "p-queue";
|
||||
import z from "zod";
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
ROM_PLATFORMS: 'rom-platforms',
|
||||
|
|
@ -12,17 +13,17 @@ export const CACHE_KEYS = {
|
|||
|
||||
export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true });
|
||||
|
||||
export async function getOrCached<T> (key: string, getter: () => Promise<T>, options?: { expireMs?: number; }): Promise<T>
|
||||
export async function getOrCached<T> (key: string, getter: (lastValue: T | undefined) => Promise<T>, options?: { expireMs?: number; force?: boolean; }): Promise<T>
|
||||
{
|
||||
const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) });
|
||||
const updated_at = new Date();
|
||||
|
||||
if (cached && cached.expire_at > updated_at)
|
||||
if (cached && cached.expire_at > updated_at && !options?.force)
|
||||
{
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
const data = await getter();
|
||||
const data = await getter(cached?.data as T);
|
||||
if (data === undefined) return data;
|
||||
|
||||
const expire_at = options?.expireMs ? new Date(updated_at.getTime() + options.expireMs) : new Date(updated_at.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
|
@ -38,12 +39,15 @@ export async function getOrCached<T> (key: string, getter: () => Promise<T>, opt
|
|||
return data;
|
||||
}
|
||||
|
||||
export async function getOrCachedGithubRelease (path: string)
|
||||
export async function getOrCachedGithubRelease (path: string, forceCheck?: boolean)
|
||||
{
|
||||
return getOrCached(`github-release-${path}`, async () => githubRequestQueue.add(async () =>
|
||||
return getOrCached<z.infer<typeof GithubReleaseSchema>>(`github-release-${path}`, () => githubRequestQueue.add(async () =>
|
||||
{
|
||||
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" });
|
||||
const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, {
|
||||
method: "GET"
|
||||
});
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
return GithubReleaseSchema.parseAsync(await response.json());
|
||||
}), { expireMs: 1000 * 60 * 60 });
|
||||
const release = await GithubReleaseSchema.parseAsync(await response.json());
|
||||
return release;
|
||||
}), { expireMs: 1000 * 60 * 60, force: forceCheck });
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import z from "zod";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
|
||||
import { db, events, plugins } from "../app";
|
||||
import { config, db, events, plugins } from "../app";
|
||||
import * as appSchema from "@schema/app";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { spawn } from 'node:child_process';
|
||||
|
|
@ -51,6 +51,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
command: this.validCommand,
|
||||
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
|
||||
validChangedSaveFiles: {},
|
||||
saveFolderSlots: this.saveSlots,
|
||||
gameInfo
|
||||
}).catch(e =>
|
||||
{
|
||||
|
|
@ -129,31 +130,41 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
|
||||
if (Array.isArray(this.validCommand.command))
|
||||
{
|
||||
const bunGame = Bun.spawn(this.validCommand.command, {
|
||||
let command = this.validCommand.command;
|
||||
if (process.env.FLATPAK_BUILD) command = ['flatpak-spawn', '--host', `--directory=${config.get('downloadPath')}`, ...command];
|
||||
|
||||
const bunGame = Bun.spawn(command, {
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
...process.env,
|
||||
...this.validCommand.env
|
||||
}
|
||||
},
|
||||
onExit (subprocess, exitCode, signalCode, error)
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
console.error(error);
|
||||
reject(error);
|
||||
} else
|
||||
{
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
context.setProgress(0, "playing");
|
||||
|
||||
bunGame.exited.then(e =>
|
||||
{
|
||||
resolve(true);
|
||||
}).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
game = bunGame;
|
||||
} else
|
||||
{
|
||||
|
||||
let command = this.validCommand.command;
|
||||
|
||||
if (process.env.FLATPAK_BUILD) command = `flatpak-spawn --host --directory=${config.get('downloadPath')} ${command}`;
|
||||
|
||||
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||
const spawnGame = spawn(this.validCommand.command, {
|
||||
const spawnGame = spawn(command, {
|
||||
shell: this.validCommand.shell ?? true,
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
|
|
@ -178,7 +189,6 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
|
||||
game = spawnGame;
|
||||
}
|
||||
|
||||
}
|
||||
else if (this.validCommand.metadata.emulatorBin)
|
||||
{
|
||||
|
|
@ -186,14 +196,28 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
|
||||
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
|
||||
|
||||
let command = [this.validCommand.metadata.emulatorBin, ...commandArgs.args];
|
||||
if (process.env.FLATPAK_BUILD) command = ['flatpak-spawn', '--host', `--directory=${config.get('downloadPath')}`, ...command];
|
||||
|
||||
// We have full control over launching integrated emulators better to use bun spawn
|
||||
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], {
|
||||
const bunGame = Bun.spawn(command, {
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
...process.env,
|
||||
...commandArgs.env
|
||||
}
|
||||
},
|
||||
onExit (subprocess, exitCode, signalCode, error)
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
console.error(error);
|
||||
reject(error);
|
||||
} else
|
||||
{
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
context.setProgress(0, "playing");
|
||||
|
|
@ -219,15 +243,6 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
});
|
||||
}*/
|
||||
|
||||
bunGame.exited.then(e =>
|
||||
{
|
||||
resolve(true);
|
||||
}).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
game = bunGame;
|
||||
|
||||
} else
|
||||
|
|
|
|||
118
src/bun/api/jobs/self-update-job.ts
Normal file
118
src/bun/api/jobs/self-update-job.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import z from "zod";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { cleanPromise, cleanup, events, plugins } from "../app";
|
||||
import fs from 'fs/promises';
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import path from 'node:path';
|
||||
import os from "node:os";
|
||||
import winUpdateScript from '@/bun/utils/update-gameflow-windows.bat' with { type: "text" };
|
||||
import linuxUpdateScript from '@/bun/utils/update-gameflow-linux.sh' with { type: "text" };
|
||||
import mustache from "mustache";
|
||||
import pkg from '~/package.json';
|
||||
import { sleep } from "bun";
|
||||
|
||||
export default class SelfUpdateJob implements IJob<never, string>
|
||||
{
|
||||
static id = "self-update-job" as const;
|
||||
static dataSchema = z.never();
|
||||
group = "self-update";
|
||||
|
||||
async downloadUpdate (url: URL, dest: string | undefined, filename: string, ctx: JobContext<IJob<never, string>, never, string>)
|
||||
{
|
||||
const downloader = new Downloader('update',
|
||||
[{
|
||||
url: url,
|
||||
file_path: "",
|
||||
file_name: filename
|
||||
}],
|
||||
dest,
|
||||
{
|
||||
onProgress (stats)
|
||||
{
|
||||
ctx.setProgress(stats.progress, "Downloading Update");
|
||||
},
|
||||
});
|
||||
return downloader.start();
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<never, string>, never, string>)
|
||||
{
|
||||
context.setProgress(0, "Downloading Update");
|
||||
await sleep(1000);
|
||||
const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest');
|
||||
if (latest.ok)
|
||||
{
|
||||
const data = await latest.json();
|
||||
let validAsset: any | undefined;
|
||||
switch (process.platform)
|
||||
{
|
||||
case "win32":
|
||||
validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.zip`).match(e.name));
|
||||
break;
|
||||
case "linux":
|
||||
validAsset = data.assets.find((e: any) => new Bun.Glob(`Gameflow-${process.platform}-${process.arch}.AppImage`).match(e.name));
|
||||
if (!validAsset)
|
||||
{
|
||||
validAsset = data.assets.find((e: any) => new Bun.Glob(`*.AppImage`).match(e.name));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
events.emit('notification', { message: "Unsupported Platfrom", title: 'Failed Update', type: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validAsset)
|
||||
{
|
||||
events.emit('notification', { message: "Could not find download", title: 'Failed Update', type: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Found Download", validAsset.browser_download_url);
|
||||
console.log("Starting Download");
|
||||
|
||||
switch (process.platform)
|
||||
{
|
||||
case "linux":
|
||||
const appimage = process.env.APPIMAGE;
|
||||
if (!appimage)
|
||||
{
|
||||
events.emit('notification', {
|
||||
message: "Only AppImage supported",
|
||||
title: 'Failed Update',
|
||||
type: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const linuxDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, path.basename(appimage), context);
|
||||
if (!linuxDownloads) return;
|
||||
const shPath = path.join(os.tmpdir(), "update-gameflow.sh");
|
||||
await Bun.write(shPath, mustache.render(linuxUpdateScript, {
|
||||
tempFile: linuxDownloads[0],
|
||||
appImagePath: appimage
|
||||
}));
|
||||
context.setProgress(0, "Restarting App To Update");
|
||||
events.emit('exitapp');
|
||||
Bun.spawn(["bash", shPath], { detached: true });
|
||||
process.exit(0);
|
||||
case "win32":
|
||||
const winDownloads = await this.downloadUpdate(new URL(validAsset.browser_download_url), undefined, "Gameflow-update.zip", context);
|
||||
if (!winDownloads) return;
|
||||
const batPath = path.join(os.tmpdir(), "update-gameflow.bat");
|
||||
await Bun.write(batPath, mustache.render(winUpdateScript, {
|
||||
tempFile: winDownloads[0],
|
||||
extractDir: path.dirname(process.execPath),
|
||||
exePath: `${pkg.bin}.exe`
|
||||
}));
|
||||
context.setProgress(0, "Restarting App To Update");
|
||||
await cleanup();
|
||||
events.emit('exitapp');
|
||||
Bun.spawn(["cmd", "/c", batPath], { detached: true });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
} else
|
||||
{
|
||||
events.emit('notification', { message: latest.statusText, title: 'Failed Update', type: "error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,17 @@ export default class CEMUIntegration implements PluginType
|
|||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
|
||||
});
|
||||
|
||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
|
||||
{
|
||||
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||
validChangedSaveFiles[this.emulator] = {
|
||||
cwd: saveFolderSlots[this.emulator].cwd,
|
||||
shared: true,
|
||||
subPath: '*.{tga,xml,dat}',
|
||||
isGlob: true
|
||||
};
|
||||
});
|
||||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||
{
|
||||
const args: string[] = [];
|
||||
|
|
@ -29,7 +40,7 @@ export default class CEMUIntegration implements PluginType
|
|||
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
|
||||
}
|
||||
|
||||
return { args, savesPath: { cemu: { cwd: savesPath } } };
|
||||
return { args, savesPath: { [this.emulator]: { cwd: savesPath } } };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -68,21 +68,20 @@ export default class DOLPHINIntegration implements PluginType
|
|||
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`);
|
||||
|
||||
finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder;
|
||||
return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } };
|
||||
}
|
||||
|
||||
return { args, savesPath: { dolphin: { cwd: finalSavesPath } } };
|
||||
return { args };
|
||||
});
|
||||
|
||||
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderSlots, command, gameInfo }) =>
|
||||
ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) =>
|
||||
{
|
||||
if (command.emulator === this.emulator && saveFolderSlots && command.metadata.romPath)
|
||||
{
|
||||
validChangedSaveFiles.dolphin = {
|
||||
cwd: saveFolderSlots.dolphin.cwd,
|
||||
subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir),
|
||||
shared: false
|
||||
};
|
||||
}
|
||||
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||
validChangedSaveFiles[this.emulator] = {
|
||||
cwd: saveFolderSlots[this.emulator].cwd,
|
||||
subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir),
|
||||
shared: false
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,18 @@ export default class PCSX2Integration implements PluginType
|
|||
}
|
||||
});
|
||||
|
||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
|
||||
{
|
||||
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||
validChangedSaveFiles[this.emulator] = {
|
||||
cwd: saveFolderSlots[this.emulator].cwd,
|
||||
shared: true,
|
||||
subPath: '*.ps2',
|
||||
isGlob: true,
|
||||
fixedSize: true
|
||||
};
|
||||
});
|
||||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||
{
|
||||
const args: string[] = [];
|
||||
|
|
@ -103,7 +115,7 @@ export default class PCSX2Integration implements PluginType
|
|||
|
||||
await Bun.write(configPath, ini.stringify(configFile));
|
||||
|
||||
return { args, savesPath: { pcsx2: { cwd: paths.MEMORY_CARDS_PATH } } };
|
||||
return { args, savesPath: { [this.emulator]: { cwd: paths.MEMORY_CARDS_PATH } } };
|
||||
}
|
||||
|
||||
return { args };
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import Mustache from "mustache";
|
|||
import { ensureDir } from "fs-extra";
|
||||
import { homedir } from "node:os";
|
||||
import ini from 'ini';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
export default class PPSSPPIntegration implements PluginType
|
||||
{
|
||||
|
|
@ -19,10 +20,14 @@ export default class PPSSPPIntegration implements PluginType
|
|||
{
|
||||
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||
{
|
||||
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
||||
if (process.platform === 'win32')
|
||||
const stat = await fs.stat(ctx.path);
|
||||
if (stat.isDirectory())
|
||||
{
|
||||
await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator));
|
||||
await Bun.write(path.join(ctx.path, "portable.txt"), "");
|
||||
if (process.platform === 'win32')
|
||||
{
|
||||
await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -44,6 +49,17 @@ export default class PPSSPPIntegration implements PluginType
|
|||
}
|
||||
});
|
||||
|
||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name }, async ({ saveFolderSlots, validChangedSaveFiles, command }) =>
|
||||
{
|
||||
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||
validChangedSaveFiles[this.emulator] = {
|
||||
cwd: saveFolderSlots[this.emulator].cwd,
|
||||
shared: true,
|
||||
subPath: '*.{SFO,sfo,PNG,png}',
|
||||
isGlob: true
|
||||
};
|
||||
});
|
||||
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
||||
{
|
||||
const args: string[] = [];
|
||||
|
|
@ -114,7 +130,14 @@ export default class PPSSPPIntegration implements PluginType
|
|||
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
|
||||
}
|
||||
|
||||
return { args, savesPath: { ppsspp: { cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") } } };
|
||||
return {
|
||||
args,
|
||||
savesPath: {
|
||||
[this.emulator]: {
|
||||
cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { args };
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export default class XENIAIntegration implements PluginType
|
|||
if (ctx.autoValidCommand.metadata.romPath)
|
||||
{
|
||||
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
|
||||
return { args, savesPath: { xenia: { cwd: finalSavesPath } } };
|
||||
return { args, savesPath: { [this.emulator]: { cwd: finalSavesPath } } };
|
||||
}
|
||||
|
||||
return { args };
|
||||
|
|
@ -91,13 +91,12 @@ export default class XENIAIntegration implements PluginType
|
|||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch);
|
||||
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch);
|
||||
|
||||
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) =>
|
||||
ctx.hooks.games.postPlay.tap({ name: desc.name }, async ({ validChangedSaveFiles, saveFolderSlots, command }) =>
|
||||
{
|
||||
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
|
||||
{
|
||||
const files = await fs.readdir(saveFolderPath, { recursive: true });
|
||||
validChangedSaveFiles.gameflow = { cwd: saveFolderPath, subPath: files, shared: false };
|
||||
}
|
||||
if (command.emulator !== this.emulator || !(saveFolderSlots?.[this.emulator]) || !command.metadata.romPath) return;
|
||||
const files = await fs.readdir(saveFolderSlots[this.emulator].cwd, { recursive: true });
|
||||
validChangedSaveFiles.xenia = { cwd: saveFolderSlots[this.emulator].cwd, subPath: files, shared: false };
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||
import desc from './package.json';
|
||||
import { config, events } from "@/bun/api/app";
|
||||
import { config, db, events } from "@/bun/api/app";
|
||||
import path, { dirname } from 'node:path';
|
||||
import unzip from 'unzip-stream';
|
||||
import { chmodSync, ensureDir } from "fs-extra";
|
||||
|
|
@ -10,6 +10,10 @@ import fs from 'node:fs/promises';
|
|||
import { randomUUIDv7, sleep } from "bun";
|
||||
import z from "zod";
|
||||
import { createInterface } from "node:readline";
|
||||
import { getLocalGameMatch } from "@/bun/api/games/services/utils";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
|
||||
const DefaultLocalName = "Default_Local";
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
runWebGui: z.boolean()
|
||||
|
|
@ -18,7 +22,7 @@ const SettingsSchema = z.object({
|
|||
.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(""),
|
||||
remoteName: z.string().default(DefaultLocalName),
|
||||
verboseLog: z.boolean()
|
||||
.default(false)
|
||||
.describe("Show detailed log of operation for debugging")
|
||||
|
|
@ -116,8 +120,21 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
|
||||
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" });
|
||||
try
|
||||
{
|
||||
const data = await this.request('/config/listremotes', {});
|
||||
z.globalRegistry.add(SettingsSchema.shape.remoteName, {
|
||||
examples: [''].concat(...data.remotes),
|
||||
description: "The name of the remote to sync with"
|
||||
});
|
||||
} catch (error)
|
||||
{
|
||||
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
|
||||
z.globalRegistry.add(SettingsSchema.shape.remoteName, {
|
||||
examples: [''],
|
||||
description: "The name of the remote to sync with"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async startServer (ctx: PluginLoadingContextType<SettingsType>)
|
||||
|
|
@ -146,23 +163,29 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) });
|
||||
rl.on('line', e =>
|
||||
{
|
||||
const data = JSON.parse(e);
|
||||
|
||||
if (data.level === 'error')
|
||||
try
|
||||
{
|
||||
console.error(data.msg);
|
||||
} else if (data.level === 'critical')
|
||||
{
|
||||
console.error(data.msg);
|
||||
}
|
||||
const data = JSON.parse(e);
|
||||
|
||||
else
|
||||
if (data.level === 'error')
|
||||
{
|
||||
console.error(data.msg);
|
||||
} else if (data.level === 'critical')
|
||||
{
|
||||
console.error(data.msg);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
console.log(e);
|
||||
if (loginTokenUrlRegex.test(data.msg))
|
||||
{
|
||||
this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e);
|
||||
}
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
console.log(e);
|
||||
if (loginTokenUrlRegex.test(data.msg))
|
||||
{
|
||||
this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
|
@ -171,10 +194,16 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
{
|
||||
const handleResolve = (line: string) =>
|
||||
{
|
||||
const data = JSON.parse(line);
|
||||
if (!loginTokenUrlRegex.test(data.msg)) return;
|
||||
rl.off('line', handleResolve);
|
||||
resolve(data);
|
||||
try
|
||||
{
|
||||
const data = JSON.parse(line);
|
||||
if (!loginTokenUrlRegex.test(data.msg)) return;
|
||||
rl.off('line', handleResolve);
|
||||
resolve(data);
|
||||
} catch (error)
|
||||
{
|
||||
|
||||
}
|
||||
};
|
||||
rl.on('line', handleResolve);
|
||||
setTimeout(() => { reject("Timeout"); }, 5000);
|
||||
|
|
@ -206,100 +235,235 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
|
||||
async cleanup ()
|
||||
{
|
||||
await this.request('/core/quit', {}).catch(e =>
|
||||
await new Promise((resolve) =>
|
||||
{
|
||||
this.server?.kill("SIGKILL");
|
||||
this.request('/core/quit', {}).catch(e =>
|
||||
{
|
||||
this.server?.kill("SIGKILL");
|
||||
this.server = undefined;
|
||||
});
|
||||
|
||||
setTimeout(() =>
|
||||
{
|
||||
this.request('/core/quit', { exitCode: 9 }).then(e =>
|
||||
{
|
||||
resolve(false);
|
||||
this.server = undefined;
|
||||
}).catch(e =>
|
||||
{
|
||||
resolve(false);
|
||||
this.server?.kill("SIGKILL");
|
||||
this.server = undefined;
|
||||
});
|
||||
|
||||
|
||||
}, 5000);
|
||||
|
||||
this.server?.exited.then(() => resolve(true));
|
||||
});
|
||||
|
||||
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 }) =>
|
||||
ctx.hooks.games.prePlay.tapPromise({
|
||||
name: desc.name,
|
||||
stage: 10,
|
||||
}, async ({ source, id, setProgress, saveFolderSlots, command }) =>
|
||||
{
|
||||
if (source !== 'store' || !this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return;
|
||||
if (!this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return;
|
||||
|
||||
const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined;
|
||||
if (!destination) return;
|
||||
|
||||
const remoteName = ctx.config.get('remoteName');
|
||||
|
||||
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
|
||||
{
|
||||
|
||||
let supportsMetadata = true;
|
||||
let src: string;
|
||||
if (ctx.config.get('remoteName'))
|
||||
|
||||
if (remoteName && remoteName !== DefaultLocalName)
|
||||
{
|
||||
src = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`;
|
||||
src = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`;
|
||||
|
||||
const exists = await this.request('/operations/stat', {
|
||||
fs: `${ctx.config.get('remoteName')}:`,
|
||||
remote: `gameflow/saves/${source}/${id}/${slot}`
|
||||
fs: `${remoteName}:`,
|
||||
remote: `gameflow/saves/${destination.join('/')}/${slot}`
|
||||
}).catch(e => undefined);
|
||||
if (!exists || !exists.item) return;
|
||||
|
||||
const remote = await this.request('/operations/fsinfo', {
|
||||
fs: `${remoteName}:`
|
||||
});
|
||||
supportsMetadata = !remote.ReadMetadata;
|
||||
if (supportsMetadata)
|
||||
{
|
||||
console.warn("Remote", remoteName, "does not support metadata");
|
||||
}
|
||||
} 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;
|
||||
src = path.join(config.get('downloadPath'), 'saves', ...destination, slot);
|
||||
if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', ...destination, slot))) return;
|
||||
}
|
||||
|
||||
setProgress(0.5, "RClone: Syncing Saves");
|
||||
|
||||
const data = await this.request('/sync/copy', {
|
||||
const job = await this.request('/sync/copy', {
|
||||
srcFs: src,
|
||||
dstFs: cwd,
|
||||
createEmptySrcDirs: true,
|
||||
_async: true,
|
||||
_config: {
|
||||
UseJSONLog: true,
|
||||
LogLevel: "DEBUG",
|
||||
HumanReadable: true,
|
||||
Progress: true
|
||||
}
|
||||
});
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) =>
|
||||
{
|
||||
if (source !== 'store' || !this.rclonePath || !ctx.config.get('exportSaves')) 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('\\', '/')
|
||||
CheckFirst: true,
|
||||
Metadata: true,
|
||||
NoCheckDest: supportsMetadata
|
||||
}
|
||||
}).catch(e =>
|
||||
{
|
||||
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
||||
return undefined;
|
||||
});;
|
||||
|
||||
await new Promise(async (resolve, reject) =>
|
||||
{
|
||||
setProgress(0, "RClone: Syncing Saves");
|
||||
|
||||
const checkInterval = setInterval(async () =>
|
||||
{
|
||||
const stat = await this.request('/job/status', { jobid: job.jobid });
|
||||
if (stat.finished)
|
||||
{
|
||||
clearInterval(checkInterval);
|
||||
console.log(stat.output);
|
||||
resolve(true);
|
||||
|
||||
} else if (stat.error)
|
||||
{
|
||||
reject(stat.error);
|
||||
} else
|
||||
{
|
||||
setProgress(stat.progress, "RClone: Syncing Saves");
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
|
||||
{
|
||||
if (!this.rclonePath || !ctx.config.get('exportSaves')) return;
|
||||
const local = await db.query.games.findFirst({ where: getLocalGameMatch(id, source) });
|
||||
console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(","));
|
||||
|
||||
const destination = source === 'store' ? [source, id] : command.emulator ? [command.emulator] : undefined;
|
||||
if (!destination) return;
|
||||
|
||||
const remoteName = ctx.config.get('remoteName');
|
||||
|
||||
await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) =>
|
||||
{
|
||||
let suportsMetadata = false;
|
||||
let dest: string;
|
||||
if (remoteName && remoteName !== DefaultLocalName)
|
||||
{
|
||||
dest = `${remoteName}:gameflow/saves/${destination.join('/')}/${slot}`;
|
||||
const remote = await this.request('/operations/fsinfo', {
|
||||
fs: `${remoteName}:`
|
||||
});
|
||||
suportsMetadata = !remote.ReadMetadata;
|
||||
if (suportsMetadata)
|
||||
{
|
||||
console.warn("Remote", remoteName, "does not support metadata");
|
||||
}
|
||||
} else
|
||||
{
|
||||
dest = path.join(config.get('downloadPath'), 'saves', ...destination, slot);
|
||||
}
|
||||
|
||||
const 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('\\', '/')]
|
||||
};
|
||||
|
||||
let jobid: number | undefined = undefined;
|
||||
|
||||
if (change.fixedSize)
|
||||
{
|
||||
await this.request('/sync/copy', {
|
||||
srcFs: change.cwd,
|
||||
dstFs: dest,
|
||||
createEmptySrcDirs: true,
|
||||
_async: true,
|
||||
_config: {
|
||||
NoCheckDest: true
|
||||
},
|
||||
_filter: filter
|
||||
})
|
||||
.then(job => jobid = job.jobid)
|
||||
.catch(e =>
|
||||
{
|
||||
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
||||
return undefined;
|
||||
});
|
||||
} else
|
||||
{
|
||||
await this.request('/sync/sync', {
|
||||
srcFs: change.cwd,
|
||||
dstFs: dest,
|
||||
createEmptySrcDirs: true,
|
||||
_async: true,
|
||||
_config: {
|
||||
CheckSum: true,
|
||||
CheckFirst: true,
|
||||
Metadata: true,
|
||||
MetadataSet: {
|
||||
igdb_id: local?.igdb_id ? String(local?.igdb_id) : undefined,
|
||||
ra_id: local?.ra_id ? String(local?.ra_id) : undefined
|
||||
}
|
||||
},
|
||||
_filter: filter
|
||||
})
|
||||
.then(job => jobid = job.jobid)
|
||||
.catch(e =>
|
||||
{
|
||||
events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' });
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (!jobid) return;
|
||||
await new Promise(async (resolve, reject) =>
|
||||
{
|
||||
const checkInterval = setInterval(async () =>
|
||||
{
|
||||
const stat = await this.request('/job/status', { jobid });
|
||||
if (stat.finished)
|
||||
{
|
||||
clearInterval(checkInterval);
|
||||
console.log(stat.output);
|
||||
resolve(true);
|
||||
|
||||
} else if (stat.error)
|
||||
{
|
||||
reject(stat.error);
|
||||
} else
|
||||
{
|
||||
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
if (data)
|
||||
const stats = await this.request('/core/stats', {
|
||||
group: `job/${jobid}`
|
||||
});
|
||||
|
||||
if (stats.transfers > 0)
|
||||
{
|
||||
events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
{
|
||||
this.isSteamDeck = isSteamDeckGameMode();
|
||||
ctx.setProgress(0, "Logging Into Romm");
|
||||
await this.updateClient();
|
||||
await checkLoginAndRefreshRomm();
|
||||
await this.updateClient();
|
||||
|
||||
|
|
@ -270,7 +271,8 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
metadata: rom.metadatum,
|
||||
files,
|
||||
auth: await this.getAuthToken(),
|
||||
extract_path
|
||||
extract_path,
|
||||
id: "romm"
|
||||
};
|
||||
|
||||
return [info];
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ export class PluginManager
|
|||
{
|
||||
if (plugin.enabled || plugin.description.canDisable === false)
|
||||
{
|
||||
console.log("Loading Plugin", plugin.description.name);
|
||||
await plugin.plugin.load(ctx);
|
||||
console.log("Loaded Plugin", plugin.description.name);
|
||||
plugin.loaded = true;
|
||||
}
|
||||
} catch (error)
|
||||
|
|
@ -119,11 +121,13 @@ export class PluginManager
|
|||
{
|
||||
if (p.loaded)
|
||||
{
|
||||
console.log("Starting", p.description.name, "plugin cleanup");
|
||||
await p.plugin.cleanup!();
|
||||
console.log(p.description.name, "cleanup complete");
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
console.log("Error for plugin", p.description.name, "while cleaning up");
|
||||
console.error("Error for plugin", p.description.name, "while cleaning up");
|
||||
console.error(error);
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import Elysia from "elysia";
|
|||
import open from 'open';
|
||||
import z from "zod";
|
||||
import os from 'node:os';
|
||||
import { cachePath, config, events, taskQueue } from "./app";
|
||||
import { isSteamDeck, openExternal } from "../utils";
|
||||
import { cache, cachePath, config, events, taskQueue } from "./app";
|
||||
import { getAppVersion, isSteamDeck, openExternal } from "../utils";
|
||||
import fs from 'node:fs/promises';
|
||||
import buildNotificationsStream from "./notifications";
|
||||
import path, { dirname } from "node:path";
|
||||
|
|
@ -14,23 +14,15 @@ import si from 'systeminformation';
|
|||
import { getStoreFolder } from "./store/services/gamesService";
|
||||
import ReloadPluginsJob from "./jobs/reload-plugins-job";
|
||||
import { semver } from "bun";
|
||||
import packageDef from '~/package.json';
|
||||
import { getOrCached, githubRequestQueue } from "./cache";
|
||||
import { getOrCached, getOrCachedGithubRelease, githubRequestQueue } from "./cache";
|
||||
import SelfUpdateJob from "./jobs/self-update-job";
|
||||
|
||||
async function checkUpdate ()
|
||||
async function checkUpdate (force?: boolean)
|
||||
{
|
||||
return getOrCached('check-for-update', async () => githubRequestQueue.add(async () =>
|
||||
{
|
||||
const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest');
|
||||
if (latest.ok)
|
||||
{
|
||||
const data = await latest.json();
|
||||
const hasUpdate = semver.order(data.tag_name, packageDef.version);
|
||||
return hasUpdate;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}), { expireMs: 1000 * 60 * 60 });
|
||||
const latest = await getOrCachedGithubRelease('simeonradivoev/gameflow-deck', force);
|
||||
if (!latest || !latest.tag_name) return { hasUpdate: 0, version: getAppVersion() };
|
||||
const hasUpdate = semver.order(latest.tag_name, getAppVersion());
|
||||
return { hasUpdate, version: latest.tag_name };
|
||||
}
|
||||
|
||||
export const system = new Elysia({ prefix: '/api/system' })
|
||||
|
|
@ -71,7 +63,8 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
machine: os.machine(),
|
||||
source,
|
||||
cacheSize: (await fs.stat(cachePath)).size,
|
||||
storeSize: (await getFolderSize(getStoreFolder())).size
|
||||
storeSize: (await getFolderSize(getStoreFolder())).size,
|
||||
version: getAppVersion()
|
||||
};
|
||||
})
|
||||
.get('/notifications', ({ set }) =>
|
||||
|
|
@ -120,17 +113,25 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
|
||||
dispose.push(taskQueue.on('progress', e =>
|
||||
{
|
||||
if (e.id !== ReloadPluginsJob.id) return;
|
||||
ws.send({ type: "loading", progress: e.progress, state: e.state });
|
||||
if (e.id === ReloadPluginsJob.id)
|
||||
{
|
||||
ws.send({ type: "loading", progress: e.progress, state: e.state });
|
||||
}
|
||||
else if (e.id === SelfUpdateJob.id)
|
||||
{
|
||||
ws.send({ type: "loading", progress: e.progress, state: e.state });
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('started', e =>
|
||||
{
|
||||
if (e.id !== ReloadPluginsJob.id) return;
|
||||
ws.send({ type: "loading", progress: 0 });
|
||||
if (e.id === ReloadPluginsJob.id)
|
||||
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
|
||||
else if (e.id === SelfUpdateJob.id)
|
||||
ws.send({ type: "loading", progress: e.job.progress, state: e.job.state });
|
||||
}));
|
||||
dispose.push(taskQueue.on('ended', e =>
|
||||
{
|
||||
if (e.id !== ReloadPluginsJob.id) return;
|
||||
if (e.id !== ReloadPluginsJob.id && e.id !== SelfUpdateJob.id) return;
|
||||
ws.send({ type: "loaded" });
|
||||
}));
|
||||
|
||||
|
|
@ -268,4 +269,12 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
.get('/update', async () =>
|
||||
{
|
||||
return checkUpdate();
|
||||
})
|
||||
.post('/update', async () =>
|
||||
{
|
||||
return taskQueue.enqueue(SelfUpdateJob.id, new SelfUpdateJob());
|
||||
})
|
||||
.post('/update/check', async () =>
|
||||
{
|
||||
return checkUpdate(true);
|
||||
});
|
||||
|
|
@ -106,7 +106,18 @@ export class TaskQueue
|
|||
{
|
||||
this.queue = [];
|
||||
this.activeQueue.forEach(c => c.abort());
|
||||
return Promise.all(this.activeQueue.map(c => c.promise.promise.catch(e => console.error("Error During Task Queue Closing"))));
|
||||
return Promise.all(this.activeQueue.map(c =>
|
||||
{
|
||||
return new Promise(resolve =>
|
||||
{
|
||||
c.promise.promise.then(resolve).catch(e =>
|
||||
{
|
||||
console.error("Error During Task Queue Closing");
|
||||
resolve(false);
|
||||
});
|
||||
setTimeout(resolve, 5000);
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,24 +6,42 @@ import { dlopen, FFIType, Pointer } from "bun:ffi";
|
|||
import { SERVER_URL } from '@/shared/constants';
|
||||
import { host } from './utils/host';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ensureDir } from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
|
||||
export default async function init (events: EventEmitter, forceBrowser: boolean, params: BrowserParams)
|
||||
export default async function init (events: EventEmitter, params: BrowserParams)
|
||||
{
|
||||
if (forceBrowser)
|
||||
if (params.forceNWJS)
|
||||
{
|
||||
await runBrowser(events, params);
|
||||
} else
|
||||
{
|
||||
try
|
||||
{
|
||||
await runWebview(events, params);
|
||||
} catch (error)
|
||||
{
|
||||
await runBrowser(events, params);
|
||||
}
|
||||
await runNW(events, params);
|
||||
return;
|
||||
}
|
||||
|
||||
await runNW(events, params);
|
||||
if (params.forceBrowser)
|
||||
{
|
||||
await runBrowser(events, params);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await runWebview(events, params);
|
||||
return;
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await runNW(events, params);
|
||||
return;
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await runBrowser(events, params);
|
||||
}
|
||||
|
||||
function focusWindow (id: Pointer)
|
||||
|
|
@ -51,17 +69,50 @@ function focusWindow (id: Pointer)
|
|||
|
||||
async function runNW (events: EventEmitter, params: BrowserParams)
|
||||
{
|
||||
const path = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw';
|
||||
if (!await fs.exists(path))
|
||||
let nwPath = process.platform === 'win32' ? './bin/nw/nw.exe' : './bin/nw/nw';
|
||||
if (process.env.FLATPAK_BUILD)
|
||||
{
|
||||
console.error("Could not find NW.js");
|
||||
return;
|
||||
nwPath = '/app/bin/nw/nw';
|
||||
} else if (process.env.APPIMAGE)
|
||||
{
|
||||
nwPath = path.join(process.env.APPDIR ?? '', 'usr', 'bin', 'nw');
|
||||
}
|
||||
|
||||
if (!await fs.exists(nwPath))
|
||||
{
|
||||
throw new Error(`Could not find NW.js at ${nwPath}`);
|
||||
}
|
||||
const signalHandler = new AbortController();
|
||||
const chromeArgs: string[] = ['--in-process-gpu'];
|
||||
if (params.isSteamDeckGameMode)
|
||||
{
|
||||
chromeArgs.push('--kiosk');
|
||||
chromeArgs.push(`--window-size=1280,800`);
|
||||
} else if (params.windowSize)
|
||||
{
|
||||
chromeArgs.push(`--window-size=${params.windowSize.width},${params.windowSize.height}`);
|
||||
}
|
||||
if (params.windowPosition) chromeArgs.push(`--window-position=${params.windowPosition.x},${params.windowPosition.y}`);
|
||||
events.on('exitapp', () => signalHandler.abort());
|
||||
const args = [path, `--url=${SERVER_URL(host)}`];
|
||||
if (process.env.NODE_ENV !== 'development') args.push("--disable-devtools");
|
||||
const nwProcess = Bun.spawn(args, { signal: signalHandler.signal });
|
||||
const configPath = path.join(params.configPath, 'nw-user-data');
|
||||
await ensureDir(configPath);
|
||||
console.log("NW config path at:", configPath);
|
||||
const args = [nwPath, `--url=${SERVER_URL(host)}`, `--user-data-dir=${configPath}`];
|
||||
|
||||
if (process.env.NODE_ENV !== 'development')
|
||||
{
|
||||
console.log("Disabling devtools");
|
||||
args.push("--disable-devtools");
|
||||
}
|
||||
console.log("Launching NW.js");
|
||||
const nwProcess = Bun.spawn(args, {
|
||||
signal: signalHandler.signal,
|
||||
killSignal: "SIGKILL",
|
||||
env: {
|
||||
...process.env,
|
||||
NW_PRE_ARGS: chromeArgs.join(" ")
|
||||
}
|
||||
});
|
||||
await nwProcess.exited;
|
||||
}
|
||||
|
||||
|
|
@ -131,8 +182,7 @@ async function runBrowser (events: EventEmitter, params: BrowserParams)
|
|||
const browserParams = await BuildParams(params);
|
||||
if (!browserParams)
|
||||
{
|
||||
console.error("Could not find valid browser");
|
||||
return Promise.resolve();
|
||||
throw new Error("Could not find valid browser");
|
||||
}
|
||||
else if (!Bun.env.HEADLESS)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,14 +5,28 @@ import { dirname } from 'pathe';
|
|||
import { createInterface } from 'readline';
|
||||
import { isSteamDeckGameMode } from './utils';
|
||||
|
||||
async function cleanup ()
|
||||
async function cleanup (code: number)
|
||||
{
|
||||
await app.cleanup();
|
||||
process.exit(0);
|
||||
app.cleanup()
|
||||
.then(() =>
|
||||
{
|
||||
process.exit(code);
|
||||
})
|
||||
.catch(e => console.error);
|
||||
}
|
||||
|
||||
await app.load();
|
||||
|
||||
async function shutdown (code: number)
|
||||
{
|
||||
console.log("Graceful Shutdown");
|
||||
await cleanup(code);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on('SIGUSR1', () => shutdown(3));
|
||||
|
||||
if (process.env.HEADLESS)
|
||||
{
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
|
|
@ -22,7 +36,7 @@ if (process.env.HEADLESS)
|
|||
if (line.trim() === "shutdown")
|
||||
{
|
||||
console.log("Graceful Shutdown");
|
||||
await cleanup();
|
||||
await cleanup(0);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -30,23 +44,23 @@ if (process.env.HEADLESS)
|
|||
app.events.on('exitapp', () =>
|
||||
{
|
||||
process.stdout.write('exitapp\n');
|
||||
cleanup();
|
||||
process.send?.("exitapp");
|
||||
cleanup(0);
|
||||
});
|
||||
app.events.on('focus', () =>
|
||||
{
|
||||
process.stdout.write("focus\n");
|
||||
process.send?.("focus");
|
||||
});
|
||||
} else
|
||||
{
|
||||
await init(app.events, process.env.FORCE_BROWSER === "true", {
|
||||
await init(app.events, {
|
||||
configPath: dirname(app.config.path),
|
||||
windowPosition: app.config.get('windowPosition'),
|
||||
windowSize: app.config.get('windowSize'),
|
||||
isSteamDeckGameMode: isSteamDeckGameMode()
|
||||
isSteamDeckGameMode: isSteamDeckGameMode(),
|
||||
forceBrowser: process.env.FORCE_BROWSER === "true",
|
||||
forceNWJS: process.env.FORCE_NWJS === "true"
|
||||
});
|
||||
await cleanup();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
await cleanup(0);
|
||||
}
|
||||
10
src/bun/types/types.d.ts
vendored
10
src/bun/types/types.d.ts
vendored
|
|
@ -29,4 +29,14 @@ declare interface AppEventMap
|
|||
exitapp: [];
|
||||
notification: [FrontendNotification];
|
||||
focus: [];
|
||||
}
|
||||
|
||||
declare module '*.bat' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.sh' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import path from 'node:path';
|
|||
import { SettingsType } from '@/shared/constants';
|
||||
import { config } from './api/app';
|
||||
import fs from 'node:fs/promises';
|
||||
import packageDef from '~/package.json';
|
||||
|
||||
export function checkRunning (pid: number)
|
||||
{
|
||||
|
|
@ -172,4 +173,9 @@ export async function moveAllFiles (srcDir: string, destDir: string)
|
|||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getAppVersion ()
|
||||
{
|
||||
return process.env.VERSION_OVERRIDE ?? packageDef.version;
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ export interface BrowserParams
|
|||
windowPosition?: { x: number, y: number; };
|
||||
windowSize?: { width?: number, height?: number; };
|
||||
isSteamDeckGameMode: boolean;
|
||||
forceBrowser?: boolean;
|
||||
forceNWJS?: boolean;
|
||||
}
|
||||
|
||||
export async function BuildParams (data: BrowserParams)
|
||||
|
|
@ -54,6 +56,13 @@ export async function BuildParams (data: BrowserParams)
|
|||
args.push('--allow-insecure-localhost');
|
||||
args.push('--auto-accept-camera-and-microphone-capture');
|
||||
|
||||
if (process.env.FLATPAK_BUILD)
|
||||
{
|
||||
args.push('--no-sandbox');
|
||||
args.push('--disable-gpu-sandbox');
|
||||
args.push('--test-type');
|
||||
}
|
||||
|
||||
if (data.isSteamDeckGameMode)
|
||||
{
|
||||
args.push('--kiosk');
|
||||
|
|
|
|||
|
|
@ -27,15 +27,22 @@ export class Downloader
|
|||
onProgress?: (stats: ProgressStats) => void;
|
||||
signal?: AbortSignal;
|
||||
activeFile?: DownloadFileEntry;
|
||||
downloadPath: string;
|
||||
downloadPath: string | undefined;
|
||||
id: string;
|
||||
tmpPath: string;
|
||||
tmpPathMeta: string;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id Id of the download. Should be unique
|
||||
* @param files All the files to download
|
||||
* @param downloadPath The destination path when all downloads are complete they will bemoved here. If undefined they will remain in the tmp path.
|
||||
*/
|
||||
constructor(
|
||||
id: string,
|
||||
files: DownloadFileEntry[],
|
||||
downloadPath: string, init?: {
|
||||
downloadPath: string | undefined,
|
||||
init?: {
|
||||
headers?: Record<string, string>,
|
||||
onProgress?: (stats: ProgressStats) => void;
|
||||
signal?: AbortSignal;
|
||||
|
|
@ -210,11 +217,19 @@ export class Downloader
|
|||
});
|
||||
}
|
||||
|
||||
await moveAllFiles(this.tmpPath, this.downloadPath);
|
||||
if (await fs.exists(this.tmpPath))
|
||||
await fs.rm(this.tmpPath, { recursive: true });
|
||||
await fs.rm(this.tmpPathMeta);
|
||||
if (this.downloadPath === undefined)
|
||||
{
|
||||
await fs.rm(this.tmpPathMeta);
|
||||
return this.files.map(f => path.join(this.tmpPath, f.file_path, f.file_name));
|
||||
} else
|
||||
{
|
||||
await moveAllFiles(this.tmpPath, this.downloadPath);
|
||||
if (await fs.exists(this.tmpPath))
|
||||
await fs.rm(this.tmpPath, { recursive: true });
|
||||
await fs.rm(this.tmpPathMeta);
|
||||
|
||||
return this.files.map(f => path.join(this.downloadPath!, f.file_path, f.file_name));
|
||||
}
|
||||
|
||||
return this.files.map(f => path.join(this.downloadPath, f.file_path, f.file_name));
|
||||
}
|
||||
}
|
||||
6
src/bun/utils/update-gameflow-linux.sh
Normal file
6
src/bun/utils/update-gameflow-linux.sh
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#!/bin/bash
|
||||
sleep 2
|
||||
mv "{{{tempFile}}}" "{{{appImagePath}}}"
|
||||
chmod +x "{{{appImagePath}}}"
|
||||
"{{{appImagePath}}}" &
|
||||
rm -- "$0"
|
||||
6
src/bun/utils/update-gameflow-windows.bat
Normal file
6
src/bun/utils/update-gameflow-windows.bat
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
@echo off
|
||||
timeout /t 2 /nobreak
|
||||
powershell -Command "Expand-Archive -Force '{{{tempFile}}}' '{{{installDir}}}'"
|
||||
del "{{{tempFile}}}"
|
||||
start "" /D "{{{installDir}}}" "{{{exePath}}}"
|
||||
del "%~f0"
|
||||
Loading…
Add table
Add a link
Reference in a new issue