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

@@ -68,6 +68,10 @@
vertical-align: top;
}
.pre-wrap {
white-space: pre-wrap;
}
.dragover {
border-top: 2px solid #ffc400;
}
@@ -87,7 +91,7 @@
<thead>
<tr>
<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>
</thead>
<tbody>
@@ -104,18 +108,18 @@
@drop="onDrop($event, app, i)"
>
<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)">
<i class="fas fa-edit"></i> {{ $t('apps.edit') }}
<i class="fas fa-edit"></i>
</button>
<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 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 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>
</td>
<td v-else></td>
@@ -193,76 +197,35 @@
</select>
<div class="form-text">{{ $t('config.gamepad_desc') }}</div>
</div>
<!-- allow client commands -->
<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>
<!-- command -->
<div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.cmd_prep_name') }}</label>
<div class="form-text">{{ $t('apps.cmd_prep_desc') }}</div>
<div class="d-flex justify-content-start mb-3 mt-3" v-if="editForm['prep-cmd'].length === 0">
<button class="btn btn-success" @click="addPrepCmd(-1)">
<i class="fas fa-plus mr-1"></i> {{ $t('apps.add_cmds') }}
</button>
<label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label>
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
v-model="editForm.cmd" />
<div id="appCmdHelp" class="form-text">
{{ $t('apps.cmd_desc') }}<br>
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }}
</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>
<!-- 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 -->
<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">
<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)">
&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>
</div>
<div class="d-flex justify-content-between">
@@ -275,16 +238,71 @@
<b>{{ $t('_common.note') }}</b> {{ $t('apps.detached_cmds_note') }}
</div>
</div>
<!-- command -->
<div class="mb-3">
<label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label>
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
v-model="editForm.cmd" />
<div id="appCmdHelp" class="form-text">
{{ $t('apps.cmd_desc') }}<br>
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }}
<!-- allow client commands -->
<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 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>
</template>
<!-- working dir -->
<div class="mb-3">
<label for="appWorkingDir" class="form-label">{{ $t('apps.working_dir') }}</label>
@@ -299,15 +317,6 @@
v-model="editForm.output" />
<div id="appOutputHelp" class="form-text">{{ $t('apps.output_desc') }}</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 -->
<Checkbox class="mb-3"
id="autoDetach"
@@ -392,6 +401,10 @@
<td style="font-family: monospace">APOLLO_APP_UUID</td>
<td>{{ $t('apps.env_app_uuid') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_APP_STATUS</td>
<td>{{ $t('apps.env_app_status') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_UUID</td>
<td>{{ $t('apps.env_client_uuid') }}</td>
@@ -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 = [];

View File

@@ -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">
</general>
@@ -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() {

View File

@@ -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(() => {
<div class="form-text">{{ $t('config.log_level_desc') }}</div>
</div>
<!-- Global Prep Commands -->
<div id="global_prep_cmd" class="mb-3 d-flex flex-column">
<label class="form-label">{{ $t('config.global_prep_cmd') }}</label>
<div class="form-text">{{ $t('config.global_prep_cmd_desc') }}</div>
<table class="table" v-if="globalPrepCmd.length > 0">
<!-- Global Prep/State Commands -->
<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_${type}_cmd`) }}</label>
<div class="form-text pre-wrap">{{ $t(`config.global_${type}_cmd_desc`) }}</div>
<table class="table" v-if="cmds[type].length > 0">
<thead>
<tr>
<th scope="col"><i class="fas fa-play"></i> {{ $t('_common.do_cmd') }}</th>
@@ -115,7 +122,7 @@ onMounted(() => {
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in globalPrepCmd">
<tr v-for="(c, i) in cmds[type]">
<td>
<input type="text" class="form-control monospace" v-model="c.do" />
</td>
@@ -123,25 +130,25 @@ onMounted(() => {
<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"
<Checkbox :id="type + '-cmd-admin-' + i"
label="_common.elevated"
desc=""
default="false"
v-model="c.elevated"
></Checkbox>
</td>
<td>
<button class="btn btn-danger me-2" @click="removeCmd(globalPrepCmd, i)">
<td class="text-end">
<button class="btn btn-danger me-2" @click="removeCmd(cmds[type], i)">
<i class="fas fa-trash"></i>
</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>
</button>
</td>
</tr>
</tbody>
</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') }}
</button>
</div>
@@ -178,7 +185,7 @@ onMounted(() => {
<label :for="'server-cmd-admin-' + i" class="form-check-label">{{ $t('_common.elevated') }}</label>
</div>
</td>
<td>
<td class="text-end">
<button class="btn btn-danger me-2" @click="removeCmd(serverCmd, i)">
<i class="fas fa-trash"></i>
</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_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",

View File

@@ -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 支持",