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