Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs
T
kdletters d6c939fa31 AGC 原生侧新增平台 facade,退役渲染层 HTTP 能力
- 新增 account_api、auth_session、platform_asset_upload、game_distribution_publish、llm_catalog、workspace_preferences 与 error_report::submit:凭据、origin、HTTP、队列等待、幂等键与导入队列全部由 Rust 持有。
- 删除 capabilities/main.json 的 http:default 授权与主窗口的 tauri-plugin-http 注册,Cargo.toml / Cargo.lock / AGC package.json 与根 package-lock.json 同步移除该依赖。
- 修复 workspace_preferences 重复 #[test] 造成的分片门禁「同一用例被分到两片」误判,并同步该模块的会话存储与偏好读写用例。
2026-09-28 14:15:16 +08:00

521 lines
20 KiB
Rust

//! AGC 客户端偏好存储。
//!
//! 这里持有渲染层不该自行持久化的数据:工作区偏好(项目创建目录、最近工作区)以及
//! 本地 UI 偏好(发送前提醒是否已关闭)。React 只读取投影并通过 typed command 表达
//! 变更意图,不再直接访问 `localStorage`。
use super::*;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
pub(crate) const WORKSPACE_PREFERENCES_CHANGED_EVENT: &str =
"game-creator-workspace-preferences-changed";
const WORKSPACE_PREFERENCES_SCHEMA_VERSION: &str = "agc-workspace-preferences.v1";
const WORKSPACE_PREFERENCES_FILE_NAME: &str = "workspace-preferences.json";
const MAX_RECENT_WORKSPACES: usize = 8;
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct WorkspacePreferencesFile {
#[serde(default = "workspace_preferences_schema_version")]
schema_version: String,
#[serde(default)]
project_creation_directory: Option<String>,
#[serde(default)]
recent_workspaces: Vec<String>,
/// 「发送前提醒」是否已被用户关闭。`None` 表示从未选择过,按未关闭处理。
#[serde(default)]
chat_prompt_polish_reminder_disabled: Option<bool>,
/// 本地 debug 的服务器选择;打包产物不读它,始终跟随构建渠道。
#[serde(default)]
client_server_selection: Option<ClientServerSelectionPreference>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ClientServerSelectionPreference {
preset: String,
#[serde(default)]
custom_base_url: String,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WorkspacePreferencesSnapshot {
pub(crate) project_creation_directory: Option<String>,
pub(crate) recent_workspaces: Vec<String>,
pub(crate) chat_prompt_polish_reminder_disabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) client_server_selection: Option<ClientServerSelectionPreference>,
}
fn workspace_preferences_schema_version() -> String {
WORKSPACE_PREFERENCES_SCHEMA_VERSION.to_string()
}
fn workspace_preferences_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|root| root.join(WORKSPACE_PREFERENCES_FILE_NAME))
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
fn normalize_absolute_path(value: &str) -> Option<String> {
let trimmed = value.trim();
let path = Path::new(trimmed);
if trimmed.is_empty() || !path.is_absolute() || project_path_has_control_chars(path) {
return None;
}
Some(path.to_string_lossy().into_owned())
}
fn normalize_project_creation_directory(value: Option<&str>) -> Option<String> {
value.and_then(normalize_absolute_path)
}
fn normalize_recent_workspaces(values: impl IntoIterator<Item = String>) -> Vec<String> {
let mut recent = Vec::with_capacity(MAX_RECENT_WORKSPACES);
for value in values {
let Some(path) = normalize_absolute_path(&value) else {
continue;
};
if recent.iter().any(|existing| existing == &path) {
continue;
}
recent.push(path);
if recent.len() >= MAX_RECENT_WORKSPACES {
break;
}
}
recent
}
fn normalize_server_selection(
selection: Option<ClientServerSelectionPreference>,
) -> Option<ClientServerSelectionPreference> {
let selection = selection?;
let preset = selection.preset.trim().to_ascii_lowercase();
let custom_base_url = selection.custom_base_url.trim().to_string();
match preset.as_str() {
"release" | "dev" => Some(ClientServerSelectionPreference {
preset,
custom_base_url: String::new(),
}),
"custom" => crate::auth_session::validate_client_api_base_url(&custom_base_url)
.ok()
.map(|custom_base_url| ClientServerSelectionPreference {
preset,
custom_base_url,
}),
_ => None,
}
}
fn normalize_file(file: WorkspacePreferencesFile) -> WorkspacePreferencesFile {
WorkspacePreferencesFile {
schema_version: WORKSPACE_PREFERENCES_SCHEMA_VERSION.to_string(),
project_creation_directory: normalize_project_creation_directory(
file.project_creation_directory.as_deref(),
),
recent_workspaces: normalize_recent_workspaces(file.recent_workspaces),
chat_prompt_polish_reminder_disabled: file.chat_prompt_polish_reminder_disabled,
client_server_selection: normalize_server_selection(file.client_server_selection),
}
}
fn snapshot_from_file(file: WorkspacePreferencesFile) -> WorkspacePreferencesSnapshot {
let file = normalize_file(file);
WorkspacePreferencesSnapshot {
project_creation_directory: file.project_creation_directory,
recent_workspaces: file.recent_workspaces,
chat_prompt_polish_reminder_disabled: file
.chat_prompt_polish_reminder_disabled
.unwrap_or(false),
client_server_selection: file.client_server_selection,
}
}
fn file_from_snapshot(snapshot: WorkspacePreferencesSnapshot) -> WorkspacePreferencesFile {
WorkspacePreferencesFile {
schema_version: WORKSPACE_PREFERENCES_SCHEMA_VERSION.to_string(),
project_creation_directory: normalize_project_creation_directory(
snapshot.project_creation_directory.as_deref(),
),
recent_workspaces: normalize_recent_workspaces(snapshot.recent_workspaces),
chat_prompt_polish_reminder_disabled: Some(snapshot.chat_prompt_polish_reminder_disabled),
client_server_selection: normalize_server_selection(snapshot.client_server_selection),
}
}
fn read_preferences_at(path: &Path) -> WorkspacePreferencesSnapshot {
let Ok(metadata) = fs::symlink_metadata(path) else {
return WorkspacePreferencesSnapshot::default();
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return WorkspacePreferencesSnapshot::default();
}
let Ok(content) = fs::read_to_string(path) else {
return WorkspacePreferencesSnapshot::default();
};
let Ok(file) = serde_json::from_str::<WorkspacePreferencesFile>(&content) else {
return WorkspacePreferencesSnapshot::default();
};
snapshot_from_file(file)
}
fn write_preferences_at(
path: &Path,
snapshot: WorkspacePreferencesSnapshot,
) -> Result<WorkspacePreferencesSnapshot, String> {
let file = file_from_snapshot(snapshot);
let content = serde_json::to_string_pretty(&file)
.map_err(|error| format!("序列化工作区偏好失败:{error}"))?;
let parent = path
.parent()
.ok_or_else(|| "工作区偏好缺少父目录".to_string())?;
fs::create_dir_all(parent)
.map_err(|error| format!("创建工作区偏好目录失败:{}: {error}", parent.display()))?;
if let Ok(metadata) = fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("工作区偏好文件必须是普通文件".to_string());
}
}
let temp_path = path.with_file_name(format!(
".{}.tmp.{}.{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or(WORKSPACE_PREFERENCES_FILE_NAME),
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let mut file_handle = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.map_err(|error| format!("创建工作区偏好临时文件失败:{error}"))?;
let write_result = file_handle
.write_all(format!("{content}\n").as_bytes())
.and_then(|_| file_handle.sync_all());
drop(file_handle);
if let Err(error) = write_result {
let _ = fs::remove_file(&temp_path);
return Err(format!("写入工作区偏好失败:{error}"));
}
#[cfg(windows)]
if path.exists() {
fs::remove_file(path).map_err(|error| {
let _ = fs::remove_file(&temp_path);
format!("替换工作区偏好失败:{error}")
})?;
}
if let Err(error) = fs::rename(&temp_path, path) {
let _ = fs::remove_file(&temp_path);
return Err(format!("提交工作区偏好失败:{error}"));
}
Ok(snapshot_from_file(file))
}
fn with_preferences_lock<T>(f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let _guard = LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.map_err(|_| "工作区偏好锁不可用".to_string())?;
f()
}
fn read_preferences_for_app(
app: &tauri::AppHandle,
) -> Result<WorkspacePreferencesSnapshot, String> {
let path = workspace_preferences_path(app)?;
with_preferences_lock(|| Ok(read_preferences_at(&path)))
}
fn update_preferences_for_app(
app: &tauri::AppHandle,
update: impl FnOnce(WorkspacePreferencesSnapshot) -> WorkspacePreferencesSnapshot,
) -> Result<WorkspacePreferencesSnapshot, String> {
let path = workspace_preferences_path(app)?;
let snapshot = with_preferences_lock(|| {
let current = read_preferences_at(&path);
let next = update(current.clone());
if next == current {
return Ok(current);
}
write_preferences_at(&path, next)
})?;
let _ = app.emit(WORKSPACE_PREFERENCES_CHANGED_EVENT, snapshot.clone());
Ok(snapshot)
}
pub(crate) fn stored_project_creation_directory(app: &tauri::AppHandle) -> Option<String> {
read_preferences_for_app(app)
.ok()
.and_then(|snapshot| snapshot.project_creation_directory)
}
#[tauri::command]
pub(crate) fn read_workspace_preferences(
app: tauri::AppHandle,
) -> Result<WorkspacePreferencesSnapshot, String> {
read_preferences_for_app(&app)
}
#[tauri::command]
pub(crate) fn set_project_creation_directory(
app: tauri::AppHandle,
directory: Option<String>,
) -> Result<WorkspacePreferencesSnapshot, String> {
let directory = match directory
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
None => None,
Some(value) => Some(
normalize_absolute_path(value)
.ok_or_else(|| "项目创建目录必须是绝对路径且不能包含控制字符".to_string())?,
),
};
update_preferences_for_app(&app, |mut snapshot| {
snapshot.project_creation_directory = directory;
snapshot
})
}
/// 写入本地 debug 的服务器选择。打包产物不提供该入口,因此这里也不做渠道放行。
#[tauri::command]
pub(crate) fn set_client_server_selection(
app: tauri::AppHandle,
preset: String,
custom_base_url: Option<String>,
) -> Result<WorkspacePreferencesSnapshot, String> {
let selection = normalize_server_selection(Some(ClientServerSelectionPreference {
preset,
custom_base_url: custom_base_url.unwrap_or_default(),
}))
.ok_or_else(|| "服务器选择无效".to_string())?;
update_preferences_for_app(&app, |mut snapshot| {
snapshot.client_server_selection = Some(selection);
snapshot
})
}
/// 写入「发送前提醒」的本地偏好。返回最新偏好投影,事件由更新入口统一发送。
#[tauri::command]
pub(crate) fn set_chat_prompt_polish_reminder_disabled(
app: tauri::AppHandle,
disabled: bool,
) -> Result<WorkspacePreferencesSnapshot, String> {
update_preferences_for_app(&app, |mut snapshot| {
snapshot.chat_prompt_polish_reminder_disabled = disabled;
snapshot
})
}
#[tauri::command]
pub(crate) fn remember_recent_workspace(
app: tauri::AppHandle,
project_path: String,
) -> Result<WorkspacePreferencesSnapshot, String> {
let Some(project_path) = normalize_absolute_path(&project_path) else {
return read_preferences_for_app(&app);
};
update_preferences_for_app(&app, |snapshot| WorkspacePreferencesSnapshot {
project_creation_directory: snapshot.project_creation_directory,
recent_workspaces: normalize_recent_workspaces(
std::iter::once(project_path).chain(snapshot.recent_workspaces),
),
chat_prompt_polish_reminder_disabled: snapshot.chat_prompt_polish_reminder_disabled,
client_server_selection: snapshot.client_server_selection,
})
}
#[tauri::command]
pub(crate) fn remove_recent_workspace(
app: tauri::AppHandle,
project_path: String,
) -> Result<WorkspacePreferencesSnapshot, String> {
let project_path = normalize_absolute_path(&project_path);
update_preferences_for_app(&app, |snapshot| WorkspacePreferencesSnapshot {
project_creation_directory: snapshot.project_creation_directory,
recent_workspaces: snapshot
.recent_workspaces
.into_iter()
.filter(|path| project_path.as_deref() != Some(path.as_str()))
.collect(),
chat_prompt_polish_reminder_disabled: snapshot.chat_prompt_polish_reminder_disabled,
client_server_selection: snapshot.client_server_selection,
})
}
#[tauri::command]
pub(crate) fn migrate_workspace_preferences(
app: tauri::AppHandle,
project_creation_directory: Option<String>,
recent_workspaces: Vec<String>,
) -> Result<WorkspacePreferencesSnapshot, String> {
update_preferences_for_app(&app, |current| {
if current.project_creation_directory.is_some() || !current.recent_workspaces.is_empty() {
return current;
}
WorkspacePreferencesSnapshot {
project_creation_directory: normalize_project_creation_directory(
project_creation_directory.as_deref(),
),
recent_workspaces: normalize_recent_workspaces(recent_workspaces),
chat_prompt_polish_reminder_disabled: current.chat_prompt_polish_reminder_disabled,
client_server_selection: current.client_server_selection,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
const DEVELOPMENT_ORIGIN_FOR_TEST: &str = "https://dev.genarrative.world";
#[test]
fn normalizes_recent_paths_with_a_strict_eight_item_limit() {
let root = tempfile::tempdir().expect("recent workspace fixture");
let paths = (0..10)
.map(|index| {
root.path()
.join(format!("project-{index}"))
.to_string_lossy()
.into_owned()
})
.chain(std::iter::once("relative/project".to_string()))
.collect::<Vec<_>>();
let normalized = normalize_recent_workspaces(paths);
assert_eq!(normalized.len(), 8);
assert!(normalized[0].ends_with("project-0"));
assert!(!normalized.iter().any(|path| path == "relative/project"));
}
#[test]
fn malformed_preference_file_falls_back_to_empty_snapshot() {
let directory = tempfile::tempdir().expect("preference fixture");
let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME);
fs::write(&path, b"{not json").expect("malformed preference fixture");
assert_eq!(
read_preferences_at(&path),
WorkspacePreferencesSnapshot::default()
);
}
#[test]
fn chat_prompt_polish_reminder_round_trips_and_defaults_to_enabled() {
let directory = tempfile::tempdir().expect("preference fixture");
let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME);
assert!(
!WorkspacePreferencesSnapshot::default().chat_prompt_polish_reminder_disabled,
"缺失值必须按「仍然提醒」处理"
);
let snapshot = WorkspacePreferencesSnapshot {
project_creation_directory: None,
recent_workspaces: Vec::new(),
chat_prompt_polish_reminder_disabled: true,
client_server_selection: Some(ClientServerSelectionPreference {
preset: "custom".to_string(),
custom_base_url: DEVELOPMENT_ORIGIN_FOR_TEST.to_string(),
}),
};
write_preferences_at(&path, snapshot.clone()).expect("write snapshot");
assert_eq!(read_preferences_at(&path), snapshot);
fs::write(
&path,
br#"{"schemaVersion":"agc-workspace-preferences.v1"}"#,
)
.expect("legacy file without the new field");
assert!(!read_preferences_at(&path).chat_prompt_polish_reminder_disabled);
}
#[test]
fn client_server_selection_is_normalized_and_validated() {
let directory = tempfile::tempdir().expect("preference fixture");
let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME);
let snapshot = WorkspacePreferencesSnapshot {
project_creation_directory: None,
recent_workspaces: Vec::new(),
chat_prompt_polish_reminder_disabled: false,
client_server_selection: normalize_server_selection(Some(
ClientServerSelectionPreference {
preset: " custom ".to_string(),
custom_base_url: " http://127.0.0.1:10001/ ".to_string(),
},
)),
};
assert_eq!(
snapshot.client_server_selection.as_ref().map(|selection| (
selection.preset.as_str(),
selection.custom_base_url.as_str()
)),
Some(("custom", "http://127.0.0.1:10001"))
);
write_preferences_at(&path, snapshot.clone()).expect("write snapshot");
assert_eq!(read_preferences_at(&path), snapshot);
// 非法预设与非法地址都不落盘。
assert!(
normalize_server_selection(Some(ClientServerSelectionPreference {
preset: "staging".to_string(),
custom_base_url: String::new(),
}))
.is_none()
);
assert!(
normalize_server_selection(Some(ClientServerSelectionPreference {
preset: "custom".to_string(),
custom_base_url: "http://example.com".to_string(),
}))
.is_none()
);
assert!(
normalize_server_selection(Some(ClientServerSelectionPreference {
preset: "release".to_string(),
custom_base_url: "https://evil.example".to_string(),
}))
.is_some_and(|selection| selection.custom_base_url.is_empty())
);
}
#[test]
fn writes_and_reads_a_versioned_snapshot_atomically() {
let directory = tempfile::tempdir().expect("preference fixture");
let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME);
let snapshot = WorkspacePreferencesSnapshot {
project_creation_directory: Some(
directory
.path()
.join("games")
.to_string_lossy()
.into_owned(),
),
recent_workspaces: vec![
directory.path().join("one").to_string_lossy().into_owned(),
directory.path().join("two").to_string_lossy().into_owned(),
],
chat_prompt_polish_reminder_disabled: true,
client_server_selection: None,
};
write_preferences_at(&path, snapshot.clone()).expect("write snapshot");
assert_eq!(read_preferences_at(&path), snapshot);
let content = fs::read_to_string(path).expect("read snapshot");
assert!(content.contains(WORKSPACE_PREFERENCES_SCHEMA_VERSION));
}
}