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