From 5af44ef742fd9317b93ac52176d978c43cc057e9 Mon Sep 17 00:00:00 2001 From: Artem Beer Date: Sun, 15 Mar 2026 21:30:47 -0500 Subject: [PATCH 1/2] Fix macOS file association open and scroll sync feedback loop Handle macOS Apple Events for file associations (#1) by switching from argv-based detection to Tauri's RunEvent::Opened handler, emitting an event to the frontend. Fix scroll sync drift (#2) by replacing the single-frame requestAnimationFrame guard with a 200ms debounced timeout to prevent feedback loops on macOS. Closes #1, Closes #2 Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.lock | 2 +- src-tauri/src/lib.rs | 17 +++++++++++++++-- src/main.js | 5 +++++ src/preview/scroll-sync.js | 12 +++++++++--- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dd65fab..dd8c2ee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "inkwell" -version = "0.1.0" +version = "0.2.0" dependencies = [ "log", "notify", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1d2ab6..dea87e0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ mod commands; #[cfg(desktop)] mod platform; +use tauri::Emitter; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let mut builder = tauri::Builder::default() @@ -41,6 +43,17 @@ pub fn run() { } builder - .run(tauri::generate_context!()) - .expect("error running Inkwell"); + .build(tauri::generate_context!()) + .expect("error building Inkwell") + .run(|app_handle, event| { + if let tauri::RunEvent::Opened { urls } = event { + for url in urls { + if let Ok(path) = url.to_file_path() { + if let Some(path_str) = path.to_str() { + let _ = app_handle.emit("open-file-requested", path_str.to_string()); + } + } + } + } + }); } diff --git a/src/main.js b/src/main.js index 433105a..b85c703 100644 --- a/src/main.js +++ b/src/main.js @@ -64,6 +64,11 @@ async function init() { } else { await showWelcome(); } + + // Handle macOS file-open events (double-click / "Open With" file associations) + await listen("open-file-requested", async (event) => { + await loadFile(event.payload); + }); } // --- File operations --- diff --git a/src/preview/scroll-sync.js b/src/preview/scroll-sync.js index e23e54d..fed60b3 100644 --- a/src/preview/scroll-sync.js +++ b/src/preview/scroll-sync.js @@ -2,6 +2,12 @@ let editorScroller = null; let previewEl = null; let syncSource = null; +let syncResetTimer = null; + +function deferSyncReset() { + clearTimeout(syncResetTimer); + syncResetTimer = setTimeout(() => { syncSource = null; }, 200); +} export function setupScrollSync(editorView, previewElement) { editorScroller = editorView.scrollDOM; @@ -17,7 +23,7 @@ export function setupScrollSync(editorView, previewElement) { const previewMax = previewEl.scrollHeight - previewEl.clientHeight; previewEl.scrollTop = ratio * previewMax; } - requestAnimationFrame(() => { syncSource = null; }); + deferSyncReset(); }); // Preview -> Editor @@ -30,7 +36,7 @@ export function setupScrollSync(editorView, previewElement) { const editorMax = editorScroller.scrollHeight - editorScroller.clientHeight; editorScroller.scrollTop = ratio * editorMax; } - requestAnimationFrame(() => { syncSource = null; }); + deferSyncReset(); }); } @@ -65,5 +71,5 @@ export function applyScrollRatio(ratio, targetMode) { previewEl.scrollTop = ratio * max; } - requestAnimationFrame(() => { syncSource = null; }); + deferSyncReset(); } From 5379309855629229c5af17f344ca811933e73594 Mon Sep 17 00:00:00 2001 From: Artem Beer Date: Sun, 15 Mar 2026 21:59:41 -0500 Subject: [PATCH 2/2] Fix file-open race condition and scroll sync applyScrollRatio regression Issue #1: RunEvent::Opened fires before the webview is ready on macOS, so app_handle.emit() silently drops events on cold launch. Fix by buffering opened paths in managed AppState (PendingOpenFiles) and exposing get_pending_open_file command for the frontend to poll at startup. The event listener is kept for runtime opens when the app is already running. Issue #2: The 200ms deferSyncReset was applied to applyScrollRatio, blocking user scroll for 200ms after every view-mode switch. Restore requestAnimationFrame for applyScrollRatio (one-shot restore only needs one-frame suppression) while keeping the 200ms debounce for the bidirectional sync handlers where macOS async scroll delivery requires a longer guard window. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/file_ops.rs | 13 ++++++++++++ src-tauri/src/lib.rs | 32 +++++++++++++++++++++++------- src/main.js | 22 ++++++++++++-------- src/preview/scroll-sync.js | 6 ++++-- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 8a2d319..71bf939 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -1,5 +1,7 @@ use std::fs; +use crate::PendingOpenFiles; + #[tauri::command] pub async fn read_file(path: String) -> Result { fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e)) @@ -18,6 +20,7 @@ pub async fn get_file_size(path: String) -> Result { } /// Returns the file path passed as a CLI argument (for file associations), if any. +/// Works on Linux/Windows where the OS passes the file path via argv. #[tauri::command] pub async fn get_open_file_arg() -> Option { let args: Vec = std::env::args().collect(); @@ -30,3 +33,13 @@ pub async fn get_open_file_arg() -> Option { } }) } + +/// Returns and drains any file paths received via macOS Apple Events (RunEvent::Opened) +/// before the frontend was ready. Called by the frontend on init to handle cold-launch opens. +#[tauri::command] +pub async fn get_pending_open_file(state: tauri::State<'_, PendingOpenFiles>) -> Result, String> { + let mut pending = state.0.lock().unwrap(); + let first = pending.first().cloned(); + pending.clear(); + Ok(first) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dea87e0..6ef5c2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,12 @@ mod commands; #[cfg(desktop)] mod platform; -use tauri::Emitter; +use std::sync::Mutex; +use tauri::{Emitter, Manager}; + +/// Stores file paths received via macOS Apple Events (RunEvent::Opened) +/// before the frontend is ready to handle them. +pub struct PendingOpenFiles(pub Mutex>); #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -11,6 +16,7 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_store::Builder::default().build()) + .manage(PendingOpenFiles(Mutex::new(Vec::new()))) .invoke_handler(tauri::generate_handler![ commands::file_ops::read_file, commands::file_ops::write_file, @@ -22,6 +28,7 @@ pub fn run() { commands::recent::add_recent_file, commands::recent::clear_recent_files, commands::file_ops::get_open_file_arg, + commands::file_ops::get_pending_open_file, #[cfg(desktop)] platform::desktop::watch_file, #[cfg(desktop)] @@ -47,12 +54,23 @@ pub fn run() { .expect("error building Inkwell") .run(|app_handle, event| { if let tauri::RunEvent::Opened { urls } = event { - for url in urls { - if let Ok(path) = url.to_file_path() { - if let Some(path_str) = path.to_str() { - let _ = app_handle.emit("open-file-requested", path_str.to_string()); - } - } + let paths: Vec = urls + .iter() + .filter_map(|url| url.to_file_path().ok()) + .filter_map(|p| p.to_str().map(String::from)) + .collect(); + + if paths.is_empty() { + return; + } + + // Try to emit to frontend (works when app is already running). + // Also store in state for cold-launch (frontend polls on init). + let state = app_handle.state::(); + let mut pending = state.0.lock().unwrap(); + for path in &paths { + pending.push(path.clone()); + let _ = app_handle.emit("open-file-requested", path.clone()); } } }); diff --git a/src/main.js b/src/main.js index b85c703..74436fb 100644 --- a/src/main.js +++ b/src/main.js @@ -57,18 +57,24 @@ async function init() { document.getElementById("status-version").textContent = `v${version}`; } catch {} - // Check if app was launched with a file argument (file association / "Open With") + // Handle macOS file-open events for when the app is already running + // (registered early so no events are missed during the checks below) + await listen("open-file-requested", async (event) => { + await loadFile(event.payload); + }); + + // Check if app was launched with a file argument + // 1. CLI argv (Linux/Windows file associations) + // 2. macOS Apple Events buffered in AppState (RunEvent::Opened fires before webview is ready) const fileArg = await invoke("get_open_file_arg"); - if (fileArg) { - await loadFile(fileArg); + const pendingFile = !fileArg ? await invoke("get_pending_open_file") : null; + const openPath = fileArg || pendingFile; + + if (openPath) { + await loadFile(openPath); } else { await showWelcome(); } - - // Handle macOS file-open events (double-click / "Open With" file associations) - await listen("open-file-requested", async (event) => { - await loadFile(event.payload); - }); } // --- File operations --- diff --git a/src/preview/scroll-sync.js b/src/preview/scroll-sync.js index fed60b3..f3181f4 100644 --- a/src/preview/scroll-sync.js +++ b/src/preview/scroll-sync.js @@ -58,7 +58,9 @@ export function getScrollRatio(activeMode) { return 0; } -// Apply a scroll ratio to the target pane(s), suppressing sync feedback +// Apply a scroll ratio to the target pane(s), suppressing sync feedback. +// Uses a single requestAnimationFrame guard (not the 200ms debounce) since +// this is a one-shot restore that shouldn't block user scroll input. export function applyScrollRatio(ratio, targetMode) { syncSource = "restore"; @@ -71,5 +73,5 @@ export function applyScrollRatio(ratio, targetMode) { previewEl.scrollTop = ratio * max; } - deferSyncReset(); + requestAnimationFrame(() => { syncSource = null; }); }