Implement pause/resume commands w/ APOLLO_APP_STATUS envvar

This commit is contained in:
Yukino Song
2025-06-05 01:57:41 +08:00
parent 2795e34e16
commit 3e0cbaf2c2
13 changed files with 312 additions and 119 deletions

View File

@@ -594,6 +594,7 @@ namespace config {
false, // notify_pre_releases false, // notify_pre_releases
false, // legacy_ordering false, // legacy_ordering
{}, // prep commands {}, // prep commands
{}, // state commands
{}, // server commands {}, // server commands
}; };
@@ -1211,6 +1212,7 @@ namespace config {
string_f(vars, "external_ip", nvhttp.external_ip); string_f(vars, "external_ip", nvhttp.external_ip);
list_prep_cmd_f(vars, "global_prep_cmd", config::sunshine.prep_cmds); list_prep_cmd_f(vars, "global_prep_cmd", config::sunshine.prep_cmds);
list_prep_cmd_f(vars, "global_state_cmd", config::sunshine.state_cmds);
list_server_cmd_f(vars, "server_cmd", config::sunshine.server_cmds); list_server_cmd_f(vars, "server_cmd", config::sunshine.server_cmds);
string_f(vars, "audio_sink", audio.sink); string_f(vars, "audio_sink", audio.sink);

View File

@@ -282,6 +282,7 @@ namespace config {
bool notify_pre_releases; bool notify_pre_releases;
bool legacy_ordering; bool legacy_ordering;
std::vector<prep_cmd_t> prep_cmds; std::vector<prep_cmd_t> prep_cmds;
std::vector<prep_cmd_t> state_cmds;
std::vector<server_cmd_t> server_cmds; std::vector<server_cmd_t> server_cmds;
}; };

View File

@@ -1285,6 +1285,8 @@ namespace confighttp {
print_req(request); print_req(request);
proc::proc.terminate();
// We may not return from this call // We may not return from this call
platf::restart(); platf::restart();
} }
@@ -1304,6 +1306,9 @@ namespace confighttp {
print_req(request); print_req(request);
BOOST_LOG(warning) << "Requested quit from config page!"sv; BOOST_LOG(warning) << "Requested quit from config page!"sv;
proc::proc.terminate();
#ifdef _WIN32 #ifdef _WIN32
if (GetConsoleWindow() == NULL) { if (GetConsoleWindow() == NULL) {
lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true);

View File

@@ -268,6 +268,9 @@ int main(int argc, char *argv[]) {
logging::log_flush(); logging::log_flush();
lifetime::debug_trap(); lifetime::debug_trap();
}; };
proc::proc.terminate();
force_shutdown = task_pool.pushDelayed(task, 10s).task_id; force_shutdown = task_pool.pushDelayed(task, 10s).task_id;
shutdown_event->raise(true); shutdown_event->raise(true);

View File

@@ -127,7 +127,7 @@ namespace proc {
} }
} }
boost::filesystem::path find_working_directory(const std::string &cmd, boost::process::v1::environment &env) { boost::filesystem::path find_working_directory(const std::string &cmd, const boost::process::v1::environment &env) {
// Parse the raw command string into parts to get the actual command portion // Parse the raw command string into parts to get the actual command portion
#ifdef _WIN32 #ifdef _WIN32
auto parts = boost::program_options::split_winmain(cmd); auto parts = boost::program_options::split_winmain(cmd);
@@ -375,6 +375,7 @@ namespace proc {
_env["APOLLO_APP_ID"] = _app.id; _env["APOLLO_APP_ID"] = _app.id;
_env["APOLLO_APP_NAME"] = _app.name; _env["APOLLO_APP_NAME"] = _app.name;
_env["APOLLO_APP_UUID"] = _app.uuid; _env["APOLLO_APP_UUID"] = _app.uuid;
_env["APOLLO_APP_STATUS"] = "STARTING";
_env["APOLLO_CLIENT_UUID"] = launch_session->unique_id; _env["APOLLO_CLIENT_UUID"] = launch_session->unique_id;
_env["APOLLO_CLIENT_NAME"] = launch_session->device_name; _env["APOLLO_CLIENT_NAME"] = launch_session->device_name;
_env["APOLLO_CLIENT_WIDTH"] = std::to_string(render_width); _env["APOLLO_CLIENT_WIDTH"] = std::to_string(render_width);
@@ -457,6 +458,8 @@ namespace proc {
} }
} }
_env["APOLLO_APP_STATUS"] = "RUNNING";
for (auto &cmd : _app.detached) { for (auto &cmd : _app.detached) {
boost::filesystem::path working_dir = _app.working_dir.empty() ? boost::filesystem::path working_dir = _app.working_dir.empty() ?
find_working_directory(cmd, _env) : find_working_directory(cmd, _env) :
@@ -593,16 +596,108 @@ namespace proc {
return 0; return 0;
} }
void proc_t::pause() { void proc_t::resume() {
if (_app.terminate_on_pause) { BOOST_LOG(info) << "Session resuming for app [" << _app_name << "].";
terminate();
} else { if (!_app.state_cmds.empty()) {
#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 auto exec_thread = std::thread([cmd_list = _app.state_cmds, app_working_dir = _app.working_dir, _env = _env]() mutable {
system_tray::update_tray_pausing(proc::proc.get_last_run_app_name());
#endif _env["APOLLO_APP_STATUS"] = "RESUMING";
std::error_code ec;
auto _state_resume_it = std::begin(cmd_list);
for (; _state_resume_it != std::end(cmd_list); ++_state_resume_it) {
auto &cmd = *_state_resume_it;
// Skip empty commands
if (cmd.do_cmd.empty()) {
continue;
}
boost::filesystem::path working_dir = app_working_dir.empty() ?
find_working_directory(cmd.do_cmd, _env) :
boost::filesystem::path(app_working_dir);
BOOST_LOG(info) << "Executing Resume Cmd: ["sv << cmd.do_cmd << "] elevated: " << cmd.elevated;
auto child = platf::run_command(cmd.elevated, true, cmd.do_cmd, working_dir, _env, nullptr, ec, nullptr);
if (ec) {
BOOST_LOG(error) << "Couldn't run ["sv << cmd.do_cmd << "]: System: "sv << ec.message();
break;
}
child.wait();
auto ret = child.exit_code();
if (ret != 0 && ec != std::errc::permission_denied) {
BOOST_LOG(error) << '[' << cmd.do_cmd << "] failed with code ["sv << ret << ']';
break;
}
}
});
exec_thread.detach();
} }
} }
void proc_t::pause() {
if (!running()) {
BOOST_LOG(info) << "Session already stopped, do not run pause commands.";
return;
}
if (_app.terminate_on_pause) {
BOOST_LOG(info) << "Terminating app [" << _app_name << "] when all clients are disconnected. Pause commands are skipped.";
terminate();
return;
}
BOOST_LOG(info) << "Session pausing for app [" << _app_name << "].";
if (!_app.state_cmds.empty()) {
auto exec_thread = std::thread([cmd_list = _app.state_cmds, app_working_dir = _app.working_dir, _env = _env]() mutable {
_env["APOLLO_APP_STATUS"] = "PAUSING";
std::error_code ec;
auto _state_pause_it = std::begin(cmd_list);
for (; _state_pause_it != std::end(cmd_list); ++_state_pause_it) {
auto &cmd = *_state_pause_it;
// Skip empty commands
if (cmd.undo_cmd.empty()) {
continue;
}
boost::filesystem::path working_dir = app_working_dir.empty() ?
find_working_directory(cmd.undo_cmd, _env) :
boost::filesystem::path(app_working_dir);
BOOST_LOG(info) << "Executing Pause Cmd: ["sv << cmd.undo_cmd << "] elevated: " << cmd.elevated;
auto child = platf::run_command(cmd.elevated, true, cmd.undo_cmd, working_dir, _env, nullptr, ec, nullptr);
if (ec) {
BOOST_LOG(error) << "Couldn't run ["sv << cmd.undo_cmd << "]: System: "sv << ec.message();
break;
}
child.wait();
auto ret = child.exit_code();
if (ret != 0 && ec != std::errc::permission_denied) {
BOOST_LOG(error) << '[' << cmd.undo_cmd << "] failed with code ["sv << ret << ']';
break;
}
}
});
exec_thread.detach();
}
#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1
system_tray::update_tray_pausing(proc::proc.get_last_run_app_name());
#endif
}
void proc_t::terminate(bool immediate, bool needs_refresh) { void proc_t::terminate(bool immediate, bool needs_refresh) {
std::error_code ec; std::error_code ec;
placebo = false; placebo = false;
@@ -614,6 +709,8 @@ namespace proc {
_process = boost::process::v1::child(); _process = boost::process::v1::child();
_process_group = boost::process::v1::group(); _process_group = boost::process::v1::group();
_env["APOLLO_APP_STATUS"] = "TERMINATING";
for (; _app_prep_it != _app_prep_begin; --_app_prep_it) { for (; _app_prep_it != _app_prep_begin; --_app_prep_it) {
auto &cmd = *(_app_prep_it - 1); auto &cmd = *(_app_prep_it - 1);
@@ -1195,6 +1292,34 @@ namespace proc {
} }
} }
// Build the list of pause/resume commands.
std::vector<proc::cmd_t> state_cmds;
bool exclude_global_state_cmds = app_node.value("exclude-global-state-cmd", false);
if (!exclude_global_state_cmds) {
state_cmds.reserve(config::sunshine.state_cmds.size());
for (auto &state_cmd : config::sunshine.state_cmds) {
auto do_cmd = parse_env_val(this_env, state_cmd.do_cmd);
auto undo_cmd = parse_env_val(this_env, state_cmd.undo_cmd);
state_cmds.emplace_back(
std::move(do_cmd),
std::move(undo_cmd),
std::move(state_cmd.elevated)
);
}
}
if (app_node.contains("state-cmd") && app_node["state-cmd"].is_array()) {
for (auto &prep_node : app_node["state-cmd"]) {
std::string do_cmd = parse_env_val(this_env, prep_node.value("do", ""));
std::string undo_cmd = parse_env_val(this_env, prep_node.value("undo", ""));
bool elevated = prep_node.value("elevated", false);
state_cmds.emplace_back(
std::move(do_cmd),
std::move(undo_cmd),
std::move(elevated)
);
}
}
// Build the list of detached commands. // Build the list of detached commands.
std::vector<std::string> detached; std::vector<std::string> detached;
if (app_node.contains("detached") && app_node["detached"].is_array()) { if (app_node.contains("detached") && app_node["detached"].is_array()) {
@@ -1243,6 +1368,7 @@ namespace proc {
ctx.name = std::move(name); ctx.name = std::move(name);
ctx.prep_cmds = std::move(prep_cmds); ctx.prep_cmds = std::move(prep_cmds);
ctx.state_cmds = std::move(state_cmds);
ctx.detached = std::move(detached); ctx.detached = std::move(detached);
apps.emplace_back(std::move(ctx)); apps.emplace_back(std::move(ctx));

View File

@@ -62,6 +62,7 @@ namespace proc {
*/ */
struct ctx_t { struct ctx_t {
std::vector<cmd_t> prep_cmds; std::vector<cmd_t> prep_cmds;
std::vector<cmd_t> state_cmds;
/** /**
* Some applications, such as Steam, either exit quickly, or keep running indefinitely. * Some applications, such as Steam, either exit quickly, or keep running indefinitely.
@@ -136,6 +137,7 @@ namespace proc {
std::string get_last_run_app_name(); std::string get_last_run_app_name();
std::string get_running_app_uuid(); std::string get_running_app_uuid();
boost::process::v1::environment get_env(); boost::process::v1::environment get_env();
void resume();
void pause(); void pause();
void terminate(bool immediate = false, bool needs_refresh = true); void terminate(bool immediate = false, bool needs_refresh = true);
@@ -164,7 +166,7 @@ namespace proc {
}; };
boost::filesystem::path boost::filesystem::path
find_working_directory(const std::string &cmd, boost::process::v1::environment &env); find_working_directory(const std::string &cmd, const boost::process::v1::environment &env);
/** /**
* @brief Calculate a stable id based on name and image data * @brief Calculate a stable id based on name and image data

View File

@@ -2120,6 +2120,7 @@ namespace stream {
// If this is the first session, invoke the platform callbacks // If this is the first session, invoke the platform callbacks
if (++running_sessions == 1) { if (++running_sessions == 1) {
platf::streaming_will_start(); platf::streaming_will_start();
proc::proc.resume();
} }
if (!session.do_cmds.empty()) { if (!session.do_cmds.empty()) {

View File

@@ -79,12 +79,15 @@ namespace system_tray {
void tray_restart_cb(struct tray_menu *item) { void tray_restart_cb(struct tray_menu *item) {
BOOST_LOG(info) << "Restarting from system tray"sv; BOOST_LOG(info) << "Restarting from system tray"sv;
proc::proc.terminate();
platf::restart(); platf::restart();
} }
void tray_quit_cb(struct tray_menu *item) { void tray_quit_cb(struct tray_menu *item) {
BOOST_LOG(info) << "Quitting from system tray"sv; BOOST_LOG(info) << "Quitting from system tray"sv;
proc::proc.terminate();
#ifdef _WIN32 #ifdef _WIN32
// If we're running in a service, return a special status to // If we're running in a service, return a special status to
// tell it to terminate too, otherwise it will just respawn us. // tell it to terminate too, otherwise it will just respawn us.

View File

@@ -68,6 +68,10 @@
vertical-align: top; vertical-align: top;
} }
.pre-wrap {
white-space: pre-wrap;
}
.dragover { .dragover {
border-top: 2px solid #ffc400; border-top: 2px solid #ffc400;
} }
@@ -87,7 +91,7 @@
<thead> <thead>
<tr> <tr>
<th scope="col">{{ $t('apps.name') }}</th> <th scope="col">{{ $t('apps.name') }}</th>
<th scope="col">{{ $t('apps.actions') }}</th> <th scope="col" class="text-end">{{ $t('apps.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -104,18 +108,18 @@
@drop="onDrop($event, app, i)" @drop="onDrop($event, app, i)"
> >
<td>{{app.name || ' '}}</td> <td>{{app.name || ' '}}</td>
<td v-if="app.uuid"> <td v-if="app.uuid" class="text-end">
<button class="btn btn-primary me-2" :disabled="actionDisabled" @click="editApp(app)"> <button class="btn btn-primary me-2" :disabled="actionDisabled" @click="editApp(app)">
<i class="fas fa-edit"></i> {{ $t('apps.edit') }} <i class="fas fa-edit"></i>
</button> </button>
<button class="btn btn-danger me-2" :disabled="actionDisabled" @click="showDeleteForm(app)"> <button class="btn btn-danger me-2" :disabled="actionDisabled" @click="showDeleteForm(app)">
<i class="fas fa-trash"></i> {{ $t('apps.delete') }} <i class="fas fa-trash"></i>
</button> </button>
<button class="btn btn-warning" :disabled="actionDisabled" @click="closeApp()" v-if="currentApp === app.uuid"> <button class="btn btn-warning" :disabled="actionDisabled" @click="closeApp()" v-if="currentApp === app.uuid">
<i class="fas fa-stop"></i> {{ $t('apps.close') }} <i class="fas fa-stop"></i>
</button> </button>
<button class="btn btn-success" :disabled="actionDisabled" @click="launchApp(app)" v-else> <button class="btn btn-success" :disabled="actionDisabled" @click="launchApp(app)" v-else>
<i class="fas fa-play"></i> {{ $t('apps.launch') }} <i class="fas fa-play"></i>
</button> </button>
</td> </td>
<td v-else></td> <td v-else></td>
@@ -193,76 +197,35 @@
</select> </select>
<div class="form-text">{{ $t('config.gamepad_desc') }}</div> <div class="form-text">{{ $t('config.gamepad_desc') }}</div>
</div> </div>
<!-- allow client commands --> <!-- command -->
<Checkbox class="mb-3"
id="clientCommands"
label="apps.allow_client_commands"
desc="apps.allow_client_commands_desc"
v-model="editForm['allow-client-commands']"
default="true"
></Checkbox>
<!-- prep-cmd -->
<Checkbox class="mb-3"
id="excludeGlobalPrep"
label="apps.global_prep_name"
desc="apps.global_prep_desc"
v-model="editForm['exclude-global-prep-cmd']"
default="true"
inverse-values
></Checkbox>
<div class="mb-3"> <div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.cmd_prep_name') }}</label> <label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label>
<div class="form-text">{{ $t('apps.cmd_prep_desc') }}</div> <input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
<div class="d-flex justify-content-start mb-3 mt-3" v-if="editForm['prep-cmd'].length === 0"> v-model="editForm.cmd" />
<button class="btn btn-success" @click="addPrepCmd(-1)"> <div id="appCmdHelp" class="form-text">
<i class="fas fa-plus mr-1"></i> {{ $t('apps.add_cmds') }} {{ $t('apps.cmd_desc') }}<br>
</button> <b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }}
</div> </div>
<table class="table" v-if="editForm['prep-cmd'].length > 0">
<thead>
<tr>
<th scope="col"><i class="fas fa-play"></i> {{ $t('_common.do_cmd') }}</th>
<th scope="col"><i class="fas fa-undo"></i> {{ $t('_common.undo_cmd') }}</th>
<th scope="col" v-if="platform === 'windows'">
<i class="fas fa-shield-alt"></i> {{ $t('_common.run_as') }}
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in editForm['prep-cmd']">
<td>
<input type="text" class="form-control monospace" v-model="c.do" />
</td>
<td>
<input type="text" class="form-control monospace" v-model="c.undo" />
</td>
<td v-if="platform === 'windows'" class="align-middle">
<Checkbox :id="'prep-cmd-admin-' + i"
label="_common.elevated"
desc=""
v-model="c.elevated"
></Checkbox>
</td>
<td>
<button class="btn btn-danger" @click="editForm['prep-cmd'].splice(i,1)">
<i class="fas fa-trash"></i>
</button>
<button class="btn btn-success" @click="addPrepCmd(i)">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div> </div>
<!-- elevation -->
<Checkbox v-if="platform === 'windows'"
class="mb-3"
id="appElevation"
label="_common.run_as"
desc="apps.run_as_desc"
v-model="editForm.elevated"
default="false"
></Checkbox>
<!-- detached --> <!-- detached -->
<div class="mb-3"> <div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.detached_cmds') }}</label> <label class="form-label">{{ $t('apps.detached_cmds') }}</label>
<div v-for="(c,i) in editForm.detached" class="d-flex justify-content-between my-2"> <div v-for="(c,i) in editForm.detached" class="d-flex justify-content-between my-2">
<input type="text" v-model="editForm.detached[i]" class="form-control monospace"> <input type="text" v-model="editForm.detached[i]" class="form-control monospace">
<button class="btn btn-danger mx-2" @click="editForm.detached.splice(i,1)"> <button class="btn btn-danger mx-2" @click="editForm.detached.splice(i,1)">
&times; <i class="fas fa-trash"></i>
</button>
<button class="btn btn-success" @click="editForm.detached.splice(i, 0, '')">
<i class="fas fa-plus"></i>
</button> </button>
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
@@ -275,16 +238,71 @@
<b>{{ $t('_common.note') }}</b> {{ $t('apps.detached_cmds_note') }} <b>{{ $t('_common.note') }}</b> {{ $t('apps.detached_cmds_note') }}
</div> </div>
</div> </div>
<!-- command --> <!-- allow client commands -->
<div class="mb-3"> <Checkbox class="mb-3"
<label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label> id="clientCommands"
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp" label="apps.allow_client_commands"
v-model="editForm.cmd" /> desc="apps.allow_client_commands_desc"
<div id="appCmdHelp" class="form-text"> v-model="editForm['allow-client-commands']"
{{ $t('apps.cmd_desc') }}<br> default="true"
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }} ></Checkbox>
<!-- prep and state-cmd -->
<template v-for="type in ['prep', 'state']">
<Checkbox class="mb-3"
:id="'excludeGlobal_' + type"
:label="'apps.global_' + type + '_name'"
:desc="'apps.global_' + type + '_desc'"
v-model="editForm['exclude-global-' + type + '-cmd']"
default="true"
inverse-values
></Checkbox>
<div class="mb-3">
<label class="form-label">{{ $t('apps.cmd_' + type + '_name') }}</label>
<div class="form-text pre-wrap">{{ $t('apps.cmd_' + type + '_desc') }}</div>
<table class="table" v-if="editForm[type + '-cmd'].length > 0">
<thead>
<tr>
<th scope="col"><i class="fas fa-play"></i> {{ $t('_common.do_cmd') }}</th>
<th scope="col"><i class="fas fa-undo"></i> {{ $t('_common.undo_cmd') }}</th>
<th scope="col" v-if="platform === 'windows'">
<i class="fas fa-shield-alt"></i> {{ $t('_common.run_as') }}
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in editForm[type + '-cmd']">
<td>
<input type="text" class="form-control monospace" v-model="c.do" />
</td>
<td>
<input type="text" class="form-control monospace" v-model="c.undo" />
</td>
<td v-if="platform === 'windows'" class="align-middle">
<Checkbox :id="type + '-cmd-admin-' + i"
label="_common.elevated"
desc=""
v-model="c.elevated"
></Checkbox>
</td>
<td class="text-end">
<button class="btn btn-danger mx-2" @click="editForm[type + '-cmd'].splice(i,1)">
<i class="fas fa-trash"></i>
</button>
<button class="btn btn-success" @click="addCmd(editForm[type + '-cmd'], i)">
<i class="fas fa-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
<div class="d-flex justify-content-start mb-3 mt-3">
<button class="btn btn-success" @click="addCmd(editForm[type + '-cmd'], -1)">
<i class="fas fa-plus mr-1"></i> {{ $t('apps.add_cmds') }}
</button>
</div>
</div> </div>
</div> </template>
<!-- working dir --> <!-- working dir -->
<div class="mb-3"> <div class="mb-3">
<label for="appWorkingDir" class="form-label">{{ $t('apps.working_dir') }}</label> <label for="appWorkingDir" class="form-label">{{ $t('apps.working_dir') }}</label>
@@ -299,15 +317,6 @@
v-model="editForm.output" /> v-model="editForm.output" />
<div id="appOutputHelp" class="form-text">{{ $t('apps.output_desc') }}</div> <div id="appOutputHelp" class="form-text">{{ $t('apps.output_desc') }}</div>
</div> </div>
<!-- elevation -->
<Checkbox v-if="platform === 'windows'"
class="mb-3"
id="appElevation"
label="_common.run_as"
desc="apps.run_as_desc"
v-model="editForm.elevated"
default="false"
></Checkbox>
<!-- auto-detach --> <!-- auto-detach -->
<Checkbox class="mb-3" <Checkbox class="mb-3"
id="autoDetach" id="autoDetach"
@@ -392,6 +401,10 @@
<td style="font-family: monospace">APOLLO_APP_UUID</td> <td style="font-family: monospace">APOLLO_APP_UUID</td>
<td>{{ $t('apps.env_app_uuid') }}</td> <td>{{ $t('apps.env_app_uuid') }}</td>
</tr> </tr>
<tr>
<td style="font-family: monospace">APOLLO_APP_STATUS</td>
<td>{{ $t('apps.env_app_status') }}</td>
</tr>
<tr> <tr>
<td style="font-family: monospace">APOLLO_CLIENT_UUID</td> <td style="font-family: monospace">APOLLO_CLIENT_UUID</td>
<td>{{ $t('apps.env_client_uuid') }}</td> <td>{{ $t('apps.env_client_uuid') }}</td>
@@ -499,11 +512,13 @@
"output": "", "output": "",
"cmd": "", "cmd": "",
"exclude-global-prep-cmd": false, "exclude-global-prep-cmd": false,
"exclude-global-state-cmd": false,
"elevated": false, "elevated": false,
"auto-detach": true, "auto-detach": true,
"wait-all": true, "wait-all": true,
"exit-timeout": 5, "exit-timeout": 5,
"prep-cmd": [], "prep-cmd": [],
"state-cmd": [],
"detached": [], "detached": [],
"image-path": "", "image-path": "",
"scale-factor": 100, "scale-factor": 100,
@@ -729,7 +744,7 @@
}); });
} }
}, },
addPrepCmd(idx) { addCmd(cmdArr, idx) {
const template = { const template = {
do: "", do: "",
undo: "" undo: ""
@@ -739,7 +754,11 @@
template.elevated = false; template.elevated = false;
} }
this.editForm["prep-cmd"].splice(idx + 1, 0, template); if (idx < 0) {
cmdArr.push(template);
} else {
cmdArr.splice(idx, 0, template);
}
}, },
showCoverFinder($event) { showCoverFinder($event) {
this.coverCandidates = []; this.coverCandidates = [];

View File

@@ -38,6 +38,7 @@
v-if="currentTab === 'general'" v-if="currentTab === 'general'"
:config="config" :config="config"
:global-prep-cmd="global_prep_cmd" :global-prep-cmd="global_prep_cmd"
:global-state-cmd="global_state_cmd"
:server-cmd="server_cmd" :server-cmd="server_cmd"
:platform="platform"> :platform="platform">
</general> </general>
@@ -137,6 +138,7 @@
currentTab: "general", currentTab: "general",
vdisplayStatus: "1", vdisplayStatus: "1",
global_prep_cmd: [], global_prep_cmd: [],
global_state_cmd: [],
server_cmd: [], server_cmd: [],
tabs: [ // TODO: Move the options to each Component instead, encapsulate. tabs: [ // TODO: Move the options to each Component instead, encapsulate.
{ {
@@ -147,6 +149,7 @@
"sunshine_name": "", "sunshine_name": "",
"min_log_level": 2, "min_log_level": 2,
"global_prep_cmd": [], "global_prep_cmd": [],
"global_state_cmd": [],
"server_cmd": [], "server_cmd": [],
"notify_pre_releases": "disabled", "notify_pre_releases": "disabled",
"hide_tray_controls": "disabled", "hide_tray_controls": "disabled",
@@ -358,7 +361,7 @@
// TODO: let each tab's Component handle it's own data instead of doing it here // TODO: let each tab's Component handle it's own data instead of doing it here
// Parse the special options before population if available // Parse the special options before population if available
const specialOptions = ["dd_mode_remapping", "global_prep_cmd", "server_cmd"] const specialOptions = ["dd_mode_remapping", "global_prep_cmd", "global_state_cmd", "server_cmd"]
for (const optionKey of specialOptions) { for (const optionKey of specialOptions) {
if (typeof this.config[optionKey] === "string") { if (typeof this.config[optionKey] === "string") {
this.config[optionKey] = JSON.parse(this.config[optionKey]); this.config[optionKey] = JSON.parse(this.config[optionKey]);
@@ -369,6 +372,7 @@
this.config.dd_mode_remapping ??= {mixed: [], resolution_only: [], refresh_rate_only: []}; this.config.dd_mode_remapping ??= {mixed: [], resolution_only: [], refresh_rate_only: []};
this.config.global_prep_cmd ??= []; this.config.global_prep_cmd ??= [];
this.config.global_state_cmd ??= [];
this.config.server_cmd ??= []; this.config.server_cmd ??= [];
// Populate default values from tabs options // Populate default values from tabs options
@@ -386,12 +390,17 @@
i.elevated = !!i.elevated i.elevated = !!i.elevated
return i return i
}); });
this.global_state_cmd = this.config.global_state_cmd.map((i) => {
i.elevated = !!i.elevated
return i
});
this.server_cmd = this.config.server_cmd.map((i) => { this.server_cmd = this.config.server_cmd.map((i) => {
i.elevated = !!i.elevated i.elevated = !!i.elevated
return i return i
}); });
} else { } else {
this.global_prep_cmd = this.config.global_prep_cmd; this.global_prep_cmd = this.config.global_prep_cmd;
this.global_state_cmd = this.config.global_state_cmd;
this.server_cmd = this.config.server_cmd; this.server_cmd = this.config.server_cmd;
} }
@@ -409,9 +418,10 @@
fallbackDisplayModeCache = this.config.fallback_mode; fallbackDisplayModeCache = this.config.fallback_mode;
} }
let config = JSON.parse(JSON.stringify(this.config)); let config = JSON.parse(JSON.stringify(this.config));
config.global_prep_cmd = this.global_prep_cmd.filter(cmd => cmd.do || cmd.undo); config.global_prep_cmd = this.global_prep_cmd.filter(cmd => (cmd.do && cmd.do.trim()) || (cmd.undo && cmd.undo.trim()));
config.global_state_cmd = this.global_state_cmd.filter(cmd => (cmd.do && cmd.do.trim()) || (cmd.undo && cmd.undo.trim()));
config.dd_mode_remapping = config.dd_mode_remapping; config.dd_mode_remapping = config.dd_mode_remapping;
config.server_cmd = this.server_cmd.filter(cmd => cmd.name && cmd.cmd); config.server_cmd = this.server_cmd.filter(cmd => (cmd.name && cmd.cmd && cmd.name.trim() && cmd.cmd.trim()));
return config; return config;
}, },
save() { save() {

View File

@@ -6,13 +6,20 @@ const props = defineProps({
platform: String, platform: String,
config: Object, config: Object,
globalPrepCmd: Array, globalPrepCmd: Array,
globalStateCmd: Array,
serverCmd: Array serverCmd: Array
}) })
const config = ref(props.config) const config = ref(props.config)
const globalPrepCmd = ref(props.globalPrepCmd) const globalPrepCmd = ref(props.globalPrepCmd)
const globalStateCmd = ref(props.globalStateCmd)
const serverCmd = ref(props.serverCmd) const serverCmd = ref(props.serverCmd)
const cmds = ref({
prep: globalPrepCmd,
state: globalStateCmd
})
const prepCmdTemplate = { const prepCmdTemplate = {
do: "", do: "",
undo: "", undo: "",
@@ -32,7 +39,7 @@ function addCmd(cmdArr, template, idx) {
if (idx < 0) { if (idx < 0) {
cmdArr.push(_tpl); cmdArr.push(_tpl);
} else { } else {
cmdArr.splice(idx + 1, 0, _tpl); cmdArr.splice(idx, 0, _tpl);
} }
} }
@@ -99,11 +106,11 @@ onMounted(() => {
<div class="form-text">{{ $t('config.log_level_desc') }}</div> <div class="form-text">{{ $t('config.log_level_desc') }}</div>
</div> </div>
<!-- Global Prep Commands --> <!-- Global Prep/State Commands -->
<div id="global_prep_cmd" class="mb-3 d-flex flex-column"> <div v-for="type in ['prep', 'state']" :id="`global_${type}_cmd`" class="mb-3 d-flex flex-column">
<label class="form-label">{{ $t('config.global_prep_cmd') }}</label> <label class="form-label">{{ $t(`config.global_${type}_cmd`) }}</label>
<div class="form-text">{{ $t('config.global_prep_cmd_desc') }}</div> <div class="form-text pre-wrap">{{ $t(`config.global_${type}_cmd_desc`) }}</div>
<table class="table" v-if="globalPrepCmd.length > 0"> <table class="table" v-if="cmds[type].length > 0">
<thead> <thead>
<tr> <tr>
<th scope="col"><i class="fas fa-play"></i> {{ $t('_common.do_cmd') }}</th> <th scope="col"><i class="fas fa-play"></i> {{ $t('_common.do_cmd') }}</th>
@@ -115,7 +122,7 @@ onMounted(() => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(c, i) in globalPrepCmd"> <tr v-for="(c, i) in cmds[type]">
<td> <td>
<input type="text" class="form-control monospace" v-model="c.do" /> <input type="text" class="form-control monospace" v-model="c.do" />
</td> </td>
@@ -123,25 +130,25 @@ onMounted(() => {
<input type="text" class="form-control monospace" v-model="c.undo" /> <input type="text" class="form-control monospace" v-model="c.undo" />
</td> </td>
<td v-if="platform === 'windows'" class="align-middle"> <td v-if="platform === 'windows'" class="align-middle">
<Checkbox :id="'prep-cmd-admin-' + i" <Checkbox :id="type + '-cmd-admin-' + i"
label="_common.elevated" label="_common.elevated"
desc="" desc=""
default="false" default="false"
v-model="c.elevated" v-model="c.elevated"
></Checkbox> ></Checkbox>
</td> </td>
<td> <td class="text-end">
<button class="btn btn-danger me-2" @click="removeCmd(globalPrepCmd, i)"> <button class="btn btn-danger me-2" @click="removeCmd(cmds[type], i)">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
<button class="btn btn-success" @click="addCmd(globalPrepCmd, prepCmdTemplate, i)"> <button class="btn btn-success" @click="addCmd(cmds[type], prepCmdTemplate, i)">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</button> </button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<button class="ms-0 mt-2 btn btn-success" style="margin: 0 auto" @click="addCmd(globalPrepCmd, prepCmdTemplate, -1)"> <button class="ms-0 mt-2 btn btn-success" style="margin: 0 auto" @click="addCmd(cmds[type], prepCmdTemplate, -1)">
&plus; {{ $t('config.add') }} &plus; {{ $t('config.add') }}
</button> </button>
</div> </div>
@@ -178,7 +185,7 @@ onMounted(() => {
<label :for="'server-cmd-admin-' + i" class="form-check-label">{{ $t('_common.elevated') }}</label> <label :for="'server-cmd-admin-' + i" class="form-check-label">{{ $t('_common.elevated') }}</label>
</div> </div>
</td> </td>
<td> <td class="text-end">
<button class="btn btn-danger me-2" @click="removeCmd(serverCmd, i)"> <button class="btn btn-danger me-2" @click="removeCmd(serverCmd, i)">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>

View File

@@ -53,6 +53,8 @@
"cmd_note": "If the path to the command executable contains spaces, you must enclose it in quotes.", "cmd_note": "If the path to the command executable contains spaces, you must enclose it in quotes.",
"cmd_prep_desc": "A list of commands to be run before/after this application. If any of the prep-commands fail, starting the application is aborted.", "cmd_prep_desc": "A list of commands to be run before/after this application. If any of the prep-commands fail, starting the application is aborted.",
"cmd_prep_name": "Command Preparations", "cmd_prep_name": "Command Preparations",
"cmd_state_desc": "A list of commands to be run when resuming(first client connects when no clients are connected) or pausing(all clients disconnect) this application.\nDo commands for resume and Undo command for pause.\nPlease make sure to clean up any side effects of the commands in the preparation undo commands.\nPlease note that pause command will not be executed when the session terminates.",
"cmd_state_name": "Resume/Pause Commands",
"covers_found": "Covers Found", "covers_found": "Covers Found",
"delete": "Delete", "delete": "Delete",
"delete_failed": "App delete failed: ", "delete_failed": "App delete failed: ",
@@ -64,6 +66,7 @@
"env_app_id": "App ID (legacy)", "env_app_id": "App ID (legacy)",
"env_app_name": "App Name", "env_app_name": "App Name",
"env_app_uuid": "App UUID", "env_app_uuid": "App UUID",
"env_app_status": "App Status: One of 'STARTING', 'RUNNING', 'PAUSING', 'RESUMING', 'TERMINATING' (string)",
"env_client_audio_config": "The Audio Configuration requested by the client (2.0/5.1/7.1)", "env_client_audio_config": "The Audio Configuration requested by the client (2.0/5.1/7.1)",
"env_client_enable_sops": "The client has requested the option to optimize the game for optimal streaming (true/false)", "env_client_enable_sops": "The client has requested the option to optimize the game for optimal streaming (true/false)",
"env_client_fps": "The FPS requested by the client (float)", "env_client_fps": "The FPS requested by the client (float)",
@@ -88,6 +91,8 @@
"find_cover": "Find Cover", "find_cover": "Find Cover",
"global_prep_desc": "Enable/Disable the execution of Global Prep Commands for this application.", "global_prep_desc": "Enable/Disable the execution of Global Prep Commands for this application.",
"global_prep_name": "Global Prep Commands", "global_prep_name": "Global Prep Commands",
"global_state_desc": "Enable/Disable the execution of Global Resume/Pause Commands for this application.",
"global_state_name": "Global Resume/Pause Commands",
"image": "Image", "image": "Image",
"image_desc": "Application icon/picture/image path that will be sent to client. Image must be a PNG file. If not set, Apollo will send default box image.", "image_desc": "Application icon/picture/image path that will be sent to client. Image must be a PNG file. If not set, Apollo will send default box image.",
"launch": "Launch", "launch": "Launch",
@@ -278,6 +283,8 @@
"gamepad_xone": "XOne (Xbox One)", "gamepad_xone": "XOne (Xbox One)",
"global_prep_cmd": "Command Preparations", "global_prep_cmd": "Command Preparations",
"global_prep_cmd_desc": "Configure a list of commands to be executed before or after running any application. If any of the specified preparation commands fail, the application launch process will be aborted.", "global_prep_cmd_desc": "Configure a list of commands to be executed before or after running any application. If any of the specified preparation commands fail, the application launch process will be aborted.",
"global_state_cmd": "Resume/Pause Commands",
"global_state_cmd_desc": "Configure a list of commands to be executed when resuming(first client connects when no clients are connected) or pausing(all clients disconnect) any application.\nDo commands for resume and Undo command for pause.\nPlease make sure to clean up any side effects of the commands in the preparation undo commands.\nPlease note that pause command will not be executed when the session terminates.",
"headless_mode": "Headless Mode", "headless_mode": "Headless Mode",
"headless_mode_desc": "Start Apollo in headless mode. When enabled, all apps will start in virtual display.", "headless_mode_desc": "Start Apollo in headless mode. When enabled, all apps will start in virtual display.",
"hevc_mode": "HEVC Support", "hevc_mode": "HEVC Support",

View File

@@ -13,7 +13,7 @@
"disabled_def": "禁用(默认)", "disabled_def": "禁用(默认)",
"disabled_def_cbox": "默认值:未选", "disabled_def_cbox": "默认值:未选",
"dismiss": "关闭", "dismiss": "关闭",
"do_cmd": "打开时执行命令", "do_cmd": "前置命令",
"elevated": "提权运行", "elevated": "提权运行",
"enabled": "启用", "enabled": "启用",
"enabled_def": "启用(默认)", "enabled_def": "启用(默认)",
@@ -26,7 +26,7 @@
"save": "保存", "save": "保存",
"see_more": "查看更多", "see_more": "查看更多",
"success": "成功!", "success": "成功!",
"undo_cmd": "退出应用时执行命令", "undo_cmd": "后置命令",
"username": "用户名", "username": "用户名",
"warning": "警告!" "warning": "警告!"
}, },
@@ -51,8 +51,10 @@
"cmd": "命令", "cmd": "命令",
"cmd_desc": "要启动的主要应用程序。如果为空,将不会启动任何应用程序。", "cmd_desc": "要启动的主要应用程序。如果为空,将不会启动任何应用程序。",
"cmd_note": "如果命令中可执行文件的路径包含空格,则必须用引号括起来。", "cmd_note": "如果命令中可执行文件的路径包含空格,则必须用引号括起来。",
"cmd_prep_desc": "应用运行前/后要行的命令列表。如果任何前置命令失败,应用的启动过程将被中止。", "cmd_prep_desc": "应用运行前/后要行的命令列表。如果任何前置命令失败,应用的启动过程将被中止。",
"cmd_prep_name": "命令准备工作", "cmd_prep_name": "准备命令",
"cmd_state_desc": "应用暂停(所有客户端断开连接)或恢复(第一个客户端连接)时执行的命令列表。\n前置命令在恢复时执行后置命令在暂停时执行。\n请确保在命令准备工作的后置命令中清理任何命令产生的副作用当会话终止时暂停命令将不会被执行。",
"cmd_state_name": "暂停/恢复命令",
"covers_found": "找到的封面", "covers_found": "找到的封面",
"delete": "删除", "delete": "删除",
"delete_failed": "APP删除失败", "delete_failed": "APP删除失败",
@@ -64,6 +66,7 @@
"env_app_id": "应用 ID (已弃用)", "env_app_id": "应用 ID (已弃用)",
"env_app_name": "应用名称", "env_app_name": "应用名称",
"env_app_uuid": "应用 UUID", "env_app_uuid": "应用 UUID",
"env_app_status": "应用状态: 值为 'STARTING', 'RUNNING', 'PAUSING', 'RESUMING', 'TERMINATING' 的任意一个 (string)",
"env_client_audio_config": "客户端请求的音频配置 (2.0/5.1/7.1)", "env_client_audio_config": "客户端请求的音频配置 (2.0/5.1/7.1)",
"env_client_enable_sops": "客户端请求自动更改游戏设置以实现最佳串流效果 (true/false)", "env_client_enable_sops": "客户端请求自动更改游戏设置以实现最佳串流效果 (true/false)",
"env_client_fps": "客户端请求的帧率 (float)", "env_client_fps": "客户端请求的帧率 (float)",
@@ -86,8 +89,10 @@
"exit_timeout": "退出超时", "exit_timeout": "退出超时",
"exit_timeout_desc": "请求退出时,等待所有应用进程正常关闭的秒数。 如果未设置默认等待5秒钟。如果设置为零或负值应用程序将立即终止。", "exit_timeout_desc": "请求退出时,等待所有应用进程正常关闭的秒数。 如果未设置默认等待5秒钟。如果设置为零或负值应用程序将立即终止。",
"find_cover": "查找封面", "find_cover": "查找封面",
"global_prep_desc": "启用/禁用此应用程序的全局预览命令。", "global_prep_desc": "启用/禁用此应用程序的全局准备命令。",
"global_prep_name": "全局预处理命令", "global_prep_name": "全局准备命令",
"global_state_desc": "启用/禁用此应用程序的全局暂停/恢复命令。",
"global_state_name": "全局暂停/恢复命令",
"image": "图片", "image": "图片",
"image_desc": "发送到客户端的应用程序图标/图片/图像的路径。图片必须是 PNG 文件。如果未设置Apollo 将发送默认图片。", "image_desc": "发送到客户端的应用程序图标/图片/图像的路径。图片必须是 PNG 文件。如果未设置Apollo 将发送默认图片。",
"launch": "启动", "launch": "启动",
@@ -273,6 +278,8 @@
"gamepad_xone": "XOne (Xbox One)", "gamepad_xone": "XOne (Xbox One)",
"global_prep_cmd": "命令准备工作", "global_prep_cmd": "命令准备工作",
"global_prep_cmd_desc": "任何应用运行前/后要运行的命令列表。如果任何前置命令失败,应用的启动过程将被中止。", "global_prep_cmd_desc": "任何应用运行前/后要运行的命令列表。如果任何前置命令失败,应用的启动过程将被中止。",
"global_state_cmd": "暂停/恢复命令",
"global_state_cmd_desc": "任何应用暂停(所有客户端断开连接)或恢复(第一个客户端连接)时执行的命令列表。\n前置命令在恢复时执行后置命令在暂停时执行。\n请确保在命令准备工作的后置命令中清理任何命令产生的副作用当会话终止时暂停命令将不会被执行。",
"headless_mode": "无头模式", "headless_mode": "无头模式",
"headless_mode_desc": "启用后Apollo将支持无显示器模式所有App都将在虚拟显示器中启动。", "headless_mode_desc": "启用后Apollo将支持无显示器模式所有App都将在虚拟显示器中启动。",
"hevc_mode": "HEVC 支持", "hevc_mode": "HEVC 支持",