Skip to content

Commit aaf5156

Browse files
feat(agent): migrate from productinfo.htm to productinfo.json format (#1591)
Replaces the legacy flat key-value productinfo.htm format with a structured JSON format that provides better organization and extensibility. The new format supports multiple release channels (Current, Beta, Update, Stable) and includes explicit architecture and file type metadata.
1 parent 54e57d0 commit aaf5156

5 files changed

Lines changed: 145 additions & 101 deletions

File tree

devolutions-agent/src/updater/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub(crate) enum UpdaterError {
3232
AclString { acl: String },
3333
#[error("failed to set permissions for file: `{file_path}`")]
3434
SetFilePermissions { file_path: Utf8PathBuf },
35-
#[error("invalid productinfo.htm format")]
35+
#[error("invalid productinfo.json format")]
3636
ProductInfo,
3737
#[error(transparent)]
3838
WindowsRegistry(#[from] devolutions_agent_shared::windows::registry::RegistryError),
Lines changed: 92 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,46 @@
1-
//! Devolutions product information (https://devolutions.net/productinfo.htm) parser
1+
//! Devolutions product information (https://devolutions.net/productinfo.json) parser
22
3+
use serde::{Deserialize, Serialize};
34
use std::collections::HashMap;
45
use std::str::FromStr;
56

67
use crate::updater::UpdaterError;
78

9+
/// Information about a product file available for download
10+
#[derive(Debug, Clone, Deserialize, Serialize)]
11+
pub(crate) struct ProductFile {
12+
#[serde(rename = "Arch")]
13+
pub arch: String,
14+
#[serde(rename = "Type")]
15+
pub file_type: String,
16+
#[serde(rename = "Url")]
17+
pub url: String,
18+
#[serde(rename = "Hash")]
19+
pub hash: String,
20+
}
21+
22+
/// Product information for a specific channel (Current, Beta, Update, Stable)
23+
#[derive(Debug, Clone, Deserialize, Serialize)]
24+
pub(crate) struct ChannelData {
25+
#[serde(rename = "Version")]
26+
pub version: String,
27+
#[serde(rename = "Files")]
28+
pub files: Vec<ProductFile>,
29+
}
30+
31+
/// Product information containing multiple channels
32+
#[derive(Debug, Clone, Deserialize, Serialize)]
33+
pub(crate) struct ProductData {
34+
#[serde(rename = "Current")]
35+
pub current: Option<ChannelData>,
36+
#[serde(rename = "Beta")]
37+
pub beta: Option<ChannelData>,
38+
#[serde(rename = "Update")]
39+
pub update: Option<ChannelData>,
40+
#[serde(rename = "Stable")]
41+
pub stable: Option<ChannelData>,
42+
}
43+
844
#[derive(Debug, Clone, Default)]
945
pub(crate) struct ProductInfo {
1046
pub version: String,
@@ -16,32 +52,61 @@ pub(crate) struct ProductInfoDb {
1652
pub records: HashMap<String, ProductInfo>,
1753
}
1854

55+
/// Determine the target architecture at compile time or runtime, defaulting to x64
56+
fn get_target_arch() -> String {
57+
if cfg!(target_arch = "x86_64") {
58+
"x64".to_owned()
59+
} else if cfg!(target_arch = "aarch64") {
60+
"arm64".to_owned()
61+
} else {
62+
// Runtime fallback: check the environment, default to x64
63+
match std::env::consts::ARCH {
64+
"x86_64" => "x64".to_owned(),
65+
"aarch64" => "arm64".to_owned(),
66+
_ => "x64".to_owned(), // Default to x64 for unknown architectures
67+
}
68+
}
69+
}
70+
71+
/// Select a file from the product files matching the target architecture and type
72+
fn select_file(files: &[ProductFile], target_arch: &str, file_type: &str) -> Option<ProductFile> {
73+
files
74+
.iter()
75+
.find(|f| f.arch == target_arch && f.file_type == file_type)
76+
.cloned()
77+
}
78+
1979
impl FromStr for ProductInfoDb {
2080
type Err = UpdaterError;
2181

2282
fn from_str(s: &str) -> Result<Self, Self::Err> {
83+
// Parse the JSON content
84+
let json: serde_json::Value = serde_json::from_str(s).map_err(|_| UpdaterError::ProductInfo)?;
85+
2386
let mut records = HashMap::new();
87+
let target_arch = get_target_arch();
2488

25-
for line in s.lines() {
26-
if line.is_empty() {
27-
continue;
28-
}
89+
// Iterate through products in the JSON object
90+
if let Some(obj) = json.as_object() {
91+
for (product_name, product_value) in obj {
92+
// Try to deserialize the product data
93+
let product_data: ProductData =
94+
serde_json::from_value(product_value.clone()).map_err(|_| UpdaterError::ProductInfo)?;
2995

30-
let (key, value) = line.split_once('=').ok_or(UpdaterError::ProductInfo)?;
31-
let (product_id, property) = key.split_once('.').ok_or(UpdaterError::ProductInfo)?;
32-
33-
let entry = records
34-
.entry(product_id.to_owned())
35-
.or_insert_with(ProductInfo::default);
36-
37-
match property {
38-
"Version" => entry.version = value.to_owned(),
39-
"Url" => entry.url = value.to_owned(),
40-
"hash" => entry.hash = Some(value.to_owned()),
41-
_ => {
42-
trace!(%product_id, %property, "Unknown productinfo property");
43-
continue;
44-
}
96+
// Use Current channel for now (as specified)
97+
let channel = product_data.current.ok_or(UpdaterError::ProductInfo)?;
98+
99+
// Select the appropriate file based on architecture and type (msi)
100+
let selected_file =
101+
select_file(&channel.files, &target_arch, "msi").ok_or(UpdaterError::ProductInfo)?;
102+
103+
let product_info = ProductInfo {
104+
version: channel.version.clone(),
105+
hash: Some(selected_file.hash.clone()),
106+
url: selected_file.url.clone(),
107+
};
108+
109+
records.insert(product_name.clone(), product_info);
45110
}
46111
}
47112

@@ -65,63 +130,24 @@ mod tests {
65130
let input = include_str!("../../../test_assets/test_asset_db");
66131
let db: ProductInfoDb = input.parse().expect("failed to parse product info database");
67132

68-
assert_eq!(db.get("Gatewaybin").expect("product not found").version, "2024.2.1.0");
133+
assert_eq!(db.get("Gateway").expect("product not found").version, "2024.2.1.0");
69134
assert_eq!(
70-
db.get("Gatewaybin").expect("product not found").url,
135+
db.get("Gateway").expect("product not found").url,
71136
"https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi"
72137
);
73138
assert_eq!(
74-
db.get("Gatewaybin").expect("product not found").hash.as_deref(),
139+
db.get("Gateway").expect("product not found").hash.as_deref(),
75140
Some("BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35")
76141
);
77142

143+
assert_eq!(db.get("HubServices").expect("product not found").version, "2024.2.1.0");
78144
assert_eq!(
79-
db.get("GatewaybinBeta").expect("product not found").version,
80-
"2024.2.1.0"
145+
db.get("HubServices").expect("product not found").url,
146+
"https://cdn.devolutions.net/download/HubServices-x86_64-2024.2.1.0.msi"
81147
);
82148
assert_eq!(
83-
db.get("GatewaybinBeta").expect("product not found").url,
84-
"https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi"
85-
);
86-
assert_eq!(
87-
db.get("GatewaybinBeta").expect("product not found").hash.as_deref(),
88-
Some("BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35")
89-
);
90-
91-
assert_eq!(
92-
db.get("GatewaybinDebX64").expect("product not found").version,
93-
"2024.2.1.0"
94-
);
95-
assert_eq!(
96-
db.get("GatewaybinDebX64").expect("product not found").url,
97-
"https://cdn.devolutions.net/download/devolutions-gateway_2024.2.1.0_amd64.deb"
98-
);
99-
assert_eq!(
100-
db.get("GatewaybinDebX64").expect("product not found").hash.as_deref(),
101-
Some("72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA")
102-
);
103-
104-
assert_eq!(
105-
db.get("GatewaybinDebX64Beta").expect("product not found").version,
106-
"2024.2.1.0"
107-
);
108-
assert_eq!(
109-
db.get("GatewaybinDebX64Beta").expect("product not found").url,
110-
"https://cdn.devolutions.net/download/devolutions-gateway_2024.2.1.0_amd64.deb"
111-
);
112-
assert_eq!(
113-
db.get("GatewaybinDebX64Beta")
114-
.expect("product not found")
115-
.hash
116-
.as_deref(),
149+
db.get("HubServices").expect("product not found").hash.as_deref(),
117150
Some("72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA")
118151
);
119-
120-
assert_eq!(db.get("DevoCLIbin").expect("product not found").version, "2023.3.0.0");
121-
assert_eq!(
122-
db.get("DevoCLIbin").expect("product not found").url,
123-
"https://cdn.devolutions.net/download/DevoCLI.2023.3.0.0.zip"
124-
);
125-
assert_eq!(db.get("DevoCLIbin").expect("product not found").hash, None);
126152
}
127153
}
Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
mod db;
22

3-
pub(crate) const DEVOLUTIONS_PRODUCTINFO_URL: &str = "https://devolutions.net/productinfo.htm";
3+
pub(crate) const DEVOLUTIONS_PRODUCTINFO_URL: &str = "https://devolutions.net/productinfo.json";
44

5-
#[cfg(windows)]
6-
pub(crate) const GATEWAY_PRODUCT_ID: &str = "Gatewaybin";
7-
#[cfg(not(windows))]
8-
pub(crate) const GATEWAY_PRODUCT_ID: &str = "GatewaybinDebX64";
5+
pub(crate) const GATEWAY_PRODUCT_ID: &str = "Gateway";
96

10-
pub(crate) const HUB_SERVICE_PRODUCT_ID: &str = "HubServicesbin";
7+
pub(crate) const HUB_SERVICE_PRODUCT_ID: &str = "HubServices";
118

129
pub(crate) use db::ProductInfoDb;
Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,40 @@
1-
Gatewaybin.Version=2024.2.1.0
2-
Gatewaybin.Url=https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi
3-
Gatewaybin.hash=BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35
4-
GatewaybinBeta.Version=2024.2.1.0
5-
GatewaybinBeta.Url=https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi
6-
GatewaybinBeta.hash=BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35
7-
GatewaybinDebX64.Version=2024.2.1.0
8-
GatewaybinDebX64.Url=https://cdn.devolutions.net/download/devolutions-gateway_2024.2.1.0_amd64.deb
9-
GatewaybinDebX64.hash=72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA
10-
GatewaybinDebX64Beta.Version=2024.2.1.0
11-
GatewaybinDebX64Beta.Url=https://cdn.devolutions.net/download/devolutions-gateway_2024.2.1.0_amd64.deb
12-
GatewaybinDebX64Beta.hash=72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA
13-
14-
DevoCLIbin.Version=2023.3.0.0
15-
DevoCLIbin.Url=https://cdn.devolutions.net/download/DevoCLI.2023.3.0.0.zip
16-
DevoCLIbinBeta.Version=2023.3.0.0
17-
DevoCLIBeta.Url=https://cdn.devolutions.net/download/DevoCLI.2023.3.0.0.zip
1+
{
2+
"Gateway": {
3+
"Current": {
4+
"Version": "2024.2.1.0",
5+
"Files": [
6+
{
7+
"Arch": "x64",
8+
"Type": "msi",
9+
"Url": "https://cdn.devolutions.net/download/DevolutionsGateway-x86_64-2024.2.1.0.msi",
10+
"Hash": "BD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35"
11+
},
12+
{
13+
"Arch": "arm64",
14+
"Type": "msi",
15+
"Url": "https://cdn.devolutions.net/download/DevolutionsGateway-arm64-2024.2.1.0.msi",
16+
"Hash": "CD2805075FCD78AC339126F4C4D9E6773DC3127CBE7DF48256D6910FA0C59C35"
17+
}
18+
]
19+
}
20+
},
21+
"HubServices": {
22+
"Current": {
23+
"Version": "2024.2.1.0",
24+
"Files": [
25+
{
26+
"Arch": "x64",
27+
"Type": "msi",
28+
"Url": "https://cdn.devolutions.net/download/HubServices-x86_64-2024.2.1.0.msi",
29+
"Hash": "72D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA"
30+
},
31+
{
32+
"Arch": "arm64",
33+
"Type": "msi",
34+
"Url": "https://cdn.devolutions.net/download/HubServices-arm64-2024.2.1.0.msi",
35+
"Hash": "82D7A836A6AF221D4E7631D27B91A358915CF985AA544CC0F7F5612B85E989AA"
36+
}
37+
]
38+
}
39+
}
40+
}

tools/updater/GatewayUpdater.ps1

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,10 @@ function Get-DGatewayPackageLocation
8686
)
8787

8888
$VersionQuad = '';
89-
$ProductsUrl = "https://devolutions.net/productinfo.htm"
89+
$ProductsUrl = "https://devolutions.net/productinfo.json"
9090

91-
$ProductsHtm = Invoke-RestMethod -Uri $ProductsUrl -Method 'GET' -ContentType 'text/plain'
92-
$VersionMatches = $($ProductsHtm | Select-String -AllMatches -Pattern "Gatewaybin.Version=(\S+)").Matches
93-
$LatestVersion = $VersionMatches.Groups[1].Value
91+
$ProductsJson = Invoke-RestMethod -Uri $ProductsUrl -Method 'GET' -ContentType 'application/json'
92+
$LatestVersion = $ProductsJson.Gateway.Current.Version
9493

9594
if ($RequiredVersion) {
9695
if ($RequiredVersion -Match "^\d+`.\d+`.\d+$") {
@@ -107,14 +106,13 @@ function Get-DGatewayPackageLocation
107106
$VersionPatch = $VersionMatches.Groups[3].Value
108107
$VersionTriple = "${VersionMajor}.${VersionMinor}.${VersionPatch}"
109108

110-
$GatewayUrlMatches = $($ProductsHtm | Select-String -AllMatches -Pattern "(Gateway\S+).Url=(\S+)").Matches
111-
$GatewayHashMatches = $($ProductsHtm | Select-String -AllMatches -Pattern "(Gateway\S+).hash=(\S+)").Matches
112-
$GatewayMsiUrl = $GatewayUrlMatches | Where-Object { $_.Groups[1].Value -eq 'Gatewaybin' }
113-
$GatewayMsiHash = $GatewayHashMatches | Where-Object { $_.Groups[1].Value -eq 'Gatewaybin' }
109+
# Find the MSI file for the current architecture
110+
$CurrentArchitecture = if ([Environment]::Is64BitProcess) { "x64" } else { "arm64" }
111+
$GatewayMsiFile = $ProductsJson.Gateway.Current.Files | Where-Object { $_.Type -eq 'msi' -and $_.Arch -eq $CurrentArchitecture } | Select-Object -First 1
114112

115-
if ($GatewayMsiUrl) {
116-
$DownloadUrl = $GatewayMsiUrl.Groups[2].Value
117-
$DownloadHash = $GatewayMsiHash.Groups[2].Value
113+
if ($GatewayMsiFile) {
114+
$DownloadUrl = $GatewayMsiFile.Url
115+
$DownloadHash = $GatewayMsiFile.Hash
118116
}
119117

120118
if ($RequiredVersion) {

0 commit comments

Comments
 (0)