834 lines
33 KiB
C#
834 lines
33 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.IO.Pipes;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.UI.Xaml;
|
|
using Microsoft.UI.Xaml.Controls;
|
|
using Windows.ApplicationModel.DataTransfer;
|
|
using Windows.Storage;
|
|
using Windows.Storage.Pickers;
|
|
using Windows.System;
|
|
using WinRT.Interop;
|
|
using YMhut.Box.Core.App;
|
|
using YMhut.Box.Core.Logging;
|
|
using YMhut.Box.Core.Platform;
|
|
using YMhut.Box.Core.Plugins;
|
|
using YMhut.Box.Core.Plugins.Runtime;
|
|
using YMhut.Box.Core.Settings;
|
|
using YMhut.Box.Core.Tools;
|
|
|
|
namespace YMhut.Box.WinUI.Services;
|
|
|
|
public sealed class TauriPluginProcessService(
|
|
AppPaths paths,
|
|
IPluginHostProcessService pluginHost,
|
|
IToolLinkNavigationService linkNavigationService,
|
|
ISettingsService settingsService,
|
|
ILogService? logService = null) : IPluginRuntimeLauncher, IDisposable
|
|
{
|
|
private static readonly TimeSpan BrokerConnectTimeout = TimeSpan.FromSeconds(12);
|
|
private readonly ConcurrentDictionary<string, PluginRuntimeSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ConcurrentDictionary<string, Process> _processes = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ConcurrentDictionary<string, List<ShellOutputEvent>> _output = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ConcurrentDictionary<string, ExternalRuntimeBroker> _brokers = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ConcurrentDictionary<string, string> _externalOutputs = new(StringComparer.OrdinalIgnoreCase);
|
|
private long _sequence;
|
|
private int _snapshotSubscribed;
|
|
private bool _disposed;
|
|
|
|
public async Task<PluginRuntimeSession> LaunchAsync(PluginRuntimeLaunchRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var sessionId = Guid.NewGuid().ToString("N");
|
|
var startedAt = DateTimeOffset.Now;
|
|
var host = ResolveHostExecutable();
|
|
var session = new PluginRuntimeSession(
|
|
sessionId,
|
|
request.PluginId,
|
|
request.SurfaceId,
|
|
request.RuntimeKind,
|
|
null,
|
|
PluginRuntimeSessionStatus.Starting,
|
|
startedAt);
|
|
|
|
_sessions[sessionId] = session;
|
|
_output[sessionId] = [];
|
|
|
|
if (host is null)
|
|
{
|
|
session = session with
|
|
{
|
|
Status = PluginRuntimeSessionStatus.Failed,
|
|
EndedAt = DateTimeOffset.Now,
|
|
LastError = "Tauri plugin host executable was not found."
|
|
};
|
|
_sessions[sessionId] = session;
|
|
await WriteLogAsync("Error", request.PluginId, "Tauri plugin host missing", session.LastError, cancellationToken).ConfigureAwait(false);
|
|
return session;
|
|
}
|
|
|
|
ExternalRuntimeBroker? broker = null;
|
|
Process? process = null;
|
|
|
|
try
|
|
{
|
|
EnsureSnapshotSubscription();
|
|
if (!settingsService.Current.PluginDeveloperMode)
|
|
{
|
|
throw new UnauthorizedAccessException("The controlled external runtime requires plugin developer mode.");
|
|
}
|
|
var snapshot = await pluginHost.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
|
var pluginDto = snapshot.Plugins.FirstOrDefault(candidate =>
|
|
string.Equals(candidate.Manifest.Id, request.PluginId, StringComparison.OrdinalIgnoreCase));
|
|
if (pluginDto is null || !pluginDto.IsValid || !pluginDto.State.Enabled ||
|
|
pluginDto.Manifest.Runtime != PluginRuntimeKind.Tauri)
|
|
{
|
|
throw new UnauthorizedAccessException("The Tauri plugin is unavailable, disabled, or invalid.");
|
|
}
|
|
|
|
var plugin = pluginDto.ToLoadedPlugin();
|
|
if (!PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, PluginPermission.ExternalRuntime) ||
|
|
!string.Equals(plugin.State.ExternalRuntimeConfirmation, plugin.Manifest.Version, StringComparison.Ordinal))
|
|
{
|
|
throw new UnauthorizedAccessException("The external runtime permission or version confirmation is not current.");
|
|
}
|
|
|
|
var registeredRoot = Path.GetFullPath(plugin.RootPath);
|
|
var requestedRoot = Path.GetFullPath(request.PluginRoot);
|
|
var requestedEntry = Path.GetFullPath(request.Entry);
|
|
if (!string.Equals(registeredRoot.TrimEnd(Path.DirectorySeparatorChar), requestedRoot.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase) ||
|
|
!PluginRegistryService.IsInside(registeredRoot, requestedEntry) ||
|
|
!File.Exists(requestedEntry))
|
|
{
|
|
throw new InvalidDataException("The external runtime launch paths do not match the registered plugin package.");
|
|
}
|
|
|
|
var allowedOrigins = await ResolveAllowedOriginsAsync(plugin, cancellationToken).ConfigureAwait(false);
|
|
var origin = PluginExternalWebOrigin.Create(request.PluginId, request.SurfaceId);
|
|
var protocolName = PluginExternalWebOrigin.ProtocolName(request.PluginId, request.SurfaceId);
|
|
var bridgeSessionToken = await pluginHost.OpenBridgeSessionAsync(
|
|
request.PluginId,
|
|
request.SurfaceId,
|
|
origin,
|
|
cancellationToken).ConfigureAwait(false);
|
|
var pipeName = $"YMhutBoxExternalPlugin-{Environment.ProcessId}-{Guid.NewGuid():N}";
|
|
var pipe = new NamedPipeServerStream(
|
|
pipeName,
|
|
PipeDirection.InOut,
|
|
1,
|
|
PipeTransmissionMode.Byte,
|
|
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
|
broker = new ExternalRuntimeBroker(
|
|
sessionId,
|
|
request.PluginId,
|
|
request.SurfaceId,
|
|
origin,
|
|
bridgeSessionToken,
|
|
RuntimePolicyKey(plugin),
|
|
pipe);
|
|
_brokers[sessionId] = broker;
|
|
|
|
var profileRoot = Path.Combine(paths.Cache, "WebView2", "Plugins", request.PluginId, "Tauri", request.SurfaceId);
|
|
Directory.CreateDirectory(profileRoot);
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = host,
|
|
WorkingDirectory = Path.GetDirectoryName(host) ?? AppContext.BaseDirectory,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = false
|
|
};
|
|
|
|
AddArgument(startInfo, "--session", sessionId);
|
|
AddArgument(startInfo, "--plugin-id", request.PluginId);
|
|
AddArgument(startInfo, "--surface-id", request.SurfaceId);
|
|
AddArgument(startInfo, "--runtime-kind", request.RuntimeKind.ToString());
|
|
AddArgument(startInfo, "--plugin-root", registeredRoot);
|
|
AddArgument(startInfo, "--entry", requestedEntry);
|
|
AddArgument(startInfo, "--protocol-name", protocolName);
|
|
AddArgument(startInfo, "--plugin-origin", origin);
|
|
AddArgument(startInfo, "--profile-root", profileRoot);
|
|
AddArgument(startInfo, "--broker-pipe", pipeName);
|
|
AddArgument(startInfo, "--developer-mode", settingsService.Current.PluginDeveloperMode ? "true" : "false");
|
|
foreach (var allowedOrigin in allowedOrigins)
|
|
{
|
|
AddArgument(startInfo, "--allowed-origin", allowedOrigin);
|
|
}
|
|
|
|
process = Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start Tauri plugin host.");
|
|
process.EnableRaisingEvents = true;
|
|
process.OutputDataReceived += (_, args) => CaptureOutput(sessionId, ShellOutputStream.Stdout, args.Data);
|
|
process.ErrorDataReceived += (_, args) => CaptureOutput(sessionId, ShellOutputStream.Stderr, args.Data);
|
|
process.Exited += (_, _) => CompleteSession(sessionId, process);
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
_processes[sessionId] = process;
|
|
|
|
await pipe.WaitForConnectionAsync(cancellationToken)
|
|
.WaitAsync(BrokerConnectTimeout, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
broker.Reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: true);
|
|
broker.Writer = new StreamWriter(pipe, new UTF8Encoding(false), bufferSize: 4096, leaveOpen: true) { AutoFlush = true };
|
|
await broker.Writer.WriteLineAsync(PluginRuntimeProtocol.Serialize(new PluginRuntimeMessage(
|
|
PluginRuntimeProtocol.Ready,
|
|
Version: PluginRuntimeProtocol.Version,
|
|
SessionToken: bridgeSessionToken,
|
|
Origin: origin))).ConfigureAwait(false);
|
|
broker.LoopTask = Task.Run(() => RunBrokerAsync(broker), CancellationToken.None);
|
|
|
|
session = session with
|
|
{
|
|
ProcessId = process.Id,
|
|
Status = PluginRuntimeSessionStatus.Running
|
|
};
|
|
_sessions[sessionId] = session;
|
|
await WriteLogAsync("Information", request.PluginId, "Tauri plugin host launched", $"session={sessionId}; pid={process.Id}", cancellationToken).ConfigureAwait(false);
|
|
return session;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
if (process is { HasExited: false })
|
|
{
|
|
try
|
|
{
|
|
process.Kill(entireProcessTree: true);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
if (broker is not null)
|
|
{
|
|
await CloseBrokerAsync(sessionId, broker).ConfigureAwait(false);
|
|
}
|
|
session = session with
|
|
{
|
|
Status = PluginRuntimeSessionStatus.Failed,
|
|
EndedAt = DateTimeOffset.Now,
|
|
LastError = exception.Message
|
|
};
|
|
_sessions[sessionId] = session;
|
|
await WriteLogAsync("Error", request.PluginId, "Tauri plugin host launch failed", exception.Message, cancellationToken).ConfigureAwait(false);
|
|
return session;
|
|
}
|
|
}
|
|
|
|
public async Task StopAsync(string sessionId, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (_brokers.TryGetValue(sessionId, out var broker))
|
|
{
|
|
await CloseBrokerAsync(sessionId, broker).ConfigureAwait(false);
|
|
}
|
|
if (_processes.TryRemove(sessionId, out var process))
|
|
{
|
|
try
|
|
{
|
|
if (!process.HasExited)
|
|
{
|
|
process.Kill(entireProcessTree: true);
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
CaptureOutput(sessionId, ShellOutputStream.System, $"Stop failed: {exception.Message}");
|
|
}
|
|
finally
|
|
{
|
|
process.Dispose();
|
|
}
|
|
}
|
|
|
|
if (_sessions.TryGetValue(sessionId, out var session))
|
|
{
|
|
_sessions[sessionId] = session with
|
|
{
|
|
Status = PluginRuntimeSessionStatus.Cancelled,
|
|
EndedAt = DateTimeOffset.Now
|
|
};
|
|
await WriteLogAsync("Information", session.PluginId, "Tauri plugin host stopped", $"session={sessionId}", cancellationToken).ConfigureAwait(false);
|
|
}
|
|
_externalOutputs.TryRemove(sessionId, out _);
|
|
}
|
|
|
|
public Task<PluginRuntimeSession?> GetSessionAsync(string sessionId, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
_sessions.TryGetValue(sessionId, out var session);
|
|
return Task.FromResult(session);
|
|
}
|
|
|
|
public Task<IReadOnlyList<ShellOutputEvent>> ReadRecentOutputAsync(string sessionId, int take = 200, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (!_output.TryGetValue(sessionId, out var lines))
|
|
{
|
|
return Task.FromResult<IReadOnlyList<ShellOutputEvent>>([]);
|
|
}
|
|
|
|
lock (lines)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<ShellOutputEvent>>(lines.TakeLast(Math.Max(0, take)).ToArray());
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
if (Interlocked.Exchange(ref _snapshotSubscribed, 0) != 0)
|
|
{
|
|
pluginHost.SnapshotChanged -= PluginHost_SnapshotChanged;
|
|
}
|
|
foreach (var sessionId in _processes.Keys.ToArray())
|
|
{
|
|
try
|
|
{
|
|
StopAsync(sessionId).GetAwaiter().GetResult();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
private void CompleteSession(string sessionId, Process process)
|
|
{
|
|
try
|
|
{
|
|
if (_brokers.TryGetValue(sessionId, out var broker))
|
|
{
|
|
_ = CloseBrokerAsync(sessionId, broker);
|
|
}
|
|
_externalOutputs.TryRemove(sessionId, out _);
|
|
int? exitCode = process.HasExited ? process.ExitCode : null;
|
|
if (_sessions.TryGetValue(sessionId, out var session))
|
|
{
|
|
_sessions[sessionId] = session with
|
|
{
|
|
Status = exitCode == 0 ? PluginRuntimeSessionStatus.Exited : PluginRuntimeSessionStatus.Failed,
|
|
EndedAt = DateTimeOffset.Now,
|
|
ExitCode = exitCode
|
|
};
|
|
_ = WriteLogAsync(
|
|
exitCode == 0 ? "Information" : "Error",
|
|
session.PluginId,
|
|
"Tauri plugin host exited",
|
|
$"session={sessionId}; exitCode={exitCode}",
|
|
CancellationToken.None);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_processes.TryRemove(sessionId, out _);
|
|
process.Dispose();
|
|
}
|
|
}
|
|
|
|
private void CaptureOutput(string sessionId, ShellOutputStream stream, string? line)
|
|
{
|
|
if (line is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var output = new ShellOutputEvent(sessionId, stream, line, DateTimeOffset.Now, Interlocked.Increment(ref _sequence));
|
|
var lines = _output.GetOrAdd(sessionId, _ => []);
|
|
lock (lines)
|
|
{
|
|
lines.Add(output);
|
|
if (lines.Count > 5000)
|
|
{
|
|
lines.RemoveRange(0, lines.Count - 5000);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task WriteLogAsync(string level, string pluginId, string message, string? detail, CancellationToken cancellationToken)
|
|
{
|
|
await (logService?.WriteAsync(level, $"plugin:{pluginId}:runtime", message, detail, cancellationToken) ?? Task.CompletedTask).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<IReadOnlyList<string>> ResolveAllowedOriginsAsync(LoadedPlugin plugin, CancellationToken cancellationToken)
|
|
{
|
|
if (!PluginPermissionPolicy.IsGrantCurrent(plugin.Manifest, plugin.State, PluginPermission.Http))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var origins = new List<string>();
|
|
foreach (var value in plugin.Manifest.Network?.AllowedOrigins ?? [])
|
|
{
|
|
if (!PluginNetworkPolicy.TryNormalizePublicOrigin(value, allowWebSocket: true, out var origin) ||
|
|
!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
|
|
!await PluginNetworkPolicy.ResolvesToPublicAddressAsync(uri.Host, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
throw new UnauthorizedAccessException("A declared external runtime origin did not resolve exclusively to public addresses.");
|
|
}
|
|
origins.Add(origin);
|
|
}
|
|
return origins.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
|
}
|
|
|
|
private void EnsureSnapshotSubscription()
|
|
{
|
|
if (Interlocked.Exchange(ref _snapshotSubscribed, 1) == 0)
|
|
{
|
|
pluginHost.SnapshotChanged += PluginHost_SnapshotChanged;
|
|
}
|
|
}
|
|
|
|
private void PluginHost_SnapshotChanged(object? sender, PluginSnapshot snapshot)
|
|
{
|
|
foreach (var broker in _brokers.Values)
|
|
{
|
|
var plugin = snapshot.Plugins.FirstOrDefault(candidate =>
|
|
string.Equals(candidate.Manifest.Id, broker.PluginId, StringComparison.OrdinalIgnoreCase));
|
|
if (plugin is null || !plugin.IsValid || RuntimePolicyKey(plugin.ToLoadedPlugin()) != broker.PolicyKey)
|
|
{
|
|
_ = StopAsync(broker.SessionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string RuntimePolicyKey(LoadedPlugin plugin)
|
|
{
|
|
var state = plugin.State;
|
|
return JsonSerializer.Serialize(new
|
|
{
|
|
plugin.Manifest.Version,
|
|
plugin.Manifest.Runtime,
|
|
plugin.Manifest.Permissions,
|
|
plugin.Manifest.Security,
|
|
plugin.Manifest.PermissionReasons,
|
|
plugin.Manifest.Network,
|
|
plugin.Manifest.Requirements,
|
|
state.Enabled,
|
|
Granted = state.GrantedPermissions.OrderBy(value => value).ToArray(),
|
|
Fingerprints = state.PermissionPolicyFingerprints?.OrderBy(value => value.Key).ToArray(),
|
|
state.ExternalRuntimeConfirmation
|
|
});
|
|
}
|
|
|
|
private async Task RunBrokerAsync(ExternalRuntimeBroker broker)
|
|
{
|
|
try
|
|
{
|
|
while (!broker.Cancellation.IsCancellationRequested && broker.Reader is not null)
|
|
{
|
|
var line = await broker.Reader.ReadLineAsync(broker.Cancellation.Token).ConfigureAwait(false);
|
|
if (line is null)
|
|
{
|
|
break;
|
|
}
|
|
if (Encoding.UTF8.GetByteCount(line) > 512 * 1024)
|
|
{
|
|
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
|
|
PluginRuntimeProtocol.Error,
|
|
Error: "External runtime message exceeded the size limit.")).ConfigureAwait(false);
|
|
break;
|
|
}
|
|
|
|
PluginRuntimeMessage? message;
|
|
try
|
|
{
|
|
message = PluginRuntimeProtocol.Deserialize(line);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
message = null;
|
|
}
|
|
|
|
if (message is null)
|
|
{
|
|
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
|
|
PluginRuntimeProtocol.Error,
|
|
Error: "External runtime sent an invalid message.")).ConfigureAwait(false);
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(message.Type, PluginRuntimeProtocol.Ping, StringComparison.Ordinal))
|
|
{
|
|
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
|
|
PluginRuntimeProtocol.Pong,
|
|
message.RequestId,
|
|
Version: PluginRuntimeProtocol.Version)).ConfigureAwait(false);
|
|
continue;
|
|
}
|
|
|
|
if (!string.Equals(message.Type, PluginRuntimeProtocol.BridgeCall, StringComparison.Ordinal) ||
|
|
message.BridgeRequest is null)
|
|
{
|
|
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
|
|
PluginRuntimeProtocol.Error,
|
|
message.RequestId,
|
|
Error: "External runtime message type is not supported.")).ConfigureAwait(false);
|
|
continue;
|
|
}
|
|
|
|
var response = await HandleBrokerBridgeCallAsync(broker, message.BridgeRequest).ConfigureAwait(false);
|
|
await WriteBrokerResponseAsync(broker, new PluginRuntimeMessage(
|
|
PluginRuntimeProtocol.BridgeCall,
|
|
message.RequestId,
|
|
BridgeResponse: response)).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
CaptureOutput(broker.SessionId, ShellOutputStream.System, "External runtime broker stopped unexpectedly.");
|
|
await WriteLogAsync(
|
|
"Warning",
|
|
broker.PluginId,
|
|
"External runtime broker failed",
|
|
AppLocalizer.SanitizeSensitiveText(exception.Message, 220),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
await CloseBrokerAsync(broker.SessionId, broker).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task<PluginBridgeResponse> HandleBrokerBridgeCallAsync(ExternalRuntimeBroker broker, PluginBridgeRequest request)
|
|
{
|
|
if (!string.Equals(request.PluginId, broker.PluginId, StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(request.SurfaceId, broker.SurfaceId, StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(request.SessionToken, broker.BridgeSessionToken, StringComparison.Ordinal) ||
|
|
!string.Equals(request.Origin, broker.Origin, StringComparison.OrdinalIgnoreCase) ||
|
|
Encoding.UTF8.GetByteCount(request.PayloadJson) > 256 * 1024)
|
|
{
|
|
return new PluginBridgeResponse(
|
|
false,
|
|
Error: "The external runtime bridge session is invalid.",
|
|
ErrorCode: PluginBridgeErrorCode.SessionInvalid);
|
|
}
|
|
|
|
var response = await pluginHost.BridgeCallAsync(request, broker.Cancellation.Token).ConfigureAwait(false);
|
|
if (!response.Ok)
|
|
{
|
|
return response;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(request.PayloadJson) ? "null" : request.PayloadJson);
|
|
var payload = document.RootElement.Clone();
|
|
var handled = false;
|
|
object? value = null;
|
|
if (response.UiAction is not null)
|
|
{
|
|
handled = true;
|
|
value = await ExecuteUiActionAsync(broker, response.UiAction, payload).ConfigureAwait(false);
|
|
}
|
|
else if (request.Method is "output.set" or "output.append" or "output.clear")
|
|
{
|
|
handled = true;
|
|
value = UpdateExternalOutput(broker.SessionId, request.Method, payload);
|
|
}
|
|
|
|
return handled
|
|
? response with { ValueJson = JsonSerializer.Serialize(value), UiAction = null }
|
|
: response with { UiAction = null };
|
|
}
|
|
catch (OperationCanceledException) when (broker.Cancellation.IsCancellationRequested)
|
|
{
|
|
return new PluginBridgeResponse(false, Error: "The external runtime session was closed.", ErrorCode: PluginBridgeErrorCode.SessionInvalid);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
await WriteLogAsync(
|
|
"Warning",
|
|
broker.PluginId,
|
|
"External runtime UI action failed",
|
|
AppLocalizer.SanitizeSensitiveText(exception.Message, 220),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
return new PluginBridgeResponse(false, Error: "The external runtime UI action failed.", ErrorCode: PluginBridgeErrorCode.HostFailure);
|
|
}
|
|
}
|
|
|
|
private Task<object?> ExecuteUiActionAsync(ExternalRuntimeBroker broker, string uiAction, JsonElement payload)
|
|
{
|
|
return RunOnUiThreadAsync<object?>(async () => uiAction switch
|
|
{
|
|
"clipboard.readText" => await ReadClipboardAsync().ConfigureAwait(true),
|
|
"clipboard.writeText" => WriteClipboard(payload),
|
|
"file.openPicker" => await OpenFilePickerAsync().ConfigureAwait(true),
|
|
"file.savePicker" => await SaveFilePickerAsync(payload).ConfigureAwait(true),
|
|
"openExternal" => await OpenExternalAsync(broker.PluginId, payload).ConfigureAwait(true),
|
|
_ => throw new NotSupportedException("The approved UI action is not supported by the external runtime broker.")
|
|
});
|
|
}
|
|
|
|
private object UpdateExternalOutput(string sessionId, string method, JsonElement payload)
|
|
{
|
|
if (method == "output.clear")
|
|
{
|
|
_externalOutputs.TryRemove(sessionId, out _);
|
|
CaptureOutput(sessionId, ShellOutputStream.System, "Plugin output cleared.");
|
|
return true;
|
|
}
|
|
|
|
var value = JsonValue(payload);
|
|
var next = method == "output.append" && _externalOutputs.TryGetValue(sessionId, out var current)
|
|
? current + value
|
|
: value;
|
|
if (next.Length > 256 * 1024)
|
|
{
|
|
next = next[(next.Length - (256 * 1024))..];
|
|
}
|
|
_externalOutputs[sessionId] = next;
|
|
CaptureOutput(sessionId, ShellOutputStream.System, $"Plugin output updated ({next.Length} chars).");
|
|
return true;
|
|
}
|
|
|
|
private static async Task<object?> ReadClipboardAsync()
|
|
{
|
|
var content = Clipboard.GetContent();
|
|
return content.Contains(StandardDataFormats.Text)
|
|
? await content.GetTextAsync().AsTask().ConfigureAwait(true)
|
|
: string.Empty;
|
|
}
|
|
|
|
private static object WriteClipboard(JsonElement payload)
|
|
{
|
|
var package = new DataPackage();
|
|
package.SetText(JsonValue(payload));
|
|
Clipboard.SetContent(package);
|
|
return true;
|
|
}
|
|
|
|
private static async Task<object?> OpenFilePickerAsync()
|
|
{
|
|
if (App.CurrentWindow is null)
|
|
{
|
|
throw new InvalidOperationException("No active window is available for the file picker.");
|
|
}
|
|
|
|
var picker = new FileOpenPicker();
|
|
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow));
|
|
picker.FileTypeFilter.Add("*");
|
|
var file = await picker.PickSingleFileAsync();
|
|
if (file is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var properties = await file.GetBasicPropertiesAsync();
|
|
if (properties.Size > 2 * 1024 * 1024)
|
|
{
|
|
throw new InvalidDataException("Selected plugin input files cannot exceed 2 MiB.");
|
|
}
|
|
|
|
return new { name = file.Name, content = await FileIO.ReadTextAsync(file) };
|
|
}
|
|
|
|
private static async Task<object?> SaveFilePickerAsync(JsonElement payload)
|
|
{
|
|
if (App.CurrentWindow is null)
|
|
{
|
|
throw new InvalidOperationException("No active window is available for the file picker.");
|
|
}
|
|
|
|
var suggestedName = ReadString(payload, "name") ?? "plugin-output.txt";
|
|
var value = ReadString(payload, "value") ?? ReadString(payload, "bytesOrText") ?? ReadString(payload, "text") ?? string.Empty;
|
|
var extension = Path.GetExtension(suggestedName);
|
|
if (string.IsNullOrWhiteSpace(extension))
|
|
{
|
|
extension = ".txt";
|
|
suggestedName += extension;
|
|
}
|
|
|
|
var picker = new FileSavePicker { SuggestedFileName = Path.GetFileNameWithoutExtension(suggestedName) };
|
|
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(App.CurrentWindow));
|
|
picker.FileTypeChoices.Add("Plugin output", [extension]);
|
|
var file = await picker.PickSaveFileAsync();
|
|
if (file is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await FileIO.WriteTextAsync(file, value);
|
|
return new { name = file.Name };
|
|
}
|
|
|
|
private async Task<object> OpenExternalAsync(string pluginId, JsonElement payload)
|
|
{
|
|
var value = ReadString(payload, "url") ?? JsonValue(payload);
|
|
var target = payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("options", out var options)
|
|
? ReadString(options, "target")
|
|
: null;
|
|
var linkTarget = string.Equals(target, "system", StringComparison.OrdinalIgnoreCase)
|
|
? ToolLinkTarget.SystemBrowser
|
|
: ToolLinkTarget.SafeBrowser;
|
|
if (linkTarget == ToolLinkTarget.SystemBrowser)
|
|
{
|
|
var root = App.CurrentWindow?.Content as FrameworkElement;
|
|
if (root?.XamlRoot is null)
|
|
{
|
|
throw new InvalidOperationException("No active window is available for confirmation.");
|
|
}
|
|
var dialog = new ContentDialog
|
|
{
|
|
Title = "允许插件打开系统浏览器?",
|
|
Content = $"插件 {pluginId} 请求打开:\n{value}",
|
|
PrimaryButtonText = "允许本次",
|
|
CloseButtonText = "取消",
|
|
DefaultButton = ContentDialogButton.Close,
|
|
XamlRoot = root.XamlRoot
|
|
};
|
|
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
|
|
{
|
|
throw new UnauthorizedAccessException("The system browser request was cancelled.");
|
|
}
|
|
}
|
|
|
|
if (!await linkNavigationService.OpenAsync(value, linkTarget).ConfigureAwait(true))
|
|
{
|
|
throw new InvalidOperationException("The approved external link could not be opened.");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static Task<T> RunOnUiThreadAsync<T>(Func<Task<T>> action)
|
|
{
|
|
var dispatcher = App.CurrentWindow?.DispatcherQueue
|
|
?? throw new InvalidOperationException("No active UI dispatcher is available.");
|
|
if (dispatcher.HasThreadAccess)
|
|
{
|
|
return action();
|
|
}
|
|
|
|
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
if (!dispatcher.TryEnqueue(async () =>
|
|
{
|
|
try
|
|
{
|
|
completion.TrySetResult(await action().ConfigureAwait(true));
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
completion.TrySetException(exception);
|
|
}
|
|
}))
|
|
{
|
|
completion.TrySetException(new InvalidOperationException("The UI dispatcher rejected the external runtime action."));
|
|
}
|
|
return completion.Task;
|
|
}
|
|
|
|
private static async Task WriteBrokerResponseAsync(ExternalRuntimeBroker broker, PluginRuntimeMessage message)
|
|
{
|
|
if (broker.Writer is null || broker.Cancellation.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
await broker.WriteGate.WaitAsync(broker.Cancellation.Token).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await broker.Writer.WriteLineAsync(PluginRuntimeProtocol.Serialize(message)).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
broker.WriteGate.Release();
|
|
}
|
|
}
|
|
|
|
private async Task CloseBrokerAsync(string sessionId, ExternalRuntimeBroker broker)
|
|
{
|
|
if (Interlocked.Exchange(ref broker.Closed, 1) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_brokers.TryRemove(sessionId, out _);
|
|
broker.Cancellation.Cancel();
|
|
try
|
|
{
|
|
broker.Reader?.Dispose();
|
|
broker.Writer?.Dispose();
|
|
broker.Pipe.Dispose();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
try
|
|
{
|
|
await pluginHost.CloseBridgeSessionAsync(broker.BridgeSessionToken).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
|
{
|
|
startInfo.ArgumentList.Add(name);
|
|
startInfo.ArgumentList.Add(value);
|
|
}
|
|
|
|
private static string JsonValue(JsonElement element)
|
|
{
|
|
return element.ValueKind == JsonValueKind.String ? element.GetString() ?? string.Empty : element.GetRawText();
|
|
}
|
|
|
|
private static string? ReadString(JsonElement element, string property)
|
|
{
|
|
return element.ValueKind == JsonValueKind.Object && element.TryGetProperty(property, out var value)
|
|
? JsonValue(value)
|
|
: null;
|
|
}
|
|
|
|
private string? ResolveHostExecutable()
|
|
{
|
|
var candidates = new List<string>
|
|
{
|
|
Path.Combine(AppContext.BaseDirectory, "tauri-host", "ymhut-box-plugin-tauri-host.exe"),
|
|
Path.Combine(AppContext.BaseDirectory, "tauri-host", "YMhut.Box.PluginTauriHost.exe"),
|
|
Path.Combine(paths.Root, "Runtime", "tauri-host", "ymhut-box-plugin-tauri-host.exe")
|
|
};
|
|
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.PluginTauriHost", "src-tauri", "target", "release", "ymhut-box-plugin-tauri-host.exe"));
|
|
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.PluginTauriHost", "src-tauri", "target", "debug", "ymhut-box-plugin-tauri-host.exe"));
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
return candidates.FirstOrDefault(File.Exists);
|
|
}
|
|
|
|
private sealed class ExternalRuntimeBroker(
|
|
string sessionId,
|
|
string pluginId,
|
|
string surfaceId,
|
|
string origin,
|
|
string bridgeSessionToken,
|
|
string policyKey,
|
|
NamedPipeServerStream pipe)
|
|
{
|
|
public string SessionId { get; } = sessionId;
|
|
public string PluginId { get; } = pluginId;
|
|
public string SurfaceId { get; } = surfaceId;
|
|
public string Origin { get; } = origin;
|
|
public string BridgeSessionToken { get; } = bridgeSessionToken;
|
|
public string PolicyKey { get; } = policyKey;
|
|
public NamedPipeServerStream Pipe { get; } = pipe;
|
|
public CancellationTokenSource Cancellation { get; } = new();
|
|
public SemaphoreSlim WriteGate { get; } = new(1, 1);
|
|
public StreamReader? Reader { get; set; }
|
|
public StreamWriter? Writer { get; set; }
|
|
public Task? LoopTask { get; set; }
|
|
public int Closed;
|
|
}
|
|
}
|