mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Add desktop update diagnostics to codex doctor (#39074)
## What changed - Probe the installed desktop app's update endpoint on macOS and Windows and report update-CDN reachability alongside the existing network diagnostics. - Report newer Windows Store builds and macOS updates that Sparkle has staged for installation in the updates check and human-readable notes. - Validate Windows update manifests against the production app identity, and use the persisted production appcast configuration when selecting the macOS feed. ## Testing - Cover Windows version comparison and manifest identity validation. - Cover macOS appcast selection and staged-bundle discovery. - Verify the human-readable desktop update note. GitOrigin-RevId: 1af5aa750144346b4b31f2b27a20371daf40d3c0
This commit is contained in:
@@ -543,6 +543,11 @@ async fn build_report(
|
||||
|
||||
progress.begin("desktop");
|
||||
if let Some(desktop) = desktop::collect().await {
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
if let Some(application) = desktop.application.as_ref() {
|
||||
updates::append_desktop_update(&mut checks, config_result.as_ref().ok(), application)
|
||||
.await;
|
||||
}
|
||||
progress.finish("desktop", overall_status(&desktop.checks));
|
||||
checks.extend(desktop.checks);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::DoctorCheck;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_security;
|
||||
mod platform;
|
||||
pub(super) mod platform;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod windows_security;
|
||||
|
||||
@@ -28,6 +28,8 @@ const HANDSHAKE_CHECK_ID: &str = "desktop.app_server.handshake";
|
||||
|
||||
pub(super) struct DesktopDiagnostics {
|
||||
pub(super) checks: Vec<DoctorCheck>,
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub(super) application: Option<platform::InstalledApp>,
|
||||
}
|
||||
|
||||
struct DesktopLog {
|
||||
@@ -67,6 +69,8 @@ pub(super) async fn collect() -> Option<DesktopDiagnostics> {
|
||||
#[cfg(target_os = "windows")]
|
||||
windows_security::collect().await,
|
||||
],
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
application: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -100,6 +104,8 @@ pub(super) async fn collect() -> Option<DesktopDiagnostics> {
|
||||
#[cfg(target_os = "windows")]
|
||||
windows_security::collect().await,
|
||||
],
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
application: Some(application),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ use std::os::windows::io::FromRawHandle;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::io::OwnedHandle;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::path::Path;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::path::PathBuf;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Stdio;
|
||||
@@ -43,20 +45,19 @@ use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
|
||||
use super::super::CheckStatus;
|
||||
use super::super::DoctorCheck;
|
||||
|
||||
pub(super) struct InstalledApp {
|
||||
pub(super) identity: &'static str,
|
||||
pub(super) version: String,
|
||||
pub(in crate::doctor) struct InstalledApp {
|
||||
pub(in crate::doctor) identity: &'static str,
|
||||
pub(in crate::doctor) version: String,
|
||||
#[cfg(target_os = "windows")]
|
||||
package_family: &'static str,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) bundle: PathBuf,
|
||||
pub(in crate::doctor) bundle: PathBuf,
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(dead_code, reason = "used by downstream desktop diagnostics")]
|
||||
pub(super) build: u64,
|
||||
pub(in crate::doctor) build: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct DiscoveryError;
|
||||
pub(in crate::doctor) struct DiscoveryError;
|
||||
|
||||
pub(super) async fn installed_app() -> Result<Option<InstalledApp>, DiscoveryError> {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -232,42 +233,51 @@ async fn installed_macos_app() -> Result<Option<InstalledApp>, DiscoveryError> {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(_) => return Err(DiscoveryError),
|
||||
}
|
||||
|
||||
let mut command = Command::new("/usr/bin/plutil");
|
||||
command
|
||||
.args(["-convert", "json", "-o", "-"])
|
||||
.arg(bundle.join("Contents/Info.plist"))
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
let output = timeout(Duration::from_secs(5), command.output())
|
||||
.await
|
||||
.map_err(|_| DiscoveryError)?
|
||||
.map_err(|_| DiscoveryError)?;
|
||||
if !output.status.success() || output.stdout.len() > 64 * 1024 {
|
||||
return Err(DiscoveryError);
|
||||
if let Some(application) = inspect_macos_bundle(&bundle).await? {
|
||||
return Ok(Some(application));
|
||||
}
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|_| DiscoveryError)?;
|
||||
if metadata.get("CFBundleIdentifier").and_then(Value::as_str) != Some("com.openai.codex") {
|
||||
continue;
|
||||
}
|
||||
let version = metadata
|
||||
.get("CFBundleShortVersionString")
|
||||
.or_else(|| metadata.get("CFBundleVersion"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(DiscoveryError)?;
|
||||
let build = metadata
|
||||
.get("CFBundleVersion")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or(DiscoveryError)?;
|
||||
return Ok(Some(InstalledApp {
|
||||
identity: "com.openai.codex",
|
||||
version: version.to_string(),
|
||||
bundle,
|
||||
build,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(in crate::doctor) async fn inspect_macos_bundle(
|
||||
bundle: &Path,
|
||||
) -> Result<Option<InstalledApp>, DiscoveryError> {
|
||||
let mut command = Command::new("/usr/bin/plutil");
|
||||
command
|
||||
.args(["-convert", "json", "-o", "-"])
|
||||
.arg(bundle.join("Contents/Info.plist"))
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
let output = timeout(Duration::from_secs(5), command.output())
|
||||
.await
|
||||
.map_err(|_| DiscoveryError)?
|
||||
.map_err(|_| DiscoveryError)?;
|
||||
if !output.status.success() || output.stdout.len() > 64 * 1024 {
|
||||
return Err(DiscoveryError);
|
||||
}
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|_| DiscoveryError)?;
|
||||
if metadata.get("CFBundleIdentifier").and_then(Value::as_str) != Some("com.openai.codex") {
|
||||
return Ok(None);
|
||||
}
|
||||
let version = metadata
|
||||
.get("CFBundleShortVersionString")
|
||||
.or_else(|| metadata.get("CFBundleVersion"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(DiscoveryError)?;
|
||||
let build = metadata
|
||||
.get("CFBundleVersion")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or(DiscoveryError)?;
|
||||
|
||||
Ok(Some(InstalledApp {
|
||||
identity: "com.openai.codex",
|
||||
version: version.to_string(),
|
||||
bundle: bundle.to_path_buf(),
|
||||
build,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -138,23 +138,7 @@ pub(super) async fn probe_status(
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
.map_err(|error| {
|
||||
match error.failure_class() {
|
||||
Some(RouteFailureClass::TlsError) => "TLS handshake or certificate validation failed",
|
||||
Some(RouteFailureClass::ProxyAuthenticationRequired) => "proxy authentication required",
|
||||
Some(RouteFailureClass::InvalidProxyConfig) => "invalid proxy configuration",
|
||||
Some(RouteFailureClass::ProxyResolutionUnavailable) => {
|
||||
"system proxy configuration unavailable"
|
||||
}
|
||||
Some(RouteFailureClass::ConnectTimeout) => "request timed out",
|
||||
Some(RouteFailureClass::UnsupportedProxyScheme) => "unsupported proxy configuration",
|
||||
Some(RouteFailureClass::ResolverError) => "proxy resolution failed",
|
||||
None if error.is_timeout() => "request timed out",
|
||||
None if error.is_connect() => "connect failed",
|
||||
None => "request failed",
|
||||
}
|
||||
.to_string()
|
||||
})?;
|
||||
.map_err(request_error)?;
|
||||
let status = response.status().as_u16();
|
||||
if status == 407 {
|
||||
return Err("proxy authentication required (HTTP 407)".to_string());
|
||||
@@ -162,6 +146,24 @@ pub(super) async fn probe_status(
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub(super) fn request_error(error: RouteAwareRequestError) -> String {
|
||||
match error.failure_class() {
|
||||
Some(RouteFailureClass::TlsError) => "TLS handshake or certificate validation failed",
|
||||
Some(RouteFailureClass::ProxyAuthenticationRequired) => "proxy authentication required",
|
||||
Some(RouteFailureClass::InvalidProxyConfig) => "invalid proxy configuration",
|
||||
Some(RouteFailureClass::ProxyResolutionUnavailable) => {
|
||||
"system proxy configuration unavailable"
|
||||
}
|
||||
Some(RouteFailureClass::ConnectTimeout) => "request timed out",
|
||||
Some(RouteFailureClass::UnsupportedProxyScheme) => "unsupported proxy configuration",
|
||||
Some(RouteFailureClass::ResolverError) => "proxy resolution failed",
|
||||
None if error.is_timeout() => "request timed out",
|
||||
None if error.is_connect() => "connect failed",
|
||||
None => "request failed",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "network_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -499,6 +499,9 @@ fn notes_for_report(report: &DoctorReport) -> Vec<DoctorNote> {
|
||||
update_note(check, report)
|
||||
.into_iter()
|
||||
.for_each(|note| notes.push(note));
|
||||
desktop_update_note(check)
|
||||
.into_iter()
|
||||
.for_each(|note| notes.push(note));
|
||||
}
|
||||
if let Some(check) = find_check(report, "state") {
|
||||
rollout_note(check)
|
||||
@@ -548,6 +551,21 @@ fn update_note(check: &DoctorCheck, report: &DoctorReport) -> Option<DoctorNote>
|
||||
})
|
||||
}
|
||||
|
||||
fn desktop_update_note(check: &DoctorCheck) -> Option<DoctorNote> {
|
||||
let status = detail::detail_value(check, "desktop update status")?;
|
||||
let build = detail::detail_value(check, "desktop latest build")?;
|
||||
let summary = match status.as_str() {
|
||||
"ready to install" => format!("build {build} available (ready to install)"),
|
||||
"available" => format!("build {build} available"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(DoctorNote {
|
||||
status: DisplayStatus::Update,
|
||||
name: "desktop".to_string(),
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
fn rollout_note(check: &DoctorCheck) -> Option<DoctorNote> {
|
||||
let active = detail::detail_value(check, "active rollout files")?;
|
||||
let (files, bytes) = detail::rollout_files_and_bytes(&active)?;
|
||||
@@ -1350,6 +1368,21 @@ Background Server
|
||||
"the desktop app-server initialized successfully",
|
||||
),
|
||||
]);
|
||||
let update = report
|
||||
.checks
|
||||
.iter_mut()
|
||||
.find(|check| check.category == "updates")
|
||||
.unwrap();
|
||||
for (status, expected) in [
|
||||
("available", "build 123 available"),
|
||||
("ready to install", "build 123 available (ready to install)"),
|
||||
] {
|
||||
update.details = vec![
|
||||
format!("desktop update status: {status}"),
|
||||
"desktop latest build: 123".to_string(),
|
||||
];
|
||||
assert_eq!(desktop_update_note(update).unwrap().summary, expected);
|
||||
}
|
||||
insta::assert_snapshot!(
|
||||
"doctor_human_report_environment_rows",
|
||||
render_human_report(&report, detailed_no_color_unicode_options())
|
||||
|
||||
@@ -5,6 +5,7 @@ expression: "render_human_report(&report, detailed_no_color_unicode_options())"
|
||||
Codex Doctor v0.0.0
|
||||
|
||||
Notes
|
||||
↑ desktop build 123 available (ready to install)
|
||||
⚠ terminal narrow terminal
|
||||
✗ auth token expired - Run `codex login`.
|
||||
⚠ git this worktree is not on a Windows Dev Drive - create a trusted Windows Dev Drive: https://learn.microsoft.com/en-us/windows/dev-drive/
|
||||
@@ -56,6 +57,8 @@ Desktop App
|
||||
|
||||
Updates
|
||||
✓ updates update configuration is locally consistent
|
||||
desktop update status ready to install
|
||||
desktop latest build 123
|
||||
|
||||
Connectivity
|
||||
✓ network network environment readable
|
||||
@@ -66,7 +69,7 @@ Background Server
|
||||
✓ app-server background server is not running
|
||||
|
||||
─────────────────────────────────────────────────────────────
|
||||
15 ok · 4 notes · 3 warn · 1 fail failed
|
||||
15 ok · 5 notes · 3 warn · 1 fail failed
|
||||
|
||||
--summary compact output --all expand truncated lists
|
||||
--json redacted report
|
||||
|
||||
@@ -7,23 +7,50 @@
|
||||
//! an update command.
|
||||
|
||||
use std::path::Path;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::path::PathBuf;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::config::Config;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use codex_http_client::ClientRouteClass;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use codex_http_client::RouteAwareClientPool;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_install_context::InstallMethod;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use http::Method;
|
||||
use serde::Deserialize;
|
||||
#[cfg(target_os = "macos")]
|
||||
use url::Url;
|
||||
|
||||
use super::CheckStatus;
|
||||
use super::DoctorCheck;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use super::DoctorIssue;
|
||||
use super::NpmRootCheck;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use super::desktop::platform::InstalledApp;
|
||||
use super::doctor_install_context;
|
||||
use super::doctor_managed_by_npm;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use super::network;
|
||||
use super::npm_global_root_check;
|
||||
use super::run_command;
|
||||
|
||||
const VERSION_FILE_NAME: &str = "version.json";
|
||||
const GITHUB_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
|
||||
const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json";
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
const DESKTOP_UPDATE_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/appcast-x64.xml";
|
||||
#[cfg(all(target_os = "macos", not(target_arch = "x86_64")))]
|
||||
const DESKTOP_UPDATE_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml";
|
||||
#[cfg(target_os = "macos")]
|
||||
const BACKEND_DESKTOP_UPDATE_URL: &str = "https://chatgpt.com/backend-api/wham/app/appcast";
|
||||
#[cfg(target_os = "windows")]
|
||||
const DESKTOP_UPDATE_URL: &str =
|
||||
"https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json";
|
||||
|
||||
/// Builds the update-health row for the current installation.
|
||||
///
|
||||
@@ -107,6 +134,278 @@ pub(super) fn updates_check(config: &Config) -> DoctorCheck {
|
||||
check
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub(super) async fn append_desktop_update(
|
||||
checks: &mut [DoctorCheck],
|
||||
config: Option<&Config>,
|
||||
application: &InstalledApp,
|
||||
) {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
|
||||
&& let Some(build) = latest_macos_staged_build(
|
||||
&home
|
||||
.join("Library/Caches")
|
||||
.join(application.identity)
|
||||
.join("org.sparkle-project.Sparkle/Installation"),
|
||||
application.build,
|
||||
)
|
||||
.await
|
||||
&& let Some(update) = checks.iter_mut().find(|check| check.id == "updates.status")
|
||||
{
|
||||
update.details.extend([
|
||||
"desktop update status: ready to install".to_string(),
|
||||
format!("desktop latest build: {build}"),
|
||||
format!("desktop application: {}", application.identity),
|
||||
]);
|
||||
}
|
||||
|
||||
let Some(config) = config else {
|
||||
return;
|
||||
};
|
||||
let Some(reachability_index) = checks
|
||||
.iter()
|
||||
.position(|check| check.id == "network.provider_reachability")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let desktop_update_url = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.map(|home| {
|
||||
macos_desktop_update_url(&home, application, &os_info::get().version().to_string())
|
||||
})
|
||||
.unwrap_or_else(|| DESKTOP_UPDATE_URL.to_string());
|
||||
#[cfg(target_os = "windows")]
|
||||
let desktop_update_url = DESKTOP_UPDATE_URL;
|
||||
#[cfg(target_os = "macos")]
|
||||
let desktop_update_url = desktop_update_url.as_str();
|
||||
let desktop_update_display_url = desktop_update_url
|
||||
.split_once('?')
|
||||
.map_or(desktop_update_url, |(endpoint, _)| endpoint);
|
||||
let client = RouteAwareClientPool::new_without_request_logging(
|
||||
config.http_client_factory(),
|
||||
ClientRouteClass::Other,
|
||||
);
|
||||
let outcome = match client
|
||||
.request(Method::GET, desktop_update_url)
|
||||
.timeout(deadline.saturating_duration_since(tokio::time::Instant::now()))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let status = response.status().as_u16();
|
||||
#[cfg(target_os = "windows")]
|
||||
if status == 404 && response.url().scheme() == "https" {
|
||||
checks[reachability_index].details.push(format!(
|
||||
"desktop assets CDN: {desktop_update_display_url} reachable (HTTP 404; no update available)"
|
||||
));
|
||||
return;
|
||||
}
|
||||
if cfg!(target_os = "windows") && response.url().scheme() != "https" {
|
||||
Err("update manifest redirected to a non-HTTPS URL".to_string())
|
||||
} else if status == 407 {
|
||||
Err("proxy authentication required (HTTP 407)".to_string())
|
||||
} else if !(200..=299).contains(&status) {
|
||||
Err(format!("HTTP {status}"))
|
||||
} else {
|
||||
checks[reachability_index].details.push(format!(
|
||||
"desktop assets CDN: {desktop_update_display_url} reachable (HTTP {status})"
|
||||
));
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(update) = checks.iter_mut().find(|check| check.id == "updates.status") {
|
||||
match response.bytes().await {
|
||||
Ok(body) => match windows_store_update(&body, &application.version) {
|
||||
Ok(Some(build)) => update.details.extend([
|
||||
"desktop update status: available".to_string(),
|
||||
format!("desktop latest build: {build}"),
|
||||
format!("desktop application: {}", application.identity),
|
||||
]),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
update.status = update.status.max(CheckStatus::Warning);
|
||||
update
|
||||
.details
|
||||
.push(format!("desktop update manifest: {error}"));
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
update.status = update.status.max(CheckStatus::Warning);
|
||||
update
|
||||
.details
|
||||
.push("desktop update manifest: response could not be read".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Err(error) => Err(network::request_error(error)),
|
||||
};
|
||||
|
||||
if let Err(error) = outcome {
|
||||
let reachability = &mut checks[reachability_index];
|
||||
reachability.details.push(format!(
|
||||
"desktop assets CDN: {desktop_update_display_url} {error} (optional)"
|
||||
));
|
||||
if reachability.status == CheckStatus::Ok {
|
||||
reachability.status = CheckStatus::Warning;
|
||||
reachability.summary = "desktop update and runtime CDN is unreachable".to_string();
|
||||
}
|
||||
reachability.issues.push(
|
||||
DoctorIssue::new(
|
||||
CheckStatus::Warning,
|
||||
"desktop update and runtime CDN is unreachable",
|
||||
)
|
||||
.measured(format!("{desktop_update_display_url} {error}"))
|
||||
.expected("desktop update and runtime CDN reachable over HTTPS")
|
||||
.remedy(
|
||||
if desktop_update_display_url.starts_with("https://chatgpt.com/") {
|
||||
"check proxy, firewall, DNS, and certificate access to chatgpt.com"
|
||||
} else {
|
||||
"check proxy, firewall, DNS, and certificate access to persistent.oaistatic.com"
|
||||
},
|
||||
)
|
||||
.field("desktop assets CDN"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_desktop_update_url(home: &Path, application: &InstalledApp, os_version: &str) -> String {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProductionAppcastState {
|
||||
#[serde(default)]
|
||||
backend_appcast_enabled: bool,
|
||||
installation_id: Option<String>,
|
||||
}
|
||||
|
||||
let state_path = home
|
||||
.join("Library/Application Support")
|
||||
.join(application.identity)
|
||||
.join("production-appcast-bootstrap.json");
|
||||
let Some(state) = std::fs::read(state_path)
|
||||
.ok()
|
||||
.and_then(|contents| serde_json::from_slice::<ProductionAppcastState>(&contents).ok())
|
||||
else {
|
||||
return DESKTOP_UPDATE_URL.to_string();
|
||||
};
|
||||
let Some(installation_id) = state
|
||||
.backend_appcast_enabled
|
||||
.then_some(state.installation_id)
|
||||
.flatten()
|
||||
else {
|
||||
return DESKTOP_UPDATE_URL.to_string();
|
||||
};
|
||||
|
||||
let Ok(mut url) = Url::parse(BACKEND_DESKTOP_UPDATE_URL) else {
|
||||
return DESKTOP_UPDATE_URL.to_string();
|
||||
};
|
||||
url.query_pairs_mut().extend_pairs([
|
||||
("installation_id", installation_id.as_str()),
|
||||
(
|
||||
"arch",
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
"x64"
|
||||
} else {
|
||||
"arm64"
|
||||
},
|
||||
),
|
||||
("app_version", application.version.as_str()),
|
||||
("beta", "false"),
|
||||
("os-version", os_version),
|
||||
("plan_type", "unknown"),
|
||||
]);
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_store_update(
|
||||
manifest: &[u8],
|
||||
installed_version: &str,
|
||||
) -> Result<Option<String>, &'static str> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StoreManifest {
|
||||
schema_version: u64,
|
||||
build_version: String,
|
||||
store_product_id: String,
|
||||
package_identity: String,
|
||||
}
|
||||
|
||||
let manifest: StoreManifest =
|
||||
serde_json::from_slice(manifest).map_err(|_| "invalid Windows Store update manifest")?;
|
||||
if manifest.schema_version == 0
|
||||
|| manifest.store_product_id != "9PLM9XGG6VKS"
|
||||
|| manifest.package_identity != "OpenAI.Codex"
|
||||
{
|
||||
return Err("Windows Store update manifest does not target the production application");
|
||||
}
|
||||
let version = |value: &str| -> Option<[u64; 4]> {
|
||||
value
|
||||
.split('.')
|
||||
.map(str::parse::<u64>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()
|
||||
};
|
||||
let latest = version(&manifest.build_version)
|
||||
.ok_or("Windows Store update manifest contains an invalid build version")?;
|
||||
let installed =
|
||||
version(installed_version).ok_or("installed Windows application has an invalid version")?;
|
||||
Ok((latest > installed).then_some(manifest.build_version))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn latest_macos_staged_build(root: &Path, installed_build: u64) -> Option<u64> {
|
||||
const MAX_STAGED_BUNDLES: usize = 64;
|
||||
|
||||
if !std::fs::symlink_metadata(root).ok()?.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
|
||||
let mut inspected = 0;
|
||||
let mut latest = None;
|
||||
for entry in std::fs::read_dir(root).ok()? {
|
||||
if inspected == MAX_STAGED_BUNDLES || tokio::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let extracted = entry.path().join("extracted");
|
||||
if !std::fs::symlink_metadata(&extracted).is_ok_and(|metadata| metadata.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let bundle = extracted.join("ChatGPT.app");
|
||||
if !std::fs::symlink_metadata(&bundle).is_ok_and(|metadata| metadata.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
inspected += 1;
|
||||
let Ok(result) = tokio::time::timeout_at(
|
||||
deadline,
|
||||
super::desktop::platform::inspect_macos_bundle(&bundle),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if let Ok(Some(application)) = result
|
||||
&& application.build > installed_build
|
||||
{
|
||||
latest = Some(latest.map_or(application.build, |latest: u64| {
|
||||
latest.max(application.build)
|
||||
}));
|
||||
}
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
fn push_cached_version_details(details: &mut Vec<String>, version_file: &Path) {
|
||||
details.push(format!("version cache: {}", version_file.display()));
|
||||
match std::fs::read_to_string(version_file) {
|
||||
@@ -209,6 +508,117 @@ struct VersionInfo {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_update_probe_uses_the_persisted_production_appcast_feed() {
|
||||
let home = tempfile::tempdir().expect("temporary home should be created");
|
||||
let application = InstalledApp {
|
||||
identity: "com.openai.codex",
|
||||
version: "26.623.10000".to_string(),
|
||||
bundle: PathBuf::new(),
|
||||
build: 6139,
|
||||
};
|
||||
assert_eq!(
|
||||
macos_desktop_update_url(home.path(), &application, "26.6.0"),
|
||||
DESKTOP_UPDATE_URL
|
||||
);
|
||||
|
||||
let state_directory = home
|
||||
.path()
|
||||
.join("Library/Application Support/com.openai.codex");
|
||||
std::fs::create_dir_all(&state_directory)
|
||||
.expect("production appcast state directory should be created");
|
||||
std::fs::write(
|
||||
state_directory.join("production-appcast-bootstrap.json"),
|
||||
r#"{"backendAppcastEnabled":true,"installationId":"028e90f8-5f2a-47db-a05c-6a48f548d728"}"#,
|
||||
)
|
||||
.expect("production appcast state should be created");
|
||||
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x64"
|
||||
} else {
|
||||
"arm64"
|
||||
};
|
||||
assert_eq!(
|
||||
macos_desktop_update_url(home.path(), &application, "26.6.0"),
|
||||
format!(
|
||||
"{BACKEND_DESKTOP_UPDATE_URL}?installation_id=028e90f8-5f2a-47db-a05c-6a48f548d728&arch={arch}&app_version=26.623.10000&beta=false&os-version=26.6.0&plan_type=unknown"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn macos_staged_updates_require_a_newer_matching_extracted_bundle() {
|
||||
let root = tempfile::tempdir().expect("temporary Sparkle cache should be created");
|
||||
for index in 0..320 {
|
||||
std::fs::create_dir(root.path().join(format!("unrelated-{index}")))
|
||||
.expect("unrelated Sparkle cache directory should be created");
|
||||
}
|
||||
for (name, identity, build) in [
|
||||
("newest", "com.openai.codex", "6268"),
|
||||
("newer", "com.openai.codex", "6168"),
|
||||
("older", "com.openai.codex", "6138"),
|
||||
("different", "com.example.other", "9999"),
|
||||
("invalid", "com.openai.codex", "invalid"),
|
||||
] {
|
||||
let bundle = root.path().join(name).join("extracted/ChatGPT.app");
|
||||
write_macos_bundle(&bundle, identity, build);
|
||||
}
|
||||
let outside = tempfile::tempdir().expect("external fixture should be created");
|
||||
let linked = outside.path().join("ChatGPT.app");
|
||||
write_macos_bundle(&linked, "com.openai.codex", "9999");
|
||||
std::os::unix::fs::symlink(&linked, root.path().join("ChatGPT.app"))
|
||||
.expect("symlinked staged app fixture should be created");
|
||||
|
||||
assert_eq!(
|
||||
latest_macos_staged_build(root.path(), /*installed_build*/ 6139).await,
|
||||
Some(6268)
|
||||
);
|
||||
assert_eq!(
|
||||
latest_macos_staged_build(root.path(), /*installed_build*/ 6268).await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_store_updates_compare_all_four_production_build_components() {
|
||||
let mut manifest = serde_json::json!({
|
||||
"schemaVersion": 1,
|
||||
"buildVersion": "26.803.5235.1",
|
||||
"storeProductId": "9PLM9XGG6VKS",
|
||||
"packageIdentity": "OpenAI.Codex",
|
||||
});
|
||||
assert_eq!(
|
||||
windows_store_update(&serde_json::to_vec(&manifest).unwrap(), "26.803.5235.0"),
|
||||
Ok(Some("26.803.5235.1".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
windows_store_update(&serde_json::to_vec(&manifest).unwrap(), "26.803.5235.1"),
|
||||
Ok(None)
|
||||
);
|
||||
manifest["storeProductId"] = "other".into();
|
||||
assert!(
|
||||
windows_store_update(&serde_json::to_vec(&manifest).unwrap(), "26.803.5235.0").is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn write_macos_bundle(path: &Path, identity: &str, build: &str) {
|
||||
let contents = path.join("Contents");
|
||||
std::fs::create_dir_all(&contents).expect("staged app fixture should be created");
|
||||
std::fs::write(
|
||||
contents.join("Info.plist"),
|
||||
format!(
|
||||
"<?xml version=\"1.0\"?><plist version=\"1.0\"><dict>\
|
||||
<key>CFBundleIdentifier</key><string>{identity}</string>\
|
||||
<key>CFBundleVersion</key><string>{build}</string>\
|
||||
</dict></plist>"
|
||||
),
|
||||
)
|
||||
.expect("staged app metadata should be created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_newer_compares_plain_semver() {
|
||||
assert_eq!(is_newer("1.2.4", "1.2.3"), Some(true));
|
||||
|
||||
@@ -5,8 +5,10 @@ use std::process::Stdio;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
#[cfg(target_os = "macos")]
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
#[cfg(target_os = "macos")]
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
@@ -63,9 +65,10 @@ async fn invalid_custom_ca_falls_back_to_system_roots() -> Result<()> {
|
||||
.context("failed to run the doctor with an invalid custom CA")?;
|
||||
let report: Value = serde_json::from_slice(&output.stdout)?;
|
||||
|
||||
assert_eq!(
|
||||
report["checks"]["network.provider_reachability"]["status"],
|
||||
json!("ok")
|
||||
assert!(
|
||||
report["checks"]["network.provider_reachability"]["details"]["local API inference URL"]
|
||||
.as_str()
|
||||
.is_some_and(|detail| detail.ends_with("reachable (HTTP 200)"))
|
||||
);
|
||||
}
|
||||
server.verify().await;
|
||||
|
||||
Reference in New Issue
Block a user