diff --git a/Cargo.lock b/Cargo.lock index 9357a151f..8867e2995 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -804,6 +804,7 @@ dependencies = [ "time", "tokio", "toml", + "toml_edit 0.22.27", "tracing", "tracing-subscriber", "unicode-segmentation", diff --git a/Cargo.toml b/Cargo.toml index 145731951..0ea6dbdbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ sha2 = "0.10" time = { version = "0.3.47", features = ["formatting"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "signal"] } toml = "0.8" +toml_edit = "0.22" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } unicode-width = "0.2" diff --git a/docs/next/website/src/content/docs/install.mdx b/docs/next/website/src/content/docs/install.mdx index 5ec26d10e..0a9c34218 100644 --- a/docs/next/website/src/content/docs/install.mdx +++ b/docs/next/website/src/content/docs/install.mdx @@ -137,6 +137,8 @@ For direct installs, changing channels checks the selected channel and installs Preview builds are regularly published GitHub prereleases from the current development branch. They are useful when you want fixes before the next stable release, but they can regress. Homebrew, mise, and Nix installs do not use the preview channel. +The preview channel also installs stable releases when their version is higher than the latest preview's base version. It stays configured for preview, so a later preview based on that stable release becomes available automatically. Update checks do not offer releases based on an older version than the installed binary. + Stable is the recommended channel for normal Windows use. Preview receives fixes sooner but can regress, so opt in only when you want that tradeoff. Existing Windows preview installs remain on preview until you switch them explicitly. If an older preview build rejects `herdr channel set stable`, run `herdr update` once on preview, then retry the channel switch. By default, `herdr update` installs the new binary and leaves endpoint-generation-1 servers and their pane processes running. Start Herdr again to reconnect with the updated client. Client-only changes work immediately. A new server-side action stays unavailable until that server is updated and restarted; the client shows a notice instead of disconnecting. Servers from before endpoint generation 1 need one final stop during update. diff --git a/docs/next/website/src/content/docs/ja/install.mdx b/docs/next/website/src/content/docs/ja/install.mdx index c23e9fb5e..1b1c25dbb 100644 --- a/docs/next/website/src/content/docs/ja/install.mdx +++ b/docs/next/website/src/content/docs/ja/install.mdx @@ -137,6 +137,8 @@ herdr channel set stable プレビュービルドは、現在の開発ブランチから定期的に公開される GitHub プレリリースです。次の安定版リリースより前に修正が欲しいときに便利ですが、リグレッションの可能性があります。Homebrew、mise、Nix のインストールはプレビューチャンネルを使いません。 +プレビューチャンネルでも、安定版のバージョンが最新プレビューのベースバージョンより高ければ、その安定版をインストールします。設定はプレビューのままなので、その安定版をベースにした後続のプレビューも自動的に更新候補になります。インストール済みのバイナリより古いバージョンをベースにしたリリースは、更新候補として表示されません。 + Windows の通常利用には安定版を推奨します。プレビューでは修正をより早く受け取れますが、リグレッションの可能性があるため、そのトレードオフを望む場合に限りオプトインしてください。既存の Windows プレビューインストールは、明示的に切り替えるまでプレビューに残ります。古いプレビュービルドで `herdr channel set stable` が拒否される場合は、プレビューのまま一度 `herdr update` を実行してから、チャンネル切り替えを再試行してください。 デフォルトでは、`herdr update` は新しいバイナリをインストールし、エンドポイント世代 1 のサーバーとペインプロセスを動かしたままにします。再度 Herdr を起動すると、更新済みクライアントで再接続します。クライアントだけの変更はすぐに使えます。新しいサーバー側アクションは、そのサーバーを更新して再起動するまで利用できず、クライアントは切断せずに通知を表示します。エンドポイント世代 1 より前のサーバーだけは、更新時に最後の 1 回の停止が必要です。 diff --git a/docs/next/website/src/content/docs/zh-cn/install.mdx b/docs/next/website/src/content/docs/zh-cn/install.mdx index 1d44c225d..97dadb9de 100644 --- a/docs/next/website/src/content/docs/zh-cn/install.mdx +++ b/docs/next/website/src/content/docs/zh-cn/install.mdx @@ -137,6 +137,8 @@ herdr channel set stable 预览构建是从当前开发分支定期发布的 GitHub 预发布。当你想在下一个稳定版之前拿到修复时很有用,但它们可能出现回归。Homebrew、mise 和 Nix 安装不使用预览通道。 +当稳定版的版本号高于最新预览构建的基础版本时,预览通道也会安装该稳定版。通道设置仍保留为预览,因此之后基于该稳定版发布的预览构建会自动成为更新候选。更新检查不会提供基于比已安装二进制文件更旧版本的发布。 + Windows 的常规使用建议选择稳定版。预览版能更早获得修复,但可能出现回归,因此只有在接受这一取舍时才应主动启用。现有的 Windows 预览安装会继续留在预览通道,直到你明确切换。如果旧预览构建拒绝 `herdr channel set stable`,请先在预览通道运行一次 `herdr update`,再重试切换通道。 默认情况下,`herdr update` 会安装新二进制文件,并让端点第 1 代服务器及其窗格进程继续运行。再次启动 Herdr 即可用更新后的客户端重新连接。纯客户端改动会立即生效。新的服务器端操作会保持不可用,直到对应服务器更新并重启;客户端会显示通知,而不会断开连接。只有早于端点第 1 代的服务器需要在更新时再停止最后一次。 diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 7f797ef7f..d9285fbc6 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -181,7 +181,13 @@ impl ClientShellState { if let Err(error) = crate::config::update_file_at( &self.config.local_config_path, "onboarding setting", - |content| crate::config::upsert_top_level_bool(content, "onboarding", false), + |content| { + Ok(crate::config::upsert_top_level_bool( + content, + "onboarding", + false, + )) + }, ) { self.set_local_config_diagnostic(Some(error)); } diff --git a/src/config/write.rs b/src/config/write.rs index f34ec2f65..97e81f073 100644 --- a/src/config/write.rs +++ b/src/config/write.rs @@ -49,7 +49,7 @@ impl ConfigEdit<'_> { pub(crate) fn update_file_at( path: &std::path::Path, description: &str, - update: impl FnOnce(&str) -> String, + update: impl FnOnce(&str) -> Result, ) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -64,13 +64,13 @@ pub(crate) fn update_file_at( )); } }; - std::fs::write(path, update(&content)) - .map_err(|error| format!("failed to save {description}: {error}")) + let updated = update(&content)?; + std::fs::write(path, updated).map_err(|error| format!("failed to save {description}: {error}")) } pub(crate) fn write_edit(edit: ConfigEdit<'_>) -> Result<(), String> { update_file_at(&super::config_path(), edit.description(), |content| { - edit.apply(content) + Ok(edit.apply(content)) }) } @@ -90,7 +90,11 @@ mod tests { .unwrap(); update_file_at(&path, "onboarding setting", |content| { - crate::config::upsert_top_level_bool(content, "onboarding", false) + Ok(crate::config::upsert_top_level_bool( + content, + "onboarding", + false, + )) }) .unwrap(); diff --git a/src/update.rs b/src/update.rs index 854bacdce..f12af0aa7 100644 --- a/src/update.rs +++ b/src/update.rs @@ -381,12 +381,15 @@ fn handle_manifest_announcement(version: &str, value: Option<&serde_json::Value> } } -fn release_info_from_manifest(manifest: &UpdateManifest) -> Result, String> { - let current = Version::current(); +fn release_info_from_manifest( + manifest: &UpdateManifest, + current: &Version, + installed_is_preview: bool, +) -> Result, String> { let latest = Version::parse(&manifest.version) .ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?; - if !stable_channel_should_install(&latest, ¤t, crate::build_info::is_preview()) { + if !stable_channel_should_install(&latest, current, installed_is_preview) { return Ok(None); // up to date } @@ -449,6 +452,7 @@ fn preview_display_version(base_version: &str, build_id: &str) -> String { fn release_info_from_preview_manifest( manifest: &PreviewManifest, + installed_preview_build_id: Option<&str>, ) -> Result, String> { if manifest.channel != "preview" { return Err(format!( @@ -460,9 +464,7 @@ fn release_info_from_preview_manifest( if build_id.is_empty() { return Err("preview manifest build_id is empty".into()); } - if crate::build_info::is_preview() - && crate::build_info::build_id().is_some_and(|current| current == build_id) - { + if installed_preview_build_id.is_some_and(|current| current == build_id) { return Ok(None); } @@ -521,7 +523,6 @@ fn release_info_from_preview_manifest( })) } -/// Check the hosted update manifest for the latest release. Returns release info if newer. fn first_windows_stable_is_pending( manifest: &UpdateManifest, is_windows: bool, @@ -530,19 +531,65 @@ fn first_windows_stable_is_pending( is_windows && installed_is_preview && !manifest.assets.contains_key("windows-x86_64") } -fn check_latest() -> Result, String> { - let channel = UpdateChannel::configured(); - if channel == UpdateChannel::Preview { - return release_info_from_preview_manifest(&fetch_preview_manifest()?); +fn release_info_from_manifests( + stable: &UpdateManifest, + preview: Option<&PreviewManifest>, + current: &Version, + installed_is_preview: bool, + installed_build_id: Option<&str>, +) -> Result, String> { + if let Some(preview) = preview { + let stable_version = Version::parse(&stable.version) + .ok_or_else(|| format!("invalid version in update manifest: {}", stable.version))?; + let preview_base = Version::parse(&preview.base_version).ok_or_else(|| { + format!( + "invalid base_version in preview manifest: {}", + preview.base_version + ) + })?; + let (os, arch) = platform_target(); + // A preview is development after its base release; stable supersedes older bases. + if stable_version > preview_base && stable.assets.contains_key(&format!("{os}-{arch}")) { + if stable_version <= *current { + return Ok(None); + } + } else { + if preview_base < *current { + return Ok(None); + } + return release_info_from_preview_manifest( + preview, + installed_build_id.filter(|_| installed_is_preview), + ); + } } - let manifest = fetch_update_manifest()?; - if first_windows_stable_is_pending(&manifest, cfg!(windows), crate::build_info::is_preview()) { + if first_windows_stable_is_pending(stable, cfg!(windows), installed_is_preview) { tracing::info!("waiting for the first stable Windows release"); return Ok(None); } - let release = release_info_from_manifest(&manifest)?; - if let Some(release) = &release { + release_info_from_manifest(stable, current, installed_is_preview) +} + +/// Check the hosted update manifests for the latest release. Returns release info if newer. +fn check_latest() -> Result, String> { + let manifest = fetch_update_manifest()?; + let preview = if UpdateChannel::configured() == UpdateChannel::Preview { + Some(fetch_preview_manifest()?) + } else { + None + }; + let release = release_info_from_manifests( + &manifest, + preview.as_ref(), + &Version::current(), + crate::build_info::is_preview(), + crate::build_info::build_id(), + )?; + if let Some(release) = release + .as_ref() + .filter(|release| release.channel == UpdateChannel::Stable) + { if let Some(metadata) = manifest.metadata_for_version(&release.version) { handle_manifest_announcement( &release.version.to_string(), @@ -2105,6 +2152,33 @@ fn homebrew_cellar_keg_root(path: &Path) -> Option { // Public API // --------------------------------------------------------------------------- +#[cfg(any(windows, test))] +fn preserve_preview_channel_for_install( + config_path: &Path, + configured_channel: UpdateChannel, + release_channel: UpdateChannel, +) -> Result<(), String> { + if configured_channel == UpdateChannel::Preview && release_channel == UpdateChannel::Stable { + // Windows preview defaults would otherwise become stable with the new binary. + crate::config::update_file_at(config_path, "update channel", |content| { + let mut document = content + .parse::() + .map_err(|error| format!("cannot preserve update channel: {error}"))?; + let update = document + .entry("update") + .or_insert(toml_edit::Item::Table(toml_edit::Table::new())) + .as_table_like_mut() + .ok_or("cannot preserve update channel: update must be a table")?; + if update.contains_key("channel") { + return Ok(content.to_owned()); + } + update.insert("channel", toml_edit::value("preview")); + Ok(document.to_string()) + })?; + } + Ok(()) +} + /// Manual self-update command (`herdr update`). pub fn self_update(options: SelfUpdateOptions) -> Result { let channel = UpdateChannel::configured(); @@ -2179,6 +2253,11 @@ pub fn self_update(options: SelfUpdateOptions) -> Result { eprintln!("downloading {}...", release.label()); let downloaded_update = download_windows_update(&release)?; eprintln!("downloaded {}", release.label()); + preserve_preview_channel_for_install( + &crate::config::config_path(), + channel, + release.channel, + )?; install_windows_update_with_installer(&release, &downloaded_update)?; let updated_exe = windows_installed_herdr_exe_path()?; eprintln!("installed {}", release.label()); @@ -2500,6 +2579,218 @@ fn platform_target() -> (&'static str, &'static str) { // Tests // --------------------------------------------------------------------------- +#[cfg(test)] +mod channel_tests { + use super::*; + + fn manifests() -> (UpdateManifest, PreviewManifest) { + let (os, arch) = platform_target(); + let asset_key = format!("{os}-{arch}"); + let stable = serde_json::from_value(serde_json::json!({ + "version": "0.9.5", + "notes": "stable notes", + "protocol": 22, + "endpoint_generation": 1, + "assets": { (asset_key.clone()): { + "url": "https://example.com/stable.zip", + "sha256": "stable-checksum", + "format": "zip" + }} + })) + .unwrap(); + let preview = serde_json::from_value(serde_json::json!({ + "channel": "preview", + "base_version": "0.9.4", + "build_id": "active-preview", + "commit": "preview-commit", + "built_at": "2026-10-14T15:27:19Z", + "protocol": 23, + "endpoint_generation": 1, + "notes": "preview notes", + "assets": { (asset_key): { + "url": "https://example.com/preview.zip", + "sha256": "preview-checksum", + "format": "zip" + }} + })) + .unwrap(); + (stable, preview) + } + + #[test] + fn preview_channel_reconciles_releases_without_downgrading_or_reinstalling() { + use UpdateChannel::{Preview, Stable}; + + for (current, installed_build, stable_version, preview_base, expected) in [ + ( + "0.9.4", + Some("active-preview"), + "0.9.5", + "0.9.4", + Some(Stable), + ), + ("0.9.4", None, "0.9.5", "0.9.4", Some(Stable)), + ("0.9.5", None, "0.9.5", "0.9.4", None), + ("0.9.5", None, "0.9.5", "0.9.5", Some(Preview)), + ("0.9.5", Some("active-preview"), "0.9.5", "0.9.5", None), + ( + "0.9.5", + Some("older-preview"), + "0.9.5", + "0.9.5", + Some(Preview), + ), + ( + "0.9.5", + Some("older-preview"), + "0.9.5", + "0.9.6", + Some(Preview), + ), + ("0.9.6", Some("older-preview"), "0.9.5", "0.9.4", None), + ("0.9.6", Some("older-preview"), "0.9.4", "0.9.5", None), + ("0.9.6", None, "0.9.5", "0.9.4", None), + ("0.9.5", Some("older-preview"), "0.9.5", "0.9.4", None), + ] { + let (mut stable, mut preview) = manifests(); + stable.version = stable_version.into(); + preview.base_version = preview_base.into(); + let release = release_info_from_manifests( + &stable, + Some(&preview), + &Version::parse(current).unwrap(), + installed_build.is_some(), + installed_build, + ) + .unwrap(); + assert_eq!( + release.as_ref().map(|release| release.channel), + expected, + "installed {current}/{installed_build:?}, stable {stable_version}, preview {preview_base}" + ); + if let Some(release) = release { + let source = release.channel.as_str(); + assert_eq!( + release.download_url, + format!("https://example.com/{source}.zip") + ); + assert_eq!(release.notes_body, format!("{source} notes")); + assert_eq!(release.sha256, Some(format!("{source}-checksum"))); + #[cfg(not(windows))] + assert_eq!( + release.target_protocol, + Some(if source == "stable" { 22 } else { 23 }) + ); + } + } + } + + #[test] + fn preview_channel_validates_assets_only_for_the_selected_release() { + let (mut stable, mut preview) = manifests(); + let current = Version::parse("0.9.4").unwrap(); + let preview_assets = std::mem::take(&mut preview.assets); + let release = release_info_from_manifests( + &stable, + Some(&preview), + ¤t, + true, + Some("active-preview"), + ) + .unwrap() + .expect("stable supersedes the installed preview even without preview assets"); + assert_eq!(release.channel, UpdateChannel::Stable); + + stable.assets.clear(); + preview.assets = preview_assets; + let release = release_info_from_manifests( + &stable, + Some(&preview), + ¤t, + true, + Some("older-preview"), + ) + .unwrap() + .expect("preview remains available when stable has no platform asset"); + assert_eq!(release.channel, UpdateChannel::Preview); + } + + #[test] + fn explicit_stable_channel_still_allows_returning_from_a_newer_preview() { + let (stable, _) = manifests(); + let release = release_info_from_manifests( + &stable, + None, + &Version::parse("0.9.6").unwrap(), + true, + Some("newer-preview"), + ) + .unwrap() + .expect("explicit stable selection permits a downgrade"); + assert_eq!(release.identity, "0.9.5"); + assert_eq!(release.channel, UpdateChannel::Stable); + } + + #[test] + fn stable_fallback_persists_an_implicit_preview_preference() { + let dir = std::env::temp_dir().join(format!( + "herdr-preview-channel-install-{}", + std::process::id() + )); + let path = dir.join("config.toml"); + for original in [ + None, + Some("# keep settings\n[update]\nversion_check = false\n"), + Some("# keep settings\n[update] # updates\nversion_check = false\n"), + Some("# keep settings\n[\"update\"] # updates\nversion_check = false\n"), + Some("# keep settings\nupdate = { version_check = false }\n"), + Some("# keep settings\nupdate.version_check = false\n"), + Some("# keep settings\n[update] # updates\nversion_check = false\n'channel' = 'preview' # keep\n"), + ] { + if let Some(original) = original { + fs::write(&path, original).unwrap(); + } + preserve_preview_channel_for_install( + &path, + UpdateChannel::Preview, + UpdateChannel::Stable, + ) + .unwrap(); + let written = fs::read_to_string(&path).unwrap(); + let config: crate::config::Config = toml::from_str(&written).unwrap(); + assert_eq!( + config.update.channel, + crate::config::UpdateChannelConfig::Preview + ); + if original.is_some() { + assert!(!config.update.version_check); + assert!(written.starts_with("# keep settings\n")); + } + if original.is_some_and(|original| original.contains("'channel'")) { + assert_eq!(written, original.unwrap()); + } + } + for original in ["[update", "update = 1\n"] { + fs::write(&path, original).unwrap(); + assert!(preserve_preview_channel_for_install( + &path, + UpdateChannel::Preview, + UpdateChannel::Stable, + ) + .is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } + fs::remove_file(&path).unwrap(); + preserve_preview_channel_for_install(&path, UpdateChannel::Stable, UpdateChannel::Stable) + .unwrap(); + assert!( + !path.exists(), + "stable updates must not create a preview preference" + ); + fs::remove_dir(&dir).unwrap(); + } +} + #[cfg(all(test, unix))] mod tests { use super::*; @@ -3784,9 +4075,11 @@ mod tests { ); let manifest: UpdateManifest = serde_json::from_str(&json).unwrap(); - assert!(release_info_from_manifest(&manifest) - .unwrap_err() - .contains("missing a SHA-256 checksum")); + assert!( + release_info_from_manifest(&manifest, &Version::current(), false) + .unwrap_err() + .contains("missing a SHA-256 checksum") + ); } #[test] @@ -3814,7 +4107,7 @@ mod tests { let manifest: UpdateManifest = serde_json::from_str(&json).unwrap(); handle_manifest_announcement(&manifest.version, manifest.announcement.as_ref()); - let release = release_info_from_manifest(&manifest) + let release = release_info_from_manifest(&manifest, &Version::current(), false) .unwrap() .expect("release info"); @@ -3875,7 +4168,7 @@ mod tests { ); let manifest: PreviewManifest = serde_json::from_str(&json).unwrap(); - let release = release_info_from_preview_manifest(&manifest) + let release = release_info_from_preview_manifest(&manifest, None) .unwrap() .expect("preview update");