[feat] add remote host version checks and update prompts

This commit is contained in:
dijunkun
2026-09-28 13:54:39 +08:00
parent f347fc2387
commit 51d392ef48
25 changed files with 891 additions and 392 deletions
@@ -1481,6 +1481,12 @@ void GuiApplication::BindStreamCallbacks() {
return;
}
auto& stream = *ui_->stream;
stream->on_dismiss_remote_update([this] {
if (auto props = SelectedSession()) {
std::lock_guard lock(props->remote_version_mutex_);
props->remote_update_dismissed_ = true;
}
});
auto close_stream_sessions = [this] {
std::vector<std::string> ids;
{
@@ -2525,6 +2531,11 @@ void GuiApplication::SyncXWaylandWindowActivation() {
#endif
void GuiApplication::SetStreamKeyboardFocus(bool focused) {
// The local upgrade dialog owns input even while the native window is active.
if (focused && ui_ && ui_->stream &&
(*ui_->stream)->get_remote_update_visible()) {
focused = false;
}
if (focus_on_stream_window_ == focused) {
return;
}
@@ -2702,6 +2713,21 @@ void GuiApplication::SyncStreamWindow() {
return;
}
const ConnectionStatus status = props->connection_status_.load();
bool remote_update_available = false;
if (status == ConnectionStatus::Connected) {
std::lock_guard lock(props->remote_version_mutex_);
// Wait for host_info: a version still in transit is not a legacy peer.
if (props->remote_host_info_received_ && !props->remote_update_dismissed_) {
remote_update_available = props->remote_app_version_.empty() ||
(update_checker_->latest() &&
AvailableAppUpdate(props->remote_app_version_,
*update_checker_->latest()).has_value());
}
}
(*ui_->stream)->set_remote_update_visible(remote_update_available);
if (remote_update_available) {
SetStreamKeyboardFocus(false);
}
const bool waiting_for_frame =
status == ConnectionStatus::Connected &&
!(*ui_->stream)->get_has_frame();
@@ -191,6 +191,9 @@ struct TranslationRow {
u8"Способ захвата:") \
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
X(remote_update_title, u8"被控端版本过低", "Remote device needs an update", u8"Версия на удалённом устройстве устарела") \
X(remote_update, u8"请升级到最新版本。", "Please update to the latest version.", u8"Обновите её до последней версии.") \
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
X(video_settings_unavailable, u8"连接建立后可调整,需被控端支持。", "Available when connected to a supported remote device.", u8"Доступно при подключении к поддерживаемому устройству.") \
@@ -2,6 +2,7 @@
#include "runtime/peer_event_handler.h"
#include <app_version.h>
#include <remote_action.h>
#include <nlohmann/json.hpp>
@@ -318,6 +319,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
if (is_client_mode) {
if (props && source_id == props->control_data_label_) {
std::lock_guard lock(props->remote_version_mutex_);
props->remote_host_info_received_ = true;
props->remote_app_version_ = IsValidAppVersion(remote_action.i.app_version)
? remote_action.i.app_version : "";
}
// Negotiate live settings only on the reliable control stream. Old
// hosts omit this capability and retain their existing video behavior.
if (props && source_id == props->control_data_label_) {
@@ -270,7 +270,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
{
RemoteAction remote_action = MakeHostInformation(
GetHostName(), runtime->devices_.host_display_list(),
/*supports_privacy_screen=*/true);
/*supports_privacy_screen=*/true, CROSSDESK_VERSION);
remote_action.i.supports_video_settings = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
@@ -300,6 +300,12 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->keyboard_.ReleaseRemotePressedKeys(remote_id,
"connection_closed");
props->connection_established_ = false;
{
std::lock_guard lock(props->remote_version_mutex_);
props->remote_host_info_received_ = false;
props->remote_app_version_.clear();
props->remote_update_dismissed_ = false;
}
props->net_traffic_stats_.Reset();
props->enable_mouse_control_ = false;
runtime->ResetRemoteServiceStatus(*props);
@@ -395,7 +401,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
{
RemoteAction remote_action = MakeHostInformation(
GetHostName(), runtime->devices_.host_display_list(),
/*supports_privacy_screen=*/true);
/*supports_privacy_screen=*/true, CROSSDESK_VERSION);
remote_action.i.supports_video_settings = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
@@ -124,6 +124,11 @@ struct RemoteSession {
bool tab_selected_ = false;
bool tab_opened_ = true;
std::string remote_host_name_;
// Received version and dismissal state belong to this connection.
std::mutex remote_version_mutex_;
bool remote_host_info_received_ = false;
std::string remote_app_version_;
bool remote_update_dismissed_ = false;
bool remote_service_status_received_ = false;
std::mutex video_settings_mutex_;
// Requested and confirmed settings for this remote session only.
+2 -1
View File
@@ -365,6 +365,7 @@ export component CompactButton inherits Rectangle {
export component PrimaryButton inherits Rectangle {
in property <string> text;
in property <length> text-size: ImGuiFontStyle.body;
in property <bool> enabled: true;
callback clicked;
@@ -379,7 +380,7 @@ export component PrimaryButton inherits Rectangle {
Text {
text: root.text;
color: white;
font-size: ImGuiFontStyle.body;
font-size: root.text-size;
horizontal-alignment: center;
vertical-alignment: center;
}
+110 -5
View File
@@ -1,5 +1,5 @@
import { ComboBox, ScrollView } from "std-widgets.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, DialogSurface, ImGuiLineStyle, ImGuiFontStyle } from "common.slint";
export struct StreamTab {
remote-id: string,
@@ -23,6 +23,9 @@ export struct NetworkStatsRow {
}
export global StreamStrings {
in-out property <string> remote-update-title: "Remote device needs an update";
in-out property <string> remote-update: "Please update to the latest version.";
in-out property <string> remote-update-dismiss: "Got it";
in-out property <string> video-settings: "Video settings";
in-out property <string> video-settings-unavailable: "The remote device does not support live video settings";
in-out property <string> video-quality: "Video quality";
@@ -196,6 +199,8 @@ export component StreamWindow inherits Window {
in property <bool> has-frame: false;
in property <string> status-text: "";
in property <string> receiving-text: "";
in property <bool> remote-update-visible: false;
callback dismiss-remote-update;
in property <[string]> displays;
in-out property <int> selected-display: 0;
in property <bool> mouse-control-enabled: true;
@@ -271,6 +276,15 @@ export component StreamWindow inherits Window {
changed selected-tab => { root.video-menu-open = false; }
changed control-expanded => { if !root.control-expanded { root.video-menu-open = false; } }
changed window-active => { if !root.window-active { root.video-menu-open = false; } }
changed remote-update-visible => {
if root.remote-update-visible {
root.video-menu-open = false;
root.display-menu-open = false;
root.shortcut-menu-open = false;
}
input-focus.focus();
root.keyboard-focus-changed(!root.remote-update-visible);
}
private property <bool> control-tooltip-visible: false;
private property <ControlBarTooltip> hovered-control: {
if root.window-active && !root.control-dragging
@@ -345,6 +359,7 @@ export component StreamWindow inherits Window {
}
is-local-control-area(pointer-x, pointer-y) => {
if root.remote-update-visible { return true; }
let over-control = pointer-x >= control.x
&& pointer-x <= control.x + control.width
&& pointer-y >= control.y
@@ -655,9 +670,11 @@ export component StreamWindow inherits Window {
drop-zone := DropArea {
can-drop(event) => {
return root.can-drop-file(event.data) ? DragAction.copy : DragAction.none;
return !root.remote-update-visible && root.can-drop-file(event.data)
? DragAction.copy : DragAction.none;
}
dropped(event) => {
if root.remote-update-visible { return DragAction.none; }
root.file-dropped(event.data);
return DragAction.copy;
}
@@ -668,11 +685,20 @@ export component StreamWindow inherits Window {
focus-gained => { root.keyboard-focus-changed(true); }
focus-lost => { root.keyboard-focus-changed(false); }
key-pressed(event) => {
root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
if !root.remote-update-visible {
root.key-input(event.text, true, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
}
accept
}
key-released(event) => {
root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
if root.remote-update-visible {
// Dismiss on release so the same key cannot reach the peer.
if event.text == Key.Escape || event.text == Key.Return {
root.dismiss-remote-update();
}
} else {
root.key-input(event.text, false, event.modifiers.control, event.modifiers.alt, event.modifiers.shift, event.modifiers.meta);
}
accept
}
pointer := TouchArea {
@@ -723,7 +749,12 @@ export component StreamWindow inherits Window {
root.suppress-next-remote-pointer-release = false;
input-focus.focus();
}
if event.kind == PointerEventKind.down
// A gesture started before the modal appeared keeps
// pointer capture. Let its release reach the peer.
if root.remote-update-visible && !root.control-dragging
&& event.kind == PointerEventKind.up {
root.pointer-input(event.button, event.kind, self.mouse-x, self.mouse-y);
} else if event.kind == PointerEventKind.down
&& event.button == PointerEventButton.left
&& over-control {
root.begin-control-drag(self.mouse-x, self.mouse-y);
@@ -1272,6 +1303,80 @@ export component StreamWindow inherits Window {
}
}
if root.remote-update-visible: Rectangle {
width: parent.width; height: parent.height;
background: #0c162c52;
z: 13;
TouchArea {
mouse-cursor: default;
scroll-event(event) => { accept }
}
}
remote-update-banner := DialogSurface {
visible: root.remote-update-visible;
x: (parent.width - self.width) / 2;
y: min(max(64px, parent.height * 0.18), max(16px, parent.height - self.height - 16px));
width: min(520px, parent.width - 48px);
height: max(208px, remote-update-message.y + remote-update-message.height + 92px);
border-color: #e5e9f0;
border-radius: 14px;
drop-shadow-color: #07142b30;
drop-shadow-blur: 32px;
drop-shadow-offset-y: 12px;
z: 14;
TouchArea {
mouse-cursor: default;
pointer-event(event) => {
if event.kind == PointerEventKind.down {
root.suppress-next-remote-pointer-release = true;
}
}
scroll-event(event) => { accept }
}
Rectangle {
x: 32px; y: 32px; width: 48px; height: 48px;
background: #edf4ff; border-radius: 12px;
Path {
x: 12px; y: 12px; width: 24px; height: 24px;
viewbox-width: 24; viewbox-height: 24;
commands: "M 12 16 L 12 3 M 6.5 8.5 L 12 3 L 17.5 8.5 M 4 15 L 4 20 Q 4 21 5 21 L 19 21 Q 20 21 20 20 L 20 15";
fill: transparent;
stroke: #2463c7; stroke-width: 1.8px;
}
}
remote-update-title := Text {
x: 100px; y: 34px; width: parent.width - 144px;
height: max(28px, self.preferred-height);
text: StreamStrings.remote-update-title;
color: #1b2433; font-size: 20px; font-weight: 600;
wrap: word-wrap;
}
remote-update-message := Text {
x: remote-update-title.x;
y: remote-update-title.y + remote-update-title.height + 8px;
width: remote-update-title.width;
height: max(22px, self.preferred-height);
text: StreamStrings.remote-update;
color: #667085; font-size: 15px;
wrap: word-wrap;
}
IconButton {
x: parent.width - 44px; y: 12px; width: 32px; height: 32px;
icon: FontAwesomeIcons.xmark; icon-size: 14px;
icon-color: #8a93a3;
hover-background: #f2f4f7;
clicked => { root.dismiss-remote-update(); }
}
PrimaryButton {
x: parent.width - 136px; y: parent.height - 68px;
width: 104px; height: 36px;
border-radius: 7px;
text: StreamStrings.remote-update-dismiss;
text-size: 14px;
clicked => { root.dismiss-remote-update(); }
}
}
// Transfer progress overlay at bottom-left.
if root.file-transfer-visible: Rectangle {
x: 18px; y: parent.height - min(210px, parent.height * 0.35); width: min(430px, parent.width * 0.6); height: min(190px, parent.height * 0.32);
@@ -133,6 +133,9 @@ inline int ApplyStreamWindowStrings(
const int language =
localization::detail::ClampLanguageIndex(language_index);
auto& strings = window->template global<ui::StreamStrings>();
strings.set_remote_update_title(Text(localization::remote_update_title[language]));
strings.set_remote_update(Text(localization::remote_update[language]));
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
strings.set_select_display(Text(localization::select_display[language]));
strings.set_send_shortcut(Text(localization::send_shortcut[language]));
strings.set_privacy_enable(Text(localization::privacy_enable[language]));
@@ -11,224 +11,15 @@
#include "rd_log.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <limits>
#include <sstream>
#include <string>
#include <vector>
namespace crossdesk {
std::vector<int> SplitVersion(const std::string& ver);
namespace {
constexpr size_t kMaxInlinePatchDigits = 4;
struct ParsedVersion {
std::vector<int> numbers;
std::string date;
bool has_patch = false;
int patch = 0;
};
bool IsDigit(char c) {
return std::isdigit(static_cast<unsigned char>(c)) != 0;
}
bool IsAlphaNumeric(char c) {
return std::isalnum(static_cast<unsigned char>(c)) != 0;
}
bool IsAllDigits(const std::string& value) {
if (value.empty()) {
return false;
}
for (char c : value) {
if (!IsDigit(c)) {
return false;
}
}
return true;
}
bool TryParseNonNegativeInt(const std::string& value, int* result) {
if (!IsAllDigits(value)) {
return false;
}
try {
const long long parsed = std::stoll(value);
if (parsed > std::numeric_limits<int>::max()) {
return false;
}
*result = static_cast<int>(parsed);
return true;
} catch (...) {
return false;
}
}
bool TryParseInlinePatch(const std::string& value, int* result) {
if (value.size() > kMaxInlinePatchDigits) {
return false;
}
return TryParseNonNegativeInt(value, result);
}
size_t FindNumericStart(const std::string& version) {
size_t start = 0;
while (start < version.size() && !IsDigit(version[start])) {
start++;
}
return start;
}
size_t FindNumericEnd(const std::string& version, size_t start) {
size_t end = start;
while (end < version.size() &&
(IsDigit(version[end]) || version[end] == '.')) {
end++;
}
return end;
}
bool HasDigitBoundary(const std::string& value, size_t pos, size_t len) {
const bool before_ok = pos == 0 || !IsDigit(value[pos - 1]);
const size_t end = pos + len;
const bool after_ok = end >= value.size() || !IsDigit(value[end]);
return before_ok && after_ok;
}
bool IsCompactDateAt(const std::string& value, size_t pos) {
if (pos + 8 > value.size() || !HasDigitBoundary(value, pos, 8)) {
return false;
}
for (size_t i = 0; i < 8; ++i) {
if (!IsDigit(value[pos + i])) {
return false;
}
}
return true;
}
std::string CompactDateToIso(const std::string& compact_date) {
return compact_date.substr(0, 4) + "-" + compact_date.substr(4, 2) + "-" +
compact_date.substr(6, 2);
}
bool ExtractDateFromText(const std::string& value,
std::string* date,
size_t* date_end) {
for (size_t i = 0; i < value.size(); ++i) {
if (IsCompactDateAt(value, i)) {
*date = CompactDateToIso(value.substr(i, 8));
*date_end = i + 8;
return true;
}
}
return false;
}
ParsedVersion ParseVersion(const std::string& version) {
const size_t numeric_start = FindNumericStart(version);
const size_t numeric_end = FindNumericEnd(version, numeric_start);
ParsedVersion parsed;
parsed.numbers = SplitVersion(version.substr(numeric_start,
numeric_end - numeric_start));
const std::string suffix = version.substr(numeric_end);
size_t pos = 0;
while (pos < suffix.size()) {
while (pos < suffix.size() && !IsAlphaNumeric(suffix[pos])) {
pos++;
}
const size_t token_start = pos;
while (pos < suffix.size() && IsAlphaNumeric(suffix[pos])) {
pos++;
}
if (token_start == pos) {
continue;
}
const std::string token = suffix.substr(token_start, pos - token_start);
if (parsed.date.empty() && IsCompactDateAt(token, 0)) {
parsed.date = CompactDateToIso(token);
continue;
}
int patch = 0;
if (!parsed.has_patch && TryParseInlinePatch(token, &patch)) {
parsed.has_patch = true;
parsed.patch = patch;
}
}
return parsed;
}
int CompareNumericVersion(const std::vector<int>& current,
const std::vector<int>& latest) {
std::vector<int> current_parts = current;
std::vector<int> latest_parts = latest;
const size_t len = std::max(current_parts.size(), latest_parts.size());
current_parts.resize(len, 0);
latest_parts.resize(len, 0);
for (size_t i = 0; i < len; ++i) {
if (latest_parts[i] > current_parts[i]) {
return 1;
}
if (latest_parts[i] < current_parts[i]) {
return -1;
}
}
return 0;
}
bool ReadPatchField(const nlohmann::json& json, int* patch) {
if (!json.contains("patch")) {
return false;
}
const auto& patch_value = json["patch"];
if (patch_value.is_number_unsigned()) {
const auto parsed = patch_value.get<unsigned long long>();
if (parsed > static_cast<unsigned long long>(std::numeric_limits<int>::max())) {
return false;
}
*patch = static_cast<int>(parsed);
return true;
}
if (patch_value.is_number_integer()) {
const long long parsed = patch_value.get<long long>();
if (parsed < 0 || parsed > std::numeric_limits<int>::max()) {
return false;
}
*patch = static_cast<int>(parsed);
return true;
}
if (patch_value.is_string()) {
return TryParseNonNegativeInt(patch_value.get<std::string>(), patch);
}
return false;
}
void LogHttpError(const httplib::Result& result) {
LOG_WARN("Failed to fetch version.json: error={}, message={}",
static_cast<int>(result.error()), httplib::to_string(result.error()));
@@ -310,125 +101,6 @@ void ConfigureLinuxCaCerts(httplib::Client* cli) {
} // namespace
std::string ExtractNumericPart(const std::string& ver) {
const size_t start = FindNumericStart(ver);
const size_t end = FindNumericEnd(ver, start);
return ver.substr(start, end - start);
}
std::vector<int> SplitVersion(const std::string& ver) {
std::vector<int> nums;
std::istringstream ss(ver);
std::string token;
while (std::getline(ss, token, '.')) {
try {
nums.push_back(std::stoi(token));
} catch (...) {
nums.push_back(0);
}
}
return nums;
}
// extract date from version string (format: v1.2.3-20251113-abc
// or 1.2.3-20251113-abc)
std::string ExtractDateFromVersion(const std::string& version) {
const size_t numeric_start = FindNumericStart(version);
const size_t numeric_end = FindNumericEnd(version, numeric_start);
const std::string suffix = version.substr(numeric_end);
std::string date;
size_t date_end = 0;
if (ExtractDateFromText(suffix, &date, &date_end)) {
return date;
}
return "";
}
// compare two dates in YYYY-MM-DD format
bool IsNewerDate(const std::string& date1, const std::string& date2) {
if (date1.empty() || date2.empty()) return false;
// simple string comparison works for ISO date format (YYYY-MM-DD)
return date2 > date1;
}
bool IsNewerVersion(const std::string& current, const std::string& latest) {
return IsNewerVersionWithMetadata(current, latest, "", -1);
}
bool IsNewerVersionWithMetadata(const std::string& current,
const std::string& latest,
const std::string& latest_date,
int latest_patch) {
(void)latest_date;
const ParsedVersion current_version = ParseVersion(current);
const ParsedVersion latest_version = ParseVersion(latest);
const int numeric_compare =
CompareNumericVersion(current_version.numbers, latest_version.numbers);
if (numeric_compare > 0) {
return true;
}
if (numeric_compare < 0) {
return false;
}
const bool metadata_has_patch = latest_patch >= 0;
const bool latest_has_patch = metadata_has_patch || latest_version.has_patch;
if (latest_has_patch || current_version.has_patch) {
const int resolved_latest_patch =
metadata_has_patch ? latest_patch
: (latest_version.has_patch ? latest_version.patch
: 0);
const int resolved_current_patch =
current_version.has_patch ? current_version.patch : 0;
return resolved_latest_patch > resolved_current_patch;
}
return false;
}
std::optional<VersionInfo> ParseVersionInfo(const nlohmann::json& json) {
if (!json.is_object()) {
return std::nullopt;
}
const auto read_string = [&](const char* key) -> std::string {
const auto it = json.find(key);
return it != json.end() && it->is_string() ? it->get<std::string>()
: std::string{};
};
VersionInfo info;
info.version = read_string("latest_version");
if (info.version.empty()) {
info.version = read_string("version");
}
if (!info.version.empty() &&
(info.version.front() == 'v' || info.version.front() == 'V')) {
info.version.erase(0, 1);
}
// Invalid/missing version data is a failed check, never "up to date".
const auto numeric_end = FindNumericEnd(info.version, 0);
if (numeric_end == 0 || info.version[numeric_end - 1] == '.' ||
(numeric_end < info.version.size() &&
(numeric_end + 1 == info.version.size() ||
(info.version[numeric_end] != '-' && info.version[numeric_end] != '+')))) {
return std::nullopt;
}
std::istringstream parts(info.version.substr(0, numeric_end));
std::string part;
int number = 0;
while (std::getline(parts, part, '.')) {
if (!TryParseNonNegativeInt(part, &number)) {
return std::nullopt;
}
}
info.release_name = read_string("releaseName");
info.release_notes = read_string("releaseNotes");
info.release_date = read_string("releaseDate");
ReadPatchField(json, &info.patch);
return info;
}
std::optional<VersionInfo> CheckUpdate() {
httplib::Client cli("https://version.crossdesk.cn");
@@ -8,30 +8,13 @@
#define _VERSION_CHECKER_H_
#include <nlohmann/json_fwd.hpp>
#include <optional>
#include <string>
#include <app_version.h>
namespace crossdesk {
struct VersionInfo {
std::string version;
std::string release_name;
std::string release_notes;
std::string release_date;
int patch = -1;
};
std::optional<VersionInfo> ParseVersionInfo(const nlohmann::json& json);
std::optional<VersionInfo> CheckUpdate();
bool IsNewerVersion(const std::string& current, const std::string& latest);
// Pass latest_patch < 0 when patch metadata is unavailable.
bool IsNewerVersionWithMetadata(const std::string& current,
const std::string& latest,
const std::string& latest_date,
int latest_patch);
} // namespace crossdesk
#endif