升级插件安全策略、包管理和运行时能力
This commit is contained in:
@@ -1,19 +1,40 @@
|
||||
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, ILogService? logService = null) : IPluginRuntimeLauncher, IDisposable
|
||||
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)
|
||||
@@ -47,39 +68,96 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
return session;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = host,
|
||||
WorkingDirectory = Path.GetDirectoryName(host) ?? AppContext.BaseDirectory,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = false
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("--session");
|
||||
startInfo.ArgumentList.Add(sessionId);
|
||||
startInfo.ArgumentList.Add("--plugin-id");
|
||||
startInfo.ArgumentList.Add(request.PluginId);
|
||||
startInfo.ArgumentList.Add("--surface-id");
|
||||
startInfo.ArgumentList.Add(request.SurfaceId);
|
||||
startInfo.ArgumentList.Add("--runtime-kind");
|
||||
startInfo.ArgumentList.Add(request.RuntimeKind.ToString());
|
||||
startInfo.ArgumentList.Add("--plugin-root");
|
||||
startInfo.ArgumentList.Add(request.PluginRoot);
|
||||
startInfo.ArgumentList.Add("--manifest");
|
||||
startInfo.ArgumentList.Add(request.ManifestPath);
|
||||
startInfo.ArgumentList.Add("--entry");
|
||||
startInfo.ArgumentList.Add(request.Entry);
|
||||
if (!string.IsNullOrWhiteSpace(request.CommandId))
|
||||
{
|
||||
startInfo.ArgumentList.Add("--command");
|
||||
startInfo.ArgumentList.Add(request.CommandId);
|
||||
}
|
||||
ExternalRuntimeBroker? broker = null;
|
||||
Process? process = null;
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start Tauri plugin host.");
|
||||
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);
|
||||
@@ -88,6 +166,18 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
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,
|
||||
@@ -99,6 +189,20 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
}
|
||||
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,
|
||||
@@ -114,6 +218,10 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
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
|
||||
@@ -142,6 +250,7 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
};
|
||||
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)
|
||||
@@ -173,6 +282,10 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (Interlocked.Exchange(ref _snapshotSubscribed, 0) != 0)
|
||||
{
|
||||
pluginHost.SnapshotChanged -= PluginHost_SnapshotChanged;
|
||||
}
|
||||
foreach (var sessionId in _processes.Keys.ToArray())
|
||||
{
|
||||
try
|
||||
@@ -189,6 +302,11 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
{
|
||||
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))
|
||||
{
|
||||
@@ -237,6 +355,438 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
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>
|
||||
@@ -256,4 +806,28 @@ public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logSe
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user