docs: 将仓库代码注释统一为中文
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
CI / docs-check (pull_request) Canceled after 0s
CI / backend-test (pull_request) Canceled after 0s
CI / service-test (pull_request) Canceled after 0s
CI / frontend-test (pull_request) Canceled after 0s
CI / rust-core (pull_request) Canceled after 0s
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
CI / docs-check (pull_request) Canceled after 0s
CI / backend-test (pull_request) Canceled after 0s
CI / service-test (pull_request) Canceled after 0s
CI / frontend-test (pull_request) Canceled after 0s
CI / rust-core (pull_request) Canceled after 0s
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Usage: node scripts/generate-language-icons.mjs /path/to/@iconify-json/vscode-icons
|
||||
// Source: @iconify-json/vscode-icons 1.2.76 (MIT). No runtime network requests.
|
||||
// 用法:node scripts/generate-language-icons.mjs /path/to/@iconify-json/vscode-icons
|
||||
// 来源:@iconify-json/vscode-icons 1.2.76(MIT);运行时不会发起网络请求。
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { bundledLanguagesInfo } from 'shiki/langs'
|
||||
@@ -36,7 +36,7 @@ const svgUrl = icon => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${item.width ?? data.width ?? 32} ${item.height ?? data.height ?? 32}">${item.body}</svg>`
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
|
||||
}
|
||||
let css = `/* Generated by scripts/generate-language-icons.mjs. VSCode Icons (MIT); see language-icons-LICENSE.txt. */\n${base} { display: flex; align-items: center; gap: 8px; }\n${base}::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat ${svgUrl('default-file')}; }\n`
|
||||
let css = `/* 由 scripts/generate-language-icons.mjs 自动生成。VSCode Icons 采用 MIT 许可证;详情见 language-icons-LICENSE.txt。 */\n${base} { display: flex; align-items: center; gap: 8px; }\n${base}::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat ${svgUrl('default-file')}; }\n`
|
||||
for (const [icon, ids] of groups) {
|
||||
css += ids.map(id => `${base}[data-language="${id}"]::before`).join(',\n') + ` { background-image: ${svgUrl(icon)}; }\n`
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Securit
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", optional = true , features = [] }
|
||||
|
||||
# Cryptographic KDFs retain their production work factors in debug/test runs.
|
||||
# Optimize dependencies rather than weakening those factors for local execution.
|
||||
# 加密 KDF 在调试/测试运行中保留其生产工作因素。优化依赖关系,而不是削弱本地执行的这些因素。
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Trusted Core process supervisor. The WebView never receives session material.
|
||||
//! 可信的 Core 进程监管器;WebView 永远不会接触会话材料。
|
||||
use command_group::{CommandGroup, GroupChild};
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Deserialize;
|
||||
@@ -14,7 +14,7 @@ use zeroize::Zeroizing;
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
pub type Broker = Arc<dyn Fn(&serde_json::Value) -> Result<serde_json::Value> + Send + Sync>;
|
||||
|
||||
/// The manifest is embedded in the Host at build time, never loaded from the installation.
|
||||
/// 清单在构建时嵌入到 Host 中,从未从安装中加载。
|
||||
pub fn verify_bundle(root: &Path, manifest: &str) -> Result<()> {
|
||||
use sha2::Digest;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -182,7 +182,7 @@ impl Drop for Session {
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
// Kill the entire group even if its leader has exited.
|
||||
// 即使主进程已经退出,也要终止整个进程组。
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
@@ -200,7 +200,7 @@ pub struct CoreSupervisor {
|
||||
bundle_manifest: Option<String>,
|
||||
}
|
||||
|
||||
/// Host-only request context; deliberately neither Serialize nor Debug.
|
||||
/// 仅 Host 请求上下文;特意既不序列化也不调试。
|
||||
pub struct RequestSession {
|
||||
pub url: String,
|
||||
pub authorization: Zeroizing<String>,
|
||||
@@ -304,7 +304,7 @@ impl CoreSupervisor {
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
// Runtime requirements only; never copy Provider tokens or general PATH.
|
||||
// 这里只复制运行时必需项,绝不复制提供商令牌或通用 PATH。
|
||||
for key in [
|
||||
"SystemRoot",
|
||||
"WINDIR",
|
||||
@@ -323,7 +323,7 @@ impl CoreSupervisor {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
command.creation_flags(0x08000000); // 使用 CREATE_NO_WINDOW
|
||||
}
|
||||
let child = {
|
||||
#[cfg(windows)]
|
||||
@@ -397,8 +397,7 @@ impl CoreSupervisor {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A child has no broker authority until its ready frame has
|
||||
// passed the protocol, identity, generation, and HMAC checks.
|
||||
// 子级在其就绪帧通过协议、身份、生成和 HMAC 检查之前没有代理权限。
|
||||
if !activated {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Device-local Stronghold broker. No public IPC returns secret bytes.
|
||||
//! 设备本地 Stronghold 代理;任何公开 IPC 都不会返回机密字节。
|
||||
//!
|
||||
//! Stronghold Store contains AEAD ciphertext, including while unlocked. Snapshot
|
||||
//! and salt are one atomic envelope, so password changes cannot tear two files.
|
||||
//! Stronghold 存储即使在解锁期间也只包含 AEAD 密文。快照与盐构成一个原子整体,
|
||||
//! 避免密码变更使两个文件处于不一致状态。
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, Payload},
|
||||
@@ -157,8 +157,7 @@ pub struct CredentialId {
|
||||
}
|
||||
|
||||
impl CredentialId {
|
||||
/// Preserve opaque legacy references. Hashed Plugin/MCP IDs remain isolated
|
||||
/// from Provider IDs; only the trusted Core adapter can use these aliases.
|
||||
/// 保留不透明的遗留引用。散列 Plugin/MCP ID 与提供商 ID 保持隔离;只有受信任的 Core 适配器才能使用这些别名。
|
||||
pub fn legacy(id: &str) -> Self {
|
||||
let scope = if let Some(owner) = id.strip_prefix("plugin.") {
|
||||
Scope::Plugin(owner.into())
|
||||
@@ -348,16 +347,14 @@ fn verify_migration_backup(
|
||||
pub struct CredentialBroker {
|
||||
path: PathBuf,
|
||||
unlocked: Option<Unlocked>,
|
||||
// Separate stable inode: snapshots are atomically replaced, so locking the
|
||||
// snapshot itself would not protect the next writer after replacement.
|
||||
// 单独的稳定索引节点:快照被原子替换,因此锁定快照本身不会保护替换后的下一个写入者。
|
||||
ownership: Option<fs::File>,
|
||||
lock_epoch: Arc<AtomicU64>,
|
||||
unlocked_epoch: u64,
|
||||
}
|
||||
|
||||
impl CredentialBroker {
|
||||
/// Source comes from the native file picker, never a raw WebView path.
|
||||
/// Import is idempotent; conflicting IDs stop the entire transaction.
|
||||
/// 源来自本机文件选择器,而不是原始 WebView 路径。导入是幂等的;冲突的 ID 会停止整个事务。
|
||||
pub fn import_fernet(
|
||||
&mut self,
|
||||
directory: &Path,
|
||||
@@ -459,8 +456,7 @@ impl CredentialBroker {
|
||||
}
|
||||
}
|
||||
fs::create_dir_all(&backup).map_err(|_| "MIGRATION_BACKUP_FAILED")?;
|
||||
// Backups contain ciphertext; the legacy key is sealed under the already
|
||||
// unlocked device key, rather than adding another plaintext master.key.
|
||||
// 备份包含密文;旧密钥被密封在已解锁的设备密钥下,而不是添加另一个明文 master.key。
|
||||
let mut nonce = [0u8; 12];
|
||||
rand::rngs::OsRng
|
||||
.try_fill_bytes(&mut nonce)
|
||||
@@ -506,7 +502,7 @@ impl CredentialBroker {
|
||||
state.state = "copied".into();
|
||||
persist_state(&journal, &state)?;
|
||||
checkpoint("copied")?;
|
||||
// Re-open the committed Stronghold snapshot, not the in-memory cache.
|
||||
// 重新打开提交的 Stronghold 快照,而不是内存缓存。
|
||||
let envelope = fs::read(&self.path).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
|
||||
let mut temporary =
|
||||
tempfile::NamedTempFile::new_in(&backup).map_err(|_| "MIGRATION_VERIFY_FAILED")?;
|
||||
@@ -552,8 +548,7 @@ impl CredentialBroker {
|
||||
result
|
||||
}
|
||||
|
||||
/// Deletes only the verified legacy files and this migration's encrypted backup.
|
||||
/// The native Host must obtain explicit user confirmation for source_sha256 first.
|
||||
/// 仅删除已验证的旧文件和此迁移的加密备份。本机 Host 必须首先获得 source_sha256 的明确用户确认。
|
||||
pub fn cleanup_fernet(
|
||||
&mut self,
|
||||
directory: &Path,
|
||||
@@ -733,7 +728,7 @@ impl CredentialBroker {
|
||||
let source = CredentialId::legacy(old);
|
||||
let target =
|
||||
CredentialId::legacy(new.as_str().ok_or("HOST_REQUEST_INVALID")?);
|
||||
// ID migrations cannot change Provider/Plugin/MCP families.
|
||||
// ID 迁移无法更改提供商/Plugin/MCP 系列。
|
||||
if std::mem::discriminant(&source.scope)
|
||||
!= std::mem::discriminant(&target.scope)
|
||||
{
|
||||
@@ -750,8 +745,7 @@ impl CredentialBroker {
|
||||
moves.push((source_key, target_key, value));
|
||||
}
|
||||
}
|
||||
// Reject cycles/overlapping source+destination rather than deleting
|
||||
// a newly written value midway through a multi-ID migration.
|
||||
// 拒绝循环/重叠源+目标,而不是在多 ID 迁移中途删除新写入的值。
|
||||
if moves.iter().any(|(old, new, _)| {
|
||||
old != new && moves.iter().any(|(source, _, _)| source == new)
|
||||
}) {
|
||||
@@ -879,7 +873,7 @@ impl CredentialBroker {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
options.share_mode(0x1 | 0x2); // Do not allow replacing the held lock file.
|
||||
options.share_mode(0x1 | 0x2); // 不允许替换保留的锁定文件。
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -906,7 +900,7 @@ impl CredentialBroker {
|
||||
let mut salt = [0u8; 32];
|
||||
salt.copy_from_slice(&data[8..40]);
|
||||
let session = Unlocked::derive(password, salt)?;
|
||||
// Backups can be on read-only media. This temporary file contains ciphertext only.
|
||||
// 备份可以位于只读介质上。该临时文件仅包含密文。
|
||||
let mut temp = tempfile::NamedTempFile::new().map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
temp.write_all(&data[40..])
|
||||
.map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
@@ -921,7 +915,7 @@ impl CredentialBroker {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Native picker selected destination; backup is encrypted and never overwrites.
|
||||
/// 本机选择器选择的目的地;备份已加密并且永远不会覆盖。
|
||||
pub fn backup(&self, destination: &Path) -> Result<()> {
|
||||
self.session()?;
|
||||
let parent = destination.parent().ok_or("CREDENTIAL_PATH_INVALID")?;
|
||||
@@ -938,8 +932,7 @@ impl CredentialBroker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate every record before atomic replacement; preserve the previous encrypted file.
|
||||
/// Caller must obtain explicit confirmation through the native dialog.
|
||||
/// 在原子替换之前验证每条记录;保留之前的加密文件。调用者必须通过本机对话框获得明确的确认。
|
||||
pub fn restore(&mut self, source: &Path, password: Zeroizing<Vec<u8>>) -> Result<usize> {
|
||||
if !self.is_locked() {
|
||||
return Err("CREDENTIALS_MUST_LOCK".into());
|
||||
@@ -976,7 +969,7 @@ impl CredentialBroker {
|
||||
previous.keep().map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||
}
|
||||
session.persist(&self.path)?;
|
||||
// Restoration deliberately leaves the vault locked; no implicit permission grant.
|
||||
// 恢复特意将金库锁定;没有隐式许可授予。
|
||||
Ok(keys.len())
|
||||
}
|
||||
|
||||
@@ -996,7 +989,7 @@ impl CredentialBroker {
|
||||
.and_then(|_| session.persist(&self.path));
|
||||
if result.is_err() {
|
||||
self.lock();
|
||||
} // Never serve uncommitted memory after disk failure.
|
||||
} // 磁盘故障后切勿服务未提交的内存。
|
||||
result
|
||||
}
|
||||
pub fn delete(&mut self, id: &CredentialId) -> Result<()> {
|
||||
@@ -1011,8 +1004,7 @@ impl CredentialBroker {
|
||||
}
|
||||
result
|
||||
}
|
||||
/// Internal consumers must supply the scope established by the Host dispatcher.
|
||||
/// This method must never be registered as a Tauri command.
|
||||
/// 内部消费者必须提供 Host 调度程序建立的范围。此方法绝不能注册为 Tauri 命令。
|
||||
pub fn resolve(&self, caller: &Scope, id: &CredentialId) -> Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
if caller != &id.scope {
|
||||
return Err("CREDENTIAL_SCOPE_DENIED".into());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Host-memory call reviews. The UI/registry must establish actual user consent
|
||||
//! before confirm; no renderer command or automatic-consent policy is added here.
|
||||
//! Host 内存中的调用审核。UI 或注册表必须先确认用户确已授权,再执行确认;
|
||||
//! 此处不提供渲染进程命令,也不设置自动授权策略。
|
||||
use crate::{
|
||||
extension_mcp_tools::{Description, Tool},
|
||||
extension_permit::{Claims, ExecutionKind},
|
||||
@@ -26,7 +26,7 @@ pub struct Identity {
|
||||
execution_digest: String,
|
||||
}
|
||||
impl Identity {
|
||||
/// Only called after launch permit/entry/context validation.
|
||||
/// 仅在启动许可/条目/上下文验证后调用。
|
||||
pub(crate) fn from_claims(claims: &Claims) -> Result<Self> {
|
||||
let bytes = Zeroizing::new(
|
||||
serde_json::to_vec(claims)
|
||||
@@ -64,8 +64,7 @@ struct Pending {
|
||||
expires: Instant,
|
||||
bytes: usize,
|
||||
}
|
||||
/// An in-process, non-cloneable, non-serializable, single-consumption capability.
|
||||
/// Tool name and arguments cannot be replaced after review confirmation.
|
||||
/// 进程内、不可克隆、不可序列化、单次消耗功能。审核确认后,工具名称和参数无法更换。
|
||||
pub struct ApprovedCall {
|
||||
instance: String,
|
||||
epoch: String,
|
||||
@@ -127,7 +126,7 @@ impl Gate {
|
||||
valid_for_seconds: 120,
|
||||
})
|
||||
}
|
||||
/// The authenticated Host approval route must verify user consent first.
|
||||
/// 经过身份验证的 Host 批准路线必须首先验证用户同意。
|
||||
pub(crate) fn confirm(&mut self, review_id: &str) -> Result<ApprovedCall> {
|
||||
let call = self
|
||||
.pending
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Only the local main window may review Host trust or prepared installations.
|
||||
//! 只有本地主窗口可以检查 Host 信任或准备的安装。
|
||||
use super::Host;
|
||||
use notesagent_host::extension_store::{ExtensionStore, InstallRequest, TrustSetting};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Signed, offline configuration schemas. Credentials belong to the vault broker.
|
||||
//! 已签名的离线配置结构;凭据由 Vault 代理管理。
|
||||
use crate::workspace::{HostError, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -11,7 +11,7 @@ fn walk(value: &Value, depth: usize, nodes: &mut usize, schema: bool) -> Result<
|
||||
Value::Object(map) => {
|
||||
for (key, value) in map {
|
||||
if schema && matches!(key.as_str(), "$ref" | "$dynamicRef" | "$recursiveRef") {
|
||||
// Recursive/unbounded schema execution is not allowed in the Host.
|
||||
// Host 中不允许递归/无界模式执行。
|
||||
return Err(HostError::new("EXTENSION_CONFIG_REFERENCE"));
|
||||
}
|
||||
if !schema
|
||||
@@ -44,8 +44,7 @@ fn walk(value: &Value, depth: usize, nodes: &mut usize, schema: bool) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Examine every applicable schema branch; an alternative branch must not
|
||||
// turn a secret declaration back into persistable plaintext.
|
||||
// 检查每个适用的模式分支;替代分支不得将秘密声明转回可持久的明文。
|
||||
fn reject_secrets(schema: &Value, instance: &Value) -> Result<()> {
|
||||
let Some(map) = schema.as_object() else {
|
||||
return Ok(());
|
||||
@@ -124,7 +123,7 @@ fn reject_secrets(schema: &Value, instance: &Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Schema is read from the verified manifest, never from the proposed configuration.
|
||||
/// 架构是从已验证的清单中读取的,而不是从建议的配置中读取的。
|
||||
pub fn validate(manifest: &Value, configuration: &Value) -> Result<()> {
|
||||
if !configuration.is_object() {
|
||||
return Err(HostError::new("EXTENSION_CONFIG_INVALID"));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Per-instance AppContainer profile ownership. No existing profile is adopted.
|
||||
//! 每个实例独占其 AppContainer 配置,不接管任何已有配置。
|
||||
use crate::workspace::{HostError, Result};
|
||||
use windows_sys::Win32::Security::{
|
||||
FreeSid, IsValidSid,
|
||||
@@ -34,7 +34,7 @@ impl Profile {
|
||||
)
|
||||
};
|
||||
if status < 0 {
|
||||
// In particular, ERROR_ALREADY_EXISTS must not transfer ownership.
|
||||
// 特别是,ERROR_ALREADY_EXISTS 不得转让所有权。
|
||||
if !sid.is_null() {
|
||||
unsafe {
|
||||
FreeSid(sid);
|
||||
@@ -52,25 +52,22 @@ impl Profile {
|
||||
}
|
||||
Ok(profile)
|
||||
}
|
||||
/// Borrowed SID for SECURITY_CAPABILITIES. Valid only while this owner lives.
|
||||
/// 为 SECURITY_CAPABILITIES 借用的 SID,仅在此所有者对象存续期间有效。
|
||||
pub fn sid(&self) -> PSID {
|
||||
self.sid
|
||||
}
|
||||
/// Grant this instance read/execute access to one Host-owned package object.
|
||||
/// The caller must open it without following reparse points and retain the
|
||||
/// verified package handles for the entire launch. No recursive inheritance
|
||||
/// is used: every directory and file must be checked and granted separately.
|
||||
/// This adds an ACE; it does not sanitize pre-existing permissions.
|
||||
/// 授予当前实例读取和执行一个 Host 所有包对象的权限。调用方打开对象时不得跟随重解析点,
|
||||
/// 并须在整个启动期间持有已验证的包句柄。这里不使用递归继承,每个目录和文件都要分别检查、授权。
|
||||
/// 此操作只会添加一条 ACE,不会清理已有权限。
|
||||
pub fn grant_package_read_execute(&self, object: &std::fs::File) -> Result<()> {
|
||||
self.update_package_access(object, false)
|
||||
}
|
||||
/// Remove only this freshly-created instance's allowed ACEs, using the
|
||||
/// original held object handle. Other principals keep their current ACLs.
|
||||
/// 使用最初持有的对象句柄,仅移除这个新实例对应的允许 ACE;其他安全主体的 ACL 保持不变。
|
||||
pub fn revoke_package_access(&self, object: &std::fs::File) -> Result<()> {
|
||||
self.update_package_access(object, true)
|
||||
}
|
||||
fn update_package_access(&self, object: &std::fs::File, revoke: bool) -> Result<()> {
|
||||
// Serialize Host read/merge/write operations across concurrent instances.
|
||||
// 跨并发实例序列化 Host 读/合并/写操作。
|
||||
let _lock = PACKAGE_ACL_LOCK
|
||||
.lock()
|
||||
.map_err(|_| HostError::new("EXTENSION_CONTAINER_ACL_FAILED"))?;
|
||||
@@ -125,8 +122,7 @@ impl Profile {
|
||||
)
|
||||
};
|
||||
let _descriptor = LocalAllocation(descriptor);
|
||||
// A null DACL grants everyone full access, so fail closed rather than
|
||||
// silently treating it as a suitably isolated package object.
|
||||
// 空 DACL 会向所有人授予完全访问权限,因此这里必须拒绝处理,不能误判为已妥善隔离的包对象。
|
||||
if status != 0 || old_acl.is_null() {
|
||||
return Err(HostError::new("EXTENSION_CONTAINER_ACL_FAILED"));
|
||||
}
|
||||
@@ -205,7 +201,7 @@ impl Profile {
|
||||
}
|
||||
result
|
||||
}
|
||||
/// Stop all container processes and close their handles before removal.
|
||||
/// 在删除之前停止所有容器进程并关闭其句柄。
|
||||
pub fn remove(mut self) -> Result<()> {
|
||||
self.remove_inner()
|
||||
}
|
||||
@@ -221,7 +217,7 @@ impl Profile {
|
||||
}
|
||||
impl Drop for Profile {
|
||||
fn drop(&mut self) {
|
||||
// Explicit remove reports failures; drop is a final best-effort retry.
|
||||
// 显式删除报告失败; drop 是最后的尽力重试。
|
||||
let _ = self.remove_inner();
|
||||
if !self.sid.is_null() {
|
||||
unsafe {
|
||||
@@ -302,7 +298,7 @@ mod tests {
|
||||
assert_eq!(unsafe { EqualSid(one.sid(), two.sid()) }, 0);
|
||||
let name = String::from_utf16(&one.name[..one.name.len() - 1]).unwrap();
|
||||
assert!(Profile::create_named(name.clone()).is_err());
|
||||
// Repeated collision must still fail: the failing owner did not delete it.
|
||||
// 重复发生名称冲突时仍须失败:创建失败的一方并不拥有该配置,也无权删除它。
|
||||
assert!(Profile::create_named(name.clone()).is_err());
|
||||
one.remove().unwrap();
|
||||
Profile::create_named(name).unwrap().remove().unwrap();
|
||||
@@ -362,8 +358,7 @@ mod tests {
|
||||
profile.remove().unwrap();
|
||||
}
|
||||
|
||||
// Only tests use cmd.exe, with fixed commands and controlled temporary paths.
|
||||
// A production extension launcher must use a verified entry, never a shell.
|
||||
// 只有测试会通过 cmd.exe 运行固定命令和受控临时路径;生产扩展启动器必须使用已验证入口,不能调用 shell。
|
||||
fn checked_process(profile: &Profile, command: Option<&str>) -> Option<u32> {
|
||||
let executable = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap())
|
||||
.join("System32/cmd.exe");
|
||||
@@ -391,7 +386,7 @@ mod tests {
|
||||
if let Some(data) = data.take() {
|
||||
let suspended =
|
||||
crate::extension_process::Suspended::create(profile, executable, data).unwrap();
|
||||
// Controlled test fixture only; no user extension is authorized here.
|
||||
// 仅用于受控测试夹具;此处没有授权任何用户扩展。
|
||||
let running = unsafe { suspended.resume().unwrap() };
|
||||
let result = running.wait(std::time::Duration::from_secs(10)).unwrap();
|
||||
assert!(result.is_some());
|
||||
@@ -612,7 +607,7 @@ mod tests {
|
||||
let read = format!("set /p value=<\"{}\"", payload.display());
|
||||
assert_ne!(checked_process(&profile, Some(&read)), Some(0));
|
||||
profile.grant_package_read_execute(&root_handle).unwrap();
|
||||
// The directory ACE does not propagate to existing children.
|
||||
// 目录 ACE 不会传播到现有子级。
|
||||
assert_ne!(checked_process(&profile, Some(&read)), Some(0));
|
||||
profile.grant_package_read_execute(&file_handle).unwrap();
|
||||
assert_eq!(checked_process(&profile, Some(&read)), Some(0));
|
||||
@@ -759,8 +754,7 @@ mod tests {
|
||||
profile.grant_package_read_execute(&root).unwrap();
|
||||
profile.grant_package_read_execute(&entry).unwrap();
|
||||
}
|
||||
// Exercise the actual builder, not a test-side quote decoder. The child
|
||||
// compares argv and its entire environment without logging values.
|
||||
// 测试真实构建器,而不是在测试侧另写引号解码器;子进程会比较 argv 和完整环境,但不会记录具体值。
|
||||
let args = [
|
||||
"launch",
|
||||
"",
|
||||
@@ -822,9 +816,8 @@ mod tests {
|
||||
vault_id: uuid::Uuid::new_v4().to_string(),
|
||||
platform: "windows".into(),
|
||||
policy_version: "1".into(),
|
||||
// This probe checks argv/environment, not expiry. Keep its permit
|
||||
// longer than the bounded process observation under parallel load.
|
||||
// The dedicated expiry probe below still uses a two-second lease.
|
||||
// 此探测器检查 argv 与环境,不验证过期行为。许可有效期须覆盖并行负载下的有界进程观测;
|
||||
// 下方专用的过期探测仍使用两秒租期。
|
||||
expires_at_ms: 120_000,
|
||||
};
|
||||
broker
|
||||
@@ -858,8 +851,7 @@ mod tests {
|
||||
running.active_test_processes().ok(),
|
||||
);
|
||||
drop(running);
|
||||
// Actual native RPC: the child cannot name an identity or connect to
|
||||
// a shared endpoint; only its own stdio pipe reaches this broker.
|
||||
// 实际本机 RPC:子进程无法命名身份或连接到共享端点;只有它自己的 stdio 管道才能到达该代理。
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
@@ -1335,7 +1327,7 @@ mod tests {
|
||||
"expiry" => {}
|
||||
_ => drop(issuer),
|
||||
}
|
||||
// The monitor must act without check_authorization or tool polling.
|
||||
// 监视器必须在没有 check_authorization 或工具轮询的情况下运行。
|
||||
assert!(running
|
||||
.wait(std::time::Duration::from_secs(5))
|
||||
.unwrap()
|
||||
@@ -1397,7 +1389,7 @@ mod tests {
|
||||
let deadline = running
|
||||
.start_test_tool_call(std::time::Duration::from_millis(100))
|
||||
.unwrap();
|
||||
// No check() or finish() drives expiration; wait only on the OS process.
|
||||
// 过期处理不依赖 check() 或 finish() 驱动;这里只等待 OS 进程退出。
|
||||
assert!(running
|
||||
.wait(std::time::Duration::from_secs(5))
|
||||
.unwrap()
|
||||
@@ -1444,7 +1436,7 @@ mod tests {
|
||||
("udp", udp.local_addr().unwrap()),
|
||||
] {
|
||||
let address = address.to_string();
|
||||
// The exact executable and target work outside containment.
|
||||
// 验证同一个可执行文件与目标在容器隔离之外能够正常工作。
|
||||
assert!(std::process::Command::new(&executable)
|
||||
.args([mode, &address])
|
||||
.status()
|
||||
@@ -1470,9 +1462,7 @@ mod tests {
|
||||
&std::collections::BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
// Loopback isolation can silently drop packets. TCP must
|
||||
// explicitly report denial or timeout; UDP send may succeed,
|
||||
// but no datagram may reach the controlled listener below.
|
||||
// 环回隔离可以静默丢弃数据包。 TCP必须明确报告拒绝或超时; UDP 发送可能会成功,但没有数据报可能到达下面的受控侦听器。
|
||||
let exit = checked_executable_data(&profile, &executable, None, Some(data));
|
||||
eprintln!("container network probe {mode} {address}: {exit:?}");
|
||||
if mode == "tcp" {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Per-tool-call deadline. A persistent server does not have a 60-second lifetime.
|
||||
//! 每个工具调用的截止时间。持久服务器的生命周期不是 60 秒。
|
||||
use crate::{
|
||||
extension_job::Job,
|
||||
workspace::{HostError, Result},
|
||||
@@ -18,10 +18,7 @@ struct State {
|
||||
wake: Condvar,
|
||||
outcome: AtomicU8,
|
||||
}
|
||||
/// Host-owned guard, created before dispatching a tool call. Finish it only when
|
||||
/// the call completes. Expiration kills the entire instance group even if the
|
||||
/// caller never polls. Dropping without finish aborts the instance; only explicit
|
||||
/// successful completion cancels the timer while preserving the server.
|
||||
/// Host 拥有的防护,在调度工具调用之前创建。仅当呼叫完成时才完成。即使调用者从不轮询,过期也会杀死整个实例组。未完成就丢弃会中止实例;只有显式成功完成才能取消计时器,同时保留服务器。
|
||||
pub struct ToolDeadline {
|
||||
state: Arc<State>,
|
||||
job: Arc<Job>,
|
||||
@@ -91,8 +88,7 @@ impl ToolDeadline {
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
/// Completion cannot cancel an already elapsed budget, even if the timer
|
||||
/// thread has not yet been scheduled to observe expiration.
|
||||
/// 完成无法取消已用完的预算,即使尚未安排计时器线程来观察到期情况。
|
||||
pub fn finish(mut self) -> Result<()> {
|
||||
self.stop();
|
||||
match self.state.outcome.load(Ordering::Acquire) {
|
||||
@@ -102,7 +98,7 @@ impl ToolDeadline {
|
||||
_ => Err(HostError::new("EXTENSION_TOOL_DEADLINE_EXCEEDED")),
|
||||
}
|
||||
}
|
||||
/// Explicit abandonment terminates the instance and reports kill failures.
|
||||
/// 显式放弃终止实例并报告终止失败。
|
||||
pub fn cancel(mut self) -> Result<()> {
|
||||
let result = self.job.terminate();
|
||||
self.stop();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Deterministic, bounded dependency planning; no mutation, activation or automatic downloads.
|
||||
//! 确定性、有界依赖规划;没有突变、激活或自动下载。
|
||||
use crate::{
|
||||
extension_package::Release,
|
||||
workspace::{hash, HostError, Result},
|
||||
@@ -410,7 +410,7 @@ mod tests {
|
||||
resolve(&candidates).unwrap_err().code,
|
||||
"EXTENSION_DEPENDENCY_COMPLEXITY"
|
||||
);
|
||||
// A valid-size graph with oversized permission metadata must not create an unbounded IPC result.
|
||||
// 节点数合法但权限元数据过大的依赖图,也不能产生无界的 IPC 结果。
|
||||
let mut wide = vec![candidate("root", "1.0.0", &[])];
|
||||
for i in 0..100 {
|
||||
let id = format!("leaf-{i:03}");
|
||||
@@ -421,7 +421,7 @@ mod tests {
|
||||
.collect();
|
||||
wide.push(leaf);
|
||||
}
|
||||
// Split the direct dependencies to respect the per-release 64-entry limit.
|
||||
// 拆分直接依赖项以遵守每个版本 64 个条目的限制。
|
||||
let second = wide[0].release.dependencies.split_off("leaf-050");
|
||||
wide[0]
|
||||
.release
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! Instance-bound file RPC policy. Transport must bind one Broker to one
|
||||
//! authenticated instance. Write commits currently use Workspace's transaction;
|
||||
//! full handle-relative write hardening is required before untrusted activation.
|
||||
//! 实例绑定文件 RPC 策略。 Transport 必须将一个 Broker 绑定到一个经过身份验证的实例。写入提交当前使用 Workspace 的事务;在不受信任的激活之前,需要完全的句柄相关的写强化。
|
||||
use crate::{
|
||||
credentials::CredentialBroker,
|
||||
extension_permit::{Authority, Claims, Lease, Permit},
|
||||
@@ -45,7 +43,7 @@ pub struct Broker {
|
||||
requests: u32,
|
||||
}
|
||||
impl Broker {
|
||||
/// Called by the Host after instance identity binding; never an IPC command.
|
||||
/// 实例身份绑定后由Host调用;绝不是 IPC 命令。
|
||||
pub fn bind(
|
||||
authority: &Authority,
|
||||
permit: &Permit,
|
||||
@@ -108,8 +106,7 @@ impl Broker {
|
||||
}
|
||||
self.lease.check()
|
||||
}
|
||||
/// The transport must apply MAX_FRAME_BYTES while reading, before allocation.
|
||||
/// Hold the Host workspace lock throughout dispatch and commit.
|
||||
/// 传输在分配之前读取时必须应用 MAX_FRAME_BYTES。在整个调度和提交过程中保持 Host 工作区锁。
|
||||
pub fn dispatch(&mut self, workspace: &mut Workspace, bytes: &[u8]) -> Result<Value> {
|
||||
self.lease.check()?;
|
||||
if workspace.vault_id != self.vault {
|
||||
@@ -295,7 +292,7 @@ mod tests {
|
||||
call(&mut broker, &mut ws, altered).unwrap_err().code,
|
||||
"OPERATION_PAYLOAD_CONFLICT"
|
||||
);
|
||||
// Build a stale CAS with a genuinely new operation ID.
|
||||
// 使用真正的新操作 ID 构建过期的 CAS。
|
||||
let mut stale = write.clone();
|
||||
stale["operation_id"] = json!(uuid::Uuid::new_v4().to_string());
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Native instance workers. Construct/drop this manager off the UI thread.
|
||||
//! Production callers must still satisfy the complete launch policy contract.
|
||||
//! 原生实例工作线程。必须在 UI 线程之外创建和销毁此管理器;
|
||||
//! 生产调用方仍须满足完整的启动策略约定。
|
||||
use crate::{
|
||||
credentials::CredentialBroker,
|
||||
extension_call_authorization::{Identity, Review},
|
||||
@@ -46,8 +46,7 @@ pub struct LaunchSpec {
|
||||
pub vault_id: String,
|
||||
pub policy_version: String,
|
||||
pub system_root: PathBuf,
|
||||
/// Revalidate active install/current trust and all external policy immediately
|
||||
/// before resume, after expensive package checks. Errors prohibit execution.
|
||||
/// 在昂贵的软件包检查之后,在恢复之前立即重新验证活动安装/当前信任和所有外部策略。错误禁止执行。
|
||||
pub before_resume: ResumeCheck,
|
||||
}
|
||||
#[derive(Clone, Copy, Serialize, PartialEq, Eq, Debug)]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Bounded IO for Host-created anonymous pipes. Never run shutdown on the UI
|
||||
//! thread: cancellation waits for the native pipe operations to acknowledge it.
|
||||
//! 用于 Host 创建的匿名管道的有界 IO。切勿在 UI 线程上运行关闭:取消等待本机管道操作确认它。
|
||||
use crate::{
|
||||
extension_job::Job,
|
||||
extension_stdio::{write_frame, Frames, HostIo, MAX_FRAME_BYTES},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Windows resource containment only. This is NOT a filesystem/network sandbox.
|
||||
//! 仅负责 Windows 资源隔离,不构成文件系统或网络沙箱。
|
||||
use crate::workspace::{HostError, Result};
|
||||
use std::{
|
||||
mem::size_of,
|
||||
@@ -91,11 +91,11 @@ impl Job {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Attach before any extension instruction executes. No breakaway flags are enabled.
|
||||
/// 在任何扩展指令执行之前附加。没有启用任何分离标志。
|
||||
///
|
||||
/// # Safety
|
||||
/// Caller must own an unresumed CREATE_SUSPENDED process and terminate it on
|
||||
/// any error. Resume only after all AppContainer/handle/permission checks pass.
|
||||
/// # 安全性
|
||||
/// 调用方必须拥有尚未恢复执行的 CREATE_SUSPENDED 进程,并在出现任何错误时终止该进程。
|
||||
/// 只有 AppContainer、句柄与权限检查全部通过后,才能恢复执行。
|
||||
pub unsafe fn assign_suspended(&self, process: BorrowedHandle<'_>) -> Result<()> {
|
||||
self.check_resources()?;
|
||||
if unsafe { AssignProcessToJobObject(self.handle.as_raw_handle(), process.as_raw_handle()) }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Derive launch bytes from a verified permit and Host-bound entry/context.
|
||||
//! This preparation step does not authorize resume or establish sandbox readiness.
|
||||
//! 根据已验证许可及 Host 绑定的入口和上下文生成启动数据;
|
||||
//! 此准备步骤既不授权恢复执行,也不表示沙箱已经就绪。
|
||||
use crate::{
|
||||
credentials::{CredentialBroker, CredentialId, Scope},
|
||||
extension_launch_data::LaunchData,
|
||||
@@ -11,7 +11,7 @@ use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, path::Path, sync::atomic::Ordering};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// Only the Host's selected workspace, policy and container supply these values.
|
||||
/// 只有 Host 选定的工作区、策略和容器提供这些值。
|
||||
pub struct Context<'a> {
|
||||
pub vault_id: &'a str,
|
||||
pub policy_version: &'a str,
|
||||
@@ -80,9 +80,7 @@ impl PreparedLaunch {
|
||||
}
|
||||
}
|
||||
impl<'a> LeasedSuspended<'a> {
|
||||
/// # Safety
|
||||
/// Live trust, active installation, broker and all sandbox resource policy
|
||||
/// requirements must also hold. A lease does not establish those conditions.
|
||||
/// # Safety Live 信任、主动安装、代理和所有沙箱资源策略要求也必须满足。租约不规定这些条件。
|
||||
pub unsafe fn resume(self) -> Result<crate::extension_process::Running<'a>> {
|
||||
unsafe { self.process.resume_with_lease(self.lease, self.identity) }
|
||||
}
|
||||
@@ -146,8 +144,7 @@ impl Context<'_> {
|
||||
now_ms: u64,
|
||||
) -> Result<PreparedLaunch> {
|
||||
let mut lease = authority.lease(permit, claims, now_ms)?;
|
||||
// Locking the Host session gates all third-party execution, including
|
||||
// packages that do not request environment secrets.
|
||||
// 锁定 Host 会话会限制所有第三方执行,包括不请求环境机密的包。
|
||||
lease.bind_credential(broker.lock_signal());
|
||||
if broker.is_locked() {
|
||||
return Err(HostError::new("CREDENTIALS_LOCKED"));
|
||||
@@ -163,8 +160,7 @@ impl Context<'_> {
|
||||
tree: entry.tree_sha256().to_owned(),
|
||||
})
|
||||
}
|
||||
/// Caller must still recheck live trust/permit/session state immediately
|
||||
/// before resume; returning encoded data is not an execution lease.
|
||||
/// 调用者仍必须在恢复之前立即重新检查实时信任/许可/会话状态;返回编码数据不是执行租约。
|
||||
fn build(
|
||||
&self,
|
||||
authority: &Authority,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Native argv/environment encoding. This does not authorize or launch a process.
|
||||
//! 本机 argv/环境编码。这不会授权或启动进程。
|
||||
use crate::workspace::{HostError, Result};
|
||||
use std::{collections::BTreeMap, os::windows::ffi::OsStrExt, path::Path};
|
||||
use zeroize::Zeroize;
|
||||
@@ -14,9 +14,7 @@ impl Drop for LaunchData {
|
||||
}
|
||||
}
|
||||
impl LaunchData {
|
||||
/// The Host supplies verified absolute paths and explicitly declared/resolved
|
||||
/// environment values. Never reads the parent environment. CRT argv rules
|
||||
/// apply to native executables, not cmd.exe, batch files or shell interpreters.
|
||||
/// Host 提供经过验证的绝对路径和显式声明/解析的环境值。从不读取父环境。 CRT argv 规则适用于本机可执行文件,而不是 cmd.exe、批处理文件或 shell 解释器。
|
||||
pub fn new(
|
||||
executable: &Path,
|
||||
arguments: &[String],
|
||||
@@ -50,8 +48,7 @@ impl LaunchData {
|
||||
if argument.len() > 8192 || argument.contains('\0') {
|
||||
return Err(bad());
|
||||
}
|
||||
// Reserve the full bounded buffers once: do not leave earlier
|
||||
// copies of resolved values behind through Vec reallocations.
|
||||
// 保留一次完整的有界缓冲区:不要通过 Vec 重新分配留下解析值的早期副本。
|
||||
let mut encoded_len = 0;
|
||||
let mut trailing = 0;
|
||||
for unit in argument.encode_utf16() {
|
||||
@@ -94,8 +91,7 @@ impl LaunchData {
|
||||
if result.command.len() > 32767 {
|
||||
return Err(bad());
|
||||
}
|
||||
// ASCII names give a deterministic Windows case-insensitive order.
|
||||
// Values remain borrowed until encoded so there are no secret clones.
|
||||
// ASCII 名称给出确定性的 Windows 不区分大小写的顺序。值在编码之前一直是借用的,因此不存在秘密克隆。
|
||||
let mut fields: BTreeMap<String, &std::ffi::OsStr> = BTreeMap::new();
|
||||
for (name, path) in [
|
||||
("SYSTEMROOT", system_root),
|
||||
@@ -146,7 +142,7 @@ impl LaunchData {
|
||||
pub(crate) fn command_mut(&mut self) -> &mut [u16] {
|
||||
&mut self.command
|
||||
}
|
||||
/// Pass with CREATE_UNICODE_ENVIRONMENT; never substitute a null pointer.
|
||||
/// 通过CREATE_UNICODE_ENVIRONMENT;切勿替换空指针。
|
||||
pub fn environment(&self) -> &[u16] {
|
||||
&self.environment
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Bounded declarative manifest inspection. No includes, environment interpolation or code execution.
|
||||
//! 有界声明性清单检查。无包含、环境插值或代码执行。
|
||||
use crate::{
|
||||
extension_package::{Inventory, Release},
|
||||
workspace::{HostError, Result},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Serial MCP session over an already-authorized native instance. The Host
|
||||
//! approval route still must establish user consent and current installation/trust.
|
||||
//! 通过已授权的本机实例进行串行 MCP 会话。 Host 批准途径仍必须建立用户同意和当前安装/信任。
|
||||
use crate::{
|
||||
extension_io::{Event, Pump},
|
||||
extension_process::Running,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Bounded, offline MCP tool contracts. Descriptions/annotations are untrusted
|
||||
//! data and never confer permissions. URI content is validated, never fetched.
|
||||
//! 有界、离线 MCP 工具约定。描述/注释是不受信任的数据,永远不会授予权限。 URI 内容经过验证,从未获取。
|
||||
use crate::workspace::{HostError, Result};
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Offline verification primitives. Passing these checks does not authorize installation or execution.
|
||||
//! 离线验证原语。通过这些检查并不意味着授权安装或执行。
|
||||
use crate::workspace::{hash, HostError, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
@@ -66,7 +66,7 @@ fn version(s: &str) -> bool {
|
||||
})
|
||||
}
|
||||
impl Release {
|
||||
/// Full offline package check. Online revocation freshness and runtime permissions remain Host responsibilities.
|
||||
/// 完整执行离线包检查;在线撤销信息的时效性与运行时权限仍由 Host 负责。
|
||||
pub fn verify_package(
|
||||
&self,
|
||||
pinned: &[u8; 32],
|
||||
@@ -165,7 +165,7 @@ impl Release {
|
||||
}
|
||||
Ok(canonical(&value).into_bytes())
|
||||
}
|
||||
/// `pinned` must come from the Host trust store, never from the archive or a WebView assertion.
|
||||
/// `pinned` 必须来自 Host 信任存储,不能取自归档内容或 WebView 声明。
|
||||
pub fn verify(
|
||||
&self,
|
||||
pinned: &[u8; 32],
|
||||
@@ -204,8 +204,8 @@ pub struct Inventory {
|
||||
pub expanded_size: u64,
|
||||
pub manifest: String,
|
||||
}
|
||||
// ZipArchive stores names in a map and can hide duplicate central entries. Inspect the
|
||||
// bounded central directory before handing the archive to its decompressor.
|
||||
// ZipArchive 使用映射保存名称,可能掩盖重复的中央目录条目。因此在将归档交给解压器前,
|
||||
// 必须先检查有界的中央目录。
|
||||
fn directory(bytes: &[u8], max_entries: usize) -> Result<()> {
|
||||
let bad = || HostError::new("EXTENSION_ZIP_INVALID");
|
||||
let u16at = |p: usize| -> Result<usize> {
|
||||
@@ -311,8 +311,7 @@ fn path(name: &str) -> Result<String> {
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
/// Checks all bytes (including CRC), without creating any package files.
|
||||
/// Type-specific manifest schema/identity validation must follow before staging.
|
||||
/// 检查全部字节(包括 CRC),且不创建任何包文件。暂存前还必须执行对应类型的清单结构与身份校验。
|
||||
pub fn inspect(release: &Release, bytes: &[u8]) -> Result<Inventory> {
|
||||
release.validate()?;
|
||||
if bytes.len() as u64 != release.size || hash(bytes) != release.sha256 {
|
||||
@@ -340,7 +339,7 @@ pub fn inspect(release: &Release, bytes: &[u8]) -> Result<Inventory> {
|
||||
.by_index(index)
|
||||
.map_err(|_| HostError::new("EXTENSION_ZIP_INVALID"))?;
|
||||
let name = path(file.name())?;
|
||||
// Include current Rust Unicode case mappings as well as full multi-character folds.
|
||||
// 包括当前的 Rust Unicode 大小写映射以及完整的多字符折叠。
|
||||
let folded: String = name
|
||||
.case_fold()
|
||||
.flat_map(char::to_uppercase)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Host-only execution permit binding. No IPC caller can mint these permits.
|
||||
//! The installer must complete user consent and current trust checks before issue.
|
||||
//! 仅 Host 可以绑定执行许可,IPC 调用方无法伪造许可;
|
||||
//! 安装器签发许可前必须完成用户授权与当前信任检查。
|
||||
use crate::workspace::{HostError, Result};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
@@ -19,7 +19,7 @@ use zeroize::Zeroize;
|
||||
#[serde(tag = "kind", content = "value", deny_unknown_fields)]
|
||||
pub enum Environment {
|
||||
Literal(String),
|
||||
// An opaque credential reference in the Host-derived package scope, never plaintext.
|
||||
// Host 派生包范围中的不透明凭证引用,绝不是明文。
|
||||
CredentialScope(String),
|
||||
}
|
||||
|
||||
@@ -51,8 +51,7 @@ pub struct Claims {
|
||||
pub expires_at_ms: u64,
|
||||
}
|
||||
|
||||
/// Opaque authenticator; the Host retains claims separately. No paths, arguments
|
||||
/// or credential declarations need to be passed to a renderer with the token.
|
||||
/// 不透明验证器; Host 保留单独的权利要求。不需要使用令牌将路径、参数或凭据声明传递给渲染器。
|
||||
pub struct Permit {
|
||||
mac: [u8; 32],
|
||||
generation: u64,
|
||||
@@ -198,15 +197,14 @@ impl Claims {
|
||||
}
|
||||
}
|
||||
impl Authority {
|
||||
/// Host event wiring only; never expose this signal through IPC.
|
||||
/// 仅 Host 事件接线;切勿通过 IPC 暴露此信号。
|
||||
pub fn revocation_signal(&self) -> Arc<AtomicU64> {
|
||||
Arc::clone(&self.generation)
|
||||
}
|
||||
pub fn revoke(&self) {
|
||||
self.generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
/// Call only after consent and live trust validation. This authenticates the
|
||||
/// decision; it does not establish sandbox availability or grant broker access.
|
||||
/// 仅在同意和实时信任验证后才能调用。这证实了该决定;它不会建立沙箱可用性或授予代理访问权限。
|
||||
pub fn issue(&self, claims: &Claims, now_ms: u64) -> Result<Permit> {
|
||||
let generation = self.generation.load(Ordering::SeqCst);
|
||||
let encoded = claims.encoded(now_ms)?;
|
||||
@@ -250,8 +248,7 @@ impl Authority {
|
||||
lease.check()?;
|
||||
Ok(lease)
|
||||
}
|
||||
/// Lock/logout/policy invalidation may discard all permits. Restart creates a
|
||||
/// fresh key, so an old process token cannot silently revive authorization.
|
||||
/// 锁定/注销/策略失效可能会丢弃所有许可。重新启动会创建一个新密钥,因此旧进程令牌无法静默恢复授权。
|
||||
pub fn invalidate_all(&mut self) {
|
||||
self.revoke();
|
||||
self.key.zeroize();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Windows package handles retained across verification and launch. This pins
|
||||
//! existing objects; it is not a read-only filesystem mount.
|
||||
//! Windows 包句柄在验证和启动过程中保留。这会固定现有对象;它不是只读文件系统挂载。
|
||||
use crate::{
|
||||
extension_container::Profile,
|
||||
extension_package::Inventory,
|
||||
@@ -15,8 +14,8 @@ pub struct PinnedPackage {
|
||||
files: BTreeMap<String, File>,
|
||||
tree_sha256: String,
|
||||
}
|
||||
/// Scoped ACL ownership, created before any mutation. Release only after all
|
||||
/// instance processes/handles have closed; drop retries cleanup on error/unwind.
|
||||
/// 在任何变更前建立限定作用域的 ACL 所有权。只有所有实例进程与句柄均已关闭后才能释放;
|
||||
/// 若发生错误或栈展开,Drop 会再次尝试清理。
|
||||
pub struct PackageAccess<'a> {
|
||||
package: &'a PinnedPackage,
|
||||
profile: &'a Profile,
|
||||
@@ -119,9 +118,7 @@ fn directory(parent: &Dir, name: &str) -> Result<Dir> {
|
||||
Ok(Dir::from_std_file(handle))
|
||||
}
|
||||
impl PinnedPackage {
|
||||
/// `root` and inventory originate from the verified Host store. Keep this
|
||||
/// owner until the instance stops; no renderer-supplied filesystem path is
|
||||
/// accepted here. Callers must also constrain ancestors used by native launch.
|
||||
/// `root` 与清单来自已验证的 Host 存储。实例停止前必须保留此所有者;此处不接受渲染进程提供的文件系统路径。调用方还必须限制原生启动所使用的祖先目录。
|
||||
pub fn open(root: &Dir, inventory: &Inventory, expected_tree: &str) -> Result<Self> {
|
||||
let bad = || HostError::new("EXTENSION_STORE_CORRUPT");
|
||||
if inventory.files.is_empty()
|
||||
@@ -179,8 +176,7 @@ impl PinnedPackage {
|
||||
}
|
||||
pinned.files.insert(path.clone(), handle);
|
||||
}
|
||||
// All existing objects are already pinned when verification reopens
|
||||
// them. Sharing violations or hash mismatches release the entire set.
|
||||
// 当验证重新打开它们时,所有现有对象都已被固定。共享违规或哈希不匹配会释放整个集合。
|
||||
pinned.tree_sha256 =
|
||||
crate::extension_unpack::verify_tree(&pinned.directories[""], inventory)?;
|
||||
if pinned.tree_sha256 != expected_tree {
|
||||
@@ -188,9 +184,7 @@ impl PinnedPackage {
|
||||
}
|
||||
Ok(pinned)
|
||||
}
|
||||
/// Resolve through the owned file handle, then pin the volume-rooted path
|
||||
/// component by component and compare native file identity. No drive-letter
|
||||
/// or UNC fallback is permitted if volume GUID lookup is unavailable.
|
||||
/// 通过拥有的文件句柄进行解析,然后逐个组件固定卷根路径并比较本机文件标识。如果卷 GUID 查找不可用,则不允许驱动器号或 UNC 回退。
|
||||
pub fn bind_entry(&self, name: &str) -> Result<BoundEntry<'_>> {
|
||||
use std::{
|
||||
os::windows::fs::OpenOptionsExt as _,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Windows process ownership primitive. A suspended process is not execution
|
||||
//! authorization; the extension runtime must complete its checks before resume.
|
||||
//! Windows 进程所有权原语。暂停的进程不是执行授权;扩展运行时必须在恢复之前完成其检查。
|
||||
use crate::{
|
||||
extension_container::Profile,
|
||||
extension_job::Job,
|
||||
@@ -51,8 +50,7 @@ impl Attributes {
|
||||
return Err(bad());
|
||||
}
|
||||
value.initialized = true;
|
||||
// The attribute stores a pointer to SECURITY_CAPABILITIES. The caller
|
||||
// updates it with storage that remains alive until CreateProcessW.
|
||||
// 该属性存储指向SECURITY_CAPABILITIES的指针。调用者使用在 CreateProcessW 之前保持活动状态的存储来更新它。
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
@@ -223,8 +221,7 @@ impl<'a> Suspended<'a> {
|
||||
value.0._bound_entry = Some(entry);
|
||||
Ok(value)
|
||||
}
|
||||
/// Create instance-specific stdio without exposing an address or trusting a
|
||||
/// self-reported process/package identity. Host endpoints are never inherited.
|
||||
/// 创建特定于实例的 stdio,而不暴露地址或信任自我报告的进程/包身份。 Host 端点永远不会被继承。
|
||||
#[cfg(feature = "desktop")]
|
||||
pub fn create_bound_with_stdio(
|
||||
profile: &'a Profile,
|
||||
@@ -254,9 +251,8 @@ impl<'a> Suspended<'a> {
|
||||
identity: None,
|
||||
})
|
||||
}
|
||||
/// # Safety
|
||||
/// The same complete resource/broker/trust preconditions as resume apply.
|
||||
/// This additionally arms revocation monitoring before any instruction resumes.
|
||||
/// # 安全性
|
||||
/// 必须满足与 resume 相同的完整资源、代理与信任前提;此外还要在恢复执行任何指令前启用撤销监控。
|
||||
#[cfg(feature = "desktop")]
|
||||
pub(crate) unsafe fn resume_with_lease(
|
||||
self,
|
||||
@@ -311,8 +307,7 @@ impl Running<'_> {
|
||||
pub(crate) fn active_test_processes(&self) -> Result<u32> {
|
||||
self.process.job.active_processes()
|
||||
}
|
||||
/// Arm before dispatching a tool request; finish after receiving its result.
|
||||
/// Failure to arm must prevent dispatch. This does not time server lifetime.
|
||||
/// 发送工具请求前启动计时,收到结果后结束。若启动计时失败,必须阻止调度;该计时不限制服务器生命周期。
|
||||
pub fn start_tool_call(&self) -> Result<crate::extension_deadline::ToolDeadline> {
|
||||
self.check_authorization()?;
|
||||
crate::extension_deadline::ToolDeadline::arm(&self.process.job)
|
||||
@@ -324,7 +319,7 @@ impl Running<'_> {
|
||||
) -> Result<crate::extension_deadline::ToolDeadline> {
|
||||
crate::extension_deadline::ToolDeadline::arm_test(&self.process.job, budget)
|
||||
}
|
||||
/// A bounded observation only. The runtime must enforce the tool deadline.
|
||||
/// 此处只进行有界观测;工具截止时间必须由运行时强制执行。
|
||||
pub fn wait(&self, timeout: Duration) -> Result<Option<u32>> {
|
||||
let milliseconds = u32::try_from(timeout.as_millis())
|
||||
.ok()
|
||||
@@ -347,7 +342,7 @@ impl Running<'_> {
|
||||
_ => Err(HostError::new("EXTENSION_PROCESS_WAIT_FAILED")),
|
||||
}
|
||||
}
|
||||
/// Terminates the entire managed group, including descendants.
|
||||
/// 终止整个托管组,包括后代。
|
||||
pub fn terminate(&self) -> Result<()> {
|
||||
self.process.job.terminate()
|
||||
}
|
||||
@@ -458,7 +453,7 @@ mod tests {
|
||||
unsafe { WaitForSingleObject(observer.as_raw_handle(), 0) },
|
||||
WAIT_TIMEOUT
|
||||
);
|
||||
drop(suspended); // Never resumed any command interpreter instruction.
|
||||
drop(suspended); // 从未恢复任何命令解释器指令。
|
||||
assert_eq!(
|
||||
unsafe { WaitForSingleObject(observer.as_raw_handle(), 5000) },
|
||||
WAIT_OBJECT_0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Native instance monitor; revocation does not depend on the caller polling.
|
||||
//! 本机实例监视器;撤销不依赖于调用者轮询。
|
||||
use crate::{
|
||||
extension_job::Job,
|
||||
extension_permit::Lease,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Per-launch anonymous pipes. Only child ends enter the explicit inheritance
|
||||
//! list. The runtime owns Host ends and must bound frames and cancel blocked IO.
|
||||
//! 每次启动的匿名管道。只有子端进入显式继承列表。运行时拥有 Host 端,必须绑定帧并取消阻塞的 IO。
|
||||
use crate::workspace::{HostError, Result};
|
||||
#[cfg(any(feature = "desktop", test))]
|
||||
use std::os::windows::io::FromRawHandle;
|
||||
@@ -55,8 +54,8 @@ impl ChildIo {
|
||||
},
|
||||
))
|
||||
}
|
||||
/// Own both the pipe ends and the launch lock. Field drop order closes all
|
||||
/// inheritable ends before allowing a competing Host launch to proceed.
|
||||
/// 同时持有管道端点与启动锁。字段的销毁顺序会先关闭所有可继承端点,
|
||||
/// 再允许其他并发 Host 启动继续执行。
|
||||
pub(crate) fn inherit(self) -> Result<InheritedIo> {
|
||||
let lock = crate::process_creation::lock().map_err(HostError::new)?;
|
||||
let guarded = InheritedIo {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Durable verified-package staging. Staging never enables a package or grants permissions.
|
||||
//! 持久的验证包暂存。暂存从不启用包或授予权限。
|
||||
use crate::{
|
||||
extension_package::Release,
|
||||
workspace::{hash, HostError, Result},
|
||||
@@ -161,7 +161,7 @@ fn source(value: &str) -> Result<String> {
|
||||
Ok(url.to_string())
|
||||
}
|
||||
impl ExtensionStore {
|
||||
/// Creates a lock preview from local staged packages. This does not replace online revocation checks.
|
||||
/// 从本地暂存包创建锁定预览。这不会取代在线撤销检查。
|
||||
pub fn dependency_plan(
|
||||
&self,
|
||||
root_key: &str,
|
||||
@@ -250,7 +250,7 @@ impl ExtensionStore {
|
||||
})?;
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
/// Host supplies an existing application-owned directory, never a package-supplied path.
|
||||
/// Host 提供现有应用程序拥有的目录,而不是包提供的路径。
|
||||
pub fn open(root: &Path) -> Result<Self> {
|
||||
ordinary(root)?;
|
||||
if !root.is_dir() {
|
||||
@@ -338,8 +338,7 @@ impl ExtensionStore {
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
/// Called after the user confirms the displayed fingerprint. A concurrent
|
||||
/// setting change requires a fresh review; refresh never calls this method.
|
||||
/// 用户确认显示的指纹后调用。并发设置更改需要重新审核;刷新从不调用此方法。
|
||||
pub fn confirm_trust(
|
||||
&mut self,
|
||||
setting: &TrustSetting,
|
||||
@@ -358,7 +357,7 @@ impl ExtensionStore {
|
||||
if old_revision.as_deref() != expected_revision {
|
||||
return Err(HostError::new("EXTENSION_TRUST_CONFLICT"));
|
||||
}
|
||||
// Prevent one canonical URL from silently acquiring a second source identity.
|
||||
// 防止同一个规范化 URL 在无提示的情况下获得第二个来源标识。
|
||||
let mut statement = self
|
||||
.db
|
||||
.prepare("SELECT setting FROM extension_trust WHERE source=?1")?;
|
||||
@@ -433,7 +432,7 @@ impl ExtensionStore {
|
||||
};
|
||||
Ok(hash(&serde_json::to_vec(&identity).unwrap()))
|
||||
}
|
||||
/// A persisted denial is independent of rollback and renewed source consent.
|
||||
/// 持续拒绝与回滚和更新源同意无关。
|
||||
pub fn check_not_revoked(
|
||||
&self,
|
||||
source_url: &str,
|
||||
@@ -481,8 +480,7 @@ impl ExtensionStore {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Builds the complete consent payload; staging work is allowed, but active
|
||||
/// pointers, running instances and permissions are untouched.
|
||||
/// 构建完整的同意有效负载;允许暂存工作,但活动指针、运行实例和权限不受影响。
|
||||
pub fn installation_preview(&mut self, request: &InstallRequest) -> Result<InstallPreview> {
|
||||
use crate::extension_transaction::{Change, Target};
|
||||
let vault = Uuid::parse_str(&request.vault_id)
|
||||
@@ -586,8 +584,7 @@ impl ExtensionStore {
|
||||
changes,
|
||||
})
|
||||
}
|
||||
/// Recompute the exact reviewed payload before online checks. The main-window
|
||||
/// confirmation UI must supply this digest; this method alone is not consent.
|
||||
/// 在线检查之前重新计算准确的已审核有效负载。主窗口确认 UI 必须提供此摘要;仅此方法并不表示同意。
|
||||
pub async fn install_confirmed(
|
||||
&mut self,
|
||||
operation: &str,
|
||||
@@ -698,7 +695,7 @@ impl ExtensionStore {
|
||||
|_| checkpoint(),
|
||||
)
|
||||
}
|
||||
/// Online installation gate, using confirmed Host trust settings only.
|
||||
/// 在线安装检查点,只使用已确认的 Host 信任设置。
|
||||
pub async fn switch_online(
|
||||
&mut self,
|
||||
operation: &str,
|
||||
@@ -760,8 +757,7 @@ impl ExtensionStore {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
/// Atomically selects a prepared group after installer policy checks. This
|
||||
/// method does not stop processes, validate configuration schemas or issue permits.
|
||||
/// 安装器策略检查通过后,以原子方式选定已准备的分组。此方法不会停止进程、验证配置结构或颁发许可。
|
||||
pub fn switch_prepared(
|
||||
&mut self,
|
||||
operation: &str,
|
||||
@@ -844,8 +840,7 @@ impl ExtensionStore {
|
||||
) -> Result<Option<crate::extension_transaction::Active>> {
|
||||
crate::extension_transaction::active(&self.db, slot)
|
||||
}
|
||||
/// Prepare a verified staged package. The caller supplies current signer/revocation
|
||||
/// policy; persisted preparation does not bypass that policy on replay.
|
||||
/// 准备经过验证的暂存包。调用者提供当前的签名者/撤销策略;持久准备不会在重放时绕过该策略。
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
package_key: &str,
|
||||
@@ -899,7 +894,7 @@ impl ExtensionStore {
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((directory, tree_sha256)) = existing {
|
||||
// Database data never supplies an arbitrary relative path.
|
||||
// 数据库数据从不提供任意相对路径。
|
||||
if Uuid::parse_str(&directory)
|
||||
.map(|id| id.to_string())
|
||||
.ok()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Atomic package/configuration pointers. A pointer is never a runtime permission.
|
||||
//! 原子包/配置指针。指针从来都不是运行时权限。
|
||||
use crate::workspace::{hash, HostError, Result};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -143,8 +143,7 @@ fn switch_inner(
|
||||
})
|
||||
}
|
||||
|
||||
/// `healthy` must come from the Host's matching package/config health probe.
|
||||
/// Recovery calls this with false; it never reissues any execution permits.
|
||||
/// “healthy”必须来自 Host 的匹配包/配置运行状况探测。恢复称其为 false;它从不重新签发任何执行许可证。
|
||||
pub fn finish(db: &mut Connection, operation: &str, healthy: bool) -> Result<Receipt> {
|
||||
let tx = db.transaction()?;
|
||||
let (before, after, state): (String, String, String) = tx.query_row(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Fresh Community state checked against Host-pinned keys; never TOFU on refresh.
|
||||
//! 根据 Host 固定密钥检查新的社区状态;刷新时绝不会 TOFU。
|
||||
use crate::{
|
||||
extension_package::Release,
|
||||
workspace::{hash, HostError, Result},
|
||||
@@ -413,7 +413,7 @@ mod tests {
|
||||
} else {
|
||||
"200 OK"
|
||||
};
|
||||
// No content length: exercise the streaming cap independently.
|
||||
// 没有 Content-Length 时,单独验证流式传输上限。
|
||||
write!(stream, "HTTP/1.1 {status}\r\nConnection: close\r\n\r\n").unwrap();
|
||||
stream.write_all(&body).unwrap();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Private, capability-relative extraction. Prepared trees are not executable installs.
|
||||
//! 私有的、与能力相关的提取。准备好的树不是可执行安装。
|
||||
use crate::{
|
||||
extension_package::{Inventory, Release},
|
||||
workspace::{HostError, Result},
|
||||
@@ -95,7 +95,7 @@ fn collect(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-read the exact file set and all content through directory capabilities.
|
||||
/// 通过目录功能重新读取确切的文件集和所有内容。
|
||||
pub fn verify_tree(root: &Dir, inventory: &Inventory) -> Result<String> {
|
||||
let mut found = BTreeSet::new();
|
||||
collect(root, "", &mut found, &mut 10000, inventory)?;
|
||||
@@ -162,8 +162,7 @@ pub fn verify_tree(root: &Dir, inventory: &Inventory) -> Result<String> {
|
||||
Ok(format!("{:x}", tree.finalize()))
|
||||
}
|
||||
|
||||
/// `root` must be an application-owned private staging directory. Trust freshness
|
||||
/// and permission grants remain installation-layer responsibilities.
|
||||
/// “root”必须是应用程序拥有的私有暂存目录。信任新鲜度和权限授予仍然是安装层的责任。
|
||||
pub fn prepare(
|
||||
root: &Dir,
|
||||
release: &Release,
|
||||
@@ -207,7 +206,7 @@ pub fn prepare(
|
||||
}
|
||||
}
|
||||
let tree_sha256 = verify_tree(&target, &inventory)?;
|
||||
// Failed preparations remain isolated UUID directories; never expose them as current.
|
||||
// 失败的准备工作仍然隔离UUID目录;切勿将它们暴露为当前状态。
|
||||
Ok(Prepared {
|
||||
directory,
|
||||
tree_sha256,
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Host {
|
||||
*active = next;
|
||||
}
|
||||
fn lock_credentials(&self) -> Result<(), String> {
|
||||
// These do not wait for an in-flight unlock/KDF or credential operation.
|
||||
// 这些不等待进行中解锁/KDF 或凭证操作。
|
||||
self.extension_authority.revoke();
|
||||
if let Some(signal) = self.credential_signal.get() {
|
||||
signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
@@ -1159,7 +1159,7 @@ mod lifecycle_tests {
|
||||
}
|
||||
let observed = credentials.load(Ordering::SeqCst);
|
||||
let revoked = extension.load(Ordering::SeqCst);
|
||||
// Release before asserting, so an assertion cannot deadlock scope join.
|
||||
// 在断言之前释放,因此断言不能死锁作用域连接。
|
||||
drop(held);
|
||||
worker.join().unwrap().unwrap();
|
||||
assert!(observed > 0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Immutable payloads are fsynced before any SQLite reference becomes visible.
|
||||
//! 在任何 SQLite 引用变得可见之前,不可变的有效负载会被 fsync。
|
||||
use crate::workspace::{hash, HostError, Result, Workspace};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -104,7 +104,7 @@ impl Workspace {
|
||||
.optional()?)
|
||||
}
|
||||
}
|
||||
/// A new write either supplies bytes or references an existing immutable spool.
|
||||
/// 新写入要么直接提供字节,要么引用现有的不可变暂存文件。
|
||||
pub(crate) enum WritePayload<'a> {
|
||||
Inline(&'a [u8]),
|
||||
Stored { digest: &'a str, size: u64 },
|
||||
@@ -200,8 +200,8 @@ fn hash_file_info(path: &Path) -> Result<(String, u64)> {
|
||||
pub(crate) fn verify(path: &Path, digest: &str, size: u64) -> Result<()> {
|
||||
open_verified(path, digest, size).map(drop)
|
||||
}
|
||||
/// Return the verified handle, rewound for use by a streaming caller.
|
||||
/// Path containment is the caller's responsibility; this is not a sandbox opener.
|
||||
/// 返回已经验证并回绕到起始位置的句柄,供流式调用方使用。
|
||||
/// 路径约束由调用方负责;此函数并不负责建立沙箱。
|
||||
pub(crate) fn open_verified(path: &Path, digest: &str, size: u64) -> Result<fs::File> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if meta.file_type().is_symlink() || !meta.is_file() || meta.len() != size {
|
||||
@@ -236,8 +236,7 @@ pub(crate) fn copy_verified(
|
||||
let mut buffer = vec![0; VERIFY_BUFFER_BYTES];
|
||||
let mut length = 0u64;
|
||||
loop {
|
||||
// Even if a file grows after metadata inspection, consume at most the
|
||||
// declared payload plus one byte, never an unbounded changing stream.
|
||||
// 即使文件在元数据检查后增长,最多消耗声明的有效负载加上一个字节,而不是无限变化的流。
|
||||
let limit = size
|
||||
.saturating_sub(length)
|
||||
.saturating_add(1)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Portable settings may declare required permissions, but never carry device grants, paths or secrets.
|
||||
//! 可移植设置可以声明所需的权限,但绝不携带设备授权、路径或秘密。
|
||||
use crate::workspace::{HostError, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Coordinate Host-controlled Windows launches while inheritable handles exist.
|
||||
//! This does not serialize foreign libraries that bypass this Host boundary.
|
||||
//! 在存在可继承手柄的情况下协调 Host 控制的 Windows 启动。这不会序列化绕过此 Host 边界的外部库。
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
static CREATION: Mutex<()> = Mutex::new(());
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Main-window preference records; no generic credential or application-state accessor.
|
||||
//! 主窗口偏好记录;没有通用凭证或应用程序状态访问器。
|
||||
use super::{with_workspace, Host};
|
||||
use notesagent_host::{
|
||||
records,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Versioned logical records: explicit fields only, never raw application databases/config.
|
||||
//! 版本化逻辑记录:仅显式字段,从不原始应用程序数据库/配置。
|
||||
use crate::workspace::{HostError, Result, Workspace};
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Reserve before dispatch so cancellation cannot race a delayed IPC invocation.
|
||||
//! 调度前保留,因此取消不能与延迟的 IPC 调用竞争。
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
@@ -71,7 +71,7 @@ impl Requests {
|
||||
}
|
||||
}
|
||||
impl Lease {
|
||||
/// Recheck after synchronous encoding/validation, immediately before network IO.
|
||||
/// 同步编码/验证后、紧接网络 IO 之前重新检查。
|
||||
pub fn checkpoint(&self) -> impl Fn() -> Result<(), String> + Send + 'static {
|
||||
let cancel = self.cancel.clone();
|
||||
let deadline = self.deadline;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! C23 compatibility for the MSVCRT-based Windows GNU development target.
|
||||
//! Recent libsodium archives reference memset_explicit, absent in MSVCRT.
|
||||
//! Volatile stores preserve its non-elidable wipe semantics; MSVC/UCRT release
|
||||
//! builds use their native runtime and do not compile this compatibility symbol.
|
||||
//! C23 与基于 MSVCRT 的 Windows GNU 开发目标的兼容性。最近的 libsodium 档案参考 memset_explicit,MSVCRT 中不存在。易失性存储保留其不可消除的擦除语义; MSVC/UCRT 发行版本使用其本机运行时,并且不编译此兼容性符号。
|
||||
|
||||
#[cfg(all(windows, target_env = "gnu"))]
|
||||
#[no_mangle]
|
||||
@@ -11,8 +8,7 @@ unsafe extern "C" fn memset_explicit(
|
||||
count: usize,
|
||||
) -> *mut std::ffi::c_void {
|
||||
for offset in 0..count {
|
||||
// SAFETY: the C ABI caller must supply a writable region of count bytes,
|
||||
// exactly as for memset. Volatile stores cannot be removed as dead writes.
|
||||
// SAFETY:C ABI 调用者必须提供 count 字节的可写区域,与 memset 完全相同。易失性存储无法作为死写删除。
|
||||
unsafe {
|
||||
destination
|
||||
.cast::<u8>()
|
||||
@@ -30,7 +26,7 @@ mod tests {
|
||||
fn explicit_memset_preserves_surrounding_bytes_and_return_pointer() {
|
||||
let mut data = [0x55u8; 34];
|
||||
let pointer = data[1..33].as_mut_ptr().cast();
|
||||
// SAFETY: the subslice contains exactly 32 writable bytes.
|
||||
// SAFETY: 子片恰好包含 32 个可写字节。
|
||||
assert_eq!(unsafe { super::memset_explicit(pointer, 0, 32) }, pointer);
|
||||
assert_eq!(data[0], 0x55);
|
||||
assert_eq!(data[33], 0x55);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Windows session notifications. Revocation is atomic and never waits for a KDF.
|
||||
//! https://learn.microsoft.com/en-us/windows/win32/termserv/wm-wtssession-change
|
||||
//! Windows 会话通知。撤销是原子性的,永远不会等待 KDF。 https://learn.microsoft.com/en-us/windows/win32/termserv/wm-wtssession-change
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
@@ -152,7 +151,7 @@ mod tests {
|
||||
fn native_message_revokes_without_unlocking_on_session_return() {
|
||||
let signal = Arc::new(AtomicU64::new(0));
|
||||
let monitor = SessionMonitor::start(signal.clone()).unwrap();
|
||||
// Inject only into our hidden test window; never lock the user's desktop.
|
||||
// 仅注入到我们的隐藏测试窗口中;永远不要锁定用户的桌面。
|
||||
unsafe {
|
||||
SendMessageW(
|
||||
monitor.window as HWND,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Device-local Sync sessions. The serialized record never crosses IPC.
|
||||
//! 设备本地同步会话。序列化记录从未跨越IPC。
|
||||
use crate::{
|
||||
credentials::{CredentialBroker, CredentialId, Scope},
|
||||
sync_client::{Session, SyncClient, SyncError},
|
||||
@@ -64,7 +64,7 @@ pub fn available(credentials: &Credentials, endpoint: &str, account: &str) -> Re
|
||||
.map(|v| v.is_some())
|
||||
})
|
||||
}
|
||||
/// Dropping a guarded HTTP future closes the in-flight operation on any lock epoch change.
|
||||
/// 删除受保护的 HTTP 未来会关闭任何锁定纪元更改的正在进行的操作。
|
||||
pub async fn guarded<T>(
|
||||
credentials: &Credentials,
|
||||
future: impl Future<Output = Result<T>>,
|
||||
@@ -123,7 +123,7 @@ pub async fn login(
|
||||
})
|
||||
.await
|
||||
}
|
||||
/// The caller serializes refreshes with the coordinator gate.
|
||||
/// 调用者使用协调器门来串行刷新。
|
||||
pub async fn client(
|
||||
credentials: &Credentials,
|
||||
endpoint: &str,
|
||||
@@ -158,8 +158,7 @@ pub async fn client(
|
||||
saved.allow_test_http,
|
||||
)
|
||||
}
|
||||
/// The coordinator must serialize calls. Only use for read-only or durably idempotent work:
|
||||
/// a 401 repeats the operation once with the same device after rotating its session.
|
||||
/// 协调器必须序列化调用。仅用于只读或持久幂等工作:401 在轮换其会话后使用同一设备重复该操作一次。
|
||||
pub async fn authenticated<T, F, Fut>(
|
||||
credentials: &Credentials,
|
||||
endpoint: &str,
|
||||
@@ -181,7 +180,7 @@ where
|
||||
}
|
||||
pub async fn logout(credentials: &Credentials, endpoint: &str, account: &str) -> Result<()> {
|
||||
let client = client(credentials, endpoint, account, false).await?;
|
||||
// A failed server revocation is reported; the encrypted record remains available for retry.
|
||||
// 报告服务器吊销失败;加密记录仍可供重试。
|
||||
guarded(
|
||||
credentials,
|
||||
client.json(reqwest::Method::DELETE, "sync/v1/auth/sessions", None),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Bounded Sync v1 transport. No redirects, no token-bearing URLs, no implicit retries.
|
||||
//! 有界同步 v1 传输。没有重定向,没有带有令牌的 URL,没有隐式重试。
|
||||
use crate::{
|
||||
sync_state::{Binding, Job},
|
||||
workspace::Workspace,
|
||||
@@ -83,7 +83,7 @@ impl From<std::io::Error> for SyncError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist only via the Stronghold Sync scope, never as an IPC response.
|
||||
/// 仅通过 Stronghold Sync 范围持续,绝不作为 IPC 响应。
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub access_token: String,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Main-window commands. Every ongoing run is bound to one Workspace and one account.
|
||||
//! 主窗口命令。每次正在进行的运行都绑定到一个工作区和一个帐户。
|
||||
use super::{with_workspace, Host};
|
||||
use notesagent_host::{
|
||||
sync_auth,
|
||||
@@ -420,7 +420,7 @@ async fn cycle(host: &Host, binding: &Binding) -> Result<(), SyncError> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Finish the fixed incoming window before freezing any new remote base.
|
||||
// 在冻结任何新的远程基地之前完成固定的传入窗口。
|
||||
if host
|
||||
.workspace
|
||||
.access(|ws| ws.sync_boundary(&binding.id))?
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Reconcile externally edited files against committed snapshots, never against UI read caches.
|
||||
//! 根据已提交的快照协调外部编辑的文件,而不是根据 UI 读取缓存。
|
||||
use crate::workspace::{HostError, Result, Workspace};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
use uuid::Uuid;
|
||||
/// File transport only. Logical records receive their own versioned whitelist separately.
|
||||
/// 仅文件传输。逻辑记录单独接收自己的版本白名单。
|
||||
pub fn allowed(path: &str) -> bool {
|
||||
if crate::records::is_record(path) {
|
||||
return crate::records::allowed(path);
|
||||
@@ -115,7 +115,7 @@ impl Workspace {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Confirm the snapshot without writing back over an external editor.
|
||||
// 确认快照而不通过外部编辑器回写。
|
||||
let operation = Uuid::new_v4().to_string();
|
||||
self.store_payload_file(&operation, &target, &digest)?;
|
||||
let file_id = previous.map_or_else(|| Uuid::new_v4().to_string(), |e| e.file_id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Persist received revisions before Workspace writes; cursor advancement follows application.
|
||||
//! 在Workspace写入之前保留收到的修订;光标前进跟随应用程序。
|
||||
use crate::{
|
||||
sync_state::{Binding, Job},
|
||||
workspace::{hash, HostError, Result, Workspace},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Initial merge uses a confirmed fixed remote snapshot and never replays obsolete paths.
|
||||
//! 初始合并使用已确认的固定远程快照,并且从不重播过时的路径。
|
||||
use crate::{
|
||||
sync_inbox::RemoteRevision,
|
||||
sync_state::Binding,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! User decisions are durable before changing files; journal IDs make restart replay safe.
|
||||
//! 用户决策在更改文件之前是持久的;日志 ID 使重新启动重放变得安全。
|
||||
use crate::workspace::hash;
|
||||
use crate::{
|
||||
sync_inbox::RemoteRevision,
|
||||
@@ -79,8 +79,7 @@ impl Workspace {
|
||||
if !crate::sync_discovery::allowed(destination) {
|
||||
return Err(HostError::new("SYNC_PATH_DENIED"));
|
||||
}
|
||||
// Validate the intended record path before freezing the decision.
|
||||
// A typo must not leave an unchangeable, unappliable resolution.
|
||||
// 在冻结决定之前验证预期的记录路径。拼写错误不得留下不可更改、不适用的解决方案。
|
||||
if crate::records::is_record(destination) {
|
||||
let spool = self.sync_spool(¤t)?;
|
||||
let size = fs::metadata(&spool)?.len();
|
||||
@@ -161,8 +160,7 @@ impl Workspace {
|
||||
if head != sequence {
|
||||
return Err(HostError::new("SYNC_CONFLICT_CHANGED"));
|
||||
}
|
||||
// local_path is frozen when the conflict is recorded. Recomputing it from
|
||||
// file identity after a partial resolution can select a different file.
|
||||
// 记录冲突时local_path被冻结。部分解析后根据文件标识重新计算可以选择不同的文件。
|
||||
let path = stored_path;
|
||||
if !self
|
||||
.operation(&operation)?
|
||||
@@ -216,10 +214,8 @@ impl Workspace {
|
||||
&& !source_hash.is_empty()
|
||||
&& !source_renamed
|
||||
{
|
||||
// The deleted target still owns its unique database path until the
|
||||
// final identity-aware write retires that tombstone. Retire the
|
||||
// incoming identity at its old path, then resurrect it at the
|
||||
// target with the frozen remote or chosen-local bytes.
|
||||
// 被删除的目标仍占用其唯一数据库路径,直到最后一次感知身份的写入撤销该逻辑删除记录。
|
||||
// 先在旧路径停用传入标识,再用冻结的远程内容或用户选择的本地内容在目标路径恢复它。
|
||||
self.mutate_with_origin(
|
||||
"delete",
|
||||
&source_path,
|
||||
@@ -640,7 +636,7 @@ mod tests {
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
// Simulate a crash after the filesystem journal commits but before the resolution transaction.
|
||||
// 在文件系统日志提交之后但在解析事务之前模拟崩溃。
|
||||
if choice == "copy" {
|
||||
ws.write_operation("copy.md", "", b"local", "local", ©)
|
||||
.unwrap();
|
||||
@@ -732,7 +728,7 @@ mod tests {
|
||||
remote.operation_id = Uuid::new_v4().to_string();
|
||||
receive(&mut ws, &binding.id, &remote);
|
||||
assert_eq!(ws.read("a.md").unwrap().entry.file_id, remote.file_id);
|
||||
// A tombstoned identity can reappear at another free path.
|
||||
// 已标记为删除的标识可以在另一个空闲路径重新出现。
|
||||
remote.sequence = 4;
|
||||
remote.base_revision = 2;
|
||||
remote.file_id = original.clone();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Binding-scoped retry decisions survive restart; wall time is bounded after clock changes.
|
||||
//! 绑定范围内的重试决定在重启后仍然有效;系统时钟变化后也会约束实际经过时间。
|
||||
use crate::workspace::{Result, Workspace};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
@@ -105,7 +105,7 @@ impl Workspace {
|
||||
if code == "SYNC_CANCELLED" {
|
||||
return Ok(());
|
||||
}
|
||||
// Only retain a bounded machine code, never an arbitrary remote response string.
|
||||
// 仅保留有界机器代码,从不保留任意远程响应字符串。
|
||||
let code = machine_code(code);
|
||||
let failures = if code == "CREDENTIALS_LOCKED" {
|
||||
0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Device-local choices for optional logical data; never exported as sync records.
|
||||
//! 可选逻辑数据的设备本地选择;从未导出为同步记录。
|
||||
use crate::workspace::{HostError, Result, Workspace};
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -91,7 +91,7 @@ mod tests {
|
||||
.sync_bind_empty("https://sync.example", "remote", "account")
|
||||
.unwrap();
|
||||
let job = ws.sync_next(&binding.id).unwrap().unwrap();
|
||||
// Models a pre-scope database's pending job after schema migration.
|
||||
// 对模式迁移后范围内数据库的待处理作业进行建模。
|
||||
ws.db
|
||||
.execute("DELETE FROM sync_optional_scope", [])
|
||||
.unwrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Durable queue state. Network code never invents a remote base from a local revision.
|
||||
//! 持久队列状态。网络代码永远不会从本地版本创建远程基础。
|
||||
use crate::workspace::{HostError, Result, Workspace};
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -56,7 +56,7 @@ impl Workspace {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Caller verifies an empty remote and obtains a reconciliation confirmation first.
|
||||
/// 调用者验证空远程并首先获得协调确认。
|
||||
pub fn sync_bind_empty(
|
||||
&mut self,
|
||||
endpoint: &str,
|
||||
@@ -73,7 +73,7 @@ impl Workspace {
|
||||
})?;
|
||||
let paths = self.sync_paths()?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
// Rebinding explicitly starts from the current snapshot, never an old account's queue.
|
||||
// 重新绑定显式从当前快照开始,而不是旧帐户的队列。
|
||||
if had_binding {
|
||||
self.db.execute(
|
||||
"UPDATE outbox SET state='archived' WHERE state IN ('pending','queued')",
|
||||
@@ -246,7 +246,7 @@ impl Workspace {
|
||||
}
|
||||
pub fn sync_commit_payload(&self, job: &Job) -> Result<serde_json::Value> {
|
||||
self.check_job(job)?;
|
||||
// The base is frozen exactly once. A response loss reuses the byte-equivalent payload.
|
||||
// 基准快照只冻结一次;响应丢失后复用字节完全相同的负载。
|
||||
self.db.execute("UPDATE sync_jobs SET state='committing',base_revision=COALESCE((SELECT revision FROM sync_heads WHERE binding=?1 AND file_id=?3),0) WHERE binding=?1 AND operation_id=?2 AND base_revision IS NULL",
|
||||
params![job.binding,job.operation_id,job.file_id])?;
|
||||
let base: i64 = self.db.query_row(
|
||||
|
||||
@@ -152,7 +152,7 @@ impl Workspace {
|
||||
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
|
||||
}
|
||||
if (1..13).contains(&version) {
|
||||
// Independent, complete SQLite backup before the schema ownership change.
|
||||
// 模式所有权更改之前独立、完整的 SQLite 备份。
|
||||
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
|
||||
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
|
||||
}
|
||||
@@ -476,10 +476,7 @@ impl Workspace {
|
||||
)
|
||||
}
|
||||
|
||||
/// Revalidate the caller before work and immediately before committing the
|
||||
/// durable write intent. Once accepted, recovery must finish that intent.
|
||||
/// The caller must serialize the authorization boundary if strict atomic
|
||||
/// ordering with concurrent revocation is required.
|
||||
/// 在工作之前和提交持久写入意图之前重新验证调用者。一旦接受,恢复必须完成该意图。如果需要并发撤销的严格原子排序,调用者必须序列化授权边界。
|
||||
#[cfg(any(test, all(windows, feature = "desktop")))]
|
||||
pub(crate) fn write_operation_guarded(
|
||||
&mut self,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Narrow Core RPC. Every request is bound to the Vault captured by the Host transport.
|
||||
//! 受限的 Core RPC;每个请求都绑定到 Host 传输捕获的 Vault。
|
||||
use crate::workspace::Workspace;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Executes the real worktree Core, with no personal data or external Provider.
|
||||
//! 执行真实的工作树Core,没有个人数据或外部提供者。
|
||||
use notesagent_host::core::CoreSupervisor;
|
||||
use notesagent_host::credentials::{CredentialBroker, CredentialId, Scope};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#![cfg(feature = "desktop")]
|
||||
//! Real Python Core + Host pipes + isolated Workspace; no personal data or Provider.
|
||||
//! 真正的Python Core + Host管道+隔离工作区;没有个人数据或提供商。
|
||||
use notesagent_host::{core::CoreSupervisor, workspace::Workspace, workspace_broker};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
@@ -289,8 +289,7 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits()
|
||||
.path()
|
||||
.join("core/unbound-vault/Core fixture.md")
|
||||
.exists());
|
||||
// The actual Python HTTP handler must round-trip revision through Host pipes,
|
||||
// including repeat requests after a successful commit.
|
||||
// 实际的 Python HTTP 处理程序必须通过 Host 管道进行往返修订,包括成功提交后的重复请求。
|
||||
let path = "/api/settings/persona";
|
||||
let (status, empty) = request(
|
||||
&mut core,
|
||||
@@ -369,8 +368,7 @@ async fn real_core_notes_roundtrip_only_through_bound_host_and_confirm_commits()
|
||||
.unwrap();
|
||||
assert_eq!(stored["hash"], first["revision"]);
|
||||
|
||||
// User-created Skills are Vault records, use Host CAS/idempotency, and are
|
||||
// resolved by the actual Agent route without copying package paths or grants.
|
||||
// 用户创建的Skills是Vault记录,使用Host CAS/幂等性,并通过实际的Agent路由解析,无需复制包路径或授权。
|
||||
let create_skill_operation = uuid::Uuid::new_v4();
|
||||
let skill_body = json!({
|
||||
"revision":"", "name":"Vault reviewer", "description":"portable",
|
||||
|
||||
@@ -25,7 +25,7 @@ fn stale_writer_is_rejected_then_handoff_preserves_both_commits() {
|
||||
assert!(two.resolve(&Scope::Provider, &a).unwrap().is_some());
|
||||
two.put(&b, Zeroizing::new(b"fixture-b".to_vec())).unwrap();
|
||||
two.lock();
|
||||
// A failed password attempt must release its ownership too.
|
||||
// 失败的密码尝试也必须释放其所有权。
|
||||
assert!(one
|
||||
.unlock(Zeroizing::new(b"wrong-fixture-password".to_vec()))
|
||||
.is_err());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Standalone native test probe; never shipped or used to launch extensions.
|
||||
//! 独立本机测试探针;从未发货或用于启动扩展。
|
||||
use std::net::{SocketAddr, TcpStream, UdpSocket};
|
||||
use std::time::Duration;
|
||||
fn main() {
|
||||
@@ -84,7 +84,7 @@ fn main() {
|
||||
if !stale.is_empty() { reply(id(&stale), r#"{"content":[],"structuredContent":{"ok":true}}"#); }
|
||||
return;
|
||||
}
|
||||
// MCP server remains alive between calls until Host closes stdin.
|
||||
// MCP 服务器在调用之间保持活动状态,直到 Host 关闭标准输入。
|
||||
while !read(&mut input).is_empty() {}
|
||||
return;
|
||||
}
|
||||
@@ -107,8 +107,7 @@ fn main() {
|
||||
}
|
||||
let sentinel: usize = args[2].parse().unwrap();
|
||||
let mut identity = FileIdentity { volume: 0, id: [0; 16] };
|
||||
// Numeric handles may alias unrelated child objects. Compare the actual
|
||||
// file identity without reading from a possibly aliased pipe handle.
|
||||
// 数字句柄可能会为不相关的子对象起别名。比较实际文件标识,而不读取可能有别名的管道句柄。
|
||||
if unsafe { GetFileInformationByHandleEx(sentinel as *mut _, 18,
|
||||
(&mut identity as *mut FileIdentity).cast(),
|
||||
std::mem::size_of::<FileIdentity>() as u32) } != 0 {
|
||||
|
||||
@@ -108,8 +108,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.sync_next(&binding.id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// Commit the first revision, then kill the client before the response can
|
||||
// acknowledge the local journal. Reopen must keep all pending operations.
|
||||
// 提交第一个修订,然后在响应确认本地日志之前终止客户端。重新打开必须保留所有挂起的操作。
|
||||
std::fs::write(
|
||||
root.path().join("interrupt-revision"),
|
||||
b"controlled-fixture",
|
||||
@@ -255,7 +254,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.file_id,
|
||||
first.file_id
|
||||
);
|
||||
// Receiving one's historical commits never rolls back newer local edits.
|
||||
// 接收历史提交永远不会回滚较新的本地编辑。
|
||||
{
|
||||
let mut ws = workspace.lock().unwrap();
|
||||
let current = ws.read("note.md").unwrap();
|
||||
@@ -310,7 +309,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.cursor,
|
||||
21
|
||||
);
|
||||
// All three explicit choices converge; the local copy gets an independent file ID.
|
||||
// 所有三个显式选择都收敛;本地副本获得独立文件 ID。
|
||||
for (iteration, choice) in ["local", "remote", "copy"].into_iter().enumerate() {
|
||||
if iteration > 0 {
|
||||
for (ws, content) in [(&workspace, "next-a"), (&workspace_b, "next-b")] {
|
||||
@@ -351,7 +350,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ws.sync_conflicts(&binding_b.id).unwrap().is_empty());
|
||||
// Repeating a persisted decision is harmless.
|
||||
// 重复提交同一个已持久化决定不会产生副作用。
|
||||
ws.sync_resolve(
|
||||
&binding_b.id,
|
||||
sequence,
|
||||
@@ -447,7 +446,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.file_id
|
||||
);
|
||||
|
||||
// Initial merge reviews a fixed snapshot and preserves conflicting local content.
|
||||
// 初始合并会检查固定快照并保留冲突的本地内容。
|
||||
let merge_root = tempfile::tempdir().unwrap();
|
||||
let merge_ws = Arc::new(Mutex::new(Workspace::open(merge_root.path()).unwrap()));
|
||||
let snapshot = client.snapshot(remote).await.unwrap();
|
||||
@@ -625,8 +624,8 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.unwrap()["record"]["data"]["fontEditorSize"],
|
||||
24
|
||||
);
|
||||
// Portable records traverse real HTTP, including equal display versions with
|
||||
// different contents. A numeric persona version cannot replace content CAS.
|
||||
// 可移植记录通过真实 HTTP 传输,包括显示版本相同但内容不同的情况;
|
||||
// 数值型角色版本不能取代内容 CAS。
|
||||
for (kind, id, data, field) in [
|
||||
(
|
||||
"persona",
|
||||
@@ -760,8 +759,8 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
);
|
||||
}
|
||||
|
||||
// A default-off device consumes history metadata without downloading either
|
||||
// optional record. Rebinding after opt-in must fetch the already-seen heads.
|
||||
// 默认关闭该功能的设备只读取历史元数据,不下载任何可选记录;启用后重新绑定时,
|
||||
// 必须获取先前已经见过的最新版本。
|
||||
let excluded_root = tempfile::tempdir().unwrap();
|
||||
let excluded_ws = Arc::new(Mutex::new(Workspace::open(excluded_root.path()).unwrap()));
|
||||
let excluded_binding = excluded_ws
|
||||
@@ -853,8 +852,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
// Kill the actual client process after each durable 10 MiB server offset,
|
||||
// before its response reaches the client. The next process must query offset.
|
||||
// 在每个持久的 10 MiB 服务器偏移之后,在其响应到达客户端之前,终止实际的客户端进程。下一个进程必须查询偏移量。
|
||||
use sha2::{Digest, Sha256};
|
||||
let large_remote = client
|
||||
.json(
|
||||
@@ -981,7 +979,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
// SQLite stores metadata, never the 100 MiB body.
|
||||
// SQLite 存储元数据,而不是 100 MiB 主体。
|
||||
for directory in [large_root.path(), download_root.path()] {
|
||||
let managed = directory.join(".ainote");
|
||||
for item in std::fs::read_dir(managed).unwrap().flatten() {
|
||||
@@ -993,7 +991,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
}
|
||||
}
|
||||
}
|
||||
// Host sessions survive encrypted storage reopen and refresh on the actual service.
|
||||
// Host 会话可以在实际服务上重新打开加密存储并刷新。
|
||||
use notesagent_host::{credentials::CredentialBroker, sync_auth};
|
||||
let credential_root = tempfile::tempdir().unwrap();
|
||||
let credential_path = credential_root.path().join("credentials.onxcred");
|
||||
@@ -1032,7 +1030,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.unwrap()
|
||||
.unlock(Zeroizing::new(b"fixture-stronghold-password".to_vec()))
|
||||
.unwrap();
|
||||
// Expire the access token early in this isolated fixture; the refresh token stays valid.
|
||||
// 在此隔离装置中尽早使访问令牌过期;刷新令牌保持有效。
|
||||
let database = rusqlite::Connection::open(root.path().join("sync.sqlite3")).unwrap();
|
||||
database
|
||||
.execute("UPDATE sessions SET expires=0", [])
|
||||
@@ -1054,7 +1052,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|v| v["id"] == remote));
|
||||
// Even a repeated 401 must stop after one rotation, rather than refresh indefinitely.
|
||||
// 即使再次收到 401,也只能轮换一次令牌,不能无限刷新。
|
||||
attempts.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
let denied = sync_auth::authenticated(&credentials, &canonical, "rust-fixture", |_client| {
|
||||
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
@@ -32,7 +32,7 @@ const desktop = isDesktop()
|
||||
let statusTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let disposed = false
|
||||
async function pollIndex() {
|
||||
try { const status = await getIndexStatus(); if (!disposed) settingsStore.indexStatus = status } catch { /* retain last status; retry */ }
|
||||
try { const status = await getIndexStatus(); if (!disposed) settingsStore.indexStatus = status } catch { /* 保留最后状态;重试 */ }
|
||||
const busy = settingsStore.indexStatus.status === 'indexing' || settingsStore.indexStatus.active_searches
|
||||
if (!disposed) statusTimer = setTimeout(pollIndex, busy || route.name === 'settings' || route.name === 'search' ? 1000 : 5000)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,7 @@ function anchorZoom(svg: SVGSVGElement, event: WheelEvent) {
|
||||
anchorUntil = performance.now() + 240
|
||||
const follow = () => {
|
||||
if (!svg.isConnected) return
|
||||
// Inner horizontal overflow and the editor's outer vertical scroll may differ.
|
||||
// Re-measure after each scroll, letting the outer container take the remainder.
|
||||
// 内部水平溢出和编辑器的外部垂直滚动可能不同。每次滚动后重新测量,让外容器带走剩余的部分。
|
||||
for (const node of scrollers) {
|
||||
const current = svg.getBoundingClientRect()
|
||||
node.scrollLeft += current.left + x * current.width - screenX
|
||||
@@ -134,8 +133,7 @@ async function interact(event: MouseEvent) {
|
||||
disarm()
|
||||
opener = button
|
||||
const intrinsicWidth = widthOf(svg)
|
||||
// Mermaid HTML labels live in SVG foreignObject nodes. Preserve that
|
||||
// integration point while still sanitizing the embedded HTML and handlers.
|
||||
// Mermaid HTML 标签位于 SVGforeignObject 节点中。保留该集成点,同时仍然清理嵌入式 HTML 和处理程序。
|
||||
const copy = svg.cloneNode(true) as SVGSVGElement
|
||||
for (const label of copy.querySelectorAll('foreignObject, foreignobject')) {
|
||||
label.innerHTML = DOMPurify.sanitize(label.innerHTML, { USE_PROFILES: { html: true } })
|
||||
@@ -151,8 +149,7 @@ async function interact(event: MouseEvent) {
|
||||
const viewport = viewer.value?.querySelector<HTMLElement>('.diagram-viewer-scroll')
|
||||
const box = svg.getAttribute('viewBox')?.trim().split(/[ ,]+/).map(Number)
|
||||
const intrinsicHeight = box?.length === 4 && box[3]! > 0 ? box[3]! : svg.getBoundingClientRect().height
|
||||
// Opening is independent of the inline preview's zoom and any previous modal scroll.
|
||||
// Keep native size for small diagrams; fit wide/tall diagrams completely at 100%.
|
||||
// 打开独立于内联预览的缩放和任何先前的模式滚动。保持小图表的原始大小; 100% 完全适合宽/高图表。
|
||||
baseWidth.value = Math.min(intrinsicWidth, viewport?.clientWidth || intrinsicWidth,
|
||||
intrinsicHeight > 0 && viewport?.clientHeight ? viewport.clientHeight * intrinsicWidth / intrinsicHeight : intrinsicWidth)
|
||||
await nextTick()
|
||||
@@ -203,7 +200,7 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
|
||||
.diagram-viewer header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); flex-shrink: 0; }
|
||||
.diagram-viewer header .diagram-controls { flex-wrap: wrap; }
|
||||
.diagram-viewer-scroll { display: flex; flex: 1; min-height: 0; overflow: auto; }
|
||||
/* Auto margins center small diagrams and become zero on overflow, keeping all edges reachable. */
|
||||
/* 自动边距使小图居中并在溢出时变为零,从而保持所有边缘可达。 */
|
||||
.diagram-viewer-image { flex: 0 0 auto; margin: auto; transition: width 180ms ease-out; }
|
||||
.diagram-viewer-image svg { display: block; width: 100% !important; max-width: none !important; height: auto !important; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Reference counts keep the underlying page locked when dialogs are nested.
|
||||
// 当对话框嵌套时,引用计数会锁定底层页面。
|
||||
const locks = new WeakMap<HTMLElement, { count: number; value: string; priority: string }>()
|
||||
export function lockDialogScroll(dialog: HTMLElement): () => void {
|
||||
const elements: HTMLElement[] = []
|
||||
|
||||
@@ -2,7 +2,7 @@ import { nextTick, onBeforeUnmount, shallowRef } from 'vue'
|
||||
|
||||
export interface ActionDialogRequest { message: string; mode: 'confirm' | 'prompt'; initialValue: string }
|
||||
|
||||
/** Requests belong to the invoking view; leaving it cancels pending work. */
|
||||
/** 请求属于调用视图;离开它会取消待处理的工作。 */
|
||||
export function useActionDialog() {
|
||||
const actionDialog = shallowRef<ActionDialogRequest | null>(null)
|
||||
let pending: ((value: string | null) => void) | undefined
|
||||
@@ -11,7 +11,7 @@ export function useActionDialog() {
|
||||
const resolve = pending
|
||||
pending = undefined
|
||||
actionDialog.value = null
|
||||
await nextTick() // Restore focus and release the modal before the caller continues.
|
||||
await nextTick() // 在调用者继续之前恢复焦点并释放模式。
|
||||
resolve?.(disposed ? null : value)
|
||||
}
|
||||
function request(mode: ActionDialogRequest['mode'], message: string, initialValue = '') {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { onMounted, onUnmounted } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
|
||||
/** Web fallback until the desktop host supplies filesystem events. No overlapping polls. */
|
||||
/** Web 回退,直到桌面主机提供文件系统事件。没有重叠的民意调查。 */
|
||||
export function useWorkspaceRefresh() {
|
||||
const workspace = useWorkspaceStore()
|
||||
const editor = useEditorStore()
|
||||
@@ -21,7 +21,7 @@ export function useWorkspaceRefresh() {
|
||||
else await editor.checkExternalFile()
|
||||
}
|
||||
}
|
||||
} catch { /* Keep the existing tree; the store exposes the error and retries. */ }
|
||||
} catch { /* 保留现有树;商店暴露错误并重试。 */ }
|
||||
finally {
|
||||
running = false
|
||||
if (!stopped) timer = setTimeout(refresh, 2000)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============ Notes & Blocks ============
|
||||
// ============ 笔记与内容块 ============
|
||||
|
||||
export interface Note {
|
||||
note_id: string
|
||||
@@ -34,7 +34,7 @@ export interface FileNode {
|
||||
is_external_changed?: boolean
|
||||
}
|
||||
|
||||
// ============ Search ============
|
||||
// ============ 搜索 ============
|
||||
|
||||
export interface SearchRequest {
|
||||
query: string
|
||||
@@ -58,7 +58,7 @@ export interface SearchResult {
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
// ============ Chat ============
|
||||
// ============ 对话 ============
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string
|
||||
@@ -101,7 +101,7 @@ export interface Citation {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Model Events (SSE) ============
|
||||
// ============ 模型事件(SSE) ============
|
||||
|
||||
export type ModelEventType =
|
||||
| 'ContextStatus'
|
||||
@@ -122,7 +122,7 @@ export interface ModelEvent {
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// ============ Agent ============
|
||||
// ============ 智能体 ============
|
||||
|
||||
export type AgentRunStatus =
|
||||
| 'queued'
|
||||
@@ -221,7 +221,7 @@ export interface TokenUsage {
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
// ============ Skill ============
|
||||
// ============ Skill(技能) ============
|
||||
|
||||
export type SkillStatus =
|
||||
| 'installed'
|
||||
@@ -288,7 +288,7 @@ export interface UserSkillWriteRequest {
|
||||
required_capabilities: string[]
|
||||
}
|
||||
|
||||
// ============ Plugin ============
|
||||
// ============ Plugin(插件) ============
|
||||
|
||||
export type PluginStatus =
|
||||
| 'installed'
|
||||
@@ -420,7 +420,7 @@ export interface Plugin {
|
||||
dependent_skills?: string[]
|
||||
}
|
||||
|
||||
// ============ Provider ============
|
||||
// ============ 提供商 ============
|
||||
|
||||
export type ProviderType = ApiProviderType
|
||||
|
||||
@@ -514,7 +514,7 @@ export interface ModelRoutingResponse {
|
||||
}>
|
||||
}
|
||||
|
||||
// ============ Tasks ============
|
||||
// ============ 任务 ============
|
||||
|
||||
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
|
||||
export type TaskPriority = 'low' | 'medium' | 'high'
|
||||
@@ -534,7 +534,7 @@ export interface TaskItem {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ============ Theme ============
|
||||
// ============ 主题 ============
|
||||
|
||||
export interface ThemeConfig {
|
||||
theme_id: string
|
||||
@@ -547,7 +547,7 @@ export interface ThemeConfig {
|
||||
code_theme?: 'github-light' | 'github-dark'
|
||||
}
|
||||
|
||||
// ============ Index ============
|
||||
// ============ 索引 ============
|
||||
|
||||
export interface IndexStatus {
|
||||
running_jobs?: number
|
||||
@@ -568,7 +568,7 @@ export interface IndexStatus {
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ============ System ============
|
||||
// ============ 系统 ============
|
||||
|
||||
export interface ApiError {
|
||||
code: string
|
||||
@@ -598,9 +598,9 @@ export type SaveStatus =
|
||||
|
||||
export type AiCoreStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
|
||||
|
||||
// ============ FastAPI wire contracts ============
|
||||
// UI view models above may contain presentation-only fields. Services must use
|
||||
// these DTOs at the HTTP boundary and explicitly map them to view models.
|
||||
// ============ FastAPI 传输契约 ============
|
||||
// 上方的 UI 视图模型可能含有仅用于展示的字段。服务必须在 HTTP 边界使用这些 DTO,
|
||||
// 并将其显式映射为视图模型。
|
||||
|
||||
export interface PageMeta {
|
||||
total: number
|
||||
@@ -871,7 +871,7 @@ export interface ApiIndexJob {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ============ Theme Package (Phase 2) ============
|
||||
// ============ 主题包(第二阶段) ============
|
||||
|
||||
export interface ThemeManifest {
|
||||
theme_id: string
|
||||
@@ -924,7 +924,7 @@ export type ThemeErrorCode =
|
||||
| 'THEME_INSTALL_FAILED'
|
||||
| 'THEME_UNINSTALL_FAILED'
|
||||
|
||||
// ============ Mermaid Renderer (Phase 2) ============
|
||||
// ============ Mermaid 渲染器(第二阶段) ============
|
||||
|
||||
export interface MermaidRenderResult {
|
||||
svg: string
|
||||
@@ -939,7 +939,7 @@ export interface MermaidParseError {
|
||||
column?: number
|
||||
}
|
||||
|
||||
// ============ Agent Trace Node (Phase 2 visualization) ============
|
||||
// ============ Agent Trace 节点(第二阶段可视化) ============
|
||||
|
||||
export type TraceNodeType =
|
||||
| 'run'
|
||||
|
||||
@@ -106,8 +106,7 @@ const toolDescriptions: Record<string, string> = {
|
||||
'text.uppercase': '将输入文本中的字母转换为大写。',
|
||||
}
|
||||
|
||||
// MCP IDs contain a server-specific namespace. Localize the remote tool name
|
||||
// for presentation only; requests must keep using the complete original ID.
|
||||
// MCP ID 包含特定于服务器的命名空间。本地化远程工具名称仅用于演示;要求必须继续使用完整的原装ID。
|
||||
const mcpTools: Record<string, { label: string; description: string }> = {
|
||||
web_search: {
|
||||
label: '网页搜索',
|
||||
|
||||
@@ -10,8 +10,8 @@ const panel = ref<HTMLElement | null>(null)
|
||||
const storageKey = 'notes-agent.workspace-chat.bounds.v1'
|
||||
const width = ref(640), height = ref(680)
|
||||
const x = ref(Math.max(8, window.innerWidth - 660)), y = ref(64)
|
||||
try { const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); if (saved && [saved.x,saved.y,saved.width,saved.height].every(Number.isFinite)) { x.value=saved.x; y.value=saved.y; width.value=saved.width; height.value=saved.height } } catch { /* storage unavailable */ }
|
||||
function save() { try { localStorage.setItem(storageKey, JSON.stringify({x:x.value,y:y.value,width:width.value,height:height.value})) } catch { /* storage unavailable */ } }
|
||||
try { const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); if (saved && [saved.x,saved.y,saved.width,saved.height].every(Number.isFinite)) { x.value=saved.x; y.value=saved.y; width.value=saved.width; height.value=saved.height } } catch { /* 存储不可用 */ }
|
||||
function save() { try { localStorage.setItem(storageKey, JSON.stringify({x:x.value,y:y.value,width:width.value,height:height.value})) } catch { /* 存储不可用 */ } }
|
||||
function reset() { width.value=640; height.value=680; x.value=window.innerWidth-660; y.value=32; clamp(); save() }
|
||||
let resizing: { x:number; y:number; width:number; height:number } | null = null
|
||||
function resizeStart(e: PointerEvent) { if (e.button !== 0) return; resizing={x:e.clientX,y:e.clientY,width:width.value,height:height.value}; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); e.preventDefault() }
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('EditorPane file switching', () => {
|
||||
|
||||
expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(wrapper.text()).not.toContain('祝你写作愉快')
|
||||
}, 15000) // Real Milkdown is now imported lazily; cold module transforms count toward this integration test.
|
||||
}, 15000) // 真正的Milkdown现在被延迟导入;冷模块将计数转换为此集成测试。
|
||||
|
||||
it('applies the saved spell-check and language settings to source mode', async () => {
|
||||
const editor = useEditorStore()
|
||||
@@ -68,5 +68,5 @@ describe('EditorPane file switching', () => {
|
||||
expect(textarea.attributes('spellcheck')).toBe('true')
|
||||
expect(textarea.attributes('lang')).toBe('en')
|
||||
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
|
||||
}, 15000) // Lazy source-editor module transforms need the same cold-start budget.
|
||||
}, 15000) // 惰性源编辑器模块转换需要相同的冷启动预算。
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// The application has a doctype; happy-dom otherwise reports quirks mode to KaTeX.
|
||||
// 应用程序有一个文档类型; happy-dom 否则会向 KaTeX 报告怪癖模式。
|
||||
vi.hoisted(() => { Object.defineProperty(document, 'compatMode', {value:'CSS1Compat',configurable:true}) })
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
@@ -29,7 +29,7 @@ async function waitForEditor(wrapper: VueWrapper): Promise<Editor> {
|
||||
try {
|
||||
editor.action(getMarkdown())
|
||||
return editor
|
||||
} catch { /* editor is still creating */ }
|
||||
} catch { /* 编辑器仍在创建 */ }
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
@@ -263,7 +263,7 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
|
||||
mounted.push(wrapper)
|
||||
const editor = await waitForEditor(wrapper)
|
||||
// Chromium/IME can omit data and commit its DOM change after the input event.
|
||||
// Chromium/IME 可以省略数据并在输入事件后提交其 DOM 更改。
|
||||
await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null})
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
|
||||
@@ -292,8 +292,7 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
view.dispatch(view.state.tr.insertText('``'))
|
||||
await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'`'})
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
// Empty pairs are serialized as escaped literal text, but that must not
|
||||
// prevent recognition after the user moves back and fills in the content.
|
||||
// 空对被序列化为转义文字文本,但这不得妨碍用户向后移动并填写内容后的识别。
|
||||
expect(editor.action(getMarkdown())).toContain('\\`')
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)).insertText('s'))
|
||||
await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'s'})
|
||||
|
||||
@@ -350,8 +350,8 @@ onMounted(async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
// Crepe's defaultsDeep merges language arrays and theme extension internals.
|
||||
// Replace both AFTER feature configuration to avoid default grammar collisions.
|
||||
// Crepe 的 defaultsDeep 会合并语言数组与主题扩展内部配置。
|
||||
// 必须在功能配置完成后同时替换两者,以免默认语法发生冲突。
|
||||
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
|
||||
...config,
|
||||
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
|
||||
@@ -391,7 +391,7 @@ onMounted(async () => {
|
||||
const sections = headingSections(current.state.doc)
|
||||
const folded = headingFoldKey.getState(current.state)
|
||||
hasFoldableHeadings.value = sections.length > 0
|
||||
// Hidden descendants retain their own state but are not visible expanded sections.
|
||||
// 被隐藏的后代节点保留自身状态,但不算作可见的展开章节。
|
||||
let hiddenUntil = -1
|
||||
allHeadingsFolded.value = sections.length > 0 && sections.every(section => {
|
||||
if (section.from < hiddenUntil) return true
|
||||
@@ -585,8 +585,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
|
||||
/* Mermaid measures HTML labels outside the editor. Crepe's paragraph padding
|
||||
must not enlarge them after insertion into fixed-size SVG foreignObjects. */
|
||||
/* Mermaid 在编辑器外部测量 HTML 标签。 Crepe 的段落填充在插入固定大小的 SVGforeignObjects 后不得放大它们。 */
|
||||
.milkdown-host :deep(.editor-mermaid-preview svg foreignObject p) { margin: 0; padding: 0; line-height: inherit; font-weight: inherit; }
|
||||
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
|
||||
.milkdown-host :deep(.font-size-marker) { display: none; }
|
||||
|
||||
@@ -16,7 +16,7 @@ export const configureCalloutSerialization: Parameters<Editor['config']>[0] = ct
|
||||
tracker.shift(2)
|
||||
const result = state.indentLines(state.containerFlow(node, tracker.current()), (line, _index, blank) => `>${blank ? '' : ' '}${line}`)
|
||||
exit()
|
||||
// Only remove escaping from a leading callout marker, never body literals.
|
||||
// 仅删除前导标注标记的转义,绝不删除正文文字。
|
||||
return result.replace(/^(> )\\\[!([\w-]+)\\?\]/, '$1[!$2]')
|
||||
} },
|
||||
}))
|
||||
@@ -36,8 +36,7 @@ function calloutMarkers(doc: ProseNode) {
|
||||
return markers
|
||||
}
|
||||
|
||||
// Keep native blockquotes in the document: typing, undo and Markdown serialization
|
||||
// remain Milkdown transactions; the view never rewrites a user's callout source.
|
||||
// 在文档中保留本机块引用:键入、撤消和 Markdown 序列化保留 Milkdown 事务;该视图永远不会重写用户的标注源。
|
||||
export const calloutPlugin = $prose(() => new Plugin({
|
||||
props: {
|
||||
decorations(state) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Mirror changed language labels without rescanning every code block on each DOM mutation. */
|
||||
/** 镜像更改的语言标签,无需重新扫描每个 DOM 突变上的每个代码块。 */
|
||||
export function installCodeBlockLabels(root: HTMLElement): () => void {
|
||||
const sync = (block: HTMLElement) => {
|
||||
const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text'
|
||||
@@ -13,7 +13,7 @@ export function installCodeBlockLabels(root: HTMLElement): () => void {
|
||||
const changed = new Set<HTMLElement>()
|
||||
for (const record of records) {
|
||||
const element = record.target instanceof Element ? record.target : record.target.parentElement
|
||||
// CodeMirror viewport/text changes do not change the footer's language.
|
||||
// CodeMirror 视口/文本更改不会更改页脚的语言。
|
||||
const label = element?.closest('.language-button')
|
||||
const block = label?.closest<HTMLElement>('.milkdown-code-block')
|
||||
if (block) changed.add(block)
|
||||
|
||||
@@ -8,7 +8,7 @@ export const headingFoldKey = new PluginKey<Set<number>>('heading-folding')
|
||||
type Section = { from: number; body: number; end: number; level: number }
|
||||
const sectionCache = new WeakMap<Node, Section[]>()
|
||||
const decorationCache = new WeakMap<Node, WeakMap<Set<number>, DecorationSet>>()
|
||||
/** A section ends at the next sibling heading of the same or a higher rank. */
|
||||
/** 节以相同或更高级别的下一个同级标题结束。 */
|
||||
export function headingSections(doc: Node): Section[] {
|
||||
const cached = sectionCache.get(doc)
|
||||
if (cached) return cached
|
||||
@@ -79,7 +79,7 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
|
||||
const result = tr.mapping.mapResult(old, 1)
|
||||
if (!result.deleted && positions.has(result.pos)) mapped.add(result.pos)
|
||||
}
|
||||
// Outline jumps, find and keyboard navigation must never leave a hidden caret.
|
||||
// 轮廓跳转、查找和键盘导航绝不能留下隐藏的插入符号。
|
||||
if (tr.selectionSet || tr.docChanged) {
|
||||
for (const section of sections) if (tr.selection.to >= section.body && tr.selection.from < section.end) mapped.delete(section.from)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ function reconcile(view: EditorView) {
|
||||
const { $from } = view.state.selection
|
||||
if (!$from.parent.isTextblock || $from.parent.type.spec.code) return
|
||||
const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\ufffc')
|
||||
// Also inspect the closing delimiter AFTER the caret: users commonly type
|
||||
// a pair of backticks first, move left, and then fill in the code.
|
||||
// 还要检查结束分隔符 AFTER 插入符号:用户通常首先键入一对反引号,向左移动,然后填写代码。
|
||||
const spans = /(^|[^\\`])`([^`\n\ufffc]+)`(?!`)/g
|
||||
let candidate: { start: number; end: number } | undefined
|
||||
for (const match of text.matchAll(spans)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Generated by scripts/generate-language-icons.mjs. VSCode Icons (MIT); see language-icons-LICENSE.txt. */
|
||||
/* 由 scripts/generate-language-icons.mjs 自动生成。VSCode Icons 采用 MIT 许可证;详情见 language-icons-LICENSE.txt。 */
|
||||
.milkdown-host .language-list-item[data-language] { display: flex; align-items: center; gap: 8px; }
|
||||
.milkdown-host .language-list-item[data-language]::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23c5c5c5%22%20d%3D%22M20.414%202H5v28h22V8.586ZM7%2028V4h12v6h6v18Z%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
.milkdown-host .language-list-item[data-language][data-language="actionscript-3"]::before { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M2%2015.281c1.918%200%202.11-1.055%202.11-1.918a17%2017%200%200%200-.192-2.205a19%2019%200%200%201-.192-2.205c0-2.4%201.63-3.452%203.836-3.452h.575v1.437h-.479c-1.534%200-2.11.767-2.11%202.205a14%2014%200%200%200%20.192%201.918a14%2014%200%200%201%20.192%202.014c0%201.726-.671%202.493-1.918%202.877v.1c1.151.288%201.918%201.151%201.918%202.877a14%2014%200%200%201-.192%202.014a13%2013%200%200%200-.192%201.918c0%201.438.575%202.3%202.11%202.3h.479V26.6h-.575c-2.205%200-3.836-.959-3.836-3.644a19%2019%200%200%201%20.192-2.205a16%2016%200%200%200%20.192-2.11c0-.863-.288-1.918-2.11-1.918Z%22%2F%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M9.479%2018.062L8.233%2021.8H6.6l4.03-11.889h1.822L16.479%2021.8h-1.534L13.7%2018.062Zm3.932-1.151l-1.151-3.452a9.4%209.4%200%200%201-.575-2.205c-.192.671-.384%201.438-.575%202.11l-1.151%203.451h3.452Zm4.507%203.068a5.94%205.94%200%200%200%202.781.767c1.534%200%202.493-.863%202.493-2.014s-.671-1.726-2.205-2.4c-1.918-.671-3.164-1.726-3.164-3.356c0-1.822%201.534-3.26%203.836-3.26a5.14%205.14%200%200%201%202.589.575l-.384%201.247a5.5%205.5%200%200%200-2.3-.479c-1.63%200-2.205.959-2.205%201.822c0%201.151.767%201.63%202.4%202.3c2.014.767%203.068%201.726%203.068%203.452c0%201.822-1.342%203.452-4.123%203.452a5.8%205.8%200%200%201-3.068-.767Z%22%2F%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M30%2016.623c-1.918%200-2.11%201.151-2.11%201.918a16%2016%200%200%200%20.192%202.11a16%2016%200%200%201%20.192%202.205c0%202.685-1.63%203.644-3.836%203.644h-.575v-1.438h.479c1.438%200%202.11-.863%202.11-2.3a13%2013%200%200%200-.192-1.918a14%2014%200%200%201-.192-2.014c0-1.726.767-2.589%201.918-2.877v-.1c-1.151-.288-1.918-1.151-1.918-2.877a14%2014%200%200%201%20.192-2.014a13%2013%200%200%200%20.192-1.918c0-1.438-.575-2.205-2.11-2.3h-.479V5.4h.575c2.205%200%203.836%201.055%203.836%203.452a17%2017%200%200%201-.192%202.205a17%2017%200%200%200-.192%202.205c0%20.959.288%201.918%202.11%201.918Z%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Promote menus to the top layer; only open menus need scroll measurements. */
|
||||
/** 将菜单提升到顶层;只有打开的菜单才需要滚动测量。 */
|
||||
export function installLanguagePickerPopover(root: HTMLElement): () => void {
|
||||
const menus = new Set<HTMLElement>()
|
||||
const openMenus = new Set<HTMLElement>()
|
||||
@@ -55,7 +55,7 @@ export function installLanguagePickerPopover(root: HTMLElement): () => void {
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>('.language-picker').forEach(sync)
|
||||
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
|
||||
// The outer editor viewport is an ancestor of root, so listen in capture on the document.
|
||||
// 外部编辑器视口是根的祖先,因此在文档上侦听捕获。
|
||||
document.addEventListener('scroll', positionOpenMenus, { capture: true, passive: true })
|
||||
window.addEventListener('resize', positionOpenMenus)
|
||||
return () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Editable anchors need explicit navigation; plain clicks keep editing the link. */
|
||||
/** 可编辑锚点需要显式导航;简单的点击即可继续编辑链接。 */
|
||||
export function installLinkNavigation(root: HTMLElement): () => void {
|
||||
const navigate = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || !(event.ctrlKey || event.metaKey) || event.altKey) return
|
||||
@@ -7,7 +7,7 @@ export function installLinkNavigation(root: HTMLElement): () => void {
|
||||
if (!link || !root.contains(link)) return
|
||||
const href = link.getAttribute('href')?.trim()
|
||||
if (!href) return
|
||||
// Consume modified clicks before Milkdown's link editor or native navigation.
|
||||
// 在 Milkdown 的链接编辑器或本机导航之前消耗修改的点击。
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
let url: URL
|
||||
|
||||
@@ -6,9 +6,7 @@ import { appendDiagramControls } from '@/utils/diagramControls'
|
||||
|
||||
let previewId = 0
|
||||
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void, kind = 'mermaid', themeId = 'light'): HTMLElement {
|
||||
// Each revision owns its element, so a slow render cannot replace newer content.
|
||||
// Milkdown sanitizes Element input to its inner HTML; retain the revision
|
||||
// marker and controls inside an otherwise disposable envelope.
|
||||
// 每个修订版本都拥有其元素,因此缓慢的渲染无法替换较新的内容。 Milkdown 清理其内部 HTML 的 Element 输入;将修订标记和控件保留在一次性信封内。
|
||||
const envelope = document.createElement('div')
|
||||
const container = document.createElement('div')
|
||||
envelope.append(container)
|
||||
@@ -19,12 +17,10 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
container.textContent = t('正在渲染图表…', 'Rendering diagram…')
|
||||
const publish = async () => {
|
||||
await nextTick()
|
||||
// Milkdown sanitizes and copies this element. Publish only if its revision
|
||||
// still exists; edits, language changes and unmounts remove the old marker.
|
||||
// Milkdown 清理并复制该元素。仅当其修订版本仍然存在时才发布;编辑、语言更改和卸载会删除旧标记。
|
||||
const visible = document.getElementById(container.id)
|
||||
if (visible) {
|
||||
// PreviewPanel copies HTML instead of retaining the supplied element.
|
||||
// Update the current copy through Milkdown's reactive callback.
|
||||
// PreviewPanel 复制 HTML,而不是保留提供的元素。通过 Milkdown 的反应式回调更新当前副本。
|
||||
applyPreview(envelope.cloneNode(true) as HTMLElement)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +34,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
void publish()
|
||||
return
|
||||
}
|
||||
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
|
||||
// Mermaid以严格模式运行; Milkdown 在插入之前清理预览。
|
||||
container.innerHTML = result.svg
|
||||
appendDiagramControls(container)
|
||||
if (result.warnings.length) { const warning = document.createElement('p'); warning.textContent = result.warnings.join('\n'); warning.setAttribute('role', 'status'); container.append(warning) }
|
||||
|
||||
@@ -7,8 +7,7 @@ type CodeTheme = 'github-light' | 'github-dark'
|
||||
|
||||
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
|
||||
const tokenize = await getCodeTokenizer(theme, language)
|
||||
// Milkdown recreates off-screen CodeMirror views. Reuse immutable ranges for
|
||||
// identical code within this language/theme, with a bounded retention budget.
|
||||
// Milkdown 重新创建屏幕外 CodeMirror 视图。在该语言/主题内重复使用相同代码的不可变范围,并保留有限的预算。
|
||||
const cache = new Map<string, DecorationSet>()
|
||||
let cachedCharacters = 0
|
||||
const highlights = ViewPlugin.fromClass(class {
|
||||
@@ -53,7 +52,7 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
|
||||
}
|
||||
}, { decorations: value => value.decorations })
|
||||
|
||||
// CodeMirror still owns selection, input and undo. Shiki owns token colors.
|
||||
// CodeMirror仍然拥有选择、输入和撤消功能。 Shiki拥有令牌颜色。
|
||||
const parser = StreamLanguage.define({ token(stream) { stream.skipToEnd(); return null } })
|
||||
return new LanguageSupport(parser, highlights)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ async function load(reset = false, older = false) {
|
||||
const viewport = scroller.value
|
||||
const oldHeight = viewport?.scrollHeight ?? 0
|
||||
const oldTop = viewport?.scrollTop ?? 0
|
||||
// Preserve a visible row when adding history and trimming the opposite edge.
|
||||
// 添加历史记录并修剪相对边缘时保留可见行。
|
||||
const anchor = older && viewport ? [...viewport.querySelectorAll<HTMLElement>('[data-log-id]')].find(row => row.getBoundingClientRect().bottom > viewport.getBoundingClientRect().top) : undefined
|
||||
const anchorTop = anchor?.getBoundingClientRect().top
|
||||
const anchorId = anchor?.dataset.logId
|
||||
@@ -30,7 +30,7 @@ async function load(reset = false, older = false) {
|
||||
try {
|
||||
const result = await apiClient.get<LogPage>('/api/logs', { params: { limit: 50, before: older ? page.value.next_cursor ?? undefined : undefined, ...applied } })
|
||||
if (version !== revision) return
|
||||
// If the reader scrolled away during a refresh, leave their view untouched.
|
||||
// 如果读者在刷新期间滚动离开,请保持他们的视图不变。
|
||||
if (!reset && !older && !following.value) return
|
||||
const previous = page.value.items
|
||||
const overlaps = result.items.some(item => previous.some(old => old.id === item.id))
|
||||
|
||||
@@ -115,8 +115,7 @@ function formPayload(): McpServerInput {
|
||||
function payload(requireConnection = true): McpServerInput {
|
||||
const { config, secrets } = editorMode.value === 'form'
|
||||
? normalizeMcpConfig(formPayload(), '', requireConnection) : parseMcpJson(rawConfig.value, form.name, requireConnection)
|
||||
// Keep only still-declared drafts. A mode switch must not discard imported keys,
|
||||
// and editing the declaration must not later send a removed key to the Secret API.
|
||||
// 仅保留仍声明的草稿。模式开关不得丢弃导入的密钥,并且编辑声明后不得将删除的密钥发送到 Secret API。
|
||||
importedSecrets.value = mergeImportedSecrets(config, importedSecrets.value, secrets)
|
||||
if (editingId.value) config.version = form.version
|
||||
if (editorMode.value === 'json') rawConfig.value = JSON.stringify(config, null, 2)
|
||||
@@ -149,8 +148,7 @@ async function save() {
|
||||
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !(await askConfirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?')))) return
|
||||
busy.value = 'save'
|
||||
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
||||
// Commit the returned ID/version before saving secrets so a partial failure can
|
||||
// retry this server instead of creating a duplicate or sending a stale version.
|
||||
// 在保存机密之前提交返回的 ID/版本,以便部分失败可以重试此服务器,而不是创建重复版本或发送过时的版本。
|
||||
editingId.value = saved.server_id
|
||||
editingOriginal.value = saved
|
||||
resetEditor({ ...input, version: saved.version })
|
||||
|
||||
@@ -11,8 +11,7 @@ export function mergeImportedSecrets(config: McpServerInput, previous: ImportedS
|
||||
const keys = item.kind === 'header' ? config.secret_header_keys : config.secret_environment_keys
|
||||
const declared = keys.find(key => normalize(key) === normalize(item.key))
|
||||
if (declared === undefined) continue
|
||||
// HTTP identity is case-insensitive, but the Secret API requires the current
|
||||
// declared spelling. New inline values replace older drafts of that identity.
|
||||
// HTTP 身份不区分大小写,但 Secret API 需要当前声明的拼写。新的内联值取代了该身份的旧草稿。
|
||||
merged.set(`${item.kind}:${normalize(declared)}`, { ...item, key: declared })
|
||||
}
|
||||
return [...merged.values()]
|
||||
@@ -50,7 +49,7 @@ function timeout(value: unknown, fallback: number, max: number, label: string):
|
||||
return value
|
||||
}
|
||||
|
||||
// Do not silently rewrite executable arguments or secret values copied from chat.
|
||||
// 不要默默地重写从聊天复制的可执行参数或秘密值。
|
||||
function checkUrl(value: string, label: string) {
|
||||
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}${t('请填写纯 URL,不要粘贴 Markdown 链接', ': enter a plain URL instead of a Markdown link')}`)
|
||||
}
|
||||
@@ -62,9 +61,7 @@ export function parseMcpJson(raw: string, fallbackName = '', requireConnection =
|
||||
return normalizeMcpConfig(parsed, fallbackName, requireConnection)
|
||||
}
|
||||
|
||||
/** Normalize external client JSON before it reaches either the form or the API.
|
||||
* Inline secrets leave the public config here and are sent only to the Secret API.
|
||||
*/
|
||||
/** 在外部客户端 JSON 到达表单或 API 之前对其进行标准化。内联机密在此处保留公共配置,并且仅发送到机密 API。 */
|
||||
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
|
||||
let raw = object(parsed, t('服务器配置', 'Server configuration'))
|
||||
if ('mcpServers' in raw) {
|
||||
@@ -75,7 +72,7 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
|
||||
}
|
||||
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
|
||||
if (Object.keys(raw).some(key => !allowed.has(key))) {
|
||||
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
|
||||
// 永远不要回显任意未知密钥:粘贴的秘密有时会变成 JSON 密钥。
|
||||
throw new Error(t('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层', 'The server configuration contains unsupported fields. Put API keys in the corresponding env/environment variables, not at the top level.'))
|
||||
}
|
||||
if (raw.env !== undefined && raw.environment !== undefined) throw new Error(t('env 与 environment 请只保留一个,避免覆盖配置', 'Keep either env or environment, not both'))
|
||||
@@ -100,7 +97,7 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
|
||||
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
|
||||
config.permissions = strings(raw.permissions, 'permissions')
|
||||
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, t('启动超时', 'Startup timeout'))
|
||||
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
|
||||
// 兼容性策略:旧的读取超时成为工具等待预算,而不是 SSE 传输设置。
|
||||
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, t('工具超时', 'Tool timeout'))
|
||||
if (config.transport === 'stdio') {
|
||||
if (requireConnection && !config.command) throw new Error(t('stdio 配置必须填写 command', 'stdio configuration requires command'))
|
||||
|
||||
@@ -21,7 +21,7 @@ const documents: Record<string, string> = {
|
||||
minimax: 'https://platform.minimaxi.com/docs/api-reference/text-openai-api',
|
||||
stepfun: 'https://platform.stepfun.com/docs/zh/guides/models/overview',
|
||||
}
|
||||
// Exact documented model IDs only; an unrecognised model is always manual.
|
||||
// 仅准确记录的型号 ID;无法识别的模型始终是手动的。
|
||||
const documentedWindow = computed(() => {
|
||||
if (props.preset === 'minimax') {
|
||||
if (props.model === 'MiniMax-M3') return 1000000
|
||||
|
||||
@@ -165,10 +165,10 @@ async function save() {
|
||||
if (!form.name.trim() || !form.base_url.trim()) throw new Error(t('请填写名称和 Base URL。', 'Enter a name and Base URL.'))
|
||||
if (!requestJsonValid.value) throw new Error(t('请先修正自定义请求 JSON。', 'Fix the custom request JSON first.'))
|
||||
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error(t('请输入 API Key。密钥将由后端加密保存。', 'Enter an API key. It will be encrypted by the backend.'))
|
||||
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
|
||||
// 等待之前的快照:关闭/卸载绝不能创建草稿已更改的提供程序。
|
||||
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value, context_policies: JSON.parse(JSON.stringify(contextPolicies.value)) }
|
||||
if (apiKey.value.trim()) {
|
||||
// Rotate even an existing reference: older installations may share preset credential IDs.
|
||||
// 甚至轮换现有参考:较旧的安装可能共享预设的凭据 ID。
|
||||
const nextId = newCredentialId()
|
||||
const request = service.putCredential(nextId, apiKey.value.trim())
|
||||
apiKey.value = ''
|
||||
@@ -178,7 +178,7 @@ async function save() {
|
||||
configured.value = true
|
||||
}
|
||||
const reference = configured.value ? credentialId.value : undefined
|
||||
// A failed status check must not silently unlink the provider's existing credential.
|
||||
// 失败的状态检查不得以静默方式取消链接提供者的现有凭证。
|
||||
if (credentialError.value && !reference) throw new Error(credentialError.value)
|
||||
const saved = props.provider
|
||||
? await service.updateProvider(props.provider.provider_id, { ...data, version: props.provider.version, credential_id: reference ?? null })
|
||||
|
||||
@@ -29,7 +29,7 @@ it('shades by consumed metric and gives equal usage equal shades', async () => {
|
||||
expect(segments[0]!.attributes('style')).not.toBe(segments[1]!.attributes('style'))
|
||||
expect(wrapper.get('.model-legend').text()).toContain('model-1')
|
||||
expect(segments[0]!.attributes('title')).toContain('100')
|
||||
// happy-dom drops color-mix declarations; inspect the bound color values.
|
||||
// happy-dom 删除颜色混合声明;检查绑定的颜色值。
|
||||
const colors = wrapper.vm as unknown as {modelColor:(key:string,source:'api') => string}
|
||||
expect(colors.modelColor('m0','api')).toContain('67.5%')
|
||||
expect(colors.modelColor('m1','api')).toContain('95%')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
// Vitest disables CSS by default, including CSS raw imports. Load the real files here.
|
||||
// Vitest 默认禁用 CSS,包括 CSS 原始导入。在这里加载真实的文件。
|
||||
vi.mock('@/styles/features.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/features.css', 'utf8') }))
|
||||
vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') }))
|
||||
vi.mock('@/styles/callouts.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/callouts.css', 'utf8') }))
|
||||
@@ -29,7 +29,7 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
|
||||
expect(doc.querySelector('style')!.textContent).toContain('.button-primary:hover')
|
||||
expect(doc.querySelector('style')!.textContent).not.toContain('color:white')
|
||||
const rules = Array.from(doc.styleSheets[0]!.cssRules) as CSSStyleRule[]
|
||||
// The sandbox cannot inherit MarkdownContent's component stylesheet.
|
||||
// 沙箱无法继承MarkdownContent的组件样式表。
|
||||
const codeRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki code')!
|
||||
const lineRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki .line')!
|
||||
expect(codeRule.style.getPropertyValue('display')).toBe('block')
|
||||
@@ -37,7 +37,7 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
|
||||
expect(lineRule.style.getPropertyValue('min-height')).toBe('1lh')
|
||||
const rootRule = rules.filter(rule => rule.selectorText === 'html').pop()!
|
||||
const bodyRule = rules.filter(rule => rule.selectorText === 'body').pop()!
|
||||
// The embedded document must override the app-shell overflow lock.
|
||||
// 嵌入文档必须覆盖应用程序外壳溢出锁定。
|
||||
expect(rootRule.style.getPropertyValue('overflow-y')).toBe('auto')
|
||||
expect(rootRule.style.getPropertyPriority('overflow-y')).toBe('important')
|
||||
expect(bodyRule.style.getPropertyValue('height')).toBe('auto')
|
||||
|
||||
@@ -15,8 +15,7 @@ const props = defineProps<{ themeId: string; name?: string; css?: string }>()
|
||||
const emit = defineEmits<{ (event: 'close'): void }>()
|
||||
const theme = computed(() => props.name ? { name: props.name } : mockCommunityThemes.find(item => item.theme_id === props.themeId))
|
||||
const previewDocument = computed(() => {
|
||||
// Both imported and bundled CSS are previewed in a script-free isolated document.
|
||||
// Previewing never installs a theme or changes application styles/storage.
|
||||
// 导入和捆绑的 CSS 都可以在无脚本的独立文档中预览。预览永远不会安装主题或更改应用程序样式/存储。
|
||||
const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '')
|
||||
doc.documentElement.dataset.theme = props.themeId
|
||||
const policy = doc.createElement('meta')
|
||||
|
||||
@@ -53,7 +53,7 @@ async function run() {
|
||||
if (missingRequiredFields(command, args.value).length) { error.value = t('请填写必填参数', 'Complete required fields'); return }
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
// Runtime rechecks enabled state, schema, when conditions and permissions.
|
||||
// 运行时重新检查启用状态、架构、条件和权限。
|
||||
const result = await executePluginCommand(command.command_id, cleanArguments(args.value), { ...snapshot.value })
|
||||
await applyCommandEffect(result.effect, {
|
||||
navigate: path => router.push(path),
|
||||
|
||||
@@ -18,7 +18,7 @@ watch(appLocale, (value) => {
|
||||
if (typeof document !== 'undefined') document.documentElement.lang = value
|
||||
}, { immediate: true })
|
||||
|
||||
/** Keep the Chinese source beside its English translation while the UI is migrated. */
|
||||
/** 迁移 UI 时,将中文源保留在英文翻译旁边。 */
|
||||
export function t(zh: string, en: string): string {
|
||||
return appLocale.value === 'en' ? en : zh
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
try {
|
||||
errBody = (await resp.json()) as ErrorResponse
|
||||
} catch {
|
||||
/* ignore */
|
||||
/* 忽略 */
|
||||
}
|
||||
|
||||
const code = errBody?.error?.code || `HTTP_${resp.status}`
|
||||
|
||||
@@ -37,8 +37,7 @@ export const mediaService = {
|
||||
},
|
||||
}
|
||||
|
||||
// Keep one identity until the input/options change, including a lost HTTP response.
|
||||
// Payloads remain in memory; durable uploads/jobs are owned by the backend.
|
||||
// 保留一个身份,直到输入/选项发生变化,包括丢失 HTTP 响应。有效负载保留在内存中;持久上传/作业归后端所有。
|
||||
export function createMediaSubmission() {
|
||||
let pending: {file: File; options: string; uploadKey: string; jobKey: string; attachmentId?: string} | null = null
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ export function getModelRouting(): Promise<ModelRoutingResponse> {
|
||||
return apiClient.get('/api/model-routing')
|
||||
}
|
||||
|
||||
// version is the last version read from the server (optimistic concurrency).
|
||||
// 版本是从服务器读取的最后一个版本(乐观并发)。
|
||||
export function saveModelRouting(config: ModelRoutingConfig): Promise<ModelRoutingResponse> {
|
||||
return apiClient.put('/api/model-routing', config)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { hostInvoke } from './desktop'
|
||||
|
||||
export interface RequestProgress { issued: boolean; requestId?: string }
|
||||
|
||||
/** A reservation makes cancel-before-dispatch definitive even across IPC ordering. */
|
||||
/** 即使在 IPC 订购中,预订也可以确保发货前取消。 */
|
||||
export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number, progress: RequestProgress): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
@@ -31,7 +31,7 @@ export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSigna
|
||||
if (!settled) { settled = true; reject(error) }
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort)
|
||||
// Also discard a reservation if dispatch failed before Rust claimed it.
|
||||
// 如果在 Rust 声明保留之前调度失败,则也丢弃保留。
|
||||
cancel()
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { hostInvoke } from './desktop'
|
||||
type Message = { kind: 'headers'; status: number } | { kind: 'chunk'; data: string }
|
||||
| { kind: 'done' } | { kind: 'error'; code: string }
|
||||
|
||||
/** Native session credentials stay in Rust; this channel carries response bytes only. */
|
||||
/** 本机会话凭证保留在 Rust 中;该通道仅承载响应字节。 */
|
||||
export function coreStream(path: string, init: RequestInit): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = crypto.randomUUID()
|
||||
@@ -33,7 +33,7 @@ export function coreStream(path: string, init: RequestInit): Promise<Response> {
|
||||
if (message.kind === 'chunk') {
|
||||
const bytes = Uint8Array.from(atob(message.data), c => c.charCodeAt(0))
|
||||
controller.enqueue(bytes)
|
||||
// Bound queued data if a consumer stops reading without cancelling.
|
||||
// 如果消费者停止读取而不取消,则绑定排队数据。
|
||||
if ((controller.desiredSize ?? 0) < -4096) fail(new Error('CORE_STREAM_BACKPRESSURE'))
|
||||
}
|
||||
if (message.kind === 'error') fail(new Error(message.code))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Portable preference records are bound to the active Vault; local drafts retain their own Vault key. */
|
||||
/** 可移植偏好记录与主用Vault绑定;本地草稿保留自己的 Vault 密钥。 */
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** A durable preference draft keeps its original CAS base until the user resolves a conflict. */
|
||||
/** 持久偏好草案保留其原始 CAS 基础,直到用户解决冲突。 */
|
||||
export interface LogicalRecord<T> { schema: 1; kind: string; id: string; data: T }
|
||||
export interface RecordDocument<T> { record: LogicalRecord<T>; hash: string; file_id: string }
|
||||
interface Draft<T> { record: LogicalRecord<T>; expected: string; operation_id: string }
|
||||
@@ -40,7 +40,7 @@ export class RecordBinding<T> {
|
||||
if (this.stopped) return
|
||||
const data = JSON.parse(JSON.stringify(this.options.read())) as T
|
||||
if (!this.draft && this.remote && JSON.stringify(data) === JSON.stringify(this.remote.record.data)) return
|
||||
// New edits while a request runs get a new operation, but preserve the unresolved base.
|
||||
// 请求运行时的新编辑会获取新操作,但保留未解析的基础。
|
||||
this.draft = { record: { schema: 1, kind: this.options.kind, id: this.options.id, data }, expected: this.draft?.expected ?? this.remote?.hash ?? '', operation_id: crypto.randomUUID() }
|
||||
this.restored = false; this.invalidDraft = false
|
||||
try { this.persist(); if (this.error === 'PREFERENCE_DRAFT_STORE_FAILED') this.error = '' } catch { this.error = 'PREFERENCE_DRAFT_STORE_FAILED'; this.options.changed?.() }
|
||||
@@ -71,7 +71,7 @@ export class RecordBinding<T> {
|
||||
if (this.draft.operation_id === draft.operation_id) {
|
||||
this.persist(null); this.draft = null; this.appliedHash = committed.hash
|
||||
} else {
|
||||
// The next local edit follows the just-confirmed predecessor, not its older CAS base.
|
||||
// 下一个本地编辑遵循刚刚确认的前身,而不是其较旧的 CAS 基础。
|
||||
this.draft.expected = committed.hash; this.persist()
|
||||
}
|
||||
} else if (this.remote && this.remote.hash !== this.appliedHash) {
|
||||
|
||||
@@ -67,7 +67,7 @@ export function coerceArgument(field: CommandField, raw: string): unknown {
|
||||
return Number.isNaN(parsed) ? undefined : parsed
|
||||
}
|
||||
if (field.type === 'object' || field.type === 'array') {
|
||||
try { return JSON.parse(raw) } catch { return raw } // Backend reports the schema error without discarding the input.
|
||||
try { return JSON.parse(raw) } catch { return raw } // 后端报告模式错误而不丢弃输入。
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { isMap, parseDocument } from 'yaml'
|
||||
|
||||
export const THEME_APP_VERSION = appPackage.version
|
||||
|
||||
/**
|
||||
* Semantic colors every page and component may consume. Theme packages can
|
||||
* override any subset; the compatibility layer supplies the rest.
|
||||
*/
|
||||
/** 每个页面和组件可能消耗的语义颜色。主题包可以覆盖任何子集;兼容层提供其余部分。 */
|
||||
export const REQUIRED_THEME_COLOR_TOKENS = [
|
||||
'background-primary', 'background-secondary', 'background-tertiary', 'background-hover', 'background-active', 'background-overlay',
|
||||
'surface-primary', 'surface-secondary', 'surface-elevated',
|
||||
@@ -29,7 +26,7 @@ const STORAGE_KEY = 'installed-themes'
|
||||
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
|
||||
export const MAX_THEME_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** Normalize all transports to the existing single-file inspection format. */
|
||||
/** 将所有传输标准化为现有的单文件检查格式。 */
|
||||
export async function decodeThemePackage(bytes: Uint8Array): Promise<string> {
|
||||
if (bytes.length > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
|
||||
const decode = (data: Uint8Array) => new TextDecoder('utf-8', { fatal: true }).decode(data)
|
||||
@@ -180,7 +177,7 @@ function applyThemeCss(themeId: string, css: string) {
|
||||
|
||||
const THEME_CONTRACT_MARKER = '/* opennexus-theme-contract */'
|
||||
|
||||
/** Fill incomplete third-party themes with an accessible semantic palette. */
|
||||
/** 使用可访问的语义调色板填充不完整的第三方主题。 */
|
||||
export function withThemeContract(themeId: string, isDark: boolean, css: string): string {
|
||||
if (css.includes(THEME_CONTRACT_MARKER)) return css
|
||||
const selector = `[data-theme="${themeId}"]`
|
||||
@@ -408,7 +405,7 @@ export function setActiveCustomTheme(themeId: string | null) {
|
||||
const theme = themeId ? loadStoredThemes().find(item => item.theme_id === themeId) : undefined
|
||||
const storedCss = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
|
||||
const css = themeId && theme && storedCss ? withThemeContract(themeId, theme.is_dark, storedCss) : storedCss
|
||||
// Validate before changing the current page. Only the selected theme owns a style node.
|
||||
// 更改当前页面之前进行验证。只有选定的主题才拥有样式节点。
|
||||
if (css) validateCssSafety(css)
|
||||
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(style => style.remove())
|
||||
if (themeId && css) applyThemeCss(themeId, css)
|
||||
|
||||
@@ -147,7 +147,7 @@ export async function readFileContent(filePath: string): Promise<string> {
|
||||
return note.markdown
|
||||
}
|
||||
|
||||
/** Resolve the backend note identity already associated with a workspace path. */
|
||||
/** 解析已与工作空间路径关联的后端笔记标识。 */
|
||||
export async function getNoteId(filePath: string): Promise<string> {
|
||||
return requireNoteId(filePath)
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export async function saveFileContent(filePath: string, content: string, expecte
|
||||
await noteService.updateNote(await requireNoteId(filePath), {
|
||||
markdown: content,
|
||||
...(expectedHash ? { expected_content_hash: expectedHash } : {}),
|
||||
// Explicit [] clears the index; absent tags retain API-managed tags.
|
||||
// 显式[]清除索引;缺失的标签保留 API 管理的标签。
|
||||
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ it('restores each answer context after history reload, including explicitly abse
|
||||
vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.()
|
||||
await s.retryMessage(s.messages[1]!.message_id,undefined,null)
|
||||
vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.()
|
||||
// API serializes absent captured context as null; do not fall back to the original user snapshot.
|
||||
// API 将缺失的捕获上下文序列化为 null;不要回退到原始用户快照。
|
||||
vi.mocked(listConversationMessages).mockResolvedValue({items:JSON.parse(JSON.stringify(s.messages)),page:{total:2,limit:500,offset:0}})
|
||||
await s.setActiveConversation(s.activeConversationId!)
|
||||
await s.sendMessage('continue')
|
||||
|
||||
@@ -175,7 +175,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
historyError.value = ''
|
||||
contextNotice.value = ''
|
||||
const conversation = addLocalConversation(t('新对话', 'New conversation'))
|
||||
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
|
||||
try { await persistConversation(conversation) } catch { /* 通过historyError暴露 */ }
|
||||
}
|
||||
|
||||
async function sendMessage(text: string, retryMessageId?: string, workspaceContext?: WorkspaceContext | null) {
|
||||
@@ -206,7 +206,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
finally {
|
||||
if (version === streamVersion) isPreparing.value = false
|
||||
}
|
||||
// Switching, stopping or deleting cancels sends still waiting for creation.
|
||||
// 切换、停止或删除会取消仍在等待创建的发送。
|
||||
if (version !== streamVersion || activeConversationId.value !== conversation.conversation_id) return
|
||||
|
||||
const conversationId = conversation.conversation_id
|
||||
@@ -279,7 +279,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (call && typeof event.data.arguments_delta === 'string') {
|
||||
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
|
||||
argumentBuffers.set(call.tool_call_id, buffer)
|
||||
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
|
||||
try { call.parameters = JSON.parse(buffer) } catch { /* 不完整的JSON片段 */ }
|
||||
}
|
||||
if (call && event.data.arguments && typeof event.data.arguments === 'object') Object.assign(call.parameters, event.data.arguments)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ function validate(value: ChatPreferences) {
|
||||
}
|
||||
export const useChatPreferences = defineStore('chatPreferences', () => {
|
||||
const settings = ref<ChatPreferences>(empty())
|
||||
try { const stored = localStorage.getItem(storageKey); if (stored) settings.value = validate(JSON.parse(stored)) } catch { /* Invalid or unavailable local settings use defaults. */ }
|
||||
try { const stored = localStorage.getItem(storageKey); if (stored) settings.value = validate(JSON.parse(stored)) } catch { /* 无效或不可用的本地设置使用默认值。 */ }
|
||||
function save(value: ChatPreferences) {
|
||||
const next = validate(value)
|
||||
localStorage.setItem(storageKey, JSON.stringify(next))
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useLayoutPreferencesStore = defineStore('layoutPreferences', () =>
|
||||
localStorage.setItem('primary-sidebar-expanded', String(primaryExpanded.value))
|
||||
localStorage.setItem('workspace-sidebar-width', String(workspaceWidth.value))
|
||||
localStorage.setItem('chat-sidebar-width', String(chatWidth.value))
|
||||
} catch { /* Keep the current layout usable when local storage is unavailable. */ }
|
||||
} catch { /* 当本地存储不可用时,保持当前布局可用。 */ }
|
||||
}, { flush: 'sync' })
|
||||
return { primaryExpanded, workspaceWidth, chatWidth }
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ export const markdownPresets = {
|
||||
const key = 'markdown-preferences'
|
||||
export const useMarkdownPreferencesStore = defineStore('markdown-preferences', () => {
|
||||
let saved: Record<string, unknown> = {}
|
||||
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') ?? {} } catch { /* defaults */ }
|
||||
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') ?? {} } catch { /* 默认值 */ }
|
||||
const preferences = ref(normalizeMarkdownPreferences(saved.preferences))
|
||||
const customPresets = ref<{ name: string; preferences: MarkdownPreferences }[]>(Array.isArray(saved.presets)
|
||||
? saved.presets.filter(item => item && typeof item.name === 'string').slice(0, 20).map(item => ({ name: item.name.slice(0, 40), preferences: normalizeMarkdownPreferences(item.preferences) })) : [])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user