feat: 初始功能
This commit is contained in:
9
Services/Abstractions/IAppLogger.cs
Normal file
9
Services/Abstractions/IAppLogger.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface IAppLogger
|
||||
{
|
||||
string LogDirectory { get; }
|
||||
void Write(AppLogEntry entry);
|
||||
}
|
||||
16
Services/Abstractions/IFileService.cs
Normal file
16
Services/Abstractions/IFileService.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface IFileService
|
||||
{
|
||||
long MaxFileSizeBytes { get; }
|
||||
IReadOnlyCollection<string> SupportedExtensions { get; }
|
||||
|
||||
bool IsSupported(string path);
|
||||
FileValidationResult ValidateFile(string path, long? maxFileSizeBytes = null);
|
||||
CloudPrintDocument CreateLocalDocument(string path);
|
||||
CloudPrintDocument CopyToLibrary(string sourcePath, string libraryRoot, string categoryName, int categoryId = 0);
|
||||
string PrepareForPrint(PrintJob job);
|
||||
Task<FilePreparationResult> PrepareForPrintAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default);
|
||||
}
|
||||
27
Services/Abstractions/IPrintBackendClient.cs
Normal file
27
Services/Abstractions/IPrintBackendClient.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface IPrintBackendClient : IAsyncDisposable
|
||||
{
|
||||
event EventHandler<PrintJobReceivedEventArgs>? PrintJobReceived;
|
||||
event EventHandler<AppLogEntry>? LogReceived;
|
||||
|
||||
bool IsConnected { get; }
|
||||
int PrinterId { get; }
|
||||
string Token { get; }
|
||||
AdminSession AdminSession { get; }
|
||||
|
||||
Task ConnectAsync(string address, string apiKey, CancellationToken cancellationToken = default);
|
||||
Task<AdminSession> LoginAdminAsync(string username, string password, CancellationToken cancellationToken = default);
|
||||
void SetAdminToken(string username, string accessToken);
|
||||
void LogoutAdmin();
|
||||
Task RegisterPrinterAsync(PrinterInfo printer, string apiKey, CancellationToken cancellationToken = default);
|
||||
Task SubscribePrintJobsAsync(PrinterInfo printer, CancellationToken cancellationToken = default);
|
||||
Task<string> DownloadPrintFileAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default);
|
||||
Task UploadLibraryDocumentAsync(CloudPrintDocument document, CancellationToken cancellationToken = default);
|
||||
Task CancelPrintJobAsync(PrintJob job, CancellationToken cancellationToken = default);
|
||||
Task SendStatusUpdateAsync(PrintJob job, CancellationToken cancellationToken = default);
|
||||
Task SendHeartbeatAsync(PrinterInfo printer, CancellationToken cancellationToken = default);
|
||||
void Disconnect();
|
||||
}
|
||||
13
Services/Abstractions/IPrintService.cs
Normal file
13
Services/Abstractions/IPrintService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface IPrintService
|
||||
{
|
||||
IReadOnlyList<PrinterInfo> GetPrinters();
|
||||
PrinterStatus GetPrinterStatus(string printerName);
|
||||
Task<string> SubmitPrintJobAsync(PrintJob job, IProgress<int>? progress = null, CancellationToken cancellationToken = default);
|
||||
bool CancelPrintJob(string jobId);
|
||||
bool CancelPrintJob(PrintJob job);
|
||||
PrintJobStatus GetJobStatus(string jobId);
|
||||
}
|
||||
7
Services/Abstractions/ISecretProtector.cs
Normal file
7
Services/Abstractions/ISecretProtector.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface ISecretProtector
|
||||
{
|
||||
string Protect(string plainText);
|
||||
string Unprotect(string protectedText);
|
||||
}
|
||||
9
Services/Abstractions/ISettingsService.cs
Normal file
9
Services/Abstractions/ISettingsService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface ISettingsService
|
||||
{
|
||||
ClientSettings Load();
|
||||
void Save(ClientSettings settings);
|
||||
}
|
||||
14
Services/Abstractions/IWebSocketService.cs
Normal file
14
Services/Abstractions/IWebSocketService.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
public interface IWebSocketService
|
||||
{
|
||||
event EventHandler<AppLogEntry>? LogReceived;
|
||||
|
||||
bool IsConnected { get; }
|
||||
Uri? Endpoint { get; }
|
||||
|
||||
Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default);
|
||||
Task DisconnectAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
34
Services/DpapiSecretProtector.cs
Normal file
34
Services/DpapiSecretProtector.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
public sealed class DpapiSecretProtector : ISecretProtector
|
||||
{
|
||||
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("CloudPrint.Client.Settings.v1");
|
||||
|
||||
public string Protect(string plainText)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plainText))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(plainText);
|
||||
var protectedBytes = ProtectedData.Protect(bytes, Entropy, DataProtectionScope.CurrentUser);
|
||||
return Convert.ToBase64String(protectedBytes);
|
||||
}
|
||||
|
||||
public string Unprotect(string protectedText)
|
||||
{
|
||||
if (string.IsNullOrEmpty(protectedText))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var protectedBytes = Convert.FromBase64String(protectedText);
|
||||
var bytes = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
}
|
||||
38
Services/FileAppLogger.cs
Normal file
38
Services/FileAppLogger.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.IO;
|
||||
using CloudPrint.Client.Models;
|
||||
using CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
public sealed class FileAppLogger : IAppLogger
|
||||
{
|
||||
private readonly object _syncRoot = new();
|
||||
|
||||
public FileAppLogger()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
LogDirectory = Path.Combine(appData, "CloudPrint.Client", "logs");
|
||||
}
|
||||
|
||||
public string LogDirectory { get; }
|
||||
|
||||
public void Write(AppLogEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
Directory.CreateDirectory(LogDirectory);
|
||||
|
||||
var logPath = Path.Combine(LogDirectory, $"cloudprint-{DateTime.Now:yyyyMMdd}.log");
|
||||
lock (_syncRoot)
|
||||
{
|
||||
File.AppendAllText(logPath, Format(entry) + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Format(AppLogEntry entry)
|
||||
{
|
||||
var exceptionText = string.IsNullOrWhiteSpace(entry.ExceptionText)
|
||||
? string.Empty
|
||||
: $" | Exception={entry.ExceptionText}";
|
||||
return $"{entry.Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{entry.LevelText}] {entry.Message}{exceptionText}";
|
||||
}
|
||||
}
|
||||
378
Services/FileService.cs
Normal file
378
Services/FileService.cs
Normal file
@@ -0,0 +1,378 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using CloudPrint.Client.Helpers;
|
||||
using CloudPrint.Client.Models;
|
||||
using CloudPrint.Client.Services.Abstractions;
|
||||
using SharpCompress.Common;
|
||||
using SharpCompress.Readers;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 文件处理服务:校验白名单、管理本地文库副本、解压压缩包、转换 Office/TXT 并处理 PDF 页码范围。
|
||||
/// </summary>
|
||||
public sealed class FileService : IFileService
|
||||
{
|
||||
private const int MaxArchiveEntries = 512;
|
||||
private const int MaxArchiveNestingDepth = 3;
|
||||
private const long MaxExtractedFileSizeBytes = 200L * 1024 * 1024;
|
||||
|
||||
private static readonly HashSet<string> SupportedExtensionSet = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".pdf", ".doc", ".docx", ".txt", ".xls", ".xlsx", ".ppt", ".pptx",
|
||||
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".zip", ".rar"
|
||||
};
|
||||
|
||||
private readonly PdfProcessor _pdfProcessor;
|
||||
|
||||
public FileService()
|
||||
: this(new PdfProcessor())
|
||||
{
|
||||
}
|
||||
|
||||
public FileService(PdfProcessor pdfProcessor)
|
||||
{
|
||||
_pdfProcessor = pdfProcessor;
|
||||
}
|
||||
|
||||
public long MaxFileSizeBytes { get; } = 50L * 1024 * 1024;
|
||||
|
||||
public IReadOnlyCollection<string> SupportedExtensions => SupportedExtensionSet;
|
||||
|
||||
public bool IsSupported(string path)
|
||||
{
|
||||
return SupportedExtensionSet.Contains(Path.GetExtension(path));
|
||||
}
|
||||
|
||||
public FileValidationResult ValidateFile(string path, long? maxFileSizeBytes = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return FileValidationResult.Failure("文件路径不能为空");
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return FileValidationResult.Failure("文件不存在");
|
||||
}
|
||||
|
||||
if (!IsSupported(path))
|
||||
{
|
||||
return FileValidationResult.Failure($"不支持的文件类型:{Path.GetExtension(path)}");
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(path);
|
||||
var limit = maxFileSizeBytes ?? MaxFileSizeBytes;
|
||||
if (fileInfo.Length > limit)
|
||||
{
|
||||
return FileValidationResult.Failure($"文件超过大小限制:{FormatSize(limit)}");
|
||||
}
|
||||
|
||||
return FileValidationResult.Success();
|
||||
}
|
||||
|
||||
public CloudPrintDocument CreateLocalDocument(string path)
|
||||
{
|
||||
var validation = ValidateFile(path);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
throw new InvalidOperationException(validation.Message);
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(path);
|
||||
return new CloudPrintDocument
|
||||
{
|
||||
Id = Environment.TickCount,
|
||||
FileName = fileInfo.Name,
|
||||
FilePath = fileInfo.FullName,
|
||||
FileType = fileInfo.Extension.TrimStart('.').ToLowerInvariant(),
|
||||
FileSize = fileInfo.Length,
|
||||
Title = Path.GetFileNameWithoutExtension(fileInfo.Name),
|
||||
Description = "本地文库待上传文件",
|
||||
CategoryName = "未分类",
|
||||
CreatedAt = DateTime.Now
|
||||
};
|
||||
}
|
||||
|
||||
public CloudPrintDocument CopyToLibrary(string sourcePath, string libraryRoot, string categoryName, int categoryId = 0)
|
||||
{
|
||||
var document = CreateLocalDocument(sourcePath);
|
||||
var safeCategoryName = PathHelper.SanitizePathSegment(string.IsNullOrWhiteSpace(categoryName) ? "未分类" : categoryName);
|
||||
var root = string.IsNullOrWhiteSpace(libraryRoot)
|
||||
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "CloudPrintLibrary")
|
||||
: libraryRoot;
|
||||
|
||||
var targetDirectory = Path.Combine(root, safeCategoryName);
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
|
||||
var targetPath = PathHelper.CreateUniqueFilePath(targetDirectory, document.FileName);
|
||||
File.Copy(sourcePath, targetPath);
|
||||
|
||||
var copiedFileInfo = new FileInfo(targetPath);
|
||||
return new CloudPrintDocument
|
||||
{
|
||||
Id = Environment.TickCount,
|
||||
FileName = copiedFileInfo.Name,
|
||||
FilePath = copiedFileInfo.FullName,
|
||||
FileType = copiedFileInfo.Extension.TrimStart('.').ToLowerInvariant(),
|
||||
FileSize = copiedFileInfo.Length,
|
||||
Title = document.Title,
|
||||
Description = document.Description,
|
||||
CategoryName = categoryName,
|
||||
CategoryId = categoryId,
|
||||
SyncStatus = "本地",
|
||||
CreatedAt = DateTime.Now
|
||||
};
|
||||
}
|
||||
|
||||
public string PrepareForPrint(PrintJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(job.FilePath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(job.FilePath).ToLowerInvariant();
|
||||
return extension switch
|
||||
{
|
||||
".pdf" => job.FilePath,
|
||||
".jpg" or ".jpeg" or ".png" or ".bmp" or ".gif" => job.FilePath,
|
||||
".doc" or ".docx" or ".xls" or ".xlsx" or ".ppt" or ".pptx" or ".txt" => job.FilePath,
|
||||
".zip" or ".rar" => job.FilePath,
|
||||
_ => job.FilePath
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<FilePreparationResult> PrepareForPrintAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(job.FilePath))
|
||||
{
|
||||
throw new InvalidOperationException("打印任务缺少文件路径");
|
||||
}
|
||||
|
||||
if (!File.Exists(job.FilePath))
|
||||
{
|
||||
throw new FileNotFoundException("打印文件不存在", job.FilePath);
|
||||
}
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(cacheRoot)
|
||||
? Path.Combine(Path.GetTempPath(), "CloudPrint.Client", "prepared")
|
||||
: cacheRoot;
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
return await PreparePathAsync(job.FilePath, root, job.PageRange, 0, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task<FilePreparationResult> PreparePathAsync(string sourcePath, string root, string pageRange, int archiveDepth, CancellationToken cancellationToken)
|
||||
{
|
||||
var extension = Path.GetExtension(sourcePath).ToLowerInvariant();
|
||||
|
||||
if (IsPdf(extension))
|
||||
{
|
||||
return await PreparePdfAsync(sourcePath, root, pageRange, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
if (IsImage(extension))
|
||||
{
|
||||
return new FilePreparationResult
|
||||
{
|
||||
SourcePath = sourcePath,
|
||||
PreparedPath = sourcePath,
|
||||
Converted = false,
|
||||
Message = "文件无需转换"
|
||||
};
|
||||
}
|
||||
|
||||
if (extension is ".zip" or ".rar")
|
||||
{
|
||||
if (archiveDepth >= MaxArchiveNestingDepth)
|
||||
{
|
||||
throw new InvalidOperationException($"压缩包嵌套层级过深,最多允许 {MaxArchiveNestingDepth} 层");
|
||||
}
|
||||
|
||||
var extractedPath = ExtractFirstPrintableFileFromArchive(sourcePath, root);
|
||||
return await PreparePathAsync(extractedPath, root, pageRange, archiveDepth + 1, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
if (IsOfficeOrText(extension))
|
||||
{
|
||||
var pdfPath = await ConvertToPdfWithLibreOfficeAsync(sourcePath, root, cancellationToken).ConfigureAwait(true);
|
||||
var preparedPdf = await PreparePdfAsync(pdfPath, root, pageRange, cancellationToken).ConfigureAwait(true);
|
||||
return new FilePreparationResult
|
||||
{
|
||||
SourcePath = sourcePath,
|
||||
PreparedPath = preparedPdf.PreparedPath,
|
||||
Converted = true,
|
||||
Message = preparedPdf.PreparedPath == pdfPath ? "已转换为 PDF" : "已转换为 PDF 并裁剪页码范围"
|
||||
};
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"不支持的打印文件类型:{extension}");
|
||||
}
|
||||
|
||||
private async Task<FilePreparationResult> PreparePdfAsync(string pdfPath, string root, string pageRange, CancellationToken cancellationToken)
|
||||
{
|
||||
var outputDirectory = Path.Combine(root, "pdf-pages", DateTime.Now.ToString("yyyyMMddHHmmssfff"));
|
||||
var preparedPath = await _pdfProcessor.ExtractPageRangeAsync(pdfPath, pageRange, outputDirectory, cancellationToken).ConfigureAwait(true);
|
||||
return new FilePreparationResult
|
||||
{
|
||||
SourcePath = pdfPath,
|
||||
PreparedPath = preparedPath,
|
||||
Converted = preparedPath != pdfPath,
|
||||
Message = preparedPath == pdfPath ? "PDF 文件无需转换" : $"已按页码范围裁剪:{pageRange}"
|
||||
};
|
||||
}
|
||||
|
||||
private string ExtractFirstPrintableFileFromArchive(string archivePath, string root)
|
||||
{
|
||||
var extractDirectory = Path.Combine(root, PathHelper.SanitizePathSegment(Path.GetFileNameWithoutExtension(archivePath)), DateTime.Now.ToString("yyyyMMddHHmmssfff"));
|
||||
Directory.CreateDirectory(extractDirectory);
|
||||
|
||||
using var stream = File.OpenRead(archivePath);
|
||||
using var reader = ReaderFactory.OpenReader(stream);
|
||||
var entryCount = 0;
|
||||
|
||||
while (reader.MoveToNextEntry())
|
||||
{
|
||||
if (reader.Entry.IsDirectory)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entryCount++;
|
||||
if (entryCount > MaxArchiveEntries)
|
||||
{
|
||||
throw new InvalidOperationException($"压缩包条目过多,最多允许 {MaxArchiveEntries} 个文件");
|
||||
}
|
||||
|
||||
if (!IsPrintableArchiveEntry(reader.Entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reader.Entry.Size > MaxExtractedFileSizeBytes)
|
||||
{
|
||||
throw new InvalidOperationException($"压缩包内文件超过解压大小限制:{FormatSize(MaxExtractedFileSizeBytes)}");
|
||||
}
|
||||
|
||||
var targetPath = CreateSafeExtractPath(extractDirectory, reader.Entry.Key);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? extractDirectory);
|
||||
using var input = reader.OpenEntryStream();
|
||||
using var output = File.Create(targetPath);
|
||||
input.CopyTo(output);
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("压缩包中没有可打印文件");
|
||||
}
|
||||
|
||||
private static string CreateSafeExtractPath(string extractDirectory, string? entryKey)
|
||||
{
|
||||
var normalizedKey = string.IsNullOrWhiteSpace(entryKey)
|
||||
? Guid.NewGuid().ToString("N")
|
||||
: entryKey.Replace('\\', '/');
|
||||
var safeSegments = normalizedKey
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(segment => segment != "." && segment != "..")
|
||||
.Select(PathHelper.SanitizePathSegment)
|
||||
.Where(segment => !string.IsNullOrWhiteSpace(segment))
|
||||
.ToArray();
|
||||
|
||||
if (safeSegments.Length == 0)
|
||||
{
|
||||
safeSegments = new[] { Guid.NewGuid().ToString("N") };
|
||||
}
|
||||
|
||||
var targetPath = Path.GetFullPath(Path.Combine(new[] { extractDirectory }.Concat(safeSegments).ToArray()));
|
||||
var safeRoot = Path.GetFullPath(extractDirectory) + Path.DirectorySeparatorChar;
|
||||
if (!targetPath.StartsWith(safeRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("压缩包包含不安全的文件路径");
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
private static bool IsPrintableArchiveEntry(IEntry entry)
|
||||
{
|
||||
var extension = Path.GetExtension(entry.Key ?? string.Empty).ToLowerInvariant();
|
||||
return SupportedExtensionSet.Contains(extension);
|
||||
}
|
||||
|
||||
private static async Task<string> ConvertToPdfWithLibreOfficeAsync(string sourcePath, string outputRoot, CancellationToken cancellationToken)
|
||||
{
|
||||
var sofficePath = FindLibreOfficeExecutable();
|
||||
if (string.IsNullOrWhiteSpace(sofficePath))
|
||||
{
|
||||
throw new FileNotFoundException("未找到 LibreOffice soffice.exe,无法转换 Office/TXT 文件");
|
||||
}
|
||||
|
||||
var outputDirectory = Path.Combine(outputRoot, "converted", DateTime.Now.ToString("yyyyMMddHHmmssfff"));
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
Arguments = $"--headless --convert-to pdf --outdir \"{outputDirectory}\" \"{sourcePath}\"",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("启动 LibreOffice 失败");
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(true);
|
||||
throw new InvalidOperationException($"LibreOffice 转换失败:{error}");
|
||||
}
|
||||
|
||||
var expectedPdfPath = Path.Combine(outputDirectory, Path.ChangeExtension(Path.GetFileName(sourcePath), ".pdf"));
|
||||
if (File.Exists(expectedPdfPath))
|
||||
{
|
||||
return expectedPdfPath;
|
||||
}
|
||||
|
||||
var generatedPdf = Directory.EnumerateFiles(outputDirectory, "*.pdf", SearchOption.TopDirectoryOnly).FirstOrDefault();
|
||||
return generatedPdf ?? throw new FileNotFoundException("LibreOffice 未生成 PDF 文件");
|
||||
}
|
||||
|
||||
private static string? FindLibreOfficeExecutable()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Environment.GetEnvironmentVariable("SOFFICE_PATH"),
|
||||
@"C:\Program Files\LibreOffice\program\soffice.exe",
|
||||
@"C:\Program Files (x86)\LibreOffice\program\soffice.exe"
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path));
|
||||
}
|
||||
|
||||
private static bool IsPdf(string extension) => extension == ".pdf";
|
||||
|
||||
private static bool IsImage(string extension)
|
||||
{
|
||||
return extension is ".jpg" or ".jpeg" or ".png" or ".bmp" or ".gif";
|
||||
}
|
||||
|
||||
private static bool IsOfficeOrText(string extension)
|
||||
{
|
||||
return extension is ".doc" or ".docx" or ".xls" or ".xlsx" or ".ppt" or ".pptx" or ".txt";
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
return bytes >= 1024 * 1024
|
||||
? $"{bytes / 1024d / 1024d:F2} MB"
|
||||
: $"{bytes / 1024d:F2} KB";
|
||||
}
|
||||
}
|
||||
55
Services/Grpc/CloudPrintGrpcMapper.cs
Normal file
55
Services/Grpc/CloudPrintGrpcMapper.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Grpc;
|
||||
|
||||
public static class CloudPrintGrpcMapper
|
||||
{
|
||||
public static PrintJob ToPrintJob(Cloudprint.PrintJob backendJob)
|
||||
{
|
||||
var orderItem = backendJob.OrderItem;
|
||||
var file = orderItem?.File;
|
||||
|
||||
return new PrintJob
|
||||
{
|
||||
Id = backendJob.Id,
|
||||
JobNo = backendJob.JobNo,
|
||||
PrinterId = backendJob.PrinterId,
|
||||
OrderItemId = backendJob.OrderItemId,
|
||||
FileName = file?.FileName ?? backendJob.JobNo,
|
||||
FilePath = file?.FilePath ?? string.Empty,
|
||||
FileId = orderItem?.FileId ?? 0,
|
||||
Copies = Math.Max(1, orderItem?.Copies ?? 1),
|
||||
Color = orderItem?.Color ?? false,
|
||||
Duplex = orderItem?.Duplex ?? false,
|
||||
PageRange = string.IsNullOrWhiteSpace(orderItem?.PageRange) ? "all" : orderItem.PageRange,
|
||||
Status = FromBackendStatus(backendJob.Status),
|
||||
Progress = backendJob.Progress,
|
||||
ErrorMessage = backendJob.ErrorMsg
|
||||
};
|
||||
}
|
||||
|
||||
public static string ToBackendStatus(PrintJobStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
PrintJobStatus.Pending => "pending",
|
||||
PrintJobStatus.Printing => "printing",
|
||||
PrintJobStatus.Completed => "completed",
|
||||
PrintJobStatus.Failed => "failed",
|
||||
PrintJobStatus.Cancelled => "cancelled",
|
||||
_ => "pending"
|
||||
};
|
||||
}
|
||||
|
||||
private static PrintJobStatus FromBackendStatus(string status)
|
||||
{
|
||||
return status?.ToLowerInvariant() switch
|
||||
{
|
||||
"printing" => PrintJobStatus.Printing,
|
||||
"completed" => PrintJobStatus.Completed,
|
||||
"failed" => PrintJobStatus.Failed,
|
||||
"cancelled" => PrintJobStatus.Cancelled,
|
||||
_ => PrintJobStatus.Pending
|
||||
};
|
||||
}
|
||||
}
|
||||
30
Services/Grpc/GrpcMetadataFactory.cs
Normal file
30
Services/Grpc/GrpcMetadataFactory.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using CloudPrint.Client.Models;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace CloudPrint.Client.Services.Grpc;
|
||||
|
||||
public static class GrpcMetadataFactory
|
||||
{
|
||||
public static Metadata Create(string apiKey, string printerToken, AdminSession adminSession)
|
||||
{
|
||||
var metadata = new Metadata();
|
||||
|
||||
AddIfNotEmpty(metadata, "x-api-key", apiKey);
|
||||
AddIfNotEmpty(metadata, "x-printer-token", printerToken);
|
||||
|
||||
if (adminSession.IsAuthenticated)
|
||||
{
|
||||
metadata.Add("authorization", $"Bearer {adminSession.AccessToken}");
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static void AddIfNotEmpty(Metadata metadata, string key, string value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
metadata.Add(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
362
Services/GrpcService.cs
Normal file
362
Services/GrpcService.cs
Normal file
@@ -0,0 +1,362 @@
|
||||
using CloudPrint.Client.Models;
|
||||
using CloudPrint.Client.Services.Grpc;
|
||||
using CloudPrint.Client.Services.Abstractions;
|
||||
using Google.Protobuf;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using System.IO;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
public sealed class PrintJobReceivedEventArgs : EventArgs
|
||||
{
|
||||
public PrintJobReceivedEventArgs(PrintJob job)
|
||||
{
|
||||
Job = job;
|
||||
}
|
||||
|
||||
public PrintJob Job { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 真实 gRPC 通信服务,负责连接后端、注册打印机、订阅任务、下载文件和状态上报。
|
||||
/// </summary>
|
||||
public sealed class GrpcService : IPrintBackendClient
|
||||
{
|
||||
private CancellationTokenSource? _subscriptionCts;
|
||||
private GrpcChannel? _channel;
|
||||
private Cloudprint.PrintService.PrintServiceClient? _printClient;
|
||||
private Cloudprint.LibraryService.LibraryServiceClient? _libraryClient;
|
||||
private Cloudprint.FileService.FileServiceClient? _fileClient;
|
||||
private Cloudprint.AdminService.AdminServiceClient? _adminClient;
|
||||
private string _apiKey = string.Empty;
|
||||
|
||||
public event EventHandler<PrintJobReceivedEventArgs>? PrintJobReceived;
|
||||
public event EventHandler<AppLogEntry>? LogReceived;
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
public int PrinterId { get; private set; }
|
||||
public string Token { get; private set; } = string.Empty;
|
||||
public AdminSession AdminSession { get; private set; } = new();
|
||||
public bool AllowInsecureGrpc { get; set; } = true;
|
||||
|
||||
public async Task ConnectAsync(string address, string apiKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
throw new ArgumentException("服务器地址不能为空", nameof(address));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
throw new ArgumentException("API Key 不能为空", nameof(apiKey));
|
||||
}
|
||||
|
||||
_apiKey = apiKey;
|
||||
if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && AllowInsecureGrpc)
|
||||
{
|
||||
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
||||
}
|
||||
|
||||
_channel?.Dispose();
|
||||
_channel = GrpcChannel.ForAddress(address);
|
||||
_printClient = new Cloudprint.PrintService.PrintServiceClient(_channel);
|
||||
_libraryClient = new Cloudprint.LibraryService.LibraryServiceClient(_channel);
|
||||
_fileClient = new Cloudprint.FileService.FileServiceClient(_channel);
|
||||
_adminClient = new Cloudprint.AdminService.AdminServiceClient(_channel);
|
||||
IsConnected = true;
|
||||
Log(AppLogLevel.Success, $"已连接 gRPC 服务:{address}");
|
||||
}
|
||||
|
||||
public async Task<AdminSession> LoginAdminAsync(string username, string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
throw new ArgumentException("管理员用户名不能为空", nameof(username));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
throw new ArgumentException("管理员密码不能为空", nameof(password));
|
||||
}
|
||||
|
||||
var response = await EnsureAdminClient().LoginAsync(new Cloudprint.AdminLoginRequest
|
||||
{
|
||||
Username = username,
|
||||
Password = password
|
||||
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
||||
|
||||
AdminSession = new AdminSession
|
||||
{
|
||||
AdminId = response.Admin?.Id ?? 0,
|
||||
Username = response.Admin?.Username ?? username,
|
||||
Nickname = response.Admin?.Nickname ?? string.Empty,
|
||||
Role = response.Admin?.Role ?? 0,
|
||||
AccessToken = response.AccessToken
|
||||
};
|
||||
Log(AppLogLevel.Success, $"管理员登录成功:{AdminSession.DisplayName}");
|
||||
return AdminSession;
|
||||
}
|
||||
|
||||
public void SetAdminToken(string username, string accessToken)
|
||||
{
|
||||
AdminSession = new AdminSession
|
||||
{
|
||||
Username = username,
|
||||
Nickname = username,
|
||||
AccessToken = accessToken
|
||||
};
|
||||
}
|
||||
|
||||
public void LogoutAdmin()
|
||||
{
|
||||
AdminSession = new AdminSession();
|
||||
Log(AppLogLevel.Warning, "管理员已退出登录");
|
||||
}
|
||||
|
||||
public async Task RegisterPrinterAsync(PrinterInfo printer, string apiKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(printer);
|
||||
var client = EnsurePrintClient();
|
||||
var response = await client.RegisterPrinterAsync(new Cloudprint.RegisterPrinterRequest
|
||||
{
|
||||
Name = printer.Name,
|
||||
DisplayName = printer.DisplayName,
|
||||
ApiKey = apiKey
|
||||
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
||||
|
||||
PrinterId = response.PrinterId;
|
||||
Token = response.Token;
|
||||
Log(AppLogLevel.Success, $"已注册打印机:{printer.DisplayName},PrinterId={PrinterId}");
|
||||
}
|
||||
|
||||
public async Task SubscribePrintJobsAsync(PrinterInfo printer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsConnected)
|
||||
{
|
||||
throw new InvalidOperationException("请先连接 gRPC 服务");
|
||||
}
|
||||
|
||||
_subscriptionCts?.Cancel();
|
||||
_subscriptionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var token = _subscriptionCts.Token;
|
||||
|
||||
Log(AppLogLevel.Info, $"开始订阅打印任务流:{printer.DisplayName}");
|
||||
_ = Task.Run(async () => await ReadPrintJobStreamAsync(token).ConfigureAwait(false), CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task<string> DownloadPrintFileAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
if (job.FileId <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("打印任务缺少 file_id,无法下载文件");
|
||||
}
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(cacheRoot)
|
||||
? Path.Combine(Path.GetTempPath(), "CloudPrint.Client", "print-cache")
|
||||
: cacheRoot;
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(job.FileName) ? $"{job.JobNo}.bin" : job.FileName;
|
||||
var targetPath = PathHelper.CreateUniqueFilePath(root, fileName);
|
||||
using var call = EnsureFileClient().GetFile(new Cloudprint.GetFileRequest { FileId = job.FileId }, headers: CreateMetadata(), cancellationToken: cancellationToken);
|
||||
await using var output = File.Create(targetPath);
|
||||
|
||||
await foreach (var chunk in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (chunk.Data.Length > 0)
|
||||
{
|
||||
chunk.Data.WriteTo(output);
|
||||
}
|
||||
}
|
||||
|
||||
Log(AppLogLevel.Success, $"打印文件已下载:{targetPath}");
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
public async Task UploadLibraryDocumentAsync(CloudPrintDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
if (!IsConnected)
|
||||
{
|
||||
throw new InvalidOperationException("请先连接 gRPC 服务");
|
||||
}
|
||||
|
||||
await UploadLibraryDocumentByGrpcAsync(document, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
public async Task CancelPrintJobAsync(PrintJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
if (!IsConnected)
|
||||
{
|
||||
job.MarkCancelled();
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsurePrintClient().CancelPrintJobAsync(new Cloudprint.CancelPrintJobRequest
|
||||
{
|
||||
JobNo = job.JobNo
|
||||
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
||||
|
||||
job.MarkCancelled();
|
||||
Log(AppLogLevel.Warning, $"已取消打印任务:{job.JobNo}");
|
||||
}
|
||||
|
||||
public async Task SendStatusUpdateAsync(PrintJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
if (IsConnected)
|
||||
{
|
||||
await EnsurePrintClient().ReportJobStatusAsync(new Cloudprint.ReportJobStatusRequest
|
||||
{
|
||||
JobNo = job.JobNo,
|
||||
Status = CloudPrintGrpcMapper.ToBackendStatus(job.Status),
|
||||
Progress = job.Progress,
|
||||
ErrorMsg = job.ErrorMessage
|
||||
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
Log(AppLogLevel.Info, $"上报任务状态:{job.JobNo} / {job.StatusText} / {job.Progress}%");
|
||||
}
|
||||
|
||||
public async Task SendHeartbeatAsync(PrinterInfo printer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(printer);
|
||||
if (IsConnected)
|
||||
{
|
||||
await EnsurePrintClient().PrinterHeartbeatAsync(new Cloudprint.PrinterHeartbeatRequest
|
||||
{
|
||||
PrinterId = PrinterId == 0 ? printer.Id : PrinterId,
|
||||
Status = (int)printer.Status,
|
||||
Token = Token
|
||||
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
Log(AppLogLevel.Info, $"心跳:{printer.DisplayName} / {printer.StatusText}");
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
_subscriptionCts?.Cancel();
|
||||
IsConnected = false;
|
||||
_channel?.Dispose();
|
||||
_channel = null;
|
||||
_printClient = null;
|
||||
_libraryClient = null;
|
||||
_fileClient = null;
|
||||
_adminClient = null;
|
||||
Log(AppLogLevel.Warning, "已断开 gRPC 连接");
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_subscriptionCts?.Cancel();
|
||||
_subscriptionCts?.Dispose();
|
||||
_channel?.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ReadPrintJobStreamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var call = EnsurePrintClient().SubscribeJobs(new Cloudprint.SubscribeJobsRequest
|
||||
{
|
||||
PrinterId = PrinterId,
|
||||
Token = Token
|
||||
}, headers: CreateMetadata(), cancellationToken: cancellationToken);
|
||||
|
||||
await foreach (var backendJob in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var job = CloudPrintGrpcMapper.ToPrintJob(backendJob);
|
||||
PrintJobReceived?.Invoke(this, new PrintJobReceivedEventArgs(job));
|
||||
Log(AppLogLevel.Info, $"收到打印任务:{job.JobNo}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log(AppLogLevel.Warning, "打印任务订阅已取消");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log(AppLogLevel.Error, $"打印任务订阅中断:{ex.Message}");
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadLibraryDocumentByGrpcAsync(CloudPrintDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(document.FilePath))
|
||||
{
|
||||
throw new FileNotFoundException("文库文件不存在", document.FilePath);
|
||||
}
|
||||
|
||||
document.SyncStatus = "上传中";
|
||||
using var call = EnsureLibraryClient().UploadFile(headers: CreateMetadata(), cancellationToken: cancellationToken);
|
||||
const int bufferSize = 64 * 1024;
|
||||
var buffer = new byte[bufferSize];
|
||||
await using var stream = File.OpenRead(document.FilePath);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(true);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await call.RequestStream.WriteAsync(new Cloudprint.UploadLibraryFileRequest
|
||||
{
|
||||
CategoryId = document.CategoryId,
|
||||
Title = document.Title,
|
||||
Description = document.Description,
|
||||
FileName = document.FileName,
|
||||
FileType = document.FileType,
|
||||
Data = ByteString.CopyFrom(buffer, 0, read)
|
||||
}, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
await call.RequestStream.CompleteAsync().ConfigureAwait(true);
|
||||
_ = await call.ResponseAsync.ConfigureAwait(true);
|
||||
document.IsUploaded = true;
|
||||
document.SyncStatus = "已同步";
|
||||
Log(AppLogLevel.Success, $"文库文件已上传:{document.Title}");
|
||||
}
|
||||
|
||||
private Cloudprint.PrintService.PrintServiceClient EnsurePrintClient()
|
||||
{
|
||||
return _printClient ?? throw new InvalidOperationException("gRPC 打印服务未连接");
|
||||
}
|
||||
|
||||
private Cloudprint.LibraryService.LibraryServiceClient EnsureLibraryClient()
|
||||
{
|
||||
return _libraryClient ?? throw new InvalidOperationException("gRPC 文库服务未连接");
|
||||
}
|
||||
|
||||
private Cloudprint.FileService.FileServiceClient EnsureFileClient()
|
||||
{
|
||||
return _fileClient ?? throw new InvalidOperationException("gRPC 文件服务未连接");
|
||||
}
|
||||
|
||||
private Cloudprint.AdminService.AdminServiceClient EnsureAdminClient()
|
||||
{
|
||||
return _adminClient ?? throw new InvalidOperationException("gRPC 管理员服务未连接");
|
||||
}
|
||||
|
||||
private global::Grpc.Core.Metadata CreateMetadata()
|
||||
{
|
||||
return GrpcMetadataFactory.Create(_apiKey, Token, AdminSession);
|
||||
}
|
||||
|
||||
private void Log(AppLogLevel level, string message)
|
||||
{
|
||||
LogReceived?.Invoke(this, new AppLogEntry
|
||||
{
|
||||
Level = level,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
}
|
||||
138
Services/JsonSettingsService.cs
Normal file
138
Services/JsonSettingsService.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using CloudPrint.Client.Models;
|
||||
using CloudPrint.Client.Services.Abstractions;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
public sealed class JsonSettingsService : ISettingsService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly ISecretProtector _secretProtector;
|
||||
|
||||
public JsonSettingsService()
|
||||
: this(new DpapiSecretProtector())
|
||||
{
|
||||
}
|
||||
|
||||
public JsonSettingsService(ISecretProtector secretProtector)
|
||||
: this(secretProtector, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CloudPrint.Client", "settings.json"))
|
||||
{
|
||||
}
|
||||
|
||||
public JsonSettingsService(ISecretProtector secretProtector, string settingsPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settingsPath))
|
||||
{
|
||||
throw new ArgumentException("设置文件路径不能为空", nameof(settingsPath));
|
||||
}
|
||||
|
||||
_secretProtector = secretProtector;
|
||||
_settingsPath = settingsPath;
|
||||
}
|
||||
|
||||
public ClientSettings Load()
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
{
|
||||
return new ClientSettings();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var dto = JsonSerializer.Deserialize<SettingsFileDto>(json, SerializerOptions);
|
||||
return dto?.ToSettings(_secretProtector) ?? new ClientSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ClientSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(ClientSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(SettingsFileDto.FromSettings(settings, _secretProtector), SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
}
|
||||
|
||||
private sealed class SettingsFileDto
|
||||
{
|
||||
public string ServerAddress { get; set; } = "http://localhost:8080";
|
||||
public string ApiKey { get; set; } = string.Empty; // Legacy plaintext field.
|
||||
public string ProtectedApiKey { get; set; } = string.Empty;
|
||||
public string AdminUsername { get; set; } = string.Empty;
|
||||
public string AdminAccessToken { get; set; } = string.Empty; // Legacy plaintext field.
|
||||
public string ProtectedAdminAccessToken { get; set; } = string.Empty;
|
||||
public string StoreName { get; set; } = "默认打印店";
|
||||
public bool AllowInsecureGrpc { get; set; } = true;
|
||||
public string LibraryRoot { get; set; } = string.Empty;
|
||||
public string PrintCacheRoot { get; set; } = string.Empty;
|
||||
public int MaxUploadSizeMb { get; set; } = 50;
|
||||
public int HeartbeatIntervalSeconds { get; set; } = 15;
|
||||
|
||||
public ClientSettings ToSettings(ISecretProtector protector)
|
||||
{
|
||||
return new ClientSettings
|
||||
{
|
||||
ServerAddress = ServerAddress,
|
||||
ApiKey = ReadSecret(ProtectedApiKey, ApiKey, protector),
|
||||
AdminUsername = AdminUsername,
|
||||
AdminAccessToken = ReadSecret(ProtectedAdminAccessToken, AdminAccessToken, protector),
|
||||
StoreName = StoreName,
|
||||
AllowInsecureGrpc = AllowInsecureGrpc,
|
||||
LibraryRoot = LibraryRoot,
|
||||
PrintCacheRoot = PrintCacheRoot,
|
||||
MaxUploadSizeMb = MaxUploadSizeMb,
|
||||
HeartbeatIntervalSeconds = HeartbeatIntervalSeconds
|
||||
};
|
||||
}
|
||||
|
||||
public static SettingsFileDto FromSettings(ClientSettings settings, ISecretProtector protector)
|
||||
{
|
||||
return new SettingsFileDto
|
||||
{
|
||||
ServerAddress = settings.ServerAddress,
|
||||
ProtectedApiKey = protector.Protect(settings.ApiKey),
|
||||
AdminUsername = settings.AdminUsername,
|
||||
ProtectedAdminAccessToken = protector.Protect(settings.AdminAccessToken),
|
||||
StoreName = settings.StoreName,
|
||||
AllowInsecureGrpc = settings.AllowInsecureGrpc,
|
||||
LibraryRoot = settings.LibraryRoot,
|
||||
PrintCacheRoot = settings.PrintCacheRoot,
|
||||
MaxUploadSizeMb = settings.MaxUploadSizeMb,
|
||||
HeartbeatIntervalSeconds = settings.HeartbeatIntervalSeconds
|
||||
};
|
||||
}
|
||||
|
||||
private static string ReadSecret(string protectedValue, string legacyPlainText, ISecretProtector protector)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(protectedValue))
|
||||
{
|
||||
return legacyPlainText;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return protector.Unprotect(protectedValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return legacyPlainText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
Services/PathHelper.cs
Normal file
35
Services/PathHelper.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.IO;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
public static class PathHelper
|
||||
{
|
||||
public static string CreateUniqueFilePath(string directory, string fileName)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var safeFileName = SanitizeFileName(string.IsNullOrWhiteSpace(fileName) ? "untitled" : fileName);
|
||||
var name = Path.GetFileNameWithoutExtension(safeFileName);
|
||||
var extension = Path.GetExtension(safeFileName);
|
||||
var candidate = Path.Combine(directory, safeFileName);
|
||||
var index = 1;
|
||||
|
||||
while (File.Exists(candidate))
|
||||
{
|
||||
candidate = Path.Combine(directory, $"{name}_{index++}{extension}");
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
public static string SanitizePathSegment(string value)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
return SanitizePathSegment(fileName);
|
||||
}
|
||||
}
|
||||
67
Services/PrintJobCoordinator.cs
Normal file
67
Services/PrintJobCoordinator.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using CloudPrint.Client.Models;
|
||||
using CloudPrint.Client.Services.Abstractions;
|
||||
using System.IO;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
public sealed class PrintJobCoordinator
|
||||
{
|
||||
private readonly IFileService _fileService;
|
||||
private readonly IPrintService _printService;
|
||||
private readonly IPrintBackendClient _backendClient;
|
||||
|
||||
public PrintJobCoordinator(IFileService fileService, IPrintService printService, IPrintBackendClient backendClient)
|
||||
{
|
||||
_fileService = fileService;
|
||||
_printService = printService;
|
||||
_backendClient = backendClient;
|
||||
}
|
||||
|
||||
public async Task ProcessAsync(PrintJob job, ClientSettings settings, IProgress<int>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
try
|
||||
{
|
||||
job.MarkPrinting();
|
||||
await _backendClient.SendStatusUpdateAsync(job, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if ((string.IsNullOrWhiteSpace(job.FilePath) || !File.Exists(job.FilePath)) && job.FileId > 0)
|
||||
{
|
||||
job.FilePath = await _backendClient.DownloadPrintFileAsync(job, settings.PrintCacheRoot, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
var preparationResult = await _fileService.PrepareForPrintAsync(job, settings.PrintCacheRoot, cancellationToken).ConfigureAwait(true);
|
||||
job.FilePath = preparationResult.PreparedPath;
|
||||
|
||||
var forwardingProgress = new Progress<int>(async value =>
|
||||
{
|
||||
job.Progress = value;
|
||||
if (value >= 100)
|
||||
{
|
||||
job.MarkCompleted();
|
||||
}
|
||||
|
||||
progress?.Report(value);
|
||||
await _backendClient.SendStatusUpdateAsync(job, cancellationToken).ConfigureAwait(true);
|
||||
});
|
||||
|
||||
await _printService.SubmitPrintJobAsync(job, forwardingProgress, cancellationToken).ConfigureAwait(true);
|
||||
job.MarkCompleted();
|
||||
await _backendClient.SendStatusUpdateAsync(job, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
job.MarkCancelled();
|
||||
await _backendClient.SendStatusUpdateAsync(job, CancellationToken.None).ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
job.MarkFailed(ex.Message);
|
||||
await _backendClient.SendStatusUpdateAsync(job, CancellationToken.None).ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
170
Services/PrintService.cs
Normal file
170
Services/PrintService.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using System.Drawing.Printing;
|
||||
using System.Management;
|
||||
using CloudPrint.Client.Models;
|
||||
using CloudPrint.Client.Services.Abstractions;
|
||||
using CloudPrint.Client.Services.Printing;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 真实打印服务:枚举 Windows 打印机,按文件类型提交到图片/PDF/RAW 打印执行器。
|
||||
/// </summary>
|
||||
public sealed class PrintService : IPrintService
|
||||
{
|
||||
private readonly IReadOnlyList<IPrintJobExecutor> _executors;
|
||||
private readonly IPrinterStatusReader _statusReader;
|
||||
private readonly IWindowsPrintQueueService _queueService;
|
||||
|
||||
public PrintService()
|
||||
: this(CreateDefaultExecutors(), new WindowsPrinterStatusReader(), new WindowsPrintQueueService())
|
||||
{
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IPrintJobExecutor> CreateDefaultExecutors()
|
||||
{
|
||||
var queueService = new WindowsPrintQueueService();
|
||||
return new IPrintJobExecutor[]
|
||||
{
|
||||
new ImagePrintJobExecutor(queueService),
|
||||
new PdfPrintJobExecutor(queueService),
|
||||
new RawPrintJobExecutor()
|
||||
};
|
||||
}
|
||||
|
||||
public PrintService(IEnumerable<IPrintJobExecutor> executors, IPrinterStatusReader statusReader, IWindowsPrintQueueService queueService)
|
||||
{
|
||||
_executors = executors.ToList();
|
||||
_statusReader = statusReader;
|
||||
_queueService = queueService;
|
||||
if (_executors.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("至少需要一个打印执行器", nameof(executors));
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PrinterInfo> GetPrinters()
|
||||
{
|
||||
var printers = new List<PrinterInfo>();
|
||||
var index = 1;
|
||||
|
||||
foreach (string printerName in PrinterSettings.InstalledPrinters)
|
||||
{
|
||||
if (IsVirtualPrinter(printerName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var snapshot = _statusReader.Read(printerName);
|
||||
printers.Add(new PrinterInfo
|
||||
{
|
||||
Id = index++,
|
||||
Name = printerName,
|
||||
DisplayName = printerName,
|
||||
IsActive = true,
|
||||
Status = snapshot.Status,
|
||||
QueueCount = snapshot.QueueCount,
|
||||
StatusMessage = snapshot.Message,
|
||||
LastHeartbeat = DateTime.Now
|
||||
});
|
||||
}
|
||||
|
||||
return printers;
|
||||
}
|
||||
|
||||
public PrinterStatus GetPrinterStatus(string printerName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(printerName))
|
||||
{
|
||||
return PrinterStatus.Unknown;
|
||||
}
|
||||
|
||||
return _statusReader.Read(printerName).Status;
|
||||
}
|
||||
|
||||
public async Task<string> SubmitPrintJobAsync(PrintJob job, IProgress<int>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
job.StartedAt = DateTime.Now;
|
||||
job.MarkPrinting();
|
||||
progress?.Report(5);
|
||||
|
||||
var executor = _executors.FirstOrDefault(item => item.CanHandle(job))
|
||||
?? throw new NotSupportedException($"没有可用的打印执行器处理文件:{job.FilePath}");
|
||||
|
||||
await executor.ExecuteAsync(job, progress, cancellationToken).ConfigureAwait(true);
|
||||
job.MarkCompleted();
|
||||
return job.JobNo;
|
||||
}
|
||||
|
||||
public bool CancelPrintJob(string jobId)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(jobId);
|
||||
}
|
||||
|
||||
public bool CancelPrintJob(PrintJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
return job.WindowsJobId is > 0 && _queueService.CancelJob(job.PrinterName, job.WindowsJobId.Value);
|
||||
}
|
||||
|
||||
public PrintJobStatus GetJobStatus(string jobId)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(jobId) ? PrintJobStatus.Failed : PrintJobStatus.Pending;
|
||||
}
|
||||
|
||||
private static bool IsVirtualPrinter(string printerName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(printerName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher("SELECT Name, DriverName, PortName FROM Win32_Printer");
|
||||
foreach (ManagementObject printer in searcher.Get())
|
||||
{
|
||||
var name = Convert.ToString(printer["Name"]);
|
||||
if (!string.Equals(name, printerName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var driverName = Convert.ToString(printer["DriverName"]) ?? string.Empty;
|
||||
var portName = Convert.ToString(printer["PortName"]) ?? string.Empty;
|
||||
return IsKnownVirtualPrinter(printerName, driverName, portName);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If WMI is unavailable, fall back to conservative name-based filtering.
|
||||
}
|
||||
|
||||
return IsKnownVirtualPrinter(printerName, string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
private static bool IsKnownVirtualPrinter(string printerName, string driverName, string portName)
|
||||
{
|
||||
var value = $"{printerName}|{driverName}|{portName}";
|
||||
return ContainsAny(value,
|
||||
"Microsoft Print to PDF",
|
||||
"Microsoft XPS Document Writer",
|
||||
"OneNote",
|
||||
"Fax",
|
||||
"Foxit PDF",
|
||||
"PDF Printer",
|
||||
"Print to Evernote",
|
||||
"Evernote",
|
||||
"XPS",
|
||||
"PORTPROMPT:",
|
||||
"FILE:",
|
||||
"NUL:");
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string value, params string[] candidates)
|
||||
{
|
||||
return candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
}
|
||||
9
Services/Printing/IPrintJobExecutor.cs
Normal file
9
Services/Printing/IPrintJobExecutor.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public interface IPrintJobExecutor
|
||||
{
|
||||
bool CanHandle(PrintJob job);
|
||||
Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default);
|
||||
}
|
||||
6
Services/Printing/IPrinterStatusReader.cs
Normal file
6
Services/Printing/IPrinterStatusReader.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public interface IPrinterStatusReader
|
||||
{
|
||||
PrinterStatusSnapshot Read(string printerName);
|
||||
}
|
||||
7
Services/Printing/IWindowsPrintQueueService.cs
Normal file
7
Services/Printing/IWindowsPrintQueueService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public interface IWindowsPrintQueueService
|
||||
{
|
||||
WindowsPrintQueueJob? FindLatestJob(string printerName, string documentName);
|
||||
bool CancelJob(string printerName, int jobId);
|
||||
}
|
||||
88
Services/Printing/ImagePrintJobExecutor.cs
Normal file
88
Services/Printing/ImagePrintJobExecutor.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using CloudPrint.Client.Models;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Printing;
|
||||
using System.IO;
|
||||
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public sealed class ImagePrintJobExecutor : IPrintJobExecutor
|
||||
{
|
||||
private readonly IWindowsPrintQueueService _queueService;
|
||||
|
||||
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".bmp", ".gif"
|
||||
};
|
||||
|
||||
public ImagePrintJobExecutor(IWindowsPrintQueueService queueService)
|
||||
{
|
||||
_queueService = queueService;
|
||||
}
|
||||
|
||||
public bool CanHandle(PrintJob job)
|
||||
{
|
||||
return SupportedExtensions.Contains(Path.GetExtension(job.FilePath));
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PrintFileGuards.EnsurePrintableFile(job);
|
||||
|
||||
return Task.Run(() =>
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var image = Image.FromFile(job.FilePath);
|
||||
using var document = new PrintDocument
|
||||
{
|
||||
DocumentName = string.IsNullOrWhiteSpace(job.FileName) ? job.JobNo : job.FileName,
|
||||
PrinterSettings =
|
||||
{
|
||||
PrinterName = job.PrinterName,
|
||||
Copies = (short)Math.Clamp(job.Copies, 1, short.MaxValue)
|
||||
}
|
||||
};
|
||||
|
||||
if (document.PrinterSettings.CanDuplex)
|
||||
{
|
||||
document.PrinterSettings.Duplex = job.Duplex ? Duplex.Vertical : Duplex.Simplex;
|
||||
}
|
||||
|
||||
document.DefaultPageSettings.Color = job.Color;
|
||||
document.PrintPage += (_, args) => DrawImageToPage(image, args);
|
||||
progress?.Report(20);
|
||||
document.Print();
|
||||
CaptureWindowsJob(job, document.DocumentName);
|
||||
progress?.Report(100);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private void CaptureWindowsJob(PrintJob job, string documentName)
|
||||
{
|
||||
var queueJob = _queueService.FindLatestJob(job.PrinterName, documentName);
|
||||
if (queueJob is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.WindowsJobId = queueJob.JobId;
|
||||
job.WindowsJobStatus = queueJob.Status;
|
||||
}
|
||||
|
||||
private static void DrawImageToPage(Image image, PrintPageEventArgs args)
|
||||
{
|
||||
if (args.Graphics is null)
|
||||
{
|
||||
throw new InvalidOperationException("打印图形上下文不可用");
|
||||
}
|
||||
|
||||
var bounds = args.MarginBounds;
|
||||
var ratio = Math.Min(bounds.Width / (double)image.Width, bounds.Height / (double)image.Height);
|
||||
var width = (int)(image.Width * ratio);
|
||||
var height = (int)(image.Height * ratio);
|
||||
var x = bounds.Left + (bounds.Width - width) / 2;
|
||||
var y = bounds.Top + (bounds.Height - height) / 2;
|
||||
|
||||
args.Graphics.DrawImage(image, x, y, width, height);
|
||||
args.HasMorePages = false;
|
||||
}
|
||||
}
|
||||
100
Services/Printing/PdfPrintJobExecutor.cs
Normal file
100
Services/Printing/PdfPrintJobExecutor.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using CloudPrint.Client.Models;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public sealed class PdfPrintJobExecutor : IPrintJobExecutor
|
||||
{
|
||||
private readonly IWindowsPrintQueueService _queueService;
|
||||
|
||||
public PdfPrintJobExecutor(IWindowsPrintQueueService queueService)
|
||||
{
|
||||
_queueService = queueService;
|
||||
}
|
||||
|
||||
public bool CanHandle(PrintJob job)
|
||||
{
|
||||
return string.Equals(Path.GetExtension(job.FilePath), ".pdf", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PrintFileGuards.EnsurePrintableFile(job);
|
||||
progress?.Report(10);
|
||||
|
||||
var sumatraPath = FindSumatraPdfExecutable();
|
||||
if (!string.IsNullOrWhiteSpace(sumatraPath))
|
||||
{
|
||||
await PrintWithSumatraAsync(sumatraPath, job, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await PrintWithShellAsync(job, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
CaptureWindowsJob(job);
|
||||
|
||||
progress?.Report(100);
|
||||
}
|
||||
|
||||
private void CaptureWindowsJob(PrintJob job)
|
||||
{
|
||||
var documentName = Path.GetFileName(job.FilePath);
|
||||
var queueJob = _queueService.FindLatestJob(job.PrinterName, documentName);
|
||||
if (queueJob is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
job.WindowsJobId = queueJob.JobId;
|
||||
job.WindowsJobStatus = queueJob.Status;
|
||||
}
|
||||
|
||||
private static async Task PrintWithSumatraAsync(string sumatraPath, PrintJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = sumatraPath,
|
||||
Arguments = $"-print-to \"{job.PrinterName}\" -silent -exit-when-done \"{job.FilePath}\"",
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("启动 SumatraPDF 失败");
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"SumatraPDF 打印失败,退出码:{process.ExitCode}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PrintWithShellAsync(PrintJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = job.FilePath,
|
||||
Verb = "printto",
|
||||
Arguments = $"\"{job.PrinterName}\"",
|
||||
UseShellExecute = true,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("无法调用系统默认 PDF 打印程序,请安装 SumatraPDF 或配置 PDF 默认打印程序");
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static string? FindSumatraPdfExecutable()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Environment.GetEnvironmentVariable("SUMATRA_PDF_PATH"),
|
||||
@"C:\Program Files\SumatraPDF\SumatraPDF.exe",
|
||||
@"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe"
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path));
|
||||
}
|
||||
}
|
||||
22
Services/Printing/PrintFileGuards.cs
Normal file
22
Services/Printing/PrintFileGuards.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using CloudPrint.Client.Models;
|
||||
using System.IO;
|
||||
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
internal static class PrintFileGuards
|
||||
{
|
||||
public static void EnsurePrintableFile(PrintJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(job.PrinterName))
|
||||
{
|
||||
throw new InvalidOperationException("打印机名称不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(job.FilePath) || !File.Exists(job.FilePath))
|
||||
{
|
||||
throw new FileNotFoundException("待打印文件不存在", job.FilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Services/Printing/PrinterStatusSnapshot.cs
Normal file
10
Services/Printing/PrinterStatusSnapshot.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using CloudPrint.Client.Models;
|
||||
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public sealed class PrinterStatusSnapshot
|
||||
{
|
||||
public PrinterStatus Status { get; init; } = PrinterStatus.Unknown;
|
||||
public int QueueCount { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
94
Services/Printing/RawPrintJobExecutor.cs
Normal file
94
Services/Printing/RawPrintJobExecutor.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using CloudPrint.Client.Helpers;
|
||||
using CloudPrint.Client.Models;
|
||||
using System.IO;
|
||||
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public sealed class RawPrintJobExecutor : IPrintJobExecutor
|
||||
{
|
||||
public bool CanHandle(PrintJob job) => true;
|
||||
|
||||
public async Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PrintFileGuards.EnsurePrintableFile(job);
|
||||
|
||||
if (!WindowsPrintApi.OpenPrinter(job.PrinterName, out var printerHandle, IntPtr.Zero))
|
||||
{
|
||||
throw new InvalidOperationException($"打开打印机失败:{job.PrinterName}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var docInfo = new WindowsPrintApi.DocInfo
|
||||
{
|
||||
DocumentName = string.IsNullOrWhiteSpace(job.FileName) ? job.JobNo : job.FileName,
|
||||
DataType = "RAW"
|
||||
};
|
||||
|
||||
var windowsJobId = WindowsPrintApi.StartDocPrinter(printerHandle, 1, docInfo);
|
||||
if (windowsJobId == 0)
|
||||
{
|
||||
throw new InvalidOperationException("启动打印文档失败");
|
||||
}
|
||||
|
||||
job.WindowsJobId = windowsJobId;
|
||||
job.WindowsJobStatus = "已提交到 Windows 打印队列";
|
||||
|
||||
try
|
||||
{
|
||||
if (!WindowsPrintApi.StartPagePrinter(printerHandle))
|
||||
{
|
||||
throw new InvalidOperationException("启动打印页面失败");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await WriteFileToPrinterAsync(printerHandle, job.FilePath, progress, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WindowsPrintApi.EndPagePrinter(printerHandle);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
WindowsPrintApi.EndDocPrinter(printerHandle);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
WindowsPrintApi.ClosePrinter(printerHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteFileToPrinterAsync(IntPtr printerHandle, string filePath, IProgress<int>? progress, CancellationToken cancellationToken)
|
||||
{
|
||||
const int bufferSize = 64 * 1024;
|
||||
var buffer = new byte[bufferSize];
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
long totalWritten = 0;
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(true);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var chunk = read == buffer.Length ? buffer : buffer[..read];
|
||||
if (!WindowsPrintApi.WritePrinter(printerHandle, chunk, read, out var written) || written != read)
|
||||
{
|
||||
throw new InvalidOperationException("写入打印机失败");
|
||||
}
|
||||
|
||||
totalWritten += written;
|
||||
var percent = fileInfo.Length == 0 ? 100 : (int)Math.Min(99, totalWritten * 100 / fileInfo.Length);
|
||||
progress?.Report(percent);
|
||||
}
|
||||
|
||||
progress?.Report(100);
|
||||
}
|
||||
}
|
||||
12
Services/Printing/WindowsPrintQueueJob.cs
Normal file
12
Services/Printing/WindowsPrintQueueJob.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public sealed class WindowsPrintQueueJob
|
||||
{
|
||||
public int JobId { get; init; }
|
||||
public string PrinterName { get; init; } = string.Empty;
|
||||
public string DocumentName { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public int Position { get; init; }
|
||||
public int TotalPages { get; init; }
|
||||
public int PagesPrinted { get; init; }
|
||||
}
|
||||
164
Services/Printing/WindowsPrintQueueService.cs
Normal file
164
Services/Printing/WindowsPrintQueueService.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System.Management;
|
||||
using System.Runtime.InteropServices;
|
||||
using CloudPrint.Client.Helpers;
|
||||
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public sealed class WindowsPrintQueueService : IWindowsPrintQueueService
|
||||
{
|
||||
public WindowsPrintQueueJob? FindLatestJob(string printerName, string documentName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(printerName) || string.IsNullOrWhiteSpace(documentName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return EnumerateJobs(printerName)
|
||||
.Where(job => job.DocumentName.Contains(documentName, StringComparison.OrdinalIgnoreCase)
|
||||
|| documentName.Contains(job.DocumentName, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(job => job.JobId)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CancelJob(string printerName, int jobId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(printerName) || jobId <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (CancelJobWithWinSpool(printerName, jobId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var job in QueryJobObjects(printerName))
|
||||
{
|
||||
var currentJobId = Convert.ToInt32(job["JobId"] ?? 0);
|
||||
if (currentJobId != jobId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
job.InvokeMethod("Delete", null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<WindowsPrintQueueJob> EnumerateJobs(string printerName)
|
||||
{
|
||||
var spoolerJobs = EnumerateJobsWithWinSpool(printerName);
|
||||
if (spoolerJobs.Count > 0)
|
||||
{
|
||||
return spoolerJobs;
|
||||
}
|
||||
|
||||
return EnumerateJobsWithWmi(printerName);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WindowsPrintQueueJob> EnumerateJobsWithWinSpool(string printerName)
|
||||
{
|
||||
if (!WindowsPrintApi.OpenPrinter(printerName, out var printerHandle, IntPtr.Zero))
|
||||
{
|
||||
return Array.Empty<WindowsPrintQueueJob>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const int maxJobs = 256;
|
||||
_ = WindowsPrintApi.EnumJobs(printerHandle, 0, maxJobs, 1, IntPtr.Zero, 0, out var bytesNeeded, out _);
|
||||
if (bytesNeeded <= 0)
|
||||
{
|
||||
return Array.Empty<WindowsPrintQueueJob>();
|
||||
}
|
||||
|
||||
var buffer = Marshal.AllocHGlobal(bytesNeeded);
|
||||
try
|
||||
{
|
||||
if (!WindowsPrintApi.EnumJobs(printerHandle, 0, maxJobs, 1, buffer, bytesNeeded, out _, out var jobsReturned))
|
||||
{
|
||||
return Array.Empty<WindowsPrintQueueJob>();
|
||||
}
|
||||
|
||||
var jobs = new List<WindowsPrintQueueJob>(jobsReturned);
|
||||
var itemSize = Marshal.SizeOf<WindowsPrintApi.JobInfo1>();
|
||||
for (var index = 0; index < jobsReturned; index++)
|
||||
{
|
||||
var itemPointer = IntPtr.Add(buffer, index * itemSize);
|
||||
var jobInfo = Marshal.PtrToStructure<WindowsPrintApi.JobInfo1>(itemPointer);
|
||||
jobs.Add(new WindowsPrintQueueJob
|
||||
{
|
||||
JobId = jobInfo.JobId,
|
||||
PrinterName = printerName,
|
||||
DocumentName = jobInfo.DocumentName ?? string.Empty,
|
||||
Status = string.IsNullOrWhiteSpace(jobInfo.StatusText) ? $"0x{jobInfo.Status:X}" : jobInfo.StatusText,
|
||||
Position = jobInfo.Position,
|
||||
TotalPages = jobInfo.TotalPages,
|
||||
PagesPrinted = jobInfo.PagesPrinted
|
||||
});
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = WindowsPrintApi.ClosePrinter(printerHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CancelJobWithWinSpool(string printerName, int jobId)
|
||||
{
|
||||
if (!WindowsPrintApi.OpenPrinter(printerName, out var printerHandle, IntPtr.Zero))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return WindowsPrintApi.SetJob(printerHandle, jobId, 0, IntPtr.Zero, WindowsPrintApi.JobControlDelete)
|
||||
|| WindowsPrintApi.SetJob(printerHandle, jobId, 0, IntPtr.Zero, WindowsPrintApi.JobControlCancel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = WindowsPrintApi.ClosePrinter(printerHandle);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<WindowsPrintQueueJob> EnumerateJobsWithWmi(string printerName)
|
||||
{
|
||||
foreach (var job in QueryJobObjects(printerName))
|
||||
{
|
||||
yield return new WindowsPrintQueueJob
|
||||
{
|
||||
JobId = Convert.ToInt32(job["JobId"] ?? 0),
|
||||
PrinterName = printerName,
|
||||
DocumentName = Convert.ToString(job["Document"]) ?? string.Empty,
|
||||
Status = Convert.ToString(job["JobStatus"]) ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ManagementObject> QueryJobObjects(string printerName)
|
||||
{
|
||||
var escapedName = printerName.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "''", StringComparison.Ordinal);
|
||||
using var searcher = new ManagementObjectSearcher($"SELECT JobId, Document, JobStatus, Name FROM Win32_PrintJob WHERE Name LIKE '{escapedName},%'");
|
||||
foreach (ManagementObject job in searcher.Get())
|
||||
{
|
||||
yield return job;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
Services/Printing/WindowsPrinterStatusReader.cs
Normal file
119
Services/Printing/WindowsPrinterStatusReader.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using CloudPrint.Client.Models;
|
||||
using System.Management;
|
||||
|
||||
namespace CloudPrint.Client.Services.Printing;
|
||||
|
||||
public sealed class WindowsPrinterStatusReader : IPrinterStatusReader
|
||||
{
|
||||
public PrinterStatusSnapshot Read(string printerName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(printerName))
|
||||
{
|
||||
return new PrinterStatusSnapshot { Status = PrinterStatus.Unknown, Message = "打印机名称为空" };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return ReadCore(printerName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrinterStatusSnapshot
|
||||
{
|
||||
Status = PrinterStatus.Unknown,
|
||||
Message = $"状态读取失败:{ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static PrinterStatusSnapshot ReadCore(string printerName)
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, WorkOffline, PrinterStatus, DetectedErrorState FROM Win32_Printer");
|
||||
|
||||
foreach (ManagementObject printer in searcher.Get())
|
||||
{
|
||||
var name = Convert.ToString(printer["Name"]);
|
||||
if (!string.Equals(name, printerName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var workOffline = Convert.ToBoolean(printer["WorkOffline"] ?? false);
|
||||
var printerStatus = Convert.ToUInt32(printer["PrinterStatus"] ?? 2u);
|
||||
var errorState = Convert.ToUInt32(printer["DetectedErrorState"] ?? 0u);
|
||||
var queueCount = CountQueueJobs(printerName);
|
||||
|
||||
var status = MapStatus(workOffline, printerStatus, errorState, queueCount);
|
||||
return new PrinterStatusSnapshot
|
||||
{
|
||||
Status = status,
|
||||
QueueCount = queueCount,
|
||||
Message = BuildMessage(workOffline, printerStatus, errorState)
|
||||
};
|
||||
}
|
||||
|
||||
return new PrinterStatusSnapshot
|
||||
{
|
||||
Status = PrinterStatus.Offline,
|
||||
Message = "未找到打印机"
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountQueueJobs(string printerName)
|
||||
{
|
||||
var escapedName = printerName.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "''", StringComparison.Ordinal);
|
||||
using var searcher = new ManagementObjectSearcher($"SELECT Name FROM Win32_PrintJob WHERE Name LIKE '{escapedName},%'");
|
||||
return searcher.Get().Count;
|
||||
}
|
||||
|
||||
private static PrinterStatus MapStatus(bool workOffline, uint printerStatus, uint errorState, int queueCount)
|
||||
{
|
||||
if (workOffline || printerStatus == 7)
|
||||
{
|
||||
return PrinterStatus.Offline;
|
||||
}
|
||||
|
||||
if (errorState is >= 3 and <= 11 || printerStatus == 6)
|
||||
{
|
||||
return PrinterStatus.Error;
|
||||
}
|
||||
|
||||
if (printerStatus == 4 || queueCount > 0)
|
||||
{
|
||||
return PrinterStatus.Printing;
|
||||
}
|
||||
|
||||
return printerStatus == 3 ? PrinterStatus.Idle : PrinterStatus.Unknown;
|
||||
}
|
||||
|
||||
private static string BuildMessage(bool workOffline, uint printerStatus, uint errorState)
|
||||
{
|
||||
if (workOffline)
|
||||
{
|
||||
return "脱机";
|
||||
}
|
||||
|
||||
return errorState switch
|
||||
{
|
||||
3 => "缺纸",
|
||||
4 => "卡纸",
|
||||
5 => "碳粉不足",
|
||||
6 => "门打开",
|
||||
7 => "需要人工干预",
|
||||
8 => "离线",
|
||||
9 => "维修中",
|
||||
10 => "输出纸盘已满",
|
||||
11 => "缺纸",
|
||||
_ => printerStatus switch
|
||||
{
|
||||
3 => "就绪",
|
||||
4 => "打印中",
|
||||
5 => "预热中",
|
||||
6 => "已停止",
|
||||
7 => "离线",
|
||||
_ => string.Empty
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
182
Services/WebSocketService.cs
Normal file
182
Services/WebSocketService.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using CloudPrint.Client.Models;
|
||||
using CloudPrint.Client.Services.Abstractions;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
|
||||
namespace CloudPrint.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket 备用通信通道。
|
||||
/// 当前后端主链路仍使用 gRPC 订阅;当后端提供 /ws/print 时,本服务可保持备用长连接并记录推送消息。
|
||||
/// </summary>
|
||||
public sealed class WebSocketService : IWebSocketService
|
||||
{
|
||||
private const int ReceiveBufferSize = 8 * 1024;
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private ClientWebSocket? _socket;
|
||||
private CancellationTokenSource? _receiveCts;
|
||||
|
||||
public event EventHandler<AppLogEntry>? LogReceived;
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public Uri? Endpoint { get; private set; }
|
||||
|
||||
public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoint);
|
||||
if (endpoint.Scheme is not "ws" and not "wss")
|
||||
{
|
||||
throw new ArgumentException("WebSocket 地址必须以 ws:// 或 wss:// 开头", nameof(endpoint));
|
||||
}
|
||||
|
||||
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await DisconnectCoreAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
Endpoint = endpoint;
|
||||
|
||||
var socket = new ClientWebSocket();
|
||||
_receiveCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is WebSocketException or HttpRequestException or IOException)
|
||||
{
|
||||
socket.Dispose();
|
||||
_receiveCts.Dispose();
|
||||
_receiveCts = null;
|
||||
IsConnected = false;
|
||||
Log(AppLogLevel.Warning, $"WebSocket 备用通道不可用:{ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_socket = socket;
|
||||
IsConnected = true;
|
||||
Log(AppLogLevel.Success, $"WebSocket 备用通道已连接:{endpoint}");
|
||||
_ = Task.Run(() => ReceiveLoopAsync(socket, _receiveCts.Token), CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await DisconnectCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[ReceiveBufferSize];
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
|
||||
{
|
||||
using var message = new MemoryStream();
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await CloseSocketAsync(socket, cancellationToken).ConfigureAwait(false);
|
||||
MarkDisconnected(socket, "WebSocket 备用通道被服务端关闭");
|
||||
return;
|
||||
}
|
||||
|
||||
message.Write(buffer, 0, result.Count);
|
||||
}
|
||||
while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetString(message.ToArray());
|
||||
Log(AppLogLevel.Info, $"WebSocket 推送:{TrimPayload(payload)}");
|
||||
}
|
||||
else if (result.MessageType == WebSocketMessageType.Binary)
|
||||
{
|
||||
Log(AppLogLevel.Info, $"WebSocket 收到二进制消息:{message.Length} 字节");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown path.
|
||||
}
|
||||
catch (Exception ex) when (ex is WebSocketException or IOException)
|
||||
{
|
||||
MarkDisconnected(socket, $"WebSocket 备用通道中断:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_receiveCts?.Cancel();
|
||||
|
||||
var socket = _socket;
|
||||
_socket = null;
|
||||
IsConnected = false;
|
||||
|
||||
if (socket is not null)
|
||||
{
|
||||
await CloseSocketAsync(socket, cancellationToken).ConfigureAwait(false);
|
||||
socket.Dispose();
|
||||
Log(AppLogLevel.Warning, "WebSocket 备用通道已断开");
|
||||
}
|
||||
|
||||
_receiveCts?.Dispose();
|
||||
_receiveCts = null;
|
||||
}
|
||||
|
||||
private static async Task CloseSocketAsync(ClientWebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "client closing", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkDisconnected(ClientWebSocket socket, string message)
|
||||
{
|
||||
if (ReferenceEquals(_socket, socket))
|
||||
{
|
||||
_socket = null;
|
||||
IsConnected = false;
|
||||
_receiveCts?.Dispose();
|
||||
_receiveCts = null;
|
||||
}
|
||||
|
||||
socket.Dispose();
|
||||
Log(AppLogLevel.Warning, message);
|
||||
}
|
||||
|
||||
private void Log(AppLogLevel level, string message)
|
||||
{
|
||||
LogReceived?.Invoke(this, new AppLogEntry
|
||||
{
|
||||
Level = level,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
|
||||
private static string TrimPayload(string payload)
|
||||
{
|
||||
const int maxLength = 500;
|
||||
return payload.Length <= maxLength ? payload : payload[..maxLength] + "...";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user