Support Reordering apps(requires Artemis)

This commit is contained in:
Yukino Song
2025-05-14 02:43:08 +08:00
parent 47686b5136
commit 2b86bc541d
6 changed files with 244 additions and 29 deletions

View File

@@ -619,6 +619,120 @@ namespace confighttp {
send_response(response, output_tree);
}
/**
* @brief Reorder applications.
* @param response The HTTP response object.
* @param request The HTTP request object.
*
* @api_examples{/api/apps/reorder| POST| {"order": ["aaaa-bbbb", "cccc-dddd"]}}
*/
void reorderApps(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
try {
std::stringstream ss;
ss << request->content.rdbuf();
nlohmann::json input_tree = nlohmann::json::parse(ss.str());
nlohmann::json output_tree;
// Read the existing apps file.
std::string content = file_handler::read_file(config::stream.file_apps.c_str());
nlohmann::json fileTree = nlohmann::json::parse(content);
// Get the desired order of UUIDs from the request.
if (!input_tree.contains("order") || !input_tree["order"].is_array()) {
throw std::runtime_error("Missing or invalid 'order' array in request body");
}
const auto& order_uuids_json = input_tree["order"];
// Get the original apps array from the fileTree.
// Default to an empty array if "apps" key is missing or if it's present but not an array (after logging an error).
nlohmann::json original_apps_list = nlohmann::json::array();
if (fileTree.contains("apps")) {
if (fileTree["apps"].is_array()) {
original_apps_list = fileTree["apps"];
} else {
// "apps" key exists but is not an array. This is a malformed state.
BOOST_LOG(error) << "ReorderApps: 'apps' key in apps configuration file ('" << config::stream.file_apps
<< "') is present but not an array.";
throw std::runtime_error("'apps' in file is not an array, cannot reorder.");
}
} else {
// "apps" key is missing. Treat as an empty list. Reordering an empty list is valid.
BOOST_LOG(debug) << "ReorderApps: 'apps' key missing in apps configuration file ('" << config::stream.file_apps
<< "'). Treating as an empty list for reordering.";
// original_apps_list is already an empty array, so no specific action needed here.
}
nlohmann::json reordered_apps_list = nlohmann::json::array();
std::vector<bool> item_moved(original_apps_list.size(), false);
// Phase 1: Place apps according to the 'order' array from the request.
// Iterate through the desired order of UUIDs.
for (const auto& uuid_json_value : order_uuids_json) {
if (!uuid_json_value.is_string()) {
BOOST_LOG(warning) << "ReorderApps: Encountered a non-string UUID in the 'order' array. Skipping this entry.";
continue;
}
std::string target_uuid = uuid_json_value.get<std::string>();
bool found_match_for_ordered_uuid = false;
// Find the first unmoved app in the original list that matches the current target_uuid.
for (size_t i = 0; i < original_apps_list.size(); ++i) {
if (item_moved[i]) {
continue; // This specific app object has already been placed.
}
const auto& app_item = original_apps_list[i];
// Ensure the app item is an object and has a UUID to match against.
if (app_item.is_object() && app_item.contains("uuid") && app_item["uuid"].is_string()) {
if (app_item["uuid"].get<std::string>() == target_uuid) {
reordered_apps_list.push_back(app_item); // Add the found app object to the new list.
item_moved[i] = true; // Mark this specific object as moved.
found_match_for_ordered_uuid = true;
break; // Found an app for this UUID, move to the next UUID in the 'order' array.
}
}
}
if (!found_match_for_ordered_uuid) {
// This means a UUID specified in the 'order' array was not found in the original_apps_list
// among the currently available (unmoved) app objects.
// Per instruction "If the uuid is missing from the original json file, omit it."
BOOST_LOG(debug) << "ReorderApps: UUID '" << target_uuid << "' from 'order' array not found in available apps list or its matching app was already processed. Omitting.";
}
}
// Phase 2: Append any remaining apps from the original list that were not explicitly ordered.
// These are app objects that were not marked 'item_moved' in Phase 1.
for (size_t i = 0; i < original_apps_list.size(); ++i) {
if (!item_moved[i]) {
reordered_apps_list.push_back(original_apps_list[i]);
}
}
// Update the fileTree with the new, reordered list of apps.
fileTree["apps"] = reordered_apps_list;
// Write the modified fileTree back to the apps configuration file.
file_handler::write_file(config::stream.file_apps.c_str(), fileTree.dump(4));
// Notify relevant parts of the system that the apps configuration has changed.
proc::refresh(config::stream.file_apps);
output_tree["status"] = true;
send_response(response, output_tree);
} catch (std::exception &e) {
BOOST_LOG(warning) << "ReorderApps: "sv << e.what();
bad_request(response, request, e.what());
}
}
/**
* @brief Delete an application.
* @param response The HTTP response object.
@@ -1320,6 +1434,7 @@ namespace confighttp {
server.resource["^/api/otp$"]["GET"] = getOTP;
server.resource["^/api/apps$"]["GET"] = getApps;
server.resource["^/api/apps$"]["POST"] = saveApp;
server.resource["^/api/apps/reorder$"]["POST"] = reorderApps;
server.resource["^/api/apps/delete$"]["POST"] = deleteApp;
server.resource["^/api/apps/launch$"]["POST"] = launchApp;
server.resource["^/api/apps/close$"]["POST"] = closeApp;

View File

@@ -670,6 +670,14 @@ namespace proc {
_launch_session.reset();
virtual_display = false;
allow_client_commands = false;
if (refreshing) {
return;
}
refreshing = true;
refresh(config::stream.file_apps);
refreshing = false;
}
const std::vector<ctx_t> &proc_t::get_apps() const {
@@ -1303,8 +1311,6 @@ namespace proc {
}
ids.insert(ctx.id);
BOOST_LOG(info) << "VIRTUAL DISPLAY APP ID::: " << ctx.id;
apps.emplace_back(std::move(ctx));
}
#endif
@@ -1388,7 +1394,11 @@ namespace proc {
}
void refresh(const std::string &file_name) {
proc.terminate();
if (!proc.refreshing) {
proc.refreshing = true;
proc.terminate();
proc.refreshing = false;
}
#ifdef _WIN32
size_t fail_count = 0;

View File

@@ -100,6 +100,7 @@ namespace proc {
bool initial_hdr;
bool virtual_display;
bool allow_client_commands;
bool refreshing;
proc_t(

View File

@@ -67,6 +67,10 @@
border-bottom: rgba(0, 0, 0, 0.25) 1px solid;
vertical-align: top;
}
.dragover {
border-top: 2px solid #ffc400;
}
</style>
</head>
@@ -74,8 +78,9 @@
<Navbar></Navbar>
<div class="container">
<div class="my-4">
<h1>{{ $t('apps.applications_title') }}</h1>
<div>{{ $t('apps.applications_desc') }}</div>
<h1>{{ $t('apps.applications_title') }}</h1>
<div>{{ $t('apps.applications_desc') }}</div>
<div>{{ $t('apps.applications_reorder_desc') }}</div>
</div>
<div class="card p-4">
<table class="table">
@@ -86,9 +91,20 @@
</tr>
</thead>
<tbody>
<tr v-for="(app,i) in apps" :key="app.uuid">
<td>{{app.name}}</td>
<td>
<tr
v-for="(app,i) in apps"
:key="app.uuid"
:class="{dragover: app.dragover}"
draggable="true"
@dragstart="onDragStart(i)"
@dragenter="onDragEnter($event, app)"
@dragover="onDragOver($event)"
@dragleave="onDragLeave(app)"
@dragend="onDragEnd()"
@drop="onDrop($event, app, i)"
>
<td>{{app.name || ' '}}</td>
<td v-if="app.uuid">
<button class="btn btn-primary me-2" :disabled="actionDisabled" @click="editApp(app)">
<i class="fas fa-edit"></i> {{ $t('apps.edit') }}
</button>
@@ -102,6 +118,7 @@
<i class="fas fa-play"></i> {{ $t('apps.launch') }}
</button>
</td>
<td v-else></td>
</tr>
</tbody>
</table>
@@ -461,7 +478,8 @@
coverFinderBusy: false,
coverCandidates: [],
platform: "",
currentApp: ""
currentApp: "",
draggingApp: -1
};
},
created() {
@@ -474,13 +492,77 @@
.then(r => this.platform = r.platform);
},
methods: {
onDragStart(idx) {
this.draggingApp = idx;
this.apps.push({})
},
onDragEnter(e, app) {
if (this.draggingApp < 0) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
app.dragover = true;
},
onDragOver(e) {
if (this.draggingApp < 0) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
},
onDragLeave(app) {
app.dragover = false;
},
onDragEnd() {
this.draggingApp = -1;
this.apps.pop();
},
onDrop(e, app, idx) {
app.dragover = false;
if (this.draggingApp < 0) {
return;
}
e.preventDefault();
if (idx === this.draggingApp || idx - 1 === this.draggingApp) {
return;
}
const draggedApp = this.apps[this.draggingApp];
this.apps.splice(this.draggingApp, 1);
if (idx > this.draggingApp) {
idx -= 1;
}
this.apps.splice(idx, 0, draggedApp);
const reorderedUUIDs = this.apps.map(i => i.uuid);
reorderedUUIDs.pop();
fetch("./api/apps/reorder", {
credentials: 'include',
method: "POST",
body: JSON.stringify({order: reorderedUUIDs})
})
.then(r => r.json())
.then((r) => {
if (!r.status) {
alert(this.$t("apps.reorder_failed") + r.error);
}
})
.finally(() => {
this.loadApps();
});
},
loadApps() {
fetch("./api/apps", {
credentials: 'include'
})
.then(r => r.json())
.then(r => {
this.apps = r.apps.map(i => ({...i, launching: false}));
this.apps = r.apps.map(i => ({...i, launching: false, dragover: false}));
this.currentApp = r.current_app;
});
},
@@ -666,7 +748,9 @@
this.editForm["exit-timeout"] = parseInt(this.editForm["exit-timeout"]) || 5
this.editForm["scale-factor"] = parseInt(this.editForm["scale-factor"]) || 100
this.editForm["image-path"] = this.editForm["image-path"].toString().trim().replace(/"/g, '');
delete this.editForm["id"];
delete this.editForm.id;
delete this.editForm.launching;
delete this.editForm.dragover;
fetch("./api/apps", {
credentials: 'include',
method: "POST",
@@ -675,9 +759,10 @@
.then((r) => {
if (!r.status) {
alert(this.$t('apps.save_failed') + r.error);
throw new Error(`App save failed: ${r.error}`);
}
})
.finally(() => {
.then(() => {
this.showEditForm = false;
this.loadApps();
});

View File

@@ -36,7 +36,8 @@
"allow_client_commands_desc": "Whether to execute client prepare commands when running this app.",
"app_name": "Application Name",
"app_name_desc": "Application Name, as shown on Moonlight",
"applications_desc": "Applications are refreshed only when Client is restarted",
"applications_desc": "Applications are refreshed when a session is terminated.",
"applications_reorder_desc": "Drag and drop apps to reorder them. Any changes made will terminate the current running app.",
"applications_title": "Applications",
"auto_detach": "Continue streaming if the application exits quickly",
"auto_detach_desc": "This will attempt to automatically detect launcher-type apps that close quickly after launching another program or instance of themselves. When a launcher-type app is detected, it is treated as a detached app.",
@@ -94,20 +95,21 @@
"output_name": "Output",
"per_client_app_identity": "Per Client App Identity",
"per_client_app_identity_desc": "Separate the app's identity per-client. Useful when you want different virtual display configurations on this specific app for different clients",
"reorder_failed": "Failed to reorder apps: ",
"resolution_scale_factor": "Resolution Scale Factor",
"resolution_scale_factor_desc": "Scale the client requested resolution based on this factor. e.g. 2000x1000 with a factor of 120% will become 2400x1200. Overrides client requested factor when the number isn't 100%. This option won't affect client requested streaming resolution.",
"run_as_desc": "This can be necessary for some applications that require administrator permissions to run properly. Might cause URL schemes to fail.",
"save_failed": "Failed to save app: ",
"wait_all": "Continue streaming until all app processes exit",
"wait_all_desc": "This will continue streaming until all processes started by the app have terminated. When unchecked, streaming will stop when the initial app process exits, even if other app processes are still running.",
"working_dir": "Working Directory",
"working_dir_desc": "The working directory that should be passed to the process. For example, some applications use the working directory to search for configuration files. If not set, Apollo will default to the parent directory of the command",
"use_app_identity": "Use App Identity",
"use_app_identity_desc": "Use the app's own identity while creating virtual displays instead of client's. This is useful when you want display configuration for each APP separately.",
"virtual_display": "Always use Virtual Display",
"virtual_display_desc": "Always use virtual display on this app, overriding client request. Please make sure the SudoVDA driver is installed and enabled.",
"virtual_display_primary": "Enforce Virtual Display Primary",
"virtual_display_primary_desc": "Automatically set the virtual display as primary display when the app starts. Virtual display will always be set to primary when client requests to use virtual display. (Recommended to keep on) [Broken on Windows 11 24H2]",
"resolution_scale_factor": "Resolution Scale Factor",
"resolution_scale_factor_desc": "Scale the client requested resolution based on this factor. e.g. 2000x1000 with a factor of 120% will become 2400x1200. Overrides client requested factor when the number isn't 100%. This option won't affect client requested streaming resolution.",
"use_app_identity": "Use App Identity",
"use_app_identity_desc": "Use the app's own identity while creating virtual displays instead of client's. This is useful when you want display configuration for each APP separately."
"wait_all": "Continue streaming until all app processes exit",
"wait_all_desc": "This will continue streaming until all processes started by the app have terminated. When unchecked, streaming will stop when the initial app process exits, even if other app processes are still running.",
"working_dir": "Working Directory",
"working_dir_desc": "The working directory that should be passed to the process. For example, some applications use the working directory to search for configuration files. If not set, Apollo will default to the parent directory of the command"
},
"client_card": {
"clients": "Clients",

View File

@@ -36,7 +36,8 @@
"allow_client_commands_desc": "在此APP运行时是否允许执行客户端准备命令",
"app_name": "应用名称",
"app_name_desc": "在 Moonlight 显示的应用名称",
"applications_desc": "只有重启客户端时应用列表才会被刷新",
"applications_desc": "应用列表在会话终止时刷新",
"applications_reorder_desc": "拖动应用以重新排序。任何更改都会终止当前正在运行的APP。",
"applications_title": "应用",
"auto_detach": "启动串流后应用突然关闭时不退出串流",
"auto_detach_desc": "这将尝试自动检测在启动另一个程序或自身实例后很快关闭的启动类应用。 检测到启动型应用程序时,它会被视为一个分离的应用程序。",
@@ -95,18 +96,19 @@
"output_name": "输出",
"per_client_app_identity": "按客户端区分 App 身份",
"per_client_app_identity_desc": "当你希望在使用此 App 时每个客户端都有不同的虚拟显示器组合配置时有用。",
"reorder_failed": "重新排序应用失败:",
"resolution_scale_factor": "分辨率缩放比例",
"resolution_scale_factor_desc": "基于此比例缩放客户端请求的分辨率。例如 2000x1000 缩放 120% 将变成 2400x1200。当此项为非 100% 时覆盖客户端请求的缩放比例。此选项不会影响客户端请求的串流分辨率。",
"run_as_desc": "这可能是某些需要管理员权限才能正常运行的应用程序所必需的。可能会导致 URL schemes 无法正常启动。",
"save_failed": "保存APP失败",
"use_app_identity": "使用 App 身份",
"use_app_identity_desc": "在创建虚拟显示器时使用 App 自身的身份,而非客户端的。这样可以针对 APP 进行单独的显示器组合配置。",
"virtual_display": "总是使用虚拟显示器",
"virtual_display_desc": "在使用这个 App 的时候总是使用虚拟显示器,覆盖客户端请求。请确保 SudoVDA 虚拟显示器驱动已安装并启用。",
"wait_all": "继续串流直到所有应用进程退出",
"wait_all_desc": "这将继续串流直到应用程序启动的所有进程终止。 当未选中时,串流将在初始应用进程终止时停止,即使其他应用进程仍在运行。",
"working_dir": "工作目录",
"working_dir_desc": "应传递给进程的工作目录。例如某些应用程序使用工作目录搜索配置文件。如果不设置Apollo 将默认使用命令的父目录",
"virtual_display": "总是使用虚拟显示器",
"virtual_display_desc": "在使用这个 App 的时候总是使用虚拟显示器,覆盖客户端请求。请确保 SudoVDA 虚拟显示器驱动已安装并启用。",
"resolution_scale_factor": "分辨率缩放比例",
"resolution_scale_factor_desc": "基于此比例缩放客户端请求的分辨率。例如 2000x1000 缩放 120% 将变成 2400x1200。当此项为非 100% 时覆盖客户端请求的缩放比例。此选项不会影响客户端请求的串流分辨率。",
"use_app_identity": "使用 App 身份",
"use_app_identity_desc": "在创建虚拟显示器时使用 App 自身的身份,而非客户端的。这样可以针对 APP 进行单独的显示器组合配置。"
"working_dir_desc": "应传递给进程的工作目录。例如某些应用程序使用工作目录搜索配置文件。如果不设置Apollo 将默认使用命令的父目录"
},
"client_card": {
"clients": "客户端",