Files
mnote/rust/crates/mnote-web/src/app.rs
T

256 lines
9.2 KiB
Rust
Raw Normal View History

use crate::acp_runtime::AcpRuntimeManager;
2026-05-20 10:43:38 +08:00
use crate::document_buffer_store::BufferStore;
use crate::editor_actor::EditorRuntimeActor;
2026-05-11 13:16:34 +08:00
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
use crate::middleware::request_context::inject_request_context;
use crate::routes::build_router;
use axum::Router;
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
use std::env;
use std::fs;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
#[derive(Debug, Clone)]
pub struct AppConfig {
pub service_name: String,
pub service_version: String,
pub bind_addr: String,
2026-04-29 12:24:44 +08:00
pub public_bind_addr: String,
pub legacy_next_base_url: Option<String>,
pub enable_legacy_next_compat: bool,
pub enable_debug_shell_routes: bool,
pub enable_editor_actor: bool,
pub hermes_base_path: String,
pub compat_next_base_path: String,
pub convex_url: Option<String>,
pub convex_admin_key: Option<String>,
pub allow_dev_fixtures: bool,
pub query_fixtures_json: Option<String>,
pub mutation_fixtures_json: Option<String>,
pub dev_user_id: String,
pub dev_user_name: String,
pub dev_user_email: String,
}
impl AppConfig {
pub fn from_env() -> Self {
Self {
service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()),
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
2026-04-29 12:24:44 +08:00
bind_addr: env::var("MNOTE_WEB_BIND")
.or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND"))
.unwrap_or_else(|_| "127.0.0.1:0".into()),
public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND")
.unwrap_or_else(|_| "127.0.0.1:3000".into()),
legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL")
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()),
enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false),
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false),
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
.unwrap_or_else(|_| "/api/hermes".into()),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
.unwrap_or_else(|_| "/api/compat/next".into()),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false),
query_fixtures_json: env::var("MNOTE_WEB_QUERY_FIXTURES_JSON")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
mutation_fixtures_json: env::var("MNOTE_WEB_MUTATION_FIXTURES_JSON")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
dev_user_id: env::var("DEV_USER_ID")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_ID"))
.unwrap_or_else(|| "dev-user".into()),
dev_user_name: env::var("DEV_USER_NAME")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_NAME"))
.unwrap_or_else(|| "开发用户".into()),
dev_user_email: env::var("DEV_USER_EMAIL")
.ok()
.or_else(|| read_env_or_dotenv("DEV_USER_EMAIL"))
.unwrap_or_else(|| "dev@mnote.local".into()),
}
}
}
2026-04-29 12:24:44 +08:00
fn env_bool(key: &str, default: bool) -> bool {
env::var(key)
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(default)
}
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() != key {
continue;
}
let trimmed = v.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
use tokio::sync::broadcast;
#[derive(Clone)]
pub struct AppState {
config: Arc<AppConfig>,
local_folder_watcher_registry: LocalFolderWatcherRegistry,
pub editor_actor: EditorRuntimeActor,
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
pub acp_runtime: Arc<AcpRuntimeManager>,
2026-05-20 10:43:38 +08:00
pub buffer_store: BufferStore,
control_plane: Arc<SqliteControlPlaneStore>,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
let (block_delta_tx, _) = broadcast::channel(256);
let (stream_delta_tx, _) = broadcast::channel(256);
let actor = EditorRuntimeActor::new();
actor.set_block_delta_tx(block_delta_tx.clone());
2026-05-20 10:43:38 +08:00
let buffer_store = BufferStore::new();
let control_plane = Arc::new(open_control_plane_store());
Self {
config: Arc::new(config),
2026-05-20 10:43:38 +08:00
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(buffer_store.clone()),
editor_actor: actor,
block_delta_tx,
stream_delta_tx,
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
2026-05-20 10:43:38 +08:00
buffer_store,
control_plane,
}
}
pub fn config(&self) -> &AppConfig {
self.config.as_ref()
}
pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry {
&self.local_folder_watcher_registry
}
pub fn control_plane(&self) -> &dyn ControlPlaneStore {
self.control_plane.as_ref()
}
}
#[cfg(test)]
fn open_control_plane_store() -> SqliteControlPlaneStore {
SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面")
}
#[cfg(not(test))]
fn open_control_plane_store() -> SqliteControlPlaneStore {
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string());
if let Some(parent) = std::path::Path::new(&db_path).parent() {
fs::create_dir_all(parent).expect("创建 SQLite 控制面目录");
}
SqliteControlPlaneStore::open(&db_path).expect("初始化 SQLite 控制面")
}
pub fn build_app(state: AppState) -> Router {
build_router(state)
.layer(TraceLayer::new_for_http())
.layer(axum::middleware::from_fn(inject_request_context))
}
#[cfg(test)]
mod tests {
use super::*;
use control_plane::UpsertUserInput;
fn test_config() -> AppConfig {
AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: None,
enable_legacy_next_compat: false,
enable_debug_shell_routes: false,
enable_editor_actor: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: false,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}
}
#[test]
fn app_state_initializes_sqlite_control_plane_store() {
let state = AppState::new(test_config());
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("shujuan".into()),
email: Some("shujuan@163.com".into()),
username: "shujuan".into(),
display_name: "shujuan".into(),
role: None,
password_hash: None,
})
.expect("upsert user");
let workspace = state
.control_plane()
.ensure_default_workspace("shujuan")
.expect("default workspace");
assert_eq!(workspace.name, "shujuan 的空间");
let access = state
.control_plane()
.resolve_access("shujuan", &workspace.root_uri)
.expect("owner access");
assert_eq!(access.permission, "write");
}
}