d6c939fa31
- 新增 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] 造成的分片门禁「同一用例被分到两片」误判,并同步该模块的会话存储与偏好读写用例。
521 lines
20 KiB
Rust
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));
|
|
}
|
|
}
|