diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 5e83a6d..d386b86 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Weak}; use sea_orm::DatabaseConnection; use swarm_p2p_core::libp2p::identity::Keypair; +use swarm_p2p_core::libp2p::PeerId; use tokio::sync::Mutex; use tracing::info; use uuid::Uuid; @@ -25,7 +26,7 @@ use crate::keychain::KeychainProvider; use crate::network::config::create_node_config; use crate::network::event_loop::spawn_event_loop; use crate::network::{AppNetClient, NetManager, NodeStatus}; -use crate::protocol::{AppRequest, AppResponse, OsInfo}; +use crate::protocol::{AppRequest, AppResponse, OsInfo, WorkspaceRequest, WorkspaceResponse}; use crate::workspace::sync::{AppSyncCoordinator, WorkspaceSync}; use crate::workspace::{ self, db::init_devices_db, db::init_workspace_db, load_or_create_workspace_info, WorkspaceCore, @@ -78,6 +79,12 @@ pub struct AppCore { /// actually frees the workspace; the map is cleaned on demand in /// [`AppCore::open_workspace`]. workspaces: Mutex>>, + + /// 被「懒打开」(lazy-open)以服务对端拉取的 headless / sync-only 工作区—— + /// 持**强引用**让它们在无窗口、无 `Weak` 升级来源时仍存活,从而能被发现 + /// (`build_workspace_list`)与经 RR 同步。它们不订阅 gossip 实时广播;用户真正 + /// 打开窗口时再经 `open_workspace` 升级为订阅态。见 [`AppCore::ensure_open_for_sync`]。 + headless: Mutex>>, } /// Builder for [`AppCore`]. Collects the three required platform @@ -171,6 +178,7 @@ impl AppCoreBuilder { net: Mutex::new(None), sync_coordinator: Mutex::new(None), workspaces: Mutex::new(HashMap::new()), + headless: Mutex::new(HashMap::new()), })) } } @@ -217,7 +225,7 @@ impl AppCore { self: &Arc, path: impl Into, ) -> AppResult> { - self.open_workspace_impl(path, true).await + self.open_workspace_impl(path, true, true).await } /// Like [`AppCore::open_workspace`] but for a workspace being synced/joined @@ -227,13 +235,17 @@ impl AppCore { self: &Arc, path: impl Into, ) -> AppResult> { - self.open_workspace_impl(path, false).await + self.open_workspace_impl(path, false, true).await } + /// `register_sync = false` 时打开但**不安装 WorkspaceSync**——即不订阅 gossip + /// topic,只能经 request-response 被动服务(供 [`AppCore::ensure_open_for_sync`] + /// 的 headless / sync-only 打开使用)。 async fn open_workspace_impl( self: &Arc, path: impl Into, init_keys: bool, + register_sync: bool, ) -> AppResult> { let path: PathBuf = path.into(); if !path.is_dir() { @@ -313,10 +325,14 @@ impl AppCore { } }; - // If P2P is running, install per-workspace sync + subscribe. - if let Some(coordinator) = self.sync_coordinator().await { - self.install_workspace_sync(&winner, coordinator.client(), true) - .await; + // 若 P2P 在运行且需要实时订阅,安装 per-workspace sync(订阅 gossip)。 + // headless / sync-only 打开(register_sync=false)跳过——它只经 RR 被动 + // 服务,不订阅实时广播。 + if register_sync { + if let Some(coordinator) = self.sync_coordinator().await { + self.install_workspace_sync(&winner, coordinator.client(), true) + .await; + } } // Persist to recent_workspaces so hosts can surface MRU lists without @@ -340,6 +356,9 @@ impl AppCore { /// authoritative shutdown hook used by the host when the last window /// referencing a workspace closes. pub async fn close_workspace(&self, uuid: Uuid) -> AppResult<()> { + // 同时释放可能持有的 headless 强引用,否则关窗口后 WorkspaceCore 不会真正 + // 释放(下次对端拉取会经 ensure_open_for_sync 重新 headless 打开)。 + self.headless.lock().await.remove(&uuid); let mut guard = self.workspaces.lock().await; let Some(weak) = guard.remove(&uuid) else { return Ok(()); @@ -361,6 +380,107 @@ impl AppCore { guard.get(uuid).and_then(|w| w.upgrade()) } + /// 邀请已配对设备协作某工作区(owner 发起)。向对端发 `ShareInvitation` + /// 请求并**阻塞等待对方接受/拒绝**(对端弹窗,结果经 request-response 回填); + /// 仅当对方接受时才签发 `Grant` op 授权——未接受不授权。返回是否被接受。 + pub async fn invite_device( + self: &Arc, + workspace_uuid: Uuid, + target_peer_id: &str, + ) -> AppResult { + // 仅 owner 可邀请——发邀请前先校验(grant 时还会再校验一次)。 + let ws = self + .get_workspace(&workspace_uuid) + .await + .ok_or(AppError::NoWorkspaceOpen)?; + let me = self.identity.peer_id()?; + if crate::workspace::permissions::role_of(ws.db(), workspace_uuid, &me).await? + != Some(crate::workspace::permissions::Role::Owner) + { + return Err(AppError::PermissionDenied( + "only the workspace owner can invite members".into(), + )); + } + let name = ws.info.name.clone(); + let pid: PeerId = target_peer_id.parse().map_err(|e| AppError::SwarmIo { + context: "invite parse peer id", + reason: format!("{e}"), + })?; + + let client = self.client().await?; + let resp = client + .send_request( + pid, + AppRequest::Workspace(WorkspaceRequest::ShareInvitation { + workspace_uuid, + name, + }), + ) + .await + .map_err(|e| AppError::SwarmIo { + context: "send share invitation", + reason: e.to_string(), + })?; + + let accepted = matches!( + resp, + AppResponse::Workspace(WorkspaceResponse::ShareInvitationResult { accepted: true }) + ); + // 对方接受后才真正授权(签 Grant op + 广播链)。 + if accepted { + crate::workspace::sharing::grant_collaborator(self, workspace_uuid, target_peer_id) + .await?; + } + Ok(accepted) + } + + /// 被邀请方对一条分享邀请的应答:把结果经 request-response 回填给邀请方 + /// (`accept=true` 时邀请方随后签发授权)。`pending_id` 来自 + /// `ShareInvitationReceived` 事件。 + pub async fn respond_share_invitation(&self, pending_id: u64, accept: bool) -> AppResult<()> { + let client = self.client().await?; + client + .send_response( + pending_id, + AppResponse::Workspace(WorkspaceResponse::ShareInvitationResult { + accepted: accept, + }), + ) + .await + .map_err(|e| AppError::SwarmIo { + context: "respond share invitation", + reason: e.to_string(), + })?; + Ok(()) + } + + /// 确保某工作区已打开以供对端拉取(**懒打开**):已打开(窗口或之前 headless) + /// 则直接返回;否则按 `recent_workspaces` 记录的路径以 headless / sync-only 方式 + /// 打开(不订阅 gossip)并持强引用,使其在无窗口时也能经 RR 被发现/服务。 + /// 找不到路径或打开失败返回 `None`。 + pub async fn ensure_open_for_sync(self: &Arc, uuid: Uuid) -> Option> { + if let Some(ws) = self.get_workspace(&uuid).await { + return Some(ws); + } + let target = uuid.to_string(); + let path = self + .recent_workspaces() + .await + .into_iter() + .find(|w| w.uuid.as_deref() == Some(target.as_str())) + .map(|w| w.path)?; + match self.open_workspace_impl(path, false, false).await { + Ok(ws) => { + self.headless.lock().await.insert(uuid, ws.clone()); + Some(ws) + } + Err(e) => { + tracing::warn!("ensure_open_for_sync: 打开工作区 {uuid} 失败: {e}"); + None + } + } + } + /// Snapshot of every live workspace (active `Arc` upgrades only). /// Stale `Weak` entries are *not* pruned here — that happens on the /// next `open_workspace` call. diff --git a/crates/core/src/events.rs b/crates/core/src/events.rs index 075c595..735cd07 100644 --- a/crates/core/src/events.rs +++ b/crates/core/src/events.rs @@ -85,6 +85,25 @@ pub enum AppEvent { peer_id: String, }, + // ── Sharing / membership ── + /// 收到一条工作区协作邀请,等待用户接受/拒绝。前端 SHOULD 弹窗,用户决定后 + /// 调 `respond_share_invitation(pending_id, accept)`。邀请方的请求在此期间 + /// 阻塞等待(经 request-response 回填)。 + ShareInvitationReceived { + pending_id: u64, + peer_id: String, + workspace_uuid: Uuid, + workspace_name: String, + expires_at: DateTime, + }, + /// This device was removed from a shared workspace (its role was revoked by + /// the owner). The device has stopped subscribing to that workspace's + /// realtime updates; frontend SHOULD notify the user. Content already + /// synced stays locally readable. + MemberRevoked { + workspace_id: Uuid, + }, + // ── Network / P2P node ── /// NAT status changed (behind symmetric NAT, public reachable, etc.). NetworkStatusChanged { diff --git a/crates/core/src/network/event_loop.rs b/crates/core/src/network/event_loop.rs index 426c940..18abc93 100644 --- a/crates/core/src/network/event_loop.rs +++ b/crates/core/src/network/event_loop.rs @@ -178,7 +178,9 @@ async fn handle_event( .await; } else if let Some(ws_uuid) = parse_ws_awareness_topic(&topic) { // Encrypted workspace awareness broadcast — decrypt + fan-out. - coordinator.handle_ws_awareness_gossip(ws_uuid, data).await; + coordinator + .handle_ws_awareness_gossip(source, ws_uuid, data) + .await; } else { info!("GossipSub message on unknown topic: {topic}"); } @@ -229,6 +231,26 @@ async fn handle_inbound_request( } } + AppRequest::Workspace(WorkspaceRequest::ShareInvitation { + workspace_uuid, + name, + }) => { + // 入站分享邀请:不立即响应,弹给用户决定。请求方的 send_request 在 + // 此期间阻塞;用户接受/拒绝后经 respond_share_invitation 回填 + // (send_response(pending_id, ...))。 + info!( + "Received share invitation for {workspace_uuid} from {peer_id} (pending_id={pending_id})" + ); + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(90); + core.event_bus.emit(AppEvent::ShareInvitationReceived { + pending_id, + peer_id: peer_id.to_string(), + workspace_uuid: *workspace_uuid, + workspace_name: name.clone(), + expires_at, + }); + } + AppRequest::Sync(sync_req) => { coordinator .handle_inbound_request(peer_id, pending_id, sync_req.clone()) @@ -239,17 +261,43 @@ async fn handle_inbound_request( /// 构建工作区元数据列表,**只包含请求方被授权访问的工作区**(与 key 分发 / /// 同步响应的权限 gating 一致,避免请求方"看得到却拉不动")。 +/// +/// 候选集 = 已打开的工作区 ∪ 最近列表里**未打开**的工作区——后者会被 +/// [`AppCore::ensure_open_for_sync`] 以 headless / sync-only 方式懒打开,使本设备 +/// 即使没在窗口里打开某工作区,授权的对端也能发现并拉取它(仍按 role 过滤)。 async fn build_workspace_list(core: &Arc, requester: PeerId) -> WorkspaceResponse { use entity::workspace::documents; let requester_str = requester.to_string(); - let workspaces = core.list_workspaces().await; - let mut metas = Vec::with_capacity(workspaces.len()); - for ws in &workspaces { - // Only advertise workspaces the requester is an authorized member of. + // 收集候选 UUID:先已打开的,再补上最近列表里尚未打开的(去重)。 + let mut candidates: Vec = core + .list_workspaces() + .await + .iter() + .map(|w| w.info.id) + .collect(); + for rw in core.recent_workspaces().await { + if let Some(uuid) = rw + .uuid + .as_deref() + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + { + if !candidates.contains(&uuid) { + candidates.push(uuid); + } + } + } + + let mut metas = Vec::new(); + for uuid in candidates { + // 懒打开(已打开则直接拿到;未打开则 sync-only 打开)。 + let Some(ws) = core.ensure_open_for_sync(uuid).await else { + continue; + }; + // 只广播请求方有角色的工作区。 let authorized = matches!( - crate::workspace::permissions::role_of(ws.db(), ws.info.id, &requester_str).await, + crate::workspace::permissions::role_of(ws.db(), uuid, &requester_str).await, Ok(Some(_)) ); if !authorized { @@ -259,7 +307,7 @@ async fn build_workspace_list(core: &Arc, requester: PeerId) -> Workspa let doc_count = documents::Entity::find().count(ws.db()).await.unwrap_or(0) as u32; metas.push(WorkspaceMeta { - uuid: ws.info.id, + uuid, name: ws.info.name.clone(), doc_count, updated_at: ws.info.updated_at.timestamp_millis(), diff --git a/crates/core/src/protocol/workspace.rs b/crates/core/src/protocol/workspace.rs index e61808c..36f8761 100644 --- a/crates/core/src/protocol/workspace.rs +++ b/crates/core/src/protocol/workspace.rs @@ -3,16 +3,30 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// Workspace resource discovery requests. +/// 工作区资源发现 / 分享请求。 #[derive(Debug, Clone, Serialize, Deserialize)] pub enum WorkspaceRequest { - /// Query the peer's currently-open workspace list. + /// 查询对端当前打开的工作区列表。 ListWorkspaces, + /// 邀请本设备协作某工作区(owner 发起)。对端弹窗让用户接受/拒绝, + /// 响应回 [`WorkspaceResponse::ShareInvitationResult`]。在对端接受前 + /// owner 不会签发 grant op,即未接受不授权。 + ShareInvitation { + workspace_uuid: Uuid, + /// 工作区名,仅用于在邀请弹窗里展示。 + name: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum WorkspaceResponse { - WorkspaceList { workspaces: Vec }, + WorkspaceList { + workspaces: Vec, + }, + /// 被邀请方对 [`WorkspaceRequest::ShareInvitation`] 的应答。 + ShareInvitationResult { + accepted: bool, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/core/src/workspace/keys.rs b/crates/core/src/workspace/keys.rs index 0dc43d2..d468bf5 100644 --- a/crates/core/src/workspace/keys.rs +++ b/crates/core/src/workspace/keys.rs @@ -306,6 +306,44 @@ pub async fn install_received_key( Ok(()) } +/// Rotate to a fresh key version: generate new `read_key`/`write_key`, seal a +/// self-Lockbox for this device, persist it, and return the new `key_version`. +/// +/// Old versions are **kept** in the DB (and reload on open) so old ciphertexts +/// stay decryptable — lazy revocation, no forward secrecy (see threat model). +/// Remaining members pick up the new version on demand: they re-request the +/// workspace key when membership changes, and the owner re-seals the *current* +/// version to authorized requesters (`build_workspace_key_response`). The +/// removed device, lacking a Lockbox for the new version, cannot decrypt +/// content broadcast under it. +pub async fn rotate_workspace_key( + db: &DatabaseConnection, + workspace_id: Uuid, + my_peer_id: &str, + my_secret: &StaticSecret, + my_public: &PublicKey, +) -> AppResult { + let current = load_workspace_keys(db, workspace_id, my_peer_id, my_secret, my_public).await?; + let next = current.current_version().unwrap_or(0) + 1; + + let read = random_key(); + let write = random_key(); + let sealed_read = seal_lockbox(my_secret, my_public, &read)?; + let sealed_write = seal_lockbox(my_secret, my_public, &write)?; + + install_received_key( + db, + workspace_id, + my_peer_id, + my_peer_id, + next, + sealed_read, + Some(sealed_write), + ) + .await?; + Ok(next) +} + fn to_key(bytes: Vec, what: &'static str) -> AppResult<[u8; KEY_LEN]> { bytes.try_into().map_err(|_| AppError::Crypto { context: "workspace-keys", @@ -489,4 +527,67 @@ mod tests { "device without the key must not decrypt" ); } + + /// Revocation cut-off: after the owner rotates the workspace key, a device + /// that only holds the old version can no longer decrypt content broadcast + /// under the new version — but old content (old key_version) stays readable + /// (lazy, no forward secrecy). + #[tokio::test] + async fn rotation_cuts_off_old_version_holder() { + use crate::workspace::sync::{ + decode_encrypted_gossip, encode_encrypted_gossip, MSG_TYPE_DOC, + }; + + let db_a = mem_db().await; + let ws = Uuid::now_v7(); + insert_workspace(&db_a, ws).await; + let (peer_a, sec_a, pub_a) = real_device(); + let (peer_b, sec_b, pub_b) = real_device(); + + // A owns v1; B receives v1 only. + let a_keys = initialize_workspace_keys(&db_a, ws, &peer_a, &sec_a, &pub_a) + .await + .unwrap(); + let b_pub = peer_id_to_x25519_public(&PeerId::from_str(&peer_b).unwrap()).unwrap(); + let (v, sr, sw) = seal_keys_for_recipient(&sec_a, &b_pub, &a_keys, true).unwrap(); + let db_b = mem_db().await; + insert_workspace(&db_b, ws).await; + install_received_key(&db_b, ws, &peer_b, &peer_a, v, sr, sw) + .await + .unwrap(); + let b_keys = load_workspace_keys(&db_b, ws, &peer_b, &sec_b, &pub_b) + .await + .unwrap(); + + // A rotates to v2 (e.g. after removing another member). + let new_v = rotate_workspace_key(&db_a, ws, &peer_a, &sec_a, &pub_a) + .await + .unwrap(); + assert_eq!(new_v, 2); + let a_keys = load_workspace_keys(&db_a, ws, &peer_a, &sec_a, &pub_a) + .await + .unwrap(); + assert_eq!(a_keys.current_version(), Some(2)); + assert!(a_keys.read_key(1).is_some(), "old version retained (lazy)"); + + // A broadcasts under the current (v2) key — B (v1 only) can't decrypt it. + let doc = Uuid::now_v7(); + let wire = + encode_encrypted_gossip(&a_keys, &ws, &doc, MSG_TYPE_DOC, b"v2-content").unwrap(); + assert!( + decode_encrypted_gossip(&b_keys, &ws, MSG_TYPE_DOC, &wire).is_err(), + "v1-only holder cannot decrypt content rotated to v2" + ); + + // Old v1 content stays decryptable for a v1 holder (lazy boundary). + let v1_only = WorkspaceKeys::test_single( + 1, + *a_keys.read_key(1).unwrap(), + a_keys.write_key(1).copied(), + ); + let old_wire = + encode_encrypted_gossip(&v1_only, &ws, &doc, MSG_TYPE_DOC, b"v1-old").unwrap(); + let (_d, got) = decode_encrypted_gossip(&b_keys, &ws, MSG_TYPE_DOC, &old_wire).unwrap(); + assert_eq!(got, b"v1-old", "old-version content stays readable"); + } } diff --git a/crates/core/src/workspace/sharing.rs b/crates/core/src/workspace/sharing.rs index 45879de..e94b1fe 100644 --- a/crates/core/src/workspace/sharing.rs +++ b/crates/core/src/workspace/sharing.rs @@ -97,15 +97,37 @@ pub async fn grant_collaborator( .await } -/// Revoke a member's access. Lazy: the device keeps any key it already holds -/// (so it can still read content it synced before), but its next key request -/// is denied and it receives no rotated key. True cut-off needs v2 key rotation. +/// Revoke a member's access, then rotate the workspace key so the removed +/// device cannot decrypt **future** content. Lazy: the device keeps any key it +/// already holds, so content it synced **before** removal stays readable (no +/// forward secrecy — see threat model); but new broadcasts use a new +/// `key_version` it never receives a Lockbox for, and its key requests are +/// denied. Remaining members re-fetch the rotated key on the permission-op +/// broadcast (see `coordinator::handle_ctrl_message`). pub async fn revoke_member( core: &Arc, ws_id: Uuid, target_peer_id: &str, ) -> AppResult<()> { - issue_owner_op(core, ws_id, OpKind::Revoke, target_peer_id, None).await + issue_owner_op(core, ws_id, OpKind::Revoke, target_peer_id, None).await?; + rotate_after_revoke(core, ws_id).await +} + +/// Generate a fresh workspace `key_version` and reload it into the live +/// `WorkspaceCore` so subsequent broadcasts use it. Only the owner reaches here +/// (revoke is owner-gated in `issue_owner_op`). +async fn rotate_after_revoke(core: &Arc, ws_id: Uuid) -> AppResult<()> { + let ws = core + .get_workspace(&ws_id) + .await + .ok_or(AppError::NoWorkspaceOpen)?; + let identity = core.identity(); + let me = identity.peer_id()?; + let secret = identity.x25519_secret()?; + let public = identity.x25519_public()?; + super::keys::rotate_workspace_key(ws.db(), ws_id, &me, &secret, &public).await?; + ws.reload_keys().await?; + Ok(()) } /// List the workspace's current members (materialized roles joined with device diff --git a/crates/core/src/workspace/sync/coordinator.rs b/crates/core/src/workspace/sync/coordinator.rs index 9802c27..6ff1f7d 100644 --- a/crates/core/src/workspace/sync/coordinator.rs +++ b/crates/core/src/workspace/sync/coordinator.rs @@ -87,6 +87,51 @@ impl AppSyncCoordinator { warn!("Failed to save permission op for {workspace_uuid}: {e}"); } } + + // React to a removal carried by this update. + let has_revoke = ops + .iter() + .any(|o| o.op_kind == crate::workspace::permissions::OpKind::Revoke); + if has_revoke { + if let Ok(me) = self.core.identity().peer_id() { + match crate::workspace::permissions::role_of(ws.db(), workspace_uuid, &me) + .await + { + // We were the one removed: stop subscribing + notify. + Ok(None) if !ws.keys().await.is_empty() => { + info!("Revoked from workspace {workspace_uuid}; unsubscribing"); + if let Some(ws_sync) = ws.sync().await { + ws_sync.unsubscribe().await; + } + self.core.event_bus().emit( + crate::events::AppEvent::MemberRevoked { + workspace_id: workspace_uuid, + }, + ); + } + // Still a member: the owner likely rotated the key on + // removal — re-fetch the current key from the + // broadcaster so post-rotation gossip stays decryptable. + Ok(Some(_)) => { + let core = Arc::clone(&self.core); + let client = self.client.clone(); + tokio::spawn(async move { + if let Some(ws) = core.get_workspace(&workspace_uuid).await { + full_sync::fetch_workspace_key( + &core, + &client, + source, + &ws, + workspace_uuid, + ) + .await; + } + }); + } + _ => {} + } + } + } } } } @@ -164,6 +209,9 @@ impl AppSyncCoordinator { match request { SyncRequest::DocList { workspace_uuid } => { info!("Inbound DocList request from {peer_id} for workspace {workspace_uuid}"); + // 懒打开:对端拉取本设备未在窗口打开的工作区时,先 headless 打开它 + // 以便鉴权 + 服务(sync-only,不订阅 gossip)。后续 doc 级请求复用。 + self.core.ensure_open_for_sync(workspace_uuid).await; if !self.is_authorized(workspace_uuid, peer_id).await { warn!("Declining DocList to unauthorized peer {peer_id} for {workspace_uuid}"); let resp = AppResponse::Sync(SyncResponse::DocList { docs: vec![] }); @@ -276,6 +324,8 @@ impl AppSyncCoordinator { } SyncRequest::WorkspaceKey { workspace_uuid } => { info!("Inbound WorkspaceKey request from {peer_id} for workspace {workspace_uuid}"); + // 懒打开同上:未打开的工作区也能响应密钥请求。 + self.core.ensure_open_for_sync(workspace_uuid).await; let (sealed, ops) = self .build_workspace_key_response(peer_id, workspace_uuid) .await; @@ -369,6 +419,17 @@ impl AppSyncCoordinator { let Some(ws) = self.core.get_workspace(&workspace_uuid).await else { return; }; + // Drop broadcasts from peers who are no longer authorized members — e.g. + // a revoked device still publishing under an old key_version we retain in + // history. GossipSub can't filter per-subscriber, so we gate on the + // source's current role here (the cryptographic cut-off is key rotation; + // this is the online-write cut-off). + if let Some(src) = source { + if !self.is_authorized(workspace_uuid, src).await { + tracing::debug!("dropping ws gossip from unauthorized {src} for {workspace_uuid}"); + return; + } + } let keys = ws.keys().await; let (doc_uuid, update) = match super::decode_encrypted_gossip( &keys, @@ -391,10 +452,22 @@ impl AppSyncCoordinator { /// Handle an incoming **encrypted** awareness GossipSub message: decrypt, /// then pure fan-out to the event bus (no persistence/apply/buffer). - pub async fn handle_ws_awareness_gossip(&self, workspace_uuid: Uuid, data: Vec) { + pub async fn handle_ws_awareness_gossip( + &self, + source: Option, + workspace_uuid: Uuid, + data: Vec, + ) { let Some(ws) = self.core.get_workspace(&workspace_uuid).await else { return; }; + // Same source-authorization gate as doc-update gossip: don't surface a + // revoked peer's cursor/presence. + if let Some(src) = source { + if !self.is_authorized(workspace_uuid, src).await { + return; + } + } let keys = ws.keys().await; let (doc_uuid, update) = match super::decode_encrypted_gossip( &keys, diff --git a/crates/core/src/workspace/sync/full_sync.rs b/crates/core/src/workspace/sync/full_sync.rs index 20e9eba..31d22e1 100644 --- a/crates/core/src/workspace/sync/full_sync.rs +++ b/crates/core/src/workspace/sync/full_sync.rs @@ -13,6 +13,7 @@ use crate::error::{AppError, AppResult}; use crate::events::AppEvent; use crate::network::AppNetClient; use crate::protocol::{AppRequest, AppResponse, DocMeta, SyncRequest, SyncResponse}; +use crate::workspace::WorkspaceCore; use super::{asset_sync, doc_sync}; @@ -137,7 +138,21 @@ async fn ensure_workspace_key( if !ws.keys().await.is_empty() { return; } + fetch_workspace_key(core, client, peer_id, &ws, workspace_uuid).await; +} +/// Request the **current** workspace key from `peer_id` over Noise RR, persist +/// the received permission chain, pin the authoritative owner, and install + +/// reload the key. Unlike [`ensure_workspace_key`], this does NOT early-return +/// when keys already exist — it is used to pick up a **rotated** key after a +/// membership change (the broadcaster re-seals its current version to us). +pub(crate) async fn fetch_workspace_key( + core: &Arc, + client: &AppNetClient, + peer_id: PeerId, + ws: &Arc, + workspace_uuid: Uuid, +) { let request = AppRequest::Sync(SyncRequest::WorkspaceKey { workspace_uuid }); let response = match tokio::time::timeout( Duration::from_secs(5), diff --git a/dev-notes/blog/e2e-encrypted-sharing.md b/dev-notes/blog/e2e-encrypted-sharing.md index cc057d1..6e45f00 100644 --- a/dev-notes/blog/e2e-encrypted-sharing.md +++ b/dev-notes/blog/e2e-encrypted-sharing.md @@ -21,7 +21,8 @@ 7. [拼起来:一次完整的"分享工作区"之旅](#7-拼起来一次完整的分享工作区之旅) 8. [一个差点上线的严重漏洞:伪造创世](#8-一个差点上线的严重漏洞伪造创世) 9. [我们诚实地"不防"什么](#9-我们诚实地不防什么) -10. [小结与关键文件](#10-小结与关键文件) +10. [v1.1 续集:把分享用顺手](#10-v11-续集把分享用顺手) +11. [小结与关键文件](#11-小结与关键文件) --- @@ -229,14 +230,14 @@ flowchart TD ```mermaid sequenceDiagram participant A as 设备 A(发送方) - participant Box as Lockbox(网络/数据库) + participant LB as Lockbox(网络/数据库) participant B as 设备 B(接收方) Note over A: seal_lockbox A->>A: 1. ECDH(A私钥, B公钥) → 共享秘密 A->>A: 2. HKDF(共享, kek) → KEK;HKDF(共享, commit) → 承诺 A->>A: 3. XChaCha20-Poly1305 用 KEK 加密钥匙 - A->>Box: 4. [version|nonce|承诺|密文] - Box->>B: (随同步/入库送达) + A->>LB: 4. version,nonce,承诺,密文 + LB->>B: (随同步/入库送达) Note over B: open_lockbox B->>B: 1. ECDH(B私钥, A公钥) → 同一个共享秘密 B->>B: 2. 先比对承诺:不对就拒绝 @@ -367,7 +368,7 @@ SwarmNote 的答案:**把创世绑定到工作区的权威创建者**。工作 ```mermaid flowchart TD - LOAD[从数据库读出所有 op] --> V{每条:签名有效吗?} + LOAD[从数据库读出所有 op] --> V{"每条:签名有效吗?"} V -->|否| X1[🗑️ 丢弃] V -->|是| ORDER[按 prev_hash 链 + op_id 排序] ORDER --> LOOP[逐条重放] @@ -477,7 +478,7 @@ flowchart TD | --- | --- | | **丢设备 / 本地磁盘取证** | 本地 `.md` 是明文(folder-is-truth 的代价),交给操作系统全盘加密 | | **前向保密(FS)** | CRDT 必须重放全历史 → 必须保留所有历史密钥,应用层 FS 名存实亡 | -| **"踢人即焚"** | 撤销是**惰性**的:被移除的设备对它**离开前**能读到的内容**永久可读**(它本地已有那把钥匙)。真正切断 = 将来轮换密钥让新内容对它失效 | +| **"踢人即焚"(旧内容收不回)** | v1.1 撤销已做**两段切断**(见第 10 节):① 在线即时丢弃被移除设备的新广播;② **轮换密钥**让新内容对它失效。但它对**离开前**已同步的旧内容(旧密钥版本)**永久可读**、无法远程删除——这是物理边界 | | **恶意的已授权设备** | v1 "有钥匙即可写",逐条更新签名带了但还没强制校验 | 还有一个**诚实记录的边界**,来自第 7 节第③步的"认 Owner"机制: @@ -488,7 +489,74 @@ flowchart TD --- -## 10. 小结与关键文件 +## 10. v1.1 续集:把分享用顺手 + +v0.5.0 搭好了加密内核;真机双设备一测,又发现三个"骨架还差点意思"的地方。v1.1 补齐了它们——都建立在前面那四块拼图之上,不需要新概念,只是把流程走顺。 + +### ① 邀请要"敲门",不是直接塞进来 + +v0.5.0 里 owner 一点"分享",对方设备**立刻**就被授权了——对方甚至不知道。这在 E2E、无账号的场景里不太对:等于你能把一台设备**静默**塞进自己的工作区。 + +v1.1 改成**邀请→接受**握手(和"配对"用的是同一套机制): + +```mermaid +sequenceDiagram + participant O as Owner + participant B as 被邀请设备 + O->>B: 发邀请(ShareInvitation),阻塞等回应 + Note over B: 弹窗:谁邀请你、哪个工作区、接受/拒绝 + alt 接受 + B->>O: 接受 + Note over O: 这时才签发授权 op、广播权限链 + else 拒绝 / 超时(90s) + B->>O: 拒绝 + Note over O: 不授权 + end +``` + +关键:**对方没接受,owner 就不签授权 op**——对方不是成员、拿不到钥匙(对齐 Matrix「join 才拿 key」、MLS「处理 Welcome 才入组」)。嫌每次都点麻烦?设置里有个开关「自动接受协作邀请」(默认关),对**已配对设备**免确认——因为配对本身已经是一次互信了。 + +### ② 撤销要真的"踢得动" + +第 9 节说过:v0.5.0 的撤销是"惰性"的——其实当时连**在线**都没踢干净。真机一测就发现:**移除协作者后,对方还在收你的实时编辑**。原因有两个: + +1. 没换钥匙——对方手里那把 `read_key` 还能解。 +2. GossipSub 是"广场喊话",没法把某个订阅者单独踢出去。 + +v1.1 上了**两道互补的切断**: + +```mermaid +flowchart LR + R[移除 B] --> C1["① 在线切断:诚实成员解密前先查
「B 还是成员吗」,不是就丢掉 B 的广播"] + R --> C2["② 密钥轮换:换一把新钥匙(key_version+1)
只发给剩下的人,B 拿不到 → 解不开新内容"] + C2 -.旧内容仍可读.-> Lazy["lazy:B 离开前同步过的旧内容
用旧钥匙仍能解(收不回,物理边界)"] +``` + +- **在线切断**让 B 的实时写入**立刻**不被采纳(但这不是密码学意义的切断——B 仍持旧钥匙,能离线解它**已经抓到**的旧密文)。 +- **密钥轮换**才是真切断:换新钥匙后,你发的新内容用新版本加密,B 没有新 Lockbox → **解不开新内容**。剩下的成员会在收到"权限变了"的广播时,自动去要新钥匙。 +- 但**旧内容收不回**:B 离开前已经同步到本地的东西,用旧钥匙永远能读——这是任何 E2E 系统都逃不掉的物理边界(Signal/MLS/Matrix/Tresorit 都一样)。所以撤销对话框诚实写着"已同步的内容仍保留在其本地"。 + +### ③ 工作区不用"开着"也能被另一台设备拉 + +v0.5.0 有个隐藏限制:只有**当前打开着**(有窗口)的工作区才能被另一台设备发现/同步。因为同步能力是挂在"活着的工作区运行时"上的,关掉窗口它就被回收了。 + +v1.1 让它**懒打开**:你的工作区即使没开窗口,当**授权的对端真的来拉取**时,本机会按需把它以"只读服务(sync-only,不订阅实时广播)"的方式临时打开来响应——用完留在后台一会儿,省得反复开。 + +```mermaid +flowchart LR + B["另一台设备:我要看/拉 工作区 W"] --> A["你的设备:W 没开窗口"] + A --> Lazy["ensure_open_for_sync(W)
按需 sync-only 打开"] + Lazy --> Serve["响应发现 + 拉取(仍按角色过滤)"] + Serve --> B +``` + +仍然**按角色过滤**:没被授权的对端既看不到工作区名、也拉不到内容——不放宽既有的权限闸门。 + +> 一句话:v1.1 没有改加密原理,只是把"加入(邀请同意)、离开(真撤销)、随时可同步(懒打开)"这三件事补成了完整的成员生命周期。 + +--- + +## 11. 小结与关键文件 把四块拼图连起来回顾一遍: diff --git a/dev-notes/design/05-sharing.md b/dev-notes/design/05-sharing.md index 5aac00c..09bf09c 100644 --- a/dev-notes/design/05-sharing.md +++ b/dev-notes/design/05-sharing.md @@ -3,6 +3,15 @@ > **2026-06 调研定稿**。两条路径:① 已配对设备/人之间用 **X25519 Lockbox** 直传密钥;② 给陌生人用 **Mega 式链接分享**(URL fragment 内嵌密钥 + DHT 签名邀请包)。 > 加密底层见 [08-e2e-encryption.md](08-e2e-encryption.md),权限见 [04-permissions.md](04-permissions.md),边界见 [11-threat-model.md](11-threat-model.md)。 +## v1.1 实现增量(2026-06,分支 feature/sharing-v1.1,OpenSpec `sharing-v1-1`) + +v0.5.0 落地了路径一的密钥分发与权限内核;v1.1 把分享生命周期补全(均已编译 + core 106 tests 绿): + +- **邀请→接受握手**:路径一不再是「owner 单方推 grant」,而是 owner 发 `WorkspaceRequest::ShareInvitation`(`AppCore::invite_device`,阻塞等响应)→ 对端经通知队列弹 `ShareInvitationDialog` → **接受后 owner 才 `grant_collaborator`**(未接受不授权)。复用配对的 request-response pending 关联机制。可在设置「网络」tab 开启「自动接受协作邀请」(默认关)对已配对设备免确认。 +- **撤销 = 在线切断 + 密钥轮换**(兑现本文档原「撤销链接 = 轮换 workspace key」的承诺,即 e2e-sharing-v1 延后的 5.4):`revoke_member` 发完 `Revoke` op 后调 `keys::rotate_workspace_key`(bump `key_version` + 新 self-Lockbox);剩余成员收到 `PermissionOpsUpdate` 时反应式重取新 key(`coordinator` → `fetch_workspace_key`),被移除设备解不开新内容;同时 gossip 入站按 `is_authorized(source)` 预检即时丢弃被移除设备的广播。lazy:旧内容仍可读(见威胁模型)。被移除设备收 `MemberRevoked` 事件自动退订 + 提示。 +- **未打开工作区可同步(headless 懒打开)**:`AppCore::ensure_open_for_sync` 在被授权对端 `ListWorkspaces`/`DocList`/`WorkspaceKey` 时按需以 sync-only(不订阅 gossip)打开本机未在窗口打开的工作区,使其可被发现/拉取(仍 `role_of` 过滤)。 +- **未做(延后)**:接受邀请后 auto-join(需定落盘位置)、被分享方持久「分享给我」清单、链接分享(路径二)、双机实测、i18n。 + ## 路径一:配对设备/人分享(Lockbox) 前提:双方已完成现有配对流程(`PairingManager` 已交换 PeerId/Ed25519 公钥并建立信任)。 diff --git a/dev-notes/design/11-threat-model.md b/dev-notes/design/11-threat-model.md index 1f8daed..1e4b78a 100644 --- a/dev-notes/design/11-threat-model.md +++ b/dev-notes/design/11-threat-model.md @@ -23,7 +23,7 @@ 1. **丢设备 / 本地磁盘取证**:授权设备本地写明文 `.md`(folder-is-truth 的代价),交给 OS 全盘加密。v1 不做本地静态加密、不上 StrongBox/锁屏门控。 2. **前向保密(FS)/ 后泄露安全(PCS)**:CRDT 必须重放全历史 → 必须保留全部历史 key,应用层 FS 名存实亡(Keyhive 已论证)。不做 ratchet。 -3. **"踢人即焚"**:撤销是 lazy 的——被移除设备/已存链接者对其能访问过的旧内容**永久可读**。真正切断 = 轮换 key 让新内容对其失效。 +3. **"踢人即焚"(v1.1 已做两段式切断,但旧内容仍永久可读)**:移除成员现在会 **(a) 在线即时切断**——诚实成员对被移除 source 的 gossip 解密前用 `is_authorized` 预检直接丢弃(`coordinator::handle_ws_gossip_update`),使其在线实时写入即刻失效;**(b) 密钥轮换**——`revoke_member` 发完 `Revoke` op 后调 `keys::rotate_workspace_key`(bump `key_version` + 新 self-Lockbox),剩余成员收到 `PermissionOpsUpdate` 时反应式重取新版本(`coordinator` → `fetch_workspace_key`),被移除设备拿不到新 Lockbox → 解不开**新内容**。但**撤销仍是 lazy 的**:被移除设备对其离开**前**已同步的旧内容(旧 `key_version`)**永久可读**、无法远程删除/收回(全行业物理边界)。在线切断不是密码学切断(对方仍持旧 key、能离线解已抓的旧密文)。离线成员在轮换后上线、拿到新 key 前用旧 key 解密合法(身份未变)。链接邀请的撤销同理靠轮换。 4. **恶意已授权设备**:v1「有 key 即可写」,逐 update 签名携带但不强制校验。拿到 write_key 的设备可注入伪造/损坏 update(CRDT 仍会合并)。真正的写权限强制(丢弃越权 update)= v2 Reader 时一并打开。 5. **元数据完全隐匿**:已配对设备能看到对方该工作区的**完整路径树**(rel_path/title 经 Noise RR 传,对端可见)。文件名加密是 v2+ 的元数据最小化。 @@ -41,6 +41,12 @@ - **owner 设备**:`created_by` = 自己,创建时即正确。 - **joiner 设备**:本地行创建时 `created_by` = 自己;首次同步(`ensure_workspace_key`)收到经 Noise 认证的对端返回的链后,用链中唯一 genesis 的 issuer 把 `created_by` 钉成真 owner(**trust-on-first-use**)。 +## 分享的知情同意(v1.1) + +- 分享改为**邀请→接受双向握手**:owner 发 `ShareInvitation`、对端弹窗,**未接受不签发 grant op**(对端不被授权、拿不到 key)。这避免设备被静默塞进别人工作区(对齐 Matrix/MLS「处理才入组」)。拒绝/超时(90s)= 不授权。 +- 用户可在设置里开启「自动接受协作邀请」(默认关):开启后**对已配对设备的邀请自动接受**——这是用户主动选择把"已配对"提升为"信任邀请",等价于放弃这一层确认;仅对已配对设备生效(配对本身已是一次互信)。 +- **未打开工作区的可发现性(headless 懒打开)**:成员工作区即使没在窗口打开,也会在被授权对端 `ListWorkspaces` 时按需 sync-only 打开并广播——**仍按 `role_of` 过滤**,未授权对端既看不到名字也拉不到内容,不放宽既有 gating。 + ## 残留风险(接受并记录) - DHT 邀请**存在性**无法完全隐藏(存储节点知道"有这么一条记录");已用 HMAC key + 签名 + custom validator 缓解枚举与投毒。 diff --git a/src-tauri/src/commands/share.rs b/src-tauri/src/commands/share.rs index 3c84f09..dc0929b 100644 --- a/src-tauri/src/commands/share.rs +++ b/src-tauri/src/commands/share.rs @@ -49,3 +49,31 @@ pub async fn revoke_workspace_member( ) -> AppResult<()> { sharing::revoke_member(core.inner(), parse_uuid(&workspace_uuid)?, &target_peer_id).await } + +/// 邀请已配对设备协作此工作区(owner 发起)。**阻塞等待对方接受/拒绝**, +/// 返回是否被接受;接受后内部已签发授权 op。 +#[tauri::command] +#[specta::specta] +pub async fn invite_to_workspace( + workspace_uuid: String, + target_peer_id: String, + core: State<'_, Arc>, +) -> AppResult { + core.inner() + .invite_device(parse_uuid(&workspace_uuid)?, &target_peer_id) + .await +} + +/// 被邀请方应答一条分享邀请(接受/拒绝)。`pending_id` 来自 +/// `ShareInvitationReceived` 事件。 +#[tauri::command] +#[specta::specta] +pub async fn respond_share_invitation( + pending_id: u64, + accept: bool, + core: State<'_, Arc>, +) -> AppResult<()> { + core.inner() + .respond_share_invitation(pending_id, accept) + .await +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index f642b91..414ecf5 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -77,6 +77,28 @@ pub struct PairedDeviceRemoved { pub peer_id: String, } +// === 分享 / 成员 === + +/// 收到一条工作区协作邀请,等待用户接受/拒绝。前端弹窗,用户决定后调 +/// `respond_share_invitation(pending_id, accept)`。 +#[derive(Debug, Clone, Serialize, specta::Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct ShareInvitationReceived { + pub pending_id: u64, + pub peer_id: String, + pub workspace_uuid: Uuid, + pub workspace_name: String, + pub expires_at: chrono::DateTime, +} + +/// 本设备被移出某共享工作区(owner 撤销了其权限)。前端 SHOULD 提示用户; +/// 已同步到本地的内容仍可读。 +#[derive(Debug, Clone, Serialize, specta::Type, tauri_specta::Event)] +#[serde(rename_all = "camelCase")] +pub struct MemberRevoked { + pub workspace_id: Uuid, +} + // === 网络 / P2P 节点 === #[derive(Debug, Clone, Serialize, specta::Type, tauri_specta::Event)] diff --git a/src-tauri/src/platform/event_bus.rs b/src-tauri/src/platform/event_bus.rs index 7de8649..4e58e7d 100644 --- a/src-tauri/src/platform/event_bus.rs +++ b/src-tauri/src/platform/event_bus.rs @@ -11,9 +11,9 @@ use swarmnote_core::{AppEvent, EventBus}; use crate::events::{ DevicesChanged, DocFlushed, ExternalAwarenessUpdate, ExternalConflict, ExternalUpdate, - FileTreeChanged, NetworkStatusChanged, NodeStarted, NodeStopped, PairedDeviceAdded, - PairedDeviceRemoved, PairingRequestReceived, SyncCompleted, SyncProgress, SyncResult, - SyncStarted, + FileTreeChanged, MemberRevoked, NetworkStatusChanged, NodeStarted, NodeStopped, + PairedDeviceAdded, PairedDeviceRemoved, PairingRequestReceived, ShareInvitationReceived, + SyncCompleted, SyncProgress, SyncResult, SyncStarted, }; /// `EventBus` implementation backed by Tauri's `AppHandle::emit`. Broadcasts @@ -90,6 +90,27 @@ impl EventBus for TauriEventBus { let _ = PairedDeviceRemoved { peer_id }.emit(&self.app); } + // ── Sharing / membership ── + AppEvent::ShareInvitationReceived { + pending_id, + peer_id, + workspace_uuid, + workspace_name, + expires_at, + } => { + let _ = ShareInvitationReceived { + pending_id, + peer_id, + workspace_uuid, + workspace_name, + expires_at, + } + .emit(&self.app); + } + AppEvent::MemberRevoked { workspace_id } => { + let _ = MemberRevoked { workspace_id }.emit(&self.app); + } + // ── Network ── AppEvent::NetworkStatusChanged { nat_status, diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs index 4bb1c94..46c788c 100644 --- a/src-tauri/src/setup.rs +++ b/src-tauri/src/setup.rs @@ -83,6 +83,8 @@ pub fn specta_builder() -> SpectaBuilder { commands::share::share_workspace_to_device, commands::share::list_workspace_members, commands::share::revoke_workspace_member, + commands::share::invite_to_workspace, + commands::share::respond_share_invitation, ]) .events(collect_events![ // YDoc / 文档 @@ -98,6 +100,9 @@ pub fn specta_builder() -> SpectaBuilder { events::PairingRequestReceived, events::PairedDeviceAdded, events::PairedDeviceRemoved, + // 分享 / 成员 + events::ShareInvitationReceived, + events::MemberRevoked, // 网络 events::NetworkStatusChanged, events::NodeStarted, diff --git a/src/components/pairing/GlobalActionDialogs.tsx b/src/components/pairing/GlobalActionDialogs.tsx index 2f5632a..9cb8233 100644 --- a/src/components/pairing/GlobalActionDialogs.tsx +++ b/src/components/pairing/GlobalActionDialogs.tsx @@ -1,10 +1,13 @@ +import { useLingui } from "@lingui/react/macro"; import { useCallback, useState } from "react"; import { toast } from "sonner"; +import { ShareInvitationDialog } from "@/components/share/ShareInvitationDialog"; import { commands } from "@/lib/bindings"; import { useNotificationStore } from "@/stores/notificationStore"; import { PairingRequestDialog } from "./PairingRequestDialog"; export function GlobalActionDialogs() { + const { t } = useLingui(); const current = useNotificationStore((s) => s.current); const [responding, setResponding] = useState(false); @@ -18,11 +21,23 @@ export function GlobalActionDialogs() { (pendingId: number, accept: boolean) => { setResponding(true); commands.respondPairingRequest(pendingId, accept).catch(() => { - toast.error(accept ? "接受配对失败" : "拒绝配对失败"); + toast.error(accept ? t`接受配对失败` : t`拒绝配对失败`); }); dismiss(); }, - [dismiss], + [dismiss, t], + ); + + // 应答分享邀请:接受会让邀请方(阻塞中的 invite)解阻塞并签发授权。 + const handleShareInvitationRespond = useCallback( + (pendingId: number, accept: boolean) => { + setResponding(true); + commands.respondShareInvitation(pendingId, accept).catch(() => { + toast.error(accept ? t`接受邀请失败` : t`拒绝邀请失败`); + }); + dismiss(); + }, + [dismiss, t], ); if (!current) return null; @@ -40,5 +55,18 @@ export function GlobalActionDialogs() { ); } + if (current.type === "share-invitation") { + const data = current.payload; + return ( + handleShareInvitationRespond(data.pendingId, true)} + onReject={() => handleShareInvitationRespond(data.pendingId, false)} + onClose={dismiss} + /> + ); + } + return null; } diff --git a/src/components/pairing/PairingRequestDialog.tsx b/src/components/pairing/PairingRequestDialog.tsx index 1015962..f3364a7 100644 --- a/src/components/pairing/PairingRequestDialog.tsx +++ b/src/components/pairing/PairingRequestDialog.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -8,6 +7,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useCountdown } from "@/hooks/useCountdown"; import type { PairingRequestReceived } from "@/lib/bindings"; import { DeviceInfoCard } from "./DeviceInfoCard"; @@ -26,23 +26,8 @@ export function PairingRequestDialog({ onReject, onClose, }: PairingRequestDialogProps) { - const [remaining, setRemaining] = useState(() => - Math.max(0, Math.ceil((new Date(data.expiresAt).getTime() - Date.now()) / 1000)), - ); - - // Countdown timer — auto-reject on expiry - useEffect(() => { - const interval = setInterval(() => { - const left = Math.max(0, Math.ceil((new Date(data.expiresAt).getTime() - Date.now()) / 1000)); - setRemaining(left); - if (left <= 0) { - clearInterval(interval); - onReject(); - } - }, 1000); - - return () => clearInterval(interval); - }, [data.expiresAt, onReject]); + // 倒计时——到期自动拒绝。 + const remaining = useCountdown(data.expiresAt, onReject); return ( !open && onClose()}> diff --git a/src/components/share/ShareDialog.tsx b/src/components/share/ShareDialog.tsx index e250237..5cdbfaf 100644 --- a/src/components/share/ShareDialog.tsx +++ b/src/components/share/ShareDialog.tsx @@ -51,11 +51,18 @@ export function ShareDialog({ open, onOpenChange, workspaceId, workspaceName }: const memberPeers = useMemo(() => new Set(members.map((m) => m.peerId)), [members]); const addable = pairedDevices.filter((d) => !memberPeers.has(d.peerId)); + // 发邀请(而非立即授权):invite_to_workspace 会阻塞等待对方接受/拒绝, + // 仅当对方接受时后端才签发授权 op。 async function handleShare(peerId: string, name: string) { + toast.info(t`已向 ${name} 发送邀请,等待对方接受…`); await run(async () => { - await commands.shareWorkspaceToDevice(workspaceId, peerId); - toast.success(t`已分享给 ${name}`); - await refreshMembers(); + const accepted = await commands.inviteToWorkspace(workspaceId, peerId); + if (accepted) { + toast.success(t`${name} 已接受邀请`); + await refreshMembers(); + } else { + toast.info(t`${name} 拒绝了邀请`); + } }); } diff --git a/src/components/share/ShareInvitationDialog.tsx b/src/components/share/ShareInvitationDialog.tsx new file mode 100644 index 0000000..a77547a --- /dev/null +++ b/src/components/share/ShareInvitationDialog.tsx @@ -0,0 +1,76 @@ +import { Trans } from "@lingui/react/macro"; +import { Share2 } from "lucide-react"; +import { useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useCountdown } from "@/hooks/useCountdown"; +import type { ShareInvitationReceived } from "@/lib/bindings"; +import { useNetworkStore } from "@/stores/networkStore"; + +interface ShareInvitationDialogProps { + data: ShareInvitationReceived; + responding: boolean; + onAccept: () => void; + onReject: () => void; + onClose: () => void; +} + +/** 收到工作区协作邀请的弹窗(镜像 PairingRequestDialog)。未接受前对端不授权; + * 接受后对端才签发 grant op,本设备可在「同步」里拉取该工作区。 */ +export function ShareInvitationDialog({ + data, + responding, + onAccept, + onReject, + onClose, +}: ShareInvitationDialogProps) { + // 倒计时——到期自动拒绝。 + const remaining = useCountdown(data.expiresAt, onReject); + + // 邀请方设备名:从已知设备列表解析,解析不到则用截断的 peerId 兜底。 + const devices = useNetworkStore((s) => s.devices); + const inviterName = useMemo(() => { + const d = devices.find((dev) => dev.peerId === data.peerId); + return d?.name ?? data.peerId.slice(0, 12); + }, [devices, data.peerId]); + + return ( + !open && onClose()}> + + + + + 工作区协作邀请 + + + + + + {inviterName} 邀请你协作工作区「{data.workspaceName}」。接受后你将作为协作者加入, + 可在「同步」里拉取其内容。 + + + +
+ 剩余时间:{remaining} 秒 +
+ + + + + +
+
+ ); +} diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..437ba4c --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * 倒计时 hook:返回距 `expiresAt`(ISO 时间串)的剩余整秒数,每秒刷新; + * 归零时调用 `onExpired` 一次。 + * + * `onExpired` 用 ref 固化,所以即使调用方每次 render 传新的内联回调,interval + * 也只在 `expiresAt` 变化时重建(而非每 render 重建)。 + */ +export function useCountdown(expiresAt: string, onExpired: () => void): number { + const [remaining, setRemaining] = useState(() => + Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1000)), + ); + + const onExpiredRef = useRef(onExpired); + onExpiredRef.current = onExpired; + + useEffect(() => { + const interval = setInterval(() => { + const left = Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1000)); + setRemaining(left); + if (left <= 0) { + clearInterval(interval); + onExpiredRef.current(); + } + }, 1000); + return () => clearInterval(interval); + }, [expiresAt]); + + return remaining; +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 5966ebf..7447966 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -102,6 +102,16 @@ export const commands = { listWorkspaceMembers: (workspaceUuid: string) => __TAURI_INVOKE("list_workspace_members", { workspaceUuid }), /** Revoke a member's access (owner only). */ revokeWorkspaceMember: (workspaceUuid: string, targetPeerId: string) => __TAURI_INVOKE("revoke_workspace_member", { workspaceUuid, targetPeerId }), + /** + * 邀请已配对设备协作此工作区(owner 发起)。**阻塞等待对方接受/拒绝**, + * 返回是否被接受;接受后内部已签发授权 op。 + */ + inviteToWorkspace: (workspaceUuid: string, targetPeerId: string) => __TAURI_INVOKE("invite_to_workspace", { workspaceUuid, targetPeerId }), + /** + * 被邀请方应答一条分享邀请(接受/拒绝)。`pending_id` 来自 + * `ShareInvitationReceived` 事件。 + */ + respondShareInvitation: (pendingId: number, accept: boolean) => __TAURI_INVOKE("respond_share_invitation", { pendingId, accept }), }; /** Events */ @@ -112,6 +122,7 @@ export const events = { externalConflict: makeEvent("external-conflict"), externalUpdate: makeEvent("external-update"), fileTreeChanged: makeEvent("file-tree-changed"), + memberRevoked: makeEvent("member-revoked"), navigate: makeEvent("navigate"), networkStatusChanged: makeEvent("network-status-changed"), nodeStarted: makeEvent("node-started"), @@ -119,6 +130,7 @@ export const events = { pairedDeviceAdded: makeEvent("paired-device-added"), pairedDeviceRemoved: makeEvent("paired-device-removed"), pairingRequestReceived: makeEvent("pairing-request-received"), + shareInvitationReceived: makeEvent("share-invitation-received"), syncCompleted: makeEvent("sync-completed"), syncProgress: makeEvent("sync-progress"), syncStarted: makeEvent("sync-started"), @@ -293,6 +305,14 @@ export type MemberInfo = { isSelf: boolean, }; +/** + * 本设备被移出某共享工作区(owner 撤销了其权限)。前端 SHOULD 提示用户; + * 已同步到本地的内容仍可读。 + */ +export type MemberRevoked = { + workspaceId: string, +}; + export type MoveDocumentInput = { /** 源路径(文件或目录),相对工作区根。 */ fromRelPath: string, @@ -453,6 +473,18 @@ export type SaveDocumentResult = { fileHash: string, }; +/** + * 收到一条工作区协作邀请,等待用户接受/拒绝。前端弹窗,用户决定后调 + * `respond_share_invitation(pending_id, accept)`。 + */ +export type ShareInvitationReceived = { + pendingId: number, + peerId: string, + workspaceUuid: string, + workspaceName: string, + expiresAt: string, +}; + export type SyncCompleted = { workspaceUuid: string, peerId: string, diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index 54ebc8e..7ecb8d2 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -13,6 +13,11 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#. js-lingui-explicit-id +#: src/hooks/useKeyboardShortcuts.ts:35 +msgid "新建笔记" +msgstr "New note" + #. js-lingui-explicit-id #: src/lib/dateUtils.ts:4 msgid "从未在线" @@ -38,11 +43,6 @@ msgstr "{hours} hours ago" msgid "{days} 天前" msgstr "{days} days ago" -#. js-lingui-explicit-id -#: src/hooks/useKeyboardShortcuts.ts:35 -msgid "新建笔记" -msgstr "New note" - #. placeholder {0}: event.payload.relPath #: src/components/editor/NoteEditor.tsx:466 msgid "\"{0}\" 已被外部修改。是否重新加载?当前未保存的编辑将丢失。" @@ -78,6 +78,19 @@ msgstr "{0} documents" msgid "{doneCount} 个成功,{errorCount} 个失败" msgstr "{doneCount} succeeded, {errorCount} failed" +#. placeholder {0}: data.workspaceName +#: src/components/share/ShareInvitationDialog.tsx:55 +msgid "{inviterName} 邀请你协作工作区「{0}」。接受后你将作为协作者加入, 可在「同步」里拉取其内容。" +msgstr "" + +#: src/components/share/ShareDialog.tsx:61 +msgid "{name} 已接受邀请" +msgstr "" + +#: src/components/share/ShareDialog.tsx:64 +msgid "{name} 拒绝了邀请" +msgstr "" + #: src/components/layout/SyncStatusBar.tsx:32 msgid "{peerCount} 台 · 同步中 {completed}/{total}" msgstr "{peerCount} devices · syncing {completed}/{total}" @@ -110,7 +123,7 @@ msgstr "Mermaid diagrams" msgid "P2P 同步" msgstr "P2P Sync" -#: src/routes/settings/network.tsx:26 +#: src/routes/settings/network.tsx:28 msgid "P2P 网络" msgstr "P2P Network" @@ -189,6 +202,14 @@ msgstr "You can choose workspaces to sync in the Workspace Manager window" msgid "你可以稍后在设置 → 设备中配对设备" msgstr "You can pair devices later in Settings → Devices" +#: src/routes/__root.tsx:113 +msgid "你已被移出「{name}」工作区" +msgstr "" + +#: src/routes/__root.tsx:113 +msgid "你已被移出一个共享工作区" +msgstr "" + #: src/components/onboarding/PathChoiceStep.tsx:18 msgid "你是如何开始的?" msgstr "How are you getting started?" @@ -197,7 +218,7 @@ msgstr "How are you getting started?" msgid "你的设备身份已建立,可以开始使用 SwarmNote 了。" msgstr "Your device identity is set up. You can start using SwarmNote." -#: src/components/share/ShareDialog.tsx:117 +#: src/components/share/ShareDialog.tsx:124 msgid "先在「设备」设置里配对设备" msgstr "" @@ -209,7 +230,7 @@ msgstr "Start fresh" msgid "全选" msgstr "Select all" -#: src/components/share/ShareDialog.tsx:69 +#: src/components/share/ShareDialog.tsx:76 msgid "共享「{workspaceName}」" msgstr "" @@ -259,7 +280,7 @@ msgstr "Preparing to sync · {0} documents" msgid "准备就绪!" msgstr "Ready to Go!" -#: src/components/share/ShareDialog.tsx:139 +#: src/components/share/ShareDialog.tsx:146 msgid "分享" msgstr "" @@ -329,6 +350,10 @@ msgstr "Refresh Code" msgid "剪切" msgstr "Cut" +#: src/components/share/ShareInvitationDialog.tsx:62 +msgid "剩余时间:{remaining} 秒" +msgstr "" + #: src/components/editor/EditorContextMenu.tsx:185 msgid "加粗" msgstr "Bold" @@ -337,7 +362,7 @@ msgstr "Bold" msgid "加载中..." msgstr "Loading..." -#: src/components/share/ShareDialog.tsx:88 +#: src/components/share/ShareDialog.tsx:95 msgid "加载中…" msgstr "" @@ -549,6 +574,10 @@ msgstr "Render Markdown images as inline / block widgets" msgid "将一个本地文件夹作为工作区打开。" msgstr "Open a local folder as a workspace." +#: src/routes/__root.tsx:114 +msgid "将不再接收其新内容;已同步到本地的内容仍保留。" +msgstr "" + #: src/routes/workspace-manager.tsx:268 msgid "将已配对设备的工作区同步到本地。需先启动 P2P 网络。" msgstr "Sync workspaces from paired devices to local. The P2P network must be running first." @@ -570,6 +599,10 @@ msgstr "" msgid "展开侧边栏" msgstr "Expand Sidebar" +#: src/components/share/ShareInvitationDialog.tsx:50 +msgid "工作区协作邀请" +msgstr "" + #: src/components/workspace/WorkspacePicker.tsx:113 msgid "工作区管理" msgstr "Workspace manager" @@ -596,8 +629,8 @@ msgid "已停止" msgstr "Stopped" #: src/components/share/ShareDialog.tsx:57 -msgid "已分享给 {name}" -msgstr "" +#~ msgid "已分享给 {name}" +#~ msgstr "" #: src/components/pairing/UnpairConfirmDialog.tsx:37 msgid "已取消与 {deviceName} 的配对" @@ -611,6 +644,10 @@ msgstr "Synced" msgid "已同步 · 最后同步 {timeStr}" msgstr "Synced · last synced {timeStr}" +#: src/components/share/ShareDialog.tsx:57 +msgid "已向 {name} 发送邀请,等待对方接受…" +msgstr "" + #: src/routes/settings/about.tsx:59 msgid "已是最新" msgstr "Up to Date" @@ -619,6 +656,11 @@ msgstr "Up to Date" msgid "已移除 {name}" msgstr "" +#. placeholder {0}: payload.workspaceName +#: src/routes/__root.tsx:91 +msgid "已自动接受「{0}」的协作邀请" +msgstr "" + #: src/components/settings/NetworkStatusCard.tsx:64 msgid "已连接 {connectedCount} 台设备" msgstr "Connected to {connectedCount} device(s)" @@ -636,6 +678,10 @@ msgstr "Connected devices ({0})" msgid "已配对设备" msgstr "Paired Devices" +#: src/routes/settings/network.tsx:49 +msgid "开启后,已配对设备的工作区邀请会自动接受、不再弹窗确认" +msgstr "" + #: src/components/onboarding/WelcomeStep.tsx:39 msgid "开始使用" msgstr "Get Started" @@ -644,7 +690,7 @@ msgstr "Get Started" msgid "开始同步" msgstr "Start sync" -#: src/routes/settings/network.tsx:39 +#: src/routes/settings/network.tsx:41 msgid "开机自动启动网络" msgstr "Auto-start network on launch" @@ -664,7 +710,7 @@ msgstr "Current device" msgid "恢复上次工作区" msgstr "Restore last workspace" -#: src/components/share/ShareDialog.tsx:83 +#: src/components/share/ShareDialog.tsx:90 msgid "成员" msgstr "" @@ -698,7 +744,7 @@ msgstr "Open" msgid "打开工作区文件夹" msgstr "Open workspace folder" -#: src/routes/settings/network.tsx:40 +#: src/routes/settings/network.tsx:42 msgid "打开工作区时自动启动 P2P 节点" msgstr "Automatically start P2P node when opening a workspace" @@ -730,6 +776,30 @@ msgstr "Devices found" msgid "把 mermaid 代码块渲染为 SVG" msgstr "Render mermaid code blocks as SVG" +#: src/components/share/ShareInvitationDialog.tsx:67 +msgid "拒绝" +msgstr "" + +#: src/components/pairing/GlobalActionDialogs.tsx:36 +msgid "拒绝邀请失败" +msgstr "" + +#: src/components/pairing/GlobalActionDialogs.tsx:24 +msgid "拒绝配对失败" +msgstr "" + +#: src/components/share/ShareInvitationDialog.tsx:70 +msgid "接受" +msgstr "" + +#: src/components/pairing/GlobalActionDialogs.tsx:36 +msgid "接受邀请失败" +msgstr "" + +#: src/components/pairing/GlobalActionDialogs.tsx:24 +msgid "接受配对失败" +msgstr "" + #: src/components/editor/EditorContextMenu.tsx:155 msgid "插入" msgstr "Insert" @@ -839,7 +909,7 @@ msgstr "No workspaces" msgid "暂无已连接设备" msgstr "No connected devices" -#: src/components/share/ShareDialog.tsx:88 +#: src/components/share/ShareDialog.tsx:95 msgid "暂无成员" msgstr "" @@ -968,7 +1038,7 @@ msgstr "Restarting..." msgid "正文" msgstr "Body text" -#: src/components/share/ShareDialog.tsx:75 +#: src/components/share/ShareDialog.tsx:82 msgid "此工作区的成员" msgstr "" @@ -984,7 +1054,7 @@ msgstr "Paragraph" msgid "没有匹配的文件" msgstr "No matching files" -#: src/components/share/ShareDialog.tsx:114 +#: src/components/share/ShareDialog.tsx:121 msgid "没有可添加的设备" msgstr "" @@ -1001,7 +1071,7 @@ msgid "深色" msgstr "Dark" #: src/components/onboarding/PathChoiceStep.tsx:57 -#: src/components/share/ShareDialog.tsx:108 +#: src/components/share/ShareDialog.tsx:115 msgid "添加设备" msgstr "Add device" @@ -1137,7 +1207,7 @@ msgid "编辑器插件" msgstr "Editor plugins" #: src/routes/settings.tsx:27 -#: src/routes/settings/network.tsx:18 +#: src/routes/settings/network.tsx:20 msgid "网络" msgstr "Network" @@ -1154,6 +1224,10 @@ msgstr "Network settings" msgid "自动" msgstr "Auto" +#: src/routes/settings/network.tsx:48 +msgid "自动接受协作邀请" +msgstr "" + #: src/components/settings/NetworkStatusCard.tsx:67 msgid "节点启动失败" msgstr "Node failed to start" @@ -1193,7 +1267,7 @@ msgstr "Device Management" #: src/components/layout/TitleBar.tsx:150 #: src/routes/settings.tsx:50 -#: src/routes/settings/network.tsx:34 +#: src/routes/settings/network.tsx:36 msgid "设置" msgstr "Settings" @@ -1319,7 +1393,7 @@ msgstr "General" msgid "通过 DOMPurify 渲染嵌入的原生 HTML" msgstr "Render embedded HTML via DOMPurify" -#: src/components/share/ShareDialog.tsx:73 +#: src/components/share/ShareDialog.tsx:80 msgid "邀请已配对的设备协作编辑此工作区" msgstr "" diff --git a/src/locales/zh/messages.po b/src/locales/zh/messages.po index 8b69248..51b04a0 100644 --- a/src/locales/zh/messages.po +++ b/src/locales/zh/messages.po @@ -13,6 +13,11 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#. js-lingui-explicit-id +#: src/hooks/useKeyboardShortcuts.ts:35 +msgid "新建笔记" +msgstr "新建笔记" + #. js-lingui-explicit-id #: src/lib/dateUtils.ts:4 msgid "从未在线" @@ -38,11 +43,6 @@ msgstr "{hours} 小时前" msgid "{days} 天前" msgstr "{days} 天前" -#. js-lingui-explicit-id -#: src/hooks/useKeyboardShortcuts.ts:35 -msgid "新建笔记" -msgstr "新建笔记" - #. placeholder {0}: event.payload.relPath #: src/components/editor/NoteEditor.tsx:466 msgid "\"{0}\" 已被外部修改。是否重新加载?当前未保存的编辑将丢失。" @@ -78,6 +78,19 @@ msgstr "{0} 篇文档" msgid "{doneCount} 个成功,{errorCount} 个失败" msgstr "{doneCount} 个成功,{errorCount} 个失败" +#. placeholder {0}: data.workspaceName +#: src/components/share/ShareInvitationDialog.tsx:55 +msgid "{inviterName} 邀请你协作工作区「{0}」。接受后你将作为协作者加入, 可在「同步」里拉取其内容。" +msgstr "{inviterName} 邀请你协作工作区「{0}」。接受后你将作为协作者加入, 可在「同步」里拉取其内容。" + +#: src/components/share/ShareDialog.tsx:61 +msgid "{name} 已接受邀请" +msgstr "{name} 已接受邀请" + +#: src/components/share/ShareDialog.tsx:64 +msgid "{name} 拒绝了邀请" +msgstr "{name} 拒绝了邀请" + #: src/components/layout/SyncStatusBar.tsx:32 msgid "{peerCount} 台 · 同步中 {completed}/{total}" msgstr "{peerCount} 台 · 同步中 {completed}/{total}" @@ -110,7 +123,7 @@ msgstr "Mermaid 图表" msgid "P2P 同步" msgstr "P2P 同步" -#: src/routes/settings/network.tsx:26 +#: src/routes/settings/network.tsx:28 msgid "P2P 网络" msgstr "P2P 网络" @@ -189,6 +202,14 @@ msgstr "你可以在工作区管理窗口中选择要同步的工作区" msgid "你可以稍后在设置 → 设备中配对设备" msgstr "你可以稍后在设置 → 设备中配对设备" +#: src/routes/__root.tsx:113 +msgid "你已被移出「{name}」工作区" +msgstr "你已被移出「{name}」工作区" + +#: src/routes/__root.tsx:113 +msgid "你已被移出一个共享工作区" +msgstr "你已被移出一个共享工作区" + #: src/components/onboarding/PathChoiceStep.tsx:18 msgid "你是如何开始的?" msgstr "你是如何开始的?" @@ -197,7 +218,7 @@ msgstr "你是如何开始的?" msgid "你的设备身份已建立,可以开始使用 SwarmNote 了。" msgstr "你的设备身份已建立,可以开始使用 SwarmNote 了。" -#: src/components/share/ShareDialog.tsx:117 +#: src/components/share/ShareDialog.tsx:124 msgid "先在「设备」设置里配对设备" msgstr "先在「设备」设置里配对设备" @@ -209,7 +230,7 @@ msgstr "全新开始" msgid "全选" msgstr "全选" -#: src/components/share/ShareDialog.tsx:69 +#: src/components/share/ShareDialog.tsx:76 msgid "共享「{workspaceName}」" msgstr "共享「{workspaceName}」" @@ -259,7 +280,7 @@ msgstr "准备同步 · {0} 篇文档" msgid "准备就绪!" msgstr "准备就绪!" -#: src/components/share/ShareDialog.tsx:139 +#: src/components/share/ShareDialog.tsx:146 msgid "分享" msgstr "分享" @@ -329,6 +350,10 @@ msgstr "刷新码" msgid "剪切" msgstr "剪切" +#: src/components/share/ShareInvitationDialog.tsx:62 +msgid "剩余时间:{remaining} 秒" +msgstr "剩余时间:{remaining} 秒" + #: src/components/editor/EditorContextMenu.tsx:185 msgid "加粗" msgstr "加粗" @@ -337,7 +362,7 @@ msgstr "加粗" msgid "加载中..." msgstr "加载中..." -#: src/components/share/ShareDialog.tsx:88 +#: src/components/share/ShareDialog.tsx:95 msgid "加载中…" msgstr "加载中…" @@ -549,6 +574,10 @@ msgstr "将 Markdown 图片渲染为内联 / 块级 widget" msgid "将一个本地文件夹作为工作区打开。" msgstr "将一个本地文件夹作为工作区打开。" +#: src/routes/__root.tsx:114 +msgid "将不再接收其新内容;已同步到本地的内容仍保留。" +msgstr "将不再接收其新内容;已同步到本地的内容仍保留。" + #: src/routes/workspace-manager.tsx:268 msgid "将已配对设备的工作区同步到本地。需先启动 P2P 网络。" msgstr "将已配对设备的工作区同步到本地。需先启动 P2P 网络。" @@ -570,6 +599,10 @@ msgstr "居中对齐" msgid "展开侧边栏" msgstr "展开侧边栏" +#: src/components/share/ShareInvitationDialog.tsx:50 +msgid "工作区协作邀请" +msgstr "工作区协作邀请" + #: src/components/workspace/WorkspacePicker.tsx:113 msgid "工作区管理" msgstr "工作区管理" @@ -596,8 +629,8 @@ msgid "已停止" msgstr "已停止" #: src/components/share/ShareDialog.tsx:57 -msgid "已分享给 {name}" -msgstr "已分享给 {name}" +#~ msgid "已分享给 {name}" +#~ msgstr "已分享给 {name}" #: src/components/pairing/UnpairConfirmDialog.tsx:37 msgid "已取消与 {deviceName} 的配对" @@ -611,6 +644,10 @@ msgstr "已同步" msgid "已同步 · 最后同步 {timeStr}" msgstr "已同步 · 最后同步 {timeStr}" +#: src/components/share/ShareDialog.tsx:57 +msgid "已向 {name} 发送邀请,等待对方接受…" +msgstr "已向 {name} 发送邀请,等待对方接受…" + #: src/routes/settings/about.tsx:59 msgid "已是最新" msgstr "已是最新" @@ -619,6 +656,11 @@ msgstr "已是最新" msgid "已移除 {name}" msgstr "已移除 {name}" +#. placeholder {0}: payload.workspaceName +#: src/routes/__root.tsx:91 +msgid "已自动接受「{0}」的协作邀请" +msgstr "已自动接受「{0}」的协作邀请" + #: src/components/settings/NetworkStatusCard.tsx:64 msgid "已连接 {connectedCount} 台设备" msgstr "已连接 {connectedCount} 台设备" @@ -636,6 +678,10 @@ msgstr "已连接设备 ({0})" msgid "已配对设备" msgstr "已配对设备" +#: src/routes/settings/network.tsx:49 +msgid "开启后,已配对设备的工作区邀请会自动接受、不再弹窗确认" +msgstr "开启后,已配对设备的工作区邀请会自动接受、不再弹窗确认" + #: src/components/onboarding/WelcomeStep.tsx:39 msgid "开始使用" msgstr "开始使用" @@ -644,7 +690,7 @@ msgstr "开始使用" msgid "开始同步" msgstr "开始同步" -#: src/routes/settings/network.tsx:39 +#: src/routes/settings/network.tsx:41 msgid "开机自动启动网络" msgstr "开机自动启动网络" @@ -664,7 +710,7 @@ msgstr "当前设备" msgid "恢复上次工作区" msgstr "恢复上次工作区" -#: src/components/share/ShareDialog.tsx:83 +#: src/components/share/ShareDialog.tsx:90 msgid "成员" msgstr "成员" @@ -698,7 +744,7 @@ msgstr "打开" msgid "打开工作区文件夹" msgstr "打开工作区文件夹" -#: src/routes/settings/network.tsx:40 +#: src/routes/settings/network.tsx:42 msgid "打开工作区时自动启动 P2P 节点" msgstr "打开工作区时自动启动 P2P 节点" @@ -730,6 +776,30 @@ msgstr "找到设备" msgid "把 mermaid 代码块渲染为 SVG" msgstr "把 mermaid 代码块渲染为 SVG" +#: src/components/share/ShareInvitationDialog.tsx:67 +msgid "拒绝" +msgstr "拒绝" + +#: src/components/pairing/GlobalActionDialogs.tsx:36 +msgid "拒绝邀请失败" +msgstr "拒绝邀请失败" + +#: src/components/pairing/GlobalActionDialogs.tsx:24 +msgid "拒绝配对失败" +msgstr "拒绝配对失败" + +#: src/components/share/ShareInvitationDialog.tsx:70 +msgid "接受" +msgstr "接受" + +#: src/components/pairing/GlobalActionDialogs.tsx:36 +msgid "接受邀请失败" +msgstr "接受邀请失败" + +#: src/components/pairing/GlobalActionDialogs.tsx:24 +msgid "接受配对失败" +msgstr "接受配对失败" + #: src/components/editor/EditorContextMenu.tsx:155 msgid "插入" msgstr "插入" @@ -839,7 +909,7 @@ msgstr "暂无工作区" msgid "暂无已连接设备" msgstr "暂无已连接设备" -#: src/components/share/ShareDialog.tsx:88 +#: src/components/share/ShareDialog.tsx:95 msgid "暂无成员" msgstr "暂无成员" @@ -968,7 +1038,7 @@ msgstr "正在重启..." msgid "正文" msgstr "正文" -#: src/components/share/ShareDialog.tsx:75 +#: src/components/share/ShareDialog.tsx:82 msgid "此工作区的成员" msgstr "此工作区的成员" @@ -984,7 +1054,7 @@ msgstr "段落设置" msgid "没有匹配的文件" msgstr "没有匹配的文件" -#: src/components/share/ShareDialog.tsx:114 +#: src/components/share/ShareDialog.tsx:121 msgid "没有可添加的设备" msgstr "没有可添加的设备" @@ -1001,7 +1071,7 @@ msgid "深色" msgstr "深色" #: src/components/onboarding/PathChoiceStep.tsx:57 -#: src/components/share/ShareDialog.tsx:108 +#: src/components/share/ShareDialog.tsx:115 msgid "添加设备" msgstr "添加设备" @@ -1137,7 +1207,7 @@ msgid "编辑器插件" msgstr "编辑器插件" #: src/routes/settings.tsx:27 -#: src/routes/settings/network.tsx:18 +#: src/routes/settings/network.tsx:20 msgid "网络" msgstr "网络" @@ -1154,6 +1224,10 @@ msgstr "网络设置" msgid "自动" msgstr "自动" +#: src/routes/settings/network.tsx:48 +msgid "自动接受协作邀请" +msgstr "自动接受协作邀请" + #: src/components/settings/NetworkStatusCard.tsx:67 msgid "节点启动失败" msgstr "节点启动失败" @@ -1193,7 +1267,7 @@ msgstr "设备管理" #: src/components/layout/TitleBar.tsx:150 #: src/routes/settings.tsx:50 -#: src/routes/settings/network.tsx:34 +#: src/routes/settings/network.tsx:36 msgid "设置" msgstr "设置" @@ -1319,7 +1393,7 @@ msgstr "通用" msgid "通过 DOMPurify 渲染嵌入的原生 HTML" msgstr "通过 DOMPurify 渲染嵌入的原生 HTML" -#: src/components/share/ShareDialog.tsx:73 +#: src/components/share/ShareDialog.tsx:80 msgid "邀请已配对的设备协作编辑此工作区" msgstr "邀请已配对的设备协作编辑此工作区" diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index f883382..e52d193 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,9 +1,11 @@ import { i18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; +import { useLingui } from "@lingui/react/macro"; import { createRootRoute, Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/router-devtools"; import { Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { CommandPalette } from "@/components/layout/CommandPalette"; import { GlobalActionDialogs } from "@/components/pairing/GlobalActionDialogs"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -13,6 +15,7 @@ import { commands, events } from "@/lib/bindings"; import { useEditorStore, waitForEditorHydration } from "@/stores/editorStore"; import { useNotificationStore } from "@/stores/notificationStore"; import { waitForOnboardingHydration } from "@/stores/onboardingStore"; +import { usePreferencesStore } from "@/stores/preferencesStore"; import { useUpgradeStore } from "@/stores/upgradeStore"; import { useWorkspaceStore } from "@/stores/workspaceStore"; @@ -22,6 +25,7 @@ export const Route = createRootRoute({ function RootComponent() { useKeyboardShortcuts(); + const { t } = useLingui(); const initFromBackend = useWorkspaceStore((s) => s.initFromBackend); const [hydrated, setHydrated] = useState(false); @@ -77,6 +81,45 @@ function RootComponent() { }; }, []); + // 监听工作区协作邀请。若用户在设置里开启了"自动接受邀请",直接接受、不弹窗; + // 否则走通知队列弹窗让用户决定。 + useEffect(() => { + const unlisten = events.shareInvitationReceived.listen((event) => { + const payload = event.payload; + if (usePreferencesStore.getState().autoAcceptInvitations) { + void commands.respondShareInvitation(payload.pendingId, true); + toast.info(t`已自动接受「${payload.workspaceName}」的协作邀请`); + return; + } + useNotificationStore.getState().push({ + id: `share-invite-${payload.pendingId}`, + type: "share-invitation", + payload, + timestamp: Date.now(), + }); + }); + return () => { + unlisten.then((fn) => fn()); + }; + }, [t]); + + // Notify when this device is removed from a shared workspace (owner revoked + // our access). The backend has already unsubscribed us from its realtime + // updates; we just surface it + refresh workspace state. + useEffect(() => { + const unlisten = events.memberRevoked.listen((event) => { + const current = useWorkspaceStore.getState().workspace; + const name = current?.id === event.payload.workspaceId ? current.name : null; + toast.info(name ? t`你已被移出「${name}」工作区` : t`你已被移出一个共享工作区`, { + description: t`将不再接收其新内容;已同步到本地的内容仍保留。`, + }); + void useWorkspaceStore.getState().initFromBackend(); + }); + return () => { + unlisten.then((fn) => fn()); + }; + }, [t]); + if (!hydrated) { return (
diff --git a/src/routes/settings/network.tsx b/src/routes/settings/network.tsx index 2c47373..4bd900c 100644 --- a/src/routes/settings/network.tsx +++ b/src/routes/settings/network.tsx @@ -1,6 +1,6 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { createFileRoute } from "@tanstack/react-router"; -import { Zap } from "lucide-react"; +import { UserCheck, Zap } from "lucide-react"; import { NetworkStatusCard } from "@/components/settings/NetworkStatusCard"; import { SettingRow } from "@/components/settings/SettingRow"; import { Switch } from "@/components/ui/switch"; @@ -10,6 +10,8 @@ function NetworkSettingsPage() { const { t } = useLingui(); const autoStartP2P = usePreferencesStore((s) => s.autoStartP2P); const setAutoStartP2P = usePreferencesStore((s) => s.setAutoStartP2P); + const autoAcceptInvitations = usePreferencesStore((s) => s.autoAcceptInvitations); + const setAutoAcceptInvitations = usePreferencesStore((s) => s.setAutoAcceptInvitations); return (
@@ -41,6 +43,13 @@ function NetworkSettingsPage() { > + + +
diff --git a/src/stores/notificationStore.ts b/src/stores/notificationStore.ts index 33a69de..1cea66d 100644 --- a/src/stores/notificationStore.ts +++ b/src/stores/notificationStore.ts @@ -1,11 +1,15 @@ import { create } from "zustand"; -import type { PairingRequestReceived } from "@/lib/bindings"; +import type { PairingRequestReceived, ShareInvitationReceived } from "@/lib/bindings"; +// 需要用户决策的通知(顺序队列,一次一个对话框)。 export type ActionNotification = { id: string; timestamp: number; -} & { type: "pairing-request"; payload: PairingRequestReceived }; +} & ( + | { type: "pairing-request"; payload: PairingRequestReceived } + | { type: "share-invitation"; payload: ShareInvitationReceived } +); interface NotificationState { queue: ActionNotification[]; diff --git a/src/stores/preferencesStore.ts b/src/stores/preferencesStore.ts index 669aa5b..88a29b7 100644 --- a/src/stores/preferencesStore.ts +++ b/src/stores/preferencesStore.ts @@ -32,6 +32,9 @@ export type CodeBlockPluginMode = "inline" | "auto" | "toggle"; interface PreferencesState { autoStartP2P: boolean; restoreLastWorkspace: boolean; + /** 是否自动接受他人发来的工作区协作邀请(默认关闭,更安全:收到邀请时弹窗 + * 让用户决定;开启后对已配对设备的邀请会自动接受、不弹窗)。 */ + autoAcceptInvitations: boolean; /** 启用的编辑器 plugin id 集合(与 EDITOR_PLUGIN_IDS 对应) */ enabledPlugins: EditorPluginId[]; /** codeBlock plugin 启用时的渲染模式 */ @@ -41,6 +44,7 @@ interface PreferencesState { interface PreferencesActions { setAutoStartP2P: (value: boolean) => void; setRestoreLastWorkspace: (value: boolean) => void; + setAutoAcceptInvitations: (value: boolean) => void; setPluginEnabled: (id: EditorPluginId, enabled: boolean) => void; setCodeBlockMode: (mode: CodeBlockPluginMode) => void; } @@ -105,11 +109,13 @@ export const usePreferencesStore = create (set) => ({ autoStartP2P: true, restoreLastWorkspace: true, + autoAcceptInvitations: false, enabledPlugins: DEFAULT_ENABLED_PLUGINS, codeBlockMode: DEFAULT_CODE_BLOCK_MODE, setAutoStartP2P: (value: boolean) => set({ autoStartP2P: value }), setRestoreLastWorkspace: (value: boolean) => set({ restoreLastWorkspace: value }), + setAutoAcceptInvitations: (value: boolean) => set({ autoAcceptInvitations: value }), setPluginEnabled: (id: EditorPluginId, enabled: boolean) => set((state) => { const has = state.enabledPlugins.includes(id);