実際の利用場面
| 場面 | Gua.Runtimeが担当すること | adapter側が担当すること |
|---|---|---|
| 新しいgame engineへ対応 | C ABIのmanaged wrapper、request queue、version、bridge | engineのUI列挙、座標・状態変換、入力配送 |
| 独自UI frameworkへ対応 | Semantic node登録、action要求、完了event | Widgetをroleへ写像し、実際のWidget APIを呼ぶ |
| 社内toolkitをInspector/MCPへ公開 | WebSocket bridge、UI Tree JSON、log、screenshot要求 | toolkitの描画loopとcapture APIへ接続 |
導入とnative library
dotnet add package Gua.RuntimeGua.Runtimeはnet10.0とnetstandard2.1を対象にします。実行時には対象platform/architectureと一致するgua.dllとgua_runtime.dllも配置します。不一致や欠落はconstructorで明示的なload errorになります。
adapterの1 frame
using System;
using System.Collections.Generic;
using Gua.Core;
using Gua.Runtime;
public sealed class CustomUiGuaAdapter(ICustomUi ui) : IDisposable
{
// 最長のremote action timeout以上に設定する。
private static readonly TimeSpan StaleIdRetention = TimeSpan.FromSeconds(30);
private readonly GuaRuntime runtime = new();
private readonly Dictionary<string, DateTimeOffset> retainedNodeIds =
new(StringComparer.Ordinal);
public void Start()
{
runtime.SetAdapterVersion("custom_ui", "1.0.0");
if (!runtime.StartInspectorBridge(GetBridgePort()))
throw new InvalidOperationException("Gua bridge could not start.");
}
private static int GetBridgePort()
{
const int defaultPort = 8765;
var value = Environment.GetEnvironmentVariable("GUA_BRIDGE_PORT");
if (string.IsNullOrWhiteSpace(value))
return defaultPort;
if (!int.TryParse(value, out var port) || port is < 1 or > 65535)
throw new InvalidOperationException("GUA_BRIDGE_PORT must be between 1 and 65535.");
return port;
}
public void Tick()
{
var retainUntil = DateTimeOffset.UtcNow + StaleIdRetention;
runtime.BeginFrame(ui.ScreenName);
foreach (var control in ui.Controls)
{
retainedNodeIds[control.Id] = retainUntil;
runtime.RegisterNode(new GuaNodeDescriptor(
control.Id, control.Role, control.Label,
new GuaBounds(control.X, control.Y, control.Width, control.Height),
Visible: control.Visible,
Enabled: control.Enabled,
ParentId: control.ParentId,
Text: control.Sensitive ? null : control.Text,
Value: control.Sensitive ? null : control.Value,
Focused: control.Focused,
Checked: control.Checked));
}
runtime.EndFrame();
foreach (var nodeId in retainedNodeIds.Keys)
DrainActions(nodeId);
DrainActions(null); // global press_key requests
var now = DateTimeOffset.UtcNow;
var expiredNodeIds = new List<string>();
foreach (var pair in retainedNodeIds)
if (pair.Value <= now)
expiredNodeIds.Add(pair.Key);
foreach (var expired in expiredNodeIds)
retainedNodeIds.Remove(expired);
}
private void DrainActions(string? nodeId)
{
foreach (GuaActionType action in Enum.GetValues(typeof(GuaActionType)))
{
while (runtime.TryConsumeAction(action, nodeId, out var request))
{
if (request.Action == GuaActionType.Click)
{
var error = TryClick(request.NodeId);
runtime.EmitActionResult(request,
error == GuaActionError.None, error);
}
else
{
runtime.EmitActionResult(request, false, GuaActionError.Unsupported);
}
}
}
}
private GuaActionError TryClick(string? nodeId)
{
if (nodeId is null || !ui.TryGetControl(nodeId, out var control))
return GuaActionError.NodeNotFound;
if (!control.Visible)
return GuaActionError.Hidden;
if (!control.Enabled)
return GuaActionError.Disabled;
return ui.TryClick(nodeId)
? GuaActionError.None
: GuaActionError.Unsupported;
}
public void Dispose() => runtime.Dispose();
}GetBridgePortは外部test hostがGUA_BRIDGE_PORTで割り当てたportを優先し、範囲を検証します。overrideがない場合だけ8765を使います。ICustomUiは説明用のhost固有interfaceです。password、token等を登録前にSensitiveで識別し、TextとValueの両方を省略することで、secretがsnapshot、Inspector/MCP応答、retained diagnosticsへ入るのを防ぎます。IDはframeをまたいで安定させ、boundsは物理viewport pixel・左上原点で渡します。
actionを正しく完了させる
TryConsumeActionはaction typeとnode IDの両方が一致する要求を取り出します。直前snapshotを見たclientのrequestは、Controlが消えた最初のframeでdrainした後に到着する可能性があるため、一度のdrainだけでは不十分です。最後に観測した各IDを有限のstaleness windowだけ保持し、その間はdrainを継続して期限後に除去します。StaleIdRetentionは最長のremote action timeoutとtransportの余裕以上に設定します。この例は30秒です。これにより許可されたclientを途中で見捨てず、動的画面のmemoryとframe処理量を直近のID生成rateへ制限できます。空IDはglobal key要求用に別途drainします。
| 結果 | 返すerror | 例 |
|---|---|---|
| 対象が消えた | NodeNotFound | request後、処理前にsceneが変わった |
| 非表示・無効 | Hidden / Disabled | host状態が最新snapshotから変化した |
| roleが操作非対応 | Unsupported | text nodeへのclick |
| 値を解釈できない | InvalidValue | sliderへ数値以外を設定 |
screenshot要求
if (runtime.TryConsumeScreenshotRequest(out var request))
{
if (ui.IsHeadless)
{
runtime.CompleteScreenshot(request, GuaScreenshotAvailability.Headless);
}
else if (ui.IsRenderingDisabled)
{
runtime.CompleteScreenshot(request, GuaScreenshotAvailability.RenderingDisabled);
}
else
{
var png = ui.CapturePngAfterDraw();
var dataUri = "data:image/png;base64," + Convert.ToBase64String(png.Bytes);
runtime.CompleteScreenshot(request, GuaScreenshotAvailability.Available,
dataUri, png.Width, png.Height);
}
}PNG captureを試す前に、headlessとrendering disabledを別々に判定します。captureは可能なら描画完了後に行います。CompleteScreenshotがdocumented completion APIです。consumeしたrequestごとに一度だけ呼びます。timeout・cancel・context reset後のlate completionはruntimeが無視するため、再試行しません。
実装チェックリスト
SetAdapterVersionへlowercase ASCIIの安定したadapter名とversionを設定する。- node ID、role、parent、state、action対応をframe間で一貫させる。
- 外部actionをengineの通常input/listener経路へ配送し、処理後に必ず成功または失敗を返す。
- 秘密値をlog、diagnostics、完了eventへ書かない。
- headless・rendering disabled・stale sessionをscreenshot結果として区別する。
- 終了時に
Dispose()し、bridgeとnative handleを解放する。