diff --git a/Docs~/UnityCliPluginSync_zh.md b/Docs~/UnityCliPluginSync_zh.md index 3db4d06..07e79e2 100644 --- a/Docs~/UnityCliPluginSync_zh.md +++ b/Docs~/UnityCliPluginSync_zh.md @@ -1,5 +1,48 @@ # unity-cli-plugin 协议同步记录 +## 2026-07-24:`editor/console.get` 有界诊断读取 + +新增只读内置命令 `editor/console.get`。接口只包含一个可选参数: + +```json +{ + "afterMarkerId": "editor/console.mark 返回的 32 位十六进制 id" +} +``` + +- 省略参数时读取 Unity 2022 `Editor.log` 的最近有界窗口; +- 传入 marker id 时只返回该 marker 记录结束后的日志; +- marker 格式非法时返回 `validation_error`;格式正确但在最近 8 MiB 日志快照内 + 找不到时返回 `system_error`,不会退化为当前日志尾部,也不能据此重跑结果未明的 + 原操作; +- 调用开始时固定文件长度,调用期间新增的日志不进入本次结果; +- 不读取或修改 Unity Console 的搜索、级别、折叠、清理选项; +- `resultJson` 的 UTF-8 大小硬限制为 16 KiB。 + +为了让 marker 边界不可被 label 伪造,`editor/console.mark` 的 `label` 现在限制为 +单行、最多 200 个字符,并拒绝保留的 marker 前缀。 + +成功结果固定为: + +```json +{ + "text": "按原顺序返回并统一为 LF 的日志文本", + "truncated": false +} +``` + +`truncated=true` 表示历史尾部或 marker 后内容因固定预算被省略。调用方不能在 +该状态下声称窗口内不存在其他诊断。推荐的复杂工作流是: + +```text +editor/console.mark → 执行操作 → wait-ready / diagnose +→ editor/console.get(afterMarkerId=) +``` + +该命令刻意不提供分页、正则、任意日志路径、source 选择或可调输出上限,避免把 +日志查询复杂度和无界 token 成本暴露给 Agent。`console.mark` 的 marker 格式和 +编辑器日志路径解析也已收拢到同一个内部模块。 + ## 2026-07-24:HTTP protocol v2 invocation 去重与诊断字段 本次将 `ConsoleServiceConfig.ProtocolVersion` 从 1 升为 2,package 版本不变。 diff --git a/README.md b/README.md index 08cb775..c688a3f 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ Tab completion works for both command names and argument names. ## 📋 Built-in Actions -59 built-in commands across 13 namespaces, covering editor control, scene manipulation, asset management, and more. +60 built-in commands across 13 namespaces, covering editor control, scene manipulation, asset management, and more. | Namespace | Action | Description | |-----------|--------|-------------| @@ -244,6 +244,7 @@ Tab completion works for both command names and argument names. | | `window.open` | Open an editor window by type name | | | `console.clear` | Clear the editor console | | | `console.mark` | Write a searchable marker into the editor log and return the log file path | +| | `console.get` | Read bounded Editor log output, optionally after a console marker | | **project** | `scene.list` | List all scenes in the project | | | `scene.open` | Open a scene by path | | | `scene.save` | Save the current scene | diff --git a/README_zh.md b/README_zh.md index 6d65c74..b72ddb7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -206,7 +206,7 @@ Tab 补全支持命令名和参数名。 ## 📋 内置 Action -13 个命名空间,59 个内置命令,覆盖编辑器控制、场景操作、资产管理等常见场景。 +13 个命名空间,60 个内置命令,覆盖编辑器控制、场景操作、资产管理等常见场景。 | 命名空间 | Action | 说明 | |----------|--------|------| @@ -244,6 +244,7 @@ Tab 补全支持命令名和参数名。 | | `window.open` | 通过类型名打开编辑器窗口 | | | `console.clear` | 清空编辑器控制台 | | | `console.mark` | 向编辑器日志写入可搜索标记并返回日志文件路径 | +| | `console.get` | 读取有界的编辑器日志,可选从控制台标记之后开始 | | **project** | `scene.list` | 列出项目中所有场景 | | | `scene.open` | 通过路径打开场景 | | | `scene.save` | 保存当前场景 | diff --git a/Runtime/Service/Commands/Handlers/EditorCommandActions.cs b/Runtime/Service/Commands/Handlers/EditorCommandActions.cs index 22f15ef..0eef97c 100644 --- a/Runtime/Service/Commands/Handlers/EditorCommandActions.cs +++ b/Runtime/Service/Commands/Handlers/EditorCommandActions.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; #if UNITY_EDITOR -using System.Globalization; -using System.IO; using System.Reflection; using UnityEditor; using UnityEngine; @@ -46,16 +44,6 @@ private sealed class PlaymodeStatusResult public bool isCompiling; } - [Serializable] - private sealed class ConsoleMarkResult - { - public string logPath = ""; - public string id = ""; - public string label = ""; - public string timestampUtc = ""; - public string markerText = ""; - } - private enum PlaymodeLifecycleState { EditMode = 0, @@ -173,28 +161,12 @@ private static CommandResponse ClearConsole() } [CommandAction("editor", "console.mark", editorOnly: true, summary: "Write a searchable marker into the editor log and return the log file path")] - private static CommandResponse MarkConsole(string label = "") - { - var markerId = Guid.NewGuid().ToString("N"); - var timestampUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); - var trimmedLabel = (label ?? "").Trim(); - var markerText = string.IsNullOrEmpty(trimmedLabel) - ? $"[C#Console][ConsoleMark] id={markerId} utc={timestampUtc}" - : $"[C#Console][ConsoleMark] id={markerId} utc={timestampUtc} label={trimmedLabel}"; + private static CommandResponse MarkConsole(string label = "") => + EditorConsoleDiagnostics.Mark(label); - UnityEngine.Debug.Log(markerText); - - var result = new ConsoleMarkResult - { - logPath = ResolveEditorLogPath(), - id = markerId, - label = trimmedLabel, - timestampUtc = timestampUtc, - markerText = markerText - }; - - return CommandResponseFactory.Ok($"Wrote console marker '{markerId}'", JsonUtility.ToJson(result)); - } + [CommandAction("editor", "console.get", editorOnly: true, summary: "Read a bounded Unity Editor log window, optionally after a console marker")] + private static CommandResponse GetConsole(string afterMarkerId = "") => + EditorConsoleDiagnostics.Get(afterMarkerId); private static string ValidatePlaymodeTransition(bool enter) { @@ -339,56 +311,6 @@ private static bool ClearConsoleEntries() return true; } - private static string ResolveEditorLogPath() - { - try - { - if (!string.IsNullOrEmpty(Application.consoleLogPath)) - { - return Application.consoleLogPath; - } - } - catch - { - // Fall back to Unity's default editor log locations below. - } - - try - { - if (Application.platform == RuntimePlatform.WindowsEditor) - { - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (!string.IsNullOrEmpty(localAppData)) - { - return Path.Combine(localAppData, "Unity", "Editor", "Editor.log"); - } - } - - if (Application.platform == RuntimePlatform.OSXEditor) - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - if (!string.IsNullOrEmpty(home)) - { - return Path.Combine(home, "Library", "Logs", "Unity", "Editor.log"); - } - } - - if (Application.platform == RuntimePlatform.LinuxEditor) - { - var home = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - if (!string.IsNullOrEmpty(home)) - { - return Path.Combine(home, ".config", "unity3d", "Editor.log"); - } - } - } - catch - { - // Ignore fallback resolution failures and return empty below. - } - - return ""; - } #endif } } diff --git a/Runtime/Service/Commands/Handlers/EditorConsoleDiagnostics.cs b/Runtime/Service/Commands/Handlers/EditorConsoleDiagnostics.cs new file mode 100644 index 0000000..575741f --- /dev/null +++ b/Runtime/Service/Commands/Handlers/EditorConsoleDiagnostics.cs @@ -0,0 +1,438 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; +using UnityEngine; +using Zh1Zh1.CSharpConsole.Service.Commands.Core; + +namespace Zh1Zh1.CSharpConsole.Service.Commands.Handlers +{ +#if UNITY_EDITOR + /// + /// Owns the Editor-log marker protocol and bounded diagnostic reads. + /// The command interface stays small while file snapshot, marker lookup, + /// encoding, record boundaries, and response budgeting remain local here. + /// + internal static class EditorConsoleDiagnostics + { + private const string MarkerPrefix = "[C#Console][ConsoleMark] id="; + private const int MaxMarkerSearchBytes = 8 * 1024 * 1024; + private const int TailReadBytes = 64 * 1024; + private const int MaxResultJsonBytes = 16 * 1024; + private static readonly Encoding s_StrictUtf8 = + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + [Serializable] + private sealed class ConsoleMarkResult + { + public string logPath = ""; + public string id = ""; + public string label = ""; + public string timestampUtc = ""; + public string markerText = ""; + } + + [Serializable] + private sealed class ConsoleGetResult + { + public string text = ""; + public bool truncated; + } + + internal static CommandResponse Mark(string label) + { + var markerId = Guid.NewGuid().ToString("N"); + var timestampUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + var trimmedLabel = (label ?? "").Trim(); + if (trimmedLabel.IndexOfAny(new[] { '\r', '\n' }) >= 0) + { + return CommandResponseFactory.ValidationError( + "editor/console.mark label must be a single line"); + } + if (trimmedLabel.Length > 200) + { + return CommandResponseFactory.ValidationError( + "editor/console.mark label must not exceed 200 characters"); + } + if (trimmedLabel.IndexOf(MarkerPrefix, StringComparison.OrdinalIgnoreCase) >= 0) + { + return CommandResponseFactory.ValidationError( + "editor/console.mark label cannot contain the reserved marker prefix"); + } + + var markerText = string.IsNullOrEmpty(trimmedLabel) + ? $"{MarkerPrefix}{markerId} utc={timestampUtc}" + : $"{MarkerPrefix}{markerId} utc={timestampUtc} label={trimmedLabel}"; + + Debug.Log(markerText); + + var result = new ConsoleMarkResult + { + logPath = ResolveEditorLogPath(), + id = markerId, + label = trimmedLabel, + timestampUtc = timestampUtc, + markerText = markerText + }; + + return CommandResponseFactory.Ok( + $"Wrote console marker '{markerId}'", + JsonUtility.ToJson(result)); + } + + internal static CommandResponse Get(string afterMarkerId) + { + var markerText = (afterMarkerId ?? "").Trim(); + var hasMarker = !string.IsNullOrEmpty(markerText); + var markerId = Guid.Empty; + if (hasMarker && !Guid.TryParseExact(markerText, "N", out markerId)) + { + return CommandResponseFactory.ValidationError( + "afterMarkerId must be a 32-character hexadecimal id returned by editor/console.mark"); + } + + var logPath = ResolveEditorLogPath(); + if (string.IsNullOrEmpty(logPath)) + { + return CommandResponseFactory.SystemError( + "Unity 2022 Editor log path is unavailable"); + } + + try + { + using var stream = new FileStream( + logPath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + var snapshotLength = stream.Length; + + if (hasMarker) + { + return ReadAfterMarker(stream, snapshotLength, markerId.ToString("N")); + } + + return ReadTail(stream, snapshotLength); + } + catch (FileNotFoundException) + { + return CommandResponseFactory.SystemError( + "Unity 2022 Editor log does not exist"); + } + catch (DirectoryNotFoundException) + { + return CommandResponseFactory.SystemError( + "Unity 2022 Editor log directory does not exist"); + } + catch (Exception e) + { + return CommandResponseFactory.SystemError( + $"Could not read the Unity 2022 Editor log: {e.Message}"); + } + } + + private static CommandResponse ReadAfterMarker( + FileStream stream, + long snapshotLength, + string markerId) + { + var readLength = (int)Math.Min(snapshotLength, MaxMarkerSearchBytes); + var readStart = snapshotLength - readLength; + var window = ReadUtf8Window(stream, readStart, readLength); + var markerToken = MarkerPrefix + markerId + " utc="; + // The generated GUID cannot be known before the real marker is + // written. Select its first complete marker prefix so later user + // logs that echo markerText cannot move the causal boundary. + var markerIndex = window.IndexOf( + markerToken, + StringComparison.OrdinalIgnoreCase); + if (markerIndex < 0) + { + return CommandResponseFactory.SystemError( + $"Console marker '{markerId}' is unavailable; do not infer a clean result or repeat an unresolved operation. Write a new editor/console.mark before a new diagnostic window"); + } + + var contentStart = FindRecordEnd(window, markerIndex + markerToken.Length); + var text = contentStart >= window.Length + ? "" + : window.Substring(contentStart).TrimStart('\n'); + return BuildBoundedResponse(text, preferStart: true, sourceTruncated: false); + } + + private static CommandResponse ReadTail(FileStream stream, long snapshotLength) + { + var readLength = (int)Math.Min(snapshotLength, TailReadBytes); + var readStart = snapshotLength - readLength; + var text = ReadUtf8Window(stream, readStart, readLength); + var sourceTruncated = readStart > 0; + if (sourceTruncated) + { + text = TrimPartialLeadingRecord(text); + } + + return BuildBoundedResponse( + text.TrimStart('\n'), + preferStart: false, + sourceTruncated: sourceTruncated); + } + + private static CommandResponse BuildBoundedResponse( + string sourceText, + bool preferStart, + bool sourceTruncated) + { + var normalized = NormalizeNewlines(sourceText ?? "").TrimEnd('\n'); + var completeResult = new ConsoleGetResult + { + text = normalized, + truncated = sourceTruncated + }; + var completeJson = JsonUtility.ToJson(completeResult); + if (Encoding.UTF8.GetByteCount(completeJson) <= MaxResultJsonBytes) + { + return CommandResponseFactory.Ok( + "Read bounded Unity Editor log output", + completeJson); + } + + var low = 0; + var high = normalized.Length; + var best = ""; + while (low <= high) + { + var middle = low + ((high - low) / 2); + var candidate = preferStart + ? SafePrefix(normalized, middle) + : SafeSuffix(normalized, middle); + var candidateJson = JsonUtility.ToJson(new ConsoleGetResult + { + text = candidate, + truncated = true + }); + if (Encoding.UTF8.GetByteCount(candidateJson) <= MaxResultJsonBytes) + { + best = candidate; + low = middle + 1; + } + else + { + high = middle - 1; + } + } + + best = preferStart + ? TrimPartialTrailingLine(best) + : TrimPartialLeadingLine(best); + var boundedJson = JsonUtility.ToJson(new ConsoleGetResult + { + text = best, + truncated = true + }); + return CommandResponseFactory.Ok( + "Read truncated Unity Editor log output", + boundedJson); + } + + private static string ReadUtf8Window( + FileStream stream, + long start, + int requestedLength) + { + if (requestedLength <= 0) + { + return ""; + } + + stream.Seek(start, SeekOrigin.Begin); + var bytes = new byte[requestedLength]; + var totalRead = 0; + while (totalRead < requestedLength) + { + var read = stream.Read(bytes, totalRead, requestedLength - totalRead); + if (read <= 0) + { + throw new IOException( + "Editor log changed before its captured snapshot could be read"); + } + + totalRead += read; + } + + var offset = 0; + if (start > 0) + { + while (offset < bytes.Length && (bytes[offset] & 0xC0) == 0x80) + { + offset++; + } + } + + string text; + try + { + text = s_StrictUtf8.GetString(bytes, offset, bytes.Length - offset); + } + catch (DecoderFallbackException e) + { + throw new IOException( + "Captured Editor log snapshot ended with incomplete or invalid UTF-8 data; retry the read-only command", + e); + } + + return NormalizeNewlines(text).TrimStart('\uFEFF'); + } + + private static int FindRecordEnd(string text, int searchStart) + { + var boundary = text.IndexOf("\n\n", searchStart, StringComparison.Ordinal); + if (boundary >= 0) + { + var contentStart = boundary + 2; + if (text.IndexOf("(Filename:", contentStart, StringComparison.Ordinal) == contentStart) + { + var filenameBoundary = text.IndexOf( + "\n\n", + contentStart, + StringComparison.Ordinal); + if (filenameBoundary >= 0) + { + return filenameBoundary + 2; + } + + throw new IOException( + "Captured Editor log snapshot ended inside the console marker footer; retry the read-only command"); + } + + return contentStart; + } + + throw new IOException( + "Captured Editor log snapshot ended inside the console marker record; retry the read-only command"); + } + + private static string TrimPartialLeadingRecord(string text) + { + var boundary = text.IndexOf("\n\n", StringComparison.Ordinal); + if (boundary >= 0) + { + return text.Substring(boundary + 2); + } + + return TrimPartialLeadingLine(text); + } + + private static string TrimPartialLeadingLine(string text) + { + var lineEnd = text.IndexOf('\n'); + return lineEnd >= 0 ? text.Substring(lineEnd + 1) : ""; + } + + private static string TrimPartialTrailingLine(string text) + { + var lineEnd = text.LastIndexOf('\n'); + return lineEnd >= 0 ? text.Substring(0, lineEnd) : text; + } + + private static string SafePrefix(string text, int length) + { + var safeLength = Math.Max(0, Math.Min(length, text.Length)); + if (safeLength > 0 + && safeLength < text.Length + && char.IsHighSurrogate(text[safeLength - 1]) + && char.IsLowSurrogate(text[safeLength])) + { + safeLength--; + } + + return text.Substring(0, safeLength); + } + + private static string SafeSuffix(string text, int length) + { + var safeLength = Math.Max(0, Math.Min(length, text.Length)); + var start = text.Length - safeLength; + if (start > 0 + && start < text.Length + && char.IsLowSurrogate(text[start]) + && char.IsHighSurrogate(text[start - 1])) + { + start++; + } + + return text.Substring(start); + } + + private static string NormalizeNewlines(string text) + { + return (text ?? "").Replace("\r\n", "\n").Replace('\r', '\n'); + } + + internal static string ResolveEditorLogPath() + { + try + { + if (!string.IsNullOrEmpty(Application.consoleLogPath)) + { + return Application.consoleLogPath; + } + } + catch + { + // Fall back to Unity's default editor log locations below. + } + + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) + { + var localAppData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrEmpty(localAppData)) + { + return Path.Combine( + localAppData, + "Unity", + "Editor", + "Editor.log"); + } + } + + if (Application.platform == RuntimePlatform.OSXEditor) + { + var home = Environment.GetFolderPath( + Environment.SpecialFolder.Personal); + if (!string.IsNullOrEmpty(home)) + { + return Path.Combine( + home, + "Library", + "Logs", + "Unity", + "Editor.log"); + } + } + + if (Application.platform == RuntimePlatform.LinuxEditor) + { + var home = Environment.GetFolderPath( + Environment.SpecialFolder.Personal); + if (!string.IsNullOrEmpty(home)) + { + return Path.Combine( + home, + ".config", + "unity3d", + "Editor.log"); + } + } + } + catch + { + // Ignore fallback resolution failures and return empty below. + } + + return ""; + } + } +#endif +} diff --git a/Runtime/Service/Commands/Handlers/EditorConsoleDiagnostics.cs.meta b/Runtime/Service/Commands/Handlers/EditorConsoleDiagnostics.cs.meta new file mode 100644 index 0000000..7855a93 --- /dev/null +++ b/Runtime/Service/Commands/Handlers/EditorConsoleDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fdddbdca17cc4386997fbda6a549c738 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: