升级插件安全策略、包管理和运行时能力

This commit is contained in:
2026-08-17 09:06:32 +08:00
parent 92e5c330f2
commit c3a8737fd6
50 changed files with 4409 additions and 2646 deletions
+4 -1
View File
@@ -44,11 +44,14 @@ public static class AppServices
provider.GetRequiredService<IStartupCheckStore>(),
provider.GetService<ILogService>()));
services.AddSingleton<IPluginStateStore, PluginStateStore>();
services.AddSingleton<IPluginPackageService, PluginPackageService>();
services.AddSingleton<PluginLogService>();
services.AddSingleton<IPlatformCapabilities, WindowsPlatformCapabilities>();
services.AddSingleton<IShellRuntime>(provider => new ShellRuntimeService(provider.GetService<ILogService>()));
services.AddSingleton<IPluginRuntimeLauncher>(provider => new TauriPluginProcessService(
provider.GetRequiredService<AppPaths>(),
provider.GetRequiredService<IPluginHostProcessService>(),
provider.GetRequiredService<IToolLinkNavigationService>(),
provider.GetRequiredService<ISettingsService>(),
provider.GetService<ILogService>()));
services.AddSingleton<IBuiltInPluginInstallerService>(provider => new BuiltInPluginInstallerService(
provider.GetRequiredService<AppPaths>(),
@@ -26,8 +26,14 @@ public interface IPluginHostProcessService : IDisposable
Task<PluginSnapshot> SetSurfaceMountedAsync(string pluginId, string surfaceId, bool mounted, CancellationToken cancellationToken = default);
Task<PluginSnapshot> SetExternalRuntimeConfirmationAsync(string pluginId, string? version, CancellationToken cancellationToken = default);
Task<PluginBridgeResponse> BridgeCallAsync(PluginBridgeRequest request, CancellationToken cancellationToken = default);
Task<string> OpenBridgeSessionAsync(string pluginId, string surfaceId, string origin, CancellationToken cancellationToken = default);
Task CloseBridgeSessionAsync(string sessionToken, CancellationToken cancellationToken = default);
void ResetFailedState();
void Stop();
@@ -92,12 +98,45 @@ public sealed class PluginHostProcessService(ILogService? logService = null) : I
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginSnapshot> SetExternalRuntimeConfirmationAsync(string pluginId, string? version, CancellationToken cancellationToken = default)
{
var response = await SendRequestAsync(new PluginHostMessage(
PluginHostProtocol.SetExternalRuntimeConfirmation,
PluginId: pluginId,
ExternalRuntimeConfirmation: version), cancellationToken).ConfigureAwait(false);
return ApplySnapshot(response.Snapshot);
}
public async Task<PluginBridgeResponse> BridgeCallAsync(PluginBridgeRequest request, CancellationToken cancellationToken = default)
{
var response = await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.BridgeCall, BridgeRequest: request), cancellationToken).ConfigureAwait(false);
return response.BridgeResponse ?? new PluginBridgeResponse(false, Error: response.Error ?? "Plugin host returned no bridge response.");
}
public async Task<string> OpenBridgeSessionAsync(string pluginId, string surfaceId, string origin, CancellationToken cancellationToken = default)
{
var token = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
var response = await SendRequestAsync(new PluginHostMessage(
PluginHostProtocol.OpenBridgeSession,
PluginId: pluginId,
SurfaceId: surfaceId,
SessionToken: token,
Origin: origin), cancellationToken).ConfigureAwait(false);
return string.IsNullOrWhiteSpace(response.SessionToken)
? throw new InvalidOperationException(response.Error ?? "Plugin host did not open the bridge session.")
: response.SessionToken;
}
public async Task CloseBridgeSessionAsync(string sessionToken, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sessionToken) || Status != PluginHostStatus.Ready)
{
return;
}
await SendRequestAsync(new PluginHostMessage(PluginHostProtocol.CloseBridgeSession, SessionToken: sessionToken), cancellationToken).ConfigureAwait(false);
}
public void Stop()
{
_stopping = true;
@@ -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;
}
}