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
3 changes: 3 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions src-tauri/src/bookmark.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>> {
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<String> {
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<Vec<u8>> {
None
}

#[cfg(not(target_os = "macos"))]
pub fn resolve_and_start(_bytes: &[u8]) -> Option<String> {
None
}
78 changes: 77 additions & 1 deletion src-tauri/src/commands_vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

fn session_file(app: &AppHandle) -> AppResult<PathBuf> {
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,
Expand Down Expand Up @@ -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<Option<String>> {
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::<VaultSession>(&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();
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod bookmark;
mod commands;
mod commands_locale;
mod commands_vault;
Expand All @@ -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;
Expand Down Expand Up @@ -109,6 +111,7 @@ pub fn run() {
list_vault_files,
current_vault,
search_vault,
restore_vault,
take_pending_files,
])
.build(tauri::generate_context!())
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ pub struct VaultFileEntry {
#[derive(Default)]
pub struct VaultState {
inner: RwLock<Option<OpenVault>>,
/// 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 {
Expand Down Expand Up @@ -58,6 +62,8 @@ impl VaultState {
app: AppHandle,
index_dir: PathBuf,
) -> AppResult<usize> {
// 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: {}",
Expand Down
18 changes: 18 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ import {
readFile,
renameFile,
renderHtml,
restoreVault,
takePendingFiles,
writeFile,
} from "./lib/tauri";
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 10 additions & 7 deletions src/lib/milkdown/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;
},
},
Expand Down
7 changes: 7 additions & 0 deletions src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export async function closeVault(): Promise<void> {
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<string | null> {
return await invoke<string | null>("restore_vault");
}

export async function listVaultFiles(): Promise<VaultFile[]> {
return await invoke<VaultFile[]>("list_vault_files");
}
Expand Down
Loading