feat: add iPad/iOS prototype via Tauri v2 mobile target

Initialize Tauri iOS support with conditional compilation:
- Desktop-only features (git CLI, menu, MCP, Claude CLI) gated behind #[cfg(desktop)]
- Mobile stubs return graceful errors so frontend degrades smoothly
- Vault read/write, AI chat, search, settings work unchanged on both platforms
- Xcode project generated, Rust cross-compiles cleanly to aarch64-apple-ios-sim
- All 581 Rust tests and 2201 frontend tests still pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-27 17:18:34 +01:00
parent 41edd75837
commit 5464da9c6e
42 changed files with 37050 additions and 24 deletions

View File

@@ -1,9 +1,9 @@
use std::borrow::Cow;
use crate::ai_chat::{AiChatRequest, AiChatResponse};
use crate::claude_cli::{
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
};
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
#[cfg(desktop)]
use crate::claude_cli::ClaudeStreamEvent;
use crate::frontmatter::FrontmatterValue;
use crate::git::{
GitCommit, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile,
@@ -14,7 +14,9 @@ use crate::search::SearchResponse;
use crate::settings::Settings;
use crate::vault::{RenameResult, VaultEntry};
use crate::vault_list::VaultList;
use crate::{frontmatter, git, github, menu, search, vault, vault_list};
#[cfg(desktop)]
use crate::menu;
use crate::{frontmatter, git, search, vault, vault_list};
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
@@ -222,6 +224,7 @@ pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
// ── Git commands ────────────────────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -229,12 +232,14 @@ pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommi
git::get_file_history(&vault_path, &path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_modified_files(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
@@ -242,6 +247,7 @@ pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String>
git::get_file_diff(&vault_path, &path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_diff_at_commit(
vault_path: String,
@@ -253,6 +259,7 @@ pub fn get_file_diff_at_commit(
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_vault_pulse(
vault_path: String,
@@ -265,18 +272,21 @@ pub fn get_vault_pulse(
git::get_vault_pulse(&vault_path, limit, skip)
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit(&vault_path, &message)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_last_commit_info(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
@@ -285,18 +295,21 @@ pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_files(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_conflict_mode(vault_path: String) -> String {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_mode(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_resolve_conflict(
vault_path: String,
@@ -307,12 +320,14 @@ pub fn git_resolve_conflict(
git::git_resolve_conflict(&vault_path, &file, &strategy)
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit_conflict_resolution(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
@@ -321,6 +336,7 @@ pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
@@ -329,41 +345,192 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
.map_err(|e| format!("Task panicked: {e}"))?
}
// ── GitHub commands ─────────────────────────────────────────────────────────
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
github::github_list_repos(&token).await
pub fn get_file_history(_vault_path: String, _path: String) -> Result<Vec<GitCommit>, String> {
Err("Git history is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_modified_files(_vault_path: String) -> Result<Vec<ModifiedFile>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_diff(_vault_path: String, _path: String) -> Result<String, String> {
Err("Git diff is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_diff_at_commit(
_vault_path: String,
_path: String,
_commit_hash: String,
) -> Result<String, String> {
Err("Git diff is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_vault_pulse(
_vault_path: String,
_limit: Option<usize>,
_skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_commit(_vault_path: String, _message: String) -> Result<String, String> {
Err("Git commit is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_last_commit_info(_vault_path: String) -> Result<Option<LastCommitInfo>, String> {
Ok(None)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_pull(_vault_path: String) -> Result<GitPullResult, String> {
Err("Git pull is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_conflict_files(_vault_path: String) -> Result<Vec<String>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_conflict_mode(_vault_path: String) -> String {
"none".to_string()
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_resolve_conflict(
_vault_path: String,
_file: String,
_strategy: String,
) -> Result<(), String> {
Err("Git conflict resolution is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_commit_conflict_resolution(_vault_path: String) -> Result<String, String> {
Err("Git conflict resolution is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_push(_vault_path: String) -> Result<GitPushResult, String> {
Err("Git push is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, String> {
Ok(GitRemoteStatus {
branch: String::new(),
has_remote: false,
ahead: 0,
behind: 0,
})
}
// ── GitHub commands ─────────────────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
crate::github::github_list_repos(&token).await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_create_repo(
token: String,
name: String,
private: bool,
) -> Result<GithubRepo, String> {
github::github_create_repo(&token, &name, private).await
crate::github::github_create_repo(&token, &name, private).await
}
#[cfg(desktop)]
#[tauri::command]
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
github::clone_repo(&url, &token, &local_path)
crate::github::clone_repo(&url, &token, &local_path)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github::github_device_flow_start().await
crate::github::github_device_flow_start().await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
github::github_device_flow_poll(&device_code).await
crate::github::github_device_flow_poll(&device_code).await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
github::github_get_user(&token).await
crate::github::github_get_user(&token).await
}
// ── GitHub commands (mobile stubs) ──────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub async fn github_list_repos(_token: String) -> Result<Vec<GithubRepo>, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_create_repo(
_token: String,
_name: String,
_private: bool,
) -> Result<GithubRepo, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn clone_repo(_url: String, _token: String, _local_path: String) -> Result<String, String> {
Err("Git clone is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_device_flow_poll(_device_code: String) -> Result<DeviceFlowPollResult, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_get_user(_token: String) -> Result<GitHubUser, String> {
Err("GitHub integration is not available on mobile".into())
}
// ── AI / Claude commands ────────────────────────────────────────────────────
@@ -373,11 +540,13 @@ pub async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
crate::ai_chat::send_chat(request).await
}
#[cfg(desktop)]
#[tauri::command]
pub fn check_claude_cli() -> ClaudeCliStatus {
crate::claude_cli::check_cli()
}
#[cfg(desktop)]
#[tauri::command]
pub async fn stream_claude_chat(
app_handle: tauri::AppHandle,
@@ -393,6 +562,7 @@ pub async fn stream_claude_chat(
.map_err(|e| format!("Task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn stream_claude_agent(
app_handle: tauri::AppHandle,
@@ -408,6 +578,35 @@ pub async fn stream_claude_agent(
.map_err(|e| format!("Task failed: {e}"))?
}
// ── Claude CLI (mobile stubs) ───────────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub fn check_claude_cli() -> ClaudeCliStatus {
ClaudeCliStatus {
installed: false,
version: None,
}
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_claude_chat(
_app_handle: tauri::AppHandle,
_request: ChatStreamRequest,
) -> Result<String, String> {
Err("Claude CLI is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_claude_agent(
_app_handle: tauri::AppHandle,
_request: AgentStreamRequest,
) -> Result<String, String> {
Err("Claude CLI is not available on mobile".into())
}
// ── Search commands ─────────────────────────────────────────────────────────
#[tauri::command]
@@ -426,6 +625,7 @@ pub async fn search_vault(
// ── MCP commands ────────────────────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
@@ -434,6 +634,7 @@ pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
@@ -441,6 +642,18 @@ pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(mobile)]
#[tauri::command]
pub async fn register_mcp_tools(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
@@ -463,6 +676,7 @@ pub fn get_build_number(app_handle: tauri::AppHandle) -> String {
parse_build_label(&version)
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_menu_state(
app_handle: tauri::AppHandle,
@@ -480,6 +694,17 @@ pub fn update_menu_state(
Ok(())
}
#[cfg(mobile)]
#[tauri::command]
pub fn update_menu_state(
_app_handle: tauri::AppHandle,
_has_active_note: bool,
_has_modified_files: Option<bool>,
_has_conflicts: Option<bool>,
) -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub fn get_settings() -> Result<Settings, String> {
crate::settings::get_settings()

View File

@@ -5,6 +5,7 @@ pub mod frontmatter;
pub mod git;
pub mod github;
pub mod mcp;
#[cfg(desktop)]
pub mod menu;
pub mod search;
pub mod settings;
@@ -12,11 +13,15 @@ pub mod telemetry;
pub mod vault;
pub mod vault_list;
#[cfg(desktop)]
use std::process::Child;
#[cfg(desktop)]
use std::sync::Mutex;
#[cfg(desktop)]
struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -26,6 +31,7 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
}
/// Run startup housekeeping on the default vault (purge old trash, migrate legacy frontmatter).
#[cfg(desktop)]
fn run_startup_tasks() {
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
@@ -54,6 +60,7 @@ fn run_startup_tasks() {
}
}
#[cfg(desktop)]
fn spawn_ws_bridge(app: &mut tauri::App) {
use tauri::Manager;
let vault_path = dirs::home_dir()
@@ -71,8 +78,12 @@ fn spawn_ws_bridge(app: &mut tauri::App) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(WsBridgeChild(Mutex::new(None)))
let builder = tauri::Builder::default();
#[cfg(desktop)]
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
builder
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -96,8 +107,13 @@ pub fn run() {
if telemetry::init_sentry_from_settings() {
log::info!("Sentry initialized (crash reporting enabled)");
}
run_startup_tasks();
spawn_ws_bridge(app);
#[cfg(desktop)]
{
run_startup_tasks();
spawn_ws_bridge(app);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -162,14 +178,17 @@ pub fn run() {
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
use tauri::Manager;
if let tauri::RunEvent::Exit = event {
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut guard = state.0.lock().unwrap();
if let Some(ref mut child) = *guard {
let _ = child.kill();
log::info!("ws-bridge child process killed on exit");
.run(|_app_handle, _event| {
#[cfg(desktop)]
{
use tauri::Manager;
if let tauri::RunEvent::Exit = _event {
let state: tauri::State<'_, WsBridgeChild> = _app_handle.state();
let mut guard = state.0.lock().unwrap();
if let Some(ref mut child) = *guard {
let _ = child.kill();
log::info!("ws-bridge child process killed on exit");
}
}
}
});