diff --git a/CHANGELOG.md b/CHANGELOG.md index f945398..105497c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ [简体中文](CHANGELOG.zh-CN.md) · English +## v0.9.3 — 2026-11-13 + +- Fixed Chrome Beta being incorrectly reported as Stable or showing a channel mismatch after installation; the same correction applies to Edge and Brave. +- Switching the selected channel no longer changes the recorded installed channel. Channel-switch and downgrade confirmations remain separate. +- For Chrome, Edge, or Brave installed with an older Browser Harbor version, reinstall the desired channel once using v0.9.3 to establish its channel record. + ## v0.9.2 — 2026-11-08 - When an installation or upgrade is blocked because the browser is still running, the message now lists the detected process names and PIDs (up to three), so you can see exactly what is holding the program files. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 189930e..98f1d85 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) · 简体中文 +## v0.9.3 — 2026-11-13 + +- 修复 Chrome Beta 安装后被误判为 Stable 或反复提示“渠道不同”的问题,同时修正 Edge、Brave 的同类识别逻辑。 +- 切换所选渠道不再影响已安装渠道记录;切换渠道与降级仍分别要求确认。 +- 旧版 Browser Harbor 安装的 Chrome、Edge、Brave,请使用 v0.9.3 重新安装所需渠道一次,以建立渠道记录。 + ## v0.9.2 — 2026-11-08 - 安装或升级被“浏览器仍在运行”挡住时,提示现在会列出检测到的进程名称与 PID(最多 3 个),便于确认是哪个进程占用了程序文件。 diff --git a/Cargo.lock b/Cargo.lock index eab83b6..066d3b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -596,7 +596,7 @@ dependencies = [ [[package]] name = "browser-harbor" -version = "0.9.2" +version = "0.9.3" dependencies = [ "base64 0.22.1", "cab", diff --git a/Cargo.toml b/Cargo.toml index c2ac3b2..4889714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "browser-harbor" -version = "0.9.2" +version = "0.9.3" edition = "2024" rust-version = "1.99" license = "MIT OR Apache-2.0" diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index bad9e9f..9c25976 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -57,7 +57,9 @@ Browser Harbor checks the browser version and download integrity before changing Downgrading also requires confirmation: an older browser may be unable to read user data migrated by a newer version. Back up your data before proceeding. -If the selected channel differs from the channel detected in the existing local executable, Browser Harbor asks before replacing the program directory. +If the selected channel differs from the verified installed channel, Browser Harbor asks before replacing the program directory. Switching the channel selector does not change which channel is already installed; downgrades still require a separate confirmation. + +Chrome, Edge, and Brave channel records are checked against the installed program files. Older installations without a record cannot be reliably identified from a generic product name alone. Reinstall the desired channel once through Browser Harbor v0.9.3 or later to establish its record. External changes to the program files may invalidate that record. The browser page checks the selected target, channel, and build once per session. A manual check is still available. An installation started within ten minutes can reuse the verified upstream result. diff --git a/docs/USER_GUIDE.zh-CN.md b/docs/USER_GUIDE.zh-CN.md index 2a26046..fd7a669 100644 --- a/docs/USER_GUIDE.zh-CN.md +++ b/docs/USER_GUIDE.zh-CN.md @@ -53,7 +53,9 @@ Browser Harbor 会在修改安装前检查浏览器版本和下载完整性。 降级也需要明确确认:旧版浏览器可能无法读取已被新版迁移的用户数据,请先备份再操作。 -如果所选渠道与本地 EXE 探测到的渠道不同,Browser Harbor 会在替换程序目录前要求确认。 +如果所选渠道与已核验的安装渠道不同,Browser Harbor 会在替换程序目录前要求确认。切换渠道下拉框不会改变已经安装的渠道;涉及降级时仍需单独确认。 + +Chrome、Edge 和 Brave 的渠道记录会与实际程序文件核对。旧安装没有渠道记录时,无法仅凭通用产品名称可靠识别;请使用 Browser Harbor v0.9.3 或更新版本重新安装所需渠道一次,以建立记录。外部替换程序文件后,原记录可能失效。 进入浏览器页后,程序会对当前目标、渠道和构建自动检查一次;也可以手动刷新。十分钟内开始安装时会复用已验证的上游结果。 diff --git a/docs/releases/v0.9.3.md b/docs/releases/v0.9.3.md new file mode 100644 index 0000000..b436580 --- /dev/null +++ b/docs/releases/v0.9.3.md @@ -0,0 +1,27 @@ +# Browser Harbor v0.9.3 + +## 下载 · Download + +普通使用只需下载 **BrowserHarbor-0.9.3-x64.exe**。Windows 10/11 x64,无需安装。 + +Download **BrowserHarbor-0.9.3-x64.exe** for Windows 10/11 x64. No installation required. + +[GitHub](https://github.com/MagicCube99/browser-harbor/releases/tag/v0.9.3) · [Gitee 国内下载](https://gitee.com/MagicCube99/browser-harbor/releases/tag/v0.9.3) + +## 本次更新 · What's new + +- 修复 Chrome Beta 安装后被误判为 Stable 或反复提示“渠道不同”的问题,同时修正 Edge、Brave 的同类识别逻辑。 +- 切换所选渠道不再影响已安装渠道记录。安装渠道不同会先提示切换,涉及降级时仍需单独确认。 + +- Fixed Chrome Beta being incorrectly reported as Stable or showing a channel mismatch after installation; the same correction applies to Edge and Brave. +- Selecting another channel no longer changes the recorded installed channel. Channel switching and downgrading still require separate confirmations. + +## 升级 · Upgrade + +替换旧 EXE 即可,无需删除 `BrowserHarbor.json` 或浏览器用户数据。 + +**旧安装提示:** 对于旧版 Browser Harbor 安装的 Chrome、Edge、Brave,请使用 v0.9.3 重新安装所需渠道一次,以建立渠道记录;之后重启工具或切换 Stable/Beta 即可核验已安装渠道。 + +Replace the old EXE without deleting `BrowserHarbor.json` or browser user data. + +**Existing installations:** For Chrome, Edge, or Brave installed with an older Browser Harbor version, reinstall the desired channel once using v0.9.3 to establish its channel record. The installed channel can then be verified after restarting the tool or switching between Stable and Beta. diff --git a/src/app/mod.rs b/src/app/mod.rs index 84e5f92..660bbbf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -344,6 +344,7 @@ impl AppState { })?; config.installed_version = Some(receipt.version.clone()); config.catsxp_build = receipt.catsxp_build.clone(); + config.installed_channel = receipt.installed_channel.clone(); config.browser_directory = Some(receipt.browser_directory.clone()); config.data_directory = Some(receipt.data_directory.clone()); config.profile_directory = Some(receipt.profile_directory.clone()); @@ -707,6 +708,7 @@ mod tests { let receipt = crate::services::install::InstallReceipt { version: "1.2.3".to_owned(), catsxp_build: None, + installed_channel: None, browser_directory: PathBuf::from(r"D:\Browsers\Chrome\APP"), data_directory: PathBuf::from(r"D:\Browsers\Chrome\Data"), profile_directory: PathBuf::from(r"D:\Browsers\Chrome\Data"), diff --git a/src/app/operation.rs b/src/app/operation.rs index d35c0e3..fe35fed 100644 --- a/src/app/operation.rs +++ b/src/app/operation.rs @@ -50,6 +50,9 @@ pub enum OperationPhase { CatsxpIdentified { observation: Option, }, + ChannelIdentified { + observation: Option, + }, ChannelMismatch { current_version: String, current_channel: ReleaseChannel, @@ -73,6 +76,7 @@ pub enum OperationPhase { current_version: String, upstream: UpstreamCheck, release: ReleaseDescriptor, + channel_change_confirmed: bool, }, Installed { receipt: InstallReceipt, @@ -238,6 +242,7 @@ pub fn start_operation(request: StartOperationRequest) -> OperationController { current_version, upstream, release, + channel_change_confirmed, }) => { let _ = sender.send(OperationEvent::new( target, @@ -245,6 +250,7 @@ pub fn start_operation(request: StartOperationRequest) -> OperationController { current_version, upstream, release, + channel_change_confirmed, }, )); } @@ -382,7 +388,28 @@ async fn run_operation( } let provider = ProviderResolver::new(http.clone()); let mut current_version = request.current_version.clone(); - let mut catsxp_channel = None; + let mut installed_channel = None; + if matches!(target, TargetId::Chrome | TargetId::Edge | TargetId::Brave) { + let observation = if let Some(program) = request.target_config.browser_directory.as_deref() + { + match crate::services::installed_channel::observe( + target, + program, + request.target_config.installed_channel.as_ref(), + &cancellation, + ) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => { + return Err(Box::new(crate::services::ServiceError::Cancelled)); + } + Err(_) => None, + } + } else { + None + }; + installed_channel = observation.as_ref().map(|value| value.identity.channel); + emit(OperationPhase::ChannelIdentified { observation }); + } if target == TargetId::Catsxp { let observation = if let Some(program) = request.target_config.browser_directory.as_deref() { @@ -413,7 +440,7 @@ async fn run_operation( }) .map(|probe| probe.version) }); - catsxp_channel = observation.as_ref().map(|value| value.build.channel); + installed_channel = observation.as_ref().map(|value| value.build.channel); emit(OperationPhase::CatsxpIdentified { observation }); } let mut metadata_source = MetadataSource::Official; @@ -548,9 +575,8 @@ async fn run_operation( }); } if !request.channel_change_confirmed - && !request.replace_same_version && let Some(current_channel) = - catsxp_channel.filter(|channel| *channel != descriptor.channel) + installed_channel.filter(|channel| *channel != descriptor.channel) { return Ok(OperationResult::ChannelMismatch { current_channel, @@ -564,7 +590,7 @@ async fn run_operation( &descriptor.version, ) || (!request.replace_same_version && target == TargetId::Catsxp - && catsxp_channel.is_none() + && installed_channel.is_none() && crate::services::catsxp::base_version(&descriptor.version, descriptor.channel) .as_deref() == Some(current_version))) @@ -573,6 +599,7 @@ async fn run_operation( current_version: current_version.to_owned(), upstream: check, release: descriptor, + channel_change_confirmed: request.channel_change_confirmed, }); } @@ -1057,6 +1084,7 @@ enum OperationResult { current_version: String, upstream: UpstreamCheck, release: ReleaseDescriptor, + channel_change_confirmed: bool, }, Installed(InstallReceipt), ChromePlusChecked { @@ -1892,6 +1920,7 @@ mod tests { checked_at: 0, }, release: descriptor(), + channel_change_confirmed: false, } .is_terminal() ); @@ -2059,6 +2088,87 @@ mod tests { ); } + #[tokio::test] + async fn verified_beta_record_blocks_stable_before_download_and_requires_downgrade_confirmation() + { + let directory = tempfile::tempdir().unwrap(); + let target = crate::domain::target::TargetId::Chrome; + let root = directory.path().join("Chrome"); + let program = root.join("App"); + std::fs::create_dir_all(&program).unwrap(); + std::fs::copy(std::env::current_exe().unwrap(), program.join("chrome.exe")).unwrap(); + let version = crate::services::version_probe::probe_local_version(target, &program) + .unwrap() + .version; + std::fs::create_dir(program.join(&version)).unwrap(); + std::fs::write(program.join(&version).join("chrome.dll"), b"beta core").unwrap(); + let mut config = crate::domain::settings::TargetConfig::for_target(target); + config.browser_root = Some(root); + config.derive_paths_from_root(target); + config.installed_channel = crate::services::installed_channel::capture( + target, + &program, + &version, + ReleaseChannel::Beta, + ) + .unwrap(); + // A nonexistent local package proves confirmation happens before artifact access. + config.local_package = Some(directory.path().join("must-not-open.zip")); + let mut release = descriptor(); + release.target = target; + release.channel = ReleaseChannel::Stable; + release.version = "0.0.1".into(); + for (confirmed, replace_same_version) in [(false, false), (false, true), (true, false)] { + let (sender, receiver) = std::sync::mpsc::channel(); + let result = super::run_operation( + super::StartOperationRequest { + target, + channel_change_confirmed: confirmed, + mode: super::OperationMode::Install, + target_config: config.clone(), + network: Default::default(), + cache_dir: directory.path().join("cache"), + current_version: Some(version.clone()), + chrome_plus_expected_version: None, + replace_same_version, + allow_local_package: true, + allow_signed_metadata_cache: true, + cached_release: Some(CachedReleaseMetadata { + check: super::UpstreamCheck::fresh( + "0.0.1".into(), + ReleaseChannel::Stable, + CpuBuild::X64, + ), + release: release.clone(), + observed_at: unix_now(), + }), + }, + sender, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .await + .unwrap(); + if confirmed { + assert!(matches!(result, super::OperationResult::SameVersion { .. })); + } else { + assert!(matches!( + result, + super::OperationResult::ChannelMismatch { + current_channel: ReleaseChannel::Beta, + .. + } + )); + } + assert!(receiver.try_iter().any(|event| matches!( + event.phase, + OperationPhase::ChannelIdentified { + observation: Some(_) + } + ))); + assert!(!directory.path().join("cache").exists()); + } + } + #[tokio::test] async fn unsupported_libportable_is_rejected_before_using_a_browser_package() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/domain/release.rs b/src/domain/release.rs index 32e5964..b4a0dd0 100644 --- a/src/domain/release.rs +++ b/src/domain/release.rs @@ -36,6 +36,17 @@ pub struct CatsxpBuildIdentity { pub core_sha256: String, } +/// Local receipt evidence, not a replacement for artifact verification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledChannelIdentity { + pub target: TargetId, + pub version: String, + pub channel: ReleaseChannel, + pub executable_sha256: String, + pub core_sha256: String, +} + impl ReleaseChannel { pub const fn display_name(self) -> &'static str { match self { diff --git a/src/domain/settings.rs b/src/domain/settings.rs index 40c6f68..c528e8f 100644 --- a/src/domain/settings.rs +++ b/src/domain/settings.rs @@ -5,7 +5,7 @@ use std::{collections::BTreeMap, path::PathBuf}; use serde::{Deserialize, Serialize}; use super::{ - release::{CatsxpBuildIdentity, CpuBuild, ReleaseChannel}, + release::{CatsxpBuildIdentity, CpuBuild, InstalledChannelIdentity, ReleaseChannel}, runtime::ChromePlusConfig, target::TargetId, }; @@ -117,6 +117,8 @@ pub struct TargetConfig { pub installed_version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub catsxp_build: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub installed_channel: Option, pub local_package: Option, pub chrome_plus: ChromePlusConfig, pub chrome_plus_version: Option, @@ -228,6 +230,7 @@ impl Default for TargetConfig { cpu_build: CpuBuild::X64, installed_version: None, catsxp_build: None, + installed_channel: None, local_package: None, chrome_plus: ChromePlusConfig::default(), chrome_plus_version: None, diff --git a/src/services/install.rs b/src/services/install.rs index 7b661af..ad49a69 100644 --- a/src/services/install.rs +++ b/src/services/install.rs @@ -69,6 +69,7 @@ pub struct InstallRequest { pub struct InstallReceipt { pub version: String, pub catsxp_build: Option, + pub installed_channel: Option, pub browser_directory: PathBuf, pub data_directory: PathBuf, pub profile_directory: PathBuf, @@ -398,6 +399,7 @@ impl InstallService { )); } let executable = final_program.join(&executable_name); + let mut installed_channel = None; if let Err(error) = verify_installed(&executable, &final_program, request.release.target) .and_then(|()| { verify_runtime_result( @@ -406,6 +408,16 @@ impl InstallService { chrome_plus_version.as_deref(), ) }) + .and_then(|()| { + installed_channel = super::installed_channel::capture( + request.release.target, + &final_program, + &request.release.version, + request.release.channel, + ) + .map_err(|source| io_error(&final_program, source))?; + Ok(()) + }) { return Err(rollback_error(&mut transaction, error, &backup)); } @@ -414,6 +426,7 @@ impl InstallService { Ok(InstallReceipt { version: request.release.version, catsxp_build: None, + installed_channel, browser_directory: stored_browser_directory( request.release.placement, &commit_root, @@ -736,6 +749,7 @@ fn install_native_package( )?; let installed_executable = destination.join(relative_executable); let mut catsxp_build = None; + let mut installed_channel = None; if let Err(error) = verify_installed( &installed_executable, &final_program, @@ -757,6 +771,13 @@ fn install_native_package( ) .map_err(|source| io_error(&final_program, source))?; } + installed_channel = super::installed_channel::capture( + request.release.target, + &final_program, + &request.release.version, + request.release.channel, + ) + .map_err(|source| io_error(&final_program, source))?; Ok(()) }) { return Err(recover_error( @@ -770,6 +791,7 @@ fn install_native_package( Ok(InstallReceipt { version: request.release.version.clone(), catsxp_build, + installed_channel, browser_directory: final_program, data_directory: data_directory.to_path_buf(), profile_directory: profile_directory.to_path_buf(), @@ -2391,6 +2413,58 @@ mod tests { } } + #[test] + fn verified_beta_install_records_the_channel_and_program_hashes() { + for placement in [ProgramPlacement::App, ProgramPlacement::NativePackage] { + let directory = tempdir().unwrap(); + let root = directory.path().join("Chrome"); + let executable = std::env::current_exe().unwrap(); + let version = + crate::infra::windows::version_info::executable_product_version(&executable) + .unwrap(); + let archive = directory.path().join("chrome.zip"); + let mut writer = ZipWriter::new(File::create(&archive).unwrap()); + writer + .start_file("chrome.exe", SimpleFileOptions::default()) + .unwrap(); + writer.write_all(&fs::read(executable).unwrap()).unwrap(); + writer + .start_file( + format!("{version}/chrome.dll"), + SimpleFileOptions::default(), + ) + .unwrap(); + writer.write_all(b"beta core fixture").unwrap(); + writer.finish().unwrap(); + let mut descriptor = release(&version, &archive); + descriptor.target = TargetId::Chrome; + descriptor.channel = ReleaseChannel::Beta; + descriptor.placement = placement; + let mut install = request(descriptor, &archive, &root); + install.chrome_plus_runtime = Some(chrome_plus_runtime( + &directory.path().join("runtime.7z"), + "1.18.3", + )); + let receipt = InstallService::install(install).unwrap(); + let identity = receipt + .installed_channel + .as_ref() + .expect("verified install must record Beta identity"); + assert_eq!(identity.channel, ReleaseChannel::Beta); + assert_eq!(identity.version, version); + assert!( + crate::services::installed_channel::observe( + TargetId::Chrome, + &receipt.browser_directory, + Some(identity), + &std::sync::atomic::AtomicBool::new(false), + ) + .unwrap() + .is_some() + ); + } + } + #[test] fn install_receipt_records_chromeplus_before_replacing_files() { for placement in [ProgramPlacement::App, ProgramPlacement::NativePackage] { diff --git a/src/services/installed_channel.rs b/src/services/installed_channel.rs new file mode 100644 index 0000000..a9775e0 --- /dev/null +++ b/src/services/installed_channel.rs @@ -0,0 +1,306 @@ +//! Content-bound channel identity for Chromium packages with generic ProductName resources. + +use std::{ + fs, + io::{self, Read}, + path::{Path, PathBuf}, + sync::atomic::{AtomicBool, Ordering}, + time::SystemTime, +}; + +use super::version_probe::probe_local_version; +use crate::domain::{ + release::{InstalledChannelIdentity, ReleaseChannel}, + target::TargetId, +}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Observation { + pub identity: InstalledChannelIdentity, + program: PathBuf, + files: [PathBuf; 2], + stamp: [(u64, SystemTime); 2], +} + +fn core_name(target: TargetId) -> Option<&'static str> { + match target { + TargetId::Chrome | TargetId::Brave => Some("chrome.dll"), + TargetId::Edge => Some("msedge.dll"), + _ => None, + } +} + +fn files(target: TargetId, program: &Path, version: &str) -> io::Result> { + let Some(core) = core_name(target) else { + return Ok(None); + }; + let probe = match probe_local_version(target, program) { + Ok(probe) if probe.version == version => probe, + _ => return Ok(None), + }; + let Some(parent) = probe.executable.parent() else { + return Ok(None); + }; + let mut candidates = vec![parent.join(version).join(core), parent.join(core)]; + // Brave's product version can differ from its Chromium version directory. + if let Ok(file_version) = + crate::infra::windows::version_info::executable_file_version(&probe.executable) + { + candidates.push(parent.join(file_version).join(core)); + } + let core = candidates.into_iter().find(|path| path.is_file()); + Ok(core.map(|core| [probe.executable, core])) +} + +fn stamp(files: &[PathBuf; 2]) -> io::Result<[(u64, SystemTime); 2]> { + let mut result = [(0, SystemTime::UNIX_EPOCH); 2]; + for (index, path) in files.iter().enumerate() { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() { + return Err(io::Error::other( + "channel identity requires regular program files", + )); + } + result[index] = (metadata.len(), metadata.modified()?); + } + Ok(result) +} + +fn hash(path: &Path, cancellation: &AtomicBool) -> io::Result { + let mut file = fs::File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + if cancellation.load(Ordering::Relaxed) { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "channel identity check cancelled", + )); + } + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(hex::encode(digest.finalize())) +} + +pub fn capture( + target: TargetId, + program: &Path, + version: &str, + channel: ReleaseChannel, +) -> io::Result> { + let Some(files) = files(target, program, version)? else { + return Ok(None); + }; + let before = stamp(&files)?; + let cancellation = AtomicBool::new(false); + let identity = InstalledChannelIdentity { + target, + version: version.to_owned(), + channel, + executable_sha256: hash(&files[0], &cancellation)?, + core_sha256: hash(&files[1], &cancellation)?, + }; + if before != stamp(&files)? { + return Err(io::Error::other( + "program files changed while recording their channel", + )); + } + Ok(Some(identity)) +} + +pub fn observe( + target: TargetId, + program: &Path, + identity: Option<&InstalledChannelIdentity>, + cancellation: &AtomicBool, +) -> io::Result> { + let Some(identity) = identity.filter(|value| value.target == target) else { + return Ok(None); + }; + let Some(files) = files(target, program, &identity.version)? else { + return Ok(None); + }; + let before = stamp(&files)?; + if hash(&files[0], cancellation)? != identity.executable_sha256 + || hash(&files[1], cancellation)? != identity.core_sha256 + || before != stamp(&files)? + { + return Ok(None); + } + Ok(Some(Observation { + identity: identity.clone(), + program: program.to_owned(), + files, + stamp: before, + })) +} + +impl Observation { + // Only use directly with the receipt returned by a successful verified install. + pub fn from_installed( + program: &Path, + identity: InstalledChannelIdentity, + ) -> io::Result> { + let Some(files) = files(identity.target, program, &identity.version)? else { + return Ok(None); + }; + let stamp = stamp(&files)?; + Ok(Some(Self { + identity, + program: program.to_owned(), + files, + stamp, + })) + } + + pub fn is_current(&self, target: TargetId, program: &Path, version: &str) -> bool { + self.identity.target == target + && self.identity.version == version + && self.program == program + && stamp(&self.files).is_ok_and(|stamp| stamp == self.stamp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + + #[test] + fn beta_identity_requires_matching_executable_core_and_version() { + let directory = tempfile::tempdir().unwrap(); + let program = directory.path(); + std::fs::copy(std::env::current_exe().unwrap(), program.join("chrome.exe")).unwrap(); + let version = + crate::services::version_probe::probe_local_version(TargetId::Chrome, program) + .unwrap() + .version; + std::fs::create_dir(program.join(&version)).unwrap(); + let core = program.join(&version).join("chrome.dll"); + std::fs::write(&core, b"beta core").unwrap(); + let identity = capture(TargetId::Chrome, program, &version, ReleaseChannel::Beta) + .unwrap() + .unwrap(); + let cancellation = AtomicBool::new(false); + let observation = observe(TargetId::Chrome, program, Some(&identity), &cancellation) + .unwrap() + .unwrap(); + assert_eq!(observation.identity.channel, ReleaseChannel::Beta); + assert!(observation.is_current(TargetId::Chrome, program, &version)); + assert!(!observation.is_current(TargetId::Chrome, program, "different version")); + assert!( + observe(TargetId::Edge, program, Some(&identity), &cancellation) + .unwrap() + .is_none() + ); + + std::fs::write(&core, b"stable core replacing beta").unwrap(); + assert!(!observation.is_current(TargetId::Chrome, program, &version)); + assert!( + observe(TargetId::Chrome, program, Some(&identity), &cancellation) + .unwrap() + .is_none() + ); + std::fs::write(&core, b"beta core").unwrap(); + std::fs::write( + program.join("chrome.exe"), + b"externally replaced executable", + ) + .unwrap(); + assert!( + observe(TargetId::Chrome, program, Some(&identity), &cancellation) + .unwrap() + .is_none() + ); + } + + #[test] + fn edge_and_brave_channel_records_follow_the_actual_program_layout() { + for (target, executable, core, versioned) in [ + (TargetId::Edge, "msedge.exe", "msedge.dll", true), + (TargetId::Brave, "brave.exe", "chrome.dll", false), + ] { + let directory = tempfile::tempdir().unwrap(); + let version = crate::infra::windows::version_info::executable_product_version( + &std::env::current_exe().unwrap(), + ) + .unwrap(); + let program = if versioned { + directory.path().join(&version) + } else { + directory.path().to_path_buf() + }; + std::fs::create_dir_all(&program).unwrap(); + std::fs::copy(std::env::current_exe().unwrap(), program.join(executable)).unwrap(); + let observed_version = + crate::services::version_probe::probe_local_version(target, &program) + .unwrap() + .version; + std::fs::write(program.join(core), b"beta core").unwrap(); + let identity = capture( + target, + directory.path(), + &observed_version, + ReleaseChannel::Beta, + ) + .unwrap() + .unwrap(); + let cancellation = AtomicBool::new(false); + let observation = observe(target, directory.path(), Some(&identity), &cancellation) + .unwrap() + .unwrap(); + assert_eq!(observation.identity.channel, ReleaseChannel::Beta); + std::fs::write(program.join(core), b"external replacement").unwrap(); + assert!( + observe(target, directory.path(), Some(&identity), &cancellation) + .unwrap() + .is_none() + ); + } + } + + #[test] + fn identity_without_a_record_or_with_cancelled_check_is_not_assumed() { + let directory = tempfile::tempdir().unwrap(); + let program = directory.path(); + let cancellation = AtomicBool::new(false); + assert!( + observe(TargetId::Chrome, program, None, &cancellation) + .unwrap() + .is_none() + ); + std::fs::copy(std::env::current_exe().unwrap(), program.join("chrome.exe")).unwrap(); + let version = + crate::services::version_probe::probe_local_version(TargetId::Chrome, program) + .unwrap() + .version; + std::fs::create_dir(program.join(&version)).unwrap(); + std::fs::write(program.join(&version).join("chrome.dll"), b"core").unwrap(); + let identity = capture(TargetId::Chrome, program, &version, ReleaseChannel::Beta) + .unwrap() + .unwrap(); + assert_eq!( + observe( + TargetId::Chrome, + program, + Some(&identity), + &AtomicBool::new(true) + ) + .unwrap_err() + .kind(), + std::io::ErrorKind::Interrupted + ); + let mut stale = identity; + stale.version = "159.0.8154.6".to_owned(); + assert!( + observe(TargetId::Chrome, program, Some(&stale), &cancellation) + .unwrap() + .is_none() + ); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 64a06d3..24d9898 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -8,6 +8,7 @@ pub mod health; pub mod http; pub mod install; mod install_transaction; +pub mod installed_channel; pub mod trusted_metadata; pub mod update_check; pub mod version_probe; diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index d892780..e075d56 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -338,7 +338,13 @@ fn show_same_version_prompt(app: &mut BrowserHarborApp, ctx: &egui::Context) { } if confirm { app.same_version_prompt = None; - app.start_operation(OperationMode::Install, true); + app.start_operation_checked_with_expected( + OperationMode::Install, + true, + false, + None, + prompt.channel_change_confirmed, + ); } else if cancel { app.same_version_prompt = None; app.operation_phase = None; diff --git a/src/ui/localization.rs b/src/ui/localization.rs index bffd2b3..8eca984 100644 --- a/src/ui/localization.rs +++ b/src/ui/localization.rs @@ -98,6 +98,7 @@ pub fn localize_widget_text(text: impl Into) -> WidgetText { fn exact_translation(text: &str) -> Option<&'static str> { Some(match text { + "已核对安装渠道" => "Installed channel verified", "已核对 Catsxp 程序文件" => "Catsxp program files verified", "已核对 EXE 与核心 DLL;第五段为 Beta 修订号" => { "EXE and core DLL verified; the fifth component identifies the Beta revision" diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 264febe..1818e9e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -306,6 +306,13 @@ pub struct BrowserHarborApp { chrome_plus_ini_loaded: BTreeSet, local_versions: BTreeMap, catsxp_observation: Option, + channel_observations: BTreeMap, + channel_receiver: Option<( + TargetId, + PathBuf, + crate::domain::release::InstalledChannelIdentity, + std::sync::mpsc::Receiver>, + )>, health_dialog: Option, same_version_prompt: Option, chrome_plus_update_prompt: Option, @@ -338,6 +345,7 @@ enum LocalVersionState { struct SameVersionPrompt { current_version: String, upstream: UpstreamCheck, + channel_change_confirmed: bool, } #[derive(Debug, Clone)] @@ -400,6 +408,8 @@ impl BrowserHarborApp { chrome_plus_ini_loaded: BTreeSet::new(), local_versions: BTreeMap::new(), catsxp_observation: None, + channel_observations: BTreeMap::new(), + channel_receiver: None, health_dialog: None, same_version_prompt: None, chrome_plus_update_prompt: None, @@ -524,6 +534,112 @@ impl BrowserHarborApp { } }; self.local_versions.insert(target, state); + self.start_channel_observation(target); + } + + fn start_channel_observation(&mut self, target: TargetId) { + let Some(config) = self.state.draft().targets.get(&target) else { + return; + }; + let Some(identity) = config.installed_channel.clone() else { + return; + }; + let Some(program) = config + .browser_directory + .as_deref() + .and_then(|path| resolve_preview_path(self.state.tool_root(), path)) + else { + return; + }; + if self + .channel_observations + .get(&target) + .is_some_and(|observation| { + observation.identity == identity + && observation.is_current(target, &program, &identity.version) + }) + { + return; + } + if self + .channel_receiver + .as_ref() + .is_some_and(|(pending, path, record, _)| { + *pending == target && path == &program && record == &identity + }) + { + return; + } + self.channel_observations.remove(&target); + let (sender, receiver) = std::sync::mpsc::channel(); + let pending_program = program.clone(); + let pending_identity = identity.clone(); + std::thread::spawn(move || { + let result = crate::services::installed_channel::observe( + target, + &program, + Some(&identity), + &std::sync::atomic::AtomicBool::new(false), + ) + .ok() + .flatten(); + let _ = sender.send(result); + }); + self.channel_receiver = Some((target, pending_program, pending_identity, receiver)); + } + + fn poll_channel_observation(&mut self) { + let Some((target, pending_program, pending_identity, receiver)) = &self.channel_receiver + else { + return; + }; + let target = *target; + let result = match receiver.try_recv() { + Ok(result) => result, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => None, + }; + let stale = self + .state + .draft() + .targets + .get(&target) + .is_some_and(|config| { + config.installed_channel.as_ref() != Some(pending_identity) + || config + .browser_directory + .as_deref() + .and_then(|path| resolve_preview_path(self.state.tool_root(), path)) + .as_ref() + != Some(pending_program) + }); + self.channel_receiver = None; + if let Some(observation) = result + && self + .state + .draft() + .targets + .get(&target) + .is_some_and(|config| { + config.installed_channel.as_ref() == Some(&observation.identity) + && config + .browser_directory + .as_deref() + .and_then(|path| resolve_preview_path(self.state.tool_root(), path)) + .is_some_and(|program| { + observation.is_current( + target, + &program, + &observation.identity.version, + ) + }) + }) + { + self.channel_observations.insert(target, observation); + } + if stale { + self.start_channel_observation(target); + } } fn refresh_program_identity(&mut self, target: TargetId) { @@ -569,6 +685,16 @@ impl BrowserHarborApp { .filter(|value| value.build.version == version) .map(|value| value.build.channel.display_name()); } + if let Some(observation) = self.channel_observations.get(&target) + && let Some(config) = self.state.draft().targets.get(&target) + && let Some(program) = config + .browser_directory + .as_deref() + .and_then(|path| resolve_preview_path(self.state.tool_root(), path)) + && observation.is_current(target, &program, version) + { + return Some(observation.identity.channel.display_name()); + } installed_channel_label(target, version, product_name) } @@ -855,6 +981,7 @@ impl BrowserHarborApp { self.same_version_prompt = Some(SameVersionPrompt { current_version: current.version, upstream, + channel_change_confirmed: false, }); return; } @@ -983,6 +1110,7 @@ impl BrowserHarborApp { self.same_version_prompt = Some(SameVersionPrompt { current_version: current.clone(), upstream, + channel_change_confirmed, }); self.status_message = Some(localized("降级需要确认,旧版可能无法读取现有用户数据")); return; @@ -1076,6 +1204,14 @@ impl BrowserHarborApp { self.operation = None; } match &event.phase { + OperationPhase::ChannelIdentified { observation } => { + if let Some(observation) = observation { + self.channel_observations + .insert(event.target, observation.clone()); + } else { + self.channel_observations.remove(&event.target); + } + } OperationPhase::CatsxpIdentified { observation } => { self.catsxp_observation = observation.clone(); self.refresh_local_version(TargetId::Catsxp); @@ -1113,12 +1249,14 @@ impl BrowserHarborApp { current_version, upstream, release, + channel_change_confirmed, } => { self.record_checked_release(event.target, upstream.clone(), release.clone()); self.status_message = Some("本地版本与上游版本相同,等待确认替换".to_owned()); self.same_version_prompt = Some(SameVersionPrompt { current_version: current_version.clone(), upstream: upstream.clone(), + channel_change_confirmed: *channel_change_confirmed, }); } OperationPhase::Installed { receipt } => { @@ -1179,6 +1317,16 @@ impl BrowserHarborApp { ) .ok(); } + self.channel_observations.remove(&event.target); + if let Some(identity) = receipt.installed_channel.clone() + && let Ok(Some(observation)) = + crate::services::installed_channel::Observation::from_installed( + &receipt.browser_directory, + identity, + ) + { + self.channel_observations.insert(event.target, observation); + } self.refresh_local_version(event.target); } OperationPhase::ChromePlusChecked { @@ -2273,6 +2421,7 @@ impl eframe::App for BrowserHarborApp { self.maybe_check_app_update(); self.poll_app_update(); self.poll_operation(); + self.poll_channel_observation(); self.handle_window_close(ui.ctx()); let window_position = frame.winit_window().and_then(|window| { let position = window.outer_position().ok()?; @@ -2283,7 +2432,7 @@ impl eframe::App for BrowserHarborApp { }) }); self.track_window_position(ui.ctx(), window_position); - if self.operation.is_some() { + if self.operation.is_some() || self.channel_receiver.is_some() { ui.ctx() .request_repaint_after(std::time::Duration::from_millis(100)); } @@ -2786,33 +2935,47 @@ fn installed_channel_label( let product = product_name.to_ascii_lowercase(); let version = version.to_ascii_lowercase(); match target { - TargetId::Chrome if product.contains("chrome") => Some(if product.contains("canary") { - "Canary" - } else if product.contains("dev") { - "Dev" - } else if product.contains("beta") { - "Beta" - } else { - "Stable" - }), - TargetId::Edge if product.contains("edge") => Some(if product.contains("canary") { - "Canary" - } else if product.contains("dev") { - "Dev" - } else if product.contains("beta") { - "Beta" - } else { - "Stable" - }), - TargetId::Brave if product.contains("brave") => Some(if product.contains("nightly") { - "Nightly" - } else if product.contains("dev") { - "Dev" - } else if product.contains("beta") { - "Beta" - } else { - "Stable" - }), + // Generic Chromium ProductName resources are shared by multiple channels. + // Absence of a channel suffix is not evidence of a Stable installation. + TargetId::Chrome if product.contains("chrome") => { + if product.contains("canary") { + Some("Canary") + } else if product.contains("dev") { + Some("Dev") + } else if product.contains("beta") { + Some("Beta") + } else if product.contains("stable") { + Some("Stable") + } else { + None + } + } + TargetId::Edge if product.contains("edge") => { + if product.contains("canary") { + Some("Canary") + } else if product.contains("dev") { + Some("Dev") + } else if product.contains("beta") { + Some("Beta") + } else if product.contains("stable") { + Some("Stable") + } else { + None + } + } + TargetId::Brave if product.contains("brave") => { + if product.contains("nightly") { + Some("Nightly") + } else if product.contains("dev") { + Some("Dev") + } else if product.contains("beta") { + Some("Beta") + } else if product.contains("stable") { + Some("Stable") + } else { + None + } + } TargetId::Firefox => Some(if version.contains("esr") { "ESR" } else if version.contains('b') { @@ -2915,6 +3078,7 @@ pub(super) fn resolve_preview_path( fn operation_status(phase: &OperationPhase) -> String { match phase { + OperationPhase::ChannelIdentified { .. } => "已核对安装渠道".to_owned(), OperationPhase::CatsxpIdentified { .. } => "已核对 Catsxp 程序文件".to_owned(), OperationPhase::ChannelMismatch { .. } => { "现有安装渠道与当前选择不一致,等待确认".to_owned() @@ -2975,6 +3139,7 @@ fn operation_cancelable(phase: &OperationPhase) -> bool { phase, OperationPhase::Preparing | OperationPhase::CatsxpIdentified { .. } + | OperationPhase::ChannelIdentified { .. } | OperationPhase::Resolving | OperationPhase::Notice(_) | OperationPhase::Downloading { .. } diff --git a/src/ui/tests.rs b/src/ui/tests.rs index a35326e..8c6dee3 100644 --- a/src/ui/tests.rs +++ b/src/ui/tests.rs @@ -340,6 +340,16 @@ fn mismatched_browser_identity_requires_install_confirmation() { #[test] fn installed_channel_is_inferred_before_replacing_program_files() { + for (target, product) in [ + (TargetId::Chrome, "Google Chrome Stable"), + (TargetId::Edge, "Microsoft Edge Stable"), + (TargetId::Brave, "Brave Stable"), + ] { + assert_eq!( + installed_channel_label(target, "157.0.1", product), + Some("Stable") + ); + } assert_eq!( installed_channel_label(TargetId::Chrome, "158.0.1", "Google Chrome Beta"), Some("Beta") @@ -378,6 +388,287 @@ fn installed_channel_is_inferred_before_replacing_program_files() { ); } +#[test] +fn unmarked_chromium_product_names_do_not_prove_a_stable_channel() { + for (target, product) in [ + (TargetId::Chrome, "Google Chrome"), + (TargetId::Edge, "Microsoft Edge"), + (TargetId::Brave, "Brave"), + ] { + assert_eq!( + installed_channel_label(target, "159.0.8154.6", product), + None, + "{product} without an explicit channel must not be classified as Stable" + ); + } +} + +#[test] +fn chrome_with_generic_product_name_preserves_version_confirmations() { + for (channel, upstream_version, expected_chip) in [ + (ReleaseChannel::Beta, None, "与上游版本相同"), + (ReleaseChannel::Stable, Some("0.0.1"), "本地版本较新"), + ] { + let directory = tempdir().unwrap(); + let root = directory.path().join("Chrome"); + let program = root.join("App"); + fs::create_dir_all(&program).unwrap(); + fs::copy(std::env::current_exe().unwrap(), program.join("chrome.exe")).unwrap(); + let version = + crate::services::version_probe::probe_local_version(TargetId::Chrome, &program) + .unwrap() + .version; + let mut settings = Settings { + selected_browser: TargetId::Chrome, + auto_check_metadata: false, + ..Settings::default() + }; + let config = settings.targets.get_mut(&TargetId::Chrome).unwrap(); + config.browser_root = Some(root); + config.browser_directory = Some(program); + config.channel = channel; + let state = AppState::new( + settings, + ConfigStore::new(directory.path().join("BrowserHarbor.json")), + ); + let mut harness = harness_for(BrowserHarborApp::new(state)); + // Both Stable and Beta packages may expose the same generic ProductName. + harness.state_mut().program_identity = ProgramIdentity::Match { + product_name: "Google Chrome".to_owned(), + }; + harness.state_mut().latest_versions.insert( + (TargetId::Chrome, channel, CpuBuild::X64), + UpstreamCheck { + version: upstream_version.unwrap_or(&version).to_owned(), + channel, + cpu_build: CpuBuild::X64, + source: MetadataSource::Official, + checked_at: 0, + }, + ); + harness.run(); + assert!(harness.query_by_label("渠道不同").is_none()); + harness.get_by_label(expected_chip); + + harness.state_mut().request_install_action(); + assert!(harness.state().channel_mismatch_prompt.is_none()); + assert!(harness.state().same_version_prompt.is_some()); + assert!(harness.state().operation.is_none()); + } +} + +#[test] +fn verified_beta_receipt_survives_reload_and_selection_changes() { + let directory = tempdir().unwrap(); + let root = directory.path().join("Chrome"); + let program = root.join("App"); + fs::create_dir_all(&program).unwrap(); + fs::copy(std::env::current_exe().unwrap(), program.join("chrome.exe")).unwrap(); + let version = crate::services::version_probe::probe_local_version(TargetId::Chrome, &program) + .unwrap() + .version; + fs::create_dir(program.join(&version)).unwrap(); + fs::write(program.join(&version).join("chrome.dll"), b"beta core").unwrap(); + let identity = crate::services::installed_channel::capture( + TargetId::Chrome, + &program, + &version, + ReleaseChannel::Beta, + ) + .unwrap() + .unwrap(); + let mut settings = Settings { + selected_browser: TargetId::Chrome, + auto_check_metadata: false, + ..Settings::default() + }; + let config = settings.targets.get_mut(&TargetId::Chrome).unwrap(); + config.browser_root = Some(root); + config.browser_directory = Some(program.clone()); + config.channel = ReleaseChannel::Stable; + config.auto_create_shortcut = false; + let path = directory.path().join("BrowserHarbor.json"); + let mut state = AppState::new(settings, ConfigStore::new(path.clone())); + state + .record_install_success( + TargetId::Chrome, + &crate::services::install::InstallReceipt { + version: version.clone(), + catsxp_build: None, + installed_channel: Some(identity), + browser_directory: program.clone(), + data_directory: directory.path().join("Profiles"), + profile_directory: directory.path().join("Profiles"), + cache_directory: None, + chrome_plus_version: None, + previous_chrome_plus_version: None, + }, + ) + .unwrap(); + let settings = ConfigStore::new(path.clone()).load().unwrap(); + assert_eq!( + settings.targets[&TargetId::Chrome] + .installed_channel + .as_ref() + .unwrap() + .channel, + ReleaseChannel::Beta + ); + let mut harness = harness_for(BrowserHarborApp::new(AppState::new( + settings, + ConfigStore::new(path), + ))); + harness.state_mut().program_identity = ProgramIdentity::Match { + product_name: "Google Chrome".into(), + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while harness + .state() + .current_channel_label(TargetId::Chrome, &version, "Google Chrome") + != Some("Beta") + { + harness.run(); + assert!( + std::time::Instant::now() < deadline, + "persisted Beta record was not verified after restart" + ); + std::thread::yield_now(); + } + harness.state_mut().latest_versions.insert( + (TargetId::Chrome, ReleaseChannel::Stable, CpuBuild::X64), + UpstreamCheck { + version: "0.0.1".into(), + channel: ReleaseChannel::Stable, + cpu_build: CpuBuild::X64, + source: MetadataSource::Official, + checked_at: 0, + }, + ); + harness.run(); + harness.get_by_label("渠道不同"); + harness.get_by_label("使用 Stable 渠道安装").click(); + harness.run(); + harness.get_by_label_contains("当前安装:Chrome Beta"); + harness.get_by_label("仍然替换").click(); + harness.run(); + assert!( + harness.state().same_version_prompt.is_some(), + "channel confirmation must not bypass downgrade confirmation" + ); + assert!( + harness + .state() + .same_version_prompt + .as_ref() + .unwrap() + .channel_change_confirmed, + "downgrade prompt must retain prior channel consent" + ); + assert!(harness.state().operation.is_none()); + harness.get_by_label("保留当前版本").click(); + harness.run(); + harness.state_mut().state.update_draft(|settings| { + settings.targets.get_mut(&TargetId::Chrome).unwrap().channel = ReleaseChannel::Beta + }); + harness.state_mut().latest_versions.insert( + (TargetId::Chrome, ReleaseChannel::Beta, CpuBuild::X64), + UpstreamCheck { + version: version.clone(), + channel: ReleaseChannel::Beta, + cpu_build: CpuBuild::X64, + source: MetadataSource::Official, + checked_at: 0, + }, + ); + harness.run(); + harness.get_by_label("与上游版本相同"); + assert!(harness.query_by_label("渠道不同").is_none()); + fs::write(program.join(&version).join("chrome.dll"), b"different core").unwrap(); + assert_eq!( + harness + .state() + .current_channel_label(TargetId::Chrome, &version, "Google Chrome"), + None + ); +} + +#[test] +fn pending_channel_observation_is_retried_after_a_path_change() { + let directory = tempdir().unwrap(); + let old_program = directory.path().join("old/App"); + let new_program = directory.path().join("new/App"); + fs::create_dir_all(&old_program).unwrap(); + fs::copy( + std::env::current_exe().unwrap(), + old_program.join("chrome.exe"), + ) + .unwrap(); + let version = + crate::services::version_probe::probe_local_version(TargetId::Chrome, &old_program) + .unwrap() + .version; + fs::create_dir(old_program.join(&version)).unwrap(); + fs::write(old_program.join(&version).join("chrome.dll"), b"beta core").unwrap(); + let identity = crate::services::installed_channel::capture( + TargetId::Chrome, + &old_program, + &version, + ReleaseChannel::Beta, + ) + .unwrap() + .unwrap(); + let observation = crate::services::installed_channel::observe( + TargetId::Chrome, + &old_program, + Some(&identity), + &std::sync::atomic::AtomicBool::new(false), + ) + .unwrap() + .unwrap(); + fs::create_dir_all(&new_program).unwrap(); + fs::copy( + old_program.join("chrome.exe"), + new_program.join("chrome.exe"), + ) + .unwrap(); + fs::create_dir(new_program.join(&version)).unwrap(); + fs::write(new_program.join(&version).join("chrome.dll"), b"beta core").unwrap(); + let mut harness = harness(); + let (sender, receiver) = std::sync::mpsc::channel(); + harness.state_mut().channel_receiver = Some(( + TargetId::Chrome, + old_program.clone(), + identity.clone(), + receiver, + )); + harness.state_mut().state.update_draft(|settings| { + let config = settings.targets.get_mut(&TargetId::Chrome).unwrap(); + config.installed_channel = Some(identity); + config.browser_directory = Some(new_program); + }); + sender.send(Some(observation)).unwrap(); + harness + .state_mut() + .start_channel_observation(TargetId::Chrome); + harness.state_mut().poll_channel_observation(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + harness.state_mut().poll_channel_observation(); + if harness + .state() + .channel_observations + .contains_key(&TargetId::Chrome) + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "new-path observation lost after stale result" + ); + std::thread::yield_now(); + } +} + #[test] fn channel_mismatch_shows_warning_chip_and_switch_action() { let directory = tempdir().unwrap(); @@ -2778,6 +3069,7 @@ fn catsxp_downgrade_does_not_claim_an_equal_base_version() { harness.state_mut().state.select(TargetId::Catsxp); harness.state_mut().same_version_prompt = Some(super::SameVersionPrompt { current_version: "156.6.12.1".into(), + channel_change_confirmed: false, upstream: UpstreamCheck::fresh("156.6.11.1.3".into(), ReleaseChannel::Beta, CpuBuild::X64), }); harness.run();