Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions desktop/scripts/installer-language.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** 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<string, string>();
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(),
);
});
57 changes: 56 additions & 1 deletion desktop/src-tauri/src/acquire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,13 +300,25 @@ pub fn machine_exists() -> Result<bool, String> {
/// `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(
Expand All @@ -315,6 +327,7 @@ fn create_machine_with(
disk_gib: u32,
image: Option<&Path>,
exists: impl FnOnce() -> Result<bool, String>,
before_init: impl FnOnce(),
mut run: impl FnMut(&[&str]) -> Result<String, String>,
) -> StepOutcome {
match exists() {
Expand All @@ -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::<Vec<_>>();
before_init();
match run(&refs) {
Ok(_) => StepOutcome::went(Step::CreateMachine, format!("{MACHINE} created.")),
Err(error) => StepOutcome::stopped(Step::CreateMachine, &error),
Expand Down Expand Up @@ -808,6 +822,7 @@ mod tests {
20,
None,
|| machine_exists_with(|| Ok(listing.into())),
|| {},
|_args| {
init_called = true;
Err("machine openbot already exists".into())
Expand All @@ -834,6 +849,7 @@ mod tests {
Err("podman machine list exited with status 125; stdout: denied".into())
})
},
|| {},
|_args| {
init_called = true;
Ok(String::new())
Expand Down Expand Up @@ -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:?}");
Expand All @@ -891,6 +908,7 @@ mod tests {
64,
None,
|| Ok(false),
|| {},
|args| {
captured = args.iter().map(|arg| (*arg).to_string()).collect();
Ok(String::new())
Expand All @@ -916,6 +934,7 @@ mod tests {
60,
Some(image),
|| Ok(false),
|| {},
|args| {
captured = args.iter().map(|arg| (*arg).to_string()).collect();
Ok(String::new())
Expand All @@ -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::<String>::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!(
Expand Down Expand Up @@ -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:?}");
Expand Down
67 changes: 67 additions & 0 deletions desktop/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,42 @@ pub fn drive_of(path: &str) -> Option<String> {
#[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<MachineDrive> {
let folder = distribution_folder(registry, distribution)?;
Expand Down Expand Up @@ -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(&registry);
let after_fresh = windows_registry::CURRENT_USER
.open(&registry)
.and_then(|key| key.get_u32(WSL_WELCOME_SHOWN));
let reset = windows_registry::CURRENT_USER
.create(&registry)
.and_then(|key| key.set_u32(WSL_WELCOME_SHOWN, 0));
let again = skip_wsl_welcome_in(&registry);
let after_reset = windows_registry::CURRENT_USER
.open(&registry)
.and_then(|key| key.get_u32(WSL_WELCOME_SHOWN));
let _ = windows_registry::CURRENT_USER.remove_tree(&registry);

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",
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading