⚗️ Import/Export: Export client
This commit is contained in:
parent
75b23cdaed
commit
bb721bc37f
170
Pal.Client/Net/RemoteApi.AccountService.cs
Normal file
170
Pal.Client/Net/RemoteApi.AccountService.cs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
using Account;
|
||||||
|
using Dalamud.Logging;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Pal.Client.Net
|
||||||
|
{
|
||||||
|
internal partial class RemoteApi
|
||||||
|
{
|
||||||
|
private async Task<(bool Success, string Error)> TryConnect(CancellationToken cancellationToken, ILoggerFactory? loggerFactory = null, bool retry = true)
|
||||||
|
{
|
||||||
|
if (Service.Configuration.Mode != Configuration.EMode.Online)
|
||||||
|
{
|
||||||
|
PluginLog.Debug("TryConnect: Not Online, not attempting to establish a connection");
|
||||||
|
return (false, "You are not online.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_channel == null || !(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle))
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
|
||||||
|
PluginLog.Information("TryConnect: Creating new gRPC channel");
|
||||||
|
_channel = GrpcChannel.ForAddress(RemoteUrl, new GrpcChannelOptions
|
||||||
|
{
|
||||||
|
HttpHandler = new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(5),
|
||||||
|
SslOptions = GetSslClientAuthenticationOptions(),
|
||||||
|
},
|
||||||
|
LoggerFactory = loggerFactory,
|
||||||
|
});
|
||||||
|
|
||||||
|
PluginLog.Information($"TryConnect: Connecting to upstream service at {RemoteUrl}");
|
||||||
|
await _channel.ConnectAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountClient = new AccountService.AccountServiceClient(_channel);
|
||||||
|
if (AccountId == null)
|
||||||
|
{
|
||||||
|
PluginLog.Information($"TryConnect: No account information saved for {RemoteUrl}, creating new account");
|
||||||
|
var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest(), headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
||||||
|
if (createAccountReply.Success)
|
||||||
|
{
|
||||||
|
Account = new Configuration.AccountInfo
|
||||||
|
{
|
||||||
|
Id = Guid.Parse(createAccountReply.AccountId),
|
||||||
|
};
|
||||||
|
PluginLog.Information($"TryConnect: Account created with id {PartialAccountId}");
|
||||||
|
|
||||||
|
Service.Configuration.Save();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PluginLog.Error($"TryConnect: Account creation failed with error {createAccountReply.Error}");
|
||||||
|
if (createAccountReply.Error == CreateAccountError.UpgradeRequired && !_warnedAboutUpgrade)
|
||||||
|
{
|
||||||
|
Service.Chat.PrintError("[Palace Pal] Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer.");
|
||||||
|
_warnedAboutUpgrade = true;
|
||||||
|
}
|
||||||
|
return (false, $"Could not create account ({createAccountReply.Error}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AccountId == null)
|
||||||
|
{
|
||||||
|
PluginLog.Warning("TryConnect: No account id to login with");
|
||||||
|
return (false, "No account-id after account was attempted to be created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_loginInfo.IsValid)
|
||||||
|
{
|
||||||
|
PluginLog.Information($"TryConnect: Logging in with account id {PartialAccountId}");
|
||||||
|
LoginReply loginReply = await accountClient.LoginAsync(new LoginRequest { AccountId = AccountId?.ToString() }, headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
||||||
|
if (loginReply.Success)
|
||||||
|
{
|
||||||
|
PluginLog.Information($"TryConnect: Login successful with account id: {PartialAccountId}");
|
||||||
|
_loginInfo = new LoginInfo(loginReply.AuthToken);
|
||||||
|
|
||||||
|
var account = Account;
|
||||||
|
if (account != null)
|
||||||
|
{
|
||||||
|
account.CachedRoles = _loginInfo.Claims?.Roles?.ToList() ?? new List<string>();
|
||||||
|
Service.Configuration.Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PluginLog.Error($"TryConnect: Login failed with error {loginReply.Error}");
|
||||||
|
_loginInfo = new LoginInfo(null);
|
||||||
|
if (loginReply.Error == LoginError.InvalidAccountId)
|
||||||
|
{
|
||||||
|
Account = null;
|
||||||
|
Service.Configuration.Save();
|
||||||
|
if (retry)
|
||||||
|
{
|
||||||
|
PluginLog.Information("TryConnect: Attempting connection retry without account id");
|
||||||
|
return await TryConnect(cancellationToken, retry: false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return (false, "Invalid account id.");
|
||||||
|
}
|
||||||
|
if (loginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade)
|
||||||
|
{
|
||||||
|
Service.Chat.PrintError("[Palace Pal] Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer.");
|
||||||
|
_warnedAboutUpgrade = true;
|
||||||
|
}
|
||||||
|
return (false, $"Could not log in ({loginReply.Error}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_loginInfo.IsValid)
|
||||||
|
{
|
||||||
|
PluginLog.Error($"TryConnect: Login state is loggedIn={_loginInfo.IsLoggedIn}, expired={_loginInfo.IsExpired}");
|
||||||
|
return (false, "No login information available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> Connect(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await TryConnect(cancellationToken);
|
||||||
|
return result.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> VerifyConnection(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_warnedAboutUpgrade = false;
|
||||||
|
|
||||||
|
var connectionResult = await TryConnect(cancellationToken, loggerFactory: _grpcToPluginLogLoggerFactory);
|
||||||
|
if (!connectionResult.Success)
|
||||||
|
return $"Could not connect to server: {connectionResult.Error}";
|
||||||
|
|
||||||
|
PluginLog.Information("VerifyConnection: Connection established, trying to verify auth token");
|
||||||
|
var accountClient = new AccountService.AccountServiceClient(_channel);
|
||||||
|
await accountClient.VerifyAsync(new VerifyRequest(), headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
||||||
|
return "Connection successful.";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class LoginInfo
|
||||||
|
{
|
||||||
|
public LoginInfo(string? authToken)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(authToken))
|
||||||
|
{
|
||||||
|
IsLoggedIn = true;
|
||||||
|
AuthToken = authToken;
|
||||||
|
Claims = JwtClaims.FromAuthToken(authToken!);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
IsLoggedIn = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLoggedIn { get; }
|
||||||
|
public string? AuthToken { get; }
|
||||||
|
public JwtClaims? Claims { get; }
|
||||||
|
public DateTimeOffset ExpiresAt => Claims?.ExpiresAt.Subtract(TimeSpan.FromMinutes(5)) ?? DateTimeOffset.MinValue;
|
||||||
|
public bool IsExpired => ExpiresAt < DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public bool IsValid => IsLoggedIn && !IsExpired;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
Pal.Client/Net/RemoteApi.ExportService.cs
Normal file
23
Pal.Client/Net/RemoteApi.ExportService.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
using Account;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Pal.Client.Net
|
||||||
|
{
|
||||||
|
internal partial class RemoteApi
|
||||||
|
{
|
||||||
|
public async Task<(bool, ExportRoot)> DoExport(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!await Connect(cancellationToken))
|
||||||
|
return new(false, new());
|
||||||
|
|
||||||
|
var exportClient = new ExportService.ExportServiceClient(_channel);
|
||||||
|
var exportReply = await exportClient.ExportAsync(new ExportRequest
|
||||||
|
{
|
||||||
|
ServerUrl = RemoteUrl,
|
||||||
|
}, headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(120), cancellationToken: cancellationToken);
|
||||||
|
return (exportReply.Success, exportReply.Data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
78
Pal.Client/Net/RemoteApi.PalaceService.cs
Normal file
78
Pal.Client/Net/RemoteApi.PalaceService.cs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
using Palace;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Pal.Client.Net
|
||||||
|
{
|
||||||
|
internal partial class RemoteApi
|
||||||
|
{
|
||||||
|
public async Task<(bool, List<Marker>)> DownloadRemoteMarkers(ushort territoryId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!await Connect(cancellationToken))
|
||||||
|
return (false, new());
|
||||||
|
|
||||||
|
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
|
||||||
|
var downloadReply = await palaceClient.DownloadFloorsAsync(new DownloadFloorsRequest { TerritoryType = territoryId }, headers: AuthorizedHeaders(), cancellationToken: cancellationToken);
|
||||||
|
return (downloadReply.Success, downloadReply.Objects.Select(o => CreateMarkerFromNetworkObject(o)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool, List<Marker>)> UploadMarker(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (markers.Count == 0)
|
||||||
|
return (true, new());
|
||||||
|
|
||||||
|
if (!await Connect(cancellationToken))
|
||||||
|
return (false, new());
|
||||||
|
|
||||||
|
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
|
||||||
|
var uploadRequest = new UploadFloorsRequest
|
||||||
|
{
|
||||||
|
TerritoryType = territoryType,
|
||||||
|
};
|
||||||
|
uploadRequest.Objects.AddRange(markers.Select(m => new PalaceObject
|
||||||
|
{
|
||||||
|
Type = (ObjectType)m.Type,
|
||||||
|
X = m.Position.X,
|
||||||
|
Y = m.Position.Y,
|
||||||
|
Z = m.Position.Z
|
||||||
|
}));
|
||||||
|
var uploadReply = await palaceClient.UploadFloorsAsync(uploadRequest, headers: AuthorizedHeaders(), cancellationToken: cancellationToken);
|
||||||
|
return (uploadReply.Success, uploadReply.Objects.Select(o => CreateMarkerFromNetworkObject(o)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> MarkAsSeen(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (markers.Count == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!await Connect(cancellationToken))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
|
||||||
|
var seenRequest = new MarkObjectsSeenRequest { TerritoryType = territoryType };
|
||||||
|
foreach (var marker in markers)
|
||||||
|
seenRequest.NetworkIds.Add(marker.NetworkId.ToString());
|
||||||
|
|
||||||
|
var seenReply = await palaceClient.MarkObjectsSeenAsync(seenRequest, headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
||||||
|
return seenReply.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Marker CreateMarkerFromNetworkObject(PalaceObject obj) =>
|
||||||
|
new Marker((Marker.EType)obj.Type, new Vector3(obj.X, obj.Y, obj.Z), Guid.Parse(obj.NetworkId));
|
||||||
|
|
||||||
|
public async Task<(bool, List<FloorStatistics>)> FetchStatistics(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!await Connect(cancellationToken))
|
||||||
|
return new(false, new List<FloorStatistics>());
|
||||||
|
|
||||||
|
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
|
||||||
|
var statisticsReply = await palaceClient.FetchStatisticsAsync(new StatisticsRequest(), headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(30), cancellationToken: cancellationToken);
|
||||||
|
return (statisticsReply.Success, statisticsReply.FloorStatistics.ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
63
Pal.Client/Net/RemoteApi.Utils.cs
Normal file
63
Pal.Client/Net/RemoteApi.Utils.cs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
using Dalamud.Logging;
|
||||||
|
using Grpc.Core;
|
||||||
|
using System.Net.Security;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace Pal.Client.Net
|
||||||
|
{
|
||||||
|
internal partial class RemoteApi
|
||||||
|
{
|
||||||
|
private Metadata UnauthorizedHeaders() => new Metadata
|
||||||
|
{
|
||||||
|
{ "User-Agent", UserAgent },
|
||||||
|
};
|
||||||
|
|
||||||
|
private Metadata AuthorizedHeaders() => new Metadata
|
||||||
|
{
|
||||||
|
{ "Authorization", $"Bearer {_loginInfo?.AuthToken}" },
|
||||||
|
{ "User-Agent", UserAgent },
|
||||||
|
};
|
||||||
|
|
||||||
|
private SslClientAuthenticationOptions? GetSslClientAuthenticationOptions()
|
||||||
|
{
|
||||||
|
#if !DEBUG
|
||||||
|
var secrets = typeof(RemoteApi).Assembly.GetType("Pal.Client.Secrets");
|
||||||
|
if (secrets == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var pass = secrets.GetProperty("CertPassword")?.GetValue(null) as string;
|
||||||
|
if (pass == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var manifestResourceStream = typeof(RemoteApi).Assembly.GetManifestResourceStream("Pal.Client.Certificate.pfx");
|
||||||
|
if (manifestResourceStream == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var bytes = new byte[manifestResourceStream.Length];
|
||||||
|
manifestResourceStream.Read(bytes, 0, bytes.Length);
|
||||||
|
|
||||||
|
var certificate = new X509Certificate2(bytes, pass, X509KeyStorageFlags.DefaultKeySet);
|
||||||
|
PluginLog.Debug($"Using client certificate {certificate.GetCertHashString()}");
|
||||||
|
return new SslClientAuthenticationOptions
|
||||||
|
{
|
||||||
|
ClientCertificates = new X509CertificateCollection()
|
||||||
|
{
|
||||||
|
certificate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
#else
|
||||||
|
PluginLog.Debug("Not using client certificate");
|
||||||
|
return null;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasRoleOnCurrentServer(string role)
|
||||||
|
{
|
||||||
|
if (Service.Configuration.Mode != Configuration.EMode.Online)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var account = Account;
|
||||||
|
return account == null || account.CachedRoles.Contains(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,30 +1,16 @@
|
|||||||
using Account;
|
using Dalamud.Logging;
|
||||||
using Dalamud.Logging;
|
|
||||||
using Grpc.Core;
|
|
||||||
using Grpc.Net.Client;
|
using Grpc.Net.Client;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Palace;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Net.Security;
|
|
||||||
using System.Numerics;
|
|
||||||
using System.Security.Cryptography.X509Certificates;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Pal.Client.Net
|
namespace Pal.Client.Net
|
||||||
{
|
{
|
||||||
internal class RemoteApi : IDisposable
|
internal partial class RemoteApi : IDisposable
|
||||||
{
|
{
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private const string remoteUrl = "http://localhost:5145";
|
public static string RemoteUrl { get; } = "http://localhost:5145";
|
||||||
#else
|
#else
|
||||||
private const string remoteUrl = "https://pal.μ.tv";
|
public static string RemoteUrl { get; } = "https://pal.μ.tv";
|
||||||
#endif
|
#endif
|
||||||
private readonly string UserAgent = $"{typeof(RemoteApi).Assembly.GetName().Name?.Replace(" ", "")}/{typeof(RemoteApi).Assembly.GetName().Version?.ToString(2)}";
|
private readonly string UserAgent = $"{typeof(RemoteApi).Assembly.GetName().Name?.Replace(" ", "")}/{typeof(RemoteApi).Assembly.GetName().Version?.ToString(2)}";
|
||||||
|
|
||||||
@ -36,13 +22,13 @@ namespace Pal.Client.Net
|
|||||||
|
|
||||||
public Configuration.AccountInfo? Account
|
public Configuration.AccountInfo? Account
|
||||||
{
|
{
|
||||||
get => Service.Configuration.Accounts.TryGetValue(remoteUrl, out Configuration.AccountInfo? accountInfo) ? accountInfo : null;
|
get => Service.Configuration.Accounts.TryGetValue(RemoteUrl, out Configuration.AccountInfo? accountInfo) ? accountInfo : null;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (value != null)
|
if (value != null)
|
||||||
Service.Configuration.Accounts[remoteUrl] = value;
|
Service.Configuration.Accounts[RemoteUrl] = value;
|
||||||
else
|
else
|
||||||
Service.Configuration.Accounts.Remove(remoteUrl);
|
Service.Configuration.Accounts.Remove(RemoteUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,283 +37,11 @@ namespace Pal.Client.Net
|
|||||||
private string PartialAccountId =>
|
private string PartialAccountId =>
|
||||||
Account?.Id?.ToString()?.PadRight(14).Substring(0, 13) ?? "[no account id]";
|
Account?.Id?.ToString()?.PadRight(14).Substring(0, 13) ?? "[no account id]";
|
||||||
|
|
||||||
private async Task<(bool Success, string Error)> TryConnect(CancellationToken cancellationToken, ILoggerFactory? loggerFactory = null, bool retry = true)
|
|
||||||
{
|
|
||||||
if (Service.Configuration.Mode != Configuration.EMode.Online)
|
|
||||||
{
|
|
||||||
PluginLog.Debug("TryConnect: Not Online, not attempting to establish a connection");
|
|
||||||
return (false, "You are not online.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_channel == null || !(_channel.State == ConnectivityState.Ready || _channel.State == ConnectivityState.Idle))
|
|
||||||
{
|
|
||||||
Dispose();
|
|
||||||
|
|
||||||
PluginLog.Information("TryConnect: Creating new gRPC channel");
|
|
||||||
_channel = GrpcChannel.ForAddress(remoteUrl, new GrpcChannelOptions
|
|
||||||
{
|
|
||||||
HttpHandler = new SocketsHttpHandler
|
|
||||||
{
|
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(5),
|
|
||||||
SslOptions = GetSslClientAuthenticationOptions(),
|
|
||||||
},
|
|
||||||
LoggerFactory = loggerFactory,
|
|
||||||
});
|
|
||||||
|
|
||||||
PluginLog.Information($"TryConnect: Connecting to upstream service at {remoteUrl}");
|
|
||||||
await _channel.ConnectAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var accountClient = new AccountService.AccountServiceClient(_channel);
|
|
||||||
if (AccountId == null)
|
|
||||||
{
|
|
||||||
PluginLog.Information($"TryConnect: No account information saved for {remoteUrl}, creating new account");
|
|
||||||
var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest(), headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
|
||||||
if (createAccountReply.Success)
|
|
||||||
{
|
|
||||||
Account = new Configuration.AccountInfo
|
|
||||||
{
|
|
||||||
Id = Guid.Parse(createAccountReply.AccountId),
|
|
||||||
};
|
|
||||||
PluginLog.Information($"TryConnect: Account created with id {PartialAccountId}");
|
|
||||||
|
|
||||||
Service.Configuration.Save();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
PluginLog.Error($"TryConnect: Account creation failed with error {createAccountReply.Error}");
|
|
||||||
if (createAccountReply.Error == CreateAccountError.UpgradeRequired && !_warnedAboutUpgrade)
|
|
||||||
{
|
|
||||||
Service.Chat.PrintError("[Palace Pal] Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer.");
|
|
||||||
_warnedAboutUpgrade = true;
|
|
||||||
}
|
|
||||||
return (false, $"Could not create account ({createAccountReply.Error}).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AccountId == null)
|
|
||||||
{
|
|
||||||
PluginLog.Warning("TryConnect: No account id to login with");
|
|
||||||
return (false, "No account-id after account was attempted to be created.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_loginInfo.IsValid)
|
|
||||||
{
|
|
||||||
PluginLog.Information($"TryConnect: Logging in with account id {PartialAccountId}");
|
|
||||||
LoginReply loginReply = await accountClient.LoginAsync(new LoginRequest { AccountId = AccountId?.ToString() }, headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
|
||||||
if (loginReply.Success)
|
|
||||||
{
|
|
||||||
PluginLog.Information($"TryConnect: Login successful with account id: {PartialAccountId}");
|
|
||||||
_loginInfo = new LoginInfo(loginReply.AuthToken);
|
|
||||||
|
|
||||||
var account = Account;
|
|
||||||
if (account != null)
|
|
||||||
{
|
|
||||||
account.CachedRoles = _loginInfo.Claims?.Roles?.ToList() ?? new List<string>();
|
|
||||||
Service.Configuration.Save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
PluginLog.Error($"TryConnect: Login failed with error {loginReply.Error}");
|
|
||||||
_loginInfo = new LoginInfo(null);
|
|
||||||
if (loginReply.Error == LoginError.InvalidAccountId)
|
|
||||||
{
|
|
||||||
Account = null;
|
|
||||||
Service.Configuration.Save();
|
|
||||||
if (retry)
|
|
||||||
{
|
|
||||||
PluginLog.Information("TryConnect: Attempting connection retry without account id");
|
|
||||||
return await TryConnect(cancellationToken, retry: false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return (false, "Invalid account id.");
|
|
||||||
}
|
|
||||||
if (loginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade)
|
|
||||||
{
|
|
||||||
Service.Chat.PrintError("[Palace Pal] Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer.");
|
|
||||||
_warnedAboutUpgrade = true;
|
|
||||||
}
|
|
||||||
return (false, $"Could not log in ({loginReply.Error}).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_loginInfo.IsValid)
|
|
||||||
{
|
|
||||||
PluginLog.Error($"TryConnect: Login state is loggedIn={_loginInfo.IsLoggedIn}, expired={_loginInfo.IsExpired}");
|
|
||||||
return (false, "No login information available.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (true, string.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> Connect(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var result = await TryConnect(cancellationToken);
|
|
||||||
return result.Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> VerifyConnection(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_warnedAboutUpgrade = false;
|
|
||||||
|
|
||||||
var connectionResult = await TryConnect(cancellationToken, loggerFactory: _grpcToPluginLogLoggerFactory);
|
|
||||||
if (!connectionResult.Success)
|
|
||||||
return $"Could not connect to server: {connectionResult.Error}";
|
|
||||||
|
|
||||||
PluginLog.Information("VerifyConnection: Connection established, trying to verify auth token");
|
|
||||||
var accountClient = new AccountService.AccountServiceClient(_channel);
|
|
||||||
await accountClient.VerifyAsync(new VerifyRequest(), headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
|
||||||
return "Connection successful.";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<(bool, List<Marker>)> DownloadRemoteMarkers(ushort territoryId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!await Connect(cancellationToken))
|
|
||||||
return (false, new());
|
|
||||||
|
|
||||||
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
|
|
||||||
var downloadReply = await palaceClient.DownloadFloorsAsync(new DownloadFloorsRequest { TerritoryType = territoryId }, headers: AuthorizedHeaders(), cancellationToken: cancellationToken);
|
|
||||||
return (downloadReply.Success, downloadReply.Objects.Select(o => CreateMarkerFromNetworkObject(o)).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<(bool, List<Marker>)> UploadMarker(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (markers.Count == 0)
|
|
||||||
return (true, new());
|
|
||||||
|
|
||||||
if (!await Connect(cancellationToken))
|
|
||||||
return (false, new());
|
|
||||||
|
|
||||||
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
|
|
||||||
var uploadRequest = new UploadFloorsRequest
|
|
||||||
{
|
|
||||||
TerritoryType = territoryType,
|
|
||||||
};
|
|
||||||
uploadRequest.Objects.AddRange(markers.Select(m => new PalaceObject
|
|
||||||
{
|
|
||||||
Type = (ObjectType)m.Type,
|
|
||||||
X = m.Position.X,
|
|
||||||
Y = m.Position.Y,
|
|
||||||
Z = m.Position.Z
|
|
||||||
}));
|
|
||||||
var uploadReply = await palaceClient.UploadFloorsAsync(uploadRequest, headers: AuthorizedHeaders(), cancellationToken: cancellationToken);
|
|
||||||
return (uploadReply.Success, uploadReply.Objects.Select(o => CreateMarkerFromNetworkObject(o)).ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> MarkAsSeen(ushort territoryType, IList<Marker> markers, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (markers.Count == 0)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (!await Connect(cancellationToken))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
|
|
||||||
var seenRequest = new MarkObjectsSeenRequest { TerritoryType = territoryType };
|
|
||||||
foreach (var marker in markers)
|
|
||||||
seenRequest.NetworkIds.Add(marker.NetworkId.ToString());
|
|
||||||
|
|
||||||
var seenReply = await palaceClient.MarkObjectsSeenAsync(seenRequest, headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
|
||||||
return seenReply.Success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Marker CreateMarkerFromNetworkObject(PalaceObject obj) =>
|
|
||||||
new Marker((Marker.EType)obj.Type, new Vector3(obj.X, obj.Y, obj.Z), Guid.Parse(obj.NetworkId));
|
|
||||||
|
|
||||||
public async Task<(bool, List<FloorStatistics>)> FetchStatistics(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!await Connect(cancellationToken))
|
|
||||||
return new(false, new List<FloorStatistics>());
|
|
||||||
|
|
||||||
var palaceClient = new PalaceService.PalaceServiceClient(_channel);
|
|
||||||
var statisticsReply = await palaceClient.FetchStatisticsAsync(new StatisticsRequest(), headers: AuthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(30), cancellationToken: cancellationToken);
|
|
||||||
return (statisticsReply.Success, statisticsReply.FloorStatistics.ToList());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Metadata UnauthorizedHeaders() => new Metadata
|
|
||||||
{
|
|
||||||
{ "User-Agent", UserAgent },
|
|
||||||
};
|
|
||||||
|
|
||||||
private Metadata AuthorizedHeaders() => new Metadata
|
|
||||||
{
|
|
||||||
{ "Authorization", $"Bearer {_loginInfo?.AuthToken}" },
|
|
||||||
{ "User-Agent", UserAgent },
|
|
||||||
};
|
|
||||||
|
|
||||||
private SslClientAuthenticationOptions? GetSslClientAuthenticationOptions()
|
|
||||||
{
|
|
||||||
#if !DEBUG
|
|
||||||
var secrets = typeof(RemoteApi).Assembly.GetType("Pal.Client.Secrets");
|
|
||||||
if (secrets == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var pass = secrets.GetProperty("CertPassword")?.GetValue(null) as string;
|
|
||||||
if (pass == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var manifestResourceStream = typeof(RemoteApi).Assembly.GetManifestResourceStream("Pal.Client.Certificate.pfx");
|
|
||||||
if (manifestResourceStream == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var bytes = new byte[manifestResourceStream.Length];
|
|
||||||
manifestResourceStream.Read(bytes, 0, bytes.Length);
|
|
||||||
|
|
||||||
var certificate = new X509Certificate2(bytes, pass, X509KeyStorageFlags.DefaultKeySet);
|
|
||||||
PluginLog.Debug($"Using client certificate {certificate.GetCertHashString()}");
|
|
||||||
return new SslClientAuthenticationOptions
|
|
||||||
{
|
|
||||||
ClientCertificates = new X509CertificateCollection()
|
|
||||||
{
|
|
||||||
certificate,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
#else
|
|
||||||
PluginLog.Debug("Not using client certificate");
|
|
||||||
return null;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool HasRoleOnCurrentServer(string role)
|
|
||||||
{
|
|
||||||
if (Service.Configuration.Mode != Configuration.EMode.Online)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
var account = Account;
|
|
||||||
return account == null || account.CachedRoles.Contains(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
PluginLog.Debug("Disposing gRPC channel");
|
PluginLog.Debug("Disposing gRPC channel");
|
||||||
_channel?.Dispose();
|
_channel?.Dispose();
|
||||||
_channel = null;
|
_channel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class LoginInfo
|
|
||||||
{
|
|
||||||
public LoginInfo(string? authToken)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(authToken))
|
|
||||||
{
|
|
||||||
IsLoggedIn = true;
|
|
||||||
AuthToken = authToken;
|
|
||||||
Claims = JwtClaims.FromAuthToken(authToken!);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
IsLoggedIn = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsLoggedIn { get; }
|
|
||||||
public string? AuthToken { get; }
|
|
||||||
public JwtClaims? Claims { get; }
|
|
||||||
public DateTimeOffset ExpiresAt => Claims?.ExpiresAt.Subtract(TimeSpan.FromMinutes(5)) ?? DateTimeOffset.MinValue;
|
|
||||||
public bool IsExpired => ExpiresAt < DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
public bool IsValid => IsLoggedIn && !IsExpired;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,6 +49,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Protobuf Include="..\Pal.Common\Protos\account.proto" Link="Protos\account.proto" GrpcServices="Client" Access="Internal" />
|
<Protobuf Include="..\Pal.Common\Protos\account.proto" Link="Protos\account.proto" GrpcServices="Client" Access="Internal" />
|
||||||
<Protobuf Include="..\Pal.Common\Protos\palace.proto" Link="Protos\palace.proto" GrpcServices="Client" Access="Internal" />
|
<Protobuf Include="..\Pal.Common\Protos\palace.proto" Link="Protos\palace.proto" GrpcServices="Client" Access="Internal" />
|
||||||
|
<Protobuf Include="..\Pal.Common\Protos\export.proto" Link="Protos\export.proto" GrpcServices="Client" Access="Internal" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
using Dalamud.Interface.Components;
|
using Account;
|
||||||
|
using Dalamud.Interface;
|
||||||
|
using Dalamud.Interface.Components;
|
||||||
|
using Dalamud.Interface.ImGuiFileDialog;
|
||||||
using Dalamud.Interface.Windowing;
|
using Dalamud.Interface.Windowing;
|
||||||
using Dalamud.Logging;
|
using Dalamud.Logging;
|
||||||
using ECommons.Reflection;
|
using ECommons.Reflection;
|
||||||
using ECommons.SplatoonAPI;
|
using ECommons.SplatoonAPI;
|
||||||
|
using Google.Protobuf;
|
||||||
using ImGuiNET;
|
using ImGuiNET;
|
||||||
|
using Pal.Client.Net;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
@ -32,6 +38,12 @@ namespace Pal.Client.Windows
|
|||||||
|
|
||||||
private string? _connectionText;
|
private string? _connectionText;
|
||||||
private bool _switchToCommunityTab;
|
private bool _switchToCommunityTab;
|
||||||
|
private string _openImportPath = string.Empty;
|
||||||
|
private string _saveExportPath = string.Empty;
|
||||||
|
private string? _openImportDialogStartPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||||
|
private string? _saveExportDialogStartPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||||
|
private FileDialogManager _importDialog;
|
||||||
|
private FileDialogManager _exportDialog;
|
||||||
|
|
||||||
public ConfigWindow() : base("Palace Pal###PalPalaceConfig")
|
public ConfigWindow() : base("Palace Pal###PalPalaceConfig")
|
||||||
{
|
{
|
||||||
@ -41,6 +53,9 @@ namespace Pal.Client.Windows
|
|||||||
SizeCondition = ImGuiCond.FirstUseEver;
|
SizeCondition = ImGuiCond.FirstUseEver;
|
||||||
Position = new Vector2(300, 300);
|
Position = new Vector2(300, 300);
|
||||||
PositionCondition = ImGuiCond.FirstUseEver;
|
PositionCondition = ImGuiCond.FirstUseEver;
|
||||||
|
|
||||||
|
_importDialog = new FileDialogManager { AddedWindowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking };
|
||||||
|
_exportDialog = new FileDialogManager { AddedWindowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnOpen()
|
public override void OnOpen()
|
||||||
@ -59,6 +74,12 @@ namespace Pal.Client.Windows
|
|||||||
_connectionText = null;
|
_connectionText = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override void OnClose()
|
||||||
|
{
|
||||||
|
_importDialog.Reset();
|
||||||
|
_exportDialog.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
public override void Draw()
|
public override void Draw()
|
||||||
{
|
{
|
||||||
bool save = false;
|
bool save = false;
|
||||||
@ -67,11 +88,15 @@ namespace Pal.Client.Windows
|
|||||||
{
|
{
|
||||||
DrawTrapCofferTab(ref save, ref saveAndClose);
|
DrawTrapCofferTab(ref save, ref saveAndClose);
|
||||||
DrawCommunityTab(ref saveAndClose);
|
DrawCommunityTab(ref saveAndClose);
|
||||||
|
//DrawImportTab();
|
||||||
|
DrawExportTab();
|
||||||
DrawDebugTab();
|
DrawDebugTab();
|
||||||
|
|
||||||
ImGui.EndTabBar();
|
ImGui.EndTabBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_importDialog.Draw();
|
||||||
|
|
||||||
if (save || saveAndClose)
|
if (save || saveAndClose)
|
||||||
{
|
{
|
||||||
var config = Service.Configuration;
|
var config = Service.Configuration;
|
||||||
@ -169,6 +194,63 @@ namespace Pal.Client.Windows
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DrawImportTab()
|
||||||
|
{
|
||||||
|
if (ImGui.BeginTabItem("Import"))
|
||||||
|
{
|
||||||
|
ImGui.Text("File to Import:");
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.InputTextWithHint("", "Path to *.pal file", ref _openImportPath, 260);
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGuiComponents.IconButton(FontAwesomeIcon.Search))
|
||||||
|
{
|
||||||
|
_importDialog.OpenFileDialog("Palace Pal - Import", "Palace Pal (*.pal) {*.pal}", (success, paths) =>
|
||||||
|
{
|
||||||
|
if (success && paths.Count == 1)
|
||||||
|
{
|
||||||
|
_openImportPath = paths.First();
|
||||||
|
}
|
||||||
|
}, selectionCountMax: 1, startPath: _openImportDialogStartPath, isModal: false);
|
||||||
|
_openImportDialogStartPath = null; // only use this once, FileDialogManager will save path between calls
|
||||||
|
}
|
||||||
|
ImGui.EndTabItem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawExportTab()
|
||||||
|
{
|
||||||
|
if (Service.RemoteApi.HasRoleOnCurrentServer("export:run") && ImGui.BeginTabItem("Export"))
|
||||||
|
{
|
||||||
|
string todaysFileName = $"export-{DateTime.Today:yyyy-MM-dd}.pal";
|
||||||
|
if (string.IsNullOrEmpty(_saveExportPath) && !string.IsNullOrEmpty(_saveExportDialogStartPath))
|
||||||
|
_saveExportPath = Path.Join(_saveExportDialogStartPath, todaysFileName);
|
||||||
|
|
||||||
|
ImGui.TextWrapped($"Export all markers from {RemoteApi.RemoteUrl}:");
|
||||||
|
ImGui.Text("Save as:");
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.InputTextWithHint("", "Path to *.pal file", ref _saveExportPath, 260);
|
||||||
|
ImGui.SameLine();
|
||||||
|
if (ImGuiComponents.IconButton(FontAwesomeIcon.Search))
|
||||||
|
{
|
||||||
|
_importDialog.SaveFileDialog("Palace Pal - Export", "Palace Pal (*.pal) {*.pal}", todaysFileName, "pal", (success, path) =>
|
||||||
|
{
|
||||||
|
if (success && !string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
_saveExportPath = path;
|
||||||
|
}
|
||||||
|
}, startPath: _saveExportDialogStartPath, isModal: false);
|
||||||
|
_saveExportDialogStartPath = null; // only use this once, FileDialogManager will save path between calls
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui.BeginDisabled(string.IsNullOrEmpty(_saveExportPath) || File.Exists(_saveExportPath));
|
||||||
|
if (ImGui.Button("Start Export"))
|
||||||
|
this.DoExport(_saveExportPath);
|
||||||
|
ImGui.EndDisabled();
|
||||||
|
|
||||||
|
ImGui.EndTabItem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawDebugTab()
|
private void DrawDebugTab()
|
||||||
{
|
{
|
||||||
if (ImGui.BeginTabItem("Debug"))
|
if (ImGui.BeginTabItem("Debug"))
|
||||||
@ -227,10 +309,10 @@ namespace Pal.Client.Windows
|
|||||||
if (pos != null)
|
if (pos != null)
|
||||||
{
|
{
|
||||||
var elements = new List<Element>
|
var elements = new List<Element>
|
||||||
{
|
{
|
||||||
Plugin.CreateSplatoonElement(Marker.EType.Trap, pos.Value, _trapColor),
|
Plugin.CreateSplatoonElement(Marker.EType.Trap, pos.Value, _trapColor),
|
||||||
Plugin.CreateSplatoonElement(Marker.EType.Hoard, pos.Value, _hoardColor),
|
Plugin.CreateSplatoonElement(Marker.EType.Hoard, pos.Value, _hoardColor),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!Splatoon.AddDynamicElements("PalacePal.Test", elements.ToArray(), new long[] { Environment.TickCount64 + 10000 }))
|
if (!Splatoon.AddDynamicElements("PalacePal.Test", elements.ToArray(), new long[] { Environment.TickCount64 + 10000 }))
|
||||||
{
|
{
|
||||||
@ -299,5 +381,32 @@ namespace Pal.Client.Windows
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void DoExport(string destination)
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(bool success, ExportRoot export) = await Service.RemoteApi.DoExport();
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
using var output = File.Create(destination);
|
||||||
|
export.WriteTo(output);
|
||||||
|
|
||||||
|
Service.Chat.Print($"Export saved as {destination}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Service.Chat.PrintError("Export failed due to server error.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
PluginLog.Error(e, "Export failed");
|
||||||
|
Service.Chat.PrintError($"Export failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user