Implement server commands through control stream

This commit is contained in:
Yukino Song
2024-09-11 07:30:50 +08:00
parent 2d084ed6f5
commit df7c742ca8
10 changed files with 190 additions and 24 deletions

View File

@@ -455,6 +455,7 @@ namespace config {
platf::appdata().string() + "/sunshine.log", // log file platf::appdata().string() + "/sunshine.log", // log file
false, // notify_pre_releases false, // notify_pre_releases
{}, // prep commands {}, // prep commands
{}, // server commands
}; };
bool bool
@@ -846,6 +847,33 @@ namespace config {
} }
} }
void
list_server_cmd_f(std::unordered_map<std::string, std::string> &vars, const std::string &name, std::vector<server_cmd_t> &input) {
std::string string;
string_f(vars, name, string);
std::stringstream jsonStream;
// check if string is empty, i.e. when the value doesn't exist in the config file
if (string.empty()) {
return;
}
// We need to add a wrapping object to make it valid JSON, otherwise ptree cannot parse it.
jsonStream << "{\"server_cmd\":" << string << "}";
boost::property_tree::ptree jsonTree;
boost::property_tree::read_json(jsonStream, jsonTree);
for (auto &[_, prep_cmd] : jsonTree.get_child("server_cmd"s)) {
auto cmd_name = prep_cmd.get_optional<std::string>("name"s);
auto cmd_val = prep_cmd.get_optional<std::string>("cmd"s);
auto elevated = prep_cmd.get_optional<bool>("elevated"s);
input.emplace_back(cmd_name.value_or(""), cmd_val.value_or(""), elevated.value_or(false));
}
}
void void
list_int_f(std::unordered_map<std::string, std::string> &vars, const std::string &name, std::vector<int> &input) { list_int_f(std::unordered_map<std::string, std::string> &vars, const std::string &name, std::vector<int> &input) {
std::vector<std::string> list; std::vector<std::string> list;
@@ -1037,6 +1065,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_server_cmd_f(vars, "server_cmd", config::sunshine.server_cmds);
string_f(vars, "audio_sink", audio.sink); string_f(vars, "audio_sink", audio.sink);
string_f(vars, "virtual_sink", audio.virtual_sink); string_f(vars, "virtual_sink", audio.virtual_sink);
@@ -1283,7 +1312,7 @@ namespace config {
// so that service instance will do the work instead. // so that service instance will do the work instead.
if (!config_loaded && !shortcut_launch) { if (!config_loaded && !shortcut_launch) {
BOOST_LOG(fatal) << "To relaunch Sunshine successfully, use the shortcut in the Start Menu. Do not run Sunshine.exe manually."sv; BOOST_LOG(fatal) << "To relaunch Apollo successfully, use the shortcut in the Start Menu. Do not run sunshine.exe manually."sv;
std::this_thread::sleep_for(10s); std::this_thread::sleep_for(10s);
#else #else
if (!config_loaded) { if (!config_loaded) {

View File

@@ -163,6 +163,14 @@ namespace config {
std::string undo_cmd; std::string undo_cmd;
bool elevated; bool elevated;
}; };
struct server_cmd_t {
server_cmd_t(std::string &&cmd_name, std::string &&cmd_val, bool &&elevated):
cmd_name(std::move(cmd_name)), cmd_val(std::move(cmd_val)), elevated(std::move(elevated)) {}
std::string cmd_name;
std::string cmd_val;
bool elevated;
};
struct sunshine_t { struct sunshine_t {
bool hide_tray_controls; bool hide_tray_controls;
std::string locale; std::string locale;
@@ -188,6 +196,7 @@ namespace config {
std::string log_file; std::string log_file;
bool notify_pre_releases; bool notify_pre_releases;
std::vector<prep_cmd_t> prep_cmds; std::vector<prep_cmd_t> prep_cmds;
std::vector<server_cmd_t> server_cmds;
}; };
extern video_t video; extern video_t video;

View File

@@ -755,15 +755,26 @@ namespace nvhttp {
tree.put("root.ExternalPort", net::map_port(PORT_HTTP)); tree.put("root.ExternalPort", net::map_port(PORT_HTTP));
tree.put("root.MaxLumaPixelsHEVC", video::active_hevc_mode > 1 ? "1869449984" : "0"); tree.put("root.MaxLumaPixelsHEVC", video::active_hevc_mode > 1 ? "1869449984" : "0");
#ifdef _WIN32
tree.put("root.VirtualDisplayCapable", true);
tree.put("root.VirtualDisplayDriverReady", proc::vDisplayDriverStatus == VDISPLAY::DRIVER_STATUS::OK);
#endif
// Only include the MAC address for requests sent from paired clients over HTTPS. // Only include the MAC address for requests sent from paired clients over HTTPS.
// For HTTP requests, use a placeholder MAC address that Moonlight knows to ignore. // For HTTP requests, use a placeholder MAC address that Moonlight knows to ignore.
if constexpr (std::is_same_v<SunshineHTTPS, T>) { if constexpr (std::is_same_v<SunshineHTTPS, T>) {
tree.put("root.mac", platf::get_mac_address(net::addr_to_normalized_string(local_endpoint.address()))); tree.put("root.mac", platf::get_mac_address(net::addr_to_normalized_string(local_endpoint.address())));
pt::ptree& root_node = tree.get_child("root");
if (config::sunshine.server_cmds.size() > 0) {
// Broadcast server_cmds
for (const auto& cmd : config::sunshine.server_cmds) {
pt::ptree cmd_node;
cmd_node.put_value(cmd.cmd_name);
root_node.push_back(std::make_pair("ServerCommand", cmd_node));
}
}
#ifdef _WIN32
tree.put("root.VirtualDisplayCapable", true);
tree.put("root.VirtualDisplayDriverReady", proc::vDisplayDriverStatus == VDISPLAY::DRIVER_STATUS::OK);
#endif
} }
else { else {
tree.put("root.mac", "00:00:00:00:00:00"); tree.put("root.mac", "00:00:00:00:00:00");

View File

@@ -554,6 +554,11 @@ namespace proc {
return _app.name; return _app.name;
} }
boost::process::environment
proc_t::get_env() {
return _env;
}
proc_t::~proc_t() { proc_t::~proc_t() {
// It's not safe to call terminate() here because our proc_t is a static variable // It's not safe to call terminate() here because our proc_t is a static variable
// that may be destroyed after the Boost loggers have been destroyed. Instead, // that may be destroyed after the Boost loggers have been destroyed. Instead,

View File

@@ -83,6 +83,7 @@ namespace proc {
bool virtual_display; bool virtual_display;
bool initial_hdr; bool initial_hdr;
proc_t( proc_t(
boost::process::environment &&env, boost::process::environment &&env,
std::vector<ctx_t> &&apps): std::vector<ctx_t> &&apps):
@@ -109,15 +110,17 @@ namespace proc {
get_app_image(int app_id); get_app_image(int app_id);
std::string std::string
get_last_run_app_name(); get_last_run_app_name();
boost::process::environment
get_env();
void void
terminate(); terminate();
private: private:
int _app_id; int _app_id;
boost::process::environment _env;
std::shared_ptr<rtsp_stream::launch_session_t> _launch_session; std::shared_ptr<rtsp_stream::launch_session_t> _launch_session;
boost::process::environment _env;
std::vector<ctx_t> _apps; std::vector<ctx_t> _apps;
ctx_t _app; ctx_t _app;
std::chrono::steady_clock::time_point _app_launch_time; std::chrono::steady_clock::time_point _app_launch_time;
@@ -133,6 +136,9 @@ namespace proc {
std::vector<cmd_t>::const_iterator _app_prep_begin; std::vector<cmd_t>::const_iterator _app_prep_begin;
}; };
boost::filesystem::path
find_working_directory(const std::string &cmd, boost::process::environment &env);
/** /**
* @brief Calculate a stable id based on name and image data * @brief Calculate a stable id based on name and image data
* @return Tuple of id calculated without index (for use if no collision) and one with. * @return Tuple of id calculated without index (for use if no collision) and one with.

View File

@@ -46,6 +46,9 @@ extern "C" {
#define IDX_RUMBLE_TRIGGER_DATA 12 #define IDX_RUMBLE_TRIGGER_DATA 12
#define IDX_SET_MOTION_EVENT 13 #define IDX_SET_MOTION_EVENT 13
#define IDX_SET_RGB_LED 14 #define IDX_SET_RGB_LED 14
#define IDX_EXEC_SERVER_CMD 15
#define IDX_SET_CLIPBOARD 16
#define IDX_FILE_TRANSFER_NONCE_REQUEST 17
static const short packetTypes[] = { static const short packetTypes[] = {
0x0305, // Start A 0x0305, // Start A
@@ -63,6 +66,9 @@ static const short packetTypes[] = {
0x5500, // Rumble triggers (Sunshine protocol extension) 0x5500, // Rumble triggers (Sunshine protocol extension)
0x5501, // Set motion event (Sunshine protocol extension) 0x5501, // Set motion event (Sunshine protocol extension)
0x5502, // Set RGB LED (Sunshine protocol extension) 0x5502, // Set RGB LED (Sunshine protocol extension)
0x3000, // Execute Server Command (Apollo protocol extension)
0x3001, // Set Clipboard (Apollo protocol extension)
0x3002, // File transfer nonce request (Apollo protocol extension)
}; };
namespace asio = boost::asio; namespace asio = boost::asio;
@@ -989,6 +995,37 @@ namespace stream {
input::passthrough(session->input, std::move(plaintext)); input::passthrough(session->input, std::move(plaintext));
}); });
server->map(packetTypes[IDX_EXEC_SERVER_CMD], [server](session_t *session, const std::string_view &payload) {
BOOST_LOG(debug) << "type [IDX_EXEC_SERVER_CMD]: "sv;
uint8_t cmdIndex = *(uint8_t*)payload.data();
if (cmdIndex < config::sunshine.server_cmds.size()) {
const auto& cmd = config::sunshine.server_cmds[cmdIndex];
BOOST_LOG(info) << "Executing server command: " << cmd.cmd_name;
std::error_code ec;
auto env = proc::proc.get_env();
boost::filesystem::path working_dir = proc::find_working_directory(cmd.cmd_val, env);
auto child = platf::run_command(cmd.elevated, true, cmd.cmd_val, working_dir, {}, nullptr, ec, nullptr);
if (ec) {
BOOST_LOG(error) << "Failed to execute server command: " << ec.message();
} else {
child.detach();
}
} else {
BOOST_LOG(error) << "Invalid server command index: " << (int)cmdIndex;
}
});
server->map(packetTypes[IDX_SET_CLIPBOARD], [server](session_t *session, const std::string_view &payload) {
BOOST_LOG(info) << "type [IDX_SET_CLIPBOARD]: "sv << payload << " size: " << payload.size();
});
server->map(packetTypes[IDX_FILE_TRANSFER_NONCE_REQUEST], [server](session_t *session, const std::string_view &payload) {
BOOST_LOG(info) << "type [IDX_FILE_TRANSFER_NONCE_REQUEST]: "sv << payload << " size: " << payload.size();
});
server->map(packetTypes[IDX_ENCRYPTED], [server](session_t *session, const std::string_view &payload) { server->map(packetTypes[IDX_ENCRYPTED], [server](session_t *session, const std::string_view &payload) {
BOOST_LOG(verbose) << "type [IDX_ENCRYPTED]"sv; BOOST_LOG(verbose) << "type [IDX_ENCRYPTED]"sv;

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"
:server-cmd="server_cmd"
:platform="platform"> :platform="platform">
</general> </general>
@@ -134,6 +135,7 @@
currentTab: "general", currentTab: "general",
vdisplayStatus: "1", vdisplayStatus: "1",
global_prep_cmd: [], global_prep_cmd: [],
server_cmd: [],
tabs: [ // TODO: Move the options to each Component instead, encapsulate. tabs: [ // TODO: Move the options to each Component instead, encapsulate.
{ {
id: "general", id: "general",
@@ -143,6 +145,7 @@
"sunshine_name": "", "sunshine_name": "",
"min_log_level": 2, "min_log_level": 2,
"global_prep_cmd": "[]", "global_prep_cmd": "[]",
"server_cmd": "[]",
"notify_pre_releases": "disabled", "notify_pre_releases": "disabled",
}, },
}, },
@@ -328,7 +331,9 @@
}); });
this.config.global_prep_cmd = this.config.global_prep_cmd || []; this.config.global_prep_cmd = this.config.global_prep_cmd || [];
this.config.server_cmd = this.config.server_cmd || [];
this.global_prep_cmd = JSON.parse(this.config.global_prep_cmd); this.global_prep_cmd = JSON.parse(this.config.global_prep_cmd);
this.server_cmd = JSON.parse(this.config.server_cmd);
}); });
}, },
methods: { methods: {
@@ -336,7 +341,8 @@
this.$forceUpdate() this.$forceUpdate()
}, },
serialize() { serialize() {
this.config.global_prep_cmd = JSON.stringify(this.global_prep_cmd); this.config.global_prep_cmd = JSON.stringify(this.global_prep_cmd.filter(cmd => cmd.do || cmd.undo));
this.config.server_cmd = JSON.stringify(this.server_cmd.filter(cmd => cmd.name && cmd.cmd));
}, },
save() { save() {
this.saved = false; this.saved = false;

View File

@@ -4,26 +4,35 @@ import { ref } from 'vue'
const props = defineProps({ const props = defineProps({
platform: String, platform: String,
config: Object, config: Object,
globalPrepCmd: Array globalPrepCmd: Array,
serverCmd: Array
}) })
const config = ref(props.config) const config = ref(props.config)
const globalPrepCmd = ref(props.globalPrepCmd) const globalPrepCmd = ref(props.globalPrepCmd)
const serverCmd = ref(props.serverCmd)
function addCmd() { const prepCmdTemplate = {
let template = { do: "",
do: "", undo: "",
undo: "",
};
if (props.platform === 'windows') {
template = { ...template, elevated: false };
}
globalPrepCmd.value.push(template);
} }
function removeCmd(index) { const serverCmdTemplate = {
globalPrepCmd.value.splice(index,1) name: "",
cmd: ""
}
function addCmd(cmdArr, template) {
const _tpl = Object.assign({}, template);
if (props.platform === 'windows') {
_tpl.elevated = false;
}
cmdArr.push(_tpl);
}
function removeCmd(cmdArr, index) {
cmdArr.splice(index,1)
} }
</script> </script>
@@ -114,17 +123,63 @@ function removeCmd(index) {
</div> </div>
</td> </td>
<td> <td>
<button class="btn btn-danger" @click="removeCmd(i)"> <button class="btn btn-danger" @click="removeCmd(globalPrepCmd, i)">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
<button class="btn btn-success" @click="addCmd"> <button class="btn btn-success" @click="addCmd(globalPrepCmd, prepCmdTemplate)">
<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"> <button class="ms-0 mt-2 btn btn-success" style="margin: 0 auto" @click="addCmd(globalPrepCmd, prepCmdTemplate)">
&plus; {{ $t('config.add') }}
</button>
</div>
<!-- Server Commands -->
<div id="server_cmd" class="mb-3 d-flex flex-column">
<label class="form-label">{{ $t('config.server_cmd') }}</label>
<div class="form-text">{{ $t('config.server_cmd_desc') }}</div>
<table class="table" v-if="serverCmd.length > 0">
<thead>
<tr>
<th scope="col"><i class="fas fa-tag"></i> {{ $t('_common.cmd_name') }}</th>
<th scope="col"><i class="fas fa-terminal"></i> {{ $t('_common.cmd_val') }}</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 serverCmd">
<td>
<input type="text" class="form-control" v-model="c.name" />
</td>
<td>
<input type="text" class="form-control monospace" v-model="c.cmd" />
</td>
<td v-if="platform === 'windows'">
<div class="form-check">
<input type="checkbox" class="form-check-input" :id="'server-cmd-admin-' + i" v-model="c.elevated"
true-value="true" false-value="false" />
<label :for="'server-cmd-admin-' + i" class="form-check-label">{{ $t('_common.elevated') }}</label>
</div>
</td>
<td>
<button class="btn btn-danger" @click="removeCmd(serverCmd, i)">
<i class="fas fa-trash"></i>
</button>
<button class="btn btn-success" @click="addCmd(serverCmd, serverCmdTemplate)">
<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(serverCmd, serverCmdTemplate)">
&plus; {{ $t('config.add') }} &plus; {{ $t('config.add') }}
</button> </button>
</div> </div>

View File

@@ -5,6 +5,8 @@
"autodetect": "Autodetect (recommended)", "autodetect": "Autodetect (recommended)",
"beta": "(beta)", "beta": "(beta)",
"cancel": "Cancel", "cancel": "Cancel",
"cmd_name": "Command Name",
"cmd_val": "Command Value",
"disabled": "Disabled", "disabled": "Disabled",
"disabled_def": "Disabled (default)", "disabled_def": "Disabled (default)",
"dismiss": "Dismiss", "dismiss": "Dismiss",
@@ -308,6 +310,8 @@
"qsv_slow_hevc": "Allow Slow HEVC Encoding", "qsv_slow_hevc": "Allow Slow HEVC Encoding",
"qsv_slow_hevc_desc": "This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU usage and worse performance.", "qsv_slow_hevc_desc": "This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU usage and worse performance.",
"restart_note": "Apollo is restarting to apply changes.", "restart_note": "Apollo is restarting to apply changes.",
"server_cmd": "Server Commands",
"server_cmd_desc": "Configure a list of commands to be executed when called from client during streaming.",
"sunshine_name": "Apollo Name", "sunshine_name": "Apollo Name",
"sunshine_name_desc": "The name displayed by Moonlight. If not specified, the PC's hostname is used", "sunshine_name_desc": "The name displayed by Moonlight. If not specified, the PC's hostname is used",
"sw_preset": "SW Presets", "sw_preset": "SW Presets",

View File

@@ -5,6 +5,8 @@
"autodetect": "自动检测 (推荐)", "autodetect": "自动检测 (推荐)",
"beta": "(测试版)", "beta": "(测试版)",
"cancel": "取消", "cancel": "取消",
"cmd_name": "命令名称",
"cmd_val": "命令值",
"disabled": "禁用", "disabled": "禁用",
"disabled_def": "禁用(默认)", "disabled_def": "禁用(默认)",
"dismiss": "关闭", "dismiss": "关闭",
@@ -308,6 +310,8 @@
"res_fps_desc": "由 Apollo 通告的显示模式。 某些版本的 Moonlight如 Moonlight-nx (Switch),依靠这些清单来确保支持所请求的分辨率和 fps。 此设置不会改变屏幕串流送至 Moonlight 的方式。", "res_fps_desc": "由 Apollo 通告的显示模式。 某些版本的 Moonlight如 Moonlight-nx (Switch),依靠这些清单来确保支持所请求的分辨率和 fps。 此设置不会改变屏幕串流送至 Moonlight 的方式。",
"resolutions": "通告分辨率", "resolutions": "通告分辨率",
"restart_note": "正在重启 Apollo 以应用更改。", "restart_note": "正在重启 Apollo 以应用更改。",
"server_cmd": "服务端命令",
"server_cmd_desc": "配置一个命令列表,当串流时从客户端调用。",
"sunshine_name": "Apollo 主机名称", "sunshine_name": "Apollo 主机名称",
"sunshine_name_desc": "在 Moonlight 中显示的名称。如果未指定,则使用 PC 的主机名", "sunshine_name_desc": "在 Moonlight 中显示的名称。如果未指定,则使用 PC 的主机名",
"sw_preset": "软件编码预设", "sw_preset": "软件编码预设",