From 485443b9df2e7d6f5695086950bcb6ba2822c9ef Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 1 Aug 2026 15:38:06 +0900 Subject: [PATCH] fix(windows): give windows an icon and register Arto as a document handler Two gaps in the Windows integration, both absent on macOS because the app bundle covers them. No window icon: tao registers its window class with a null hIcon and only sends WM_SETICON when a window icon is supplied, so the title bar, taskbar and Alt+Tab all fell back to the generic placeholder. The icon dx links into the executable only covers Explorer, not the running window. Every WindowBuilder now carries the embedded Arto.png. On Windows the builder icon fills the small (title bar) slot only, so the taskbar/Alt+Tab slot is set separately through the platform extension trait, and both are pre-scaled with Lanczos3 because Windows scales icons without smoothing. Not offered for Markdown files: the NSIS script dx renders installs the binary and nothing else, and dx has no file_associations setting to fill the gap, so Windows never learned that Arto opens documents -- it was missing from "Open with", from "Choose another app" and from Settings -> Default apps. An installer hook now writes the ProgIDs, the per-executable SupportedTypes entry, the OpenWithProgids candidates and the Capabilities key, for the same extensions the macOS Info.plist declares. Registration adds Arto as a candidate without taking the file type over, and the uninstaller removes it symmetrically. Co-Authored-By: Claude Opus 5 --- desktop/Dioxus.toml | 6 ++ desktop/src/window.rs | 1 + desktop/src/window/child.rs | 12 +++- desktop/src/window/icon.rs | 100 +++++++++++++++++++++++++++ desktop/src/window/main.rs | 5 +- extras/windows/file-associations.nsh | 92 ++++++++++++++++++++++++ 6 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 desktop/src/window/icon.rs create mode 100644 extras/windows/file-associations.nsh diff --git a/desktop/Dioxus.toml b/desktop/Dioxus.toml index 5649759d..167fe4a6 100644 --- a/desktop/Dioxus.toml +++ b/desktop/Dioxus.toml @@ -35,6 +35,12 @@ depends = [ "libssl3t64 | libssl3", ] +# The generated NSIS script installs the binary and nothing else, so the hook +# script adds the registry keys that tell Windows which documents Arto opens — +# the counterpart to CFBundleDocumentTypes in the macOS Info.plist. +[bundle.windows.nsis] +installer_hooks = "../extras/windows/file-associations.nsh" + [bundle.macos] license = "../LICENSE" provider_short_name = "Alisue" diff --git a/desktop/src/window.rs b/desktop/src/window.rs index ad2ce449..9e99f2b6 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -1,4 +1,5 @@ pub mod child; +pub mod icon; pub mod index; pub mod main; pub mod metrics; diff --git a/desktop/src/window/child.rs b/desktop/src/window/child.rs index 701a4a1b..90d5ffd7 100644 --- a/desktop/src/window/child.rs +++ b/desktop/src/window/child.rs @@ -180,7 +180,9 @@ pub fn open_or_focus_mermaid_window(source: String, theme: Theme) { ); let config = Config::new() .with_menu(None) - .with_window(WindowBuilder::new().with_title("Mermaid Viewer")) + .with_window(super::icon::apply_app_icon( + WindowBuilder::new().with_title("Mermaid Viewer"), + )) .with_custom_head(indoc::formatdoc! {r#""#}) .with_custom_index(build_mermaid_window_index(theme)); @@ -205,7 +207,9 @@ pub fn open_or_focus_math_window(source: String, theme: Theme) { ); let config = Config::new() .with_menu(None) - .with_window(WindowBuilder::new().with_title("Math Viewer")) + .with_window(super::icon::apply_app_icon( + WindowBuilder::new().with_title("Math Viewer"), + )) .with_custom_head(indoc::formatdoc! {r#""#}) .with_custom_index(build_math_window_index(theme)); @@ -231,7 +235,9 @@ pub fn open_or_focus_image_window(src: String, alt: Option, theme: Theme ); let config = Config::new() .with_menu(None) - .with_window(WindowBuilder::new().with_title("Image Viewer")) + .with_window(super::icon::apply_app_icon( + WindowBuilder::new().with_title("Image Viewer"), + )) .with_custom_head(indoc::formatdoc! {r#""#}) .with_custom_index(build_image_window_index(theme)); diff --git a/desktop/src/window/icon.rs b/desktop/src/window/icon.rs new file mode 100644 index 00000000..5f4ef3c2 --- /dev/null +++ b/desktop/src/window/icon.rs @@ -0,0 +1,100 @@ +use dioxus::desktop::tao::window::Icon; +use dioxus::desktop::WindowBuilder; +use std::cell::RefCell; +use std::collections::HashMap; + +/// The application icon, embedded so window creation never touches the +/// filesystem (the bundled asset directory sits in a different place relative +/// to the binary on every platform, and a missing icon must not be able to fail +/// a window). +const ICON_PNG: &[u8] = include_bytes!("../../assets/Arto.png"); + +/// Edge length for the icon Windows draws in the title bar. Windows happily +/// scales an icon of any size into the 16px slot, but does it without +/// smoothing, so the source is pre-scaled here with a real filter instead. +#[cfg(target_os = "windows")] +const SMALL_ICON_SIZE: u32 = 32; + +/// Edge length for every other slot: the taskbar and Alt+Tab switcher on +/// Windows, the window list and dock on Linux. These are drawn anywhere from +/// 32px to 256px depending on DPI and shell, so hand over the large size and +/// let the compositor scale down. +const LARGE_ICON_SIZE: u32 = 256; + +thread_local! { + /// Decoding costs a PNG parse, a rescale and a native icon handle, so keep + /// each size for the lifetime of the process. `Icon` wraps a native handle + /// and is not `Send`, hence a thread-local rather than a `OnceLock` — every + /// window is created on the main thread anyway. + static ICONS: RefCell>> = RefCell::new(HashMap::new()); +} + +/// Give a window the application icon. +/// +/// Windows and Linux draw a generic placeholder unless the window carries an +/// icon of its own — the icon linked into the executable only covers Explorer, +/// not the running window. macOS has no per-window icons and ignores this; its +/// icon comes from the app bundle. +pub fn apply_app_icon(builder: WindowBuilder) -> WindowBuilder { + #[cfg(target_os = "windows")] + let builder = builder.with_window_icon(app_icon(SMALL_ICON_SIZE)); + #[cfg(not(target_os = "windows"))] + let builder = builder.with_window_icon(app_icon(LARGE_ICON_SIZE)); + + // On Windows the builder icon only fills the small (title bar) slot. The + // taskbar and Alt+Tab switcher read the big one, a separate slot that tao + // exposes only through the platform extension trait. + #[cfg(target_os = "windows")] + let builder = { + use dioxus::desktop::tao::platform::windows::WindowBuilderExtWindows; + builder.with_taskbar_icon(app_icon(LARGE_ICON_SIZE)) + }; + + builder +} + +/// The application icon rendered at `size` x `size`, or `None` if the embedded +/// PNG cannot be decoded — which leaves the window looking exactly as it did +/// before rather than failing the launch. +fn app_icon(size: u32) -> Option { + ICONS.with(|cache| { + cache + .borrow_mut() + .entry(size) + .or_insert_with(|| load_app_icon(size)) + .clone() + }) +} + +fn load_app_icon(size: u32) -> Option { + let image = match image::load_from_memory_with_format(ICON_PNG, image::ImageFormat::Png) { + Ok(image) => image, + Err(err) => { + tracing::warn!("Failed to decode the application icon: {err}"); + return None; + } + }; + let rgba = image + .resize_exact(size, size, image::imageops::FilterType::Lanczos3) + .into_rgba8(); + + match Icon::from_rgba(rgba.into_raw(), size, size) { + Ok(icon) => Some(icon), + Err(err) => { + tracing::warn!("Failed to build the application icon: {err}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_icon_decodes_at_every_size() { + #[cfg(target_os = "windows")] + assert!(app_icon(SMALL_ICON_SIZE).is_some()); + assert!(app_icon(LARGE_ICON_SIZE).is_some()); + } +} diff --git a/desktop/src/window/main.rs b/desktop/src/window/main.rs index 807ca318..08cd8f8a 100644 --- a/desktop/src/window/main.rs +++ b/desktop/src/window/main.rs @@ -20,6 +20,7 @@ use crate::state::Tab; use crate::theme::Theme; use crate::utils::screen::get_current_display_bounds; +use super::icon; use super::index::build_custom_index; use super::metrics::capture_window_metrics; use super::settings; @@ -32,12 +33,12 @@ pub fn create_main_window_config(params: &CreateMainWindowConfigParams) -> Confi let initial_size = params.size; Config::new() - .with_window( + .with_window(icon::apply_app_icon( WindowBuilder::new() .with_title("Arto") .with_position(params.position) .with_inner_size(params.size), - ) + )) // Dioxus/tao can lose the requested inner height on macOS during window // construction, so apply the same size once the native window exists. .with_on_window(move |window, _| { diff --git a/extras/windows/file-associations.nsh b/extras/windows/file-associations.nsh new file mode 100644 index 00000000..c0519069 --- /dev/null +++ b/extras/windows/file-associations.nsh @@ -0,0 +1,92 @@ +; Windows file-type registration for Arto. +; +; `dx bundle` renders its NSIS script from a fixed template that installs the +; binary, shortcuts and an uninstaller, and nothing else — so Windows never +; learns that Arto can open a document. Without the keys below Arto is missing +; from a Markdown file's "Open with" menu, from the "Choose another app" dialog +; and from Settings → Apps → Default apps. This is the registry spelling of what +; the macOS bundle declares as CFBundleDocumentTypes in extras/mac/Info.plist; +; keep the two extension lists in step. +; +; Wired up through `[bundle.windows.nsis] installer_hooks` in desktop/Dioxus.toml. +; The template `!include`s this file at top level, between the install and the +; uninstall section, so it may only define sections and macros — never bare +; statements. The installer shows no components page, so the section names here +; are internal. +; +; Everything is written under SHCTX, the installer's shell context: HKCU for the +; default per-user install, HKLM for a per-machine one. That puts it in the same +; hive as the Add/Remove Programs entry the template writes, so the uninstall +; below stays symmetric with it. + +!define ARTO_EXE "arto.exe" +!define ARTO_MARKDOWN_PROGID "Arto.Markdown" +!define ARTO_TEXT_PROGID "Arto.Text" + +; A ProgID is the file type itself: the name Explorer shows in the Type column, +; the icon it draws, and the command that opens it. +!macro ArtoWriteProgId ProgId TypeName + WriteRegStr SHCTX "Software\Classes\${ProgId}" "" "${TypeName}" + WriteRegStr SHCTX "Software\Classes\${ProgId}\DefaultIcon" "" "$INSTDIR\${ARTO_EXE},0" + WriteRegStr SHCTX "Software\Classes\${ProgId}\shell\open" "FriendlyAppName" "Arto" + WriteRegStr SHCTX "Software\Classes\${ProgId}\shell\open\command" "" '"$INSTDIR\${ARTO_EXE}" "%1"' +!macroend + +; Offer Arto for an extension without taking it over: OpenWithProgids only adds +; a candidate to the "Open with" list, leaving whatever the user already has as +; the default. SupportedTypes is the other half — it is what the "Choose another +; app" dialog reads to decide that Arto is worth suggesting for this extension. +!macro ArtoRegisterExtension Extension ProgId + WriteRegStr SHCTX "Software\Classes\${Extension}\OpenWithProgids" "${ProgId}" "" + WriteRegStr SHCTX "Software\Classes\Applications\${ARTO_EXE}\SupportedTypes" "${Extension}" "" +!macroend + +!macro ArtoUnregisterExtension Extension ProgId + DeleteRegValue SHCTX "Software\Classes\${Extension}\OpenWithProgids" "${ProgId}" +!macroend + +Section "Arto file associations" + !insertmacro ArtoWriteProgId "${ARTO_MARKDOWN_PROGID}" "Markdown Document" + !insertmacro ArtoWriteProgId "${ARTO_TEXT_PROGID}" "Text Document" + + ; The per-executable entry is what makes the "Open with" dialog list Arto + ; under a readable name instead of the raw file name of the binary. + WriteRegStr SHCTX "Software\Classes\Applications\${ARTO_EXE}" "FriendlyAppName" "Arto" + WriteRegStr SHCTX "Software\Classes\Applications\${ARTO_EXE}\DefaultIcon" "" "$INSTDIR\${ARTO_EXE},0" + WriteRegStr SHCTX "Software\Classes\Applications\${ARTO_EXE}\shell\open\command" "" '"$INSTDIR\${ARTO_EXE}" "%1"' + + !insertmacro ArtoRegisterExtension ".md" "${ARTO_MARKDOWN_PROGID}" + !insertmacro ArtoRegisterExtension ".markdown" "${ARTO_MARKDOWN_PROGID}" + !insertmacro ArtoRegisterExtension ".txt" "${ARTO_TEXT_PROGID}" + !insertmacro ArtoRegisterExtension ".text" "${ARTO_TEXT_PROGID}" + + ; Settings → Default apps only lists an application that publishes a + ; Capabilities key and points RegisteredApplications at it. + WriteRegStr SHCTX "Software\Arto\Capabilities" "ApplicationName" "Arto" + WriteRegStr SHCTX "Software\Arto\Capabilities" "ApplicationDescription" "A GitHub Markdown viewer" + WriteRegStr SHCTX "Software\Arto\Capabilities\FileAssociations" ".md" "${ARTO_MARKDOWN_PROGID}" + WriteRegStr SHCTX "Software\Arto\Capabilities\FileAssociations" ".markdown" "${ARTO_MARKDOWN_PROGID}" + WriteRegStr SHCTX "Software\Arto\Capabilities\FileAssociations" ".txt" "${ARTO_TEXT_PROGID}" + WriteRegStr SHCTX "Software\Arto\Capabilities\FileAssociations" ".text" "${ARTO_TEXT_PROGID}" + WriteRegStr SHCTX "Software\RegisteredApplications" "Arto" "Software\Arto\Capabilities" + + ; The shell caches the association tables; without this notification the new + ; entries only appear after Explorer is restarted. + System::Call 'shell32.dll::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)' +SectionEnd + +Section "un.Arto file associations" + !insertmacro ArtoUnregisterExtension ".md" "${ARTO_MARKDOWN_PROGID}" + !insertmacro ArtoUnregisterExtension ".markdown" "${ARTO_MARKDOWN_PROGID}" + !insertmacro ArtoUnregisterExtension ".txt" "${ARTO_TEXT_PROGID}" + !insertmacro ArtoUnregisterExtension ".text" "${ARTO_TEXT_PROGID}" + + DeleteRegKey SHCTX "Software\Classes\${ARTO_MARKDOWN_PROGID}" + DeleteRegKey SHCTX "Software\Classes\${ARTO_TEXT_PROGID}" + DeleteRegKey SHCTX "Software\Classes\Applications\${ARTO_EXE}" + + DeleteRegValue SHCTX "Software\RegisteredApplications" "Arto" + DeleteRegKey SHCTX "Software\Arto" + + System::Call 'shell32.dll::SHChangeNotify(i 0x08000000, i 0, i 0, i 0)' +SectionEnd