diff --git a/README.md b/README.md index 49b12ed..acf5a26 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,11 @@ This is our current roadmap goals. You can find older ones [here](ROADMAP.md) * Make `Launcher` class to manage launcher-related features * Downloading progress * Launcher state functionality + * Game launch available + * Game update (installation) required + * Voice data update (installation) required + * Patch unavailable + * Test patch available * Make Vue components * Checkbox * Selection diff --git a/index.html b/index.html index a596c26..cb9386c 100644 --- a/index.html +++ b/index.html @@ -24,7 +24,7 @@
- +
diff --git a/package.json b/package.json index 55db141..ab79825 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "dependencies": { "js-md5": "^0.7.3", - "vue": "^3.2.25" + "vue": "^3.2.25", + "yaml": "^1.10.2" }, "devDependencies": { "@neutralinojs/neu": "^8.0.0", diff --git a/src/pages/index.ts b/src/pages/index.ts index d177ef4..6db259e 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -5,16 +5,39 @@ import Window from '../ts/neutralino/Window'; import Launcher from '../ts/Launcher'; import Configs from '../ts/Configs'; import constants from '../ts/Constants'; +import promisify from '../ts/core/promisify'; +import Process from '../ts/neutralino/Process'; -(async () => { +promisify(async () => { Configs.defaults({ lang: { launcher: 'en-us', voice: 'en-us' }, - prefix: await constants.paths.prefix.default + + // Path to wine prefix + prefix: await constants.paths.prefix.default, + + // runner name to use, or null if runner is not specified + runner: null, + + /** + * HUD + * + * null if don't use + * otherwise should be "dxvk" or "mangohud" + */ + hud: null, + + /** + * vkBasalt preset to use + * + * null if don't use + * otherwise should be some folder name from the "shaders" folder + */ + shaders: null }); -})(); +}); let app = createApp({ data: () => ({ diff --git a/src/ts/Configs.ts b/src/ts/Configs.ts index 71f9f96..32e8f37 100644 --- a/src/ts/Configs.ts +++ b/src/ts/Configs.ts @@ -1,3 +1,5 @@ +import YAML from 'yaml'; + import constants from './Constants'; declare const Neutralino; @@ -19,7 +21,7 @@ export default class Configs { return new Promise(async (resolve) => { Neutralino.filesystem.readFile(await constants.paths.config).then((config) => { - config = JSON.parse(config); + config = YAML.parse(config); if (name !== '') { @@ -54,12 +56,12 @@ export default class Configs value = await Promise.resolve(value); Neutralino.filesystem.readFile(await constants.paths.config).then(async (config) => { - config = JSON.stringify(getUpdatedArray(name.split('.'), JSON.parse(config), value), null, 4); + config = YAML.stringify(getUpdatedArray(name.split('.'), YAML.parse(config), value)); Neutralino.filesystem.writeFile(await constants.paths.config, config) .then(() => resolve()); }).catch(async () => { - let config = JSON.stringify(getUpdatedArray(name.split('.'), {}, value), null, 4); + let config = YAML.stringify(getUpdatedArray(name.split('.'), {}, value)); Neutralino.filesystem.writeFile(await constants.paths.config, config) .then(() => resolve()); @@ -74,32 +76,32 @@ export default class Configs * * @returns Promise indicates if the default settings were applied */ - public static defaults(configs: scalar): Promise + public static defaults(configs: object): Promise { return new Promise(async (resolve) => { - const setDefaults = async (current: scalar) => { - const updateDefaults = (current: scalar, defaults: scalar) => { - Object.keys(defaults!).forEach((key) => { + const setDefaults = async (current: object) => { + const updateDefaults = (current: object, defaults: object) => { + Object.keys(defaults).forEach((key) => { // If the field exists in defaults and doesn't exist in current - if (current![key] === undefined) - current![key] = defaults![key]; + if (current[key] === undefined) + current[key] = defaults[key]; // If both of default and current are objects - else if (typeof current![key] == 'object' && typeof defaults![key] == 'object') - current![key] = updateDefaults(current![key], defaults![key]); + // and we also should check if they're not nulls + // because JS thinks that [typeof null === 'object'] + else if (typeof current[key] == 'object' && typeof defaults[key] == 'object' && current[key] !== null && defaults[key] !== null) + current[key] = updateDefaults(current[key], defaults![key]); }); return current; }; - current = JSON.stringify(updateDefaults(current, configs), null, 4); - - Neutralino.filesystem.writeFile(await constants.paths.config, current) + Neutralino.filesystem.writeFile(await constants.paths.config, YAML.stringify(updateDefaults(current, configs))) .then(() => resolve()); }; Neutralino.filesystem.readFile(await constants.paths.config) - .then((config) => setDefaults(JSON.parse(config))) + .then((config) => setDefaults(YAML.parse(config))) .catch(() => setDefaults({})); }); } diff --git a/src/ts/Constants.ts b/src/ts/Constants.ts index 21af4a0..9eda6fc 100644 --- a/src/ts/Constants.ts +++ b/src/ts/Constants.ts @@ -83,11 +83,11 @@ class Paths /** * Config file * - * @default "~/.local/share/anime-game-launcher/config.json" + * @default "~/.local/share/anime-game-launcher/config.yaml" */ public static get config(): Promise { - return new Promise(async (resolve) => resolve(`${await this.launcherDir}/config.json`)); + return new Promise(async (resolve) => resolve(`${await this.launcherDir}/config.yaml`)); } /** diff --git a/src/ts/Launcher.ts b/src/ts/Launcher.ts index 2cfd9a2..c5bbd85 100644 --- a/src/ts/Launcher.ts +++ b/src/ts/Launcher.ts @@ -1,18 +1,22 @@ import constants from './Constants'; import Configs from './Configs'; + import Background from './launcher/Background'; import ProgressBar from './launcher/ProgressBar'; - -declare const Neutralino; +import State from './launcher/State'; export default class Launcher { public app; + + public state: State; public progressBar: ProgressBar; public constructor(app) { this.app = app; + + this.state = new State(this); this.progressBar = new ProgressBar(this); // Progress bar test @@ -40,6 +44,9 @@ export default class Launcher t(0); } + /** + * Update launcher background picture + */ public updateBackground(): Promise { return new Promise(async (resolve) => { diff --git a/src/ts/core/Runners.ts b/src/ts/core/Runners.ts index ab6d4a9..1929add 100644 --- a/src/ts/core/Runners.ts +++ b/src/ts/core/Runners.ts @@ -4,6 +4,7 @@ import { } from '../types/Runners'; import constants from '../Constants'; +import Configs from '../Configs'; import AbstractInstaller from './AbstractInstaller'; declare const Neutralino; @@ -18,10 +19,25 @@ class Stream extends AbstractInstaller class Runners { + /** + * Get the current using runner according to the config file + */ + public static get current(): Promise + { + return new Promise((resolve) => { + Configs.get('runner').then((runner) => { + if (typeof runner === 'string') + Runners.get(runner).then((runner) => resolve(runner)); + + else resolve(null); + }); + }); + } + /** * Get runners list */ - public static get(): Promise + public static list(): Promise { return new Promise((resolve) => { constants.paths.runnersDir.then(async (runnersDir: string) => { @@ -58,6 +74,29 @@ class Runners }); } + /** + * Get the runner with a specified name + * + * @returns null if the runner with this name is not found + */ + public static get(name: string): Promise + { + return new Promise((resolve) => { + this.list().then((list) => { + for (const family of list) + for (const runner of family.runners) + if (runner.name == name) + { + resolve(runner); + + return; + } + + resolve(null); + }); + }); + } + /** * Download runner to the [constants.paths.runners] directory * @@ -66,21 +105,14 @@ class Runners */ public static download(runner: Runner|Runner['name']): Promise { - return new Promise(async (resolve) => { + return new Promise((resolve) => { // If we provided runner parameter with a name of a runner // then we should find this runner and call this method for it if (typeof runner == 'string') { - let foundRunner; - - (await this.get()).forEach((family) => { - family.runners.forEach((familyRunner) => { - if (familyRunner.name == runner) - foundRunner = familyRunner; - }); + this.get(runner).then((foundRunner) => { + resolve(foundRunner === null ? null : new Stream(foundRunner)); }); - - resolve(foundRunner === null ? null : new Stream(foundRunner)); } // Otherwise we can use runner.uri and so on to download runner diff --git a/src/ts/core/promisify.ts b/src/ts/core/promisify.ts new file mode 100644 index 0000000..7785e10 --- /dev/null +++ b/src/ts/core/promisify.ts @@ -0,0 +1,9 @@ +/** + * Make a promise from a synchronous function and run it + */ +export default function promisify(callback: () => any): Promise +{ + return new Promise((resolve) => { + resolve(callback()); + }); +}; diff --git a/src/ts/launcher/State.ts b/src/ts/launcher/State.ts new file mode 100644 index 0000000..26cd523 --- /dev/null +++ b/src/ts/launcher/State.ts @@ -0,0 +1,54 @@ +import Launcher from '../Launcher'; + +import type { LauncherState } from '../types/Launcher'; + +export default class State +{ + public launcher: Launcher; + + public launchButton: HTMLElement; + + protected _state: LauncherState = 'game-launch-available'; + + protected events = { + 'game-launch-available': import('./states/Launch') + }; + + public constructor(launcher: Launcher) + { + this.launcher = launcher; + + this.launchButton = document.getElementById('launch'); + + this.launchButton.onclick = () => { + if (this.events[this._state]) + this.events[this._state].then((event) => event.default()); + }; + } + + /** + * Get current launcher state + */ + public get(): LauncherState + { + return this._state; + } + + /** + * Set launcher state + */ + public set(state: LauncherState): void + { + this._state = state; + + switch(state) + { + case 'game-launch-available': + this.launcher.progressBar.hide(); + + this.launchButton.textContent = 'Launch'; + + break; + } + } +}; diff --git a/src/ts/launcher/states/Launch.ts b/src/ts/launcher/states/Launch.ts new file mode 100644 index 0000000..14dfd1a --- /dev/null +++ b/src/ts/launcher/states/Launch.ts @@ -0,0 +1,123 @@ +import Configs from '../../Configs'; +import constants from '../../Constants'; +import Runners from '../../core/Runners'; +import Launcher from '../../Launcher'; +import Process from '../../neutralino/Process'; + +declare const Neutralino; + +export default (launcher: Launcher): Promise => { + return new Promise(async (resolve) => { + /** + * Selecting wine executable + */ + let wineExeutable = 'wine'; + + const runner = await Runners.current; + + console.log(runner); + + if (runner !== null) + { + wineExeutable = `${constants.paths.runnersDir}/${runner.name}/${runner.files.wine}`; + + try + { + Neutralino.filesystem.getStats(wineExeutable); + } + + catch + { + wineExeutable = 'wine'; + + await Configs.set('runner', null); + } + } + + console.log(`Wine executable: ${wineExeutable}`); + + // Some special variables + let env: any = {}; + + /** + * HUD + */ + switch (await Configs.get('hud')) + { + case 'dxvk': + env['DXVK_HUD'] = 'fps,frametimes'; + + break; + + case 'mangohud': + env['MANGOHUD'] = 1; + + break; + } + + /** + * Shaders + */ + const shaders = await Configs.get('shaders'); + + if (shaders !== null) + { + const userShadersFile = `${constants.paths.shadersDir}/${shaders}/vkBasalt.conf`; + const launcherShadersFile = `${await constants.paths.launcherDir}/vkBasalt.conf`; + + env['ENABLE_VKBASALT'] = 1; + env['VKBASALT_CONFIG_FILE'] = launcherShadersFile; + + await Neutralino.filesystem.writeFile(launcherShadersFile, await Neutralino.filesystem.readFile(userShadersFile)); + } + + /** + * GPU selection + */ + /*if (LauncherLib.getConfig('gpu') != 'default') + { + const gpu = await SwitcherooControl.getGpuByName(LauncherLib.getConfig('gpu')); + + if (gpu) + { + env = { + ...env, + ...SwitcherooControl.getEnvAsObject(gpu) + }; + } + + else console.warn(`GPU ${LauncherLib.getConfig('gpu')} not found. Launching on the default GPU`); + }*/ + + // let command = `${wineExeutable} ${LauncherLib.getConfig('fpsunlock') ? 'fpsunlock.bat' : 'launcher.bat'}`; + + /** + * Gamemode integration + */ + /*if (LauncherLib.getConfig('gamemode')) + command = `gamemoderun ${command}`;*/ + + const command = `${wineExeutable} launcher.bat`; + + console.log(`Execution command: ${command}`); + + /** + * Starting the game + */ + const startTime = Date.now(); + + const process = await Process.run(command, { + env: env, + cwd: await constants.paths.gameDir + }); + + // Game closed event + process.finish(() => { + const stopTime = Date.now(); + + // todo + + resolve(); + }); + }); +}; diff --git a/src/ts/neutralino/Process.ts b/src/ts/neutralino/Process.ts new file mode 100644 index 0000000..5da65e9 --- /dev/null +++ b/src/ts/neutralino/Process.ts @@ -0,0 +1,100 @@ +declare const Neutralino; + +type ProcessOptions = { + input?: string; + env?: object; + cwd?: string; +}; + +class Process +{ + public readonly id: number; + + /** + * Interval between process status update + */ + public interval: number = 200; + + protected _finished: boolean = false; + + /** + * Whether the process was finished + */ + public get finished(): boolean + { + return this._finished; + }; + + protected onFinish?: (process: Process) => void; + + public constructor(pid: number) + { + this.id = pid; + + const updateStatus = async () => { + Neutralino.os.execCommand(`ps -p ${this.id}`).then((output) => { + // The process is still running + if (output.stdOut.includes(this.id)) + setTimeout(updateStatus, this.interval); + + // Otherwise the process was stopped + else + { + this._finished = true; + + if (this.onFinish) + this.onFinish(this); + } + }); + }; + + setTimeout(updateStatus, this.interval); + } + + /** + * Specify callback to run when the process will be finished + */ + public finish(callback: (process: Process) => void) + { + this.onFinish = callback; + + if (this._finished) + callback(this); + } + + /** + * Run shell command + */ + public static run(command: string, options: ProcessOptions = {}): Promise + { + // Replace '\a\b' to '\\a\\b' + // And replace ''' to '\'' + const addSlashes = (str: string) => str.replaceAll('\\', '\\\\').replaceAll('\'', '\\\''); + + return new Promise(async (resolve) => { + // Set env variables + if (options.env) + { + Object.keys(options.env).forEach((key) => { + command = `${key}='${addSlashes(options.env![key])}' ${command}`; + }); + } + + // Set current working directory + if (options.cwd) + command = `cd '${addSlashes(options.cwd)}' && ${command} && cd -`; + + // And run the command + const process = await Neutralino.os.execCommand(command, { + background: true, + stdin: options.input ?? '' + }); + + resolve(new Process(process.pid)); + }); + } +} + +export type { ProcessOptions }; + +export default Process; diff --git a/src/ts/types/Launcher.ts b/src/ts/types/Launcher.ts new file mode 100644 index 0000000..ba60adc --- /dev/null +++ b/src/ts/types/Launcher.ts @@ -0,0 +1,11 @@ +type LauncherState = + | 'patch-unavailable' + | 'test-patch-available' + | 'patch-applying' + | 'game-update-available' + | 'game-installation-available' + | 'game-voice-update-required' + | 'resume-download-available' + | 'game-launch-available'; + +export type { LauncherState }; \ No newline at end of file