Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions desktop/Dioxus.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions desktop/src/window.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod child;
pub mod icon;
pub mod index;
pub mod main;
pub mod metrics;
Expand Down
12 changes: 9 additions & 3 deletions desktop/src/window/child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<link rel="stylesheet" href="{MAIN_STYLE}">"#})
.with_custom_index(build_mermaid_window_index(theme));

Expand All @@ -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#"<link rel="stylesheet" href="{MAIN_STYLE}">"#})
.with_custom_index(build_math_window_index(theme));

Expand All @@ -231,7 +235,9 @@ pub fn open_or_focus_image_window(src: String, alt: Option<String>, 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#"<link rel="stylesheet" href="{MAIN_STYLE}">"#})
.with_custom_index(build_image_window_index(theme));

Expand Down
100 changes: 100 additions & 0 deletions desktop/src/window/icon.rs
Original file line number Diff line number Diff line change
@@ -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<HashMap<u32, Option<Icon>>> = 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<Icon> {
ICONS.with(|cache| {
cache
.borrow_mut()
.entry(size)
.or_insert_with(|| load_app_icon(size))
.clone()
})
}

fn load_app_icon(size: u32) -> Option<Icon> {
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());
}
}
5 changes: 3 additions & 2 deletions desktop/src/window/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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, _| {
Expand Down
92 changes: 92 additions & 0 deletions extras/windows/file-associations.nsh
Original file line number Diff line number Diff line change
@@ -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