拆分游戏创作壳 Tauri 入口
拆出 CLI、命令、配置、Agent、素材、项目、预览和窗口模块 拆出 Rust 单测并保留 main.rs 作为 Tauri 薄入口 扩展壳配置与原生壳门禁的 Rust 源码扫描范围 同步 AI 游戏创作 App 技术方案和决策记录
This commit is contained in:
@@ -30,6 +30,10 @@ const tauriHandlerSource = fs.readFileSync(
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const tauriRustSource = readSourceTree(
|
||||
new URL('../src-tauri/src/', import.meta.url),
|
||||
'.rs',
|
||||
);
|
||||
const sharedContractSource = fs.readFileSync(
|
||||
new URL(
|
||||
'../../../packages/shared/src/contracts/gameCreationApp.ts',
|
||||
@@ -73,6 +77,26 @@ function collectFiles(path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function readSourceTree(path, extension) {
|
||||
const stat = fs.statSync(path);
|
||||
if (stat.isDirectory()) {
|
||||
return fs
|
||||
.readdirSync(path, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((entry) =>
|
||||
readSourceTree(
|
||||
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
|
||||
extension,
|
||||
),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
if (pathnameExtension(path.pathname) !== extension) {
|
||||
return '';
|
||||
}
|
||||
return fs.readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
function pathnameExtension(pathname) {
|
||||
const index = pathname.lastIndexOf('.');
|
||||
return index === -1 ? '' : pathname.slice(index);
|
||||
@@ -299,7 +323,7 @@ assertCommandNamesSubset(
|
||||
assertCommandNamesSubset(
|
||||
'AI game creator shell Tauri command implementation',
|
||||
parseTauriHandlerCommandNames(tauriHandlerSource),
|
||||
parseRustFunctionNames(tauriHandlerSource),
|
||||
parseRustFunctionNames(tauriRustSource),
|
||||
);
|
||||
|
||||
assertCommandNamesSubset(
|
||||
@@ -481,11 +505,6 @@ for (const snippet of [
|
||||
}
|
||||
}
|
||||
|
||||
const tauriMainSource = fs.readFileSync(
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
for (const snippet of [
|
||||
'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"',
|
||||
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
|
||||
@@ -513,7 +532,7 @@ for (const snippet of [
|
||||
'"command.auto"',
|
||||
'GameCreationAppPermission::Auto',
|
||||
]) {
|
||||
if (!tauriMainSource.includes(snippet)) {
|
||||
if (!tauriRustSource.includes(snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
||||
);
|
||||
@@ -524,7 +543,7 @@ for (const snippet of [
|
||||
'open_developer_window(app)?;',
|
||||
'tauri::WebviewWindowBuilder::new(app, "developer"',
|
||||
]) {
|
||||
if (tauriMainSource.includes(snippet)) {
|
||||
if (tauriRustSource.includes(snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator shell must not auto-open developer windows: ${snippet}`,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) enum CliCommand {
|
||||
LlmStatus,
|
||||
AgentRun {
|
||||
project_path: PathBuf,
|
||||
prompt: String,
|
||||
wait_for_enter: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec<String> {
|
||||
let mut lines = vec![
|
||||
format!("llm.configured={}", status.configured),
|
||||
format!("llm.apiKeyPresent={}", status.api_key_present),
|
||||
format!(
|
||||
"llm.baseUrl={}",
|
||||
status.base_url.as_deref().unwrap_or_default()
|
||||
),
|
||||
format!("llm.model={}", status.model.as_deref().unwrap_or_default()),
|
||||
format!("llm.apiKind={}", status.api_kind),
|
||||
format!("llm.stream={}", status.stream),
|
||||
];
|
||||
for agent in &status.agents {
|
||||
lines.push(format!(
|
||||
"llm.agent.{}.configured={}",
|
||||
agent.agent_id, agent.configured
|
||||
));
|
||||
lines.push(format!(
|
||||
"llm.agent.{}.apiKeyPresent={}",
|
||||
agent.agent_id, agent.api_key_present
|
||||
));
|
||||
lines.push(format!(
|
||||
"llm.agent.{}.baseUrl={}",
|
||||
agent.agent_id,
|
||||
agent.base_url.as_deref().unwrap_or_default()
|
||||
));
|
||||
lines.push(format!(
|
||||
"llm.agent.{}.model={}",
|
||||
agent.agent_id,
|
||||
agent.model.as_deref().unwrap_or_default()
|
||||
));
|
||||
lines.push(format!(
|
||||
"llm.agent.{}.apiKind={}",
|
||||
agent.agent_id, agent.api_kind
|
||||
));
|
||||
lines.push(format!(
|
||||
"llm.agent.{}.stream={}",
|
||||
agent.agent_id, agent.stream
|
||||
));
|
||||
if let Some(error) = agent.error.as_deref() {
|
||||
lines.push(format!("llm.agent.{}.error={error}", agent.agent_id));
|
||||
}
|
||||
}
|
||||
if let Some(error) = status.error.as_deref() {
|
||||
lines.push(format!("llm.error={error}"));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, String> {
|
||||
if args.first().map(String::as_str) == Some("--llm-status") {
|
||||
return Ok(Some(CliCommand::LlmStatus));
|
||||
}
|
||||
if args.first().map(String::as_str) != Some("--agent-run") {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut rest = args[1..].to_vec();
|
||||
let wait_for_enter = if let Some(index) = rest.iter().position(|arg| arg == "--no-wait") {
|
||||
rest.remove(index);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
};
|
||||
let project_path = rest
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| "用法:--agent-run [--no-wait] <本地项目绝对路径> <创作需求>".to_string())?;
|
||||
if rest.len() < 2 {
|
||||
return Err("用法:--agent-run [--no-wait] <本地项目绝对路径> <创作需求>".to_string());
|
||||
}
|
||||
let prompt = rest[1..].join(" ");
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err("创作需求不能为空".to_string());
|
||||
}
|
||||
Ok(Some(CliCommand::AgentRun {
|
||||
project_path: PathBuf::from(project_path),
|
||||
prompt: prompt.to_string(),
|
||||
wait_for_enter,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
|
||||
match command {
|
||||
CliCommand::LlmStatus => {
|
||||
let status = check_game_creator_llm_config_from_config();
|
||||
for line in game_creator_llm_status_lines(&status) {
|
||||
println!("{line}");
|
||||
}
|
||||
if status.configured {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("LLM 配置未就绪".to_string())
|
||||
}
|
||||
}
|
||||
CliCommand::AgentRun {
|
||||
project_path,
|
||||
prompt,
|
||||
wait_for_enter,
|
||||
} => {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
|
||||
let result =
|
||||
runtime.block_on(generate_local_game_draft_at(&project_path, &prompt, None))?;
|
||||
let (preview, stop) = start_local_game_preview_for_project(&project_path)?;
|
||||
record_preview_state(
|
||||
&project_path,
|
||||
GameCreationAppPreviewStatus::Running,
|
||||
Some(preview.url.clone()),
|
||||
Some(preview.port),
|
||||
)?;
|
||||
append_preview_log(&project_path, "running", Some(&preview.url))?;
|
||||
append_preview_start_trace_step(&project_path, &preview)?;
|
||||
println!("agent.run.completed");
|
||||
println!("projectPath={}", result.project_path);
|
||||
println!("gameIndexPath={}", result.game_index_path);
|
||||
println!("designPath={}", result.design_path);
|
||||
println!(
|
||||
"tracePath={}",
|
||||
project_path.join(".agent/run.latest.json").display()
|
||||
);
|
||||
println!("previewUrl={}", preview.url);
|
||||
if wait_for_enter {
|
||||
println!("按 Enter 停止本地预览。");
|
||||
let mut line = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut line);
|
||||
}
|
||||
let _ = stop.send(());
|
||||
let _ = record_preview_state(
|
||||
&project_path,
|
||||
GameCreationAppPreviewStatus::Stopped,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = append_preview_log(&project_path, "stopped", None);
|
||||
let _ = append_preview_stop_trace_step(&project_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PreviewRegistry {
|
||||
current: Mutex<Option<PreviewServer>>,
|
||||
}
|
||||
|
||||
struct PreviewServer {
|
||||
preview: LocalPreviewResult,
|
||||
stop: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl PreviewRegistry {
|
||||
pub(crate) fn set_running(
|
||||
&self,
|
||||
preview: LocalPreviewResult,
|
||||
stop: mpsc::Sender<()>,
|
||||
) -> (LocalPreviewResult, Option<LocalPreviewResult>) {
|
||||
let mut current = self.current.lock().expect("preview registry lock");
|
||||
let previous_preview = if let Some(previous) = current.take() {
|
||||
let preview = previous.preview;
|
||||
let _ = previous.stop.send(());
|
||||
Some(preview)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
*current = Some(PreviewServer {
|
||||
preview: preview.clone(),
|
||||
stop,
|
||||
});
|
||||
(preview, previous_preview)
|
||||
}
|
||||
|
||||
pub(crate) fn status(&self) -> LocalPreviewStatus {
|
||||
let current = self.current.lock().expect("preview registry lock");
|
||||
if let Some(server) = current.as_ref() {
|
||||
local_preview_status_from_result(&server.preview)
|
||||
} else {
|
||||
stopped_preview_status()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&self) -> LocalPreviewStatus {
|
||||
let mut current = self.current.lock().expect("preview registry lock");
|
||||
if let Some(server) = current.take() {
|
||||
let _ = server.stop.send(());
|
||||
}
|
||||
stopped_preview_status()
|
||||
}
|
||||
|
||||
pub(crate) fn stop_for_project(&self, root: Option<&Path>) -> (LocalPreviewStatus, bool) {
|
||||
let mut current = self.current.lock().expect("preview registry lock");
|
||||
let Some(server) = current.as_ref() else {
|
||||
return (stopped_preview_status(), false);
|
||||
};
|
||||
if let Some(root) = root {
|
||||
let status = local_preview_status_from_result(&server.preview);
|
||||
if ensure_preview_belongs_to_project(&status, root).is_err() {
|
||||
return (stopped_preview_status(), false);
|
||||
}
|
||||
}
|
||||
let Some(server) = current.take() else {
|
||||
return (stopped_preview_status(), false);
|
||||
};
|
||||
let _ = server.stop.send(());
|
||||
(stopped_preview_status(), true)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stopped_preview_status() -> LocalPreviewStatus {
|
||||
LocalPreviewStatus {
|
||||
status: "stopped".to_string(),
|
||||
url: None,
|
||||
port: None,
|
||||
root: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_preview_status_from_result(preview: &LocalPreviewResult) -> LocalPreviewStatus {
|
||||
LocalPreviewStatus {
|
||||
status: "running".to_string(),
|
||||
url: Some(preview.url.clone()),
|
||||
port: Some(preview.port),
|
||||
root: Some(preview.root.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn preview_open_url(status: &LocalPreviewStatus) -> Result<String, String> {
|
||||
if status.status == "running" {
|
||||
if let Some(url) = status.url.as_deref() {
|
||||
if url.starts_with("http://127.0.0.1:") {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("preview is not running".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_preview_open_project(
|
||||
status: &LocalPreviewStatus,
|
||||
project_path: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let Some(project_path) = project_path.map(str::trim).filter(|path| !path.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let root = Path::new(project_path);
|
||||
enforce_project_permission_policy(root, "preview.open")?;
|
||||
ensure_preview_belongs_to_project(status, root)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_preview_belongs_to_project(
|
||||
status: &LocalPreviewStatus,
|
||||
root: &Path,
|
||||
) -> Result<(), String> {
|
||||
if root.as_os_str().is_empty() {
|
||||
return Err("项目目录不能为空".to_string());
|
||||
}
|
||||
if !root.is_absolute() {
|
||||
return Err("项目目录必须是绝对路径".to_string());
|
||||
}
|
||||
let preview_root = status
|
||||
.root
|
||||
.as_deref()
|
||||
.ok_or_else(|| "preview is not running".to_string())?;
|
||||
let expected_root = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?;
|
||||
let actual_root = Path::new(preview_root)
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("读取预览项目目录失败:{preview_root}: {error}"))?;
|
||||
if actual_root == expected_root {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("当前预览不属于已授权本地项目".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn filter_preview_status_for_project(
|
||||
status: LocalPreviewStatus,
|
||||
project_path: Option<&str>,
|
||||
) -> LocalPreviewStatus {
|
||||
let Some(project_path) = project_path.map(str::trim).filter(|path| !path.is_empty()) else {
|
||||
return status;
|
||||
};
|
||||
if ensure_preview_belongs_to_project(&status, Path::new(project_path)).is_err() {
|
||||
stopped_preview_status()
|
||||
} else {
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn start_local_game_preview(
|
||||
project_path: String,
|
||||
registry: tauri::State<'_, PreviewRegistry>,
|
||||
) -> Result<LocalPreviewResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "preview.start")?;
|
||||
let _lock = acquire_project_write_lock(root, "preview.start")?;
|
||||
let (preview, stop) = start_local_game_preview_for_project(root)?;
|
||||
if let Err(error) = record_preview_state(
|
||||
root,
|
||||
GameCreationAppPreviewStatus::Running,
|
||||
Some(preview.url.clone()),
|
||||
Some(preview.port),
|
||||
) {
|
||||
let _ = stop.send(());
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) {
|
||||
let _ = stop.send(());
|
||||
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None);
|
||||
return Err(error);
|
||||
}
|
||||
let (preview, previous_preview) = registry.set_running(preview, stop);
|
||||
if let Some(previous_preview) = previous_preview.as_ref() {
|
||||
record_replaced_preview_stop(previous_preview);
|
||||
}
|
||||
if let Err(error) = append_preview_start_trace_step(root, &preview) {
|
||||
let _ = registry.stop();
|
||||
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn stop_local_game_preview(
|
||||
project_path: Option<String>,
|
||||
registry: tauri::State<'_, PreviewRegistry>,
|
||||
) -> Result<LocalPreviewStatus, String> {
|
||||
let project_path = project_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty());
|
||||
let root = project_path.map(Path::new);
|
||||
let _lock = if let Some(root) = root {
|
||||
enforce_project_permission_policy(root, "preview.stop")?;
|
||||
Some(acquire_project_write_lock(root, "preview.stop")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
stop_local_game_preview_for_root(root, ®istry)
|
||||
}
|
||||
|
||||
pub(crate) fn stop_local_game_preview_for_root(
|
||||
root: Option<&Path>,
|
||||
registry: &PreviewRegistry,
|
||||
) -> Result<LocalPreviewStatus, String> {
|
||||
let (status, stopped) = registry.stop_for_project(root);
|
||||
if let Some(root) = root.filter(|_| stopped) {
|
||||
record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?;
|
||||
append_preview_log(root, "stopped", None)?;
|
||||
append_preview_stop_trace_step(root)?;
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_local_game_preview_status(
|
||||
registry: tauri::State<'_, PreviewRegistry>,
|
||||
project_path: Option<String>,
|
||||
) -> Result<LocalPreviewStatus, String> {
|
||||
get_local_game_preview_status_at(®istry, project_path.as_deref())
|
||||
}
|
||||
|
||||
pub(crate) fn get_local_game_preview_status_at(
|
||||
registry: &PreviewRegistry,
|
||||
project_path: Option<&str>,
|
||||
) -> Result<LocalPreviewStatus, String> {
|
||||
let project_path = project_path.map(str::trim).filter(|path| !path.is_empty());
|
||||
if let Some(project_path) = project_path {
|
||||
enforce_project_permission_policy(Path::new(project_path), "preview.status")?;
|
||||
}
|
||||
Ok(filter_preview_status_for_project(
|
||||
registry.status(),
|
||||
project_path,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_local_game_preview(
|
||||
app: tauri::AppHandle,
|
||||
registry: tauri::State<'_, PreviewRegistry>,
|
||||
project_path: Option<String>,
|
||||
) -> Result<LocalPreviewStatus, String> {
|
||||
let status = registry.status();
|
||||
validate_preview_open_project(&status, project_path.as_deref())?;
|
||||
let url = preview_open_url(&status)?;
|
||||
app.opener()
|
||||
.open_url(&url, None::<&str>)
|
||||
.map_err(|error| format!("preview open failed: {error}"))?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub(crate) fn start_local_game_preview_for_project(
|
||||
root: &Path,
|
||||
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
|
||||
if root.as_os_str().is_empty() {
|
||||
return Err("项目目录不能为空".to_string());
|
||||
}
|
||||
if !root.is_absolute() {
|
||||
return Err("项目目录必须是绝对路径".to_string());
|
||||
}
|
||||
|
||||
let game_root = root.join("game");
|
||||
if !game_root.is_dir() {
|
||||
return Err(format!("游戏目录不存在:{}", game_root.display()));
|
||||
}
|
||||
if !game_root.join("index.html").is_file() {
|
||||
return Err(format!(
|
||||
"游戏入口不存在:{}",
|
||||
game_root.join("index.html").display()
|
||||
));
|
||||
}
|
||||
|
||||
let listener =
|
||||
TcpListener::bind(("127.0.0.1", 0)).map_err(|error| format!("启动预览失败:{error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("读取预览端口失败:{error}"))?
|
||||
.port();
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|error| format!("设置预览监听失败:{error}"))?;
|
||||
let served_root = root.to_path_buf();
|
||||
let (stop_sender, stop_receiver) = mpsc::channel();
|
||||
|
||||
thread::spawn(move || loop {
|
||||
if stop_receiver.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => handle_preview_stream(stream, &served_root),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
});
|
||||
|
||||
Ok((
|
||||
LocalPreviewResult {
|
||||
url: format!("http://127.0.0.1:{port}/"),
|
||||
port,
|
||||
root: root.to_string_lossy().into_owned(),
|
||||
},
|
||||
stop_sender,
|
||||
))
|
||||
}
|
||||
|
||||
fn handle_preview_stream(mut stream: TcpStream, root: &Path) {
|
||||
let mut request_line = String::new();
|
||||
{
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
if reader.read_line(&mut request_line).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts.next().unwrap_or_default();
|
||||
let url_path = parts.next().unwrap_or("/");
|
||||
let response = build_preview_response(root, method, url_path);
|
||||
let _ = stream.write_all(&response);
|
||||
}
|
||||
|
||||
pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) -> Vec<u8> {
|
||||
let is_head = method == "HEAD";
|
||||
if method != "GET" && !is_head {
|
||||
return http_response(
|
||||
"405 Method Not Allowed",
|
||||
"text/plain",
|
||||
b"method not allowed",
|
||||
b"method not allowed".len(),
|
||||
);
|
||||
}
|
||||
|
||||
let file_path = match resolve_preview_path(root, url_path) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
let body: &[u8] = if is_head { &[] } else { b"not found" };
|
||||
return http_response("404 Not Found", "text/plain", body, b"not found".len());
|
||||
}
|
||||
};
|
||||
let body = match fs::read(&file_path) {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
let body: &[u8] = if is_head { &[] } else { b"not found" };
|
||||
return http_response("404 Not Found", "text/plain", body, b"not found".len());
|
||||
}
|
||||
};
|
||||
let content_length = body.len();
|
||||
let body = if is_head { Vec::new() } else { body };
|
||||
http_response("200 OK", content_type(&file_path), &body, content_length)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result<PathBuf, String> {
|
||||
let path = url_path.split('?').next().unwrap_or("/");
|
||||
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
|
||||
let relative = decoded.trim_start_matches('/');
|
||||
if relative.is_empty() {
|
||||
return canonical_preview_path(root, &root.join("game/index.html"));
|
||||
}
|
||||
|
||||
let mut file_path = root.to_path_buf();
|
||||
let mut parts = relative.split('/');
|
||||
let first = parts.next().ok_or_else(|| "预览路径非法".to_string())?;
|
||||
if first != "game" && first != "assets" {
|
||||
return Err("预览路径只能访问 game/ 或 assets/".to_string());
|
||||
}
|
||||
file_path.push(first);
|
||||
for part in parts {
|
||||
if part.is_empty() || part == "." || part == ".." || part.contains('\\') {
|
||||
return Err("预览路径非法".to_string());
|
||||
}
|
||||
file_path.push(part);
|
||||
}
|
||||
canonical_preview_path(root, &file_path)
|
||||
}
|
||||
|
||||
fn canonical_preview_path(root: &Path, file_path: &Path) -> Result<PathBuf, String> {
|
||||
let canonical_root = root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("预览根目录不可用:{}: {error}", root.display()))?;
|
||||
let canonical_file = file_path
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("预览文件不可用:{}: {error}", file_path.display()))?;
|
||||
for segment in ["game", "assets"] {
|
||||
let allowed_dir = root.join(segment);
|
||||
let metadata = match fs::symlink_metadata(&allowed_dir) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"预览目录不可用:{}: {error}",
|
||||
allowed_dir.display()
|
||||
))
|
||||
}
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(format!("预览目录不能是符号链接:{}", allowed_dir.display()));
|
||||
}
|
||||
let canonical_allowed_dir = allowed_dir
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("预览目录不可用:{}: {error}", allowed_dir.display()))?;
|
||||
if !canonical_allowed_dir.starts_with(&canonical_root) {
|
||||
return Err("预览目录越过项目目录".to_string());
|
||||
}
|
||||
if canonical_file.starts_with(canonical_allowed_dir) {
|
||||
return Ok(canonical_file);
|
||||
}
|
||||
}
|
||||
Err("预览路径只能访问真实 game/ 或 assets/ 目录".to_string())
|
||||
}
|
||||
|
||||
fn percent_decode_path(path: &str) -> Option<String> {
|
||||
let bytes = path.as_bytes();
|
||||
let mut output = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%' {
|
||||
let high = hex_value(*bytes.get(index + 1)?)?;
|
||||
let low = hex_value(*bytes.get(index + 2)?)?;
|
||||
output.push((high << 4) | low);
|
||||
index += 3;
|
||||
} else {
|
||||
output.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(output).ok()
|
||||
}
|
||||
|
||||
fn hex_value(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn content_type(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|extension| extension.to_str()) {
|
||||
Some("aac") => "audio/aac",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("flac") => "audio/flac",
|
||||
Some("gif") => "image/gif",
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("jpeg" | "jpg") => "image/jpeg",
|
||||
Some("js") => "text/javascript; charset=utf-8",
|
||||
Some("json") => "application/json; charset=utf-8",
|
||||
Some("m4a") => "audio/mp4",
|
||||
Some("mp3") => "audio/mpeg",
|
||||
Some("mp4") => "video/mp4",
|
||||
Some("ogg") => "audio/ogg",
|
||||
Some("png") => "image/png",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("wasm") => "application/wasm",
|
||||
Some("wav") => "audio/wav",
|
||||
Some("webm") => "video/webm",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn http_response(status: &str, content_type: &str, body: &[u8], content_length: usize) -> Vec<u8> {
|
||||
let header = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
content_length
|
||||
);
|
||||
let mut response = header.into_bytes();
|
||||
response.extend_from_slice(body);
|
||||
response
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn workspace_window_url(project_path: &str) -> tauri::WebviewUrl {
|
||||
tauri::WebviewUrl::App(PathBuf::from(format!(
|
||||
"index.html?main&projectPath={}",
|
||||
percent_encode_query_value(project_path)
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn launcher_window_url() -> tauri::WebviewUrl {
|
||||
tauri::WebviewUrl::App(PathBuf::from("index.html?launcher"))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> {
|
||||
let project_path = project_path.trim();
|
||||
if project_path.is_empty() {
|
||||
return Err("请提供工作区绝对路径".to_string());
|
||||
}
|
||||
if !Path::new(project_path).is_absolute() {
|
||||
return Err("工作区路径必须是绝对路径".to_string());
|
||||
}
|
||||
if project_path.chars().any(char::is_control) {
|
||||
return Err("工作区路径不能包含控制字符".to_string());
|
||||
}
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
fn percent_encode_query_value(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
encoded.push(byte as char)
|
||||
}
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_game_creator_workspace_window(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
project_path: String,
|
||||
) -> Result<(), String> {
|
||||
let project_path = validate_workspace_window_project_path(&project_path)?;
|
||||
if let Some(existing) = app.get_webview_window("main") {
|
||||
existing.close().map_err(|error| error.to_string())?;
|
||||
}
|
||||
tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path))
|
||||
.title("AI 游戏创作")
|
||||
.inner_size(1180.0, 820.0)
|
||||
.min_inner_size(760.0, 560.0)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_game_creator_launcher_window(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
) -> Result<(), String> {
|
||||
if let Some(existing) = app.get_webview_window("launcher") {
|
||||
existing.set_focus().map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url())
|
||||
.title("AI 游戏创作")
|
||||
.inner_size(820.0, 640.0)
|
||||
.min_inner_size(720.0, 520.0)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -3954,8 +3954,9 @@
|
||||
- 2026-06-24 调整:普通用户通过聊天输入 `/project /绝对路径` 触发 `project.create` 待确认命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;不要把开发窗口项目路径输入框暴露到正式用户界面。
|
||||
- 2026-06-25 调整:普通用户侧所有会写入、运行、查看 / 打开预览或导入本地产物的命令必须先完成 `/project` 初始化,包括 `game.generate_draft`、`asset.upload`、`game.run_local`、`command.run_limited`、`preview.start`、`preview.status`、`preview.open`、`preview.stop`、`memory.write`、`memory.delete`、`canvas.project_sync`、`canvas.asset_import` 和 `canvas.export_import`;没有已授权本地项目时只提示设置项目,不得落到默认 `/tmp` 草稿目录。
|
||||
- 2026-06-24 调整,2026-06-30 更新:终端测试入口使用同一个 Tauri Rust 二进制的 `--agent-run <本地项目绝对路径> <创作需求>`,只复用现有 `game.generate_draft`、`game.static_smoke` 和本地 HTTP 预览链路,不另建第二套 agent runtime;发布 App 的 LLM 配置从 Tauri 应用配置目录读取,不写入仓库默认配置或项目文件。需要自动验证时可追加 `--no-wait`,生成预览 trace 后立即停止本地预览,避免命令卡在回车等待。
|
||||
- 2026-06-24 调整:AI 游戏创作 App 的 release 配置只登记 `main` 聊天窗口,主窗口保持聊天尺寸;任务、文件、记忆、预览、日志和能力面板只能通过 debug/dev 下额外创建的 `developer` 窗口或 Vite dev `?dev/#dev` 查看,不进入普通用户窗口。
|
||||
- 2026-06-24 调整:`check:native-shells` 必须静态守住 AI 游戏创作 App 的用户 / 开发边界:release 只保留 `main` 聊天窗口,`developer` 窗口只在 debug 创建,开发面板只能在 `devMode` 分支渲染。
|
||||
- 2026-07-04 调整:`apps/ai-game-creator-shell/src-tauri/src/main.rs` 拆成薄入口,继续只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;CLI 参数解析与终端运行输出放入 `cli.rs`,Tauri command 包装放入 `commands.rs`,运行时配置 / LLM 配置检查放入 `config.rs`,Agent loop 与生成编排放入 `agent.rs`,上传 / 画板 / 平台美术生成接入放入 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放入 `project.rs`,本地 HTTP 预览 server、preview registry 和 preview Tauri command 放入 `preview.rs`,启动器 / 主窗口 URL 与窗口切换 command 放入 `windows.rs`,Rust 单测放入 `tests.rs`。拆分不得改变 Tauri command 名、JSON 字段、`.agent/*` 路径、项目权限策略或错误语义。
|
||||
- 2026-06-24 调整,2026-07-04 更新:AI 游戏创作 App 的 release 配置只登记 `launcher` 启动器窗口,选择工作区后由 Tauri command 关闭启动器并打开 `main` 主窗口;任务、文件、记忆、预览、日志和能力面板只能通过 Vite dev 的 `?dev/#dev` 分支查看,不进入普通用户窗口,Tauri 不再自动额外创建 `developer` 窗口。
|
||||
- 2026-06-24 调整,2026-07-04 更新:`check:native-shells` 必须静态守住 AI 游戏创作 App 的用户 / 开发边界:release 只保留 `launcher` 启动器窗口,工作区切换命令会关闭来源窗口,用户侧预览只交给系统外部浏览器,开发面板只能在 `devMode` 分支渲染,Tauri 不得自动额外打开 `developer` 窗口。
|
||||
- 2026-06-25 调整:`check:native-shells` 在 `ai-game-creator-shell:check` 之后必须追加 `ai-game-creator-shell:build -- --no-bundle`,让原生壳总门禁同时证明 AI 游戏创作独立 Tauri 壳能完成 release 编译,而不是只证明前端 / Rust 逻辑测试通过。
|
||||
- 2026-06-24 调整:普通用户通过聊天输入 `/preview` 触发待确认 `preview.start`,完成 `/project` 初始化后可通过 `/open-preview` 触发待确认 `preview.open` 并只打开当前已授权项目对应的 `127.0.0.1` 本地预览,通过 `/preview-status` 查询当前项目预览,通过 `/preview-stop` 停止当前项目预览;预览 iframe 和状态面板仍只在开发窗口可见,不能把 `preview.open` 扩展成任意 URL 打开能力,也不能打开、展示或停止其它本地项目遗留的全局预览。
|
||||
- 2026-06-25 调整:`/preview-status` 虽然是只读命令,也必须写入 `preview.status` 命令日志并向聊天返回错误,不得因查询失败产生未捕获异常或无审计记录。
|
||||
|
||||
@@ -228,7 +228,7 @@ game-project/
|
||||
- `/publish` 聊天入口由 `appSurface.test.ts` 主窗口 smoke 覆盖:只基于当前已加载 manifest、最近 run trace、预览状态、资产来源和最近命令生成发布准备清单,提供 `/run`、`/trace`、`/agent-resume ` 或 `/export` 草稿,不触发 Tauri 读写、预览启动、文件读取或新增普通用户面板。
|
||||
- 主窗口策略快捷入口只填入 `/policy-confirm project.index`、`/policy-confirm asset.register`、`/policy-confirm memory.write`、`/policy-confirm preview.start`、`/policy-confirm preview.open`、`/policy-confirm preview.stop`、`/policy-confirm agent.run_status`、`/policy-confirm conversation.read` 或 `/policy-confirm conversation.write` 草稿;Agent 状态栏的“继续说明”只填入 `/agent-resume ` 草稿,不直接触发 run 生命周期写入。
|
||||
- 主窗口最近 checkpoint 列表展示 checkpoint id、文件数、大小和创建时间,同时提供直接对比、填入 `/diff`、确认回滚和填入 `/restore`;回滚继续走 `project.restore` 确认卡,不直接写项目文件。
|
||||
- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 窗口边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记 `launcher` 启动器窗口,选择工作区后才关闭启动器并打开 `main` 主窗口,开发面板只在 debug/dev 路径打开,独立壳能完成 release 编译。
|
||||
- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记 `launcher` 启动器窗口,选择工作区后才关闭启动器并打开 `main` 主窗口,Tauri 不自动额外打开 `developer` 窗口,开发面板只在 Vite dev 的 `?dev` / `#dev` 分支渲染,独立壳能完成 release 编译。
|
||||
- `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。
|
||||
- `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。
|
||||
- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `llm.stream` 为 `true`。
|
||||
@@ -236,6 +236,7 @@ game-project/
|
||||
## 当前最小落地
|
||||
|
||||
- `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`。
|
||||
- Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,启动器 / 主窗口切换命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。
|
||||
- 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。
|
||||
- v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。
|
||||
- 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。
|
||||
|
||||
@@ -29,9 +29,9 @@ const aiGameCreatorShellAppSource = fs.readFileSync(
|
||||
const aiGameCreatorShellTauriConfig = JSON.parse(
|
||||
fs.readFileSync('apps/ai-game-creator-shell/src-tauri/tauri.conf.json', 'utf8'),
|
||||
);
|
||||
const aiGameCreatorShellTauriSource = fs.readFileSync(
|
||||
'apps/ai-game-creator-shell/src-tauri/src/main.rs',
|
||||
'utf8',
|
||||
const aiGameCreatorShellTauriSource = readSourceTree(
|
||||
'apps/ai-game-creator-shell/src-tauri/src',
|
||||
'.rs',
|
||||
);
|
||||
|
||||
const productionShellScanRoots = [
|
||||
@@ -80,6 +80,21 @@ const h5HostBridgeScannedFacadeImports = new Set([
|
||||
'writeHostClipboardText',
|
||||
]);
|
||||
|
||||
function readSourceTree(entryPath, extension) {
|
||||
const stats = fs.statSync(entryPath);
|
||||
if (stats.isDirectory()) {
|
||||
return fs
|
||||
.readdirSync(entryPath, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((entry) => readSourceTree(path.join(entryPath, entry.name), extension))
|
||||
.join('\n');
|
||||
}
|
||||
if (path.extname(entryPath) !== extension) {
|
||||
return '';
|
||||
}
|
||||
return fs.readFileSync(entryPath, 'utf8');
|
||||
}
|
||||
|
||||
function assertRootNativeShellCheckScripts() {
|
||||
if (rootPackageJson.scripts?.['check:native-shells'] !== 'node scripts/check-native-shells.mjs') {
|
||||
throw new Error('root check:native-shells script must run scripts/check-native-shells.mjs');
|
||||
@@ -2000,7 +2015,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
"await invoke<LocalPreviewStatus>('open_local_game_preview'",
|
||||
"return '已交给外部浏览器打开。';",
|
||||
'const openMessage = await openPreviewInExternalBrowser(',
|
||||
"appendLocalPermissionLog(\n result.projectPath,\n 'permission.confirm',\n 'project.create',",
|
||||
"appendLocalPermissionLog(\n openedProject.projectPath,\n 'permission.confirm',\n 'project.create',",
|
||||
]) {
|
||||
if (!aiGameCreatorShellAppSource.includes(snippet)) {
|
||||
throw new Error(`AI game creator external preview boundary drifted: missing ${snippet}`);
|
||||
@@ -2008,15 +2023,20 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'#[cfg(debug_assertions)]\nfn developer_window_url()',
|
||||
'tauri::WebviewUrl::App(PathBuf::from("index.html?dev"))',
|
||||
'WebviewWindowBuilder::new(app, "developer", developer_window_url())',
|
||||
'#[cfg(debug_assertions)]\n open_developer_window(app)?;',
|
||||
'fn open_local_game_preview(',
|
||||
'.open_url(&url, None::<&str>)',
|
||||
]) {
|
||||
if (!aiGameCreatorShellTauriSource.includes(snippet)) {
|
||||
throw new Error(`AI game creator developer window boundary drifted: missing ${snippet}`);
|
||||
throw new Error(`AI game creator external preview boundary drifted: missing ${snippet}`);
|
||||
}
|
||||
}
|
||||
for (const snippet of [
|
||||
'fn open_developer_window(',
|
||||
'WebviewWindowBuilder::new(app, "developer"',
|
||||
'open_developer_window(app)?',
|
||||
]) {
|
||||
if (aiGameCreatorShellTauriSource.includes(snippet)) {
|
||||
throw new Error(`AI game creator release shell must not auto-open developer windows: ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2026,21 +2046,14 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
const launcherWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf(
|
||||
'fn open_game_creator_launcher_window(',
|
||||
);
|
||||
const developerWindowIndex = aiGameCreatorShellTauriSource.indexOf(
|
||||
'#[cfg(debug_assertions)]\nfn open_developer_window(',
|
||||
);
|
||||
const workspaceWindowCommandSource = aiGameCreatorShellTauriSource.slice(
|
||||
workspaceWindowCommandIndex,
|
||||
launcherWindowCommandIndex,
|
||||
);
|
||||
const launcherWindowCommandSource = aiGameCreatorShellTauriSource.slice(
|
||||
launcherWindowCommandIndex,
|
||||
developerWindowIndex,
|
||||
);
|
||||
const launcherWindowCommandSource = aiGameCreatorShellTauriSource.slice(launcherWindowCommandIndex);
|
||||
if (
|
||||
workspaceWindowCommandIndex < 0 ||
|
||||
launcherWindowCommandIndex < 0 ||
|
||||
developerWindowIndex < 0 ||
|
||||
!workspaceWindowCommandSource.includes('window.close().map_err(|error| error.to_string())?;') ||
|
||||
!launcherWindowCommandSource.includes('window.close().map_err(|error| error.to_string())?;')
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user