Add iOS Files vault selection

This commit is contained in:
Yuri Karamian
2026-07-24 02:19:00 +02:00
parent 51acd51cb2
commit d497ff8aaf
15 changed files with 846 additions and 39 deletions
+10
View File
@@ -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"
+3
View File
@@ -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"
@@ -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"] }
@@ -0,0 +1,3 @@
fn main() {
tauri_plugin::Builder::new(&[]).ios_path("ios").build();
}
@@ -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"
)
]
)
@@ -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()
}
@@ -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<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> IosVaultAccess<R> {
pub fn choose_folder(&self) -> Result<FolderSelection, String> {
self.0
.run_mobile_plugin("chooseFolder", ())
.map_err(|error| error.to_string())
}
pub fn resolve_folder(&self, bookmark_id: &str) -> Result<FolderSelection, String> {
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<R: Runtime> {
fn ios_vault_access(&self) -> &IosVaultAccess<R>;
}
impl<R: Runtime, T: Manager<R>> IosVaultAccessExt<R> for T {
fn ios_vault_access(&self) -> &IosVaultAccess<R> {
self.state::<IosVaultAccess<R>>().inner()
}
}
fn initialize<R: Runtime>(
_app: &AppHandle<R>,
api: PluginApi<R, ()>,
) -> tauri::Result<IosVaultAccess<R>> {
let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
Ok(IosVaultAccess(handle))
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("ios-vault-access")
.setup(|app, api| {
let access = initialize(app, api)?;
app.manage(access);
Ok(())
})
.build()
}
+265 -22
View File
@@ -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<FolderSelection> 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<Option<ExternalVaultResult>, 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<ExternalVaultResult, String> {
#[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<String>,
) -> 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(())
}
+5
View File
@@ -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,
+5
View File
@@ -54,6 +54,8 @@ pub struct NoteContent {
pub struct VaultConfig {
pub path: String,
pub name: String,
#[serde(default)]
pub bookmark_id: Option<String>,
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
#[serde(default)]
pub sync_provider: Option<String>,
@@ -98,6 +100,8 @@ pub struct CustomTheme {
pub struct AppConfig {
pub vaults: Vec<VaultConfig>,
pub active_vault: Option<String>,
#[serde(default)]
pub active_bookmark_id: Option<String>,
pub theme: String,
#[serde(default)]
pub accent_color: Option<String>,
@@ -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,