508 lines
13 KiB
Go
508 lines
13 KiB
Go
//go:build windows
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
)
|
|
|
|
var (
|
|
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
|
procMultiByteToWideChar = kernel32.NewProc("MultiByteToWideChar")
|
|
)
|
|
|
|
// ansiToUtf8 converts ANSI (CP_ACP) bytes to UTF-8 string
|
|
func ansiToUtf8(b []byte) (string, error) {
|
|
if len(b) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
// CP_ACP = 0
|
|
// 1. Get required length
|
|
ret, _, _ := procMultiByteToWideChar.Call(
|
|
0, // CP_ACP
|
|
0,
|
|
uintptr(unsafe.Pointer(&b[0])),
|
|
uintptr(len(b)),
|
|
0,
|
|
0,
|
|
)
|
|
if ret == 0 {
|
|
return "", fmt.Errorf("MultiByteToWideChar failed")
|
|
}
|
|
|
|
// 2. Allocate buffer
|
|
utf16buf := make([]uint16, ret)
|
|
|
|
// 3. Convert
|
|
ret, _, _ = procMultiByteToWideChar.Call(
|
|
0,
|
|
0,
|
|
uintptr(unsafe.Pointer(&b[0])),
|
|
uintptr(len(b)),
|
|
uintptr(unsafe.Pointer(&utf16buf[0])),
|
|
ret,
|
|
)
|
|
if ret == 0 {
|
|
return "", fmt.Errorf("MultiByteToWideChar failed")
|
|
}
|
|
|
|
return syscall.UTF16ToString(utf16buf), nil
|
|
}
|
|
|
|
// decodeCmdOutput handles WMIC encoding quirks (UTF-16LE BOM or ANSI)
|
|
func decodeCmdOutput(output []byte) (string, error) {
|
|
if len(output) >= 2 && output[0] == 0xFF && output[1] == 0xFE {
|
|
// UTF-16LE BOM detected
|
|
// Skip BOM
|
|
raw16 := output[2:]
|
|
// Make sure even number of bytes
|
|
if len(raw16)%2 != 0 {
|
|
raw16 = append(raw16, 0)
|
|
}
|
|
u16s := make([]uint16, len(raw16)/2)
|
|
for i := 0; i < len(u16s); i++ {
|
|
u16s[i] = uint16(raw16[i*2]) | uint16(raw16[i*2+1])<<8
|
|
}
|
|
return syscall.UTF16ToString(u16s), nil
|
|
}
|
|
|
|
// Assume ANSI (e.g. GBK on Chinese Windows)
|
|
return ansiToUtf8(output)
|
|
}
|
|
|
|
func (b *Bridge) getPrintersPlatform() ([]PrinterInfo, error) {
|
|
// Use WMIC to get printer names and default flags in CSV format
|
|
cmd := exec.Command("wmic", "printer", "get", "name,default", "/format:csv")
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return getPrintersViaPowerShell()
|
|
}
|
|
|
|
decodedStr, err := decodeCmdOutput(output)
|
|
if err != nil {
|
|
// Fallback
|
|
decodedStr = string(output)
|
|
}
|
|
|
|
reader := csv.NewReader(strings.NewReader(decodedStr))
|
|
reader.FieldsPerRecord = -1
|
|
reader.LazyQuotes = true
|
|
records, err := reader.ReadAll()
|
|
if err != nil {
|
|
return getPrintersViaPowerShell()
|
|
}
|
|
|
|
var printers []PrinterInfo
|
|
for _, record := range records {
|
|
if len(record) < 3 {
|
|
continue
|
|
}
|
|
if strings.EqualFold(record[1], "Default") && strings.EqualFold(record[2], "Name") {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(record[len(record)-1])
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if strings.EqualFold(name, "Name") {
|
|
continue
|
|
}
|
|
isDefault := strings.EqualFold(strings.TrimSpace(record[1]), "TRUE")
|
|
printers = append(printers, PrinterInfo{Name: name, IsDefault: isDefault})
|
|
}
|
|
if len(printers) == 0 {
|
|
return getPrintersViaPowerShell()
|
|
}
|
|
return printers, nil
|
|
}
|
|
|
|
func getPrintersViaPowerShell() ([]PrinterInfo, error) {
|
|
ps := `Get-Printer | Select-Object Name,Default | ConvertTo-Json -Depth 3`
|
|
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
decoded, err := decodeCmdOutput(output)
|
|
if err != nil {
|
|
decoded = string(output)
|
|
}
|
|
|
|
var list []struct {
|
|
Name string `json:"Name"`
|
|
Default bool `json:"Default"`
|
|
}
|
|
if err := json.Unmarshal([]byte(decoded), &list); err == nil {
|
|
printers := make([]PrinterInfo, 0, len(list))
|
|
for _, item := range list {
|
|
name := strings.TrimSpace(item.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
printers = append(printers, PrinterInfo{Name: name, IsDefault: item.Default})
|
|
}
|
|
return printers, nil
|
|
}
|
|
|
|
var single struct {
|
|
Name string `json:"Name"`
|
|
Default bool `json:"Default"`
|
|
}
|
|
if err := json.Unmarshal([]byte(decoded), &single); err != nil {
|
|
return nil, err
|
|
}
|
|
name := strings.TrimSpace(single.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("no printers found")
|
|
}
|
|
return []PrinterInfo{{Name: name, IsDefault: single.Default}}, nil
|
|
}
|
|
|
|
func (b *Bridge) getPrinterCapabilitiesPlatform(printerName string) (map[string]interface{}, error) {
|
|
printerName = strings.TrimSpace(printerName)
|
|
if printerName == "" {
|
|
return nil, fmt.Errorf("printer name is empty")
|
|
}
|
|
|
|
escaped := strings.ReplaceAll(printerName, "'", "''")
|
|
ps := fmt.Sprintf(`$p = Get-CimInstance Win32_Printer -Filter "Name='%s'"; `+
|
|
`$c = Get-CimInstance Win32_PrinterConfiguration -Filter "Name='%s'"; `+
|
|
`if ($null -eq $p) { throw "Printer not found" }; `+
|
|
`[pscustomobject]@{ `+
|
|
`name = $p.Name; `+
|
|
`defaultPaperSize = $p.DefaultPaperSize; `+
|
|
`printerPaperNames = $p.PrinterPaperNames; `+
|
|
`paperSizes = $p.PaperSizes; `+
|
|
`colorSupported = $p.ColorSupported; `+
|
|
`duplexSupported = $p.DuplexSupported; `+
|
|
`driverName = $p.DriverName; `+
|
|
`portName = $p.PortName; `+
|
|
`printProcessor = $p.PrintProcessor; `+
|
|
`deviceId = $p.DeviceID; `+
|
|
`config = @{ `+
|
|
`paperSize = $c.PaperSize; `+
|
|
`orientation = $c.Orientation; `+
|
|
`color = $c.Color; `+
|
|
`duplex = $c.Duplex; `+
|
|
`copies = $c.Copies `+
|
|
`} `+
|
|
`} | ConvertTo-Json -Depth 4`, escaped, escaped)
|
|
|
|
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get printer capabilities failed: %v", err)
|
|
}
|
|
|
|
decoded, err := decodeCmdOutput(output)
|
|
if err != nil {
|
|
decoded = string(output)
|
|
}
|
|
|
|
var caps map[string]interface{}
|
|
if err := json.Unmarshal([]byte(decoded), &caps); err != nil {
|
|
return nil, fmt.Errorf("parse printer capabilities failed: %v", err)
|
|
}
|
|
|
|
return caps, nil
|
|
}
|
|
|
|
func (b *Bridge) printPDFPlatform(printerName, filePath string, options PrintOptions) error {
|
|
sumatraPath, err := findSumatraPDF()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
settings := buildSumatraPrintSettings(options)
|
|
|
|
args := []string{"-print-to", printerName}
|
|
if settings != "" {
|
|
args = append(args, "-print-settings", settings)
|
|
}
|
|
args = append(args, filePath)
|
|
|
|
cmd := exec.Command(sumatraPath, args...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
|
|
queueSnapshot, _ := getPrintJobIDs(printerName)
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("sumatra print failed to start: %v", err)
|
|
}
|
|
|
|
cmdDone := make(chan error, 1)
|
|
go func() {
|
|
cmdDone <- cmd.Wait()
|
|
}()
|
|
|
|
if err := waitForWindowsPrintCompletion(printerName, queueSnapshot, cmdDone); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type windowsPrintJob struct {
|
|
Id int `json:"Id"`
|
|
JobStatus interface{} `json:"JobStatus"`
|
|
DocumentName string `json:"DocumentName"`
|
|
}
|
|
|
|
const (
|
|
printQueuePollInterval = 500 * time.Millisecond
|
|
printQueueAppearTimeout = 120 * time.Second
|
|
printQueueCompleteTimeout = 5 * time.Minute
|
|
)
|
|
|
|
func getPrintJobs(printerName string) ([]windowsPrintJob, error) {
|
|
printerName = strings.TrimSpace(printerName)
|
|
if printerName == "" {
|
|
return nil, fmt.Errorf("printer name is empty")
|
|
}
|
|
|
|
escaped := strings.ReplaceAll(printerName, "'", "''")
|
|
ps := fmt.Sprintf(`Get-PrintJob -PrinterName '%s' | Select-Object Id,JobStatus,DocumentName | ConvertTo-Json -Depth 3`, escaped)
|
|
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
decoded, err := decodeCmdOutput(output)
|
|
if err != nil {
|
|
decoded = string(output)
|
|
}
|
|
decoded = strings.TrimSpace(decoded)
|
|
if decoded == "" || decoded == "null" {
|
|
return nil, nil
|
|
}
|
|
|
|
var list []windowsPrintJob
|
|
if err := json.Unmarshal([]byte(decoded), &list); err == nil {
|
|
return list, nil
|
|
}
|
|
|
|
var single windowsPrintJob
|
|
if err := json.Unmarshal([]byte(decoded), &single); err != nil {
|
|
return nil, err
|
|
}
|
|
return []windowsPrintJob{single}, nil
|
|
}
|
|
|
|
func getPrintJobIDs(printerName string) (map[int]bool, error) {
|
|
jobs, err := getPrintJobs(printerName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make(map[int]bool, len(jobs))
|
|
for _, job := range jobs {
|
|
if job.Id > 0 {
|
|
ids[job.Id] = true
|
|
}
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func waitForWindowsPrintCompletion(printerName string, existingIDs map[int]bool, cmdDone <-chan error) error {
|
|
appearDeadline := time.Now().Add(printQueueAppearTimeout)
|
|
completeDeadline := time.Now().Add(printQueueCompleteTimeout)
|
|
|
|
queued := false
|
|
jobID := 0
|
|
sumatraDone := false
|
|
|
|
for {
|
|
select {
|
|
case err := <-cmdDone:
|
|
sumatraDone = true
|
|
if err != nil && !queued {
|
|
return fmt.Errorf("sumatra print failed: %v", err)
|
|
}
|
|
// Sumatra 已正常退出且 spooler 未出现新任务:部分驱动/打印机直接出纸,不经过队列
|
|
if err == nil && !queued {
|
|
return nil
|
|
}
|
|
default:
|
|
}
|
|
|
|
now := time.Now()
|
|
if !queued && sumatraDone {
|
|
return nil
|
|
}
|
|
if !queued && now.After(appearDeadline) {
|
|
return fmt.Errorf("print job not queued within %s", printQueueAppearTimeout)
|
|
}
|
|
if queued && now.After(completeDeadline) {
|
|
return fmt.Errorf("print job not completed within %s", printQueueCompleteTimeout)
|
|
}
|
|
|
|
jobs, err := getPrintJobs(printerName)
|
|
if err == nil {
|
|
if !queued {
|
|
for _, job := range jobs {
|
|
if !existingIDs[job.Id] {
|
|
jobID = job.Id
|
|
queued = true
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
found := false
|
|
var statusList []string
|
|
for _, job := range jobs {
|
|
if job.Id == jobID {
|
|
found = true
|
|
statusList = normalizeJobStatus(job.JobStatus)
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
if isWindowsJobFailed(statusList) {
|
|
return fmt.Errorf("print job failed: %s", strings.Join(statusList, ", "))
|
|
}
|
|
}
|
|
}
|
|
|
|
time.Sleep(printQueuePollInterval)
|
|
}
|
|
}
|
|
|
|
func normalizeJobStatus(status interface{}) []string {
|
|
switch v := status.(type) {
|
|
case string:
|
|
if strings.TrimSpace(v) == "" {
|
|
return nil
|
|
}
|
|
return []string{strings.TrimSpace(v)}
|
|
case []interface{}:
|
|
out := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
if strings.TrimSpace(s) != "" {
|
|
out = append(out, strings.TrimSpace(s))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func isWindowsJobFailed(statuses []string) bool {
|
|
for _, status := range statuses {
|
|
value := strings.ToLower(status)
|
|
if strings.Contains(value, "error") || strings.Contains(value, "offline") || strings.Contains(value, "paused") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func findSumatraPDF() (string, error) {
|
|
if envPath := strings.TrimSpace(os.Getenv("SUMATRAPDF_PATH")); envPath != "" {
|
|
if fileExists(envPath) {
|
|
return envPath, nil
|
|
}
|
|
}
|
|
|
|
if exe, err := os.Executable(); err == nil {
|
|
candidate := filepath.Join(filepath.Dir(exe), "SumatraPDF.exe")
|
|
if fileExists(candidate) {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
|
|
if path, err := exec.LookPath("SumatraPDF.exe"); err == nil {
|
|
return path, nil
|
|
}
|
|
if path, err := exec.LookPath("SumatraPDF"); err == nil {
|
|
return path, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("SumatraPDF.exe not found. Place it next to the app, add it to PATH, or set SUMATRAPDF_PATH")
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
func buildSumatraPrintSettings(options PrintOptions) string {
|
|
var settings []string
|
|
|
|
if options.PageRange != "" {
|
|
settings = append(settings, options.PageRange)
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(options.PageSet)) {
|
|
case "even":
|
|
settings = append(settings, "even")
|
|
case "odd":
|
|
settings = append(settings, "odd")
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(options.Scale)) {
|
|
case "fit":
|
|
settings = append(settings, "fit")
|
|
case "shrink":
|
|
settings = append(settings, "shrink")
|
|
case "none", "actual", "noscale":
|
|
settings = append(settings, "noscale")
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(options.Orientation)) {
|
|
case "portrait":
|
|
settings = append(settings, "portrait")
|
|
case "landscape":
|
|
settings = append(settings, "landscape")
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(options.Duplex)) {
|
|
case "simplex", "one-sided":
|
|
settings = append(settings, "simplex")
|
|
case "long-edge", "long", "duplex", "duplexlong":
|
|
settings = append(settings, "duplex")
|
|
case "short-edge", "short", "duplexshort":
|
|
settings = append(settings, "duplexshort")
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(options.ColorMode)) {
|
|
case "color":
|
|
settings = append(settings, "color")
|
|
case "mono", "monochrome", "grayscale", "gray":
|
|
settings = append(settings, "monochrome")
|
|
}
|
|
|
|
if options.Paper != "" {
|
|
settings = append(settings, fmt.Sprintf("paper=%s", options.Paper))
|
|
}
|
|
|
|
if options.TrayBin != "" {
|
|
settings = append(settings, fmt.Sprintf("bin=%s", options.TrayBin))
|
|
}
|
|
|
|
if options.Copies > 1 {
|
|
settings = append(settings, fmt.Sprintf("%dx", options.Copies))
|
|
}
|
|
|
|
return strings.Join(settings, ",")
|
|
}
|