feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,65 @@
using System;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
using PCL.Core.App.IoC;
namespace PCL.Core.App.Essentials;
[LifecycleService(LifecycleState.BeforeLoading, Priority = int.MinValue)]
[LifecycleScope("application", "应用程序", false)]
public sealed partial class ApplicationService
{
public static Func<Application>? Loading { private get; set; }
[LifecycleStart]
private static void _Start()
{
Context.Debug("正在初始化 WPF 应用程序容器");
var app = Loading!.Invoke();
app.DispatcherUnhandledException += (_, e) => Lifecycle.OnException(e.Exception);
app.Startup += (_, _) => Lifecycle.OnLoading();
Lifecycle.CurrentApplication = app;
Loading = null;
Context.Trace("应用程序容器初始化完毕");
}
[LifecycleStop]
private static void _Stop()
{
var app = Lifecycle.CurrentApplication;
var dispatcher = app.Dispatcher;
if (Lifecycle.IsForceShutdown)
{
Context.Warn("已指定强制关闭,跳过 WPF 标准关闭流程");
return;
}
if (dispatcher is null || dispatcher.HasShutdownFinished) return;
using var exited = new ManualResetEventSlim();
dispatcher.BeginInvoke(DispatcherPriority.Send, () =>
{
app.Exit += Exited;
if (dispatcher.HasShutdownStarted) return;
Context.Debug("发起 WPF 退出流程");
app.Shutdown();
});
try
{
Context.Debug("正在等待应用程序容器退出");
var result = exited.Wait(5000);
if (result) Context.Trace("应用程序容器已退出");
else Context.Warn("应用程序容器退出超时,停止等待");
}
finally
{
dispatcher.BeginInvoke(DispatcherPriority.Send, () => app.Exit -= Exited);
}
return;
void Exited(object? sender, EventArgs e)
{
// ReSharper disable once AccessToDisposedClosure
exited.Set();
}
}
}
@@ -0,0 +1,24 @@
using System;
using System.Windows;
using PCL.Core.App.IoC;
namespace PCL.Core.App.Essentials;
[LifecycleService(LifecycleState.WindowCreating, Priority = int.MaxValue)]
public sealed class MainWindowService : GeneralService
{
public static Func<Window>? Loading { private get; set; }
private static LifecycleContext? _context;
private static LifecycleContext Context => _context!;
private MainWindowService() : base("window", "主窗体", false) { _context = ServiceContext; }
public override void Start()
{
Context.Debug("正在初始化 WPF 窗体");
var window = Loading!.Invoke();
window.Loaded += (_, _) => Lifecycle.OnWindowCreated();
Lifecycle.CurrentApplication.MainWindow = window;
Context.Trace("窗体创建完毕");
}
}
@@ -0,0 +1,340 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Text.Json;
using System.Threading;
using PCL.Core.App.IoC;
using PCL.Core.IO;
using PCL.Core.Logging;
using PCL.Core.Utils.OS;
using PCL.Core.Utils;
namespace PCL.Core.App.Essentials;
public delegate string? PromoteOperationFunction(string? arg);
/// <summary>
/// 标记一个方法,使其能够被提权进程调用,方法签名需符合 <see cref="PromoteOperationFunction"/>。
/// </summary>
/// <param name="name">提权操作名</param>
[DependencyCollector<PromoteOperationFunction>("promote", AttributeTargets.Method)]
[AttributeUsage(AttributeTargets.Method)]
public sealed class PromoteOperationAttribute(string name) : Attribute;
[LifecycleService(LifecycleState.BeforeLoading, Priority = -10)]
[LifecycleScope("promote", "提权服务", false)]
public sealed partial class PromoteService
{
private static Process? _promoteProcess;
private static NamedPipeServerStream? _promotePipeServer;
private static readonly ConcurrentQueue<PromoteOperation> _PendingOperations = [];
private readonly record struct PromoteOperation(string Command, Action<string?>? Callback, bool DetailLog);
/// <summary>
/// 提权进程是否正在运行。
/// </summary>
public static bool IsPromoteProcessRunning => _promoteProcess is not null;
/// <summary>
/// 当前进程是否是提权进程。
/// </summary>
public static bool IsCurrentProcessPromoted { get; private set; }
private static string _GetPromotePipeName(int processId) => $"PCLCE_PM@{processId}";
private static readonly Dictionary<string, PromoteOperationFunction> _OperationFunctions = new();
/// <summary>
/// 添加提权操作,仅在提权进程中有效。
/// </summary>
/// <param name="name">操作名</param>
/// <param name="operation">操作实现,接收参数并返回结果,返回值会被自动压缩为单行</param>
/// <returns>是否添加成功,若在主进程中调用或已存在相同操作名,则为 <c>false</c></returns>
public static bool AddOperationFunction(string name, PromoteOperationFunction operation)
{
return IsCurrentProcessPromoted && _OperationFunctions.TryAdd(name, operation);
}
/// <summary>
/// 添加自动将参数 JSON 反序列化的提权操作,仅在提权进程中有效。
/// </summary>
/// <param name="name">操作名</param>
/// <param name="operation">操作实现,接收反序列化的并返回结果,返回值会被自动压缩为单行</param>
/// <typeparam name="TValue">反序列化的目标类型</typeparam>
/// <returns>是否添加成功,若在主进程中调用或已存在相同操作名,则为 <c>false</c></returns>
public static bool AddJsonOperationFunction<TValue>(string name, Func<TValue?, string?> operation)
{
return AddOperationFunction(name, arg =>
{
if (arg is null) return OperationErrEmpty;
var obj = JsonSerializer.Deserialize<TValue>(arg, JsonCompat.SerializerOptions);
return operation(obj);
});
}
private const string OperationErrNotFound = "ERR_OPERATION_NOT_FOUND";
private const string OperationErrInvalidArgument = "ERR_ILLEGAL_ARGUMENT";
private const string OperationErrExceptionThrown = "ERR_UNHANDLED_EXCEPTION";
private const string OperationErrEmpty = "ERR_EMPTY";
/// <summary>
/// 提权进程接收到操作请求时触发的事件,接收一个字符串作为操作命令并返回一个字符串作为结果。<br/>
/// <b>注意:如果你不知道这是做什么的,请勿覆盖默认实现。</b>请使用 <see cref="AddOperationFunction"/>。
/// </summary>
public static Func<string, string?> Operate {
private get => field ??= command =>
{
var split = command.Split([' '], 2);
_OperationFunctions.TryGetValue(split[0], out var operation);
if (operation is null) return OperationErrNotFound;
try
{
return operation(split.Length > 1 ? split[1] : null) ?? OperationErrEmpty;
}
catch (Exception ex)
{
Context.Warn("操作出错", ex);
return OperationErrExceptionThrown;
}
};
set;
} = null!;
private static string _ShortenString(string str)
{
#if TRACE
const int maxLength = 40;
#else
const int maxLength = 15;
#endif
if (str.Length <= maxLength) return str;
return str[..maxLength] + "...";
}
// 提权进程: 连接管道开始通信
private static void _PerformAsPromoteProcess(string pid)
{
Context.Info("正在连接提权通信管道");
var process = Process.GetProcessById(int.Parse(pid));
// 验证来源
var mainProcessPath = Path.GetFullPath(process.MainModule!.FileName);
if (!string.Equals(mainProcessPath, Basics.ExecutablePath, StringComparison.OrdinalIgnoreCase))
{
Context.Error("来源验证失败,正在退出");
return;
}
// 连接管道
var pipeName = _GetPromotePipeName(process.Id);
var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut);
pipe.Connect(10000);
Context.Info("已连接,开始通信");
var reader = new StreamReader(pipe);
var writer = new StreamWriter(pipe);
while (true)
{
var command = reader.ReadLine();
if (string.IsNullOrEmpty(command))
{
Context.Info("管道已关闭,正在退出");
break;
}
Context.Debug($"正在执行: {_ShortenString(command)}");
var result = Operate(command) ?? OperationErrEmpty;
Context.Trace($"返回结果: {_ShortenString(result)}");
writer.WriteLine(result.Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' '));
writer.Flush();
Context.Trace("返回成功");
}
}
private static readonly AutoResetEvent _ActivateEvent = new(false);
// 主进程: 管道连接回调
private static bool _PromotePipeCallback(StreamReader reader, StreamWriter writer, Process? client)
{
while (IsPromoteProcessRunning)
{
if (!_PendingOperations.TryDequeue(out var operation))
{
_ActivateEvent.WaitOne();
continue;
}
var command = operation.Command.Replace("\r\n", " ").Replace('\n', ' ').Replace('\r', ' ');
var commandLog = operation.DetailLog ? command : _ShortenString(command);
Context.Debug($"正在执行: {commandLog}");
writer.WriteLine(command);
writer.Flush();
var result = reader.ReadLine();
if (result is null)
{
Context.Warn("管道输入流已结束");
break;
}
var resultLog = operation.DetailLog ? result : _ShortenString(result);
Context.Trace($"执行结果: {resultLog}");
if (result == OperationErrEmpty) result = null;
operation.Callback?.Invoke(result);
}
return false;
}
// 主进程: 初始化提权后台服务
private static bool _StartPromoteProcess()
{
// 启动提权进程
_promoteProcess = ProcessInterop.Start(
Basics.ExecutablePath, $"promote {Basics.CurrentProcessId}", true);
if (_promoteProcess is null)
{
Context.Warn("提权进程启动失败");
return false;
}
_promoteProcess.Exited += (_, _) => _promoteProcess = null;
// 启动提权通信管道服务端
_promotePipeServer ??= PipeComm.StartPipeServer(
"Promote", _GetPromotePipeName(Basics.CurrentProcessId), _PromotePipeCallback,
() => _promotePipeServer = null, true, [_promoteProcess.Id]);
return true;
}
/// <summary>
/// 向等待区添加操作。
/// </summary>
/// <param name="command">操作命令</param>
/// <param name="callback">结果返回后的回调</param>
/// <param name="detailLog">指定是否打印详细日志,若为 <c>false</c>,则日志仅保留前 40 或 15 字符(取决于是否为调试构建)</param>
public static void Append(string command, Action<string?>? callback = null, bool detailLog = true)
{
_PendingOperations.Enqueue(new PromoteOperation(command, callback, detailLog));
}
/// <summary>
/// 尝试启动提权进程并开始执行操作。
/// </summary>
/// <returns>是否成功开始执行,若提权进程启动失败则为 <c>false</c></returns>
public static bool Activate()
{
if (!IsPromoteProcessRunning && !_StartPromoteProcess())
{
_PendingOperations.Clear();
return false;
}
_ActivateEvent.Set();
return true;
}
private static readonly Dictionary<string, Process> _RunningProcesses = new();
// name: kill
// arg: process-id [timeout]
// return: kill result (false over timeout)
[PromoteOperation("kill")]
public static string? KillProcess(string? arg)
{
if (arg is null) return OperationErrInvalidArgument;
var split = arg.Split(' ');
if (!_RunningProcesses.TryGetValue(split[0], out var process)) return null;
process.Kill();
if (split.Length > 1)
{
int.TryParse(split[1], out var timeout);
return process.WaitForExit(timeout).ToString();
}
process.WaitForExit();
return true.ToString();
}
// name: start
// arg: path\to\executable[.] ; arguments
// return: process id
[PromoteOperation("start")]
public static string? StartProcess(string? arg)
{
if (arg is null) return OperationErrInvalidArgument;
var split = arg.Split([" ; "], 2, StringSplitOptions.RemoveEmptyEntries);
var createNoWindow = false;
if (split[0].EndsWith('.'))
{
split[0] = split[0][..^1];
createNoWindow = true;
}
var psi = new ProcessStartInfo(split[0]);
if (createNoWindow)
{
psi.CreateNoWindow = true;
psi.UseShellExecute = false;
psi.RedirectStandardInput = true;
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
}
if (split.Length > 1) psi.Arguments = split[1];
return _StartProcessWithInfo(psi);
}
// name: start-json
// arg: {...}
// return: process id
private static string? _StartProcessWithInfo(ProcessStartInfo? info)
{
if (info is null) return OperationErrInvalidArgument;
var process = Process.Start(info);
if (process is null) return null;
var id = process.Id.ToString();
process.Exited += (_, _) => _RunningProcesses.Remove(id);
_RunningProcesses[id] = process;
return id;
}
[DependencyInjectionPoint("promote", false)]
private static void _CollectOperationFunction(PromoteOperationFunction operation, string name)
=> AddOperationFunction(name, operation);
[LifecycleStart]
private static void _Start()
{
var args = Basics.CommandLineArguments;
if (args is ["promote", _])
{
Context.Info("当前进程为提权进程");
IsCurrentProcessPromoted = true;
// 预定义操作
Context.Info("正在加载提权操作");
_CollectOperationFunction_InvokeInjection_Promote();
AddJsonOperationFunction<ProcessStartInfo>("start-json", _StartProcessWithInfo);
// 结束生命周期管理,启动提权操作线程
Lifecycle.PendingLogFileName = "LastPending_Promote.log";
LogWrapper.OnLog += (level, msg, module, ex) => Context.CustomLog($"[{module}] {msg}", ex, level);
Context.Info("已接管通用日志");
Context.Info("正在启动服务线程");
new Thread(() => _PerformAsPromoteProcess(args[1])) { Name = "Promote" }.Start();
Context.RequestStopLoading();
Context.DeclareStopped();
}
else
{
Context.Info("当前进程为主进程");
IsCurrentProcessPromoted = false;
// TODO 提权进程自动启动
}
}
[LifecycleStop]
private static void _Stop()
{
if (_promotePipeServer is not null)
{
Context.Debug("正在结束提权管道服务");
_promotePipeServer.Dispose();
}
if (_promoteProcess is not null && !_promoteProcess.WaitForExit(3000))
{
Context.Debug("正在结束提权进程");
ProcessInterop.Kill(_promoteProcess, 0, true);
}
}
}
@@ -0,0 +1,9 @@
namespace PCL.Core.App.Essentials;
/// <summary>
/// RPC 函数<br/>
/// 接收参数并返回响应内容
/// </summary>
/// <param name="argument">参数</param>
/// <returns>响应内容</returns>
public delegate RpcResponse RpcFunction(string? argument, string? content, bool indent);
@@ -0,0 +1,58 @@
using System;
namespace PCL.Core.App.Essentials;
public class RpcPropertyOperationFailedException : Exception;
/// <summary>
/// RPC 属性<br/>
/// 大多数时候只需要使用构造方法,其他结构保留供内部使用
/// </summary>
public class RpcProperty
{
public delegate void GetValueDelegate(out string? outValue);
public event GetValueDelegate GetValue;
public delegate void SetValueDelegate(string? value, ref bool success);
public event SetValueDelegate? SetValue;
public readonly string Name;
public readonly bool Settable = true;
public string? Value
{
get
{
GetValue.Invoke(out var value);
return value;
}
set
{
var success = true;
SetValue?.Invoke(value, ref success);
if (!success)
throw new RpcPropertyOperationFailedException();
}
}
/// <param name="name">属性名称</param>
/// <param name="onGetValue">默认的 <c>GetValue</c> 回调</param>
/// <param name="onSetValue">默认的 <c>SetValue</c> 回调</param>
/// <param name="settable">指定该属性是否可更改,若该值为 <c>false</c> 的同时 <paramref name="onSetValue"/> 为 <c>null</c>,则该属性成为只读属性</param>
public RpcProperty(string name, Func<string?> onGetValue, Action<string?>? onSetValue = null, bool settable = false)
{
Name = name;
GetValue += (out outValue) => { outValue = onGetValue(); };
if (onSetValue is not null)
{
SetValue += (value, ref _) => { onSetValue(value); };
}
else if (!settable)
{
Settable = false;
SetValue += (_, ref success) => { success = false; };
}
}
}
@@ -0,0 +1,73 @@
using System;
using System.IO;
namespace PCL.Core.App.Essentials;
public enum RpcResponseStatus
{
Success,
Failure,
Err
}
public enum RpcResponseType
{
Empty,
Text,
Json,
Base64
}
/// <summary>
/// Pipe RPC 响应
/// </summary>
public class RpcResponse
{
public RpcResponseStatus Status { get; }
public RpcResponseType Type { get; }
public string? Name { get; }
public string? Content { get; }
public RpcResponse(RpcResponseStatus status, RpcResponseType type = RpcResponseType.Empty, string? content = null,
string? name = null)
{
if (content is not null && type == RpcResponseType.Empty)
throw new ArgumentException("Empty response with non-null content");
Status = status;
Type = type;
Content = content;
Name = name;
}
// STATUS type [name]
// [content]
public void Response(StreamWriter writer)
{
var nameArea = Name is null ? "" : $" {Name}";
writer.WriteLine($"{Status.ToString().ToUpperInvariant()} {Type.ToString().ToLowerInvariant()}{nameArea}");
if (Content is not null)
writer.WriteLine(Content);
}
public static readonly RpcResponse EmptySuccess = new RpcResponse(RpcResponseStatus.Success);
public static readonly RpcResponse EmptyFailure = new RpcResponse(RpcResponseStatus.Failure);
public static RpcResponse Err(string content, string? name = null)
{
return new RpcResponse(RpcResponseStatus.Err, RpcResponseType.Text, content, name);
}
public static RpcResponse Success(RpcResponseType type, string content, string? name = null)
{
return new RpcResponse(RpcResponseStatus.Success, type, content, name);
}
public static RpcResponse Failure(RpcResponseType type, string content, string? name = null)
{
return new RpcResponse(RpcResponseStatus.Failure, type, content, name);
}
}
@@ -0,0 +1,276 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using PCL.Core.App.IoC;
using PCL.Core.IO;
namespace PCL.Core.App.Essentials;
/// <summary>
/// 用于终止 Pipe RPC 执行过程并返回错误信息的异常<br/>
/// 当抛出该异常时 RPC 服务端将会返回内容为 <c>Reason</c> 的 <c>ERR</c> 响应
/// </summary>
public class RpcException(string reason) : Exception
{
public string Reason => reason;
}
/// <summary>
/// 标记一个方法或属性,使其成为 RPC 函数/属性。<br/>
/// 方法签名需兼容 <see cref="RpcFunction"/> 委托,属性仅支持 <see langword="string"/> 类型。
/// </summary>
/// <param name="name">该 RPC 函数/属性的名称</param>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method)]
[DependencyCollector<RpcFunction>("rpc-function", AttributeTargets.Method)]
[DependencyCollector<string>("rpc-property", AttributeTargets.Property)]
public sealed class RegisterRpc(string name) : Attribute;
/// <summary>
/// RPC 服务项
/// </summary>
[LifecycleService(LifecycleState.Loaded)]
[LifecycleScope("rpc", "远程执行服务")]
public sealed partial class RpcService
{
private NamedPipeServerStream? _pipe;
[LifecycleStart]
private void _Start()
{
_pipe = PipeComm.StartPipeServer("Echo", _EchoPipeName, _EchoPipeCallback);
}
[LifecycleStop]
private async Task _StopAsync()
{
if (_pipe is not null) await _pipe.DisposeAsync();
}
[LifecycleDependencyInjection("rpc-function", AttributeTargets.Method)]
private static void _CollectRpcFunctionRegistry(ImmutableList<(RpcFunction func, string name)> items)
{
foreach (var (func, name) in items)
{
AddFunction(name, func);
}
}
[LifecycleDependencyInjection("rpc-property", AttributeTargets.Property)]
private static void _CollectRpcPropertyRegistry(ImmutableList<(PropertyAccessor<string> prop, string name)> items)
{
foreach (var (prop, name) in items)
{
AddProperty(new RpcProperty(
name,
() => prop.Value,
value => prop.Value = value ?? "",
prop.CanSet
));
}
}
public const string PipePrefix = "PCLCE_RPC";
private static readonly string _EchoPipeName = $"{PipePrefix}@{Basics.CurrentProcess.Id}";
private static readonly string[] _RequestTypeArray = ["GET", "SET", "REQ"];
private static readonly HashSet<string> _RequestType = [.._RequestTypeArray];
#region Property
private static readonly Dictionary<string, RpcProperty> _PropertyMap = new();
/// <summary>
/// 添加一个新的 RPC 属性,若有多个使用 foreach 即可
/// </summary>
/// <param name="prop">要添加的属性</param>
/// <returns>是否成功添加(若已存在相同名称的属性则无法添加)</returns>
public static bool AddProperty(RpcProperty prop) => _PropertyMap.TryAdd(prop.Name, prop);
/// <summary>
/// 通过指定的名称删除已存在的 RPC 属性
/// </summary>
/// <param name="name">属性名称</param>
/// <returns>是否成功删除(若不存在该名称则无法删除)</returns>
public static bool RemoveProperty(string name)
{
return _PropertyMap.Remove(name);
}
/// <summary>
/// 删除已存在的 RPC 属性,实质上仍然是通过属性的名称删除,但会检查是否是同一个对象
/// </summary>
/// <param name="prop">要删除的属性</param>
/// <returns></returns>
public static bool RemoveProperty(RpcProperty prop)
{
var key = prop.Name;
var result = _PropertyMap.TryGetValue(key, out var value);
if (!result || value != prop) return false;
_PropertyMap.Remove(key);
return true;
}
#endregion
#region Function
private static readonly Dictionary<string, RpcFunction> _FunctionMap = new() {
["ping"] = ((_, _, _) => RpcResponse.EmptySuccess),
["activate"] = ((_, _, _) =>
{
if (Lifecycle.CurrentState >= LifecycleState.WindowCreated) ActivateMainWindow();
else Lifecycle.When(LifecycleState.WindowCreated, ActivateMainWindow);
return RpcResponse.EmptySuccess;
void ActivateMainWindow()
{
var app = Lifecycle.CurrentApplication;
app.Dispatcher.BeginInvoke(() =>
{
var window = app.MainWindow!;
if (window.WindowState == WindowState.Minimized) window.WindowState = WindowState.Normal;
if (!window.Topmost)
{
window.Topmost = true;
window.Topmost = false;
}
window.Activate();
});
}
})
};
/// <summary>
/// 添加一个新的 RPC 函数,若有多个使用 foreach 即可
/// </summary>
/// <param name="name">函数名称</param>
/// <param name="func">函数过程</param>
/// <returns>是否成功添加(若已存在相同名称的函数则无法添加)</returns>
public static bool AddFunction(string name, RpcFunction func) => _FunctionMap.TryAdd(name, func);
/// <summary>
/// 通过指定的名称删除已存在的 RPC 函数
/// </summary>
/// <param name="name">函数名称</param>
/// <returns>是否成功删除(若不存在该名称则无法删除)</returns>
public static bool RemoveFunction(string name)
{
return _FunctionMap.Remove(name);
}
#endregion
private static bool _EchoPipeCallback(StreamReader reader, StreamWriter writer, Process? client)
{
try
{
// GET/SET/REQ [target]
// [content]
var header = reader.ReadLine(); // 读入请求头
Context.Info($"客户端请求: {header}");
var args = header?.Split([' '], 2) ?? []; // 分离请求类型和参数
if (args.Length < 2 || args[1].Length == 0) throw new RpcException("请求参数过少");
var type = args[0].ToUpperInvariant();
if (!_RequestType.Contains(type)) throw new RpcException($"请求类型必须为 {string.Join("/", _RequestTypeArray)} 其中之一");
var target = args[1];
// 读入请求内容(可能没有)
var buffer = new StringBuilder();
var tmp = reader.Read();
while (tmp != PipeComm.PipeEndingChar)
{
buffer.Append((char)tmp);
tmp = reader.Read();
}
var content = buffer.Length == 0 ? null : buffer.ToString();
switch (type)
{
case "GET": case "SET": {
target = target.ToLowerInvariant();
var result = _PropertyMap.TryGetValue(target, out var prop);
if (!result) throw new RpcException($"不存在属性 {target}");
RpcResponse response;
if (type == "GET")
{
try
{
var value = prop!.Value;
response = new RpcResponse(RpcResponseStatus.Success, RpcResponseType.Text, value, target);
Context.Trace($"返回值: {value}");
}
catch (RpcPropertyOperationFailedException)
{
response = RpcResponse.EmptyFailure;
Context.Debug("设置失败: 只写属性或请求被拒绝");
}
}
else if (prop!.Settable)
{
try
{
prop.Value = content;
response = RpcResponse.EmptySuccess;
Context.Trace($"设置成功: {content}");
}
catch (RpcPropertyOperationFailedException)
{
response = RpcResponse.EmptyFailure;
Context.Debug("设置失败: 请求被拒绝");
}
}
else
{
response = RpcResponse.EmptyFailure;
Context.Debug("设置失败: 只读属性");
}
response.Response(writer);
break;
}
case "REQ": {
var targetArgs = target.Split([' '], 2); // 分离函数名和参数
var name = targetArgs[0].ToLowerInvariant();
var indent = false; // 检测缩进指示
if (name.EndsWith('$'))
{
indent = true;
name = name[..^1];
}
var result = _FunctionMap.TryGetValue(name, out var func);
if (!result) throw new RpcException($"不存在函数 {name}");
string? argument = null;
if (targetArgs.Length > 1)
argument = targetArgs[1];
Context.Trace($"正在调用函数 {name} {argument}");
var response = func!(argument, content, indent);
response.Response(writer);
Context.Trace($"函数已退出,返回状态 {response.Status}");
break;
}
}
}
catch (Exception ex)
{
if (ex is RpcException rpcEx)
{
var reason = rpcEx.Reason;
RpcResponse.Err(reason).Response(writer);
Context.Info($"出错: {reason}");
}
else
{
RpcResponse.Err(ex.ToString(), "stacktrace").Response(writer);
Context.Error("处理请求时发生异常", ex);
}
}
return true;
}
}
@@ -0,0 +1,70 @@
using System;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using PCL.Core.App.IoC;
using PCL.Core.IO;
using PCL.Core.Utils;
namespace PCL.Core.App.Essentials;
[LifecycleService(LifecycleState.BeforeLoading, Priority = -2134567890)]
[LifecycleScope("single-instance", "单例", false)]
public sealed partial class SingleInstanceService
{
private static FileStream? _lockStream;
private static readonly string _LockFilePath = Path.Combine(Paths.SharedLocalData, "instance.lock");
private static void _TryRpc(string processId, string content)
{
var pipeName = $"{RpcService.PipePrefix}@{processId}";
using var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut);
pipe.Connect(1000);
using var sw = new StreamWriter(pipe, PipeComm.PipeEncoding);
sw.WriteLine(content);
sw.Write(PipeComm.PipeEndingChar);
sw.Flush();
}
[LifecycleStart]
private static void _Start()
{
try
{
var stream = File.Open(_LockFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
Context.Debug("未发现重复实例,正在向单例锁写入信息");
using var sw = new StreamWriter(stream, Encoding.ASCII, 8, true);
sw.Write(Basics.CurrentProcessId);
sw.Flush();
_lockStream = stream;
}
catch (Exception)
{
try
{
using var stream = File.Open(_LockFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
var pid = reader.ReadToEnd();
Context.Info($"发现重复实例 {pid},尝试传递参数并拉起主窗口");
try
{
_TryRpc(pid, "REQ cli\n" + JsonSerializer.Serialize(StartupService.UnhandledCommands, JsonCompat.SerializerOptions));
_TryRpc(pid, "REQ activate");
}
catch (Exception ex) { Context.Warn("RPC 通信失败", ex); }
}
catch (Exception ex) { Context.Error("读取单例锁出错", ex); }
finally { Context.RequestExit(1); }
}
}
[LifecycleStop]
private static void _Stop()
{
if (_lockStream is null) return;
Context.Debug("正在删除单例锁");
_lockStream.Dispose();
File.Delete(_LockFilePath);
}
}
@@ -0,0 +1,159 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using PCL.Core.App.Cli;
using PCL.Core.App.IoC;
using PCL.Core.Utils.OS;
using PCL.Core.Utils.Secret;
using PCL.Core.Utils;
namespace PCL.Core.App.Essentials;
/// <summary>
/// 命令处理委托
/// </summary>
/// <param name="model">命令模型</param>
/// <param name="isCallback">
/// 指示该委托是否由已注册的回调触发,若是,则代表可能由 RPC
/// 或用户后续操作而非当前进程的命令行参数触发,应注意鉴权问题
/// </param>
public delegate void CommandHandler(CommandLine model, bool isCallback);
[LifecycleService(LifecycleState.BeforeLoading, Priority = int.MaxValue)]
[LifecycleScope("startup", "基本信息", false)]
public sealed partial class StartupService
{
private static Exception _GetUninitializedException() => new InvalidOperationException("Not initialized");
/// <summary>
/// 解析后的命令行模型实例
/// </summary>
/// <exception cref="Exception">尚未初始化完成</exception>
public static CommandLine CommandLine
{
get => field ?? throw _GetUninitializedException();
private set;
} = null!;
private static readonly Dictionary<string, CommandLine> _UnhandledCommandMap = [];
private static readonly ConcurrentDictionary<string, CommandHandler> _HandleCallbackMap = [];
/// <summary>
/// 未处理的子命令
/// </summary>
public static IReadOnlyDictionary<string, CommandLine> UnhandledCommands => _UnhandledCommandMap.AsReadOnly();
/// <summary>
/// 处理一个子命令
/// </summary>
/// <param name="command">子命令</param>
/// <param name="handler">用于处理命令的委托,传入 <see langword="null"/> 则触发已注册的处理回调</param>
/// <param name="registerCallback">指定是否注册该委托为处理回调</param>
/// <returns>是否执行成功,若子命令不存在或未注册任何处理回调则不成功</returns>
public static bool TryHandleCommand(
string command,
CommandHandler? handler = null,
bool registerCallback = false)
{
var isCallback = false;
if (handler is null)
{
_HandleCallbackMap.TryGetValue(command, out handler);
if (handler is null) return false;
isCallback = true;
}
else if (registerCallback) _HandleCallbackMap.TryAdd(command, handler);
lock (_UnhandledCommandMap)
{
_UnhandledCommandMap.TryGetValue(command, out var model);
if (model is null) return false;
// remove all related commands
foreach (var x in _UnhandledCommandMap.Keys.Where(x => x.StartsWith(command)).ToList())
_UnhandledCommandMap.Remove(x);
// run handler
try { handler(model, isCallback); }
catch (Exception ex) { Context.Warn($"Exception thrown while handle command: {command}", ex); }
return true;
}
}
[LifecycleStart]
private static void _LogBasicInfo()
{
var info = new StringBuilder();
info.Append("\n版本: ").Append(Basics.Metadata.Version).Append(" (").Append(GetArchitectureName(RuntimeInformation.ProcessArchitecture)).Append(')');
info.Append("\n路径: ").Append(Basics.ExecutablePath);
info.Append("\n命令行参数:");
if (Basics.CommandLineArguments.Length == 0) info.Append(" []");
else foreach (var x in Basics.CommandLineArguments) info.Append("\n - ").Append(x);
info.Append("\n系统版本: ").Append(Environment.OSVersion.Version).Append(" (").Append(GetArchitectureName(RuntimeInformation.OSArchitecture)).Append(')');
var memory = KernelInterop.GetPhysicalMemoryBytes();
const int memoryDiv = 1024 * 1024;
info.Append("\n可用内存: ").Append(memory.Available / memoryDiv).Append('/').Append(memory.Total / memoryDiv).Append(" MiB");
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp = Encoding.GetEncoding(0);
info.Append("\n默认代码页: ").Append(cp.EncodingName).Append(" (").Append(cp.CodePage).Append(')');
info.Append("\n管理员身份: ").Append(ProcessInterop.IsAdmin());
info.Append("\n识别码: ").Append(Identify.LauncherId);
Context.Info(info.ToString());
return;
string GetArchitectureName(Architecture arch) => arch switch
{
Architecture.X64 => "x64",
Architecture.Arm64 => "ARM64",
_ => arch.ToString()
};
}
[LifecycleStart]
private static void _ParseCommandLineArgs()
{
IEnumerable<SubcommandDefinition> subcommands = [
("update", [("execute"), ("success"), ("failed")]),
("activate", []),
("promote", []),
];
Context.Debug("正在解析命令行参数...");
var c = CommandLine.Parse(Basics.FullCommandLineArguments, subcommands);
var prefix = new StringBuilder();
while (true)
{
_UnhandledCommandMap[prefix.ToString()] = c;
if (c.Subcommand is null) break;
if (prefix.Length != 0) prefix.Append('.');
prefix.Append(c.Subcommand.CommandText);
c = c.Subcommand;
}
_UnhandledCommandMap.Remove("", out c!);
CommandLine = c;
}
[RegisterRpc("cli")]
public static RpcResponse OnRpcCommand(string? argument, string? content, bool indent)
{
if (content is null) return RpcResponse.Err("Must provide valid JSON model");
try
{
var models = JsonSerializer.Deserialize<Dictionary<string, CommandLine>>(content, JsonCompat.SerializerOptions);
if (models is null) return RpcResponse.Err("Invalid JSON: empty/null content");
Task.Run(() =>
{
foreach (var (command, model) in models)
{
_UnhandledCommandMap[command] = model;
TryHandleCommand(command);
}
});
}
catch (JsonException ex)
{
return RpcResponse.Err($"Invalid JSON: {ex.Message}");
}
return RpcResponse.EmptySuccess;
}
}
@@ -0,0 +1,29 @@
using System;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Media;
namespace PCL.Core.App.Essentials;
public static class StartupValidation
{
/// <summary>
/// 确保 WPF 字体渲染环境正常(修复缺失 %windir% 环境变量导致的字体渲染异常 #3555)
/// </summary>
public static void EnsureWpfFont()
{
try
{
_ = new FormattedText("", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
Fonts.SystemTypefaces.First(), 96d, Brushes.Black, 96d);
}
catch (UriFormatException)
{
Environment.SetEnvironmentVariable("windir", Environment.GetEnvironmentVariable("SystemRoot"),
EnvironmentVariableTarget.User);
_ = new FormattedText("", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
Fonts.SystemTypefaces.First(), 96d, Brushes.Black, 96d);
}
}
}
@@ -0,0 +1,228 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Win32;
using PCL.Core.App.IoC;
using PCL.Core.IO.Net;
using PCL.Core.IO.Net.Dns;
using PCL.Core.Logging;
using PCL.Core.Utils.OS;
using STUN.Client;
using Sentry;
using Sentry.Extensibility;
namespace PCL.Core.App.Essentials;
[LifecycleScope("telemetry", "遥测")]
[LifecycleService(LifecycleState.Running)]
public sealed partial class TelemetryService
{
private static void _InitSentry()
{
Context.Info("开始初始化 Sentry SDK");
var dsn = EnvironmentInterop.GetSecret("SENTRY_DSN");
if (dsn is null)
{
Context.Warn("未找到 Sentry DSN");
return;
}
var release = $"{Basics.VersionName}";
#if DEBUG
var environment = "Debug";
#else
var environment = "Production";
#endif
SentrySdk.Init(options =>
{
options.Dsn = dsn;
#if DEBUG
options.Debug = true;
#else
options.Debug = false;
#endif
options.SendDefaultPii = false;
options.IsGlobalModeEnabled = true;
options.AutoSessionTracking = true;
options.Release = release;
options.Environment = environment;
// 应该被直接丢弃不上报的异常类型
options.AddExceptionFilterForType<TimeoutException>();
options.AddExceptionFilterForType<HttpRequestException>();
options.AddExceptionFilterForType<WebException>();
options.AddExceptionFilterForType<TaskCanceledException>();
options.AddExceptionFilterForType<DirectoryNotFoundException>();
options.AddExceptionFilterForType<UnauthorizedAccessException>();
options.AddExceptionFilterForType<FileNotFoundException>();
// 细分类型的过滤器
options.AddExceptionFilter(new SocketExceptionFilter());
options.SetBeforeSend(@event => @event.Level is SentryLevel.Debug ? null : @event);
});
SentrySdk.ConfigureScope(scope =>
{
scope.User = new SentryUser
{
Id = Utils.Secret.Identify.LauncherId
};
});
Context.Info("Sentry SDK 初始化完成");
}
// 错误上报
public static void ReportException(Exception ex, string plain, LogLevel level)
{
var sentryEvent = new SentryEvent(ex)
{
Level = level.RealLevel() switch
{
LogLevel.Fatal => SentryLevel.Fatal,
LogLevel.Error => SentryLevel.Error,
LogLevel.Warning => SentryLevel.Warning,
LogLevel.Info => SentryLevel.Info,
LogLevel.Debug or LogLevel.Trace => SentryLevel.Debug,
_ => throw new ArgumentOutOfRangeException(nameof(level))
}
};
if (!string.IsNullOrWhiteSpace(plain))
{
sentryEvent.Message = new SentryMessage { Formatted = plain };
}
SentrySdk.CaptureEvent(sentryEvent);
}
// 设备环境上报
private static void _ReportDeviceEnvironment(TelemetryDeviceEnvironment content)
{
Context.Info("正在上报设备环境调查数据");
SentrySdk.ConfigureScope(scope =>
{
scope.Contexts["Telemetry"] = content;
});
try
{
SentrySdk.CaptureMessage("设备环境调查");
Context.Info("已发送设备环境调查数据");
}
catch(Exception ex)
{
Context.Error("设备环境调查数据发送失败,请检查网络连接以及使用的版本", ex);
}
}
// ReSharper disable UnusedAutoPropertyAccessor.Local
private class TelemetryDeviceEnvironment
{
public required string Tag { get; set; }
public required string Id { get; set; }
[JsonPropertyName("OS")] public required int Os { get; set; }
public required bool Is64Bit { get; set; }
[JsonPropertyName("IsARM64")] public required bool IsArm64 { get; set; }
public required string Launcher { get; set; }
public required string LauncherBranch {get; set; }
[JsonPropertyName("UsedOfficialPCL")] public required bool UsedOfficialPcl { get; set; }
[JsonPropertyName("UsedHMCL")] public required bool UsedHmcl { get; set; }
[JsonPropertyName("UsedBakaXL")] public required bool UsedBakaXl { get; set; }
public required ulong Memory { get; set; }
public required string? NatMapBehaviour { get; set; }
public required string? NatFilterBehaviour { get; set; }
[JsonPropertyName("IPv6Status")] public required string Ipv6Status { get; set; }
}
// ReSharper disable once InconsistentNaming
private const string STUN_SERVER_ADDR = "stun.miwifi.com";
// ReSharper restore UnusedAutoPropertyAccessor.Local
[LifecycleStart]
private static async Task _StartAsync()
{
if (!Config.System.Telemetry) return;
_InitSentry();
var appDataFolder = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
// stun test
StunClient5389UDP? natTest = null;
var miWifiIps = await DnsQuery.Instance.QueryForIpAsync(STUN_SERVER_ADDR).ConfigureAwait(false);
try
{
miWifiIps ??= await Dns.GetHostAddressesAsync(STUN_SERVER_ADDR).ConfigureAwait(false);
} catch(Exception) { /* Ignore dns error */ }
if (miWifiIps is not null && miWifiIps.Length != 0)
{
natTest = new StunClient5389UDP(new IPEndPoint(miWifiIps.First(), 3478),
new IPEndPoint(IPAddress.Any, 0));
await natTest.QueryAsync().ConfigureAwait(false);
}
var telemetry = new TelemetryDeviceEnvironment
{
Tag = "Telemetry",
Id = Utils.Secret.Identify.LauncherId,
Os = Environment.OSVersion.Version.Build,
Is64Bit = Environment.Is64BitOperatingSystem,
IsArm64 = RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64),
Launcher = Basics.VersionName,
LauncherBranch = Config.Update.UpdateChannel switch
{
UpdateChannel.Release => "Release",
UpdateChannel.Beta => "Beta",
UpdateChannel.Dev => "Dev",
_ => "Unknown"
},
UsedOfficialPcl =
bool.TryParse(Registry.GetValue(@"HKEY_CURRENT_USER\Software\PCL", "SystemEula", "false") as string,
out var officialPcl) && officialPcl,
UsedHmcl = Directory.Exists(Path.Combine(appDataFolder, ".hmcl")),
UsedBakaXl = Directory.Exists(Path.Combine(appDataFolder, "BakaXL")),
Memory = KernelInterop.GetPhysicalMemoryBytes().Total,
NatMapBehaviour = natTest?.State.MappingBehavior.ToString(),
NatFilterBehaviour = natTest?.State.FilteringBehavior.ToString(),
Ipv6Status = NetworkInterfaceUtils.GetIPv6Status().ToString()
};
_ReportDeviceEnvironment(telemetry);
}
// 用来细分过滤 SocketException 的过滤器,我觉得应该除了遥测服务之外没有其他东西会用到这破玩意儿
private sealed class SocketExceptionFilter : IExceptionFilter
{
public bool Filter(Exception ex)
{
if (ex is SocketException socketEx)
{
return socketEx.SocketErrorCode is
SocketError.ConnectionRefused or
SocketError.TimedOut or
SocketError.HostNotFound or
SocketError.NetworkUnreachable or
SocketError.ConnectionReset;
}
return false;
}
}
[LifecycleStop]
private static void _StopAsync()
{
SentrySdk.Close();
}
}
@@ -0,0 +1,48 @@
using System;
using System.Globalization;
using System.IO;
using System.Threading;
namespace PCL.Core.App.Essentials;
public static class UpdateHelper
{
/// <summary>
/// 更新启动器(替换文件)
/// </summary>
/// <param name="source">用于替换的来源文件路径</param>
/// <param name="target">目标文件路径</param>
public static Exception? Replace(string source, string target)
{
var backup = $"{target}.bak.{DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
Exception? lastEx = null;
try
{
source = Path.GetFullPath(source);
target = Path.GetFullPath(target);
// 备份目标文件
File.Copy(target, backup);
if (!File.Exists(backup)) throw new FileNotFoundException("备份目标文件失败", backup);
// 删除原文件并等待文件删除事件
var watcher = new FileSystemWatcher(Basics.GetParentPathOrEmpty(target), Path.GetFileName(target));
var deletedEvent = new ManualResetEventSlim(false);
watcher.Deleted += (_, _) => deletedEvent.Set();
watcher.EnableRaisingEvents = true;
File.Delete(target);
if (!deletedEvent.Wait(TimeSpan.FromSeconds(3))) throw new TimeoutException("删除目标文件失败");
watcher.EnableRaisingEvents = false;
watcher.Dispose();
// 复制到目标文件
File.Copy(source, target);
if (!File.Exists(target)) throw new FileNotFoundException("复制到目标文件失败", target);
}
catch (Exception ex)
{
// 出错:恢复原文件并返回异常
if (File.Exists(backup) && !File.Exists(target)) File.Move(backup, target);
lastEx = ex;
}
if (File.Exists(backup)) File.Delete(backup); // 删除备份文件
return lastEx;
}
}
@@ -0,0 +1,91 @@
using System;
using System.Diagnostics;
using System.IO;
using PCL.Core.App.IoC;
using PCL.Core.Utils.Exts;
namespace PCL.Core.App.Essentials;
[LifecycleService(LifecycleState.BeforeLoading)]
public sealed class UpdateService : GeneralService
{
private static LifecycleContext? _context;
private static LifecycleContext Context => _context!;
private UpdateService() : base("update", "更新", false) { _context = ServiceContext; }
public override void Start()
{
var args = Basics.CommandLineArguments;
if (args is not ["update", _, _, _, _])
{
switch (args)
{
case ["update_finished", _]:
{
var toDelete = args[1];
File.Delete(toDelete);
Context.Debug("更新来源文件已删除");
break;
}
case ["update_failed", _]:
{
var reason = args[1];
Context.Error(
$"更新失败: {reason}\n你可以手动将 exe 文件替换为 PCL 目录中的新版本" +
$"或再次尝试更新,若再次尝试仍然失败,请尽快反馈这个问题");
break;
}
default: Context.Debug("无更新任务"); break;
}
Context.DeclareStopped();
return;
}
try
{
Context.Info("开始更新");
Lifecycle.PendingLogDirectory = Path.Combine(Basics.ExecutableDirectory, "Log");
Lifecycle.PendingLogFileName = "LastPending_Update.log";
var oldProcessId = args[1].Convert<int>();
Context.Debug($"旧版本进程 ID: {oldProcessId}");
try
{
var oldProcess = Process.GetProcessById(oldProcessId);
Context.Debug("正在等待旧版本进程退出");
oldProcess.WaitForExit();
Context.Trace("旧版本进程已退出");
}
catch
{
/* ignored */
}
Context.Debug("正在替换文件");
var target = args[2];
Context.Trace($"目标: {target}");
var source = args[3];
Context.Trace($"来源: {source}");
var ex = UpdateHelper.Replace(source, target);
if (ex is null) Context.Trace("替换完成");
else Context.Error("替换文件出错", ex);
var restart = args[4].Convert<bool>();
if (restart)
{
var restartArgs = (ex is null) ? $"finished \"{source}\"" : $"failed \"{ex.Message}\"";
restartArgs = $"update_{restartArgs}";
Context.Debug($"重启中,使用参数: {restartArgs}");
Process.Start(target, restartArgs);
}
}
catch (Exception ex)
{
Context.Error("更新过程出错", ex);
}
Context.RequestExit();
}
}