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