feat(游戏共创): 注册并处理 Fork 深链 genarrative://fork?gameId=…
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

冻结约定:`genarrative://fork?gameId=<gameId>`,同时接受 `genarrative://fork/<gameId>`。

方案(用官方两个插件,不手搓单实例与注册表):
- `tauri-plugin-single-instance`(开 `deep-link` feature)**第一个注册**:Windows/Linux 上深链是
  「带 URL 启动第二个进程」,该插件把第二个实例的 argv 转交给已有实例,并(feature 生效时)
  交给 deep-link 插件发事件——否则用户点第二次链接会再开一个窗口。macOS 上它用 Unix socket
  做同样的收口(系统本身也只会有一个 .app 实例)。
- `tauri-plugin-deep-link`:`tauri.conf.json > plugins.deep-link.desktop.schemes = ["genarrative"]`。
  · macOS:打包器按该配置往 `Info.plist` 注入 `CFBundleURLTypes`(插件 `config.rs` 里那句
    `// Used in tauri-bundler` 是代码级证据),运行时由系统把 URL 交给已运行的 .app。
  · Windows:运行时 `register_all()` 写 `HKCU\Software\Classes\genarrative`(`URL Protocol` 标记 +
    `DefaultIcon` + `shell\open\command`,不需要管理员);安装器侧同样按该配置注册。
  · Linux:运行时写用户级 `applications/<exe>-handler.desktop` 并调 xdg-mime(AGC 未以此平台分发,
  但仍走同一条运行时路径)。
- 冷启动:插件在插件初始化阶段就解析 argv 并暂存,而应用 `setup` 晚于插件,因此
  `initialize_fork_deep_link` **先取 `get_current()`**、再 `on_open_url` 订阅后续链接(漏了前者会
  丢冷启动那一次)。
- 收到 URL 后:聚焦主窗口(unminimize + show + set_focus)→ 发 `game-fork-deep-link` 事件
  `{ gameId | message }` → 渲染层把请求存进 store 并切回首页;**不自动下载**,用户按
  「Fork 到我的项目」才走与手工输入完全相同的提交路径。正停在项目工作区时不强行切走
  (避免把用户从进行中的项目里踢出来),请求留在 store 里等用户回到首页。
- 非法链接:解析出可读原因(空链接 / 非 genarrative 方案 / 非 fork 形式 / 缺 gameId / gameId 不合法)
  一并聚焦窗口并交给渲染层展示,不做静默失败。

测试:`fork_game_id_from_deep_link` 的合法(两种形式、大小写、多余参数、game_id/id 别名)与
非法(空、错方案、错形式、缺 gameId、`../escape`、URL 编码斜杠、非 URL)各一组;`gameId` 形状
判据与取件路径共用(`is_safe_fork_game_id`);TS 侧覆盖载荷解析、订阅映射、深链预填后**不自动
下载**、非法链接展示原因且不请求。
This commit is contained in:
2026-10-06 01:05:04 +08:00
parent 96309c895c
commit f94946ef8e
11 changed files with 643 additions and 8 deletions
+117 -2
View File
@@ -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"
@@ -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"
@@ -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(())
})
@@ -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<u8>), 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()?;
@@ -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=<gameId>`,同时接受 `genarrative://fork/<gameId>`。
/// 只做解析与形状校验,不碰网络也不建项目:非法链接返回可读原因,由桌面层聚焦窗口后把原因交给
/// 渲染层展示(用户点错链接时得到的是提示,而不是静默无事发生)。
pub(crate) fn fork_game_id_from_deep_link(raw_url: &str) -> Result<String, String> {
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();
@@ -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\<scheme>`、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:按取件形态建成项目。
///
@@ -60,6 +60,11 @@
]
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["genarrative"]
}
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFN0NFOEUzNDczNDg4Q0IKUldUTGlEUkg0K2g4VGpaQ3FiTXdoNnJTV0JDSWU4VjQrTkcrMkovS2RleFloUXVhdWZIVGpMOTYK",
"endpoints": [
@@ -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({
@@ -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<ForkDeepLinkStore>((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<unknown>(FORK_DEEP_LINK_EVENT, (event) => {
const request = forkDeepLinkRequestFromEvent(event.payload);
if (request) {
onRequest(request);
}
});
}
@@ -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<HTMLInputElement>(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<HTMLFormElement>) {
event.preventDefault();
@@ -127,6 +157,7 @@ export default function PlatformGameForkSection({
>
<input
className="min-w-0 flex-1 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-3 py-2 text-[12.5px] text-(--platform-text-strong) outline-none disabled:opacity-60"
ref={inputRef}
aria-label="平台作品 ID 或链接"
value={input}
disabled={busy}
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InitLocalProjectResult } from '../src/app/types';
import {
FORK_DEEP_LINK_EVENT,
forkDeepLinkRequestFromEvent,
subscribeForkDeepLink,
useForkDeepLinkStore,
} from '../src/features/platform-fork/forkDeepLink';
import PlatformGameForkSection from '../src/view/home/PlatformGameForkSection';
import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
const FORK_COMMAND = 'create_local_project_from_platform_game';
const PROJECT_RESULT = {
projectPath: 'C:\\projects\\gameagent-fork-1',
manifestPath: 'C:\\projects\\gameagent-fork-1\\.agent\\manifest.json',
manifest: { name: 'Fork 项目 1' },
} as unknown as InitLocalProjectResult;
type InvokeCall = { command: string; args?: Record<string, unknown> };
let eventFake: TauriEventFake | null = null;
function installInvoke(
implementation: (command: string, args?: Record<string, unknown>) => unknown,
) {
const calls: InvokeCall[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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(<PlatformGameForkSection onProjectCreated={onProjectCreated} />);
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);
});
});