Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions crates/core/src/app_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,25 +269,52 @@ pub fn master_key_on_startup_required(
}

pub fn process_context() -> Result<AppPathResolutionContext> {
let executable_path = std::env::current_exe().context("resolve current executable")?;
Ok(AppPathResolutionContext {
executable_path: std::env::current_exe().context("resolve current executable")?,
development_data_dir: development_data_dir_for(&executable_path),
executable_path,
current_dir: std::env::current_dir().context("resolve current directory")?,
portable_environment: std::env::var_os("NAVOP_PORTABLE"),
data_dir_environment: std::env::var_os("NAVOP_DATA_DIR"),
development_data_dir: development_data_dir(),
})
}

/// debug 构建默认写入独立的开发目录,避免 `cargo run` 覆盖已安装版本的配置(issue: 开发态配置污染生产)。
/// release 构建恒返回 `None`,保持安装态路径不变。
fn development_data_dir() -> Option<PathBuf> {
if !cfg!(debug_assertions) {
/// 本次运行该不该用独立的开发目录。
///
/// 只按 `cfg!(debug_assertions)` 判断不够:`cargo run --release`、`cargo build --release`
/// 后直接执行 `target/release/navop`、以及本地 `cargo bundle` 出来的 `.app`,过去都套用
/// 「正式安装态」的路径规则,会直接读写 `~/.config/navop` 与生产 keychain(issue: 开发态
/// 配置污染生产)。因此额外按**可执行文件来源**判定:只要 exe 还在 cargo 产物目录里,
/// 就是自测构建,一律隔离。
fn development_data_dir_for(executable: &Path) -> Option<PathBuf> {
if !cfg!(debug_assertions) && !runs_from_source_build(executable) {
return None;
}

dirs::home_dir().map(|home| home.join(app_path_names().development_data_dir_name))
}

/// 可执行文件是否来自源码构建:位于 cargo 的 `target/{debug,release}`(含 `targets`,
/// 兼容自定义 `CARGO_TARGET_DIR`)或 `cargo install` 的 `~/.cargo/bin`。
fn runs_from_source_build(executable: &Path) -> bool {
let components: Vec<&OsStr> = executable
.components()
.map(|component| component.as_os_str())
.collect();
let within_cargo_target = components
.iter()
.any(|component| matches!(component.to_str(), Some("target" | "targets")))
&& components
.iter()
.any(|component| matches!(component.to_str(), Some("debug" | "release")));
if within_cargo_target {
return true;
}

dirs::home_dir()
.is_some_and(|home| executable.starts_with(home.join(".cargo").join("bin")))
}

fn development_paths(root: PathBuf) -> AppPaths {
AppPaths {
mode: AppRunMode::Development { root: root.clone() },
Expand Down
44 changes: 43 additions & 1 deletion crates/core/src/app_paths_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ fn development_paths_are_created_and_checked_for_writes() {

#[test]
fn development_directory_is_derived_from_the_build_profile() {
let directory = development_data_dir();
let directory = development_data_dir_for(Path::new("/usr/local/bin/navop"));

if cfg!(debug_assertions) {
assert_eq!(
Expand All @@ -340,3 +340,45 @@ fn development_directory_is_derived_from_the_build_profile() {
assert!(directory.is_none());
}
}

#[test]
fn source_builds_are_isolated_even_in_release_profile() {
// release 自测同样必须隔离:这些路径过去都被当成「正式安装态」,
// 一次运行就会写生产配置与生产 keychain。
for executable in [
"/repo/target/release/navop",
"/repo/target/debug/navop",
"/repo/targets/build-123/release/navop",
"/repo/target/release/bundle/macos/Navop.app/Contents/MacOS/navop",
] {
assert!(
runs_from_source_build(Path::new(executable)),
"{executable} 应当识别为源码构建"
);
}

// cargo install 的产物落在 ~/.cargo/bin,同样不是安装态。
let home = dirs::home_dir().expect("home");
assert!(
runs_from_source_build(&home.join(".cargo/bin/navop")),
"cargo install 产物应当识别为源码构建"
);

// 真正的安装位置不隔离。
for executable in [
"/Applications/Navop.app/Contents/MacOS/navop",
"/usr/bin/navop",
"/opt/Navop/navop",
] {
assert!(
!runs_from_source_build(Path::new(executable)),
"{executable} 应当按安装态处理"
);
if !cfg!(debug_assertions) {
assert!(
development_data_dir_for(Path::new(executable)).is_none(),
"{executable} 在 release 下应当使用安装态目录"
);
}
}
}
83 changes: 76 additions & 7 deletions crates/core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use tracing::{error, info};

/// 本次运行是否从「无法解析」的 settings.json 起步。
///
/// 为 `true` 时禁止再写盘:坏文件已被隔离成 `.corrupt-*`,放任后续保存会用默认值
/// 重写 `settings.json`,把「哪一份才是好配置」彻底搅乱(2026-11-01 事故的翻版)。
static LOAD_DEGRADED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

mod locale;
mod remote_file_editor;

Expand Down Expand Up @@ -2085,6 +2091,7 @@ impl AppSettings {
};
error!("{message}");
eprintln!("[settings] {message}");
LOAD_DEGRADED.store(true, std::sync::atomic::Ordering::Relaxed);
Self::default()
}
},
Expand All @@ -2093,12 +2100,40 @@ impl AppSettings {
let message = format!("Failed to read settings file: {read_error}");
error!("{message}");
eprintln!("[settings] {message}");
LOAD_DEGRADED.store(true, std::sync::atomic::Ordering::Relaxed);
Self::default()
}
}
}

/// 把整份设置写回 `settings.json`。
///
/// 两道硬闸,都是为了「设置不能被静默还原」:
///
/// 1. 应用路径未初始化时拒绝落盘。测试进程、命令行辅助路径、以及任何在
/// `initialize_app_paths` 之前触发的保存,目录都会经 `app_dirs::config_dir()`
/// 回落成正式安装目录,一次整份原子写就把用户真实设置覆盖成默认值
/// (2026-11-01:`cargo test -p db_view` 的用例把 `~/.config/navop/settings.json`
/// 写成了 `AppSettings::default()`)。
/// 2. 本次运行是从读不出/解析不了的 settings.json 起步时拒绝落盘,避免默认值
/// 覆盖已经被隔离成 `.corrupt-*` 的现场(下次启动会正常加载/重建)。
pub fn save(&self) {
if crate::app_paths::initialized_paths().is_none() {
let message = "Refusing to save settings: application paths are not initialized, \
so the write target would fall back to the installed config directory";
error!("{message}");
eprintln!("[settings] {message}");
return;
}
if LOAD_DEGRADED.load(std::sync::atomic::Ordering::Relaxed) {
let message = "Refusing to save settings: this run started from a settings.json \
that could not be loaded (it has been quarantined); saving defaults would \
hide the cause";
error!("{message}");
eprintln!("[settings] {message}");
return;
}

let Some(path) = Self::config_path() else {
error!("Could not determine config path");
return;
Expand Down Expand Up @@ -2206,29 +2241,63 @@ impl AppSettings {
}
}

/// 原子写 settings.json,并在覆盖前保留上一份为 `settings.json.bak`。
/// 原子写 settings.json,并在覆盖前把上一份滚进多代备份。
///
/// 直接 `fs::write` 覆盖有两个老问题:写到一半崩溃会留下半截 JSON,而半截 JSON 会让
/// [`AppSettings::load`] 走进 `Err` 分支、把整份设置回落成默认值;覆盖前也没有任何可回滚的副本。
///
/// 备份保留多代(`settings.json.bak`、`.bak.1`、`.bak.2`):只留一代时,坏掉的默认值
/// 被启动期连续 save 两次就会把仅有的好配置顶掉,之后再无回滚可能。
fn write_settings_atomically(path: &Path, content: &str) -> std::io::Result<()> {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("settings.json");
let tmp_path = path.with_file_name(format!("{file_name}.tmp"));
// 临时文件名带进程号:两个实例同时保存时,固定名 `.tmp` 会互相写穿,
// 落盘的就是半截 JSON。
let tmp_path = path.with_file_name(format!("{file_name}.{}.tmp", std::process::id()));
std::fs::write(&tmp_path, content)?;

if path.exists() {
let backup_path = path.with_file_name(format!("{file_name}.bak"));
// 备份失败不阻断主写入:保住用户这次的改动比保住旧副本更重要。
if let Err(e) = std::fs::copy(path, &backup_path) {
error!("Failed to back up settings file: {}", e);
}
rotate_settings_backups(path, file_name);
}

std::fs::rename(&tmp_path, path)
}

/// 备份代数上限:`.bak`、`.bak.1`、`.bak.2`。
const SETTINGS_BACKUP_GENERATIONS: usize = 3;

fn rotate_settings_backups(path: &Path, file_name: &str) {
let generation_path = |generation: usize| {
if generation == 0 {
path.with_file_name(format!("{file_name}.bak"))
} else {
path.with_file_name(format!("{file_name}.bak.{generation}"))
}
};

let oldest = generation_path(SETTINGS_BACKUP_GENERATIONS - 1);
if oldest.exists() {
let _ = std::fs::remove_file(&oldest);
}
for generation in (0..SETTINGS_BACKUP_GENERATIONS - 1).rev() {
let from = generation_path(generation);
if from.exists() {
let to = generation_path(generation + 1);
if let Err(error) = std::fs::rename(&from, &to) {
error!("Failed to rotate settings backup: {}", error);
}
}
}

// 备份失败不阻断主写入:保住用户这次的改动比保住旧副本更重要。
let backup_path = generation_path(0);
if let Err(error) = std::fs::copy(path, &backup_path) {
error!("Failed to back up settings file: {}", error);
}
}

/// 把无法解析的 settings.json 改名保留为 `settings.json.corrupt-<时间戳>`。
///
/// 只改名不删除:坏文件是「设置为什么被还原」的唯一现场证据。返回 `None` 表示改名失败
Expand Down
26 changes: 26 additions & 0 deletions crates/db_view/src/sql_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3307,8 +3307,34 @@ mod tests {
assert!(has_hover_item);
}

/// 测试进程绝不能解析到真实安装目录。
///
/// 本用例会走 `AppSettings::update_and_save`(整份写 settings.json),而测试里的
/// 设置起步是 `AppSettings::default()`;应用路径未初始化时 `app_dirs::config_dir()`
/// 会回落成 `~/.config/navop`,于是「跑一次单测=把生产配置写成默认值」
/// (2026-11-01 事故即此)。这里把路径显式指到临时目录,进程内只初始化一次。
fn isolate_test_app_paths() {
static INIT: std::sync::Once = std::sync::Once::new();
INIT.call_once(|| {
let root = std::env::temp_dir().join(format!(
"navop-db-view-tests-{}",
std::process::id()
));
let Ok(context) = one_core::app_paths::process_context() else {
return;
};
if let Err(error) = one_core::app_paths::initialize_app_paths(
&one_core::app_paths::AppPathOverrides::with_data_dir(root),
&context,
) {
eprintln!("[db_view tests] failed to isolate app paths: {error}");
}
});
}

#[gpui::test]
fn sql_editor_installs_mouse_hover_provider_only_when_enabled(cx: &mut gpui::TestAppContext) {
isolate_test_app_paths();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(AppSettings::default());
Expand Down
4 changes: 4 additions & 0 deletions crates/navop_app/locales/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3828,6 +3828,10 @@ Settings:
en: "Failed to install Navop Skill: %{command} was not found. Install Node.js or add Node.js to the app PATH."
zh-CN: "Navop Skill 安装失败:未找到 %{command}。请安装 Node.js,或将 Node.js 的 bin 目录加入 PATH。"
zh-HK: "Navop Skill 安裝失敗:未找到 %{command}。請安裝 Node.js,或將 Node.js 的 bin 目錄加入 PATH。"
skill_install_skipped_in_development:
en: Skill installation is skipped in development builds so they cannot write to the production user Skill directories.
zh-CN: 开发构建不写入全局 Skill 目录,已跳过安装(避免污染生产用户目录)。
zh-HK: 開發構建不寫入全域 Skill 目錄,已跳過安裝(避免污染生產使用者目錄)。
install_client_config_success:
en: "MCP client config installed: %{path}"
zh-CN: "MCP 客户端配置已安装:%{path}"
Expand Down
18 changes: 18 additions & 0 deletions crates/navop_app/src/settings/mcp_skill_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,24 @@ enum SkillInstallFailure {
}

fn install_skill(target: SkillTarget, force: bool, window: &mut Window, cx: &mut App) {
// 目标目录是跨应用的全局目录(~/.codex、~/.agents),没有按构建模式区分的隔离位。
// 开发构建一旦安装,就会把开发版内容写进正式用户在用的 Skill 目录;因此开发态直接跳过。
if !one_core::app_paths::is_installed() {
tracing::info!(
target = target.id(),
"Skipping Navop Skill installation: development build"
);
window.push_notification(
Notification::warning(
t!("Settings.General.Mcp.skill_install_skipped_in_development").to_string(),
)
.autohide(true),
cx,
);
window.refresh();
return;
}

let install = ClientConfigInstall::from_current_app();
let launcher = install.launch_spec.command;
let launcher_name = launcher.clone();
Expand Down
9 changes: 9 additions & 0 deletions crates/notes/src/storage_location.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,16 @@ struct NotesLocation {
}

impl NotesStorage {
/// 笔记默认根目录。
///
/// 跟随应用路径解析走:开发构建落在 `~/.navop-dev/state/notes`,安装态仍是
/// `~/Library/Application Support/navop/notes`。这里过去硬编码
/// `dirs::data_local_dir()/navop/notes`,`cargo run` 起来的开发实例会直接读写
/// 生产笔记目录(2026-11-01 事故复盘里被列为隔离缺口之一)。
pub fn default_root() -> Result<PathBuf> {
if let Some(data_dir) = one_core::app_dirs::data_dir() {
return Ok(data_dir.join("notes"));
}
let base = dirs::data_local_dir().context("local data directory is unavailable")?;
Ok(base.join("navop").join("notes"))
}
Expand Down
8 changes: 8 additions & 0 deletions crates/terminal_view/src/view/render_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ impl TerminalView {
) -> AnyElement {
let state = self.tool_dock_render_state(cx);
let view = cx.entity().clone();
let bounds_view = view.clone();
let bg_color = self.current_theme.background;
let center = CenterRegionState {
font_family,
Expand All @@ -106,6 +107,13 @@ impl TerminalView {
.min_w_0()
.min_h_0()
.overflow_hidden()
// 拖动换算要用 dock 根元素的真实 bounds;事件监听元素自身是 0×0,
// 而 window.bounds() 在嵌入面板里也不等于 dock 的位置。
.on_prepaint(move |bounds, _, cx| {
bounds_view.update(cx, |view, _| {
view.view_bounds = bounds;
});
})
.bg(bg_color)
.key_context(TERMINAL_CONTEXT)
.on_action(cx.listener(Self::start_recording_action))
Expand Down
13 changes: 4 additions & 9 deletions crates/terminal_view/src/view/resize_event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,11 @@ impl Element for ResizeEventHandler {
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
let bounds = window.bounds();
self.view.update(cx, |view, _| {
view.view_bounds = Bounds {
origin: Point::default(),
size: bounds.size,
};
});
// view_bounds 由 dock 根元素的 on_prepaint 写入:本元素是 0×0 的弹性
// 子项,而窗口 bounds 在嵌入面板里也不等于 dock 的位置。
}

fn paint(
Expand Down
Loading
Loading