From d497ff8aafc9eb492538f2d87a7c8a49d2226aac Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Fri, 24 Jul 2026 02:19:00 +0200 Subject: [PATCH] Add iOS Files vault selection --- src-tauri/Cargo.lock | 10 + src-tauri/Cargo.toml | 3 + src-tauri/plugins/ios-vault-access/Cargo.toml | 15 + src-tauri/plugins/ios-vault-access/build.rs | 3 + .../ios-vault-access/ios/Package.swift | 24 ++ .../ios/Sources/IosVaultAccessPlugin.swift | 312 ++++++++++++++++++ src-tauri/plugins/ios-vault-access/src/lib.rs | 92 ++++++ src-tauri/src/commands.rs | 287 ++++++++++++++-- src-tauri/src/lib.rs | 5 + src-tauri/src/types.rs | 5 + src/lib/api.ts | 13 +- src/lib/components/VaultPicker.svelte | 73 +++- src/lib/stores/app.ts | 5 +- src/lib/types.ts | 8 + src/routes/+page.svelte | 30 +- 15 files changed, 846 insertions(+), 39 deletions(-) create mode 100644 src-tauri/plugins/ios-vault-access/Cargo.toml create mode 100644 src-tauri/plugins/ios-vault-access/build.rs create mode 100644 src-tauri/plugins/ios-vault-access/ios/Package.swift create mode 100644 src-tauri/plugins/ios-vault-access/ios/Sources/IosVaultAccessPlugin.swift create mode 100644 src-tauri/plugins/ios-vault-access/src/lib.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dd540c3..8229dd4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1876,6 +1876,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-ios-vault-access", "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-single-instance", @@ -5201,6 +5202,15 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-ios-vault-access" +version = "0.1.0" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-log" version = "2.8.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1ccbc75..30ecb32 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,6 +42,9 @@ quick-xml = "0.36" urlencoding = "2" sha2 = "0.10" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } +[target.'cfg(target_os = "ios")'.dependencies] +tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" } + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" diff --git a/src-tauri/plugins/ios-vault-access/Cargo.toml b/src-tauri/plugins/ios-vault-access/Cargo.toml new file mode 100644 index 0000000..075184c --- /dev/null +++ b/src-tauri/plugins/ios-vault-access/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tauri-plugin-ios-vault-access" +version = "0.1.0" +authors = ["HelixNotes"] +description = "iOS Files folder access for HelixNotes" +edition = "2021" +rust-version = "1.77.2" +links = "tauri-plugin-ios-vault-access" + +[dependencies] +tauri = "2" +serde = { version = "1", features = ["derive"] } + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } diff --git a/src-tauri/plugins/ios-vault-access/build.rs b/src-tauri/plugins/ios-vault-access/build.rs new file mode 100644 index 0000000..9e2b2a6 --- /dev/null +++ b/src-tauri/plugins/ios-vault-access/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_plugin::Builder::new(&[]).ios_path("ios").build(); +} diff --git a/src-tauri/plugins/ios-vault-access/ios/Package.swift b/src-tauri/plugins/ios-vault-access/ios/Package.swift new file mode 100644 index 0000000..2aab613 --- /dev/null +++ b/src-tauri/plugins/ios-vault-access/ios/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.3 +import PackageDescription + +let package = Package( + name: "tauri-plugin-ios-vault-access", + platforms: [.iOS(.v14)], + products: [ + .library( + name: "tauri-plugin-ios-vault-access", + type: .static, + targets: ["tauri-plugin-ios-vault-access"] + ) + ], + dependencies: [ + .package(name: "Tauri", path: "../.tauri/tauri-api") + ], + targets: [ + .target( + name: "tauri-plugin-ios-vault-access", + dependencies: [.byName(name: "Tauri")], + path: "Sources" + ) + ] +) diff --git a/src-tauri/plugins/ios-vault-access/ios/Sources/IosVaultAccessPlugin.swift b/src-tauri/plugins/ios-vault-access/ios/Sources/IosVaultAccessPlugin.swift new file mode 100644 index 0000000..a154620 --- /dev/null +++ b/src-tauri/plugins/ios-vault-access/ios/Sources/IosVaultAccessPlugin.swift @@ -0,0 +1,312 @@ +import Foundation +import SwiftRs +import Tauri +import UIKit +import UniformTypeIdentifiers + +private struct BookmarkRequest: Decodable { + let bookmarkId: String +} + +final class IosVaultAccessPlugin: Plugin, UIDocumentPickerDelegate { + private let fileManager = FileManager.default + private let lock = NSLock() + private var pickerInvoke: Invoke? + private var activeBookmarkId: String? + private var activeURL: URL? + private var stagedBookmarkId: String? + private var stagedURL: URL? + + deinit { + rollbackStagedScope() + releaseCurrentScope() + } + + @objc func chooseFolder(_ invoke: Invoke) { + DispatchQueue.main.async { + guard self.pickerInvoke == nil else { + invoke.reject("A folder picker is already open.") + return + } + guard let viewController = self.manager.viewController else { + self.pickerInvoke = nil + invoke.reject("Files is unavailable right now. Try again.") + return + } + self.pickerInvoke = invoke + let picker = UIDocumentPickerViewController( + forOpeningContentTypes: [.folder], + asCopy: false + ) + picker.delegate = self + picker.allowsMultipleSelection = false + viewController.present(picker, animated: true) + } + } + + @objc func resolveFolder(_ invoke: Invoke) throws { + let request = try invoke.parseArgs(BookmarkRequest.self) + do { + invoke.resolve(try resolveBookmark(request.bookmarkId)) + } catch { + invoke.reject(message(for: error)) + } + } + + @objc func releaseActive(_ invoke: Invoke) { + rollbackStagedScope() + releaseCurrentScope() + invoke.resolve() + } + + @objc func commitStaged(_ invoke: Invoke) { + commitStagedScope() + invoke.resolve() + } + + @objc func rollbackStaged(_ invoke: Invoke) { + rollbackStagedScope() + invoke.resolve() + } + + @objc func forgetBookmark(_ invoke: Invoke) throws { + let request = try invoke.parseArgs(BookmarkRequest.self) + do { + let file = try bookmarkFile(for: request.bookmarkId, createDirectory: false) + lock.lock() + let isActive = activeBookmarkId == request.bookmarkId + let isStaged = stagedBookmarkId == request.bookmarkId + lock.unlock() + if fileManager.fileExists(atPath: file.path) { + try fileManager.removeItem(at: file) + } + if isActive { + releaseCurrentScope() + } + if isStaged { + rollbackStagedScope() + } + invoke.resolve() + } catch { + invoke.reject(message(for: error)) + } + } + + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + guard let invoke = takePickerInvoke() else { return } + guard let url = urls.first else { + invoke.resolve(cancelledResult()) + return + } + + do { + guard url.startAccessingSecurityScopedResource() else { + throw AccessError.denied + } + var adopted = false + defer { + if !adopted { url.stopAccessingSecurityScopedResource() } + } + + let name = try validateFolder(url) + let bookmarkId = existingBookmarkId(for: url) ?? UUID().uuidString.lowercased() + let bookmark = try url.bookmarkData( + options: .minimalBookmark, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + try bookmark.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic) + stageScope(url: url, bookmarkId: bookmarkId) + adopted = true + invoke.resolve(result(bookmarkId: bookmarkId, url: url, name: name)) + } catch { + invoke.reject(message(for: error)) + } + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + takePickerInvoke()?.resolve(cancelledResult()) + } + + private func resolveBookmark(_ bookmarkId: String) throws -> [String: Any] { + guard UUID(uuidString: bookmarkId) != nil else { throw AccessError.missing } + + lock.lock() + let currentURL = activeBookmarkId == bookmarkId ? activeURL : nil + lock.unlock() + if let currentURL { + return result(bookmarkId: bookmarkId, url: currentURL, name: try validateFolder(currentURL)) + } + + let data = try Data(contentsOf: bookmarkFile(for: bookmarkId, createDirectory: false)) + var stale = false + let url = try URL( + resolvingBookmarkData: data, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &stale + ) + guard url.startAccessingSecurityScopedResource() else { throw AccessError.denied } + var adopted = false + defer { + if !adopted { url.stopAccessingSecurityScopedResource() } + } + + let name = try validateFolder(url) + if stale { + let refreshed = try url.bookmarkData( + options: .minimalBookmark, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + try refreshed.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic) + } + stageScope(url: url, bookmarkId: bookmarkId) + adopted = true + return result(bookmarkId: bookmarkId, url: url, name: name) + } + + private func validateFolder(_ url: URL) throws -> String { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .nameKey]) + guard values.isDirectory == true else { throw AccessError.notFolder } + guard try url.checkResourceIsReachable() else { throw AccessError.unavailable } + return values.name ?? url.lastPathComponent + } + + private func stageScope(url: URL, bookmarkId: String) { + lock.lock() + let previous = stagedURL + stagedURL = url + stagedBookmarkId = bookmarkId + lock.unlock() + previous?.stopAccessingSecurityScopedResource() + } + + private func commitStagedScope() { + lock.lock() + guard let next = stagedURL else { + lock.unlock() + return + } + let previous = activeURL + activeURL = next + activeBookmarkId = stagedBookmarkId + stagedURL = nil + stagedBookmarkId = nil + lock.unlock() + previous?.stopAccessingSecurityScopedResource() + } + + private func rollbackStagedScope() { + lock.lock() + let staged = stagedURL + stagedURL = nil + stagedBookmarkId = nil + lock.unlock() + staged?.stopAccessingSecurityScopedResource() + } + + private func releaseCurrentScope() { + lock.lock() + let previous = activeURL + activeURL = nil + activeBookmarkId = nil + lock.unlock() + previous?.stopAccessingSecurityScopedResource() + } + + private func takePickerInvoke() -> Invoke? { + let invoke = pickerInvoke + pickerInvoke = nil + return invoke + } + + private func bookmarkDirectory(create: Bool) throws -> URL { + let base = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let directory = base.appendingPathComponent("VaultBookmarks", isDirectory: true) + if create { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + return directory + } + + private func bookmarkFile(for id: String, createDirectory: Bool) throws -> URL { + guard UUID(uuidString: id) != nil else { throw AccessError.missing } + let directory = try bookmarkDirectory(create: createDirectory) + return directory.appendingPathComponent("\(id).bookmark") + } + + private func existingBookmarkId(for selectedURL: URL) -> String? { + guard + let directory = try? bookmarkDirectory(create: true), + let files = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + else { return nil } + + let selectedPath = selectedURL.standardizedFileURL.path + for file in files where file.pathExtension == "bookmark" { + guard let data = try? Data(contentsOf: file) else { continue } + var stale = false + guard + let resolved = try? URL( + resolvingBookmarkData: data, + options: [], + relativeTo: nil, + bookmarkDataIsStale: &stale + ), + resolved.standardizedFileURL.path == selectedPath + else { continue } + let id = file.deletingPathExtension().lastPathComponent + if UUID(uuidString: id) != nil { return id } + } + return nil + } + + private func result(bookmarkId: String, url: URL, name: String) -> [String: Any] { + ["bookmarkId": bookmarkId, "path": url.path, "name": name, "cancelled": false] + } + + private func cancelledResult() -> [String: Any] { + ["bookmarkId": "", "path": "", "name": "", "cancelled": true] + } + + private func message(for error: Error) -> String { + if let accessError = error as? AccessError { return accessError.localizedDescription } + return "The selected folder is unavailable. Open Files and try again." + } +} + +private enum AccessError: LocalizedError { + case denied + case missing + case notFolder + case unavailable + + var errorDescription: String? { + switch self { + case .denied: + return "HelixNotes no longer has access to this folder. Choose it again in Files." + case .missing: + return "Saved access to this folder is missing. Choose it again in Files." + case .notFolder: + return "The selected item is not a folder." + case .unavailable: + return "The selected folder is unavailable. Open Files and try again." + } + } +} + +@_cdecl("init_plugin_ios_vault_access") +func initPlugin() -> Plugin { + IosVaultAccessPlugin() +} diff --git a/src-tauri/plugins/ios-vault-access/src/lib.rs b/src-tauri/plugins/ios-vault-access/src/lib.rs new file mode 100644 index 0000000..f8b499b --- /dev/null +++ b/src-tauri/plugins/ios-vault-access/src/lib.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; +use tauri::{ + plugin::{Builder, PluginApi, PluginHandle, TauriPlugin}, + AppHandle, Manager, Runtime, +}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderSelection { + pub bookmark_id: String, + pub path: String, + pub name: String, + #[serde(default)] + pub cancelled: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BookmarkRequest<'a> { + bookmark_id: &'a str, +} + +#[cfg(target_os = "ios")] +tauri::ios_plugin_binding!(init_plugin_ios_vault_access); + +pub struct IosVaultAccess(PluginHandle); + +impl IosVaultAccess { + pub fn choose_folder(&self) -> Result { + self.0 + .run_mobile_plugin("chooseFolder", ()) + .map_err(|error| error.to_string()) + } + + pub fn resolve_folder(&self, bookmark_id: &str) -> Result { + self.0 + .run_mobile_plugin("resolveFolder", BookmarkRequest { bookmark_id }) + .map_err(|error| error.to_string()) + } + + pub fn release_active(&self) -> Result<(), String> { + self.0 + .run_mobile_plugin("releaseActive", ()) + .map_err(|error| error.to_string()) + } + + pub fn commit_staged(&self) -> Result<(), String> { + self.0 + .run_mobile_plugin("commitStaged", ()) + .map_err(|error| error.to_string()) + } + + pub fn rollback_staged(&self) -> Result<(), String> { + self.0 + .run_mobile_plugin("rollbackStaged", ()) + .map_err(|error| error.to_string()) + } + + pub fn forget_bookmark(&self, bookmark_id: &str) -> Result<(), String> { + self.0 + .run_mobile_plugin("forgetBookmark", BookmarkRequest { bookmark_id }) + .map_err(|error| error.to_string()) + } +} + +pub trait IosVaultAccessExt { + fn ios_vault_access(&self) -> &IosVaultAccess; +} + +impl> IosVaultAccessExt for T { + fn ios_vault_access(&self) -> &IosVaultAccess { + self.state::>().inner() + } +} + +fn initialize( + _app: &AppHandle, + api: PluginApi, +) -> tauri::Result> { + let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?; + Ok(IosVaultAccess(handle)) +} + +pub fn init() -> TauriPlugin { + Builder::new("ios-vault-access") + .setup(|app, api| { + let access = initialize(app, api)?; + app.manage(access); + Ok(()) + }) + .build() +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d749f9c..c9efc47 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -21,15 +21,63 @@ fn remove_note_bg(state: &State<'_, AppState>, path: &str) { } } +fn clear_vault_runtime(state: &State<'_, AppState>) -> Result<(), String> { + *state.watcher.lock().map_err(|error| error.to_string())? = None; + *state + .search_index + .lock() + .map_err(|error| error.to_string())? = None; + Ok(()) +} + // ── Vault Management ── -#[tauri::command] -pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { +#[cfg(target_os = "ios")] +use tauri_plugin_ios_vault_access::{FolderSelection, IosVaultAccessExt}; + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalVaultResult { + pub bookmark_id: String, + pub path: String, + pub name: String, +} + +#[cfg(target_os = "ios")] +impl From for ExternalVaultResult { + fn from(selection: FolderSelection) -> Self { + Self { + bookmark_id: selection.bookmark_id, + path: selection.path, + name: selection.name, + } + } +} + +fn open_vault_path( + app: AppHandle, + state: &State<'_, AppState>, + path: String, + external: Option<(String, String)>, +) -> Result<(), String> { operations::ensure_vault_structure(&path)?; - // Initialize search index - rebuild in background on mobile (sandboxed FS is slow) + // Stage the search index and watcher before replacing the active runtime. let search = std::sync::Arc::new(SearchIndex::new(&path)?); - #[cfg(mobile)] + #[cfg(target_os = "ios")] + { + if external.is_some() { + search.rebuild(&path)?; + } else { + let search_bg = search.clone(); + let vault = path.clone(); + std::thread::spawn(move || { + let _ = search_bg.rebuild(&vault); + log::info!("mobile: search index rebuild complete"); + }); + } + } + #[cfg(all(mobile, not(target_os = "ios")))] { let search_bg = search.clone(); let vault = path.clone(); @@ -39,43 +87,238 @@ pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> R }); } #[cfg(desktop)] - { - search.rebuild(&path)?; - } - *state.search_index.lock().map_err(|e| e.to_string())? = Some(search); + search.rebuild(&path)?; - // Start file watcher - let w = watcher::start_watcher(app.clone(), path.clone())?; - *state.watcher.lock().map_err(|e| e.to_string())? = Some(w); + let new_watcher = watcher::start_watcher(app.clone(), path.clone())?; - // Update config + // Update config. External vaults use the bookmark as their stable identity; + // the resolved path is refreshed whenever the vault opens. + let mut search_slot = state.search_index.lock().map_err(|e| e.to_string())?; + let mut watcher_slot = state.watcher.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?; - if !config.vaults.iter().any(|v| v.path == path) { + let mut next = config.clone(); + let active_bookmark_id = external + .as_ref() + .map(|(bookmark_id, _)| bookmark_id.clone()); + if let Some((bookmark_id, name)) = external { + if let Some(vault) = next + .vaults + .iter_mut() + .find(|vault| vault.bookmark_id.as_deref() == Some(bookmark_id.as_str())) + { + vault.path.clone_from(&path); + vault.name = name; + } else { + next.vaults.push(VaultConfig { + path: path.clone(), + name, + bookmark_id: Some(bookmark_id), + ..Default::default() + }); + } + } else if !next + .vaults + .iter() + .any(|vault| vault.bookmark_id.is_none() && vault.path == path) + { let name = std::path::Path::new(&path) .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); - config.vaults.push(VaultConfig { + next.vaults.push(VaultConfig { path: path.clone(), name, ..Default::default() }); } - config.active_vault = Some(path); - save_app_config(&config)?; + next.active_vault = Some(path); + next.active_bookmark_id = active_bookmark_id; + save_app_config(&next)?; + *search_slot = Some(search); + *watcher_slot = Some(new_watcher); + *config = next; Ok(()) } #[tauri::command] -pub fn remove_vault(state: State<'_, AppState>, path: String) -> Result<(), String> { - let mut config = state.config.lock().map_err(|e| e.to_string())?; - config.vaults.retain(|v| v.path != path); - if config.active_vault.as_deref() == Some(path.as_str()) { - config.active_vault = None; +pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { + open_vault_path(app.clone(), &state, path, None)?; + #[cfg(target_os = "ios")] + app.ios_vault_access().release_active()?; + Ok(()) +} + +#[tauri::command] +pub async fn choose_external_vault( + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + #[cfg(target_os = "ios")] + { + let picker_app = app.clone(); + let selection = tauri::async_runtime::spawn_blocking(move || { + picker_app.ios_vault_access().choose_folder() + }) + .await + .map_err(|error| error.to_string())??; + if selection.cancelled { + return Ok(None); + } + let result = ExternalVaultResult::from(selection); + let bookmark_was_registered = state + .config + .lock() + .map_err(|error| error.to_string())? + .vaults + .iter() + .any(|vault| vault.bookmark_id.as_deref() == Some(result.bookmark_id.as_str())); + if let Err(error) = open_vault_path( + app.clone(), + &state, + result.path.clone(), + Some((result.bookmark_id.clone(), result.name.clone())), + ) { + if bookmark_was_registered { + let _ = app.ios_vault_access().rollback_staged(); + } else { + let _ = app + .ios_vault_access() + .forget_bookmark(&result.bookmark_id); + } + return Err(error); + } + app.ios_vault_access().commit_staged()?; + Ok(Some(result)) } - save_app_config(&config)?; + #[cfg(not(target_os = "ios"))] + { + let _ = (app, state); + Err("Files folders are only available on iOS.".to_string()) + } +} + +#[tauri::command] +pub async fn restore_external_vault( + app: AppHandle, + state: State<'_, AppState>, + bookmark_id: String, +) -> Result { + #[cfg(target_os = "ios")] + { + let resolver_app = app.clone(); + let selection = tauri::async_runtime::spawn_blocking(move || { + resolver_app.ios_vault_access().resolve_folder(&bookmark_id) + }) + .await + .map_err(|error| error.to_string())??; + let result = ExternalVaultResult::from(selection); + if let Err(error) = open_vault_path( + app.clone(), + &state, + result.path.clone(), + Some((result.bookmark_id.clone(), result.name.clone())), + ) { + let _ = app.ios_vault_access().rollback_staged(); + return Err(error); + } + app.ios_vault_access().commit_staged()?; + Ok(result) + } + #[cfg(not(target_os = "ios"))] + { + let _ = (app, state, bookmark_id); + Err("Files folders are only available on iOS.".to_string()) + } +} + +#[tauri::command] +pub async fn remove_vault( + app: AppHandle, + state: State<'_, AppState>, + path: String, + bookmark_id: Option, +) -> Result<(), String> { + let target = { + let config = state.config.lock().map_err(|error| error.to_string())?; + let vault = if let Some(bookmark_id) = bookmark_id.as_deref() { + config + .vaults + .iter() + .find(|vault| vault.bookmark_id.as_deref() == Some(bookmark_id)) + } else { + config + .vaults + .iter() + .find(|vault| vault.bookmark_id.is_none() && vault.path == path) + }; + vault.map(|vault| { + let is_active = if let Some(bookmark_id) = vault.bookmark_id.as_deref() { + config.active_bookmark_id.as_deref() == Some(bookmark_id) + } else { + config.active_bookmark_id.is_none() + && config.active_vault.as_deref() == Some(vault.path.as_str()) + }; + (vault.path.clone(), vault.bookmark_id.clone(), is_active) + }) + }; + + let Some((target_path, target_bookmark, is_active)) = target else { + return Ok(()); + }; + + let old = state + .config + .lock() + .map_err(|error| error.to_string())? + .clone(); + let mut next = old.clone(); + if let Some(id) = target_bookmark.as_deref() { + next.vaults + .retain(|vault| vault.bookmark_id.as_deref() != Some(id)); + } else { + next.vaults + .retain(|vault| vault.bookmark_id.is_some() || vault.path != target_path); + } + if is_active { + next.active_vault = None; + next.active_bookmark_id = None; + } + save_app_config(&next)?; + + #[cfg(target_os = "ios")] + if let Some(id) = target_bookmark.as_deref() { + let forget_app = app.clone(); + let id_to_forget = id.to_string(); + let forget_result = match tauri::async_runtime::spawn_blocking(move || { + forget_app + .ios_vault_access() + .forget_bookmark(&id_to_forget) + }) + .await + { + Ok(result) => result, + Err(error) => Err(error.to_string()), + }; + + if let Err(error) = forget_result { + return match save_app_config(&old) { + Ok(()) => Err(error), + Err(rollback_error) => Err(format!( + "{error} Configuration rollback also failed: {rollback_error}" + )), + }; + } + } + #[cfg(not(target_os = "ios"))] + let _ = (&app, &target_bookmark); + + if is_active { + clear_vault_runtime(&state)?; + } + *state.config.lock().map_err(|error| error.to_string())? = next; + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7826d7e..c4dd35f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,6 +57,9 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) .setup(move |app| { + #[cfg(target_os = "ios")] + app.handle().plugin(tauri_plugin_ios_vault_access::init())?; + if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() @@ -119,6 +122,8 @@ pub fn run() { .manage(app_state) .invoke_handler(tauri::generate_handler![ commands::open_vault, + commands::choose_external_vault, + commands::restore_external_vault, commands::remove_vault, commands::get_app_config, commands::set_theme, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 96bb08c..d67ae57 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -54,6 +54,8 @@ pub struct NoteContent { pub struct VaultConfig { pub path: String, pub name: String, + #[serde(default)] + pub bookmark_id: Option, // Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration). #[serde(default)] pub sync_provider: Option, @@ -98,6 +100,8 @@ pub struct CustomTheme { pub struct AppConfig { pub vaults: Vec, pub active_vault: Option, + #[serde(default)] + pub active_bookmark_id: Option, pub theme: String, #[serde(default)] pub accent_color: Option, @@ -255,6 +259,7 @@ impl Default for AppConfig { Self { vaults: Vec::new(), active_vault: None, + active_bookmark_id: None, theme: "system".to_string(), accent_color: None, font_size: None, diff --git a/src/lib/api.ts b/src/lib/api.ts index 2b45857..eb5b5c8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,14 +15,23 @@ import type { BackupEntry, VersionEntry, TaskItem, + ExternalVaultResult, } from "./types"; export async function openVault(path: string): Promise { return invoke("open_vault", { path }); } -export async function removeVault(path: string): Promise { - return invoke("remove_vault", { path }); +export async function chooseExternalVault(): Promise { + return invoke("choose_external_vault"); +} + +export async function restoreExternalVault(bookmarkId: string): Promise { + return invoke("restore_external_vault", { bookmarkId }); +} + +export async function removeVault(path: string, bookmarkId?: string | null): Promise { + return invoke("remove_vault", { path, bookmarkId: bookmarkId ?? null }); } export async function getAppConfig(): Promise { diff --git a/src/lib/components/VaultPicker.svelte b/src/lib/components/VaultPicker.svelte index f249b61..9c0f90b 100644 --- a/src/lib/components/VaultPicker.svelte +++ b/src/lib/components/VaultPicker.svelte @@ -2,11 +2,13 @@ import { open } from '@tauri-apps/plugin-dialog'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { documentDir } from '@tauri-apps/api/path'; - import { openVault, removeVault, getAppConfig } from '$lib/api'; + import { chooseExternalVault, getAppConfig, openVault, removeVault, restoreExternalVault } from '$lib/api'; import { appConfig, vaultReady } from '$lib/stores/app'; import { isAndroid, isIOS, isMobile } from '$lib/platform'; import type { VaultConfig } from '$lib/types'; + let { initialError = '' }: { initialError?: string } = $props(); + const appWindow = getCurrentWindow(); let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []); @@ -15,6 +17,10 @@ let vaultName = $state('HelixNotes'); let hasPermission = $state(!isAndroid); let selectedLocation = $state('Documents'); + + $effect(() => { + if (initialError && !error) error = initialError; + }); let customPath = $state(''); let iosBasePath = $state(''); @@ -108,14 +114,57 @@ } } - async function forgetVault(path: string) { + async function pickExternalFolder() { + loading = true; + error = ''; try { - await removeVault(path); + const selected = await chooseExternalVault(); + if (!selected) return; $appConfig = await getAppConfig(); + $vaultReady = true; + } catch (e) { + error = String(e); + } finally { + loading = false; + } + } + + async function openRecentVault(vault: VaultConfig) { + if (!isIOS || !vault.bookmark_id) { + await openSelectedVault(vault.path); + return; + } + + loading = true; + error = ''; + try { + await restoreExternalVault(vault.bookmark_id); + $appConfig = await getAppConfig(); + $vaultReady = true; + } catch (e) { + error = String(e); + } finally { + loading = false; + } + } + + async function forgetVault(vault: VaultConfig) { + try { + const wasActive = isActiveVault(vault); + await removeVault(vault.path, vault.bookmark_id); + $appConfig = await getAppConfig(); + if (wasActive) $vaultReady = false; } catch (e) { error = String(e); } } + + function isActiveVault(vault: VaultConfig): boolean { + if (vault.bookmark_id) { + return vault.bookmark_id === $appConfig?.active_bookmark_id; + } + return !$appConfig?.active_bookmark_id && vault.path === $appConfig?.active_vault; + } @@ -146,7 +195,7 @@

Local markdown notes

{#if isMobile} {#if isIOS} -

Your notes are saved in the app's folder and appear in the Files app under "On My iPhone → HelixNotes".

+

Keep notes in HelixNotes, or use an existing folder from iCloud Drive or Files.

{:else}

Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.

{/if} @@ -197,6 +246,8 @@ + {#if isIOS} + + {/if} + {#if $appConfig?.active_vault} -