Skip to main content

tano_native/
lib.rs

1mod agent;
2mod assets;
3#[cfg(desktop)]
4mod assistant;
5#[cfg(desktop)]
6mod assistant_cli;
7mod assistant_files;
8pub mod identity_manifest;
9#[cfg(desktop)]
10mod import_connections;
11#[cfg(desktop)]
12mod import_oauth;
13mod mobile_storage;
14#[cfg(test)]
15mod performance;
16mod personal_plans;
17#[cfg(desktop)]
18mod plan_connections;
19#[cfg(desktop)]
20mod plugin_connection_http;
21#[cfg(desktop)]
22mod plugin_connection_types;
23#[cfg(desktop)]
24mod plugin_connection_vault;
25#[cfg(desktop)]
26mod plugin_connections;
27#[cfg(desktop)]
28mod plugin_native;
29#[cfg(desktop)]
30mod plugin_oauth;
31#[cfg(desktop)]
32mod plugin_oauth_loopback;
33#[cfg(desktop)]
34mod plugin_oauth_types;
35mod search;
36mod session_storage;
37pub mod storage;
38mod updates;
39#[cfg(desktop)]
40mod workspace_git;
41mod workspace_identity;
42mod workspace_lock;
43#[cfg(test)]
44mod workspace_responsiveness_tests;
45#[cfg(desktop)]
46mod workspace_sync;
47
48use notify::{RecursiveMode, Watcher};
49use std::{
50    path::{Path, PathBuf},
51    sync::Mutex,
52};
53use tauri::{Emitter, Manager, State};
54
55pub struct Session {
56    pub root: PathBuf,
57    pub id: String,
58    root_key: String,
59    _watcher: notify::RecommendedWatcher,
60    _lock: std::fs::File,
61}
62
63#[derive(Default)]
64pub struct AppState {
65    session: Mutex<Option<Session>>,
66}
67
68fn workspace_switch_blocked(app: &tauri::AppHandle) -> bool {
69    // Settings are prewarmed and follow the main window's workspace snapshot. Only
70    // independent content windows must be closed before replacing the shared session.
71    app.webview_windows().keys().any(|label| {
72        label != "main" && !label.starts_with("settings-") && !label.starts_with("file-")
73    })
74}
75
76fn workspace_lock_directory(app: &tauri::AppHandle) -> Result<PathBuf, String> {
77    #[cfg(desktop)]
78    {
79        Ok(workspace_lock::shared_directory(
80            &app.path().data_dir().map_err(|e| e.to_string())?,
81        ))
82    }
83    #[cfg(mobile)]
84    {
85        Ok(app_data(app)?.join("locks"))
86    }
87}
88
89fn should_notify_workspace_change(relative: &Path) -> bool {
90    relative.components().enumerate().all(|(index, component)| {
91        let name = component.as_os_str().to_string_lossy();
92        (index == 0 && name == ".tano") || !storage::ignored_workspace_name(&name)
93    })
94}
95
96#[cfg(test)]
97mod watcher_path_tests {
98    use super::should_notify_workspace_change;
99    use std::path::Path;
100
101    #[test]
102    fn refreshes_for_identity_metadata_but_ignores_old_hidden_data() {
103        for path in [
104            ".tano",
105            ".tano/config.json",
106            ".tano/identities.json",
107            "note.md",
108        ] {
109            assert!(should_notify_workspace_change(Path::new(path)), "{path}");
110        }
111        for path in [
112            ".workspace/objects/old.tano.json",
113            ".git/HEAD",
114            "node_modules/package/index.js",
115            "nested/target/debug/app.exe",
116            "dist/index.html",
117            ".tano/.tano-temporary",
118            "notes/.draft.md",
119        ] {
120            assert!(!should_notify_workspace_change(Path::new(path)), "{path}");
121        }
122    }
123}
124
125fn with_session<T>(
126    state: State<AppState>,
127    f: impl FnOnce(&Session) -> Result<T, String>,
128) -> Result<T, String> {
129    // Every command using this lock must dispatch off the window thread. Opening
130    // a workspace holds it during scanning; a synchronous IPC/asset callback
131    // waiting here would stall the WebView and all window events.
132    let guard = state.session.lock().map_err(|e| e.to_string())?;
133    let session = guard.as_ref().ok_or("请先打开一个工作空间")?;
134    if !session.root_key.is_empty() && storage::native_key(&session.root) != session.root_key {
135        return Err("工作区根目录已替换,请重新打开工作区".into());
136    }
137    identity_manifest::require(&session.root, &session.id)?;
138    f(session)
139}
140
141fn with_session_for_journal<T>(
142    state: State<AppState>,
143    f: impl FnOnce(&Session) -> Result<T, String>,
144) -> Result<T, String> {
145    let guard = state.session.lock().map_err(|e| e.to_string())?;
146    f(guard.as_ref().ok_or("请先打开一个工作空间")?)
147}
148
149pub fn cache_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
150    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
151    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
152    Ok(dir.join("index.sqlite"))
153}
154
155#[tauri::command]
156async fn open_workspace(path: String, app: tauri::AppHandle) -> Result<storage::Workspace, String> {
157    tauri::async_runtime::spawn_blocking(move || {
158        let state = app.state::<AppState>();
159        open_workspace_blocking(path, app.clone(), state)
160    })
161    .await
162    .map_err(|e| format!("打开工作空间任务失败:{e}"))?
163}
164
165#[tauri::command(async)]
166fn close_workspace(
167    workspace_id: String,
168    state: State<AppState>,
169    app: tauri::AppHandle,
170) -> Result<(), String> {
171    let mut guard = state.session.lock().map_err(|e| e.to_string())?;
172    if guard
173        .as_ref()
174        .is_some_and(|session| session.id == workspace_id)
175    {
176        if workspace_switch_blocked(&app) {
177            return Err("请先关闭此工作区的独立窗口,再退出工作区".into());
178        }
179        stop_workspace_plugin_calls(&app);
180        guard.take();
181    }
182    Ok(())
183}
184
185#[tauri::command(async)]
186fn unregister_workspace(
187    workspace_id: String,
188    state: State<AppState>,
189    app: tauri::AppHandle,
190) -> Result<(), String> {
191    let mut guard = state.session.lock().map_err(|e| e.to_string())?;
192    let session = guard.as_ref().ok_or("请先打开一个工作空间")?;
193    if session.id != workspace_id {
194        return Err("工作区已切换,请重新确认删除注册".into());
195    }
196    // The settings window is part of this flow; only detached editors block unregistering.
197    if app
198        .webview_windows()
199        .keys()
200        .any(|label| label.starts_with("detached-"))
201    {
202        return Err("请先关闭此工作区的独立文档窗口,再删除注册".into());
203    }
204    session_storage::unregister(
205        &cache_path(&app)?,
206        &app_data(&app)?,
207        &session.root,
208        &session.id,
209    )?;
210    stop_workspace_plugin_calls(&app);
211    guard.take();
212    Ok(())
213}
214
215fn stop_workspace_plugin_calls(_app: &tauri::AppHandle) {
216    // Cancel at the transition itself, so switching away and back between polling
217    // ticks cannot revive a previous workspace's request or account login.
218    #[cfg(desktop)]
219    if let Some(state) = _app.try_state::<plugin_native::PluginNativeState>() {
220        state.stop_workspace();
221    }
222}
223
224fn open_workspace_blocking(
225    path: String,
226    app: tauri::AppHandle,
227    state: State<AppState>,
228) -> Result<storage::Workspace, String> {
229    let mut guard = state.session.lock().map_err(|e| e.to_string())?;
230    let root = std::fs::canonicalize(&path)
231        .map_err(|e| format!("无法打开工作文件夹,请确认目录仍然存在:{e}"))?;
232    #[cfg(mobile)]
233    let root = mobile_storage::validate(&app_data(&app)?, &root)?;
234    if !root.is_dir() {
235        return Err("请选择一个文件夹".into());
236    }
237    let newly_created = storage::existing_workspace_identity(&root)?.is_none();
238    if let Some(session) = guard.as_ref() {
239        if session.root == root && !newly_created {
240            identity_manifest::require(&root, &session.id)?;
241            return storage::scan(&root, &session.id, &cache_path(&app)?);
242        }
243        if workspace_switch_blocked(&app) {
244            return Err("请先关闭此工作区的独立窗口,再切换工作区".into());
245        }
246    }
247    let data = app_data(&app)?;
248    let registered = workspace_identity::registered_identity(&data, &root)?;
249    let recent_id = if registered.is_none() {
250        guard
251            .as_ref()
252            .filter(|session| session.root == root)
253            .map(|session| session.id.clone())
254            .or(session_storage::known_identity(&cache_path(&app)?, &root)?)
255    } else {
256        None
257    };
258    let reinitialized = newly_created && (registered.is_some() || recent_id.is_some());
259    let id = workspace_identity::identity_for_open(
260        &data,
261        &root,
262        recent_id.as_deref(),
263        &cache_path(&app)?,
264    )?;
265    let reuse_lock = guard.as_ref().is_some_and(|session| session.id == id);
266    let lock = if reuse_lock {
267        None
268    } else {
269        Some(workspace_lock::acquire(
270            &workspace_lock_directory(&app)?,
271            &id,
272        )?)
273    };
274    storage::assert_workspace_identity(&root, &id)?;
275    let mut workspace = storage::scan(&root, &id, &cache_path(&app)?)?;
276    workspace.newly_created = newly_created.then_some(true);
277    workspace.reinitialized = reinitialized.then_some(true);
278    let handle = app.clone();
279    let watch_root = root.clone();
280    let mut watcher = notify::recommended_watcher(
281        move |event: Result<notify::Event, notify::Error>| match event {
282            Ok(event) => {
283                if matches!(event.kind, notify::EventKind::Access(_)) {
284                    return;
285                }
286                if event.paths.iter().any(|path| {
287                    path.strip_prefix(&watch_root)
288                        .is_ok_and(should_notify_workspace_change)
289                }) {
290                    let _ = handle.emit("workspace-changed", ());
291                }
292            }
293            Err(error) => {
294                let _ = handle.emit("workspace-watch-error", error.to_string());
295            }
296        },
297    )
298    .map_err(|e| e.to_string())?;
299    watcher
300        .watch(&root, RecursiveMode::Recursive)
301        .map_err(|e| e.to_string())?;
302    session_storage::record(&cache_path(&app)?, &workspace)?;
303    workspace_identity::register_identity(&data, &root, &id)?;
304    let lock = match lock {
305        Some(lock) => lock,
306        None => guard.take().ok_or("工作区会话已关闭")?._lock,
307    };
308    stop_workspace_plugin_calls(&app);
309    *guard = Some(Session {
310        root_key: storage::native_key(&root),
311        root,
312        id,
313        _watcher: watcher,
314        _lock: lock,
315    });
316    Ok(workspace)
317}
318
319#[tauri::command]
320async fn refresh_workspace(app: tauri::AppHandle) -> Result<storage::Workspace, String> {
321    tauri::async_runtime::spawn_blocking(move || {
322        let state = app.state::<AppState>();
323        with_session(state, |s| storage::scan(&s.root, &s.id, &cache_path(&app)?))
324    })
325    .await
326    .map_err(|e| format!("刷新工作空间任务失败:{e}"))?
327}
328
329#[tauri::command(async)]
330fn search_documents(
331    workspace_id: String,
332    query: String,
333    app: tauri::AppHandle,
334    state: State<AppState>,
335) -> Result<Vec<search::SearchHit>, String> {
336    with_workspace_session(&workspace_id, state, |s| {
337        search::query(&cache_path(&app)?, &s.id, &query)
338    })
339}
340
341#[tauri::command(async)]
342fn recent_workspaces(
343    app: tauri::AppHandle,
344) -> Result<Vec<session_storage::RecentWorkspace>, String> {
345    let recent = session_storage::recent(&cache_path(&app)?)?;
346    #[cfg(mobile)]
347    let data = app_data(&app)?;
348    #[cfg(mobile)]
349    let recent = recent
350        .into_iter()
351        .filter(|entry| mobile_storage::validate(&data, std::path::Path::new(&entry.path)).is_ok())
352        .collect();
353    Ok(recent)
354}
355
356#[tauri::command]
357fn runtime_platform() -> serde_json::Value {
358    serde_json::json!({"mobile": cfg!(mobile), "os": std::env::consts::OS})
359}
360
361#[cfg(desktop)]
362#[tauri::command]
363fn open_devtools(window: tauri::WebviewWindow) {
364    window.open_devtools();
365}
366
367#[tauri::command]
368async fn tab_drag_release() -> Result<serde_json::Value, String> {
369    #[cfg(windows)]
370    {
371        tauri::async_runtime::spawn_blocking(|| {
372            use windows_sys::Win32::Foundation::POINT;
373            use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON};
374            use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos;
375
376            // The WebView may not dispatch dragend after an OS drag ends outside its window.
377            // Observe the button on a host thread so renderer timer throttling cannot lose it.
378            while unsafe { GetAsyncKeyState(VK_LBUTTON.into()) < 0 } {
379                std::thread::sleep(std::time::Duration::from_millis(8));
380            }
381            let mut point = POINT { x: 0, y: 0 };
382            if unsafe { GetCursorPos(&mut point) } == 0 {
383                return Err(std::io::Error::last_os_error().to_string());
384            }
385            Ok(serde_json::json!({"x": point.x, "y": point.y}))
386        })
387        .await
388        .map_err(|error| error.to_string())?
389    }
390    #[cfg(not(windows))]
391    {
392        Err("Tab drag release observation is only available on Windows".to_owned())
393    }
394}
395
396#[tauri::command]
397async fn open_mobile_workspace(
398    name: Option<String>,
399    app: tauri::AppHandle,
400) -> Result<storage::Workspace, String> {
401    if !cfg!(mobile) {
402        return Err("仅在移动应用中创建沙箱空间".into());
403    }
404    let data = app_data(&app)?;
405    let path = match name {
406        Some(name) => mobile_storage::create(&data, &name)?,
407        None => mobile_storage::default_workspace(&data)?,
408    };
409    open_workspace(path.to_string_lossy().to_string(), app).await
410}
411
412#[tauri::command(async)]
413fn mobile_recycled(
414    workspace_id: String,
415    app: tauri::AppHandle,
416    state: State<AppState>,
417) -> Result<Vec<mobile_storage::RecycledFile>, String> {
418    if !cfg!(mobile) {
419        return Err("桌面端请使用系统回收站".into());
420    }
421    with_workspace_session(&workspace_id, state, |s| {
422        mobile_storage::recycled(&app_data(&app)?, &s.id)
423    })
424}
425
426#[tauri::command(async)]
427fn mobile_recover(
428    workspace_id: String,
429    id: String,
430    app: tauri::AppHandle,
431    state: State<AppState>,
432) -> Result<String, String> {
433    if !cfg!(mobile) {
434        return Err("桌面端请使用系统回收站".into());
435    }
436    with_workspace_session(&workspace_id, state, |s| {
437        mobile_storage::recover(&app_data(&app)?, &s.root, &s.id, &id)
438    })
439}
440#[tauri::command(async)]
441fn load_session(
442    workspace_id: String,
443    app: tauri::AppHandle,
444    state: State<AppState>,
445) -> Result<Option<session_storage::WorkspaceSession>, String> {
446    with_session_for_journal(state, |s| {
447        if s.id != workspace_id {
448            return Err("会话所属工作区已切换".into());
449        }
450        session_storage::load(&app_data(&app)?, &s.id)
451    })
452}
453#[tauri::command(async)]
454fn save_session(
455    workspace_id: String,
456    session: session_storage::WorkspaceSession,
457    app: tauri::AppHandle,
458    state: State<AppState>,
459) -> Result<(), String> {
460    with_session_for_journal(state, |s| {
461        if s.id != workspace_id {
462            return Err("会话所属工作区已切换".into());
463        }
464        session_storage::save(&app_data(&app)?, &s.id, &session)
465    })
466}
467
468#[tauri::command(async)]
469fn import_attachment(
470    workspace_id: String,
471    name: String,
472    content: String,
473    require_image: bool,
474    app: tauri::AppHandle,
475    state: State<AppState>,
476) -> Result<assets::ImportedAsset, String> {
477    use base64::Engine;
478    if content.len() > 70 * 1024 * 1024 {
479        return Err("附件过大".into());
480    }
481    let bytes = base64::engine::general_purpose::STANDARD
482        .decode(content)
483        .map_err(|_| "附件编码无效")?;
484    with_workspace_mutating_session(&workspace_id, state, &app, |s| {
485        assets::import(
486            &s.root,
487            &name,
488            &bytes,
489            require_image,
490            Some((&s.id, &cache_path(&app)?)),
491        )
492    })
493}
494
495#[tauri::command(async)]
496fn read_attachment(
497    workspace_id: String,
498    path: String,
499    state: State<AppState>,
500) -> Result<String, String> {
501    use base64::Engine;
502    with_workspace_session(&workspace_id, state, |s| {
503        let bytes = assets::read(&s.root, &path)?;
504        Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
505    })
506}
507
508#[tauri::command(async)]
509fn export_attachment(
510    workspace_id: String,
511    path: String,
512    destination: String,
513    app: tauri::AppHandle,
514    state: State<AppState>,
515) -> Result<(), String> {
516    with_workspace_session(&workspace_id, state, |s| {
517        let bytes = assets::read(&s.root, &path)?;
518        write_export(&app, &destination, &bytes)
519    })
520}
521
522#[tauri::command(async)]
523fn export_document(
524    content: String,
525    destination: String,
526    app: tauri::AppHandle,
527) -> Result<(), String> {
528    use base64::Engine;
529    if content.len() > 70 * 1024 * 1024 {
530        return Err("导出文件不能超过 50 MiB".into());
531    }
532    let bytes = base64::engine::general_purpose::STANDARD
533        .decode(content)
534        .map_err(|e| format!("无效导出内容:{e}"))?;
535    if bytes.len() > 50 * 1024 * 1024 {
536        return Err("导出文件不能超过 50 MiB".into());
537    }
538    write_export(&app, &destination, &bytes)
539}
540
541fn write_export(app: &tauri::AppHandle, destination: &str, bytes: &[u8]) -> Result<(), String> {
542    #[cfg(mobile)]
543    {
544        use std::io::Write;
545        use tauri_plugin_fs::FsExt;
546        let destination: tauri_plugin_fs::FilePath = destination
547            .parse()
548            .map_err(|e| format!("无效导出位置:{e}"))?;
549        #[cfg(target_os = "android")]
550        if !matches!(&destination, tauri_plugin_fs::FilePath::Url(url) if url.scheme() == "content")
551        {
552            return Err("请通过 Android 文件选择器选择导出位置".into());
553        }
554        #[cfg(target_os = "ios")]
555        {
556            let target = destination.clone().into_path().map_err(|e| e.to_string())?;
557            let parent = std::fs::canonicalize(target.parent().ok_or("导出路径无效")?)
558                .map_err(|e| e.to_string())?;
559            if parent
560                .starts_with(std::fs::canonicalize(app_data(&app)?).map_err(|e| e.to_string())?)
561            {
562                return Err("请选择工作空间之外的导出位置".into());
563            }
564        }
565        let mut options = tauri_plugin_fs::OpenOptions::new();
566        options.write(true).truncate(true).create(true);
567        let result = (|| {
568            let mut file = app
569                .fs()
570                .open(destination.clone(), options)
571                .map_err(|e| e.to_string())?;
572            file.write_all(&bytes).map_err(|e| e.to_string())?;
573            file.sync_all().map_err(|e| e.to_string())
574        })();
575        #[cfg(target_os = "ios")]
576        let _ = app
577            .fs()
578            .stop_accessing_security_scoped_resource(destination);
579        result
580    }
581    #[cfg(desktop)]
582    {
583        let _ = app;
584        let target = PathBuf::from(destination);
585        if !target.is_absolute() {
586            return Err("请选择完整的保存路径".into());
587        }
588        storage::create_atomic(&target, bytes)
589            .map_err(|e| format!("无法导出;请使用尚未存在的文件名:{e}"))
590    }
591}
592
593#[tauri::command(async)]
594fn open_external_url(url: String, app: tauri::AppHandle) -> Result<(), String> {
595    let parsed = validated_external_url(&url)?;
596    use tauri_plugin_opener::OpenerExt;
597    app.opener()
598        .open_url(parsed.as_str(), None::<&str>)
599        .map_err(|e| e.to_string())
600}
601
602fn validated_external_url(url: &str) -> Result<tauri::Url, String> {
603    let parsed = tauri::Url::parse(url).map_err(|_| "链接地址无效")?;
604    match parsed.scheme() {
605        "http" | "https" if parsed.username().is_empty() && parsed.password().is_none() => {}
606        "mailto" => {
607            let address = parsed.path();
608            let valid_address = address.split_once('@').is_some_and(|(local, domain)| {
609                !local.is_empty()
610                    && !domain.is_empty()
611                    && !domain.contains('@')
612                    && address.bytes().all(|byte| {
613                        byte.is_ascii_alphanumeric()
614                            || matches!(byte, b'@' | b'.' | b'_' | b'+' | b'-')
615                    })
616            });
617            if !valid_address || parsed.query().is_some() || parsed.fragment().is_some() {
618                return Err("邮箱链接仅支持单个有效地址".into());
619            }
620        }
621        _ => return Err("外部链接只支持 HTTP、HTTPS 和 mailto 地址".into()),
622    }
623    Ok(parsed)
624}
625
626#[cfg(test)]
627mod external_url_tests {
628    use super::validated_external_url;
629
630    #[test]
631    fn accepts_web_and_email_links() {
632        assert!(validated_external_url("https://example.com/path").is_ok());
633        assert!(validated_external_url("mailto:test+tag@example.com").is_ok());
634    }
635
636    #[test]
637    fn rejects_unsafe_or_ambiguous_external_links() {
638        for url in [
639            "javascript:alert(1)",
640            "https://user:secret@example.com",
641            "mailto:test@example.com?subject=hello",
642            "mailto:test%0a@example.com",
643            "mailto:test@example.com,other@example.com",
644        ] {
645            assert!(validated_external_url(url).is_err(), "{url}");
646        }
647    }
648}
649
650#[tauri::command(async)]
651fn read_document(path: String, state: State<AppState>) -> Result<storage::Snapshot, String> {
652    with_session(state, |s| {
653        storage::read(&storage::resolve_storage(&s.root, &path, true)?)
654    })
655}
656
657#[tauri::command(async)]
658fn write_document(
659    path: String,
660    content: String,
661    expected_hash: String,
662    state: State<AppState>,
663) -> Result<storage::WriteResult, String> {
664    with_session(state, |s| {
665        let target = storage::resolve_storage(&s.root, &path, false)?;
666        storage::write(&target, &content, &expected_hash)
667    })
668}
669
670#[tauri::command(async)]
671fn create_agent_record(
672    path: String,
673    content: String,
674    app: tauri::AppHandle,
675    state: State<AppState>,
676) -> Result<(), String> {
677    let object: serde_json::Value = serde_json::from_str(&content).map_err(|_| "对象 JSON 无效")?;
678    let id = object
679        .get("id")
680        .and_then(serde_json::Value::as_str)
681        .ok_or("对象身份缺失")?;
682    if !storage::is_short_id(id)
683        || !(path == format!(".tano/objects/{id}.tano.json")
684            || (!path.starts_with('.') && path.ends_with(".table.json")))
685    {
686        return Err("对象路径无效".into());
687    }
688    with_session(state, |s| {
689        let target = storage::ensure_file_parent(&s.root, &path)?;
690        if target.exists() {
691            return Err("对象路径已存在".into());
692        }
693        identity_manifest::claim_external(&s.root, &s.id, &cache_path(&app)?, id)?;
694        // Failed publication deliberately keeps the ID reserved; never reuse an identity.
695        storage::create_atomic(&target, content.as_bytes())
696    })
697}
698
699#[tauri::command(async)]
700fn reserve_reference_id(
701    app: tauri::AppHandle,
702    occupied: Vec<String>,
703    state: State<AppState>,
704) -> Result<String, String> {
705    with_session(state, |s| {
706        identity_manifest::reserve(&s.root, &s.id, &cache_path(&app)?, &occupied)
707    })
708}
709
710#[tauri::command(async)]
711fn create_document(
712    path: String,
713    content: String,
714    app: tauri::AppHandle,
715    state: State<AppState>,
716) -> Result<(), String> {
717    with_session(state, |s| {
718        let target = if storage::is_workspace_data_path(&path) {
719            storage::ensure_file_parent(&s.root, &path)?
720        } else {
721            storage::resolve(&s.root, &path, false)?
722        };
723        let cache = cache_path(&app)?;
724        identity_manifest::begin_create(&s.root, &s.id, &cache, &path, content.as_bytes())?;
725        if let Err(error) = storage::create(&target, &content) {
726            let _ = identity_manifest::recover_mutation(&s.root, &s.id, &cache);
727            return Err(error);
728        }
729        identity_manifest::recover_mutation(&s.root, &s.id, &cache)
730    })
731}
732
733#[tauri::command(async)]
734fn create_folder(path: String, state: State<AppState>) -> Result<(), String> {
735    with_session(state, |s| {
736        std::fs::create_dir(storage::resolve(&s.root, &path, false)?).map_err(|e| e.to_string())
737    })
738}
739
740#[tauri::command(async)]
741fn rename_document(
742    from: String,
743    to: String,
744    app: tauri::AppHandle,
745    state: State<AppState>,
746) -> Result<(), String> {
747    with_session(state, |s| {
748        storage::rename(&s.root, &s.id, &from, &to, &cache_path(&app)?)
749    })
750}
751
752#[tauri::command(async)]
753fn move_entry(
754    from: String,
755    to: String,
756    app: tauri::AppHandle,
757    state: State<AppState>,
758) -> Result<(), String> {
759    with_session(state, |s| {
760        storage::move_entry(&s.root, &s.id, &from, &to, &cache_path(&app)?)
761    })
762}
763
764#[tauri::command(async)]
765fn remove_document(
766    path: String,
767    app: tauri::AppHandle,
768    state: State<AppState>,
769) -> Result<(), String> {
770    with_session(state, |s| {
771        let target = storage::resolve_storage(&s.root, &path, true)?;
772        #[cfg(mobile)]
773        if !target.is_file() {
774            return Err("手机回收站暂不支持文件夹".into());
775        }
776        let cache = cache_path(&app)?;
777        identity_manifest::begin_delete(&s.root, &s.id, &cache, &path)?;
778        #[cfg(desktop)]
779        {
780            if let Err(error) = trash::delete(target) {
781                let _ = identity_manifest::recover_mutation(&s.root, &s.id, &cache);
782                return Err(error.to_string());
783            }
784        }
785        #[cfg(mobile)]
786        {
787            if let Err(error) = mobile_storage::recycle(&app_data(&app)?, &s.root, &s.id, &path) {
788                let _ = identity_manifest::recover_mutation(&s.root, &s.id, &cache);
789                return Err(error);
790            }
791        }
792        identity_manifest::recover_mutation(&s.root, &s.id, &cache)
793    })
794}
795
796#[tauri::command(async)]
797fn change_assistant_file(
798    request: assistant_files::FileChangeRequest,
799    app: tauri::AppHandle,
800    state: State<AppState>,
801) -> Result<storage::WriteResult, String> {
802    with_session(state, |s| {
803        assistant_files::change(&s.root, &s.id, &cache_path(&app)?, &request, |target| {
804            #[cfg(desktop)]
805            {
806                trash::delete(target).map_err(|error| error.to_string())
807            }
808            #[cfg(mobile)]
809            {
810                let _ = target;
811                mobile_storage::recycle(&app_data(&app)?, &s.root, &s.id, &request.path)
812            }
813        })
814    })
815}
816
817#[tauri::command(async)]
818fn delete_workspace_object(
819    request: assistant_files::FileChangeRequest,
820    app: tauri::AppHandle,
821    state: State<AppState>,
822) -> Result<storage::WriteResult, String> {
823    with_session(state, |s| {
824        assistant_files::delete_object(&s.root, &s.id, &cache_path(&app)?, &request, |target| {
825            #[cfg(desktop)]
826            {
827                trash::delete(target).map_err(|error| error.to_string())
828            }
829            #[cfg(mobile)]
830            {
831                let _ = target;
832                mobile_storage::recycle(&app_data(&app)?, &s.root, &s.id, &request.path)
833            }
834        })
835    })
836}
837
838#[tauri::command(async)]
839fn reveal_workspace(state: State<AppState>) -> Result<(), String> {
840    #[cfg(mobile)]
841    {
842        let _ = state;
843        return Err("手机空间保存在应用沙箱中".into());
844    }
845    #[cfg(desktop)]
846    with_session(state, |s| {
847        #[cfg(windows)]
848        let command = "explorer";
849        #[cfg(target_os = "macos")]
850        let command = "open";
851        #[cfg(all(unix, not(target_os = "macos")))]
852        let command = "xdg-open";
853        std::process::Command::new(command)
854            .arg(&s.root)
855            .spawn()
856            .map_err(|e| e.to_string())?;
857        Ok(())
858    })
859}
860
861#[tauri::command(async)]
862fn open_workspace_entry(
863    workspace_id: String,
864    path: String,
865    reveal: bool,
866    state: State<AppState>,
867) -> Result<(), String> {
868    #[cfg(mobile)]
869    {
870        let _ = (workspace_id, path, reveal, state);
871        return Err("请在桌面端打开工作区文件".into());
872    }
873    #[cfg(desktop)]
874    with_workspace_session(&workspace_id, state, |s| {
875        let target = storage::resolve(&s.root, &path, true)?;
876        if reveal {
877            tauri_plugin_opener::reveal_item_in_dir(&target)
878        } else {
879            tauri_plugin_opener::open_path(&target, None::<&str>)
880        }
881        .map_err(|e| e.to_string())
882    })
883}
884
885#[tauri::command(async)]
886fn workspace_entry_path(
887    workspace_id: String,
888    path: String,
889    state: State<AppState>,
890) -> Result<String, String> {
891    #[cfg(mobile)]
892    {
893        let _ = (workspace_id, path, state);
894        return Err("请在桌面端复制工作区文件路径".into());
895    }
896    #[cfg(desktop)]
897    with_workspace_session(&workspace_id, state, |s| {
898        storage::resolve(&s.root, &path, true)?
899            .into_os_string()
900            .into_string()
901            .map_err(|_| "文件路径包含无法复制的字符".into())
902    })
903}
904
905#[tauri::command(async)]
906fn duplicate_workspace_entry(
907    workspace_id: String,
908    path: String,
909    app: tauri::AppHandle,
910    state: State<AppState>,
911) -> Result<String, String> {
912    with_workspace_mutating_session(&workspace_id, state, &app, |s| {
913        let duplicate = storage::duplicate_entry(&s.root, &path)?;
914        storage::scan(&s.root, &s.id, &cache_path(&app)?)?;
915        Ok(duplicate)
916    })
917}
918
919#[tauri::command(async)]
920fn open_workspace_terminal(workspace_id: String, state: State<AppState>) -> Result<(), String> {
921    if cfg!(mobile) {
922        return Err("请在电脑端打开工作区终端".into());
923    }
924    with_workspace_session(&workspace_id, state, |s| agent::terminal(&s.root))
925}
926
927#[tauri::command(async)]
928fn agent_setup(
929    workspace_id: String,
930    app: tauri::AppHandle,
931    state: State<AppState>,
932) -> Result<serde_json::Value, String> {
933    if cfg!(mobile) {
934        return Err("本机 MCP 仅在桌面提供,请在电脑端使用".into());
935    }
936    with_workspace_session(&workspace_id, state, |s| {
937        let bundled = app
938            .path()
939            .resource_dir()
940            .map_err(|e| e.to_string())?
941            .join("mcp/tano-mcp.cjs");
942        let development =
943            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../apps/mcp/dist/tano-mcp.cjs");
944        let script = if bundled.is_file() {
945            bundled
946        } else {
947            development
948        };
949        let exists = script.is_file();
950        let script = if exists {
951            std::fs::canonicalize(script).map_err(|e| e.to_string())?
952        } else {
953            script
954        };
955        #[cfg(target_os = "linux")]
956        let script = if exists {
957            agent::persistent_mcp_script(&script, &app_data(&app)?)?
958        } else {
959            script
960        };
961        let plain = |p: &std::path::Path| {
962            p.display()
963                .to_string()
964                .trim_start_matches("\\\\?\\")
965                .to_string()
966        };
967        Ok(
968            serde_json::json!({"bundled": exists, "config": {"mcpServers": {"tano": {
969                "command": "node", "args": [plain(&script), "--workspace", plain(&s.root), "--data-dir", plain(&app_data(&app)?)]
970            }}}}),
971        )
972    })
973}
974
975#[tauri::command(async)]
976fn agent_changes(
977    workspace_id: String,
978    selected: Option<String>,
979    app: tauri::AppHandle,
980    state: State<AppState>,
981) -> Result<Vec<agent::Change>, String> {
982    with_workspace_session(&workspace_id, state, |s| {
983        agent::changes(&app_data(&app)?, &s.id, &s.root, selected.as_deref())
984    })
985}
986
987pub fn with_workspace_session<T>(
988    workspace_id: &str,
989    state: State<AppState>,
990    f: impl FnOnce(&Session) -> Result<T, String>,
991) -> Result<T, String> {
992    with_session(state, |session| {
993        if session.id != workspace_id {
994            return Err("所属工作空间已切换,操作已取消".into());
995        }
996        f(session)
997    })
998}
999
1000pub fn with_workspace_mutating_session<T>(
1001    workspace_id: &str,
1002    state: State<AppState>,
1003    _app: &tauri::AppHandle,
1004    f: impl FnOnce(&Session) -> Result<T, String>,
1005) -> Result<T, String> {
1006    with_workspace_session(workspace_id, state, f)
1007}
1008
1009/// Called under the workspace session lock before a cloud adapter applies local files.
1010pub fn assert_cloud_sync_available(data: &Path, workspace_id: &str) -> Result<(), String> {
1011    let path = data
1012        .join("workspace-sync")
1013        .join(workspace_id)
1014        .join("state.json");
1015    let bytes = match std::fs::read(path) {
1016        Ok(bytes) => bytes,
1017        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1018        Err(error) => return Err(error.to_string()),
1019    };
1020    let state: serde_json::Value =
1021        serde_json::from_slice(&bytes).map_err(|_| "同步配置损坏,已停止云端写入")?;
1022    match state.get("backend") {
1023        Some(serde_json::Value::Null) => Ok(()),
1024        Some(serde_json::Value::String(backend)) if backend == "cloud" => Ok(()),
1025        _ => Err("当前工作区由 Git 同步,已阻止云端文件写入".into()),
1026    }
1027}
1028
1029pub fn app_data(app: &tauri::AppHandle) -> Result<PathBuf, String> {
1030    let directory = app.path().app_data_dir().map_err(|e| e.to_string())?;
1031    std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
1032    Ok(directory)
1033}
1034
1035pub fn configure(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
1036    configure_with_commands(builder, &[], |_| false)
1037}
1038
1039/// Compose host commands with the local command router without replacing it.
1040pub fn configure_with_commands(
1041    builder: tauri::Builder<tauri::Wry>,
1042    commands: &'static [&'static str],
1043    extra: impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static,
1044) -> tauri::Builder<tauri::Wry> {
1045    #[cfg(desktop)]
1046    let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
1047    #[cfg(desktop)]
1048    let builder = builder.manage(assistant::AssistantState::default());
1049    #[cfg(desktop)]
1050    let builder = builder.manage(plugin_native::PluginNativeState::default());
1051    #[cfg(desktop)]
1052    let builder = builder.manage(plugin_oauth::PluginOAuthState::default());
1053    builder
1054        .plugin(tauri_plugin_dialog::init())
1055        .plugin(tauri_plugin_opener::init())
1056        .plugin(tauri_plugin_fs::init())
1057        .manage(AppState::default())
1058        .manage(updates::UpdateState::default())
1059        .on_window_event(|window, event| {
1060            #[cfg(desktop)]
1061            if matches!(event, tauri::WindowEvent::Destroyed) {
1062                window
1063                    .state::<assistant::AssistantState>()
1064                    .stop_window(window.label());
1065                window
1066                    .state::<plugin_native::PluginNativeState>()
1067                    .stop_window(window.label());
1068            }
1069        })
1070        .register_asynchronous_uri_scheme_protocol("tano-asset", |context, request, responder| {
1071            let app = context.app_handle().clone();
1072            tauri::async_runtime::spawn_blocking(move || {
1073                let state = app.state::<AppState>();
1074                let response = match state.session.lock() {
1075                    Ok(guard) if guard.is_some() => {
1076                        let session = guard.as_ref().unwrap();
1077                        assets::respond(&session.root, &session.id, &request)
1078                    }
1079                    _ => tauri::http::Response::builder()
1080                        .status(404)
1081                        .body(b"No workspace".to_vec())
1082                        .expect("static response"),
1083                };
1084                responder.respond(response);
1085            });
1086        })
1087        .invoke_handler(move |invoke| {
1088            #[cfg(desktop)]
1089            if invoke.message.command() == "workspace_git_query" {
1090                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool =
1091                    tauri::generate_handler![workspace_git::workspace_git_query];
1092                return handler(invoke);
1093            }
1094            #[cfg(desktop)]
1095            if invoke.message.command() == "workspace_sync" {
1096                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool =
1097                    tauri::generate_handler![workspace_sync::workspace_sync];
1098                return handler(invoke);
1099            }
1100
1101            #[cfg(desktop)]
1102            if invoke.message.command().starts_with("plugin_connections_") {
1103                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool = tauri::generate_handler![
1104                    plugin_connections::plugin_connections_list,
1105                    plugin_connections::plugin_connections_save,
1106                    plugin_connections::plugin_connections_remove,
1107                    plugin_connections::plugin_connections_request,
1108                    plugin_oauth::plugin_connections_oauth_providers,
1109                    plugin_oauth::plugin_connections_oauth_start,
1110                    plugin_oauth::plugin_connections_oauth_status,
1111                    plugin_oauth::plugin_connections_oauth_cancel,
1112                ];
1113                return handler(invoke);
1114            }
1115            #[cfg(desktop)]
1116            if matches!(
1117                invoke.message.command(),
1118                "plugin_native_request" | "plugin_native_cancel"
1119            ) {
1120                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool = tauri::generate_handler![
1121                    plugin_native::plugin_native_request,
1122                    plugin_native::plugin_native_cancel,
1123                ];
1124                return handler(invoke);
1125            }
1126            #[cfg(desktop)]
1127            if invoke.message.command() == "open_devtools" {
1128                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool =
1129                    tauri::generate_handler![open_devtools];
1130                return handler(invoke);
1131            }
1132            #[cfg(desktop)]
1133            if invoke.message.command().starts_with("assistant_") {
1134                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool = tauri::generate_handler![
1135                    assistant::assistant_protocol_version,
1136                    assistant::assistant_connections,
1137                    assistant::assistant_models,
1138                    assistant::assistant_connection_save,
1139                    assistant::assistant_connection_remove,
1140                    assistant::assistant_request,
1141                    assistant::assistant_cancel,
1142                    assistant::assistant_sessions_load,
1143                    assistant::assistant_session_save,
1144                    assistant::assistant_session_delete,
1145                    assistant::assistant_external_sessions,
1146                    assistant::assistant_external_result,
1147                ];
1148                return handler(invoke);
1149            }
1150            #[cfg(desktop)]
1151            if matches!(
1152                invoke.message.command(),
1153                "plan_accounts_list"
1154                    | "plan_account_save"
1155                    | "plan_account_remove"
1156                    | "plan_account_oauth"
1157                    | "plan_remote_request"
1158                    | "plan_sync_state_read"
1159                    | "plan_sync_state_write"
1160            ) {
1161                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool = tauri::generate_handler![
1162                    plan_connections::plan_accounts_list,
1163                    plan_connections::plan_account_save,
1164                    plan_connections::plan_account_remove,
1165                    plan_connections::plan_account_oauth,
1166                    plan_connections::plan_remote_request,
1167                    plan_connections::plan_sync_state_read,
1168                    plan_connections::plan_sync_state_write,
1169                ];
1170                return handler(invoke);
1171            }
1172            #[cfg(desktop)]
1173            if matches!(
1174                invoke.message.command(),
1175                "import_capabilities"
1176                    | "import_accounts"
1177                    | "import_connect"
1178                    | "import_cancel"
1179                    | "import_disconnect"
1180                    | "import_request"
1181            ) {
1182                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool = tauri::generate_handler![
1183                    import_connections::import_capabilities,
1184                    import_connections::import_accounts,
1185                    import_connections::import_connect,
1186                    import_connections::import_cancel,
1187                    import_connections::import_disconnect,
1188                    import_connections::import_request
1189                ];
1190                return handler(invoke);
1191            }
1192            if commands.contains(&invoke.message.command()) {
1193                extra(invoke)
1194            } else {
1195                let handler: fn(tauri::ipc::Invoke<tauri::Wry>) -> bool = tauri::generate_handler![
1196                    updates::update_status,
1197                    updates::update_check,
1198                    updates::update_download,
1199                    updates::update_install,
1200                    open_workspace,
1201                    close_workspace,
1202                    unregister_workspace,
1203                    runtime_platform,
1204                    tab_drag_release,
1205                    open_mobile_workspace,
1206                    mobile_recycled,
1207                    mobile_recover,
1208                    search_documents,
1209                    recent_workspaces,
1210                    load_session,
1211                    save_session,
1212                    import_attachment,
1213                    read_attachment,
1214                    export_attachment,
1215                    export_document,
1216                    open_external_url,
1217                    refresh_workspace,
1218                    reserve_reference_id,
1219                    create_agent_record,
1220                    personal_plans::personal_plans_list,
1221                    personal_plans::personal_plans_read,
1222                    personal_plans::personal_plans_reserve,
1223                    personal_plans::personal_plans_create,
1224                    personal_plans::personal_plans_write,
1225                    read_document,
1226                    write_document,
1227                    create_document,
1228                    create_folder,
1229                    rename_document,
1230                    move_entry,
1231                    remove_document,
1232                    change_assistant_file,
1233                    delete_workspace_object,
1234                    reveal_workspace,
1235                    open_workspace_entry,
1236                    workspace_entry_path,
1237                    duplicate_workspace_entry,
1238                    open_workspace_terminal,
1239                    agent_setup,
1240                    agent_changes
1241                ];
1242                handler(invoke)
1243            }
1244        })
1245}