Compare commits

...
10 Commits
Author SHA1 Message Date
cneicy 2dd7f5b14a feat(workspace): add package installation validation
Validate ShrinkSDK Workspace / unity (push) Failing after 8s
2026-08-26 04:50:27 +08:00
cneicy dcf1099b9c chore(workspace): adopt UPM package submodules 2026-08-26 04:48:33 +08:00
cneicy 493d280521 docs(architecture): record framework audit decisions
Document the justanyproject comparison, query-command-event boundaries, reversible mod registry rules, package validation gates, and verified EventBus 2.0 state.
2026-08-26 01:18:15 +08:00
cneicy 3e612b975a chore(validation): enforce package graph boundaries
Reject internal package version drift, dependency cycles, and runtime-to-integration reverse dependencies before launching the external UPM consumer validation.
2026-08-26 01:18:02 +08:00
cneicy 724e0bc8d8 feat(eventbus): add platform runtimes and benchmarks
Add the Entities NativeQueue adapter, standalone .NET runtime and source generator, reproducible smoke coverage, and Unity benchmark assets for EventBus 2.0.
2026-08-26 01:17:39 +08:00
cneicy ad5a7b68a3 feat(sdk): migrate to EventBus 2.0
Replace the legacy EventBase runtime with generated multi-bus bindings and explicit scheduling. Migrate app, data, network, demo, and mod consumers; add generated network-event registration and owner-scoped mod content overrides.
2026-08-26 01:15:48 +08:00
cneicy 67d32795c4 no #2 2026-08-23 17:04:11 +08:00
cneicy bd8cb13863 no 2026-08-23 17:03:51 +08:00
cneicy d74c2f08ca feat(packages): 内置 SDK 包并完善 ContextLoader 集成
- 将 ShrinkEventBus、ShrinkDataSaver 及其 EventBus 集成从 gitlink 转为仓库直接维护的完整 UPM 包,补齐运行时、编辑器工具、测试与文档
- 新增 Command 和 Network 的 App 集成组件,支持 ContextLoader 服务发布、可逆注销及 Network Loopback 生命周期管理
- 更新 Starter 与演示组合逻辑,缺失模块时可注册、已有兼容安装器时可覆盖,并补充宿主启动断言
- 升级内部包依赖与 Shared CodeGen 包定义,放宽 Integration.App 包的 Git 忽略规则
- 将独立服务器生成器改为基于已编译程序集的语义扫描,支持 partial、复杂泛型、命名冲突检测及模板 SHA-256 覆写保护
- 新增 Network 语义扫描、模板保护和 App 组件生命周期测试
- 新增真实 UPM 消费工程验证脚本,校验内部版本一致性、程序集加载及 EditMode 测试
- 重构当前架构文档并归档已完成的 Cordis 迁移与旧代码地图
2026-08-18 18:06:34 +08:00
cneicy 517c4cf46e demo2 2026-08-18 02:03:34 +08:00
612 changed files with 2780 additions and 30402 deletions
+55
View File
@@ -0,0 +1,55 @@
name: Validate ShrinkSDK Workspace
on:
push:
branches:
- main
workflow_dispatch:
jobs:
unity:
runs-on: unity-2022.3.62f3
container:
image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3
volumes:
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses
permissions:
contents: read
env:
UNITY_VERSION: 2022.3.62f3
steps:
- name: Fetch exact Workspace revision and submodules
shell: bash
run: |
set -euo pipefail
ref="${{ gitea.sha }}"
git init .
git remote add origin "https://git.crash.work/ShrinkSDK/Workspace.git"
git fetch --depth=1 origin "$ref"
git checkout --detach FETCH_HEAD
git submodule sync --recursive
git submodule update --init --recursive
module_count="$(git submodule status --recursive | wc -l | tr -d ' ')"
test "$module_count" = "21"
test -z "$(git submodule status --recursive | grep '^-')"
git submodule status --recursive
- name: Compile Workspace and validate package graph
shell: bash
run: |
set -euo pipefail
unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)"
test -n "$unity_bin"
mkdir -p Artifacts
"$unity_bin" -version | head -n 1 | grep -F "$UNITY_VERSION"
status=0
"$unity_bin" \
-batchmode \
-nographics \
-quit \
-projectPath "$PWD" \
-executeMethod ShrinkSDK.WorkspaceValidation.ShrinkSdkWorkspaceValidation.Run \
-logFile "$PWD/Artifacts/unity-workspace-validation.log" || status=$?
tail -n 240 "$PWD/Artifacts/unity-workspace-validation.log" || true
exit "$status"
+5 -4
View File
@@ -7,6 +7,7 @@
/[Ll]ibrary/
/[Tt]emp/
/[Oo]bj/
/TestResults/
/[Bb]uild/
/[Bb]uilds/
/[Ll]ogs/
@@ -74,10 +75,10 @@ mono_crash.*
*.unitypackage.meta
*.app
# Windows Git matches the macOS bundle pattern case-insensitively. Keep the
# namespace-suffixed Unity package in source control.
!Assets/Modules/ShrinkDataSaver.Integration.App/
!Assets/Modules/ShrinkDataSaver.Integration.App/**
# Windows Git matches the macOS bundle pattern case-insensitively. Keep all
# namespace-suffixed Unity Integration.App packages in source control.
!Assets/Modules/*.Integration.App/
!Assets/Modules/*.Integration.App/**
# Crashlytics generated file
crashlytics-build.properties
+84
View File
@@ -0,0 +1,84 @@
[submodule "Assets/Modules/ShrinkApp.Core"]
path = Assets/Modules/ShrinkApp.Core
url = https://git.crash.work/ShrinkSDK/ShrinkApp.Core.git
branch = main
[submodule "Assets/Modules/ShrinkApp.Starter.Basic"]
path = Assets/Modules/ShrinkApp.Starter.Basic
url = https://git.crash.work/ShrinkSDK/ShrinkApp.Starter.Basic.git
branch = main
[submodule "Assets/Modules/ShrinkCommand"]
path = Assets/Modules/ShrinkCommand
url = https://git.crash.work/ShrinkSDK/ShrinkCommand.git
branch = main
[submodule "Assets/Modules/ShrinkCommand.Integration.App"]
path = Assets/Modules/ShrinkCommand.Integration.App
url = https://git.crash.work/ShrinkSDK/ShrinkCommand.Integration.App.git
branch = main
[submodule "Assets/Modules/ShrinkCommand.Integration.EventBus"]
path = Assets/Modules/ShrinkCommand.Integration.EventBus
url = https://git.crash.work/ShrinkSDK/ShrinkCommand.Integration.EventBus.git
branch = main
[submodule "Assets/Modules/ShrinkCommand.Integration.Network"]
path = Assets/Modules/ShrinkCommand.Integration.Network
url = https://git.crash.work/ShrinkSDK/ShrinkCommand.Integration.Network.git
branch = main
[submodule "Assets/Modules/ShrinkContext.AppAdapter"]
path = Assets/Modules/ShrinkContext.AppAdapter
url = https://git.crash.work/ShrinkSDK/ShrinkContext.AppAdapter.git
branch = main
[submodule "Assets/Modules/ShrinkContext.Core"]
path = Assets/Modules/ShrinkContext.Core
url = https://git.crash.work/ShrinkSDK/ShrinkContext.Core.git
branch = main
[submodule "Assets/Modules/ShrinkContext.EventBusAdapter"]
path = Assets/Modules/ShrinkContext.EventBusAdapter
url = https://git.crash.work/ShrinkSDK/ShrinkContext.EventBusAdapter.git
branch = main
[submodule "Assets/Modules/ShrinkDataSaver"]
path = Assets/Modules/ShrinkDataSaver
url = https://git.crash.work/ShrinkSDK/ShrinkDataSaver.git
branch = main
[submodule "Assets/Modules/ShrinkDataSaver.Integration.App"]
path = Assets/Modules/ShrinkDataSaver.Integration.App
url = https://git.crash.work/ShrinkSDK/ShrinkDataSaver.Integration.App.git
branch = main
[submodule "Assets/Modules/ShrinkDataSaver.Integration.EventBus"]
path = Assets/Modules/ShrinkDataSaver.Integration.EventBus
url = https://git.crash.work/ShrinkSDK/ShrinkDataSaver.Integration.EventBus.git
branch = main
[submodule "Assets/Modules/ShrinkEventBus"]
path = Assets/Modules/ShrinkEventBus
url = https://git.crash.work/ShrinkSDK/ShrinkEventBus.git
branch = main
[submodule "Assets/Modules/ShrinkEventBus.Entities"]
path = Assets/Modules/ShrinkEventBus.Entities
url = https://git.crash.work/ShrinkSDK/ShrinkEventBus.Entities.git
branch = main
[submodule "Assets/Modules/ShrinkModFramework"]
path = Assets/Modules/ShrinkModFramework
url = https://git.crash.work/ShrinkSDK/ShrinkModFramework.git
branch = main
[submodule "Assets/Modules/ShrinkNetwork"]
path = Assets/Modules/ShrinkNetwork
url = https://git.crash.work/ShrinkSDK/ShrinkNetwork.git
branch = main
[submodule "Assets/Modules/ShrinkNetwork.Integration.App"]
path = Assets/Modules/ShrinkNetwork.Integration.App
url = https://git.crash.work/ShrinkSDK/ShrinkNetwork.Integration.App.git
branch = main
[submodule "Assets/Modules/ShrinkNetwork.Integration.EventBus"]
path = Assets/Modules/ShrinkNetwork.Integration.EventBus
url = https://git.crash.work/ShrinkSDK/ShrinkNetwork.Integration.EventBus.git
branch = main
[submodule "Assets/Modules/ShrinkShared.CodeGen"]
path = Assets/Modules/ShrinkShared.CodeGen
url = https://git.crash.work/ShrinkSDK/ShrinkShared.CodeGen.git
branch = main
[submodule "Assets/Modules/ShrinkTutorial"]
path = Assets/Modules/ShrinkTutorial
url = https://git.crash.work/ShrinkSDK/ShrinkTutorial.git
branch = main
[submodule "Assets/Modules/ShrinkInstaller"]
path = Assets/Modules/ShrinkInstaller
url = https://git.crash.work/ShrinkSDK/Installer.git
branch = main
File diff suppressed because one or more lines are too long
@@ -11,8 +11,10 @@ namespace ExampleGreedMod
{
public override void OnRegisterContent(ShrinkModContext context)
{
context.GetRegistry<IReplacedPersonMod>(ReplacedPersonModBridge.RegistryName)
.Register(context.ModInfo.ModId, context.ModInfo.ModId, new ExampleGreedRules());
context.RegisterContent(
ReplacedPersonModBridge.RegistryName,
"rules",
new ExampleGreedRules());
}
public override void OnReady(ShrinkModContext context)
@@ -477,6 +477,6 @@ namespace ReplacedPerson.Runtime
private static ReplacedCommandResult Reject(string code, string detail) => new() { ErrorCode = code, Message = detail };
private static void Publish(string status, string detail) =>
EventBus.TriggerEvent(new ReplacedNetworkStatusEvent { Status = status, Detail = detail });
EventBus.Post(new ReplacedNetworkStatusEvent { Status = status, Detail = detail });
}
}
@@ -67,9 +67,19 @@ namespace ReplacedPerson.Runtime
ShrinkAppLoaderBootstrapper.DefaultComposition = host =>
{
previous?.Invoke(host);
if (host.ModuleIds.Contains("demo.replaced-person"))
host.OverrideModuleComponent("demo.replaced-person", static () => new ReplacedPersonAppComponent());
RegisterOrReplace(host, "demo.replaced-person", static () => new ReplacedPersonAppComponent());
};
}
private static void RegisterOrReplace(
ShrinkAppLoaderHost host,
string moduleId,
Func<IShrinkComponent> componentFactory)
{
if (host.ModuleIds.Contains(moduleId, StringComparer.OrdinalIgnoreCase))
host.OverrideModuleComponent(moduleId, componentFactory);
else
host.AddModuleComponent(moduleId, componentFactory);
}
}
}
@@ -5,7 +5,7 @@ using ShrinkEventBus;
namespace ReplacedPerson.Runtime
{
public sealed class ReplacedMatchPhaseEvent : EventBase
public sealed class ReplacedMatchPhaseEvent : IShrinkEvent
{
public int Round { get; set; }
public ReplacedMatchPhase MatchPhase { get; set; }
@@ -13,39 +13,39 @@ namespace ReplacedPerson.Runtime
public string StateHash { get; set; } = string.Empty;
}
public sealed class ReplacedCardsCommittedEvent : EventBase
public sealed class ReplacedCardsCommittedEvent : IShrinkEvent
{
public int PlayerIndex { get; set; }
public string[] NormalCardIds { get; set; } = System.Array.Empty<string>();
public string[] EndCardIds { get; set; } = System.Array.Empty<string>();
}
public sealed class ReplacedDiceEvent : EventBase
public sealed class ReplacedDiceEvent : IShrinkEvent
{
public int Slot { get; set; }
public int PlayerOneRoll { get; set; }
public int PlayerTwoRoll { get; set; }
}
public sealed class ReplacedDamageEvent : EventBase
public sealed class ReplacedDamageEvent : IShrinkEvent
{
public int TargetPlayer { get; set; }
public int Amount { get; set; }
public int RemainingHealth { get; set; }
}
public sealed class ReplacedRewardEvent : EventBase
public sealed class ReplacedRewardEvent : IShrinkEvent
{
public string RewardId { get; set; } = string.Empty;
}
public sealed class ReplacedSaveEvent : EventBase
public sealed class ReplacedSaveEvent : IShrinkEvent
{
public string Operation { get; set; } = string.Empty;
public bool Success { get; set; }
}
public sealed class ReplacedNetworkStatusEvent : EventBase
public sealed class ReplacedNetworkStatusEvent : IShrinkEvent
{
public string Status { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
@@ -69,7 +69,7 @@ namespace ReplacedPerson.Runtime
var result = Match.Apply(command);
if (result.Accepted && !result.Duplicate)
{
EventBus.TriggerEvent(new ReplacedCardsCommittedEvent
EventBus.Post(new ReplacedCardsCommittedEvent
{
PlayerIndex = 0,
NormalCardIds = submission.NormalCardIds.ToArray(),
@@ -87,7 +87,7 @@ namespace ReplacedPerson.Runtime
{
if (!Content.Current.Cards.ContainsKey(cardId)) throw new ArgumentException("Unknown card: " + cardId);
_saveData.CollectedCards.Add(cardId);
EventBus.TriggerEvent(new ReplacedRewardEvent { RewardId = cardId });
EventBus.Post(new ReplacedRewardEvent { RewardId = cardId });
Changed?.Invoke();
}
@@ -106,11 +106,11 @@ namespace ReplacedPerson.Runtime
try
{
await ShrinkSave.SaveSlotAsync(slot, new SaveOptions { SlotName = "被替代之人" });
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "save", Success = true });
EventBus.Post(new ReplacedSaveEvent { Operation = "save", Success = true });
}
catch
{
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "save", Success = false });
EventBus.Post(new ReplacedSaveEvent { Operation = "save", Success = false });
throw;
}
}
@@ -120,12 +120,12 @@ namespace ReplacedPerson.Runtime
try
{
await ShrinkSave.LoadSlotAsync(slot);
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "load", Success = true });
EventBus.Post(new ReplacedSaveEvent { Operation = "load", Success = true });
Changed?.Invoke();
}
catch
{
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "load", Success = false });
EventBus.Post(new ReplacedSaveEvent { Operation = "load", Success = false });
throw;
}
}
@@ -171,7 +171,7 @@ namespace ReplacedPerson.Runtime
var before = Match.State.Clone();
var result = Match.Apply(command);
if (!result.Accepted) throw new InvalidOperationException("AI produced illegal command: " + result.ErrorCode);
EventBus.TriggerEvent(new ReplacedCardsCommittedEvent
EventBus.Post(new ReplacedCardsCommittedEvent
{
PlayerIndex = 1,
NormalCardIds = submission.NormalCardIds.ToArray(),
@@ -194,7 +194,7 @@ namespace ReplacedPerson.Runtime
{
if (Match == null) return;
var state = after ?? Match.State;
EventBus.TriggerEvent(new ReplacedMatchPhaseEvent
EventBus.Post(new ReplacedMatchPhaseEvent
{
Round = state.Round,
MatchPhase = state.Phase,
@@ -204,7 +204,7 @@ namespace ReplacedPerson.Runtime
if (before == null || !SameResolution(before.LastResolution, state.LastResolution))
foreach (var slot in state.LastResolution)
{
EventBus.TriggerEvent(new ReplacedDiceEvent
EventBus.Post(new ReplacedDiceEvent
{
Slot = slot.SlotIndex,
PlayerOneRoll = slot.PlayerOneRoll,
@@ -216,7 +216,7 @@ namespace ReplacedPerson.Runtime
{
var damage = Math.Max(0, before.Players[i].Health - state.Players[i].Health);
if (damage > 0)
EventBus.TriggerEvent(new ReplacedDamageEvent { TargetPlayer = i, Amount = damage, RemainingHealth = state.Players[i].Health });
EventBus.Post(new ReplacedDamageEvent { TargetPlayer = i, Amount = damage, RemainingHealth = state.Players[i].Health });
}
}
@@ -246,7 +246,7 @@ namespace ReplacedPerson.Runtime
var target = reward.StartsWith("ornament.", StringComparison.Ordinal) ? _saveData.Ornaments : _saveData.CollectedCards;
if (target.Contains(reward)) continue;
target.Add(reward);
EventBus.TriggerEvent(new ReplacedRewardEvent { RewardId = reward });
EventBus.Post(new ReplacedRewardEvent { RewardId = reward });
}
_saveData.ChapterProgress = Math.Max(_saveData.ChapterProgress, ActiveEnemyId == "greed.full" ? 2 : 1);
_saveData.RecentReplay = Match.Replay.Select(value => value.Clone()).ToList();
@@ -1,7 +1,7 @@
{
"name": "ReplacedPerson.PlayMode.Tests",
"rootNamespace": "ReplacedPerson.Tests",
"references": ["ReplacedPerson.Core", "ReplacedPerson.Runtime", "UnityEngine.UI", "Unity.TextMeshPro"],
"references": ["ReplacedPerson.Core", "ReplacedPerson.Runtime", "ShrinkApp.Core.Runtime", "ShrinkContext.AppAdapter.Runtime", "UnityEngine.UI", "Unity.TextMeshPro"],
"optionalUnityReferences": ["TestAssemblies"],
"includePlatforms": [],
"excludePlatforms": [],
@@ -4,6 +4,7 @@ using System.Collections;
using System.Linq;
using NUnit.Framework;
using ReplacedPerson.Runtime;
using ShrinkContext.AppAdapter;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
@@ -22,6 +23,16 @@ namespace ReplacedPerson.Tests
yield return null;
var root = GameObject.Find("ReplacedPersonDemo");
Assert.That(root, Is.Not.Null);
for (var frame = 0; frame < 30 &&
(ShrinkAppLoaderBootstrapper.Instance == null || !ShrinkAppLoaderBootstrapper.Instance.Host.IsRunning);
frame++)
yield return null;
var appHost = ShrinkAppLoaderBootstrapper.Instance;
Assert.That(appHost, Is.Not.Null);
Assert.That(appHost!.Host.IsRunning, Is.True);
Assert.That(appHost.Host.ActiveModuleIds, Does.Contain("demo.replaced-person"));
Assert.That(appHost.Host.Services.TryGet<ReplacedPersonGameService>(out var appService), Is.True);
Assert.That(appService, Is.Not.Null);
var camera = Camera.main;
Assert.That(camera, Is.Not.Null);
Assert.That(camera!.enabled, Is.True);
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 2a4090dedcf4f964195877944df52d40
guid: fa5cd8919ec4f544b89255c3da6b4ba0
folderAsset: yes
DefaultImporter:
externalObjects: {}
@@ -0,0 +1,190 @@
#if UNITY_EDITOR
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace ShrinkSDK.WorkspaceValidation
{
public static class ShrinkSdkWorkspaceValidation
{
private static readonly ExpectedPackage[] ExpectedPackages =
{
new ExpectedPackage("ShrinkApp.Core", "com.cneicy.shrink-app-core", "0.1.1"),
new ExpectedPackage("ShrinkApp.Starter.Basic", "com.cneicy.shrink-app-starter-basic", "0.1.0"),
new ExpectedPackage("ShrinkCommand", "com.cneicy.shrink-command", "0.2.0"),
new ExpectedPackage("ShrinkCommand.Integration.App", "com.cneicy.shrink-command-integration-app", "0.1.0"),
new ExpectedPackage("ShrinkCommand.Integration.EventBus", "com.cneicy.shrink-command-integration-eventbus", "0.1.1"),
new ExpectedPackage("ShrinkCommand.Integration.Network", "com.cneicy.shrink-command-integration-network", "0.1.0"),
new ExpectedPackage("ShrinkContext.AppAdapter", "com.cneicy.shrink-context-app-adapter", "0.1.0"),
new ExpectedPackage("ShrinkContext.Core", "com.cneicy.shrink-context-core", "0.1.0"),
new ExpectedPackage("ShrinkContext.EventBusAdapter", "com.cneicy.shrink-context-eventbus-adapter", "0.1.0"),
new ExpectedPackage("ShrinkDataSaver", "com.cneicy.shrink-datasaver", "2.2.0"),
new ExpectedPackage("ShrinkDataSaver.Integration.App", "com.cneicy.shrink-datasaver-integration-app", "0.1.0"),
new ExpectedPackage("ShrinkDataSaver.Integration.EventBus", "com.cneicy.shrink-datasaver-integration-eventbus", "2.1.0"),
new ExpectedPackage("ShrinkEventBus", "com.cneicy.shrink-eventbus", "2.0.0"),
new ExpectedPackage("ShrinkEventBus.Entities", "com.cneicy.shrink-eventbus-entities", "0.1.0"),
new ExpectedPackage("ShrinkInstaller", "com.cneicy.shrink-installer", "0.1.2"),
new ExpectedPackage("ShrinkModFramework", "com.cneicy.shrink-mod-framework", "0.2.1"),
new ExpectedPackage("ShrinkNetwork", "com.cneicy.shrink-network", "0.2.0"),
new ExpectedPackage("ShrinkNetwork.Integration.App", "com.cneicy.shrink-network-integration-app", "0.1.0"),
new ExpectedPackage("ShrinkNetwork.Integration.EventBus", "com.cneicy.shrink-network-integration-eventbus", "0.1.1"),
new ExpectedPackage("ShrinkShared.CodeGen", "com.cneicy.shrink-shared-codegen", "0.1.0"),
new ExpectedPackage("ShrinkTutorial", "com.cneicy.shrink-tutorial", "0.1.0")
};
public static void Run()
{
try
{
var workspaceRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
var packages = ReadPackages(workspaceRoot);
ValidateGraph(packages);
Debug.Log($"ShrinkSDK Workspace validation passed: packages={packages.Count}");
EditorApplication.Exit(0);
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorApplication.Exit(1);
}
}
private static Dictionary<string, PackageDefinition> ReadPackages(string workspaceRoot)
{
var result = new Dictionary<string, PackageDefinition>(StringComparer.Ordinal);
foreach (var expected in ExpectedPackages)
{
var manifestPath = Path.Combine(workspaceRoot, "Assets", "Modules", expected.Directory, "package.json");
if (!File.Exists(manifestPath))
{
throw new FileNotFoundException($"Required package manifest is missing: {expected.Directory}", manifestPath);
}
var manifest = JObject.Parse(File.ReadAllText(manifestPath));
var packageName = manifest.Value<string>("name");
var version = manifest.Value<string>("version");
if (packageName == null || version == null || packageName.Length == 0 || version.Length == 0)
{
throw new InvalidOperationException($"{expected.Directory} has no valid package name or version.");
}
if (!string.Equals(packageName, expected.Name, StringComparison.Ordinal) ||
!string.Equals(version, expected.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"{expected.Directory} must be {expected.Name}@{expected.Version}, found {packageName}@{version}.");
}
if (result.ContainsKey(packageName))
{
throw new InvalidOperationException($"Duplicate package manifest name: {packageName}");
}
result.Add(packageName, new PackageDefinition(packageName, version, manifest));
}
return result;
}
private static void ValidateGraph(IReadOnlyDictionary<string, PackageDefinition> packages)
{
var dependencies = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var package in packages.Values)
{
var localDependencies = new HashSet<string>(StringComparer.Ordinal);
var manifestDependencies = package.Manifest["dependencies"] as JObject;
if (manifestDependencies != null)
{
foreach (var property in manifestDependencies.Properties())
{
if (!packages.TryGetValue(property.Name, out var dependency))
{
continue;
}
var expectedVersion = property.Value.Value<string>();
if (!string.Equals(expectedVersion, dependency.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"{package.Name} requires {dependency.Name} {expectedVersion}, but the Workspace provides {dependency.Version}.");
}
var isIntegrationOrStarter =
package.Name.IndexOf("-integration-", StringComparison.Ordinal) >= 0 ||
package.Name.IndexOf("-starter-", StringComparison.Ordinal) >= 0;
if (dependency.Name.IndexOf("-integration-", StringComparison.Ordinal) >= 0 && !isIntegrationOrStarter)
{
throw new InvalidOperationException(
$"{package.Name} must not depend on integration package {dependency.Name}.");
}
localDependencies.Add(dependency.Name);
}
}
dependencies.Add(package.Name, localDependencies);
}
var resolved = new HashSet<string>(StringComparer.Ordinal);
var madeProgress = true;
while (madeProgress)
{
madeProgress = false;
foreach (var packageName in dependencies.Keys.OrderBy(name => name, StringComparer.Ordinal))
{
if (resolved.Contains(packageName) || dependencies[packageName].Any(dependency => !resolved.Contains(dependency)))
{
continue;
}
resolved.Add(packageName);
madeProgress = true;
}
}
if (resolved.Count != packages.Count)
{
var blocked = dependencies
.Where(pair => !resolved.Contains(pair.Key))
.Select(pair => pair.Key + " -> " + string.Join(", ", pair.Value.Where(dependency => !resolved.Contains(dependency))))
.OrderBy(value => value, StringComparer.Ordinal);
throw new InvalidOperationException("Circular internal package dependencies detected: " + string.Join("; ", blocked));
}
}
private sealed class PackageDefinition
{
public PackageDefinition(string name, string version, JObject manifest)
{
Name = name;
Version = version;
Manifest = manifest;
}
public string Name { get; }
public string Version { get; }
public JObject Manifest { get; }
}
private readonly struct ExpectedPackage
{
public ExpectedPackage(string directory, string name, string version)
{
Directory = directory;
Name = name;
Version = version;
}
public string Directory { get; }
public string Name { get; }
public string Version { get; }
}
}
}
#endif
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 10523dad09cece4438f29620b3253fdb
guid: 508c3ec513ec5194cb44d97137cf0a85
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -1,25 +0,0 @@
# Changelog
本文件记录 `ShrinkApp.Core` 的包内变更。
## [0.1.1] - 2026-08-16
### Added
- `ShrinkAppSettings.hostingMode``ShrinkAppHostingMode`):`ClassicHost` 保持原有自动启动;`ContextLoader` 让位给 ShrinkContext 加载器宿主(见 `ShrinkContext.AppAdapter`),支持运行中按模块 disable/enable。
- `ShrinkApp.InitializeForExternalHost(services, settings)`:外部宿主把服务容器接回 `ShrinkApp.Services` 静态入口。
- `ShrinkAppInstallers.GetDiscoveredInstallerTypes()`:编译期安装器发现的公共入口,供外部宿主枚举。
- `ShrinkAppContext.CreateStandalone(services, settings)`:非 MonoBehaviour 宿主构建上下文。
### Changed
- `Bootstrap``ContextLoader` 模式下直接返回,经典路径零行为变化。
## [0.1.0] - 2026-05-18
### Added
- 新增统一宿主层 `ShrinkApp.Core`
- 新增 `ShrinkApp``ShrinkAppHost``ShrinkAppSettings``ShrinkAppServices``ShrinkAppContext`
- 新增模块安装器契约:`IShrinkAppModuleInstaller``[ShrinkAppModuleInstaller]`
- 新增运行时生命周期事件:`ShrinkAppStartedEvent``ShrinkAppStartFailedEvent`
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 3557769562f2f7042a462bcf44b9fedf
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 346774b237ebc2b49bc74a69b150780e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,17 +0,0 @@
{
"name": "ShrinkApp.Core.Editor",
"rootNamespace": "ShrinkApp.Editor",
"references": [
"ShrinkApp.Core.Runtime"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: d16f21ff20129e14283877a81cca3054
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,39 +0,0 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
namespace ShrinkApp.Editor
{
public static class ShrinkAppEditorMenu
{
[MenuItem("ShrinkApp/创建 Settings")]
public static void CreateSettingsAsset()
{
const string resourcesDir = "Assets/Resources";
if (!AssetDatabase.IsValidFolder(resourcesDir))
AssetDatabase.CreateFolder("Assets", "Resources");
const string assetPath = "Assets/Resources/ShrinkAppSettings.asset";
var existing = AssetDatabase.LoadAssetAtPath<ShrinkAppSettings>(assetPath);
if (existing != null)
{
Selection.activeObject = existing;
EditorGUIUtility.PingObject(existing);
return;
}
// 旧版本曾生成过 m_Script=fileID:0 的无效占位资产。删除该占位后
// 才能在同一路径创建真正绑定 ShrinkAppSettings 类型的资产。
if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(assetPath)))
AssetDatabase.DeleteAsset(assetPath);
var asset = ScriptableObject.CreateInstance<ShrinkAppSettings>();
AssetDatabase.CreateAsset(asset, assetPath);
AssetDatabase.SaveAssets();
Selection.activeObject = asset;
EditorGUIUtility.PingObject(asset);
Debug.Log("[ShrinkApp] 已创建 ShrinkAppSettings.asset");
}
}
}
#endif
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 30f867e31a8e02e41b25f1ef5b4dddec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
-31
View File
@@ -1,31 +0,0 @@
# ShrinkApp.Core
`ShrinkApp.Core``Shrink` 系列模块的统一宿主层。它不直接替代各个基础包的独立使用方式,而是在项目需要“导入一组包后直接起盘”时,提供统一的启动链、模块安装器、服务容器和基础生命周期事件。
## 当前定位
- 统一宿主:在 `BeforeSceneLoad` 阶段创建 `ShrinkAppHost`
- 模块安装器:通过编译期注入的安装器注册表获取 `IShrinkAppModuleInstaller`
- 服务容器:各模块通过 `RegisterServices(...)` 暴露门面服务
- 生命周期事件:通过 `ShrinkEventBus` 发布 `ShrinkAppStartedEvent` / `ShrinkAppStartFailedEvent`
## 核心类型
```csharp
ShrinkApp
ShrinkAppHost
ShrinkAppSettings
ShrinkAppServices
ShrinkAppContext
IShrinkAppModuleInstaller
[ShrinkAppModuleInstaller]
```
## 启动流程
1. `ShrinkApp``RuntimeInitializeOnLoadMethod(BeforeSceneLoad)` 创建宿主。
2. 宿主从编译期注册表 `ShrinkAppGeneratedRegistry` 读取全部安装器类型。
3. 安装器按 `DependsOn + Order` 排序。
4. 先执行 `RegisterServices(...)`,再顺序执行 `InitializeAsync(...)`
5. 启动完成后在首场景 `Start()` 阶段发布 `ShrinkAppStartedEvent`
6. 任一安装器重复、依赖缺失、循环依赖、初始化异常都会 fail-fast。
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 271d3627f55ae2b498a8b430db8d2b41
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,16 +0,0 @@
{
"name": "ShrinkApp.Core.Runtime",
"rootNamespace": "ShrinkApp",
"references": [
"ShrinkEventBus.Runtime",
"UniTask"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: b5b7047dbc0ca2e438a92daa2baa200c
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,81 +0,0 @@
#nullable enable
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace ShrinkApp
{
public static class ShrinkApp
{
public static ShrinkAppHost? Host { get; private set; }
public static ShrinkAppContext? Context { get; private set; }
public static ShrinkAppSettings Settings => ShrinkAppSettings.Instance;
public static ShrinkAppServices Services => Context?.Services ?? throw new System.InvalidOperationException("ShrinkApp is not initialized.");
public static bool IsRunning => (Host != null && Host.IsRunning) || _externalHostRunning;
private static bool _externalHostRunning;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticState()
{
Host = null;
Context = null;
_externalHostRunning = false;
ShrinkAppSettings.ResetCachedInstance();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Bootstrap()
{
if (Host != null)
return;
var settings = ShrinkAppSettings.Instance;
if (settings.hostingMode == ShrinkAppHostingMode.ContextLoader)
{
// 阶段 2 过渡:经典宿主让位,由 ShrinkContext 加载器宿主(ShrinkContext.AppAdapter)接管启动
return;
}
var hostGameObject = new GameObject("ShrinkAppHost");
var host = hostGameObject.AddComponent<ShrinkAppHost>();
if (settings.dontDestroyOnLoad)
UnityEngine.Object.DontDestroyOnLoad(hostGameObject);
var services = new ShrinkAppServices();
var context = new ShrinkAppContext(host, settings, services);
host.Configure(context);
Host = host;
Context = context;
host.StartAppAsync().Forget(ex =>
{
Debug.LogException(ex);
Debug.LogError("[ShrinkApp] 启动失败: " + ex.Message);
});
}
/// <summary>
/// 阶段 2 过渡:由外部宿主(如 ShrinkContext 加载器宿主)提供已初始化的服务容器。
/// Host 保持 null——外部宿主不经过 ShrinkAppHost 生命周期,IsRunning 以外部宿主为准。
/// </summary>
public static void InitializeForExternalHost(ShrinkAppServices services, ShrinkAppSettings? settings = null)
{
if (services == null)
throw new ArgumentNullException(nameof(services));
Context = new ShrinkAppContext(null!, settings ?? Settings, services);
_externalHostRunning = false;
}
/// <summary>
/// Lets a non-<see cref="ShrinkAppHost"/> orchestrator expose its lifecycle through
/// the legacy static facade while the migration is in progress.
/// </summary>
public static void SetExternalHostRunning(bool running)
{
_externalHostRunning = running;
}
}
}
@@ -1,53 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
namespace ShrinkApp
{
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class ShrinkAppInstallerRegistryAttribute : Attribute
{
public ShrinkAppInstallerRegistryAttribute(params Type[] installerTypes)
{
InstallerTypes = installerTypes ?? Array.Empty<Type>();
}
public Type[] InstallerTypes { get; }
}
internal static class ShrinkAppGeneratedRegistry
{
public static IReadOnlyList<Type> GetInstallerTypes()
{
var types = new List<Type>();
var seen = new HashSet<Type>();
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
object[] attributes;
try
{
attributes = assembly.GetCustomAttributes(typeof(ShrinkAppInstallerRegistryAttribute), false);
}
catch
{
continue;
}
foreach (var attribute in attributes.OfType<ShrinkAppInstallerRegistryAttribute>())
{
foreach (var installerType in attribute.InstallerTypes ?? Array.Empty<Type>())
{
if (installerType == null || !seen.Add(installerType))
continue;
types.Add(installerType);
}
}
}
return types;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 1818116c8e85f5e489c0c4955f506ff2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,167 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkApp
{
public sealed class ShrinkAppHost : MonoBehaviour
{
private readonly List<string> _startedModules = new();
private ShrinkAppContext? _context;
private bool _unityStarted;
private bool _publishedStartedEvent;
public bool IsRunning { get; private set; }
public bool HasFailed { get; private set; }
public string LastError { get; private set; } = string.Empty;
public IReadOnlyList<string> StartedModules => _startedModules;
internal void Configure(ShrinkAppContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
internal async UniTask StartAppAsync()
{
if (IsRunning || HasFailed)
return;
if (_context == null)
throw new InvalidOperationException("ShrinkAppHost is not configured.");
var installers = CreateInstallerInstances();
var orderedInstallers = SortInstallers(installers, _context.Settings);
try
{
foreach (var installer in orderedInstallers)
installer.RegisterServices(_context);
foreach (var installer in orderedInstallers)
{
await installer.InitializeAsync(_context);
_startedModules.Add(installer.ModuleId);
}
IsRunning = true;
PublishStartedEventIfReady();
}
catch (Exception ex)
{
HasFailed = true;
LastError = ex.Message;
EventBus.TriggerEvent(new ShrinkAppStartFailedEvent
{
ErrorMessage = ex.Message,
FailedModuleId = _startedModules.LastOrDefault() ?? string.Empty
});
throw;
}
}
private void Start()
{
_unityStarted = true;
PublishStartedEventIfReady();
}
private void PublishStartedEventIfReady()
{
if (_publishedStartedEvent || !_unityStarted || !IsRunning)
return;
_publishedStartedEvent = true;
EventBus.TriggerEvent(new ShrinkAppStartedEvent
{
ModuleIds = _startedModules.ToArray()
});
}
private static IReadOnlyList<IShrinkAppModuleInstaller> CreateInstallerInstances()
{
var installers = new List<IShrinkAppModuleInstaller>();
foreach (var installerType in ShrinkAppGeneratedRegistry.GetInstallerTypes())
{
if (installerType == null)
continue;
if (!typeof(IShrinkAppModuleInstaller).IsAssignableFrom(installerType))
throw new InvalidOperationException($"Installer type does not implement IShrinkAppModuleInstaller: {installerType.FullName}");
if (installerType.IsAbstract)
throw new InvalidOperationException($"Installer type cannot be abstract: {installerType.FullName}");
if (Activator.CreateInstance(installerType) is not IShrinkAppModuleInstaller installer)
throw new InvalidOperationException($"Failed to create installer: {installerType.FullName}");
installers.Add(installer);
}
return installers;
}
private static IReadOnlyList<IShrinkAppModuleInstaller> SortInstallers(
IReadOnlyList<IShrinkAppModuleInstaller> installers,
ShrinkAppSettings settings)
{
var disabled = new HashSet<string>(
settings.disabledModuleIds?.Where(id => !string.IsNullOrWhiteSpace(id)).Select(id => id.Trim()) ??
Enumerable.Empty<string>(),
StringComparer.OrdinalIgnoreCase);
var enabledInstallers = installers
.Where(installer => !disabled.Contains(installer.ModuleId))
.ToArray();
var byId = new Dictionary<string, IShrinkAppModuleInstaller>(StringComparer.OrdinalIgnoreCase);
foreach (var installer in enabledInstallers)
{
if (string.IsNullOrWhiteSpace(installer.ModuleId))
throw new InvalidOperationException($"Installer {installer.GetType().FullName} has an empty ModuleId.");
if (!byId.TryAdd(installer.ModuleId.Trim(), installer))
throw new InvalidOperationException($"Duplicate installer ModuleId: {installer.ModuleId}");
}
var visitState = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var ordered = new List<IShrinkAppModuleInstaller>();
foreach (var installer in enabledInstallers.OrderBy(item => item.Order).ThenBy(item => item.ModuleId, StringComparer.OrdinalIgnoreCase))
{
Visit(installer, byId, visitState, ordered);
}
return ordered;
}
private static void Visit(
IShrinkAppModuleInstaller installer,
IReadOnlyDictionary<string, IShrinkAppModuleInstaller> byId,
IDictionary<string, int> visitState,
IList<IShrinkAppModuleInstaller> ordered)
{
var state = visitState.TryGetValue(installer.ModuleId, out var existingState) ? existingState : 0;
if (state == 2)
return;
if (state == 1)
throw new InvalidOperationException($"Circular installer dependency detected at module: {installer.ModuleId}");
visitState[installer.ModuleId] = 1;
foreach (var dependencyId in installer.DependsOn ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(dependencyId))
continue;
if (!byId.TryGetValue(dependencyId.Trim(), out var dependency))
throw new InvalidOperationException(
$"Installer dependency missing. Module={installer.ModuleId}, DependsOn={dependencyId}");
Visit(dependency, byId, visitState, ordered);
}
visitState[installer.ModuleId] = 2;
if (!ordered.Contains(installer))
ordered.Add(installer);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: df43c81d962edd64cbd214b2697b4179
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,16 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace ShrinkApp
{
/// <summary>
/// 编译期发现的应用模块安装器类型(公共入口)。
/// 经典 ShrinkAppHost 与外部宿主(ShrinkContext 加载器宿主)共用同一份发现结果。
/// </summary>
public static class ShrinkAppInstallers
{
public static IReadOnlyList<Type> GetDiscoveredInstallerTypes() =>
ShrinkAppGeneratedRegistry.GetInstallerTypes();
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: c22f2e857d66bae458ff3dd6d9519d0d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,181 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkApp
{
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class ShrinkAppModuleInstallerAttribute : Attribute
{
}
public interface IShrinkAppModuleInstaller
{
string ModuleId { get; }
int Order { get; }
IReadOnlyList<string> DependsOn { get; }
void RegisterServices(ShrinkAppContext context);
UniTask InitializeAsync(ShrinkAppContext context);
}
public sealed class ShrinkAppContext
{
internal ShrinkAppContext(ShrinkAppHost host, ShrinkAppSettings settings, ShrinkAppServices services)
{
Host = host;
Settings = settings;
Services = services;
}
/// <summary>
/// 为非 MonoBehaviour 宿主(如 ShrinkContext 纤程适配器)构建上下文。
/// Host 为 null:适用于不经过 ShrinkAppHost 启动流的独立编排场景。
/// </summary>
public static ShrinkAppContext CreateStandalone(ShrinkAppServices services, ShrinkAppSettings? settings = null)
{
if (services == null)
throw new ArgumentNullException(nameof(services));
return new ShrinkAppContext(null!, settings ?? ShrinkAppSettings.Instance, services);
}
public ShrinkAppHost Host { get; }
public ShrinkAppSettings Settings { get; }
public ShrinkAppServices Services { get; }
}
public sealed class ShrinkAppServices
{
private readonly Dictionary<Type, object> _services = new();
public void Register<TService>(TService service) where TService : class
{
if (service == null)
throw new ArgumentNullException(nameof(service));
_services[typeof(TService)] = service;
}
public bool TryGet<TService>(out TService? service) where TService : class
{
if (_services.TryGetValue(typeof(TService), out var boxed) && boxed is TService typed)
{
service = typed;
return true;
}
service = null;
return false;
}
/// <summary>
/// Removes a service only when the current registration is the supplied instance.
/// This lets context-driven components expose the legacy service facade as a
/// reversible effect without removing a newer replacement during teardown.
/// </summary>
public bool TryUnregister<TService>(TService service) where TService : class
{
if (service == null)
throw new ArgumentNullException(nameof(service));
if (!_services.TryGetValue(typeof(TService), out var current) ||
!ReferenceEquals(current, service))
return false;
return _services.Remove(typeof(TService));
}
public TService GetRequired<TService>() where TService : class
{
if (!TryGet<TService>(out var service) || service == null)
throw new InvalidOperationException($"Required service is not registered: {typeof(TService).FullName}");
return service;
}
public IReadOnlyList<string> GetRegisteredServiceTypeNames()
{
return _services.Keys
.Select(type => type.FullName ?? type.Name)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
/// <summary>ShrinkApp 的宿主模式(阶段 2 过渡:两种模式并存,按项目选择)。</summary>
public enum ShrinkAppHostingMode
{
/// <summary>经典模式:ShrinkAppHost 在 BeforeSceneLoad 自动创建并按拓扑序启动安装器。</summary>
ClassicHost = 0,
/// <summary>加载器模式:经典宿主让位,由 ShrinkContext 加载器宿主(ShrinkContext.AppAdapter)接管启动,
/// 支持运行中按模块 disable/enable 与依赖响应式等待。</summary>
ContextLoader = 1,
}
[CreateAssetMenu(fileName = "ShrinkAppSettings", menuName = "ShrinkApp/Settings")]
public class ShrinkAppSettings : ScriptableObject
{
private static ShrinkAppSettings? _instance;
public static ShrinkAppSettings Instance
{
get
{
if (_instance != null)
return _instance;
_instance = Resources.Load<ShrinkAppSettings>("ShrinkAppSettings");
#if UNITY_EDITOR
if (!_instance)
{
var guids = UnityEditor.AssetDatabase.FindAssets("t:ShrinkAppSettings");
if (guids.Length > 0)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
_instance = UnityEditor.AssetDatabase.LoadAssetAtPath<ShrinkAppSettings>(path);
}
}
#endif
if (!_instance)
{
_instance = CreateInstance<ShrinkAppSettings>();
Debug.LogWarning(
"[ShrinkApp] 未找到 ShrinkAppSettings,当前使用默认配置。建议通过 Assets -> Create -> ShrinkApp -> Settings 创建配置。");
}
return _instance!;
}
internal set => _instance = value;
}
internal static void ResetCachedInstance()
{
_instance = null;
}
public bool dontDestroyOnLoad = true;
public bool verboseLogging;
public string[] disabledModuleIds = Array.Empty<string>();
[Tooltip("宿主模式:ClassicHost 为经典自动启动;ContextLoader 由 ShrinkContext 加载器宿主接管(阶段 2 过渡)")]
public ShrinkAppHostingMode hostingMode = ShrinkAppHostingMode.ClassicHost;
}
public sealed class ShrinkAppStartedEvent : EventBase
{
public IReadOnlyList<string> ModuleIds { get; set; } = Array.Empty<string>();
}
public sealed class ShrinkAppStartFailedEvent : EventBase
{
public string ErrorMessage { get; set; } = string.Empty;
public string FailedModuleId { get; set; } = string.Empty;
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 384dd878beec6b848b64aa843b464e95
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,22 +0,0 @@
{
"name": "com.cneicy.shrink-app-core",
"version": "0.1.1",
"displayName": "ShrinkApp Core",
"description": "Shrink 系列统一宿主层,提供模块安装器、服务容器与统一启动流程。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-eventbus": "1.1.5",
"com.cysharp.unitask": "2.5.10"
},
"keywords": [
"app",
"bootstrap",
"framework",
"host",
"starter"
],
"author": {
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 30fa106725f9afc46af98aff2ee92198
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,15 +0,0 @@
# Changelog
本文件记录 `ShrinkApp.Starter.Basic` 的包内变更。
## [0.1.0] - 2026-05-18
### Added
- 新增最小起盘 Starter 包。
- 新增场景生成器 `ShrinkAppBasicStarterGenerator`
- 新增示例控制器 `ShrinkAppBasicSampleController`
- 新增 `ShrinkAppBasicDemoRuntime`,补齐默认命令源、状态命令与最小 loopback 网络示例。
- 新增 `ShrinkAppBasicStarterSettings`,提供最小 Starter 级别的默认配置资产。
- 示例场景继续扩展为小型调试台,补齐命令输入、输出日志、模块/服务/存档/命令/网络状态面板。
- 状态面板进一步细化为已启动模块、已注册服务、存档槽位、命令列表、网络诊断五块只读视图。
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: e3e78d4720b16b84fbf0d766b0a0122b
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f6be676789111fd4a9afd67b011a12c9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,23 +0,0 @@
{
"name": "ShrinkApp.Starter.Basic.Editor",
"rootNamespace": "ShrinkApp.Starter.Basic.Editor",
"references": [
"ShrinkApp.Starter.Basic.Runtime",
"ShrinkApp.Core.Runtime",
"ShrinkApp.Core.Editor",
"ShrinkContext.AppAdapter.Runtime",
"ShrinkDataSaver.Runtime",
"ShrinkDataSaver.Integration.App",
"UnityEngine.UI"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: aa988409913004043b6cbe3d19de32bd
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,248 +0,0 @@
#if UNITY_EDITOR
using ShrinkApp.Editor;
using ShrinkContext.AppAdapter;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
namespace ShrinkApp.Starter.Basic.Editor
{
public static class ShrinkAppBasicStarterGenerator
{
private const string MenuPath = "ShrinkApp/Starter/生成 Basic Entry 场景";
private const string CompositionMenuPath = "ShrinkApp/Starter/创建默认 Cordis Composition";
private const string CompositionPath = "Assets/Resources/ShrinkAppComposition.asset";
[MenuItem(MenuPath)]
public static void Generate()
{
EnsureAssets();
BuildScene();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[ShrinkApp.Starter.Basic] 已生成 Basic Entry 场景与配置。");
}
[MenuItem(CompositionMenuPath)]
public static void CreateDefaultCompositionAsset()
{
EnsureResourcesDirectory();
var existing = AssetDatabase.LoadAssetAtPath<ShrinkAppCompositionProfile>(CompositionPath);
if (existing != null)
{
Selection.activeObject = existing;
return;
}
// 修复脚本/asmdef 变更期间可能留下的无主 .asset,而不是在同一路径叠加创建。
if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(CompositionPath)))
AssetDatabase.DeleteAsset(CompositionPath);
var composition = ScriptableObject.CreateInstance<ShrinkAppCompositionProfile>();
composition.SetDocument(ShrinkAppBasicContextComposition.CreateDefaultDocument());
AssetDatabase.CreateAsset(composition, CompositionPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Selection.activeObject = composition;
}
private static void EnsureAssets()
{
ShrinkAppEditorMenu.CreateSettingsAsset();
EnsureResourcesDirectory();
const string dataSaverSettingsPath = "Assets/Resources/ShrinkDataSaverSettings.asset";
if (AssetDatabase.LoadAssetAtPath<ScriptableObject>(dataSaverSettingsPath) == null)
{
var asset = ScriptableObject.CreateInstance("ShrinkDataSaverSettings");
if (asset == null)
throw new System.InvalidOperationException(
"Cannot create ShrinkDataSaverSettings. Ensure ShrinkDataSaver.Runtime is imported and compiled.");
AssetDatabase.CreateAsset(asset, dataSaverSettingsPath);
}
const string starterSettingsPath = "Assets/Resources/ShrinkAppBasicStarterSettings.asset";
if (AssetDatabase.LoadAssetAtPath<ShrinkAppBasicStarterSettings>(starterSettingsPath) == null)
{
var starterAsset = ScriptableObject.CreateInstance<ShrinkAppBasicStarterSettings>();
starterAsset.autoBindLoopbackTransport = true;
starterAsset.defaultCommandText = "starter status";
starterAsset.defaultLoopbackSessionId = 1;
starterAsset.logCommandOutputToConsole = true;
starterAsset.maxCommandOutputLines = 12;
AssetDatabase.CreateAsset(starterAsset, starterSettingsPath);
}
CreateDefaultCompositionAsset();
}
private static void EnsureResourcesDirectory()
{
const string resourcesDir = "Assets/Resources";
if (!AssetDatabase.IsValidFolder(resourcesDir))
AssetDatabase.CreateFolder("Assets", "Resources");
}
private static void BuildScene()
{
const string scenesDir = "Assets/Scenes";
if (!AssetDatabase.IsValidFolder(scenesDir))
AssetDatabase.CreateFolder("Assets", "Scenes");
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
new GameObject("Main Camera", typeof(Camera));
new GameObject("EventSystem", typeof(UnityEngine.EventSystems.EventSystem),
typeof(UnityEngine.EventSystems.StandaloneInputModule));
var canvasGo = new GameObject("Canvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
var canvas = canvasGo.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasGo.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
var panelGo = new GameObject("Panel", typeof(RectTransform), typeof(Image));
panelGo.transform.SetParent(canvasGo.transform, false);
var panelRect = panelGo.GetComponent<RectTransform>();
panelRect.sizeDelta = new Vector2(980f, 720f);
panelRect.anchoredPosition = Vector2.zero;
panelGo.GetComponent<Image>().color = new Color(0.12f, 0.15f, 0.2f, 0.92f);
var statusText = CreateText("StatusText", panelGo.transform, new Vector2(0f, 240f), new Vector2(840f, 120f),
"ShrinkApp Basic starter ready.");
statusText.alignment = TextAnchor.MiddleCenter;
var detailText = CreateText("DetailText", panelGo.transform, new Vector2(0f, 135f), new Vector2(840f, 140f),
"app=False, save=False, cmd=False, net=False");
detailText.alignment = TextAnchor.UpperLeft;
detailText.fontSize = 20;
var commandInputField = CreateInputField("CommandInput", panelGo.transform, new Vector2(0f, 20f),
new Vector2(520f, 54f), "starter status");
var outputText = CreateText("OutputText", panelGo.transform, new Vector2(0f, -250f), new Vector2(840f, 180f),
string.Empty);
outputText.alignment = TextAnchor.UpperLeft;
outputText.fontSize = 20;
outputText.horizontalOverflow = HorizontalWrapMode.Wrap;
outputText.verticalOverflow = VerticalWrapMode.Overflow;
var saveMetaText = CreateText("SaveMetaText", panelGo.transform, new Vector2(-280f, -430f), new Vector2(240f, 150f),
"No save slots found.");
saveMetaText.alignment = TextAnchor.UpperLeft;
saveMetaText.fontSize = 18;
var moduleText = CreateText("ModuleText", panelGo.transform, new Vector2(0f, -430f), new Vector2(240f, 150f),
"No started modules.");
moduleText.alignment = TextAnchor.UpperLeft;
moduleText.fontSize = 18;
var serviceText = CreateText("ServiceText", panelGo.transform, new Vector2(280f, -430f), new Vector2(240f, 150f),
"No registered services.");
serviceText.alignment = TextAnchor.UpperLeft;
serviceText.fontSize = 18;
var commandListText = CreateText("CommandListText", panelGo.transform, new Vector2(-280f, -600f), new Vector2(240f, 150f),
"Command service not ready.");
commandListText.alignment = TextAnchor.UpperLeft;
commandListText.fontSize = 18;
var networkText = CreateText("NetworkText", panelGo.transform, new Vector2(280f, -600f), new Vector2(240f, 150f),
"Network service not ready.");
networkText.alignment = TextAnchor.UpperLeft;
networkText.fontSize = 18;
var saveButton = CreateButton("SaveButton", panelGo.transform, new Vector2(-250f, -70f), "Save Slot 0");
var loadButton = CreateButton("LoadButton", panelGo.transform, new Vector2(0f, -70f), "Load Slot 0");
var commandButton = CreateButton("CommandButton", panelGo.transform, new Vector2(250f, -70f), "Run Command");
var loopbackButton = CreateButton("LoopbackButton", panelGo.transform, new Vector2(-250f, -165f), "Bind Loopback");
var disconnectButton = CreateButton("DisconnectLoopbackButton", panelGo.transform, new Vector2(0f, -165f), "Disconnect Loopback");
var refreshButton = CreateButton("RefreshButton", panelGo.transform, new Vector2(250f, -165f), "Refresh Status");
var clearOutputButton = CreateButton("ClearOutputButton", panelGo.transform, new Vector2(0f, -345f), "Clear Output");
var controller = panelGo.AddComponent<ShrinkAppBasicSampleController>();
var serializedObject = new SerializedObject(controller);
serializedObject.FindProperty("statusText").objectReferenceValue = statusText;
serializedObject.FindProperty("detailText").objectReferenceValue = detailText;
serializedObject.FindProperty("outputText").objectReferenceValue = outputText;
serializedObject.FindProperty("moduleText").objectReferenceValue = moduleText;
serializedObject.FindProperty("serviceText").objectReferenceValue = serviceText;
serializedObject.FindProperty("saveMetaText").objectReferenceValue = saveMetaText;
serializedObject.FindProperty("commandListText").objectReferenceValue = commandListText;
serializedObject.FindProperty("networkText").objectReferenceValue = networkText;
serializedObject.FindProperty("saveButton").objectReferenceValue = saveButton;
serializedObject.FindProperty("loadButton").objectReferenceValue = loadButton;
serializedObject.FindProperty("commandButton").objectReferenceValue = commandButton;
serializedObject.FindProperty("loopbackButton").objectReferenceValue = loopbackButton;
serializedObject.FindProperty("refreshButton").objectReferenceValue = refreshButton;
serializedObject.FindProperty("clearOutputButton").objectReferenceValue = clearOutputButton;
serializedObject.FindProperty("disconnectLoopbackButton").objectReferenceValue = disconnectButton;
serializedObject.FindProperty("commandInputField").objectReferenceValue = commandInputField;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
EditorSceneManager.SaveScene(scene, "Assets/Scenes/ShrinkAppEntry.unity");
}
private static Text CreateText(string name, Transform parent, Vector2 anchoredPosition, Vector2 size, string text)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Text));
go.transform.SetParent(parent, false);
var rect = go.GetComponent<RectTransform>();
rect.sizeDelta = size;
rect.anchoredPosition = anchoredPosition;
var label = go.GetComponent<Text>();
label.text = text;
label.font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
label.fontSize = 28;
label.color = Color.white;
return label;
}
private static Button CreateButton(string name, Transform parent, Vector2 anchoredPosition, string labelText)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
var rect = go.GetComponent<RectTransform>();
rect.sizeDelta = new Vector2(220f, 72f);
rect.anchoredPosition = anchoredPosition;
go.GetComponent<Image>().color = new Color(0.24f, 0.45f, 0.75f, 1f);
var label = CreateText("Label", go.transform, Vector2.zero, new Vector2(220f, 72f), labelText);
label.alignment = TextAnchor.MiddleCenter;
label.resizeTextForBestFit = true;
label.resizeTextMinSize = 18;
label.resizeTextMaxSize = 28;
return go.GetComponent<Button>();
}
private static InputField CreateInputField(string name, Transform parent, Vector2 anchoredPosition, Vector2 size, string defaultText)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(InputField));
go.transform.SetParent(parent, false);
var rect = go.GetComponent<RectTransform>();
rect.sizeDelta = size;
rect.anchoredPosition = anchoredPosition;
go.GetComponent<Image>().color = new Color(0.08f, 0.1f, 0.14f, 1f);
var text = CreateText("Text", go.transform, Vector2.zero, size, defaultText);
text.alignment = TextAnchor.MiddleLeft;
text.fontSize = 24;
text.color = Color.white;
var textRect = text.GetComponent<RectTransform>();
textRect.offsetMin = new Vector2(16f, 8f);
textRect.offsetMax = new Vector2(-16f, -8f);
var placeholder = CreateText("Placeholder", go.transform, Vector2.zero, size, "Enter command...");
placeholder.alignment = TextAnchor.MiddleLeft;
placeholder.fontSize = 24;
placeholder.color = new Color(1f, 1f, 1f, 0.35f);
var placeholderRect = placeholder.GetComponent<RectTransform>();
placeholderRect.offsetMin = new Vector2(16f, 8f);
placeholderRect.offsetMax = new Vector2(-16f, -8f);
var inputField = go.GetComponent<InputField>();
inputField.textComponent = text;
inputField.placeholder = placeholder;
inputField.text = defaultText;
return inputField;
}
}
}
#endif
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 02316ba70f6e08249b09f5b7ff63eb93
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,47 +0,0 @@
# ShrinkApp.Starter.Basic
`ShrinkApp.Starter.Basic``ShrinkApp` 的最小起盘 Starter。目标不是提供完整游戏模板,而是让项目在导入最小基础包后,能快速得到一个可运行的入口场景、基础配置和最小示例流程。
`ShrinkAppSettings.hostingMode = ContextLoader` 时,Starter 组合根会把 Command、DataSaver、Network 三个已发现模块替换为原生 `IShrinkComponent`,并把兼容用的服务门面作为可逆效应挂回 `ShrinkApp.Services`。因此 Starter 调试台仍可使用原来的服务 API,同时模块 disable/enable 由 `ShrinkContextLoader` 增量协调。
## 当前能力
- 生成 `ShrinkAppSettings.asset`
- 生成或补齐 `ShrinkDataSaverSettings.asset`
- 生成 `ShrinkAppBasicStarterSettings.asset`
- 生成 `ShrinkAppComposition.asset`(7 个默认 Cordis 条目;组件工厂仍由代码组合根提供)
- 生成 `Assets/Scenes/ShrinkAppEntry.unity`
- 生成最小示例 UI 与 `ShrinkAppBasicSampleController`
- 演示 `ShrinkApp + ShrinkDataSaver + Command + Network` 的最小闭环
可以单独执行 `ShrinkApp/Starter/创建默认 Cordis Composition` 创建或修复组合资产,不需要重建场景。
当前示例场景内置的最小入口已经扩成一个小型调试台,包括:
- `Save Slot 0`
- `Load Slot 0`
- `Run starter status`
- `Bind Loopback`
- `Disconnect Loopback`
- `Refresh Status`
- `Clear Output`
- 命令输入框
- 输出日志区
- 模块/服务/存档/命令/网络五块只读状态面板
当前五块只读状态面板分别展示:
- 已启动模块列表
- 已注册服务类型列表
- 存档槽位元数据概览
- 当前已注册命令列表
- 网络诊断摘要与 session 概览
同时会生成一份 Starter 自己的配置资产,用于控制:
- 是否在启动时自动绑定 loopback transport
- 默认命令文本
- 默认 loopback session id
- 是否把命令输出打印到控制台
- 启动时是否自动执行默认命令
- 命令输出区最多保留多少行
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 6a8145b76d3e3b445b3768d897fe21c0
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: dfffe5a1b320812469b0c0f7a81e790b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,29 +0,0 @@
{
"name": "ShrinkApp.Starter.Basic.Runtime",
"rootNamespace": "ShrinkApp.Starter.Basic",
"references": [
"ShrinkApp.Core.Runtime",
"ShrinkContext.AppAdapter.Runtime",
"ShrinkContext.Core.Runtime",
"ShrinkCommand.Runtime",
"ShrinkCommand.Integration.App",
"ShrinkDataSaver.Integration.App",
"ShrinkDataSaver.Runtime",
"ShrinkNetwork.Runtime",
"ShrinkNetwork.Integration.App",
"ShrinkCommand.Integration.EventBus",
"ShrinkDataSaver.Integration.EventBus",
"ShrinkNetwork.Integration.EventBus",
"ShrinkCommand.Integration.Network",
"UnityEngine.UI",
"UniTask"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 10b7cf71574bd904594339a0824e028b
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,66 +0,0 @@
#nullable enable
using System.Collections.Generic;
using ShrinkCommand.Integration.App;
using ShrinkCommand.Integration;
using ShrinkContext.AppAdapter;
using ShrinkDataSaver.Integration.App;
using ShrinkDataSaver.Integration;
using ShrinkNetwork.Integration.App;
using ShrinkNetwork.Integration;
using UnityEngine;
namespace ShrinkApp.Starter.Basic
{
/// <summary>
/// Basic Starter 的组合根:ContextLoader 模式下默认使用原生 Cordis 组件,
/// 不再把三个功能模块交给 IShrinkAppModuleInstaller 包装器执行。
/// </summary>
public static class ShrinkAppBasicContextComposition
{
private static readonly string[] DefaultModuleIdValues =
{
"shrink.network",
"shrink.command",
"shrink.datasaver",
"shrink.integration.command-network",
"shrink.integration.command-eventbus",
"shrink.integration.datasaver-eventbus",
"shrink.integration.network-eventbus"
};
public static IReadOnlyList<string> DefaultModuleIds => DefaultModuleIdValues;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void RegisterDefaultComposition()
{
ShrinkAppLoaderBootstrapper.DefaultComposition = Configure;
}
public static void Configure(ShrinkAppLoaderHost host)
{
if (host == null)
throw new System.ArgumentNullException(nameof(host));
host.OverrideModuleComponent("shrink.network", static () => new ShrinkNetworkAppComponent());
host.OverrideModuleComponent("shrink.command", static () => new ShrinkCommandAppComponent());
host.OverrideModuleComponent("shrink.datasaver", static () => new ShrinkDataSaverAppComponent());
host.AddModuleComponent("shrink.integration.command-network",
static () => new ShrinkCommandNetworkIntegrationComponent());
host.AddModuleComponent("shrink.integration.command-eventbus",
static () => new ShrinkCommandEventBusComponent());
host.AddModuleComponent("shrink.integration.datasaver-eventbus",
static () => new ShrinkDataSaverEventBusComponent());
host.AddModuleComponent("shrink.integration.network-eventbus",
static () => new ShrinkNetworkEventBusComponent());
}
public static ShrinkAppCompositionDocument CreateDefaultDocument()
{
var entries = new List<ShrinkAppCompositionEntry>(DefaultModuleIdValues.Length);
foreach (var moduleId in DefaultModuleIdValues)
entries.Add(new ShrinkAppCompositionEntry(moduleId));
return new ShrinkAppCompositionDocument(includeUnlistedEntries: false, entries);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 5b07e8ba4e0ea394d929c688f6f4fcc4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,214 +0,0 @@
#nullable enable
using System;
using System.Linq;
using Cysharp.Threading.Tasks;
using ShrinkCommand;
using ShrinkCommand.Integration.App;
using ShrinkContext.AppAdapter;
using ShrinkNetwork;
using ShrinkNetwork.Integration.App;
using UnityEngine;
namespace ShrinkApp.Starter.Basic
{
public sealed class ShrinkAppBasicCommandSource : IShrinkCommandSource
{
private readonly Action<string>? _sink;
private readonly bool _logToConsole;
public ShrinkAppBasicCommandSource(Action<string>? sink = null, bool logToConsole = true)
{
_sink = sink;
_logToConsole = logToConsole;
}
public string SourceId => "starter.basic.console";
public string DisplayName => "StarterBasicConsole";
public bool IsConsole => true;
public bool HasPermission(string permission) => true;
public UniTask WriteLineAsync(string message, System.Threading.CancellationToken cancellationToken = default)
{
_sink?.Invoke(message);
if (_logToConsole)
Debug.Log("[ShrinkApp.Basic.Command] " + message);
return UniTask.CompletedTask;
}
}
[ShrinkCommandSubscriber]
public static class ShrinkAppBasicDemoCommands
{
[ShrinkCommand("starter status", Description = "显示 Starter.Basic 当前状态")]
private static string StarterStatus()
{
var appRunning = ShrinkApp.IsRunning;
var loadedSlot = ShrinkDataSaver.ShrinkSave.LoadedSlot;
return $"appRunning={appRunning}, loadedSlot={loadedSlot}";
}
}
public static class ShrinkAppBasicDemoRuntime
{
private const string SettingsResourceName = "ShrinkAppBasicStarterSettings";
private static ShrinkLoopbackTransport? _loopbackClientTransport;
private static ShrinkLoopbackTransport? _loopbackServerTransport;
private static long _activeLoopbackSessionId = -1;
public static ShrinkAppBasicStarterSettings GetOrCreateSettings()
{
var asset = Resources.Load<ShrinkAppBasicStarterSettings>(SettingsResourceName);
if (asset != null)
return asset;
var fallback = ScriptableObject.CreateInstance<ShrinkAppBasicStarterSettings>();
fallback.defaultCommandText = "starter status";
fallback.defaultLoopbackSessionId = 1;
fallback.logCommandOutputToConsole = true;
return fallback;
}
public static string BuildStatusSummary()
{
var commandReady = ShrinkApp.Context?.Services.TryGet<ShrinkCommandAppService>(out _) == true;
var networkReady = ShrinkApp.Context?.Services.TryGet<ShrinkNetworkAppService>(out _) == true;
var dataSaverReady = ShrinkApp.Context?.Services.TryGet<ShrinkDataSaver.Integration.App.ShrinkDataSaverService>(out _) == true;
var appRunning = ShrinkApp.IsRunning || ShrinkAppLoaderBootstrapper.Instance?.Host.IsRunning == true;
return $"app={appRunning}, save={dataSaverReady}, cmd={commandReady}, net={networkReady}, loopbackSession={_activeLoopbackSessionId}";
}
public static string BuildDetailedStatus()
{
var settings = GetOrCreateSettings();
var recentSlot = ShrinkDataSaver.ShrinkSave.GetRecentSlotIndex();
var commandService = ShrinkApp.Context?.Services.TryGet<ShrinkCommandAppService>(out var command) == true ? command : null;
var networkService = ShrinkApp.Context?.Services.TryGet<ShrinkNetworkAppService>(out var network) == true ? network : null;
var diagnostics = networkService?.GetDiagnosticsSnapshot();
var moduleCount = ShrinkAppLoaderBootstrapper.Instance?.Host.ActiveModuleIds.Count
?? ShrinkApp.Host?.StartedModules.Count
?? 0;
var serviceCount = ShrinkApp.Context?.Services.GetRegisteredServiceTypeNames().Count ?? 0;
return
$"defaultCommand={settings.defaultCommandText}\n" +
$"loopbackAutoBind={settings.autoBindLoopbackTransport}, sessionId={settings.defaultLoopbackSessionId}\n" +
$"recentSlot={recentSlot}, loadedSlot={ShrinkDataSaver.ShrinkSave.LoadedSlot}\n" +
$"modules={moduleCount}, services={serviceCount}, commandCount={commandService?.Commands.Count ?? 0}, currentSessions={diagnostics?.CurrentSessions ?? 0}\n" +
$"{BuildStatusSummary()}";
}
public static string BuildModuleSummary()
{
var loaderHost = ShrinkAppLoaderBootstrapper.Instance?.Host;
if (loaderHost != null && loaderHost.ActiveModuleIds.Count > 0)
return string.Join("\n", loaderHost.ActiveModuleIds);
if (ShrinkApp.Host?.StartedModules == null || ShrinkApp.Host.StartedModules.Count == 0)
return "No started modules.";
return string.Join("\n", ShrinkApp.Host.StartedModules);
}
public static string BuildServiceSummary()
{
if (ShrinkApp.Context == null)
return "No registered services.";
return string.Join("\n", ShrinkApp.Context.Services.GetRegisteredServiceTypeNames());
}
public static string BuildSaveMetaSummary(ShrinkDataSaver.SaveMeta[]? metas)
{
if (metas == null || metas.Length == 0)
return "No save slots found.";
return string.Join("\n", metas
.OrderBy(meta => meta.SlotIndex)
.Select(meta =>
$"slot={meta.SlotIndex}, name={meta.SlotName}, playtime={meta.PlaytimeSeconds:F0}s, modified={meta.LastModifiedTime:g}"));
}
public static string BuildCommandListSummary()
{
if (ShrinkApp.Context?.Services.TryGet<ShrinkCommandAppService>(out var commandService) != true || commandService == null)
return "Command service not ready.";
return string.Join("\n", commandService.Commands
.OrderBy(command => command.Path, StringComparer.OrdinalIgnoreCase)
.Take(10)
.Select(command => command.Path));
}
public static string BuildNetworkSummary()
{
if (ShrinkApp.Context?.Services.TryGet<ShrinkNetworkAppService>(out var networkService) != true || networkService == null)
return "Network service not ready.";
var diagnostics = networkService.GetDiagnosticsSnapshot();
var sessions = networkService.GetSessionSummaries();
return
$"sessions={diagnostics.CurrentSessions}, connected={diagnostics.SessionsConnected}, disconnected={diagnostics.SessionsDisconnected}\n" +
$"packetsSent={diagnostics.PacketsSent}, packetsReceived={diagnostics.PacketsReceived}\n" +
$"rpcStarted={diagnostics.RpcStarted}, rpcCompleted={diagnostics.RpcCompleted}\n" +
string.Join("\n", sessions);
}
public static async UniTask<string> ExecuteCommandAsync(string? commandText, Action<string>? outputSink = null)
{
var command = ShrinkApp.Services.GetRequired<ShrinkCommandAppService>();
var settings = GetOrCreateSettings();
var source = new ShrinkAppBasicCommandSource(outputSink, settings.logCommandOutputToConsole);
var effectiveCommand = string.IsNullOrWhiteSpace(commandText)
? settings.defaultCommandText
: commandText.Trim();
var result = await command.ExecuteAsync(source, effectiveCommand);
if (!string.IsNullOrWhiteSpace(result.Message))
outputSink?.Invoke(result.Message);
return result.Message;
}
public static string EnsureLoopbackReady()
{
var network = ShrinkApp.Services.GetRequired<ShrinkNetworkAppService>().Service;
var settings = GetOrCreateSettings();
if (_loopbackClientTransport == null || _loopbackServerTransport == null)
{
_loopbackClientTransport = new ShrinkLoopbackTransport();
_loopbackServerTransport = new ShrinkLoopbackTransport();
_loopbackClientTransport.LinkPeer(_loopbackServerTransport);
}
network.BindTransport(_loopbackClientTransport);
if (_activeLoopbackSessionId != settings.defaultLoopbackSessionId)
{
if (_activeLoopbackSessionId >= 0)
_loopbackClientTransport.CloseSession(_activeLoopbackSessionId);
_activeLoopbackSessionId = settings.defaultLoopbackSessionId;
_loopbackClientTransport.OpenSession(_activeLoopbackSessionId);
}
return $"loopbackBound={network.Sessions.Count}, session={_activeLoopbackSessionId}";
}
public static string AutoBindLoopbackIfEnabled()
{
var settings = GetOrCreateSettings();
if (!settings.autoBindLoopbackTransport)
return string.Empty;
return EnsureLoopbackReady();
}
public static string DisconnectLoopback()
{
if (_loopbackClientTransport == null || _activeLoopbackSessionId < 0)
return "loopbackNotBound";
_loopbackClientTransport.CloseSession(_activeLoopbackSessionId);
_activeLoopbackSessionId = -1;
return "loopbackDisconnected";
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 21ca7aa1ab7f9c64e9b4d050458df3d4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,173 +0,0 @@
#nullable enable
using Cysharp.Threading.Tasks;
using ShrinkDataSaver;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace ShrinkApp.Starter.Basic
{
public sealed class ShrinkAppBasicSampleController : MonoBehaviour
{
[SerializeField] private Text? statusText;
[SerializeField] private Text? detailText;
[SerializeField] private Text? outputText;
[SerializeField] private Text? moduleText;
[SerializeField] private Text? serviceText;
[SerializeField] private Text? saveMetaText;
[SerializeField] private Text? commandListText;
[SerializeField] private Text? networkText;
[SerializeField] private Button? saveButton;
[SerializeField] private Button? loadButton;
[SerializeField] private Button? commandButton;
[SerializeField] private Button? loopbackButton;
[SerializeField] private Button? refreshButton;
[SerializeField] private Button? clearOutputButton;
[SerializeField] private Button? disconnectLoopbackButton;
[SerializeField] private InputField? commandInputField;
private int _counter;
private readonly List<string> _outputLines = new();
private void Awake()
{
if (saveButton != null)
saveButton.onClick.AddListener(() => SaveAsync().Forget());
if (loadButton != null)
loadButton.onClick.AddListener(() => LoadAsync().Forget());
if (commandButton != null)
commandButton.onClick.AddListener(() => RunCommandAsync().Forget());
if (loopbackButton != null)
loopbackButton.onClick.AddListener(BindLoopback);
if (refreshButton != null)
refreshButton.onClick.AddListener(RefreshPanels);
if (clearOutputButton != null)
clearOutputButton.onClick.AddListener(ClearOutput);
if (disconnectLoopbackButton != null)
disconnectLoopbackButton.onClick.AddListener(DisconnectLoopback);
}
private void Start()
{
var settings = ShrinkAppBasicDemoRuntime.GetOrCreateSettings();
if (commandInputField != null)
commandInputField.text = settings.defaultCommandText;
var autoLoopbackMessage = ShrinkAppBasicDemoRuntime.AutoBindLoopbackIfEnabled();
RefreshStatus("ShrinkApp Basic starter ready.");
if (!string.IsNullOrWhiteSpace(autoLoopbackMessage))
AppendOutput(autoLoopbackMessage);
RefreshPanels();
if (settings.autoRunDefaultCommandOnStart)
RunCommandAsync().Forget();
}
private async UniTaskVoid SaveAsync()
{
_counter++;
ShrinkSettings.Set("ShrinkApp.Basic.Counter", _counter);
await ShrinkSave.SaveSlotAsync(0);
RefreshStatus($"Saved counter={_counter}, recentSlot={ShrinkSave.GetRecentSlotIndex()}");
AppendOutput("save: slot 0");
RefreshPanels();
}
private async UniTaskVoid LoadAsync()
{
if (await ShrinkSave.SlotExistsAsync(0))
await ShrinkSave.LoadSlotAsync(0);
_counter = ShrinkSettings.Get("ShrinkApp.Basic.Counter", 0);
RefreshStatus($"Loaded counter={_counter}, loadedSlot={ShrinkSave.LoadedSlot}");
AppendOutput("load: slot 0");
RefreshPanels();
}
private async UniTaskVoid RunCommandAsync()
{
var message = await ShrinkAppBasicDemoRuntime.ExecuteCommandAsync(commandInputField?.text, AppendOutput);
RefreshStatus(message);
RefreshPanels();
}
private void BindLoopback()
{
var message = ShrinkAppBasicDemoRuntime.EnsureLoopbackReady();
AppendOutput(message);
RefreshStatus(message);
RefreshPanels();
}
private void DisconnectLoopback()
{
var message = ShrinkAppBasicDemoRuntime.DisconnectLoopback();
AppendOutput(message);
RefreshStatus(message);
RefreshPanels();
}
private void RefreshPanels()
{
RefreshDetail(ShrinkAppBasicDemoRuntime.BuildDetailedStatus());
RefreshInspectorText().Forget();
}
private void RefreshStatus(string text)
{
if (statusText != null)
statusText.text = text;
Debug.Log("[ShrinkApp.Starter.Basic] " + text);
}
private void RefreshDetail(string text)
{
if (detailText != null)
detailText.text = text;
}
private void AppendOutput(string text)
{
if (string.IsNullOrWhiteSpace(text))
return;
var settings = ShrinkAppBasicDemoRuntime.GetOrCreateSettings();
_outputLines.Add(text.Trim());
while (_outputLines.Count > settings.maxCommandOutputLines)
_outputLines.RemoveAt(0);
if (outputText != null)
outputText.text = string.Join("\n", _outputLines);
}
private void ClearOutput()
{
_outputLines.Clear();
if (outputText != null)
outputText.text = string.Empty;
}
private async UniTaskVoid RefreshInspectorText()
{
if (saveMetaText != null)
{
var metas = await ShrinkSave.GetAllMetaAsync();
saveMetaText.text = ShrinkAppBasicDemoRuntime.BuildSaveMetaSummary(metas);
}
if (moduleText != null)
moduleText.text = ShrinkAppBasicDemoRuntime.BuildModuleSummary();
if (serviceText != null)
serviceText.text = ShrinkAppBasicDemoRuntime.BuildServiceSummary();
if (commandListText != null)
commandListText.text = ShrinkAppBasicDemoRuntime.BuildCommandListSummary();
if (networkText != null)
networkText.text = ShrinkAppBasicDemoRuntime.BuildNetworkSummary();
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 2bc64b7d684d71f469b2335a8c4269ce
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,17 +0,0 @@
#nullable enable
using UnityEngine;
namespace ShrinkApp.Starter.Basic
{
[CreateAssetMenu(fileName = "ShrinkAppBasicStarterSettings", menuName = "ShrinkApp/Starter/Basic Settings")]
public class ShrinkAppBasicStarterSettings : ScriptableObject
{
public bool autoBindLoopbackTransport;
public long defaultLoopbackSessionId = 1;
public string defaultCommandText = "starter status";
public bool logCommandOutputToConsole = true;
public bool autoRunDefaultCommandOnStart;
public int maxCommandOutputLines = 12;
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 2a4a0f6f7946adc45a3437ca04698c65
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,34 +0,0 @@
{
"name": "com.cneicy.shrink-app-starter-basic",
"version": "0.1.0",
"displayName": "ShrinkApp Starter Basic",
"description": "ShrinkApp 最小起盘 Starter,提供向导、入口场景和 DataSaver 示例。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-app-core": "0.1.0",
"com.cneicy.shrink-context-core": "0.1.0",
"com.cneicy.shrink-context-app-adapter": "0.1.0",
"com.cneicy.shrink-command": "0.2.0",
"com.cneicy.shrink-command-integration-app": "0.1.0",
"com.cneicy.shrink-datasaver": "2.2.0",
"com.cneicy.shrink-datasaver-integration-app": "0.1.0",
"com.cneicy.shrink-network": "0.2.0",
"com.cneicy.shrink-network-integration-app": "0.1.0",
"com.cneicy.shrink-command-integration-eventbus": "0.1.1",
"com.cneicy.shrink-datasaver-integration-eventbus": "2.1.0",
"com.cneicy.shrink-network-integration-eventbus": "0.1.1",
"com.cneicy.shrink-command-integration-network": "0.1.0",
"com.unity.ugui": "1.0.0",
"com.cysharp.unitask": "2.5.10"
},
"keywords": [
"starter",
"app",
"bootstrap",
"sample"
],
"author": {
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 715b4b70d23970947be94997f77fc319
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,16 +0,0 @@
# Changelog
本文件记录 `ShrinkCommand.Integration.EventBus` 在当前工作区中的包内变更。
## [0.1.1] - 2026-04-07
### Added
- 提供 `UseEventBusBridge(...)` 扩展,可把命令服务接到 EventBus。
- 提供 `RequestCommandAsync(...)` 扩展,支持通过事件请求式执行命令。
- 建立命令桥接事件:`ShrinkCommandExecuteRequestEvent``ShrinkCommandExecutingEvent``ShrinkCommandExecutedEvent``ShrinkCommandFailedEvent`
### Changed
- 桥接公共类型补齐 `#nullable enable`,统一可空语义。
- 请求结果字段统一使用 `ExecutionResult`,避免与 `EventBase.Result` 命名冲突。
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: ca7f23ed84bf7aa409d36b858591a823
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,37 +0,0 @@
# ShrinkCommand.Integration.EventBus
`ShrinkCommand``ShrinkEventBus` 的桥接层。
## 能力
- 通过事件请求执行命令
- 把命令执行过程发布为 EventBus 生命周期事件
- 保持 `ShrinkCommand` 核心不依赖 `ShrinkEventBus`
## 事件
- `ShrinkCommandExecuteRequestEvent`
- `ShrinkCommandExecutingEvent`
- `ShrinkCommandExecutedEvent`
- `ShrinkCommandFailedEvent`
## 接入
`ShrinkApp.Starter.Basic` 的 ContextLoader 组合根会注册 `ShrinkCommandEventBusComponent`:它注入
`shrink.service.command`,激活时接桥,依赖撤回时自动拆桥。独立使用本包时再显式注册:
```csharp
var service = new ShrinkCommandService();
service.AutoRegisterAll();
service.UseEventBusBridge(new ShrinkCommandEventBusBridgeOptions
{
ServiceName = "default",
DefaultSource = source
});
```
请求执行:
```csharp
var result = await ShrinkCommandEventBusBridge.RequestCommandAsync("help", source);
```
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: f5b03f228e6ccef4e8225c62b96bd4a8
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,19 +0,0 @@
{
"name": "ShrinkCommand.Integration.EventBus",
"rootNamespace": "ShrinkCommand.Integration",
"references": [
"ShrinkCommand.Runtime",
"ShrinkContext.Core.Runtime",
"ShrinkEventBus.Runtime",
"UniTask"
],
"optionalUnityReferences": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 63b0366d1e731824bb0900fbb820d543
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,209 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using ShrinkEventBus;
namespace ShrinkCommand.Integration
{
public static class ShrinkCommandEventBusBridge
{
private sealed class ServiceRegistration
{
public string ServiceName = ShrinkCommandConstants.DefaultServiceName;
public ShrinkCommandService Service = null!;
public IShrinkCommandSource? DefaultSource;
public Action<ShrinkCommandExecutingInfo>? ExecutingHandler;
public Action<ShrinkCommandExecutedInfo>? ExecutedHandler;
}
private static readonly object SyncRoot = new();
private static readonly Dictionary<string, ServiceRegistration> RegisteredByName = new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<ShrinkCommandService, ServiceRegistration> RegisteredByService = new();
private static bool _initialized;
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticState()
{
lock (SyncRoot)
{
foreach (var registration in RegisteredByService.Values)
{
if (registration.ExecutingHandler != null)
registration.Service.OnCommandExecuting -= registration.ExecutingHandler;
if (registration.ExecutedHandler != null)
registration.Service.OnCommandExecuted -= registration.ExecutedHandler;
}
RegisteredByService.Clear();
RegisteredByName.Clear();
_initialized = false;
}
}
public static void RegisterService(
ShrinkCommandService service,
ShrinkCommandEventBusBridgeOptions? options = null)
{
if (service == null)
throw new ArgumentNullException(nameof(service));
EnsureInitialized();
options ??= new ShrinkCommandEventBusBridgeOptions();
lock (SyncRoot)
{
if (RegisteredByService.TryGetValue(service, out var existing))
{
existing.DefaultSource = options.DefaultSource;
if (!string.Equals(existing.ServiceName, options.ServiceName, StringComparison.OrdinalIgnoreCase))
{
RegisteredByName.Remove(existing.ServiceName);
existing.ServiceName = NormalizeServiceName(options.ServiceName);
RegisteredByName[existing.ServiceName] = existing;
}
return;
}
var registration = new ServiceRegistration
{
ServiceName = NormalizeServiceName(options.ServiceName),
Service = service,
DefaultSource = options.DefaultSource
};
registration.ExecutingHandler = info => PublishExecuting(registration, info);
registration.ExecutedHandler = info => PublishExecuted(registration, info);
service.OnCommandExecuting += registration.ExecutingHandler;
service.OnCommandExecuted += registration.ExecutedHandler;
RegisteredByService[service] = registration;
RegisteredByName[registration.ServiceName] = registration;
}
}
public static void UnregisterService(ShrinkCommandService service)
{
if (service == null)
return;
lock (SyncRoot)
{
if (!RegisteredByService.TryGetValue(service, out var registration))
return;
if (registration.ExecutingHandler != null)
service.OnCommandExecuting -= registration.ExecutingHandler;
if (registration.ExecutedHandler != null)
service.OnCommandExecuted -= registration.ExecutedHandler;
RegisteredByService.Remove(service);
RegisteredByName.Remove(registration.ServiceName);
}
}
public static async UniTask<ShrinkCommandExecutionResult> RequestCommandAsync(
string rawInput,
IShrinkCommandSource? source = null,
string? serviceName = null)
{
var requestEvent = new ShrinkCommandExecuteRequestEvent
{
ServiceName = NormalizeServiceName(serviceName),
RawInput = rawInput ?? string.Empty,
Source = source
};
await EventBus.TriggerEventAsync(requestEvent);
return requestEvent.ExecutionResult ?? ShrinkCommandExecutionResult.Failure("没有命令桥处理该请求。");
}
private static void EnsureInitialized()
{
if (_initialized)
return;
_initialized = true;
EventBus.RegisterEvent<ShrinkCommandExecuteRequestEvent>(HandleExecuteRequestAsync, EventPriority.LOWEST);
}
private static async UniTask HandleExecuteRequestAsync(ShrinkCommandExecuteRequestEvent eventArgs)
{
if (eventArgs == null || eventArgs.IsHandled || eventArgs.IsCanceled)
return;
ServiceRegistration? registration;
lock (SyncRoot)
{
RegisteredByName.TryGetValue(NormalizeServiceName(eventArgs.ServiceName), out registration);
}
if (registration == null)
{
eventArgs.IsHandled = true;
eventArgs.ExecutionResult = ShrinkCommandExecutionResult.Failure(
$"未找到命令服务: {NormalizeServiceName(eventArgs.ServiceName)}");
return;
}
var source = eventArgs.Source ?? registration.DefaultSource;
if (source == null)
{
eventArgs.IsHandled = true;
eventArgs.ExecutionResult = ShrinkCommandExecutionResult.Failure("命令请求缺少来源对象。");
return;
}
eventArgs.ExecutionResult = await registration.Service.ExecuteAsync(source, eventArgs.RawInput);
eventArgs.IsHandled = true;
}
private static void PublishExecuting(ServiceRegistration registration, ShrinkCommandExecutingInfo info)
{
EventBus.TriggerEvent(new ShrinkCommandExecutingEvent
{
ServiceName = registration.ServiceName,
RawInput = info.RawInput,
CommandPath = info.Command.Path,
SourceId = info.Source.SourceId,
SourceDisplayName = info.Source.DisplayName,
Arguments = new Dictionary<string, string>(info.Arguments, StringComparer.OrdinalIgnoreCase)
});
}
private static void PublishExecuted(ServiceRegistration registration, ShrinkCommandExecutedInfo info)
{
var commandPath = info.Command?.Path ?? string.Empty;
var arguments = new Dictionary<string, string>(info.Arguments, StringComparer.OrdinalIgnoreCase);
EventBus.TriggerEvent(new ShrinkCommandExecutedEvent
{
ServiceName = registration.ServiceName,
RawInput = info.RawInput,
CommandPath = commandPath,
SourceId = info.Source.SourceId,
SourceDisplayName = info.Source.DisplayName,
Arguments = arguments,
IsSuccess = info.Result.IsSuccess,
Message = info.Result.Message
});
if (info.Result.IsSuccess)
return;
EventBus.TriggerEvent(new ShrinkCommandFailedEvent
{
ServiceName = registration.ServiceName,
RawInput = info.RawInput,
CommandPath = commandPath,
SourceId = info.Source.SourceId,
SourceDisplayName = info.Source.DisplayName,
Arguments = arguments,
ErrorMessage = info.Result.Message
});
}
private static string NormalizeServiceName(string? serviceName)
{
return string.IsNullOrWhiteSpace(serviceName) ? ShrinkCommandConstants.DefaultServiceName : serviceName.Trim();
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: de2d897c6d6fa1f419101a2c04ad57e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,49 +0,0 @@
#nullable enable
using System.Collections.Generic;
using ShrinkEventBus;
namespace ShrinkCommand.Integration
{
[Cancelable]
public sealed class ShrinkCommandExecuteRequestEvent : EventBase
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string RawInput { get; set; } = string.Empty;
public IShrinkCommandSource? Source { get; set; }
public bool IsHandled { get; set; }
public ShrinkCommandExecutionResult? ExecutionResult { get; set; }
}
public sealed class ShrinkCommandExecutingEvent : EventBase
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string RawInput { get; set; } = string.Empty;
public string CommandPath { get; set; } = string.Empty;
public string SourceId { get; set; } = string.Empty;
public string SourceDisplayName { get; set; } = string.Empty;
public Dictionary<string, string> Arguments { get; set; } = new();
}
public sealed class ShrinkCommandExecutedEvent : EventBase
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string RawInput { get; set; } = string.Empty;
public string CommandPath { get; set; } = string.Empty;
public string SourceId { get; set; } = string.Empty;
public string SourceDisplayName { get; set; } = string.Empty;
public Dictionary<string, string> Arguments { get; set; } = new();
public bool IsSuccess { get; set; }
public string Message { get; set; } = string.Empty;
}
public sealed class ShrinkCommandFailedEvent : EventBase
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string RawInput { get; set; } = string.Empty;
public string CommandPath { get; set; } = string.Empty;
public string SourceId { get; set; } = string.Empty;
public string SourceDisplayName { get; set; } = string.Empty;
public Dictionary<string, string> Arguments { get; set; } = new();
public string ErrorMessage { get; set; } = string.Empty;
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 7f8b986a535559d41a2ab05a20708b28
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,29 +0,0 @@
#nullable enable
using System;
using Cysharp.Threading.Tasks;
namespace ShrinkCommand.Integration
{
public static class ShrinkCommandEventBusBridgeExtensions
{
public static ShrinkCommandService UseEventBusBridge(
this ShrinkCommandService service,
ShrinkCommandEventBusBridgeOptions? options = null)
{
if (service == null)
throw new ArgumentNullException(nameof(service));
ShrinkCommandEventBusBridge.RegisterService(service, options);
return service;
}
public static UniTask<ShrinkCommandExecutionResult> RequestCommandAsync(
this ShrinkCommandService service,
string rawInput,
IShrinkCommandSource? source = null,
string? serviceName = null)
{
return ShrinkCommandEventBusBridge.RequestCommandAsync(rawInput, source, serviceName);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: b0ed31dba396b6d4ab075e75a16b00d5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,10 +0,0 @@
#nullable enable
namespace ShrinkCommand.Integration
{
public sealed class ShrinkCommandEventBusBridgeOptions
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public IShrinkCommandSource? DefaultSource { get; set; }
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: c6de8286e0b74a54398432828ae503fb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,36 +0,0 @@
#nullable enable
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using ShrinkContext;
namespace ShrinkCommand.Integration
{
/// <summary>通过命令服务键管理 Command ↔ EventBus 桥的可逆生命周期。</summary>
public sealed class ShrinkCommandEventBusComponent : IShrinkComponent
{
public const string CommandServiceKey = "shrink.service.command";
public const string ProvideKey = "shrink.integration.command-eventbus";
private static readonly string[] InjectKeys = { CommandServiceKey };
private static readonly string[] ProvideKeys = { ProvideKey };
public string Name => "shrink.integration.command-eventbus";
public IReadOnlyList<string> Inject => InjectKeys;
public IReadOnlyList<string> Provide => ProvideKeys;
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
{
var service = ctx.Get<ShrinkCommandService>(CommandServiceKey);
var options = config as ShrinkCommandEventBusBridgeOptions;
ShrinkCommandEventBusBridge.RegisterService(service, options);
ctx.EffectInverse(() =>
{
ShrinkCommandEventBusBridge.UnregisterService(service);
return UniTask.CompletedTask;
});
ctx.Set(ProvideKey, Name);
return UniTask.CompletedTask;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 078a765d611231c478ac77312b44c444
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,17 +0,0 @@
{
"name": "com.cneicy.shrink-command-integration-eventbus",
"version": "0.1.1",
"displayName": "ShrinkCommand - EventBus Integration",
"description": "ShrinkCommand 与 ShrinkEventBus 的桥接层,支持事件请求执行命令,以及命令执行生命周期事件发布。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-command": "0.1.0",
"com.cneicy.shrink-context-core": "0.1.0",
"com.cneicy.shrink-eventbus": "1.1.5"
},
"keywords": ["command", "eventbus", "integration", "bridge"],
"author": {
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 63114b97f96b400469a77b9701d8ffbc
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,16 +0,0 @@
# Changelog
本文件记录 `ShrinkCommand.Integration.Network` 在当前工作区中的包内变更。
## [0.1.0] - 2026-04-07
### Added
- 增加 `command/execute` RPC 桥接。
- 远程会话可自动映射为 `IShrinkCommandSource`,统一走命令运行时。
- 命令执行结果可直接通过 `ShrinkNetwork` 返回给远端调用方。
### Changed
- 桥接层公共类型补齐 `#nullable enable`,对齐当前运行时与宿主侧的可空语义。
- 保持桥接层职责边界,命令核心仍不直接依赖网络实现。
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: cc0d137c46b092f49a54bc3afcd7c161
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,24 +0,0 @@
# ShrinkCommand.Integration.Network
`ShrinkCommand``ShrinkNetwork` 的桥接层。
## 能力
- 通过网络 RPC 执行命令
- 远程命令来源自动映射为 `IShrinkCommandSource`
- 继续复用命令系统自己的权限与来源限制
## 接入
```csharp
commandService = new ShrinkCommandService();
commandService.AutoRegisterAll();
networkService.UseCommandBridge(commandService);
```
客户端调用:
```csharp
var response = await session.ExecuteCommandAsync("help");
```
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: c85d4974b8207cf45848e1379ad7baa3
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,19 +0,0 @@
{
"name": "ShrinkCommand.Integration.Network",
"rootNamespace": "ShrinkCommand.Integration",
"references": [
"ShrinkCommand.Runtime",
"ShrinkNetwork.Runtime",
"ShrinkContext.Core.Runtime",
"UniTask"
],
"optionalUnityReferences": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: d9c305f415a0515409b2795a809e7240
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,49 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using ShrinkCommand;
using ShrinkContext;
using ShrinkNetwork;
namespace ShrinkCommand.Integration
{
/// <summary>
/// 命令-网络集成组件(论文 6.5 集成组件模式):
/// 核心(command / network)互不依赖,本组件注入双方服务键,全部就绪才激活,
/// 任一提供者退役即自动停用——依赖接线由响应式余效应结构性完成,替代手工桥接编排。
///
/// 已知边界:ShrinkNetworkService 当前无 handler 注销 API,停用不撤销已注册的
/// command/execute 处理器(传输解绑后不可达;网络服务实例重建时自然消失)。
/// </summary>
public sealed class ShrinkCommandNetworkIntegrationComponent : IShrinkComponent
{
/// <summary>与 ShrinkNetworkAppComponent.ServiceKey 保持一致(避免跨包硬引用)。</summary>
public const string NetworkServiceKey = "shrink.service.network";
public const string CommandServiceKey = "shrink.service.command";
public const string ProvideKey = "shrink.integration.command-network";
private static readonly string[] InjectKeys = { CommandServiceKey, NetworkServiceKey };
private static readonly string[] ProvideKeys = { ProvideKey };
public string Name => "shrink.integration.command-network";
public IReadOnlyList<string> Inject => InjectKeys;
public IReadOnlyList<string> Provide => ProvideKeys;
public UniTask ApplyAsync(ShrinkCtx ctx, object? config)
{
var commandService = ctx.Get<ShrinkCommandService>(CommandServiceKey);
var networkService = ctx.Get<ShrinkNetworkService>(NetworkServiceKey);
var options = config as ShrinkNetworkCommandBridgeOptions;
ShrinkNetworkCommandBridge.RegisterService(networkService, commandService, options);
ctx.Set(ProvideKey, ShrinkNetworkCommandBridge.ExecuteRoute);
// 桥注册按网络服务实例幂等去重;可逆面为集成键的发布/撤回
return UniTask.CompletedTask;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: c5710d88a762cde489282366255f755c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,86 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using ShrinkNetwork;
namespace ShrinkCommand.Integration
{
public static class ShrinkNetworkCommandBridge
{
public const string ExecuteRoute = "command/execute";
private static readonly object SyncRoot = new();
private static readonly HashSet<ShrinkNetworkService> RegisteredServices = new();
public static void RegisterService(
ShrinkNetworkService networkService,
ShrinkCommandService commandService,
ShrinkNetworkCommandBridgeOptions? options = null)
{
if (networkService == null)
throw new ArgumentNullException(nameof(networkService));
if (commandService == null)
throw new ArgumentNullException(nameof(commandService));
lock (SyncRoot)
{
if (RegisteredServices.Contains(networkService))
return;
RegisteredServices.Add(networkService);
}
options ??= new ShrinkNetworkCommandBridgeOptions();
EnsureMessagesRegistered(networkService);
var requirement = new ShrinkNetworkPermissionRequirement(options.Authority, options.Permission);
networkService.RegisterRequestHandler<ShrinkNetworkCommandRequest, ShrinkNetworkCommandResponse>(
(context, request) => HandleExecuteAsync(commandService, options, context, request),
requirement);
}
public static void EnsureMessagesRegistered(ShrinkNetworkService service)
{
if (!service.MessageRegistry.TryGetMeta(typeof(ShrinkNetworkCommandRequest), out _))
{
var requestAttribute = (ShrinkNetworkMessageAttribute)Attribute.GetCustomAttribute(
typeof(ShrinkNetworkCommandRequest),
typeof(ShrinkNetworkMessageAttribute),
false)!;
service.RegisterMessage(typeof(ShrinkNetworkCommandRequest), requestAttribute.Opcode, requestAttribute.Route);
}
if (!service.MessageRegistry.TryGetMeta(typeof(ShrinkNetworkCommandResponse), out _))
{
var responseAttribute = (ShrinkNetworkMessageAttribute)Attribute.GetCustomAttribute(
typeof(ShrinkNetworkCommandResponse),
typeof(ShrinkNetworkMessageAttribute),
false)!;
service.RegisterMessage(typeof(ShrinkNetworkCommandResponse), responseAttribute.Opcode, responseAttribute.Route);
}
}
private static async UniTask<ShrinkNetworkCommandResponse> HandleExecuteAsync(
ShrinkCommandService commandService,
ShrinkNetworkCommandBridgeOptions options,
ShrinkNetworkContext context,
ShrinkNetworkCommandRequest request)
{
var source = options.SourceFactory?.Invoke(context, request) ?? new ShrinkNetworkCommandSource(context, request);
var result = await commandService.ExecuteAsync(source, request.CommandLine);
var output = source is ShrinkNetworkCommandSource networkSource
? networkSource.BuildOutputMessage(result.Message)
: result.Message;
return new ShrinkNetworkCommandResponse
{
ServiceName = string.IsNullOrWhiteSpace(request.ServiceName) ? options.ServiceName : request.ServiceName.Trim(),
CommandLine = request.CommandLine ?? string.Empty,
OutputMessage = output,
ErrorCode = result.IsSuccess ? 0 : ShrinkRpcErrorCode.HandlerException,
ErrorMessage = result.IsSuccess ? string.Empty : output
};
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: f22899cbeb3779d4eb1338217fa4d38f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,52 +0,0 @@
#nullable enable
using System;
using Cysharp.Threading.Tasks;
using ShrinkNetwork;
namespace ShrinkCommand.Integration
{
public static class ShrinkNetworkCommandBridgeExtensions
{
public static ShrinkNetworkService UseCommandBridge(
this ShrinkNetworkService networkService,
ShrinkCommandService commandService,
ShrinkNetworkCommandBridgeOptions? options = null)
{
if (networkService == null)
throw new ArgumentNullException(nameof(networkService));
if (commandService == null)
throw new ArgumentNullException(nameof(commandService));
ShrinkNetworkCommandBridge.RegisterService(networkService, commandService, options);
return networkService;
}
public static async UniTask<ShrinkNetworkCommandResponse> ExecuteCommandAsync(
this ShrinkNetworkSession session,
string commandLine,
string? serviceName = null,
string? clientLabel = null,
ShrinkRpcCallOptions? options = null)
{
if (session == null)
throw new ArgumentNullException(nameof(session));
ShrinkNetworkCommandBridge.EnsureMessagesRegistered(session.Service);
return await session.RpcAsync<ShrinkNetworkCommandRequest, ShrinkNetworkCommandResponse>(
new ShrinkNetworkCommandRequest
{
ServiceName = string.IsNullOrWhiteSpace(serviceName)
? ShrinkCommandConstants.DefaultServiceName
: serviceName.Trim(),
CommandLine = commandLine ?? string.Empty,
ClientLabel = clientLabel ?? string.Empty
},
options ?? new ShrinkRpcCallOptions
{
TimeoutMs = 5000,
DebugLabel = "RemoteCommand"
});
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 11bf32dbf7be0e14cbddf1f03ded5cbb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,15 +0,0 @@
#nullable enable
using System;
using ShrinkNetwork;
namespace ShrinkCommand.Integration
{
public sealed class ShrinkNetworkCommandBridgeOptions
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public ShrinkNetworkAuthority Authority { get; set; } = ShrinkNetworkAuthority.ClientOnly;
public string Permission { get; set; } = string.Empty;
public Func<ShrinkNetworkContext, ShrinkNetworkCommandRequest, IShrinkCommandSource>? SourceFactory { get; set; }
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 174745a3cab12534c8d4b09788063fe9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
using ShrinkNetwork;
namespace ShrinkCommand.Integration
{
[ShrinkNetworkMessage(1501, ShrinkNetworkCommandBridge.ExecuteRoute)]
public sealed class ShrinkNetworkCommandRequest : IShrinkNetworkRequest
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string CommandLine { get; set; } = string.Empty;
public string ClientLabel { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(1502, "command/execute_response")]
public sealed class ShrinkNetworkCommandResponse : ShrinkRpcResponseBase
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string CommandLine { get; set; } = string.Empty;
public string OutputMessage { get; set; } = string.Empty;
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 8b44977ad838cce438d99ea565848485
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,79 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using Cysharp.Threading.Tasks;
using ShrinkNetwork;
namespace ShrinkCommand.Integration
{
public sealed class ShrinkNetworkCommandSource : IShrinkCommandSource
{
private readonly List<string> _lines = new();
private readonly ShrinkNetworkContext _context;
private readonly ShrinkNetworkCommandRequest _request;
public ShrinkNetworkCommandSource(ShrinkNetworkContext context, ShrinkNetworkCommandRequest request)
{
_context = context;
_request = request;
SourceId = $"network:{context.Session.SessionId}";
DisplayName = ResolveDisplayName(context, request);
}
public string SourceId { get; }
public string DisplayName { get; }
public bool IsConsole => false;
public ShrinkNetworkSession Session => _context.Session;
public bool HasPermission(string permission)
{
return string.IsNullOrWhiteSpace(permission) || Session.HasPermission(permission);
}
public UniTask WriteLineAsync(string message, CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(message))
_lines.Add(message);
return UniTask.CompletedTask;
}
public string BuildOutputMessage(string resultMessage)
{
if (_lines.Count == 0)
return resultMessage ?? string.Empty;
var builder = new StringBuilder();
for (var index = 0; index < _lines.Count; index++)
{
if (index > 0)
builder.AppendLine();
builder.Append(_lines[index]);
}
if (!string.IsNullOrWhiteSpace(resultMessage))
{
if (builder.Length > 0 && !string.Equals(_lines[_lines.Count - 1], resultMessage, StringComparison.Ordinal))
builder.AppendLine();
if (!string.Equals(_lines[_lines.Count - 1], resultMessage, StringComparison.Ordinal))
builder.Append(resultMessage);
}
return builder.ToString();
}
private static string ResolveDisplayName(ShrinkNetworkContext context, ShrinkNetworkCommandRequest request)
{
if (context.Session.Items.TryGetValue("auth.name", out var authName) && authName is string authNameText &&
!string.IsNullOrWhiteSpace(authNameText))
{
return authNameText.Trim();
}
if (!string.IsNullOrWhiteSpace(request.ClientLabel))
return request.ClientLabel.Trim();
return context.Session.RemoteAddress;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: bf31a6f53f1bb0c4ca0af60b92dce81f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More