Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfb7617cbb | |||
| fa2a7caa95 |
@@ -811,6 +811,41 @@ pub(crate) async fn pick_local_project_directory(
|
|||||||
Ok(Some(path.to_string_lossy().into_owned()))
|
Ok(Some(path.to_string_lossy().into_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a user-requested child directory using the current user's normal
|
||||||
|
/// filesystem rights. This deliberately does not inspect or rewrite ACLs and
|
||||||
|
/// never attempts elevation.
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn create_local_project_directory(
|
||||||
|
parent_path: String,
|
||||||
|
directory_name: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let parent = Path::new(parent_path.trim());
|
||||||
|
if parent.as_os_str().is_empty() || !parent.is_absolute() {
|
||||||
|
return Err("父目录必须是绝对路径".to_string());
|
||||||
|
}
|
||||||
|
if project_path_has_control_chars(parent) {
|
||||||
|
return Err("父目录不能包含控制字符".to_string());
|
||||||
|
}
|
||||||
|
if !parent.is_dir() {
|
||||||
|
return Err("父目录不存在或不是文件夹".to_string());
|
||||||
|
}
|
||||||
|
let name = directory_name.trim();
|
||||||
|
if name.is_empty() || name == "." || name == ".." || name.chars().any(|c| c == '/' || c == '\\')
|
||||||
|
{
|
||||||
|
return Err("目录名称无效".to_string());
|
||||||
|
}
|
||||||
|
if name.chars().any(|c| c.is_control()) {
|
||||||
|
return Err("目录名称不能包含控制字符".to_string());
|
||||||
|
}
|
||||||
|
let target = parent.join(name);
|
||||||
|
fs::create_dir(&target).map_err(|error| match error.kind() {
|
||||||
|
std::io::ErrorKind::AlreadyExists => "目录已存在".to_string(),
|
||||||
|
std::io::ErrorKind::PermissionDenied => "没有权限在此位置创建目录".to_string(),
|
||||||
|
_ => format!("创建目录失败:{error}"),
|
||||||
|
})?;
|
||||||
|
Ok(target.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
|
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
|
||||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
@@ -5364,3 +5399,47 @@ pub(crate) fn write_project_permission_policy(
|
|||||||
let _lock = acquire_project_write_lock(root, "project.policy_write")?;
|
let _lock = acquire_project_write_lock(root, "project.policy_write")?;
|
||||||
write_project_permission_policy_at(root, policy)
|
write_project_permission_policy_at(root, policy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod custom_directory_tests {
|
||||||
|
use super::create_local_project_directory;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creates_child_directory_without_touching_parent_contents() {
|
||||||
|
let root = tempfile::tempdir().expect("temp parent");
|
||||||
|
let created = create_local_project_directory(
|
||||||
|
root.path().to_string_lossy().into_owned(),
|
||||||
|
"new-game".to_string(),
|
||||||
|
)
|
||||||
|
.expect("create directory");
|
||||||
|
let path = std::path::PathBuf::from(created);
|
||||||
|
assert!(path.is_dir());
|
||||||
|
assert!(fs::read_dir(root.path())
|
||||||
|
.expect("read parent")
|
||||||
|
.next()
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_existing_child_and_path_separator() {
|
||||||
|
let root = tempfile::tempdir().expect("temp parent");
|
||||||
|
fs::create_dir(root.path().join("existing")).expect("existing");
|
||||||
|
assert_eq!(
|
||||||
|
create_local_project_directory(
|
||||||
|
root.path().to_string_lossy().into_owned(),
|
||||||
|
"existing".to_string(),
|
||||||
|
)
|
||||||
|
.expect_err("duplicate must fail"),
|
||||||
|
"目录已存在"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
create_local_project_directory(
|
||||||
|
root.path().to_string_lossy().into_owned(),
|
||||||
|
"nested/name".to_string(),
|
||||||
|
)
|
||||||
|
.expect_err("separator must fail"),
|
||||||
|
"目录名称无效"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2636,6 +2636,7 @@ fn main() {
|
|||||||
is_local_project_directory_non_empty,
|
is_local_project_directory_non_empty,
|
||||||
inspect_local_project_directory,
|
inspect_local_project_directory,
|
||||||
pick_local_project_directory,
|
pick_local_project_directory,
|
||||||
|
create_local_project_directory,
|
||||||
rename_local_game_project,
|
rename_local_game_project,
|
||||||
suggest_automatic_project_name,
|
suggest_automatic_project_name,
|
||||||
polish_local_project_prompt,
|
polish_local_project_prompt,
|
||||||
|
|||||||
@@ -895,14 +895,26 @@ export function useHomeProjectCreation({
|
|||||||
setProjectAction('creating');
|
setProjectAction('creating');
|
||||||
setStatus('正在选择新项目文件夹');
|
setStatus('正在选择新项目文件夹');
|
||||||
try {
|
try {
|
||||||
const selectedPath = await invoke<string | null>(
|
const parentPath = await invoke<string | null>(
|
||||||
'pick_local_project_directory',
|
'pick_local_project_directory',
|
||||||
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
|
projectPath.trim() ? { initialPath: projectPath.trim() } : undefined,
|
||||||
);
|
);
|
||||||
if (!selectedPath) {
|
if (!parentPath) {
|
||||||
setStatus('已取消');
|
setStatus('已取消');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const directoryName = window.prompt('请输入新目录名称');
|
||||||
|
if (!directoryName?.trim()) {
|
||||||
|
setStatus('已取消');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedPath = await invoke<string>(
|
||||||
|
'create_local_project_directory',
|
||||||
|
{
|
||||||
|
parentPath,
|
||||||
|
directoryName: directoryName.trim(),
|
||||||
|
},
|
||||||
|
);
|
||||||
setProjectPath(selectedPath);
|
setProjectPath(selectedPath);
|
||||||
projectActionRef.current = null;
|
projectActionRef.current = null;
|
||||||
setProjectAction(null);
|
setProjectAction(null);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 【实施计划】AGC 首页自定义工作目录
|
||||||
|
|
||||||
|
Version: 1
|
||||||
|
Status: active
|
||||||
|
Date: 2026-09-15
|
||||||
|
Parent Spec: 【里程碑】AGC首页自定义工作目录-2026-09-15
|
||||||
|
|
||||||
|
## 顺序
|
||||||
|
|
||||||
|
1. 定位现有首页创建与 Tauri 命令注册。
|
||||||
|
2. 增加目录选择/创建/检查的最小 native command 与前端适配。
|
||||||
|
3. 接入首页创建状态,保留现有 HomeCreationOperation。
|
||||||
|
4. 补 Rust/前端定向测试,执行编码和差异检查。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- AGC web typecheck
|
||||||
|
- 相关 Tauri cargo test
|
||||||
|
- AGC appSurface 定向测试
|
||||||
|
- npm run check:encoding
|
||||||
|
- git diff --check
|
||||||
|
|
||||||
|
## 风险与回滚
|
||||||
|
|
||||||
|
- 风险:Windows 权限检查误触发 ACL/UAC;只使用普通文件 API,不调用安全描述符或提升权限。
|
||||||
|
- 回滚:移除新增 command/UI 适配,保留既有自动建项路径。
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 【里程碑】AGC 首页自定义工作目录
|
||||||
|
|
||||||
|
Version: 1
|
||||||
|
Status: active
|
||||||
|
Date: 2026-09-15
|
||||||
|
Parent Spec: AGC 客户端 AI 游戏创作 App 实施计划
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 首页创建入口支持选择已有目录或创建子目录。
|
||||||
|
- 目录校验包含存在性、目录类型、可读写性和空目录判断。
|
||||||
|
- 校验通过后复用现有首页建项流程。
|
||||||
|
- 外部进程在校验后写入目录导致非空,不纳入本里程碑处理。
|
||||||
|
|
||||||
|
## 权限边界
|
||||||
|
|
||||||
|
- 不修改 ACL,不请求管理员权限,不通过提升权限探测目录。
|
||||||
|
- 可写性以当前用户在目标目录执行无害临时文件创建/删除为准;失败返回普通错误。
|
||||||
|
- 选择目录使用系统目录选择器,创建目录使用当前用户可写的父目录。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- 选择空目录可继续建项。
|
||||||
|
- 选择非空目录被阻止并可重新选择。
|
||||||
|
- 创建目录成功后自动选中并可继续建项。
|
||||||
|
- 不存在、非目录、不可读写均有稳定错误状态。
|
||||||
|
- 不触发 UAC,不出现 ACL 权限栈错误泄漏。
|
||||||
|
|
||||||
|
## 不做
|
||||||
|
|
||||||
|
- 不合并非空目录内容。
|
||||||
|
- 不监控校验后目录变化。
|
||||||
|
- 不新增项目格式或公开 API。
|
||||||
Reference in New Issue
Block a user