Severl changes

- disabled neutralino window hot reloading
- fixed `Game.current` version gathering from the game files
- fixed `Patch.getPatchInfo()` method working, so now this function and
  the `Patch.current` field will return correct information whether
  the patch was applied or not
- `Patch.install()` method now can return null if the patch state is "preparation"
- added `Voice.selected` field that will represent the `lang.voice` config
- fixed `Voice.getDiff()` method errors
- improved `Archive`'s stream unpacked files listing
- made `Prefix` class, some `Runners` methods from the previous update were moved there
- fixed `ProgressBar.init()` method work which also fixes ETA calculation
- added bunch of new launcher states and events for them
- added `State.update()` method to automatically update launcher's state
- `Process` object now can get the output of the process that was started
  by the `Process.run()` method and can be accesed
  by the `output` event
- also was removed `input` field for the `Process.run()` method options
This commit is contained in:
Observer KRypt0n_ 2021-12-27 16:08:06 +02:00
parent 68d766da58
commit 7f4f14d76b
No known key found for this signature in database
GPG key ID: DC5D4EC1303465DA
18 changed files with 506 additions and 183 deletions

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"neu": "neu",
"dev": "vite build && neu run",
"dev": "vite build && neu run --disable-auto-reload",
"build": "vite build && neu build --release",
"check": "svelte-check --tsconfig ./tsconfig.json"
},

View file

@ -46,7 +46,7 @@ settings:
gamemode: GameMode
fps_unlocker: Unlock FPS
purge_dxvk_logs: Auto-delete DXVK logs
purge_dxvk_logs: Delete DXVK logs
# DXVKs
dxvks:

View file

@ -47,7 +47,7 @@ settings:
# TODO: add hints to the components so I could describe what these options means
gamemode: GameMode
fps_unlocker: Разблокировать FPS
purge_dxvk_logs: Автоматически удалять логи DXVK
purge_dxvk_logs: Удалять логи DXVK
# DXVKs
dxvks:

View file

@ -35,9 +35,10 @@ export default class Game
Neutralino.filesystem.readFile(persistentPath)
.then((version) => resolve(version))
.catch(() => {
Neutralino.filesystem.readFile(globalGameManagersPath)
.then((config) => {
const version = /([1-9]+\.[0-9]+\.[0-9]+)_[\d]+_[\d]+/.exec(config);
Neutralino.filesystem.readBinaryFile(globalGameManagersPath)
.then((config: ArrayBuffer) => {
const buffer = new TextDecoder('ascii').decode(new Uint8Array(config));
const version = /([1-9]+\.[0-9]+\.[0-9]+)_[\d]+_[\d]+/.exec(buffer);
resolve(version !== null ? version[1] : null);
})

View file

@ -17,32 +17,8 @@ export default class Launcher
public constructor(onMount)
{
onMount(() => {
this.state = new State(this);
this.progressBar = new ProgressBar(this);
// Progress bar test
/*this.progressBar.init({
label: 'Abobus',
showSpeed: true,
showEta: true,
showPercents: true,
showTotals: true,
finish: () => this.progressBar!.hide()
});
this.progressBar.show();
const t = (curr) => {
if (curr <= 3000)
{
this.progressBar!.update(curr, 3000, 1);
setTimeout(() => t(curr + 1), 10);
}
};
t(0);*/
this.state = new State(this);
});
}
@ -65,7 +41,8 @@ export default class Launcher
if (window.status)
{
this.settingsMenu = new Process(window.data!.pid, null);
this.settingsMenu = new Process(window.data!.pid);
this.settingsMenu.runningInterval = null;
/*this.settingsMenu.finish(() => {
Window.current.show();

View file

@ -241,9 +241,19 @@ export default class Patch
// compare it with actual UnityPlayer.dll hash and say whether the patch
// was applied or not
if (originalPlayer !== null)
patchInfo.applied = md5(`${constants.paths.gameDir}/UnityPlayer.dll`) != originalPlayer[1];
{
constants.paths.gameDir.then((gameDir) => {
Neutralino.filesystem.readBinaryFile(`${gameDir}/UnityPlayer.dll`)
.then((currPlayer: ArrayBuffer) => {
patchInfo.applied = md5(currPlayer) != originalPlayer[1];
resolve(patchInfo);
resolve(patchInfo);
})
.catch(() => resolve(patchInfo));
});
}
else resolve(patchInfo);
}
});
}
@ -255,12 +265,20 @@ export default class Patch
/**
* Get patch installation stream
*
* @returns null if the latest available patch in preparation state
* @returns rejects Error object if the patch's repositories are unreachable or they responded with an error
*/
public static install(): Promise<Stream>
public static install(): Promise<Stream|null>
{
return new Promise((resolve, reject) => {
this.latest
.then((patch) => resolve(new Stream(constants.getPatchUri(patch.source ?? 'origin'), patch.version)))
.then((patch) => {
if (patch.state === 'preparation')
resolve(null);
else resolve(new Stream(constants.getPatchUri(patch.source ?? 'origin'), patch.version));
})
.catch((err) => reject(err));
});
}

View file

@ -1,9 +1,10 @@
import type { VoicePack } from './types/GameData';
import type { InstalledVoice } from './types/Voice';
import type { InstalledVoice, VoiceLang } from './types/Voice';
import constants from './Constants';
import Game from './Game';
import AbstractInstaller from './core/AbstractInstaller';
import Configs from './Configs';
declare const Neutralino;
@ -60,6 +61,14 @@ export default class Voice
});
}
/**
* Get currently selected voice package language accotring to the config file
*/
public static get selected(): Promise<VoiceLang>
{
return Configs.get('lang.voice') as Promise<VoiceLang>;
}
/**
* Get latest voice data info
*
@ -84,7 +93,7 @@ export default class Voice
{
return new Promise((resolve, reject) => {
Game.getDiff(version)
.then((data) => resolve(data!.voice_packs ?? null))
.then((data) => resolve(data?.voice_packs ?? null))
.catch((error) => reject(error));
});
}

View file

@ -5,6 +5,8 @@ import type {
ArchiveInfo
} from '../types/Archive';
import promisify from './promisify';
declare const Neutralino;
declare const NL_CWD;
@ -83,21 +85,34 @@ class Stream
const updateProgress = async () => {
let difference: number = 0;
let pool: any[] = [];
remainedFiles.forEach((file) => {
if (file.path != '#unpacked#')
{
Neutralino.filesystem.getStats(`${baseDir}/${file.path}`)
.then(() => {
this.unpacked += file.size.uncompressed!;
difference += file.size.uncompressed!;
pool.push((): Promise<void> => {
return new Promise((resolve) => {
Neutralino.filesystem.getStats(`${baseDir}/${file.path}`)
.then(() => {
this.unpacked += file.size.uncompressed!;
difference += file.size.uncompressed!;
file.path = '#unpacked#';
})
.catch(() => {});
file.path = '#unpacked#';
resolve();
})
.catch(() => resolve())
});
});
}
});
await promisify({
callbacks: pool,
callAtOnce: true,
interval: 200
});
remainedFiles = remainedFiles.filter((file) => file.path != '#unpacked#');
if (this.onProgress)

125
src/ts/core/Prefix.ts Normal file
View file

@ -0,0 +1,125 @@
import constants from '../Constants';
import Process from '../neutralino/Process';
import Downloader from './Downloader';
import Runners from './Runners';
declare const Neutralino;
export default class Prefix
{
/**
* Check if the wine prefix is created in the specified path
*/
public static exists(path: string|null = null): Promise<boolean>
{
return new Promise(async (resolve) => {
path ??= await constants.paths.prefix.current;
Neutralino.filesystem.getStats(`${path}/drive_c`)
.then(() => resolve(true))
.catch(() => resolve(false));
});
}
/**
* Get path to the winetricks.sh file
*
* If this file is not downloaded - then this method will download it
* and return the path after it
*/
public static getWinetricks(): Promise<string>
{
return new Promise(async (resolve) => {
const winetricksPath = `${await constants.paths.launcherDir}/winetricks.sh`;
Neutralino.filesystem.getStats(winetricksPath)
.then(() => resolve(winetricksPath))
.catch(() => {
Downloader.download(constants.uri.winetricks, winetricksPath).then((stream) => {
stream.finish(() => resolve(winetricksPath));
});
});
});
}
/**
* Create wine prefix using the current selected wine
*
* @param path folder to create prefix in
* @param progress function that will be called with every creation step
*
* @returns false if there's no selected wine version. Otherwise true
*/
public static create(path: string, progress?: (output: string, current: number, total: number) => void): Promise<boolean>
{
const installationSteps = [
// corefonts
'Executing w_do_call corefonts',
'Executing load_corefonts',
'Executing load_andale',
'Executing load_arial',
'Executing load_comicsans',
'Executing load_courier',
'Executing load_georgia',
'Executing load_impact',
'Executing load_times',
'Executing load_trebuchet',
'Executing load_verdana',
'Executing load_webdings',
// usetakefocus=n (fullscreen input issues fix)
'Executing load_usetakefocus n'
];
return new Promise((resolve) => {
Runners.current.then((runner) => {
if (runner === null)
resolve(false);
else
{
this.getWinetricks().then(async (winetricks) => {
let installationProgress = 0;
const process = await Process.run(`bash '${Process.addSlashes(winetricks)}' corefonts usetakefocus=n`, {
env: {
WINE: `${await constants.paths.runnersDir}/${runner.name}/${runner.files.wine}`,
WINESERVER: `${await constants.paths.runnersDir}/${runner.name}/${runner.files.wineserver}`,
WINEPREFIX: path
}
});
process.outputInterval = null;
if (progress)
{
process.outputInterval = 1500;
process.output((output) => {
for (let i = 0; i < installationSteps.length; ++i)
if (output.includes(installationSteps[i]))
{
installationProgress = i + 1;
break;
}
if (output != '')
{
const lastLine = output.split(/\r\n|\r|\n/gm)
.filter((line) => line.length > 0)
.pop()?.trim();
if (lastLine && !lastLine.includes('------'))
progress(lastLine, installationProgress, installationSteps.length);
}
});
}
process.finish(() => resolve(true));
});
}
});
});
}
};

View file

@ -121,97 +121,6 @@ class Runners
else resolve(new Stream(runner));
});
}
/**
* Get path to the winetricks.sh file
*
* If this file is not downloaded - then this method will download it
* and return the path after it
*/
public static getWinetricks(): Promise<string>
{
return new Promise(async (resolve) => {
const winetricksPath = `${await constants.paths.launcherDir}/winetricks.sh`;
Neutralino.filesystem.getStats(winetricksPath)
.then(() => resolve(winetricksPath))
.catch(() => {
Downloader.download(constants.uri.winetricks, winetricksPath).then((stream) => {
stream.finish(() => resolve(winetricksPath));
});
});
});
}
/**
* Create wine prefix using the current selected wine
*
* @param path folder to create prefix in
* @param progress function that will be called with every creation step
*
* @returns false if there's no selected wine version. Otherwise true
*/
public static createPrefix(path: string, progress?: (output: string, current: number, total: number) => void): Promise<boolean>
{
const installationSteps = [
// corefonts
'Executing w_do_call corefonts',
'Executing load_corefonts',
'Executing load_andale',
'Executing load_arial',
'Executing load_comicsans',
'Executing load_courier',
'Executing load_georgia',
'Executing load_impact',
'Executing load_times',
'Executing load_trebuchet',
'Executing load_verdana',
'Executing load_webdings',
// usetakefocus=n (fullscreen input issues fix)
'Executing load_usetakefocus n'
];
return new Promise((resolve) => {
this.current.then((runner) => {
if (runner === null)
resolve(false);
else
{
this.getWinetricks().then(async (winetricks) => {
// let installationProgress = 0;
const process = await Process.run(`bash '${Process.addSlashes(winetricks)}' corefonts usetakefocus=n`, {
env: {
WINE: `${await constants.paths.runnersDir}/${runner.name}/${runner.files.wine}`,
WINESERVER: `${await constants.paths.runnersDir}/${runner.name}/${runner.files.wineserver}`,
WINEPREFIX: path
}
});
// todo: add process output reading
process.finish(() => resolve(true));
/*installerProcess.stdout.on('data', (data: string) => {
let str = data.toString();
for (let i = 0; i < installationSteps.length; ++i)
if (str.includes(installationSteps[i]))
{
installationProgress = i + 1;
break;
}
progress(str, installationProgress, installationSteps.length);
});*/
});
}
});
});
}
}
export default Runners;

View file

@ -75,10 +75,14 @@ export default class ProgressBar
this.speedLabelElement.textContent = '';
this.etaLabelElement.textContent = '';
if (typeof options.label === 'string')
this.downloadedLabelElement.textContent = options.label;
this.downloadedLabelElement.textContent = typeof options.label === 'string' ?
options.label : '';
this.progress.beganAt = Date.now();
this.progress = {
beganAt: Date.now(),
prevTime: Date.now(),
temp: 0
};
}
/**
@ -93,7 +97,7 @@ export default class ProgressBar
// Otherwise update percents and totals if we should
else if (this.options!.showPercents || this.options!.showPercents)
{
this.downloadedLabelElement.textContent = `${this.options!.label}:`;
this.downloadedLabelElement.textContent = this.options!.label;
if (this.options!.showPercents)
this.downloadedLabelElement.textContent += ` ${Math.round(current / total * 100)}%`;

View file

@ -1,4 +1,6 @@
import Game from '../Game';
import type Launcher from '../Launcher';
import Patch from '../Patch';
import type { LauncherState } from '../types/Launcher';
@ -8,13 +10,18 @@ export default class State
public launchButton: HTMLElement;
protected _state: LauncherState = 'game-installation-available';
protected _state: LauncherState = 'game-launch-available';
protected events = {
'game-launch-available': import('./states/Launch'),
'game-installation-available': import('./states/Install'),
'game-update-available': import('./states/Install')
'game-update-available': import('./states/Install'),
'game-voice-update-required': import('./states/InstallVoice'),
'test-patch-available': import('./states/ApplyPatch'),
'patch-available': import('./states/ApplyPatch')
};
public constructor(launcher: Launcher)
@ -27,6 +34,8 @@ export default class State
if (this.events[this._state])
this.events[this._state].then((event) => event.default(this.launcher));
};
this.update();
}
/**
@ -44,14 +53,82 @@ export default class State
{
this._state = state;
this.launcher.progressBar!.hide();
switch(state)
{
case 'game-launch-available':
this.launcher.progressBar!.hide();
this.launchButton.textContent = 'Launch';
break;
case 'game-installation-available':
this.launchButton.textContent = 'Install';
break;
case 'game-update-available':
case 'game-voice-update-required':
this.launchButton.textContent = 'Update';
break;
case 'patch-available':
this.launchButton.textContent = 'Apply patch';
break;
case 'test-patch-available':
// todo some warning message
this.launchButton.textContent = 'Apply test patch';
break;
case 'patch-unavailable':
// todo some warning message
this.launchButton.textContent = 'Patch unavailable';
break;
}
}
/**
* Update launcher state
*
* @returns new launcher state
*
* This state will be automatically applied to the launcher
* so you don't need to do it manually
*/
public update(): Promise<string>
{
return new Promise(async (resolve) => {
let state: LauncherState;
const gameCurrent = await Game.current;
const gameLatest = (await Game.latest).version;
const patch = await Patch.latest;
console.log(patch);
if (gameCurrent === null)
state = 'game-installation-available';
else if (gameCurrent != gameLatest)
state = 'game-update-available';
else if (!patch.applied)
{
state = patch.state == 'preparation' ?
'patch-unavailable' : (patch.state == 'testing' ?
'test-patch-available' : 'patch-available');
}
else state = 'game-launch-available';
this.set(state);
resolve(state);
});
}
};

View file

@ -0,0 +1,39 @@
import type Launcher from '../../Launcher';
import Patch from '../../Patch';
export default (launcher: Launcher): Promise<void> => {
return new Promise(async (resolve) => {
Patch.latest.then((patch) => {
if (patch.applied)
resolve();
else
{
launcher.progressBar?.init({
label: 'Applying patch...',
showSpeed: false,
showEta: false,
showPercents: false,
showTotals: false
});
Patch.install().then((stream) => {
if (stream === null)
resolve();
else
{
stream.downloadStart(() => launcher.progressBar?.show());
stream.patchFinish(() => {
launcher.progressBar?.hide();
resolve();
});
}
});
}
});
});
};

View file

@ -0,0 +1,51 @@
import type Launcher from '../../Launcher';
import constants from '../../Constants';
import Prefix from '../../core/Prefix';
export default (launcher: Launcher): Promise<void> => {
return new Promise(async (resolve) => {
const prefixDir = await constants.paths.prefix.current;
Prefix.exists().then((exists) => {
if (exists)
resolve();
else
{
let progressLabel = 'Creating prefix...';
launcher.progressBar!.init({
label: () => progressLabel,
showSpeed: false,
showEta: false,
showPercents: false,
showTotals: false
});
launcher.progressBar!.show();
Prefix.create(prefixDir, (output, current, total) => {
progressLabel = output;
if (progressLabel.length > 70)
progressLabel = progressLabel.substring(0, 70) + '...';
launcher.progressBar!.update(current, total, 1);
})
.then((result) => {
if (result === true)
resolve();
else
{
// TODO
console.error('There\'s no wine version installed to use to create the prefix');
resolve();
}
});
}
});
});
};

View file

@ -1,34 +1,23 @@
import type Launcher from '../../Launcher';
import Game from '../../Game';
import constants from '../../Constants';
import Runners from '../../core/Runners';
declare const Neutralino;
import Prefix from '../../core/Prefix';
export default (launcher: Launcher): Promise<void> => {
return new Promise(async (resolve) => {
const prefixDir = await constants.paths.prefix.current;
Neutralino.filesystem.getStats(prefixDir)
.then(() => updateGame())
.catch(() => {
Runners.createPrefix(prefixDir).then((result) => {
if (result === true)
updateGame();
else
{
// TODO
console.error('There\'s no wine version installed to use to create the prefix');
resolve();
}
Prefix.exists().then((exists) => {
if (!exists)
{
import('./CreatePrefix').then((module) => {
module.default(launcher).then(() => updateGame());
});
});
}
});
const updateGame = async () => {
Game.update(await Game.current).then((stream) => {
const prevGameVersion = await Game.current;
Game.update(prevGameVersion).then((stream) => {
launcher.progressBar?.init({
label: 'Downloading game...',
showSpeed: true,
@ -57,7 +46,12 @@ export default (launcher: Launcher): Promise<void> => {
launcher.progressBar?.update(current, total, difference);
});
stream?.unpackFinish(() => resolve());
stream?.unpackFinish(() => {
// Download voice package when the game itself was installed
import('./InstallVoice').then((module) => {
module.default(launcher, prevGameVersion).then(() => resolve());
});
});
});
};
});

View file

@ -0,0 +1,43 @@
import type Launcher from '../../Launcher';
import Voice from '../../Voice';
export default (launcher: Launcher, prevGameVersion: string|null = null): Promise<void> => {
return new Promise(async (resolve) => {
Voice.update(await Voice.selected, prevGameVersion).then((stream) => {
launcher.progressBar?.init({
label: 'Downloading voice package...',
showSpeed: true,
showEta: true,
showPercents: true,
showTotals: true
});
stream?.downloadStart(() => launcher.progressBar?.show());
stream?.downloadProgress((current: number, total: number, difference: number) => {
launcher.progressBar?.update(current, total, difference);
});
stream?.unpackStart(() => {
launcher.progressBar?.init({
label: 'Unpacking voice package...',
showSpeed: true,
showEta: true,
showPercents: true,
showTotals: true
});
});
stream?.unpackProgress((current: number, total: number, difference: number) => {
launcher.progressBar?.update(current, total, difference);
});
stream?.unpackFinish(() => {
launcher.progressBar?.hide();
resolve();
});
});
});
};

View file

@ -1,7 +1,7 @@
declare const Neutralino;
declare const NL_CWD;
type ProcessOptions = {
input?: string;
env?: object;
cwd?: string;
};
@ -20,7 +20,19 @@ class Process
*
* @default 200
*/
public interval: number|null;
public runningInterval: number|null = 200;
/**
* Interval in ms between process output update
*
* null if you don't want to update process output
*
* @default 500
*/
public outputInterval: number|null = 500;
protected outputFile: string|null;
protected outputOffset: number = 0;
protected _finished: boolean = false;
@ -32,20 +44,21 @@ class Process
return this._finished;
};
protected onOutput?: (output: string, process: Process) => void;
protected onFinish?: (process: Process) => void;
public constructor(pid: number, interval: number|null = 200)
public constructor(pid: number, outputFile: string|null = null)
{
this.id = pid;
this.interval = interval;
this.outputFile = outputFile;
const updateStatus = () => {
this.running().then((running) => {
// The process is still running
if (running)
{
if (this.interval)
setTimeout(updateStatus, this.interval);
if (this.runningInterval)
setTimeout(updateStatus, this.runningInterval);
}
// Otherwise the process was stopped
@ -59,8 +72,34 @@ class Process
});
};
if (this.interval)
setTimeout(updateStatus, this.interval);
if (this.runningInterval)
setTimeout(updateStatus, this.runningInterval);
if (this.outputFile)
{
const updateOutput = () => {
Neutralino.filesystem.readFile(this.outputFile)
.then((output: string) => {
if (this.onOutput)
this.onOutput(output.substring(this.outputOffset), this);
this.outputOffset = output.length;
if (this._finished)
Neutralino.filesystem.removeFile(this.outputFile);
else if (this.outputInterval)
setTimeout(updateOutput, this.outputInterval);
})
.catch(() => {
if (this.outputInterval && !this._finished)
setTimeout(updateOutput, this.outputInterval);
});
};
if (this.outputInterval)
setTimeout(updateOutput, this.outputInterval);
}
}
/**
@ -75,7 +114,7 @@ class Process
// If user stopped process status auto-checking
// then we should check it manually when this method was called
else if (this.interval === null)
else if (this.runningInterval === null)
{
this.running().then((running) => {
if (!running)
@ -88,6 +127,11 @@ class Process
}
}
public output(callback: (output: string, process: Process) => void)
{
this.onOutput = callback;
}
/**
* Kill process
*/
@ -117,8 +161,9 @@ class Process
*/
public static run(command: string, options: ProcessOptions = {}): Promise<Process>
{
return new Promise(async (resolve) => {
const tmpFile = `${NL_CWD}/${10000 + Math.round(Math.random() * 89999)}.tmp`;
// Set env variables
if (options.env)
{
@ -127,17 +172,19 @@ class Process
});
}
// Set output redirection to the temp file
command = `${command} > '${this.addSlashes(tmpFile)}' 2>&1`;
// Set current working directory
if (options.cwd)
command = `cd '${this.addSlashes(options.cwd)}' && ${command} && cd -`;
// And run the command
const process = await Neutralino.os.execCommand(command, {
background: true,
stdin: options.input ?? ''
background: true
});
resolve(new Process(process.pid));
resolve(new Process(process.pid, tmpFile));
});
}

View file

@ -1,11 +1,25 @@
/**
* With a first run the game will not be installed
* and the launcher will have "game-installation-available" state
*
* With it, launcher will create wine prefix if it is required,
* download the game, voice data and unpack them
*
* Then, with game's updates launcher will have "game-update-available" state
* and with it it will download and unpack game and voice updates
*
* When the game is installed and updated - then launcher will have either
* "patch-unavailable", "test-patch-available", "patch-available", or "game-launch-available"
* So it will either download and apply patch, launch the game or notify user that the patch is not available
*/
type LauncherState =
| 'patch-unavailable'
| 'test-patch-available'
| 'patch-applying'
| 'game-update-available'
| 'patch-available'
| 'game-installation-available'
| 'game-update-available'
| 'game-voice-update-required'
| 'resume-download-available'
| 'game-launch-available';
export type { LauncherState };