feat: 初始功能

This commit is contained in:
Shiqvlizi Name
2026-05-23 18:16:50 +08:00
parent a5bfd8792e
commit 9a51259e9e
63 changed files with 5377 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Abstractions;
public interface IAppLogger
{
string LogDirectory { get; }
void Write(AppLogEntry entry);
}

View 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);
}

View 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();
}

View 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);
}

View File

@@ -0,0 +1,7 @@
namespace CloudPrint.Client.Services.Abstractions;
public interface ISecretProtector
{
string Protect(string plainText);
string Unprotect(string protectedText);
}

View File

@@ -0,0 +1,9 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Abstractions;
public interface ISettingsService
{
ClientSettings Load();
void Save(ClientSettings settings);
}

View 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);
}

View 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
View 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
View 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";
}
}

View 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
};
}
}

View 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
View 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
});
}
}

View 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
View 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);
}
}

View 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
View 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));
}
}

View 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);
}

View File

@@ -0,0 +1,6 @@
namespace CloudPrint.Client.Services.Printing;
public interface IPrinterStatusReader
{
PrinterStatusSnapshot Read(string printerName);
}

View 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);
}

View 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;
}
}

View 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));
}
}

View 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);
}
}
}

View 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;
}

View 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);
}
}

View 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; }
}

View 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;
}
}
}

View 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
}
};
}
}

View 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] + "...";
}
}