572 lines
22 KiB
C#
572 lines
22 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Reflection;
|
||
using System.Text;
|
||
using TH1_Logic.Editor.HybridCLR;
|
||
using TH1_Logic.Editor.YooAssetTools;
|
||
using TH1_Logic.Hotfix;
|
||
using UnityEditor;
|
||
using UnityEditor.Build;
|
||
using UnityEditor.Build.Reporting;
|
||
using UnityEngine;
|
||
|
||
namespace TH1_Logic.Editor
|
||
{
|
||
public sealed class TH1MigrationBuildPanel : EditorWindow
|
||
{
|
||
private Vector2 _scroll;
|
||
private bool _developmentBuild;
|
||
private bool _runGenerateAll = true;
|
||
private bool _showAdvancedActions;
|
||
private List<MigrationStatusItem> _statusItems = new List<MigrationStatusItem>();
|
||
private string _lastAction = "Ready";
|
||
|
||
[MenuItem("Tools/TH1/iOS Migration/Build Panel")]
|
||
public static void Open()
|
||
{
|
||
var window = GetWindow<TH1MigrationBuildPanel>("TH1 Build Panel");
|
||
window.minSize = new Vector2(620, 520);
|
||
window.RefreshStatus();
|
||
window.Show();
|
||
}
|
||
|
||
private void OnEnable()
|
||
{
|
||
_developmentBuild = EditorUserBuildSettings.development;
|
||
RefreshStatus();
|
||
}
|
||
|
||
private void OnGUI()
|
||
{
|
||
DrawHeader();
|
||
DrawGuide();
|
||
|
||
using (new EditorGUILayout.HorizontalScope())
|
||
{
|
||
if (GUILayout.Button("刷新状态", GUILayout.Height(26)))
|
||
{
|
||
RefreshStatus();
|
||
}
|
||
|
||
if (GUILayout.Button("打开 StreamingAssets", GUILayout.Height(26)))
|
||
{
|
||
EditorUtility.RevealInFinder(Application.streamingAssetsPath);
|
||
}
|
||
}
|
||
|
||
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||
DrawStatus();
|
||
EditorGUILayout.EndScrollView();
|
||
|
||
EditorGUILayout.Space(8);
|
||
DrawActions();
|
||
}
|
||
|
||
private void DrawHeader()
|
||
{
|
||
EditorGUILayout.LabelField("TH1 iOS / HybridCLR / YooAsset 出包面板", EditorStyles.boldLabel);
|
||
EditorGUILayout.LabelField("当前平台", EditorUserBuildSettings.activeBuildTarget.ToString());
|
||
EditorGUILayout.LabelField("脚本后端", GetScriptingBackend(EditorUserBuildSettings.activeBuildTarget).ToString());
|
||
EditorGUILayout.LabelField("上次操作", _lastAction);
|
||
|
||
_developmentBuild = EditorGUILayout.ToggleLeft("热更 DLL 使用 Development 编译(PC Smoke Test 建议开启;正式包可关闭)", _developmentBuild);
|
||
_runGenerateAll = EditorGUILayout.ToggleLeft("一键准备前先执行 HybridCLR Generate All(迁移阶段建议保持开启)", _runGenerateAll);
|
||
|
||
if (EditorApplication.isCompiling)
|
||
{
|
||
EditorGUILayout.HelpBox("Unity 正在编译脚本,等编译完成后再执行构建动作。", MessageType.Warning);
|
||
}
|
||
}
|
||
|
||
private void DrawGuide()
|
||
{
|
||
var errorCount = CountStatus(MigrationStatusLevel.Error);
|
||
var warningCount = CountStatus(MigrationStatusLevel.Warning);
|
||
|
||
if (errorCount > 0)
|
||
{
|
||
EditorGUILayout.HelpBox(
|
||
$"现在不能直接出包:还有 {errorCount} 个阻断项。你现在只需要点下面的大按钮“新手只点这个:准备当前平台出包”。",
|
||
MessageType.Error);
|
||
}
|
||
else if (warningCount > 0)
|
||
{
|
||
EditorGUILayout.HelpBox(
|
||
$"基础产物已经可用,但还有 {warningCount} 个提示项。建议点一次大按钮刷新所有产物后再出包。",
|
||
MessageType.Warning);
|
||
}
|
||
else
|
||
{
|
||
EditorGUILayout.HelpBox(
|
||
"当前平台出包准备完成:热更 DLL、AOT Metadata、YooAsset AB 都可用。现在可以 Build Player。",
|
||
MessageType.Info);
|
||
}
|
||
|
||
EditorGUILayout.HelpBox(
|
||
"日常规则:改了代码或资源后,打 PC/iOS 包前都点一次大按钮。编辑器内 Play 测试一般不用点。",
|
||
MessageType.None);
|
||
}
|
||
|
||
private void DrawStatus()
|
||
{
|
||
foreach (var item in _statusItems)
|
||
{
|
||
EditorGUILayout.LabelField(item.Title, EditorStyles.boldLabel);
|
||
EditorGUILayout.HelpBox(item.Message, ToMessageType(item.Level));
|
||
}
|
||
}
|
||
|
||
private void DrawActions()
|
||
{
|
||
using (new EditorGUI.DisabledScope(EditorApplication.isCompiling || EditorApplication.isPlayingOrWillChangePlaymode))
|
||
{
|
||
if (GUILayout.Button("新手只点这个:准备当前平台出包(热更 DLL + AOT + AB)", GUILayout.Height(44)))
|
||
{
|
||
RunAction("Prepare Player Build", PreparePlayerBuild);
|
||
}
|
||
|
||
EditorGUILayout.Space(8);
|
||
_showAdvancedActions = EditorGUILayout.Foldout(_showAdvancedActions, "高级/单项操作(平时不用)", true);
|
||
if (!_showAdvancedActions) return;
|
||
|
||
EditorGUILayout.LabelField("HybridCLR", EditorStyles.boldLabel);
|
||
using (new EditorGUILayout.HorizontalScope())
|
||
{
|
||
if (GUILayout.Button("配置 HybridCLR", GUILayout.Height(28)))
|
||
{
|
||
RunAction("Configure HybridCLR", TH1HybridCLRBuildTools.ConfigureHotfixSettings);
|
||
}
|
||
|
||
if (GUILayout.Button("Generate All", GUILayout.Height(28)))
|
||
{
|
||
RunAction("HybridCLR Generate All", TH1HybridCLRBuildTools.GenerateAll);
|
||
}
|
||
}
|
||
|
||
if (GUILayout.Button("构建热更 DLL 并写入 StreamingAssets", GUILayout.Height(32)))
|
||
{
|
||
RunAction("Build Hotfix DLL", () =>
|
||
{
|
||
if (!TH1HybridCLRBuildTools.BuildAndCopyHotfixArtifacts(_developmentBuild))
|
||
{
|
||
throw new Exception("Build hotfix dll failed. See Console for details.");
|
||
}
|
||
});
|
||
}
|
||
|
||
if (GUILayout.Button("测试 Hotfix 混淆", GUILayout.Height(32)))
|
||
{
|
||
RunAction("Test Hotfix Obfuscation", () =>
|
||
{
|
||
if (!TH1HybridCLRBuildTools.BuildAndCopyHotfixArtifacts(_developmentBuild, true))
|
||
{
|
||
throw new Exception("Test hotfix obfuscation failed. See Console for details.");
|
||
}
|
||
});
|
||
}
|
||
|
||
EditorGUILayout.Space(6);
|
||
EditorGUILayout.LabelField("YooAsset", EditorStyles.boldLabel);
|
||
using (new EditorGUILayout.HorizontalScope())
|
||
{
|
||
if (GUILayout.Button("配置 Collector", GUILayout.Height(28)))
|
||
{
|
||
RunAction("Configure YooAsset Collector", TH1YooAssetBuildTools.ConfigureDefaultPackageCollector);
|
||
}
|
||
|
||
if (GUILayout.Button("验证 Collector 地址", GUILayout.Height(28)))
|
||
{
|
||
RunAction("Validate YooAsset Collector", TH1YooAssetBuildTools.ValidateCollectorAddresses);
|
||
}
|
||
}
|
||
|
||
if (GUILayout.Button("构建 YooAsset 内置 AB", GUILayout.Height(32)))
|
||
{
|
||
RunAction("Build YooAsset AB", TH1YooAssetBuildTools.BuildBuiltinDefaultPackage);
|
||
}
|
||
|
||
EditorGUILayout.Space(8);
|
||
EditorGUILayout.HelpBox("上面的单项按钮用于排查问题。正常出包只点顶部的大按钮。", MessageType.None);
|
||
}
|
||
}
|
||
|
||
private void PreparePlayerBuild()
|
||
{
|
||
TH1HybridCLRBuildTools.ConfigureHotfixSettings();
|
||
TH1YooAssetBuildTools.ConfigureDefaultPackageCollector();
|
||
|
||
if (_runGenerateAll)
|
||
{
|
||
TH1HybridCLRBuildTools.GenerateAll();
|
||
}
|
||
|
||
if (!TH1HybridCLRBuildTools.BuildAndCopyHotfixArtifacts(_developmentBuild))
|
||
{
|
||
throw new Exception("Build hotfix dll failed. See Console for details.");
|
||
}
|
||
|
||
TH1YooAssetBuildTools.BuildBuiltinDefaultPackage();
|
||
}
|
||
|
||
private void RunAction(string title, System.Action action)
|
||
{
|
||
try
|
||
{
|
||
_lastAction = $"{title} running...";
|
||
Repaint();
|
||
|
||
AssetDatabase.SaveAssets();
|
||
action();
|
||
AssetDatabase.Refresh();
|
||
|
||
_lastAction = $"{title} OK";
|
||
Debug.Log($"[TH1.Migration] {title} OK");
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
var root = e is TargetInvocationException && e.InnerException != null ? e.InnerException : e;
|
||
_lastAction = $"{title} failed: {root.Message}";
|
||
Debug.LogError($"[TH1.Migration] {title} failed:\n{root}");
|
||
}
|
||
finally
|
||
{
|
||
RefreshStatus();
|
||
Repaint();
|
||
}
|
||
}
|
||
|
||
private void RefreshStatus()
|
||
{
|
||
_statusItems = TH1MigrationBuildStatus.Collect(EditorUserBuildSettings.activeBuildTarget);
|
||
}
|
||
|
||
private int CountStatus(MigrationStatusLevel level)
|
||
{
|
||
var count = 0;
|
||
foreach (var item in _statusItems)
|
||
{
|
||
if (item.Level == level) count++;
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
private static MessageType ToMessageType(MigrationStatusLevel level)
|
||
{
|
||
return level switch
|
||
{
|
||
MigrationStatusLevel.Ok => MessageType.Info,
|
||
MigrationStatusLevel.Warning => MessageType.Warning,
|
||
MigrationStatusLevel.Error => MessageType.Error,
|
||
_ => MessageType.None
|
||
};
|
||
}
|
||
|
||
private static ScriptingImplementation GetScriptingBackend(BuildTarget target)
|
||
{
|
||
return PlayerSettings.GetScriptingBackend(BuildPipeline.GetBuildTargetGroup(target));
|
||
}
|
||
}
|
||
|
||
public sealed class TH1MigrationBuildPreprocessor : IPreprocessBuildWithReport
|
||
{
|
||
public int callbackOrder => -1000;
|
||
|
||
public void OnPreprocessBuild(BuildReport report)
|
||
{
|
||
if (TH1MigrationBuildValidationGate.IsSuppressed) return;
|
||
|
||
var errors = TH1MigrationBuildStatus.GetBuildBlockingMessages(report.summary.platform);
|
||
if (errors.Count == 0) return;
|
||
|
||
throw new BuildFailedException(
|
||
"[TH1.Migration] 出包前准备未完成,请打开 Tools/TH1/iOS Migration/Build Panel 执行一键准备出包。\n" +
|
||
string.Join("\n", errors));
|
||
}
|
||
}
|
||
|
||
internal static class TH1MigrationBuildValidationGate
|
||
{
|
||
private static int _suppressCount;
|
||
|
||
public static bool IsSuppressed => _suppressCount > 0;
|
||
|
||
public static IDisposable Suppress()
|
||
{
|
||
_suppressCount++;
|
||
return new SuppressScope();
|
||
}
|
||
|
||
private sealed class SuppressScope : IDisposable
|
||
{
|
||
private bool _disposed;
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
_suppressCount = Math.Max(0, _suppressCount - 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
internal static class TH1MigrationBuildStatus
|
||
{
|
||
public static List<MigrationStatusItem> Collect(BuildTarget target)
|
||
{
|
||
return Collect(target, TH1HybridCLRBuildTools.IsHotfixObfuscationEnabled(), true);
|
||
}
|
||
|
||
public static List<MigrationStatusItem> Collect(BuildTarget target, bool expectObfuscatedHotfix)
|
||
{
|
||
return Collect(target, expectObfuscatedHotfix, true);
|
||
}
|
||
|
||
public static List<MigrationStatusItem> Collect(BuildTarget target, bool expectObfuscatedHotfix, bool checkYooAssetAb)
|
||
{
|
||
var items = new List<MigrationStatusItem>();
|
||
AddHotfixStatus(items, target, expectObfuscatedHotfix);
|
||
AddAotMetadataStatus(items, target);
|
||
if (checkYooAssetAb)
|
||
{
|
||
AddYooAssetStatus(items);
|
||
}
|
||
else
|
||
{
|
||
items.Add(new MigrationStatusItem(
|
||
"4. YooAsset 内置 AB",
|
||
MigrationStatusLevel.Warning,
|
||
"已按一体化出包选项跳过检查,将沿用 StreamingAssets 中已有的 YooAsset AB。"));
|
||
}
|
||
return items;
|
||
}
|
||
|
||
public static List<string> GetBuildBlockingMessages(BuildTarget target)
|
||
{
|
||
return GetBuildBlockingMessages(target, TH1HybridCLRBuildTools.IsHotfixObfuscationEnabled(), true);
|
||
}
|
||
|
||
public static List<string> GetBuildBlockingMessages(BuildTarget target, bool expectObfuscatedHotfix)
|
||
{
|
||
return GetBuildBlockingMessages(target, expectObfuscatedHotfix, true);
|
||
}
|
||
|
||
public static List<string> GetBuildBlockingMessages(BuildTarget target, bool expectObfuscatedHotfix, bool checkYooAssetAb)
|
||
{
|
||
var errors = new List<string>();
|
||
foreach (var item in Collect(target, expectObfuscatedHotfix, checkYooAssetAb))
|
||
{
|
||
if (item.Level == MigrationStatusLevel.Error)
|
||
{
|
||
errors.Add($"- {item.Title}: {item.Message}");
|
||
}
|
||
}
|
||
|
||
return errors;
|
||
}
|
||
|
||
private static void AddHotfixStatus(List<MigrationStatusItem> items, BuildTarget target, bool expectObfuscatedHotfix)
|
||
{
|
||
var source = TH1HybridCLRBuildTools.GetHotfixDllSourcePath(target);
|
||
var obfuscated = TH1HybridCLRBuildTools.GetObfuscatedHotfixDllPath(target);
|
||
var streaming = TH1HybridCLRBuildTools.GetStreamingHotfixDllPath();
|
||
|
||
var sourceOk = TH1HybridCLRBuildTools.IsUsableHotfixDll(source, out var sourceMessage);
|
||
items.Add(new MigrationStatusItem(
|
||
"1. 代码热更 DLL 输出",
|
||
sourceOk ? MigrationStatusLevel.Ok : MigrationStatusLevel.Error,
|
||
$"{sourceMessage}\n这是 HybridCLR 针对当前平台编出来的 TH1.Hotfix.dll。"));
|
||
|
||
var expected = source;
|
||
var expectedOk = sourceOk;
|
||
var expectedLabel = "未混淆输出";
|
||
if (expectObfuscatedHotfix)
|
||
{
|
||
var obfuscatedOk = TH1HybridCLRBuildTools.IsUsableHotfixDll(obfuscated, out var obfuscatedMessage);
|
||
items.Add(new MigrationStatusItem(
|
||
"1.5 OPS 混淆后 DLL 输出",
|
||
obfuscatedOk ? MigrationStatusLevel.Ok : MigrationStatusLevel.Error,
|
||
$"{obfuscatedMessage}\n这是打包会优先拷贝的 TH1.Hotfix.dll。"));
|
||
|
||
expected = obfuscated;
|
||
expectedOk = obfuscatedOk;
|
||
expectedLabel = "混淆输出";
|
||
}
|
||
|
||
var streamingOk = TH1HybridCLRBuildTools.IsUsableHotfixDll(streaming, out var streamingMessage);
|
||
var streamingLevel = streamingOk ? MigrationStatusLevel.Ok : MigrationStatusLevel.Error;
|
||
var streamingDetails = $"{streamingMessage}\n{streaming}";
|
||
|
||
if (streamingOk && FileContainsAscii(streaming, "UnityEditor"))
|
||
{
|
||
streamingLevel = MigrationStatusLevel.Error;
|
||
streamingDetails += "\n该 DLL 疑似引用 UnityEditor,不能进 PC/iOS 包。请用面板重新构建目标平台热更 DLL。";
|
||
}
|
||
else if (streamingOk && expectedOk)
|
||
{
|
||
var sourceInfo = new FileInfo(expected);
|
||
var streamingInfo = new FileInfo(streaming);
|
||
if (streamingInfo.LastWriteTimeUtc.AddSeconds(1) < sourceInfo.LastWriteTimeUtc ||
|
||
streamingInfo.Length != sourceInfo.Length)
|
||
{
|
||
streamingLevel = MigrationStatusLevel.Error;
|
||
streamingDetails += $"\n它和{expectedLabel}不一致,请重新执行大按钮。";
|
||
}
|
||
}
|
||
|
||
items.Add(new MigrationStatusItem("2. 打包会携带的热更 DLL", streamingLevel, streamingDetails));
|
||
}
|
||
|
||
private static void AddAotMetadataStatus(List<MigrationStatusItem> items, BuildTarget target)
|
||
{
|
||
var streamingDir = TH1HybridCLRBuildTools.GetStreamingAotMetadataDir();
|
||
var missing = new List<string>();
|
||
foreach (var fileName in HotfixManifest.AotMetadataAssemblyFileNames)
|
||
{
|
||
var path = Path.Combine(streamingDir, fileName);
|
||
if (!File.Exists(path) || new FileInfo(path).Length == 0)
|
||
{
|
||
missing.Add(fileName);
|
||
}
|
||
}
|
||
|
||
var backend = PlayerSettings.GetScriptingBackend(BuildPipeline.GetBuildTargetGroup(target));
|
||
if (missing.Count > 0)
|
||
{
|
||
var level = backend == ScriptingImplementation.IL2CPP ? MigrationStatusLevel.Error : MigrationStatusLevel.Warning;
|
||
items.Add(new MigrationStatusItem(
|
||
"3. AOT Metadata",
|
||
level,
|
||
$"缺少: {string.Join(", ", missing)}\n{streamingDir}"));
|
||
return;
|
||
}
|
||
|
||
items.Add(new MigrationStatusItem("3. AOT Metadata", MigrationStatusLevel.Ok, $"OK\n{streamingDir}"));
|
||
}
|
||
|
||
private static void AddYooAssetStatus(List<MigrationStatusItem> items)
|
||
{
|
||
var packageRoot = TH1YooAssetBuildTools.GetStreamingPackageRoot();
|
||
var manifestVersion = TH1YooAssetBuildTools.GetStreamingManifestVersionPath();
|
||
if (!File.Exists(manifestVersion))
|
||
{
|
||
items.Add(new MigrationStatusItem(
|
||
"4. YooAsset 内置 AB",
|
||
MigrationStatusLevel.Error,
|
||
$"缺少 DefaultPackage manifest,请构建 YooAsset 内置 AB。\n{manifestVersion}"));
|
||
return;
|
||
}
|
||
|
||
var packageFileCount = CountFiles(packageRoot);
|
||
var latestPackageWrite = GetLatestWriteTimeUtc(packageRoot);
|
||
var resourceRoot = ToAbsoluteProjectPath(TH1YooAssetBuildTools.BundleResourceRoot);
|
||
var latestResourceWrite = GetLatestWriteTimeUtc(resourceRoot);
|
||
|
||
var level = MigrationStatusLevel.Ok;
|
||
var message = $"OK, files={packageFileCount}, latest={FormatTime(latestPackageWrite)}\n{packageRoot}";
|
||
if (packageFileCount == 0)
|
||
{
|
||
level = MigrationStatusLevel.Error;
|
||
message = $"manifest 存在,但包内没有可用文件,请重新构建 YooAsset 内置 AB。\n{packageRoot}";
|
||
}
|
||
|
||
if (latestResourceWrite.HasValue && latestPackageWrite.HasValue &&
|
||
latestPackageWrite.Value.AddSeconds(1) < latestResourceWrite.Value)
|
||
{
|
||
level = MigrationStatusLevel.Error;
|
||
message += $"\nBundleResources 最近变更时间 {FormatTime(latestResourceWrite)} 晚于 AB,请重新构建 YooAsset 内置 AB。";
|
||
}
|
||
|
||
items.Add(new MigrationStatusItem("4. YooAsset 内置 AB", level, message));
|
||
}
|
||
|
||
private static int CountFiles(string root)
|
||
{
|
||
if (!Directory.Exists(root)) return 0;
|
||
var count = 0;
|
||
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
|
||
{
|
||
if (file.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) continue;
|
||
count++;
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
private static DateTime? GetLatestWriteTimeUtc(string root)
|
||
{
|
||
if (!Directory.Exists(root)) return null;
|
||
|
||
DateTime? latest = Directory.GetLastWriteTimeUtc(root);
|
||
foreach (var directory in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories))
|
||
{
|
||
var writeTime = Directory.GetLastWriteTimeUtc(directory);
|
||
if (!latest.HasValue || writeTime > latest.Value) latest = writeTime;
|
||
}
|
||
|
||
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
|
||
{
|
||
if (file.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) continue;
|
||
var writeTime = File.GetLastWriteTimeUtc(file);
|
||
if (!latest.HasValue || writeTime > latest.Value) latest = writeTime;
|
||
}
|
||
|
||
return latest;
|
||
}
|
||
|
||
private static string ToAbsoluteProjectPath(string projectRelativePath)
|
||
{
|
||
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath;
|
||
return Path.Combine(projectRoot, projectRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||
}
|
||
|
||
private static string FormatTime(DateTime? utcTime)
|
||
{
|
||
return utcTime.HasValue ? utcTime.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") : "missing";
|
||
}
|
||
|
||
private static bool FileContainsAscii(string path, string marker)
|
||
{
|
||
var bytes = File.ReadAllBytes(path);
|
||
var markerBytes = Encoding.ASCII.GetBytes(marker);
|
||
for (var i = 0; i <= bytes.Length - markerBytes.Length; i++)
|
||
{
|
||
var matched = true;
|
||
for (var j = 0; j < markerBytes.Length; j++)
|
||
{
|
||
if (bytes[i + j] == markerBytes[j]) continue;
|
||
matched = false;
|
||
break;
|
||
}
|
||
|
||
if (matched) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
internal enum MigrationStatusLevel
|
||
{
|
||
Ok,
|
||
Warning,
|
||
Error
|
||
}
|
||
|
||
internal readonly struct MigrationStatusItem
|
||
{
|
||
public readonly string Title;
|
||
public readonly MigrationStatusLevel Level;
|
||
public readonly string Message;
|
||
|
||
public MigrationStatusItem(string title, MigrationStatusLevel level, string message)
|
||
{
|
||
Title = title;
|
||
Level = level;
|
||
Message = message;
|
||
}
|
||
}
|
||
}
|