From 0346d2986640d3a3e7a049a00cb83f7df56a422e Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 16:47:15 +0800 Subject: [PATCH 1/2] Sandbox vault persistence via macOS security-scoped bookmarks (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the App Sandbox (MAS) build re-open the user's vault across launches, and gives the direct build "reopen last vault" for free. - src/bookmark.rs: create/resolve security-scoped bookmarks via objc2-foundation NSURL. resolve_and_start() re-grants folder access process-wide (startAccessingSecurityScopedResource) so our std::fs vault scanner works again; the resolved NSURL is leaked to keep access alive for the session. No-op stubs off macOS. - commands_vault: open_vault persists {path, bookmark_b64} to app_data_dir/last-vault.json; new restore_vault command resolves it (bookmark on sandbox, plain path on direct) and returns a path to open. - vault.rs: VaultState gains a tokio open_lock so a launch-time restore can't race a user-triggered open on the Tantivy writer lock (the cause of the earlier "can't open vault" regression). - App.tsx: on mount, restoreVault() → openRecentVault(path). - Cargo: base64 + macOS-only objc2 / objc2-foundation (NSURL feature). Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/Cargo.lock | 3 ++ src-tauri/Cargo.toml | 15 +++++++ src-tauri/src/bookmark.rs | 67 ++++++++++++++++++++++++++++ src-tauri/src/commands_vault.rs | 78 ++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 5 ++- src-tauri/src/vault.rs | 6 +++ src/App.tsx | 18 ++++++++ src/lib/tauri.ts | 7 +++ 8 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/bookmark.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 777f98a..1791518 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2449,10 +2449,13 @@ checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" name = "markup" version = "0.5.0" dependencies = [ + "base64 0.22.1", "comrak", "criterion", "notify", "notify-debouncer-full", + "objc2", + "objc2-foundation", "parking_lot", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 352ba08..e1d0bab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,21 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Markdown parser (Rust-side, for indexing snippets and exports) comrak = { version = "0.40", default-features = false } +# Security-scoped bookmark persistence (base64-encode the bookmark blob) +base64 = "0.22" + +# macOS security-scoped bookmarks — lets a sandboxed (Mac App Store) build +# re-access the user's vault folder across launches. No-op on other OSes. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = [ + "NSURL", + "NSString", + "NSData", + "NSError", + "NSArray", +] } + [dev-dependencies] tempfile = "3" criterion = "0.7" diff --git a/src-tauri/src/bookmark.rs b/src-tauri/src/bookmark.rs new file mode 100644 index 0000000..0784e55 --- /dev/null +++ b/src-tauri/src/bookmark.rs @@ -0,0 +1,67 @@ +//! macOS security-scoped bookmarks for App Sandbox (Mac App Store) builds. +//! +//! Under the sandbox, the access a user grants by picking a folder in the +//! open panel lasts only for the session. To re-open that vault on the +//! next launch we capture a *security-scoped bookmark* while access is +//! live, persist it, and on the next launch resolve it and call +//! `startAccessingSecurityScopedResource` — which re-grants access +//! process-wide, so our plain `std::fs` vault scanner/reader works again. +//! +//! Non-sandboxed (direct-download) builds don't need any of this: `std::fs` +//! can read anywhere. There, `create()` returns `None` and the caller +//! persists just the plain path (which resolves directly on relaunch). + +#[cfg(target_os = "macos")] +pub fn create(path: &str) -> Option> { + use objc2_foundation::{NSString, NSURL, NSURLBookmarkCreationOptions}; + let ns_path = NSString::from_str(path); + let url = NSURL::fileURLWithPath(&ns_path); + let data = url + .bookmarkDataWithOptions_includingResourceValuesForKeys_relativeToURL_error( + NSURLBookmarkCreationOptions::WithSecurityScope, + None, + None, + ) + .ok()?; + Some(data.to_vec()) +} + +/// Resolve a bookmark, start accessing the security-scoped resource, and +/// return the folder path. The resolved `NSURL` is intentionally leaked so +/// the access it holds persists for the whole process — the vault stays +/// open for the session, so there's nothing to balance a `stop` against. +#[cfg(target_os = "macos")] +pub fn resolve_and_start(bytes: &[u8]) -> Option { + use objc2_foundation::{NSData, NSURL, NSURLBookmarkResolutionOptions}; + let data = NSData::with_bytes(bytes); + let url = unsafe { + NSURL::URLByResolvingBookmarkData_options_relativeToURL_bookmarkDataIsStale_error( + &data, + NSURLBookmarkResolutionOptions::WithSecurityScope, + None, + std::ptr::null_mut(), + ) + } + .ok()?; + let started = unsafe { url.startAccessingSecurityScopedResource() }; + if !started { + return None; + } + let path = url.path().map(|p| p.to_string()); + // Keep the security-scoped access alive: dropping the NSURL would + // implicitly stop it. We want it for the lifetime of the process. + std::mem::forget(url); + path +} + +// --- Non-macOS stubs: no sandbox, std::fs is unrestricted --- + +#[cfg(not(target_os = "macos"))] +pub fn create(_path: &str) -> Option> { + None +} + +#[cfg(not(target_os = "macos"))] +pub fn resolve_and_start(_bytes: &[u8]) -> Option { + None +} diff --git a/src-tauri/src/commands_vault.rs b/src-tauri/src/commands_vault.rs index 9d4f912..c3d1ee1 100644 --- a/src-tauri/src/commands_vault.rs +++ b/src-tauri/src/commands_vault.rs @@ -3,11 +3,45 @@ use crate::error::{AppError, AppResult}; use crate::index::{index_dir_for_vault, SearchHit}; use crate::vault::{VaultFileEntry, VaultState}; -use serde::Serialize; +use base64::Engine; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tauri::{AppHandle, Manager, State}; use tauri_plugin_dialog::DialogExt; +/// Persisted "last vault" record. `bookmark_b64` is a macOS +/// security-scoped bookmark (sandbox builds); null on non-sandbox builds, +/// where `path` resolves directly. +#[derive(Serialize, Deserialize)] +struct VaultSession { + path: String, + bookmark_b64: Option, +} + +fn session_file(app: &AppHandle) -> AppResult { + let dir = app + .path() + .app_data_dir() + .map_err(|e| AppError::Other(format!("app_data_dir: {e}")))?; + std::fs::create_dir_all(&dir).ok(); + Ok(dir.join("last-vault.json")) +} + +/// Persist the just-opened vault so the next launch can restore it. +/// Creates a security-scoped bookmark when possible (sandbox); best-effort. +fn persist_vault_session(app: &AppHandle, path: &str) { + let bookmark_b64 = crate::bookmark::create(path) + .map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)); + let session = VaultSession { + path: path.to_string(), + bookmark_b64, + }; + let Ok(file) = session_file(app) else { return }; + if let Ok(json) = serde_json::to_string(&session) { + let _ = std::fs::write(file, json); + } +} + #[derive(Debug, Serialize)] pub struct VaultOpened { pub root: String, @@ -42,12 +76,54 @@ pub async fn open_vault( let index_dir = index_dir_for_vault(&app_data, &root); let count = state.open(root.clone(), app.clone(), index_dir).await?; + // Remember this vault (+ a security-scoped bookmark on sandbox builds) + // so the next launch can restore it. Best-effort. + persist_vault_session(&app, &path); Ok(VaultOpened { root: root.to_string_lossy().into_owned(), file_count: count, }) } +/// Resolve the last vault for launch-time restore. Returns the folder +/// path after (on sandbox) re-acquiring access via its security-scoped +/// bookmark, or None if there's nothing to restore / access can't be +/// re-granted. The frontend opens the returned path through the normal +/// open-vault flow. +#[tauri::command] +pub fn restore_vault(app: AppHandle) -> AppResult> { + let Ok(file) = session_file(&app) else { + return Ok(None); + }; + let Ok(raw) = std::fs::read_to_string(&file) else { + return Ok(None); + }; + let Ok(session) = serde_json::from_str::(&raw) else { + return Ok(None); + }; + + match session.bookmark_b64 { + // Sandbox: must re-acquire access via the bookmark before std::fs + // can read the folder. If that fails, don't hand back a path we + // can't actually open. + Some(b64) => { + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) else { + return Ok(None); + }; + Ok(crate::bookmark::resolve_and_start(&bytes)) + } + // Non-sandbox: the plain path resolves directly, but only offer it + // if it still exists. + None => { + if PathBuf::from(&session.path).is_dir() { + Ok(Some(session.path)) + } else { + Ok(None) + } + } + } +} + #[tauri::command] pub fn close_vault(state: State<'_, VaultState>) -> AppResult<()> { state.close(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 29de92d..b77560e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod bookmark; mod commands; mod commands_locale; mod commands_vault; @@ -19,7 +20,8 @@ use commands::{ use commands_locale::set_locale; use commands_window::new_window; use commands_vault::{ - close_vault, current_vault, list_vault_files, open_vault, pick_vault, search_vault, + close_vault, current_vault, list_vault_files, open_vault, pick_vault, restore_vault, + search_vault, }; use recent::{clear_recent_files, list_recent_files, push_recent_file}; use std::sync::Mutex; @@ -109,6 +111,7 @@ pub fn run() { list_vault_files, current_vault, search_vault, + restore_vault, take_pending_files, ]) .build(tauri::generate_context!()) diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index e78050c..7407d6d 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -26,6 +26,10 @@ pub struct VaultFileEntry { #[derive(Default)] pub struct VaultState { inner: RwLock>, + /// Serializes `open()` calls. Without it, a launch-time restore and a + /// user-triggered open could run concurrently and race on the Tantivy + /// index writer lock / the final state swap. + open_lock: tokio::sync::Mutex<()>, } struct OpenVault { @@ -58,6 +62,8 @@ impl VaultState { app: AppHandle, index_dir: PathBuf, ) -> AppResult { + // Serialize concurrent opens (e.g. launch restore vs. user open). + let _open_guard = self.open_lock.lock().await; if !root.is_dir() { return Err(AppError::Other(format!( "vault path is not a directory: {}", diff --git a/src/App.tsx b/src/App.tsx index 194e93c..20d580a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -127,6 +127,7 @@ import { readFile, renameFile, renderHtml, + restoreVault, takePendingFiles, writeFile, } from "./lib/tauri"; @@ -1319,6 +1320,23 @@ export function App() { [setVault, tr], ); + // Restore the last vault on launch. On sandbox (MAS) builds the Rust + // side re-acquires folder access via a security-scoped bookmark first; + // on the direct build it just hands back the stored path. Opening goes + // through the normal path (openRecentVault), and VaultState serializes + // opens so this can't race a user-triggered open. + useEffect(() => { + let cancelled = false; + restoreVault() + .then((path) => { + if (!cancelled && path) openRecentVault(path); + }) + .catch((e) => console.warn("restore vault failed:", e)); + return () => { + cancelled = true; + }; + }, [openRecentVault]); + const refreshVault = useCallback(async () => { try { const files = await listVaultFiles(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index bb4544c..a39e1fd 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -83,6 +83,13 @@ export async function closeVault(): Promise { await invoke("close_vault"); } +/** Resolve the last vault for launch-time restore. On sandbox builds this + * re-acquires folder access via a security-scoped bookmark. Returns the + * path to open, or null if there's nothing to restore. */ +export async function restoreVault(): Promise { + return await invoke("restore_vault"); +} + export async function listVaultFiles(): Promise { return await invoke("list_vault_files"); } From e7f2a4ea2474e8c68a14125b339dd37978b44fc4 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 17:02:29 +0800 Subject: [PATCH 2/2] IME: stop inserting a newline per CJK keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compositionend handler dispatched a RECOMPUTE_META transaction synchronously, while ProseMirror was still finalizing the composed text — it re-read the DOM and double-counted, adding a spurious newline on every Chinese keystroke (regression shipped in v0.5.0). Removed the dispatch; decorations refresh on the next real edit. The store flush (read-only getMarkdown + onChange) is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/milkdown/composition.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/lib/milkdown/composition.ts b/src/lib/milkdown/composition.ts index b91912c..0dab1d2 100644 --- a/src/lib/milkdown/composition.ts +++ b/src/lib/milkdown/composition.ts @@ -23,8 +23,9 @@ export function isComposing(): boolean { return composing; } -/** Transaction meta key: "recompute your decorations now" — fired once - * after a composition ends so plugins refresh against the final text. */ +/** Transaction meta key kept for the decoration plugins' API. We no + * longer dispatch it (see below); decorations refresh on the next real + * edit after a composition. */ export const RECOMPUTE_META = "markup/recompute-decos"; const COMPOSITION_KEY = new PluginKey("markup/composition"); @@ -39,12 +40,14 @@ export const compositionTracker = $prose( composing = true; return false; // observe only — let ProseMirror handle it }, - compositionend: (view) => { + compositionend: () => { + // Observe only. Do NOT dispatch a transaction here: + // mutating editor state during ProseMirror's own + // compositionend handling makes it re-read the DOM and + // double-count the composed text — inserting a spurious + // newline on every CJK keystroke. Decorations recompute on + // the next real edit (isComposing() is false by then). composing = false; - // Composition committed; nudge decoration plugins to refresh - // against the now-final document. docChanged is false here, so - // they key off RECOMPUTE_META instead. - view.dispatch(view.state.tr.setMeta(RECOMPUTE_META, true)); return false; }, },