From 7fdcd62d260f51c008e3df630f9d377d69144522 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:10:29 +0800 Subject: [PATCH 1/3] Link the C runtime statically The release executables imported VCRUNTIME140.dll, so on Windows without the Visual C++ Redistributable, such as a fresh LTSC or Sandbox image, the loader refused to start the tool before its Windows App Runtime check could run. --- .cargo/config.toml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ee27032 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +# Link the C runtime statically. Without the Visual C++ Redistributable the loader +# would stop the tool before its Windows App Runtime check can run. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] From ab92f4e88df86b4d3b28c8508129611f8bd57c92 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:10:29 +0800 Subject: [PATCH 2/3] Offer to install Windows App Runtime from the Microsoft Store A missing runtime used to leave users with a download link and an installer to run by hand. The Store install service accepts requests from unpackaged processes and installs the framework package by product ID even though its Store page offers no way to acquire it, so the prompt now installs it in place with a progress dialog and continues once the required version is present. Detection and installation come from nyanpasu-windows-dependencies, which also decides whether the Store is usable: without the Store app, as on LTSC, the install service still answers but finds no product, and only the download choices are offered. The support crates it uses are patched to the vendored windows-rs so the executable keeps one copy of windows-core. --- Cargo.lock | 89 +++++ Cargo.toml | 11 + README.md | 2 + crates/enable-loopback/Cargo.toml | 3 +- crates/enable-loopback/locales/en.json | 7 +- crates/enable-loopback/locales/ja.json | 7 +- crates/enable-loopback/locales/ko.json | 7 +- crates/enable-loopback/locales/ru.json | 7 +- crates/enable-loopback/locales/zh-cn.json | 7 +- crates/enable-loopback/locales/zh-tw.json | 7 +- crates/enable-loopback/src/runtime_check.rs | 391 +++++++++++++------- docs/design.md | 15 +- 12 files changed, 396 insertions(+), 157 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 228bb51..7a6e440 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -201,6 +201,7 @@ version = "0.1.2" dependencies = [ "embed-resource", "ico", + "nyanpasu-windows-dependencies", "rayon", "resvg", "rust-i18n", @@ -475,12 +476,39 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nyanpasu-windows-dependencies" +version = "0.1.0" +source = "git+https://github.com/libnyanpasu/nyanpasu-windows-dependencies.git?rev=df7fa8f4adbfe60f0d574560a228ccae3dd7b548#df7fa8f4adbfe60f0d574560a228ccae3dd7b548" +dependencies = [ + "snafu", + "strum", + "tracing", + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.100.0", + "windows-registry", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pico-args" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "png" version = "0.17.16" @@ -827,6 +855,27 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "svgtypes" version = "0.15.3" @@ -974,6 +1023,37 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "triomphe" version = "0.1.17" @@ -1153,6 +1233,15 @@ dependencies = [ "windows-time", ] +[[package]] +name = "windows-registry" +version = "0.100.0" +dependencies = [ + "windows-link 0.100.0", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.100.0" diff --git a/Cargo.toml b/Cargo.toml index 18f66cf..a7d9acb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,14 @@ codegen-units = 1 # Native GUI callbacks cannot unwind across the Windows ABI boundary. panic = "abort" strip = "symbols" + +# nyanpasu-windows-dependencies uses the published 0.100 support crates; build them +# from the vendored fork instead, so the executable carries one copy. +[patch.crates-io] +windows-collections = { path = "vendor/windows-rs/crates/libs/collections" } +windows-core = { path = "vendor/windows-rs/crates/libs/core" } +windows-future = { path = "vendor/windows-rs/crates/libs/future" } +windows-link = { path = "vendor/windows-rs/crates/libs/link" } +windows-registry = { path = "vendor/windows-rs/crates/libs/registry" } +windows-result = { path = "vendor/windows-rs/crates/libs/result" } +windows-strings = { path = "vendor/windows-rs/crates/libs/strings" } diff --git a/README.md b/README.md index 94a3fea..0561528 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ App Runtime **2.5.1.0 or later**, matching the process architecture: Download the official **2.5.1** installer for [x64](https://aka.ms/windowsappsdk/2.5/2.5.1/windowsappruntimeinstall-x64.exe) or [arm64](https://aka.ms/windowsappsdk/2.5/2.5.1/windowsappruntimeinstall-arm64.exe). See also the [Windows App SDK downloads](https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads). +When the runtime is missing, the GUI offers to install it from the Microsoft Store +(where the Store is available) or opens these downloads. Build and run in Git Bash: diff --git a/crates/enable-loopback/Cargo.toml b/crates/enable-loopback/Cargo.toml index a506622..14c983e 100644 --- a/crates/enable-loopback/Cargo.toml +++ b/crates/enable-loopback/Cargo.toml @@ -13,10 +13,11 @@ test = false [dependencies] rust-i18n = "4" +nyanpasu-windows-dependencies = { git = "https://github.com/libnyanpasu/nyanpasu-windows-dependencies.git", rev = "df7fa8f4adbfe60f0d574560a228ccae3dd7b548" } snafu = "0.8" sys-locale = "0.3" windows-reactor = { path = "../../vendor/windows-rs/crates/libs/reactor" } -windows = { path = "../../vendor/windows-rs/crates/libs/windows", features = ["appmodel", "libloaderapi", "debugapi", "winuser", "shellapi", "commctrl", "winerror", "winnt", "netfw", "sddl", "secext", "securitybaseapi", "shlwapi", "winbase", "heapapi", "winreg", "errhandlingapi", "processenv", "synchapi", "memoryapi", "handleapi", "processthreadsapi", "datetimeapi", "sysinfoapi"] } +windows = { path = "../../vendor/windows-rs/crates/libs/windows", features = ["libloaderapi", "debugapi", "winuser", "shellapi", "commctrl", "winerror", "winnt", "netfw", "sddl", "secext", "securitybaseapi", "shlwapi", "winbase", "heapapi", "winreg", "errhandlingapi", "processenv", "synchapi", "memoryapi", "handleapi", "processthreadsapi", "datetimeapi", "sysinfoapi"] } [build-dependencies] resvg = { version = "0.45", default-features = false } diff --git a/crates/enable-loopback/locales/en.json b/crates/enable-loopback/locales/en.json index 277d8e1..8196f89 100644 --- a/crates/enable-loopback/locales/en.json +++ b/crates/enable-loopback/locales/en.json @@ -38,9 +38,14 @@ "runtime": { "title": "Windows App Runtime required", "missing": "This tool requires Windows App Runtime %{version} or later for %{architecture}.", + "install_from_store": "Install from Microsoft Store", "download_installer": "Download installer", "open_download_page": "Open download page", - "cancel": "Cancel" + "cancel": "Cancel", + "installing": "Installing Windows App Runtime from Microsoft Store. If you close this window, the installation continues in the background.", + "close": "Close", + "store_failed": "Microsoft Store could not install Windows App Runtime: %{detail}", + "store_incomplete": "Microsoft Store finished, but Windows App Runtime %{version} or later is still not installed." }, "cli": { "syntax": "Syntax:\nenableLoopback [--lang CODE] [-all | -none | -allow NAME... | -deny NAME... | ?]\nenableLoopback [manual] [-registry] [--lang CODE]\n\n-all: Exempt all AppContainers.\n-none: Clear all exemptions.\n-allow NAME: Add AppContainers whose names contain NAME.\n-deny NAME: Remove matching exemptions; * removes all.\nCommands ignore case; the leading - or / is optional.\nNAME may contain spaces. Use * to match all.\nLanguages: en, zh-cn, zh-tw, ja, ko, ru.\nmanual: Skip automatic refresh. -registry: Read the registry directly.", diff --git a/crates/enable-loopback/locales/ja.json b/crates/enable-loopback/locales/ja.json index a4cc2f4..83873ec 100644 --- a/crates/enable-loopback/locales/ja.json +++ b/crates/enable-loopback/locales/ja.json @@ -38,9 +38,14 @@ "runtime": { "title": "Windows App Runtime が必要です", "missing": "このツールには %{architecture} 用の Windows App Runtime %{version} 以降が必要です。", + "install_from_store": "Microsoft Store からインストール", "download_installer": "インストーラーをダウンロード", "open_download_page": "ダウンロードページを開く", - "cancel": "キャンセル" + "cancel": "キャンセル", + "installing": "Microsoft Store から Windows App Runtime をインストールしています。このウィンドウを閉じても、インストールはバックグラウンドで続行されます。", + "close": "閉じる", + "store_failed": "Microsoft Store で Windows App Runtime をインストールできませんでした: %{detail}", + "store_incomplete": "Microsoft Store の処理は完了しましたが、Windows App Runtime %{version} 以降はまだインストールされていません。" }, "cli": { "syntax": "構文:\nenableLoopback [--lang CODE] [-all | -none | -allow NAME... | -deny NAME... | ?]\nenableLoopback [manual] [-registry] [--lang CODE]\n\n-all:すべての AppContainer を除外。\n-none:除外リストを空にする。\n-allow NAME:名前に NAME を含む AppContainer を追加。\n-deny NAME:一致する除外を解除。* ですべて解除。\n大文字と小文字は区別しません。先頭の - または / は省略可能です。\nNAME には空白を含められます。* はすべてに一致します。\n言語:en、zh-cn、zh-tw、ja、ko、ru。\nmanual:自動更新しない。-registry:レジストリから直接読み取る。", diff --git a/crates/enable-loopback/locales/ko.json b/crates/enable-loopback/locales/ko.json index b426789..73e2ea4 100644 --- a/crates/enable-loopback/locales/ko.json +++ b/crates/enable-loopback/locales/ko.json @@ -38,9 +38,14 @@ "runtime": { "title": "Windows App Runtime 필요", "missing": "이 도구에는 %{architecture}용 Windows App Runtime %{version} 이상이 필요합니다.", + "install_from_store": "Microsoft Store에서 설치", "download_installer": "설치 프로그램 다운로드", "open_download_page": "다운로드 페이지 열기", - "cancel": "취소" + "cancel": "취소", + "installing": "Microsoft Store에서 Windows App Runtime을 설치하는 중입니다. 이 창을 닫아도 설치는 백그라운드에서 계속됩니다.", + "close": "닫기", + "store_failed": "Microsoft Store에서 Windows App Runtime을 설치하지 못했습니다: %{detail}", + "store_incomplete": "Microsoft Store 작업은 끝났지만 Windows App Runtime %{version} 이상이 아직 설치되지 않았습니다." }, "cli": { "syntax": "사용법:\nenableLoopback [--lang CODE] [-all | -none | -allow NAME... | -deny NAME... | ?]\nenableLoopback [manual] [-registry] [--lang CODE]\n\n-all: 모든 AppContainer의 예외를 허용합니다.\n-none: 예외 목록을 비웁니다.\n-allow NAME: 이름에 NAME이 포함된 AppContainer를 추가합니다.\n-deny NAME: 일치하는 예외를 해제합니다. *는 모두 해제합니다.\n명령은 대소문자를 구분하지 않으며 앞의 - 또는 /는 생략할 수 있습니다.\nNAME에 공백을 사용할 수 있습니다. *는 모두 일치합니다.\n언어: en, zh-cn, zh-tw, ja, ko, ru.\nmanual: 자동 새로 고침을 건너뜁니다. -registry: 레지스트리에서 직접 읽습니다.", diff --git a/crates/enable-loopback/locales/ru.json b/crates/enable-loopback/locales/ru.json index 321ff19..901b374 100644 --- a/crates/enable-loopback/locales/ru.json +++ b/crates/enable-loopback/locales/ru.json @@ -38,9 +38,14 @@ "runtime": { "title": "Требуется Windows App Runtime", "missing": "Для этой утилиты требуется Windows App Runtime %{version} или новее для %{architecture}.", + "install_from_store": "Установить из Microsoft Store", "download_installer": "Скачать установщик", "open_download_page": "Открыть страницу загрузки", - "cancel": "Отмена" + "cancel": "Отмена", + "installing": "Windows App Runtime устанавливается из Microsoft Store. Если закрыть это окно, установка продолжится в фоновом режиме.", + "close": "Закрыть", + "store_failed": "Microsoft Store не удалось установить Windows App Runtime: %{detail}", + "store_incomplete": "Microsoft Store завершил работу, но Windows App Runtime %{version} или новее по-прежнему не установлен." }, "cli": { "syntax": "Синтаксис:\nenableLoopback [--lang CODE] [-all | -none | -allow NAME... | -deny NAME... | ?]\nenableLoopback [manual] [-registry] [--lang CODE]\n\n-all: Разрешить замыкание на себя всем AppContainer.\n-none: Очистить список исключений.\n-allow NAME: Добавить AppContainer, имена которых содержат NAME.\n-deny NAME: Удалить совпадающие исключения; * удаляет все.\nРегистр не учитывается. Начальный - или / необязателен.\nNAME может содержать пробелы. * соответствует всем именам.\nЯзыки: en, zh-cn, zh-tw, ja, ko, ru.\nmanual: Не обновлять автоматически. -registry: Читать прямо из реестра.", diff --git a/crates/enable-loopback/locales/zh-cn.json b/crates/enable-loopback/locales/zh-cn.json index 9996be7..7707462 100644 --- a/crates/enable-loopback/locales/zh-cn.json +++ b/crates/enable-loopback/locales/zh-cn.json @@ -38,9 +38,14 @@ "runtime": { "title": "需要 Windows App Runtime", "missing": "本工具需要适用于 %{architecture} 的 Windows App Runtime %{version} 或更高版本。", + "install_from_store": "从 Microsoft Store 安装", "download_installer": "下载安装程序", "open_download_page": "打开下载页", - "cancel": "取消" + "cancel": "取消", + "installing": "正在从 Microsoft Store 安装 Windows App Runtime。关闭此窗口后,安装会在后台继续。", + "close": "关闭", + "store_failed": "Microsoft Store 无法安装 Windows App Runtime:%{detail}", + "store_incomplete": "Microsoft Store 已完成,但仍未安装 Windows App Runtime %{version} 或更高版本。" }, "cli": { "syntax": "语法:\nenableLoopback [--lang CODE] [-all | -none | -allow NAME... | -deny NAME... | ?]\nenableLoopback [manual] [-registry] [--lang CODE]\n\n-all:豁免所有 AppContainer。\n-none:清空豁免名单。\n-allow NAME:添加名称包含 NAME 的 AppContainer。\n-deny NAME:移除名称匹配的豁免;* 表示全部移除。\n命令不区分大小写,前导 - 或 / 可省略。\nNAME 可包含空格,用 * 匹配全部。\n语言:en、zh-cn、zh-tw、ja、ko、ru。\nmanual:不自动刷新。-registry:直接读取注册表。", diff --git a/crates/enable-loopback/locales/zh-tw.json b/crates/enable-loopback/locales/zh-tw.json index 75b96fb..b8ff48b 100644 --- a/crates/enable-loopback/locales/zh-tw.json +++ b/crates/enable-loopback/locales/zh-tw.json @@ -38,9 +38,14 @@ "runtime": { "title": "需要 Windows App Runtime", "missing": "本工具需要適用於 %{architecture} 的 Windows App Runtime %{version} 或更新版本。", + "install_from_store": "從 Microsoft Store 安裝", "download_installer": "下載安裝程式", "open_download_page": "開啟下載頁面", - "cancel": "取消" + "cancel": "取消", + "installing": "正在從 Microsoft Store 安裝 Windows App Runtime。關閉此視窗後,安裝會在背景繼續。", + "close": "關閉", + "store_failed": "Microsoft Store 無法安裝 Windows App Runtime:%{detail}", + "store_incomplete": "Microsoft Store 已完成,但仍未安裝 Windows App Runtime %{version} 或更新版本。" }, "cli": { "syntax": "語法:\nenableLoopback [--lang CODE] [-all | -none | -allow NAME... | -deny NAME... | ?]\nenableLoopback [manual] [-registry] [--lang CODE]\n\n-all:豁免所有 AppContainer。\n-none:清空豁免清單。\n-allow NAME:新增名稱包含 NAME 的 AppContainer。\n-deny NAME:移除名稱相符的豁免;* 表示全部移除。\n命令不分大小寫,開頭的 - 或 / 可省略。\nNAME 可包含空格,以 * 比對全部。\n語言:en、zh-cn、zh-tw、ja、ko、ru。\nmanual:不自動重新整理。-registry:直接讀取登錄。", diff --git a/crates/enable-loopback/src/runtime_check.rs b/crates/enable-loopback/src/runtime_check.rs index 0c5521f..21a8e8e 100644 --- a/crates/enable-loopback/src/runtime_check.rs +++ b/crates/enable-loopback/src/runtime_check.rs @@ -1,23 +1,37 @@ -//! Check the framework runtime before reactor bootstrap; offer a localized download prompt (§8.2). +//! Check the framework runtime before reactor bootstrap. When it is missing, offer to +//! install it through the Microsoft Store, or the official download (§8.2). +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, + thread, +}; + +use nyanpasu_windows_dependencies::{ + FrameworkDependency, Version, + store::{InstallState, StoreInstaller}, +}; use snafu::Snafu; use windows::{ Win32::{ - appmodel::*, + HWND, LPARAM, WPARAM, commctrl::*, shellapi::ShellExecuteW, - winerror::ERROR_INSUFFICIENT_BUFFER, winnt::{PROCESSOR_ARCHITECTURE_AMD64, PROCESSOR_ARCHITECTURE_ARM64}, winuser::*, }, - core::{HSTRING, PCWSTR, PWSTR, w}, + core::{HRESULT, HSTRING, PCWSTR, w}, }; pub const DOWNLOAD_URL: &str = "https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads"; -const FAMILY: PCWSTR = w!("Microsoft.WindowsAppRuntime.2_8wekyb3d8bbwe"); +const RUNTIME: FrameworkDependency = FrameworkDependency::WindowsAppRuntime2; // windows-rs a29e064, crates/libs/reactor/src/native/bindings.rs:32212–32216, // WINDOWSAPPSDK_RUNTIME_VERSION_{MAJOR,MINOR,BUILD,REVISION,UINT64}. -const REQUIRED_VERSION: [u16; 4] = [2, 5, 1, 0]; +const REQUIRED_VERSION: Version = Version::new(2, 5, 1, 0); +/// Names this tool in the Microsoft Store's install log. +const STORE_CLIENT_ID: &str = "enable-loopback"; #[cfg(target_arch = "x86_64")] const PROCESS_ARCHITECTURE: u32 = PROCESSOR_ARCHITECTURE_AMD64 as u32; #[cfg(target_arch = "aarch64")] @@ -27,105 +41,14 @@ compile_error!("enable-loopback supports Windows x64 and arm64 only"); #[derive(Debug, Snafu)] pub enum Error { - #[snafu(display("GetPackagesByPackageFamily failed with status {status:#x}"))] - EnumeratePackages { status: i32 }, - #[snafu(display( - "PackageIdFromFullName failed for {package_full_name} with status {status:#x}" - ))] - ReadPackageId { - package_full_name: String, - status: i32, - }, #[snafu(display("ShellExecuteW failed for {url} with status {status}"))] OpenDownload { url: String, status: isize }, - #[snafu(display("Displaying the runtime download dialog failed: {source}"))] + #[snafu(display("Displaying the runtime dialog failed: {source}"))] ShowDialog { source: windows::core::Error }, } -fn required_version_u64() -> u64 { - REQUIRED_VERSION - .into_iter() - .fold(0, |version, part| (version << 16) | u64::from(part)) -} - -fn compatible(version: u64, architecture: u32) -> bool { - version >= required_version_u64() && architecture == PROCESS_ARCHITECTURE -} - -/// Query packages registered for the current user without COM or WinAppSDK initialization. -/// Retry if registration changes between the size query and the buffered query. -pub fn is_installed() -> Result { - for _ in 0..3 { - let (mut count, mut length) = (0, 0); - // SAFETY: output counters are valid; null buffers request required sizes. - let status = - unsafe { GetPackagesByPackageFamily(FAMILY, &mut count, None, &mut length, None) }; - if status == 0 && count == 0 { - return Ok(false); - } - if status != ERROR_INSUFFICIENT_BUFFER { - return Err(Error::EnumeratePackages { status }); - } - let mut names = vec![PWSTR::null(); count as usize]; - let mut buffer = vec![0u16; length as usize]; - // SAFETY: both buffers have the exact capacities returned by the first query. - let status = unsafe { - GetPackagesByPackageFamily( - FAMILY, - &mut count, - Some(names.as_mut_ptr()), - &mut length, - Some(buffer.as_mut_ptr()), - ) - }; - if status == ERROR_INSUFFICIENT_BUFFER { - continue; - } - if status != 0 { - return Err(Error::EnumeratePackages { status }); - } - for name in names.into_iter().take(count as usize) { - // SAFETY: name is a terminated string pointing into the still-live buffer. - if unsafe { package_is_compatible(PCWSTR(name.0)) }? { - return Ok(true); - } - } - return Ok(false); - } - Err(Error::EnumeratePackages { - status: ERROR_INSUFFICIENT_BUFFER, - }) -} - -/// `name` must point to a live, NUL-terminated package full name. -unsafe fn package_is_compatible(name: PCWSTR) -> Result { - let mut length = 0; - // SAFETY: caller guarantees name validity; null buffer requests its size. - let status = unsafe { PackageIdFromFullName(name, 0, &mut length, None) }; - if status != ERROR_INSUFFICIENT_BUFFER { - return Err(Error::ReadPackageId { - package_full_name: unsafe { name.to_string() }.unwrap_or_default(), - status, - }); - } - // u64 storage supplies alignment as well as the requested byte count. - let mut buffer = vec![0u64; (length as usize).div_ceil(size_of::())]; - // SAFETY: buffer is aligned and has at least length bytes; name is still live. - let status = - unsafe { PackageIdFromFullName(name, 0, &mut length, Some(buffer.as_mut_ptr().cast())) }; - if status != 0 { - return Err(Error::ReadPackageId { - package_full_name: unsafe { name.to_string() }.unwrap_or_default(), - status, - }); - } - // SAFETY: successful API call populated PACKAGE_ID and its trailing strings. - let id = unsafe { buffer.as_ptr().cast::().read_unaligned() }; - // SAFETY: Version is the packed integer representation of the version union. - Ok(compatible( - unsafe { id.version.Anonymous.Version }, - id.processorArchitecture, - )) +fn satisfied(installed: Option) -> bool { + installed.is_some_and(|version| version >= REQUIRED_VERSION) } /// Build the official installer URL for a supported process architecture. @@ -135,51 +58,100 @@ fn installer_url(architecture: u32) -> String { PROCESSOR_ARCHITECTURE_ARM64 => "arm64", _ => unreachable!("only x64 and arm64 processes are supported"), }; - let [major, minor, build, _] = REQUIRED_VERSION; + let Version { + major, + minor, + build, + .. + } = REQUIRED_VERSION; format!( "https://aka.ms/windowsappsdk/{major}.{minor}/{major}.{minor}.{build}/windowsappruntimeinstall-{architecture}.exe" ) } -/// Returns false after a missing-runtime prompt. An inconclusive check lets reactor bootstrap. +/// Returns false when the runtime is still missing after the prompt. An +/// inconclusive check lets reactor bootstrap. pub fn ensure_available() -> Result { - match is_installed() { - Ok(true) => return Ok(true), - Ok(false) => {} + match RUNTIME.diagnose() { + Ok(installed) if satisfied(installed) => return Ok(true), + Ok(_) => {} Err(error) => { - crate::debug(&error.to_string()); + crate::debug(&describe(&error)); return Ok(true); } } - const DOWNLOAD_INSTALLER: i32 = 100; - const OPEN_DOWNLOAD_PAGE: i32 = 101; - let version = REQUIRED_VERSION.map(|part| part.to_string()).join("."); + let mut store = store_available(); + loop { + let url = match prompt(store)? { + Choice::Store => match install_from_store()? { + StoreOutcome::Installed => return Ok(true), + StoreOutcome::StoppedWaiting => return Ok(false), + StoreOutcome::Failed(detail) => { + crate::show_message( + &rust_i18n::t!("runtime.store_failed", detail = detail), + MB_ICONERROR as u32, + ); + store = false; + continue; + } + }, + Choice::DownloadInstaller => installer_url(PROCESS_ARCHITECTURE), + Choice::OpenDownloadPage => DOWNLOAD_URL.to_owned(), + Choice::Cancel => return Ok(false), + }; + open(url)?; + return Ok(false); + } +} + +/// Windows without the Store app, such as LTSC, cannot install from the Store. +fn store_available() -> bool { + match StoreInstaller::new(STORE_CLIENT_ID).and_then(|store| store.is_available()) { + Ok(available) => available, + Err(error) => { + crate::debug(&describe(&error)); + false + } + } +} + +enum Choice { + Store, + DownloadInstaller, + OpenDownloadPage, + Cancel, +} + +fn prompt(store: bool) -> Result { + const INSTALL_FROM_STORE: i32 = 100; + const DOWNLOAD_INSTALLER: i32 = 101; + const OPEN_DOWNLOAD_PAGE: i32 = 102; let text = HSTRING::from( rust_i18n::t!( "runtime.missing", - version = version, + version = REQUIRED_VERSION.to_string(), architecture = std::env::consts::ARCH ) .as_ref(), ); let title = HSTRING::from(rust_i18n::t!("runtime.title").as_ref()); + let from_store = HSTRING::from(rust_i18n::t!("runtime.install_from_store").as_ref()); let installer = HSTRING::from(rust_i18n::t!("runtime.download_installer").as_ref()); let page = HSTRING::from(rust_i18n::t!("runtime.open_download_page").as_ref()); let cancel = HSTRING::from(rust_i18n::t!("runtime.cancel").as_ref()); - let buttons = [ - TASKDIALOG_BUTTON { - nButtonID: DOWNLOAD_INSTALLER, - pszButtonText: PCWSTR(installer.as_ptr()), - }, - TASKDIALOG_BUTTON { - nButtonID: OPEN_DOWNLOAD_PAGE, - pszButtonText: PCWSTR(page.as_ptr()), - }, - TASKDIALOG_BUTTON { - nButtonID: IDCANCEL, - pszButtonText: PCWSTR(cancel.as_ptr()), - }, - ]; + let button = |id, text: &HSTRING| TASKDIALOG_BUTTON { + nButtonID: id, + pszButtonText: PCWSTR(text.as_ptr()), + }; + let mut buttons = Vec::with_capacity(4); + if store { + buttons.push(button(INSTALL_FROM_STORE, &from_store)); + } + buttons.extend([ + button(DOWNLOAD_INSTALLER, &installer), + button(OPEN_DOWNLOAD_PAGE, &page), + button(IDCANCEL, &cancel), + ]); let config = TASKDIALOGCONFIG { cbSize: size_of::() as u32, dwFlags: TDF_ALLOW_DIALOG_CANCELLATION | TDF_SIZE_TO_CONTENT, @@ -187,7 +159,7 @@ pub fn ensure_available() -> Result { pszContent: PCWSTR(text.as_ptr()), cButtons: buttons.len() as u32, pButtons: buttons.as_ptr(), - nDefaultButton: IDCANCEL, + nDefaultButton: if store { INSTALL_FROM_STORE } else { IDCANCEL }, ..Default::default() }; let mut answer = IDCANCEL; @@ -195,11 +167,139 @@ pub fn ensure_available() -> Result { unsafe { TaskDialogIndirect(&config, Some(&mut answer), None, None) } .ok() .map_err(|source| Error::ShowDialog { source })?; - let url = match answer { - DOWNLOAD_INSTALLER => installer_url(PROCESS_ARCHITECTURE), - OPEN_DOWNLOAD_PAGE => DOWNLOAD_URL.to_owned(), - _ => return Ok(false), + Ok(match answer { + INSTALL_FROM_STORE => Choice::Store, + DOWNLOAD_INSTALLER => Choice::DownloadInstaller, + OPEN_DOWNLOAD_PAGE => Choice::OpenDownloadPage, + _ => Choice::Cancel, + }) +} + +enum StoreOutcome { + Installed, + /// The user closed the progress dialog; the Store keeps installing. + StoppedWaiting, + Failed(String), +} + +/// Shared between the installing thread and the progress dialog's timer. +#[derive(Default)] +struct StoreProgress { + percent: AtomicU32, + /// The bar starts as a marquee until the Store reports a percentage. + determinate: AtomicBool, + outcome: Mutex>>, +} + +/// Installs the runtime on a worker thread while a progress dialog runs here. +fn install_from_store() -> Result { + let progress = Arc::new(StoreProgress::default()); + let worker = Arc::clone(&progress); + thread::spawn(move || { + let product = RUNTIME + .store_product_id() + .expect("Windows App Runtime 2 has a Store product"); + let result = StoreInstaller::new(STORE_CLIENT_ID) + .map_err(|error| describe(&error)) + .and_then(|store| { + store + .install(product, |update| { + if matches!( + update.state, + Some(InstallState::Downloading | InstallState::Installing) + ) { + worker + .percent + .store(update.percent.round() as u32, Ordering::Relaxed); + } + }) + .map_err(|error| describe(&error)) + }); + *worker.outcome.lock().expect("no holder panics") = Some(result); + }); + + let title = HSTRING::from(rust_i18n::t!("runtime.title").as_ref()); + let text = HSTRING::from(rust_i18n::t!("runtime.installing").as_ref()); + let close = HSTRING::from(rust_i18n::t!("runtime.close").as_ref()); + let buttons = [TASKDIALOG_BUTTON { + nButtonID: IDCANCEL, + pszButtonText: PCWSTR(close.as_ptr()), + }]; + let config = TASKDIALOGCONFIG { + cbSize: size_of::() as u32, + dwFlags: TDF_ALLOW_DIALOG_CANCELLATION + | TDF_SIZE_TO_CONTENT + | TDF_SHOW_MARQUEE_PROGRESS_BAR + | TDF_CALLBACK_TIMER, + pszWindowTitle: PCWSTR(title.as_ptr()), + pszContent: PCWSTR(text.as_ptr()), + cButtons: buttons.len() as u32, + pButtons: buttons.as_ptr(), + pfCallback: Some(progress_callback), + lpCallbackData: Arc::as_ptr(&progress) as isize, + ..Default::default() }; + // SAFETY: config, buttons, strings and the progress the callback reads live + // through the modal call. + unsafe { TaskDialogIndirect(&config, None, None, None) } + .ok() + .map_err(|source| Error::ShowDialog { source })?; + + let outcome = progress.outcome.lock().expect("no holder panics").take(); + Ok(match outcome { + None => StoreOutcome::StoppedWaiting, + Some(Err(detail)) => StoreOutcome::Failed(detail), + // The Store may carry an older runtime than this tool needs. + Some(Ok(())) => match RUNTIME.diagnose() { + Ok(installed) if satisfied(installed) => StoreOutcome::Installed, + Ok(_) => StoreOutcome::Failed( + rust_i18n::t!( + "runtime.store_incomplete", + version = REQUIRED_VERSION.to_string() + ) + .into_owned(), + ), + Err(error) => StoreOutcome::Failed(describe(&error)), + }, + }) +} + +/// Mirrors the worker's progress into the dialog and closes it once the worker is done. +unsafe extern "system" fn progress_callback( + hwnd: HWND, + msg: u32, + _: WPARAM, + _: LPARAM, + data: isize, +) -> HRESULT { + // SAFETY: data points to the StoreProgress that outlives the modal dialog. + let progress = unsafe { &*(data as *const StoreProgress) }; + // SAFETY: hwnd is the live task dialog that sent this notification. + let send = |message: i32, wparam: usize, lparam: isize| unsafe { + SendMessageW(hwnd, message as u32, wparam, lparam); + }; + match msg as i32 { + TDN_CREATED => send(TDM_SET_PROGRESS_BAR_MARQUEE, 1, 0), + TDN_TIMER => { + if progress.outcome.lock().expect("no holder panics").is_some() { + send(TDM_CLICK_BUTTON, IDCANCEL as usize, 0); + return HRESULT(0); + } + let percent = progress.percent.load(Ordering::Relaxed); + if percent > 0 { + if !progress.determinate.swap(true, Ordering::Relaxed) { + send(TDM_SET_PROGRESS_BAR_MARQUEE, 0, 0); + send(TDM_SET_MARQUEE_PROGRESS_BAR, 0, 0); + } + send(TDM_SET_PROGRESS_BAR_POS, percent.min(100) as usize, 0); + } + } + _ => {} + } + HRESULT(0) +} + +fn open(url: String) -> Result<(), Error> { let target = HSTRING::from(&url); // SAFETY: URL is a derived official HTTPS address; no untrusted command or arguments. let result = unsafe { @@ -216,12 +316,25 @@ pub fn ensure_available() -> Result { if status <= 32 { return Err(Error::OpenDownload { url, status }); } - Ok(false) + Ok(()) +} + +/// An error with the causes its display leaves out. +fn describe(error: &dyn std::error::Error) -> String { + let mut text = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + text.push_str(": "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text } #[cfg(test)] mod tests { use super::*; + #[test] fn installer_urls_match_required_version_and_both_architectures() { assert_eq!( @@ -235,18 +348,10 @@ mod tests { } #[test] - fn checks_all_version_components_and_process_architecture() { - let required = required_version_u64(); - assert!(compatible(required, PROCESS_ARCHITECTURE)); - assert!(compatible(required + 1, PROCESS_ARCHITECTURE)); - assert!(!compatible(required - 1, PROCESS_ARCHITECTURE)); - assert!(!compatible( - required, - if PROCESS_ARCHITECTURE == PROCESSOR_ARCHITECTURE_AMD64 as u32 { - PROCESSOR_ARCHITECTURE_ARM64 as u32 - } else { - PROCESSOR_ARCHITECTURE_AMD64 as u32 - } - )); + fn requires_at_least_the_reactor_runtime_version() { + assert!(satisfied(Some(REQUIRED_VERSION))); + assert!(satisfied(Some(Version::new(2, 6, 0, 0)))); + assert!(!satisfied(Some(Version::new(2, 5, 0, 9)))); + assert!(!satisfied(None)); } } diff --git a/docs/design.md b/docs/design.md index f7f436c..27d553e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -338,21 +338,22 @@ master 的 `Border` 提供 `on_pointer_pressed`(`PointerEventInfo` 含 `modifi | winget | `winget install Microsoft.WindowsAppRuntime.2`(2026-10-19 最高 2.4.0,落后于下载页的 2.5.1) | | 自包含 | `windows-reactor-setup` 在构建时从 NuGet 拉取固定版本的 runtime,把所需 DLL 和目录复制到 exe 旁并嵌入自包含清单;用户机器无需安装任何东西,代价是安装包变大(数十 MB 量级,需实测) | -### 8.2 部署决定:用户自行安装(框架依赖) +### 8.2 部署决定:框架依赖,由工具自己在启动阶段补装 1. 子仓不用 `windows-reactor-setup`,exe 依赖已安装的 `Microsoft.WindowsAppRuntime.2` 框架包,版本下限等于所用 reactor 提交的 `WINDOWSAPPSDK_RUNTIME_VERSION`:0.100.0 为 2.4.0,锁定的 master `a29e064` 为 **2.5.1**(`crates/libs/reactor/src/native/bindings.rs`)。升级 reactor 提交时同步核对。 -2. 主仓在 `win_uwp.rs` 启动工具前检测:`GetPackagesByPackageFamily("Microsoft.WindowsAppRuntime.2_8wekyb3d8bbwe")`,并检查版本 ≥ 下限、架构匹配。此检测是 Windows 平台适配,放在现有 `win_uwp.rs` 旁,不进入 core。 -3. 缺失时前端弹本地化对话框,提供: - - "下载安装程序":直接打开与所需版本和架构匹配的官方安装器链接 `https://aka.ms/windowsappsdk///windowsappruntimeinstall-.exe`(锁定 `a29e064` 时为 `2.5/2.5.1`); +2. 检测与补装由工具自己负责,主仓只负责启动工具并传 `--lang`。GUI 模式在取得单实例之后、调用 reactor 之前,用 [`nyanpasu-windows-dependencies`](https://github.com/libnyanpasu/nyanpasu-windows-dependencies) 检测当前进程架构下已注册的最高版本;检测接口本身失败时只记调试日志,继续交给 reactor。命令行模式不检测(不需要 runtime)。 +3. 缺失时弹本地化对话框(`TaskDialogIndirect`,不依赖 WinUI): + - "从 Microsoft Store 安装":只在 Store 应用已注册、且策略未阻止时出现,并作为默认按钮。经 Store 安装服务按商品 `9NRZT3Q9R3DL` 安装,不打开商品页(商品页显示"当前不可用")。安装在工作线程运行,对话框显示进度条(Store 报告百分比前为滚动条)。完成后重新检测,满足下限就继续启动。用户关闭进度对话框时工具退出,Store 在后台继续安装。安装失败,或装好的版本仍低于下限时,提示原因,再回到下载选项(不再提供 Store); + - "下载安装程序":打开与所需版本和架构匹配的官方安装器链接 `https://aka.ms/windowsappsdk///windowsappruntimeinstall-.exe`(锁定 `a29e064` 时为 `2.5/2.5.1`); - "打开下载页":`https://learn.microsoft.com/windows/apps/windows-app-sdk/downloads`; - "取消"。 **不提供 winget 安装**:2026-10-19 winget 上 `Microsoft.WindowsAppRuntime.2` 最高只有 2.4.0,低于所需的 2.5.1,装完仍无法启动。winget 跟上后可再评估。 - 文案进主前端的 paraglide 消息(六语言)。安装完成后用户再次点击即可。 -4. 工具自身的兜底:若用户绕过主仓直接运行且缺失 runtime,reactor 的 bootstrap 会弹英文提示框。子仓在调用 reactor 前自行检测,缺失时弹本地化对话框(`TaskDialogIndirect`,按钮同第 3 条,不依赖 WinUI),避免出现英文提示;检测接口本身失败时只记调试日志并继续交给 reactor。命令行模式不检测(不需要 runtime)。 + 没有 Store 的系统(如 Windows LTSC)只显示后三项。 +4. C 运行时静态链接(`.cargo/config.toml` 的 `+crt-static`)。否则在没有 VC++ Redistributable 的系统上,加载器在检测代码运行之前就会失败。 ## 9. 待决问题 -已定(2026-10-19):submodule;修正保存缺陷;链接到微软文档;标题本地化;保留计数;不做 x86;runtime 由用户自行安装(框架依赖,主仓检测并本地化引导,见 §8.2);接受 git rev 依赖 windows-rs master;多选自建(§6.2);图标新绘、Fluent、SVG。 +已定(2026-10-19):submodule;修正保存缺陷;链接到微软文档;标题本地化;保留计数;不做 x86;runtime 由工具在启动阶段检测并经 Store 或官方下载补装(框架依赖,见 §8.2);接受 git rev 依赖 windows-rs master;多选自建(§6.2);图标新绘、Fluent、SVG。 1. 图标图形:待从 codex 概念图中选定。 From 1a8de54613f35964f942563510c49f3048f75878 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:14:07 +0800 Subject: [PATCH 3/3] Opt the executable into the segment heap Clash Nyanpasu switched to the segment heap and freed about 6 MiB of private memory that the NT heap had been keeping after frees. The tool's WinUI process allocates the same way, so it uses the same manifest setting. --- crates/enable-loopback/build.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/enable-loopback/build.rs b/crates/enable-loopback/build.rs index 8a7dff1..e078257 100644 --- a/crates/enable-loopback/build.rs +++ b/crates/enable-loopback/build.rs @@ -48,6 +48,7 @@ fn main() { PerMonitorV2 UTF-8 + SegmentHeap