Files
modorganizer/src/processrunner.cpp
T
isanae 1d97d914f1 InstanceManager now returns new Instance struct instead of instance name
moved most of the figuring out of instance parameters from InstanceManager to Instance, separated all the ui from it and put it in main.cpp
added ways to show single pages in the create instance dialog so they can be used when info is missing
2020-11-03 11:41:54 -05:00

967 lines
24 KiB
C++

#include "processrunner.h"
#include "organizercore.h"
#include "instancemanager.h"
#include "iuserinterface.h"
#include "envmodule.h"
#include "env.h"
#include <iplugingame.h>
#include <log.h>
using namespace MOBase;
void adjustForVirtualized(
const IPluginGame* game, spawn::SpawnParameters& sp, const Settings& settings)
{
const QString modsPath = settings.paths().mods();
// Check if this a request with either an executable or a working directory
// under our mods folder then will start the process in a virtualized
// "environment" with the appropriate paths fixed:
// (i.e. mods\FNIS\path\exe => game\data\path\exe)
QString cwdPath = sp.currentDirectory.absolutePath();
QString trailedModsPath = modsPath;
if (!trailedModsPath.endsWith('/')) {
trailedModsPath = trailedModsPath + '/';
}
bool virtualizedCwd = cwdPath.startsWith(trailedModsPath, Qt::CaseInsensitive);
QString binPath = sp.binary.absoluteFilePath();
bool virtualizedBin = binPath.startsWith(trailedModsPath, Qt::CaseInsensitive);
if (virtualizedCwd || virtualizedBin) {
if (virtualizedCwd) {
int cwdOffset = cwdPath.indexOf('/', trailedModsPath.length());
QString adjustedCwd = cwdPath.mid(cwdOffset, -1);
cwdPath = game->dataDirectory().absolutePath();
if (cwdOffset >= 0)
cwdPath += adjustedCwd;
}
if (virtualizedBin) {
int binOffset = binPath.indexOf('/', trailedModsPath.length());
QString adjustedBin = binPath.mid(binOffset, -1);
binPath = game->dataDirectory().absolutePath();
if (binOffset >= 0)
binPath += adjustedBin;
}
QString cmdline
= QString("launch \"%1\" \"%2\" %3")
.arg(QDir::toNativeSeparators(cwdPath),
QDir::toNativeSeparators(binPath), sp.arguments);
sp.binary = QFileInfo(QCoreApplication::applicationFilePath());
sp.arguments = cmdline;
sp.currentDirectory.setPath(QCoreApplication::applicationDirPath());
}
}
std::optional<ProcessRunner::Results> singleWait(HANDLE handle, DWORD pid)
{
if (handle == INVALID_HANDLE_VALUE) {
return ProcessRunner::Error;
}
const auto res = WaitForSingleObject(handle, 50);
switch (res)
{
case WAIT_OBJECT_0:
{
log::debug("process {} completed", pid);
return ProcessRunner::Completed;
}
case WAIT_TIMEOUT:
{
// still running
return {};
}
case WAIT_FAILED: // fall-through
default:
{
// error
const auto e = ::GetLastError();
log::error("failed waiting for {}, {}", pid, formatSystemMessage(e));
return ProcessRunner::Error;
}
}
}
enum class Interest
{
None = 0,
Weak,
Strong
};
QString toString(Interest i)
{
switch (i)
{
case Interest::Weak:
return "weak";
case Interest::Strong:
return "strong";
case Interest::None: // fall-through
default:
return "no";
}
}
struct InterestingProcess
{
env::Process p;
Interest interest = Interest::None;
env::HandlePtr handle;
};
InterestingProcess findRandomProcess(const env::Process& root)
{
for (auto&& c : root.children()) {
env::HandlePtr h = c.openHandleForWait();
if (h) {
return {c, Interest::Weak, std::move(h)};
}
auto r = findRandomProcess(c);
if (r.handle) {
return r;
}
}
return {};
}
// returns a process that's in the hidden list, or the top-level process if
// they're all hidden; returns an invalid process if the list is empty
//
InterestingProcess findInterestingProcessInTrees(const env::Process& root)
{
// Certain process names we wish to "hide" for aesthetic reason:
static const std::vector<QString> hiddenList = {
QFileInfo(QCoreApplication::applicationFilePath()).fileName(),
"conhost.exe"
};
if (root.children().empty()) {
return {};
}
auto isHidden = [&](auto&& p) {
for (auto& h : hiddenList) {
if (p.name().contains(h, Qt::CaseInsensitive)) {
return true;
}
}
return false;
};
for (auto&& p : root.children()) {
if (!isHidden(p)) {
env::HandlePtr h = p.openHandleForWait();
if (h) {
return {p, Interest::Strong, std::move(h)};
}
}
auto r = findInterestingProcessInTrees(p);
if (r.interest == Interest::Strong) {
return r;
}
}
// everything is hidden, just pick the first one that can be used
return findRandomProcess(root);
}
void dump(const env::Process& p, int indent)
{
log::debug(
"{}{}, pid={}, ppid={}",
std::string(indent * 4, ' '), p.name(), p.pid(), p.ppid());
for (auto&& c : p.children()) {
dump(c, indent + 1);
}
}
void dump(const env::Process& root)
{
log::debug("process tree:");
for (auto&& p : root.children()) {
dump(p, 1);
}
}
// gets the most interesting process in the list
//
InterestingProcess getInterestingProcess(HANDLE job)
{
env::Process root = env::getProcessTree(job);
if (root.children().empty()) {
log::debug("nothing to wait for");
return {};
}
dump(root);
auto interest = findInterestingProcessInTrees(root);
if (!interest.handle) {
// this can happen if none of the processes can be opened
log::debug("no interesting process to wait for");
return {};
}
return interest;
}
const std::chrono::milliseconds Infinite(-1);
// waits for completion, times out after `wait` if not Infinite
//
std::optional<ProcessRunner::Results> timedWait(
HANDLE handle, DWORD pid, UILocker::Session& ls,
std::chrono::milliseconds wait, std::atomic<bool>& interrupt)
{
using namespace std::chrono;
high_resolution_clock::time_point start;
if (wait != Infinite) {
start = high_resolution_clock::now();
}
while (!interrupt) {
// wait for a very short while, allows for processing events below
const auto r = singleWait(handle, pid);
if (r) {
// the process has either completed or an error was returned
return *r;
}
// the process is still running
// check the lock widget
switch (ls.result())
{
case UILocker::StillLocked:
{
break;
}
case UILocker::ForceUnlocked:
{
log::debug("waiting for {} force unlocked by user", pid);
return ProcessRunner::ForceUnlocked;
}
case UILocker::Cancelled:
{
log::debug("waiting for {} cancelled by user", pid);
return ProcessRunner::Cancelled;
}
case UILocker::NoResult: // fall-through
default:
{
// shouldn't happen
log::debug(
"unexpected result {} while waiting for {}",
static_cast<int>(ls.result()), pid);
return ProcessRunner::Error;
}
}
if (wait != Infinite) {
// check if enough time has elapsed
const auto now = high_resolution_clock::now();
if (duration_cast<milliseconds>(now - start) >= wait) {
// if so, return an empty result
return {};
}
}
}
log::debug("waiting for {} interrupted", pid);
return ProcessRunner::ForceUnlocked;
}
ProcessRunner::Results waitForProcessesThreadImpl(
HANDLE job, UILocker::Session& ls, std::atomic<bool>& interrupt)
{
using namespace std::chrono;
DWORD currentPID = 0;
// if the interesting process that was found is weak (such as ModOrganizer.exe
// when starting a program from within the Data directory), start with a short
// wait and check for more interesting children
const milliseconds defaultWait(50);
auto wait = defaultWait;
while (!interrupt) {
auto ip = getInterestingProcess(job);
if (!ip.handle) {
// nothing to wait on
return ProcessRunner::Completed;
}
// update the lock widget
ls.setInfo(ip.p.pid(), ip.p.name());
if (ip.p.pid() != currentPID) {
// log any change in the process being waited for
currentPID = ip.p.pid();
log::debug(
"waiting for completion on {} ({}), {} interest",
ip.p.name(), ip.p.pid(), toString(ip.interest));
}
if (ip.interest == Interest::Strong) {
// don't bother with short wait, this is a good process to wait for
wait = Infinite;
}
const auto r = timedWait(ip.handle.get(), ip.p.pid(), ls, wait, interrupt);
if (r) {
if (*r == ProcessRunner::Results::Completed) {
// process completed, check another one, reset the wait time to find
// interesting processes
wait = defaultWait;
} else if (*r != ProcessRunner::Results::Running) {
// something's wrong, or the user unlocked the ui
return *r;
}
}
// exponentially increase the wait time between checks for interesting
// processes
wait = std::min(wait * 2, milliseconds(2000));
}
log::debug("waiting for processes interrupted");
return ProcessRunner::ForceUnlocked;
}
void waitForProcessesThread(
ProcessRunner::Results& result, HANDLE job, UILocker::Session& ls,
std::atomic<bool>& interrupt)
{
result = waitForProcessesThreadImpl(job, ls, interrupt);
ls.unlock();
}
ProcessRunner::Results waitForProcesses(
const std::vector<HANDLE>& initialProcesses, UILocker::Session& ls)
{
if (initialProcesses.empty()) {
// nothing to wait for
return ProcessRunner::Completed;
}
// using a job so any child process started by any of those processes can also
// be captured and monitored
env::HandlePtr job(CreateJobObjectW(nullptr, nullptr));
if (!job) {
const auto e = GetLastError();
log::error(
"failed to create job to wait for processes, {}",
formatSystemMessage(e));
return ProcessRunner::Error;
}
bool oneWorked = false;
for (auto&& h : initialProcesses) {
if (::AssignProcessToJobObject(job.get(), h)) {
oneWorked = true;
} else {
const auto e = GetLastError();
// this happens when closing MO while multiple processes are running,
// so the logging is disabled until it gets fixed
//log::error(
// "can't assign process to job to wait for processes, {}",
// formatSystemMessage(e));
// keep going
}
}
HANDLE monitor = INVALID_HANDLE_VALUE;
if (oneWorked) {
monitor = job.get();
} else {
// none of the handles could be added to the job, just monitor the first one
monitor = initialProcesses[0];
}
auto results = ProcessRunner::Running;
std::atomic<bool> interrupt(false);
auto* t = QThread::create(
waitForProcessesThread,
std::ref(results), monitor, std::ref(ls), std::ref(interrupt));
QEventLoop events;
QObject::connect(t, &QThread::finished, [&]{
events.quit();
});
t->start();
events.exec();
if (t->isRunning()) {
interrupt = true;
t->wait();
}
delete t;
return results;
}
ProcessRunner::Results waitForProcess(
HANDLE initialProcess, LPDWORD exitCode, UILocker::Session& ls)
{
std::vector<HANDLE> processes = {initialProcess};
const auto r = waitForProcesses(processes, ls);
// as long as it's not running anymore, try to get the exit code
if (exitCode && r != ProcessRunner::Running) {
if (!::GetExitCodeProcess(initialProcess, exitCode)) {
const auto e = ::GetLastError();
log::warn(
"failed to get exit code of process, {}",
formatSystemMessage(e));
}
}
return r;
}
ProcessRunner::ProcessRunner(OrganizerCore& core, IUserInterface* ui) :
m_core(core), m_ui(ui), m_lockReason(UILocker::NoReason),
m_waitFlags(NoFlags), m_handle(INVALID_HANDLE_VALUE), m_exitCode(-1)
{
// all processes started in ProcessRunner are hooked by default
setHooked(true);
}
ProcessRunner& ProcessRunner::setBinary(const QFileInfo &binary)
{
m_sp.binary = binary;
return *this;
}
ProcessRunner& ProcessRunner::setArguments(const QString& arguments)
{
m_sp.arguments = arguments;
return *this;
}
ProcessRunner& ProcessRunner::setCurrentDirectory(const QDir& directory)
{
m_sp.currentDirectory = directory;
return *this;
}
ProcessRunner& ProcessRunner::setSteamID(const QString& steamID)
{
m_sp.steamAppID = steamID;
return *this;
}
ProcessRunner& ProcessRunner::setCustomOverwrite(const QString& customOverwrite)
{
m_customOverwrite = customOverwrite;
return *this;
}
ProcessRunner& ProcessRunner::setForcedLibraries(const ForcedLibraries& forcedLibraries)
{
m_forcedLibraries = forcedLibraries;
return *this;
}
ProcessRunner& ProcessRunner::setProfileName(const QString& profileName)
{
m_profileName = profileName;
return *this;
}
ProcessRunner& ProcessRunner::setWaitForCompletion(
WaitFlags flags, UILocker::Reasons reason)
{
m_waitFlags = flags;
m_lockReason = reason;
return *this;
}
ProcessRunner& ProcessRunner::setHooked(bool b)
{
m_sp.hooked = b;
return *this;
}
ProcessRunner& ProcessRunner::setFromFile(
QWidget* parent, const QFileInfo& targetInfo)
{
if (!parent && m_ui) {
parent = m_ui->mainWindow();
}
// if the file is a .exe, start it directly; if it's anything else, ask the
// shell to start it
const auto fec = spawn::getFileExecutionContext(parent, targetInfo);
switch (fec.type)
{
case spawn::FileExecutionTypes::Executable:
{
setBinary(fec.binary);
setArguments(fec.arguments);
setCurrentDirectory(targetInfo.absoluteDir());
break;
}
case spawn::FileExecutionTypes::Other: // fall-through
default:
{
m_shellOpen = targetInfo;
setHooked(false);
break;
}
}
return *this;
}
ProcessRunner& ProcessRunner::setFromExecutable(const Executable& exe)
{
const auto* profile = m_core.currentProfile();
if (!profile) {
throw MyException(QObject::tr("No profile set"));
}
const QString customOverwrite = profile->setting(
"custom_overwrites", exe.title()).toString();
ForcedLibraries forcedLibraries;
if (profile->forcedLibrariesEnabled(exe.title())) {
forcedLibraries = profile->determineForcedLibraries(exe.title());
}
QString currentDirectory = exe.workingDirectory();
if (currentDirectory.isEmpty()) {
currentDirectory = exe.binaryInfo().absolutePath();
}
setBinary(exe.binaryInfo());
setArguments(exe.arguments());
setCurrentDirectory(currentDirectory);
setSteamID(exe.steamAppID());
setCustomOverwrite(customOverwrite);
setForcedLibraries(forcedLibraries);
return *this;
}
ProcessRunner& ProcessRunner::setFromShortcut(const MOShortcut& shortcut)
{
const auto currentInstance = InstanceManager::instance().currentInstance();
if (currentInstance)
{
if (shortcut.hasInstance() && shortcut.instance() != currentInstance->name()) {
throw std::runtime_error(
QString("Refusing to run executable from different instance %1:%2")
.arg(shortcut.instance(),shortcut.executable())
.toLocal8Bit().constData());
}
}
const Executable& exe = m_core.executablesList()->get(shortcut.executable());
setFromExecutable(exe);
return *this;
}
ProcessRunner& ProcessRunner::setFromFileOrExecutable(
const QString &executable,
const QStringList &args,
const QString &cwd,
const QString &profileOverride,
const QString &forcedCustomOverwrite,
bool ignoreCustomOverwrite)
{
const auto* profile = m_core.currentProfile();
if (!profile) {
throw MyException(QObject::tr("No profile set"));
}
setBinary(executable);
setArguments(args.join(" "));
setCurrentDirectory(cwd);
setProfileName(profileOverride);
if (executable.contains('\\') || executable.contains('/')) {
// file path
if (m_sp.binary.isRelative()) {
// relative path, should be relative to game directory
setBinary(m_core.managedGame()->gameDirectory().absoluteFilePath(executable));
}
if (cwd == "") {
setCurrentDirectory(m_sp.binary.absolutePath());
}
try {
const Executable& exe = m_core.executablesList()->getByBinary(m_sp.binary);
setSteamID(exe.steamAppID());
setCustomOverwrite(profile->setting("custom_overwrites", exe.title()).toString());
if (profile->forcedLibrariesEnabled(exe.title())) {
setForcedLibraries(profile->determineForcedLibraries(exe.title()));
}
} catch (const std::runtime_error &) {
// nop
}
} else {
// only a file name, search executables list
try {
const Executable &exe = m_core.executablesList()->get(executable);
setSteamID(exe.steamAppID());
setCustomOverwrite(profile->setting("custom_overwrites", exe.title()).toString());
if (profile->forcedLibrariesEnabled(exe.title())) {
setForcedLibraries(profile->determineForcedLibraries(exe.title()));
}
if (args.isEmpty()) {
setArguments(exe.arguments());
}
setBinary(exe.binaryInfo());
if (cwd == "") {
setCurrentDirectory(exe.workingDirectory());
}
} catch (const std::runtime_error &) {
log::warn("\"{}\" not set up as executable", executable);
}
}
if (ignoreCustomOverwrite) {
setCustomOverwrite("");
} else if (!forcedCustomOverwrite.isEmpty()) {
setCustomOverwrite(forcedCustomOverwrite);
}
return *this;
}
bool ProcessRunner::shouldRunShell() const
{
return !m_shellOpen.filePath().isEmpty();
}
ProcessRunner::Results ProcessRunner::run()
{
// check if setHooked() was called after setFromFile(); this needs to
// modify the settings to run the associated executable instead of using
// shell::Open()
if (shouldRunShell() && m_sp.hooked) {
// this is a non-executable file, but it should be hooked; the associated
// executable needs to be retrieved and run instead
auto assoc = env::getAssociation(m_shellOpen);
if (!assoc.executable.filePath().isEmpty()) {
setBinary(assoc.executable);
setArguments(assoc.formattedCommandLine);
setCurrentDirectory(assoc.executable.absoluteDir());
m_shellOpen = {};
} else {
// if it fails, just use the regular shell open
log::error("failed to get the associated executable, running unhooked");
m_sp.hooked = false;
}
} else if (!shouldRunShell() && !m_sp.hooked) {
// this is an executable that should not be hooked; just run it through
// the shell
m_shellOpen = m_sp.binary;
}
std::optional<Results> r;
if (shouldRunShell()) {
r = runShell();
} else {
r = runBinary();
}
if (r) {
// early result: something went wrong and the process cannot be waited for
return *r;
}
return postRun();
}
std::optional<ProcessRunner::Results> ProcessRunner::runShell()
{
const auto file = m_shellOpen.absoluteFilePath();
log::debug("executing from shell: '{}'", file);
auto r = shell::Open(file);
if (!r.success()) {
return Error;
}
m_handle.reset(r.stealProcessHandle());
// not all files will return a valid handle even if opening them was
// successful, such as inproc handlers (like the photo viewer); in this
// case it's impossible to determine the status, so just say it's still
// running
if (m_handle.get() == INVALID_HANDLE_VALUE) {
log::debug("shell didn't report an error, but no handle is available");
return Running;
}
return {};
}
std::optional<ProcessRunner::Results> ProcessRunner::runBinary()
{
if (m_profileName.isEmpty()) {
// get the current profile name if it wasn't overridden
const auto* profile = m_core.currentProfile();
if (!profile) {
throw MyException(QObject::tr("No profile set"));
}
m_profileName = profile->name();
}
// saves profile, sets up usvfs, notifies plugins, etc.; can return false if
// a plugin doesn't want the program to run (such as when checkFNIS fails to
// run FNIS and the user clicks cancel)
if (!m_core.beforeRun(m_sp.binary, m_profileName, m_customOverwrite, m_forcedLibraries)) {
return Error;
}
// parent widget used for any dialog popped up while checking for things
QWidget* parent = (m_ui ? m_ui->mainWindow() : nullptr);
const auto* game = m_core.managedGame();
auto& settings = m_core.settings();
// start steam if needed
if (!checkSteam(parent, m_sp, game->gameDirectory(), m_sp.steamAppID, settings)) {
return Error;
}
// warn if the executable is on the blacklist
if (!checkBlacklist(parent, m_sp, settings)) {
return Error;
}
// if the executable is inside the mods folder another instance of
// ModOrganizer.exe is spawned instead to launch it
adjustForVirtualized(game, m_sp, settings);
// run the binary
m_handle.reset(startBinary(parent, m_sp));
if (m_handle.get() == INVALID_HANDLE_VALUE) {
return Error;
}
return {};
}
bool ProcessRunner::shouldRefresh(Results r) const
{
// afterRun() is only called with the Refresh flag; it refreshes the
// directory structure and notifies plugins
//
// refreshing is not always required and can actually cause problems:
//
// 1) running shortcuts doesn't need refreshing because MO closes right
// after
//
// 2) the mod info dialog is not set up to deal with refreshes, so that
// it will crash because the old DirectoryEntry's are still being used
// in the list
if (!m_waitFlags.testFlag(Refresh)) {
log::debug("not refreshing because the flag isn't set");
return false;
}
switch (r)
{
case Completed:
{
log::debug("refreshing because the process completed");
return true;
}
case ForceUnlocked:
{
log::debug("refreshing because the ui was force unlocked");
return true;
}
case Error: // fall-through
case Cancelled:
case Running:
default:
{
return false;
}
}
}
ProcessRunner::Results ProcessRunner::postRun()
{
const bool mustWait = (m_waitFlags & ForceWait);
if (!m_sp.hooked && !mustWait) {
// the process wasn't hooked and there's no force wait, don't lock
return Running;
}
if (mustWait && m_lockReason == UILocker::NoReason) {
// never lock the ui without an escape hatch for the user
log::debug(
"the ForceWait flag is set but the lock reason wasn't, "
"defaulting to LockUI");
m_lockReason = UILocker::LockUI;
}
if (mustWait) {
if (!m_core.settings().interface().lockGUI()) {
// at least tell the user what's going on
log::debug(
"locking is disabled, but the output of the application is required; "
"overriding this setting and locking the ui");
}
} else {
// no force wait
if (m_lockReason == UILocker::NoReason) {
// no locking requested
return Running;
}
if (!m_core.settings().interface().lockGUI()) {
// disabling locking is like clicking on unlock immediately
log::debug("not waiting for process because locking is disabled");
return ForceUnlocked;
}
}
auto r = Error;
withLock([&](auto& ls) {
r = waitForProcess(m_handle.get(), &m_exitCode, ls);
});
if (shouldRefresh(r)) {
m_core.afterRun(m_sp.binary, m_exitCode);
}
return r;
}
ProcessRunner::Results ProcessRunner::attachToProcess(HANDLE h)
{
m_handle.reset(h);
return postRun();
}
DWORD ProcessRunner::exitCode() const
{
return m_exitCode;
}
HANDLE ProcessRunner::getProcessHandle() const
{
return m_handle.get();
}
env::HandlePtr ProcessRunner::stealProcessHandle()
{
auto h = m_handle.release();
m_handle.reset(INVALID_HANDLE_VALUE);
return env::HandlePtr(h);
}
ProcessRunner::Results ProcessRunner::waitForAllUSVFSProcessesWithLock(
UILocker::Reasons reason)
{
m_lockReason = reason;
if (!m_core.settings().interface().lockGUI()) {
// disabling locking is like clicking on unlock immediately
return ForceUnlocked;
}
auto r = Error;
for (;;) {
withLock([&](auto& ls) {
const auto processes = getRunningUSVFSProcesses();
if (processes.empty()) {
r = Completed;
return;
}
r = waitForProcesses(processes, ls);
if (r != Completed) {
// error, cancelled, or unlocked
return;
}
// this process is completed, check for others
r = Running;
});
if (r != Running) {
break;
}
}
return r;
}
void ProcessRunner::withLock(std::function<void (UILocker::Session&)> f)
{
auto ls = UILocker::instance().lock(m_lockReason);
f(*ls);
}