diff --git a/CHANGELOG.md b/CHANGELOG.md index de878b9df7..c7938ab430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,12 @@ backup taken before the upgrade instead. Three things change for an existing dep and both said they were in progress at once ("Step 2 of 4" and "Step 3 of 4"). The step now shows one step at a time: the rest of OpenBot is step 3, shown once step 2 is done, as far along as it got meanwhile. +- **Windows setup no longer opens Microsoft's "Welcome to Windows Subsystem for Linux" window, and + the installer asks plainly to close a running OpenBot.** The welcome window opened partway + through setup, the first time OpenBot's Podman machine was made on a computer with no Linux + distribution. It isn't OpenBot's and isn't needed, so OpenBot now marks it as already seen for + you before it makes the machine. Installing over a running OpenBot said "OpenBot is running! + Click OK to kill it". It now says "OpenBot is open. Click OK to close it and continue." - **Setup says what it's doing in plain words, and what's installing behind every step.** The setup window said "Preparing the engine machine", "Installing Podman", "Waiting for the back end" and "Updating the back end to 0.16.0-rc.10", and a tester waiting on the first step couldn't tell diff --git a/desktop/scripts/installer-language.test.ts b/desktop/scripts/installer-language.test.ts new file mode 100644 index 0000000000..7a72df8906 --- /dev/null +++ b/desktop/scripts/installer-language.test.ts @@ -0,0 +1,137 @@ +/** + * The Windows installer asks to close a running OpenBot in plain words. + * + * Installing over a running OpenBot showed NSIS's stock "OpenBot is running! Click OK to kill it", + * from Tauri's English.nsh. Tauri 2 replaces that file with one named in + * bundle > windows > nsis > customLanguageFiles (https://v2.tauri.app/reference/config/), so + * src-tauri/windows/installer-english.nsh is Tauri's English.nsh with the three running-app + * messages rewritten. The installer and the uninstaller both show them, with OK and Cancel. + * + * The file replaces Tauri's whole English table, so it has to define every string Tauri's + * template uses. That list belongs to the Tauri CLI version it was copied from, which is pinned + * here against desktop/bun.lock: a CLI upgrade fails this test until the file is compared again. + * + * Plain file reads only, so it runs on any runner. + */ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const desktop = join(import.meta.dir, ".."); +const tauri = join(desktop, "src-tauri"); +const readJson = (file: string) => + JSON.parse(readFileSync(join(tauri, file), "utf8")); + +type Json = Record; + +/** RFC 7396 merge, as `tauri build --config` lays an overlay on tauri.conf.json. */ +function merge(base: unknown, patch: unknown): unknown { + if (typeof patch !== "object" || patch === null || Array.isArray(patch)) + return patch; + const out: Json = + typeof base === "object" && base !== null && !Array.isArray(base) + ? { ...(base as Json) } + : {}; + for (const [key, value] of Object.entries(patch as Json)) { + if (value === null) delete out[key]; + else out[key] = merge(out[key], value); + } + return out; +} + +const nsisOf = (config: unknown) => + (config as { bundle?: { windows?: { nsis?: Json } } }).bundle?.windows + ?.nsis ?? {}; + +const LANGUAGE_FILE = "./windows/installer-english.nsh"; + +/** The Tauri CLI whose English.nsh the file was copied from. */ +const COPIED_FROM_CLI = "2.11.4"; + +/** Every string Tauri 2.11.4's installer.nsi and utils.nsh read from the English table. */ +const TAURI_STRINGS = [ + "addOrReinstall", + "alreadyInstalled", + "alreadyInstalledLong", + "appRunning", + "appRunningOkKill", + "chooseMaintenanceOption", + "choowHowToInstall", + "createDesktop", + "deleteAppData", + "dontUninstall", + "dontUninstallDowngrade", + "failedToKillApp", + "installingWebview2", + "newerVersionInstalled", + "older", + "olderOrUnknownVersionInstalled", + "silentDowngrades", + "unableToUninstall", + "uninstallApp", + "uninstallBeforeInstalling", + "unknown", + "webview2AbortError", + "webview2DownloadError", + "webview2DownloadSuccess", + "webview2Downloading", + "webview2InstallError", + "webview2InstallSuccess", +]; + +/** The English table the file defines: string name to text. */ +function englishStrings() { + const path = join(tauri, LANGUAGE_FILE); + expect(existsSync(path)).toBe(true); + const strings = new Map(); + for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { + const match = /^LangString (\w+) \$\{LANG_ENGLISH\} "(.*)"$/.exec(line); + if (match) strings.set(match[1], match[2]); + } + return strings; +} + +const builds = { + "desktop CI": readJson("tauri.conf.json"), + "Windows signing": merge( + readJson("tauri.conf.json"), + readJson("tauri.windows-signing.conf.json"), + ), +}; + +for (const [build, config] of Object.entries(builds)) { + test(`the ${build} build uses OpenBot's English installer text`, () => { + expect(nsisOf(config)).toMatchObject({ + languages: ["English"], + customLanguageFiles: { English: LANGUAGE_FILE }, + }); + }); +} + +test("installing over a running OpenBot asks to close it in plain words", () => { + const strings = englishStrings(); + expect(strings.get("appRunningOkKill")).toBe( + "{{product_name}} is open. Click OK to close it and continue.", + ); + expect(strings.get("appRunning")).toBe( + "{{product_name}} is still open. Close it, then try again.", + ); + expect(strings.get("failedToKillApp")).toBe( + "Couldn't close {{product_name}}. Close it yourself, then try again.", + ); + for (const name of ["appRunningOkKill", "appRunning", "failedToKillApp"]) { + const text = strings.get(name) ?? ""; + expect(text).not.toMatch(/kill|running!|—/i); + } +}); + +test("the file defines every string Tauri's installer reads", () => { + const lock = readFileSync(join(desktop, "bun.lock"), "utf8"); + const locked = /"@tauri-apps\/cli": \["@tauri-apps\/cli@([^"]+)"/.exec( + lock, + )?.[1]; + expect(locked).toBe(COPIED_FROM_CLI); + expect([...englishStrings().keys()].sort()).toEqual( + [...TAURI_STRINGS].sort(), + ); +}); diff --git a/desktop/src-tauri/src/acquire.rs b/desktop/src-tauri/src/acquire.rs index 865c4fb345..3acc6704f5 100644 --- a/desktop/src-tauri/src/acquire.rs +++ b/desktop/src-tauri/src/acquire.rs @@ -300,13 +300,25 @@ pub fn machine_exists() -> Result { /// `image` is the machine image already downloaded (`early_downloads`), which Podman builds the /// machine from instead of pulling its own from quay.io: `--image` takes a local path, and unpacks a /// compressed one itself. `None` leaves the pull to Podman, as before. +/// +/// On Windows, `machine init` registers a WSL distribution, and the first one a person registers +/// opens WSL's own welcome window. `before_init` turns that window off first: see +/// `windows::skip_wsl_welcome`. pub fn create_machine( cpus: u32, memory_mib: u32, disk_gib: u32, image: Option<&Path>, ) -> StepOutcome { - create_machine_with(cpus, memory_mib, disk_gib, image, machine_exists, podman) + create_machine_with( + cpus, + memory_mib, + disk_gib, + image, + machine_exists, + crate::windows::skip_wsl_welcome, + podman, + ) } fn create_machine_with( @@ -315,6 +327,7 @@ fn create_machine_with( disk_gib: u32, image: Option<&Path>, exists: impl FnOnce() -> Result, + before_init: impl FnOnce(), mut run: impl FnMut(&[&str]) -> Result, ) -> StepOutcome { match exists() { @@ -329,6 +342,7 @@ fn create_machine_with( args.extend(["--image".to_string(), image.to_string_lossy().into_owned()]); } let refs = args.iter().map(String::as_str).collect::>(); + before_init(); match run(&refs) { Ok(_) => StepOutcome::went(Step::CreateMachine, format!("{MACHINE} created.")), Err(error) => StepOutcome::stopped(Step::CreateMachine, &error), @@ -808,6 +822,7 @@ mod tests { 20, None, || machine_exists_with(|| Ok(listing.into())), + || {}, |_args| { init_called = true; Err("machine openbot already exists".into()) @@ -834,6 +849,7 @@ mod tests { Err("podman machine list exited with status 125; stdout: denied".into()) }) }, + || {}, |_args| { init_called = true; Ok(String::new()) @@ -868,6 +884,7 @@ mod tests { 20, None, || machine_exists_with(|| Ok(listing.into())), + || {}, |_args| panic!("machine init must not run after malformed list output"), ); assert!(!result.ok, "{result:?}"); @@ -891,6 +908,7 @@ mod tests { 64, None, || Ok(false), + || {}, |args| { captured = args.iter().map(|arg| (*arg).to_string()).collect(); Ok(String::new()) @@ -916,6 +934,7 @@ mod tests { 60, Some(image), || Ok(false), + || {}, |args| { captured = args.iter().map(|arg| (*arg).to_string()).collect(); Ok(String::new()) @@ -929,6 +948,41 @@ mod tests { ); } + /// WSL opens its own "Welcome to Windows Subsystem for Linux" window the first time a person + /// registers a distribution, and `podman machine init` registers one. It is turned off first, + /// and only when a machine is about to be made: an existing machine registers nothing. + #[test] + fn wsl_welcome_window_is_turned_off_before_the_machine_is_made() { + let calls = std::cell::RefCell::new(Vec::::new()); + let result = create_machine_with( + 4, + 6144, + 60, + None, + || Ok(false), + || calls.borrow_mut().push("wsl welcome off".into()), + |args| { + calls.borrow_mut().push(args[..2].join(" ")); + Ok(String::new()) + }, + ); + assert!(result.ok, "{result:?}"); + assert_eq!(*calls.borrow(), ["wsl welcome off", "machine init"]); + + calls.borrow_mut().clear(); + let existing = create_machine_with( + 4, + 6144, + 60, + None, + || Ok(true), + || calls.borrow_mut().push("wsl welcome off".into()), + |args| panic!("nothing runs against an existing machine: {args:?}"), + ); + assert!(existing.ok, "{existing:?}"); + assert!(calls.borrow().is_empty(), "{:?}", calls.borrow()); + } + #[test] fn windows_machine_init_enables_user_mode_networking() { assert_eq!( @@ -985,6 +1039,7 @@ mod tests { 60, None, || machine_exists_with(|| Ok(listing.into())), + || {}, |args| panic!("nothing runs against an existing machine: {args:?}"), ); assert!(result.ok, "{result:?}"); diff --git a/desktop/src-tauri/src/windows.rs b/desktop/src-tauri/src/windows.rs index 4adc403d16..898ab624bd 100644 --- a/desktop/src-tauri/src/windows.rs +++ b/desktop/src-tauri/src/windows.rs @@ -1005,6 +1005,42 @@ pub fn drive_of(path: &str) -> Option { #[cfg(target_os = "windows")] const LXSS: &str = r"Software\Microsoft\Windows\CurrentVersion\Lxss"; +/// WSL's note, under `LXSS`, that this person has had its welcome window: a DWORD, 1 once shown. +#[cfg(target_os = "windows")] +const WSL_WELCOME_SHOWN: &str = "OOBEComplete"; + +/** +Keep WSL's own "Welcome to Windows Subsystem for Linux" window from opening when Podman makes +OpenBot's machine. + +WSL's service opens that window (`wslsettings.exe`, `wsl-settings://oobe`) when a person registers +a distribution, unless `OOBEComplete` under their own `HKCU\...\Lxss` key is set or they already +had another distribution (`LxssUserSessionImpl::_LaunchOOBEIfNeeded` in microsoft/WSL). +`podman machine init` registers one through `wsl --import`, so on a computer that has never had a +distribution the window opens in the middle of OpenBot's setup. Nothing on the command line stops +it: `--import` turns off only the distribution's own first run, `wsl --install --no-launch` is about +starting a distribution after installing one, and `.wslconfig` has no such key. Setting the value +is what the WSL team gives as the way to stop the window (microsoft/WSL#13223), and Microsoft's +WindowsDeveloperConfig sets it for the same reason. + +The value is the person's own, so it is written here, in their session, just before the machine is +made. Not in the elevated `ENABLE_WSL`, which runs as whoever approved the prompt. Best effort: a +value that cannot be written costs only the window. +*/ +pub fn skip_wsl_welcome() { + #[cfg(target_os = "windows")] + if let Err(error) = skip_wsl_welcome_in(LXSS) { + log::warn!("[wsl] could not turn off WSL's welcome window: {error}"); + } +} + +#[cfg(target_os = "windows")] +fn skip_wsl_welcome_in(registry: &str) -> windows_registry::Result<()> { + windows_registry::CURRENT_USER + .create(registry)? + .set_u32(WSL_WELCOME_SHOWN, 1) +} + #[cfg(target_os = "windows")] fn machine_drive_in(registry: &str, distribution: &str) -> Option { let folder = distribution_folder(registry, distribution)?; @@ -1117,6 +1153,37 @@ mod tests { assert_eq!(missing, None); } + /// WSL reads `OOBEComplete` as a DWORD under the person's own Lxss key, and opens its welcome + /// window while it is missing or 0 (`LXSS_REGISTRY_PATH`, `LXSS_OOBE_COMPLETE_NAME` in + /// microsoft/WSL's wslservice.idl). Written here under a key of this test's own, from missing + /// and from 0, in the person's real hive. + #[cfg(target_os = "windows")] + #[test] + fn the_wsl_welcome_window_is_marked_shown_where_wsl_reads_it() { + assert_eq!(LXSS, r"Software\Microsoft\Windows\CurrentVersion\Lxss"); + assert_eq!(WSL_WELCOME_SHOWN, "OOBEComplete"); + + let registry = format!(r"Software\OpenBot\Tests\LxssWelcome-{}", std::process::id()); + let fresh = skip_wsl_welcome_in(®istry); + let after_fresh = windows_registry::CURRENT_USER + .open(®istry) + .and_then(|key| key.get_u32(WSL_WELCOME_SHOWN)); + let reset = windows_registry::CURRENT_USER + .create(®istry) + .and_then(|key| key.set_u32(WSL_WELCOME_SHOWN, 0)); + let again = skip_wsl_welcome_in(®istry); + let after_reset = windows_registry::CURRENT_USER + .open(®istry) + .and_then(|key| key.get_u32(WSL_WELCOME_SHOWN)); + let _ = windows_registry::CURRENT_USER.remove_tree(®istry); + + fresh.expect("the value was written to a key that did not exist"); + assert_eq!(after_fresh.expect("the value reads back as a DWORD"), 1); + reset.expect("the test set the value back to 0"); + again.expect("the value was written over 0"); + assert_eq!(after_reset.expect("the value reads back as a DWORD"), 1); + } + const PROBE_OUTPUTS: [&str; 6] = [ "hypervisor=True\nfirmware=False\n", "True\n", diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index fd5907d911..654a4e0c90 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -34,6 +34,10 @@ "windows": { "nsis": { "installerHooks": "./windows/hooks.nsh", + "languages": ["English"], + "customLanguageFiles": { + "English": "./windows/installer-english.nsh" + }, "installerIcon": "./icons/icon.ico", "uninstallerIcon": "./icons/icon.ico", "headerImage": "./windows/installer-header.bmp", diff --git a/desktop/src-tauri/windows/installer-english.nsh b/desktop/src-tauri/windows/installer-english.nsh new file mode 100644 index 0000000000..a11846daf3 --- /dev/null +++ b/desktop/src-tauri/windows/installer-english.nsh @@ -0,0 +1,35 @@ +; OpenBot's English text for the Windows installer: bundle.windows.nsis.customLanguageFiles in tauri.conf.json. +; https://v2.tauri.app/reference/config/#customlanguagefiles +; +; Tauri's own English.nsh from tauri-cli v2.11.4 +; (crates/tauri-bundler/src/bundle/windows/nsis/languages/English.nsh), which this file replaces whole, with the +; three messages for a running OpenBot rewritten: appRunningOkKill, appRunning and failedToKillApp. The installer and +; the uninstaller both show them, in a box with OK and Cancel; OK closes OpenBot, Cancel stops. Every other string is +; Tauri's, unchanged. desktop/scripts/installer-language.test.ts holds the list of strings to the Tauri CLI version. +LangString addOrReinstall ${LANG_ENGLISH} "Add/Reinstall components" +LangString alreadyInstalled ${LANG_ENGLISH} "Already Installed" +LangString alreadyInstalledLong ${LANG_ENGLISH} "${PRODUCTNAME} ${VERSION} is already installed. Select the operation you want to perform and click Next to continue." +LangString appRunning ${LANG_ENGLISH} "{{product_name}} is still open. Close it, then try again." +LangString appRunningOkKill ${LANG_ENGLISH} "{{product_name}} is open. Click OK to close it and continue." +LangString chooseMaintenanceOption ${LANG_ENGLISH} "Choose the maintenance option to perform." +LangString choowHowToInstall ${LANG_ENGLISH} "Choose how you want to install ${PRODUCTNAME}." +LangString createDesktop ${LANG_ENGLISH} "Create desktop shortcut" +LangString dontUninstall ${LANG_ENGLISH} "Do not uninstall" +LangString dontUninstallDowngrade ${LANG_ENGLISH} "Do not uninstall (Downgrading without uninstall is disabled for this installer)" +LangString failedToKillApp ${LANG_ENGLISH} "Couldn't close {{product_name}}. Close it yourself, then try again." +LangString installingWebview2 ${LANG_ENGLISH} "Installing WebView2..." +LangString newerVersionInstalled ${LANG_ENGLISH} "A newer version of ${PRODUCTNAME} is already installed! It is not recommended that you install an older version. If you really want to install this older version, it's better to uninstall the current version first. Select the operation you want to perform and click Next to continue." +LangString older ${LANG_ENGLISH} "older" +LangString olderOrUnknownVersionInstalled ${LANG_ENGLISH} "An $R4 version of ${PRODUCTNAME} is installed on your system. It's recommended that you uninstall the current version before installing. Select the operation you want to perform and click Next to continue." +LangString silentDowngrades ${LANG_ENGLISH} "Downgrades are disabled for this installer, can't proceed with the silent installer, please use the graphical interface installer instead.$\n" +LangString unableToUninstall ${LANG_ENGLISH} "Unable to uninstall!" +LangString uninstallApp ${LANG_ENGLISH} "Uninstall ${PRODUCTNAME}" +LangString uninstallBeforeInstalling ${LANG_ENGLISH} "Uninstall before installing" +LangString unknown ${LANG_ENGLISH} "unknown" +LangString webview2AbortError ${LANG_ENGLISH} "Failed to install WebView2! The app can't run without it. Try restarting the installer." +LangString webview2DownloadError ${LANG_ENGLISH} "Error: Downloading WebView2 Failed - $0" +LangString webview2DownloadSuccess ${LANG_ENGLISH} "WebView2 bootstrapper downloaded successfully" +LangString webview2Downloading ${LANG_ENGLISH} "Downloading WebView2 bootstrapper..." +LangString webview2InstallError ${LANG_ENGLISH} "Error: Installing WebView2 failed with exit code $1" +LangString webview2InstallSuccess ${LANG_ENGLISH} "WebView2 installed successfully" +LangString deleteAppData ${LANG_ENGLISH} "Delete the application data"