From 84851f93721fa21beb0199bff9d6c49ac7270074 Mon Sep 17 00:00:00 2001 From: lmliheng Date: Thu, 1 Oct 2026 20:56:00 +0800 Subject: [PATCH] fix(cli): render non-ASCII console output on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Windows console starts on an OEM code page (936/GBK on a Simplified Chinese system) and reinterprets the UTF-8 bytes bsk prints, so every non-ASCII character a page contributes reaches the user as mojibake: `微众银行` prints as `寰紬閾惰`. Observed with `bsk observe` against a Chinese-language application form in a stock zh-CN console. Switch the attached console to UTF-8 for the lifetime of the process and restore the previous code page on drop, so the user's shell is left as found. Redirected streams are untouched: they receive the raw bytes verbatim, with no code page in the path. --- CHANGELOG.md | 4 + crates/bsk-cli/Cargo.toml | 1 + crates/bsk-cli/src/cli/console_encoding.rs | 122 +++++++++++++++++++++ crates/bsk-cli/src/cli/mod.rs | 1 + crates/bsk-cli/src/main.rs | 5 + 5 files changed, 133 insertions(+) create mode 100644 crates/bsk-cli/src/cli/console_encoding.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ee0ce0..6375df20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number. ### Fixed +- CLI: switch the attached Windows console to UTF-8 for the lifetime of the + process, so non-ASCII page text is printed instead of mojibake on a console + with an OEM code page. The previous code page is restored on exit; redirected + streams, non-Windows hosts and consoles already using UTF-8 are left alone. - Extension: input to a background Agent Window tab no longer keeps failing with `input_not_ready` after Chrome drops the session's focus override without a detach ([#355](https://github.com/Tencent/BrowserSkill/issues/355)). The session's diff --git a/crates/bsk-cli/Cargo.toml b/crates/bsk-cli/Cargo.toml index a7ec2d7e..76ad5313 100644 --- a/crates/bsk-cli/Cargo.toml +++ b/crates/bsk-cli/Cargo.toml @@ -72,6 +72,7 @@ windows-sys = { version = "0.59", features = [ "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Pipes", + "Win32_System_Console", "Win32_Security", "Win32_Storage_FileSystem", ] } diff --git a/crates/bsk-cli/src/cli/console_encoding.rs b/crates/bsk-cli/src/cli/console_encoding.rs new file mode 100644 index 00000000..03c28487 --- /dev/null +++ b/crates/bsk-cli/src/cli/console_encoding.rs @@ -0,0 +1,122 @@ +//! Keep console output readable when the text is not ASCII. +//! +//! `bsk` prints UTF-8 on every platform. A Windows console, however, starts +//! on an OEM code page — 936/GBK on a Simplified Chinese system — and +//! reinterprets those bytes, so the non-ASCII text a page contributes +//! (snapshot names, element labels, error hints) reaches the user as +//! mojibake: `微众银行` renders as `寰紬閾惰`. Pointing the console at UTF-8 +//! for the lifetime of the process fixes the rendering, and the previous +//! code page is restored on drop so the user's shell is left as found. + +/// Code page identifier for UTF-8. +#[cfg(windows)] +const UTF8_CODE_PAGE: u32 = 65001; + +/// Switches the attached Windows console to UTF-8 output for its lifetime. +/// +/// Holds the previous code page and restores it when dropped, so the guard +/// must outlive every write. A no-op on non-Windows hosts, when no console is +/// attached, and when the console already uses UTF-8. Redirected streams are +/// never touched: those receive the raw bytes verbatim, with no code page in +/// the path. +#[must_use = "the console reverts to its previous code page when the guard drops"] +pub struct Utf8Console { + #[cfg(windows)] + restore: Option, +} + +impl Utf8Console { + /// Enable UTF-8 console output when a console is attached and not UTF-8. + pub fn enable() -> Self { + #[cfg(windows)] + { + // SAFETY: no pointer arguments; a missing console is reported as + // code page 0, which `restore_code_page` rejects. + let current = unsafe { GetConsoleOutputCP() }; + let mut restore = None; + if let Some(code_page) = restore_code_page(console_attached(), current) { + // SAFETY: plain code page id; no pointers. + if unsafe { SetConsoleOutputCP(UTF8_CODE_PAGE) } != 0 { + restore = Some(code_page); + } + } + Self { restore } + } + #[cfg(not(windows))] + { + Self {} + } + } +} + +#[cfg(windows)] +impl Drop for Utf8Console { + fn drop(&mut self) { + if let Some(code_page) = self.restore { + // SAFETY: plain code page id; no pointers. + unsafe { + SetConsoleOutputCP(code_page); + } + } + } +} + +/// The code page to restore afterwards, or `None` when nothing must change. +/// +/// Split out from [`Utf8Console::enable`] so the decision can be tested +/// without an attached console. +#[cfg(windows)] +fn restore_code_page(attached: bool, current: u32) -> Option { + if !attached || current == 0 || current == UTF8_CODE_PAGE { + return None; + } + Some(current) +} + +/// Whether the process owns a console on either output stream. +/// +/// `stderr` counts because diagnostics and the update hint go there, and a +/// single console serves both streams. +#[cfg(windows)] +fn console_attached() -> bool { + std::io::stdout().is_terminal() || std::io::stderr().is_terminal() +} + +#[cfg(windows)] +use std::io::IsTerminal; +#[cfg(windows)] +use windows_sys::Win32::System::Console::{GetConsoleOutputCP, SetConsoleOutputCP}; + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn redirected_output_is_left_alone() { + assert_eq!(restore_code_page(false, 936), None); + } + + #[test] + fn a_utf8_console_is_left_alone() { + assert_eq!(restore_code_page(true, UTF8_CODE_PAGE), None); + } + + #[test] + fn a_missing_console_is_left_alone() { + assert_eq!(restore_code_page(true, 0), None); + } + + #[test] + fn an_oem_code_page_is_remembered_for_restore() { + assert_eq!(restore_code_page(true, 936), Some(936)); + assert_eq!(restore_code_page(true, 437), Some(437)); + } + + #[test] + fn the_guard_is_inert_under_a_redirected_harness() { + // `cargo test` captures both streams, so no console is attached and + // enabling the guard must not touch global console state. + let guard = Utf8Console::enable(); + assert!(guard.restore.is_none()); + } +} diff --git a/crates/bsk-cli/src/cli/mod.rs b/crates/bsk-cli/src/cli/mod.rs index 32afe05d..3f477808 100644 --- a/crates/bsk-cli/src/cli/mod.rs +++ b/crates/bsk-cli/src/cli/mod.rs @@ -7,6 +7,7 @@ pub mod browser_wait; pub mod browsers; pub mod business_rpc; pub mod console; +pub mod console_encoding; pub mod daemon; pub mod debug; pub mod dialogs; diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index 529f3da7..881208b6 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -7,6 +7,11 @@ use bsk::cli::status::Output; use bsk::{Cli, Command, cli}; fn main() -> ExitCode { + // A Windows console starts on an OEM code page, so the UTF-8 bytes bsk + // prints would reach a human as mojibake for any non-ASCII page text. + // The guard stays alive for the whole run and restores the code page on + // the way out; it is inert when output is redirected. + let _console = bsk::cli::console_encoding::Utf8Console::enable(); let cli = match Cli::try_parse() { Ok(cli) => cli, Err(err) => {