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 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 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 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 #[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 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 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
1089pub 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
1119pub 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}