diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index be20e97cb..412a201ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -671,7 +671,7 @@ dependencies = [ "tracing", "url", "which", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -820,6 +820,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "const_fn" version = "0.4.12" @@ -1164,6 +1184,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1790,8 +1819,10 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", "tokio", @@ -2069,6 +2100,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2243,7 +2280,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -3432,6 +3469,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -4500,6 +4547,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -5555,6 +5612,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -5619,6 +5697,23 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.11.0" @@ -5879,6 +5974,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -7019,6 +7123,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-registry" version = "0.6.1" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 592d63419..cc8eb3042 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -68,6 +68,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul regex = "1" shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } tauri = { version = "2.11.2", features = [] } +tauri-plugin-deep-link = "2.4.9" +tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } tauri-plugin-dialog = "2.7.1" tauri-plugin-opener = "2" tauri-plugin-updater = "2.11.0" diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 3aafbe065..51aa2386b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -391,6 +391,13 @@ pub(super) fn run() { ))); let setup_log = Arc::clone(&startup_log); let app = tauri::Builder::default() + // 单实例必须第一个注册:Windows/Linux 的深链是「带 URL 启动第二个进程」, + // 单实例插件把第二个实例的 argv 转交给已有实例(`deep-link` feature 会让 deep-link + // 插件据此发事件),否则用户点第二次链接会再开一个窗口而不是回到已有窗口。 + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + game_fork::focus_main_window_for_deep_link(app); + })) + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) @@ -528,6 +535,8 @@ pub(super) fn run() { ) })?; setup_log.append("startup.runner.start.complete"); + // Fork 深链:先取冷启动 URL,再订阅后续链接(并补一次协议注册)。 + game_fork::initialize_fork_deep_link(app.handle()); setup_log.append("startup.setup.complete"); Ok(()) }) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index dc3aa5d8a..d87718056 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -678,6 +678,16 @@ async fn request_fork_source_package( Ok(body) } +/// 作品 ID 的路径安全判据:只接受 ASCII 字母数字与 `-`/`_`。 +/// +/// 取件请求把作品 ID 当路径段拼进 URL,深链解析也用它校验链接里的取值——两处同一口径。 +pub(crate) fn is_safe_fork_game_id(value: &str) -> bool { + !value.is_empty() + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '-' || character == '_' + }) +} + /// 取件:读 Fork 来源元数据,再按 `bytes` 上限下载整包字节。 /// /// 摘要与字节数**不在这里**校验:调用方(`game_fork`)必须在任何落盘之前用 @@ -687,11 +697,7 @@ pub(crate) async fn fetch_platform_game_fork_source( ) -> Result<(GameDistributionForkSource, Vec), String> { let game_id = game_id.trim(); // 路径安全判据与发行入口一致:只接受 ASCII 字母数字与 `-`/`_`,不接受分隔符与盘符。 - if game_id.is_empty() - || !game_id.chars().all(|character| { - character.is_ascii_alphanumeric() || character == '-' || character == '_' - }) - { + if !is_safe_fork_game_id(game_id) { return Err("Fork 来源作品 ID 无效".to_string()); } let snapshot = require_platform_session()?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs index d4b178754..5857dbbca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs @@ -196,6 +196,50 @@ pub(crate) fn create_project_from_platform_fork_at( Err("自动工作区命名冲突,请重试".to_string()) } +/// 深链协议名:与 `tauri.conf.json` 的 `plugins.deep-link.desktop.schemes` 必须一致。 +pub(crate) const FORK_DEEP_LINK_SCHEME: &str = "genarrative"; + +/// 从深链 URL 解析要 Fork 的作品 ID。 +/// +/// 冻结约定:`genarrative://fork?gameId=`,同时接受 `genarrative://fork/`。 +/// 只做解析与形状校验,不碰网络也不建项目:非法链接返回可读原因,由桌面层聚焦窗口后把原因交给 +/// 渲染层展示(用户点错链接时得到的是提示,而不是静默无事发生)。 +pub(crate) fn fork_game_id_from_deep_link(raw_url: &str) -> Result { + let trimmed = raw_url.trim(); + if trimmed.is_empty() { + return Err("链接为空,无法识别要 Fork 的作品".to_string()); + } + let url = url::Url::parse(trimmed).map_err(|_| format!("无法解析链接:{trimmed}"))?; + if !url.scheme().eq_ignore_ascii_case(FORK_DEEP_LINK_SCHEME) { + return Err(format!( + "只接受 {FORK_DEEP_LINK_SCHEME}:// 开头的 Fork 链接" + )); + } + // `genarrative://fork/...` 里的 `fork` 是 host(动作名),作品 ID 走查询参数或路径段。 + let route = url.host_str().unwrap_or_default().to_ascii_lowercase(); + if route != "fork" { + return Err(format!( + "不支持的链接形式:需要 {FORK_DEEP_LINK_SCHEME}://fork?gameId=…" + )); + } + let from_query = url + .query_pairs() + .find(|(key, _)| matches!(key.as_ref(), "gameId" | "gameid" | "game_id" | "id")) + .map(|(_, value)| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let from_path = url + .path_segments() + .and_then(|mut segments| segments.find(|segment| !segment.is_empty())) + .map(str::to_string); + let game_id = from_query + .or(from_path) + .ok_or_else(|| "链接里没有 gameId,无法确定要 Fork 的作品".to_string())?; + if !crate::game_distribution_publish::is_safe_fork_game_id(&game_id) { + return Err(format!("链接里的 gameId 不合法:{game_id}")); + } + Ok(game_id) +} + #[cfg(test)] mod tests { use super::*; @@ -230,6 +274,86 @@ mod tests { writer.finish().expect("finish zip").into_inner() } + #[test] + fn deep_link_accepts_both_fork_forms_and_ignores_extra_parameters() { + assert_eq!( + fork_game_id_from_deep_link("genarrative://fork?gameId=game_parent_1").unwrap(), + "game_parent_1" + ); + assert_eq!( + fork_game_id_from_deep_link("genarrative://fork/game_parent_2").unwrap(), + "game_parent_2" + ); + // 大小写:scheme 与动作名由 url crate 归一,作品 ID 原样保留。 + assert_eq!( + fork_game_id_from_deep_link("GENARRATIVE://FORK?gameId=Game_Parent_3").unwrap(), + "Game_Parent_3" + ); + // 多余参数与其它键名(兼容 game_id / id)不影响解析。 + assert_eq!( + fork_game_id_from_deep_link("genarrative://fork?from=share&game_id=game_parent_4") + .unwrap(), + "game_parent_4" + ); + assert_eq!( + fork_game_id_from_deep_link("genarrative://fork?gameId=game_parent_5&extra=1").unwrap(), + "game_parent_5" + ); + // 两种形式同时出现时以查询参数为准(约定形式优先)。 + assert_eq!( + fork_game_id_from_deep_link("genarrative://fork/ignored?gameId=game_parent_6").unwrap(), + "game_parent_6" + ); + } + + #[test] + fn deep_link_rejects_empty_wrong_scheme_route_and_missing_or_illegal_game_id() { + for (raw, expected) in [ + ("", "链接为空"), + (" ", "链接为空"), + ( + "https://platform.test/games/detail?id=game_1", + "只接受 genarrative:// 开头的 Fork 链接", + ), + ("genarrative://other?gameId=game_1", "不支持的链接形式"), + ("genarrative://fork", "链接里没有 gameId"), + ("genarrative://fork/", "链接里没有 gameId"), + ("genarrative://fork?gameId=", "链接里没有 gameId"), + ( + "genarrative://fork?gameId=../escape", + "链接里的 gameId 不合法", + ), + ( + "genarrative://fork?gameId=game%2Fslash", + "链接里的 gameId 不合法", + ), + ("not a url", "无法解析链接"), + ] { + let error = fork_game_id_from_deep_link(raw) + .expect_err(&format!("{raw:?} 应被拒绝(期望原因含「{expected}」)")); + assert!( + error.contains(expected), + "{raw:?} 的原因不是期望文案:{error}" + ); + } + } + + #[test] + fn fork_game_id_shape_rule_is_shared_with_the_fetch_path() { + assert!(crate::game_distribution_publish::is_safe_fork_game_id( + "game_1" + )); + assert!(crate::game_distribution_publish::is_safe_fork_game_id( + "a-b_c" + )); + for bad in ["", " ", "game/1", "game 1", "game\\1", "game:1"] { + assert!( + !crate::game_distribution_publish::is_safe_fork_game_id(bad), + "{bad:?} 不应通过" + ); + } + } + #[test] fn fork_source_bytes_must_match_declared_size_and_digest() { let bytes = release_package_bytes(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs index 6c0afdfb9..d19f5c9fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_fork/desktop.rs @@ -1,7 +1,76 @@ // 桌面 Fork 取件接入;摘要校验、参考副本落点与来源记录规则保留在父模块供现有测试验证。 use super::*; use crate::game_distribution_publish::{fetch_platform_game_fork_source, require_platform_session}; +use serde_json::{json, Value}; use shared_contracts::game_distribution::GameDistributionForkSourceKind; +use tauri::{Emitter, Manager}; +use tauri_plugin_deep_link::DeepLinkExt; + +/// 深链事件名:渲染层订阅同名事件,收到后预填 Fork 入口(**不**自动开始下载)。 +pub(crate) const FORK_DEEP_LINK_EVENT: &str = "game-fork-deep-link"; + +/// 主窗口标签,与 `tauri.conf.json` 的 `app.windows[0].label` 一致。 +const MAIN_WINDOW_LABEL: &str = "client"; + +/// 把主窗口带到前台:深链可能来自浏览器点击,用户期望看到的正是这个窗口。 +pub(crate) fn focus_main_window_for_deep_link(app: &tauri::AppHandle) { + let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { + app_log!("game.fork.deep_link.window_missing: 找不到主窗口 {MAIN_WINDOW_LABEL}"); + return; + }; + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); +} + +/// 处理一条深链:解析出 gameId 就交给渲染层预填,解析失败就把原因交给渲染层展示。 +/// +/// 两种结果都先聚焦窗口——用户点了链接却什么都没发生是最糟的失败方式。 +fn handle_fork_deep_link_url(app: &tauri::AppHandle, raw_url: &str) { + focus_main_window_for_deep_link(app); + let payload = match fork_game_id_from_deep_link(raw_url) { + Ok(game_id) => json!({ "gameId": game_id, "message": Value::Null }), + Err(message) => { + app_log!("game.fork.deep_link.invalid: {message}"); + json!({ "gameId": Value::Null, "message": message }) + } + }; + if let Err(error) = app.emit(FORK_DEEP_LINK_EVENT, payload) { + app_log!("game.fork.deep_link.emit_failed: {error}"); + } +} + +/// 注册并接管 Fork 深链。 +/// +/// - **冷启动**(Windows/Linux):URL 是启动参数,deep-link 插件在插件初始化阶段就解析并暂存, +/// 而应用的 `setup` 晚于插件,所以这里先取「当前值」,再订阅后续链接。 +/// - **已运行**:Windows/Linux 会再起一个进程,由 `tauri-plugin-single-instance`(带 `deep-link` +/// feature)把第二个实例的 argv 转交给已有实例,交给 deep-link 插件发事件;macOS 由系统把 +/// URL 交给已运行的 .app。 +/// - **协议注册**:Windows 写 `HKCU\Software\Classes\`、Linux 写用户级 `.desktop` +/// (macOS 由打包时注入 `Info.plist` 的 `CFBundleURLTypes`)。这里运行时再注册一次,覆盖 +/// 便携/未按安装器安装的场景;失败只记日志,不影响启动。 +pub(crate) fn initialize_fork_deep_link(app: &tauri::AppHandle) { + match app.deep_link().get_current() { + Ok(Some(urls)) => { + for url in urls { + handle_fork_deep_link_url(app, url.as_str()); + } + } + Ok(None) => {} + Err(error) => app_log!("game.fork.deep_link.current_failed: {error}"), + } + let handle = app.clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + handle_fork_deep_link_url(&handle, url.as_str()); + } + }); + #[cfg(any(windows, target_os = "linux"))] + if let Err(error) = app.deep_link().register_all() { + app_log!("game.fork.deep_link.register_failed: {error}"); + } +} /// 从平台作品 Fork:按取件形态建成项目。 /// diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 63cd157e0..3db4fc244 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -60,6 +60,11 @@ ] }, "plugins": { + "deep-link": { + "desktop": { + "schemes": ["genarrative"] + } + }, "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFN0NFOEUzNDczNDg4Q0IKUldUTGlEUkg0K2g4VGpaQ3FiTXdoNnJTV0JDSWU4VjQrTkcrMkovS2RleFloUXVhdWZIVGpMOTYK", "endpoints": [ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 6e5ed55b8..7da317925 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -38,6 +38,10 @@ import { } from '../../view/project-development/projectResourceLiveUpdateModel'; import TemplateLibraryView from '../../view/template-library'; import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns'; +import { + subscribeForkDeepLink, + useForkDeepLinkStore, +} from '../platform-fork/forkDeepLink'; import { projectPathsMatchForInvalidation } from '../project-summary/projectPath'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; import { useTemplateLibrary } from '../template-library/useTemplateLibrary'; @@ -651,6 +655,44 @@ export function WorkspaceLauncherShell({ }; }, [refreshActiveProjectManifestFromDisk]); + /** + * Fork 深链(`genarrative://fork?gameId=…`)。 + * + * 原生侧收链后已经聚焦主窗口,这里只做两件事:把请求交给首页的 Fork 入口(预填输入框), + * 并在用户不处于项目工作区时切回首页,让那个入口可见。**不在这里触发下载**——预填之后需要 + * 用户自己按「Fork 到我的项目」(工程源包是 200 MiB 级别)。 + * + * 正停在项目工作区时不强行切走(那会把用户从正在进行的项目里踢出来):请求留在 store 里, + * 用户回到首页时入口会自动预填。 + */ + useEffect(() => { + if (!canSubscribeTauriEvents()) { + return; + } + let disposed = false; + let unsubscribe: (() => void) | null = null; + void subscribeForkDeepLink((request) => { + useForkDeepLinkStore.getState().request(request); + setLauncherView((current) => + current === 'project-development' ? current : 'home', + ); + }) + .then((release) => { + if (disposed) { + release(); + return; + } + unsubscribe = release; + }) + .catch(() => { + // 订阅不可用时深链静默失效:不影响应用其余功能,也不该弹错误打断用户。 + }); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); + const requestCurrentProjectPlay = useCallback((nextProjectPath: string) => { playRequestIdRef.current += 1; setPlayRequest({ diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/forkDeepLink.ts b/apps/ai-game-creator-shell/src/features/platform-fork/forkDeepLink.ts new file mode 100644 index 000000000..44b10de93 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/platform-fork/forkDeepLink.ts @@ -0,0 +1,77 @@ +/** + * Fork 深链(`genarrative://fork?gameId=…`)在渲染层的落点。 + * + * 原生侧收链后聚焦主窗口,并发 `game-fork-deep-link` 事件;这里把「要 Fork 哪个作品」存成一条 + * **待处理请求**,由首页的 Fork 入口消费:预填输入并等用户按按钮,**绝不自动开始下载** + * (工程源包是 200 MiB 级别的下载,必须由用户确认)。 + * + * 用模块级 store 而不是组件 props:链接到达时应用可能正停在项目工作区(首页没挂载), + * 请求要能留到用户回到首页时再生效。 + */ +import { create } from 'zustand'; + +import { subscribeTauriEvent } from '../../services/tauriEventSubscription'; + +/** 与原生 `game_fork::desktop::FORK_DEEP_LINK_EVENT` 同名。 */ +export const FORK_DEEP_LINK_EVENT = 'game-fork-deep-link'; + +export type ForkDeepLinkRequest = { + /** 解析出的作品 ID;链接不合法时为 null。 */ + gameId: string | null; + /** 链接不合法的可读原因;合法时为 null。 */ + message: string | null; +}; + +type ForkDeepLinkStore = { + pending: ForkDeepLinkRequest | null; + request: (request: ForkDeepLinkRequest) => void; + /** 取走并清空待处理请求:同一链接只生效一次。 */ + consume: () => ForkDeepLinkRequest | null; +}; + +export const useForkDeepLinkStore = create((set, get) => ({ + pending: null, + request: (request) => set({ pending: request }), + consume: () => { + const pending = get().pending; + set({ pending: null }); + return pending; + }, +})); + +/** + * 把原生事件载荷折成待处理请求;既没有 gameId 也没有 message 的载荷整条丢弃。 + * + * 原生可能出现「带 gameId」与「带错误原因」两种形状二选一,这里都不丢信息地映射过去。 + */ +export function forkDeepLinkRequestFromEvent( + payload: unknown, +): ForkDeepLinkRequest | null { + if (typeof payload !== 'object' || payload === null) { + return null; + } + const gameId = + 'gameId' in payload && typeof payload.gameId === 'string' + ? payload.gameId.trim() + : ''; + const message = + 'message' in payload && typeof payload.message === 'string' + ? payload.message.trim() + : ''; + if (!gameId && !message) { + return null; + } + return { gameId: gameId || null, message: message || null }; +} + +/** 订阅原生 Fork 深链事件;返回注销函数(重复调用安全)。 */ +export function subscribeForkDeepLink( + onRequest: (request: ForkDeepLinkRequest) => void, +): Promise<() => void> { + return subscribeTauriEvent(FORK_DEEP_LINK_EVENT, (event) => { + const request = forkDeepLinkRequestFromEvent(event.payload); + if (request) { + onRequest(request); + } + }); +} diff --git a/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx b/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx index 77d01657b..4cd574514 100644 --- a/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx +++ b/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx @@ -7,10 +7,11 @@ */ import type { FormEvent } from 'react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { InitLocalProjectResult } from '../../app/types'; +import { useForkDeepLinkStore } from '../../features/platform-fork/forkDeepLink'; import { classifyPlatformForkFailure, parsePlatformGameId, @@ -50,6 +51,35 @@ export default function PlatformGameForkSection({ const requestSequenceRef = useRef(0); /** 同一 tick 内的重复提交靠 ref 拦截:`busy` 状态要等下一次渲染才生效。 */ const busyRef = useRef(false); + const inputRef = useRef(null); + /** + * 深链预填:原生收到 `genarrative://fork?gameId=…` 后会放进这个 store。 + * + * 这里只做「填进输入框 + 聚焦输入框」,**不自动提交**——工程源包是 200 MiB 级别的下载, + * 必须由用户按按钮确认;这一条与手工粘贴作品 ID 走的是同一条提交路径。 + */ + const pendingDeepLink = useForkDeepLinkStore((state) => state.pending); + const consumeDeepLink = useForkDeepLinkStore((state) => state.consume); + + useEffect(() => { + if (!pendingDeepLink) { + return; + } + const request = consumeDeepLink(); + if (!request) { + return; + } + if (request.gameId) { + setInput(request.gameId); + setError(''); + setSuccess(null); + inputRef.current?.focus(); + } + if (request.message) { + setSuccess(null); + setError(request.message); + } + }, [pendingDeepLink, consumeDeepLink]); async function submitForkRequest(event: FormEvent) { event.preventDefault(); @@ -127,6 +157,7 @@ export default function PlatformGameForkSection({ > }; + +let eventFake: TauriEventFake | null = null; + +function installInvoke( + implementation: (command: string, args?: Record) => unknown, +) { + const calls: InvokeCall[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + calls.push({ command, args }); + return implementation(command, args); + }, + ); + ( + window as unknown as { __TAURI__?: { core?: { invoke?: unknown } } } + ).__TAURI__ = { core: { invoke } }; + return calls; +} + +function renderSection() { + const onProjectCreated = vi.fn(); + render(); + return { onProjectCreated }; +} + +function forkInput() { + return screen.getByLabelText('平台作品 ID 或链接') as HTMLInputElement; +} + +afterEach(() => { + cleanup(); + eventFake?.restore(); + eventFake = null; + useForkDeepLinkStore.getState().consume(); + delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; +}); + +describe('Fork 深链载荷解析', () => { + it('接受 gameId 与错误原因两种形状,并丢掉空载荷', () => { + expect( + forkDeepLinkRequestFromEvent({ gameId: 'game_from_link', message: null }), + ).toEqual({ gameId: 'game_from_link', message: null }); + expect( + forkDeepLinkRequestFromEvent({ gameId: null, message: '链接里没有 gameId' }), + ).toEqual({ gameId: null, message: '链接里没有 gameId' }); + // 两端空白由渲染层收敛,避免把带空白的 ID 填进输入框。 + expect( + forkDeepLinkRequestFromEvent({ gameId: ' game_x ', message: '' }), + ).toEqual({ gameId: 'game_x', message: null }); + for (const payload of [ + null, + undefined, + 'game_x', + 42, + {}, + { gameId: null, message: null }, + { gameId: ' ', message: ' ' }, + ]) { + expect(forkDeepLinkRequestFromEvent(payload)).toBeNull(); + } + }); + + it('订阅原生事件后把载荷写入待处理请求(同一事件只解析一次)', async () => { + eventFake = createTauriEventFake(); + eventFake.install(); + const received: unknown[] = []; + const release = await subscribeForkDeepLink((request) => { + received.push(request); + }); + eventFake.flushRegistrationEvals(); + + eventFake.emit(FORK_DEEP_LINK_EVENT, { gameId: 'game_from_link' }); + expect(received).toEqual([{ gameId: 'game_from_link', message: null }]); + + // 没有 gameId 也没有 message 的载荷不产生请求(避免用空链接清空用户输入)。 + eventFake.emit(FORK_DEEP_LINK_EVENT, {}); + expect(received).toHaveLength(1); + release(); + }); +}); + +describe('Fork 入口的深链预填', () => { + it('预填 gameId 但**不**自动下载,等用户自己确认', async () => { + const calls = installInvoke((command) => + command === FORK_COMMAND ? PROJECT_RESULT : null, + ); + const { onProjectCreated } = renderSection(); + + useForkDeepLinkStore.getState().request({ + gameId: 'game_from_link', + message: null, + }); + + await waitFor(() => + expect(forkInput().value).toBe('game_from_link'), + ); + // 关键行为:收到链接不能自己开始下载(200 MiB 级别的工程源包必须用户确认)。 + expect(calls.filter((call) => call.command === FORK_COMMAND)).toHaveLength(0); + expect(onProjectCreated).not.toHaveBeenCalled(); + + // 用户按按钮后走的是同一条提交路径。 + fireEvent.click(screen.getByRole('button', { name: 'Fork 到我的项目' })); + await waitFor(() => + expect(calls.filter((call) => call.command === FORK_COMMAND)).toHaveLength(1), + ); + expect( + calls.find((call) => call.command === FORK_COMMAND)?.args, + ).toMatchObject({ gameId: 'game_from_link', name: null, planning: false }); + }); + + it('链接不合法时展示可读原因且不预填、不请求', async () => { + const calls = installInvoke(() => PROJECT_RESULT); + renderSection(); + + useForkDeepLinkStore.getState().request({ + gameId: null, + message: '链接里没有 gameId,无法确定要 Fork 的作品', + }); + + expect((await screen.findByRole('alert')).textContent).toBe( + '链接里没有 gameId,无法确定要 Fork 的作品', + ); + expect(forkInput().value).toBe(''); + expect(calls.filter((call) => call.command === FORK_COMMAND)).toHaveLength(0); + }); +});