diff --git a/MiGu.Server/Controllers/HealthController.cs b/MiGu.Server/Controllers/HealthController.cs
index 5494bd8..8ccdd84 100644
--- a/MiGu.Server/Controllers/HealthController.cs
+++ b/MiGu.Server/Controllers/HealthController.cs
@@ -1,4 +1,5 @@
-using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
@@ -39,4 +40,16 @@ public class HealthController : ControllerBase
///
[HttpGet("simplelite")]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
+
+ ///
+ /// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
+ ///
+ [HttpPost("simplelite/restart-for-update")]
+ [Authorize]
+ public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
+ {
+ var result = _launcher.RestartForUpdate(launchMode);
+ var diag = _launcher.GetDiagnostics();
+ return Ok(new { restart = result, diagnostics = diag });
+ }
}
diff --git a/MiGu.Server/Launcher/SimpleLiteBuildSync.cs b/MiGu.Server/Launcher/SimpleLiteBuildSync.cs
new file mode 100644
index 0000000..8be821c
--- /dev/null
+++ b/MiGu.Server/Launcher/SimpleLiteBuildSync.cs
@@ -0,0 +1,94 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace MiGu.Server.Launcher;
+
+///
+/// 将 obj/Debug 下最新编译的 SimpleLite 同步到 bin/Debug(仅当 SimpleLite 未运行时)。
+///
+public static class SimpleLiteBuildSync
+{
+ public static bool TrySyncFromObjToBin(string contentRoot, ILogger? log = null)
+ {
+ if (!TryResolvePaths(contentRoot, out var objDll, out var objExe, out var binDir))
+ return false;
+
+ var binDll = Path.Combine(binDir, "SimpleLite.dll");
+ var binExe = Path.Combine(binDir, "SimpleLite.exe");
+
+ if (!File.Exists(objDll))
+ {
+ log?.LogDebug("[SimpleLiteBuildSync] obj DLL 不存在: {Path}", objDll);
+ return false;
+ }
+
+ if (Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
+ {
+ log?.LogWarning("[SimpleLiteBuildSync] SimpleLite 仍在运行,跳过 DLL 同步。请先关闭 SimpleLite 窗口。");
+ return false;
+ }
+
+ var objTime = File.GetLastWriteTimeUtc(objDll);
+ if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
+ {
+ log?.LogDebug("[SimpleLiteBuildSync] bin 已是最新,无需同步");
+ return false;
+ }
+
+ Directory.CreateDirectory(binDir);
+ File.Copy(objDll, binDll, true);
+ log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
+
+ if (File.Exists(objExe))
+ {
+ File.Copy(objExe, binExe, true);
+ log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
+ }
+
+ return true;
+ }
+
+ public static bool? ProbeGotoSiteRoute(int port = 8222)
+ {
+ try
+ {
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
+ using var resp = client.PostAsync(
+ $"http://127.0.0.1:{port}/projection/reflection/car/0/goto-site?siteId=0",
+ null).GetAwaiter().GetResult();
+ var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+ if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
+ return body.Contains("SimpleLite/Platform/PlatformLauncher.cs 镜像):
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
-/// - 生命周期跟随:Windows 上用 JobObject 把子进程绑定到 MiGu.Server 进程;
-/// MiGu.Server 被 kill -9 / 任务管理器 → 结束进程树 时,SimpleLite 一并被 OS SIGKILL。
+/// - 默认独立( = false):SimpleLite 与 MiGu.Server 互不影响,
+/// 关闭任一方不会 kill 另一方;Windows 上用 cmd /c start 脱离父进程组/控制台。
+/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite。
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 --display-mode=web / --display-mode=web+local。
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
///
@@ -26,6 +27,7 @@ public sealed class SimpleLiteLauncher : IDisposable
private Process? _proc;
private IntPtr _job = IntPtr.Zero;
private string? _lastLaunchMode;
+ private bool _disposed;
public SimpleLiteLauncher(IOptions opts, ILogger log, IHostEnvironment env)
{
@@ -118,13 +120,21 @@ public sealed class SimpleLiteLauncher : IDisposable
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
_lastLaunchMode = ExternalReuseLaunchMode;
+ var reuseWarning = $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
+ $"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。";
+ if (SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
+ {
+ reuseWarning += " 当前 SimpleLite 版本过旧,缺少「前往站点」API;请关闭 SimpleLite 窗口后调用 POST /api/health/simplelite/restart-for-update,或运行 scripts/redeploy-simplelite.ps1。";
+ }
+
return new LaunchResult(true, "ReusingExisting",
$"projection :{_opts.ProjectionPort} reachable; requested displayMode={displayMode} not applied to existing instance",
DisplayMode: ExternalReuseLaunchMode,
- Warning: $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
- $"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。");
+ Warning: reuseWarning);
}
+ SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
+
var resolved = ResolveExecutable(_opts.ExecutablePath);
if (resolved == null)
{
@@ -143,52 +153,21 @@ public sealed class SimpleLiteLauncher : IDisposable
var arguments = BuildArguments(displayMode, _opts.Arguments);
- // 会话 N+2:把 SimpleLite 作为独立进程拉起,**带自己的控制台窗口**。
- // - UseShellExecute=true:交给 OS Shell 启动,生成新的进程组 + 独立 console,
- // MiGu.Server 关闭不会影响它(不再处于父进程的「controlling process」链上)。
- // - CreateNoWindow=false + 不重定向 stdout/stderr:SimpleLite 自带 OutputType=Exe 控制台,
- // 即便选 webonly 模式也会有一个黑底控制台显示日志,便于用户观察 / 关闭。
- // - 不能与 RedirectStandard* 共用,要让日志独立就只能让用户看 SimpleLite 自己的窗口。
- var psi = new ProcessStartInfo
- {
- FileName = resolved,
- Arguments = arguments,
- WorkingDirectory = workdir,
- UseShellExecute = true,
- CreateNoWindow = false,
- WindowStyle = ProcessWindowStyle.Normal,
- };
-
try
{
- var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
- proc.Exited += (_, _) =>
+ var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
+ if (proc == null)
{
- int exit;
- try { exit = proc.ExitCode; } catch { exit = -1; }
- _log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
- lock (_sync)
- {
- if (ReferenceEquals(_proc, proc))
- {
- _proc = null;
- _lastLaunchMode = null;
- }
- }
- };
-
- if (!proc.Start())
- {
- _log.LogError("[SimpleLite] Process.Start returned false; exe={Exe} args={Args}", resolved, arguments);
+ _log.LogError("[SimpleLite] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
- Warning: $"启动 SimpleLite 进程失败(Process.Start 返回 false);exe={resolved}");
+ Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
}
+ WireProcessExitHandler(proc);
+
_proc = proc;
_lastLaunchMode = displayMode;
- // FollowParent 仅在用户显式 opt-in 时启用;UseShellExecute=true 之下 JobObject 仍可绑定 pid,
- // 但会破坏「独立程序」语义,因此默认 false(见 SimpleLiteOptions.FollowParent 说明)。
if (_opts.FollowParent) AttachToJobObject(proc);
_log.LogInformation("[SimpleLite] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
@@ -223,6 +202,8 @@ public sealed class SimpleLiteLauncher : IDisposable
IntPtr job;
lock (_sync)
{
+ if (_disposed) return;
+ _disposed = true;
proc = _proc;
job = _job;
_proc = null;
@@ -231,13 +212,12 @@ public sealed class SimpleLiteLauncher : IDisposable
if (!_opts.FollowParent)
{
- // 独立模式:不真的杀子进程,只释放本地引用让 GC 收 Process handle。
- // 这样 MiGu.Server 重启时下一次登录可以重新 MaybeStart 拉一个新的 SimpleLite,
- // 而老 SimpleLite 仍然在自己的窗口里跑。
+ // 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
if (proc is { HasExited: false })
{
- _log.LogInformation("[SimpleLite] standalone mode: skip kill on Dispose; pid={Pid} keeps running", proc.Id);
+ _log.LogInformation("[SimpleLite] standalone mode: MiGu.Server stopping — SimpleLite pid={Pid} keeps running (FollowParent=false)", proc.Id);
}
+ try { proc?.Dispose(); } catch { /* ignore */ }
return;
}
@@ -273,13 +253,64 @@ public sealed class SimpleLiteLauncher : IDisposable
return args;
}
+ ///
+ /// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
+ ///
+ public LaunchResult RestartForUpdate(string launchMode = "webonly")
+ {
+ foreach (var proc in Process.GetProcessesByName("SimpleLite"))
+ {
+ try
+ {
+ if (!proc.HasExited)
+ {
+ proc.Kill(entireProcessTree: true);
+ _log.LogInformation("[SimpleLite] restart-for-update: killed pid={Pid}", proc.Id);
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning("[SimpleLite] restart-for-update: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
+ }
+ finally
+ {
+ proc.Dispose();
+ }
+ }
+
+ Thread.Sleep(1500);
+
+ lock (_sync)
+ {
+ _proc = null;
+ _lastLaunchMode = null;
+ }
+
+ var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
+ var result = MaybeStart(launchMode);
+ if (!synced && result.Warning == null)
+ {
+ return result with
+ {
+ Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 SimpleLite 仍占用文件)。请先运行 scripts/redeploy-simplelite.ps1。"
+ };
+ }
+
+ return result;
+ }
+
/// 启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。
public SimpleLiteDiagnostics GetDiagnostics()
{
var resolved = ResolveExecutable(_opts.ExecutablePath);
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
+ var gotoSite = projectionUp ? SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) : null;
+ var deployHint = gotoSite == false
+ ? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
+ : null;
return new SimpleLiteDiagnostics(
Enabled: _opts.Enabled,
+ FollowParent: _opts.FollowParent,
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
ConfiguredWorkingDirectory: _opts.WorkingDirectory ?? "",
ContentRootPath: _env.ContentRootPath,
@@ -289,7 +320,116 @@ public sealed class SimpleLiteLauncher : IDisposable
LastLaunchMode: LastLaunchMode,
ProjectionPort: _opts.ProjectionPort,
ProjectionPortReachable: projectionUp,
- ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json");
+ GotoSiteApiAvailable: gotoSite,
+ DeployHint: deployHint,
+ ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
+ " FollowParent=false 时关闭 MiGu.Server 不会结束 SimpleLite。");
+ }
+
+ ///
+ /// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 cmd /c start 脱离父进程组,避免平台退出连带结束 SimpleLite。
+ ///
+ private Process? StartSimpleLiteProcess(string resolved, string arguments, string workdir)
+ {
+ if (_opts.FollowParent)
+ return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
+
+ if (OperatingSystem.IsWindows())
+ return StartSimpleLiteDetachedWindows(resolved, arguments, workdir)
+ ?? StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
+
+ return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
+ }
+
+ private static Process? StartSimpleLiteDirect(string resolved, string arguments, string workdir, bool useShellExecute)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = resolved,
+ Arguments = arguments,
+ WorkingDirectory = workdir,
+ UseShellExecute = useShellExecute,
+ CreateNoWindow = false,
+ WindowStyle = ProcessWindowStyle.Normal,
+ };
+ var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ return proc.Start() ? proc : null;
+ }
+
+ ///
+ /// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
+ ///
+ private Process? StartSimpleLiteDetachedWindows(string resolved, string arguments, string workdir)
+ {
+ var exeName = Path.GetFileNameWithoutExtension(resolved);
+ var beforeIds = new HashSet(
+ Process.GetProcessesByName(exeName).Select(p => { try { return p.Id; } catch { return -1; } })
+ .Where(id => id > 0));
+
+ var cmdArgs = $"/c start \"SimpleLite\" /D \"{workdir}\" \"{resolved}\" {arguments}";
+ var shimPsi = new ProcessStartInfo
+ {
+ FileName = "cmd.exe",
+ Arguments = cmdArgs,
+ WorkingDirectory = workdir,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ using var shim = Process.Start(shimPsi);
+ shim?.WaitForExit(8000);
+
+ for (var i = 0; i < 25; i++)
+ {
+ foreach (var p in Process.GetProcessesByName(exeName))
+ {
+ try
+ {
+ if (p.HasExited) continue;
+ if (beforeIds.Contains(p.Id)) continue;
+ _log.LogInformation("[SimpleLite] detached start via cmd.exe; new pid={Pid}", p.Id);
+ return AttachToExistingProcess(p.Id);
+ }
+ catch { /* ignore */ }
+ }
+ Thread.Sleep(200);
+ }
+
+ _log.LogWarning("[SimpleLite] detached start: no new {Name} process observed after cmd.exe start", exeName);
+ return null;
+ }
+
+ private Process? AttachToExistingProcess(int pid)
+ {
+ try
+ {
+ var proc = Process.GetProcessById(pid);
+ proc.EnableRaisingEvents = true;
+ return proc;
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning("[SimpleLite] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
+ return null;
+ }
+ }
+
+ private void WireProcessExitHandler(Process proc)
+ {
+ proc.Exited += (_, _) =>
+ {
+ int exit;
+ try { exit = proc.ExitCode; } catch { exit = -1; }
+ _log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
+ lock (_sync)
+ {
+ if (ReferenceEquals(_proc, proc))
+ {
+ _proc = null;
+ _lastLaunchMode = null;
+ }
+ }
+ };
}
private string? ResolveExecutable(string configured)
@@ -423,6 +563,7 @@ public sealed class SimpleLiteLauncher : IDisposable
public sealed record SimpleLiteDiagnostics(
bool Enabled,
+ bool FollowParent,
string ConfiguredExecutablePath,
string ConfiguredWorkingDirectory,
string ContentRootPath,
@@ -432,6 +573,8 @@ public sealed class SimpleLiteLauncher : IDisposable
string? LastLaunchMode,
int ProjectionPort,
bool ProjectionPortReachable,
+ bool? GotoSiteApiAvailable,
+ string? DeployHint,
string ConfigHint);
// ─── Windows JobObject:父进程被杀时 Job 内所有子进程一并 SIGKILL ────────────────────────
diff --git a/scripts/redeploy-simplelite.ps1 b/scripts/redeploy-simplelite.ps1
new file mode 100644
index 0000000..096b545
--- /dev/null
+++ b/scripts/redeploy-simplelite.ps1
@@ -0,0 +1,29 @@
+# 将最新编译的 SimpleLite 部署到 bin\Debug 并提示重启。
+# 用法:先关闭 SimpleLite(任务管理器或托盘退出),再运行本脚本。
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
+$slProj = Join-Path $repoRoot 'Simple\SimpleLite\SimpleLite.csproj'
+$outDir = Join-Path $repoRoot 'Simple\SimpleLite\bin\Debug'
+$objDll = Join-Path $repoRoot 'Simple\SimpleLite\obj\Debug\SimpleLite.dll'
+
+Write-Host '==> 编译 SimpleLite (DisableFody)...' -ForegroundColor Cyan
+dotnet build $slProj -c Debug -p:DisableFody=true
+if ($LASTEXITCODE -ne 0) { throw "dotnet build 失败 (exit $LASTEXITCODE)" }
+
+$builtDll = if (Test-Path $objDll) { $objDll } else { Join-Path $outDir 'SimpleLite.dll' }
+
+if (Get-Process SimpleLite -ErrorAction SilentlyContinue) {
+ Write-Host ''
+ Write-Host 'SimpleLite 仍在运行,无法覆盖 DLL。请先关闭 SimpleLite.exe,再重新运行本脚本。' -ForegroundColor Yellow
+ exit 1
+}
+
+Copy-Item $builtDll (Join-Path $outDir 'SimpleLite.dll') -Force
+$objExe = Join-Path $repoRoot 'Simple\SimpleLite\obj\Debug\SimpleLite.exe'
+if (Test-Path $objExe) {
+ Copy-Item $objExe (Join-Path $outDir 'SimpleLite.exe') -Force
+}
+
+Write-Host '==> 已更新' $outDir -ForegroundColor Green
+Write-Host '请重新登录迷榖平台(或手动启动 SimpleLite.exe)以加载新 API。'