mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
Add iOS Files vault selection
This commit is contained in:
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-2
@@ -15,14 +15,23 @@ import type {
|
||||
BackupEntry,
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
ExternalVaultResult,
|
||||
} from "./types";
|
||||
|
||||
export async function openVault(path: string): Promise<void> {
|
||||
return invoke("open_vault", { path });
|
||||
}
|
||||
|
||||
export async function removeVault(path: string): Promise<void> {
|
||||
return invoke("remove_vault", { path });
|
||||
export async function chooseExternalVault(): Promise<ExternalVaultResult | null> {
|
||||
return invoke("choose_external_vault");
|
||||
}
|
||||
|
||||
export async function restoreExternalVault(bookmarkId: string): Promise<ExternalVaultResult> {
|
||||
return invoke("restore_external_vault", { bookmarkId });
|
||||
}
|
||||
|
||||
export async function removeVault(path: string, bookmarkId?: string | null): Promise<void> {
|
||||
return invoke("remove_vault", { path, bookmarkId: bookmarkId ?? null });
|
||||
}
|
||||
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -146,7 +195,7 @@
|
||||
<p class="subtitle">Local markdown notes</p>
|
||||
{#if isMobile}
|
||||
{#if isIOS}
|
||||
<p class="description">Your notes are saved in the app's folder and appear in the Files app under "On My iPhone → HelixNotes".</p>
|
||||
<p class="description">Keep notes in HelixNotes, or use an existing folder from iCloud Drive or Files.</p>
|
||||
{:else}
|
||||
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
|
||||
{/if}
|
||||
@@ -197,6 +246,8 @@
|
||||
<button class="btn-primary" onclick={pickFolder} disabled={loading}>
|
||||
{#if loading}
|
||||
Opening...
|
||||
{:else if isIOS}
|
||||
Use Local Folder
|
||||
{:else if isMobile}
|
||||
Get Started
|
||||
{:else}
|
||||
@@ -204,6 +255,12 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isIOS}
|
||||
<button class="btn-back" onclick={pickExternalFolder} disabled={loading}>
|
||||
Choose Folder in Files
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.active_vault}
|
||||
<button class="btn-back" onclick={() => ($vaultReady = true)}>
|
||||
Back
|
||||
@@ -215,11 +272,11 @@
|
||||
<span class="recent-label">Recent</span>
|
||||
{#each recentVaults as vault}
|
||||
<div class="vault-row">
|
||||
<button class="vault-item" class:current={vault.path === $appConfig?.active_vault} onclick={() => openSelectedVault(vault.path)}>
|
||||
<span class="vault-name">{vault.name}{#if vault.path === $appConfig?.active_vault}<span class="vault-current-badge">Current</span>{/if}</span>
|
||||
<span class="vault-path">{vault.path}</span>
|
||||
<button class="vault-item" class:current={isActiveVault(vault)} onclick={() => openRecentVault(vault)}>
|
||||
<span class="vault-name">{vault.name}{#if isActiveVault(vault)}<span class="vault-current-badge">Current</span>{/if}</span>
|
||||
<span class="vault-path">{vault.bookmark_id ? `Files · ${vault.path}` : vault.path}</span>
|
||||
</button>
|
||||
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}>
|
||||
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault)}>
|
||||
<svg width="12" height="12" viewBox="0 0 10 10">
|
||||
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||
|
||||
@@ -18,7 +18,10 @@ export const appConfig = writable<AppConfig | null>(null);
|
||||
// The active vault's config entry (where per-vault WebDAV sync settings live).
|
||||
export function activeVaultConfig(c: AppConfig | null): VaultConfig | null {
|
||||
if (!c?.active_vault) return null;
|
||||
return c.vaults.find((v) => v.path === c.active_vault) ?? null;
|
||||
if (c.active_bookmark_id) {
|
||||
return c.vaults.find((v) => v.bookmark_id === c.active_bookmark_id) ?? null;
|
||||
}
|
||||
return c.vaults.find((v) => !v.bookmark_id && v.path === c.active_vault) ?? null;
|
||||
}
|
||||
export const vaultReady = writable(false);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface NoteContent {
|
||||
export interface VaultConfig {
|
||||
path: string;
|
||||
name: string;
|
||||
bookmark_id?: string | null;
|
||||
// Per-vault WebDAV sync (moved off the global AppConfig).
|
||||
sync_provider?: string | null;
|
||||
webdav_url?: string | null;
|
||||
@@ -55,6 +56,12 @@ export interface VaultConfig {
|
||||
last_sync_time?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalVaultResult {
|
||||
bookmarkId: string;
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CustomThemeColors {
|
||||
bg_primary: string;
|
||||
bg_secondary: string;
|
||||
@@ -77,6 +84,7 @@ export interface CustomTheme {
|
||||
export interface AppConfig {
|
||||
vaults: VaultConfig[];
|
||||
active_vault: string | null;
|
||||
active_bookmark_id?: string | null;
|
||||
theme: string;
|
||||
accent_color: string | null;
|
||||
font_size: number | null;
|
||||
|
||||
+24
-6
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||
import { getAppConfig, openVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes, isIOS } from '$lib/platform';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
let loading = $state(true);
|
||||
let noteWindowPath = $state<string | null>(null);
|
||||
let startupVaultError = $state('');
|
||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||
|
||||
@@ -204,14 +205,31 @@
|
||||
|
||||
// Auto-open last vault if available
|
||||
if (config.active_vault) {
|
||||
const activeVault = config.active_bookmark_id
|
||||
? config.vaults.find((vault) => vault.bookmark_id === config.active_bookmark_id)
|
||||
: config.vaults.find(
|
||||
(vault) => !vault.bookmark_id && vault.path === config.active_vault
|
||||
);
|
||||
try {
|
||||
// Note windows don't re-open the vault (already open in main process)
|
||||
if (!noteWindowPath) {
|
||||
await openVault(config.active_vault);
|
||||
if (isIOS && activeVault?.bookmark_id) {
|
||||
await restoreExternalVault(activeVault.bookmark_id);
|
||||
$appConfig = await getAppConfig();
|
||||
} else {
|
||||
await openVault(config.active_vault);
|
||||
}
|
||||
}
|
||||
$vaultReady = true;
|
||||
} catch {
|
||||
// Vault path might not exist anymore
|
||||
} catch (e) {
|
||||
if (isIOS && activeVault?.bookmark_id) {
|
||||
startupVaultError = String(e);
|
||||
$appConfig = {
|
||||
...config,
|
||||
active_vault: null,
|
||||
active_bookmark_id: null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -235,7 +253,7 @@
|
||||
{:else if $vaultReady}
|
||||
<AppLayout />
|
||||
{:else}
|
||||
<VaultPicker />
|
||||
<VaultPicker initialError={startupVaultError} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user