Skip to main content

vtcode_desktop_protocol/
update.rs

1//! Cached updater-status DTOs.
2
3use serde::{Deserialize, Serialize};
4
5/// Cached updater status for the desktop client. The service owns update
6/// policy (channel, pin, check interval); this snapshot never triggers
7/// network I/O, it only reports the last cached check.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct UpdateStatusSnapshot {
11    /// Version of the running service binary.
12    pub current_version: String,
13    /// Newest release the service's update cache knows about that is *newer*
14    /// than `current_version`; clients read its notes. Never the running or an
15    /// older release, so a reader row cannot label a back-version "latest".
16    /// Only [`Self::update_available`] means an update is actually offered (a
17    /// dismissed update keeps its notes readable while this stays `true`).
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub latest_version: Option<String>,
20    /// Whether `latest_version` is newer than `current_version` and not dismissed.
21    #[serde(default)]
22    pub update_available: bool,
23    /// HTTPS release page for `latest_version`, when known.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub release_url: Option<String>,
26    /// Parsed `### Highlights` bullets for `latest_version`, taken from the
27    /// cached release notes. Empty when the cache holds notes without a
28    /// Highlights section; clients render a link-only state then.
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub release_highlights: Vec<String>,
31    /// Parsed `### Highlights` bullets for the running `current_version`,
32    /// for the post-update "what's new" moment. Empty unless the cache holds
33    /// notes describing exactly the running version.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub current_release_highlights: Vec<String>,
36    /// HTTPS release page for the running version, sent alongside
37    /// `current_release_highlights` so the "what's new" viewer can link out.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub current_release_url: Option<String>,
40    /// Whether the running version's notes were already announced by any VT
41    /// Code surface. The marker lives in the shared update cache, so notes
42    /// shown by the CLI are not repeated by the desktop app and vice versa.
43    #[serde(default)]
44    pub current_release_seen: bool,
45    /// Release channel the service follows (stable/beta/nightly).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub channel: Option<String>,
48    /// Whether the configured check interval has elapsed since the last check.
49    #[serde(default)]
50    pub check_due: bool,
51    /// State of an in-app update install. The service owns install policy and
52    /// the download itself; clients render this and never derive progress from
53    /// anything else, so a re-render cannot disagree with the install.
54    #[serde(default)]
55    pub install: UpdateInstallState,
56}
57
58/// Phase of an in-app update install, carried by [`UpdateStatusSnapshot`] and
59/// the `update/installProgress` notification.
60///
61/// Internally tagged so the wire form names its own phase
62/// (`{"phase":"downloading","downloaded":10,"total":100}`), which keeps a
63/// client from mislabeling a phase it did not expect.
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase", tag = "phase", rename_all_fields = "camelCase")]
66pub enum UpdateInstallState {
67    /// No install has run, or the last one was superseded by a newer check.
68    #[default]
69    Idle,
70    /// Downloading the release archive.
71    Downloading {
72        /// Bytes written so far.
73        downloaded: u64,
74        /// Total archive size when the server sent `Content-Length`; `None`
75        /// otherwise, which renders as a byte count rather than a percentage.
76        total: Option<u64>,
77    },
78    /// Checksum metadata resolved; hashing the downloaded archive.
79    Verifying,
80    /// Unpacking the archive and staging the new bundle.
81    Extracting,
82    /// Replacing the installed bundle on disk.
83    Installing,
84    /// The install finished.
85    Completed {
86        /// Version now on disk.
87        version: String,
88        /// The replaced bundle needs a one-time Gatekeeper approval before
89        /// macOS launches it (ad hoc-signed builds): the client must say so
90        /// instead of only prompting a restart. `#[serde(default)]` keeps
91        /// older services decodable.
92        #[serde(default)]
93        needs_gatekeeper_approval: bool,
94    },
95    /// The install failed.
96    Failed {
97        /// Sanitized reason; never a raw path or URL.
98        message: String,
99    },
100}
101
102impl UpdateInstallState {
103    /// Whether an install is currently in flight, so a client can disable the
104    /// install action instead of starting a second concurrent one.
105    pub fn is_in_flight(&self) -> bool {
106        matches!(self, Self::Downloading { .. } | Self::Verifying | Self::Extracting | Self::Installing)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn update_status_snapshot_round_trips_and_older_payloads_decode() {
116        let snapshot = UpdateStatusSnapshot {
117            current_version: "0.180.5".into(),
118            latest_version: Some("0.181.0".into()),
119            update_available: true,
120            release_url: Some("https://github.com/vinhnx/vtcode/releases/tag/v0.181.0".into()),
121            release_highlights: vec!["Faster startup".into(), "New model picker".into()],
122            current_release_highlights: vec!["Older highlight".into()],
123            current_release_url: Some("https://github.com/vinhnx/vtcode/releases/tag/v0.180.5".into()),
124            current_release_seen: true,
125            channel: Some("stable".into()),
126            check_due: false,
127            install: UpdateInstallState::Downloading { downloaded: 1024, total: Some(2048) },
128        };
129        let decoded: UpdateStatusSnapshot = serde_json::from_value(serde_json::to_value(&snapshot).unwrap()).unwrap();
130        assert_eq!(decoded, snapshot);
131
132        // Asymmetric fixtures: the post-update shape carries current-version
133        // notes with no update available, and a legacy payload carries only
134        // the running version.
135        let quiet = UpdateStatusSnapshot {
136            current_version: "0.181.0".into(),
137            latest_version: None,
138            update_available: false,
139            release_url: None,
140            release_highlights: Vec::new(),
141            current_release_highlights: vec!["Freshly installed".into()],
142            current_release_url: Some("https://github.com/vinhnx/vtcode/releases/tag/v0.181.0".into()),
143            current_release_seen: false,
144            channel: None,
145            check_due: true,
146            install: UpdateInstallState::Idle,
147        };
148        let decoded_quiet: UpdateStatusSnapshot =
149            serde_json::from_value(serde_json::to_value(&quiet).unwrap()).unwrap();
150        assert_eq!(decoded_quiet, quiet);
151
152        let legacy: UpdateStatusSnapshot =
153            serde_json::from_value(serde_json::json!({ "currentVersion": "0.179.0" })).unwrap();
154        assert_eq!(legacy.current_version, "0.179.0");
155        assert!(legacy.latest_version.is_none());
156        assert!(!legacy.update_available);
157        assert!(legacy.release_url.is_none());
158        assert!(legacy.release_highlights.is_empty());
159        assert!(legacy.current_release_highlights.is_empty());
160        assert!(legacy.current_release_url.is_none());
161        assert!(!legacy.current_release_seen);
162        assert!(legacy.channel.is_none());
163        assert!(!legacy.check_due);
164        assert_eq!(legacy.install, UpdateInstallState::Idle);
165    }
166
167    /// The install state is internally tagged, so the wire form names its own
168    /// phase and a client cannot mislabel one it did not expect.
169    #[test]
170    fn install_state_round_trips_with_a_named_phase() {
171        let states = [
172            UpdateInstallState::Idle,
173            UpdateInstallState::Downloading { downloaded: 1846, total: Some(18462109) },
174            // Asymmetric input: a missing Content-Length must survive as null
175            // rather than collapsing to a zero total that renders 0%.
176            UpdateInstallState::Downloading { downloaded: 4096, total: None },
177            UpdateInstallState::Verifying,
178            UpdateInstallState::Extracting,
179            UpdateInstallState::Installing,
180            UpdateInstallState::Completed {
181                version: "0.181.8".into(),
182                needs_gatekeeper_approval: false,
183            },
184            UpdateInstallState::Failed { message: "network unreachable".into() },
185        ];
186
187        for state in states {
188            let encoded = serde_json::to_value(&state).unwrap();
189            let decoded: UpdateInstallState = serde_json::from_value(encoded.clone()).unwrap();
190            assert_eq!(decoded, state);
191            assert!(encoded.get("phase").is_some_and(|phase| phase.is_string()), "{encoded}");
192        }
193
194        // An ad hoc-signed bundle needs its approval flag to survive the wire.
195        let gated = UpdateInstallState::Completed {
196            version: "0.181.8".into(),
197            needs_gatekeeper_approval: true,
198        };
199        let decoded: UpdateInstallState = serde_json::from_value(serde_json::to_value(&gated).unwrap()).unwrap();
200        assert_eq!(decoded, gated);
201
202        // Older services send no approval flag at all; that must read as a
203        // build that needs no extra step, not as a decode failure.
204        let legacy: UpdateInstallState =
205            serde_json::from_value(serde_json::json!({ "phase": "completed", "version": "0.181.8" })).unwrap();
206        assert_eq!(
207            legacy,
208            UpdateInstallState::Completed {
209                version: "0.181.8".into(),
210                needs_gatekeeper_approval: false
211            }
212        );
213
214        assert_eq!(
215            serde_json::to_value(UpdateInstallState::Downloading { downloaded: 7, total: None }).unwrap(),
216            serde_json::json!({ "phase": "downloading", "downloaded": 7, "total": null })
217        );
218    }
219
220    /// An older service sends no install field at all; a newer client must
221    /// read that as "nothing running" rather than failing to decode.
222    #[test]
223    fn a_payload_without_install_decodes_as_idle() {
224        let decoded: UpdateStatusSnapshot =
225            serde_json::from_value(serde_json::json!({ "currentVersion": "0.181.7" })).unwrap();
226
227        assert_eq!(decoded.install, UpdateInstallState::Idle);
228    }
229
230    #[test]
231    fn only_active_phases_report_an_install_in_flight() {
232        assert!(!UpdateInstallState::Idle.is_in_flight());
233        assert!(
234            !UpdateInstallState::Completed {
235                version: "0.181.8".into(),
236                needs_gatekeeper_approval: true
237            }
238            .is_in_flight()
239        );
240        assert!(!UpdateInstallState::Failed { message: "boom".into() }.is_in_flight());
241        assert!(UpdateInstallState::Downloading { downloaded: 1, total: Some(2) }.is_in_flight());
242        assert!(UpdateInstallState::Verifying.is_in_flight());
243        assert!(UpdateInstallState::Extracting.is_in_flight());
244        assert!(UpdateInstallState::Installing.is_in_flight());
245    }
246}