diff --git a/src/bin/mdo-open.rs b/src/bin/mdo-open.rs index df36de0..5cbb1f6 100644 --- a/src/bin/mdo-open.rs +++ b/src/bin/mdo-open.rs @@ -10,10 +10,13 @@ //! but break terminal usage (no stdout to the parent shell, output races the //! returning prompt under cmd/PowerShell). //! -//! Instead, `mdo-open[.exe]` is a tiny desktop wrapper that: -//! 1. Locates `mdo[.exe]` next to itself. -//! 2. Spawns it with `--open` and the rest of the args, using -//! `CREATE_NO_WINDOW` so the child never gets a console allocated. +//! Instead, `mdo-open[.exe]` is a tiny desktop wrapper that consumes the +//! shared handler invocation contract (`mdo_cli::handler`): +//! 1. Locates `mdo[.exe]` next to itself (the one sibling-binary +//! discovery function, so a missing sibling gets one clear message). +//! 2. Spawns it with the contract argv (`--open` plus the file args), +//! using `CREATE_NO_WINDOW` so the child never gets a console +//! allocated. //! 3. If launched directly with no file args, opens onboarding: Windows //! starts the terminal setup in Windows Terminal when available; Linux //! opens `mdo-setup` for native first-run onboarding. This step is @@ -48,8 +51,8 @@ use std::process::{Command, ExitCode}; #[cfg(target_os = "windows")] use std::io; -#[cfg(target_os = "windows")] -use std::path::Path; + +use mdo_cli::handler; #[cfg(target_os = "windows")] const MDO_BIN: &str = "mdo.exe"; @@ -60,12 +63,16 @@ const MDO_BIN: &str = "mdo"; const SETUP_BIN: &str = "mdo-setup"; fn main() -> ExitCode { - let mut exe_path = match env::current_exe() { + // A GUI-launched binary often has no console attached, so this message + // may still go nowhere on Windows — but silent failure is worse + // everywhere, and on Linux it reaches the file manager's stderr log. + let exe_path = match env::current_exe() { Ok(p) => p, - Err(_) => return ExitCode::from(1), + Err(e) => { + eprintln!("mdo-open: cannot locate its own executable: {e}"); + return ExitCode::from(1); + } }; - exe_path.pop(); // strip the file name, keep the directory - let args = env::args_os().skip(1).collect::>(); #[cfg(target_os = "windows")] @@ -77,7 +84,10 @@ fn main() -> ExitCode { if args.is_empty() { return match spawn_windows_onboarding(&exe_path) { Ok(()) => ExitCode::SUCCESS, - Err(_) => ExitCode::from(1), + Err(e) => { + eprintln!("mdo-open: {e}"); + ExitCode::from(1) + } }; } @@ -90,33 +100,47 @@ fn main() -> ExitCode { // instead of always reporting success. match spawn_windows_open(&exe_path, args) { Ok(status) => exit_code_from_status(status), - Err(_) => ExitCode::from(1), + Err(e) => { + eprintln!("mdo-open: {e}"); + ExitCode::from(1) + } } } #[cfg(not(target_os = "windows"))] { #[cfg(target_os = "linux")] - let mut cmd = if args.is_empty() { - let setup = exe_path.join(SETUP_BIN); - if !setup.exists() { - return ExitCode::from(1); + if args.is_empty() { + // No-file launch is onboarding, not open: hand off to the + // sibling `mdo-setup`. + let setup = match handler::sibling_binary(&exe_path, SETUP_BIN) { + Ok(path) => path, + Err(e) => { + eprintln!("mdo-open: {e}"); + return ExitCode::from(1); + } + }; + let mut cmd = Command::new(setup); + match cmd.status() { + Ok(status) => return exit_code_from_status(status), + Err(e) => { + eprintln!("mdo-open: {e}"); + return ExitCode::from(1); + } } - Command::new(setup) - } else { - let mut cmd = Command::new(exe_path.join(MDO_BIN)); - cmd.arg("--open"); - cmd.args(args); - cmd - }; + } - #[cfg(not(target_os = "linux"))] - let mut cmd = { - let mut cmd = Command::new(exe_path.join(MDO_BIN)); - cmd.arg("--open"); - cmd.args(args); - cmd + // Everything else is the forward: sibling mdo with the contract + // argv (`--open` plus the files as received). + let mdo_bin = match handler::sibling_binary(&exe_path, MDO_BIN) { + Ok(path) => path, + Err(e) => { + eprintln!("mdo-open: {e}"); + return ExitCode::from(1); + } }; + let mut cmd = Command::new(mdo_bin); + cmd.args(handler::open_argv(&args)); // Non-Windows binaries never had a console-flash problem to begin // with (that's Windows Explorer + console-subsystem-exe behavior), @@ -126,7 +150,10 @@ fn main() -> ExitCode { // in an already-attached terminal) and propagate its exit status. match cmd.status() { Ok(status) => exit_code_from_status(status), - Err(_) => ExitCode::from(1), + Err(e) => { + eprintln!("mdo-open: {e}"); + ExitCode::from(1) + } } } } @@ -137,10 +164,17 @@ fn main() -> ExitCode { /// a failure whose low byte is zero (e.g. 0x100) must not truncate to /// "success"; any failing status maps to a non-zero code. fn exit_code_from_status(status: std::process::ExitStatus) -> ExitCode { - if status.success() { + exit_code_from_parts(status.success(), status.code()) +} + +/// The truncation rule of [`exit_code_from_status`], split from +/// `ExitStatus` so it is unit-testable portably (Unix cannot even produce a +/// failed status with a zero low byte; Windows can). +fn exit_code_from_parts(success: bool, code: Option) -> ExitCode { + if success { return ExitCode::SUCCESS; } - match status.code() { + match code { Some(code) if code as u8 != 0 => ExitCode::from(code as u8), _ => ExitCode::FAILURE, } @@ -148,28 +182,18 @@ fn exit_code_from_status(status: std::process::ExitStatus) -> ExitCode { #[cfg(target_os = "windows")] fn spawn_windows_open( - exe_dir: &Path, + anchor_exe: &std::path::Path, args: Vec, ) -> io::Result { - let mut cmd = Command::new(exe_dir.join(MDO_BIN)); - cmd.arg("--open"); - cmd.args(args); + let mdo_bin = handler::sibling_binary(anchor_exe, MDO_BIN)?; + let mut cmd = Command::new(mdo_bin); + cmd.args(handler::open_argv(&args)); spawn_without_console(cmd) } #[cfg(target_os = "windows")] -fn spawn_windows_onboarding(exe_dir: &Path) -> io::Result<()> { - let mdo = exe_dir.join(MDO_BIN); - if !mdo.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!( - "expected {MDO_BIN} next to mdo-open.exe at {}", - mdo.display() - ), - )); - } - +fn spawn_windows_onboarding(anchor_exe: &std::path::Path) -> io::Result<()> { + let mdo = handler::sibling_binary(anchor_exe, MDO_BIN)?; mdo_cli::windows_setup::spawn_terminal_setup(&mdo) } @@ -189,3 +213,51 @@ fn spawn_without_console(mut cmd: Command) -> io::Result io::Result<()> { continue_after_launcher_registration(register_launcher()); - let mdo = sibling_binary("mdo")?; - if !mdo.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("expected mdo next to mdo-setup at {}", mdo.display()), - )); - } + let mdo = mdo_cli::handler::sibling_binary(&std::env::current_exe()?, "mdo")?; // Already attached to a terminal (e.g. run from a shell): show guided setup // right here instead of spawning a second window. The `?` still @@ -254,12 +248,6 @@ mod linux_setup { .unwrap_or(false) } - fn sibling_binary(name: &str) -> io::Result { - let mut path = std::env::current_exe()?; - path.pop(); - Ok(path.join(name)) - } - #[cfg(test)] mod tests { use super::*; @@ -338,7 +326,7 @@ mod windows_setup { }; pub fn run() -> io::Result<()> { - let mdo = sibling_binary("mdo.exe")?; + let mdo = mdo_cli::handler::sibling_binary(&std::env::current_exe()?, "mdo.exe")?; if !mdo.exists() { return Err(io::Error::new( io::ErrorKind::NotFound, @@ -375,12 +363,6 @@ mod windows_setup { Ok(button) } - fn sibling_binary(name: &str) -> io::Result { - let mut path = std::env::current_exe()?; - path.pop(); - Ok(path.join(name)) - } - fn wide(value: &str) -> Vec { value.encode_utf16().chain(iter::once(0)).collect() } diff --git a/src/file_manager.rs b/src/file_manager.rs index 3674b2f..8140994 100644 --- a/src/file_manager.rs +++ b/src/file_manager.rs @@ -4,6 +4,8 @@ use std::process::Command; #[cfg(target_os = "windows")] use std::process::Stdio; +use crate::handler; + const APP_DISPLAY_NAME: &str = "Open as HTML"; #[cfg(target_os = "linux")] const DESKTOP_FILE_NAME: &str = "mdo.desktop"; @@ -27,103 +29,284 @@ const SVG_ICON: &str = r##"

— inside a ``` or ~~~ block is never mistaken for the title. - let without_fences = strip_code_fences(markdown); - for line in without_fences.lines() { - let trimmed = line.trim_start(); - if let Some(rest) = trimmed.trim_end().strip_prefix("# ") { - return rest.trim().to_string(); - } - } - if let Some(title) = derive_raw_html_h1_title(&without_fences) { - return title; - } - fallback.to_string() -} - -/// Extract a simple scalar `title:` from a complete YAML-style metadata block -/// at the start of the document. -/// -/// pulldown-cmark deliberately recognizes the block delimiters without -/// interpreting YAML. mdo follows that narrow behavior: it reads only an -/// unindented `title` key and does not add a general-purpose YAML parser (and -/// its dependency/security surface) merely to title the generated page. -fn front_matter_title(markdown: &str) -> Option { - let (metadata, _) = yaml_front_matter(markdown)?; - let mut title = None; - for line in metadata.lines() { - if line.starts_with(char::is_whitespace) { - continue; - } - if let Some((key, value)) = line.split_once(':') { - if key == "title" { - let value = unquote_front_matter_scalar(value.trim()); - if !value.is_empty() { - title = Some(value.to_string()); - } - } - } - } - - title -} - -/// Split a complete YAML-style metadata block from the document body. The -/// returned metadata excludes delimiters. -/// -/// This splitter is the single source of truth for front-matter recognition -/// (both hiding the block from rendered output and title extraction), and its -/// acceptance rules are deliberately conservative and GitHub-like: -/// -/// - the opening `---` must be the very first line of the document; -/// - the closing delimiter is `---` or `...` on its own line — trailing -/// spaces are allowed but tabs are not; -/// - the block must be non-empty and its first line must not be blank. -/// GitHub and pulldown-cmark both reject blocks whose first content line is -/// blank, so treating them as front matter here would hide visible content -/// and let e.g. `---\n\ntitle: fake\n---` spoof the page title. -fn yaml_front_matter(markdown: &str) -> Option<(&str, &str)> { - fn delimiter(line: &str) -> Option<&str> { - let line = line.trim_end_matches(['\r', '\n']).trim_end_matches(' '); - (line == "---" || line == "...").then_some(line) - } - - let first_line_end = markdown.find('\n').unwrap_or(markdown.len()); - if delimiter(&markdown[..first_line_end]) != Some("---") || first_line_end == markdown.len() { - return None; - } - - let metadata_start = first_line_end + 1; - let remainder = &markdown[metadata_start..]; - let mut consumed = 0; - // split_inclusive also yields a final segment without a trailing newline, - // so a closing delimiter at end-of-input is found by the loop as well. - for segment in remainder.split_inclusive('\n') { - if delimiter(segment).is_some() { - let metadata = &remainder[..consumed]; - let first_content_line = metadata.lines().next()?; - if first_content_line.trim().is_empty() { - return None; - } - return Some((metadata, &remainder[consumed + segment.len()..])); - } - consumed += segment.len(); - } - - None -} - -fn unquote_front_matter_scalar(value: &str) -> &str { - if value.len() < 2 { - return value; - } - - let bytes = value.as_bytes(); - if matches!( - (bytes[0], bytes[value.len() - 1]), - (b'\'', b'\'') | (b'"', b'"') - ) { - &value[1..value.len() - 1] - } else { - value - } -} - -/// Return `markdown` with fenced code blocks (``` or ~~~) removed, so title -/// detection never reads inside code samples. -fn strip_code_fences(markdown: &str) -> String { - let mut out = String::with_capacity(markdown.len()); - let mut fence: Option = None; - for line in markdown.lines() { - if let Some(marker) = code_fence_marker(line.trim_start()) { - match fence { - Some(open) if open == marker => fence = None, - Some(_) => {} - None => fence = Some(marker), - } - continue; - } - if fence.is_some() { - continue; - } - out.push_str(line); - out.push('\n'); - } - out -} - -/// Detect a code-fence line, returning its marker char when the line starts -/// with at least three backticks or tildes. This is a pragmatic subset of -/// CommonMark (it does not bound the indent or require the closing fence to be -/// at least as long), which is sufficient for title detection. -fn code_fence_marker(trimmed_start: &str) -> Option { - if trimmed_start.starts_with("```") { - Some('`') - } else if trimmed_start.starts_with("~~~") { - Some('~') - } else { - None - } -} - -fn derive_raw_html_h1_title(markdown: &str) -> Option { - let lower = markdown.to_ascii_lowercase(); - let mut search_start = 0; - - while let Some(offset) = lower[search_start..].find("') | Some(b' ') | Some(b'\t') | Some(b'\r') | Some(b'\n') - ) { - search_start = after_tag_name; - continue; - } - - let content_start = start + markdown[start..].find('>')? + 1; - let content_end = content_start + lower[content_start..].find("

")?; - let title = strip_html_tags(&markdown[content_start..content_end]) - .split_whitespace() - .collect::>() - .join(" "); - if !title.is_empty() { - return Some(title); - } - - search_start = content_end + 5; - } - - None -} - -fn strip_html_tags(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let mut in_tag = false; - - for ch in input.chars() { - match ch { - '<' => in_tag = true, - '>' if in_tag => in_tag = false, - _ if !in_tag => output.push(ch), - _ => {} - } - } - - output -} - -fn wrap_html5( - body: &str, - title: &str, - base_href: Option<&str>, - css_override: Option<&str>, - highlight_css: Option<&str>, - source_modified_unix_secs: Option, -) -> String { - // `` makes relative image/link refs in the rendered HTML resolve - // against the *source* directory even when the HTML lives elsewhere - // (e.g. when --open writes to %TEMP%). Must come before any element that - // references a URL, so we put it first inside . - let base_tag = match base_href { - Some(href) => format!("\n", html_escape(href)), - None => String::new(), - }; - let css_override_block = css_override - .filter(|css| !css.trim().is_empty()) - .map(|css| { - format!( - "\n", - escape_style_end_tags(css) - ) - }) - .unwrap_or_default(); - let mdo_default_typography = format!( - "\n", - escape_style_end_tags(MDO_DEFAULT_TYPOGRAPHY_CSS) - ); - let highlight_css = highlight_css.unwrap_or_default(); - // Provenance lives only in ; the visible page - // carries no mdo branding, version, or render timing (v0.6 quiet output). - let generator = format!("{APP_DISPLAY_NAME} {APP_VERSION}"); - // Restrained source-freshness footer: the source file's filesystem - // modification time, rendered as UTC with a machine-readable datetime. - // The tiny inline script re-formats it in the reader's locale/timezone - // when JavaScript is available. Omitted entirely when the timestamp is - // missing or unreadable. - let source_meta = source_modified_unix_secs - .map(|secs| { - let machine = utc_datetime_from_unix_secs(secs); - let human = human_utc_datetime_from_unix_secs(secs); - format!( - "
Source modified:
\n\ - \n", - machine = html_escape(&machine), - human = html_escape(&human), - ) - }) - .unwrap_or_default(); - format!( - "\n\ - \n\ - \n\ - \n\ - \n\ - \n\ - {base_tag}\ - {title}\n\ - \n\ - {theme_toggle}\ - {mdo_default_typography}\ - {css_override_block}\ - \n\ - \n\ -
\n{body}\n
\n\ - {source_meta}\ - \n\ - \n", - generator = html_escape(&generator), - base_tag = base_tag, - title = html_escape(title), - css = SIMPLE_CSS, - gfm_alerts_css = GFM_ALERTS_CSS, - highlight_css = highlight_css, - theme_toggle = THEME_TOGGLE, // ← THEME TOGGLE injection point (delete this line to remove) - mdo_default_typography = mdo_default_typography, - css_override_block = css_override_block, - body = body, - source_meta = source_meta, - ) -} - -fn escape_style_end_tags(css: &str) -> String { - const STYLE_END_PREFIX: &[u8] = b" Option { - fs::metadata(input) - .and_then(|metadata| metadata.modified()) - .ok() - .and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok()) - .map(|elapsed| elapsed.as_secs()) -} - -/// ISO 8601 UTC datetime, e.g. `2026-07-14T17:42:05Z`, for `