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
5 changes: 4 additions & 1 deletion crates/core/src/app_dirs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ pub(crate) fn installed_data_dir() -> Option<PathBuf> {
}

pub fn migrate_legacy_directories() -> Result<()> {
if crate::app_paths::initialized_paths().is_some_and(|paths| paths.is_portable()) {
// 便携与 debug 开发目录都不该触碰生产安装目录。
if crate::app_paths::initialized_paths()
.is_some_and(|paths| !paths.uses_installed_directories())
{
return Ok(());
}

Expand Down
91 changes: 73 additions & 18 deletions crates/core/src/app_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ use std::sync::OnceLock;

pub const PORTABLE_MARKER_FILE: &str = "navop.portable";

/// debug 构建的默认数据目录名。开发态必须与已安装版本完全隔离,
/// 否则 `cargo run` 会直接写生产配置(连接库、设置、登录态)。
/// 与 `.env.local` 中常用的 `NAVOP_DATA_DIR=~/.navop-dev` 保持一致。
pub const DEVELOPMENT_DATA_DIR_NAME: &str = ".navop-dev";

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AppRunMode {
Installed,
Development { root: PathBuf },
Portable { root: PathBuf },
}

Expand Down Expand Up @@ -40,6 +46,16 @@ impl AppPaths {
matches!(self.mode, AppRunMode::Portable { .. })
}

/// debug 构建使用独立的开发数据目录。
pub fn is_development(&self) -> bool {
matches!(self.mode, AppRunMode::Development { .. })
}

/// 只有正式安装态才允许迁移生产目录、注册文件关联等宿主级副作用。
pub fn uses_installed_directories(&self) -> bool {
matches!(self.mode, AppRunMode::Installed)
}

pub fn allows_persistent_master_key(&self) -> bool {
true
}
Expand Down Expand Up @@ -75,6 +91,8 @@ pub struct AppPathResolutionContext {
pub current_dir: PathBuf,
pub portable_environment: Option<OsString>,
pub data_dir_environment: Option<OsString>,
/// debug 构建的默认隔离目录;release 构建恒为 `None`。
pub development_data_dir: Option<PathBuf>,
}

static APP_PATHS: OnceLock<AppPaths> = OnceLock::new();
Expand Down Expand Up @@ -129,31 +147,43 @@ pub fn resolve_app_paths(
context: &AppPathResolutionContext,
) -> Result<AppPaths> {
let executable_root = executable_root(&context.executable_path)?;
let marker_portable = executable_root.join(PORTABLE_MARKER_FILE).is_file();
if let Some(data_root) = requested_data_root(overrides, context, &executable_root) {
return Ok(portable_paths(executable_root, data_root));
}
if let Some(development_root) = context.development_data_dir.as_ref() {
return Ok(development_paths(development_root.clone()));
}

Ok(installed_paths()?)
}

/// 用户显式声明的便携数据根。显式参数与便携环境变量优先于 debug 隔离目录。
fn requested_data_root(
overrides: &AppPathOverrides,
context: &AppPathResolutionContext,
executable_root: &Path,
) -> Option<PathBuf> {
if let Some(path) = overrides.data_dir.as_ref() {
return Some(make_absolute(path, &context.current_dir));
}
if overrides.portable {
return Some(executable_root.join("data"));
}
let environment_data_dir = context
.data_dir_environment
.as_deref()
.filter(|value| !value.is_empty())
.map(PathBuf::from);
if let Some(path) = environment_data_dir.as_ref() {
return Some(make_absolute(path, &context.current_dir));
}
let portable_environment = context
.portable_environment
.as_deref()
.is_some_and(is_truthy);
let data_root = if let Some(path) = overrides.data_dir.as_ref() {
Some(make_absolute(path, &context.current_dir))
} else if overrides.portable {
Some(executable_root.join("data"))
} else if let Some(path) = environment_data_dir.as_ref() {
Some(make_absolute(path, &context.current_dir))
} else {
(portable_environment || marker_portable).then(|| executable_root.join("data"))
};

if let Some(data_root) = data_root {
return Ok(portable_paths(executable_root, data_root));
}
let marker_portable = executable_root.join(PORTABLE_MARKER_FILE).is_file();

Ok(installed_paths()?)
(portable_environment || marker_portable).then(|| executable_root.join("data"))
}

pub fn initialize_app_paths(
Expand All @@ -176,6 +206,11 @@ pub fn is_portable() -> bool {
initialized_paths().is_some_and(AppPaths::is_portable)
}

/// 当前进程是否运行在正式安装态。未初始化时按安装态处理,保持旧行为。
pub fn is_installed() -> bool {
initialized_paths().is_none_or(AppPaths::uses_installed_directories)
}

pub fn master_key_on_startup_required(
configured: bool,
portable_remember_master_key: bool,
Expand All @@ -191,9 +226,29 @@ pub fn process_context() -> Result<AppPathResolutionContext> {
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) {
return None;
}

dirs::home_dir().map(|home| home.join(DEVELOPMENT_DATA_DIR_NAME))
}

fn development_paths(root: PathBuf) -> AppPaths {
AppPaths {
mode: AppRunMode::Development { root: root.clone() },
config_dir: root.join("config"),
data_dir: Some(root.join("state")),
cache_dir: Some(root.join("cache")),
}
}

fn portable_paths(root: PathBuf, data_root: PathBuf) -> AppPaths {
AppPaths {
mode: AppRunMode::Portable { root },
Expand All @@ -213,7 +268,7 @@ fn installed_paths() -> Result<AppPaths> {
}

fn prepare_paths(paths: &AppPaths) -> Result<()> {
if !paths.is_portable() {
if paths.uses_installed_directories() {
return Ok(());
}

Expand All @@ -232,9 +287,9 @@ fn prepare_directory(directory: &Path) -> Result<()> {
.with_context(|| format!("create {}", directory.display()))?;
let probe = directory.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"navop")
.with_context(|| format!("portable directory is not writable: {}", probe.display()))?;
.with_context(|| format!("application directory is not writable: {}", probe.display()))?;
std::fs::remove_file(&probe)
.with_context(|| format!("remove portable write probe: {}", probe.display()))?;
.with_context(|| format!("remove write probe: {}", probe.display()))?;
Ok(())
}

Expand Down
111 changes: 111 additions & 0 deletions crates/core/src/app_paths_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ fn context(temp: &tempfile::TempDir) -> AppPathResolutionContext {
current_dir: temp.path().to_path_buf(),
portable_environment: None,
data_dir_environment: None,
development_data_dir: None,
}
}

fn development_context(temp: &tempfile::TempDir) -> AppPathResolutionContext {
AppPathResolutionContext {
development_data_dir: Some(temp.path().join("navop-dev")),
..context(temp)
}
}

Expand Down Expand Up @@ -103,6 +111,7 @@ fn macos_bundle_uses_the_directory_next_to_the_app() {
current_dir: temp.path().to_path_buf(),
portable_environment: None,
data_dir_environment: None,
development_data_dir: None,
};
let overrides = AppPathOverrides {
portable: true,
Expand Down Expand Up @@ -229,3 +238,105 @@ fn installed_mode_uses_the_existing_startup_lock_setting() {
assert!(!paths.requires_master_key_on_startup(false, false));
assert!(paths.requires_master_key_on_startup(true, false));
}

#[test]
fn debug_build_uses_an_isolated_development_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let paths = resolve_app_paths(&AppPathOverrides::default(), &development_context(&temp))
.expect("resolve paths");

let root = temp.path().join("navop-dev");
assert_eq!(
&AppRunMode::Development { root: root.clone() },
paths.mode()
);
assert_eq!(root.join("config"), *paths.config_dir());
assert_eq!(Some(&root.join("state")), paths.data_dir());
assert_eq!(Some(&root.join("cache")), paths.cache_dir());
assert!(paths.is_development());
assert!(!paths.is_portable());
assert!(!paths.uses_installed_directories());
}

#[test]
fn explicit_data_dir_overrides_the_development_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let overrides = AppPathOverrides {
portable: false,
data_dir: Some(PathBuf::from("explicit-data")),
};

let paths = resolve_app_paths(&overrides, &development_context(&temp)).expect("resolve paths");

assert!(paths.is_portable());
assert_eq!(
temp.path().join("explicit-data/config"),
*paths.config_dir()
);
}

#[test]
fn environment_data_dir_overrides_the_development_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let mut context = development_context(&temp);
context.data_dir_environment = Some(OsString::from("environment-data"));

let paths = resolve_app_paths(&AppPathOverrides::default(), &context).expect("resolve paths");

assert!(paths.is_portable());
assert_eq!(
temp.path().join("environment-data/config"),
*paths.config_dir()
);
}

#[test]
fn portable_flag_overrides_the_development_directory() {
let temp = tempfile::tempdir().expect("tempdir");
let overrides = AppPathOverrides {
portable: true,
data_dir: None,
};

let paths = resolve_app_paths(&overrides, &development_context(&temp)).expect("resolve paths");

assert!(paths.is_portable());
assert_eq!(temp.path().join("data/config"), *paths.config_dir());
}

#[test]
fn development_mode_keeps_installed_master_key_semantics() {
let temp = tempfile::tempdir().expect("tempdir");
let paths = resolve_app_paths(&AppPathOverrides::default(), &development_context(&temp))
.expect("resolve paths");

assert!(!paths.requires_master_key_on_startup(false, false));
assert!(paths.requires_master_key_on_startup(true, false));
}

#[test]
fn development_paths_are_created_and_checked_for_writes() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("navop-dev");
let paths = development_paths(root.clone());

prepare_paths(&paths).expect("prepare development paths");

assert!(root.join("config").is_dir());
assert!(root.join("state").is_dir());
assert!(root.join("cache").is_dir());
}

#[test]
fn development_directory_is_derived_from_the_build_profile() {
let directory = development_data_dir();

if cfg!(debug_assertions) {
assert_eq!(
Some(OsStr::new(DEVELOPMENT_DATA_DIR_NAME)),
directory.as_deref().and_then(Path::file_name)
);
} else {
assert!(directory.is_none());
}
}
59 changes: 57 additions & 2 deletions crates/core/src/cloud_sync/personal/local_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use crate::storage::{

use super::{PersonalSyncItemSnapshot, PersonalSyncLocalSource, SyncStoreError};
use crate::cloud_sync::workspace_hierarchy::{
cloud_parent_links, reconcile_parent_links, sort_ancestors_first,
cloud_parent_links, reconcile_connection_workspaces, reconcile_parent_links,
sort_ancestors_first,
};

const CONNECTION_PREFIX: &str = "connection:";
Expand Down Expand Up @@ -326,6 +327,48 @@ impl PersonalSyncLocalRepositorySource {
.map_err(repository_error)
}
}

/// 把连接所属分组按云端引用对齐
///
/// 记录按 `updated_at` 倒序应用,连接完全可能先于它所属的分组落地;
/// 那时归属只能写成根节点,这里在所有写入结束后统一收敛回来。
fn reconcile_connection_workspace_links(
&self,
records: &[CloudSyncData],
workspaces: &[Workspace],
) -> Result<(), SyncStoreError> {
let links: Vec<(String, WorkspaceParentLink)> = records
.iter()
.filter(|record| {
record.data_type == data_type::CONNECTION
&& record.deleted_at.is_none()
&& record.team_id.is_none()
})
.filter_map(|record| {
let workspace_cloud_id = self.connection_workspace_cloud_id(record)?;
Some((
record.id.clone(),
connection_workspace_link(workspace_cloud_id),
))
})
.collect();
if links.is_empty() {
return Ok(());
}

let connections = self.connections.list_personal().map_err(repository_error)?;
reconcile_connection_workspaces(&self.connections, &connections, workspaces, &links)
.map(|_| ())
.map_err(sync_error)
}

fn connection_workspace_cloud_id(&self, record: &CloudSyncData) -> Option<Option<String>> {
let service = self.service.read().ok()?;
service
.decrypt_sync_data_connection_with_workspace_cloud_id(record)
.ok()
.map(|(_, workspace_cloud_id)| workspace_cloud_id)
}
}

#[async_trait]
Expand Down Expand Up @@ -409,7 +452,8 @@ impl PersonalSyncLocalSource for PersonalSyncLocalRepositorySource {
});
reconcile_parent_links(&self.workspaces, &workspaces, &links)
.map(|_| ())
.map_err(sync_error)
.map_err(sync_error)?;
self.reconcile_connection_workspace_links(records, &workspaces)
}

async fn mark_synced(
Expand Down Expand Up @@ -606,6 +650,17 @@ fn required_id(id: Option<i64>, entity: &str) -> Result<i64, SyncStoreError> {
id.ok_or_else(|| SyncStoreError::Parse(format!("{entity} has no local id")))
}

/// 连接载荷里的 `workspace_cloud_id` 还原成分组引用
///
/// 连接载荷自诞生就带这个字段,空值表示明确的根节点(旧载荷的语义一致),
/// 因此不存在分组层级那种「旧版载荷不可判定」的情况。
fn connection_workspace_link(workspace_cloud_id: Option<String>) -> WorkspaceParentLink {
match workspace_cloud_id {
Some(cloud_id) if !cloud_id.is_empty() => WorkspaceParentLink::Cloud(cloud_id),
_ => WorkspaceParentLink::Root,
}
}

fn repository_error(error: anyhow::Error) -> SyncStoreError {
SyncStoreError::Io(error.to_string())
}
Expand Down
Loading
Loading