From 9a51259e9e71a2a1dbc82249d4fe6434629277b5 Mon Sep 17 00:00:00 2001 From: Shiqvlizi Name Date: Sat, 23 May 2026 18:16:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 429 ++++++++++ App.xaml | 86 ++ App.xaml.cs | 38 + CloudPrint.Client.csproj | 28 + Helpers/PdfProcessor.cs | 227 ++++++ Helpers/WindowsPrintApi.cs | 117 +++ Infrastructure/AsyncRelayCommand.cs | 55 ++ Infrastructure/IUiDispatcher.cs | 6 + Infrastructure/ObservableObject.cs | 26 + Infrastructure/RelayCommand.cs | 28 + Infrastructure/WpfUiDispatcher.cs | 18 + Models/AdminSession.cs | 14 + Models/AppLogEntry.cs | 39 + Models/ClientSettings.cs | 77 ++ Models/ConnectionState.cs | 11 + Models/Document.cs | 71 ++ Models/FilePreparationResult.cs | 9 + Models/FileValidationResult.cs | 17 + Models/LibraryCategory.cs | 9 + Models/PrintJob.cs | 110 +++ Models/Printer.cs | 82 ++ README.md | 213 ++++- Services/Abstractions/IAppLogger.cs | 9 + Services/Abstractions/IFileService.cs | 16 + Services/Abstractions/IPrintBackendClient.cs | 27 + Services/Abstractions/IPrintService.cs | 13 + Services/Abstractions/ISecretProtector.cs | 7 + Services/Abstractions/ISettingsService.cs | 9 + Services/Abstractions/IWebSocketService.cs | 14 + Services/DpapiSecretProtector.cs | 34 + Services/FileAppLogger.cs | 38 + Services/FileService.cs | 378 +++++++++ Services/Grpc/CloudPrintGrpcMapper.cs | 55 ++ Services/Grpc/GrpcMetadataFactory.cs | 30 + Services/GrpcService.cs | 362 +++++++++ Services/JsonSettingsService.cs | 138 ++++ Services/PathHelper.cs | 35 + Services/PrintJobCoordinator.cs | 67 ++ Services/PrintService.cs | 170 ++++ Services/Printing/IPrintJobExecutor.cs | 9 + Services/Printing/IPrinterStatusReader.cs | 6 + .../Printing/IWindowsPrintQueueService.cs | 7 + Services/Printing/ImagePrintJobExecutor.cs | 88 ++ Services/Printing/PdfPrintJobExecutor.cs | 100 +++ Services/Printing/PrintFileGuards.cs | 22 + Services/Printing/PrinterStatusSnapshot.cs | 10 + Services/Printing/RawPrintJobExecutor.cs | 94 +++ Services/Printing/WindowsPrintQueueJob.cs | 12 + Services/Printing/WindowsPrintQueueService.cs | 164 ++++ .../Printing/WindowsPrinterStatusReader.cs | 119 +++ Services/WebSocketService.cs | 182 +++++ ViewModels/LibraryViewModel.cs | 154 ++++ ViewModels/MainViewModel.cs | 767 ++++++++++++++++++ ViewModels/PrintQueueViewModel.cs | 9 + ViewModels/SettingsViewModel.cs | 155 ++++ Views/LibraryView.xaml | 65 ++ Views/LibraryView.xaml.cs | 9 + Views/MainWindow.xaml | 89 ++ Views/MainWindow.xaml.cs | 13 + Views/PrintQueueView.xaml | 89 ++ Views/PrintQueueView.xaml.cs | 9 + Views/SettingsView.xaml | 77 ++ Views/SettingsView.xaml.cs | 17 + 63 files changed, 5377 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 App.xaml create mode 100644 App.xaml.cs create mode 100644 CloudPrint.Client.csproj create mode 100644 Helpers/PdfProcessor.cs create mode 100644 Helpers/WindowsPrintApi.cs create mode 100644 Infrastructure/AsyncRelayCommand.cs create mode 100644 Infrastructure/IUiDispatcher.cs create mode 100644 Infrastructure/ObservableObject.cs create mode 100644 Infrastructure/RelayCommand.cs create mode 100644 Infrastructure/WpfUiDispatcher.cs create mode 100644 Models/AdminSession.cs create mode 100644 Models/AppLogEntry.cs create mode 100644 Models/ClientSettings.cs create mode 100644 Models/ConnectionState.cs create mode 100644 Models/Document.cs create mode 100644 Models/FilePreparationResult.cs create mode 100644 Models/FileValidationResult.cs create mode 100644 Models/LibraryCategory.cs create mode 100644 Models/PrintJob.cs create mode 100644 Models/Printer.cs create mode 100644 Services/Abstractions/IAppLogger.cs create mode 100644 Services/Abstractions/IFileService.cs create mode 100644 Services/Abstractions/IPrintBackendClient.cs create mode 100644 Services/Abstractions/IPrintService.cs create mode 100644 Services/Abstractions/ISecretProtector.cs create mode 100644 Services/Abstractions/ISettingsService.cs create mode 100644 Services/Abstractions/IWebSocketService.cs create mode 100644 Services/DpapiSecretProtector.cs create mode 100644 Services/FileAppLogger.cs create mode 100644 Services/FileService.cs create mode 100644 Services/Grpc/CloudPrintGrpcMapper.cs create mode 100644 Services/Grpc/GrpcMetadataFactory.cs create mode 100644 Services/GrpcService.cs create mode 100644 Services/JsonSettingsService.cs create mode 100644 Services/PathHelper.cs create mode 100644 Services/PrintJobCoordinator.cs create mode 100644 Services/PrintService.cs create mode 100644 Services/Printing/IPrintJobExecutor.cs create mode 100644 Services/Printing/IPrinterStatusReader.cs create mode 100644 Services/Printing/IWindowsPrintQueueService.cs create mode 100644 Services/Printing/ImagePrintJobExecutor.cs create mode 100644 Services/Printing/PdfPrintJobExecutor.cs create mode 100644 Services/Printing/PrintFileGuards.cs create mode 100644 Services/Printing/PrinterStatusSnapshot.cs create mode 100644 Services/Printing/RawPrintJobExecutor.cs create mode 100644 Services/Printing/WindowsPrintQueueJob.cs create mode 100644 Services/Printing/WindowsPrintQueueService.cs create mode 100644 Services/Printing/WindowsPrinterStatusReader.cs create mode 100644 Services/WebSocketService.cs create mode 100644 ViewModels/LibraryViewModel.cs create mode 100644 ViewModels/MainViewModel.cs create mode 100644 ViewModels/PrintQueueViewModel.cs create mode 100644 ViewModels/SettingsViewModel.cs create mode 100644 Views/LibraryView.xaml create mode 100644 Views/LibraryView.xaml.cs create mode 100644 Views/MainWindow.xaml create mode 100644 Views/MainWindow.xaml.cs create mode 100644 Views/PrintQueueView.xaml create mode 100644 Views/PrintQueueView.xaml.cs create mode 100644 Views/SettingsView.xaml create mode 100644 Views/SettingsView.xaml.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a182b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,429 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +.artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp \ No newline at end of file diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..fb29ccb --- /dev/null +++ b/App.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..48697ef --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,38 @@ +using System.Windows.Threading; +using CloudPrint.Client.Models; +using CloudPrint.Client.Services; + +namespace CloudPrint.Client; + +public partial class App : System.Windows.Application +{ + private readonly FileAppLogger _logger = new(); + + protected override void OnStartup(System.Windows.StartupEventArgs e) + { + base.OnStartup(e); + DispatcherUnhandledException += OnDispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + } + + private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + _logger.Write(AppLogEntry.FromException("UI 未处理异常", e.Exception)); + e.Handled = true; + } + + private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception exception) + { + _logger.Write(AppLogEntry.FromException("应用未处理异常", exception)); + } + } + + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + _logger.Write(AppLogEntry.FromException("后台任务未观察异常", e.Exception)); + e.SetObserved(); + } +} \ No newline at end of file diff --git a/CloudPrint.Client.csproj b/CloudPrint.Client.csproj new file mode 100644 index 0000000..78317cd --- /dev/null +++ b/CloudPrint.Client.csproj @@ -0,0 +1,28 @@ + + + + WinExe + net8.0-windows + true + true + enable + enable + CloudPrint.Client + CloudPrint.Client + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Helpers/PdfProcessor.cs b/Helpers/PdfProcessor.cs new file mode 100644 index 0000000..e51c43a --- /dev/null +++ b/Helpers/PdfProcessor.cs @@ -0,0 +1,227 @@ +using CloudPrint.Client.Models; +using PdfSharp.Drawing; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; +using System.IO; + +namespace CloudPrint.Client.Helpers; + +/// +/// 基于 PDFsharp 的 PDF 处理工具,负责打印前的页码范围裁剪、水印和合并。 +/// +public sealed class PdfProcessor +{ + public Task AddWatermarkAsync(string pdfPath, string text, CancellationToken cancellationToken = default) + { + ValidatePdfPath(pdfPath); + if (string.IsNullOrWhiteSpace(text)) + { + return Task.FromResult(pdfPath); + } + + cancellationToken.ThrowIfCancellationRequested(); + var outputPath = CreateSiblingOutputPath(pdfPath, "watermarked"); + using var document = PdfReader.Open(pdfPath, PdfDocumentOpenMode.Modify); + + foreach (var page in document.Pages) + { + cancellationToken.ThrowIfCancellationRequested(); + using var graphics = XGraphics.FromPdfPage(page, XGraphicsPdfPageOptions.Append); + var font = new XFont("Arial", 36, XFontStyleEx.Bold); + var size = graphics.MeasureString(text, font); + var centerX = page.Width.Point / 2; + var centerY = page.Height.Point / 2; + + graphics.TranslateTransform(centerX, centerY); + graphics.RotateTransform(-35); + graphics.DrawString( + text, + font, + new XSolidBrush(XColor.FromArgb(80, 120, 120, 120)), + new XRect(-size.Width / 2, -size.Height / 2, size.Width, size.Height), + XStringFormats.Center); + graphics.RotateTransform(35); + graphics.TranslateTransform(-centerX, -centerY); + } + + document.Save(outputPath); + return Task.FromResult(outputPath); + } + + public Task ExtractPageRangeAsync(string pdfPath, string pageRange, string outputDirectory, CancellationToken cancellationToken = default) + { + ValidatePdfPath(pdfPath); + if (string.IsNullOrWhiteSpace(pageRange) || pageRange.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(pdfPath); + } + + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(outputDirectory); + using var input = PdfReader.Open(pdfPath, PdfDocumentOpenMode.Import); + var pageIndexes = ParsePageRange(pageRange, input.PageCount); + if (pageIndexes.Count == input.PageCount && pageIndexes.SequenceEqual(Enumerable.Range(0, input.PageCount))) + { + return Task.FromResult(pdfPath); + } + + using var output = new PdfDocument(); + output.Info.Title = input.Info.Title; + output.Info.Author = input.Info.Author; + output.Info.Subject = input.Info.Subject; + output.Info.Keywords = input.Info.Keywords; + + foreach (var index in pageIndexes) + { + cancellationToken.ThrowIfCancellationRequested(); + output.AddPage(input.Pages[index]); + } + + var outputPath = CreateUniquePath(Path.Combine(outputDirectory, $"{Path.GetFileNameWithoutExtension(pdfPath)}.pages-{NormalizePageRangeForFileName(pageRange)}.pdf")); + output.Save(outputPath); + return Task.FromResult(outputPath); + } + + public Task MergeAsync(IEnumerable documents, string outputPath, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(documents); + if (string.IsNullOrWhiteSpace(outputPath)) + { + throw new ArgumentException("输出路径不能为空", nameof(outputPath)); + } + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? Directory.GetCurrentDirectory()); + using var output = new PdfDocument(); + + foreach (var document in documents) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsPdf(document.FilePath)) + { + continue; + } + + using var input = PdfReader.Open(document.FilePath, PdfDocumentOpenMode.Import); + foreach (var page in input.Pages) + { + output.AddPage(page); + } + } + + if (output.PageCount == 0) + { + throw new InvalidOperationException("没有可合并的 PDF 文件"); + } + + var uniqueOutputPath = CreateUniquePath(outputPath); + output.Save(uniqueOutputPath); + return Task.FromResult(uniqueOutputPath); + } + + private static IReadOnlyList ParsePageRange(string pageRange, int pageCount) + { + if (pageCount <= 0) + { + throw new InvalidOperationException("PDF 没有可打印页面"); + } + + var pages = new SortedSet(); + foreach (var rawPart in pageRange.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var part = rawPart.Replace('-', '-').Replace('—', '-'); + if (part.Contains('-', StringComparison.Ordinal)) + { + var bounds = part.Split('-', 2, StringSplitOptions.TrimEntries); + var start = ParseOneBasedPageNumber(bounds[0], pageCount); + var end = ParseOneBasedPageNumber(bounds[1], pageCount); + if (start > end) + { + throw new ArgumentException($"页码范围无效:{rawPart}", nameof(pageRange)); + } + + for (var page = start; page <= end; page++) + { + pages.Add(page - 1); + } + } + else + { + pages.Add(ParseOneBasedPageNumber(part, pageCount) - 1); + } + } + + if (pages.Count == 0) + { + throw new ArgumentException("页码范围不能为空", nameof(pageRange)); + } + + return pages.ToArray(); + } + + private static int ParseOneBasedPageNumber(string value, int pageCount) + { + if (!int.TryParse(value, out var page) || page < 1 || page > pageCount) + { + throw new ArgumentOutOfRangeException(nameof(value), $"页码 {value} 超出有效范围 1-{pageCount}"); + } + + return page; + } + + private static void ValidatePdfPath(string pdfPath) + { + if (string.IsNullOrWhiteSpace(pdfPath)) + { + throw new ArgumentException("PDF 路径不能为空", nameof(pdfPath)); + } + + if (!File.Exists(pdfPath)) + { + throw new FileNotFoundException("PDF 文件不存在", pdfPath); + } + + if (!IsPdf(pdfPath)) + { + throw new ArgumentException("文件不是 PDF", nameof(pdfPath)); + } + } + + private static bool IsPdf(string path) + { + return Path.GetExtension(path).Equals(".pdf", StringComparison.OrdinalIgnoreCase); + } + + private static string CreateSiblingOutputPath(string sourcePath, string suffix) + { + var directory = Path.GetDirectoryName(sourcePath) ?? Directory.GetCurrentDirectory(); + var outputPath = Path.Combine(directory, $"{Path.GetFileNameWithoutExtension(sourcePath)}.{suffix}.pdf"); + return CreateUniquePath(outputPath); + } + + private static string CreateUniquePath(string path) + { + if (!File.Exists(path)) + { + return path; + } + + var directory = Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory(); + var fileName = Path.GetFileNameWithoutExtension(path); + var extension = Path.GetExtension(path); + for (var index = 1; index < 10_000; index++) + { + var candidate = Path.Combine(directory, $"{fileName}-{index}{extension}"); + if (!File.Exists(candidate)) + { + return candidate; + } + } + + throw new IOException($"无法创建唯一文件路径:{path}"); + } + + private static string NormalizePageRangeForFileName(string pageRange) + { + return string.Concat(pageRange.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-'); + } +} \ No newline at end of file diff --git a/Helpers/WindowsPrintApi.cs b/Helpers/WindowsPrintApi.cs new file mode 100644 index 0000000..a2b9344 --- /dev/null +++ b/Helpers/WindowsPrintApi.cs @@ -0,0 +1,117 @@ +using System.Runtime.InteropServices; + +namespace CloudPrint.Client.Helpers; + +/// +/// Windows Print Spooler API P/Invoke 声明,用于 RAW 打印、队列枚举和任务取消。 +/// +public static class WindowsPrintApi +{ + public const int JobControlCancel = 3; + public const int JobControlDelete = 5; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public sealed class DocInfo + { + [MarshalAs(UnmanagedType.LPWStr)] + public string DocumentName = string.Empty; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? OutputFile; + + [MarshalAs(UnmanagedType.LPWStr)] + public string DataType = "RAW"; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SystemTime + { + public short Year; + public short Month; + public short DayOfWeek; + public short Day; + public short Hour; + public short Minute; + public short Second; + public short Milliseconds; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct JobInfo1 + { + public int JobId; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? PrinterName; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? MachineName; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? UserName; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? DocumentName; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? DataType; + + [MarshalAs(UnmanagedType.LPWStr)] + public string? StatusText; + + public int Status; + public int Priority; + public int Position; + public int TotalPages; + public int PagesPrinted; + public SystemTime Submitted; + } + + [DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool OpenPrinter(string printerName, out IntPtr printerHandle, IntPtr printerDefaults); + + [DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool ClosePrinter(IntPtr printerHandle); + + [DllImport("winspool.drv", EntryPoint = "StartDocPrinterW", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern int StartDocPrinter(IntPtr printerHandle, int level, [In] DocInfo documentInfo); + + [DllImport("winspool.drv", EntryPoint = "EndDocPrinter", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool EndDocPrinter(IntPtr printerHandle); + + [DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool StartPagePrinter(IntPtr printerHandle); + + [DllImport("winspool.drv", EntryPoint = "EndPagePrinter", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool EndPagePrinter(IntPtr printerHandle); + + [DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool WritePrinter(IntPtr printerHandle, byte[] data, int count, out int written); + + [DllImport("winspool.drv", EntryPoint = "EnumJobsW", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool EnumJobs( + IntPtr printerHandle, + int firstJob, + int numberOfJobs, + int level, + IntPtr jobBuffer, + int bufferSize, + out int bytesNeeded, + out int jobsReturned); + + [DllImport("winspool.drv", EntryPoint = "SetJobW", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetJob( + IntPtr printerHandle, + int jobId, + int level, + IntPtr jobInfo, + int command); +} \ No newline at end of file diff --git a/Infrastructure/AsyncRelayCommand.cs b/Infrastructure/AsyncRelayCommand.cs new file mode 100644 index 0000000..e30d481 --- /dev/null +++ b/Infrastructure/AsyncRelayCommand.cs @@ -0,0 +1,55 @@ +using System.Windows.Input; + +namespace CloudPrint.Client.Infrastructure; + +public sealed class AsyncRelayCommand : ICommand +{ + private readonly Func _execute; + private readonly Func? _canExecute; + private bool _isExecuting; + + public AsyncRelayCommand(Func execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + public bool IsExecuting + { + get => _isExecuting; + private set + { + if (_isExecuting == value) + { + return; + } + + _isExecuting = value; + RaiseCanExecuteChanged(); + } + } + + public bool CanExecute(object? parameter) => !IsExecuting && (_canExecute?.Invoke() ?? true); + + public async void Execute(object? parameter) + { + if (!CanExecute(parameter)) + { + return; + } + + try + { + IsExecuting = true; + await _execute().ConfigureAwait(true); + } + finally + { + IsExecuting = false; + } + } + + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} \ No newline at end of file diff --git a/Infrastructure/IUiDispatcher.cs b/Infrastructure/IUiDispatcher.cs new file mode 100644 index 0000000..a7e9933 --- /dev/null +++ b/Infrastructure/IUiDispatcher.cs @@ -0,0 +1,6 @@ +namespace CloudPrint.Client.Infrastructure; + +public interface IUiDispatcher +{ + void Invoke(Action action); +} \ No newline at end of file diff --git a/Infrastructure/ObservableObject.cs b/Infrastructure/ObservableObject.cs new file mode 100644 index 0000000..99cb8cb --- /dev/null +++ b/Infrastructure/ObservableObject.cs @@ -0,0 +1,26 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace CloudPrint.Client.Infrastructure; + +public abstract class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/Infrastructure/RelayCommand.cs b/Infrastructure/RelayCommand.cs new file mode 100644 index 0000000..bd4daa7 --- /dev/null +++ b/Infrastructure/RelayCommand.cs @@ -0,0 +1,28 @@ +using System.Windows.Input; + +namespace CloudPrint.Client.Infrastructure; + +public sealed class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Predicate? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + : this(_ => execute(), canExecute is null ? null : _ => canExecute()) + { + } + + public RelayCommand(Action execute, Predicate? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; + + public void Execute(object? parameter) => _execute(parameter); + + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} \ No newline at end of file diff --git a/Infrastructure/WpfUiDispatcher.cs b/Infrastructure/WpfUiDispatcher.cs new file mode 100644 index 0000000..90d98bb --- /dev/null +++ b/Infrastructure/WpfUiDispatcher.cs @@ -0,0 +1,18 @@ +namespace CloudPrint.Client.Infrastructure; + +public sealed class WpfUiDispatcher : IUiDispatcher +{ + public void Invoke(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher is null || dispatcher.CheckAccess()) + { + action(); + return; + } + + dispatcher.Invoke(action); + } +} \ No newline at end of file diff --git a/Models/AdminSession.cs b/Models/AdminSession.cs new file mode 100644 index 0000000..b098df8 --- /dev/null +++ b/Models/AdminSession.cs @@ -0,0 +1,14 @@ +namespace CloudPrint.Client.Models; + +public sealed class AdminSession +{ + public int AdminId { get; init; } + public string Username { get; init; } = string.Empty; + public string Nickname { get; init; } = string.Empty; + public int Role { get; init; } + public string AccessToken { get; init; } = string.Empty; + + public bool IsAuthenticated => !string.IsNullOrWhiteSpace(AccessToken); + + public string DisplayName => string.IsNullOrWhiteSpace(Nickname) ? Username : Nickname; +} \ No newline at end of file diff --git a/Models/AppLogEntry.cs b/Models/AppLogEntry.cs new file mode 100644 index 0000000..b1245f9 --- /dev/null +++ b/Models/AppLogEntry.cs @@ -0,0 +1,39 @@ +namespace CloudPrint.Client.Models; + +public enum AppLogLevel +{ + Info, + Success, + Warning, + Error +} + +public sealed class AppLogEntry +{ + public DateTime Timestamp { get; init; } = DateTime.Now; + public AppLogLevel Level { get; init; } = AppLogLevel.Info; + public string Message { get; init; } = string.Empty; + public string ExceptionText { get; init; } = string.Empty; + + public string LevelText => Level switch + { + AppLogLevel.Success => "成功", + AppLogLevel.Warning => "警告", + AppLogLevel.Error => "错误", + _ => "信息" + }; + + public string DisplayText => string.IsNullOrWhiteSpace(ExceptionText) + ? $"[{Timestamp:HH:mm:ss}] [{LevelText}] {Message}" + : $"[{Timestamp:HH:mm:ss}] [{LevelText}] {Message}(详见本地日志)"; + + public static AppLogEntry FromException(string message, Exception exception) + { + return new AppLogEntry + { + Level = AppLogLevel.Error, + Message = message, + ExceptionText = exception.ToString() + }; + } +} \ No newline at end of file diff --git a/Models/ClientSettings.cs b/Models/ClientSettings.cs new file mode 100644 index 0000000..98946e5 --- /dev/null +++ b/Models/ClientSettings.cs @@ -0,0 +1,77 @@ +using CloudPrint.Client.Infrastructure; + +namespace CloudPrint.Client.Models; + +public sealed class ClientSettings : ObservableObject +{ + private string _serverAddress = "http://localhost:8080"; + private string _apiKey = string.Empty; + private string _adminUsername = string.Empty; + private string _adminAccessToken = string.Empty; + private string _storeName = "默认打印店"; + private bool _allowInsecureGrpc = true; + private string _libraryRoot = string.Empty; + private string _printCacheRoot = string.Empty; + private int _maxUploadSizeMb = 50; + private int _heartbeatIntervalSeconds = 15; + + public string ServerAddress + { + get => _serverAddress; + set => SetProperty(ref _serverAddress, value); + } + + public string ApiKey + { + get => _apiKey; + set => SetProperty(ref _apiKey, value); + } + + public string AdminUsername + { + get => _adminUsername; + set => SetProperty(ref _adminUsername, value); + } + + public string AdminAccessToken + { + get => _adminAccessToken; + set => SetProperty(ref _adminAccessToken, value); + } + + public string StoreName + { + get => _storeName; + set => SetProperty(ref _storeName, value); + } + + public bool AllowInsecureGrpc + { + get => _allowInsecureGrpc; + set => SetProperty(ref _allowInsecureGrpc, value); + } + + public string LibraryRoot + { + get => _libraryRoot; + set => SetProperty(ref _libraryRoot, value); + } + + public string PrintCacheRoot + { + get => _printCacheRoot; + set => SetProperty(ref _printCacheRoot, value); + } + + public int MaxUploadSizeMb + { + get => _maxUploadSizeMb; + set => SetProperty(ref _maxUploadSizeMb, Math.Clamp(value, 1, 1024)); + } + + public int HeartbeatIntervalSeconds + { + get => _heartbeatIntervalSeconds; + set => SetProperty(ref _heartbeatIntervalSeconds, Math.Clamp(value, 5, 300)); + } +} \ No newline at end of file diff --git a/Models/ConnectionState.cs b/Models/ConnectionState.cs new file mode 100644 index 0000000..4d2867f --- /dev/null +++ b/Models/ConnectionState.cs @@ -0,0 +1,11 @@ +namespace CloudPrint.Client.Models; + +public enum ConnectionState +{ + Disconnected, + Connecting, + Connected, + Subscribing, + Subscribed, + Error +} \ No newline at end of file diff --git a/Models/Document.cs b/Models/Document.cs new file mode 100644 index 0000000..dfa5199 --- /dev/null +++ b/Models/Document.cs @@ -0,0 +1,71 @@ +using CloudPrint.Client.Infrastructure; + +namespace CloudPrint.Client.Models; + +public sealed class CloudPrintDocument : ObservableObject +{ + private string _title = string.Empty; + private string _description = string.Empty; + private string _categoryName = "未分类"; + private bool _isUploaded; + private string _syncStatus = "本地"; + + public int Id { get; init; } + public string FileName { get; init; } = string.Empty; + public string FilePath { get; init; } = string.Empty; + public string FileType { get; init; } = string.Empty; + public long FileSize { get; init; } + public string UploaderType { get; init; } = "admin"; + public int UploaderId { get; init; } + public int CategoryId { get; init; } + public int PageCount { get; init; } + public DateTime CreatedAt { get; init; } = DateTime.Now; + + public string Title + { + get => _title; + set => SetProperty(ref _title, value); + } + + public string Description + { + get => _description; + set => SetProperty(ref _description, value); + } + + public string CategoryName + { + get => _categoryName; + set => SetProperty(ref _categoryName, value); + } + + public bool IsUploaded + { + get => _isUploaded; + set => SetProperty(ref _isUploaded, value); + } + + public string SyncStatus + { + get => _syncStatus; + set => SetProperty(ref _syncStatus, value); + } + + public string FileSizeText + { + get + { + if (FileSize >= 1024 * 1024) + { + return $"{FileSize / 1024d / 1024d:F2} MB"; + } + + if (FileSize >= 1024) + { + return $"{FileSize / 1024d:F2} KB"; + } + + return $"{FileSize} B"; + } + } +} \ No newline at end of file diff --git a/Models/FilePreparationResult.cs b/Models/FilePreparationResult.cs new file mode 100644 index 0000000..54c6636 --- /dev/null +++ b/Models/FilePreparationResult.cs @@ -0,0 +1,9 @@ +namespace CloudPrint.Client.Models; + +public sealed class FilePreparationResult +{ + public string SourcePath { get; init; } = string.Empty; + public string PreparedPath { get; init; } = string.Empty; + public bool Converted { get; init; } + public string Message { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/Models/FileValidationResult.cs b/Models/FileValidationResult.cs new file mode 100644 index 0000000..59e27ee --- /dev/null +++ b/Models/FileValidationResult.cs @@ -0,0 +1,17 @@ +namespace CloudPrint.Client.Models; + +public sealed class FileValidationResult +{ + public bool IsValid { get; init; } + public string Message { get; init; } = string.Empty; + + public static FileValidationResult Success(string message = "文件可用") + { + return new FileValidationResult { IsValid = true, Message = message }; + } + + public static FileValidationResult Failure(string message) + { + return new FileValidationResult { IsValid = false, Message = message }; + } +} \ No newline at end of file diff --git a/Models/LibraryCategory.cs b/Models/LibraryCategory.cs new file mode 100644 index 0000000..8954b15 --- /dev/null +++ b/Models/LibraryCategory.cs @@ -0,0 +1,9 @@ +namespace CloudPrint.Client.Models; + +public sealed class LibraryCategory +{ + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Icon { get; init; } = string.Empty; + public int SortOrder { get; init; } +} \ No newline at end of file diff --git a/Models/PrintJob.cs b/Models/PrintJob.cs new file mode 100644 index 0000000..6bfd924 --- /dev/null +++ b/Models/PrintJob.cs @@ -0,0 +1,110 @@ +using CloudPrint.Client.Infrastructure; + +namespace CloudPrint.Client.Models; + +public enum PrintJobStatus +{ + Pending, + Printing, + Completed, + Failed, + Cancelled +} + +public sealed class PrintJob : ObservableObject +{ + private PrintJobStatus _status = PrintJobStatus.Pending; + private int _progress; + private string _errorMessage = string.Empty; + private int? _windowsJobId; + private string _windowsJobStatus = string.Empty; + + public int Id { get; init; } + public string JobNo { get; init; } = string.Empty; + public int PrinterId { get; set; } + public string PrinterName { get; set; } = string.Empty; + public int OrderItemId { get; init; } + public int FileId { get; init; } + public string FileName { get; init; } = string.Empty; + public string FilePath { get; set; } = string.Empty; + public int Copies { get; init; } = 1; + public bool Color { get; init; } + public bool Duplex { get; init; } + public string PageRange { get; init; } = "all"; + public DateTime? StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } + + public int? WindowsJobId + { + get => _windowsJobId; + set => SetProperty(ref _windowsJobId, value); + } + + public string WindowsJobStatus + { + get => _windowsJobStatus; + set => SetProperty(ref _windowsJobStatus, value); + } + + public PrintJobStatus Status + { + get => _status; + set + { + if (SetProperty(ref _status, value)) + { + OnPropertyChanged(nameof(StatusText)); + } + } + } + + public int Progress + { + get => _progress; + set => SetProperty(ref _progress, Math.Clamp(value, 0, 100)); + } + + public string ErrorMessage + { + get => _errorMessage; + set => SetProperty(ref _errorMessage, value); + } + + public string StatusText => Status switch + { + PrintJobStatus.Pending => "待打印", + PrintJobStatus.Printing => "打印中", + PrintJobStatus.Completed => "已完成", + PrintJobStatus.Failed => "失败", + PrintJobStatus.Cancelled => "已取消", + _ => "未知" + }; + + public void MarkPrinting() + { + StartedAt ??= DateTime.Now; + Status = PrintJobStatus.Printing; + Progress = Math.Max(Progress, 1); + } + + public void MarkCompleted() + { + Status = PrintJobStatus.Completed; + Progress = 100; + CompletedAt = DateTime.Now; + ErrorMessage = string.Empty; + } + + public void MarkFailed(string message) + { + Status = PrintJobStatus.Failed; + ErrorMessage = message; + CompletedAt = DateTime.Now; + } + + public void MarkCancelled() + { + Status = PrintJobStatus.Cancelled; + CompletedAt = DateTime.Now; + } +} \ No newline at end of file diff --git a/Models/Printer.cs b/Models/Printer.cs new file mode 100644 index 0000000..8b3e634 --- /dev/null +++ b/Models/Printer.cs @@ -0,0 +1,82 @@ +using CloudPrint.Client.Infrastructure; + +namespace CloudPrint.Client.Models; + +public enum PrinterStatus +{ + Idle = 0, + Printing = 1, + Offline = 2, + Error = 3, + Unknown = 99 +} + +public sealed class PrinterInfo : ObservableObject +{ + private PrinterStatus _status; + private int _queueCount; + private string _statusMessage = string.Empty; + + public int Id { get; init; } + public string Name { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public bool IsActive { get; init; } = true; + public string ApiKey { get; init; } = string.Empty; + public DateTime? LastHeartbeat { get; set; } + + public int QueueCount + { + get => _queueCount; + set + { + if (SetProperty(ref _queueCount, Math.Max(0, value))) + { + OnPropertyChanged(nameof(DisplayText)); + } + } + } + + public string StatusMessage + { + get => _statusMessage; + set + { + if (SetProperty(ref _statusMessage, value)) + { + OnPropertyChanged(nameof(DisplayText)); + } + } + } + + public PrinterStatus Status + { + get => _status; + set + { + if (SetProperty(ref _status, value)) + { + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(DisplayText)); + } + } + } + + public string StatusText => Status switch + { + PrinterStatus.Idle => "空闲", + PrinterStatus.Printing => "打印中", + PrinterStatus.Offline => "离线", + PrinterStatus.Error => "异常", + _ => "未知" + }; + + public string DisplayText + { + get + { + var queueText = QueueCount > 0 ? $",队列 {QueueCount}" : string.Empty; + var messageText = string.IsNullOrWhiteSpace(StatusMessage) ? string.Empty : $",{StatusMessage}"; + return $"{DisplayName}({StatusText}{queueText}{messageText})"; + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 666cadb..f6e20cf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,213 @@ -# Client +# 云印享 - Windows 门店打印客户端 +云印享 Windows 门店打印客户端是部署在打印店本地 Windows 电脑上的 WPF 程序,负责连接真实 Go gRPC 后端、注册本机打印机、订阅打印任务、预处理文件并调用 Windows 打印系统执行打印。 + +本客户端只承担 **门店本地管理与打印执行** 职责,不替代微信小程序、React 管理后台或 Go 后端服务。 + +## 技术栈 + +| 技术 | 说明 | +|------|------| +| 框架 | .NET 8 + WPF | +| 语言 | C# | +| 架构 | MVVM | +| 通信 | gRPC + WebSocket | +| 协议 | Protobuf | +| 打印 | Windows Print Spooler API / PrintDocument | +| 文件处理 | PDFsharp / SharpCompress / LibreOffice Headless | +| 配置安全 | Windows DPAPI | + +## 功能模块 + +- 🖨️ **打印机管理** - 枚举本机实体/网络打印机,过滤 PDF、XPS、OneNote、Fax 等虚拟输出设备 +- 📋 **打印任务队列** - 订阅后端任务流,自动顺序处理,支持暂停、恢复、取消任务 +- 📄 **文件预处理** - 支持 PDF、图片、Office/TXT、ZIP、RAR 等文件处理 +- 📌 **PDF 处理** - 支持订单页码范围裁剪 +- 📁 **文库管理** - 管理员上传本地资料到后端文库服务 +- 🔐 **认证与配置** - API Key 注册打印机,管理员登录,敏感配置 DPAPI 加密保存 +- 🔄 **连接保活** - 心跳、订阅恢复、指数退避重连、任务去重 +- 📝 **日志记录** - UI 日志和异常堆栈落盘到本地日志目录 + +## 项目结构 + +```text +client/ +├── App.xaml # 应用入口 +├── CloudPrint.Client.csproj # WPF 项目文件 +├── Helpers/ # Windows API 与 PDF 工具 +│ ├── PdfProcessor.cs +│ └── WindowsPrintApi.cs +├── Infrastructure/ # MVVM 基础设施 +│ ├── AsyncRelayCommand.cs +│ ├── ObservableObject.cs +│ ├── RelayCommand.cs +│ └── WpfUiDispatcher.cs +├── Models/ # 领域模型 +│ ├── ClientSettings.cs +│ ├── Document.cs +│ ├── Printer.cs +│ └── PrintJob.cs +├── Services/ # 业务服务 +│ ├── Abstractions/ # 服务接口 +│ ├── Grpc/ # gRPC metadata 与模型映射 +│ ├── Printing/ # 打印执行器与队列服务 +│ ├── FileService.cs # 文件校验、解压、转换 +│ ├── GrpcService.cs # 后端 gRPC 通信 +│ ├── JsonSettingsService.cs # 本地配置持久化 +│ ├── PrintJobCoordinator.cs # 打印任务协调流程 +│ ├── PrintService.cs # 打印服务门面 +│ └── WebSocketService.cs # WebSocket 备用通道 +├── ViewModels/ # 页面 ViewModel +│ ├── MainViewModel.cs +│ ├── PrintQueueViewModel.cs +│ ├── LibraryViewModel.cs +│ └── SettingsViewModel.cs +├── Views/ # WPF 页面 +│ ├── MainWindow.xaml +│ ├── PrintQueueView.xaml +│ ├── LibraryView.xaml +│ └── SettingsView.xaml +``` + +## 环境要求 + +- Windows 10/11 +- .NET 8 SDK 或 .NET 8 Desktop Runtime +- 可用的 Go gRPC 后端服务 +- 真实有效的打印客户端 API Key +- 至少一台实体或网络 Windows 打印机 +- PDF 打印建议安装 SumatraPDF +- Office/TXT 转 PDF 需要安装 LibreOffice 或配置 `SOFFICE_PATH` + +## 快速开始 + +### 1. 还原依赖 + +```powershell +dotnet restore .\CloudPrint.Client.csproj +``` + +### 2. 启动客户端 + +```powershell +dotnet run --project .\CloudPrint.Client.csproj +``` + +### 3. 配置客户端 + +在“系统设置”页配置: + +- 服务器地址:Go gRPC 后端地址,例如 `http://localhost:8080` +- API Key:后端为门店打印客户端分配的认证 Key +- 门店名称:当前打印店名称 +- 本地文库目录:门店本地资料目录 +- 允许 HTTP gRPC:本地开发或内网明文 HTTP/2 时启用,生产环境建议关闭 +- 最大上传大小、心跳间隔等运行参数 + +### 4. 注册并订阅任务 + +1. 在主界面选择真实打印机 +2. 点击连接后端 +3. 点击订阅打印任务 +4. 保持客户端在线,后端推送任务后自动进入打印队列 + +## 开发命令 + +```powershell +# 构建客户端 +dotnet build .\CloudPrint.Client.csproj + +# 检查依赖漏洞 +dotnet list .\CloudPrint.Client.csproj package --vulnerable +``` + +## 对接服务 + +客户端基于 `backend/cloudprint-backend/proto/cloudprint.proto` 生成 C# gRPC Stub,主要对接以下服务: + +| 服务 | 用途 | +|------|------| +| PrintService.RegisterPrinter | 注册本机打印机 | +| PrintService.PrinterHeartbeat | 上报打印机心跳和状态 | +| PrintService.SubscribeJobs | 订阅后端打印任务流 | +| PrintService.ReportJobStatus | 上报打印任务进度和结果 | +| PrintService.CancelPrintJob | 取消后端打印任务 | +| FileService.GetFile | 下载待打印文件 | +| LibraryService.UploadFile | 上传门店文库文件 | +| AdminService.Login | 管理员登录 | + +## 文件处理能力 + +| 类型 | 格式 | 处理方式 | +|------|------|----------| +| PDF | `.pdf` | 支持页码范围裁剪,直接提交 PDF 打印 | +| 图片 | `.jpg`, `.jpeg`, `.png`, `.bmp`, `.gif` | 使用 `PrintDocument` 等比缩放打印 | +| Office | `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx` | 通过 LibreOffice Headless 转 PDF 后打印 | +| 文本 | `.txt` | 通过 LibreOffice Headless 转 PDF 后打印 | +| 压缩包 | `.zip`, `.rar` | 使用 SharpCompress 解压首个可打印文件后继续处理 | + +压缩包处理会限制条目数量、嵌套层级和单文件大小,并清理压缩包内路径,避免路径穿越。 + +## 打印行为 + +- 客户端只展示实体/网络打印机,默认过滤虚拟输出设备 +- PDF 打印优先使用 SumatraPDF,未安装时回退系统默认 PDF 打印程序的 `printto` 动作 +- 图片打印使用 `PrintDocument` +- 其他格式在预处理后无法匹配专用执行器时回退 RAW 打印 +- Windows 队列 JobId 优先通过 WinSpool `EnumJobs` 捕获,WMI 兜底 +- 取消任务时优先通过 WinSpool `SetJob` 删除/取消真实系统队列任务 + +## 配置与安全 + +本地设置保存到: + +```text +%APPDATA%\CloudPrint.Client\settings.json +``` + +安全策略: + +- API Key 使用 Windows DPAPI 按当前用户加密保存 +- 管理员 access token 使用 Windows DPAPI 按当前用户加密保存 +- 设置文件复制到其他 Windows 用户后无法解密敏感字段 +- 生产环境建议使用 HTTPS/TLS gRPC,并关闭 HTTP gRPC + +## 日志 + +日志目录: + +```text +%APPDATA%\CloudPrint.Client\logs +``` + +记录内容包括: + +- 连接和注册结果 +- 打印任务状态 +- 文件下载和预处理结果 +- 打印失败异常 +- 全局未处理异常 + +## 注意事项 + +- 本客户端不提供模拟后端或 DryRun,运行时必须连接真实后端和真实打印机 +- Windows 自带的 `Microsoft Print to PDF`、`XPS Document Writer`、`OneNote`、`Fax` 等不会作为门店打印机展示 +- 若门店电脑只安装虚拟打印设备,客户端打印机列表会为空 +- Office/TXT 文件转换依赖 LibreOffice,请提前安装或配置 `SOFFICE_PATH` +- PDF 打印建议安装 SumatraPDF,以获得更稳定的命令行打印能力 + +## 后续优化 + +- 根据后端最终 WebSocket 消息结构,将备用通道消息映射为打印任务或控制事件 +- 按后端任务字段接入 PDF 水印和合并业务流程 +- 增强 LibreOffice/SumatraPDF 路径检测和设置页提示 +- 增加托盘常驻、开机自启动、单实例运行和安装包 +- 继续提升同名文档并发场景下的 Windows JobId 匹配准确性 + +## 许可证 + +MIT License + +--- + +© 2026 云印享 版权所有 diff --git a/Services/Abstractions/IAppLogger.cs b/Services/Abstractions/IAppLogger.cs new file mode 100644 index 0000000..674ed79 --- /dev/null +++ b/Services/Abstractions/IAppLogger.cs @@ -0,0 +1,9 @@ +using CloudPrint.Client.Models; + +namespace CloudPrint.Client.Services.Abstractions; + +public interface IAppLogger +{ + string LogDirectory { get; } + void Write(AppLogEntry entry); +} \ No newline at end of file diff --git a/Services/Abstractions/IFileService.cs b/Services/Abstractions/IFileService.cs new file mode 100644 index 0000000..8fd2c6c --- /dev/null +++ b/Services/Abstractions/IFileService.cs @@ -0,0 +1,16 @@ +using CloudPrint.Client.Models; + +namespace CloudPrint.Client.Services.Abstractions; + +public interface IFileService +{ + long MaxFileSizeBytes { get; } + IReadOnlyCollection 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 PrepareForPrintAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Services/Abstractions/IPrintBackendClient.cs b/Services/Abstractions/IPrintBackendClient.cs new file mode 100644 index 0000000..e4b7455 --- /dev/null +++ b/Services/Abstractions/IPrintBackendClient.cs @@ -0,0 +1,27 @@ +using CloudPrint.Client.Models; + +namespace CloudPrint.Client.Services.Abstractions; + +public interface IPrintBackendClient : IAsyncDisposable +{ + event EventHandler? PrintJobReceived; + event EventHandler? LogReceived; + + bool IsConnected { get; } + int PrinterId { get; } + string Token { get; } + AdminSession AdminSession { get; } + + Task ConnectAsync(string address, string apiKey, CancellationToken cancellationToken = default); + Task 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 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(); +} \ No newline at end of file diff --git a/Services/Abstractions/IPrintService.cs b/Services/Abstractions/IPrintService.cs new file mode 100644 index 0000000..cb09c47 --- /dev/null +++ b/Services/Abstractions/IPrintService.cs @@ -0,0 +1,13 @@ +using CloudPrint.Client.Models; + +namespace CloudPrint.Client.Services.Abstractions; + +public interface IPrintService +{ + IReadOnlyList GetPrinters(); + PrinterStatus GetPrinterStatus(string printerName); + Task SubmitPrintJobAsync(PrintJob job, IProgress? progress = null, CancellationToken cancellationToken = default); + bool CancelPrintJob(string jobId); + bool CancelPrintJob(PrintJob job); + PrintJobStatus GetJobStatus(string jobId); +} \ No newline at end of file diff --git a/Services/Abstractions/ISecretProtector.cs b/Services/Abstractions/ISecretProtector.cs new file mode 100644 index 0000000..2e872cf --- /dev/null +++ b/Services/Abstractions/ISecretProtector.cs @@ -0,0 +1,7 @@ +namespace CloudPrint.Client.Services.Abstractions; + +public interface ISecretProtector +{ + string Protect(string plainText); + string Unprotect(string protectedText); +} \ No newline at end of file diff --git a/Services/Abstractions/ISettingsService.cs b/Services/Abstractions/ISettingsService.cs new file mode 100644 index 0000000..7ca4a87 --- /dev/null +++ b/Services/Abstractions/ISettingsService.cs @@ -0,0 +1,9 @@ +using CloudPrint.Client.Models; + +namespace CloudPrint.Client.Services.Abstractions; + +public interface ISettingsService +{ + ClientSettings Load(); + void Save(ClientSettings settings); +} \ No newline at end of file diff --git a/Services/Abstractions/IWebSocketService.cs b/Services/Abstractions/IWebSocketService.cs new file mode 100644 index 0000000..dea2b13 --- /dev/null +++ b/Services/Abstractions/IWebSocketService.cs @@ -0,0 +1,14 @@ +using CloudPrint.Client.Models; + +namespace CloudPrint.Client.Services.Abstractions; + +public interface IWebSocketService +{ + event EventHandler? LogReceived; + + bool IsConnected { get; } + Uri? Endpoint { get; } + + Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default); + Task DisconnectAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Services/DpapiSecretProtector.cs b/Services/DpapiSecretProtector.cs new file mode 100644 index 0000000..67dd5f9 --- /dev/null +++ b/Services/DpapiSecretProtector.cs @@ -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); + } +} \ No newline at end of file diff --git a/Services/FileAppLogger.cs b/Services/FileAppLogger.cs new file mode 100644 index 0000000..7beb57b --- /dev/null +++ b/Services/FileAppLogger.cs @@ -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}"; + } +} \ No newline at end of file diff --git a/Services/FileService.cs b/Services/FileService.cs new file mode 100644 index 0000000..4f93259 --- /dev/null +++ b/Services/FileService.cs @@ -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; + +/// +/// 文件处理服务:校验白名单、管理本地文库副本、解压压缩包、转换 Office/TXT 并处理 PDF 页码范围。 +/// +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 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 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 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 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 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 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"; + } +} \ No newline at end of file diff --git a/Services/Grpc/CloudPrintGrpcMapper.cs b/Services/Grpc/CloudPrintGrpcMapper.cs new file mode 100644 index 0000000..cfa1fc2 --- /dev/null +++ b/Services/Grpc/CloudPrintGrpcMapper.cs @@ -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 + }; + } +} \ No newline at end of file diff --git a/Services/Grpc/GrpcMetadataFactory.cs b/Services/Grpc/GrpcMetadataFactory.cs new file mode 100644 index 0000000..6ebf21f --- /dev/null +++ b/Services/Grpc/GrpcMetadataFactory.cs @@ -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); + } + } +} \ No newline at end of file diff --git a/Services/GrpcService.cs b/Services/GrpcService.cs new file mode 100644 index 0000000..9223830 --- /dev/null +++ b/Services/GrpcService.cs @@ -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; } +} + +/// +/// 真实 gRPC 通信服务,负责连接后端、注册打印机、订阅任务、下载文件和状态上报。 +/// +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? PrintJobReceived; + public event EventHandler? 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 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 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 + }); + } +} \ No newline at end of file diff --git a/Services/JsonSettingsService.cs b/Services/JsonSettingsService.cs new file mode 100644 index 0000000..f01e78e --- /dev/null +++ b/Services/JsonSettingsService.cs @@ -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(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; + } + } + } +} \ No newline at end of file diff --git a/Services/PathHelper.cs b/Services/PathHelper.cs new file mode 100644 index 0000000..966365f --- /dev/null +++ b/Services/PathHelper.cs @@ -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); + } +} \ No newline at end of file diff --git a/Services/PrintJobCoordinator.cs b/Services/PrintJobCoordinator.cs new file mode 100644 index 0000000..fd3187a --- /dev/null +++ b/Services/PrintJobCoordinator.cs @@ -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? 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(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; + } + } +} \ No newline at end of file diff --git a/Services/PrintService.cs b/Services/PrintService.cs new file mode 100644 index 0000000..6695d2c --- /dev/null +++ b/Services/PrintService.cs @@ -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; + +/// +/// 真实打印服务:枚举 Windows 打印机,按文件类型提交到图片/PDF/RAW 打印执行器。 +/// +public sealed class PrintService : IPrintService +{ + private readonly IReadOnlyList _executors; + private readonly IPrinterStatusReader _statusReader; + private readonly IWindowsPrintQueueService _queueService; + + public PrintService() + : this(CreateDefaultExecutors(), new WindowsPrinterStatusReader(), new WindowsPrintQueueService()) + { + } + + private static IReadOnlyList CreateDefaultExecutors() + { + var queueService = new WindowsPrintQueueService(); + return new IPrintJobExecutor[] + { + new ImagePrintJobExecutor(queueService), + new PdfPrintJobExecutor(queueService), + new RawPrintJobExecutor() + }; + } + + public PrintService(IEnumerable executors, IPrinterStatusReader statusReader, IWindowsPrintQueueService queueService) + { + _executors = executors.ToList(); + _statusReader = statusReader; + _queueService = queueService; + if (_executors.Count == 0) + { + throw new ArgumentException("至少需要一个打印执行器", nameof(executors)); + } + } + + public IReadOnlyList GetPrinters() + { + var printers = new List(); + 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 SubmitPrintJobAsync(PrintJob job, IProgress? 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)); + } + +} \ No newline at end of file diff --git a/Services/Printing/IPrintJobExecutor.cs b/Services/Printing/IPrintJobExecutor.cs new file mode 100644 index 0000000..4df1ba7 --- /dev/null +++ b/Services/Printing/IPrintJobExecutor.cs @@ -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? progress, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Services/Printing/IPrinterStatusReader.cs b/Services/Printing/IPrinterStatusReader.cs new file mode 100644 index 0000000..d25eb93 --- /dev/null +++ b/Services/Printing/IPrinterStatusReader.cs @@ -0,0 +1,6 @@ +namespace CloudPrint.Client.Services.Printing; + +public interface IPrinterStatusReader +{ + PrinterStatusSnapshot Read(string printerName); +} \ No newline at end of file diff --git a/Services/Printing/IWindowsPrintQueueService.cs b/Services/Printing/IWindowsPrintQueueService.cs new file mode 100644 index 0000000..0235178 --- /dev/null +++ b/Services/Printing/IWindowsPrintQueueService.cs @@ -0,0 +1,7 @@ +namespace CloudPrint.Client.Services.Printing; + +public interface IWindowsPrintQueueService +{ + WindowsPrintQueueJob? FindLatestJob(string printerName, string documentName); + bool CancelJob(string printerName, int jobId); +} \ No newline at end of file diff --git a/Services/Printing/ImagePrintJobExecutor.cs b/Services/Printing/ImagePrintJobExecutor.cs new file mode 100644 index 0000000..b6b4229 --- /dev/null +++ b/Services/Printing/ImagePrintJobExecutor.cs @@ -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 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? 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; + } +} \ No newline at end of file diff --git a/Services/Printing/PdfPrintJobExecutor.cs b/Services/Printing/PdfPrintJobExecutor.cs new file mode 100644 index 0000000..f44b4d9 --- /dev/null +++ b/Services/Printing/PdfPrintJobExecutor.cs @@ -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? 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)); + } +} \ No newline at end of file diff --git a/Services/Printing/PrintFileGuards.cs b/Services/Printing/PrintFileGuards.cs new file mode 100644 index 0000000..8281b78 --- /dev/null +++ b/Services/Printing/PrintFileGuards.cs @@ -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); + } + } +} \ No newline at end of file diff --git a/Services/Printing/PrinterStatusSnapshot.cs b/Services/Printing/PrinterStatusSnapshot.cs new file mode 100644 index 0000000..699e98d --- /dev/null +++ b/Services/Printing/PrinterStatusSnapshot.cs @@ -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; +} \ No newline at end of file diff --git a/Services/Printing/RawPrintJobExecutor.cs b/Services/Printing/RawPrintJobExecutor.cs new file mode 100644 index 0000000..6ee21cc --- /dev/null +++ b/Services/Printing/RawPrintJobExecutor.cs @@ -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? 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? 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); + } +} \ No newline at end of file diff --git a/Services/Printing/WindowsPrintQueueJob.cs b/Services/Printing/WindowsPrintQueueJob.cs new file mode 100644 index 0000000..b91899f --- /dev/null +++ b/Services/Printing/WindowsPrintQueueJob.cs @@ -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; } +} \ No newline at end of file diff --git a/Services/Printing/WindowsPrintQueueService.cs b/Services/Printing/WindowsPrintQueueService.cs new file mode 100644 index 0000000..f85ea62 --- /dev/null +++ b/Services/Printing/WindowsPrintQueueService.cs @@ -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 EnumerateJobs(string printerName) + { + var spoolerJobs = EnumerateJobsWithWinSpool(printerName); + if (spoolerJobs.Count > 0) + { + return spoolerJobs; + } + + return EnumerateJobsWithWmi(printerName); + } + + private static IReadOnlyList EnumerateJobsWithWinSpool(string printerName) + { + if (!WindowsPrintApi.OpenPrinter(printerName, out var printerHandle, IntPtr.Zero)) + { + return Array.Empty(); + } + + try + { + const int maxJobs = 256; + _ = WindowsPrintApi.EnumJobs(printerHandle, 0, maxJobs, 1, IntPtr.Zero, 0, out var bytesNeeded, out _); + if (bytesNeeded <= 0) + { + return Array.Empty(); + } + + var buffer = Marshal.AllocHGlobal(bytesNeeded); + try + { + if (!WindowsPrintApi.EnumJobs(printerHandle, 0, maxJobs, 1, buffer, bytesNeeded, out _, out var jobsReturned)) + { + return Array.Empty(); + } + + var jobs = new List(jobsReturned); + var itemSize = Marshal.SizeOf(); + for (var index = 0; index < jobsReturned; index++) + { + var itemPointer = IntPtr.Add(buffer, index * itemSize); + var jobInfo = Marshal.PtrToStructure(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 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 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; + } + } +} \ No newline at end of file diff --git a/Services/Printing/WindowsPrinterStatusReader.cs b/Services/Printing/WindowsPrinterStatusReader.cs new file mode 100644 index 0000000..8755511 --- /dev/null +++ b/Services/Printing/WindowsPrinterStatusReader.cs @@ -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 + } + }; + } +} \ No newline at end of file diff --git a/Services/WebSocketService.cs b/Services/WebSocketService.cs new file mode 100644 index 0000000..f4230d6 --- /dev/null +++ b/Services/WebSocketService.cs @@ -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; + +/// +/// WebSocket 备用通信通道。 +/// 当前后端主链路仍使用 gRPC 订阅;当后端提供 /ws/print 时,本服务可保持备用长连接并记录推送消息。 +/// +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? 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] + "..."; + } +} \ No newline at end of file diff --git a/ViewModels/LibraryViewModel.cs b/ViewModels/LibraryViewModel.cs new file mode 100644 index 0000000..96d5ff1 --- /dev/null +++ b/ViewModels/LibraryViewModel.cs @@ -0,0 +1,154 @@ +using System.Collections.ObjectModel; +using CloudPrint.Client.Infrastructure; +using CloudPrint.Client.Models; +using CloudPrint.Client.Services; +using CloudPrint.Client.Services.Abstractions; +using Microsoft.Win32; + +namespace CloudPrint.Client.ViewModels; + +public sealed class LibraryViewModel : ObservableObject +{ + private readonly IFileService _fileService; + private readonly IPrintBackendClient _backendClient; + private readonly ClientSettings _settings; + private LibraryCategory? _selectedCategory; + private CloudPrintDocument? _selectedDocument; + private string _statusMessage = "文库就绪"; + + public LibraryViewModel() + : this(new FileService(), new GrpcService(), new ClientSettings()) + { + } + + public LibraryViewModel(IFileService fileService, IPrintBackendClient backendClient, ClientSettings settings) + { + _fileService = fileService; + _backendClient = backendClient; + _settings = settings; + + Categories.Add(new LibraryCategory { Id = 1, Name = "教材资料", Icon = "📘", SortOrder = 1 }); + Categories.Add(new LibraryCategory { Id = 2, Name = "考试真题", Icon = "📝", SortOrder = 2 }); + Categories.Add(new LibraryCategory { Id = 3, Name = "证件照片", Icon = "🖼️", SortOrder = 3 }); + Categories.Add(new LibraryCategory { Id = 4, Name = "其他文件", Icon = "📁", SortOrder = 99 }); + + SelectedCategory = Categories.FirstOrDefault(); + AddDocumentCommand = new RelayCommand(AddDocument); + RemoveDocumentCommand = new RelayCommand(RemoveSelectedDocument, () => SelectedDocument is not null); + SyncSelectedCommand = new AsyncRelayCommand(SyncSelectedAsync, () => SelectedDocument is not null && _backendClient.IsConnected); + SyncAllCommand = new AsyncRelayCommand(SyncAllAsync, () => Documents.Any(document => !document.IsUploaded) && _backendClient.IsConnected); + } + + public ObservableCollection Categories { get; } = new(); + public ObservableCollection Documents { get; } = new(); + + public LibraryCategory? SelectedCategory + { + get => _selectedCategory; + set => SetProperty(ref _selectedCategory, value); + } + + public CloudPrintDocument? SelectedDocument + { + get => _selectedDocument; + set + { + if (SetProperty(ref _selectedDocument, value)) + { + RemoveDocumentCommand.RaiseCanExecuteChanged(); + SyncSelectedCommand.RaiseCanExecuteChanged(); + } + } + } + + public string StatusMessage + { + get => _statusMessage; + private set => SetProperty(ref _statusMessage, value); + } + + public RelayCommand AddDocumentCommand { get; } + public RelayCommand RemoveDocumentCommand { get; } + public AsyncRelayCommand SyncSelectedCommand { get; } + public AsyncRelayCommand SyncAllCommand { get; } + + public void RefreshCommandState() + { + SyncSelectedCommand.RaiseCanExecuteChanged(); + SyncAllCommand.RaiseCanExecuteChanged(); + } + + private void AddDocument() + { + var dialog = new Microsoft.Win32.OpenFileDialog + { + Title = "选择要加入文库的文件", + Multiselect = true, + Filter = "支持的文件|*.pdf;*.doc;*.docx;*.txt;*.xls;*.xlsx;*.ppt;*.pptx;*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.zip;*.rar|所有文件|*.*" + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + var addedCount = 0; + var categoryName = SelectedCategory?.Name ?? "未分类"; + var limit = Math.Max(1, _settings.MaxUploadSizeMb) * 1024L * 1024L; + + foreach (var fileName in dialog.FileNames) + { + var validation = _fileService.ValidateFile(fileName, limit); + if (!validation.IsValid) + { + StatusMessage = $"跳过 {System.IO.Path.GetFileName(fileName)}:{validation.Message}"; + continue; + } + + var document = _fileService.CopyToLibrary(fileName, _settings.LibraryRoot, categoryName, SelectedCategory?.Id ?? 0); + Documents.Insert(0, document); + addedCount++; + } + + StatusMessage = addedCount > 0 ? $"已加入 {addedCount} 个文件到文库" : "没有文件被加入文库"; + RefreshCommandState(); + } + + private void RemoveSelectedDocument() + { + if (SelectedDocument is null) + { + return; + } + + var document = SelectedDocument; + Documents.Remove(document); + SelectedDocument = null; + StatusMessage = $"已从列表移除:{document.Title}"; + RefreshCommandState(); + } + + private async Task SyncSelectedAsync() + { + if (SelectedDocument is null) + { + return; + } + + await _backendClient.UploadLibraryDocumentAsync(SelectedDocument).ConfigureAwait(true); + StatusMessage = $"已同步:{SelectedDocument.Title}"; + RefreshCommandState(); + } + + private async Task SyncAllAsync() + { + var pendingDocuments = Documents.Where(document => !document.IsUploaded).ToList(); + foreach (var document in pendingDocuments) + { + await _backendClient.UploadLibraryDocumentAsync(document).ConfigureAwait(true); + } + + StatusMessage = $"已同步 {pendingDocuments.Count} 个文库文件"; + RefreshCommandState(); + } +} \ No newline at end of file diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..06aa34d --- /dev/null +++ b/ViewModels/MainViewModel.cs @@ -0,0 +1,767 @@ +using System.Collections.ObjectModel; +using CloudPrint.Client.Infrastructure; +using CloudPrint.Client.Models; +using CloudPrint.Client.Services; +using CloudPrint.Client.Services.Abstractions; +using Grpc.Core; + +namespace CloudPrint.Client.ViewModels; + +public sealed class MainViewModel : ObservableObject +{ + private const int MaxReconnectAttempts = 5; + private static readonly TimeSpan SubscriptionWatchInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan InitialReconnectDelay = TimeSpan.FromSeconds(2); + private static readonly TimeSpan MaxReconnectDelay = TimeSpan.FromSeconds(30); + + private readonly IPrintService _printService; + private readonly IPrintBackendClient _backendClient; + private readonly IWebSocketService _webSocketService; + private readonly ISettingsService _settingsService; + private readonly IUiDispatcher _uiDispatcher; + private readonly IAppLogger _appLogger; + private readonly PrintJobCoordinator _jobCoordinator; + private readonly HashSet _knownJobKeys = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _queueProcessingGate = new(1, 1); + private CancellationTokenSource? _heartbeatCts; + private CancellationTokenSource? _subscriptionWatchCts; + private CancellationTokenSource? _queueCts; + private CancellationTokenSource? _processingCts; + private Task? _queueWorker; + private PrintJob? _selectedJob; + private PrintJob? _processingJob; + private PrinterInfo? _selectedPrinter; + private ConnectionState _connectionState = ConnectionState.Disconnected; + private string _lastMessage = "已就绪"; + private bool _isReconnecting; + private bool _isManualDisconnect = true; + private bool _autoProcessQueue = true; + private bool _isQueuePaused; + private bool _isQueueProcessing; + + public MainViewModel() + : this(new PrintService(), new FileService(), new GrpcService(), new WebSocketService(), new JsonSettingsService(), new WpfUiDispatcher(), new FileAppLogger()) + { + } + + public MainViewModel( + IPrintService printService, + IFileService fileService, + IPrintBackendClient backendClient, + IWebSocketService webSocketService, + ISettingsService settingsService, + IUiDispatcher uiDispatcher, + IAppLogger appLogger) + { + _printService = printService; + _backendClient = backendClient; + _webSocketService = webSocketService; + _settingsService = settingsService; + _uiDispatcher = uiDispatcher; + _appLogger = appLogger; + _jobCoordinator = new PrintJobCoordinator(fileService, printService, backendClient); + + Settings = _settingsService.Load(); + ConfigureBackendMode(); + Library = new LibraryViewModel(fileService, backendClient, Settings); + SettingsPanel = new SettingsViewModel(Settings, settingsService, backendClient, appLogger); + + _backendClient.PrintJobReceived += OnPrintJobReceived; + _backendClient.LogReceived += (_, entry) => AddLog(entry); + _webSocketService.LogReceived += (_, entry) => AddLog(entry); + + RefreshPrintersCommand = new RelayCommand(RefreshPrinters); + ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedPrinter is not null); + SubscribeCommand = new AsyncRelayCommand(SubscribeAsync, () => CanSubscribe); + ProcessSelectedJobCommand = new AsyncRelayCommand(ProcessSelectedJobAsync, () => Jobs.Any(job => job.Status == PrintJobStatus.Pending) && !IsQueueProcessing); + CancelSelectedJobCommand = new AsyncRelayCommand(CancelSelectedJobAsync, () => CanCancelSelectedJob); + PauseQueueCommand = new RelayCommand(PauseQueue, () => AutoProcessQueue && !IsQueuePaused); + ResumeQueueCommand = new RelayCommand(ResumeQueue, () => AutoProcessQueue && IsQueuePaused); + DisconnectCommand = new RelayCommand(Disconnect, () => _backendClient.IsConnected || _webSocketService.IsConnected); + SaveSettingsCommand = new RelayCommand(SaveSettings); + ClearLogsCommand = new RelayCommand(ClearLogs); + + RefreshPrinters(); + } + + public ClientSettings Settings { get; } + public ObservableCollection Printers { get; } = new(); + public ObservableCollection Jobs { get; } = new(); + public ObservableCollection Logs { get; } = new(); + public LibraryViewModel Library { get; } + public SettingsViewModel SettingsPanel { get; } + + public PrintJob? SelectedJob + { + get => _selectedJob; + set + { + if (SetProperty(ref _selectedJob, value)) + { + CancelSelectedJobCommand.RaiseCanExecuteChanged(); + } + } + } + + public PrinterInfo? SelectedPrinter + { + get => _selectedPrinter; + set + { + if (SetProperty(ref _selectedPrinter, value)) + { + (ConnectCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged(); + (SubscribeCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged(); + } + } + } + + public ConnectionState ConnectionState + { + get => _connectionState; + private set + { + if (SetProperty(ref _connectionState, value)) + { + OnPropertyChanged(nameof(ConnectionStatus)); + OnPropertyChanged(nameof(CanSubscribe)); + SubscribeCommand.RaiseCanExecuteChanged(); + DisconnectCommand.RaiseCanExecuteChanged(); + Library.RefreshCommandState(); + } + } + } + + public string ConnectionStatus => ConnectionState switch + { + ConnectionState.Connecting => "连接中", + ConnectionState.Connected => "已连接", + ConnectionState.Subscribing => "订阅中", + ConnectionState.Subscribed => "已订阅", + ConnectionState.Error => "连接异常", + _ => "未连接" + }; + + public bool CanSubscribe => SelectedPrinter is not null && _backendClient.IsConnected; + public bool CanCancelSelectedJob => SelectedJob is { Status: PrintJobStatus.Pending or PrintJobStatus.Printing }; + + public bool AutoProcessQueue + { + get => _autoProcessQueue; + set + { + if (SetProperty(ref _autoProcessQueue, value)) + { + OnPropertyChanged(nameof(QueueStatusText)); + RaiseQueueCommandStates(); + if (value) + { + StartQueueProcessingIfNeeded(); + } + } + } + } + + public bool IsQueuePaused + { + get => _isQueuePaused; + private set + { + if (SetProperty(ref _isQueuePaused, value)) + { + OnPropertyChanged(nameof(QueueStatusText)); + RaiseQueueCommandStates(); + } + } + } + + public bool IsQueueProcessing + { + get => _isQueueProcessing; + private set + { + if (SetProperty(ref _isQueueProcessing, value)) + { + OnPropertyChanged(nameof(QueueStatusText)); + RaiseQueueCommandStates(); + } + } + } + + public string QueueStatusText => AutoProcessQueue switch + { + false => "自动队列已关闭,可手动处理下一任务", + _ when IsQueuePaused => "队列已暂停,当前任务完成后不再继续处理", + _ when IsQueueProcessing => "队列处理中,任务将按顺序自动打印", + _ => "自动队列已开启,等待新任务" + }; + + public string LastMessage + { + get => _lastMessage; + private set => SetProperty(ref _lastMessage, value); + } + + public RelayCommand RefreshPrintersCommand { get; } + public AsyncRelayCommand ConnectCommand { get; } + public AsyncRelayCommand SubscribeCommand { get; } + public AsyncRelayCommand ProcessSelectedJobCommand { get; } + public AsyncRelayCommand CancelSelectedJobCommand { get; } + public RelayCommand PauseQueueCommand { get; } + public RelayCommand ResumeQueueCommand { get; } + public RelayCommand DisconnectCommand { get; } + public RelayCommand SaveSettingsCommand { get; } + public RelayCommand ClearLogsCommand { get; } + + private void RefreshPrinters() + { + Printers.Clear(); + foreach (var printer in _printService.GetPrinters()) + { + Printers.Add(printer); + } + + SelectedPrinter = Printers.FirstOrDefault(); + AddLog(AppLogLevel.Info, $"发现 {Printers.Count} 台打印机"); + } + + private async Task ConnectAsync() + { + if (SelectedPrinter is null) + { + AddLog(AppLogLevel.Warning, "请先选择打印机"); + return; + } + + try + { + _isManualDisconnect = false; + ConfigureBackendMode(); + ConnectionState = ConnectionState.Connecting; + await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey).ConfigureAwait(true); + await _backendClient.RegisterPrinterAsync(SelectedPrinter, Settings.ApiKey).ConfigureAwait(true); + await _backendClient.SendHeartbeatAsync(SelectedPrinter).ConfigureAwait(true); + await _webSocketService.ConnectAsync(new Uri(ToWebSocketAddress(Settings.ServerAddress))).ConfigureAwait(true); + _settingsService.Save(Settings); + StartHeartbeatLoop(); + + ConnectionState = ConnectionState.Connected; + DisconnectCommand.RaiseCanExecuteChanged(); + Library.RefreshCommandState(); + } + catch (Exception ex) + { + ConnectionState = ConnectionState.Error; + AddLog(AppLogEntry.FromException($"连接失败:{ex.Message}", ex)); + } + } + + private async Task SubscribeAsync() + { + if (SelectedPrinter is null) + { + AddLog(AppLogLevel.Warning, "请先选择打印机"); + return; + } + + try + { + ConnectionState = ConnectionState.Subscribing; + await _backendClient.SubscribePrintJobsAsync(SelectedPrinter).ConfigureAwait(true); + ConnectionState = ConnectionState.Subscribed; + StartSubscriptionWatchLoop(); + ProcessSelectedJobCommand.RaiseCanExecuteChanged(); + Library.RefreshCommandState(); + StartQueueProcessingIfNeeded(); + } + catch (Exception ex) + { + ConnectionState = ConnectionState.Error; + AddLog(AppLogEntry.FromException($"订阅失败:{ex.Message}", ex)); + } + } + + private async Task ProcessSelectedJobAsync() + { + var job = GetNextPendingJob(preferSelected: true); + if (job is null) + { + AddLog(AppLogLevel.Warning, "没有待打印任务"); + return; + } + + if (!await _queueProcessingGate.WaitAsync(0).ConfigureAwait(true)) + { + AddLog(AppLogLevel.Warning, "队列正在处理任务,请稍后再试"); + return; + } + + try + { + IsQueueProcessing = true; + await ProcessJobAsync(job, CancellationToken.None).ConfigureAwait(true); + } + finally + { + IsQueueProcessing = false; + _queueProcessingGate.Release(); + RaiseQueueCommandStates(); + } + } + + private async Task CancelSelectedJobAsync() + { + if (SelectedJob is null) + { + AddLog(AppLogLevel.Warning, "请先选择要取消的任务"); + return; + } + + try + { + if (ReferenceEquals(SelectedJob, _processingJob)) + { + _processingCts?.Cancel(); + } + + _printService.CancelPrintJob(SelectedJob); + await _backendClient.CancelPrintJobAsync(SelectedJob).ConfigureAwait(true); + await _backendClient.SendStatusUpdateAsync(SelectedJob).ConfigureAwait(true); + AddLog(AppLogLevel.Warning, $"已取消任务:{SelectedJob.JobNo}"); + } + catch (Exception ex) + { + AddLog(AppLogEntry.FromException($"取消任务失败:{ex.Message}", ex)); + } + finally + { + ProcessSelectedJobCommand.RaiseCanExecuteChanged(); + CancelSelectedJobCommand.RaiseCanExecuteChanged(); + } + } + + private void OnPrintJobReceived(object? sender, PrintJobReceivedEventArgs e) + { + var isDuplicate = false; + _uiDispatcher.Invoke(() => + { + if (!TryRememberJob(e.Job)) + { + isDuplicate = true; + return; + } + + Jobs.Insert(0, e.Job); + ProcessSelectedJobCommand.RaiseCanExecuteChanged(); + }); + + if (isDuplicate) + { + AddLog(AppLogLevel.Warning, $"已忽略重复打印任务:{GetJobDisplayName(e.Job)}"); + return; + } + + StartQueueProcessingIfNeeded(); + } + + private void SaveSettings() + { + ConfigureBackendMode(); + _settingsService.Save(Settings); + AddLog(AppLogLevel.Success, "设置已保存"); + } + + private void ConfigureBackendMode() + { + if (_backendClient is GrpcService grpcService) + { + grpcService.AllowInsecureGrpc = Settings.AllowInsecureGrpc; + } + } + + private void Disconnect() + { + _isManualDisconnect = true; + _heartbeatCts?.Cancel(); + _subscriptionWatchCts?.Cancel(); + _queueCts?.Cancel(); + _processingCts?.Cancel(); + _backendClient.Disconnect(); + _ = _webSocketService.DisconnectAsync(); + ConnectionState = ConnectionState.Disconnected; + AddLog(AppLogLevel.Warning, "客户端已断开连接"); + Library.RefreshCommandState(); + } + + private void PauseQueue() + { + IsQueuePaused = true; + AddLog(AppLogLevel.Warning, "自动队列已暂停,当前任务完成后停止处理后续任务"); + } + + private void ResumeQueue() + { + IsQueuePaused = false; + AddLog(AppLogLevel.Info, "自动队列已恢复"); + StartQueueProcessingIfNeeded(); + } + + private void StartQueueProcessingIfNeeded() + { + if (!AutoProcessQueue || IsQueuePaused || _isManualDisconnect) + { + RaiseQueueCommandStates(); + return; + } + + if (_queueWorker is { IsCompleted: false }) + { + return; + } + + _queueCts?.Cancel(); + _queueCts?.Dispose(); + _queueCts = new CancellationTokenSource(); + var token = _queueCts.Token; + _queueWorker = Task.Run(() => RunQueueProcessingLoopAsync(token), CancellationToken.None); + } + + private async Task RunQueueProcessingLoopAsync(CancellationToken cancellationToken) + { + if (!await _queueProcessingGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return; + } + + try + { + _uiDispatcher.Invoke(() => IsQueueProcessing = true); + while (!cancellationToken.IsCancellationRequested && AutoProcessQueue && !IsQueuePaused) + { + var job = GetNextPendingJob(preferSelected: false); + if (job is null) + { + break; + } + + await ProcessJobAsync(job, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Normal shutdown or user cancellation path. + } + finally + { + _uiDispatcher.Invoke(() => IsQueueProcessing = false); + _queueProcessingGate.Release(); + RaiseQueueCommandStates(); + _queueWorker = null; + + if (HasPendingJobs() && AutoProcessQueue && !IsQueuePaused && !_isManualDisconnect && !cancellationToken.IsCancellationRequested) + { + StartQueueProcessingIfNeeded(); + } + } + } + + private async Task ProcessJobAsync(PrintJob job, CancellationToken cancellationToken) + { + try + { + _processingJob = job; + _processingCts?.Cancel(); + _processingCts?.Dispose(); + _processingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + _uiDispatcher.Invoke(() => SelectedJob = job); + await _jobCoordinator.ProcessAsync(job, Settings, cancellationToken: _processingCts.Token).ConfigureAwait(false); + AddLog(AppLogLevel.Success, $"任务处理完成:{job.JobNo}"); + } + catch (OperationCanceledException) + { + AddLog(AppLogLevel.Warning, $"任务已取消:{job.JobNo}"); + if (cancellationToken.IsCancellationRequested) + { + throw; + } + } + catch (Exception ex) + { + AddLog(AppLogEntry.FromException($"任务处理失败:{ex.Message}", ex)); + } + finally + { + _processingJob = null; + RaiseQueueCommandStates(); + } + } + + private PrintJob? GetNextPendingJob(bool preferSelected) + { + PrintJob? job = null; + _uiDispatcher.Invoke(() => + { + job = preferSelected && SelectedJob?.Status == PrintJobStatus.Pending + ? SelectedJob + : Jobs.LastOrDefault(item => item.Status == PrintJobStatus.Pending); + }); + + return job; + } + + private bool HasPendingJobs() + { + var hasPending = false; + _uiDispatcher.Invoke(() => hasPending = Jobs.Any(item => item.Status == PrintJobStatus.Pending)); + return hasPending; + } + + private void RaiseQueueCommandStates() + { + _uiDispatcher.Invoke(() => + { + ProcessSelectedJobCommand.RaiseCanExecuteChanged(); + CancelSelectedJobCommand.RaiseCanExecuteChanged(); + PauseQueueCommand.RaiseCanExecuteChanged(); + ResumeQueueCommand.RaiseCanExecuteChanged(); + }); + } + + private void StartHeartbeatLoop() + { + _heartbeatCts?.Cancel(); + _heartbeatCts?.Dispose(); + _heartbeatCts = new CancellationTokenSource(); + var token = _heartbeatCts.Token; + + _ = Task.Run(() => RunHeartbeatLoopAsync(token), CancellationToken.None); + } + + private void StartSubscriptionWatchLoop() + { + _subscriptionWatchCts?.Cancel(); + _subscriptionWatchCts?.Dispose(); + _subscriptionWatchCts = new CancellationTokenSource(); + var token = _subscriptionWatchCts.Token; + + _ = Task.Run(() => RunSubscriptionWatchLoopAsync(token), CancellationToken.None); + } + + private async Task RunSubscriptionWatchLoopAsync(CancellationToken cancellationToken) + { + try + { + using var timer = new PeriodicTimer(SubscriptionWatchInterval); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + if (_isManualDisconnect || SelectedPrinter is null) + { + continue; + } + + if (!_backendClient.IsConnected) + { + AddLog(AppLogLevel.Warning, "检测到任务订阅流断开,准备自动恢复..."); + _uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error); + await TryReconnectAsync(cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Normal shutdown path. + } + } + + private async Task RunHeartbeatLoopAsync(CancellationToken cancellationToken) + { + try + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Max(5, Settings.HeartbeatIntervalSeconds))); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + await SendHeartbeatOnceAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Normal shutdown path. + } + } + + private async Task SendHeartbeatOnceAsync(CancellationToken cancellationToken) + { + if (SelectedPrinter is null || !_backendClient.IsConnected) + { + if (!_isManualDisconnect && SelectedPrinter is not null) + { + await TryReconnectAsync(cancellationToken).ConfigureAwait(false); + } + + return; + } + + try + { + await _backendClient.SendHeartbeatAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + AddLog(AppLogEntry.FromException($"心跳失败:{ex.Message}", ex)); + _uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error); + await TryReconnectAsync(cancellationToken).ConfigureAwait(false); + } + } + + private async Task TryReconnectAsync(CancellationToken cancellationToken) + { + if (SelectedPrinter is null || _isManualDisconnect || _isReconnecting) + { + return; + } + + _isReconnecting = true; + try + { + for (var attempt = 1; attempt <= MaxReconnectAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var delay = GetReconnectDelay(attempt); + AddLog(AppLogLevel.Warning, $"正在尝试自动重连(第 {attempt}/{MaxReconnectAttempts} 次,等待 {delay.TotalSeconds:0} 秒)..."); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + + try + { + ConfigureBackendMode(); + await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey, cancellationToken).ConfigureAwait(false); + await _backendClient.RegisterPrinterAsync(SelectedPrinter, Settings.ApiKey, cancellationToken).ConfigureAwait(false); + await _backendClient.SendHeartbeatAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false); + await _backendClient.SubscribePrintJobsAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false); + await _webSocketService.ConnectAsync(new Uri(ToWebSocketAddress(Settings.ServerAddress)), cancellationToken).ConfigureAwait(false); + _uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Subscribed); + AddLog(AppLogLevel.Success, "自动重连成功,任务订阅已恢复"); + return; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (IsFatalReconnectError(ex)) + { + _uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error); + AddLog(AppLogEntry.FromException($"自动重连停止:{ex.Message}", ex)); + return; + } + catch (Exception ex) + { + AddLog(AppLogEntry.FromException($"自动重连失败:{ex.Message}", ex)); + } + } + + _uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error); + AddLog(AppLogLevel.Error, "自动重连已达到最大次数,请检查网络、后端服务或 API Key 后手动重试"); + } + finally + { + _isReconnecting = false; + } + } + + private void ClearLogs() + { + Logs.Clear(); + LastMessage = "日志已清空"; + } + + private void AddLog(AppLogLevel level, string message) + { + AddLog(new AppLogEntry { Level = level, Message = message }); + } + + private void AddLog(AppLogEntry entry) + { + _appLogger.Write(entry); + _uiDispatcher.Invoke(() => + { + LastMessage = entry.Message; + Logs.Insert(0, entry); + + while (Logs.Count > 200) + { + Logs.RemoveAt(Logs.Count - 1); + } + }); + } + + private static string ToWebSocketAddress(string address) + { + if (address.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return "wss://" + address[8..].TrimEnd('/') + "/ws/print"; + } + + if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + return "ws://" + address[7..].TrimEnd('/') + "/ws/print"; + } + + return "ws://" + address.TrimEnd('/') + "/ws/print"; + } + + private bool TryRememberJob(PrintJob job) + { + var key = GetJobKey(job); + if (!_knownJobKeys.Add(key)) + { + return false; + } + + return !Jobs.Any(existing => GetJobKey(existing).Equals(key, StringComparison.OrdinalIgnoreCase)); + } + + private static string GetJobKey(PrintJob job) + { + if (!string.IsNullOrWhiteSpace(job.JobNo)) + { + return $"job-no:{job.JobNo}"; + } + + if (job.Id > 0) + { + return $"id:{job.Id}"; + } + + return $"file:{job.FileId}:{job.FileName}:{job.OrderItemId}"; + } + + private static string GetJobDisplayName(PrintJob job) + { + return string.IsNullOrWhiteSpace(job.JobNo) ? job.FileName : job.JobNo; + } + + private static TimeSpan GetReconnectDelay(int attempt) + { + var seconds = InitialReconnectDelay.TotalSeconds * Math.Pow(2, Math.Max(0, attempt - 1)); + return TimeSpan.FromSeconds(Math.Min(seconds, MaxReconnectDelay.TotalSeconds)); + } + + private static bool IsFatalReconnectError(Exception exception) + { + if (exception is ArgumentException or InvalidOperationException) + { + return true; + } + + if (exception is RpcException rpcException) + { + return rpcException.StatusCode is StatusCode.Unauthenticated or StatusCode.PermissionDenied or StatusCode.InvalidArgument; + } + + return false; + } +} \ No newline at end of file diff --git a/ViewModels/PrintQueueViewModel.cs b/ViewModels/PrintQueueViewModel.cs new file mode 100644 index 0000000..3ce7167 --- /dev/null +++ b/ViewModels/PrintQueueViewModel.cs @@ -0,0 +1,9 @@ +using System.Collections.ObjectModel; +using CloudPrint.Client.Models; + +namespace CloudPrint.Client.ViewModels; + +public sealed class PrintQueueViewModel +{ + public ObservableCollection Jobs { get; } = new(); +} \ No newline at end of file diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..01a397b --- /dev/null +++ b/ViewModels/SettingsViewModel.cs @@ -0,0 +1,155 @@ +using CloudPrint.Client.Models; +using CloudPrint.Client.Infrastructure; +using CloudPrint.Client.Services; +using CloudPrint.Client.Services.Abstractions; +using System.IO; + +namespace CloudPrint.Client.ViewModels; + +public sealed class SettingsViewModel : ObservableObject +{ + private readonly ISettingsService _settingsService; + private readonly IPrintBackendClient _backendClient; + private readonly IAppLogger _appLogger; + private string _statusMessage = "设置就绪"; + private string _adminPassword = string.Empty; + + public SettingsViewModel() + : this(new JsonSettingsService().Load(), new JsonSettingsService(), new GrpcService(), new FileAppLogger()) + { + } + + public SettingsViewModel(ClientSettings settings, ISettingsService settingsService, IPrintBackendClient backendClient, IAppLogger appLogger) + { + Settings = settings; + _settingsService = settingsService; + _backendClient = backendClient; + _appLogger = appLogger; + SaveCommand = new RelayCommand(Save); + SelectLibraryRootCommand = new RelayCommand(SelectLibraryRoot); + LoginAdminCommand = new AsyncRelayCommand(LoginAdminAsync); + LogoutAdminCommand = new RelayCommand(LogoutAdmin, () => IsAdminLoggedIn); + OpenLogDirectoryCommand = new RelayCommand(OpenLogDirectory); + + if (!string.IsNullOrWhiteSpace(Settings.AdminAccessToken)) + { + _backendClient.SetAdminToken(Settings.AdminUsername, Settings.AdminAccessToken); + IsAdminLoggedIn = true; + } + } + + public ClientSettings Settings { get; } + + public string StatusMessage + { + get => _statusMessage; + private set => SetProperty(ref _statusMessage, value); + } + + public RelayCommand SaveCommand { get; } + public RelayCommand SelectLibraryRootCommand { get; } + public AsyncRelayCommand LoginAdminCommand { get; } + public RelayCommand LogoutAdminCommand { get; } + public RelayCommand OpenLogDirectoryCommand { get; } + public string LogDirectory => _appLogger.LogDirectory; + + private bool _isAdminLoggedIn; + public bool IsAdminLoggedIn + { + get => _isAdminLoggedIn; + private set + { + if (SetProperty(ref _isAdminLoggedIn, value)) + { + OnPropertyChanged(nameof(AdminLoginStatus)); + LogoutAdminCommand.RaiseCanExecuteChanged(); + } + } + } + + public string AdminLoginStatus => IsAdminLoggedIn ? $"已登录:{Settings.AdminUsername}" : "未登录"; + + public string AdminPassword + { + get => _adminPassword; + set => SetProperty(ref _adminPassword, value); + } + + private void Save() + { + _settingsService.Save(Settings); + StatusMessage = "设置已保存"; + } + + private async Task LoginAdminAsync() + { + try + { + ConfigureBackendMode(); + if (!_backendClient.IsConnected) + { + await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey).ConfigureAwait(true); + } + + var session = await _backendClient.LoginAdminAsync(Settings.AdminUsername, AdminPassword).ConfigureAwait(true); + Settings.AdminUsername = session.Username; + Settings.AdminAccessToken = session.AccessToken; + AdminPassword = string.Empty; + IsAdminLoggedIn = true; + _settingsService.Save(Settings); + StatusMessage = $"管理员登录成功:{session.DisplayName}"; + } + catch (Exception ex) + { + IsAdminLoggedIn = false; + StatusMessage = $"管理员登录失败:{ex.Message}"; + } + } + + private void LogoutAdmin() + { + _backendClient.LogoutAdmin(); + Settings.AdminAccessToken = string.Empty; + AdminPassword = string.Empty; + IsAdminLoggedIn = false; + _settingsService.Save(Settings); + StatusMessage = "管理员已退出登录"; + } + + private void ConfigureBackendMode() + { + if (_backendClient is GrpcService grpcService) + { + grpcService.AllowInsecureGrpc = Settings.AllowInsecureGrpc; + } + } + + private void SelectLibraryRoot() + { + using var dialog = new System.Windows.Forms.FolderBrowserDialog + { + Description = "选择本地文库目录", + UseDescriptionForTitle = true, + SelectedPath = string.IsNullOrWhiteSpace(Settings.LibraryRoot) + ? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) + : Settings.LibraryRoot + }; + + if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) + { + Settings.LibraryRoot = dialog.SelectedPath; + StatusMessage = "已选择文库目录"; + } + } + + private void OpenLogDirectory() + { + Directory.CreateDirectory(_appLogger.LogDirectory); + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = _appLogger.LogDirectory, + UseShellExecute = true + }; + System.Diagnostics.Process.Start(startInfo); + } +} \ No newline at end of file diff --git a/Views/LibraryView.xaml b/Views/LibraryView.xaml new file mode 100644 index 0000000..47da431 --- /dev/null +++ b/Views/LibraryView.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +