package main import ( "bufio" "context" "encoding/base64" "encoding/json" "fmt" "html/template" "io/ioutil" "log" "math" "net" "net/http" "os" "path/filepath" "regexp" "runtime" "strconv" "strings" "sync" "time" "github.com/gorilla/websocket" ) type Bridge struct { server *http.Server port string key string mu sync.Mutex log func(string) // Callback to log to frontend // Log related logFileMu sync.Mutex logServer *http.Server logPort int // Connection tracking clientCount int countMu sync.Mutex onCountChange func(int) // Callback to update frontend conns map[*websocket.Conn]bool // Restart callback onRestart func() onReload func() onClientConnect func(string) // Remote forwarding remoteMu sync.Mutex remoteStop chan struct{} remoteWg sync.WaitGroup remoteCfg RemoteConfig remoteConn *websocket.Conn remoteStatus RemoteForwarderStatus forwarderStatusProvider func() RemoteForwarderStatus forwarderConnect func() forwarderDisconnect func() } func NewBridge() *Bridge { b := &Bridge{ port: "1122", key: "", conns: make(map[*websocket.Conn]bool), } b.ensureLogDir() return b } func (b *Bridge) SetLogger(logger func(string)) { b.log = logger } func (b *Bridge) SetCountCallback(cb func(int)) { b.onCountChange = cb } func (b *Bridge) SetRestartCallback(cb func()) { b.onRestart = cb } func (b *Bridge) SetReloadCallback(cb func()) { b.onReload = cb } func (b *Bridge) SetClientConnectCallback(cb func(string)) { b.onClientConnect = cb } func (b *Bridge) SetForwarderStatusProvider(cb func() RemoteForwarderStatus) { b.forwarderStatusProvider = cb } func (b *Bridge) SetForwarderConnectHandler(cb func()) { b.forwarderConnect = cb } func (b *Bridge) SetForwarderDisconnectHandler(cb func()) { b.forwarderDisconnect = cb } func (b *Bridge) updateClientCount(delta int) { b.countMu.Lock() b.clientCount += delta count := b.clientCount b.countMu.Unlock() if b.onCountChange != nil { b.onCountChange(count) } } func (b *Bridge) Log(msg string) { timestamp := time.Now().Format("15:04:05") entry := fmt.Sprintf("[%s] %s", timestamp, msg) if err := b.appendLogLine(entry); err != nil { log.Println(entry) } else if b.log != nil { b.log(msg) } } func (b *Bridge) GetPrinters() ([]PrinterInfo, error) { return b.getPrintersPlatform() } type PrinterInfo struct { Name string `json:"name"` IsDefault bool `json:"isDefault"` } func (b *Bridge) GetPrinterCapabilities(printerName string) (map[string]interface{}, error) { return b.getPrinterCapabilitiesPlatform(printerName) } func (b *Bridge) StartServer(port string, key string) error { b.mu.Lock() defer b.mu.Unlock() if b.server != nil { return fmt.Errorf("server already running") } b.port = port b.key = key mux := http.NewServeMux() mux.HandleFunc("/ws", b.handleWebSocket) b.server = &http.Server{ Addr: ":" + port, Handler: mux, } go func() { b.Log(fmt.Sprintf("Starting server on port %s...", port)) if err := b.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { b.Log(fmt.Sprintf("Server error: %v", err)) } b.Log("Server stopped") }() return nil } func (b *Bridge) StopServer() error { b.mu.Lock() defer b.mu.Unlock() if b.server == nil { return nil } // Close all active connections first b.countMu.Lock() for conn := range b.conns { conn.Close() } // Clear the map b.conns = make(map[*websocket.Conn]bool) b.countMu.Unlock() if err := b.server.Shutdown(context.Background()); err != nil { return err } b.server = nil return nil } var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true // Allow all origins }, } type PrintRequest struct { Printer string `json:"printer"` Content string `json:"content"` // Base64 encoded PDF Key string `json:"key"` Job struct { Name string `json:"name"` Copies int `json:"copies"` IntervalMs int `json:"intervalMs"` } `json:"job"` Pages struct { Range string `json:"range"` Set string `json:"set"` } `json:"pages"` Layout struct { Scale string `json:"scale"` Orientation string `json:"orientation"` } `json:"layout"` Color struct { Mode string `json:"mode"` } `json:"color"` Sides struct { Mode string `json:"mode"` } `json:"sides"` Paper PaperSpec `json:"paper"` Tray struct { Bin string `json:"bin"` } `json:"tray"` } type PaperSpec struct { Size string `json:"size"` } type PrintOptions struct { PageRange string PageSet string Duplex string ColorMode string Paper string Scale string Orientation string TrayBin string Copies int } type Response struct { Status string `json:"status"` Message string `json:"message"` } func (b *Bridge) handleWebSocket(w http.ResponseWriter, r *http.Request) { // Authentication check if b.key != "" { pass := r.URL.Query().Get("key") if pass == "" { pass = r.URL.Query().Get("password") } if pass != b.key { b.Log(fmt.Sprintf("Authentication failed for %s", r.RemoteAddr)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } c, err := upgrader.Upgrade(w, r, nil) if err != nil { b.Log(fmt.Sprintf("Upgrade error: %v", err)) return } defer c.Close() b.countMu.Lock() b.conns[c] = true b.countMu.Unlock() b.updateClientCount(1) defer func() { b.countMu.Lock() delete(b.conns, c) b.countMu.Unlock() b.updateClientCount(-1) }() b.Log(fmt.Sprintf("Client connected from %s", c.RemoteAddr())) if b.onClientConnect != nil { b.onClientConnect(c.RemoteAddr().String()) } // Send printer list immediately upon connection printers, err := b.GetPrinters() if err == nil { msg := map[string]interface{}{ "type": "printer_list", "data": printers, } if jsonBytes, err := json.Marshal(msg); err == nil { b.Log(fmt.Sprintf("Sent WS message: %s", string(jsonBytes))) } c.WriteJSON(msg) } else { b.Log(fmt.Sprintf("Failed to get printers on connect: %v", err)) errMsg := fmt.Sprintf("Failed to get printer list: %v", err) c.WriteJSON(Response{Status: "error", Message: errMsg}) } for { // Read message as raw JSON map first to check type var rawMsg map[string]interface{} err := c.ReadJSON(&rawMsg) if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { b.Log(fmt.Sprintf("Client disconnected: %v", err)) } else { // Normal closure or other error } break } if jsonBytes, err := json.Marshal(rawMsg); err == nil { b.Log(fmt.Sprintf("Received WS message: %s", string(jsonBytes))) } // Check message type if msgType, ok := rawMsg["type"].(string); ok && msgType == "get_printers" { printers, err := b.GetPrinters() if err == nil { msg := map[string]interface{}{ "type": "printer_list", "data": printers, } if jsonBytes, err := json.Marshal(msg); err == nil { b.Log(fmt.Sprintf("Sent WS message: %s", string(jsonBytes))) } c.WriteJSON(msg) } else { resp := Response{Status: "error", Message: fmt.Sprintf("Failed to get printer list: %v", err)} if jsonBytes, err := json.Marshal(resp); err == nil { b.Log(fmt.Sprintf("Sent WS message: %s", string(jsonBytes))) } c.WriteJSON(resp) } continue } if msgType, ok := rawMsg["type"].(string); ok && msgType == "get_printer_caps" { printer, _ := rawMsg["printer"].(string) printer = strings.TrimSpace(printer) if printer == "" { resp := Response{Status: "error", Message: "printer is required"} c.WriteJSON(resp) continue } caps, err := b.GetPrinterCapabilities(printer) if err != nil { b.Log(fmt.Sprintf("Failed to get printer capabilities for '%s': %v", printer, err)) resp := Response{Status: "error", Message: err.Error()} c.WriteJSON(resp) continue } msg := map[string]interface{}{ "type": "printer_caps", "printer": printer, "data": caps, } c.WriteJSON(msg) continue } // Handle as PrintRequest (default) jsonBody, _ := json.Marshal(rawMsg) var req PrintRequest if err := json.Unmarshal(jsonBody, &req); err != nil { b.Log(fmt.Sprintf("Invalid print request: %v", err)) resp := Response{Status: "error", Message: "Invalid request format"} c.WriteJSON(resp) continue } msg, err := b.processPrintRequest(req) if err == nil { resp := Response{Status: "success", Message: msg} c.WriteJSON(resp) } else { c.WriteJSON(Response{Status: "error", Message: msg}) } } } func (b *Bridge) processPrintRequest(req PrintRequest) (string, error) { jobName := strings.TrimSpace(req.Job.Name) copies := req.Job.Copies if copies <= 0 { copies = 1 } intervalMs := req.Job.IntervalMs contentToDecode := req.Content if strings.HasPrefix(contentToDecode, "data:") { if idx := strings.Index(contentToDecode, ","); idx != -1 { contentToDecode = contentToDecode[idx+1:] } } decoded, err := base64.StdEncoding.DecodeString(contentToDecode) if err != nil { b.Log("Error decoding Base64 content") return "Invalid Base64 content", fmt.Errorf("invalid base64 content") } if len(decoded) < 4 || string(decoded[0:4]) != "%PDF" { b.Log("Content is not a valid PDF (missing %PDF header)") return "Content must be a PDF file", fmt.Errorf("invalid pdf") } autoPaper := "" if strings.TrimSpace(req.Paper.Size) == "" { if name, ok := detectPaperFromPDF(decoded); ok { autoPaper = name b.Log(fmt.Sprintf("Auto paper size detected: %s", name)) } } options := PrintOptions{ PageRange: strings.TrimSpace(req.Pages.Range), PageSet: strings.TrimSpace(req.Pages.Set), Duplex: strings.TrimSpace(req.Sides.Mode), ColorMode: strings.TrimSpace(req.Color.Mode), Paper: strings.TrimSpace(firstNonEmpty(req.Paper.Size, autoPaper)), Scale: strings.TrimSpace(req.Layout.Scale), Orientation: strings.TrimSpace(req.Layout.Orientation), TrayBin: strings.TrimSpace(req.Tray.Bin), } runCount := 1 perRunCopies := copies if intervalMs > 0 { runCount = copies perRunCopies = 1 } options.Copies = perRunCopies successCount := 0 var lastErr error for i := 0; i < runCount; i++ { if i > 0 && intervalMs > 0 { time.Sleep(time.Duration(intervalMs) * time.Millisecond) } err = b.printPDF(req.Printer, jobName, decoded, options) if err != nil { lastErr = err b.Log(fmt.Sprintf("Print error (copy %d): %v", i+1, err)) break } else { successCount += perRunCopies } } if successCount == copies { b.Log("Print success") return "Printed successfully", nil } msg := fmt.Sprintf("Printed %d/%d copies. Error: %v", successCount, copies, lastErr) b.Log(msg) return msg, fmt.Errorf("print failed") } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } var ( mediaBoxRegex = regexp.MustCompile(`/MediaBox\s*\[\s*([-0-9.]+)\s+([-0-9.]+)\s+([-0-9.]+)\s+([-0-9.]+)\s*\]`) cropBoxRegex = regexp.MustCompile(`/CropBox\s*\[\s*([-0-9.]+)\s+([-0-9.]+)\s+([-0-9.]+)\s+([-0-9.]+)\s*\]`) ) func detectPaperFromPDF(pdfData []byte) (string, bool) { limit := len(pdfData) if limit > 5*1024*1024 { limit = 5 * 1024 * 1024 } chunk := string(pdfData[:limit]) match := cropBoxRegex.FindStringSubmatch(chunk) if len(match) != 5 { match = mediaBoxRegex.FindStringSubmatch(chunk) } if len(match) != 5 { return "", false } llx, err1 := strconv.ParseFloat(match[1], 64) lly, err2 := strconv.ParseFloat(match[2], 64) urx, err3 := strconv.ParseFloat(match[3], 64) ury, err4 := strconv.ParseFloat(match[4], 64) if err1 != nil || err2 != nil || err3 != nil || err4 != nil { return "", false } widthPt := math.Abs(urx - llx) heightPt := math.Abs(ury - lly) return matchStandardPaper(widthPt, heightPt) } type paperSize struct { Name string Wmm float64 Hmm float64 } func matchStandardPaper(widthPt, heightPt float64) (string, bool) { mmPerPt := 25.4 / 72.0 widthMm := widthPt * mmPerPt heightMm := heightPt * mmPerPt if widthMm <= 0 || heightMm <= 0 { return "", false } if widthMm > heightMm { widthMm, heightMm = heightMm, widthMm } standard := []paperSize{ {Name: "A2", Wmm: 420, Hmm: 594}, {Name: "A3", Wmm: 297, Hmm: 420}, {Name: "A4", Wmm: 210, Hmm: 297}, {Name: "A5", Wmm: 148, Hmm: 210}, {Name: "A6", Wmm: 105, Hmm: 148}, {Name: "letter", Wmm: 216, Hmm: 279}, {Name: "legal", Wmm: 216, Hmm: 356}, {Name: "tabloid", Wmm: 279, Hmm: 432}, {Name: "statement", Wmm: 140, Hmm: 216}, } const tolerance = 2.0 for _, size := range standard { if math.Abs(widthMm-size.Wmm) <= tolerance && math.Abs(heightMm-size.Hmm) <= tolerance { return size.Name, true } } return "", false } func (b *Bridge) printPDF(printerName string, jobName string, pdfData []byte, options PrintOptions) error { // 1. Write to temp file tmpFile, err := ioutil.TempFile("", "print-dot-*.pdf") if err != nil { return fmt.Errorf("create temp file failed: %v", err) } defer os.Remove(tmpFile.Name()) // Clean up on exit if _, err := tmpFile.Write(pdfData); err != nil { tmpFile.Close() return fmt.Errorf("write temp file failed: %v", err) } tmpFile.Close() absPath, _ := filepath.Abs(tmpFile.Name()) if jobName != "" { b.Log(fmt.Sprintf("Printing job '%s': %s to %s", jobName, absPath, printerName)) } else { b.Log(fmt.Sprintf("Printing PDF file: %s to %s", absPath, printerName)) } return b.printPDFPlatform(printerName, absPath, options) } func (b *Bridge) StartLogServer() error { b.ensureLogDir() listener, err := net.Listen("tcp", "localhost:0") if err != nil { return err } b.logPort = listener.Addr().(*net.TCPAddr).Port mux := http.NewServeMux() mux.HandleFunc("/logs", b.handleLogs) mux.HandleFunc("/api/logs", b.handleLogsJSON) mux.HandleFunc("/api/logs/clear", b.handleClearLogs) mux.HandleFunc("/api/restart", b.handleRestartRequest) mux.HandleFunc("/api/reload", b.handleReloadRequest) mux.HandleFunc("/api/forwarder/status", b.handleForwarderStatus) mux.HandleFunc("/api/forwarder/connect", b.handleForwarderConnect) mux.HandleFunc("/api/forwarder/disconnect", b.handleForwarderDisconnect) mux.HandleFunc("/api/forwarder/stream", b.handleForwarderStream) b.logServer = &http.Server{ Handler: mux, } go func() { if err := b.logServer.Serve(listener); err != nil && err != http.ErrServerClosed { b.Log(fmt.Sprintf("Log server error: %v", err)) } }() return nil } func (b *Bridge) ensureLogDir() { logDir, err := b.logDirPath() if err != nil { return } _ = os.MkdirAll(logDir, 0755) path := filepath.Join(logDir, time.Now().Format("20060102")+".txt") if f, err := os.OpenFile(path, os.O_CREATE, 0644); err == nil { f.Close() } } func (b *Bridge) StopLogServer() error { if b.logServer == nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := b.logServer.Shutdown(ctx); err != nil { _ = b.logServer.Close() return err } b.logServer = nil return nil } func (b *Bridge) handleLogs(w http.ResponseWriter, r *http.Request) { currentLogs, _ := b.readLogLines() html := ` System Logs - XSL-PrintDot Client

System Logs

Auto-refreshing every 2 seconds
{{range .}}
{{.}}
{{else}}
No logs available.
{{end}}
` t, err := template.New("logs").Parse(html) if err != nil { http.Error(w, "Template error", http.StatusInternalServerError) return } t.Execute(w, currentLogs) } func (b *Bridge) handleLogsJSON(w http.ResponseWriter, r *http.Request) { currentLogs, _ := b.readLogLines() w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // Allow child process to fetch json.NewEncoder(w).Encode(currentLogs) } func (b *Bridge) handleClearLogs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } b.clearLogFile() w.WriteHeader(http.StatusOK) } func (b *Bridge) logFilePath() (string, error) { logDir, err := b.logDirPath() if err != nil { return "", err } fileName := time.Now().Format("20060102") + ".txt" return filepath.Join(logDir, fileName), nil } func (b *Bridge) logDirPath() (string, error) { baseDir, err := dataDirPath() if err != nil { return "", err } return filepath.Join(baseDir, "logs"), nil } func dataDirPath() (string, error) { if programData := strings.TrimSpace(os.Getenv("ProgramData")); programData != "" { return filepath.Join(programData, "PrintDot"), nil } if runtime.GOOS == "darwin" { if home, err := os.UserHomeDir(); err == nil { return filepath.Join(home, "Library", "Application Support", "PrintDot"), nil } } if runtime.GOOS == "linux" { if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { return filepath.Join(dataHome, "PrintDot"), nil } if home, err := os.UserHomeDir(); err == nil { return filepath.Join(home, ".local", "share", "PrintDot"), nil } } if wd, err := os.Getwd(); err == nil { return filepath.Join(wd, "PrintDot"), nil } return "", fmt.Errorf("failed to resolve data directory") } func (b *Bridge) appendLogLine(line string) error { b.ensureLogDir() path, err := b.logFilePath() if err != nil { return err } if !strings.HasSuffix(line, "\n") { line += "\n" } b.logFileMu.Lock() defer b.logFileMu.Unlock() f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return err } defer f.Close() _, err = f.WriteString(line) return err } func (b *Bridge) readLogLines() ([]string, error) { b.ensureLogDir() path, err := b.logFilePath() if err != nil { return nil, err } b.logFileMu.Lock() defer b.logFileMu.Unlock() f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return []string{}, nil } return nil, err } defer f.Close() lines := []string{} scanner := bufio.NewScanner(f) for scanner.Scan() { text := strings.TrimSpace(scanner.Text()) if text == "" { continue } lines = append(lines, text) } if err := scanner.Err(); err != nil { return lines, err } // Reverse logs for display (newest top) for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { lines[i], lines[j] = lines[j], lines[i] } return lines, nil } func (b *Bridge) clearLogFile() { path, err := b.logFilePath() if err != nil { return } b.logFileMu.Lock() defer b.logFileMu.Unlock() _ = os.Remove(path) } func (b *Bridge) handleReloadRequest(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusOK) w.Write([]byte("Reload initiated")) // Trigger reload on main thread if b.onReload != nil { go func() { time.Sleep(100 * time.Millisecond) b.onReload() }() } } func (b *Bridge) handleForwarderStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } status := RemoteForwarderStatus{} if b.forwarderStatusProvider != nil { status = b.forwarderStatusProvider() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) } func (b *Bridge) handleForwarderConnect(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if b.forwarderConnect != nil { b.forwarderConnect() } w.WriteHeader(http.StatusOK) } func (b *Bridge) handleForwarderDisconnect(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if b.forwarderDisconnect != nil { b.forwarderDisconnect() } w.WriteHeader(http.StatusOK) } func (b *Bridge) handleForwarderStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } status := RemoteForwarderStatus{} if b.forwarderStatusProvider != nil { status = b.forwarderStatusProvider() } if data, err := json.Marshal(status); err == nil { fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } last := status ctx := r.Context() ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: current := RemoteForwarderStatus{} if b.forwarderStatusProvider != nil { current = b.forwarderStatusProvider() } if current != last { if data, err := json.Marshal(current); err == nil { fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() last = current } } } } }