From 3e0cbaf2c2b1ddaac74f0af03f78c9a45db5ac55 Mon Sep 17 00:00:00 2001 From: Yukino Song Date: Thu, 5 Jun 2025 01:57:41 +0800 Subject: [PATCH] Implement pause/resume commands w/ APOLLO_APP_STATUS envvar --- src/config.cpp | 2 + src/config.h | 1 + src/confighttp.cpp | 5 + src/main.cpp | 3 + src/process.cpp | 142 ++++++++++++- src/process.h | 4 +- src/stream.cpp | 1 + src/system_tray.cpp | 3 + src_assets/common/assets/web/apps.html | 195 ++++++++++-------- src_assets/common/assets/web/config.html | 16 +- .../assets/web/configs/tabs/General.vue | 33 +-- .../assets/web/public/assets/locale/en.json | 7 + .../assets/web/public/assets/locale/zh.json | 19 +- 13 files changed, 312 insertions(+), 119 deletions(-) diff --git a/src/config.cpp b/src/config.cpp index bf3e7869..119e95df 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -594,6 +594,7 @@ namespace config { false, // notify_pre_releases false, // legacy_ordering {}, // prep commands + {}, // state commands {}, // server commands }; @@ -1211,6 +1212,7 @@ namespace config { 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_state_cmd", config::sunshine.state_cmds); list_server_cmd_f(vars, "server_cmd", config::sunshine.server_cmds); string_f(vars, "audio_sink", audio.sink); diff --git a/src/config.h b/src/config.h index c82393ec..978f81c1 100644 --- a/src/config.h +++ b/src/config.h @@ -282,6 +282,7 @@ namespace config { bool notify_pre_releases; bool legacy_ordering; std::vector prep_cmds; + std::vector state_cmds; std::vector server_cmds; }; diff --git a/src/confighttp.cpp b/src/confighttp.cpp index a2f0bcd3..05188b1f 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1285,6 +1285,8 @@ namespace confighttp { print_req(request); + proc::proc.terminate(); + // We may not return from this call platf::restart(); } @@ -1304,6 +1306,9 @@ namespace confighttp { print_req(request); BOOST_LOG(warning) << "Requested quit from config page!"sv; + + proc::proc.terminate(); + #ifdef _WIN32 if (GetConsoleWindow() == NULL) { lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); diff --git a/src/main.cpp b/src/main.cpp index e8ffb9a3..f3980032 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -268,6 +268,9 @@ int main(int argc, char *argv[]) { logging::log_flush(); lifetime::debug_trap(); }; + + proc::proc.terminate(); + force_shutdown = task_pool.pushDelayed(task, 10s).task_id; shutdown_event->raise(true); diff --git a/src/process.cpp b/src/process.cpp index 0bd3cb52..bcca4eaf 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -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 #ifdef _WIN32 auto parts = boost::program_options::split_winmain(cmd); @@ -375,6 +375,7 @@ namespace proc { _env["APOLLO_APP_ID"] = _app.id; _env["APOLLO_APP_NAME"] = _app.name; _env["APOLLO_APP_UUID"] = _app.uuid; + _env["APOLLO_APP_STATUS"] = "STARTING"; _env["APOLLO_CLIENT_UUID"] = launch_session->unique_id; _env["APOLLO_CLIENT_NAME"] = launch_session->device_name; _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) { boost::filesystem::path working_dir = _app.working_dir.empty() ? find_working_directory(cmd, _env) : @@ -593,16 +596,108 @@ namespace proc { return 0; } - void proc_t::pause() { - if (_app.terminate_on_pause) { - terminate(); - } else { -#if defined SUNSHINE_TRAY && SUNSHINE_TRAY >= 1 - system_tray::update_tray_pausing(proc::proc.get_last_run_app_name()); -#endif + void proc_t::resume() { + BOOST_LOG(info) << "Session resuming 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"] = "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) { std::error_code ec; placebo = false; @@ -614,6 +709,8 @@ namespace proc { _process = boost::process::v1::child(); _process_group = boost::process::v1::group(); + _env["APOLLO_APP_STATUS"] = "TERMINATING"; + for (; _app_prep_it != _app_prep_begin; --_app_prep_it) { auto &cmd = *(_app_prep_it - 1); @@ -1195,6 +1292,34 @@ namespace proc { } } + // Build the list of pause/resume commands. + std::vector 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. std::vector detached; if (app_node.contains("detached") && app_node["detached"].is_array()) { @@ -1243,6 +1368,7 @@ namespace proc { ctx.name = std::move(name); ctx.prep_cmds = std::move(prep_cmds); + ctx.state_cmds = std::move(state_cmds); ctx.detached = std::move(detached); apps.emplace_back(std::move(ctx)); diff --git a/src/process.h b/src/process.h index 2c7494ff..e876e13c 100644 --- a/src/process.h +++ b/src/process.h @@ -62,6 +62,7 @@ namespace proc { */ struct ctx_t { std::vector prep_cmds; + std::vector state_cmds; /** * 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_running_app_uuid(); boost::process::v1::environment get_env(); + void resume(); void pause(); void terminate(bool immediate = false, bool needs_refresh = true); @@ -164,7 +166,7 @@ namespace proc { }; 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 diff --git a/src/stream.cpp b/src/stream.cpp index 34047e1f..bceeb93d 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -2120,6 +2120,7 @@ namespace stream { // If this is the first session, invoke the platform callbacks if (++running_sessions == 1) { platf::streaming_will_start(); + proc::proc.resume(); } if (!session.do_cmds.empty()) { diff --git a/src/system_tray.cpp b/src/system_tray.cpp index 19aa7374..a84e76f6 100644 --- a/src/system_tray.cpp +++ b/src/system_tray.cpp @@ -79,12 +79,15 @@ namespace system_tray { void tray_restart_cb(struct tray_menu *item) { BOOST_LOG(info) << "Restarting from system tray"sv; + proc::proc.terminate(); platf::restart(); } void tray_quit_cb(struct tray_menu *item) { BOOST_LOG(info) << "Quitting from system tray"sv; + proc::proc.terminate(); + #ifdef _WIN32 // If we're running in a service, return a special status to // tell it to terminate too, otherwise it will just respawn us. diff --git a/src_assets/common/assets/web/apps.html b/src_assets/common/assets/web/apps.html index 3f50c2b7..b65945a7 100644 --- a/src_assets/common/assets/web/apps.html +++ b/src_assets/common/assets/web/apps.html @@ -68,6 +68,10 @@ vertical-align: top; } + .pre-wrap { + white-space: pre-wrap; + } + .dragover { border-top: 2px solid #ffc400; } @@ -87,7 +91,7 @@ {{ $t('apps.name') }} - {{ $t('apps.actions') }} + {{ $t('apps.actions') }} @@ -104,18 +108,18 @@ @drop="onDrop($event, app, i)" > {{app.name || ' '}} - + @@ -193,76 +197,35 @@
{{ $t('config.gamepad_desc') }}
- - - - +
- -
{{ $t('apps.cmd_prep_desc') }}
-
- + + +
+ {{ $t('apps.cmd_desc') }}
+ {{ $t('_common.note') }} {{ $t('apps.cmd_note') }}
- - - - - - - - - - - - - - - - - -
{{ $t('_common.do_cmd') }} {{ $t('_common.undo_cmd') }} - {{ $t('_common.run_as') }} -
- - - - - - - - -
+ +
- +
+
@@ -275,16 +238,71 @@ {{ $t('_common.note') }} {{ $t('apps.detached_cmds_note') }}
- -
- - -
- {{ $t('apps.cmd_desc') }}
- {{ $t('_common.note') }} {{ $t('apps.cmd_note') }} + + + +
@@ -299,15 +317,6 @@ v-model="editForm.output" />
{{ $t('apps.output_desc') }}
- - APOLLO_APP_UUID {{ $t('apps.env_app_uuid') }} + + APOLLO_APP_STATUS + {{ $t('apps.env_app_status') }} + APOLLO_CLIENT_UUID {{ $t('apps.env_client_uuid') }} @@ -499,11 +512,13 @@ "output": "", "cmd": "", "exclude-global-prep-cmd": false, + "exclude-global-state-cmd": false, "elevated": false, "auto-detach": true, "wait-all": true, "exit-timeout": 5, "prep-cmd": [], + "state-cmd": [], "detached": [], "image-path": "", "scale-factor": 100, @@ -729,7 +744,7 @@ }); } }, - addPrepCmd(idx) { + addCmd(cmdArr, idx) { const template = { do: "", undo: "" @@ -739,7 +754,11 @@ 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) { this.coverCandidates = []; diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index a4e3a868..fcf5990a 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -38,6 +38,7 @@ v-if="currentTab === 'general'" :config="config" :global-prep-cmd="global_prep_cmd" + :global-state-cmd="global_state_cmd" :server-cmd="server_cmd" :platform="platform"> @@ -137,6 +138,7 @@ currentTab: "general", vdisplayStatus: "1", global_prep_cmd: [], + global_state_cmd: [], server_cmd: [], tabs: [ // TODO: Move the options to each Component instead, encapsulate. { @@ -147,6 +149,7 @@ "sunshine_name": "", "min_log_level": 2, "global_prep_cmd": [], + "global_state_cmd": [], "server_cmd": [], "notify_pre_releases": "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 // 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) { if (typeof this.config[optionKey] === "string") { 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.global_prep_cmd ??= []; + this.config.global_state_cmd ??= []; this.config.server_cmd ??= []; // Populate default values from tabs options @@ -386,12 +390,17 @@ i.elevated = !!i.elevated 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) => { i.elevated = !!i.elevated return i }); } else { 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; } @@ -409,9 +418,10 @@ fallbackDisplayModeCache = this.config.fallback_mode; } 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.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; }, save() { diff --git a/src_assets/common/assets/web/configs/tabs/General.vue b/src_assets/common/assets/web/configs/tabs/General.vue index e6122aaa..51a71696 100644 --- a/src_assets/common/assets/web/configs/tabs/General.vue +++ b/src_assets/common/assets/web/configs/tabs/General.vue @@ -6,13 +6,20 @@ const props = defineProps({ platform: String, config: Object, globalPrepCmd: Array, + globalStateCmd: Array, serverCmd: Array }) const config = ref(props.config) const globalPrepCmd = ref(props.globalPrepCmd) +const globalStateCmd = ref(props.globalStateCmd) const serverCmd = ref(props.serverCmd) +const cmds = ref({ + prep: globalPrepCmd, + state: globalStateCmd +}) + const prepCmdTemplate = { do: "", undo: "", @@ -32,7 +39,7 @@ function addCmd(cmdArr, template, idx) { if (idx < 0) { cmdArr.push(_tpl); } else { - cmdArr.splice(idx + 1, 0, _tpl); + cmdArr.splice(idx, 0, _tpl); } } @@ -99,11 +106,11 @@ onMounted(() => {
{{ $t('config.log_level_desc') }}
- -
- -
{{ $t('config.global_prep_cmd_desc') }}
- + +
+ +
{{ $t(`config.global_${type}_cmd_desc`) }}
+
@@ -115,7 +122,7 @@ onMounted(() => { - + @@ -123,25 +130,25 @@ onMounted(() => { -
{{ $t('_common.do_cmd') }}
- - + -
-
@@ -178,7 +185,7 @@ onMounted(() => {
- + diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 3f1e8348..641d5bd6 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -53,6 +53,8 @@ "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_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", "delete": "Delete", "delete_failed": "App delete failed: ", @@ -64,6 +66,7 @@ "env_app_id": "App ID (legacy)", "env_app_name": "App Name", "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_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)", @@ -88,6 +91,8 @@ "find_cover": "Find Cover", "global_prep_desc": "Enable/Disable the execution of Global Prep Commands for this application.", "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_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", @@ -278,6 +283,8 @@ "gamepad_xone": "XOne (Xbox One)", "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_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_desc": "Start Apollo in headless mode. When enabled, all apps will start in virtual display.", "hevc_mode": "HEVC Support", diff --git a/src_assets/common/assets/web/public/assets/locale/zh.json b/src_assets/common/assets/web/public/assets/locale/zh.json index f6f64c11..75c72b74 100644 --- a/src_assets/common/assets/web/public/assets/locale/zh.json +++ b/src_assets/common/assets/web/public/assets/locale/zh.json @@ -13,7 +13,7 @@ "disabled_def": "禁用(默认)", "disabled_def_cbox": "默认值:未选", "dismiss": "关闭", - "do_cmd": "打开时执行命令", + "do_cmd": "前置命令", "elevated": "提权运行", "enabled": "启用", "enabled_def": "启用(默认)", @@ -26,7 +26,7 @@ "save": "保存", "see_more": "查看更多", "success": "成功!", - "undo_cmd": "退出应用时执行命令", + "undo_cmd": "后置命令", "username": "用户名", "warning": "警告!" }, @@ -51,8 +51,10 @@ "cmd": "命令", "cmd_desc": "要启动的主要应用程序。如果为空,将不会启动任何应用程序。", "cmd_note": "如果命令中可执行文件的路径包含空格,则必须用引号括起来。", - "cmd_prep_desc": "此应用运行前/后要运行的命令列表。如果任何前置命令失败,应用的启动过程将被中止。", - "cmd_prep_name": "命令准备工作", + "cmd_prep_desc": "应用运行前/后要执行的命令列表。如果任何前置命令失败,应用的启动过程将被中止。", + "cmd_prep_name": "准备命令", + "cmd_state_desc": "应用暂停(所有客户端断开连接)或恢复(第一个客户端连接)时执行的命令列表。\n前置命令在恢复时执行,后置命令在暂停时执行。\n请确保在命令准备工作的后置命令中清理任何命令产生的副作用,当会话终止时,暂停命令将不会被执行。", + "cmd_state_name": "暂停/恢复命令", "covers_found": "找到的封面", "delete": "删除", "delete_failed": "APP删除失败:", @@ -64,6 +66,7 @@ "env_app_id": "应用 ID (已弃用)", "env_app_name": "应用名称", "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_enable_sops": "客户端请求自动更改游戏设置以实现最佳串流效果 (true/false)", "env_client_fps": "客户端请求的帧率 (float)", @@ -86,8 +89,10 @@ "exit_timeout": "退出超时", "exit_timeout_desc": "请求退出时,等待所有应用进程正常关闭的秒数。 如果未设置,默认等待5秒钟。如果设置为零或负值,应用程序将立即终止。", "find_cover": "查找封面", - "global_prep_desc": "启用/禁用此应用程序的全局预览命令。", - "global_prep_name": "全局预处理命令", + "global_prep_desc": "启用/禁用此应用程序的全局准备命令。", + "global_prep_name": "全局准备命令", + "global_state_desc": "启用/禁用此应用程序的全局暂停/恢复命令。", + "global_state_name": "全局暂停/恢复命令", "image": "图片", "image_desc": "发送到客户端的应用程序图标/图片/图像的路径。图片必须是 PNG 文件。如果未设置,Apollo 将发送默认图片。", "launch": "启动", @@ -273,6 +278,8 @@ "gamepad_xone": "XOne (Xbox One)", "global_prep_cmd": "命令准备工作", "global_prep_cmd_desc": "任何应用运行前/后要运行的命令列表。如果任何前置命令失败,应用的启动过程将被中止。", + "global_state_cmd": "暂停/恢复命令", + "global_state_cmd_desc": "任何应用暂停(所有客户端断开连接)或恢复(第一个客户端连接)时执行的命令列表。\n前置命令在恢复时执行,后置命令在暂停时执行。\n请确保在命令准备工作的后置命令中清理任何命令产生的副作用,当会话终止时,暂停命令将不会被执行。", "headless_mode": "无头模式", "headless_mode_desc": "启用后Apollo将支持无显示器模式,所有App都将在虚拟显示器中启动。", "hevc_mode": "HEVC 支持",