mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-18 09:53:05 +08:00
[feat] add remote host version checks and update prompts
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-28
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _APP_VERSION_H_
|
||||
#define _APP_VERSION_H_
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct VersionInfo {
|
||||
std::string version;
|
||||
std::string release_name;
|
||||
std::string release_notes;
|
||||
std::string release_date;
|
||||
int patch = -1;
|
||||
};
|
||||
|
||||
// Shared desktop release parsing and comparison for desktop and iOS controllers.
|
||||
std::optional<VersionInfo> ParseVersionInfoJSON(std::string_view json);
|
||||
bool IsValidAppVersion(const std::string& version);
|
||||
|
||||
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);
|
||||
|
||||
// Returns the latest release label when the peer is older. Missing or invalid
|
||||
// peer versions are treated as legacy; the latest release must still be valid.
|
||||
std::optional<std::string> AvailableAppUpdate(const std::string& current,
|
||||
const VersionInfo& latest);
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -119,6 +119,8 @@ struct CursorState {
|
||||
struct HostInfo {
|
||||
char host_name[64];
|
||||
std::size_t host_name_size;
|
||||
// Optional; an empty string means the peer version is unknown.
|
||||
char app_version[64];
|
||||
char** display_list;
|
||||
std::size_t display_num;
|
||||
int* left;
|
||||
@@ -213,9 +215,11 @@ struct HostDisplay {
|
||||
// heap-allocated with the same layout the decoder produces; release the
|
||||
// result with FreeRemoteAction. An allocation failure yields an empty display
|
||||
// list rather than a partially filled one.
|
||||
// Missing or oversized app versions are advertised as unknown (empty).
|
||||
RemoteAction MakeHostInformation(const std::string& host_name,
|
||||
const std::vector<HostDisplay>& displays,
|
||||
bool supports_privacy_screen);
|
||||
bool supports_privacy_screen,
|
||||
const std::string& app_version = {});
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
#include <app_version.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
} // 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> ParseVersionInfoJSON(std::string_view json) {
|
||||
try {
|
||||
return ParseVersionInfo(nlohmann::json::parse(json));
|
||||
} catch (const nlohmann::json::exception&) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsValidAppVersion(const std::string& version) {
|
||||
return ParseVersionInfo(nlohmann::json{{"version", version}}).has_value();
|
||||
}
|
||||
|
||||
std::optional<std::string> AvailableAppUpdate(const std::string& current,
|
||||
const VersionInfo& latest) {
|
||||
if (!IsValidAppVersion(latest.version) ||
|
||||
(IsValidAppVersion(current) &&
|
||||
!IsNewerVersionWithMetadata(current, latest.version,
|
||||
latest.release_date, latest.patch))) {
|
||||
return std::nullopt;
|
||||
}
|
||||
// Include the effective patch even when it exists only in release metadata.
|
||||
// Build dates and hashes do not participate in release ordering.
|
||||
const auto parsed = ParseVersion(latest.version);
|
||||
std::string label = ExtractNumericPart(latest.version);
|
||||
const int patch = latest.patch >= 0 ? latest.patch : parsed.patch;
|
||||
if (patch > 0) label += "-" + std::to_string(patch);
|
||||
return label;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -16,6 +16,7 @@ using json = nlohmann::json;
|
||||
void ResetHostInfo(HostInfo& info) {
|
||||
info.host_name[0] = '\0';
|
||||
info.host_name_size = 0;
|
||||
info.app_version[0] = '\0';
|
||||
info.display_list = nullptr;
|
||||
info.display_num = 0;
|
||||
info.left = nullptr;
|
||||
@@ -50,7 +51,8 @@ char* DuplicateString(const std::string& value) {
|
||||
|
||||
RemoteAction MakeHostInformation(const std::string& host_name,
|
||||
const std::vector<HostDisplay>& displays,
|
||||
bool supports_privacy_screen) {
|
||||
bool supports_privacy_screen,
|
||||
const std::string& app_version) {
|
||||
RemoteAction action{};
|
||||
action.type = ControlType::host_infomation;
|
||||
ResetHostInfo(action.i);
|
||||
@@ -76,6 +78,11 @@ RemoteAction MakeHostInformation(const std::string& host_name,
|
||||
std::memcpy(action.i.host_name, host_name.data(), name_size);
|
||||
action.i.host_name[name_size] = '\0';
|
||||
action.i.host_name_size = name_size;
|
||||
// Never advertise a truncated version as a different, valid release.
|
||||
if (app_version.size() < sizeof(action.i.app_version) &&
|
||||
app_version.find('\0') == std::string::npos) {
|
||||
std::memcpy(action.i.app_version, app_version.c_str(), app_version.size() + 1);
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
@@ -184,6 +191,7 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
|
||||
}
|
||||
object["host_info"] = {
|
||||
{"host_name", action.i.host_name},
|
||||
{"app_version", action.i.app_version},
|
||||
{"display_num", action.i.display_num},
|
||||
{"displays", displays},
|
||||
{"supports_privacy_screen", action.i.supports_privacy_screen},
|
||||
@@ -360,6 +368,14 @@ bool RemoteAction::FromJson(const std::string& json_string,
|
||||
ResetHostInfo(output.i);
|
||||
owns_host_info = true;
|
||||
const auto& host_info_object = object.at("host_info");
|
||||
const auto app_version = host_info_object.find("app_version");
|
||||
if (app_version != host_info_object.end() && app_version->is_string()) {
|
||||
const auto& version = app_version->get_ref<const std::string&>();
|
||||
if (version.size() < sizeof(output.i.app_version) &&
|
||||
version.find('\0') == std::string::npos) {
|
||||
std::memcpy(output.i.app_version, version.c_str(), version.size() + 1);
|
||||
}
|
||||
}
|
||||
const auto privacy_support =
|
||||
host_info_object.find("supports_privacy_screen");
|
||||
output.i.supports_privacy_screen =
|
||||
|
||||
@@ -134,6 +134,7 @@ int main() {
|
||||
crossdesk::RemoteAction host{};
|
||||
host.type = crossdesk::ControlType::host_infomation;
|
||||
std::strcpy(host.i.host_name, "Test Mac");
|
||||
std::strcpy(host.i.app_version, "1.5.3-2-20260928");
|
||||
host.i.host_name_size = std::strlen(host.i.host_name);
|
||||
host.i.display_list = display_names;
|
||||
host.i.display_num = 1;
|
||||
@@ -145,6 +146,7 @@ int main() {
|
||||
ok &= Expect(parsed_host.from_json(host.to_json()) &&
|
||||
parsed_host.i.display_num == 1 &&
|
||||
std::string(parsed_host.i.host_name) == "Test Mac" &&
|
||||
std::string(parsed_host.i.app_version) == "1.5.3-2-20260928" &&
|
||||
std::string(parsed_host.i.display_list[0]) ==
|
||||
"Built-in Display" &&
|
||||
parsed_host.i.right[0] == 2560 &&
|
||||
@@ -152,6 +154,34 @@ int main() {
|
||||
"host information JSON round trip failed");
|
||||
crossdesk::FreeRemoteAction(parsed_host);
|
||||
|
||||
auto advertised = crossdesk::MakeHostInformation("Host", {}, true,
|
||||
"1.5.3-20260928");
|
||||
ok &= Expect(parsed_host.from_json(advertised.to_json()) &&
|
||||
std::string(parsed_host.i.app_version) == "1.5.3-20260928",
|
||||
"host factory should advertise the app version");
|
||||
crossdesk::FreeRemoteAction(parsed_host);
|
||||
crossdesk::FreeRemoteAction(advertised);
|
||||
|
||||
// Missing and malformed optional versions must not break legacy host info.
|
||||
for (const auto& version_field : std::vector<std::string>{
|
||||
"", ",\"app_version\":null", ",\"app_version\":123",
|
||||
",\"app_version\":\"\"",
|
||||
",\"app_version\":\"1.5.3\\u0000unexpected\"",
|
||||
",\"app_version\":\"" + std::string(64, '1') + "\""}) {
|
||||
const auto message =
|
||||
"{\"type\":3,\"host_info\":{\"host_name\":\"Legacy\","
|
||||
"\"display_num\":0,\"displays\":[]" + version_field + "}}";
|
||||
ok &= Expect(parsed_host.from_json(message) &&
|
||||
parsed_host.i.app_version[0] == '\0',
|
||||
"unknown optional app version should remain empty");
|
||||
crossdesk::FreeRemoteAction(parsed_host);
|
||||
}
|
||||
advertised = crossdesk::MakeHostInformation("Host", {}, false,
|
||||
std::string(64, '1'));
|
||||
ok &= Expect(advertised.i.app_version[0] == '\0',
|
||||
"oversized app version must not be truncated");
|
||||
crossdesk::FreeRemoteAction(advertised);
|
||||
|
||||
const std::string name = "example.txt";
|
||||
const std::string payload = "CrossDesk wire";
|
||||
const auto encoded = crossdesk::EncodeFileChunk(
|
||||
|
||||
Reference in New Issue
Block a user