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 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 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 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 #[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 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 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
1009pub 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
1039pub 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}