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"] 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/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 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 概念图中选定。