diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0681b56 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = 4 + +[*.go] +indent_style = tab + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.{md,json,html}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f5cff99 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,81 @@ +name: Bug Report +description: Report a bug in TSC Bridge +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thank you for reporting a bug. Please fill in the details below. + + - type: input + id: version + attributes: + label: TSC Bridge Version + description: Run `tsc-bridge --version` to get the version. + placeholder: "3.0.0" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - macOS + - Windows 10 + - Windows 11 + - Ubuntu / Debian + - Fedora / RHEL + - Other Linux + validations: + required: true + + - type: input + id: printer + attributes: + label: Printer Model + description: The thermal printer model you are using. + placeholder: "TSC TDP-244 Plus" + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the bug. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Step-by-step instructions to reproduce the bug. + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen. + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs + description: Relevant log output from the bridge console. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..6f5694e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and Discussions + url: https://github.com/nicoyarce/tsc-bridge/discussions + about: Ask questions, share tips, and discuss TSC Bridge usage. diff --git a/.github/ISSUE_TEMPLATE/driver_request.yml b/.github/ISSUE_TEMPLATE/driver_request.yml new file mode 100644 index 0000000..edfc92f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/driver_request.yml @@ -0,0 +1,65 @@ +name: Driver Request +description: Request or propose a driver for a printer brand +labels: ["driver", "enhancement"] +body: + - type: markdown + attributes: + value: | + Use this template to request a new driver or to volunteer to write one. + + - type: input + id: language + attributes: + label: Printer Language + description: The command language the printer uses. + placeholder: "ZPL II, EPL2, CPCL, ESC/POS, DPL, SBPL, etc." + validations: + required: true + + - type: input + id: brand + attributes: + label: Printer Brand + description: The manufacturer. + placeholder: "Zebra, Brother, BIXOLON, Honeywell, SATO, etc." + validations: + required: true + + - type: textarea + id: models + attributes: + label: Printer Models + description: Specific models you have access to for testing. + placeholder: | + - Zebra ZD420 + - Zebra ZT410 + validations: + required: true + + - type: dropdown + id: volunteer + attributes: + label: Are you willing to write this driver? + options: + - "Yes, I will write the driver" + - "I can help test but not write the driver" + - "No, I am requesting that someone else write it" + validations: + required: true + + - type: textarea + id: resources + attributes: + label: Resources + description: | + Links to programming manuals, command references, or SDKs for this + printer language. + placeholder: | + - ZPL II Programming Guide: https://... + - Zebra SDK: https://... + + - type: textarea + id: notes + attributes: + label: Additional Notes + description: Anything else relevant to this driver. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..486c3b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,31 @@ +name: Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem does this feature solve? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: How would you like it to work? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Other approaches you have considered. + + - type: textarea + id: context + attributes: + label: Additional Context + description: Any other context, screenshots, or references. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3dd0890 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +## What does this PR do? + + + +## Motivation + + + +## Testing + + + +## Checklist + +- [ ] Tests pass (`go test ./...`) +- [ ] Code follows project conventions (`gofmt`, `go vet`) +- [ ] Documentation updated (if applicable) +- [ ] CHANGELOG.md updated (for user-facing changes) +- [ ] Commit messages follow the convention (`feat:`, `fix:`, `docs:`, etc.) + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] New driver +- [ ] Documentation +- [ ] Refactoring +- [ ] Build / CI +- [ ] Breaking change diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b424008 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + strategy: + matrix: + include: + - os: macos-latest + tags: "" + - os: ubuntu-latest + tags: "-tags crossbuild" + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install dependencies (macOS) + if: runner.os == 'macOS' + run: brew install libusb + + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libusb-1.0-0-dev libgtk-3-dev libappindicator3-dev + + - name: Build + run: go build ${{ matrix.tags }} ./... + + - name: Test + run: go test ${{ matrix.tags }} ./... + + - name: Vet + run: go vet ${{ matrix.tags }} ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libusb-1.0-0-dev libgtk-3-dev libappindicator3-dev + + - name: Lint with go vet + run: go vet -tags crossbuild ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 751975d..3e85f2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,11 +18,21 @@ jobs: goarch: arm64 binary: tsc-bridge-mac cgo: 1 - - os: ubuntu-latest + - os: windows-latest goos: windows goarch: amd64 binary: tsc-bridge.exe - cgo: 0 + cgo: 1 + - os: windows-latest + goos: windows + goarch: 386 + binary: tsc-bridge-32.exe + cgo: 1 + - os: ubuntu-latest + goos: linux + goarch: amd64 + binary: tsc-bridge-linux-amd64 + cgo: 1 runs-on: ${{ matrix.os }} @@ -37,17 +47,34 @@ jobs: if: runner.os == 'macOS' run: brew install libusb + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libusb-1.0-0-dev libgtk-3-dev libappindicator3-dev + + - name: Install MinGW (Windows) + if: runner.os == 'Windows' + uses: egor-tensin/setup-mingw@v2 + with: + platform: ${{ matrix.goarch == '386' && 'x86' || 'x64' }} + - name: Build binary env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} CGO_ENABLED: ${{ matrix.cgo }} run: | - if [ "$CGO_ENABLED" = "1" ] && command -v pkg-config &>/dev/null; then + LDFLAGS="-s -w" + if [ "$RUNNER_OS" = "macOS" ] && command -v pkg-config &>/dev/null; then export CGO_CFLAGS="$(pkg-config --cflags libusb-1.0)" export CGO_LDFLAGS="$(pkg-config --libs libusb-1.0)" fi - go build -ldflags="-s -w" -o ${{ matrix.binary }} . + if [ "$RUNNER_OS" = "Windows" ]; then + LDFLAGS="$LDFLAGS -H windowsgui" + fi + go build -ldflags="$LDFLAGS" -o ${{ matrix.binary }} . + shell: bash - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/renew-cert.yml b/.github/workflows/renew-cert.yml new file mode 100644 index 0000000..a6c0e70 --- /dev/null +++ b/.github/workflows/renew-cert.yml @@ -0,0 +1,43 @@ +name: Renew Certificate + +on: + schedule: + - cron: '0 6 1 * *' # First day of every month at 6am UTC + workflow_dispatch: # Manual trigger + +jobs: + renew: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install lego + run: | + curl -sL https://github.com/go-acme/lego/releases/latest/download/lego_linux_amd64.tar.gz | tar xz + sudo mv lego /usr/local/bin/ + + - name: Renew certificate + env: + CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }} + run: | + lego --email="herrera.monterroso.mario@gmail.com" \ + --dns cloudflare \ + --domains="local.labelctl.dev" \ + --accept-tos \ + --path="./.lego" \ + run + + cp .lego/certificates/local.labelctl.dev.crt certs/server.crt + cp .lego/certificates/local.labelctl.dev.key certs/server.key + + - name: Commit updated certificate + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add certs/server.crt certs/server.key + if git diff --cached --quiet; then + echo "No certificate changes" + else + git commit -m "chore: renew Let's Encrypt certificate for local.labelctl.dev" + git push + fi diff --git a/.gitignore b/.gitignore index 63a3383..5e0048b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ tsc-bridge.exe *.exe .DS_Store .ai-sessions/ +.lego/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..02feefb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,25 @@ +run: + timeout: 5m + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + disable: + - typecheck + +linters-settings: + errcheck: + exclude-functions: + - (net/http.ResponseWriter).Write + - fmt.Fprintf + - fmt.Printf + +issues: + exclude-dirs: + - dist + - docs + - testdata diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ca08bc6 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,217 @@ +# Architecture + +This document describes the internal architecture of TSC Bridge for +contributors who want to understand the codebase, fix bugs, or write drivers. + +## Overview + +TSC Bridge is a single Go binary that embeds an HTML dashboard and runs three +subsystems concurrently: + +1. **HTTP Server** -- serves the API and dashboard on localhost +2. **System Tray** -- native OS tray icon with context menu +3. **Native Window** -- platform-specific dashboard window (WKWebView on macOS, + webview_go on Windows/Linux, browser fallback) + +``` +main.go + | + +-- tray.go System tray lifecycle (fyne.io/systray) + | + +-- HTTP Server net/http on 127.0.0.1:PORT + | | + | +-- /status Bridge status + | +-- /printers Printer enumeration + | +-- /print Raw print job + | +-- /batch-pdf PDF generation from template + rows + | +-- /batch-tspl TSPL generation and printing + | +-- /dashboard Embedded HTML (go:embed) + | +-- /output/ Serve generated files + | +-- /templates Local template CRUD + | +-- ... 40+ endpoints + | + +-- webview_*.go Native window (platform-specific) + +-- config.go Configuration management + +-- auth.go Backend authentication +``` + +## Source Organization + +The project uses a flat package structure. All Go files are in `package main`. +Platform-specific code is separated by build tags. + +### Core Files + +| File | Responsibility | +|------|---------------| +| `main.go` | Entry point, HTTP router, server lifecycle | +| `config.go` | Read/write configuration, environment detection | +| `auth.go` | Token management, backend authentication | +| `api_client.go` | HTTP client for the backend API | +| `network.go` | Network utilities, device discovery | +| `tls.go` | Self-signed certificate generation | +| `crypto.go` | AES-256 encryption for stored credentials | + +### Rendering Pipeline + +| File | Responsibility | +|------|---------------| +| `pdf_renderer.go` | Renders label templates to PDF pages | +| `tspl_renderer.go` | Renders label templates to TSPL2 commands | +| `label_template.go` | Label template data structures and parsing | +| `presets.go` | Built-in label size presets | +| `batch.go` | Batch job management (Excel/CSV to labels) | +| `excel.go` | Excel file parsing | + +### Platform Layer + +| File | Platforms | Responsibility | +|------|-----------|---------------| +| `webview_darwin.go` | macOS | Native WKWebView via CGO | +| `webview.go` | Windows, Linux | webview_go library | +| `webview_stub.go` | Cross-build | Browser-only fallback | +| `printer_windows.go` | Windows | Win32 raw printing | +| `printer_other.go` | macOS, Linux | libusb + CUPS printing | +| `printer_crossbuild.go` | Cross-build | CUPS-only (no libusb) | +| `driver_darwin.go` | macOS | TSC driver detection via IOKit | +| `driver_windows.go` | Windows | TSC driver detection via Registry | +| `driver_other.go` | Linux | Stub | +| `dpi_darwin.go` | macOS | DPI query via CUPS PPD | +| `dpi_windows.go` | Windows | DPI query via WMI | +| `dpi_other.go` | Linux | Stub | +| `autostart_darwin.go` | macOS | LaunchAgent plist | +| `autostart_windows.go` | Windows | Registry run key | +| `autostart_other.go` | Linux | Stub | +| `icon_darwin.go` | macOS | Dock icon via NSImage | +| `icon_windows.go` | Windows | Taskbar icon | +| `icon_other.go` | Linux | No-op | + +### Dashboard + +The file `dashboard.html` (embedded via `go:embed`) contains the entire +dashboard UI: HTML, CSS, and JavaScript in a single file. It uses Bootstrap 5 +and vanilla JavaScript. + +The dashboard has five tabs: + +1. **Dashboard** -- printer status, quick print, connection info +2. **Designer** -- interactive label designer with drag-and-drop +3. **Batch** -- import Excel, map columns, bulk print +4. **Templates** -- manage local and server-side templates +5. **Settings** -- printer selection, DPI, backend connection + +## Build Tags + +| Tag | Purpose | +|-----|---------| +| `darwin` | macOS-specific code (CGO required) | +| `windows` | Windows-specific code | +| `!darwin && !windows` | Linux/other platforms | +| `crossbuild` | Cross-compilation without platform SDK headers | + +The `crossbuild` tag disables libusb and webview_go, producing a binary that +uses CUPS for printing and the system browser for the dashboard. + +## Configuration + +Configuration lives in `~/.tsc-bridge/config.json`. The bridge creates this +directory on first run. Key settings: + +- `port` -- HTTP server port (default: 9638) +- `printer` -- default printer name +- `dpi` -- printer DPI (auto-detected or manual) +- `backend` -- backend API URL for template sync +- `whitelabel` -- custom branding +- `tls` -- TLS certificate settings +- `cors` -- allowed CORS origins + +## Rendering Pipeline + +When a print job arrives: + +``` +JSON request + | + v +Parse template (label_template.go) + | + v +Resolve variables (pdf_renderer.go: enrichRowForVariables) + | + +---> PDF path: RenderBulkPDF() -> gopdf -> .pdf file + | + +---> TSPL path: RenderBulkTSPL() -> TSPL2 commands -> rawPrint() + | + +---> [Future] ZPL path: RenderBulkZPL() -> ZPL commands -> rawPrint() +``` + +### Variable Resolution + +Label templates use field names like `{nombre}` or `{product_code}`. When +rendering, the bridge matches these variables against the row data using: + +1. Exact match: `row["nombre"]` +2. Suffix match: `row["gafete_nombre"]` matches variable `nombre` +3. Normalized match: dots and underscores are interchangeable + +This is implemented in `enrichRowForVariables()` and +`enrichRowForPlaceholders()` in `pdf_renderer.go`. + +## Driver Interface (planned) + +The current renderers (`tspl_renderer.go`, `pdf_renderer.go`) are tightly +coupled to the main package. The planned driver architecture will extract a +clean interface: + +```go +// Driver renders labels in a printer-specific language. +type Driver interface { + // Name returns the driver identifier (e.g., "tspl", "zpl", "epl"). + Name() string + + // Languages returns the label languages this driver supports. + Languages() []string + + // Render converts a parsed label template and row data into + // printer-ready commands. + Render(template *LabelTemplate, row map[string]string, opts RenderOpts) ([]byte, error) + + // Capabilities returns what this driver supports. + Capabilities() DriverCapabilities +} + +type DriverCapabilities struct { + Barcodes []string // Supported barcode types + QRCode bool + Images bool + TrueType bool // TrueType font embedding + Rotation []int // Supported rotation angles + MaxDPI int +} + +type RenderOpts struct { + DPI int + Copies int + Mode string // "print", "preview", "raster" +} +``` + +See [docs/DRIVERS.md](docs/DRIVERS.md) for the full driver development guide. + +## Testing + +Tests are in `*_test.go` files alongside the code they test: + +```sh +go test ./... +``` + +Key test files: + +- `config_test.go` -- configuration read/write, DPI round-trip +- `auth_test.go` -- authentication state management +- `dpi_test.go` -- DPI detection parsing + +When writing a driver, use the test harness described in +[docs/DRIVERS.md](docs/DRIVERS.md) to validate output without a physical +printer. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d0b173a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.0.0] - 2026-03-08 + +### Added + +- Native macOS WKWebView dashboard window (no browser dependency) +- System tray integration for macOS, Windows, and Linux +- Interactive label designer with drag-and-drop elements +- TSPL2 renderer for TSC thermal printers +- PDF renderer with TrueType font support and vector graphics +- Batch printing from Excel/CSV files with field mapping +- QR code generation (free text, vCard, URL, custom payload) +- Barcode support (Code 128, Code 39, EAN-13, UPC-A, ITF, Codabar) +- Auto-DPI detection for TSC printers (macOS and Windows) +- Direct USB printing via libusb on macOS and Linux +- CUPS integration for macOS and Linux +- Windows raw printing via Win32 API +- Self-signed TLS certificate generation for HTTPS localhost +- AES-256 encrypted credential storage +- Whitelabel branding support (name, logo, colors) +- Autostart on login (LaunchAgent on macOS, Registry on Windows) +- Network printer discovery +- Label border styles: simple, double, thick, rounded, shadow, inset, + ornate, art deco, ticket, dashed, certificate, filigree, dotted +- Arrow key movement for designer elements (Shift for fine control) +- Auto-fit elements to canvas +- Professional application icon generation (PNG, ICO, ICNS) +- macOS .app bundle with DMG distribution +- Windows InnoSetup installer script +- Cross-compilation support (macOS to Windows/Linux) + +### Changed + +- Migrated from browser-only dashboard to embedded native window +- Dashboard is now compiled into the binary via `go:embed` + +## [2.0.0] - 2026-02-15 + +### Added + +- HTTP API for print job submission +- Basic TSPL command generation +- Configuration file support + +## [1.0.0] - 2026-01-10 + +### Added + +- Initial release +- Direct USB printing to TSC TDP-244 Plus +- Command-line interface diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..16ff22e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,44 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers at conduct@abstraktgt.com. All complaints +will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..30f57c5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,186 @@ +# Contributing + +Thank you for your interest in contributing to TSC Bridge. This document +explains how to set up your development environment, write code, and submit +changes. + +Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Development Environment](#development-environment) +- [Writing a Printer Driver](#writing-a-printer-driver) +- [Submitting Changes](#submitting-changes) +- [Coding Guidelines](#coding-guidelines) +- [Reporting Bugs](#reporting-bugs) +- [Suggesting Features](#suggesting-features) + +## Getting Started + +1. Fork the repository +2. Clone your fork: + ```sh + git clone https://github.com/YOUR_USERNAME/tsc-bridge.git + cd tsc-bridge + ``` +3. Create a branch for your work: + ```sh + git checkout -b feature/zpl-driver + ``` + +## Development Environment + +### Prerequisites + +- Go 1.21 or later +- CGO enabled +- Platform-specific dependencies: + +| Platform | Dependencies | +|----------|-------------| +| macOS | Xcode Command Line Tools, `brew install libusb` | +| Windows | MinGW-w64 or MSYS2 | +| Linux | `libusb-1.0-0-dev`, `libgtk-3-dev`, `libappindicator3-dev` | + +### Build and Run + +```sh +# Build +go build -o tsc-bridge . + +# Run +./tsc-bridge + +# Run tests +go test ./... +``` + +### Cross-Compilation + +To cross-compile without platform SDK headers, use the `crossbuild` tag: + +```sh +# From macOS to Linux +CGO_ENABLED=1 CC="zig cc -target x86_64-linux-gnu" \ + GOOS=linux GOARCH=amd64 \ + go build -tags crossbuild -o tsc-bridge-linux . +``` + +### Dashboard Development + +The dashboard is a single HTML file (`dashboard.html`) embedded in the binary +via `go:embed`. To iterate on the dashboard: + +1. Edit `dashboard.html` +2. Rebuild: `go build -o tsc-bridge .` +3. Restart the bridge + +There is no hot-reload. The dashboard must be rebuilt into the binary after +every change. + +## Writing a Printer Driver + +The most impactful contribution is a driver for a printer brand you have access +to. See [docs/DRIVERS.md](docs/DRIVERS.md) for the complete guide. + +In summary: + +1. Create a new file: `_renderer.go` (e.g., `zpl_renderer.go`) +2. Implement the `Driver` interface +3. Register the driver in `init()` +4. Add tests in `_renderer_test.go` +5. Add documentation in `docs/drivers/.md` + +### What Makes a Good Driver + +- Translates the universal label format faithfully +- Handles all field types: text, barcode, QR code, image, line, rectangle +- Respects DPI settings +- Includes tests that validate output against known-good command sequences +- Documents which printer models have been tested + +## Submitting Changes + +### Commit Messages + +Use clear, descriptive commit messages. Follow this format: + +``` +: + + +``` + +Types: `feat`, `fix`, `docs`, `test`, `refactor`, `build`, `ci`. + +Examples: +- `feat: add ZPL driver with barcode support` +- `fix: correct DPI scaling for 300dpi printers` +- `docs: add Brother QL driver development notes` + +### Pull Requests + +1. Ensure all tests pass: `go test ./...` +2. Run the linter if available: `golangci-lint run` +3. Push your branch and open a pull request +4. Fill in the PR template +5. Wait for review + +### Code Review + +All submissions require review before merging. Reviewers will check: + +- Correctness of the implementation +- Test coverage for new code +- Adherence to the coding guidelines below +- Documentation for new features or drivers + +## Coding Guidelines + +### Style + +- Follow standard Go conventions (`gofmt`, `go vet`) +- No blank line between function signature and opening brace +- Group imports: stdlib, external, internal +- Error messages are lowercase, no trailing punctuation +- Use `log.Printf` for runtime logging with `[tag]` prefixes + +### Error Handling + +- Return errors, do not panic +- Wrap errors with context: `fmt.Errorf("parse template: %w", err)` +- Log errors at the point where they are handled, not where they originate + +### Platform Code + +- Use build tags to separate platform-specific code +- Every platform-specific file must have a corresponding `_other.go` stub +- Test on at least one platform before submitting; CI covers the rest + +### Documentation + +- Document exported functions and types +- Add a `docs/drivers/.md` file for new drivers +- Update the README driver table + +## Reporting Bugs + +Open an issue using the **Bug Report** template. Include: + +- TSC Bridge version (`tsc-bridge --version`) +- Operating system and version +- Printer model +- Steps to reproduce +- Expected vs. actual behavior +- Relevant log output + +## Suggesting Features + +Open an issue using the **Feature Request** template. Describe: + +- The problem you are trying to solve +- Your proposed solution +- Alternatives you have considered + +For new printer driver requests, use the **Driver Request** template. diff --git a/GOALS.md b/GOALS.md new file mode 100644 index 0000000..9087676 --- /dev/null +++ b/GOALS.md @@ -0,0 +1,53 @@ +# Goals + +TSC Bridge aims to be the universal thermal label printing bridge. It connects +web applications to thermal label printers through a local HTTP API, removing +the need for browser extensions, Java applets, or vendor-specific SDKs. + +The following are the project goals, in order of priority from most to least +important. In case of conflict, goals higher on the list take precedence. + +## 1. Reliability + +Labels must print correctly, every time. A misprinted label costs time, money, +and trust. The bridge must never silently drop print jobs, corrupt label data, +or produce partial output. + +## 2. Universality + +Support as many thermal label printers as possible through a driver +architecture. No single vendor lock-in. The same web application should work +with TSC, Zebra, Brother, BIXOLON, Honeywell, or any other thermal printer. + +## 3. Simplicity + +A single binary with zero external dependencies. No runtime, no installer +wizard, no database. Drop the binary on any machine and it works. The HTTP API +is plain JSON over localhost. + +## 4. Standards + +Define and maintain an open label format specification that any application can +produce and any driver can consume. The format is JSON-based, human-readable, +and version-controlled. + +## 5. Community + +Make it easy for anyone to contribute a driver for their printer. Clear +interfaces, complete documentation, working examples, and a test harness that +validates driver correctness without physical hardware. + +## 6. Cross-Platform + +macOS, Windows, and Linux are first-class citizens. Platform-specific features +(USB direct printing, native windows, system tray) are implemented where +available, with graceful fallbacks where not. + +## Non-Goals + +- TSC Bridge is not a print server. It runs on the same machine as the + printer, not on a remote server. +- TSC Bridge is not a label designer. It renders labels from templates and + data. Design tools are separate concerns. +- TSC Bridge does not manage printer queues. It sends jobs and reports + success or failure. Queue management is the operating system's job. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b47f86 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 Abstrakt GT and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index fd7b987..6becaf5 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,45 @@ -VERSION := 2.0.0 +VERSION := 3.0.0 BINARY := tsc-bridge DIST := dist -.PHONY: all build-mac build-windows package-mac package-windows clean +.PHONY: all build-mac build-windows build-windows-32 package-mac package-windows icons app dmg clean -all: build-mac build-windows +all: build-mac icons app -# macOS: CGO required for libusb +# macOS: CGO required for systray build-mac: @mkdir -p $(DIST) CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o $(DIST)/$(BINARY)-mac . @echo "Built $(DIST)/$(BINARY)-mac (macOS arm64)" -# Windows: CGO disabled, no libusb β€” uses Print Spooler API +# Windows 64-bit: Intel + AMD x86-64 (brew install mingw-w64) build-windows: @mkdir -p $(DIST) - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o $(DIST)/$(BINARY).exe . + CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc \ + go build -ldflags="-s -w -H windowsgui" -o $(DIST)/$(BINARY).exe . @echo "Built $(DIST)/$(BINARY).exe (Windows amd64)" +# Windows 32-bit: Intel + AMD x86 legacy (brew install mingw-w64) +build-windows-32: + @mkdir -p $(DIST) + CGO_ENABLED=1 GOOS=windows GOARCH=386 CC=i686-w64-mingw32-gcc \ + go build -ldflags="-s -w -H windowsgui" -o $(DIST)/$(BINARY)-32.exe . + @echo "Built $(DIST)/$(BINARY)-32.exe (Windows 386)" + +# Generate all icon formats +icons: build-mac + @$(DIST)/$(BINARY)-mac --generate-icon $(DIST)/icon_1024.png + @$(DIST)/$(BINARY)-mac --generate-ico $(DIST)/$(BINARY).ico + @echo "Generated icons" + +# Package macOS .app bundle +app: build-mac icons + @bash build.sh 2>/dev/null || true + +# Create macOS DMG +dmg: app + @echo "DMG created by build.sh" + # Package macOS: binary + installer + LaunchAgent plist package-mac: build-mac @mkdir -p $(DIST)/tsc-bridge-mac-$(VERSION) @@ -31,7 +53,9 @@ package-mac: build-mac package-windows: build-windows @mkdir -p $(DIST)/tsc-bridge-win-$(VERSION) cp $(DIST)/$(BINARY).exe $(DIST)/tsc-bridge-win-$(VERSION)/ + cp $(DIST)/$(BINARY).ico $(DIST)/tsc-bridge-win-$(VERSION)/ 2>/dev/null || true cp install_windows.bat $(DIST)/tsc-bridge-win-$(VERSION)/ + cp tsc-bridge.iss $(DIST)/tsc-bridge-win-$(VERSION)/ 2>/dev/null || true cd $(DIST) && zip -r tsc-bridge-win-$(VERSION).zip tsc-bridge-win-$(VERSION)/ @echo "Packaged $(DIST)/tsc-bridge-win-$(VERSION).zip" diff --git a/README.md b/README.md new file mode 100644 index 0000000..defe630 --- /dev/null +++ b/README.md @@ -0,0 +1,294 @@ +# TSC Bridge + +A universal thermal label printing bridge. One binary, any printer, any +platform. + +TSC Bridge connects web applications to thermal label printers through a local +HTTP API. It runs as a system tray application, receives print jobs over +`localhost`, and sends them to the printer using the appropriate label language +(TSPL, ZPL, EPL, and more through community drivers). + +[Releases](https://github.com/nicoyarce/tsc-bridge/releases) | +[Documentation](#documentation) | +[Contributing](CONTRIBUTING.md) + +--- + +## Table of Contents + +- [Features](#features) +- [Install](#install) +- [Quick Start](#quick-start) +- [How It Works](#how-it-works) +- [API Reference](#api-reference) +- [Label Format](#label-format) +- [Drivers](#drivers) +- [Documentation](#documentation) +- [Building from Source](#building-from-source) +- [Contributing](#contributing) +- [License](#license) + +## Features + +- **Single binary** -- no runtime, no installer, no database +- **Cross-platform** -- macOS (arm64), Windows (amd64, i386), Linux (amd64) +- **Native UI** -- system tray icon with embedded dashboard window +- **HTTP API** -- plain JSON over localhost, CORS-aware +- **Label designer** -- interactive drag-and-drop editor in the dashboard +- **Batch printing** -- import Excel/CSV, map columns to fields, print hundreds +- **PDF output** -- vector PDF generation with TrueType fonts +- **Driver architecture** -- extensible support for multiple printer brands +- **QR and barcodes** -- Code 128, Code 39, EAN-13, UPC-A, QR codes, vCards +- **Auto-DPI detection** -- reads printer capabilities on macOS and Windows +- **USB direct printing** -- bypasses the OS print spooler via libusb +- **TLS on localhost** -- self-signed certificate for HTTPS origins +- **Whitelabel** -- custom branding (name, logo, colors) per deployment + +## Install + +### macOS + +Download the DMG from the +[releases page](https://github.com/nicoyarce/tsc-bridge/releases), open it, +and drag **TSC Bridge.app** to your Applications folder. + +Or install the raw binary: + +```sh +curl -fsSL https://github.com/nicoyarce/tsc-bridge/releases/latest/download/tsc-bridge-mac -o /usr/local/bin/tsc-bridge +chmod +x /usr/local/bin/tsc-bridge +``` + +### Windows + +Download `tsc-bridge-win-.zip` from the releases page. Extract and run +`install_windows.bat` as administrator, or compile `tsc-bridge.iss` with +[InnoSetup](https://jrsoftware.org/isinfo.php) for a GUI installer. + +### Linux + +```sh +curl -fsSL https://github.com/nicoyarce/tsc-bridge/releases/latest/download/tsc-bridge-linux-amd64 -o /usr/local/bin/tsc-bridge +chmod +x /usr/local/bin/tsc-bridge +``` + +On Linux, you may need to add your user to the `lp` group for USB printer +access: + +```sh +sudo usermod -aG lp $USER +``` + +## Quick Start + +1. Start the bridge: + +```sh +tsc-bridge +``` + +2. The system tray icon appears. Click it and select **Dashboard** to open the + native window. + +3. Send a print job from your web application: + +```sh +curl -X POST http://127.0.0.1:9638/print \ + -H "Content-Type: application/json" \ + -d '{ + "printer": "TSC_TDP-244_Plus", + "data": "SIZE 50 mm, 30 mm\nGAP 3 mm, 0 mm\nCLS\nTEXT 10,10,\"3\",0,1,1,\"Hello World\"\nPRINT 1,1\n" + }' +``` + +## How It Works + +``` +Web Application + | + | HTTP POST (JSON) + v ++------------------+ +| TSC Bridge | +| | +| HTTP Server | +| Label Renderer |---> PDF / TSPL / ZPL / EPL +| Driver Layer | ++------------------+ + | + | USB / CUPS / Win32 RAW + v + Thermal Printer +``` + +The bridge runs on `127.0.0.1:9638` (configurable). Web applications send +label data as JSON. The bridge renders the label using the appropriate driver +and sends the raw commands to the printer. + +## API Reference + +All endpoints accept and return JSON. The base URL is `http://127.0.0.1:9638`. + +### `GET /status` + +Returns bridge status, connected printers, and version. + +### `GET /printers` + +Lists all detected printers with type, status, and capabilities. + +### `POST /print` + +Sends a raw print job. Body: `{ "printer": "name", "data": "TSPL commands" }`. + +### `POST /batch-pdf` + +Generates a multi-page PDF from a template and row data. Body: +`{ "template_id": "uuid", "rows": [...], "mapping": {...} }`. + +Query parameter `?mode=url` returns a download URL instead of the binary file. + +### `POST /batch-tspl` + +Generates TSPL commands from a template and prints them. Body: +`{ "template_id": "uuid", "rows": [...], "printer": "name" }`. + +Modes: `print` (default), `preview` (returns TSPL text), `raster` (bitmap). + +### `GET /dashboard` + +Serves the embedded HTML dashboard. + +### `GET /output/{filename}` + +Serves generated files (PDF, images). Add `?dl=1` to force download. + +For the complete API reference, see [docs/API.md](docs/API.md). + +## Label Format + +TSC Bridge uses a JSON-based label format inspired by +[pdfme](https://pdfme.com/). The format describes page dimensions, field +positions, types, and variable bindings. + +```json +{ + "basePdf": { "width": 50, "height": 30 }, + "schemas": [ + [ + { + "name": "product_name", + "type": "text", + "position": { "x": 5, "y": 5 }, + "width": 40, + "height": 8, + "fontSize": 12, + "fontName": "Helvetica" + }, + { + "name": "barcode", + "type": "barcodes128", + "position": { "x": 5, "y": 15 }, + "width": 40, + "height": 10 + } + ] + ] +} +``` + +Field types: `text`, `multiVariableText`, `qrcode`, `barcodes128`, +`barcodes39`, `image`, `line`, `rectangle`. + +For the complete specification, see +[docs/LABEL_STANDARD.md](docs/LABEL_STANDARD.md). + +## Drivers + +TSC Bridge uses a driver architecture to support multiple printer brands and +label languages. Each driver translates the universal label format into +printer-specific commands. + +### Built-in Drivers + +| Driver | Language | Printers | +|--------|----------|----------| +| TSPL | TSPL2 | TSC TDP-244, TTP-245, TE200, TE300 series | +| PDF | PDF 1.4 | Any printer via OS print dialog | + +### Community Drivers (planned) + +| Driver | Language | Printers | Status | +|--------|----------|----------|--------| +| ZPL | ZPL II | Zebra ZD, ZT, GK, GX series | Seeking contributors | +| EPL | EPL2 | Zebra LP, TLP legacy series | Seeking contributors | +| CPCL | CPCL | Zebra mobile printers | Seeking contributors | +| ESC/POS| ESC/POS | Epson, Star, Bixolon receipt printers | Seeking contributors | +| DPL | DPL | Datamax-O'Neil / Honeywell | Seeking contributors | +| SBPL | SBPL | SATO printers | Seeking contributors | +| Fingerprint | Fingerprint | Intermec / Honeywell | Seeking contributors | + +To write a new driver, see [docs/DRIVERS.md](docs/DRIVERS.md). + +## Documentation + +- [Architecture](ARCHITECTURE.md) -- system design and component overview +- [Goals](GOALS.md) -- project priorities and non-goals +- [Label Standard](docs/LABEL_STANDARD.md) -- label format specification +- [Driver Guide](docs/DRIVERS.md) -- how to write a printer driver +- [API Reference](docs/API.md) -- complete HTTP API documentation +- [Changelog](CHANGELOG.md) -- version history +- [Contributing](CONTRIBUTING.md) -- how to contribute +- [Security](SECURITY.md) -- vulnerability reporting +- [Code of Conduct](CODE_OF_CONDUCT.md) -- community guidelines + +## Building from Source + +### Prerequisites + +- Go 1.21 or later +- CGO enabled (required for system tray and USB) +- macOS: Xcode Command Line Tools, `brew install libusb` +- Windows: MinGW-w64 +- Linux: `apt install libusb-1.0-0-dev libgtk-3-dev libappindicator3-dev` + +### Build + +```sh +git clone https://github.com/nicoyarce/tsc-bridge.git +cd tsc-bridge + +# macOS +make build-mac + +# Windows (from Windows or with MinGW cross-compiler) +make build-windows + +# Linux +go build -o tsc-bridge . +``` + +### Test + +```sh +go test ./... +``` + +### Full Release Build + +The `build.sh` script builds all platforms, generates icons, creates the macOS +`.app` bundle and DMG, and packages the Windows installer: + +```sh +./build.sh +``` + +## Contributing + +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for +guidelines. The most impactful way to contribute is by writing a driver for a +printer brand you have access to. + +## License + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ae53f87 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 3.x | Yes | +| < 3.0 | No | + +## Reporting a Vulnerability + +If you discover a security vulnerability in TSC Bridge, please report it +responsibly. **Do not open a public GitHub issue.** + +Send an email to security@abstraktgt.com with: + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +You will receive an acknowledgment within 48 hours. We will work with you to +understand the issue and coordinate a fix before any public disclosure. + +## Security Model + +TSC Bridge runs as a local service on `127.0.0.1`. It is not designed to be +exposed to the network. The threat model assumes: + +- **Trusted**: The local machine and its users +- **Untrusted**: Remote network traffic, web page JavaScript (CORS-restricted) + +### CORS + +The HTTP API enforces CORS headers. Only origins explicitly configured in the +bridge configuration file are allowed to make API requests. + +### TLS + +TSC Bridge can generate a self-signed TLS certificate for HTTPS on localhost. +This prevents mixed-content warnings when the calling web application uses +HTTPS. + +### Authentication + +When connected to a backend, the bridge uses token-based authentication. Tokens +are stored encrypted (AES-256) in the local configuration file. + +### USB Access + +Direct USB printing requires operating system permissions. On macOS, the bridge +may need to detach the kernel driver from the USB device. On Linux, the user +may need to be in the `lp` or `plugdev` group. diff --git a/api_client.go b/api_client.go new file mode 100644 index 0000000..28b9e7d --- /dev/null +++ b/api_client.go @@ -0,0 +1,615 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// BackendTemplate is a PDF template from the anysubscriptions backend. +type BackendTemplate struct { + ID string `json:"id"` // UUID + Name string `json:"name"` + Description string `json:"description"` + Categoria string `json:"categoria"` + Icon string `json:"icon"` + ThermalPrintable int `json:"thermal_printable"` + ConfigAdicional string `json:"config_adicional,omitempty"` // JSON string +} + +// FieldInfo describes a template field with its name and type (legacy, kept for compat). +type FieldInfo struct { + Name string `json:"name"` + Type string `json:"type"` // e.g. "multiVariableText", "qrcode", "image", "text" +} + +// FieldDetail describes a template field with full pdfme layout information. +type FieldDetail struct { + Name string `json:"name"` + Type string `json:"type"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + FontSize float64 `json:"font_size,omitempty"` + Alignment string `json:"alignment,omitempty"` + Variables []string `json:"variables,omitempty"` // variable names for mapping (multiVariableText or {placeholder}) +} + +// BackendTemplateDetail includes the pdfme content and thermal config. +type BackendTemplateDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Categoria string `json:"categoria"` + ThermalPrintable int `json:"thermal_printable"` + Fields []string `json:"fields"` // field names (backward compat) + FieldsTyped []FieldDetail `json:"fields_typed"` // fields with types + positions + Schema json.RawMessage `json:"schema,omitempty"` // raw pdfme content for preview + ThermalConfig *ThermalConfig `json:"thermal_config,omitempty"` +} + +// ThermalConfig is the thermal printing configuration from config_adicional. +type ThermalConfig struct { + Layout string `json:"layout"` // "single" or "matrix_3x1" + Matrix *MatrixConfig `json:"matrix,omitempty"` +} + +// MatrixConfig defines multi-column label layout. +type MatrixConfig struct { + Columns int `json:"columns"` + ColOffsets []int `json:"col_offsets"` // dots + TotalWidthMm int `json:"total_width_mm"` +} + +// TsplResponse is the response from the backend TSPL generation endpoint. +type TsplResponse struct { + Commands string `json:"commands"` + CommandsArray []string `json:"commands_array"` + SizeBytes int `json:"size_bytes"` + Copies int `json:"copies"` + Layout string `json:"layout"` +} + +// ApiClient communicates with the anysubscriptions backend. +type ApiClient struct { + baseURL string + bearerVal string // "KEY:SECRET" or "eyJ..." JWT + wlID string // White Label ID header value + httpClient *http.Client +} + +// NewApiClient creates a client from the current config. +// Supports two auth modes: +// - API Key:Secret β†’ Bearer KEY:SECRET (detected by presence of api_key + api_secret) +// - JWT Token β†’ Bearer eyJ... (fallback to api_token) +func NewApiClient(cfg AppConfig) *ApiClient { + bearer := cfg.ApiToken + if cfg.ApiKey != "" && cfg.ApiSecret != "" { + bearer = cfg.ApiKey + ":" + cfg.ApiSecret + } + + wl := "20" // default ISI Hospital + if cfg.ApiWhiteLabel > 0 { + wl = fmt.Sprintf("%d", cfg.ApiWhiteLabel) + } + + return &ApiClient{ + baseURL: cfg.ApiURL, + bearerVal: bearer, + wlID: wl, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +func (c *ApiClient) doGet(path string) ([]byte, error) { + url := c.baseURL + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.bearerVal) + req.Header.Set("X-Any-Wl", c.wlID) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +func (c *ApiClient) doPost(path string, payload any) ([]byte, error) { + url := c.baseURL + path + jsonBody, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.bearerVal) + req.Header.Set("X-Any-Wl", c.wlID) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + return body, nil +} + +// TestConnection verifies the backend is reachable and authenticated. +func (c *ApiClient) TestConnection() error { + // Use pdf-templates/all as health check since /status may not need auth + body, err := c.doGet("/pdf-templates/all?limit=1") + if err != nil { + return err + } + var resp struct { + Status int `json:"status"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("invalid response: %w", err) + } + if resp.Status != 1 { + return fmt.Errorf("API returned status %d", resp.Status) + } + return nil +} + +// BrandInfo contains branding information from the backend. +type BrandInfo struct { + BrandName string `json:"brandName"` + BrandLogo string `json:"brandLogo"` + BrandURL string `json:"brandUrl"` + StoreName string `json:"storeName"` + StoreLogo string `json:"storeLogo"` + WLName string `json:"whiteLabelName"` + WLLogo string `json:"whiteLabelLogo"` + WLDomain string `json:"whiteLabelDomain"` + PrimaryColor string `json:"primary"` + SecondaryColor string `json:"secondary"` +} + +// FetchBrandInfo returns brand/whitelabel info for the authenticated user. +// GET /users/brand-info +func (c *ApiClient) FetchBrandInfo() (*BrandInfo, error) { + body, err := c.doGet("/users/brand-info") + if err != nil { + return nil, err + } + var resp struct { + Status int `json:"status"` + Data struct { + BrandName string `json:"brandName"` + BrandLogo string `json:"brandLogo"` + BrandURL string `json:"brandUrl"` + StoreName string `json:"storeName"` + StoreLogo string `json:"storeLogo"` + WLName string `json:"whiteLabelName"` + WLLogo string `json:"whiteLabelLogo"` + WLDomain string `json:"whiteLabelDomain"` + Colors struct { + Primary string `json:"primary"` + Secondary string `json:"secondary"` + } `json:"colors"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parse brand info: %w", err) + } + d := resp.Data + return &BrandInfo{ + BrandName: d.BrandName, + BrandLogo: d.BrandLogo, + BrandURL: d.BrandURL, + StoreName: d.StoreName, + StoreLogo: d.StoreLogo, + WLName: d.WLName, + WLLogo: d.WLLogo, + WLDomain: d.WLDomain, + PrimaryColor: d.Colors.Primary, + SecondaryColor: d.Colors.Secondary, + }, nil +} + +// FetchTemplates returns all PDF templates from the backend. +// GET /pdf-templates/all +func (c *ApiClient) FetchTemplates() ([]BackendTemplate, error) { + body, err := c.doGet("/pdf-templates/all?limit=100") + if err != nil { + return nil, err + } + var resp struct { + Status int `json:"status"` + Data struct { + Templates []BackendTemplate `json:"templates"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parse templates: %w", err) + } + return resp.Data.Templates, nil +} + +// FetchTemplateFields returns the field names for a template. +// GET /pdf-templates/{uuid}/fields +func (c *ApiClient) FetchTemplateFields(templateID string) ([]string, error) { + body, err := c.doGet("/pdf-templates/" + templateID + "/fields") + if err != nil { + return nil, err + } + var resp struct { + Status int `json:"status"` + Data struct { + Fields []string `json:"fields"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + // Fallback: try parsing as direct array + var resp2 struct { + Status int `json:"status"` + Data []string `json:"data"` + } + if err2 := json.Unmarshal(body, &resp2); err2 != nil { + return nil, fmt.Errorf("parse fields: %w", err) + } + return resp2.Data, nil + } + return resp.Data.Fields, nil +} + +// FetchTemplateDetail returns a template with its schema content. +// GET /pdf-templates/{uuid} +// +// Uses two-pass unmarshal: pass 1 extracts metadata/fields (order irrelevant), +// pass 2 preserves raw JSON bytes for the schema so key order (= z-order in PDF) +// is maintained. Without this, Go's map[string]any alphabetizes keys and +// guilloche patterns render ON TOP of text fields. +func (c *ApiClient) FetchTemplateDetail(templateID string) (*BackendTemplateDetail, error) { + body, err := c.doGet("/pdf-templates/" + templateID) + if err != nil { + return nil, err + } + + // Pass 1: Extract metadata and fields (order doesn't matter for these) + var resp struct { + Status int `json:"status"` + Data struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Categoria string `json:"categoria"` + ThermalPrintable int `json:"thermal_printable"` + ConfigAdicional any `json:"config_adicional"` + Content any `json:"content"` // pdfme schema β€” used only for field extraction + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parse template: %w", err) + } + + detail := &BackendTemplateDetail{ + ID: resp.Data.ID, + Name: resp.Data.Name, + Description: resp.Data.Description, + Categoria: resp.Data.Categoria, + ThermalPrintable: resp.Data.ThermalPrintable, + } + + // Extract fields from pdfme schema content (uses map[string]any, order irrelevant) + if resp.Data.Content != nil { + detail.Fields, detail.FieldsTyped = extractPdfmeFieldsDetailed(resp.Data.Content) + } + + // Pass 2: Preserve raw JSON bytes for schema (ORDER MATTERS for z-order!) + // json.RawMessage keeps the original byte sequence without re-marshaling through map[string]any + var rawResp struct { + Data struct { + Content json.RawMessage `json:"content"` + } `json:"data"` + } + if err := json.Unmarshal(body, &rawResp); err == nil && len(rawResp.Data.Content) > 0 { + detail.Schema = rawResp.Data.Content // Direct assignment, NO re-marshal! + } + + // Extract thermal_config from config_adicional + detail.ThermalConfig = extractThermalConfig(resp.Data.ConfigAdicional) + + return detail, nil +} + +// GenerateTSPL calls the backend to generate TSPL commands from a template + data. +// POST /pdfs/generate-tspl-commands +func (c *ApiClient) GenerateTSPL(templateID string, data map[string]string, copies int, layout string, preset string) (*TsplResponse, error) { + payload := map[string]any{ + "template_id": templateID, + "data": data, + "copies": copies, + } + if layout != "" { + payload["layout"] = layout + } + if preset != "" { + payload["preset"] = preset + } + + body, err := c.doPost("/pdfs/generate-tspl-commands", payload) + if err != nil { + return nil, err + } + + var resp struct { + Status int `json:"status"` + Data TsplResponse `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parse tspl response: %w", err) + } + if resp.Status != 1 { + return nil, fmt.Errorf("TSPL generation failed (status %d)", resp.Status) + } + return &resp.Data, nil +} + +// extractPdfmeFieldsDetailed reads field names, types, positions, dimensions, and variables from a pdfme schema. +// Supports both v4 (keyed objects) and v5 (array with name property) formats. +// Variables are extracted so the dashboard can map at the variable level (not field name level). +func extractPdfmeFieldsDetailed(content any) ([]string, []FieldDetail) { + contentMap, ok := content.(map[string]any) + if !ok { + return nil, nil + } + + schemas, ok := contentMap["schemas"].([]any) + if !ok || len(schemas) == 0 { + return nil, nil + } + + // Only these types are mappable data fields (skip line, rectangle, image, etc.) + mappableTypes := map[string]bool{"text": true, "multiVariableText": true, "qrcode": true, "barcode": true} + + seen := map[string]bool{} + var names []string + var detailed []FieldDetail + + for _, page := range schemas { + switch p := page.(type) { + case []any: + // v5 format: array of objects with "name", "type", "position", "width", "height" + for _, elem := range p { + obj, ok := elem.(map[string]any) + if !ok { + continue + } + name, _ := obj["name"].(string) + fieldType, _ := obj["type"].(string) + if name == "" || seen[name] { + continue + } + if !mappableTypes[fieldType] { + continue + } + seen[name] = true + names = append(names, name) + + fd := FieldDetail{Name: name} + fd.Type, _ = obj["type"].(string) + fd.Width, _ = obj["width"].(float64) + fd.Height, _ = obj["height"].(float64) + fd.FontSize, _ = obj["fontSize"].(float64) + fd.Alignment, _ = obj["alignment"].(string) + + if pos, ok := obj["position"].(map[string]any); ok { + fd.X, _ = pos["x"].(float64) + fd.Y, _ = pos["y"].(float64) + } + + fd.Variables = extractFieldVariables(obj) + detailed = append(detailed, fd) + } + case map[string]any: + // v4 format: keyed objects where value may have "type" + for key, val := range p { + if seen[key] { + continue + } + + fd := FieldDetail{Name: key} + if obj, ok := val.(map[string]any); ok { + fd.Type, _ = obj["type"].(string) + if !mappableTypes[fd.Type] { + continue + } + fd.Width, _ = obj["width"].(float64) + fd.Height, _ = obj["height"].(float64) + fd.FontSize, _ = obj["fontSize"].(float64) + fd.Alignment, _ = obj["alignment"].(string) + if pos, ok := obj["position"].(map[string]any); ok { + fd.X, _ = pos["x"].(float64) + fd.Y, _ = pos["y"].(float64) + } + fd.Variables = extractFieldVariables(obj) + } else if !mappableTypes[fd.Type] { + continue + } + + seen[key] = true + names = append(names, key) + detailed = append(detailed, fd) + } + } + } + return names, detailed +} + +// extractFieldVariables extracts the mappable variable names from a pdfme field object. +// Handles: +// - multiVariableText/text with "variables" array (e.g. ["gafete.nombre", "gafete.apellido"]) +// - qrcode/barcode/text with {placeholder} in content (e.g. "{gafete.token}") +// - table with body as variable string (e.g. "{medicamentos_tabla}") +func extractFieldVariables(obj map[string]any) []string { + // Any field type can have an explicit variables array (multiVariableText, text with ISI custom vars, table) + if vars, ok := obj["variables"].([]any); ok && len(vars) > 0 { + var result []string + for _, v := range vars { + if s, ok := v.(string); ok && s != "" { + result = append(result, s) + } + } + if len(result) > 0 { + return result + } + } + + // Check text template for {placeholder} patterns (e.g. text: "{gafete.nombre}") + if text, ok := obj["text"].(string); ok && text != "" { + vars := extractAllPlaceholders(text) + if len(vars) > 0 { + return vars + } + } + + // Check content for single {placeholder} (qrcode, barcode, text without variables array) + if content, ok := obj["content"].(string); ok && content != "" { + if ph := extractSinglePlaceholder(content); ph != "" { + return []string{ph} + } + } + + // Check table body for variable reference + if body, ok := obj["body"].(string); ok && body != "" { + if ph := extractSinglePlaceholder(body); ph != "" { + return []string{ph} + } + } + + return nil +} + +// extractAllPlaceholders finds all {varName} patterns in a text string. +func extractAllPlaceholders(text string) []string { + var result []string + seen := map[string]bool{} + remaining := text + for { + start := strings.Index(remaining, "{") + if start == -1 { + break + } + end := strings.Index(remaining[start:], "}") + if end == -1 { + break + } + inner := remaining[start+1 : start+end] + remaining = remaining[start+end+1:] + // Skip JSON-like patterns and Handlebars conditionals + if strings.ContainsAny(inner, "\":# ") { + continue + } + if inner != "" && !seen[inner] { + seen[inner] = true + result = append(result, inner) + } + } + return result +} + +// extractSinglePlaceholder returns the variable name if the string is exactly "{varName}". +func extractSinglePlaceholder(s string) string { + s = strings.TrimSpace(s) + if len(s) < 3 || s[0] != '{' || s[len(s)-1] != '}' { + return "" + } + inner := s[1 : len(s)-1] + // Must not contain spaces, braces, or colons (which would indicate JSON) + if strings.ContainsAny(inner, " {}:\"") { + return "" + } + return inner +} + +// extractThermalConfig parses config_adicional to get thermal_config. +func extractThermalConfig(configAdicional any) *ThermalConfig { + if configAdicional == nil { + return nil + } + + var cfgMap map[string]any + + switch v := configAdicional.(type) { + case map[string]any: + cfgMap = v + case string: + if v == "" { + return nil + } + if err := json.Unmarshal([]byte(v), &cfgMap); err != nil { + return nil + } + default: + return nil + } + + tcRaw, ok := cfgMap["thermal_config"] + if !ok { + return nil + } + + tcMap, ok := tcRaw.(map[string]any) + if !ok { + return nil + } + + tc := &ThermalConfig{} + if layout, ok := tcMap["layout"].(string); ok { + tc.Layout = layout + } + + if matrixRaw, ok := tcMap["matrix"].(map[string]any); ok { + tc.Matrix = &MatrixConfig{} + if cols, ok := matrixRaw["columns"].(float64); ok { + tc.Matrix.Columns = int(cols) + } + if tw, ok := matrixRaw["total_width_mm"].(float64); ok { + tc.Matrix.TotalWidthMm = int(tw) + } + if offsets, ok := matrixRaw["col_offsets"].([]any); ok { + for _, o := range offsets { + if v, ok := o.(float64); ok { + tc.Matrix.ColOffsets = append(tc.Matrix.ColOffsets, int(v)) + } + } + } + } + + return tc +} diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..0e3248d --- /dev/null +++ b/auth.go @@ -0,0 +1,153 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" +) + +type AuthState struct { + Configured bool `json:"configured"` + Connected bool `json:"connected"` + ApiURL string `json:"api_url"` + WhitelabelName string `json:"whitelabel_name"` + WhitelabelID int `json:"whitelabel_id"` + LogoURL string `json:"logo_url,omitempty"` + Error string `json:"error,omitempty"` +} + +func IsAuthConfigured() bool { + cfg := getConfig() + if cfg.ApiURL == "" { + return false + } + return (cfg.ApiKey != "" && cfg.ApiSecret != "") || cfg.ApiToken != "" +} + +func GetAuthState() AuthState { + cfg := getConfig() + return AuthState{ + Configured: IsAuthConfigured(), + ApiURL: cfg.ApiURL, + WhitelabelName: cfg.Whitelabel.Name, + WhitelabelID: cfg.Whitelabel.ID, + LogoURL: cfg.Whitelabel.LogoURL, + } +} + +// handleAuthState β€” GET /auth/state +func handleAuthState(w http.ResponseWriter, r *http.Request) { + state := GetAuthState() + if state.Configured { + client := NewApiClient(getConfig()) + if err := client.TestConnection(); err != nil { + state.Connected = false + state.Error = err.Error() + } else { + state.Connected = true + } + } + jsonResponse(w, http.StatusOK, state) +} + +// handleAuthLogin β€” POST /auth/login { api_url, api_key, api_secret, wl_id? } +func handleAuthLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var req struct { + ApiURL string `json:"api_url"` + ApiKey string `json:"api_key"` + ApiSecret string `json:"api_secret"` + WlID int `json:"wl_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.ApiURL == "" || req.ApiKey == "" || req.ApiSecret == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "api_url, api_key, api_secret required"}) + return + } + + // Test connection with temporary config + testCfg := AppConfig{ + ApiURL: req.ApiURL, + ApiKey: req.ApiKey, + ApiSecret: req.ApiSecret, + ApiWhiteLabel: req.WlID, + } + client := NewApiClient(testCfg) + if err := client.TestConnection(); err != nil { + log.Printf("[auth] login failed for %s: %v", req.ApiURL, err) + jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "connection failed: " + err.Error()}) + return + } + + // Save credentials + configMu.Lock() + appConfig.ApiURL = req.ApiURL + appConfig.ApiKey = req.ApiKey + appConfig.ApiSecret = req.ApiSecret + if req.WlID > 0 { + appConfig.ApiWhiteLabel = req.WlID + } + configMu.Unlock() + + // Fetch brand info (name, logo, colors) from backend + brand, err := client.FetchBrandInfo() + if err != nil { + log.Printf("[auth] brand fetch failed (non-fatal): %v", err) + } else if brand != nil { + configMu.Lock() + name := brand.BrandName + if name == "" { + name = brand.WLName + } + logo := brand.BrandLogo + if logo == "" { + logo = brand.WLLogo + } + if name != "" { + appConfig.Whitelabel.Name = name + } + if logo != "" { + appConfig.Whitelabel.LogoURL = logo + } + if brand.PrimaryColor != "" { + appConfig.Whitelabel.PrimaryColor = brand.PrimaryColor + } + if brand.SecondaryColor != "" { + appConfig.Whitelabel.AccentColor = brand.SecondaryColor + } + configMu.Unlock() + log.Printf("[auth] brand detected: %s (logo=%s)", name, logo) + } + + if err := saveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "save failed"}) + return + } + + log.Printf("[auth] login successful for %s (wl=%d)", req.ApiURL, req.WlID) + jsonResponse(w, http.StatusOK, map[string]string{"status": "connected"}) +} + +// handleAuthLogout β€” POST /auth/logout +func handleAuthLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + configMu.Lock() + appConfig.ApiURL = "" + appConfig.ApiKey = "" + appConfig.ApiSecret = "" + appConfig.ApiToken = "" + appConfig.Whitelabel = WhitelabelConfig{} + configMu.Unlock() + saveConfig() + log.Printf("[auth] logged out") + jsonResponse(w, http.StatusOK, map[string]string{"status": "logged_out"}) +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..e381a98 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,43 @@ +package main + +import "testing" + +func TestIsAuthConfigured(t *testing.T) { + configMu.Lock() + appConfig.ApiURL = "" + appConfig.ApiKey = "" + appConfig.ApiSecret = "" + appConfig.ApiToken = "" + configMu.Unlock() + + if IsAuthConfigured() { + t.Error("should be false with no credentials") + } + + configMu.Lock() + appConfig.ApiURL = "https://example.com" + appConfig.ApiKey = "key123" + appConfig.ApiSecret = "secret456" + configMu.Unlock() + + if !IsAuthConfigured() { + t.Error("should be true with API key + secret") + } +} + +func TestAuthState(t *testing.T) { + configMu.Lock() + appConfig.ApiURL = "https://example.com" + appConfig.ApiKey = "key" + appConfig.ApiSecret = "secret" + appConfig.Whitelabel = WhitelabelConfig{Name: "TestCo", ID: 42} + configMu.Unlock() + + state := GetAuthState() + if !state.Configured { + t.Error("configured should be true") + } + if state.WhitelabelName != "TestCo" { + t.Errorf("wl name = %q, want TestCo", state.WhitelabelName) + } +} diff --git a/autostart_windows.go b/autostart_windows.go index cf77892..9f01ae8 100644 --- a/autostart_windows.go +++ b/autostart_windows.go @@ -48,6 +48,7 @@ func setAutoStart(enable bool) error { lnk, binPath, binDir, iconPath, iconPath) cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps) + hideWindow(cmd) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("shortcut creation failed: %v β€” %s", err, string(out)) } diff --git a/batch.go b/batch.go new file mode 100644 index 0000000..d431a19 --- /dev/null +++ b/batch.go @@ -0,0 +1,199 @@ +package main + +import ( + "fmt" + "log" + "strings" +) + +// BatchJob describes a batch print operation. +type BatchJob struct { + // For local template mode + Template *LabelTemplate + Preset *LabelPreset + + // For backend API mode + BackendTemplateID string // UUID of backend template + Layout string // "single" or "matrix_3x1" + PresetName string // preset name hint for backend + + // Common + Rows []map[string]string + Copies int + Printer string + Mode string // "local" or "backend" +} + +// BatchResult summarizes a completed batch print. +type BatchResult struct { + TotalRows int `json:"total_rows"` + Printed int `json:"printed"` + Errors []string `json:"errors"` + Mode string `json:"mode"` + Bytes int `json:"bytes"` +} + +// GenerateTSPL produces the complete TSPL2 command string for the batch. +// If mode is "backend", calls the API for each row. Otherwise uses local templates. +func (j *BatchJob) GenerateTSPL() (string, error) { + var tspl string + var err error + if j.Mode == "backend" { + tspl, err = j.generateViaBackend() + } else { + tspl, err = j.generateLocal(), nil + } + if err != nil { + return "", err + } + return sanitizeTSPL(tspl), nil +} + +// sanitizeTSPL cleans TSPL data to ensure the printer interprets it correctly: +// - Strips UTF-8 BOM +// - Normalizes line endings to \r\n +// - Strips leading whitespace before first command +func sanitizeTSPL(tspl string) string { + // Strip UTF-8 BOM + tspl = strings.TrimPrefix(tspl, "\xEF\xBB\xBF") + // Strip leading whitespace/newlines + tspl = strings.TrimLeft(tspl, " \t\n\r") + // Normalize line endings: first remove \r, then replace \n with \r\n + tspl = strings.ReplaceAll(tspl, "\r\n", "\n") + tspl = strings.ReplaceAll(tspl, "\r", "\n") + tspl = strings.ReplaceAll(tspl, "\n", "\r\n") + // Ensure ends with \r\n + if !strings.HasSuffix(tspl, "\r\n") { + tspl += "\r\n" + } + return tspl +} + +// generateViaBackend calls POST /pdfs/generate-tspl-commands for each row. +func (j *BatchJob) generateViaBackend() (string, error) { + cfg := getConfig() + if cfg.ApiURL == "" { + return "", fmt.Errorf("API not configured (set api_url)") + } + hasAuth := cfg.ApiToken != "" || (cfg.ApiKey != "" && cfg.ApiSecret != "") + if !hasAuth { + return "", fmt.Errorf("API auth not configured (set api_token or api_key+api_secret)") + } + + client := NewApiClient(cfg) + var sb strings.Builder + copies := j.Copies + if copies < 1 { + copies = 1 + } + + for i, row := range j.Rows { + resp, err := client.GenerateTSPL(j.BackendTemplateID, row, copies, j.Layout, j.PresetName) + if err != nil { + return "", fmt.Errorf("row %d: %w", i+1, err) + } + sb.WriteString(resp.Commands) + if !strings.HasSuffix(resp.Commands, "\r\n") { + sb.WriteString("\r\n") + } + } + + return sb.String(), nil +} + +// generateLocal produces TSPL2 using the local template engine. +func (j *BatchJob) generateLocal() string { + var sb strings.Builder + copies := j.Copies + if copies < 1 { + copies = 1 + } + + cols := j.Preset.Columns + if cols < 1 { + cols = 1 + } + + // Write header once + sb.WriteString(generatePresetHeader(j.Preset)) + + // Process rows in groups of `cols` + for i := 0; i < len(j.Rows); i += cols { + sb.WriteString("CLS\r\n") + + for c := 0; c < cols && (i+c) < len(j.Rows); c++ { + row := j.Rows[i+c] + colOffset := 0 + if c < len(j.Preset.ColOffsets) { + colOffset = j.Preset.ColOffsets[c] + } + sb.WriteString(j.Template.Render(row, colOffset)) + } + + sb.WriteString(fmt.Sprintf("PRINT %d\r\n", copies)) + } + + return sb.String() +} + +// Execute generates TSPL and sends it to the printer. +func (j *BatchJob) Execute() (*BatchResult, error) { + tspl, err := j.GenerateTSPL() + if err != nil { + return &BatchResult{ + TotalRows: len(j.Rows), + Errors: []string{err.Error()}, + Mode: j.Mode, + }, err + } + + result := &BatchResult{ + TotalRows: len(j.Rows), + Errors: []string{}, + Mode: j.Mode, + Bytes: len(tspl), + } + + printErr := sendToPrinterByName(tspl, j.Printer) + if printErr != nil { + result.Errors = append(result.Errors, printErr.Error()) + return result, printErr + } + + result.Printed = len(j.Rows) + log.Printf("[batch] Printed %d rows (%d bytes, mode=%s)", result.Printed, len(tspl), j.Mode) + return result, nil +} + +// sendToPrinterByName resolves a printer and sends raw data. +func sendToPrinterByName(tspl string, printerName string) error { + allPrinters, _ := listAllPrinters() + if printerName == "" { + cfg := getConfig() + printerName = cfg.DefaultPrinter + } + + var targetPrinter *PrinterInfo + if printerName == "" { + if len(allPrinters) > 0 { + targetPrinter = &allPrinters[0] + printerName = targetPrinter.Name + } + } else { + targetPrinter = findPrinter(printerName, allPrinters) + } + if printerName == "" { + return fmt.Errorf("no printer found") + } + + // Prepend TSPL initialization: ESC !R forces the printer into TSPL2 mode. + // This prevents the printer from printing raw text when it's stuck in + // another mode (text, hex dump, PCL, etc.) + tsplInit := "\x1b!R\r\nSET CUTTER OFF\r\n" + data := []byte(tsplInit + tspl) + + if targetPrinter != nil && (targetPrinter.Type == "network" || targetPrinter.Type == "manual" || targetPrinter.Type == "raw") { + return networkRawPrint(targetPrinter.Address, data) + } + return rawPrint(printerName, data) +} diff --git a/browser.go b/browser.go index a38be03..4bfe0f5 100644 --- a/browser.go +++ b/browser.go @@ -6,18 +6,24 @@ import ( "runtime" ) -// openBrowser opens the given URL in the default browser. +// openBrowser opens the given URL in the system's default browser. func openBrowser(url string) { var cmd *exec.Cmd switch runtime.GOOS { + case "windows": + // Use rundll32 instead of "cmd /c start" to avoid flashing a console window + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) case "darwin": cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("cmd", "/c", "start", "", url) default: cmd = exec.Command("xdg-open", url) } + if runtime.GOOS == "windows" { + hideWindowCmd(cmd) + } if err := cmd.Start(); err != nil { - log.Printf("[browser] Could not open browser: %v", err) + log.Printf("[browser] Failed to open %s: %v", url, err) + return } + go cmd.Wait() } diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..1210990 --- /dev/null +++ b/build.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# build.sh β€” Build TSC Bridge for macOS and Windows +# Generates icons, builds binaries, packages .app + .dmg, InnoSetup installer +set -e + +PROJ_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$PROJ_DIR" +mkdir -p dist + +VERSION=$(grep 'const version' main.go | head -1 | sed 's/.*"\(.*\)".*/\1/') +echo "╔══════════════════════════════════════╗" +echo "β•‘ TSC Bridge v${VERSION} β€” Build Script β•‘" +echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" + +# 1. Kill old instances +echo "" +echo "[1/7] Killing old tsc-bridge instances..." +pkill -f "tsc-bridge" 2>/dev/null && echo " Killed old processes" || echo " No old processes found" +sleep 1 + +# 2. Build macOS (arm64) +echo "" +echo "[2/7] Building macOS (arm64)..." +CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o dist/tsc-bridge-mac . +echo " βœ“ dist/tsc-bridge-mac ($(du -h dist/tsc-bridge-mac | cut -f1))" + +# 3. Build Windows 64-bit (requires mingw-w64 + Windows SDK headers) +echo "" +echo "[3/7] Building Windows 64-bit..." +if command -v x86_64-w64-mingw32-gcc &>/dev/null; then + if CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 \ + go build -ldflags="-s -w -H windowsgui" -o dist/tsc-bridge.exe . 2>/dev/null; then + echo " βœ“ dist/tsc-bridge.exe ($(du -h dist/tsc-bridge.exe | cut -f1))" + else + echo " ⚠ Windows 64-bit build failed (missing SDK headers?) β€” using existing binary if available" + [ -f dist/tsc-bridge.exe ] && echo " βœ“ dist/tsc-bridge.exe (existing: $(du -h dist/tsc-bridge.exe | cut -f1))" + fi +else + echo " ⚠ mingw-w64 (x86_64) not found β€” skipping Windows 64-bit" + [ -f dist/tsc-bridge.exe ] && echo " βœ“ dist/tsc-bridge.exe (existing: $(du -h dist/tsc-bridge.exe | cut -f1))" +fi + +# 3b. Build Windows 32-bit +echo "" +echo "[3b/7] Building Windows 32-bit..." +if command -v i686-w64-mingw32-gcc &>/dev/null; then + if CGO_ENABLED=1 CC=i686-w64-mingw32-gcc GOOS=windows GOARCH=386 \ + go build -ldflags="-s -w -H windowsgui" -o dist/tsc-bridge-32.exe . 2>/dev/null; then + echo " βœ“ dist/tsc-bridge-32.exe ($(du -h dist/tsc-bridge-32.exe | cut -f1))" + else + echo " ⚠ Windows 32-bit build failed β€” using existing binary if available" + [ -f dist/tsc-bridge-32.exe ] && echo " βœ“ dist/tsc-bridge-32.exe (existing: $(du -h dist/tsc-bridge-32.exe | cut -f1))" + fi +else + echo " ⚠ mingw-w64 (i686) not found β€” skipping Windows 32-bit" + [ -f dist/tsc-bridge-32.exe ] && echo " βœ“ dist/tsc-bridge-32.exe (existing: $(du -h dist/tsc-bridge-32.exe | cut -f1))" +fi + +# 4. Generate icons from binary +echo "" +echo "[4/7] Generating application icons..." +ICON_PNG="dist/icon_1024.png" +ICON_ICO="dist/tsc-bridge.ico" +./dist/tsc-bridge-mac --generate-icon "$ICON_PNG" +./dist/tsc-bridge-mac --generate-ico "$ICON_ICO" +echo " βœ“ $ICON_PNG" +echo " βœ“ $ICON_ICO" + +# Generate .icns for macOS +ICONSET=$(mktemp -d)/AppIcon.iconset +mkdir -p "$ICONSET" +for SIZE in 16 32 64 128 256 512; do + sips -z $SIZE $SIZE "$ICON_PNG" --out "$ICONSET/icon_${SIZE}x${SIZE}.png" >/dev/null 2>&1 + D=$((SIZE * 2)) + if [ $D -le 1024 ]; then + sips -z $D $D "$ICON_PNG" --out "$ICONSET/icon_${SIZE}x${SIZE}@2x.png" >/dev/null 2>&1 + fi +done +# 512@2x = 1024 +cp "$ICON_PNG" "$ICONSET/icon_512x512@2x.png" +ICNS_FILE="dist/AppIcon.icns" +iconutil -c icns "$ICONSET" -o "$ICNS_FILE" 2>/dev/null && echo " βœ“ $ICNS_FILE" || echo " ⚠ iconutil failed β€” .app will have no icon" +rm -rf "$(dirname "$ICONSET")" + +# 5. Package macOS .app bundle +echo "" +echo "[5/7] Packaging macOS .app bundle..." +APP="dist/TSC Bridge.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp dist/tsc-bridge-mac "$APP/Contents/MacOS/tsc-bridge" +chmod +x "$APP/Contents/MacOS/tsc-bridge" + +# Copy icon +if [ -f "$ICNS_FILE" ]; then + cp "$ICNS_FILE" "$APP/Contents/Resources/AppIcon.icns" +fi + +cat > "$APP/Contents/Info.plist" << PLIST + + + + + CFBundleExecutable + tsc-bridge + CFBundleIdentifier + com.abstraktgt.tsc-bridge + CFBundleName + TSC Bridge + CFBundleDisplayName + TSC Bridge + CFBundleVersion + ${VERSION} + CFBundleShortVersionString + ${VERSION} + CFBundlePackageType + APPL + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.utilities + CFBundleInfoDictionaryVersion + 6.0 + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + +PLIST +echo " βœ“ $APP" + +# 6. Create DMG for macOS distribution +echo "" +echo "[6/7] Creating macOS DMG..." +DMG="dist/TSC-Bridge-${VERSION}-macOS.dmg" +rm -f "$DMG" +# Create a temporary folder with the .app and a symlink to /Applications +DMG_STAGING=$(mktemp -d) +cp -R "$APP" "$DMG_STAGING/" +ln -s /Applications "$DMG_STAGING/Applications" +hdiutil create -volname "TSC Bridge ${VERSION}" -srcfolder "$DMG_STAGING" -ov -format UDZO "$DMG" >/dev/null 2>&1 \ + && echo " βœ“ $DMG" || echo " ⚠ DMG creation failed" +rm -rf "$DMG_STAGING" + +# 7. Package Windows installer files +echo "" +echo "[7/7] Packaging Windows installer..." +WIN_DIR="dist/tsc-bridge-win-${VERSION}" +rm -rf "$WIN_DIR" +mkdir -p "$WIN_DIR" +if [ -f dist/tsc-bridge.exe ]; then + cp dist/tsc-bridge.exe "$WIN_DIR/" +fi +if [ -f dist/tsc-bridge-32.exe ]; then + cp dist/tsc-bridge-32.exe "$WIN_DIR/" +fi +cp dist/tsc-bridge.ico "$WIN_DIR/" 2>/dev/null || true +cp install_windows.bat "$WIN_DIR/" +cp tsc-bridge.iss "$WIN_DIR/" 2>/dev/null || true +echo " βœ“ $WIN_DIR/" + +if [ -f dist/tsc-bridge.exe ]; then + cd dist && zip -r "tsc-bridge-win-${VERSION}.zip" "tsc-bridge-win-${VERSION}/" >/dev/null + echo " βœ“ dist/tsc-bridge-win-${VERSION}.zip" + cd "$PROJ_DIR" +fi + +# Summary +echo "" +echo "╔══════════════════════════════════════════╗" +echo "β•‘ Build Complete! v${VERSION} β•‘" +echo "╠══════════════════════════════════════════╣" +echo "β•‘ macOS: β•‘" +echo "β•‘ dist/TSC Bridge.app β•‘" +if [ -f "$DMG" ]; then +echo "β•‘ $DMG β•‘" +fi +echo "β•‘ β•‘" +if [ -f dist/tsc-bridge.exe ]; then +echo "β•‘ Windows: β•‘" +echo "β•‘ dist/tsc-bridge.exe (64-bit) β•‘" +fi +if [ -f dist/tsc-bridge-32.exe ]; then +echo "β•‘ dist/tsc-bridge-32.exe (32-bit) β•‘" +fi +echo "β•‘ β•‘" +echo "β•‘ Icons: β•‘" +echo "β•‘ dist/AppIcon.icns (macOS) β•‘" +echo "β•‘ dist/tsc-bridge.ico (Windows) β•‘" +echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" +echo "" +echo "macOS: Drag 'TSC Bridge.app' to /Applications" +echo " or distribute the DMG: $DMG" +echo "" +echo "Windows: Run install_windows.bat as admin" +echo " or compile tsc-bridge.iss with InnoSetup for GUI installer" diff --git a/certs/server.crt b/certs/server.crt new file mode 100644 index 0000000..1b1d895 --- /dev/null +++ b/certs/server.crt @@ -0,0 +1,48 @@ +-----BEGIN CERTIFICATE----- +MIIDhjCCAw2gAwIBAgISBfRSqev+aldQsYsS6WahZreFMAoGCCqGSM49BAMDMDIx +CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF +ODAeFw0yNjAzMDgyMDQxMjFaFw0yNjA2MDYyMDQxMjBaMB0xGzAZBgNVBAMTEmxv +Y2FsLmxhYmVsY3RsLmRldjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABK/KfWZb +zQ1KDKZS82OdTj/Ny9hLTYlM/YYvmFQfsbyUDynmPfk5CsxFDvMpy61Iu6Ywb1s1 +3VA2jGteuFMyKQKjggIWMIICEjAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYI +KwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUKPIkRGWqb3PFOEtNAnNv +YsJZjX4wHwYDVR0jBBgwFoAUjw0TovYuftFQbDMYOF1ZjiNykcowMgYIKwYBBQUH +AQEEJjAkMCIGCCsGAQUFBzAChhZodHRwOi8vZTguaS5sZW5jci5vcmcvMB0GA1Ud +EQQWMBSCEmxvY2FsLmxhYmVsY3RsLmRldjATBgNVHSAEDDAKMAgGBmeBDAECATAs +BgNVHR8EJTAjMCGgH6AdhhtodHRwOi8vZTguYy5sZW5jci5vcmcvNi5jcmwwggEF +BgorBgEEAdZ5AgQCBIH2BIHzAPEAdgDLOPcViXyEoURfW8Hd+8lu8ppZzUcKaQWF +sMsUwxRY5wAAAZzPZG32AAAEAwBHMEUCIQDObDO1HnbH4udWbsCvUMSUs2NsYIk4 +1QwsaxkqMPGfIAIgD7QrqqJMcG42XDD1QNV8dx+n2zHse4sBMihl/fYsnj0AdwCW +l2S/VViXrfdDh2g3CEJ36fA61fak8zZuRqQ/D8qpxgAAAZzPZG4PAAAEAwBIMEYC +IQCRu6reTOB+B2IugxQeRYXJY1BzepdoUq9kuhT8/ZmNiwIhAKzLVMmmSFUg6irJ +a6yvZarNUIgFO+eJqJSYl+A9gmgqMAoGCCqGSM49BAMDA2cAMGQCMEYmOjHwswRn +ee3mm57saH0FxdMpevPWh1mphqBMt417QfKow6YillWN/YP4R7lIPgIwBeZ6UT6l +lYd9Ub2tJn7eLOKDehD+4hKNzwgHGn3OUspApf57lfPkwLjAWWjP12vm +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBP +MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy +Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa +Fw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF +bmNyeXB0MQswCQYDVQQDEwJFODB2MBAGByqGSM49AgEGBSuBBAAiA2IABNFl8l7c +S7QMApzSsvru6WyrOq44ofTUOTIzxULUzDMMNMchIJBwXOhiLxxxs0LXeb5GDcHb +R6EToMffgSZjO9SNHfY9gjMy9vQr5/WWOrQTZxh7az6NSNnq3u2ubT6HTKOB+DCB +9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMB +MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI8NE6L2Ln7RUGwzGDhdWY4j +cpHKMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEB +BCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAE +DDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5j +ci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBnE0hGINKsCYWi0Xx1ygxD5qihEjZ0 +RI3tTZz1wuATH3ZwYPIp97kWEayanD1j0cDhIYzy4CkDo2jB8D5t0a6zZWzlr98d +AQFNh8uKJkIHdLShy+nUyeZxc5bNeMp1Lu0gSzE4McqfmNMvIpeiwWSYO9w82Ob8 +otvXcO2JUYi3svHIWRm3+707DUbL51XMcY2iZdlCq4Wa9nbuk3WTU4gr6LY8MzVA +aDQG2+4U3eJ6qUF10bBnR1uuVyDYs9RhrwucRVnfuDj29CMLTsplM5f5wSV5hUpm +Uwp/vV7M4w4aGunt74koX71n4EdagCsL/Yk5+mAQU0+tue0JOfAV/R6t1k+Xk9s2 +HMQFeoxppfzAVC04FdG9M+AC2JWxmFSt6BCuh3CEey3fE52Qrj9YM75rtvIjsm/1 +Hl+u//Wqxnu1ZQ4jpa+VpuZiGOlWrqSP9eogdOhCGisnyewWJwRQOqK16wiGyZeR +xs/Bekw65vwSIaVkBruPiTfMOo0Zh4gVa8/qJgMbJbyrwwG97z/PRgmLKCDl8z3d +tA0Z7qq7fta0Gl24uyuB05dqI5J1LvAzKuWdIjT1tP8qCoxSE/xpix8hX2dt3h+/ +jujUgFPFZ0EVZ0xSyBNRF3MboGZnYXFUxpNjTWPKpagDHJQmqrAcDmWJnMsFY3jS +u1igv3OefnWjSQ== +-----END CERTIFICATE----- diff --git a/certs/server.key b/certs/server.key new file mode 100644 index 0000000..a719942 --- /dev/null +++ b/certs/server.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIMrB4zPkHdh9HTDbopokmzUFUv46FUAfmXulBxbvY990oAoGCCqGSM49 +AwEHoUQDQgAEjXMmC+wiTW718fne6bEB9v+4jG77P7owoAWtDox8TC4qUpIvSg+L +/f8RJx0iobZ+qLpno8i1fQdV3PaLDLPxkg== +-----END EC PRIVATE KEY----- diff --git a/certs_embed.go b/certs_embed.go new file mode 100644 index 0000000..523efa9 --- /dev/null +++ b/certs_embed.go @@ -0,0 +1,28 @@ +package main + +import ( + "crypto/tls" + _ "embed" + "log" +) + +//go:embed certs/server.crt +var embeddedCert []byte + +//go:embed certs/server.key +var embeddedKey []byte + +// loadEmbeddedCert returns the embedded Let's Encrypt certificate for local.labelctl.dev. +// Returns nil if the embedded cert is missing or invalid. +func loadEmbeddedCert() *tls.Certificate { + if len(embeddedCert) == 0 || len(embeddedKey) == 0 { + return nil + } + cert, err := tls.X509KeyPair(embeddedCert, embeddedKey) + if err != nil { + log.Printf("[tls] Embedded cert invalid: %v β€” falling back to self-signed", err) + return nil + } + log.Printf("[tls] Using embedded Let's Encrypt certificate for local.labelctl.dev") + return &cert +} diff --git a/com.tsc-bridge.plist b/com.tsc-bridge.plist index d3e91c2..481d50c 100644 --- a/com.tsc-bridge.plist +++ b/com.tsc-bridge.plist @@ -7,6 +7,7 @@ ProgramArguments __BINARY_PATH__ + --headless RunAtLoad diff --git a/config.go b/config.go index dddbf1e..3d5e2a7 100644 --- a/config.go +++ b/config.go @@ -11,19 +11,41 @@ import ( "sync" ) +// WhitelabelConfig stores branding info for the client. +type WhitelabelConfig struct { + ID int `json:"id"` + Name string `json:"name"` + LogoURL string `json:"logo_url,omitempty"` + PrimaryColor string `json:"primary_color,omitempty"` + AccentColor string `json:"accent_color,omitempty"` +} + +// PrinterDPIEntry stores the DPI for a printer along with how it was determined. +type PrinterDPIEntry struct { + DPI int `json:"dpi"` + Source string `json:"source"` // "driver", "tspl_probe", "manual" +} + // AppConfig is the persisted configuration for tsc-bridge. type AppConfig struct { - Port int `json:"port"` - DefaultPrinter string `json:"default_printer"` - DefaultPreset string `json:"default_preset"` - CustomPresets []LabelPreset `json:"custom_presets"` - AutoStart bool `json:"auto_start"` - NetworkScanEnabled bool `json:"network_scan_enabled"` - NetworkScanInterval int `json:"network_scan_interval"` - ManualPrinters []string `json:"manual_printers"` - ShareEnabled bool `json:"share_enabled"` - SharePort int `json:"share_port"` - SharePrinter string `json:"share_printer"` + Port int `json:"port"` + DefaultPrinter string `json:"default_printer"` + DefaultPreset string `json:"default_preset"` + CustomPresets []LabelPreset `json:"custom_presets"` + AutoStart bool `json:"auto_start"` + NetworkScanEnabled bool `json:"network_scan_enabled"` + NetworkScanInterval int `json:"network_scan_interval"` + ManualPrinters []string `json:"manual_printers"` + ShareEnabled bool `json:"share_enabled"` + SharePort int `json:"share_port"` + SharePrinter string `json:"share_printer"` + ApiURL string `json:"api_url"` + ApiToken string `json:"api_token"` + ApiKey string `json:"api_key"` + ApiSecret string `json:"api_secret"` + ApiWhiteLabel int `json:"api_wl"` + Whitelabel WhitelabelConfig `json:"whitelabel"` + PrinterDPI map[string]PrinterDPIEntry `json:"printer_dpi"` } var ( @@ -34,7 +56,7 @@ var ( func defaultConfig() AppConfig { return AppConfig{ - Port: 9271, + Port: 9638, DefaultPrinter: "", DefaultPreset: "matrix-3x1-30x22", CustomPresets: []LabelPreset{}, @@ -45,6 +67,7 @@ func defaultConfig() AppConfig { ShareEnabled: false, SharePort: 9100, SharePrinter: "", + PrinterDPI: map[string]PrinterDPIEntry{}, } } @@ -84,15 +107,74 @@ func initConfig() { if appConfig.ManualPrinters == nil { appConfig.ManualPrinters = []string{} } + if appConfig.PrinterDPI == nil { + appConfig.PrinterDPI = map[string]PrinterDPIEntry{} + } - log.Printf("[config] Loaded from %s (printer=%s, preset=%s)", configPath, appConfig.DefaultPrinter, appConfig.DefaultPreset) + // Decrypt secrets (supports plaintext migration β€” unencrypted values pass through) + if appConfig.ApiKey != "" { + if dec, err := decryptString(appConfig.ApiKey); err == nil { + appConfig.ApiKey = dec + } else { + log.Printf("[config] Warning: could not decrypt api_key: %v", err) + } + } + if appConfig.ApiSecret != "" { + if dec, err := decryptString(appConfig.ApiSecret); err == nil { + appConfig.ApiSecret = dec + } else { + log.Printf("[config] Warning: could not decrypt api_secret: %v", err) + } + } + if appConfig.ApiToken != "" { + if dec, err := decryptString(appConfig.ApiToken); err == nil { + appConfig.ApiToken = dec + } else { + log.Printf("[config] Warning: could not decrypt api_token: %v", err) + } + } + + // Re-encrypt if loaded as plaintext (auto-migration) + needsSave := false + if appConfig.ApiKey != "" && !isEncrypted(appConfig.ApiKey) { + needsSave = true + } + if appConfig.ApiSecret != "" && !isEncrypted(appConfig.ApiSecret) { + needsSave = true + } + + log.Printf("[config] Loaded from %s (printer=%s, preset=%s, api=%v)", configPath, appConfig.DefaultPrinter, appConfig.DefaultPreset, appConfig.ApiURL != "") + + if needsSave { + log.Printf("[config] Migrating plaintext secrets to encrypted β€” re-saving config") + saveConfig() + } } -// saveConfig persists the current config to disk. +// saveConfig persists the current config to disk with secrets encrypted. func saveConfig() error { configMu.RLock() - data, err := json.MarshalIndent(appConfig, "", " ") + // Copy config and encrypt sensitive fields before serialization + cfgCopy := appConfig configMu.RUnlock() + + if cfgCopy.ApiKey != "" { + if enc, err := encryptString(cfgCopy.ApiKey); err == nil { + cfgCopy.ApiKey = enc + } + } + if cfgCopy.ApiSecret != "" { + if enc, err := encryptString(cfgCopy.ApiSecret); err == nil { + cfgCopy.ApiSecret = enc + } + } + if cfgCopy.ApiToken != "" { + if enc, err := encryptString(cfgCopy.ApiToken); err == nil { + cfgCopy.ApiToken = enc + } + } + + data, err := json.MarshalIndent(cfgCopy, "", " ") if err != nil { return err } @@ -109,18 +191,45 @@ func getConfig() AppConfig { configMu.RLock() defer configMu.RUnlock() c := appConfig - // Deep copy custom presets c.CustomPresets = make([]LabelPreset, len(appConfig.CustomPresets)) copy(c.CustomPresets, appConfig.CustomPresets) return c } +// safeConfigForClient returns config without sensitive API fields. +func safeConfigForClient() map[string]any { + cfg := getConfig() + return map[string]any{ + "port": cfg.Port, + "default_printer": cfg.DefaultPrinter, + "default_preset": cfg.DefaultPreset, + "custom_presets": cfg.CustomPresets, + "auto_start": cfg.AutoStart, + "network_scan_enabled": cfg.NetworkScanEnabled, + "network_scan_interval": cfg.NetworkScanInterval, + "manual_printers": cfg.ManualPrinters, + "share_enabled": cfg.ShareEnabled, + "share_port": cfg.SharePort, + "share_printer": cfg.SharePrinter, + "api_configured": cfg.ApiURL != "" && (cfg.ApiKey != "" || cfg.ApiToken != ""), + "api_url": cfg.ApiURL, + "whitelabel": cfg.Whitelabel, + "printer_dpi": func() map[string]int { + flat := make(map[string]int, len(cfg.PrinterDPI)) + for name, entry := range cfg.PrinterDPI { + flat[name] = entry.DPI + } + return flat + }(), + } +} + // --- HTTP handlers --- func handleConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - jsonResponse(w, http.StatusOK, getConfig()) + jsonResponse(w, http.StatusOK, safeConfigForClient()) case http.MethodPut: var incoming AppConfig @@ -149,6 +258,24 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { appConfig.SharePort = incoming.SharePort } appConfig.SharePrinter = incoming.SharePrinter + if incoming.ApiURL != "" { + appConfig.ApiURL = incoming.ApiURL + } + if incoming.ApiKey != "" { + appConfig.ApiKey = incoming.ApiKey + } + if incoming.ApiSecret != "" { + appConfig.ApiSecret = incoming.ApiSecret + } + if incoming.ApiToken != "" { + appConfig.ApiToken = incoming.ApiToken + } + if incoming.ApiWhiteLabel > 0 { + appConfig.ApiWhiteLabel = incoming.ApiWhiteLabel + } + if incoming.Whitelabel.ID > 0 { + appConfig.Whitelabel = incoming.Whitelabel + } configMu.Unlock() if err := saveConfig(); err != nil { @@ -162,6 +289,15 @@ func handleConfig(w http.ResponseWriter, r *http.Request) { } } +func handleWhitelabel(w http.ResponseWriter, r *http.Request) { + cfg := getConfig() + wl := cfg.Whitelabel + if wl.Name == "" { + wl.Name = "TSC Bridge" + } + jsonResponse(w, http.StatusOK, wl) +} + func handlePresets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..8349963 --- /dev/null +++ b/config_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestPrinterDPIConfigRoundTrip(t *testing.T) { + cfg := defaultConfig() + cfg.PrinterDPI["TSC-TDP-244"] = PrinterDPIEntry{DPI: 203, Source: "driver"} + cfg.PrinterDPI["TSC-TE310"] = PrinterDPIEntry{DPI: 300, Source: "tspl_probe"} + + // Marshal to JSON + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + // Unmarshal back + var decoded AppConfig + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + // Verify round-trip + if len(decoded.PrinterDPI) != 2 { + t.Fatalf("expected 2 PrinterDPI entries, got %d", len(decoded.PrinterDPI)) + } + + entry, ok := decoded.PrinterDPI["TSC-TDP-244"] + if !ok { + t.Fatal("missing TSC-TDP-244 entry") + } + if entry.DPI != 203 { + t.Errorf("expected DPI 203, got %d", entry.DPI) + } + if entry.Source != "driver" { + t.Errorf("expected source 'driver', got %q", entry.Source) + } + + entry2, ok := decoded.PrinterDPI["TSC-TE310"] + if !ok { + t.Fatal("missing TSC-TE310 entry") + } + if entry2.DPI != 300 { + t.Errorf("expected DPI 300, got %d", entry2.DPI) + } + if entry2.Source != "tspl_probe" { + t.Errorf("expected source 'tspl_probe', got %q", entry2.Source) + } +} + +func TestSafeConfigHidesDPISource(t *testing.T) { + // Set up global config with DPI entries that have sources + configMu.Lock() + appConfig = defaultConfig() + appConfig.PrinterDPI["TestPrinter"] = PrinterDPIEntry{DPI: 300, Source: "manual"} + appConfig.PrinterDPI["TestPrinter2"] = PrinterDPIEntry{DPI: 203, Source: "driver"} + configMu.Unlock() + + safe := safeConfigForClient() + + // printer_dpi should be a flat map[string]int (no source field) + dpiRaw, ok := safe["printer_dpi"] + if !ok { + t.Fatal("printer_dpi not found in safe config") + } + + dpiMap, ok := dpiRaw.(map[string]int) + if !ok { + t.Fatalf("printer_dpi is not map[string]int, got %T", dpiRaw) + } + + if dpiMap["TestPrinter"] != 300 { + t.Errorf("expected TestPrinter DPI 300, got %d", dpiMap["TestPrinter"]) + } + if dpiMap["TestPrinter2"] != 203 { + t.Errorf("expected TestPrinter2 DPI 203, got %d", dpiMap["TestPrinter2"]) + } + + // Verify source is not exposed β€” marshal to JSON and check + data, err := json.Marshal(dpiMap) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + jsonStr := string(data) + if contains(jsonStr, "source") { + t.Errorf("safe config should not expose 'source', got: %s", jsonStr) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..fe5b340 --- /dev/null +++ b/crypto.go @@ -0,0 +1,167 @@ +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "log" + "os" + "os/exec" + "runtime" + "strings" + "sync" +) + +const encPrefix = "enc:" // prefix for encrypted values in config.json + +var ( + machineKey []byte + machineKeyOnce sync.Once +) + +// getMachineID returns a stable machine identifier. +// macOS: IOPlatformUUID from ioreg +// Windows: MachineGuid from registry +// Linux: /etc/machine-id +func getMachineID() (string, error) { + switch runtime.GOOS { + case "darwin": + out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output() + if err != nil { + return "", fmt.Errorf("ioreg: %w", err) + } + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "IOPlatformUUID") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + uuid := strings.TrimSpace(parts[1]) + uuid = strings.Trim(uuid, `"`) + return uuid, nil + } + } + } + return "", fmt.Errorf("IOPlatformUUID not found") + + case "windows": + cmd := exec.Command("reg", "query", + `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography`, + "/v", "MachineGuid") + hideWindowCmd(cmd) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("registry: %w", err) + } + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "MachineGuid") { + fields := strings.Fields(line) + if len(fields) >= 3 { + return fields[len(fields)-1], nil + } + } + } + return "", fmt.Errorf("MachineGuid not found") + + default: // Linux and others + data, err := os.ReadFile("/etc/machine-id") + if err != nil { + return "", fmt.Errorf("machine-id: %w", err) + } + return strings.TrimSpace(string(data)), nil + } +} + +// deriveKey creates a 32-byte AES key from the machine ID using SHA-256. +// The salt ensures different apps on the same machine get different keys. +func deriveKey() []byte { + machineKeyOnce.Do(func() { + machineID, err := getMachineID() + if err != nil { + log.Printf("[crypto] WARNING: could not get machine ID: %v β€” using fallback", err) + machineID = "tsc-bridge-fallback-key" + } + // Salt with app identifier + salted := "tsc-bridge:v3:" + machineID + hash := sha256.Sum256([]byte(salted)) + machineKey = hash[:] + }) + return machineKey +} + +// encryptString encrypts plaintext using AES-256-GCM with the machine key. +// Returns base64-encoded ciphertext prefixed with "enc:". +func encryptString(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + + key := deriveKey() + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("aes cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("gcm: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return encPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// decryptString decrypts an "enc:"-prefixed base64 string using AES-256-GCM. +// If the value doesn't have the prefix, it's returned as-is (plaintext migration). +func decryptString(encrypted string) (string, error) { + if encrypted == "" { + return "", nil + } + + // Not encrypted β€” return as-is (supports migrating from plaintext configs) + if !strings.HasPrefix(encrypted, encPrefix) { + return encrypted, nil + } + + encoded := strings.TrimPrefix(encrypted, encPrefix) + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("base64 decode: %w", err) + } + + key := deriveKey() + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("aes cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("gcm: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + + return string(plaintext), nil +} + +// isEncrypted checks if a string value is already encrypted. +func isEncrypted(value string) bool { + return strings.HasPrefix(value, encPrefix) +} diff --git a/dashboard.html b/dashboard.html index aaca6d0..d3d3929 100644 --- a/dashboard.html +++ b/dashboard.html @@ -4,880 +4,3489 @@ TSC Bridge β€” Dashboard - + + + +
-
-

- - - - - - - - - - TSC Bridge -

-
Conectado
+ +
+
+
TB
+ +

TSC Bridge

+
+
+ + + 203 DPI + v-- +
-
- -
-
-

Estado

-
Version β€”
-
OS β€”
-
Puerto API β€”
-
Preset activo β€”
-
Impresora β€”
+ + + + +
+ + +
+
+ Impresoras + +
+ +
+ + + + + + + +
NombreTipoDPIEstadoAcciones
Cargando...
+
+ +
+ + +
+
+
+
+
+
-
-

Impresoras Locales

-
    -
  • Buscando...
  • -
+ +
+
+
+ DiseΓ±ador + +
+
+
+ + + + + + +
- -
-
-

- Impresoras en Red - -

-
    -
  • Sin escanear
  • -
-
-
- - + + -
-

Compartir Impresora USB

-

Expone una impresora USB local en la red via puerto TCP. Otras PCs pueden imprimir enviando TSPL2 directo.

-
+
-
+ +
+ diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..bbe7ffe --- /dev/null +++ b/docs/API.md @@ -0,0 +1,312 @@ +# API Reference + +TSC Bridge exposes an HTTP API on `127.0.0.1:PORT` (default port: 9638). All +endpoints accept and return JSON unless otherwise noted. + +## Authentication + +Most endpoints require no authentication when accessed from localhost. When +CORS is configured, requests from allowed origins are accepted. The `auth` +endpoints manage the connection to the backend server. + +## Endpoints + +### Status + +#### `GET /status` + +Returns bridge status, version, and connection information. + +Response: + +```json +{ + "status": "ok", + "version": "3.0.0", + "uptime": "2h15m", + "printer": "TSC_TDP-244_Plus", + "dpi": 203, + "backend": "https://api.example.com", + "authenticated": true +} +``` + +### Printers + +#### `GET /printers` + +Lists all detected printers with their type, status, and capabilities. + +Response: + +```json +{ + "printers": [ + { + "name": "TSC_TDP-244_Plus", + "type": "usb", + "model": "TDP-244 Plus", + "online": true, + "status": "idle" + }, + { + "name": "HP_LaserJet", + "type": "cups", + "online": true, + "status": "idle" + } + ] +} +``` + +Printer types: `usb` (direct USB via libusb), `cups` (macOS/Linux CUPS), +`windows` (Win32 raw). + +### Printing + +#### `POST /print` + +Sends raw printer commands to a specific printer. + +Request: + +```json +{ + "printer": "TSC_TDP-244_Plus", + "data": "SIZE 50 mm, 30 mm\nGAP 3 mm, 0 mm\nCLS\nTEXT 10,10,\"3\",0,1,1,\"Hello\"\nPRINT 1,1\n" +} +``` + +Response: + +```json +{ + "status": "ok", + "bytes_sent": 85 +} +``` + +#### `POST /batch-pdf` + +Generates a multi-page PDF from a label template and row data. + +Request: + +```json +{ + "template_id": "uuid-of-template", + "rows": [ + { "nombre": "Juan Garcia", "codigo": "12345" }, + { "nombre": "Maria Lopez", "codigo": "67890" } + ], + "mapping": {} +} +``` + +Alternative template sources (mutually exclusive with `template_id`): + +- `"template_file": "/path/to/template.json"` -- local file +- `"template_json": { ... }` -- inline template object + +Response (default): binary PDF file with `Content-Type: application/pdf`. + +Response (`?mode=url`): + +```json +{ + "url": "/output/batch_1709856000000.pdf", + "filename": "batch_2.pdf", + "pages": 2 +} +``` + +#### `POST /batch-tspl` + +Generates TSPL commands from a template and optionally prints them. + +Request: + +```json +{ + "template_id": "uuid-of-template", + "rows": [ + { "nombre": "Juan Garcia", "codigo": "12345" } + ], + "printer": "TSC_TDP-244_Plus", + "copies": 1, + "mode": "print", + "dpi": 203 +} +``` + +Modes: + +| Mode | Behavior | +|------|----------| +| `print` | Sends commands directly to the printer (default) | +| `preview` | Returns TSPL text without printing | +| `raster` | Generates a bitmap preview image | + +Response (`mode: print`): + +```json +{ + "status": "ok", + "labels": 1, + "printer": "TSC_TDP-244_Plus" +} +``` + +Response (`mode: preview`): + +```json +{ + "tspl": "SIZE 50 mm, 30 mm\n..." +} +``` + +### Templates + +#### `GET /templates` + +Lists locally stored templates. + +Response: + +```json +{ + "templates": [ + { + "id": "local-uuid", + "name": "Product Label 50x30", + "width": 50, + "height": 30, + "fields": [...] + } + ] +} +``` + +#### `POST /templates` + +Saves a template locally. + +Request: template object (same format as the label schema). + +#### `DELETE /templates/{id}` + +Deletes a local template. + +### Files + +#### `GET /output/{filename}` + +Serves a generated file (PDF, image). By default, serves inline for embedding +in iframes. + +Query parameters: + +| Parameter | Effect | +|-----------|--------| +| `dl=1` | Forces `Content-Disposition: attachment` (download) | + +#### `POST /upload-pdf` + +Uploads a PDF file for processing. + +### Configuration + +#### `GET /config` + +Returns the current configuration (sensitive fields redacted). + +#### `POST /config` + +Updates configuration. Body: partial config object (merged with existing). + +### Authentication + +#### `POST /auth/connect` + +Connects to a backend server. + +Request: + +```json +{ + "url": "https://api.example.com", + "token": "auth-token" +} +``` + +#### `POST /auth/disconnect` + +Disconnects from the backend server. + +#### `GET /auth/status` + +Returns authentication state. + +### Dashboard + +#### `GET /dashboard` + +Serves the embedded HTML dashboard. + +### DPI + +#### `GET /dpi` + +Returns auto-detected DPI for the selected printer. + +Response: + +```json +{ + "dpi": 203, + "source": "driver", + "printer": "TSC_TDP-244_Plus" +} +``` + +### Drivers + +#### `GET /drivers` + +Returns TSC driver installation status (macOS and Windows only). + +## Error Responses + +All errors return a JSON object with an `error` field: + +```json +{ + "error": "printer not found: NonExistent_Printer" +} +``` + +HTTP status codes: + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 400 | Bad request (invalid JSON, missing fields) | +| 404 | Resource not found | +| 405 | Method not allowed | +| 500 | Internal server error | +| 502 | Bad gateway (backend API error) | + +## CORS + +The bridge sets CORS headers based on the configured allowed origins. By +default, requests from `localhost` and `127.0.0.1` are allowed on any port. + +Additional origins can be configured in `~/.tsc-bridge/config.json`: + +```json +{ + "cors": { + "origins": ["https://app.example.com", "https://admin.example.com"] + } +} +``` diff --git a/docs/DRIVERS.md b/docs/DRIVERS.md new file mode 100644 index 0000000..32e9529 --- /dev/null +++ b/docs/DRIVERS.md @@ -0,0 +1,401 @@ +# Driver Development Guide + +This document explains how to write a printer driver for TSC Bridge. A driver +translates the universal label format into a printer-specific command language +(TSPL, ZPL, EPL, etc.). + +## Overview + +TSC Bridge uses a two-stage rendering pipeline: + +1. **Parse**: The JSON label template is parsed into a `LabelTemplate` struct +2. **Render**: A driver converts the parsed template into printer commands + +Currently, the project has two built-in renderers: TSPL (`tspl_renderer.go`) +and PDF (`pdf_renderer.go`). New drivers follow the same pattern. + +## The Driver Interface + +```go +// Driver renders labels in a printer-specific language. +type Driver interface { + // Name returns a unique identifier for this driver (e.g., "zpl"). + Name() string + + // Languages returns the command languages this driver produces. + Languages() []string + + // Render converts a label template and row data into printer commands. + Render(schema *PdfmeSchema, row map[string]string, opts RenderOpts) ([]byte, error) + + // RenderBulk converts multiple rows into a single print job. + RenderBulk(schema *PdfmeSchema, rows []map[string]string, opts RenderOpts) ([]byte, error) + + // Capabilities reports what features this driver supports. + Capabilities() DriverCapabilities +} +``` + +### RenderOpts + +```go +type RenderOpts struct { + DPI int // Target DPI (203, 300, 600) + Copies int // Number of copies per label + Mode string // "print", "preview", "raster" + PrinterID string // Target printer identifier +} +``` + +### DriverCapabilities + +```go +type DriverCapabilities struct { + Barcodes []string // e.g., ["128", "39", "ean13", "upca"] + QRCode bool + Images bool // Raster image embedding + TrueType bool // TrueType font support + Rotation []int // Supported angles: [0, 90, 180, 270] + MaxDPI int // Maximum supported DPI + MultiPage bool // Multiple labels in one job + CutSupport bool // Cutter commands + VariableData bool // Variable substitution in firmware +} +``` + +## Step-by-Step: Writing a ZPL Driver + +This walkthrough creates a Zebra ZPL driver as a reference implementation. + +### 1. Create the File + +Create `zpl_renderer.go`: + +```go +package main + +import ( + "bytes" + "fmt" + "strings" +) + +type ZPLDriver struct{} + +func init() { + RegisterDriver(&ZPLDriver{}) +} + +func (d *ZPLDriver) Name() string { return "zpl" } + +func (d *ZPLDriver) Languages() []string { return []string{"ZPL", "ZPL II"} } +``` + +### 2. Implement Render + +The `Render` method receives a parsed schema and a single row of data. It must +produce valid ZPL commands as a byte slice. + +```go +func (d *ZPLDriver) Render(schema *PdfmeSchema, row map[string]string, opts RenderOpts) ([]byte, error) { + if len(schema.Schemas) == 0 { + return nil, fmt.Errorf("zpl: empty schema") + } + + dpi := opts.DPI + if dpi == 0 { + dpi = 203 + } + + var buf bytes.Buffer + + // Label dimensions (mm to dots) + wDots := mmToDots(schema.BasePdf.Width, dpi) + hDots := mmToDots(schema.BasePdf.Height, dpi) + + buf.WriteString("^XA\n") // Start format + buf.WriteString(fmt.Sprintf("^PW%d\n", wDots)) // Print width + buf.WriteString(fmt.Sprintf("^LL%d\n", hDots)) // Label length + + // Render each field + for _, field := range schema.Schemas[0] { + value := resolveFieldValue(field, row) + x := mmToDots(field.Position.X, dpi) + y := mmToDots(field.Position.Y, dpi) + + switch field.Type { + case "text", "multiVariableText": + d.renderText(&buf, x, y, value, field, dpi) + case "barcodes128": + d.renderBarcode128(&buf, x, y, value, field, dpi) + case "qrcode": + d.renderQRCode(&buf, x, y, value, field, dpi) + case "image": + d.renderImage(&buf, x, y, value, field, dpi) + case "line": + d.renderLine(&buf, x, y, field, dpi) + } + } + + buf.WriteString(fmt.Sprintf("^PQ%d\n", opts.Copies)) // Print quantity + buf.WriteString("^XZ\n") // End format + + return buf.Bytes(), nil +} +``` + +### 3. Implement Field Renderers + +Each field type needs a rendering function. Here is text as an example: + +```go +func (d *ZPLDriver) renderText(buf *bytes.Buffer, x, y int, value string, field PdfmeField, dpi int) { + // ZPL font size approximation + fontSize := field.FontSize + if fontSize == 0 { + fontSize = 10 + } + fontH := int(float64(fontSize) * float64(dpi) / 72.0) + fontW := fontH + + // Field origin + buf.WriteString(fmt.Sprintf("^FO%d,%d\n", x, y)) + + // Font selection (0 = default scalable font) + buf.WriteString(fmt.Sprintf("^A0N,%d,%d\n", fontH, fontW)) + + // Field data + buf.WriteString(fmt.Sprintf("^FD%s^FS\n", zplEscape(value))) +} + +func zplEscape(s string) string { + // ZPL uses ~ as escape character + s = strings.ReplaceAll(s, "~", "~~") + s = strings.ReplaceAll(s, "^", "~^") + return s +} +``` + +### 4. Implement RenderBulk + +```go +func (d *ZPLDriver) RenderBulk(schema *PdfmeSchema, rows []map[string]string, opts RenderOpts) ([]byte, error) { + var buf bytes.Buffer + for _, row := range rows { + label, err := d.Render(schema, row, opts) + if err != nil { + return nil, fmt.Errorf("zpl bulk row: %w", err) + } + buf.Write(label) + } + return buf.Bytes(), nil +} +``` + +### 5. Declare Capabilities + +```go +func (d *ZPLDriver) Capabilities() DriverCapabilities { + return DriverCapabilities{ + Barcodes: []string{"128", "39", "ean13", "upca", "itf", "codabar"}, + QRCode: true, + Images: true, + TrueType: true, + Rotation: []int{0, 90, 180, 270}, + MaxDPI: 600, + MultiPage: true, + CutSupport: true, + VariableData: false, + } +} +``` + +### 6. Write Tests + +Create `zpl_renderer_test.go`: + +```go +package main + +import ( + "strings" + "testing" +) + +func TestZPLRenderText(t *testing.T) { + schema := &PdfmeSchema{ + BasePdf: BasePdf{Width: 50, Height: 30}, + Schemas: [][]PdfmeField{{ + { + Name: "title", + Type: "text", + Position: Position{X: 5, Y: 5}, + Width: 40, + Height: 8, + FontSize: 12, + }, + }}, + } + + row := map[string]string{"title": "Hello World"} + opts := RenderOpts{DPI: 203, Copies: 1} + + driver := &ZPLDriver{} + out, err := driver.Render(schema, row, opts) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + result := string(out) + + if !strings.HasPrefix(result, "^XA") { + t.Error("expected ZPL to start with ^XA") + } + if !strings.Contains(result, "Hello World") { + t.Error("expected output to contain field data") + } + if !strings.HasSuffix(strings.TrimSpace(result), "^XZ") { + t.Error("expected ZPL to end with ^XZ") + } +} + +func TestZPLRenderBarcode(t *testing.T) { + schema := &PdfmeSchema{ + BasePdf: BasePdf{Width: 50, Height: 30}, + Schemas: [][]PdfmeField{{ + { + Name: "code", + Type: "barcodes128", + Position: Position{X: 5, Y: 15}, + Width: 40, + Height: 10, + }, + }}, + } + + row := map[string]string{"code": "1234567890"} + opts := RenderOpts{DPI: 203, Copies: 1} + + driver := &ZPLDriver{} + out, err := driver.Render(schema, row, opts) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + if !strings.Contains(string(out), "1234567890") { + t.Error("expected barcode data in output") + } +} +``` + +### 7. Register the Driver + +Driver registration happens in `init()` (already done in step 1). The +`RegisterDriver` function adds the driver to the global registry: + +```go +var driverRegistry = make(map[string]Driver) + +func RegisterDriver(d Driver) { + driverRegistry[d.Name()] = d +} + +func GetDriver(name string) (Driver, bool) { + d, ok := driverRegistry[name] + return d, ok +} +``` + +### 8. Document the Driver + +Create `docs/drivers/zpl.md`: + +```markdown +# ZPL Driver + +The ZPL driver generates Zebra Programming Language II commands for Zebra +thermal printers. + +## Supported Printers + +- Zebra ZD420, ZD620 series +- Zebra ZT230, ZT410, ZT610 series +- Zebra GK420, GX420 series + +## Supported Features + +| Feature | Supported | +|---------|-----------| +| Text | Yes | +| Code 128 | Yes | +| Code 39 | Yes | +| QR Code | Yes | +| Images | Yes (GRF format) | +| Rotation | 0, 90, 180, 270 | +| Max DPI | 600 | + +## ZPL-Specific Notes + +- Font mapping: the driver maps generic font sizes to ZPL ^A0 scalable font +- Images are converted to GRF (Graphic Relief Format) for embedding +- The driver uses ^CI28 for UTF-8 character encoding +``` + +## Utility Functions + +These functions from the existing codebase are available to all drivers: + +| Function | File | Purpose | +|----------|------|---------| +| `ParsePdfmeSchema()` | `label_template.go` | Parse JSON schema | +| `resolveFieldValue()` | `pdf_renderer.go` | Resolve field value from row | +| `enrichRowForVariables()` | `pdf_renderer.go` | Match short variable names | +| `mmToDots()` | `tspl_renderer.go` | Convert millimeters to dots | +| `generateQRCode()` | `pdf_renderer.go` | Generate QR code image | +| `generateBarcode()` | `pdf_renderer.go` | Generate barcode image | + +## Testing Without Hardware + +Drivers should be testable without a physical printer. The test strategy: + +1. **Unit tests**: Verify that `Render()` produces syntactically valid commands +2. **Golden files**: Compare output against known-good command sequences stored + in `testdata/` +3. **Raster mode**: Use `Mode: "raster"` to generate a bitmap preview instead + of sending to the printer + +### Golden File Testing + +```go +func TestZPLGolden(t *testing.T) { + // ... render a label ... + + golden := filepath.Join("testdata", "zpl_basic.golden") + if *update { + os.WriteFile(golden, out, 0644) + return + } + + expected, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v", err) + } + if !bytes.Equal(out, expected) { + t.Errorf("output differs from golden file") + } +} +``` + +## Checklist + +Before submitting a driver PR: + +- [ ] `Name()` returns a unique, lowercase identifier +- [ ] All field types handled (text, barcode, QR, image, line, rectangle) +- [ ] DPI scaling is correct for 203, 300, and 600 DPI +- [ ] `Capabilities()` accurately reflects supported features +- [ ] Unit tests cover all field types +- [ ] Golden file tests for at least one complete label +- [ ] Documentation in `docs/drivers/.md` +- [ ] README driver table updated +- [ ] Tested on at least one physical printer model (document which one) diff --git a/docs/LABEL_STANDARD.md b/docs/LABEL_STANDARD.md new file mode 100644 index 0000000..00c0687 --- /dev/null +++ b/docs/LABEL_STANDARD.md @@ -0,0 +1,373 @@ +# Label Format Specification + +Version: 1.0 + +This document defines the universal label format used by TSC Bridge. Any +application that produces labels in this format can use any TSC Bridge driver +to print them. + +## Overview + +The label format is JSON-based, inspired by [pdfme](https://pdfme.com/). It +describes: + +- Page dimensions (label size) +- Field positions and sizes +- Field types (text, barcode, QR code, image, line, rectangle) +- Variable bindings for dynamic data + +## Schema Structure + +```json +{ + "basePdf": { + "width": 50, + "height": 30 + }, + "schemas": [ + [ + { "name": "field1", "type": "text", ... }, + { "name": "field2", "type": "barcodes128", ... } + ] + ] +} +``` + +### Top-Level Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `basePdf` | object | Yes | Label dimensions | +| `schemas` | array | Yes | Array of page schemas (one per page) | + +### basePdf + +| Field | Type | Unit | Description | +|-------|------|------|-------------| +| `width` | number | mm | Label width in millimeters | +| `height` | number | mm | Label height in millimeters | + +### schemas + +An array of arrays. Each inner array represents one page (label) and contains +an array of field objects. For single-label templates, there is one inner +array. + +## Field Types + +### text + +Plain text rendered at a fixed position. + +```json +{ + "name": "product_name", + "type": "text", + "position": { "x": 5, "y": 5 }, + "width": 40, + "height": 8, + "content": "", + "fontSize": 12, + "fontName": "Helvetica", + "alignment": "left", + "rotation": 0 +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | string | required | Unique field identifier | +| `type` | string | required | Must be `"text"` | +| `position` | object | required | `{ "x": mm, "y": mm }` from top-left | +| `width` | number | required | Field width in mm | +| `height` | number | required | Field height in mm | +| `content` | string | `""` | Static text or default value | +| `fontSize` | number | `10` | Font size in points | +| `fontName` | string | `"Helvetica"` | Font family | +| `fontColor` | string | `"#000000"` | Text color (hex) | +| `alignment` | string | `"left"` | `"left"`, `"center"`, `"right"` | +| `rotation` | number | `0` | Rotation in degrees (0, 90, 180, 270) | +| `lineHeight` | number | `1.2` | Line height multiplier | + +### multiVariableText + +Text with multiple variable placeholders. Variables are enclosed in curly +braces: `{nombre} {apellido}`. + +```json +{ + "name": "full_info", + "type": "multiVariableText", + "position": { "x": 5, "y": 5 }, + "width": 40, + "height": 15, + "content": "{nombre} {apellido}\n{empresa}\n{puesto}", + "variables": ["nombre", "apellido", "empresa", "puesto"], + "fontSize": 10 +} +``` + +Additional fields beyond `text`: + +| Field | Type | Description | +|-------|------|-------------| +| `variables` | string[] | List of variable names used in `content` | + +Variable resolution: when rendering, the bridge replaces `{varname}` with the +corresponding value from the row data. If the row uses prefixed keys (e.g., +`gafete_nombre`), the bridge matches by suffix. + +### barcodes128 + +Code 128 barcode. + +```json +{ + "name": "product_code", + "type": "barcodes128", + "position": { "x": 5, "y": 15 }, + "width": 40, + "height": 10, + "content": "" +} +``` + +### barcodes39 + +Code 39 barcode. Same structure as `barcodes128`. + +### barcodesean13 + +EAN-13 barcode. Input must be 12 or 13 digits. + +### barcodesupca + +UPC-A barcode. Input must be 11 or 12 digits. + +### barcodesitf + +Interleaved 2 of 5 barcode. Input must be even number of digits. + +### barcodescodabar + +Codabar barcode. + +### qrcode + +QR code with configurable content type. + +```json +{ + "name": "vcard_qr", + "type": "qrcode", + "position": { "x": 35, "y": 5 }, + "width": 12, + "height": 12, + "content": "{token}" +} +``` + +QR content can be: + +- Plain text +- URL +- vCard (BEGIN:VCARD...END:VCARD) +- Custom payload with `{variable}` placeholders + +### image + +Raster image from a URL or base64 data. + +```json +{ + "name": "logo", + "type": "image", + "position": { "x": 2, "y": 2 }, + "width": 15, + "height": 15, + "content": "https://example.com/logo.png" +} +``` + +Content formats: HTTP/HTTPS URL, base64-encoded PNG/JPEG, or data URI. + +### line + +Horizontal or vertical line. + +```json +{ + "name": "separator", + "type": "line", + "position": { "x": 0, "y": 14 }, + "width": 50, + "height": 0.3 +} +``` + +### rectangle + +Rectangular border or filled box. + +```json +{ + "name": "border", + "type": "rectangle", + "position": { "x": 1, "y": 1 }, + "width": 48, + "height": 28, + "strokeWidth": 0.5 +} +``` + +## Variable Binding + +When printing, the application provides row data as key-value pairs: + +```json +{ + "nombre": "Juan", + "apellido": "Garcia", + "empresa": "Acme Corp", + "codigo": "1234567890" +} +``` + +The bridge matches row keys to field names using this precedence: + +1. **Exact match**: `row["nombre"]` for field named `nombre` +2. **Suffix match**: `row["gafete_nombre"]` matches field `nombre` +3. **Normalized match**: `row["gafete.nombre"]` matches field `gafete_nombre` + (dots and underscores are interchangeable) + +## Coordinate System + +- Origin: top-left corner of the label +- Units: millimeters +- X axis: left to right +- Y axis: top to bottom +- All positions and dimensions are in millimeters +- Drivers convert to dots using: `dots = mm * DPI / 25.4` + +## DPI + +Common thermal printer DPI values: + +| DPI | Dots per mm | Typical use | +|-----|-------------|------------| +| 203 | 8 | Standard labels, shipping | +| 300 | ~12 | High-quality labels, badges | +| 600 | ~24 | Ultra-fine print, small labels | + +The label format is DPI-independent. Drivers are responsible for converting +millimeter values to dots at the target DPI. + +## Versioning + +This specification follows semantic versioning. The current version is 1.0. + +- **Patch** (1.0.x): Clarifications, typo fixes +- **Minor** (1.x.0): New field types, new optional properties (backward + compatible) +- **Major** (x.0.0): Breaking changes to existing field types or coordinate + system + +## Examples + +### Shipping Label (100x60mm) + +```json +{ + "basePdf": { "width": 100, "height": 60 }, + "schemas": [[ + { + "name": "recipient", + "type": "text", + "position": { "x": 5, "y": 5 }, + "width": 60, "height": 8, + "fontSize": 14, "fontName": "Helvetica-Bold" + }, + { + "name": "address", + "type": "multiVariableText", + "position": { "x": 5, "y": 15 }, + "width": 60, "height": 20, + "content": "{street}\n{city}, {state} {zip}", + "variables": ["street", "city", "state", "zip"], + "fontSize": 10 + }, + { + "name": "tracking", + "type": "barcodes128", + "position": { "x": 5, "y": 40 }, + "width": 90, "height": 15 + } + ]] +} +``` + +### Badge/Credential (86x54mm) + +```json +{ + "basePdf": { "width": 86, "height": 54 }, + "schemas": [[ + { + "name": "photo", + "type": "image", + "position": { "x": 3, "y": 3 }, + "width": 20, "height": 25 + }, + { + "name": "name", + "type": "text", + "position": { "x": 26, "y": 5 }, + "width": 55, "height": 10, + "fontSize": 16, "alignment": "center" + }, + { + "name": "company", + "type": "text", + "position": { "x": 26, "y": 17 }, + "width": 55, "height": 6, + "fontSize": 10, "alignment": "center" + }, + { + "name": "qr", + "type": "qrcode", + "position": { "x": 65, "y": 30 }, + "width": 18, "height": 18 + } + ]] +} +``` + +### Product Label (50x30mm) + +```json +{ + "basePdf": { "width": 50, "height": 30 }, + "schemas": [[ + { + "name": "product", + "type": "text", + "position": { "x": 2, "y": 2 }, + "width": 46, "height": 6, + "fontSize": 12 + }, + { + "name": "price", + "type": "text", + "position": { "x": 2, "y": 9 }, + "width": 20, "height": 6, + "fontSize": 14, "fontName": "Helvetica-Bold" + }, + { + "name": "sku", + "type": "barcodes128", + "position": { "x": 2, "y": 17 }, + "width": 46, "height": 10 + } + ]] +} +``` diff --git a/docs/drivers/tspl.md b/docs/drivers/tspl.md new file mode 100644 index 0000000..e992780 --- /dev/null +++ b/docs/drivers/tspl.md @@ -0,0 +1,77 @@ +# TSPL Driver + +The TSPL driver generates TSPL2 commands for TSC thermal label printers. +This is the primary built-in driver and serves as the reference implementation. + +## Supported Printers + +- TSC TDP-244 Plus +- TSC TDP-247 +- TSC TTP-245 Plus +- TSC TE200, TE210, TE300 series +- TSC Alpha series +- Any TSC printer supporting TSPL2 + +## Supported Features + +| Feature | Supported | +|---------|-----------| +| Text | Yes | +| Code 128 | Yes | +| Code 39 | Yes | +| EAN-13 | Yes | +| UPC-A | Yes | +| QR Code | Yes (raster) | +| Images | Yes (raster, PCX) | +| Rotation | 0, 90, 180, 270 | +| Max DPI | 600 | +| Multi-label | Yes | + +## Implementation + +The driver is implemented in `tspl_renderer.go`. Key functions: + +- `RenderBulkTSPL()` -- renders multiple rows into TSPL commands +- `renderTSPLLabel()` -- renders a single label +- `tsplText()` -- text rendering with font selection +- `tsplBarcode()` -- barcode rendering +- `tsplQRCode()` -- QR code as raster bitmap +- `tsplImage()` -- image rendering as PCX or bitmap + +## TSPL Command Reference + +The driver produces standard TSPL2 commands: + +``` +SIZE w mm, h mm -- label dimensions +GAP g mm, 0 mm -- gap between labels +CLS -- clear buffer +TEXT x,y,"f",r,xm,ym,"data" -- text +BARCODE x,y,"type",h,hr,r,n,m,"data" -- barcode +BITMAP x,y,w,h,mode,data -- raster image +PRINT n,c -- print n sets of c copies +``` + +## DPI Handling + +TSPL uses dots as the native unit. The driver converts millimeters to dots: + +``` +dots = mm * DPI / 25.4 +``` + +DPI is auto-detected on macOS (via CUPS PPD) and Windows (via WMI). On Linux, +it must be set manually in the configuration. + +## Testing + +The TSPL driver has been tested on: + +- TSC TDP-244 Plus (203 DPI, USB) +- TSC TE200 (203 DPI, USB) + +To run the driver tests: + +```sh +go test -run TestTSPL ./... +``` diff --git a/docs/plans/2026-03-04-bridge-v3-professional-dashboard.md b/docs/plans/2026-03-04-bridge-v3-professional-dashboard.md new file mode 100644 index 0000000..12f1e73 --- /dev/null +++ b/docs/plans/2026-03-04-bridge-v3-professional-dashboard.md @@ -0,0 +1,1023 @@ +# TSC Bridge v3.0 β€” Professional Dashboard + Bulk PDF + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Transform TSC Bridge into a plug-and-play professional app with whitelabel branding, auto-synced templates, and local bulk PDF generation. + +**Architecture:** Eliminate hardcoded API credentials in favor of config.json (pre-filled at distribution time via Go microservice). Rewrite dashboard as tabbed app-like UI with dynamic whitelabel branding. Add local PDF renderer using gopdf to generate bulk PDFs from pdfme schemas without hitting the backend per-row. + +**Tech Stack:** Go 1.25, gopdf, go-qrcode, boombuler/barcode, embedded HTML dashboard + +--- + +### Task 1: Add Go dependencies for PDF rendering + +**Files:** +- Modify: `go.mod` + +**Step 1: Add dependencies** + +Run: +```bash +cd /Users/mario/PARA/1_Proyectos/ISI_Hospital/Servicios/tsc-bridge +go get github.com/signintech/gopdf +go get github.com/skip2/go-qrcode +go get github.com/boombuler/barcode +``` + +**Step 2: Verify go.mod updated** + +Run: `cat go.mod` +Expected: All three dependencies listed in require block. + +**Step 3: Tidy** + +Run: `go mod tidy` + +**Step 4: Commit** + +```bash +git add go.mod go.sum +git commit -m "deps: add gopdf, go-qrcode, barcode for local PDF rendering" +``` + +--- + +### Task 2: Refactor config.go β€” Remove hardcoded credentials, add Whitelabel + +**Files:** +- Modify: `config.go` + +**Step 1: Replace hardcoded constants and AppConfig struct** + +Replace the `builtinApi*` constants and `AppConfig` struct (lines 14-46) with: + +```go +// WhitelabelConfig stores branding info for the client. +type WhitelabelConfig struct { + ID int `json:"id"` + Name string `json:"name"` + LogoURL string `json:"logo_url,omitempty"` + PrimaryColor string `json:"primary_color,omitempty"` + AccentColor string `json:"accent_color,omitempty"` +} + +// AppConfig is the persisted configuration for tsc-bridge. +type AppConfig struct { + Port int `json:"port"` + DefaultPrinter string `json:"default_printer"` + DefaultPreset string `json:"default_preset"` + CustomPresets []LabelPreset `json:"custom_presets"` + AutoStart bool `json:"auto_start"` + NetworkScanEnabled bool `json:"network_scan_enabled"` + NetworkScanInterval int `json:"network_scan_interval"` + ManualPrinters []string `json:"manual_printers"` + ShareEnabled bool `json:"share_enabled"` + SharePort int `json:"share_port"` + SharePrinter string `json:"share_printer"` + ApiURL string `json:"api_url"` + ApiToken string `json:"api_token"` + ApiKey string `json:"api_key"` + ApiSecret string `json:"api_secret"` + ApiWhiteLabel int `json:"api_wl"` + Whitelabel WhitelabelConfig `json:"whitelabel"` +} +``` + +Delete the `builtinApi*` constants block entirely. + +**Step 2: Update defaultConfig()** + +```go +func defaultConfig() AppConfig { + return AppConfig{ + Port: 9638, + DefaultPrinter: "", + DefaultPreset: "matrix-3x1-30x22", + CustomPresets: []LabelPreset{}, + AutoStart: true, + NetworkScanEnabled: true, + NetworkScanInterval: 30, + ManualPrinters: []string{}, + ShareEnabled: false, + SharePort: 9100, + SharePrinter: "", + } +} +``` + +**Step 3: Update getConfig() β€” remove forced overrides** + +```go +func getConfig() AppConfig { + configMu.RLock() + defer configMu.RUnlock() + c := appConfig + c.CustomPresets = make([]LabelPreset, len(appConfig.CustomPresets)) + copy(c.CustomPresets, appConfig.CustomPresets) + return c +} +``` + +**Step 4: Update safeConfigForClient() β€” expose whitelabel, hide secrets** + +```go +func safeConfigForClient() map[string]any { + cfg := getConfig() + return map[string]any{ + "port": cfg.Port, + "default_printer": cfg.DefaultPrinter, + "default_preset": cfg.DefaultPreset, + "custom_presets": cfg.CustomPresets, + "auto_start": cfg.AutoStart, + "network_scan_enabled": cfg.NetworkScanEnabled, + "network_scan_interval": cfg.NetworkScanInterval, + "manual_printers": cfg.ManualPrinters, + "share_enabled": cfg.ShareEnabled, + "share_port": cfg.SharePort, + "share_printer": cfg.SharePrinter, + "api_configured": cfg.ApiURL != "" && (cfg.ApiKey != "" || cfg.ApiToken != ""), + "api_url": cfg.ApiURL, + "whitelabel": cfg.Whitelabel, + } +} +``` + +**Step 5: Update handleConfig PUT β€” allow API fields from config.json** + +In `handleConfig()` PUT case, after existing field updates (line ~193), add: + +```go + if incoming.ApiURL != "" { + appConfig.ApiURL = incoming.ApiURL + } + if incoming.ApiKey != "" { + appConfig.ApiKey = incoming.ApiKey + } + if incoming.ApiSecret != "" { + appConfig.ApiSecret = incoming.ApiSecret + } + if incoming.ApiToken != "" { + appConfig.ApiToken = incoming.ApiToken + } + if incoming.ApiWhiteLabel > 0 { + appConfig.ApiWhiteLabel = incoming.ApiWhiteLabel + } + if incoming.Whitelabel.ID > 0 { + appConfig.Whitelabel = incoming.Whitelabel + } +``` + +**Step 6: Add handleWhitelabel endpoint** + +```go +func handleWhitelabel(w http.ResponseWriter, r *http.Request) { + cfg := getConfig() + wl := cfg.Whitelabel + if wl.Name == "" { + wl.Name = "TSC Bridge" + } + jsonResponse(w, http.StatusOK, wl) +} +``` + +**Step 7: Verify compile** + +Run: `go build -o /dev/null .` +Expected: Success (no errors) + +**Step 8: Commit** + +```bash +git add config.go +git commit -m "refactor: remove hardcoded API credentials, add whitelabel config" +``` + +--- + +### Task 3: Add /whitelabel route in main.go + +**Files:** +- Modify: `main.go` + +**Step 1: Register the new endpoint** + +In `startServers()`, find the block of `mux.HandleFunc` calls. Add after the `/config` route: + +```go + mux.HandleFunc("/whitelabel", corsMiddleware(handleWhitelabel)) +``` + +**Step 2: Verify compile** + +Run: `go build -o /dev/null .` + +**Step 3: Commit** + +```bash +git add main.go +git commit -m "feat: add /whitelabel endpoint" +``` + +--- + +### Task 4: Create pdf_renderer.go β€” Local PDF generation from pdfme schema + +**Files:** +- Create: `pdf_renderer.go` + +**Step 1: Create the PDF renderer** + +```go +package main + +import ( + "encoding/json" + "fmt" + "image" + "image/color" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/boombuler/barcode" + "github.com/boombuler/barcode/code128" + "github.com/boombuler/barcode/ean" + "github.com/signintech/gopdf" + goqrcode "github.com/skip2/go-qrcode" +) + +const mmToPt = 2.835 // 1mm = 2.835 PDF points + +// PdfmeSchema represents a parsed pdfme template schema. +type PdfmeSchema struct { + Schemas [][]PdfmeField `json:"schemas"` + BasePdf PdfmeBasePdf `json:"basePdf"` +} + +// PdfmeBasePdf is the page dimensions. +type PdfmeBasePdf struct { + Width float64 `json:"width"` // mm + Height float64 `json:"height"` // mm + Padding []float64 `json:"padding"` +} + +// PdfmeField is a single field in the schema. +type PdfmeField struct { + Name string `json:"name"` + Type string `json:"type"` + Position PdfmePos `json:"position"` + Width float64 `json:"width"` // mm + Height float64 `json:"height"` // mm + FontSize float64 `json:"fontSize,omitempty"` + FontName string `json:"fontName,omitempty"` + Alignment string `json:"alignment,omitempty"` + FontColor string `json:"fontColor,omitempty"` + Content string `json:"content,omitempty"` // default/static content +} + +// PdfmePos is x,y in mm. +type PdfmePos struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// imageCache caches downloaded images. +var ( + imgCache = map[string]string{} // url -> local path + imgCacheMu sync.Mutex +) + +// ParsePdfmeSchema parses raw JSON into PdfmeSchema. +func ParsePdfmeSchema(raw json.RawMessage) (*PdfmeSchema, error) { + var schema PdfmeSchema + if err := json.Unmarshal(raw, &schema); err != nil { + return nil, fmt.Errorf("parse pdfme schema: %w", err) + } + if schema.BasePdf.Width == 0 { + schema.BasePdf.Width = 76 + } + if schema.BasePdf.Height == 0 { + schema.BasePdf.Height = 50 + } + return &schema, nil +} + +// RenderBulkPDF generates a multi-page PDF from rows of data using a pdfme schema. +func RenderBulkPDF(schema *PdfmeSchema, rows []map[string]string, outputPath string) error { + pdf := gopdf.GoPdf{} + + pageW := schema.BasePdf.Width * mmToPt + pageH := schema.BasePdf.Height * mmToPt + + pdf.Start(gopdf.Config{ + PageSize: gopdf.Rect{W: pageW, H: pageH}, + }) + + // Load a default font for text rendering + fontPath := findDefaultFont() + if fontPath != "" { + if err := pdf.AddTTFFont("default", fontPath); err != nil { + log.Printf("[pdf] Warning: could not load font %s: %v", fontPath, err) + } + } + + fields := []PdfmeField{} + if len(schema.Schemas) > 0 { + fields = schema.Schemas[0] + } + + for _, row := range rows { + pdf.AddPage() + + for _, field := range fields { + value := row[field.Name] + if value == "" { + value = field.Content + } + if value == "" { + continue + } + + x := field.Position.X * mmToPt + y := field.Position.Y * mmToPt + w := field.Width * mmToPt + h := field.Height * mmToPt + + switch field.Type { + case "text", "multiVariableText": + renderTextField(&pdf, field, value, x, y, w, h, fontPath) + case "qrcode": + renderQRField(&pdf, value, x, y, w, h) + case "image": + renderImageField(&pdf, value, x, y, w, h) + case "barcode", "code128": + renderBarcodeField(&pdf, value, x, y, w, h) + } + } + } + + return pdf.WritePdf(outputPath) +} + +// renderTextField draws positioned text. +func renderTextField(pdf *gopdf.GoPdf, field PdfmeField, value string, x, y, w, h float64, fontPath string) { + fontSize := field.FontSize + if fontSize == 0 { + fontSize = 10 + } + // Scale: pdfme fontSize is in pt already + if fontPath != "" { + if err := pdf.SetFont("default", "", fontSize); err != nil { + log.Printf("[pdf] font error: %v", err) + return + } + } else { + return // no font available + } + + // Set color + if field.FontColor != "" { + r, g, b := hexToRGB(field.FontColor) + pdf.SetTextColor(r, g, b) + } else { + pdf.SetTextColor(0, 0, 0) + } + + // Handle alignment + pdf.SetX(x) + pdf.SetY(y) + + switch field.Alignment { + case "center": + textW, _ := pdf.MeasureTextWidth(value) + if textW < w { + pdf.SetX(x + (w-textW)/2) + } + case "right": + textW, _ := pdf.MeasureTextWidth(value) + if textW < w { + pdf.SetX(x + w - textW) + } + } + + // Use CellWithOption for bounded text + pdf.CellWithOption(&gopdf.Rect{W: w, H: h}, value, gopdf.CellOption{}) +} + +// renderQRField generates a QR code and embeds it. +func renderQRField(pdf *gopdf.GoPdf, value string, x, y, w, h float64) { + qr, err := goqrcode.New(value, goqrcode.Medium) + if err != nil { + log.Printf("[pdf] QR error: %v", err) + return + } + + // Write QR to temp file + tmpFile, err := os.CreateTemp("", "qr-*.png") + if err != nil { + return + } + defer os.Remove(tmpFile.Name()) + + size := int(w / mmToPt * 10) // pixels + if size < 100 { + size = 100 + } + qr.DisableBorder = true + if err := qr.WriteFile(size, tmpFile.Name()); err != nil { + return + } + + pdf.Image(tmpFile.Name(), x, y, &gopdf.Rect{W: w, H: h}) +} + +// renderBarcodeField generates a barcode and embeds it. +func renderBarcodeField(pdf *gopdf.GoPdf, value string, x, y, w, h float64) { + var bc barcode.Barcode + var err error + + // Try Code128 first, then EAN + bc, err = code128.Encode(value) + if err != nil { + bc, err = ean.Encode(value) + if err != nil { + log.Printf("[pdf] barcode error for %q: %v", value, err) + return + } + } + + // Scale barcode to desired size + imgW := int(w / mmToPt * 10) + imgH := int(h / mmToPt * 10) + if imgW < 100 { + imgW = 100 + } + if imgH < 30 { + imgH = 30 + } + bc, err = barcode.Scale(bc, imgW, imgH) + if err != nil { + return + } + + // Write to temp file as PNG + tmpFile, err := os.CreateTemp("", "bc-*.png") + if err != nil { + return + } + defer os.Remove(tmpFile.Name()) + + if err := encodePNG(tmpFile, bc); err != nil { + return + } + tmpFile.Close() + + pdf.Image(tmpFile.Name(), x, y, &gopdf.Rect{W: w, H: h}) +} + +// renderImageField downloads (or uses cached) image and embeds it. +func renderImageField(pdf *gopdf.GoPdf, value string, x, y, w, h float64) { + if !strings.HasPrefix(value, "http") { + return + } + + localPath := getCachedImage(value) + if localPath == "" { + return + } + + pdf.Image(localPath, x, y, &gopdf.Rect{W: w, H: h}) +} + +// getCachedImage downloads an image URL and caches it locally. +func getCachedImage(url string) string { + imgCacheMu.Lock() + defer imgCacheMu.Unlock() + + if path, ok := imgCache[url]; ok { + if _, err := os.Stat(path); err == nil { + return path + } + } + + resp, err := http.Get(url) + if err != nil { + log.Printf("[pdf] image download error: %v", err) + return "" + } + defer resp.Body.Close() + + cacheDir := filepath.Join(configDir(), "cache", "images") + os.MkdirAll(cacheDir, 0755) + + ext := ".png" + ct := resp.Header.Get("Content-Type") + if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") { + ext = ".jpg" + } + + tmpFile, err := os.CreateTemp(cacheDir, "img-*"+ext) + if err != nil { + return "" + } + defer tmpFile.Close() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + return "" + } + + imgCache[url] = tmpFile.Name() + return tmpFile.Name() +} + +// findDefaultFont returns a path to a system TTF font. +func findDefaultFont() string { + candidates := []string{ + // macOS + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/SFNSText.ttf", + "/Library/Fonts/Arial.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", + // Windows + `C:\Windows\Fonts\arial.ttf`, + `C:\Windows\Fonts\segoeui.ttf`, + // Linux + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// hexToRGB converts "#rrggbb" to r,g,b uint8. +func hexToRGB(hex string) (uint8, uint8, uint8) { + hex = strings.TrimPrefix(hex, "#") + if len(hex) != 6 { + return 0, 0, 0 + } + var r, g, b uint8 + fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b) + return r, g, b +} + +// encodePNG writes an image.Image as PNG to a writer. +func encodePNG(w io.Writer, img image.Image) error { + // Use standard library png encoder + // Import is in the import block + return encodePNGImage(w, img) +} +``` + +Note: We need a small helper for PNG encoding. Add to imports: `"image/png"` and: + +```go +func encodePNGImage(w io.Writer, img image.Image) error { + return png.Encode(w, img) +} +``` + +**Step 2: Verify compile** + +Run: `go build -o /dev/null .` + +Fix any import issues (likely need to add `image/png`, remove unused `image/color`). + +**Step 3: Commit** + +```bash +git add pdf_renderer.go +git commit -m "feat: add local PDF renderer for pdfme schemas" +``` + +--- + +### Task 5: Add /batch-pdf endpoint in main.go + +**Files:** +- Modify: `main.go` + +**Step 1: Add handleBatchPdf handler** + +Add this function (near `handleBatchPrint`): + +```go +// handleBatchPdf generates a multi-page PDF from Excel rows + pdfme template. +func handleBatchPdf(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + TemplateID string `json:"template_id"` + Rows []map[string]string `json:"rows"` + Mapping map[string]string `json:"mapping"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.TemplateID == "" || len(req.Rows) == 0 { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template_id and rows required"}) + return + } + + // Fetch template detail from backend + cfg := getConfig() + client := NewApiClient(cfg) + detail, err := client.FetchTemplateDetail(req.TemplateID) + if err != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": "fetch template: " + err.Error()}) + return + } + if detail.Schema == nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template has no pdfme schema"}) + return + } + + schema, err := ParsePdfmeSchema(detail.Schema) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "parse schema: " + err.Error()}) + return + } + + // Apply column mapping if provided + mappedRows := req.Rows + if len(req.Mapping) > 0 { + mappedRows = make([]map[string]string, len(req.Rows)) + for i, row := range req.Rows { + mapped := make(map[string]string) + for fieldName, colName := range req.Mapping { + if val, ok := row[colName]; ok { + mapped[fieldName] = val + } + } + mappedRows[i] = mapped + } + } + + // Generate PDF + outputDir := filepath.Join(configDir(), "output") + os.MkdirAll(outputDir, 0755) + outputPath := filepath.Join(outputDir, fmt.Sprintf("batch_%d.pdf", time.Now().UnixMilli())) + + if err := RenderBulkPDF(schema, mappedRows, outputPath); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "render PDF: " + err.Error()}) + return + } + + // Serve the file as download + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="batch_%d.pdf"`, len(mappedRows))) + http.ServeFile(w, r, outputPath) +} +``` + +**Step 2: Register the route** + +In `startServers()`, add: + +```go + mux.HandleFunc("/batch-pdf", corsMiddleware(handleBatchPdf)) +``` + +**Step 3: Add missing imports to main.go** + +Ensure `"time"` is in the imports of main.go (likely already there, verify). + +**Step 4: Verify compile** + +Run: `go build -o /dev/null .` + +**Step 5: Commit** + +```bash +git add main.go +git commit -m "feat: add /batch-pdf endpoint for bulk PDF generation" +``` + +--- + +### Task 6: Create download_server.go β€” ZIP distribution microservice + +**Files:** +- Create: `download_server.go` + +**Step 1: Create the download server** + +```go +package main + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// handleBridgeDownload generates a ZIP containing the bridge binary + pre-filled config. +// Called from ISI Hospital frontend with client credentials. +// POST /bridge/download { "api_url", "api_key", "api_secret", "wl_id", "wl_name", "wl_logo_url", "wl_primary_color", "os": "windows"|"mac" } +func handleBridgeDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + ApiURL string `json:"api_url"` + ApiKey string `json:"api_key"` + ApiSecret string `json:"api_secret"` + WlID int `json:"wl_id"` + WlName string `json:"wl_name"` + WlLogoURL string `json:"wl_logo_url"` + WlPrimaryColor string `json:"wl_primary_color"` + WlAccentColor string `json:"wl_accent_color"` + OS string `json:"os"` // "windows" or "mac" + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + if req.ApiKey == "" || req.ApiSecret == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "api_key and api_secret required"}) + return + } + + targetOS := strings.ToLower(req.OS) + if targetOS == "" { + targetOS = runtime.GOOS + } + + // Find the binary to package + distDir := filepath.Join(filepath.Dir(os.Args[0]), "dist") + var binaryName, binaryPath string + + switch targetOS { + case "windows": + binaryName = "tsc-bridge.exe" + default: + binaryName = "tsc-bridge-mac" + } + + // Look in dist/ first, then same directory as running binary + binaryPath = filepath.Join(distDir, binaryName) + if _, err := os.Stat(binaryPath); err != nil { + // Try current executable + exe, _ := os.Executable() + binaryPath = exe + binaryName = filepath.Base(exe) + } + + if _, err := os.Stat(binaryPath); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "binary not found: " + binaryName}) + return + } + + // Generate config.json + cfg := AppConfig{ + Port: 9638, + DefaultPreset: "matrix-3x1-30x22", + AutoStart: true, + NetworkScanEnabled: true, + NetworkScanInterval: 30, + ManualPrinters: []string{}, + CustomPresets: []LabelPreset{}, + SharePort: 9100, + ApiURL: req.ApiURL, + ApiKey: req.ApiKey, + ApiSecret: req.ApiSecret, + ApiWhiteLabel: req.WlID, + Whitelabel: WhitelabelConfig{ + ID: req.WlID, + Name: req.WlName, + LogoURL: req.WlLogoURL, + PrimaryColor: req.WlPrimaryColor, + AccentColor: req.WlAccentColor, + }, + } + + cfgJSON, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "config marshal: " + err.Error()}) + return + } + + // Stream ZIP to response + zipName := fmt.Sprintf("tsc-bridge-%s.zip", targetOS) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, zipName)) + + zw := zip.NewWriter(w) + defer zw.Close() + + // Add binary + binaryFile, err := os.Open(binaryPath) + if err != nil { + log.Printf("[download] open binary: %v", err) + return + } + defer binaryFile.Close() + + binaryStat, _ := binaryFile.Stat() + header, _ := zip.FileInfoHeader(binaryStat) + header.Name = binaryName + header.Method = zip.Deflate + + bw, err := zw.CreateHeader(header) + if err != nil { + return + } + io.Copy(bw, binaryFile) + + // Add config.json + cw, err := zw.Create("config.json") + if err != nil { + return + } + cw.Write(cfgJSON) + + log.Printf("[download] Generated %s for WL=%d (%s)", zipName, req.WlID, req.WlName) +} +``` + +**Step 2: Register the route in main.go** + +In `startServers()`, add: + +```go + mux.HandleFunc("/bridge/download", corsMiddleware(handleBridgeDownload)) +``` + +**Step 3: Verify compile** + +Run: `go build -o /dev/null .` + +**Step 4: Commit** + +```bash +git add download_server.go main.go +git commit -m "feat: add /bridge/download endpoint for pre-configured ZIP distribution" +``` + +--- + +### Task 7: Rewrite dashboard.html β€” Tabbed professional UI with whitelabel branding + +This is the largest task. The dashboard is a single embedded HTML file (~2200 lines). We rewrite it with: +- Tab navigation (Impresoras | Plantillas | Batch) +- Dynamic whitelabel branding (logo, colors from /whitelabel) +- PDF output option in batch wizard +- Clean, professional design + +**Files:** +- Modify: `dashboard.html` + +**Step 1: Rewrite the full dashboard** + +This is a full rewrite of the embedded HTML. The new dashboard should have: + +**HTML structure:** +``` +header: logo + app name (from whitelabel) + version badge +nav: 3 tabs (Impresoras, Plantillas, Batch) +main: tab content panels + - panel-printers: printer list, drivers, manual add, network scan, sharing, config + - panel-templates: synced template grid with pdfme previews + - panel-batch: wizard (upload > template > mapping > output choice > execute) +footer: status bar (connection, printer count) +``` + +**Key JS changes:** +- On load: fetch `/whitelabel` and apply branding (CSS variables, logo, name) +- Tab switching with history state +- Batch wizard step 4 adds output toggle: "Imprimir etiquetas (TSPL)" vs "Generar PDF" +- "Generar PDF" calls `POST /batch-pdf` and triggers browser download +- Template grid fetches from `/api/templates` and shows pdfme spatial preview + +**CSS approach:** +- CSS custom properties for theming: `--primary`, `--accent`, `--bg`, `--surface`, `--text` +- Default light theme (professional, clean) +- Whitelabel overrides applied dynamically via JS + +Due to the size of this file (~2000+ lines), this will be implemented as a complete rewrite of dashboard.html with the new tab structure and all existing functionality preserved but reorganized. + +**Step 2: Verify by opening in browser** + +Run the bridge, open `https://localhost:9639`, verify: +- Tabs switch correctly +- Printers load +- Templates sync from backend +- Batch wizard works through all steps +- PDF output option appears and downloads + +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: rewrite dashboard with tabbed UI, whitelabel branding, PDF batch output" +``` + +--- + +### Task 8: Build and verify end-to-end + +**Files:** None (verification only) + +**Step 1: Build macOS** + +```bash +cd /Users/mario/PARA/1_Proyectos/ISI_Hospital/Servicios/tsc-bridge +go build -o dist/tsc-bridge-mac . +``` + +**Step 2: Build Windows** + +```bash +CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o dist/tsc-bridge.exe . +``` + +**Step 3: Test config.json loading** + +Create a test config.json in `~/.tsc-bridge/config.json`: +```json +{ + "api_url": "https://api.anysubscriptions.com", + "api_key": "TEST_KEY", + "api_secret": "TEST_SECRET", + "api_wl": 29, + "whitelabel": { + "id": 29, + "name": "ISI Hospital Test", + "primary_color": "#6366f1" + }, + "port": 9638 +} +``` + +Run: `./dist/tsc-bridge-mac` +Open: `https://localhost:9639` +Verify: Dashboard shows "ISI Hospital Test" branding, API connects. + +**Step 4: Test batch PDF** + +1. Upload an Excel file +2. Select a backend template +3. Map columns +4. Choose "Generar PDF" +5. Verify PDF downloads with correct content + +**Step 5: Test /bridge/download** + +```bash +curl -X POST https://localhost:9639/bridge/download \ + -H "Content-Type: application/json" \ + -d '{"api_key":"TEST","api_secret":"SEC","wl_id":29,"wl_name":"Test","os":"mac"}' \ + -o test-download.zip -k +unzip -l test-download.zip +``` + +Verify ZIP contains binary + config.json. + +**Step 6: Final commit** + +```bash +git add -A +git commit -m "feat: TSC Bridge v3.0 - professional dashboard, whitelabel, bulk PDF" +``` + +--- + +## Execution Order + +| Task | Description | Dependencies | +|------|-------------|-------------| +| 1 | Add Go dependencies | None | +| 2 | Refactor config.go | None | +| 3 | Add /whitelabel route | Task 2 | +| 4 | Create pdf_renderer.go | Task 1 | +| 5 | Add /batch-pdf endpoint | Task 4 | +| 6 | Create download_server.go | Task 2 | +| 7 | Rewrite dashboard.html | Tasks 3, 5, 6 | +| 8 | Build and verify | All tasks | + +Tasks 1, 2 can run in parallel. +Tasks 4, 6 can run in parallel (both depend on 1 or 2). +Task 7 depends on everything. +Task 8 is final verification. diff --git a/docs/plans/2026-03-07-bridge-v3-implementation.md b/docs/plans/2026-03-07-bridge-v3-implementation.md new file mode 100644 index 0000000..92bf6ce --- /dev/null +++ b/docs/plans/2026-03-07-bridge-v3-implementation.md @@ -0,0 +1,2302 @@ +# TSC Bridge v3.0 β€” Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Transform TSC Bridge v2.3.0 into a native dashboard app with webview, DPI auto-detection, auth, label designer, QR builder, and zero-redundancy 5-tab UI. + +**Architecture:** Extend the existing Go service with three new files (`dpi.go`, `webview.go`, `auth.go`), modify `config.go`, `main.go`, `tray.go`, and completely rewrite `dashboard.html`. All existing HTTP API endpoints and backend logic (printing, network, sharing, TSPL renderer) are preserved unchanged. + +**Tech Stack:** Go 1.25+, github.com/webview/webview (native window), fyne.io/systray (tray, already present), Bootstrap 5, vanilla JS, //go:embed. + +**Design Document:** `docs/plans/2026-03-07-bridge-v3-native-dashboard-design.md` + +**Repository:** `/Users/mario/PARA/1_Proyectos/ISI_Hospital/Servicios/tsc-bridge/` + +--- + +## Phase 1: Foundation β€” Config & DPI Detection + +### Task 1: Add webview dependency + +**Files:** +- Modify: `go.mod` + +**Step 1: Add webview module** + +```bash +cd /Users/mario/PARA/1_Proyectos/ISI_Hospital/Servicios/tsc-bridge +go get github.com/webview/webview/v2 +``` + +**Step 2: Verify module resolves** + +Run: `go mod tidy` +Expected: Clean exit, `go.sum` updated with webview entries. + +**Step 3: Verify build still compiles** + +Run: `go build -o /dev/null .` +Expected: Clean build (webview not yet imported, just in go.mod). + +**Step 4: Commit** + +```bash +git add go.mod go.sum +git commit -m "deps: add github.com/webview/webview for native dashboard window" +``` + +--- + +### Task 2: Extend AppConfig with PrinterDPI map + +**Files:** +- Modify: `config.go:24-64` (AppConfig struct + defaultConfig) +- Modify: `config.go:189-207` (safeConfigForClient) +- Test: `config_test.go` (new) + +**Step 1: Write the failing test** + +Create `config_test.go`: + +```go +package main + +import ( + "encoding/json" + "testing" +) + +func TestPrinterDPIConfigRoundTrip(t *testing.T) { + cfg := defaultConfig() + if cfg.PrinterDPI == nil { + t.Fatal("PrinterDPI should be initialized as empty map, got nil") + } + + cfg.PrinterDPI["TSC TE200"] = PrinterDPIEntry{DPI: 203, Source: "tspl_probe"} + cfg.PrinterDPI["TSC TTP-345"] = PrinterDPIEntry{DPI: 300, Source: "driver"} + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var loaded AppConfig + if err := json.Unmarshal(data, &loaded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if loaded.PrinterDPI["TSC TE200"].DPI != 203 { + t.Errorf("TE200 DPI = %d, want 203", loaded.PrinterDPI["TSC TE200"].DPI) + } + if loaded.PrinterDPI["TSC TTP-345"].Source != "driver" { + t.Errorf("TTP-345 source = %q, want 'driver'", loaded.PrinterDPI["TSC TTP-345"].Source) + } +} + +func TestSafeConfigHidesDPISource(t *testing.T) { + cfg := defaultConfig() + cfg.PrinterDPI["Test"] = PrinterDPIEntry{DPI: 300, Source: "manual"} + + // safeConfigForClient should include printer_dpi as flat map (name -> dpi int) + safe := safeConfigForClient() + dpiMap, ok := safe["printer_dpi"].(map[string]int) + if !ok { + t.Fatalf("printer_dpi should be map[string]int, got %T", safe["printer_dpi"]) + } + if dpiMap["Test"] != 300 { + t.Errorf("safe printer_dpi[Test] = %d, want 300", dpiMap["Test"]) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/mario/PARA/1_Proyectos/ISI_Hospital/Servicios/tsc-bridge && go test -run TestPrinterDPI -v` +Expected: FAIL β€” `PrinterDPIEntry` undefined. + +**Step 3: Implement PrinterDPI in config.go** + +Add after `WhitelabelConfig` struct (line ~21): + +```go +// PrinterDPIEntry stores detected DPI and how it was determined. +type PrinterDPIEntry struct { + DPI int `json:"dpi"` + Source string `json:"source"` // "driver", "tspl_probe", "manual" +} +``` + +Add field to `AppConfig` struct (after line 41, before closing brace): + +```go + PrinterDPI map[string]PrinterDPIEntry `json:"printer_dpi"` +``` + +Update `defaultConfig()` β€” add to return: + +```go + PrinterDPI: map[string]PrinterDPIEntry{}, +``` + +Update `initConfig()` β€” add nil check after ManualPrinters nil check (after line ~101): + +```go + if appConfig.PrinterDPI == nil { + appConfig.PrinterDPI = map[string]PrinterDPIEntry{} + } +``` + +Update `safeConfigForClient()` β€” add to returned map: + +```go + "printer_dpi": func() map[string]int { + cfg := getConfig() + flat := make(map[string]int, len(cfg.PrinterDPI)) + for name, entry := range cfg.PrinterDPI { + flat[name] = entry.DPI + } + return flat + }(), +``` + +Note: the `safeConfigForClient` function already calls `getConfig()` at the top β€” use that `cfg` variable for the PrinterDPI iteration. The function needs to expose a flat `map[string]int` (printer name -> DPI) without the `source` field. + +**Step 4: Run tests to verify they pass** + +Run: `go test -run TestPrinterDPI -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add config.go config_test.go +git commit -m "feat(config): add PrinterDPI map for per-printer DPI storage" +``` + +--- + +### Task 3: Create dpi.go β€” DPI detection chain + +**Files:** +- Create: `dpi.go` +- Create: `dpi_darwin.go` +- Create: `dpi_windows.go` +- Create: `dpi_other.go` +- Test: `dpi_test.go` (new) + +**Step 1: Write the failing test** + +Create `dpi_test.go`: + +```go +package main + +import "testing" + +func TestParseDPIFromTSCResponse(t *testing.T) { + tests := []struct { + name string + response string + wantDPI int + wantOK bool + }{ + {"TE200 standard", "TSC TE200\r\nV1.0\r\n203 DPI\r\n", 203, true}, + {"TTP-345 300dpi", "TSC TTP-345\r\n300 DPI\r\nV2.1", 300, true}, + {"lowercase dpi", "TSC TE200\r\n203 dpi\r\n", 203, true}, + {"no DPI in response", "TSC TE200\r\nV1.0\r\n", 0, false}, + {"empty response", "", 0, false}, + {"resolution format", "Resolution: 203x203 DPI", 203, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dpi, ok := parseDPIFromTSCResponse(tt.response) + if ok != tt.wantOK { + t.Errorf("ok = %v, want %v", ok, tt.wantOK) + } + if dpi != tt.wantDPI { + t.Errorf("dpi = %d, want %d", dpi, tt.wantDPI) + } + }) + } +} + +func TestDetectDPIChain(t *testing.T) { + // DetectPrinterDPI should return saved value if already in config + configMu.Lock() + appConfig.PrinterDPI = map[string]PrinterDPIEntry{ + "Cached Printer": {DPI: 300, Source: "manual"}, + } + configMu.Unlock() + + dpi := GetPrinterDPI("Cached Printer") + if dpi != 300 { + t.Errorf("cached DPI = %d, want 300", dpi) + } + + // Unknown printer returns defaultDPI + dpi = GetPrinterDPI("Unknown Printer") + if dpi != defaultDPI { + t.Errorf("unknown DPI = %d, want %d", dpi, defaultDPI) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test -run "TestParseDPI|TestDetectDPI" -v` +Expected: FAIL β€” functions undefined. + +**Step 3: Implement dpi.go** + +Create `dpi.go`: + +```go +package main + +import ( + "fmt" + "log" + "regexp" + "strconv" + "strings" +) + +// parseDPIFromTSCResponse extracts DPI from a TSC ~!I probe response. +// Looks for patterns like "203 DPI", "300 dpi", "Resolution: 203x203 DPI". +func parseDPIFromTSCResponse(response string) (int, bool) { + if response == "" { + return 0, false + } + + // Pattern: "NNN DPI" or "NNN dpi" + re := regexp.MustCompile(`(\d{3})\s*[Dd][Pp][Ii]`) + if m := re.FindStringSubmatch(response); len(m) > 1 { + dpi, err := strconv.Atoi(m[1]) + if err == nil && dpi >= 96 && dpi <= 600 { + return dpi, true + } + } + + // Pattern: "Resolution: NNNxNNN" + re2 := regexp.MustCompile(`[Rr]esolution[:\s]+(\d{3})x(\d{3})`) + if m := re2.FindStringSubmatch(response); len(m) > 1 { + dpi, err := strconv.Atoi(m[1]) + if err == nil && dpi >= 96 && dpi <= 600 { + return dpi, true + } + } + + return 0, false +} + +// GetPrinterDPI returns the known DPI for a printer, or defaultDPI if unknown. +func GetPrinterDPI(printerName string) int { + configMu.RLock() + entry, ok := appConfig.PrinterDPI[printerName] + configMu.RUnlock() + if ok && entry.DPI > 0 { + return entry.DPI + } + return defaultDPI +} + +// SetPrinterDPI stores a detected DPI for a printer and persists to config. +func SetPrinterDPI(printerName string, dpi int, source string) { + configMu.Lock() + if appConfig.PrinterDPI == nil { + appConfig.PrinterDPI = map[string]PrinterDPIEntry{} + } + appConfig.PrinterDPI[printerName] = PrinterDPIEntry{DPI: dpi, Source: source} + configMu.Unlock() + log.Printf("[dpi] %s: %d DPI (source=%s)", printerName, dpi, source) + saveConfig() +} + +// DetectPrinterDPI runs the detection chain for a printer: +// 1. Check config cache +// 2. Driver query (platform-specific) +// 3. TSPL ~!I probe (for network printers) +// 4. Return defaultDPI as fallback +func DetectPrinterDPI(printer PrinterInfo) int { + // 1. Already cached? + configMu.RLock() + entry, ok := appConfig.PrinterDPI[printer.Name] + configMu.RUnlock() + if ok && entry.DPI > 0 { + return entry.DPI + } + + // 2. Driver query (platform-specific, fast ~10ms) + if dpi, ok := queryDriverDPI(printer.Name); ok { + SetPrinterDPI(printer.Name, dpi, "driver") + return dpi + } + + // 3. TSPL probe for network printers + if printer.Address != "" { + if response, isTSC := probeTSCPrinter(printer.Address); isTSC { + if dpi, ok := parseDPIFromTSCResponse(response); ok { + SetPrinterDPI(printer.Name, dpi, "tspl_probe") + return dpi + } + } + } + + // 4. Fallback β€” don't save, let user set manually + log.Printf("[dpi] %s: no DPI detected, using default %d", printer.Name, defaultDPI) + return defaultDPI +} + +// DetectAllPrinterDPIs runs detection for all known printers. +func DetectAllPrinterDPIs() { + printers, err := listAllPrinters() + if err != nil { + log.Printf("[dpi] detect all: %v", err) + return + } + for _, p := range printers { + DetectPrinterDPI(p) + } +} + +// handleDPIDetect is the HTTP handler for POST /dpi/detect +func handleDPIDetect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + printerName := r.URL.Query().Get("printer") + if printerName == "" { + // Detect all + go DetectAllPrinterDPIs() + jsonResponse(w, http.StatusOK, map[string]string{"status": "detecting"}) + return + } + + // Find printer and detect + printers, _ := listAllPrinters() + for _, p := range printers { + if p.Name == printerName { + dpi := DetectPrinterDPI(p) + jsonResponse(w, http.StatusOK, map[string]any{ + "printer": printerName, + "dpi": dpi, + }) + return + } + } + jsonResponse(w, http.StatusNotFound, map[string]string{"error": "printer not found"}) +} + +// handleDPISet is the HTTP handler for PUT /dpi +func handleDPISet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + printerName := r.URL.Query().Get("printer") + dpiStr := r.URL.Query().Get("dpi") + if printerName == "" || dpiStr == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "printer and dpi required"}) + return + } + dpi, err := strconv.Atoi(dpiStr) + if err != nil || dpi < 96 || dpi > 600 { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "dpi must be 96-600"}) + return + } + SetPrinterDPI(printerName, dpi, "manual") + jsonResponse(w, http.StatusOK, map[string]any{ + "printer": printerName, + "dpi": dpi, + "source": "manual", + }) +} +``` + +Note: `handleDPIDetect` and `handleDPISet` need `"net/http"` in the import. Add it. Also `"strconv"` and `"fmt"` are already imported β€” verify after writing. + +Create `dpi_darwin.go`: + +```go +package main + +import ( + "log" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// queryDriverDPI queries the macOS CUPS driver for printer resolution. +func queryDriverDPI(printerName string) (int, bool) { + // lpoptions -p "PrinterName" -l | grep -i resolution + cmd := exec.Command("lpoptions", "-p", printerName, "-l") + out, err := cmd.Output() + if err != nil { + log.Printf("[dpi-darwin] lpoptions for %q: %v", printerName, err) + return 0, false + } + + // Look for "Resolution/...:" lines + for _, line := range strings.Split(string(out), "\n") { + lower := strings.ToLower(line) + if !strings.Contains(lower, "resolution") { + continue + } + + // Find the default value (marked with *) + re := regexp.MustCompile(`\*(\d{3})(?:x\d{3})?dpi`) + if m := re.FindStringSubmatch(strings.ToLower(line)); len(m) > 1 { + dpi, _ := strconv.Atoi(m[1]) + if dpi >= 96 && dpi <= 600 { + log.Printf("[dpi-darwin] %s: driver reports %d DPI", printerName, dpi) + return dpi, true + } + } + + // Try without asterisk β€” any DPI value in the line + re2 := regexp.MustCompile(`(\d{3})(?:x\d{3})?dpi`) + if m := re2.FindStringSubmatch(strings.ToLower(line)); len(m) > 1 { + dpi, _ := strconv.Atoi(m[1]) + if dpi >= 96 && dpi <= 600 { + log.Printf("[dpi-darwin] %s: driver reports %d DPI (first available)", printerName, dpi) + return dpi, true + } + } + } + + return 0, false +} +``` + +Create `dpi_windows.go`: + +```go +//go:build windows + +package main + +import ( + "log" + "os/exec" + "regexp" + "strconv" +) + +// queryDriverDPI queries Windows WMI for printer resolution. +func queryDriverDPI(printerName string) (int, bool) { + // PowerShell: Get-WmiObject Win32_Printer -Filter "Name='..'" | Select HorizontalResolution + query := `Get-WmiObject Win32_Printer -Filter "Name='` + printerName + `'" | Select-Object -ExpandProperty HorizontalResolution` + cmd := exec.Command("powershell", "-NoProfile", "-Command", query) + out, err := cmd.Output() + if err != nil { + log.Printf("[dpi-windows] WMI query for %q: %v", printerName, err) + return 0, false + } + + re := regexp.MustCompile(`(\d{3,4})`) + if m := re.FindStringSubmatch(string(out)); len(m) > 1 { + dpi, _ := strconv.Atoi(m[1]) + if dpi >= 96 && dpi <= 600 { + log.Printf("[dpi-windows] %s: driver reports %d DPI", printerName, dpi) + return dpi, true + } + } + + return 0, false +} +``` + +Create `dpi_other.go`: + +```go +//go:build !darwin && !windows + +package main + +// queryDriverDPI is not supported on this platform. +func queryDriverDPI(printerName string) (int, bool) { + return 0, false +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -run "TestParseDPI|TestDetectDPI" -v` +Expected: PASS + +**Step 5: Verify build compiles** + +Run: `go build -o /dev/null .` +Expected: Clean build. + +**Step 6: Commit** + +```bash +git add dpi.go dpi_darwin.go dpi_windows.go dpi_other.go dpi_test.go +git commit -m "feat: add DPI auto-detection chain (driver -> TSPL probe -> manual)" +``` + +--- + +### Task 4: Expose DPI in /status endpoint and wire into TSPL rendering + +**Files:** +- Modify: `main.go:129-148` (handleStatus) +- Modify: `main.go:1173+` (handleBatchTspl β€” read DPI from request or printer config) +- Modify: `main.go:617-654` (startServers β€” register new DPI routes) + +**Step 1: Add DPI fields to handleStatus response** + +In `main.go` `handleStatus()`, add to the response map: + +```go + "printer_dpi": func() map[string]int { + configMu.RLock() + defer configMu.RUnlock() + flat := make(map[string]int, len(appConfig.PrinterDPI)) + for name, entry := range appConfig.PrinterDPI { + flat[name] = entry.DPI + } + return flat + }(), + "default_dpi": defaultDPI, +``` + +**Step 2: Register DPI routes in startServers** + +After the driver routes block (line ~636), add: + +```go + // DPI detection & manual override + mux.HandleFunc("/dpi/detect", corsMiddleware(handleDPIDetect)) + mux.HandleFunc("/dpi", corsMiddleware(handleDPISet)) +``` + +**Step 3: Trigger DPI detection on startup** + +In `main()`, after `go startNetworkScanner()` (line ~595), add: + +```go + go DetectAllPrinterDPIs() +``` + +**Step 4: Verify build compiles** + +Run: `go build -o /dev/null .` +Expected: Clean build. + +**Step 5: Commit** + +```bash +git add main.go +git commit -m "feat: expose printer DPI in /status, add /dpi endpoints, auto-detect on startup" +``` + +--- + +## Phase 2: Auth System + +### Task 5: Create auth.go β€” credential validation + +**Files:** +- Create: `auth.go` +- Test: `auth_test.go` (new) + +**Step 1: Write the failing test** + +Create `auth_test.go`: + +```go +package main + +import "testing" + +func TestIsAuthConfigured(t *testing.T) { + // No credentials + configMu.Lock() + appConfig.ApiURL = "" + appConfig.ApiKey = "" + appConfig.ApiSecret = "" + appConfig.ApiToken = "" + configMu.Unlock() + + if IsAuthConfigured() { + t.Error("should be false with no credentials") + } + + // API key mode + configMu.Lock() + appConfig.ApiURL = "https://example.com" + appConfig.ApiKey = "key123" + appConfig.ApiSecret = "secret456" + configMu.Unlock() + + if !IsAuthConfigured() { + t.Error("should be true with API key + secret") + } +} + +func TestAuthState(t *testing.T) { + configMu.Lock() + appConfig.ApiURL = "https://example.com" + appConfig.ApiKey = "key" + appConfig.ApiSecret = "secret" + appConfig.Whitelabel = WhitelabelConfig{Name: "TestCo", ID: 42} + configMu.Unlock() + + state := GetAuthState() + if state.Configured != true { + t.Error("configured should be true") + } + if state.WhitelabelName != "TestCo" { + t.Errorf("wl name = %q, want TestCo", state.WhitelabelName) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test -run TestIsAuth -v` +Expected: FAIL β€” `IsAuthConfigured` undefined. + +**Step 3: Implement auth.go** + +Create `auth.go`: + +```go +package main + +import ( + "encoding/json" + "log" + "net/http" +) + +// AuthState represents the current authentication state for the dashboard. +type AuthState struct { + Configured bool `json:"configured"` + Connected bool `json:"connected"` + ApiURL string `json:"api_url"` + WhitelabelName string `json:"whitelabel_name"` + WhitelabelID int `json:"whitelabel_id"` + LogoURL string `json:"logo_url,omitempty"` + Error string `json:"error,omitempty"` +} + +// IsAuthConfigured returns true if API credentials are present. +func IsAuthConfigured() bool { + cfg := getConfig() + if cfg.ApiURL == "" { + return false + } + return (cfg.ApiKey != "" && cfg.ApiSecret != "") || cfg.ApiToken != "" +} + +// GetAuthState returns the current auth state for the dashboard. +func GetAuthState() AuthState { + cfg := getConfig() + return AuthState{ + Configured: IsAuthConfigured(), + ApiURL: cfg.ApiURL, + WhitelabelName: cfg.Whitelabel.Name, + WhitelabelID: cfg.Whitelabel.ID, + LogoURL: cfg.Whitelabel.LogoURL, + } +} + +// handleAuthState returns the current auth state. +// GET /auth/state +func handleAuthState(w http.ResponseWriter, r *http.Request) { + state := GetAuthState() + + // If configured, test the connection + if state.Configured { + client := NewApiClient(getConfig()) + if err := client.TestConnection(); err != nil { + state.Connected = false + state.Error = err.Error() + } else { + state.Connected = true + } + } + + jsonResponse(w, http.StatusOK, state) +} + +// handleAuthLogin validates credentials and saves them. +// POST /auth/login { api_url, api_key, api_secret, wl_id? } +func handleAuthLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + var req struct { + ApiURL string `json:"api_url"` + ApiKey string `json:"api_key"` + ApiSecret string `json:"api_secret"` + WlID int `json:"wl_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + if req.ApiURL == "" || req.ApiKey == "" || req.ApiSecret == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "api_url, api_key, api_secret required"}) + return + } + + // Build temporary config to test connection + testCfg := AppConfig{ + ApiURL: req.ApiURL, + ApiKey: req.ApiKey, + ApiSecret: req.ApiSecret, + ApiWhiteLabel: req.WlID, + } + client := NewApiClient(testCfg) + if err := client.TestConnection(); err != nil { + log.Printf("[auth] login failed for %s: %v", req.ApiURL, err) + jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "connection failed: " + err.Error()}) + return + } + + // Save credentials + configMu.Lock() + appConfig.ApiURL = req.ApiURL + appConfig.ApiKey = req.ApiKey + appConfig.ApiSecret = req.ApiSecret + if req.WlID > 0 { + appConfig.ApiWhiteLabel = req.WlID + } + configMu.Unlock() + + if err := saveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "save failed"}) + return + } + + // Fetch whitelabel branding + go fetchAndSaveWhitelabel(client) + + log.Printf("[auth] login successful for %s (wl=%d)", req.ApiURL, req.WlID) + jsonResponse(w, http.StatusOK, map[string]string{"status": "connected"}) +} + +// handleAuthLogout clears credentials. +// POST /auth/logout +func handleAuthLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + configMu.Lock() + appConfig.ApiURL = "" + appConfig.ApiKey = "" + appConfig.ApiSecret = "" + appConfig.ApiToken = "" + appConfig.Whitelabel = WhitelabelConfig{} + configMu.Unlock() + + saveConfig() + log.Printf("[auth] logged out") + jsonResponse(w, http.StatusOK, map[string]string{"status": "logged_out"}) +} + +// fetchAndSaveWhitelabel fetches branding info from the backend and saves it. +func fetchAndSaveWhitelabel(client *ApiClient) { + // Try to get whitelabel info from /whitelabel endpoint + // This is best-effort β€” branding is not required for operation + templates, err := client.FetchTemplates() + if err != nil { + log.Printf("[auth] could not fetch templates for whitelabel: %v", err) + return + } + log.Printf("[auth] fetched %d templates from backend", len(templates)) +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test -run "TestIsAuth|TestAuthState" -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add auth.go auth_test.go +git commit -m "feat: add auth.go with login/logout/state endpoints and credential management" +``` + +--- + +### Task 6: Register auth routes in main.go + +**Files:** +- Modify: `main.go:617-654` (startServers) + +**Step 1: Add auth routes** + +After the DPI routes, add: + +```go + // Auth routes + mux.HandleFunc("/auth/state", corsMiddleware(handleAuthState)) + mux.HandleFunc("/auth/login", corsMiddleware(handleAuthLogin)) + mux.HandleFunc("/auth/logout", corsMiddleware(handleAuthLogout)) +``` + +**Step 2: Verify build compiles** + +Run: `go build -o /dev/null .` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add main.go +git commit -m "feat: register /auth/* HTTP routes" +``` + +--- + +## Phase 3: Webview Window & Tray Upgrade + +### Task 7: Create webview.go β€” native window lifecycle + +**Files:** +- Create: `webview.go` + +**Step 1: Implement webview.go** + +```go +package main + +import ( + "fmt" + "log" + "sync" + + webview "github.com/webview/webview/v2" +) + +var ( + wv webview.WebView + wvOnce sync.Once + wvMu sync.Mutex +) + +// initWebview creates the webview window (must be called from main thread). +func initWebview() webview.WebView { + wvMu.Lock() + defer wvMu.Unlock() + + if wv != nil { + return wv + } + + w := webview.New(false) // debug=false for production + if w == nil { + log.Printf("[webview] failed to create webview β€” falling back to browser mode") + return nil + } + + cfg := getConfig() + title := "TSC Bridge" + if cfg.Whitelabel.Name != "" { + title = fmt.Sprintf("TSC Bridge β€” %s", cfg.Whitelabel.Name) + } + + w.SetTitle(title) + w.SetSize(1100, 750, webview.HintNone) + wv = w + return w +} + +// showDashboard opens the native webview pointing at the local dashboard. +func showDashboard(dashURL string) { + wvMu.Lock() + w := wv + wvMu.Unlock() + + if w == nil { + // Fallback: open in browser + log.Printf("[webview] no webview available β€” opening in browser") + openBrowser(dashURL) + return + } + + w.Dispatch(func() { + w.Navigate(dashURL) + // Window is already running, just navigate + }) +} + +// runWebviewLoop starts the webview event loop (blocks, must be on main thread). +// Returns when the user closes the window. +func runWebviewLoop(dashURL string) { + w := initWebview() + if w == nil { + return + } + defer w.Destroy() + + w.Navigate(dashURL) + w.Run() // blocks until window closed +} +``` + +**Step 2: Verify build compiles** + +Run: `go build -o /dev/null .` +Expected: Build succeeds (may need CGO on macOS β€” WebKit is built-in so no extra deps). + +Note: If build fails with CGO errors, ensure `CGO_ENABLED=1` is set. On macOS this should work out of the box since WebKit is available as a system framework. + +**Step 3: Commit** + +```bash +git add webview.go +git commit -m "feat: add webview.go for native dashboard window" +``` + +--- + +### Task 8: Upgrade tray.go β€” rich menu with printer list and icon states + +**Files:** +- Modify: `tray.go` (complete rewrite) + +**Step 1: Rewrite tray.go** + +```go +package main + +import ( + "fmt" + "log" + "os" + "time" + + "fyne.io/systray" +) + +// trayIconState represents the current icon color. +type trayIconState int + +const ( + trayGray trayIconState = iota // starting up + trayGreen // connected, printer available + trayYellow // connected, no printer + trayRed // auth error or disconnected +) + +var currentTrayState = trayGray + +// runTray starts the system tray icon and blocks. +func runTray(dashURL string, autoOpen bool) { + defer func() { + if r := recover(); r != nil { + log.Printf("[tray] PANIC in systray: %v β€” falling back to headless mode", r) + select {} + } + }() + + systray.Run( + func() { onTrayReady(dashURL, autoOpen) }, + onTrayExit, + ) +} + +func onTrayReady(dashURL string, autoOpen bool) { + cfg := getConfig() + tooltip := "TSC Bridge v" + version + if cfg.Whitelabel.Name != "" { + tooltip = fmt.Sprintf("TSC Bridge β€” %s", cfg.Whitelabel.Name) + } + systray.SetTooltip(tooltip) + updateTrayIcon(trayGray) // starting + + // Dashboard + mOpen := systray.AddMenuItem("Abrir Dashboard", "Abrir panel de control en ventana nativa") + systray.AddSeparator() + + // Printer submenu (populated dynamically) + mPrinters := systray.AddMenuItem("Impresoras", "") + mPrinters.Disable() + mRefresh := systray.AddMenuItem("Re-detectar impresoras", "Escanear red y USB") + mTestPrint := systray.AddMenuItem("Test Print", "Imprimir pagina de prueba") + systray.AddSeparator() + + // Auto-start + mAutoStart := systray.AddMenuItemCheckbox( + "Iniciar con el sistema", + "Iniciar TSC Bridge al encender el equipo", + isAutoStartEnabled(), + ) + systray.AddSeparator() + + // Info + mInfo := systray.AddMenuItem( + fmt.Sprintf("Puerto %d β€” v%s", cfg.Port, version), + "Informacion del servicio", + ) + mInfo.Disable() + systray.AddSeparator() + + mQuit := systray.AddMenuItem("Salir", "Detener servicio y salir") + + // Auto-open dashboard on first run + if autoOpen { + go func() { + time.Sleep(2 * time.Second) + showDashboard(dashURL) + }() + } + + // Background: update printer count and tray icon periodically + go func() { + for { + printers, _ := listAllPrinters() + if len(printers) > 0 { + defaultDpi := GetPrinterDPI(cfg.DefaultPrinter) + label := fmt.Sprintf("%d impresora(s) β€” %d DPI", len(printers), defaultDpi) + mPrinters.SetTitle(label) + updateTrayIcon(trayGreen) + } else { + mPrinters.SetTitle("Sin impresoras") + updateTrayIcon(trayYellow) + } + time.Sleep(10 * time.Second) + } + }() + + // Event loop + go func() { + for { + select { + case <-mOpen.ClickedCh: + go showDashboard(dashURL) + case <-mRefresh.ClickedCh: + go func() { + refreshNetworkPrinters() + DetectAllPrinterDPIs() + }() + case <-mTestPrint.ClickedCh: + go func() { + // Quick test print using default printer + cfg := getConfig() + if cfg.DefaultPrinter != "" { + sendTestPrint(cfg.DefaultPrinter, cfg.DefaultPreset) + } + }() + case <-mAutoStart.ClickedCh: + enabled := !isAutoStartEnabled() + if err := setAutoStart(enabled); err != nil { + log.Printf("[tray] autostart toggle error: %v", err) + } else { + if enabled { + mAutoStart.Check() + } else { + mAutoStart.Uncheck() + } + configMu.Lock() + appConfig.AutoStart = enabled + configMu.Unlock() + saveConfig() + } + case <-mQuit.ClickedCh: + systray.Quit() + } + } + }() +} + +func onTrayExit() { + log.Printf("System tray exit β€” shutting down") + os.Exit(0) +} + +// updateTrayIcon sets the tray icon based on state. +func updateTrayIcon(state trayIconState) { + currentTrayState = state + // For now, use the same icon for all states. + // TODO: Generate colored variants of the app icon. + systray.SetIcon(generateAppIcon(32)) +} + +// sendTestPrint is a helper that prints a test page on the given printer. +func sendTestPrint(printerName, presetName string) { + preset := findPresetByID(presetName) + if preset == nil { + log.Printf("[tray] test print: preset %q not found", presetName) + return + } + tspl := generateTestTSPL(preset) + if err := printToNamedPrinter(printerName, []byte(tspl)); err != nil { + log.Printf("[tray] test print error: %v", err) + } else { + log.Printf("[tray] test print sent to %s", printerName) + } +} +``` + +Note: `sendTestPrint` references `findPresetByID`, `generateTestTSPL`, and `printToNamedPrinter` β€” these should already exist in the codebase (they're used by the test-print HTTP handler). Search for their exact names before implementing. If they have different names, adjust the calls. + +**Step 2: Verify references exist** + +Run: `grep -rn "func findPreset\|func generateTest\|func printToNamed\|func rawPrint\|func handleTestPrint" *.go` + +Adjust `sendTestPrint` to use whatever existing functions handle test printing. The key pattern is: +1. Build TSPL test page commands +2. Send to printer by name + +If `handleTestPrint` is a monolithic HTTP handler, extract the core logic or call the same underlying functions. + +**Step 3: Verify build compiles** + +Run: `go build -o /dev/null .` +Expected: Clean build. + +**Step 4: Commit** + +```bash +git add tray.go +git commit -m "feat: upgrade tray with printer count, DPI info, refresh, test print, native window" +``` + +--- + +### Task 9: Update main.go β€” webview lifecycle and setup wizard flow + +**Files:** +- Modify: `main.go:21` (version bump) +- Modify: `main.go:536-614` (main function) + +**Step 1: Update version** + +Change line 21: + +```go +const version = "3.0.0" +``` + +**Step 2: Update main() for webview** + +Replace the tray/headless block at the end of `main()` (lines ~606-614): + +```go + // Check if first run (no auth configured) β€” will show setup wizard + if !IsAuthConfigured() { + log.Printf("First run detected β€” setup wizard will appear in dashboard") + } + + // Run system tray β€” blocks until user clicks "Salir" + log.Printf("Starting system tray (headless=%v)", headless) + if headless { + log.Printf("Headless mode β€” skipping systray, blocking forever") + select {} + } + + // Auto-open: show native webview instead of browser + // tray.go's onTrayReady calls showDashboard() which uses webview + runTray(dashURL, !headless) +``` + +**Step 3: Verify build compiles** + +Run: `go build -o /dev/null .` +Expected: Clean build. + +**Step 4: Commit** + +```bash +git add main.go +git commit -m "feat: v3.0.0 β€” webview lifecycle in main, setup wizard detection" +``` + +--- + +## Phase 4: Dashboard Rewrite + +### Task 10: Dashboard HTML β€” shell with header, global state, 5-tab skeleton + +This is the largest single task. The dashboard.html will be a complete rewrite. + +**Files:** +- Modify: `dashboard.html` (complete rewrite) + +**Strategy:** Build incrementally β€” start with the shell (header, tabs, global state), then fill each tab in subsequent tasks. + +**Step 1: Write the dashboard shell** + +The new `dashboard.html` should contain: + +1. **HTML head**: Bootstrap 5 CDN, custom CSS with CSS variables for theming +2. **Header bar**: Logo + whitelabel name (left), printer selector + DPI badge (center), preset selector + auth badge (right) +3. **Tab navigation**: 5 tabs β€” Impresoras, Disenar, Batch, Templates, Config +4. **Tab content containers**: Empty divs for each tab +5. **JavaScript global state**: `AppState` object with selectedPrinter, selectedPreset, dpi, authState +6. **Init function**: Fetches /status, /config, /auth/state, populates header +7. **Polling**: Updates status every 5 seconds + +The full HTML will be ~2000-3000 lines. Here is the structure to implement: + +```html + + + + + + TSC Bridge + + + + + + + + + + + + +
+
...
+
...
+
...
+
...
+
...
+
+ + + + + + + + +``` + +**Key design rules for the rewrite:** +- Printer selector appears ONCE in the header β€” all tabs read from `AppState.selectedPrinter` +- Preset selector appears ONCE in the header β€” same pattern +- DPI badge auto-updates when printer changes (reads from `AppState.printerDPI[selectedPrinter]`) +- Auth badge shows whitelabel name + connected/disconnected state +- Setup wizard modal auto-shows on first load if `!authState.configured` +- All API calls go through a single `api(method, path, body)` helper + +**Step 2: Verify it loads in webview** + +Run the bridge and verify the dashboard loads: + +```bash +cd /Users/mario/PARA/1_Proyectos/ISI_Hospital/Servicios/tsc-bridge +go run . +``` + +Open http://127.0.0.1:9638/ in a browser β€” verify header, 5 tabs, and global state loads. + +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: dashboard v3 shell β€” header, 5-tab layout, global state, setup wizard" +``` + +--- + +### Task 11: Tab 1 β€” Impresoras + +**Files:** +- Modify: `dashboard.html` (tab-impresoras content) + +**Content:** +- Unified printer table (USB + network) with columns: Name, Type badge, DPI, Status, Actions +- Per-printer actions: [Set Default] [Test Print] [Share] [Set DPI] +- "Add Manual IP" form +- Network scan button +- USB sharing toggle + +**Key JavaScript functions:** +```js +async function loadPrinters() { + const res = await api('GET', '/printers'); + AppState.printers = res.printers || []; + renderPrinterTable(); + updateGlobalPrinterSelector(); +} + +function renderPrinterTable() { /* ... */ } + +async function setDefaultPrinter(name) { + AppState.selectedPrinter = name; + document.getElementById('global-printer').value = name; + updateDPIBadge(); + await api('PUT', '/config', { default_printer: name }); +} + +async function testPrint(printerName) { + await api('POST', '/test-print?printer=' + encodeURIComponent(printerName)); +} + +async function setManualDPI(printerName, dpi) { + await api('PUT', '/dpi?printer=' + encodeURIComponent(printerName) + '&dpi=' + dpi); + AppState.printerDPI[printerName] = dpi; + updateDPIBadge(); +} +``` + +**Step 1: Implement tab content** +**Step 2: Test manually** β€” verify printer list loads, actions work +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: dashboard Tab 1 β€” Impresoras with unified list, DPI display, actions" +``` + +--- + +### Task 12: Tab 5 β€” Config + +**Files:** +- Modify: `dashboard.html` (tab-config content) + +**Sections:** +1. **Account**: Store URL, API key status, Connected user (whitelabel name), [Logout] [Reconnect] +2. **Printing**: Default printer (read-only, set from header), Default preset, DPI per printer table (editable) +3. **System**: HTTP port, Autostart toggle, Network scan toggle + interval, Share toggle + port +4. **Downloads**: macOS / Windows installer links + +**Key JavaScript:** +```js +async function loadConfig() { + AppState.config = await api('GET', '/config'); + renderConfigForm(); +} + +async function saveConfigSection(data) { + await api('PUT', '/config', data); + showToast('Configuracion guardada'); +} +``` + +**Step 1: Implement tab content** +**Step 2: Test manually** β€” verify config loads and saves +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: dashboard Tab 5 β€” Config with account, printing, system sections" +``` + +--- + +### Task 13: Tab 4 β€” Templates + +**Files:** +- Modify: `dashboard.html` (tab-templates content) + +**Content:** +- Two sections: "Backend Templates" (from API) and "Local Templates" +- Grid/card layout with template preview thumbnail +- Per-template actions: [Preview] [Edit in Designer] [Print] [Use in Batch] +- Backend templates require auth β€” show "Conectar" button if not authenticated + +**Key JavaScript:** +```js +async function loadTemplates() { + // Local templates + const local = await api('GET', '/templates'); + AppState.localTemplates = local.templates || []; + + // Backend templates (if authenticated) + if (AppState.authState.configured) { + try { + const backend = await api('GET', '/api/templates'); + AppState.backendTemplates = backend.templates || []; + } catch (e) { + console.warn('Backend templates unavailable:', e); + } + } + renderTemplateGrid(); +} + +async function previewTemplate(templateId, source) { + const res = await fetch('/batch-preview-image', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + template_id: templateId, + source: source, + dpi: AppState.dpi, + data: {} + }) + }); + const blob = await res.blob(); + // Show in modal +} +``` + +**Step 1: Implement tab content** +**Step 2: Test manually** +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: dashboard Tab 4 β€” Templates grid with backend + local, preview" +``` + +--- + +## Phase 5: Label Designer + +### Task 14: Tab 2 β€” Designer canvas and toolbar + +**Files:** +- Modify: `dashboard.html` (tab-disenar content) + +**Layout:** +``` ++--toolbar--+--------canvas---------+--properties--+ +| [T] Text | | Position | +| [B] Barcode| Label surface | x: __ y: __ | +| [Q] QR | (mm scale) | Size | +| [L] Line | | w: __ h: __ | +| [R] Rect | | Content | +| [I] Image | | [________] | ++===========+ | Font | +| Standards | | [1] [x2] | +| [None v] | | Rotation | ++-----------+-----------------------| [0 v] | +| TSPL Code | +--------------+ +| (readonly)| ++-----------+ +``` + +**Canvas implementation:** +- HTML5 `` element scaled to label dimensions in mm +- `mmToPx(mm)` converts using screen DPI (96) and zoom level +- Elements stored in `AppState.designerElements[]` array +- Each element: `{id, type, x, y, w, h, content, font, rotation, alignment}` +- Drag-and-drop via canvas mouse events (mousedown β†’ track, mousemove β†’ update, mouseup β†’ commit) +- Selected element highlighted with resize handles +- Snap-to-grid optional (1mm grid) + +**Toolbar:** +- Add element buttons β€” clicking adds element at center of canvas +- Standards selector dropdown (None, Gafete, GS1-128, ISO 15223) +- TSPL code preview textarea (read-only, auto-updates) + +**Key JavaScript:** + +```js +const Designer = { + canvas: null, + ctx: null, + elements: [], + selected: null, + zoom: 1, + gridSnap: true, + labelW: 30, // mm + labelH: 22, // mm + standard: 'none', + + init() { /* setup canvas, event listeners */ }, + addElement(type) { /* push new element, select it */ }, + render() { /* clear canvas, draw all elements, handles */ }, + hitTest(px, py) { /* find element at canvas coords */ }, + generateTSPL() { /* convert elements to TSPL commands at AppState.dpi */ }, + toPdfmeSchema() { /* export as pdfme-compatible JSON */ }, + fromPdfmeSchema(schema) { /* import from pdfme JSON */ }, +}; +``` + +**Step 1: Implement canvas, toolbar, element rendering** +**Step 2: Test manually** β€” add elements, drag them, verify TSPL output updates +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: dashboard Tab 2 β€” Label Designer with canvas, toolbar, drag-and-drop" +``` + +--- + +### Task 15: Properties panel and field editing + +**Files:** +- Modify: `dashboard.html` (designer properties panel) + +**Properties panel updates when an element is selected:** +- Position: x/y inputs (mm, 0.1 step) +- Size: w/h inputs (mm, 0.1 step) +- Content: text input or textarea (supports `{variable}` placeholders) +- Font: TSPL font selector (1-5) + multiplier (1-8) +- Rotation: dropdown (0, 90, 180, 270) +- Alignment: button group (left, center, right) +- For barcodes: type selector (Code128, EAN13, etc.) +- For QR codes: β†’ opens QR Builder (Task 16) +- For images: file upload button + +**Step 1: Implement properties panel** +**Step 2: Test manually** β€” select element, edit properties, verify canvas updates +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: designer properties panel β€” position, size, font, content, rotation" +``` + +--- + +### Task 16: QR Builder + +**Files:** +- Modify: `dashboard.html` (QR builder in properties panel) + +**When a QR element is selected, show QR content builder instead of plain text input:** + +```html +
+ + + + + + + + + + +
+``` + +**JavaScript generators:** + +```js +const QRBuilder = { + generateVCard(fields) { + return [ + 'BEGIN:VCARD', + 'VERSION:3.0', + `FN:${fields.fn} ${fields.ln}`, + `N:${fields.ln};${fields.fn};`, + `TEL;TYPE=CELL:${fields.tel}`, + `EMAIL:${fields.email}`, + `ORG:${fields.org}`, + `TITLE:${fields.title}`, + 'END:VCARD' + ].join('\n'); + }, + + generateWiFi(ssid, pass, security) { + return `WIFI:S:${ssid};T:${security};P:${pass};;`; + }, + + generateURL(pattern) { + return pattern; // Variables resolved at print time + } +}; +``` + +**Step 1: Implement QR builder UI and generators** +**Step 2: Test manually** β€” select QR element, switch types, verify content preview +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: QR Builder β€” vCard 3.0, URL, WiFi, free text generators" +``` + +--- + +### Task 17: Designer TSPL preview, rasterize, and export + +**Files:** +- Modify: `dashboard.html` (designer bottom panel + export buttons) + +**TSPL preview:** +- Read-only textarea showing live TSPL output +- Auto-updates as elements are moved/resized +- DPI-aware output (uses `AppState.dpi`) + +**Rasterize button:** +- POST to `/batch-preview-image` with designer schema as pdfme JSON +- Shows PNG preview in modal + +**Export buttons:** +- "Guardar local" β†’ POST to `/templates` (local storage) +- "Subir al servidor" β†’ POST to backend API via bridge (requires auth) +- "Exportar JSON" β†’ download pdfme-compatible JSON file + +```js +Designer.generateTSPL = function() { + const dpi = AppState.dpi; + const lines = []; + lines.push('SIZE ' + this.labelW + ' mm, ' + this.labelH + ' mm'); + lines.push('GAP 2 mm, 0 mm'); + lines.push('DIRECTION 0,0'); + lines.push('SPEED 4'); + lines.push('DENSITY 8'); + lines.push('CLS'); + + for (const el of this.elements) { + const x = Math.round(el.x / 25.4 * dpi); + const y = Math.round(el.y / 25.4 * dpi); + const w = Math.round(el.w / 25.4 * dpi); + const h = Math.round(el.h / 25.4 * dpi); + + switch (el.type) { + case 'text': + lines.push(`TEXT ${x},${y},"${el.font || '3'}",0,${el.fontMult || 1},${el.fontMult || 1},"${el.content}"`); + break; + case 'qrcode': + const cell = Math.max(2, Math.round(w / 25)); + lines.push(`QRCODE ${x},${y},M,${cell},A,0,"${el.content}"`); + break; + case 'barcode': + lines.push(`BARCODE ${x},${y},"128",${h},0,2,2,"${el.content}"`); + break; + case 'line': + lines.push(`BAR ${x},${y},${w},${Math.max(2, h)}`); + break; + case 'rectangle': + lines.push(`BOX ${x},${y},${x+w},${y+h},2`); + break; + } + } + lines.push('PRINT 1'); + return lines.join('\r\n'); +}; + +Designer.toPdfmeSchema = function() { + return { + schemas: [this.elements.map(el => ({ + name: el.id, + type: el.type, + position: { x: el.x, y: el.y }, + width: el.w, + height: el.h, + content: el.content, + fontSize: el.fontSize || 12, + alignment: el.alignment || 'left', + rotate: el.rotation || 0, + }))], + basePdf: { + width: this.labelW, + height: this.labelH, + padding: [0, 0, 0, 0] + } + }; +}; +``` + +**Step 1: Implement TSPL preview, rasterize button, export buttons** +**Step 2: Test manually** β€” design a label, verify TSPL, rasterize preview, export JSON +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: designer TSPL preview, PNG rasterize, local/backend/JSON export" +``` + +--- + +## Phase 6: Batch Enhancements + +### Task 18: Tab 3 β€” Batch wizard (4-step) + +**Files:** +- Modify: `dashboard.html` (tab-batch content) + +**4-step wizard:** + +``` +Step 1: Upload Excel β†’ POST /excel/upload β†’ get columns + preview +Step 2: Select Template β†’ pick from local/backend templates +Step 3: Map Columns β†’ for each template field, assign Excel column or formula +Step 4: Execute β†’ print TSPL or generate PDF +``` + +**Step 1 β€” Upload Excel:** +```html +
+
Paso 1: Subir archivo Excel
+ +
+ +
+``` + +**Step 2 β€” Select Template:** +```html + +``` + +**Step 3 β€” Map Columns (with formula support, Task 19):** +```html + +``` + +**Step 4 β€” Execute:** +```html + +``` + +**Uses global printer/preset from header β€” no duplicate selectors.** + +**Step 1: Implement 4-step wizard UI and navigation** +**Step 2: Test manually** β€” upload Excel, select template, see mapping, execute +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: dashboard Tab 3 β€” Batch 4-step wizard, uses global printer/preset" +``` + +--- + +### Task 19: Formula concatenation engine (JavaScript) + +**Files:** +- Modify: `dashboard.html` (batch step 3 mapping + formula engine) + +**Mapping modes per field:** + +```html +
+ + +
+ +
+
+``` + +**Formula engine:** + +```js +const FormulaEngine = { + // Parse formula: {Col_A} + " " + {Col_B} + // Returns function that takes a row and returns resolved string + compile(formula) { + // Tokenize: split on + but respect quoted strings + const tokens = []; + let current = ''; + let inQuote = false; + + for (let i = 0; i < formula.length; i++) { + const ch = formula[i]; + if (ch === '"') { + inQuote = !inQuote; + current += ch; + } else if (ch === '+' && !inQuote) { + tokens.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) tokens.push(current.trim()); + + return (row) => { + return tokens.map(t => { + t = t.trim(); + // Quoted string literal + if (t.startsWith('"') && t.endsWith('"')) { + return t.slice(1, -1); + } + // Column reference: {Col_A} or Col_A + const colMatch = t.match(/^\{?(.+?)\}?$/); + if (colMatch) { + return row[colMatch[1]] || ''; + } + return t; + }).join(''); + }; + }, + + // vCard builder: takes field mappings, returns function + compileVCard(mappings) { + return (row) => { + const resolve = (expr) => expr ? this.compile(expr)(row) : ''; + return [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:' + resolve(mappings.fn), + 'N:' + resolve(mappings.ln) + ';' + resolve(mappings.fn) + ';', + 'TEL;TYPE=CELL:' + resolve(mappings.tel), + 'EMAIL:' + resolve(mappings.email), + 'ORG:' + resolve(mappings.org), + 'TITLE:' + resolve(mappings.title), + 'END:VCARD' + ].join('\n'); + }; + }, + + // URL pattern: https://example.com/{Col_G} + compileURL(pattern) { + return (row) => { + return pattern.replace(/\{(.+?)\}/g, (_, col) => row[col] || ''); + }; + } +}; +``` + +**Live preview:** Show first 4 rows with formulas applied in a table below the mapping. + +```js +function updateMappingPreview() { + const rows = AppState.batchRows.slice(0, 4); + const mappings = getMappings(); // read from UI + const table = rows.map(row => { + const resolved = {}; + for (const [field, mapping] of Object.entries(mappings)) { + resolved[field] = mapping.resolver(row); + } + return resolved; + }); + renderPreviewTable(table); +} +``` + +**Step 1: Implement formula engine + mapping UI + preview** +**Step 2: Test manually** β€” create formula "{Col_A} + ' ' + {Col_B}", verify preview +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: batch formula concatenation β€” direct column, formula, vCard, URL mapping" +``` + +--- + +### Task 20: Batch SSE progress streaming + +**Files:** +- Modify: `main.go` (add SSE endpoint for batch progress) +- Modify: `dashboard.html` (batch step 4 progress bar with EventSource) + +**Step 1: Add SSE endpoint in main.go** + +Add to `main.go` after batch routes: + +```go +mux.HandleFunc("/batch-progress", corsMiddleware(handleBatchProgress)) +``` + +Implement `handleBatchProgress` in `main.go` (or `batch.go`): + +```go +var batchProgressCh = make(chan BatchProgress, 100) + +type BatchProgress struct { + Current int `json:"current"` + Total int `json:"total"` + Status string `json:"status"` // "processing", "done", "error" + Message string `json:"message,omitempty"` +} + +func handleBatchProgress(w http.ResponseWriter, r *http.Request) { + 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, "SSE not supported", http.StatusInternalServerError) + return + } + + for progress := range batchProgressCh { + data, _ := json.Marshal(progress) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + if progress.Status == "done" || progress.Status == "error" { + return + } + } +} +``` + +**Step 2: Dashboard EventSource** + +```js +function batchExecuteWithProgress(mode) { + const evtSource = new EventSource('/batch-progress'); + evtSource.onmessage = (e) => { + const p = JSON.parse(e.data); + const pct = Math.round(p.current / p.total * 100); + document.getElementById('batch-progress').style.width = pct + '%'; + document.getElementById('batch-progress').textContent = `${p.current}/${p.total}`; + if (p.status === 'done' || p.status === 'error') { + evtSource.close(); + } + }; + + // Start batch job + api('POST', '/batch-tspl', { /* ... */ }); +} +``` + +**Step 3: Verify build compiles** + +Run: `go build -o /dev/null .` +Expected: Clean build. + +**Step 4: Commit** + +```bash +git add main.go dashboard.html +git commit -m "feat: batch SSE progress streaming for real-time progress bar" +``` + +--- + +## Phase 7: Standards Validation (Designer) + +### Task 21: Standards validation in designer + +**Files:** +- Modify: `dashboard.html` (designer standards selector + validation) + +**Standards:** +- **None**: No validation, free-form +- **Gafete/Credential**: Warn if no name field, suggest vCard QR, validate badge dimensions +- **GS1-128**: Validate Application Identifiers (AI), GTIN check digits, required fields (GTIN, batch, expiry) +- **ISO 15223 (Patient)**: Validate MRN, DOB, required identification fields + +```js +const Standards = { + validate(standard, elements) { + switch (standard) { + case 'gafete': return this.validateGafete(elements); + case 'gs1-128': return this.validateGS1(elements); + case 'iso-15223': return this.validateISO(elements); + default: return { valid: true, warnings: [] }; + } + }, + + validateGafete(elements) { + const warnings = []; + const hasName = elements.some(e => e.type === 'text' && /nombre|name/i.test(e.content)); + const hasQR = elements.some(e => e.type === 'qrcode'); + if (!hasName) warnings.push('Se recomienda incluir un campo de nombre'); + if (!hasQR) warnings.push('Se recomienda incluir un QR con vCard'); + return { valid: true, warnings }; + }, + + validateGS1(elements) { + const warnings = []; + const barcodes = elements.filter(e => e.type === 'barcode'); + if (barcodes.length === 0) warnings.push('Se requiere al menos un codigo de barras GS1-128'); + // Check for required AIs: (01) GTIN, (10) Batch, (17) Expiry + return { valid: warnings.length === 0, warnings }; + }, + + validateISO(elements) { + const warnings = []; + const texts = elements.filter(e => e.type === 'text'); + const hasMRN = texts.some(e => /mrn|expediente|registro/i.test(e.content)); + if (!hasMRN) warnings.push('Se requiere campo de MRN/expediente'); + return { valid: warnings.length === 0, warnings }; + } +}; +``` + +Display warnings in designer sidebar when standard is selected and validation fails. + +**Step 1: Implement standards validation** +**Step 2: Test manually** β€” select Gafete standard, add/remove name field, verify warnings +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: designer standards validation β€” Gafete, GS1-128, ISO 15223" +``` + +--- + +## Phase 8: Integration & Polish + +### Task 22: Setup wizard modal + +**Files:** +- Modify: `dashboard.html` (setup wizard modal content) + +**Setup wizard flow:** +1. Welcome screen: "Configurar TSC Bridge" +2. Store URL input: `https://tienda.anysubscriptions.com/api` or `https://latam.paygateway-api.com/api` +3. Credentials: API Key + API Secret fields (with help text linking to admin panel) +4. White-label ID (optional, auto-detected from API response) +5. Test connection button β†’ POST /auth/login +6. Success: close modal, update auth badge, load templates + +```html + +``` + +```js +async function setupConnect() { + const url = document.getElementById('setup-url').value.trim(); + const key = document.getElementById('setup-key').value.trim(); + const secret = document.getElementById('setup-secret').value.trim(); + const wl = parseInt(document.getElementById('setup-wl').value) || 0; + + try { + await api('POST', '/auth/login', { + api_url: url, + api_key: key, + api_secret: secret, + wl_id: wl + }); + document.getElementById('setup-success').textContent = 'Conectado exitosamente'; + document.getElementById('setup-success').classList.remove('d-none'); + setTimeout(() => { + bootstrap.Modal.getInstance(document.getElementById('setupWizard')).hide(); + refreshAuthState(); + loadTemplates(); + }, 1500); + } catch (e) { + document.getElementById('setup-error').textContent = e.message || 'Error de conexion'; + document.getElementById('setup-error').classList.remove('d-none'); + } +} + +// Auto-show on first load if not configured +async function checkSetupNeeded() { + const state = await api('GET', '/auth/state'); + AppState.authState = state; + if (!state.configured) { + new bootstrap.Modal(document.getElementById('setupWizard')).show(); + } +} +``` + +**Step 1: Implement setup wizard modal** +**Step 2: Test manually** β€” clear config, reload, verify wizard auto-shows +**Step 3: Commit** + +```bash +git add dashboard.html +git commit -m "feat: setup wizard modal β€” auto-shows on first run, validates credentials" +``` + +--- + +### Task 23: Final integration and build verification + +**Files:** +- Verify: all `.go` files compile +- Verify: `dashboard.html` loads in webview + +**Step 1: Clean build** + +```bash +cd /Users/mario/PARA/1_Proyectos/ISI_Hospital/Servicios/tsc-bridge +go vet ./... +go build -o tsc-bridge . +``` + +Expected: No errors, binary produced. + +**Step 2: Run all tests** + +```bash +go test -v ./... +``` + +Expected: All tests pass (config_test.go, dpi_test.go, auth_test.go). + +**Step 3: Manual smoke test** + +```bash +./tsc-bridge +``` + +Verify: +- [ ] System tray icon appears with correct menu +- [ ] Dashboard opens in native webview +- [ ] 5 tabs visible and navigable +- [ ] Printer list populates +- [ ] DPI badge shows correct value +- [ ] Config tab loads and saves +- [ ] Setup wizard shows if no auth configured + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: TSC Bridge v3.0.0 β€” native dashboard, DPI detection, auth, designer" +``` + +--- + +## Dependency Graph + +``` +Task 1 (webview dep) + └─→ Task 7 (webview.go) + └─→ Task 9 (main.go webview) + +Task 2 (config PrinterDPI) + └─→ Task 3 (dpi.go) + └─→ Task 4 (/status DPI) + +Task 5 (auth.go) + └─→ Task 6 (auth routes) + +Task 8 (tray.go) ← depends on Task 3 (DPI), Task 7 (webview) + +Task 10 (dashboard shell) ← depends on Tasks 4, 6 + β”œβ”€β†’ Task 11 (Tab 1 Impresoras) + β”œβ”€β†’ Task 12 (Tab 5 Config) + β”œβ”€β†’ Task 13 (Tab 4 Templates) + β”œβ”€β†’ Task 14 (Tab 2 Designer canvas) + β”‚ β”œβ”€β†’ Task 15 (Properties panel) + β”‚ β”œβ”€β†’ Task 16 (QR Builder) + β”‚ β”œβ”€β†’ Task 17 (TSPL preview + export) + β”‚ └─→ Task 21 (Standards validation) + β”œβ”€β†’ Task 18 (Tab 3 Batch wizard) + β”‚ β”œβ”€β†’ Task 19 (Formula engine) + β”‚ └─→ Task 20 (SSE progress) + └─→ Task 22 (Setup wizard modal) + +Task 23 (integration) ← depends on all above +``` + +## Estimated Scope + +| Phase | Tasks | New/Modified Files | +|-------|-------|--------------------| +| Phase 1: Foundation | 1-4 | config.go, dpi.go, dpi_*.go, main.go | +| Phase 2: Auth | 5-6 | auth.go, main.go | +| Phase 3: Webview + Tray | 7-9 | webview.go, tray.go, main.go | +| Phase 4: Dashboard | 10-13 | dashboard.html | +| Phase 5: Designer | 14-17 | dashboard.html | +| Phase 6: Batch | 18-20 | dashboard.html, main.go | +| Phase 7: Standards | 21 | dashboard.html | +| Phase 8: Polish | 22-23 | dashboard.html | diff --git a/docs/plans/2026-03-07-bridge-v3-native-dashboard-design.md b/docs/plans/2026-03-07-bridge-v3-native-dashboard-design.md new file mode 100644 index 0000000..2e3f456 --- /dev/null +++ b/docs/plans/2026-03-07-bridge-v3-native-dashboard-design.md @@ -0,0 +1,414 @@ +# TSC Bridge v3.0 β€” Native Dashboard Design + +**Date:** 2026-03-07 +**Status:** Approved +**Repository:** ISI_Hospital/Servicios/tsc-bridge + +--- + +## 1. Problem Statement + +The current TSC Bridge v2.3.0 dashboard has significant issues: +- **Redundancy**: Printer selector in 5 places, preset selector in 5 places, status in 3 places +- **Browser-dependent**: Requires opening an external browser to access the dashboard +- **No authentication**: Anyone on the network can access the dashboard +- **Hardcoded DPI**: All TSPL generation assumes 203 DPI +- **No label designer**: Templates must be created externally (pdfme editor) and imported +- **No QR builder**: QR content (vCard, URL) is constructed manually in frontend code + +## 2. Solution Overview + +TSC Bridge v3.0 = **Go native app** with: +- **System tray** icon (always running, desatendido) +- **Webview window** (native, no browser) for full dashboard +- **Setup wizard** for first-run configuration +- **API key authentication** compatible with paygateway-api.com +- **DPI auto-detection** chain (driver -> TSPL probe -> manual) +- **Label Designer** with drag-and-drop, standards support, QR builder +- **Batch processor** with formula concatenation in field mapping +- **Zero redundancy** dashboard with 5 clean tabs + +## 3. Architecture + +``` ++---------------------------------------------+ +| TSC Bridge v3 (Go binary) | +| | +| +--------+ +--------+ +-----------+ | +| |Systray | |Webview | | HTTP API | | +| |(background)| | | (localhost)| | +| +--------+ +--------+ +-----------+ | +| | | | | +| +---+-----------+-------------+----------+ | +| | Core Engine | | +| | +------+ +--------+ +-------------+ | | +| | | Auth | |Printer | |TSPL Renderer| | | +| | |Mgr | |Scanner | |(pdfme->TSPL)| | | +| | +------+ +--------+ +-------------+ | | +| | +------+ +--------+ +-------------+ | | +| | | DPI | | Config | | Batch/Excel | | | +| | |Detect| | Store | | Processor | | | +| | +------+ +--------+ +-------------+ | | +| +----------------------------------------+ | ++----------------------------------------------+ +``` + +### Three interfaces: +- **Systray**: Always active, context menu for quick actions +- **Webview**: Native window (Go webview library) for full dashboard +- **HTTP API**: For AnySubscription/ISI frontend to send print jobs + +### Technology: +- **Go + github.com/webview/webview** for native window +- **getlantern/systray** for system tray (already in use) +- **//go:embed** for dashboard HTML (already in use) +- Single binary, ~15MB + +## 4. Authentication + +### API Key Model (compatible with paygateway-api.com) + +The bridge authenticates using `accessKey` + `accessToken` from the `usuarioApi` table, +exactly as the existing JWT.php auth middleware works. + +### Two setup paths: + +**Path A: Pre-configured download (zero setup)** +1. Staff user downloads bridge from admin panel (already logged in) +2. Backend generates `config.json` with api_key, api_secret, whitelabel branding +3. Bridge starts fully configured + +**Path B: Manual setup wizard** +1. User enters Store URL +2. User enters API Key + Secret (or logs in with user/pass to generate keys) +3. Bridge validates credentials against backend +4. Fetches whitelabel config, stores encrypted credentials + +### Request authentication: +``` +Authorization: Bearer {accessKey}:{accessToken} +X-WhiteLabel: {wl_id} +``` + +### API key limitations: +- Scoped to: /pdf-templates, /pdfs/generate-tspl-commands, /printer/* +- No access to orders, payments, clients +- Rate limiting per key + +### Token refresh: +- API keys don't expire (persistent) +- Bridge validates connectivity on startup +- If credentials invalid, shows setup wizard + +## 5. DPI Auto-Detection + +### Detection chain (per printer): +``` +1. Driver query (fast, ~10ms) + - Windows: WMI PrinterInfo2 -> xRes/yRes + - macOS: lpoptions -> Resolution attribute + Success -> save + done + +2. TSPL ~!I probe (~1s) + - Send ~!I to printer, parse response for DPI + Success -> save + done + +3. Fallback: manual selection + - User picks from [200, 203, 300, 600] + - Saved per-printer +``` + +### Config storage: +```json +{ + "printer_dpi": { + "TSC TE200": {"dpi": 203, "source": "tspl_probe"}, + "TSC TTP-345": {"dpi": 300, "source": "driver"} + } +} +``` + +### HTTP API exposes DPI: +```json +GET /status +{ + "printers": [...], + "printer_dpi": {"TSC TE200": 203, "TTP-345": 300}, + "default_dpi": 203 +} +``` + +Frontend reads DPI from bridge and passes to backend when generating TSPL. + +## 6. Dashboard (Webview) β€” 5 Tabs, Zero Redundancy + +### Layout: +``` ++--------------------------------------------------+ +| [logo] Expomotriz * TSC TE200 v3.0| +|--------------------------------------------------| +| [Impresoras] [Disenar] [Batch] [Templates] [Config]| +|--------------------------------------------------| +| | +| (tab content) | +| | ++--------------------------------------------------+ +``` + +### Global state (set once, used everywhere): +- Selected printer (in header, not per-tab) +- Active preset (in header) +- DPI (derived from selected printer) +- Auth state (in header badge) + +### Tab 1: Impresoras +- Unified printer list (USB + network) with type badge and detected DPI +- Per-printer actions: [Set Default] [Test Print] [Share] +- Network scanner with manual IP +- USB sharing toggle +- Autostart toggle + +### Tab 2: Disenar (Label Designer) +See Section 7. + +### Tab 3: Batch +- 4-step wizard (upload Excel -> select template -> map columns -> execute) +- Uses global printer/preset selection (no duplicate selectors) +- Formula concatenation in mapping step (see Section 8) +- Output: Print TSPL or Generate PDF +- Progress bar with SSE streaming + +### Tab 4: Templates +- Grid of templates fetched from backend API (authenticated) +- Local templates +- Per-template: [Preview] [Edit in Designer] [Print] [Use in Batch] +- Template preview as rasterized PNG at target DPI + +### Tab 5: Config +- Account section: Store URL, connected user, API key status, logout/reconnect +- Printing section: Default printer, default preset, DPI per printer (editable) +- System section: HTTP port, autostart, printer sharing, network scan interval +- Downloads section: macOS / Windows installer links + +## 7. Label Designer + +### Canvas +- Real-scale rendering (mm -> px at screen DPI) +- Drag-and-drop elements on label surface +- Snap-to-grid optional +- Zoom controls + +### Toolbar elements: +- **Text** (T): Single/multi-line, TSPL fonts 1-5 with multipliers +- **Barcode**: Code128, Code39, EAN13, EAN8, UPC-A, UPC-E, Code93, ITF14, NW7 +- **QR Code**: With QR Builder (see Section 7.1) +- **Line**: Horizontal/vertical +- **Rectangle**: Outline or filled +- **Image**: Bitmap (for logos, converted to BITMAP TSPL command) + +### Properties panel (right side): +- Position (x, y in mm) +- Size (width, height in mm) +- Font (TSPL font number + multiplier) +- Content (literal text or {variable} placeholder) +- Rotation (0, 90, 180, 270) +- Alignment (left, center, right) + +### Standards selector: +- **None**: Free-form, no validation +- **Gafete/Credential**: Validates name present, vCard in QR, badge dimensions +- **GS1-128**: Validates Application Identifiers, GTIN check digits, required fields +- **ISO 15223 (Patient)**: Validates MRN, DOB, required identification fields + +### TSPL Preview: +- Live-updating TSPL code as elements are moved/resized +- TSPL output adapted to selected printer's DPI +- Rasterize button: generates PNG preview at target DPI + +### Export: +- Save locally (bridge template store) +- Upload to backend API (pdfme-compatible JSON format) +- Export as pdfme JSON + +### 7.1 QR Builder + +When a QR element is selected, the properties panel shows a QR content builder: + +**Type: vCard** +``` +Name: [{nombre} v] +Surname: [{apellido} v] +Phone: [{telefono} v] +Email: [{correo} v] +Company: [{empresa} v] +Title: [{puesto} v] +Address: [(optional) v] +``` +Generates valid vCard 3.0: +``` +BEGIN:VCARD +VERSION:3.0 +FN:{nombre} {apellido} +N:{apellido};{nombre}; +TEL;TYPE=CELL:{telefono} +EMAIL:{correo} +ORG:{empresa} +TITLE:{puesto} +END:VCARD +``` + +**Type: URL** +``` +Pattern: [https://stl.lat/{codigo}] +``` + +**Type: WiFi** +``` +SSID: [{ssid} ] +Password: [{pass} ] +Security: [WPA v] +``` +Generates: `WIFI:S:{ssid};T:WPA;P:{pass};;` + +**Type: Free text** +``` +Content: [{campo1}-{campo2}/{campo3}] +``` + +## 8. Batch Formula Concatenation + +In the batch wizard Step 3 (Column Mapping), each template field can be mapped as: + +### Mapping modes per field: + +**Direct column:** +``` +Template field: {nombre} +Mode: [Column v] +Value: [Col_A v] +``` + +**Formula (concatenation):** +``` +Template field: {nombre_completo} +Mode: [Formula v] +Value: {Col_A} + " " + {Col_B} +``` +Supported operators: `+` (concatenate), literal strings in quotes. + +**QR vCard auto-build:** +``` +Template field: {token} +Mode: [vCard Builder v] + FN: Col_A + " " + Col_B + TEL: Col_C + EMAIL: Col_D + ORG: Col_E + TITLE: Col_F +``` +Automatically builds valid vCard 3.0 per row. + +**QR URL pattern:** +``` +Template field: {qr_url} +Mode: [URL Pattern v] +Value: https://example.com/{Col_G} +``` + +### Preview: +The mapping step shows a live preview of the first 4 rows with formulas applied, +so the user can verify before executing the batch. + +## 9. System Tray + +### Menu structure: +``` +* TSC Bridge -- Expomotriz +-------------------------- + TSC TE200 (203 DPI) [default] + TSC TTP-345 (300 DPI) +-------------------------- + Abrir Dashboard + Re-detectar impresoras + Test Print +-------------------------- + Configurar... + Salir +``` + +### Tray icon states: +- Green dot: Connected, printer available +- Yellow dot: Connected, no printer +- Red dot: Auth error or disconnected +- Gray dot: Starting up + +### Behavior: +- Left-click: Opens webview dashboard +- Right-click: Context menu +- On first run without config: auto-opens setup wizard in webview + +## 10. File Structure + +``` +tsc-bridge/ + main.go # Entry: systray + webview + HTTP server + auth.go # API key auth, credential validation, login flow + config.go # AppConfig with printer_dpi, auth state, whitelabel + dashboard.go # //go:embed dashboard.html + dashboard.html # Redesigned dashboard (~2000 lines, 5 tabs) + dpi.go # DPI detection: driver query + TSPL probe + manual + webview.go # Webview window lifecycle (open, close, navigate) + tray.go # Systray menu, icon states, actions + printer_darwin.go # macOS printer detection (CUPS) + printer_windows.go # Windows printer detection (WMI/Spooler) + printer_other.go # Linux printer detection + network.go # Network scanner, manual IPs + share.go # USB printer sharing via TCP + tspl_renderer.go # pdfme -> TSPL engine (DPI-aware) + batch.go # Excel upload, batch processing, PDF generation + presets.go # Label size presets + label_template.go # Template storage and pdfme compatibility + tls.go # Self-signed certificate generation + excel.go # Excel parsing (excelize) + driver.go # Driver detection abstraction + driver_darwin.go # macOS CUPS driver queries + driver_windows.go # Windows WMI driver queries + browser.go # Open-in-browser fallback + icon.go # Tray icon assets + go.mod + go.sum +``` + +## 11. Migration from v2.3.0 + +### What stays: +- All Go backend logic (printing, network, sharing, presets, TLS, batch, TSPL renderer) +- Config structure (extended with new fields) +- HTTP API endpoints (all existing ones preserved) + +### What changes: +- `main.go`: Add webview initialization, setup wizard flow +- `config.go`: Add `printer_dpi`, extend auth fields +- `dashboard.html`: Complete rewrite (remove redundancy, add 5-tab layout, designer, QR builder) +- New files: `dpi.go`, `webview.go`, `auth.go` + +### Config migration: +- v2 configs auto-migrate (new fields get defaults) +- Existing api_key/api_secret preserved +- printer_dpi populated on first printer detection + +## 12. Dependencies + +### Go modules (new): +- `github.com/webview/webview` β€” Native webview window +- (systray already present via getlantern/systray) + +### Existing (kept): +- `github.com/xuri/excelize/v2` β€” Excel parsing +- `github.com/getlantern/systray` β€” System tray +- Standard library for HTTP, TLS, crypto, image + +### Build: +- Windows: requires CGO + WebView2 SDK +- macOS: WebKit is built-in (no extra deps) +- Cross-compile via GitHub Actions (already set up) diff --git a/download_server.go b/download_server.go new file mode 100644 index 0000000..fdacbb3 --- /dev/null +++ b/download_server.go @@ -0,0 +1,139 @@ +package main + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// handleBridgeDownload generates a ZIP containing the bridge binary + pre-filled config. +// POST /bridge/download { "api_url", "api_key", "api_secret", "wl_id", "wl_name", "wl_logo_url", "wl_primary_color", "os": "windows"|"mac" } +func handleBridgeDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + ApiURL string `json:"api_url"` + ApiKey string `json:"api_key"` + ApiSecret string `json:"api_secret"` + WlID int `json:"wl_id"` + WlName string `json:"wl_name"` + WlLogoURL string `json:"wl_logo_url"` + WlPrimaryColor string `json:"wl_primary_color"` + WlAccentColor string `json:"wl_accent_color"` + OS string `json:"os"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + if req.ApiKey == "" || req.ApiSecret == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "api_key and api_secret required"}) + return + } + + targetOS := strings.ToLower(req.OS) + if targetOS == "" { + targetOS = runtime.GOOS + } + + // Find the binary to package + distDir := filepath.Join(filepath.Dir(os.Args[0]), "dist") + var binaryName, binaryPath string + + switch targetOS { + case "windows": + binaryName = "tsc-bridge.exe" + default: + binaryName = "tsc-bridge-mac" + } + + binaryPath = filepath.Join(distDir, binaryName) + if _, err := os.Stat(binaryPath); err != nil { + exe, _ := os.Executable() + binaryPath = exe + binaryName = filepath.Base(exe) + } + + if _, err := os.Stat(binaryPath); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "binary not found: " + binaryName}) + return + } + + // Generate config with encrypted secrets + encKey, _ := encryptString(req.ApiKey) + encSecret, _ := encryptString(req.ApiSecret) + + cfg := AppConfig{ + Port: 9638, + DefaultPreset: "matrix-3x1-30x22", + AutoStart: true, + NetworkScanEnabled: true, + NetworkScanInterval: 30, + ManualPrinters: []string{}, + CustomPresets: []LabelPreset{}, + SharePort: 9100, + ApiURL: req.ApiURL, + ApiKey: encKey, + ApiSecret: encSecret, + ApiWhiteLabel: req.WlID, + Whitelabel: WhitelabelConfig{ + ID: req.WlID, + Name: req.WlName, + LogoURL: req.WlLogoURL, + PrimaryColor: req.WlPrimaryColor, + AccentColor: req.WlAccentColor, + }, + } + + cfgJSON, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "config marshal: " + err.Error()}) + return + } + + zipName := fmt.Sprintf("tsc-bridge-%s.zip", targetOS) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, zipName)) + + zw := zip.NewWriter(w) + defer zw.Close() + + // Add binary + binaryFile, err := os.Open(binaryPath) + if err != nil { + log.Printf("[download] open binary: %v", err) + return + } + defer binaryFile.Close() + + binaryStat, _ := binaryFile.Stat() + header, _ := zip.FileInfoHeader(binaryStat) + header.Name = binaryName + header.Method = zip.Deflate + + bw, err := zw.CreateHeader(header) + if err != nil { + return + } + io.Copy(bw, binaryFile) + + // Add config.json + cw, err := zw.Create("config.json") + if err != nil { + return + } + cw.Write(cfgJSON) + + log.Printf("[download] Generated %s for WL=%d (%s)", zipName, req.WlID, req.WlName) +} diff --git a/dpi.go b/dpi.go new file mode 100644 index 0000000..85a047f --- /dev/null +++ b/dpi.go @@ -0,0 +1,265 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +// DPI detection regexes +var ( + reDPISimple = regexp.MustCompile(`(\d{3})\s*DPI`) + reDPIResolution = regexp.MustCompile(`[Rr]esolution[:\s]+(\d{3})\s*[xX]\s*(\d{3})`) +) + +// parseDPIFromTSCResponse extracts DPI from a TSC printer response string. +// Supports formats like "203 DPI", "300 DPI", "Resolution: 203x203", etc. +func parseDPIFromTSCResponse(response string) (int, bool) { + // Try "NNN DPI" format first + if m := reDPISimple.FindStringSubmatch(response); len(m) > 1 { + if dpi, err := strconv.Atoi(m[1]); err == nil && dpi > 0 { + return dpi, true + } + } + + // Try "Resolution: NNNxNNN" format + if m := reDPIResolution.FindStringSubmatch(response); len(m) > 1 { + if dpi, err := strconv.Atoi(m[1]); err == nil && dpi > 0 { + return dpi, true + } + } + + return 0, false +} + +// GetPrinterDPI returns the cached DPI for a printer, or defaultDPI if unknown. +func GetPrinterDPI(printerName string) int { + configMu.RLock() + defer configMu.RUnlock() + if entry, ok := appConfig.PrinterDPI[printerName]; ok { + return entry.DPI + } + return defaultDPI +} + +// SetPrinterDPI saves a DPI value for a printer to the config. +func SetPrinterDPI(printerName string, dpi int, source string) { + configMu.Lock() + if appConfig.PrinterDPI == nil { + appConfig.PrinterDPI = map[string]PrinterDPIEntry{} + } + appConfig.PrinterDPI[printerName] = PrinterDPIEntry{DPI: dpi, Source: source} + configMu.Unlock() + + if err := saveConfig(); err != nil { + log.Printf("[dpi] Failed to save config after setting DPI for %s: %v", printerName, err) + } + log.Printf("[dpi] Set %s DPI=%d (source=%s)", printerName, dpi, source) +} + +// probeTSPLForDPI sends a TSPL ~!I command to a network printer and parses DPI from the response. +func probeTSPLForDPI(addr string) (int, bool) { + conn, err := net.DialTimeout("tcp", addr, probeTimeout) + if err != nil { + return 0, false + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(probeTimeout)) + + // Send status/info command + _, err = conn.Write([]byte("~!I\r\n")) + if err != nil { + return 0, false + } + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil || n == 0 { + return 0, false + } + + return parseDPIFromTSCResponse(string(buf[:n])) +} + +// DetectPrinterDPI runs the DPI detection chain for a single printer: +// 1. Config cache (already known) +// 2. OS driver query (lpoptions on macOS, WMI on Windows) +// 3. TSPL probe (network printers only β€” send ~!I and parse response) +// 4. Default (203 DPI) +func DetectPrinterDPI(printer PrinterInfo) int { + // 1. Check config cache + configMu.RLock() + if entry, ok := appConfig.PrinterDPI[printer.Name]; ok { + configMu.RUnlock() + log.Printf("[dpi] %s: cached DPI=%d (source=%s)", printer.Name, entry.DPI, entry.Source) + return entry.DPI + } + configMu.RUnlock() + + // 2. Try OS driver query + if dpi, ok := queryDriverDPI(printer.Name); ok { + SetPrinterDPI(printer.Name, dpi, "driver") + return dpi + } + + // 3. Try TSPL probe (only for network printers with an address) + if printer.Address != "" { + if dpi, ok := probeTSPLForDPI(printer.Address); ok { + SetPrinterDPI(printer.Name, dpi, "tspl_probe") + return dpi + } + } + + // 4. Default + log.Printf("[dpi] %s: using default DPI=%d", printer.Name, defaultDPI) + return defaultDPI +} + +// DetectAllPrinterDPIs runs DPI detection for all known printers. +func DetectAllPrinterDPIs() { + printers, err := listAllPrinters() + if err != nil { + log.Printf("[dpi] Failed to list printers: %v", err) + return + } + + log.Printf("[dpi] Detecting DPI for %d printer(s)...", len(printers)) + for _, p := range printers { + DetectPrinterDPI(p) + } + log.Printf("[dpi] DPI detection complete") +} + +// handleDPIDetect handles POST /dpi/detect?printer=name +func handleDPIDetect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + printerName := r.URL.Query().Get("printer") + if printerName == "" { + // Detect all + DetectAllPrinterDPIs() + configMu.RLock() + flat := make(map[string]int, len(appConfig.PrinterDPI)) + for name, entry := range appConfig.PrinterDPI { + flat[name] = entry.DPI + } + configMu.RUnlock() + jsonResponse(w, http.StatusOK, map[string]any{ + "status": "detected", + "printer_dpi": flat, + "default_dpi": defaultDPI, + }) + return + } + + // Find the specific printer + printers, err := listAllPrinters() + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "failed to list printers: " + err.Error()}) + return + } + + printer := findPrinter(printerName, printers) + if printer == nil { + jsonResponse(w, http.StatusNotFound, map[string]string{"error": fmt.Sprintf("printer %q not found", printerName)}) + return + } + + dpi := DetectPrinterDPI(*printer) + jsonResponse(w, http.StatusOK, map[string]any{ + "status": "detected", + "printer": printerName, + "dpi": dpi, + }) +} + +// handleDPISet handles PUT /dpi?printer=name&dpi=300 +func handleDPISet(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + // Return current DPI map + configMu.RLock() + flat := make(map[string]int, len(appConfig.PrinterDPI)) + for name, entry := range appConfig.PrinterDPI { + flat[name] = entry.DPI + } + configMu.RUnlock() + jsonResponse(w, http.StatusOK, map[string]any{ + "printer_dpi": flat, + "default_dpi": defaultDPI, + }) + return + } + + if r.Method != http.MethodPut { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + printerName := r.URL.Query().Get("printer") + dpiStr := r.URL.Query().Get("dpi") + + // Also accept JSON body + if printerName == "" || dpiStr == "" { + var body struct { + Printer string `json:"printer"` + DPI int `json:"dpi"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err == nil { + if printerName == "" { + printerName = body.Printer + } + if dpiStr == "" && body.DPI > 0 { + dpiStr = strconv.Itoa(body.DPI) + } + } + } + + if printerName == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "printer name required"}) + return + } + if dpiStr == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "dpi value required"}) + return + } + + dpi, err := strconv.Atoi(dpiStr) + if err != nil || dpi <= 0 { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid dpi value"}) + return + } + + // Validate reasonable DPI range + if dpi < 100 || dpi > 600 { + jsonResponse(w, http.StatusBadRequest, map[string]string{ + "error": fmt.Sprintf("DPI %d out of range (100-600)", dpi), + }) + return + } + + SetPrinterDPI(printerName, dpi, "manual") + jsonResponse(w, http.StatusOK, map[string]any{ + "status": "saved", + "printer": printerName, + "dpi": dpi, + "source": "manual", + }) +} + +// stripPrinterName normalizes a printer name for comparison. +func stripPrinterName(name string) string { + // Remove common suffixes/prefixes for matching + name = strings.TrimSpace(name) + name = strings.ReplaceAll(name, " ", "-") + return strings.ToLower(name) +} diff --git a/dpi_darwin.go b/dpi_darwin.go new file mode 100644 index 0000000..814d701 --- /dev/null +++ b/dpi_darwin.go @@ -0,0 +1,68 @@ +//go:build darwin + +package main + +import ( + "log" + "os/exec" + "regexp" + "strconv" + "strings" +) + +var reLPResolution = regexp.MustCompile(`(?i)resolution.*?(\d{3})`) + +// queryDriverDPI queries the CUPS driver for a printer's DPI on macOS. +// Uses lpoptions -p -l to list available options and find Resolution. +func queryDriverDPI(printerName string) (int, bool) { + // Normalize printer name for CUPS (spaces become dashes) + cupsName := strings.ReplaceAll(printerName, " ", "-") + + out, err := exec.Command("lpoptions", "-p", cupsName, "-l").Output() + if err != nil { + log.Printf("[dpi] lpoptions for %s failed: %v", cupsName, err) + return 0, false + } + + response := string(out) + + // Look for Resolution option line, e.g.: + // Resolution/Resolution: *203dpi 300dpi + // Resolution/Output Resolution: 203x203dpi *300x300dpi + for _, line := range strings.Split(response, "\n") { + lower := strings.ToLower(line) + if !strings.Contains(lower, "resolution") { + continue + } + + // Find the default value (marked with *) + parts := strings.SplitN(line, ":", 2) + if len(parts) < 2 { + continue + } + options := strings.Fields(parts[1]) + for _, opt := range options { + if strings.HasPrefix(opt, "*") { + // Extract numeric DPI from the default option, e.g. "*203dpi" or "*300x300dpi" + numStr := strings.TrimPrefix(opt, "*") + numStr = strings.Split(numStr, "x")[0] + numStr = strings.Split(numStr, "d")[0] + numStr = strings.Split(numStr, "D")[0] + if dpi, err := strconv.Atoi(numStr); err == nil && dpi > 0 { + log.Printf("[dpi] CUPS driver reports %s DPI=%d", cupsName, dpi) + return dpi, true + } + } + } + + // Fallback: try regex on the whole line + if m := reLPResolution.FindStringSubmatch(line); len(m) > 1 { + if dpi, err := strconv.Atoi(m[1]); err == nil && dpi > 0 { + log.Printf("[dpi] CUPS driver (regex) reports %s DPI=%d", cupsName, dpi) + return dpi, true + } + } + } + + return 0, false +} diff --git a/dpi_other.go b/dpi_other.go new file mode 100644 index 0000000..d864a21 --- /dev/null +++ b/dpi_other.go @@ -0,0 +1,8 @@ +//go:build !darwin && !windows + +package main + +// queryDriverDPI is a stub for unsupported platforms. +func queryDriverDPI(printerName string) (int, bool) { + return 0, false +} diff --git a/dpi_test.go b/dpi_test.go new file mode 100644 index 0000000..b51911e --- /dev/null +++ b/dpi_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "testing" +) + +func TestParseDPIFromTSCResponse(t *testing.T) { + tests := []struct { + name string + input string + wantDPI int + wantOK bool + }{ + { + name: "simple 203 DPI", + input: "203 DPI", + wantDPI: 203, + wantOK: true, + }, + { + name: "simple 300 DPI", + input: "300 DPI", + wantDPI: 300, + wantOK: true, + }, + { + name: "resolution format 203x203", + input: "Resolution: 203x203", + wantDPI: 203, + wantOK: true, + }, + { + name: "resolution format 300x300", + input: "Resolution: 300X300", + wantDPI: 300, + wantOK: true, + }, + { + name: "embedded in longer response", + input: "TSC TDP-244 Pro\nFirmware: V1.2\n203 DPI\nRAM: 32MB", + wantDPI: 203, + wantOK: true, + }, + { + name: "resolution lowercase", + input: "resolution: 300x300", + wantDPI: 300, + wantOK: true, + }, + { + name: "no DPI info", + input: "TSC TDP-244 Pro\nFirmware: V1.2", + wantDPI: 0, + wantOK: false, + }, + { + name: "empty response", + input: "", + wantDPI: 0, + wantOK: false, + }, + { + name: "DPI with extra whitespace", + input: " 203 DPI ", + wantDPI: 203, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dpi, ok := parseDPIFromTSCResponse(tt.input) + if ok != tt.wantOK { + t.Errorf("parseDPIFromTSCResponse(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + if dpi != tt.wantDPI { + t.Errorf("parseDPIFromTSCResponse(%q) dpi = %d, want %d", tt.input, dpi, tt.wantDPI) + } + }) + } +} + +func TestDetectDPIChain(t *testing.T) { + // Setup: clean config + configMu.Lock() + origConfig := appConfig + appConfig = defaultConfig() + configMu.Unlock() + + defer func() { + configMu.Lock() + appConfig = origConfig + configMu.Unlock() + }() + + t.Run("cached value returns immediately", func(t *testing.T) { + // Pre-populate cache + configMu.Lock() + appConfig.PrinterDPI["CachedPrinter"] = PrinterDPIEntry{DPI: 300, Source: "manual"} + configMu.Unlock() + + printer := PrinterInfo{Name: "CachedPrinter", Type: "usb"} + dpi := DetectPrinterDPI(printer) + if dpi != 300 { + t.Errorf("expected cached DPI 300, got %d", dpi) + } + }) + + t.Run("unknown printer returns defaultDPI", func(t *testing.T) { + printer := PrinterInfo{Name: "UnknownPrinter-XYZ-12345", Type: "usb"} + dpi := DetectPrinterDPI(printer) + if dpi != defaultDPI { + t.Errorf("expected default DPI %d, got %d", defaultDPI, dpi) + } + }) + + t.Run("GetPrinterDPI returns cached value", func(t *testing.T) { + configMu.Lock() + appConfig.PrinterDPI["TestGetDPI"] = PrinterDPIEntry{DPI: 300, Source: "driver"} + configMu.Unlock() + + dpi := GetPrinterDPI("TestGetDPI") + if dpi != 300 { + t.Errorf("expected 300, got %d", dpi) + } + }) + + t.Run("GetPrinterDPI returns default for unknown", func(t *testing.T) { + dpi := GetPrinterDPI("NonExistentPrinter-99999") + if dpi != defaultDPI { + t.Errorf("expected default DPI %d, got %d", defaultDPI, dpi) + } + }) +} diff --git a/dpi_windows.go b/dpi_windows.go new file mode 100644 index 0000000..c6a77ea --- /dev/null +++ b/dpi_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package main + +import ( + "log" + "os/exec" + "strconv" + "strings" +) + +// queryDriverDPI queries the Windows print driver for a printer's DPI via PowerShell WMI. +func queryDriverDPI(printerName string) (int, bool) { + // Use WMI to query the printer's print capabilities + psCmd := `Get-CimInstance -ClassName Win32_Printer -Filter "Name='` + printerName + `'" | Select-Object -ExpandProperty HorizontalResolution` + + cmd := exec.Command("powershell", "-NoProfile", "-Command", psCmd) + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + log.Printf("[dpi] PowerShell WMI query for %s failed: %v", printerName, err) + return 0, false + } + + dpiStr := strings.TrimSpace(string(out)) + if dpiStr == "" { + return 0, false + } + + dpi, err := strconv.Atoi(dpiStr) + if err != nil || dpi <= 0 { + return 0, false + } + + log.Printf("[dpi] Windows driver reports %s DPI=%d", printerName, dpi) + return dpi, true +} diff --git a/driver.go b/driver.go new file mode 100644 index 0000000..795ed35 --- /dev/null +++ b/driver.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "net/http" + "runtime" + "sync" +) + +// DriverStatus describes the state of TSC drivers and USB devices on this machine. +type DriverStatus struct { + DriversInstalled bool `json:"drivers_installed"` + DriverNames []string `json:"driver_names,omitempty"` + USBDevices []USBDevice `json:"usb_devices,omitempty"` + RegisteredPrinters []string `json:"registered_printers,omitempty"` + NeedsSetup bool `json:"needs_setup"` + CanAutoInstall bool `json:"can_auto_install"` + OS string `json:"os"` + Instructions string `json:"instructions,omitempty"` + DownloadURL string `json:"download_url,omitempty"` +} + +// USBDevice represents a detected TSC USB device. +type USBDevice struct { + VendorID int `json:"vendor_id"` + ProductID int `json:"product_id"` + Name string `json:"name"` +} + +// DriverSetupRequest is the payload for POST /driver/setup. +type DriverSetupRequest struct { + Action string `json:"action"` // "register" | "download" | "full-setup" +} + +// Download progress tracking +var ( + driverDownloadProgress int + driverDownloadMu sync.Mutex +) + +// Official TSC driver download URLs +const ( + tscDriverURLMac = "https://usca.tscprinters.com/en/downloads" + tscDriverURLWindows = "https://usca.tscprinters.com/en/downloads" +) + +// handleDriverStatus returns the current state of TSC drivers on this machine. +func handleDriverStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + status := detectTSCDrivers() + status.OS = runtime.GOOS + jsonResponse(w, http.StatusOK, status) +} + +// handleDriverSetup runs a driver setup action. +func handleDriverSetup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + var req DriverSetupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + result, err := runDriverSetup(req.Action) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + "action": req.Action, + }) + return + } + jsonResponse(w, http.StatusOK, result) +} + +// handleDriverProgress returns the current download progress (0-100). +func handleDriverProgress(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + driverDownloadMu.Lock() + p := driverDownloadProgress + driverDownloadMu.Unlock() + jsonResponse(w, http.StatusOK, map[string]any{"progress": p}) +} diff --git a/driver_darwin.go b/driver_darwin.go new file mode 100644 index 0000000..874ce43 --- /dev/null +++ b/driver_darwin.go @@ -0,0 +1,240 @@ +//go:build darwin + +package main + +import ( + "fmt" + "log" + "os/exec" + "strings" +) + +// detectTSCDrivers checks for TSC drivers, USB devices, and registered printers on macOS. +func detectTSCDrivers() DriverStatus { + status := DriverStatus{ + DownloadURL: tscDriverURLMac, + } + + // 1. Check for TSC PPDs (driver files) + ppds := detectTSCPPDs() + if len(ppds) > 0 { + status.DriversInstalled = true + status.DriverNames = ppds + } + + // 2. Enumerate USB devices with TSC vendor ID (0x1203) + status.USBDevices = enumerateTSCUSB() + + // 3. Check registered CUPS printers + status.RegisteredPrinters = detectTSCCUPSPrinters() + + // Determine if setup is needed + hasUSB := len(status.USBDevices) > 0 + hasRegistered := len(status.RegisteredPrinters) > 0 + + if hasUSB && !hasRegistered { + status.NeedsSetup = true + status.CanAutoInstall = true + status.Instructions = "Impresora TSC detectada por USB pero no registrada en el sistema. Puede registrarse automaticamente en modo RAW (sin driver necesario)." + } else if !hasUSB && !hasRegistered { + status.NeedsSetup = false + status.Instructions = "No se detectaron impresoras TSC por USB. Conecte la impresora e intente de nuevo." + } + + return status +} + +// detectTSCPPDs checks for TSC PPD files and available CUPS drivers. +func detectTSCPPDs() []string { + var drivers []string + + // Check lpinfo for available TSC PPDs + out, err := exec.Command("lpinfo", "-m").Output() + if err == nil { + for _, line := range strings.Split(string(out), "\n") { + lower := strings.ToLower(line) + if strings.Contains(lower, "tsc") { + fields := strings.Fields(line) + if len(fields) > 0 { + drivers = append(drivers, strings.TrimSpace(line)) + } + } + } + } + + return drivers +} + +// enumerateTSCUSB checks if any TSC USB device (vendor 0x1203) is connected. +// Uses the existing libusb C code for the known PID, plus system_profiler for discovery. +func enumerateTSCUSB() []USBDevice { + var devices []USBDevice + + // Quick check with known PID via existing C code + if usbDeviceExists() { + devices = append(devices, USBDevice{ + VendorID: 0x1203, + ProductID: 0x0133, + Name: "TSC TDP-244 Plus", + }) + } + + // Also check system_profiler for other TSC models + out, err := exec.Command("system_profiler", "SPUSBDataType", "-detailLevel", "mini").Output() + if err == nil { + lines := strings.Split(string(out), "\n") + for i, line := range lines { + lower := strings.ToLower(line) + if strings.Contains(lower, "tsc") || strings.Contains(lower, "vendor id: 0x1203") { + // Try to extract a meaningful name + name := strings.TrimSpace(line) + if strings.Contains(lower, "vendor id") { + // Look backwards for the device name + for j := i - 1; j >= 0 && j >= i-5; j-- { + trimmed := strings.TrimSpace(lines[j]) + if trimmed != "" && !strings.Contains(trimmed, ":") { + name = trimmed + break + } + } + } + // Avoid duplicating the known device + isDup := false + for _, d := range devices { + if d.Name == name || (d.VendorID == 0x1203 && d.ProductID == 0x0133) { + isDup = true + break + } + } + if !isDup && name != "" { + devices = append(devices, USBDevice{ + VendorID: 0x1203, + Name: name, + }) + } + } + } + } + + return devices +} + +// usbDeviceExists wraps the existing C call to check for the known TSC device. +func usbDeviceExists() bool { + // Use the C function from printer_other.go + return C_usb_device_exists() +} + +// detectTSCCUPSPrinters lists CUPS printers that appear to be TSC. +func detectTSCCUPSPrinters() []string { + var printers []string + out, err := exec.Command("lpstat", "-a").Output() + if err != nil { + return printers + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) == 0 { + continue + } + name := fields[0] + lower := strings.ToLower(name) + for _, kw := range []string{"tsc", "tdp", "ttp", "te2", "te3"} { + if strings.Contains(lower, kw) { + printers = append(printers, name) + break + } + } + } + return printers +} + +// runDriverSetup executes a driver setup action on macOS. +func runDriverSetup(action string) (map[string]any, error) { + switch action { + case "register": + return registerTSCPrinterCUPS() + case "download": + return map[string]any{ + "status": "redirect", + "download_url": tscDriverURLMac, + "message": "Descargue los drivers desde el sitio oficial de TSC", + }, nil + case "full-setup": + // Register in RAW mode (no driver needed for TSPL) + return registerTSCPrinterCUPS() + default: + return nil, fmt.Errorf("unknown action: %s", action) + } +} + +// registerTSCPrinterCUPS registers a TSC USB printer in CUPS using raw mode. +// This does NOT require a driver β€” CUPS sends data as-is to the printer. +func registerTSCPrinterCUPS() (map[string]any, error) { + // Discover USB URI + uri := discoverTSCURI() + if uri == "" { + return nil, fmt.Errorf("no se encontro URI de impresora TSC. Verifique la conexion USB") + } + + printerName := "TSC-TDP-244-Plus" + log.Printf("[driver] Registering TSC printer: name=%s uri=%s", printerName, uri) + + // Register with CUPS in raw mode (no PPD needed) + // -E enables the printer and accepts jobs + // -m raw means no filter β€” data is sent as-is (perfect for TSPL) + cmd := exec.Command("lpadmin", "-p", printerName, "-E", "-v", uri, "-m", "raw") + output, err := cmd.CombinedOutput() + if err != nil { + // Try "everywhere" model as fallback + cmd2 := exec.Command("lpadmin", "-p", printerName, "-E", "-v", uri, "-m", "everywhere") + output2, err2 := cmd2.CombinedOutput() + if err2 != nil { + return nil, fmt.Errorf("lpadmin failed: %v β€” %s / fallback: %v β€” %s", err, string(output), err2, string(output2)) + } + } + + // Enable the printer + exec.Command("cupsenable", printerName).Run() + exec.Command("cupsaccept", printerName).Run() + + log.Printf("[driver] TSC printer registered successfully: %s", printerName) + return map[string]any{ + "status": "registered", + "printer": printerName, + "uri": uri, + "mode": "raw", + "message": "Impresora TSC registrada en modo RAW. Lista para imprimir TSPL.", + }, nil +} + +// discoverTSCURI finds the USB URI for a connected TSC printer. +func discoverTSCURI() string { + out, err := exec.Command("lpinfo", "-v").Output() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + lower := strings.ToLower(line) + if strings.Contains(lower, "tsc") && strings.Contains(lower, "usb://") { + fields := strings.Fields(line) + for _, f := range fields { + if strings.HasPrefix(f, "usb://") { + return f + } + } + } + } + // Fallback: look for vendor 1203 + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "usb://") && strings.Contains(line, "1203") { + fields := strings.Fields(line) + for _, f := range fields { + if strings.HasPrefix(f, "usb://") { + return f + } + } + } + } + return "" +} diff --git a/driver_other.go b/driver_other.go new file mode 100644 index 0000000..e6a37ff --- /dev/null +++ b/driver_other.go @@ -0,0 +1,17 @@ +//go:build !darwin && !windows + +package main + +import "fmt" + +// detectTSCDrivers stub for unsupported platforms. +func detectTSCDrivers() DriverStatus { + return DriverStatus{ + Instructions: "Deteccion de drivers TSC no soportada en esta plataforma.", + } +} + +// runDriverSetup stub for unsupported platforms. +func runDriverSetup(action string) (map[string]any, error) { + return nil, fmt.Errorf("driver setup not supported on this platform") +} diff --git a/driver_windows.go b/driver_windows.go new file mode 100644 index 0000000..6e28341 --- /dev/null +++ b/driver_windows.go @@ -0,0 +1,180 @@ +//go:build windows + +package main + +import ( + "fmt" + "log" + "os/exec" + "strings" +) + +// detectTSCDrivers checks for TSC drivers, USB devices, and registered printers on Windows. +func detectTSCDrivers() DriverStatus { + status := DriverStatus{ + DownloadURL: tscDriverURLWindows, + } + + // 1. Check installed printer drivers + status.DriverNames = detectTSCWindowsDrivers() + status.DriversInstalled = len(status.DriverNames) > 0 + + // 2. Detect TSC USB devices via PnP + status.USBDevices = detectTSCWindowsUSB() + + // 3. Check registered printers + status.RegisteredPrinters = detectTSCWindowsPrinters() + + // Determine setup needs + hasUSB := len(status.USBDevices) > 0 + hasRegistered := len(status.RegisteredPrinters) > 0 + hasDrivers := status.DriversInstalled + + if hasUSB && !hasRegistered && hasDrivers { + status.NeedsSetup = true + status.CanAutoInstall = true + status.Instructions = "Driver TSC instalado y dispositivo detectado, pero no hay impresora registrada. Se puede registrar automaticamente." + } else if hasUSB && !hasDrivers { + status.NeedsSetup = true + status.CanAutoInstall = false + status.Instructions = "Dispositivo TSC detectado pero no hay driver instalado. Descargue el driver desde el sitio oficial de TSC." + } else if !hasUSB && !hasRegistered { + status.Instructions = "No se detectaron dispositivos TSC. Conecte la impresora e intente de nuevo." + } + + return status +} + +// detectTSCWindowsDrivers lists installed TSC printer drivers via PowerShell. +func detectTSCWindowsDrivers() []string { + var drivers []string + cmd := exec.Command("powershell", "-NoProfile", "-Command", + "Get-PrinterDriver | Where-Object {$_.Name -like '*TSC*'} | Select-Object -ExpandProperty Name") + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + return drivers + } + for _, line := range strings.Split(string(out), "\n") { + name := strings.TrimSpace(line) + if name != "" { + drivers = append(drivers, name) + } + } + return drivers +} + +// detectTSCWindowsUSB detects TSC USB devices via PnP. +func detectTSCWindowsUSB() []USBDevice { + var devices []USBDevice + cmd := exec.Command("powershell", "-NoProfile", "-Command", + "Get-PnpDevice -Class Printer -Status OK -ErrorAction SilentlyContinue | Where-Object {$_.FriendlyName -like '*TSC*'} | Select-Object -ExpandProperty FriendlyName") + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + // Fallback: check all USB devices for vendor 1203 + cmd2 := exec.Command("powershell", "-NoProfile", "-Command", + "Get-PnpDevice -Status OK -ErrorAction SilentlyContinue | Where-Object {$_.InstanceId -like '*VID_1203*'} | Select-Object -ExpandProperty FriendlyName") + hideWindow(cmd2) + out, err = cmd2.Output() + if err != nil { + return devices + } + } + for _, line := range strings.Split(string(out), "\n") { + name := strings.TrimSpace(line) + if name != "" { + devices = append(devices, USBDevice{ + VendorID: 0x1203, + Name: name, + }) + } + } + return devices +} + +// detectTSCWindowsPrinters lists registered printers that appear to be TSC. +func detectTSCWindowsPrinters() []string { + var printers []string + cmd := exec.Command("powershell", "-NoProfile", "-Command", + "Get-Printer | Where-Object {$_.Name -like '*TSC*' -or $_.DriverName -like '*TSC*'} | Select-Object -ExpandProperty Name") + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + return printers + } + for _, line := range strings.Split(string(out), "\n") { + name := strings.TrimSpace(line) + if name != "" { + printers = append(printers, name) + } + } + return printers +} + +// runDriverSetup executes a driver setup action on Windows. +func runDriverSetup(action string) (map[string]any, error) { + switch action { + case "register": + return registerTSCPrinterWindows() + case "download": + return map[string]any{ + "status": "redirect", + "download_url": tscDriverURLWindows, + "message": "Descargue los drivers desde el sitio oficial de TSC", + }, nil + case "full-setup": + return registerTSCPrinterWindows() + default: + return nil, fmt.Errorf("unknown action: %s", action) + } +} + +// registerTSCPrinterWindows registers a TSC printer in the Windows spooler. +func registerTSCPrinterWindows() (map[string]any, error) { + // Find an available TSC driver + drivers := detectTSCWindowsDrivers() + if len(drivers) == 0 { + return nil, fmt.Errorf("no TSC driver installed. Descargue el driver desde: %s", tscDriverURLWindows) + } + + driverName := drivers[0] + printerName := "TSC-TDP-244-Plus" + + // Find available USB port + portName := findTSCUSBPort() + if portName == "" { + portName = "USB001" // fallback + } + + log.Printf("[driver] Registering TSC printer: name=%s driver=%s port=%s", printerName, driverName, portName) + + cmd := exec.Command("powershell", "-NoProfile", "-Command", + fmt.Sprintf(`Add-Printer -Name "%s" -DriverName "%s" -PortName "%s" -ErrorAction Stop`, printerName, driverName, portName)) + hideWindow(cmd) + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("Add-Printer failed: %v β€” %s", err, string(output)) + } + + log.Printf("[driver] TSC printer registered successfully: %s", printerName) + return map[string]any{ + "status": "registered", + "printer": printerName, + "driver": driverName, + "port": portName, + "message": "Impresora TSC registrada correctamente.", + }, nil +} + +// findTSCUSBPort finds the USB port where a TSC printer is connected. +func findTSCUSBPort() string { + cmd := exec.Command("powershell", "-NoProfile", "-Command", + "Get-PrinterPort | Where-Object {$_.Name -like 'USB*'} | Select-Object -First 1 -ExpandProperty Name") + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/excel.go b/excel.go new file mode 100644 index 0000000..b526904 --- /dev/null +++ b/excel.go @@ -0,0 +1,76 @@ +package main + +import ( + "bytes" + "fmt" + "strings" + + "github.com/xuri/excelize/v2" +) + +// ExcelData holds the parsed contents of an Excel file. +type ExcelData struct { + Headers []string `json:"headers"` + Rows []map[string]string `json:"rows"` +} + +// ParseExcel reads the first sheet of an xlsx file and returns headers + rows. +// Row 1 is treated as headers (variable names). Rows 2+ are data. +func ParseExcel(fileBytes []byte) (*ExcelData, error) { + f, err := excelize.OpenReader(bytes.NewReader(fileBytes)) + if err != nil { + return nil, fmt.Errorf("open excel: %w", err) + } + defer f.Close() + + sheetName := f.GetSheetName(0) + if sheetName == "" { + return nil, fmt.Errorf("no sheets found") + } + + rows, err := f.GetRows(sheetName) + if err != nil { + return nil, fmt.Errorf("read rows: %w", err) + } + if len(rows) < 1 { + return nil, fmt.Errorf("empty sheet") + } + + // Row 0 = headers + headers := make([]string, len(rows[0])) + for i, h := range rows[0] { + headers[i] = strings.TrimSpace(h) + } + + // Rows 1+ = data + data := make([]map[string]string, 0, len(rows)-1) + for _, row := range rows[1:] { + record := make(map[string]string, len(headers)) + for i, header := range headers { + if header == "" { + continue + } + val := "" + if i < len(row) { + val = strings.TrimSpace(row[i]) + } + record[header] = val + } + // Skip completely empty rows + empty := true + for _, v := range record { + if v != "" { + empty = false + break + } + } + if !empty { + data = append(data, record) + } + } + + return &ExcelData{ + Headers: headers, + Rows: data, + }, nil +} diff --git a/filedialog.go b/filedialog.go new file mode 100644 index 0000000..2bb4fca --- /dev/null +++ b/filedialog.go @@ -0,0 +1,157 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// GET /native-file-dialog?type=excel|pdf|image|json +// Opens a native OS file picker and returns the file contents + metadata. +// This is needed because WKWebView does not support . +func handleNativeFileDialog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + + fileType := r.URL.Query().Get("type") + filePath, err := openNativeFileDialog(fileType) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if filePath == "" { + // User cancelled + jsonResponse(w, http.StatusOK, map[string]any{"cancelled": true}) + return + } + + data, err := os.ReadFile(filePath) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "read file: " + err.Error()}) + return + } + + log.Printf("[file-dialog] User selected: %s (%d bytes)", filepath.Base(filePath), len(data)) + + resp := map[string]any{ + "cancelled": false, + "filename": filepath.Base(filePath), + "path": filePath, + "size": len(data), + "data": base64.StdEncoding.EncodeToString(data), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// GET /native-download?file=batch_123.pdf&action=open|reveal +// Copies file from output dir to ~/Downloads and opens it (or reveals in Finder). +// Needed because WKWebView cannot download files. +func handleNativeDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + + filename := r.URL.Query().Get("file") + if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + action := r.URL.Query().Get("action") + if action == "" { + action = "open" + } + + srcPath := filepath.Join(configDir(), "output", filename) + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + jsonResponse(w, http.StatusNotFound, map[string]string{"error": "file not found"}) + return + } + + // Copy to ~/Downloads + homeDir, _ := os.UserHomeDir() + downloadsDir := filepath.Join(homeDir, "Downloads") + destPath := filepath.Join(downloadsDir, filename) + + data, err := os.ReadFile(srcPath) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "read: " + err.Error()}) + return + } + if err := os.WriteFile(destPath, data, 0644); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "write: " + err.Error()}) + return + } + + log.Printf("[download] Copied %s to %s", filename, destPath) + + // Open or reveal + switch runtime.GOOS { + case "darwin": + if action == "reveal" { + exec.Command("open", "-R", destPath).Start() + } else { + exec.Command("open", destPath).Start() + } + case "windows": + if action == "reveal" { + exec.Command("explorer", "/select,", destPath).Start() + } else { + exec.Command("cmd", "/c", "start", "", destPath).Start() + } + } + + jsonResponse(w, http.StatusOK, map[string]any{ + "path": destPath, + "filename": filename, + "action": action, + }) +} + +func openNativeFileDialog(fileType string) (string, error) { + switch runtime.GOOS { + case "darwin": + return openFileDialogDarwin(fileType) + default: + return "", nil + } +} + +func openFileDialogDarwin(fileType string) (string, error) { + // Build osascript to open a native file dialog + var typeFilter string + switch fileType { + case "excel": + typeFilter = `of type {"xlsx", "xls", "csv"}` + case "pdf": + typeFilter = `of type {"pdf"}` + case "image": + typeFilter = `of type {"png", "jpg", "jpeg", "gif", "bmp"}` + case "json": + typeFilter = `of type {"json"}` + default: + typeFilter = "" + } + + script := `POSIX path of (choose file ` + typeFilter + ` with prompt "Seleccionar archivo")` + cmd := exec.Command("osascript", "-e", script) + out, err := cmd.Output() + if err != nil { + // User cancelled or error + if strings.Contains(err.Error(), "exit status") { + return "", nil // cancelled + } + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/go.mod b/go.mod index 0b6f01f..3cb1c76 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,26 @@ module tsc-bridge go 1.25.6 require ( - github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 + fyne.io/systray v1.12.0 + github.com/boombuler/barcode v1.1.0 + github.com/signintech/gopdf v0.36.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 + github.com/xuri/excelize/v2 v2.9.0 + golang.org/x/image v0.18.0 ) require ( - github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect - golang.org/x/sys v0.1.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 // indirect + github.com/pkg/errors v0.8.1 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect + github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect + github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index fd1b756..863dd55 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,47 @@ -github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo= -github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo= -github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= -github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 h1:zyWXQ6vu27ETMpYsEMAsisQ+GqJ4e1TPvSNfdOPF0no= +github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/signintech/gopdf v0.36.0 h1:/7gPwoLtlNv5tPNpYuo3T3z0mWgo62pTrCvVNAiOo2Q= +github.com/signintech/gopdf v0.36.0/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0= github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk= -golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY= +github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE= +github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE= +github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A= +github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gui_darwin.go b/gui_darwin.go deleted file mode 100644 index 81c1507..0000000 --- a/gui_darwin.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build darwin - -package main - -import webview "github.com/webview/webview_go" - -// openGUI opens a native WebKit window with the dashboard. -func openGUI(url string) { - setAppIcon() - w := webview.New(false) - defer w.Destroy() - w.SetTitle("TSC Bridge") - w.SetSize(1060, 720, webview.HintNone) - w.Navigate(url) - w.Run() -} diff --git a/gui_other.go b/gui_other.go deleted file mode 100644 index 4824d1e..0000000 --- a/gui_other.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !darwin && !windows - -package main - -// openGUI falls back to opening the URL in the default browser on Linux/other. -func openGUI(url string) { - openBrowser(url) -} diff --git a/gui_windows.go b/gui_windows.go deleted file mode 100644 index d13fdfd..0000000 --- a/gui_windows.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build windows - -package main - -import ( - "github.com/jchv/go-webview2" -) - -// openGUI opens a native Edge WebView2 window with the dashboard. -func openGUI(url string) { - setAppIcon() - w := webview2.NewWithOptions(webview2.WebViewOptions{ - Debug: false, - AutoFocus: true, - WindowOptions: webview2.WindowOptions{ - Title: "TSC Bridge", - Width: 1060, - Height: 720, - }, - }) - if w == nil { - // WebView2 not available β€” fall back to default browser - openBrowser(url) - return - } - defer w.Destroy() - w.Navigate(url) - w.Run() -} diff --git a/icon.go b/icon.go index be19f56..c6c67bc 100644 --- a/icon.go +++ b/icon.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/binary" "image" "image/color" "image/png" @@ -22,63 +23,226 @@ func getAppIconPNG() []byte { return appIconPNG } +// generateICO creates a Windows .ico file with multiple sizes embedded as PNG. +func generateICO(sizes []int) []byte { + type entry struct { + data []byte + width int + height int + } + entries := make([]entry, len(sizes)) + for i, sz := range sizes { + entries[i] = entry{data: generateAppIcon(sz), width: sz, height: sz} + } + + var buf bytes.Buffer + // ICO header: reserved(2) + type(2) + count(2) + binary.Write(&buf, binary.LittleEndian, uint16(0)) // reserved + binary.Write(&buf, binary.LittleEndian, uint16(1)) // type = ICO + binary.Write(&buf, binary.LittleEndian, uint16(len(entries))) // count + + // Calculate offsets: header(6) + entries(16 each) + data + dataOffset := 6 + 16*len(entries) + for _, e := range entries { + w := uint8(e.width) + h := uint8(e.height) + if e.width >= 256 { + w = 0 // 0 means 256 in ICO format + } + if e.height >= 256 { + h = 0 + } + buf.WriteByte(w) // width + buf.WriteByte(h) // height + buf.WriteByte(0) // color palette + buf.WriteByte(0) // reserved + binary.Write(&buf, binary.LittleEndian, uint16(1)) // color planes + binary.Write(&buf, binary.LittleEndian, uint16(32)) // bits per pixel + binary.Write(&buf, binary.LittleEndian, uint32(len(e.data))) // size + binary.Write(&buf, binary.LittleEndian, uint32(dataOffset)) // offset + dataOffset += len(e.data) + } + for _, e := range entries { + buf.Write(e.data) + } + return buf.Bytes() +} + func generateAppIcon(size int) []byte { img := image.NewRGBA(image.Rect(0, 0, size, size)) s := float64(size) - bg := color.RGBA{15, 17, 23, 255} - bgLight := color.RGBA{22, 24, 34, 255} - surface := color.RGBA{30, 34, 48, 255} - accent := color.RGBA{108, 114, 255, 255} - accentDim := color.RGBA{108, 114, 255, 80} - green := color.RGBA{52, 211, 153, 255} - blue := color.RGBA{96, 165, 250, 255} - paper := color.RGBA{220, 224, 236, 255} - - // Fill background - iconFillAll(img, bg) - - // Rounded background card - iconFillRoundedRect(img, i(s*0.04), i(s*0.04), i(s*0.96), i(s*0.96), i(s*0.16), bgLight) - - // Printer body - bx, by := i(s*0.14), i(s*0.28) - bx2, by2 := i(s*0.86), i(s*0.68) - iconFillRoundedRect(img, bx, by, bx2, by2, i(s*0.06), surface) - iconStrokeRoundedRect(img, bx, by, bx2, by2, i(s*0.06), accent, 2) - - // Paper slot (inner dark area) - iconFillRect(img, i(s*0.22), i(s*0.34), i(s*0.78), i(s*0.48), bg) - iconStrokeRect(img, i(s*0.22), i(s*0.34), i(s*0.78), i(s*0.48), accent, 1) - - // Label lines inside slot - iconFillRect(img, i(s*0.26), i(s*0.37), i(s*0.60), i(s*0.40), accentDim) - iconFillRect(img, i(s*0.26), i(s*0.42), i(s*0.52), i(s*0.44), accentDim) - - // Paper output (label coming out bottom) - iconFillRoundedRect(img, i(s*0.20), i(s*0.66), i(s*0.80), i(s*0.82), i(s*0.03), paper) - - // Barcode on paper - barcodeY := i(s * 0.71) - barcodeH := i(s * 0.07) - for j := 0; j < 12; j++ { - bw := 2 - if j%3 == 0 { - bw = 3 + // For tray icons (small sizes), use macOS template style: black on transparent + // macOS template icons: black pixels = visible, alpha = shape mask + isTray := size <= 64 + if isTray { + black := color.RGBA{0, 0, 0, 255} + transparent := color.RGBA{0, 0, 0, 0} + + // Printer body β€” chunky recognizable silhouette + iconFillRoundedRect(img, i(s*0.08), i(s*0.20), i(s*0.92), i(s*0.62), imax(1, i(s*0.05)), black) + + // Paper input tray on top + iconFillRect(img, i(s*0.20), i(s*0.10), i(s*0.80), i(s*0.22), black) + + // Paper slot cutout (transparent hole in body) + iconFillRect(img, i(s*0.18), i(s*0.28), i(s*0.82), i(s*0.50), transparent) + + // Two text lines inside slot + iconFillRect(img, i(s*0.24), i(s*0.32), i(s*0.64), i(s*0.37), black) + iconFillRect(img, i(s*0.24), i(s*0.40), i(s*0.54), i(s*0.44), black) + + // Paper/label output below printer + iconFillRoundedRect(img, i(s*0.14), i(s*0.58), i(s*0.86), i(s*0.90), imax(1, i(s*0.03)), black) + + // Barcode lines on paper + bw := imax(1, i(s*0.04)) + for j := 0; j < 6; j++ { + w := bw + if j%2 == 0 { + w = bw + imax(1, i(s*0.02)) + } + bx := i(s*0.24) + j*i(s*0.08) + iconFillRect(img, bx, i(s*0.66), bx+w, i(s*0.80), transparent) } - bx := i(s*0.28) + j*i(s*0.035) - iconFillRect(img, bx, barcodeY, bx+bw, barcodeY+barcodeH, bg) - } - // LED indicator - iconFillCircle(img, i(s*0.76), i(s*0.36), i(s*0.025), green) + // Status LED dot + iconFillCircle(img, i(s*0.82), i(s*0.16), imax(1, i(s*0.06)), black) + } else { + // Full color icon for app/dock β€” professional dark theme + bg := color.RGBA{13, 17, 23, 255} // GitHub dark #0D1117 + bgInner := color.RGBA{22, 27, 34, 255} // #161B22 + surface := color.RGBA{33, 38, 45, 255} // #21262D + surfaceHL := color.RGBA{48, 54, 61, 255} // #30363D + accent := color.RGBA{110, 118, 255, 255} // Indigo blue + accentDim := color.RGBA{110, 118, 255, 50} // Faint accent + accentGlow := color.RGBA{110, 118, 255, 25} + green := color.RGBA{63, 185, 80, 255} // #3FB950 + greenGlow := color.RGBA{63, 185, 80, 50} // LED glow + blue := color.RGBA{88, 166, 255, 255} // #58A6FF + paper := color.RGBA{240, 246, 252, 255} // #F0F6FC + paperShadow := color.RGBA{200, 210, 224, 255} + dark := color.RGBA{1, 4, 9, 255} // Near black + highlight := color.RGBA{255, 255, 255, 15} // Glossy highlight + + // 1. Background + iconFillAll(img, bg) + + // 2. Inner squircle with subtle gradient (lighter at top) + r := i(s * 0.16) + iconFillRoundedRect(img, i(s*0.04), i(s*0.04), i(s*0.96), i(s*0.96), r, bgInner) + + // Subtle top highlight gradient on background + for row := i(s * 0.04); row < i(s*0.25); row++ { + alpha := uint8(8 - 8*float64(row-i(s*0.04))/float64(i(s*0.21))) + if alpha > 0 { + gradC := color.RGBA{255, 255, 255, alpha} + for x := i(s * 0.04); x < i(s*0.96); x++ { + if iconInRoundedRect(x, row, i(s*0.04), i(s*0.04), i(s*0.96), i(s*0.96), r) { + iconBlend(img, x, row, gradC) + } + } + } + } + + // 3. Paper input tray on top of printer + tx1, ty1 := i(s*0.24), i(s*0.16) + tx2, ty2 := i(s*0.76), i(s*0.30) + iconFillRoundedRect(img, tx1, ty1, tx2, ty2, i(s*0.03), surfaceHL) + iconStrokeRoundedRect(img, tx1, ty1, tx2, ty2, i(s*0.03), accent, imax(1, i(s*0.003))) + + // Paper edges visible in input tray + iconFillRect(img, i(s*0.30), i(s*0.19), i(s*0.70), i(s*0.20), paper) + iconFillRect(img, i(s*0.30), i(s*0.21), i(s*0.70), i(s*0.215), paperShadow) + + // 4. Printer body + bx1, by1 := i(s*0.12), i(s*0.28) + bx2, by2 := i(s*0.88), i(s*0.66) + br := i(s * 0.05) + iconFillRoundedRect(img, bx1, by1, bx2, by2, br, surface) + iconStrokeRoundedRect(img, bx1, by1, bx2, by2, br, accent, imax(1, i(s*0.004))) + + // Top highlight on printer body (glossy effect) + iconFillRect(img, bx1+i(s*0.03), by1+1, bx2-i(s*0.03), by1+i(s*0.025), highlight) + + // 5. Display/paper slot (dark inset in body) + sx1, sy1 := i(s*0.20), i(s*0.34) + sx2, sy2 := i(s*0.78), i(s*0.52) + sr := i(s * 0.02) + iconFillRoundedRect(img, sx1, sy1, sx2, sy2, sr, dark) + iconStrokeRoundedRect(img, sx1, sy1, sx2, sy2, sr, accent, imax(1, i(s*0.002))) + + // Text lines in slot (simulating a display) + lw := imax(1, i(s*0.003)) + iconFillRect(img, i(s*0.25), i(s*0.38), i(s*0.62), i(s*0.38)+lw*2, accentDim) + iconFillRect(img, i(s*0.25), i(s*0.42), i(s*0.55), i(s*0.42)+lw*2, accentDim) + iconFillRect(img, i(s*0.25), i(s*0.46), i(s*0.48), i(s*0.46)+lw*2, accentDim) + + // 6. Control panel area (right side of body) + // Status LED with glow + ledX, ledY := i(s*0.76), i(s*0.36) + ledR := imax(2, i(s*0.025)) + iconFillCircle(img, ledX, ledY, ledR*3, accentGlow) + iconFillCircle(img, ledX, ledY, ledR*2, greenGlow) + iconFillCircle(img, ledX, ledY, ledR, green) - // WiFi arcs (top-right) - wcx, wcy := i(s*0.78), i(s*0.16) - iconDrawArc(img, wcx, wcy, i(s*0.05), -math.Pi*0.75, -math.Pi*0.25, blue, 3) - iconDrawArc(img, wcx, wcy, i(s*0.09), -math.Pi*0.75, -math.Pi*0.25, blue, 3) - iconDrawArc(img, wcx, wcy, i(s*0.13), -math.Pi*0.75, -math.Pi*0.25, blue, 2) - iconFillCircle(img, wcx, wcy, i(s*0.02), blue) + // Small buttons on right side + btnC := surfaceHL + iconFillRoundedRect(img, i(s*0.72), i(s*0.44), i(s*0.80), i(s*0.47), imax(1, i(s*0.01)), btnC) + iconFillRoundedRect(img, i(s*0.72), i(s*0.49), i(s*0.80), i(s*0.52), imax(1, i(s*0.01)), btnC) + + // 7. Paper/label output β€” the hero element + px1, py1 := i(s*0.16), i(s*0.64) + px2, py2 := i(s*0.84), i(s*0.88) + pr := i(s * 0.02) + + // Paper shadow + iconFillRoundedRect(img, px1+i(s*0.01), py1+i(s*0.01), px2+i(s*0.01), py2+i(s*0.01), pr, color.RGBA{0, 0, 0, 40}) + + // Paper body + iconFillRoundedRect(img, px1, py1, px2, py2, pr, paper) + + // Barcode on paper β€” detailed pattern + barcodeY := i(s * 0.69) + barcodeH := i(s * 0.09) + barWidths := []int{3, 1, 2, 1, 3, 1, 1, 2, 1, 3, 1, 2, 3, 1, 1, 2, 1, 3, 1, 2} + bx := i(s * 0.24) + gap := imax(1, i(s*0.004)) + bUnit := imax(1, i(s*0.005)) + for _, w := range barWidths { + barW := w * bUnit + iconFillRect(img, bx, barcodeY, bx+barW, barcodeY+barcodeH, dark) + bx += barW + gap + if bx > i(s*0.76) { + break + } + } + + // Text line placeholder under barcode + iconFillRect(img, i(s*0.24), i(s*0.80), i(s*0.58), i(s*0.82), paperShadow) + iconFillRect(img, i(s*0.24), i(s*0.84), i(s*0.44), i(s*0.855), paperShadow) + + // 8. WiFi signal arcs (top right) + wcx, wcy := i(s*0.82), i(s*0.12) + wt := imax(2, i(s*0.008)) + iconDrawArc(img, wcx, wcy, i(s*0.04), -math.Pi*0.80, -math.Pi*0.20, blue, wt) + iconDrawArc(img, wcx, wcy, i(s*0.075), -math.Pi*0.80, -math.Pi*0.20, blue, wt) + iconDrawArc(img, wcx, wcy, i(s*0.11), -math.Pi*0.80, -math.Pi*0.20, blue, imax(1, wt-1)) + iconFillCircle(img, wcx, wcy, imax(2, i(s*0.015)), blue) + + // 9. Subtle accent glow at bottom + for row := i(s * 0.88); row < i(s*0.96); row++ { + alpha := uint8(12 * (1 - float64(row-i(s*0.88))/float64(i(s*0.08)))) + if alpha > 0 { + gradC := color.RGBA{110, 118, 255, alpha} + for x := i(s * 0.20); x < i(s*0.80); x++ { + if iconInRoundedRect(x, row, i(s*0.04), i(s*0.04), i(s*0.96), i(s*0.96), r) { + iconBlend(img, x, row, gradC) + } + } + } + } + } var buf bytes.Buffer png.Encode(&buf, img) @@ -87,6 +251,13 @@ func generateAppIcon(size int) []byte { func i(f float64) int { return int(f) } +func imax(a, b int) int { + if a > b { + return a + } + return b +} + func iconFillAll(img *image.RGBA, c color.RGBA) { b := img.Bounds() for y := b.Min.Y; y < b.Max.Y; y++ { @@ -141,7 +312,6 @@ func iconInRoundedRect(x, y, x1, y1, x2, y2, r int) bool { if x < x1 || x >= x2 || y < y1 || y >= y2 { return false } - // Check corners corners := [][2]int{{x1 + r, y1 + r}, {x2 - r, y1 + r}, {x1 + r, y2 - r}, {x2 - r, y2 - r}} for _, corner := range corners { cx, cy := corner[0], corner[1] @@ -200,6 +370,10 @@ func iconBlend(img *image.RGBA, x, y int, c color.RGBA) { img.SetRGBA(x, y, c) return } + if c.A == 0 { + img.SetRGBA(x, y, c) + return + } // Alpha blend bg := img.RGBAAt(x, y) a := float64(c.A) / 255.0 diff --git a/install_mac.sh b/install_mac.sh index 211117d..eec0bee 100755 --- a/install_mac.sh +++ b/install_mac.sh @@ -2,7 +2,8 @@ set -e BINARY_NAME="tsc-bridge" -INSTALL_DIR="$HOME/bin" +APP_NAME="TSC Bridge.app" +INSTALL_DIR="/Applications" PLIST_NAME="com.tsc-bridge.plist" LAUNCH_AGENTS="$HOME/Library/LaunchAgents" HOSTNAME="myprinter.com" @@ -10,117 +11,140 @@ CONFIG_DIR="$HOME/.tsc-bridge" CERT_DIR="$CONFIG_DIR/certs" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -echo "=== TSC Bridge Installer (macOS) ===" +echo "" +echo "╔══════════════════════════════════════╗" +echo "β•‘ TSC Bridge β€” Instalador macOS β•‘" +echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" echo "" -# Create install directory -mkdir -p "$INSTALL_DIR" +# --- Kill existing instance --- +echo "[1/6] Deteniendo instancias previas..." +pkill -f "$BINARY_NAME" 2>/dev/null && echo " Detenido" || echo " Ninguna encontrada" +if launchctl list 2>/dev/null | grep -q "com.tsc-bridge"; then + launchctl unload "$LAUNCH_AGENTS/$PLIST_NAME" 2>/dev/null || true + echo " LaunchAgent descargado" +fi +sleep 1 -# Copy binary -if [ -f "$SCRIPT_DIR/$BINARY_NAME" ]; then - cp "$SCRIPT_DIR/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME" - chmod +x "$INSTALL_DIR/$BINARY_NAME" - echo "[OK] Binary installed to $INSTALL_DIR/$BINARY_NAME" +# --- Install .app bundle or raw binary --- +echo "" +echo "[2/6] Instalando aplicaciΓ³n..." +if [ -d "$SCRIPT_DIR/$APP_NAME" ]; then + # Install .app bundle to /Applications + rm -rf "$INSTALL_DIR/$APP_NAME" + cp -R "$SCRIPT_DIR/$APP_NAME" "$INSTALL_DIR/$APP_NAME" + BINARY_PATH="$INSTALL_DIR/$APP_NAME/Contents/MacOS/$BINARY_NAME" + echo " βœ“ $APP_NAME instalado en $INSTALL_DIR" +elif [ -f "$SCRIPT_DIR/$BINARY_NAME" ]; then + # Fallback: install raw binary to ~/bin + mkdir -p "$HOME/bin" + cp "$SCRIPT_DIR/$BINARY_NAME" "$HOME/bin/$BINARY_NAME" + chmod +x "$HOME/bin/$BINARY_NAME" + BINARY_PATH="$HOME/bin/$BINARY_NAME" + echo " βœ“ Binario instalado en $HOME/bin/$BINARY_NAME" else - echo "[ERROR] Binary not found: $SCRIPT_DIR/$BINARY_NAME" + echo " βœ— No se encontrΓ³ $APP_NAME ni $BINARY_NAME" exit 1 fi # --- Add hostname to /etc/hosts --- +echo "" +echo "[3/6] Configurando hostname..." if grep -q "$HOSTNAME" /etc/hosts 2>/dev/null; then - echo "[OK] $HOSTNAME already in /etc/hosts" + echo " βœ“ $HOSTNAME ya estΓ‘ en /etc/hosts" else - echo "[*] Adding $HOSTNAME to /etc/hosts (requires sudo)..." + echo " Agregando $HOSTNAME a /etc/hosts (requiere sudo)..." echo "127.0.0.1 $HOSTNAME" | sudo tee -a /etc/hosts > /dev/null - echo "[OK] $HOSTNAME added to /etc/hosts" + echo " βœ“ $HOSTNAME agregado" fi -# --- Generate certs by running bridge briefly --- -echo "[*] Generating SSL certificates..." +# --- Generate certs --- +echo "" +echo "[4/6] Generando certificados SSL..." mkdir -p "$CERT_DIR" -# Start bridge temporarily to trigger cert generation, then kill -"$INSTALL_DIR/$BINARY_NAME" & +"$BINARY_PATH" --headless & BRIDGE_PID=$! -sleep 2 +sleep 3 kill $BRIDGE_PID 2>/dev/null || true wait $BRIDGE_PID 2>/dev/null || true -# --- Trust CA certificate in macOS Keychain --- +# --- Trust CA certificate --- CA_CERT="$CERT_DIR/ca.pem" if [ -f "$CA_CERT" ]; then - echo "[*] Trusting CA certificate (requires sudo)..." - sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$CA_CERT" - echo "[OK] CA certificate trusted β€” browser will show green lock" + echo " Instalando certificado CA (requiere sudo)..." + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$CA_CERT" 2>/dev/null \ + && echo " βœ“ Certificado CA instalado" \ + || echo " ⚠ No se pudo instalar β€” HTTPS puede mostrar advertencias" else - echo "[WARN] CA cert not found at $CA_CERT β€” SSL may show warnings" + echo " ⚠ Certificado CA no encontrado β€” HTTPS puede mostrar advertencias" fi -# --- Install LaunchAgent for auto-start --- +# --- Install LaunchAgent --- +echo "" +echo "[5/6] Configurando inicio automΓ‘tico..." mkdir -p "$LAUNCH_AGENTS" -# Unload existing agent if running -if launchctl list 2>/dev/null | grep -q "com.tsc-bridge"; then - launchctl unload "$LAUNCH_AGENTS/$PLIST_NAME" 2>/dev/null || true - echo "[OK] Unloaded existing LaunchAgent" -fi - -# Copy plist with correct path -sed "s|__BINARY_PATH__|$INSTALL_DIR/$BINARY_NAME|g" "$SCRIPT_DIR/$PLIST_NAME" > "$LAUNCH_AGENTS/$PLIST_NAME" -echo "[OK] LaunchAgent installed" - -# Load LaunchAgent -launchctl load "$LAUNCH_AGENTS/$PLIST_NAME" -echo "[OK] LaunchAgent loaded β€” tsc-bridge starts at login" - -# Start now -launchctl start com.tsc-bridge -echo "[OK] tsc-bridge started" - -# --- Create macOS .app wrapper for Dashboard GUI --- -APP_DIR="$HOME/Applications/TSC Bridge Dashboard.app" -mkdir -p "$APP_DIR/Contents/MacOS" -cat > "$APP_DIR/Contents/MacOS/tsc-bridge-dashboard" << 'LAUNCHER' -#!/bin/bash -# Launch tsc-bridge in dashboard (GUI) mode -exec "$HOME/bin/tsc-bridge" --dashboard -LAUNCHER -chmod +x "$APP_DIR/Contents/MacOS/tsc-bridge-dashboard" - -cat > "$APP_DIR/Contents/Info.plist" << 'PLIST' +cat > "$LAUNCH_AGENTS/$PLIST_NAME" << PLIST - CFBundleName - TSC Bridge Dashboard - CFBundleExecutable - tsc-bridge-dashboard - CFBundleIdentifier - com.tsc-bridge.dashboard - CFBundleVersion - 2.0.0 - LSUIElement - + Label + com.tsc-bridge + ProgramArguments + + ${BINARY_PATH} + --headless + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/tsc-bridge.log + StandardErrorPath + /tmp/tsc-bridge.err + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin + PLIST -echo "[OK] Dashboard app created at $APP_DIR" +echo " βœ“ LaunchAgent creado" + +launchctl load "$LAUNCH_AGENTS/$PLIST_NAME" +echo " βœ“ LaunchAgent cargado" +# --- Start service --- echo "" -echo "============================================" -echo " Servicio: Auto-start al login (LaunchAgent)" -echo " Dashboard: ~/Applications/TSC Bridge Dashboard.app" -echo " API HTTP: http://127.0.0.1:9271/" -echo " API HTTPS: https://$HOSTNAME:9272/" -echo "============================================" +echo "[6/6] Iniciando servicio..." +launchctl start com.tsc-bridge 2>/dev/null || true +sleep 2 + +# Verify +if curl -s http://127.0.0.1:9638/status >/dev/null 2>&1 || curl -s http://127.0.0.1:9271/status >/dev/null 2>&1; then + echo " βœ“ TSC Bridge estΓ‘ corriendo" +else + echo " ⚠ Servicio iniciado pero no responde aΓΊn β€” puede tardar unos segundos" +fi + echo "" -echo "Para abrir el dashboard: open '$APP_DIR'" -echo " o ejecutar: tsc-bridge --dashboard" +echo "╔══════════════════════════════════════════════╗" +echo "β•‘ βœ“ InstalaciΓ³n Completa β•‘" +echo "╠══════════════════════════════════════════════╣" +echo "β•‘ App: $INSTALL_DIR/$APP_NAME" +echo "β•‘ Servicio: Auto-start al login (LaunchAgent)" +echo "β•‘ Tray icon: Aparece en la barra de menΓΊ β•‘" +echo "β•‘ Dashboard: Clic en icono β†’ Abrir Dashboard β•‘" +echo "β•‘ HTTPS: https://$HOSTNAME:9272/ β•‘" +echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" echo "" echo "Para desinstalar:" echo " launchctl unload ~/Library/LaunchAgents/$PLIST_NAME" echo " rm ~/Library/LaunchAgents/$PLIST_NAME" -echo " rm $INSTALL_DIR/$BINARY_NAME" -echo " rm -rf '$APP_DIR'" +echo " rm -rf '/Applications/$APP_NAME'" echo " sudo sed -i '' '/$HOSTNAME/d' /etc/hosts" echo " sudo security delete-certificate -c 'TSC Bridge Local CA' /Library/Keychains/System.keychain" +echo "" diff --git a/install_windows.bat b/install_windows.bat index c66aa77..b119631 100644 --- a/install_windows.bat +++ b/install_windows.bat @@ -43,11 +43,13 @@ if %errorlevel% neq 0 ( echo 127.0.0.1 %HOSTNAME%>> "%HOSTS_FILE%" ) -:: --- Add firewall rules (silently allow ports 9271 and 9272) --- +:: --- Clean old firewall rules (port 9271/9272 from v2.2.0 and earlier) --- netsh advfirewall firewall delete rule name="TSC Bridge HTTP" >nul 2>&1 netsh advfirewall firewall delete rule name="TSC Bridge HTTPS" >nul 2>&1 -netsh advfirewall firewall add rule name="TSC Bridge HTTP" dir=in action=allow protocol=TCP localport=9271 >nul 2>&1 -netsh advfirewall firewall add rule name="TSC Bridge HTTPS" dir=in action=allow protocol=TCP localport=9272 >nul 2>&1 + +:: --- Add firewall rules (ports 9638 and 9639) --- +netsh advfirewall firewall add rule name="TSC Bridge HTTP" dir=in action=allow protocol=TCP localport=9638 >nul 2>&1 +netsh advfirewall firewall add rule name="TSC Bridge HTTPS" dir=in action=allow protocol=TCP localport=9639 >nul 2>&1 :: --- Generate certs: run bridge briefly then kill --- if not exist "%CERT_DIR%" mkdir "%CERT_DIR%" @@ -64,12 +66,16 @@ if exist "%CA_CERT%" ( certutil -addstore -f "Root" "%CA_CERT%" >nul 2>&1 ) -:: --- Create startup shortcut (service mode, minimized, no GUI) --- +:: --- Remove old startup shortcut before recreating --- +if exist "%STARTUP_DIR%\TSC Bridge.lnk" del "%STARTUP_DIR%\TSC Bridge.lnk" >nul 2>&1 + +:: --- Create startup shortcut (headless service mode at login, no browser) --- >"%TEMP%\_tsc_startup.vbs" ( echo Set oWS = WScript.CreateObject^("WScript.Shell"^) echo sLinkFile = "%STARTUP_DIR%\TSC Bridge.lnk" echo Set oLink = oWS.CreateShortcut^(sLinkFile^) echo oLink.TargetPath = "%INSTALL_DIR%\%BINARY_NAME%" + echo oLink.Arguments = "--headless" echo oLink.WorkingDirectory = "%INSTALL_DIR%" echo oLink.Description = "TSC Bridge - Servicio de impresion" echo oLink.WindowStyle = 7 @@ -78,14 +84,13 @@ if exist "%CA_CERT%" ( cscript //nologo "%TEMP%\_tsc_startup.vbs" >nul 2>&1 del "%TEMP%\_tsc_startup.vbs" >nul 2>&1 -:: --- Create desktop shortcut (dashboard GUI mode) --- +:: --- Create desktop shortcut (opens embedded dashboard) --- set DESKTOP_DIR=%USERPROFILE%\Desktop >"%TEMP%\_tsc_desktop.vbs" ( echo Set oWS = WScript.CreateObject^("WScript.Shell"^) - echo sLinkFile = "%DESKTOP_DIR%\TSC Bridge Dashboard.lnk" + echo sLinkFile = "%DESKTOP_DIR%\TSC Bridge.lnk" echo Set oLink = oWS.CreateShortcut^(sLinkFile^) echo oLink.TargetPath = "%INSTALL_DIR%\%BINARY_NAME%" - echo oLink.Arguments = "--dashboard" echo oLink.WorkingDirectory = "%INSTALL_DIR%" echo oLink.Description = "TSC Bridge - Panel de control" echo oLink.WindowStyle = 1 @@ -94,8 +99,8 @@ set DESKTOP_DIR=%USERPROFILE%\Desktop cscript //nologo "%TEMP%\_tsc_desktop.vbs" >nul 2>&1 del "%TEMP%\_tsc_desktop.vbs" >nul 2>&1 -:: --- Start the bridge service (stays running in background) --- -start "" "%INSTALL_DIR%\%BINARY_NAME%" +:: --- Start the bridge service (headless, stays running in background) --- +start "" "%INSTALL_DIR%\%BINARY_NAME%" --headless :: Done β€” no pause, fully unattended exit /b 0 diff --git a/kill_other.go b/kill_other.go new file mode 100644 index 0000000..f013f1f --- /dev/null +++ b/kill_other.go @@ -0,0 +1,17 @@ +//go:build !windows + +package main + +import ( + "log" + "os/exec" +) + +// killOldInstances kills any other tsc-bridge processes. +func killOldInstances() { + log.Printf("[kill] Killing old tsc-bridge processes") + exec.Command("pkill", "-9", "-f", "tsc-bridge").Run() +} + +// hideWindowCmd is a no-op on non-Windows platforms. +func hideWindowCmd(_ *exec.Cmd) {} diff --git a/kill_windows.go b/kill_windows.go new file mode 100644 index 0000000..bdd86f4 --- /dev/null +++ b/kill_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package main + +import ( + "fmt" + "log" + "os" + "os/exec" +) + +// killOldInstances kills any other tsc-bridge.exe processes on Windows. +// Excludes the current process by PID. +func killOldInstances() { + myPID := os.Getpid() + filter := fmt.Sprintf("PID ne %d", myPID) + + log.Printf("[kill] Killing old tsc-bridge.exe instances (excluding PID %d)", myPID) + cmd := exec.Command("taskkill", "/IM", "tsc-bridge.exe", "/F", "/FI", filter) + hideWindow(cmd) + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("[kill] taskkill: %v β€” %s", err, string(out)) + } else { + log.Printf("[kill] taskkill: %s", string(out)) + } +} diff --git a/label_template.go b/label_template.go new file mode 100644 index 0000000..bf86aad --- /dev/null +++ b/label_template.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" +) + +// LabelField defines a single field in a label template. +type LabelField struct { + Name string `json:"name"` // variable name, e.g. "descripcion" + Type string `json:"type"` // "text", "barcode", "qrcode" + X int `json:"x"` // x offset in dots (relative to column) + Y int `json:"y"` // y offset in dots + Font string `json:"font"` // TSPL font: "1"-"5" or "0" for barcode + FontSize int `json:"font_size"` // width multiplier for text + Height int `json:"height"` // barcode/qr height in dots + CellWidth int `json:"cell_width"` // barcode narrow bar width +} + +// LabelTemplate defines a reusable TSPL2 label template with variable placeholders. +type LabelTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + PresetID string `json:"preset_id"` // which LabelPreset to use for SIZE/GAP + Fields []LabelField `json:"fields"` + Builtin bool `json:"builtin"` +} + +var ( + customTemplates []LabelTemplate + templatesMu sync.RWMutex +) + +// Built-in templates for common use cases. +var builtinTemplates = []LabelTemplate{ + { + ID: "basic-barcode", + Name: "Texto + Barcode", + PresetID: "single-30x22", + Fields: []LabelField{ + {Name: "descripcion", Type: "text", X: 8, Y: 8, Font: "3", FontSize: 1, Height: 0, CellWidth: 0}, + {Name: "codigo", Type: "barcode", X: 8, Y: 48, Font: "0", FontSize: 0, Height: 40, CellWidth: 2}, + }, + Builtin: true, + }, + { + ID: "basic-qr", + Name: "Texto + QR", + PresetID: "single-30x22", + Fields: []LabelField{ + {Name: "descripcion", Type: "text", X: 8, Y: 8, Font: "3", FontSize: 1, Height: 0, CellWidth: 0}, + {Name: "codigo", Type: "qrcode", X: 8, Y: 48, Font: "0", FontSize: 0, Height: 4, CellWidth: 0}, + }, + Builtin: true, + }, + { + ID: "product-label", + Name: "Producto (desc + presentacion + barcode)", + PresetID: "matrix-3x1-30x22", + Fields: []LabelField{ + {Name: "descripcion", Type: "text", X: 0, Y: 8, Font: "2", FontSize: 1, Height: 0, CellWidth: 0}, + {Name: "presentacion", Type: "text", X: 0, Y: 32, Font: "1", FontSize: 1, Height: 0, CellWidth: 0}, + {Name: "codigo", Type: "barcode", X: 0, Y: 56, Font: "0", FontSize: 0, Height: 40, CellWidth: 2}, + {Name: "codigo", Type: "text", X: 0, Y: 100, Font: "1", FontSize: 1, Height: 0, CellWidth: 0}, + }, + Builtin: true, + }, +} + +// templatesPath returns the path to the templates JSON file. +func templatesPath() string { + return filepath.Join(configDir(), "templates.json") +} + +// LoadTemplates reads custom templates from disk. +func LoadTemplates() { + templatesMu.Lock() + defer templatesMu.Unlock() + + data, err := os.ReadFile(templatesPath()) + if err != nil { + customTemplates = []LabelTemplate{} + return + } + if err := json.Unmarshal(data, &customTemplates); err != nil { + log.Printf("[templates] Error parsing templates: %v", err) + customTemplates = []LabelTemplate{} + } +} + +// SaveTemplates writes custom templates to disk. +func SaveTemplates() error { + templatesMu.RLock() + data, err := json.MarshalIndent(customTemplates, "", " ") + templatesMu.RUnlock() + if err != nil { + return err + } + dir := filepath.Dir(templatesPath()) + os.MkdirAll(dir, 0755) + return os.WriteFile(templatesPath(), data, 0644) +} + +// GetAllTemplates returns built-in + custom templates. +func GetAllTemplates() []LabelTemplate { + templatesMu.RLock() + defer templatesMu.RUnlock() + all := make([]LabelTemplate, 0, len(builtinTemplates)+len(customTemplates)) + all = append(all, builtinTemplates...) + all = append(all, customTemplates...) + return all +} + +// GetTemplate returns a template by ID. +func GetTemplate(id string) *LabelTemplate { + for i := range builtinTemplates { + if builtinTemplates[i].ID == id { + return &builtinTemplates[i] + } + } + templatesMu.RLock() + defer templatesMu.RUnlock() + for i := range customTemplates { + if customTemplates[i].ID == id { + return &customTemplates[i] + } + } + return nil +} + +// SaveTemplate creates or updates a custom template. +func SaveTemplate(t LabelTemplate) error { + // Prevent overwriting builtins + for _, bt := range builtinTemplates { + if bt.ID == t.ID { + return fmt.Errorf("cannot overwrite built-in template") + } + } + t.Builtin = false + templatesMu.Lock() + found := false + for i, ct := range customTemplates { + if ct.ID == t.ID { + customTemplates[i] = t + found = true + break + } + } + if !found { + customTemplates = append(customTemplates, t) + } + templatesMu.Unlock() + return SaveTemplates() +} + +// DeleteTemplate removes a custom template by ID. +func DeleteTemplate(id string) error { + for _, bt := range builtinTemplates { + if bt.ID == id { + return fmt.Errorf("cannot delete built-in template") + } + } + templatesMu.Lock() + filtered := customTemplates[:0] + deleted := false + for _, ct := range customTemplates { + if ct.ID == id { + deleted = true + continue + } + filtered = append(filtered, ct) + } + customTemplates = filtered + templatesMu.Unlock() + if !deleted { + return fmt.Errorf("template not found") + } + return SaveTemplates() +} + +// RequiredVars returns the unique variable names used by a template. +func (t *LabelTemplate) RequiredVars() []string { + seen := map[string]bool{} + var vars []string + for _, f := range t.Fields { + if !seen[f.Name] { + seen[f.Name] = true + vars = append(vars, f.Name) + } + } + return vars +} + +// RenderField generates TSPL2 commands for a single field with data substitution. +// colOffset is the x offset for multi-column presets. +func RenderField(f LabelField, data map[string]string, colOffset int) string { + value := data[f.Name] + if value == "" { + value = f.Name // fallback: show variable name + } + x := f.X + colOffset + + switch f.Type { + case "barcode": + h := f.Height + if h == 0 { + h = 40 + } + cw := f.CellWidth + if cw == 0 { + cw = 2 + } + return fmt.Sprintf("BARCODE %d,%d,\"128\",%d,1,0,%d,%d,\"%s\"\r\n", + x, f.Y, h, cw, cw, value) + + case "qrcode": + cellSize := f.Height + if cellSize == 0 { + cellSize = 4 + } + return fmt.Sprintf("QRCODE %d,%d,L,%d,A,0,\"%s\"\r\n", + x, f.Y, cellSize, value) + + default: // "text" + font := f.Font + if font == "" { + font = "2" + } + size := f.FontSize + if size == 0 { + size = 1 + } + return fmt.Sprintf("TEXT %d,%d,\"%s\",0,%d,%d,\"%s\"\r\n", + x, f.Y, font, size, size, escTSPL(value)) + } +} + +// Render generates complete TSPL2 field commands for one label using the given data. +// colOffset allows multi-column placement. +func (t *LabelTemplate) Render(data map[string]string, colOffset int) string { + var sb strings.Builder + for _, f := range t.Fields { + sb.WriteString(RenderField(f, data, colOffset)) + } + return sb.String() +} + +// escTSPL escapes characters that could break TSPL string literals. +func escTSPL(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "'") + // Limit length to avoid overflowing label + if len(s) > 60 { + s = s[:57] + "..." + } + return s +} diff --git a/main.go b/main.go index b6df80e..d4e06ca 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,14 @@ package main import ( + "bytes" "crypto/tls" "encoding/json" "fmt" + "image/png" "io" "log" + "net" "net/http" "os" "path/filepath" @@ -15,19 +18,68 @@ import ( "time" ) -const version = "2.0.0" +const version = "3.0.0" var startTime = time.Now() +func init() { + // Lock the main goroutine to the OS thread. + // Required by systray for the GUI message loop. + runtime.LockOSThread() + + // Ensure at least 4 OS threads are available for goroutines. + // Critical on Windows: systray.Run() blocks the main thread + // in CGO calls β€” the HTTP server goroutine needs its own thread to run. + if runtime.GOMAXPROCS(0) < 4 { + runtime.GOMAXPROCS(4) + } +} + +// setupFileLogging redirects log output to a file in the config directory. +// Critical on Windows with -H windowsgui where there is no console. +func setupFileLogging() { + dir := configDir() + os.MkdirAll(dir, 0755) + logPath := filepath.Join(dir, "tsc-bridge.log") + + // Truncate if too large (> 1MB) + if info, err := os.Stat(logPath); err == nil && info.Size() > 1024*1024 { + os.Remove(logPath) + } + + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return + } + log.SetOutput(f) + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) +} + +// waitForServer polls the HTTP server until it responds or times out. +func waitForServer(addr string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 500 * time.Millisecond} + for time.Now().Before(deadline) { + resp, err := client.Get("http://" + addr + "/status") + if err == nil { + resp.Body.Close() + return true + } + time.Sleep(100 * time.Millisecond) + } + return false +} + // corsMiddleware adds CORS headers to every response. func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + w.Header().Set("Access-Control-Allow-Private-Network", "true") if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) return } next(w, r) @@ -92,6 +144,16 @@ func handleStatus(w http.ResponseWriter, r *http.Request) { "default_printer": cfg.DefaultPrinter, "default_preset": cfg.DefaultPreset, "share": getShareStatus(), + "printer_dpi": func() map[string]int { + configMu.RLock() + defer configMu.RUnlock() + flat := make(map[string]int, len(appConfig.PrinterDPI)) + for name, entry := range appConfig.PrinterDPI { + flat[name] = entry.DPI + } + return flat + }(), + "default_dpi": defaultDPI, }) } @@ -277,6 +339,12 @@ func handlePrint(w http.ResponseWriter, r *http.Request) { printerName = targetPrinter.Name } else { targetPrinter = findPrinter(printerName, allPrinters) + if targetPrinter == nil { + log.Printf("[print] WARNING: printer %q not found in %d available printers", printerName, len(allPrinters)) + for _, p := range allPrinters { + log.Printf("[print] available: %q (type=%s, online=%v)", p.Name, p.Type, p.Online) + } + } } // Apply preset header if requested @@ -296,7 +364,7 @@ func handlePrint(w http.ResponseWriter, r *http.Request) { // Route print by type var printErr error - if targetPrinter != nil && targetPrinter.Type == "network" { + if targetPrinter != nil && (targetPrinter.Type == "network" || targetPrinter.Type == "manual" || targetPrinter.Type == "raw") { printErr = networkRawPrint(targetPrinter.Address, body) } else { printErr = rawPrint(printerName, body) @@ -370,7 +438,7 @@ func handleTestPrint(w http.ResponseWriter, r *http.Request) { } var printErr error - if targetPrinter != nil && targetPrinter.Type == "network" { + if targetPrinter != nil && (targetPrinter.Type == "network" || targetPrinter.Type == "manual" || targetPrinter.Type == "raw") { printErr = networkRawPrint(targetPrinter.Address, []byte(tspl)) } else { printErr = rawPrint(printerName, []byte(tspl)) @@ -418,6 +486,52 @@ func stripSetupCommands(tspl string) string { return strings.Join(lines, "\r\n") + "\r\n" } +// handleManualPrinters handles GET/DELETE for manually configured printers. +func handleManualPrinters(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cfg := getConfig() + jsonResponse(w, http.StatusOK, map[string]any{ + "manual_printers": cfg.ManualPrinters, + }) + + case http.MethodDelete: + ip := r.URL.Query().Get("ip") + if ip == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "ip parameter required"}) + return + } + configMu.Lock() + filtered := make([]string, 0, len(appConfig.ManualPrinters)) + removed := false + for _, addr := range appConfig.ManualPrinters { + if addr == ip { + removed = true + continue + } + filtered = append(filtered, addr) + } + appConfig.ManualPrinters = filtered + configMu.Unlock() + + if !removed { + jsonResponse(w, http.StatusNotFound, map[string]string{"error": "ip not found in manual printers"}) + return + } + if err := saveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "save failed: " + err.Error()}) + return + } + jsonResponse(w, http.StatusOK, map[string]any{ + "status": "removed", + "manual_printers": filtered, + }) + + default: + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } +} + // serviceRunning checks if a tsc-bridge service is already listening. func serviceRunning(addr string) bool { client := &http.Client{Timeout: 500 * time.Millisecond} @@ -430,8 +544,52 @@ func serviceRunning(addr string) bool { } func main() { + // CLI-only commands that exit immediately (no service, no logging) + if hasFlag("--generate-icon") { + path := getFlagValue("--generate-icon") + if path == "" { + path = "icon_1024.png" + } + data := generateAppIcon(1024) + os.WriteFile(path, data, 0644) + fmt.Printf("Icon PNG written to %s (%d bytes)\n", path, len(data)) + os.Exit(0) + } + if hasFlag("--generate-ico") { + path := getFlagValue("--generate-ico") + if path == "" { + path = "tsc-bridge.ico" + } + data := generateICO([]int{16, 32, 48, 64, 128, 256}) + os.WriteFile(path, data, 0644) + fmt.Printf("Icon ICO written to %s (%d bytes)\n", path, len(data)) + os.Exit(0) + } + if hasFlag("--version") { + fmt.Printf("tsc-bridge v%s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH) + os.Exit(0) + } + + // File logging β€” critical on Windows where -H windowsgui hides all output + setupFileLogging() + log.Printf("=== tsc-bridge v%s starting (os=%s, args=%v) ===", version, runtime.GOOS, os.Args) + + // Catch panics β€” log to file and show MessageBox on Windows + defer func() { + if r := recover(); r != nil { + msg := fmt.Sprintf("PANIC: %v", r) + log.Print(msg) + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + log.Printf("Stack:\n%s", buf[:n]) + showError("TSC Bridge β€” Error fatal", msg) + } + }() + // Load configuration initConfig() + LoadTemplates() + startUploadCleanup() port := os.Getenv("TSC_BRIDGE_PORT") if port == "" { @@ -442,34 +600,65 @@ func main() { httpAddr := "127.0.0.1:" + port dashURL := fmt.Sprintf("http://%s/", httpAddr) - // --dashboard / -d: open native GUI window - if hasFlag("--dashboard") || hasFlag("-d") { - if serviceRunning(httpAddr) { - log.Printf("Service already running on %s β€” opening GUI only", httpAddr) - openGUI(dashURL) + headless := hasFlag("--headless") || hasFlag("--service") + + // If another instance is already running... + if serviceRunning(httpAddr) { + if headless { + log.Printf("Service already running on %s β€” exiting", httpAddr) return } - // No service running β€” start servers then open GUI - log.Printf("No service detected β€” starting servers + GUI") - startServers(port) - go startNetworkScanner() - go startShareServer() - time.Sleep(300 * time.Millisecond) - openGUI(dashURL) + log.Printf("Service already running on %s β€” opening dashboard in browser", httpAddr) + openBrowser(dashURL) return } - // Service mode: start servers and block forever - startServers(port) + // Start HTTP/HTTPS servers + background services + if err := startServers(port); err != nil { + // Port in use β€” kill zombie instances and retry once + log.Printf("First bind attempt failed: %v β€” killing old instances and retrying", err) + killOldInstances() + time.Sleep(2 * time.Second) + + if err2 := startServers(port); err2 != nil { + msg := fmt.Sprintf("No se pudo iniciar en el puerto %s.\n\n%v\n\nCierre todas las instancias de tsc-bridge e intente de nuevo.", port, err2) + log.Printf("ERROR: %s", msg) + showError("TSC Bridge β€” Error", msg) + return + } + log.Printf("Retry succeeded after killing old instances") + } go startNetworkScanner() go startShareServer() - log.Printf("tsc-bridge v%s started (os=%s)", version, runtime.GOOS) - log.Printf("Dashboard: %s", dashURL) - log.Printf("API (HTTP): http://%s/", httpAddr) - select {} + go DetectAllPrinterDPIs() + + // Verify HTTP server is actually accepting connections before proceeding. + // Critical on Windows: systray.Run() enters a Win32 message loop that can + // starve goroutines if the HTTP serve goroutine hasn't been scheduled yet. + if !waitForServer(httpAddr, 5*time.Second) { + log.Printf("WARNING: HTTP server bound but slow to accept β€” continuing anyway") + } + log.Printf("tsc-bridge v%s ready on %s", version, httpAddr) + + // Initialize webview subsystem (browser fallback until native webview is available) + initWebview(dashURL) + + // Check if this is the first run (no API credentials configured) + if !IsAuthConfigured() { + log.Printf("First run detected β€” setup wizard will appear in dashboard") + } + + // Run system tray β€” blocks until user clicks "Salir" + // autoOpen=true when NOT headless β†’ spawns dashboard window on first run + log.Printf("Starting system tray (headless=%v)", headless) + if headless { + log.Printf("Headless mode β€” skipping systray, blocking forever") + select {} + } + runTray(dashURL, !headless) } -func startServers(port string) { +func startServers(port string) error { mux := http.NewServeMux() mux.HandleFunc("/", corsMiddleware(handleDashboard)) mux.HandleFunc("/status", corsMiddleware(handleStatus)) @@ -477,55 +666,148 @@ func startServers(port string) { mux.HandleFunc("/print", corsMiddleware(handlePrint)) mux.HandleFunc("/test-print", corsMiddleware(handleTestPrint)) mux.HandleFunc("/config", corsMiddleware(handleConfig)) + mux.HandleFunc("/whitelabel", corsMiddleware(handleWhitelabel)) mux.HandleFunc("/discover", corsMiddleware(handleDiscover)) mux.HandleFunc("/share", corsMiddleware(handleShare)) mux.HandleFunc("/autostart", corsMiddleware(handleAutoStart)) mux.HandleFunc("/download", corsMiddleware(handleDownload)) mux.HandleFunc("/presets", corsMiddleware(handlePresets)) mux.HandleFunc("/presets/", corsMiddleware(handlePresets)) + mux.HandleFunc("/manual-printers", corsMiddleware(handleManualPrinters)) + + // Driver detection & setup + mux.HandleFunc("/driver/status", corsMiddleware(handleDriverStatus)) + mux.HandleFunc("/driver/setup", corsMiddleware(handleDriverSetup)) + mux.HandleFunc("/driver/progress", corsMiddleware(handleDriverProgress)) + + // DPI detection & management + mux.HandleFunc("/dpi/detect", corsMiddleware(handleDPIDetect)) + mux.HandleFunc("/dpi", corsMiddleware(handleDPISet)) + + // Batch printing routes + mux.HandleFunc("/api/test", corsMiddleware(handleApiTest)) + mux.HandleFunc("/api/templates", corsMiddleware(handleApiTemplates)) + mux.HandleFunc("/excel/upload", corsMiddleware(handleExcelUpload)) + mux.HandleFunc("/templates", corsMiddleware(handleLabelTemplates)) + mux.HandleFunc("/templates/", corsMiddleware(handleLabelTemplateByID)) + mux.HandleFunc("/batch-print", corsMiddleware(handleBatchPrint)) + mux.HandleFunc("/batch-preview", corsMiddleware(handleBatchPreview)) + mux.HandleFunc("/batch-pdf", corsMiddleware(handleBatchPdf)) + mux.HandleFunc("/batch-tspl", corsMiddleware(handleBatchTspl)) + mux.HandleFunc("/batch-preview-image", corsMiddleware(handleBatchPreviewImage)) + mux.HandleFunc("/debug-template", corsMiddleware(handleDebugTemplate)) + mux.HandleFunc("/bridge/download", corsMiddleware(handleBridgeDownload)) + mux.HandleFunc("/print-preview-thumb", corsMiddleware(handlePrintPreviewThumb)) + mux.HandleFunc("/print-job", corsMiddleware(handlePrintJob)) + mux.HandleFunc("/upload-pdf", corsMiddleware(handleUploadPdf)) + mux.HandleFunc("/native-file-dialog", corsMiddleware(handleNativeFileDialog)) + mux.HandleFunc("/native-download", corsMiddleware(handleNativeDownload)) + mux.HandleFunc("/output/", corsMiddleware(handleServeOutput)) - // HTTP server + // Auth routes + mux.HandleFunc("/auth/state", corsMiddleware(handleAuthState)) + mux.HandleFunc("/auth/login", corsMiddleware(handleAuthLogin)) + mux.HandleFunc("/auth/logout", corsMiddleware(handleAuthLogout)) + + // HTTP server β€” bind synchronously so we catch port-in-use errors immediately httpAddr := "127.0.0.1:" + port + httpLn, err := net.Listen("tcp", httpAddr) + if err != nil { + return fmt.Errorf("HTTP bind %s: %w", httpAddr, err) + } + + // Also bind IPv6 loopback β€” on Windows "localhost" resolves to [::1] + httpAddr6 := "[::1]:" + port + httpLn6, err6 := net.Listen("tcp", httpAddr6) + if err6 != nil { + log.Printf("IPv6 HTTP bind %s failed (non-fatal): %v", httpAddr6, err6) + } + + // Start HTTP server on a DEDICATED OS thread. + // Critical on Windows: the main thread will be blocked by systray.Run() + // in a CGO call. Without its own thread, the HTTP goroutine would be + // starved and never serve requests. + httpReady := make(chan struct{}) go func() { - log.Printf("HTTP server on %s", httpAddr) - if err := http.ListenAndServe(httpAddr, mux); err != nil { - log.Fatalf("HTTP server error: %v", err) + runtime.LockOSThread() // pin this goroutine to its own OS thread + close(httpReady) // signal: thread is alive, about to serve + log.Printf("HTTP server on %s (dedicated thread)", httpAddr) + if err := http.Serve(httpLn, mux); err != nil { + log.Printf("HTTP server error: %v", err) } }() + <-httpReady // wait until the HTTP goroutine has its own thread + + // IPv6 HTTP server + if httpLn6 != nil { + go func() { + runtime.LockOSThread() + log.Printf("HTTP server on %s (IPv6)", httpAddr6) + if err := http.Serve(httpLn6, mux); err != nil { + log.Printf("HTTP IPv6 server error: %v", err) + } + }() + } - // HTTPS server with auto-generated certs + // HTTPS server β€” try embedded Let's Encrypt cert first, fallback to self-signed httpsPort := fmt.Sprintf("%d", portInt(port)+1) httpsAddr := "127.0.0.1:" + httpsPort - certFile, keyFile, _, err := ensureCerts(defaultHostname) - if err != nil { - log.Printf("[tls] Could not generate certs: %v β€” HTTPS disabled", err) - return + var tlsCert tls.Certificate + if embedded := loadEmbeddedCert(); embedded != nil { + tlsCert = *embedded + } else { + certFile, keyFile, caFile, err := ensureCerts(defaultHostname) + if err != nil { + log.Printf("[tls] Could not generate certs: %v β€” HTTPS disabled", err) + return nil + } + go installCACert(caFile) + loaded, err := loadCertWithCA(certFile, keyFile, caFile) + if err != nil { + log.Printf("[tls] Could not load certs: %v β€” HTTPS disabled", err) + return nil + } + tlsCert = loaded } - tlsCert, err := tls.LoadX509KeyPair(certFile, keyFile) + httpsLn, err := net.Listen("tcp", httpsAddr) if err != nil { - log.Printf("[tls] Could not load certs: %v β€” HTTPS disabled", err) - return - } - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{tlsCert}, - } - httpsServer := &http.Server{ - Addr: httpsAddr, - Handler: mux, - TLSConfig: tlsConfig, + log.Printf("[tls] Could not bind %s: %v β€” HTTPS disabled", httpsAddr, err) + return nil } + tlsCfg := &tls.Config{Certificates: []tls.Certificate{tlsCert}} + tlsListener := tls.NewListener(httpsLn, tlsCfg) + httpsReady := make(chan struct{}) go func() { + runtime.LockOSThread() + close(httpsReady) log.Printf("HTTPS server on %s (https://%s:%s/)", httpsAddr, defaultHostname, httpsPort) - if err := httpsServer.ListenAndServeTLS("", ""); err != nil { + if err := http.Serve(tlsListener, mux); err != nil { log.Printf("[tls] HTTPS server error: %v", err) } }() + <-httpsReady + + // IPv6 HTTPS + httpsAddr6 := "[::1]:" + httpsPort + httpsLn6, err6 := net.Listen("tcp", httpsAddr6) + if err6 == nil { + tlsListener6 := tls.NewListener(httpsLn6, tlsCfg) + go func() { + runtime.LockOSThread() + log.Printf("HTTPS server on %s (IPv6)", httpsAddr6) + if err := http.Serve(tlsListener6, mux); err != nil { + log.Printf("[tls] HTTPS IPv6 server error: %v", err) + } + }() + } + + return nil } func portInt(s string) int { n, _ := strconv.Atoi(s) if n == 0 { - return 9271 + return 9638 } return n } @@ -533,9 +815,998 @@ func portInt(s string) int { // hasFlag checks if a CLI flag is present in os.Args. func hasFlag(flag string) bool { for _, arg := range os.Args[1:] { - if arg == flag { + if arg == flag || strings.HasPrefix(arg, flag+"=") { return true } } return false } + +// getFlagValue returns the value after a CLI flag (e.g. --flag value or --flag=value). +func getFlagValue(flag string) string { + for j, arg := range os.Args[1:] { + if strings.HasPrefix(arg, flag+"=") { + return strings.TrimPrefix(arg, flag+"=") + } + if arg == flag && j+2 < len(os.Args) { + next := os.Args[j+2] + if !strings.HasPrefix(next, "--") { + return next + } + } + } + return "" +} + +// --- Batch printing handlers --- + +// handleApiTest tests the connection to the backend API. +func handleApiTest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + cfg := getConfig() + if cfg.ApiURL == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "api_url not configured"}) + return + } + client := NewApiClient(cfg) + if err := client.TestConnection(); err != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + // Count available templates + templates, _ := client.FetchTemplates() + thermalCount := 0 + for _, t := range templates { + if t.Categoria == "etiqueta" || t.ThermalPrintable == 1 { + thermalCount++ + } + } + jsonResponse(w, http.StatusOK, map[string]any{ + "status": "connected", + "api_url": cfg.ApiURL, + "wl": cfg.ApiWhiteLabel, + "total_templates": len(templates), + "thermal_templates": thermalCount, + }) +} + +// handleApiTemplates fetches PDF templates from the backend. +// GET /api/templates β€” list all +// GET /api/templates?id=UUID β€” get detail + fields +func handleApiTemplates(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + cfg := getConfig() + if cfg.ApiURL == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "api_url not configured"}) + return + } + client := NewApiClient(cfg) + + // If specific ID requested, return detail + templateID := r.URL.Query().Get("id") + if templateID != "" { + detail, err := client.FetchTemplateDetail(templateID) + if err != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + jsonResponse(w, http.StatusOK, detail) + return + } + + // List all templates + templates, err := client.FetchTemplates() + if err != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + jsonResponse(w, http.StatusOK, map[string]any{"templates": templates}) +} + +// handleExcelUpload parses an uploaded xlsx file. +func handleExcelUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + // Limit upload to 10MB + r.ParseMultipartForm(10 << 20) + file, _, err := r.FormFile("file") + if err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "file required: " + err.Error()}) + return + } + defer file.Close() + + fileBytes, err := io.ReadAll(file) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "read file: " + err.Error()}) + return + } + + data, err := ParseExcel(fileBytes) + if err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "parse error: " + err.Error()}) + return + } + + jsonResponse(w, http.StatusOK, data) +} + +// handleLabelTemplates handles GET (list) and POST (create) for label templates. +func handleLabelTemplates(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + jsonResponse(w, http.StatusOK, map[string]any{"templates": GetAllTemplates()}) + + case http.MethodPost: + var t LabelTemplate + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + if t.ID == "" || t.Name == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "id and name are required"}) + return + } + if err := SaveTemplate(t); err != nil { + jsonResponse(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "saved", "id": t.ID}) + + default: + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } +} + +// handleLabelTemplateByID handles GET/PUT/DELETE for a specific template. +func handleLabelTemplateByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/templates/") + if id == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template id required"}) + return + } + + switch r.Method { + case http.MethodGet: + t := GetTemplate(id) + if t == nil { + jsonResponse(w, http.StatusNotFound, map[string]string{"error": "template not found"}) + return + } + jsonResponse(w, http.StatusOK, t) + + case http.MethodPut: + var t LabelTemplate + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + t.ID = id + if err := SaveTemplate(t); err != nil { + jsonResponse(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "updated"}) + + case http.MethodDelete: + if err := DeleteTemplate(id); err != nil { + status := http.StatusNotFound + if strings.Contains(err.Error(), "built-in") { + status = http.StatusForbidden + } + jsonResponse(w, status, map[string]string{"error": err.Error()}) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) + + default: + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } +} + +// buildBatchJob creates a BatchJob from a request, supporting both backend and local modes. +// mode "backend": template_id is a UUID, TSPL generated server-side via API +// mode "local": template_id is a local template ID, TSPL generated locally +func buildBatchJob(req struct { + TemplateID string `json:"template_id"` + PresetID string `json:"preset_id"` + Layout string `json:"layout"` + Rows []map[string]string `json:"rows"` + Copies int `json:"copies"` + Printer string `json:"printer"` + Mode string `json:"mode"` // "backend" or "local", auto-detected if empty +}) (*BatchJob, error) { + if len(req.Rows) == 0 { + return nil, fmt.Errorf("no rows provided") + } + + mode := req.Mode + // Auto-detect: UUIDs have dashes, local IDs don't + if mode == "" { + if strings.Contains(req.TemplateID, "-") && len(req.TemplateID) > 30 { + mode = "backend" + } else { + mode = "local" + } + } + + job := &BatchJob{ + Rows: req.Rows, + Copies: req.Copies, + Printer: req.Printer, + Mode: mode, + } + + if mode == "backend" { + job.BackendTemplateID = req.TemplateID + job.Layout = req.Layout + job.PresetName = req.PresetID + return job, nil + } + + // Local mode: resolve template and preset + tmpl := GetTemplate(req.TemplateID) + if tmpl == nil { + return nil, fmt.Errorf("local template not found: %s", req.TemplateID) + } + job.Template = tmpl + + presetID := req.PresetID + if presetID == "" { + presetID = tmpl.PresetID + } + cfg := getConfig() + preset := getPresetByID(presetID, cfg.CustomPresets) + if preset == nil { + return nil, fmt.Errorf("preset not found: %s", presetID) + } + job.Preset = preset + return job, nil +} + +// handleBatchPrint executes a batch print job. +func handleBatchPrint(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + var req struct { + TemplateID string `json:"template_id"` + PresetID string `json:"preset_id"` + Layout string `json:"layout"` + Rows []map[string]string `json:"rows"` + Copies int `json:"copies"` + Printer string `json:"printer"` + Mode string `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + + job, err := buildBatchJob(req) + if err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + result, err := job.Execute() + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + "result": result, + }) + return + } + jsonResponse(w, http.StatusOK, result) +} + +// handleBatchPreview returns the generated TSPL2 without printing. +func handleBatchPreview(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + var req struct { + TemplateID string `json:"template_id"` + PresetID string `json:"preset_id"` + Layout string `json:"layout"` + Rows []map[string]string `json:"rows"` + Copies int `json:"copies"` + Printer string `json:"printer"` + Mode string `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) + return + } + + job, err := buildBatchJob(req) + if err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + tspl, err := job.GenerateTSPL() + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + jsonResponse(w, http.StatusOK, map[string]any{ + "tspl": tspl, + "rows": len(req.Rows), + "bytes": len(tspl), + "mode": job.Mode, + }) +} + +// handleBatchPdf generates a multi-page PDF from Excel rows + pdfme template. +func handleBatchPdf(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + TemplateID string `json:"template_id"` + TemplateFile string `json:"template_file"` // local JSON file path (bypasses API) + TemplateJSON json.RawMessage `json:"template_json"` // inline template JSON (bypasses API) + Rows []map[string]string `json:"rows"` + Mapping map[string]string `json:"mapping"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if len(req.Rows) == 0 { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "rows required"}) + return + } + + var schemaRaw json.RawMessage + + // Priority: template_json > template_file > template_id (API) + if len(req.TemplateJSON) > 0 { + schemaRaw = req.TemplateJSON + log.Printf("[pdf] Using inline template_json (%d bytes)", len(schemaRaw)) + } else if req.TemplateFile != "" { + data, err := os.ReadFile(req.TemplateFile) + if err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "read template_file: " + err.Error()}) + return + } + schemaRaw = json.RawMessage(data) + log.Printf("[pdf] Using local template_file: %s (%d bytes)", req.TemplateFile, len(data)) + } else if req.TemplateID != "" { + cfg := getConfig() + client := NewApiClient(cfg) + detail, err := client.FetchTemplateDetail(req.TemplateID) + if err != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": "fetch template: " + err.Error()}) + return + } + if detail.Schema == nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template has no pdfme schema"}) + return + } + schemaRaw = detail.Schema + log.Printf("[pdf] Using API template_id: %s", req.TemplateID) + } else { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template_id, template_file, or template_json required"}) + return + } + + schema, err := ParsePdfmeSchema(schemaRaw) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "parse schema: " + err.Error()}) + return + } + + // Log field summary for debugging + if len(schema.Schemas) > 0 { + qrCount := 0 + for _, f := range schema.Schemas[0] { + if f.Type == "qrcode" { + qrCount++ + log.Printf("[pdf] QR field found: %q content=%q", f.Name, f.Content) + } + } + if qrCount == 0 { + log.Printf("[pdf] WARNING: No QR fields found in template!") + } + } + + // Rows arrive already mapped by the dashboard frontend (applyMapping). + // The mapping dict is informational only β€” do NOT re-apply it. + mappedRows := req.Rows + + outputDir := filepath.Join(configDir(), "output") + os.MkdirAll(outputDir, 0755) + outputPath := filepath.Join(outputDir, fmt.Sprintf("batch_%d.pdf", time.Now().UnixMilli())) + + if err := RenderBulkPDF(schema, mappedRows, outputPath); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "render PDF: " + err.Error()}) + return + } + + // Check if client wants a download URL instead of direct file (for webview compat) + if r.URL.Query().Get("mode") == "url" { + // Serve a URL the client can open in a new window/tab + filename := filepath.Base(outputPath) + jsonResponse(w, http.StatusOK, map[string]any{ + "url": "/output/" + filename, + "filename": fmt.Sprintf("batch_%d.pdf", len(mappedRows)), + "pages": len(mappedRows), + }) + return + } + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="batch_%d.pdf"`, len(mappedRows))) + http.ServeFile(w, r, outputPath) +} + +// handleBatchTspl generates TSPL2 from a pdfme template and prints or previews. +// POST /batch-tspl { template_id, rows, mapping, printer?, copies?, mode?, dpi? } +// mode: "print" (default) sends to printer; "preview" returns TSPL text; "raster" uses full bitmap mode. +func handleBatchTspl(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + TemplateID string `json:"template_id"` + Rows []map[string]string `json:"rows"` + Mapping map[string]string `json:"mapping"` + Printer string `json:"printer"` + Copies int `json:"copies"` + Mode string `json:"mode"` // "print", "preview", "raster" + DPI int `json:"dpi"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.TemplateID == "" || len(req.Rows) == 0 { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template_id and rows required"}) + return + } + if req.Copies < 1 { + req.Copies = 1 + } + + cfg := getConfig() + client := NewApiClient(cfg) + detail, err := client.FetchTemplateDetail(req.TemplateID) + if err != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": "fetch template: " + err.Error()}) + return + } + if detail.Schema == nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template has no pdfme schema"}) + return + } + + schema, err := ParsePdfmeSchema(detail.Schema) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "parse schema: " + err.Error()}) + return + } + + var tsplData []byte + if req.Mode == "raster" { + tsplData = RenderBulkTSPLRaster(schema, req.Rows, req.DPI, req.Copies) + } else { + tsplData = RenderBulkTSPL(schema, req.Rows, req.DPI, req.Copies) + } + + if req.Mode == "preview" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write(tsplData) + return + } + + // Send to printer (do NOT sanitize β€” may contain binary BITMAP data) + allPrinters, _ := listAllPrinters() + printerName := req.Printer + if printerName == "" { + printerName = cfg.DefaultPrinter + } + + var targetPrinter *PrinterInfo + if printerName == "" { + if len(allPrinters) > 0 { + targetPrinter = &allPrinters[0] + printerName = targetPrinter.Name + } + } else { + targetPrinter = findPrinter(printerName, allPrinters) + } + if printerName == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "no printer found"}) + return + } + + var printErr error + if targetPrinter != nil && (targetPrinter.Type == "network" || targetPrinter.Type == "manual" || targetPrinter.Type == "raw") { + printErr = networkRawPrint(targetPrinter.Address, tsplData) + } else { + printErr = rawPrint(printerName, tsplData) + } + + if printErr != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "print failed: " + printErr.Error()}) + return + } + + jsonResponse(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "rows": len(req.Rows), + "bytes": len(tsplData), + "printer": printerName, + "mode": req.Mode, + }) +} + +// handleBatchPreviewImage generates a PNG preview of what the thermal printer would output. +// POST /batch-preview-image { template_id, rows, mapping, dpi?, row_index? } +// Returns PNG image (monochrome raster at specified DPI, default 203). +func handleBatchPreviewImage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + TemplateID string `json:"template_id"` + Rows []map[string]string `json:"rows"` + Mapping map[string]string `json:"mapping"` + DPI int `json:"dpi"` + RowIndex int `json:"row_index"` // which row to preview (default 0) + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.TemplateID == "" || len(req.Rows) == 0 { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template_id and rows required"}) + return + } + if req.DPI <= 0 { + req.DPI = 203 + } + if req.RowIndex >= len(req.Rows) { + req.RowIndex = 0 + } + + cfg := getConfig() + client := NewApiClient(cfg) + detail, err := client.FetchTemplateDetail(req.TemplateID) + if err != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": "fetch template: " + err.Error()}) + return + } + if detail.Schema == nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template has no pdfme schema"}) + return + } + + schema, err := ParsePdfmeSchema(detail.Schema) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "parse schema: " + err.Error()}) + return + } + + row := req.Rows[req.RowIndex] + img := rasterizePage(schema, row, 0, req.DPI) + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "encode PNG: " + err.Error()}) + return + } + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Disposition", `inline; filename="preview.png"`) + w.Write(buf.Bytes()) +} + +// handleDebugTemplate returns parsed field details for a template (debugging). +// POST /debug-template { template_id } +func handleDebugTemplate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + TemplateID string `json:"template_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.TemplateID == "" { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template_id required"}) + return + } + + cfg := getConfig() + client := NewApiClient(cfg) + detail, err := client.FetchTemplateDetail(req.TemplateID) + if err != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": "fetch template: " + err.Error()}) + return + } + if detail.Schema == nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template has no pdfme schema"}) + return + } + + schema, err := ParsePdfmeSchema(detail.Schema) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "parse schema: " + err.Error()}) + return + } + + type fieldDebug struct { + Index int `json:"index"` + Name string `json:"name"` + Type string `json:"type"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Content string `json:"content,omitempty"` + Opacity float64 `json:"opacity"` + } + + var pages [][]fieldDebug + for _, page := range schema.Schemas { + var fields []fieldDebug + for i, f := range page { + fields = append(fields, fieldDebug{ + Index: i, + Name: f.Name, + Type: f.Type, + X: f.Position.X, + Y: f.Position.Y, + Width: f.Width, + Height: f.Height, + Content: f.Content, + Opacity: f.Opacity, + }) + } + pages = append(pages, fields) + } + + jsonResponse(w, http.StatusOK, map[string]interface{}{ + "template_id": req.TemplateID, + "name": detail.Name, + "base_pdf": map[string]float64{ + "width": schema.BasePdf.Width, + "height": schema.BasePdf.Height, + }, + "pages": pages, + "total_fields": len(pages[0]), + }) +} + +// ════════════════════════════════════════════════════ +// Print Dialog endpoints +// ════════════════════════════════════════════════════ + +// POST /print-preview-thumb β€” returns a PNG thumbnail for one page +func handlePrintPreviewThumb(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + Source string `json:"source"` // "batch" or "upload" + TemplateID string `json:"template_id"` + TemplateJSON json.RawMessage `json:"template_json"` + Rows []map[string]string `json:"rows"` + RowIndex int `json:"row_index"` + PageIndex int `json:"page_index"` + DPI int `json:"dpi"` + UploadID string `json:"upload_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.DPI <= 0 { + req.DPI = 72 + } + + if req.Source == "upload" { + // Serve pre-rasterized PNG from upload directory + pngPath := getUploadPagePath(req.UploadID, req.PageIndex) + if pngPath == "" { + jsonResponse(w, http.StatusNotFound, map[string]string{"error": "page not found"}) + return + } + w.Header().Set("Content-Type", "image/png") + http.ServeFile(w, r, pngPath) + return + } + + // Batch mode: rasterize a single page from schema + row data + var schema *PdfmeSchema + var err error + + if len(req.TemplateJSON) > 0 { + schema, err = ParsePdfmeSchema(req.TemplateJSON) + } else if req.TemplateID != "" { + cfg := getConfig() + client := NewApiClient(cfg) + detail, fetchErr := client.FetchTemplateDetail(req.TemplateID) + if fetchErr != nil { + jsonResponse(w, http.StatusBadGateway, map[string]string{"error": "fetch template: " + fetchErr.Error()}) + return + } + if detail.Schema == nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template has no pdfme schema"}) + return + } + schema, err = ParsePdfmeSchema(detail.Schema) + } else { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "template_json or template_id required"}) + return + } + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "parse schema: " + err.Error()}) + return + } + + if req.RowIndex >= len(req.Rows) { + req.RowIndex = 0 + } + row := req.Rows[req.RowIndex] + img := rasterizePage(schema, row, req.PageIndex, req.DPI) + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "encode PNG: " + err.Error()}) + return + } + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=60") + w.Write(buf.Bytes()) +} + +// POST /print-job β€” SSE stream that prints selected pages with progress +func handlePrintJob(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + var req struct { + Source string `json:"source"` // "batch" or "upload" + TemplateID string `json:"template_id"` + TemplateJSON json.RawMessage `json:"template_json"` + Rows []map[string]string `json:"rows"` + Pages []int `json:"pages"` // absolute page indices + Copies int `json:"copies"` + Printer string `json:"printer"` + UploadID string `json:"upload_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if len(req.Pages) == 0 { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "pages required"}) + return + } + if req.Copies < 1 { + req.Copies = 1 + } + + // Setup SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + flusher, ok := w.(http.Flusher) + if !ok { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "streaming not supported"}) + return + } + + sseWrite := func(event string, data interface{}) { + jsonData, _ := json.Marshal(data) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, jsonData) + flusher.Flush() + } + + // Parse schema for batch mode + var schema *PdfmeSchema + pagesPerRow := 1 + + if req.Source != "upload" { + var err error + if len(req.TemplateJSON) > 0 { + schema, err = ParsePdfmeSchema(req.TemplateJSON) + } else if req.TemplateID != "" { + cfg := getConfig() + client := NewApiClient(cfg) + detail, fetchErr := client.FetchTemplateDetail(req.TemplateID) + if fetchErr != nil { + sseWrite("error", map[string]string{"message": "fetch template: " + fetchErr.Error()}) + return + } + if detail.Schema == nil { + sseWrite("error", map[string]string{"message": "template has no pdfme schema"}) + return + } + schema, err = ParsePdfmeSchema(detail.Schema) + } + if err != nil { + sseWrite("error", map[string]string{"message": "parse schema: " + err.Error()}) + return + } + if schema != nil { + pagesPerRow = len(schema.Schemas) + if pagesPerRow < 1 { + pagesPerRow = 1 + } + } + } + + totalPages := len(req.Pages) + printerName := req.Printer + + sseWrite("start", map[string]any{ + "total_pages": totalPages, + "printer": printerName, + }) + + startTime := time.Now() + totalBytes := 0 + printed := 0 + + for i, absPage := range req.Pages { + // Check if client disconnected + select { + case <-r.Context().Done(): + sseWrite("error", map[string]string{"message": "cancelled by client"}) + return + default: + } + + var tsplData []byte + + if req.Source == "upload" { + // Upload mode: read rasterized PNG and convert to TSPL bitmap + tsplData = renderUploadedPageTSPL(req.UploadID, absPage, req.Copies) + } else { + // Batch mode: render single page TSPL + rowIdx := absPage / pagesPerRow + pageIdx := absPage % pagesPerRow + if rowIdx < len(req.Rows) { + tsplData = renderSinglePageTSPL(schema, req.Rows[rowIdx], pageIdx, defaultDPI, req.Copies) + } + } + + if len(tsplData) == 0 { + sseWrite("error", map[string]string{"message": fmt.Sprintf("empty TSPL for page %d", absPage+1), "page": strconv.Itoa(absPage + 1)}) + continue + } + + // Send to printer + err := sendToPrinterByName(string(tsplData), printerName) + if err != nil { + sseWrite("error", map[string]string{"message": "printer error: " + err.Error(), "page": strconv.Itoa(absPage + 1)}) + return + } + + totalBytes += len(tsplData) + printed++ + + pct := int(float64(i+1) / float64(totalPages) * 100) + sseWrite("progress", map[string]any{ + "page": i + 1, + "of": totalPages, + "percent": pct, + "bytes_sent": len(tsplData), + }) + } + + elapsed := time.Since(startTime).Milliseconds() + sseWrite("complete", map[string]any{ + "printed": printed, + "total_bytes": totalBytes, + "elapsed_ms": elapsed, + }) +} + +// POST /upload-pdf β€” accepts PDF file, rasterizes pages, returns metadata +func handleUploadPdf(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + + // Limit to 20MB + r.ParseMultipartForm(20 << 20) + file, _, err := r.FormFile("file") + if err != nil { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "file required: " + err.Error()}) + return + } + defer file.Close() + + // Read into temp file + uploadID := fmt.Sprintf("up_%d", time.Now().UnixMilli()) + uploadsDir := filepath.Join(configDir(), "uploads", uploadID) + os.MkdirAll(uploadsDir, 0755) + + pdfPath := filepath.Join(uploadsDir, "input.pdf") + outFile, err := os.Create(pdfPath) + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "create file: " + err.Error()}) + return + } + io.Copy(outFile, file) + outFile.Close() + + // Rasterize + outDir, pageCount, err := rasterizeUploadedPDF(pdfPath, 203) + if err != nil { + os.RemoveAll(uploadsDir) + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "rasterize: " + err.Error()}) + return + } + + log.Printf("[upload-pdf] Rasterized %d pages from upload %s to %s", pageCount, uploadID, outDir) + + jsonResponse(w, http.StatusOK, map[string]any{ + "upload_id": uploadID, + "total_pages": pageCount, + }) +} + +// handleServeOutput serves generated files (PDFs, etc.) from the output directory. +// GET /output/{filename} +func handleServeOutput(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + filename := strings.TrimPrefix(r.URL.Path, "/output/") + if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") { + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"}) + return + } + outputDir := filepath.Join(configDir(), "output") + filePath := filepath.Join(outputDir, filename) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + jsonResponse(w, http.StatusNotFound, map[string]string{"error": "file not found"}) + return + } + // Allow inline display for PDF preview (iframe/embed), download for explicit requests + if r.URL.Query().Get("dl") == "1" { + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + } else { + w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename)) + } + http.ServeFile(w, r, filePath) +} diff --git a/msgbox_other.go b/msgbox_other.go new file mode 100644 index 0000000..5038e68 --- /dev/null +++ b/msgbox_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package main + +func showMessage(title, text string) {} +func showError(title, text string) {} diff --git a/msgbox_windows.go b/msgbox_windows.go new file mode 100644 index 0000000..dc2a094 --- /dev/null +++ b/msgbox_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package main + +import ( + "syscall" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + messageBox = user32.NewProc("MessageBoxW") +) + +// showMessage shows a native Windows message box (works even without a console). +func showMessage(title, text string) { + titlePtr, _ := syscall.UTF16PtrFromString(title) + textPtr, _ := syscall.UTF16PtrFromString(text) + messageBox.Call( + 0, + uintptr(unsafe.Pointer(textPtr)), + uintptr(unsafe.Pointer(titlePtr)), + 0x00000040, // MB_ICONINFORMATION + ) +} + +// showError shows a native Windows error message box. +func showError(title, text string) { + titlePtr, _ := syscall.UTF16PtrFromString(title) + textPtr, _ := syscall.UTF16PtrFromString(text) + messageBox.Call( + 0, + uintptr(unsafe.Pointer(textPtr)), + uintptr(unsafe.Pointer(titlePtr)), + 0x00000010, // MB_ICONERROR + ) +} diff --git a/network.go b/network.go index d92328e..65e1f91 100644 --- a/network.go +++ b/network.go @@ -11,10 +11,13 @@ import ( // PrinterInfo describes a detected printer with metadata. type PrinterInfo struct { - Name string `json:"name"` - Type string `json:"type"` // "usb" | "cups" | "spooler" | "network" - Address string `json:"address,omitempty"` // "192.168.1.50:9100" for network printers - Model string `json:"model,omitempty"` // parsed from ~!I response + Name string `json:"name"` + Type string `json:"type"` // "usb" | "cups" | "spooler" | "network" | "manual" | "raw" + Address string `json:"address,omitempty"` // "192.168.1.50:9100" for network printers + Model string `json:"model,omitempty"` // parsed from ~!I response + Online bool `json:"online"` // true if printer is reachable/connected + IsSelf bool `json:"is_self,omitempty"` // true if this is our own shared printer + Status string `json:"status,omitempty"` // "idle", "offline", "disabled", etc. } const ( @@ -33,7 +36,9 @@ var ( // tscModelKeywords identifies TSC printers from ~!I response. var tscModelKeywords = []string{ - "TSC", "TDP", "TE2", "TE3", "TX2", "TX3", "TTP", "DA2", "MH", "Alpha", + "TSC", "TDP", "TE2", "TE3", "TX2", "TX3", + "TTP", "TTP-220", "TTP-225", "TTP-244", "TTP-247", + "DA2", "MH", "Alpha", } // getLocalSubnets returns all IPv4 /24 subnets on local interfaces. @@ -79,7 +84,67 @@ func getLocalSubnets() []net.IPNet { // probeTSCPrinter connects to addr:9100, sends ~!I, and parses the response. // Returns the model string and whether it's a TSC printer. +// If the port is open but doesn't respond to ~!I, it tries ~!T and ~!F as fallbacks. func probeTSCPrinter(addr string) (string, bool) { + // Try multiple TSC probe commands in order + probeCommands := []string{"~!I\r\n", "~!T\r\n", "~!F\r\n"} + + for _, cmd := range probeCommands { + model, isTSC := probeTSCWithCommand(addr, cmd) + if isTSC { + return model, true + } + } + + return "", false +} + +// probeRawPort checks if a TCP port is open and accepts connections. +// Returns true if the port is open (potential raw printer). +func probeRawPort(addr string) bool { + conn, err := net.DialTimeout("tcp", addr, probeTimeout) + if err != nil { + return false + } + conn.Close() + return true +} + +// isNonPrinterService sends a harmless probe and checks if the response +// looks like a non-printer service (HTTP, SSH, FTP, etc). +func isNonPrinterService(addr string) bool { + conn, err := net.DialTimeout("tcp", addr, probeTimeout) + if err != nil { + return false + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(probeTimeout)) + + // Send an HTTP-like probe β€” real printers ignore this, HTTP servers respond + conn.Write([]byte("GET / HTTP/1.0\r\n\r\n")) + + buf := make([]byte, 32) + n, _ := conn.Read(buf) + if n == 0 { + return false // no response = likely a raw printer + } + + resp := strings.TrimSpace(string(buf[:n])) + for _, prefix := range falsePositivePrefixes { + if strings.HasPrefix(resp, prefix) { + return true + } + } + return false +} + +// Non-printer response prefixes to reject (HTTP servers, etc. listening on 9100) +var falsePositivePrefixes = []string{ + "HTTP/", " 0 && len(v5.Schemas[0]) > 0 { + schema := &PdfmeSchema{Schemas: v5.Schemas, BasePdf: basePdf} + applyBasePdfDefaults(schema) + log.Printf("[pdf] Parsed v5 schema: %d pages, %d fields on page 0", len(schema.Schemas), len(schema.Schemas[0])) + return schema, nil + } + + // Try v4 format: schemas is array of keyed objects. + // IMPORTANT: Must preserve JSON key order β€” it defines the z-order (layer order). + // Go maps lose insertion order, so we use json.Decoder to iterate keys in order. + var v4raw struct { + Schemas []json.RawMessage `json:"schemas"` + } + if err := json.Unmarshal(raw, &v4raw); err != nil || len(v4raw.Schemas) == 0 { + return nil, fmt.Errorf("parse pdfme schema: no valid schemas found") + } + + schema := &PdfmeSchema{BasePdf: basePdf} + schema.Schemas = make([][]PdfmeField, len(v4raw.Schemas)) + for i, pageRaw := range v4raw.Schemas { + schema.Schemas[i] = parseV4PageOrdered(pageRaw, i) + } + + applyBasePdfDefaults(schema) + totalFields := 0 + for _, p := range schema.Schemas { + totalFields += len(p) + } + log.Printf("[pdf] Parsed v4 schema: %d pages, %d total fields", len(schema.Schemas), totalFields) + return schema, nil +} + +// parseV4PageOrdered parses a single v4 schema page, preserving JSON key order (= z-order). +// Two-phase approach: +// Phase 1: Use json.Decoder (Token for keys, Decode to skip values) to extract key order. +// Phase 2: Unmarshal into map for reliable field parsing. +func parseV4PageOrdered(pageRaw json.RawMessage, pageIndex int) []PdfmeField { + // Phase 1: Extract ordered keys + dec := json.NewDecoder(bytes.NewReader(pageRaw)) + t, err := dec.Token() // opening { + if err != nil || t != json.Delim('{') { + log.Printf("[pdf] Warning: page %d is not a JSON object", pageIndex) + return nil + } + + var keyOrder []string + for dec.More() { + // Read key + t, err := dec.Token() + if err != nil { + break + } + key, ok := t.(string) + if !ok { + break + } + keyOrder = append(keyOrder, key) + // Skip value (Decode consumes one complete JSON value) + var skip json.RawMessage + if err := dec.Decode(&skip); err != nil { + log.Printf("[pdf] Warning: could not skip value for key %q on page %d: %v", key, pageIndex, err) + break + } + } + + // Phase 2: Unmarshal full map for reliable field values + var fieldMap map[string]json.RawMessage + if err := json.Unmarshal(pageRaw, &fieldMap); err != nil { + log.Printf("[pdf] Warning: could not unmarshal page %d as map: %v", pageIndex, err) + return nil + } + + // Build fields in key order (= z-order from JSON) + var fields []PdfmeField + for _, key := range keyOrder { + if strings.HasPrefix(key, "_") { + continue + } + raw, ok := fieldMap[key] + if !ok { + continue + } + var field PdfmeField + if err := json.Unmarshal(raw, &field); err != nil { + log.Printf("[pdf] Warning: skip field %q on page %d: %v", key, pageIndex, err) + continue + } + field.Name = key + fields = append(fields, field) + } + + log.Printf("[pdf] Page %d: parsed %d fields in z-order (keys: %d, map entries: %d)", + pageIndex, len(fields), len(keyOrder), len(fieldMap)) + + return fields +} + +// parseBasePdf handles all basePdf formats: object, "BLANK", base64 PDF string. +func parseBasePdf(raw json.RawMessage) PdfmeBasePdf { + var wrapper struct { + BasePdf json.RawMessage `json:"basePdf"` + } + if err := json.Unmarshal(raw, &wrapper); err != nil || wrapper.BasePdf == nil { + return PdfmeBasePdf{} // will get defaults + } + + // Try as dimension object + var bp PdfmeBasePdf + if err := json.Unmarshal(wrapper.BasePdf, &bp); err == nil && (bp.Width > 0 || bp.Height > 0) { + return bp + } + + // Try as string ("BLANK" or base64 PDF) + var s string + if err := json.Unmarshal(wrapper.BasePdf, &s); err == nil { + if s == "BLANK" || s == "" { + return PdfmeBasePdf{Width: 210, Height: 297} // A4 + } + // base64 PDF β€” store it for use as background, default page size to A4 + return PdfmeBasePdf{Width: 210, Height: 297, BackgroundPdf: s} + } + + // Also check for pageConfig (ISI custom format) + var pcWrapper struct { + PageConfig struct { + Width float64 `json:"width"` + Height float64 `json:"height"` + Size string `json:"size"` + Orientation string `json:"orientation"` + } `json:"pageConfig"` + } + if err := json.Unmarshal(raw, &pcWrapper); err == nil && pcWrapper.PageConfig.Width > 0 { + w := pcWrapper.PageConfig.Width + h := pcWrapper.PageConfig.Height + // Some templates use cm instead of mm (width=21 instead of 210) + if w < 100 { + w *= 10 + h *= 10 + } + return PdfmeBasePdf{Width: w, Height: h} + } + + return PdfmeBasePdf{} // will get defaults +} + +func applyBasePdfDefaults(schema *PdfmeSchema) { + if schema.BasePdf.Width == 0 { + schema.BasePdf.Width = 210 // A4 default + } + if schema.BasePdf.Height == 0 { + schema.BasePdf.Height = 297 // A4 default + } +} + +// ════════════════════════════════════════════════════ +// Bulk PDF rendering β€” multi-page template support +// ════════════════════════════════════════════════════ + +// RenderBulkPDF generates a multi-page PDF from rows of data using a pdfme schema. +// For multi-page templates, each row produces N pages (one per schema page). +func RenderBulkPDF(schema *PdfmeSchema, rows []map[string]string, outputPath string) error { + pdf := gopdf.GoPdf{} + + pageW := schema.BasePdf.Width * mmToPt + pageH := schema.BasePdf.Height * mmToPt + + pdf.Start(gopdf.Config{ + PageSize: gopdf.Rect{W: pageW, H: pageH}, + }) + + // Reset font registry for this render + fontRegistryMu.Lock() + fontRegistry = map[string]bool{} + fontRegistryMu.Unlock() + + // Load default fonts + fontPath, boldFontPath := findFonts() + fontLoaded := false + if fontPath != "" { + if err := pdf.AddTTFFont("default", fontPath); err != nil { + log.Printf("[pdf] Warning: could not load font %s: %v", fontPath, err) + } else { + fontLoaded = true + fontRegistry["default"] = true + log.Printf("[pdf] Font loaded: %s", fontPath) + } + } + if boldFontPath != "" { + if err := pdf.AddTTFFont("bold", boldFontPath); err != nil { + log.Printf("[pdf] Warning: could not load bold font %s: %v", boldFontPath, err) + } else { + fontRegistry["bold"] = true + log.Printf("[pdf] Bold font loaded: %s", boldFontPath) + } + } + if !fontLoaded { + log.Printf("[pdf] WARNING: No font loaded β€” text fields will be empty.") + } + + // Pre-load named fonts referenced in schema fields + loadedFontNames := map[string]bool{} + for _, page := range schema.Schemas { + for _, field := range page { + if field.FontName != "" && !loadedFontNames[field.FontName] { + loadedFontNames[field.FontName] = true + loadNamedFont(&pdf, field.FontName) + } + } + } + + // Import background PDF template if present (basePdf as base64 PDF) + bgTplID := -1 + var bgTmpFile string + if schema.BasePdf.BackgroundPdf != "" { + bgTmpFile = decodeBackgroundPdf(schema.BasePdf.BackgroundPdf) + if bgTmpFile != "" { + bgTplID = pdf.ImportPage(bgTmpFile, 1, "/MediaBox") + log.Printf("[pdf] Background PDF imported from base64 (tplID=%d)", bgTplID) + } + } + defer func() { + if bgTmpFile != "" { + os.Remove(bgTmpFile) + } + }() + + for ri, row := range rows { + if ri == 0 { + log.Printf("[pdf] === Row 0 data keys: %v", mapKeys(row)) + } + + // Render ALL pages from the template for each row + for pi, pageFields := range schema.Schemas { + pdf.AddPage() + + // Draw background PDF on every page + if bgTplID >= 0 { + pdf.UseImportedTemplate(bgTplID, 0, 0, pageW, pageH) + } + + for _, field := range pageFields { + value := resolveFieldValue(field, row) + if ri == 0 && pi == 0 { + log.Printf("[pdf] Field %q type=%s text=%q vars=%v β†’ value=%q", + field.Name, field.Type, truncate(field.Text, 50), field.Variables, truncate(value, 60)) + } + + x := field.Position.X * mmToPt + y := field.Position.Y * mmToPt + w := field.Width * mmToPt + h := field.Height * mmToPt + + // Apply opacity by blending colors with white (gopdf SetTransparency is unreliable). + // On white paper, blended color == transparent color visually. + if field.Opacity > 0 && field.Opacity < 1 { + op := field.Opacity + field.Color = blendColorWithWhite(field.Color, op) + field.FontColor = blendColorWithWhite(field.FontColor, op) + field.BorderColor = blendColorWithWhite(field.BorderColor, op) + field.BackgroundColor = blendColorWithWhite(field.BackgroundColor, op) + } + + // Apply rotation around field center + hasRotation := field.Rotate != 0 + if hasRotation { + cx := x + w/2 + cy := y + h/2 + pdf.Rotate(field.Rotate, cx, cy) + } + + // Render background/border first (for any type) + if field.BackgroundColor != "" || float64(field.BorderWidth) > 0 { + renderFieldBackground(&pdf, field, x, y, w, h) + } + + switch field.Type { + case "text", "multiVariableText": + if value == "" { + goto fieldDone + } + renderTextField(&pdf, field, value, x, y, w, h, fontLoaded) + case "qrcode": + log.Printf("[pdf] QR CASE: field=%q value=%q content=%q opacity=%.2f w=%.1f h=%.1f", + field.Name, value, field.Content, field.Opacity, w, h) + if value == "" { + value = field.Content + } + if value == "" { + value = field.Name + log.Printf("[pdf] QR using field name as placeholder: %q", value) + } + renderQRField(&pdf, field, value, x, y, w, h) + log.Printf("[pdf] QR rendered OK: field=%q", field.Name) + case "image": + renderImageField(&pdf, field, row, x, y, w, h) + case "barcode", "code128", "code39", "ean13", "ean8": + if value == "" { + goto fieldDone + } + renderBarcodeField(&pdf, value, x, y, w, h) + case "table": + renderTableField(&pdf, field, row, x, y, w, h, fontLoaded) + case "line": + renderLineField(&pdf, field, x, y, w, h) + case "rectangle": + renderRectangleField(&pdf, field, x, y, w, h) + case "ellipse": + renderEllipseField(&pdf, field, x, y, w, h) + } + + fieldDone: + // Clear rotation + if hasRotation { + pdf.RotateReset() + } + } + } + } + + return pdf.WritePdf(outputPath) +} + +// ════════════════════════════════════════════════════ +// Field rendering +// ════════════════════════════════════════════════════ + +func renderFieldBackground(pdf *gopdf.GoPdf, field PdfmeField, x, y, w, h float64) { + if field.BackgroundColor != "" { + r, g, b, _ := parseColor(field.BackgroundColor) + pdf.SetFillColor(r, g, b) + pdf.RectFromUpperLeftWithStyle(x, y, w, h, "F") + } + // borderWidth is in mm, SetLineWidth expects pt + bw := float64(field.BorderWidth) * mmToPt + if bw > 0 { + color := field.BorderColor + if color == "" { + color = "#000000" + } + r, g, b, _ := parseColor(color) + pdf.SetStrokeColor(r, g, b) + pdf.SetLineWidth(bw) + pdf.RectFromUpperLeftWithStyle(x, y, w, h, "D") + } +} + +func renderTextField(pdf *gopdf.GoPdf, field PdfmeField, value string, x, y, w, h float64, fontLoaded bool) { + if !fontLoaded { + return + } + + // Apply padding β€” shrink effective area + px, py, pw, ph := x, y, w, h + pad := field.Padding + if pad.Top > 0 || pad.Right > 0 || pad.Bottom > 0 || pad.Left > 0 { + padT := pad.Top * mmToPt + padR := pad.Right * mmToPt + padB := pad.Bottom * mmToPt + padL := pad.Left * mmToPt + px += padL + py += padT + pw -= padL + padR + ph -= padT + padB + if pw < 0 { + pw = 0 + } + if ph < 0 { + ph = 0 + } + } + + // Resolve font family FIRST (needed for dynamic font size measurement) + fontFamily := "default" + boldFamily := "bold" + if field.FontName != "" { + fam, _ := loadNamedFont(pdf, field.FontName) + fontFamily = fam + boldFamily = fam + "_bold" + } + + // Active font = bold or regular + activeFontFamily := fontFamily + if field.FontWeight == "bold" { + activeFontFamily = boldFamily + } + + // Line height multiplier + lh := field.LineHeight + if lh <= 0 { + lh = 1.4 + } + + fontSize := field.FontSize + if fontSize == 0 { + fontSize = 10 + } + + // Dynamic font size: try to fit text within bounds (uses correct font for measuring) + if field.DynamicFontSize != nil && field.DynamicFontSize.Max > 0 { + fontSize = calculateDynamicFontSize(pdf, value, pw, ph, field.DynamicFontSize, activeFontFamily, lh) + } + + // Set the active font with style flags + // gopdf style flags: Regular=0, Bold=2, Underline=4 + styleFlag := gopdf.Regular + if field.Underline { + styleFlag |= gopdf.Underline + } + + if styleFlag != gopdf.Regular { + if err := pdf.SetFontWithStyle(activeFontFamily, styleFlag, fontSize); err != nil { + pdf.SetFont(activeFontFamily, "", fontSize) + } + } else { + if err := pdf.SetFont(activeFontFamily, "", fontSize); err != nil { + pdf.SetFont("default", "", fontSize) + } + } + + // Character spacing + if field.CharacterSpacing != 0 { + pdf.SetCharSpacing(field.CharacterSpacing) + } + + // Font color + if field.FontColor != "" { + r, g, b, _ := parseColor(field.FontColor) + pdf.SetTextColor(r, g, b) + } else { + pdf.SetTextColor(0, 0, 0) + } + + // lineSpacingPt: vertical space per line in PDF points + // fontSize is in points, lineHeight is a multiplier β†’ result is in points + lineSpacingPt := fontSize * lh + + // Word-wrap text into lines that fit within the available width + lines := wrapTextByWord(pdf, value, pw) + totalTextH := float64(len(lines)) * lineSpacingPt + + // Vertical alignment (start position) + textY := py + switch field.VerticalAlignment { + case "middle": + if totalTextH < ph { + textY = py + (ph-totalTextH)/2 + } + case "bottom": + if totalTextH < ph { + textY = py + ph - totalTextH + } + // default "top": textY = py + } + + pdf.SetX(px) + pdf.SetY(textY) + + for li, line := range lines { + if li > 0 { + textY += lineSpacingPt + pdf.SetY(textY) + } + // Don't render lines that overflow the padded area + if textY > py+ph { + break + } + + // Horizontal alignment (start position) + lineX := px + textW, _ := pdf.MeasureTextWidth(line) + switch field.Alignment { + case "center": + if textW < pw { + lineX = px + (pw-textW)/2 + } + case "right": + if textW < pw { + lineX = px + pw - textW + } + // default "left": lineX = px + } + pdf.SetX(lineX) + pdf.CellWithOption(&gopdf.Rect{W: pw, H: lineSpacingPt}, line, gopdf.CellOption{}) + + // Strikethrough β€” draw a line through the middle of the text + if field.Strikethrough { + stY := textY + lineSpacingPt*0.4 + drawW := textW + if drawW > pw { + drawW = pw + } + r, g, b, _ := parseColor(field.FontColor) + pdf.SetStrokeColor(r, g, b) + pdf.SetLineWidth(fontSize * 0.05) + pdf.Line(lineX, stY, lineX+drawW, stY) + } + } + + // Reset character spacing + if field.CharacterSpacing != 0 { + pdf.SetCharSpacing(0) + } +} + +// wrapTextByWord splits text into lines that fit within maxWidth (in PDF points). +// Respects existing \n line breaks. Wraps by word boundary (space). +// The font must already be set on pdf before calling. +func wrapTextByWord(pdf *gopdf.GoPdf, text string, maxWidth float64) []string { + if maxWidth <= 0 { + return []string{text} + } + + var result []string + paragraphs := strings.Split(text, "\n") + + for _, para := range paragraphs { + if para == "" { + result = append(result, "") + continue + } + + words := strings.Fields(para) + if len(words) == 0 { + result = append(result, "") + continue + } + + currentLine := words[0] + for i := 1; i < len(words); i++ { + candidate := currentLine + " " + words[i] + candidateW, _ := pdf.MeasureTextWidth(candidate) + if candidateW <= maxWidth { + currentLine = candidate + } else { + result = append(result, currentLine) + currentLine = words[i] + // If a single word is wider than maxWidth, it still gets its own line + } + } + result = append(result, currentLine) + } + + return result +} + +// countWrappedLines counts how many lines the text would occupy after word wrapping. +// The font must already be set on pdf before calling. +func countWrappedLines(pdf *gopdf.GoPdf, text string, maxWidth float64) int { + lines := wrapTextByWord(pdf, text, maxWidth) + return len(lines) +} + +// calculateDynamicFontSize finds the largest font size (between min and max) that fits text within wΓ—h. +// fontFamily: the gopdf font name to use for measuring. +// lineHeight: the line-height multiplier from the field (e.g. 1.4). +// w, h: available space in PDF points. +func calculateDynamicFontSize(pdf *gopdf.GoPdf, text string, w, h float64, dfs *DynFontSize, fontFamily string, lineHeight float64) float64 { + if lineHeight <= 0 { + lineHeight = 1.4 + } + if fontFamily == "" { + fontFamily = "default" + } + + for size := dfs.Max; size >= dfs.Min; size -= 0.5 { + if err := pdf.SetFont(fontFamily, "", size); err != nil { + continue + } + + lineSpacingPt := size * lineHeight + + if dfs.Fit == "horizontal" { + // For horizontal fit, each paragraph must fit in one line (no wrapping) + paragraphs := strings.Split(text, "\n") + fits := true + for _, para := range paragraphs { + paraW, _ := pdf.MeasureTextWidth(para) + if paraW > w { + fits = false + break + } + } + if fits { + return size + } + } else { + // "vertical" β€” word-wrap and check total height fits + totalLines := countWrappedLines(pdf, text, w) + totalH := float64(totalLines) * lineSpacingPt + if totalH <= h { + return size + } + } + } + return dfs.Min +} + +func renderQRField(pdf *gopdf.GoPdf, field PdfmeField, value string, x, y, w, h float64) { + log.Printf("[pdf] QR RENDER: field=%q value=%q pos=(%.1f,%.1f) size=(%.1f x %.1f)", field.Name, value, x, y, w, h) + qr, err := goqrcode.New(value, goqrcode.Medium) + if err != nil { + log.Printf("[pdf] QR error: %v", err) + return + } + qr.DisableBorder = true + bitmap := qr.Bitmap() + size := len(bitmap) + if size == 0 { + log.Printf("[pdf] QR bitmap empty for %q", value) + return + } + log.Printf("[pdf] QR bitmap size=%d modules, moduleW=%.2f moduleH=%.2f", size, w/float64(size), h/float64(size)) + + // Colors + fgColor := field.Color + if fgColor == "" { + fgColor = "#000000" + } + finderColor := field.QrFinderColor + if finderColor == "" { + finderColor = fgColor // same as foreground by default + } + + moduleW := w / float64(size) + moduleH := h / float64(size) + + // isFinderModule returns true if (row,col) is inside one of the 3 finder patterns (7x7 each). + // Finder patterns include the 7x7 area plus the 1-module separator border around them. + isFinderModule := func(row, col int) bool { + // Top-left: rows 0-6, cols 0-6 + if row <= 6 && col <= 6 { + return true + } + // Top-right: rows 0-6, cols (size-7) to (size-1) + if row <= 6 && col >= size-7 { + return true + } + // Bottom-left: rows (size-7) to (size-1), cols 0-6 + if row >= size-7 && col <= 6 { + return true + } + return false + } + + // Draw foreground modules + fgR, fgG, fgB, _ := parseColor(fgColor) + fpR, fpG, fpB, _ := parseColor(finderColor) + + for row := 0; row < size; row++ { + for col := 0; col < size; col++ { + if !bitmap[row][col] { + continue + } + mx := x + float64(col)*moduleW + my := y + float64(row)*moduleH + + if isFinderModule(row, col) { + pdf.SetFillColor(fpR, fpG, fpB) + } else { + pdf.SetFillColor(fgR, fgG, fgB) + } + pdf.RectFromUpperLeftWithStyle(mx, my, moduleW, moduleH, "F") + } + } +} + +func renderBarcodeField(pdf *gopdf.GoPdf, value string, x, y, w, h float64) { + var bc barcode.Barcode + var err error + + bc, err = code128.Encode(value) + if err != nil { + bc, err = ean.Encode(value) + if err != nil { + log.Printf("[pdf] barcode error for %q: %v", value, err) + return + } + } + + imgW := int(w / mmToPt * 10) + imgH := int(h / mmToPt * 10) + if imgW < 100 { + imgW = 100 + } + if imgH < 30 { + imgH = 30 + } + bc, err = barcode.Scale(bc, imgW, imgH) + if err != nil { + return + } + + tmpFile, err := os.CreateTemp("", "bc-*.png") + if err != nil { + return + } + defer os.Remove(tmpFile.Name()) + + if err := png.Encode(tmpFile, bc); err != nil { + return + } + tmpFile.Close() + + pdf.Image(tmpFile.Name(), x, y, &gopdf.Rect{W: w, H: h}) +} + +func renderImageField(pdf *gopdf.GoPdf, field PdfmeField, row map[string]string, x, y, w, h float64) { + // Resolve content: check row data first (for dynamic images), then static content + content := "" + // Try row variable (mapped data) + if val, ok := row[field.Name]; ok && val != "" { + content = val + } + // Try variables array + if content == "" { + for _, v := range field.Variables { + if val, ok := row[v]; ok && val != "" { + content = val + break + } + } + } + // Fallback to static content from template (base64 logos, etc.) + if content == "" { + content = field.Content + } + if content == "" { + return + } + + // Handle base64 inline images (data URI or raw base64) + if strings.HasPrefix(content, "data:image/") { + localPath := saveBase64Image(content) + if localPath != "" { + defer os.Remove(localPath) + pdf.Image(localPath, x, y, &gopdf.Rect{W: w, H: h}) + } + return + } + + // Handle raw base64 (no data: prefix but starts with base64 chars) + if len(content) > 100 && !strings.HasPrefix(content, "http") && !strings.Contains(content[:20], "/") { + // Likely raw base64 β€” try to decode + localPath := saveBase64Image("data:image/png;base64," + content) + if localPath != "" { + defer os.Remove(localPath) + pdf.Image(localPath, x, y, &gopdf.Rect{W: w, H: h}) + } + return + } + + // Handle HTTP URLs + if strings.HasPrefix(content, "http") { + localPath := getCachedImage(content) + if localPath != "" { + pdf.Image(localPath, x, y, &gopdf.Rect{W: w, H: h}) + } + return + } + + // Handle local file paths + if _, err := os.Stat(content); err == nil { + pdf.Image(content, x, y, &gopdf.Rect{W: w, H: h}) + } +} + +func renderLineField(pdf *gopdf.GoPdf, field PdfmeField, x, y, w, h float64) { + color := field.Color + if color == "" { + color = field.FontColor + } + if color == "" { + color = "#000000" + } + r, g, b, _ := parseColor(color) + + // pdfme renders lines as thin filled rectangles, not stroked paths. + // Using fill instead of stroke ensures SetTransparency applies correctly + // (gopdf transparency affects fill operations reliably). + pdf.SetFillColor(r, g, b) + pdf.RectFromUpperLeftWithStyle(x, y, w, h, "F") +} + +func renderEllipseField(pdf *gopdf.GoPdf, field PdfmeField, x, y, w, h float64) { + // Fill + if field.BackgroundColor != "" { + r, g, b, _ := parseColor(field.BackgroundColor) + pdf.SetFillColor(r, g, b) + } + // Stroke β€” borderWidth is in mm, SetLineWidth expects pt + bw := float64(field.BorderWidth) * mmToPt + if bw > 0 { + color := field.BorderColor + if color == "" { + color = field.Color + } + if color == "" { + color = "#000000" + } + r, g, b, _ := parseColor(color) + pdf.SetStrokeColor(r, g, b) + pdf.SetLineWidth(bw) + } + // Oval uses x1,y1,x2,y2 (bounding box corners) + pdf.Oval(x, y, x+w, y+h) +} + +func renderRectangleField(pdf *gopdf.GoPdf, field PdfmeField, x, y, w, h float64) { + style := "" + // pdfme uses "color" as the fill color for rectangles; also check backgroundColor + fillColor := field.Color + if fillColor == "" { + fillColor = field.BackgroundColor + } + if fillColor != "" { + r, g, b, _ := parseColor(fillColor) + pdf.SetFillColor(r, g, b) + style = "F" + } + // borderWidth is in mm, SetLineWidth expects pt + bw := float64(field.BorderWidth) * mmToPt + if bw > 0 { + color := field.BorderColor + if color == "" { + color = "#000000" + } + r, g, b, _ := parseColor(color) + pdf.SetStrokeColor(r, g, b) + pdf.SetLineWidth(bw) + if style == "F" { + style = "FD" + } else { + style = "D" + } + } + if style != "" { + pdf.RectFromUpperLeftWithStyle(x, y, w, h, style) + } +} + +func renderTableField(pdf *gopdf.GoPdf, field PdfmeField, row map[string]string, x, y, w, h float64, fontLoaded bool) { + if !fontLoaded { + return + } + + // Parse head styles + headFS := 10.0 + headBg := "#2980ba" + headFontColor := "#ffffff" + if field.HeadStyles != nil { + var hs TableStyle + if err := json.Unmarshal(field.HeadStyles, &hs); err == nil { + if hs.FontSize > 0 { + headFS = hs.FontSize + } + if hs.BackgroundColor != "" { + headBg = hs.BackgroundColor + } + if hs.FontColor != "" { + headFontColor = hs.FontColor + } + } + } + + // Parse body styles + bodyFS := 9.0 + bodyFontColor := "#000000" + bodyBg := "" + bodyAltBg := "" + if field.BodyStyles != nil { + var bs struct { + TableStyle + AlternateBackgroundColor string `json:"alternateBackgroundColor"` + } + if err := json.Unmarshal(field.BodyStyles, &bs); err == nil { + if bs.FontSize > 0 { + bodyFS = bs.FontSize + } + if bs.FontColor != "" { + bodyFontColor = bs.FontColor + } + if bs.BackgroundColor != "" { + bodyBg = bs.BackgroundColor + } + bodyAltBg = bs.AlternateBackgroundColor + } + } + + // Calculate column widths + numCols := len(field.Head) + if numCols == 0 { + return + } + colWidths := make([]float64, numCols) + if len(field.HeadWidthPercentages) == numCols { + for i, pct := range field.HeadWidthPercentages { + colWidths[i] = w * pct / 100 + } + } else { + eachW := w / float64(numCols) + for i := range colWidths { + colWidths[i] = eachW + } + } + + rowHeight := headFS * 2.5 + + // Render header + showHead := field.ShowHead == nil || *field.ShowHead + curY := y + if showHead { + curX := x + for ci, header := range field.Head { + // Header background + r, g, b := hexToRGB(headBg) + pdf.SetFillColor(r, g, b) + pdf.RectFromUpperLeftWithStyle(curX, curY, colWidths[ci], rowHeight, "F") + + // Header text + pdf.SetFont("default", "", headFS) + r, g, b = hexToRGB(headFontColor) + pdf.SetTextColor(r, g, b) + pdf.SetX(curX + 4) + pdf.SetY(curY + (rowHeight-headFS)/2) + pdf.CellWithOption(&gopdf.Rect{W: colWidths[ci] - 8, H: rowHeight}, header, gopdf.CellOption{}) + + curX += colWidths[ci] + } + curY += rowHeight + } + + // Resolve table body data + bodyData := resolveTableBody(field, row) + bodyRowH := bodyFS * 2.2 + + for ri, dataRow := range bodyData { + if curY+bodyRowH > y+h { + break // don't overflow + } + curX := x + // Alternate background + bg := bodyBg + if ri%2 == 1 && bodyAltBg != "" { + bg = bodyAltBg + } + for ci := 0; ci < numCols; ci++ { + cellVal := "" + if ci < len(dataRow) { + cellVal = dataRow[ci] + } + + if bg != "" { + r, g, b := hexToRGB(bg) + pdf.SetFillColor(r, g, b) + pdf.RectFromUpperLeftWithStyle(curX, curY, colWidths[ci], bodyRowH, "F") + } + + pdf.SetFont("default", "", bodyFS) + r, g, b := hexToRGB(bodyFontColor) + pdf.SetTextColor(r, g, b) + pdf.SetX(curX + 4) + pdf.SetY(curY + (bodyRowH-bodyFS)/2) + pdf.CellWithOption(&gopdf.Rect{W: colWidths[ci] - 8, H: bodyRowH}, cellVal, gopdf.CellOption{}) + + curX += colWidths[ci] + } + curY += bodyRowH + } +} + +// resolveTableBody gets the table body data from field content or row variables. +func resolveTableBody(field PdfmeField, row map[string]string) [][]string { + // Try to get body from row variable (e.g. {medicamentos_tabla}) + for _, v := range field.Variables { + if val, ok := row[v]; ok && val != "" { + return parseTableContent(val) + } + } + if val, ok := row[field.Name]; ok && val != "" { + return parseTableContent(val) + } + // Fallback: use Content + if field.Content != "" { + return parseTableContent(field.Content) + } + return nil +} + +// parseTableContent parses a JSON 2D array string into rows of cells. +func parseTableContent(s string) [][]string { + s = strings.TrimSpace(s) + // Try as JSON 2D array: [["a","b"],["c","d"]] + var rows [][]string + if err := json.Unmarshal([]byte(s), &rows); err == nil { + return rows + } + // Try as single row + var row []string + if err := json.Unmarshal([]byte(s), &row); err == nil { + return [][]string{row} + } + return nil +} + +// ════════════════════════════════════════════════════ +// Image utilities +// ════════════════════════════════════════════════════ + +// decodeBackgroundPdf decodes a base64-encoded PDF string (with or without data URI prefix) +// to a temporary file and returns the path. Caller must remove the file when done. +func decodeBackgroundPdf(b64 string) string { + data := b64 + // Strip data URI prefix if present + if strings.Contains(data, ",") { + parts := strings.SplitN(data, ",", 2) + data = parts[1] + } + // Strip whitespace/newlines + data = strings.ReplaceAll(data, "\n", "") + data = strings.ReplaceAll(data, "\r", "") + data = strings.ReplaceAll(data, " ", "") + + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + decoded, err = base64.RawStdEncoding.DecodeString(data) + if err != nil { + log.Printf("[pdf] Warning: could not decode background PDF base64: %v", err) + return "" + } + } + // Verify it looks like a PDF + if len(decoded) < 5 || string(decoded[:5]) != "%PDF-" { + log.Printf("[pdf] Warning: decoded background is not a valid PDF (header: %q)", string(decoded[:min(10, len(decoded))])) + return "" + } + + tmpFile, err := os.CreateTemp("", "bg-*.pdf") + if err != nil { + return "" + } + if _, err := tmpFile.Write(decoded); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return "" + } + tmpFile.Close() + return tmpFile.Name() +} + +func saveBase64Image(dataURI string) string { + // Parse "data:image/png;base64,iVBOR..." + parts := strings.SplitN(dataURI, ",", 2) + if len(parts) != 2 { + return "" + } + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + // Try RawStdEncoding (no padding) + decoded, err = base64.RawStdEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + } + + ext := ".png" + if strings.Contains(parts[0], "jpeg") || strings.Contains(parts[0], "jpg") { + ext = ".jpg" + } + + tmpFile, err := os.CreateTemp("", "b64img-*"+ext) + if err != nil { + return "" + } + if _, err := tmpFile.Write(decoded); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return "" + } + tmpFile.Close() + return tmpFile.Name() +} + +func getCachedImage(url string) string { + imgCacheMu.Lock() + defer imgCacheMu.Unlock() + + if path, ok := imgCache[url]; ok { + if _, err := os.Stat(path); err == nil { + return path + } + } + + resp, err := http.Get(url) + if err != nil { + log.Printf("[pdf] image download error: %v", err) + return "" + } + defer resp.Body.Close() + + cacheDir := filepath.Join(configDir(), "cache", "images") + os.MkdirAll(cacheDir, 0755) + + ext := ".png" + ct := resp.Header.Get("Content-Type") + if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") { + ext = ".jpg" + } + + tmpFile, err := os.CreateTemp(cacheDir, "img-*"+ext) + if err != nil { + return "" + } + defer tmpFile.Close() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + return "" + } + + imgCache[url] = tmpFile.Name() + return tmpFile.Name() +} + +// ════════════════════════════════════════════════════ +// Value resolution β€” all field types +// ════════════════════════════════════════════════════ + +// resolveFieldValue gets the display value for a field from row data. +// +// multiVariableText: uses field.Text as template, replaces {var} with row values. +// text with variables: same as multiVariableText (ISI custom format). +// qrcode/barcode: looks up row[field.Name], then interpolates {placeholder} in Content. +func resolveFieldValue(field PdfmeField, row map[string]string) string { + // multiVariableText or text with variables array β†’ template interpolation + if field.Type == "multiVariableText" || (field.Type == "text" && len(field.Variables) > 0) { + return resolveMultiVariableText(field, row) + } + + // Direct lookup by field name + if val, ok := row[field.Name]; ok && val != "" { + return val + } + + // Try normalized field name (dots ↔ underscores) + normalized := strings.ReplaceAll(field.Name, ".", "_") + dotted := strings.ReplaceAll(field.Name, "_", ".") + if val, ok := row[normalized]; ok && val != "" { + return val + } + if val, ok := row[dotted]; ok && val != "" { + return val + } + + // Try variables array as alternative keys + for _, varName := range field.Variables { + if val, ok := row[varName]; ok && val != "" { + return val + } + } + + // Fallback: Content might have a {variable} placeholder + if field.Content != "" { + // Enrich row with suffix-matched data for placeholder interpolation + enriched := enrichRowForPlaceholders(field.Content, row) + resolved := interpolatePlaceholders(field.Content, enriched) + if resolved != field.Content && resolved != "" { + return resolved + } + // Static content (no placeholders or nothing resolved) + if !strings.Contains(field.Content, "{") { + return field.Content + } + } + + return "" +} + +// enrichRowForPlaceholders extracts {placeholder} names from text and tries to +// find matching row keys by suffix matching (e.g. {token} matches row["gafete.token"]). +func enrichRowForPlaceholders(text string, row map[string]string) map[string]string { + enriched := make(map[string]string, len(row)) + for k, v := range row { + enriched[k] = v + } + + // Extract placeholder names from text + remaining := text + for { + start := strings.Index(remaining, "{") + if start == -1 { + break + } + end := strings.Index(remaining[start:], "}") + if end == -1 { + break + } + placeholder := remaining[start+1 : start+end] + remaining = remaining[start+end+1:] + + // Skip JSON-like patterns + if strings.ContainsAny(placeholder, "\":") { + continue + } + if _, found := enriched[placeholder]; found { + continue + } + + // Suffix match: find row key ending with separator + placeholder + for k, v := range row { + if strings.HasSuffix(k, "_"+placeholder) || strings.HasSuffix(k, "."+placeholder) { + enriched[placeholder] = v + break + } + } + } + + return enriched +} + +// resolveMultiVariableText handles pdfme multiVariableText and text-with-variables fields. +func resolveMultiVariableText(field PdfmeField, row map[string]string) string { + template := field.Text + if template == "" { + template = field.Content + } + if template == "" { + return "" + } + + // Build enriched data map: resolve variables that don't directly match row keys. + // pdfme multiVariableText uses short variable names (e.g. "nombre") in the template, + // but dashboard maps data to field names (e.g. "gafete_nombre" or "gafete.nombre"). + // We need to bridge this gap by suffix-matching variables to row keys. + enrichedRow := enrichRowForVariables(field, row) + + // Detect if template is JSON content (e.g. '{"gafete.nombre":"[gafete.nombre]"}') + // rather than a text template (e.g. "{gafete.nombre} {gafete.apellido}") + trimmed := strings.TrimSpace(template) + if len(trimmed) > 2 && trimmed[0] == '{' && trimmed[1] == '"' { + // This is JSON content, not a text template. + if len(field.Variables) > 0 { + var parts []string + for _, varName := range field.Variables { + if val, ok := enrichedRow[varName]; ok && val != "" { + parts = append(parts, val) + } + } + return strings.Join(parts, " ") + } + return "" + } + + // Process Handlebars conditionals: {{#if var}}text{{/if}}, {{#unless var}}text{{/unless}} + template = processConditionals(template, enrichedRow) + + // Interpolate {variable} placeholders in template with row data + result := interpolatePlaceholders(template, enrichedRow) + return strings.TrimSpace(result) +} + +// enrichRowForVariables creates an enriched data map that bridges the gap between +// pdfme variable names (short: "nombre") and dashboard row keys (full: "gafete_nombre"). +// +// For each variable in field.Variables, if the variable doesn't exist in the row, +// try to find a matching row key by: +// 1. Field name direct match (for single-variable fields) +// 2. Suffix matching: row key ending with _varName or .varName +// 3. Normalized matching: underscores ↔ dots +func enrichRowForVariables(field PdfmeField, row map[string]string) map[string]string { + enriched := make(map[string]string, len(row)+len(field.Variables)) + for k, v := range row { + enriched[k] = v + } + + for _, varName := range field.Variables { + if _, found := enriched[varName]; found { + continue // already have a direct match + } + + // Single-variable field: use the field's own data + if len(field.Variables) == 1 { + if val, ok := row[field.Name]; ok && val != "" { + enriched[varName] = val + continue + } + } + + // Suffix match: find row key ending with separator + varName + for k, v := range row { + if strings.HasSuffix(k, "_"+varName) || strings.HasSuffix(k, "."+varName) { + enriched[varName] = v + break + } + } + + // Normalized match: try with dots ↔ underscores + if _, found := enriched[varName]; !found { + normalized := strings.ReplaceAll(field.Name, ".", "_") + dotted := strings.ReplaceAll(field.Name, "_", ".") + if val, ok := row[normalized]; ok && len(field.Variables) == 1 { + enriched[varName] = val + } else if val, ok := row[dotted]; ok && len(field.Variables) == 1 { + enriched[varName] = val + } + } + } + + return enriched +} + +// processConditionals handles Handlebars-style conditionals in text templates. +// Supports: {{#if var}}text{{/if}}, {{#unless var}}text{{/unless}}, {{else}} +var ( + reIf = regexp.MustCompile(`(?s)\{\{#if\s+(\S+?)\}\}(.*?)(?:\{\{else\}\}(.*?))?\{\{/if\}\}`) + reUnless = regexp.MustCompile(`(?s)\{\{#unless\s+(\S+?)\}\}(.*?)(?:\{\{else\}\}(.*?))?\{\{/unless\}\}`) +) + +func processConditionals(text string, row map[string]string) string { + // Process {{#if var}}...{{else}}...{{/if}} + result := reIf.ReplaceAllStringFunc(text, func(match string) string { + sub := reIf.FindStringSubmatch(match) + if len(sub) < 3 { + return "" + } + varName := sub[1] + trueBranch := sub[2] + falseBranch := "" + if len(sub) >= 4 { + falseBranch = sub[3] + } + val, exists := row[varName] + if exists && val != "" { + return trueBranch + } + return falseBranch + }) + + // Process {{#unless var}}...{{else}}...{{/unless}} + result = reUnless.ReplaceAllStringFunc(result, func(match string) string { + sub := reUnless.FindStringSubmatch(match) + if len(sub) < 3 { + return "" + } + varName := sub[1] + trueBranch := sub[2] + falseBranch := "" + if len(sub) >= 4 { + falseBranch = sub[3] + } + val, exists := row[varName] + if !exists || val == "" { + return trueBranch + } + return falseBranch + }) + + return result +} + +// interpolatePlaceholders replaces {key} placeholders with values from data map. +// Unreplaced placeholders are removed. +func interpolatePlaceholders(text string, data map[string]string) string { + result := text + for key, val := range data { + result = strings.ReplaceAll(result, "{"+key+"}", val) + } + // Remove any unreplaced {placeholder} patterns (but not JSON-like patterns) + for { + start := strings.Index(result, "{") + if start == -1 { + break + } + end := strings.Index(result[start:], "}") + if end == -1 { + break + } + inner := result[start+1 : start+end] + // Don't remove if it looks like JSON (contains quotes or colons) + if strings.ContainsAny(inner, "\":") { + break + } + result = result[:start] + result[start+end+1:] + } + return result +} + +// ════════════════════════════════════════════════════ +// Font discovery +// ════════════════════════════════════════════════════ + +// ════════════════════════════════════════════════════ +// Font system β€” discovery, loading, registry +// ════════════════════════════════════════════════════ + +var ( + fontRegistry = map[string]bool{} // tracks loaded font names in gopdf + fontRegistryMu sync.Mutex +) + +// findFonts returns paths for regular and bold fonts. +func findFonts() (regular string, bold string) { + regularCandidates := []string{ + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/Library/Fonts/Arial.ttf", + "/System/Library/Fonts/SFNSText.ttf", + `C:\Windows\Fonts\arial.ttf`, + `C:\Windows\Fonts\segoeui.ttf`, + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/System/Library/Fonts/Helvetica.ttc", + } + boldCandidates := []string{ + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/Library/Fonts/Arial Bold.ttf", + `C:\Windows\Fonts\arialbd.ttf`, + `C:\Windows\Fonts\segoeuib.ttf`, + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + } + + for _, p := range regularCandidates { + if _, err := os.Stat(p); err == nil { + regular = p + break + } + } + for _, p := range boldCandidates { + if _, err := os.Stat(p); err == nil { + bold = p + break + } + } + return +} + +// findDefaultFont returns path for the default regular font (backward compat). +func findDefaultFont() string { + r, _ := findFonts() + return r +} + +// systemFontDirs returns directories where fonts are installed. +func systemFontDirs() []string { + switch runtime.GOOS { + case "darwin": + return []string{ + "/System/Library/Fonts/", + "/System/Library/Fonts/Supplemental/", + "/Library/Fonts/", + filepath.Join(os.Getenv("HOME"), "Library/Fonts/"), + } + case "windows": + return []string{`C:\Windows\Fonts\`} + default: // linux + return []string{ + "/usr/share/fonts/", + "/usr/share/fonts/truetype/", + "/usr/share/fonts/TTF/", + "/usr/local/share/fonts/", + filepath.Join(os.Getenv("HOME"), ".fonts/"), + } + } +} + +// findFontByName searches system font directories for a font matching the given name. +// Returns paths for regular and bold variants. Name matching is case-insensitive. +func findFontByName(name string) (regular string, bold string) { + if name == "" { + return "", "" + } + nameLower := strings.ToLower(name) + // Common name β†’ file mappings + nameVariations := []string{ + name, + strings.ReplaceAll(name, " ", ""), + strings.ReplaceAll(name, " ", "-"), + } + + for _, dir := range systemFontDirs() { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + fname := entry.Name() + fnameLower := strings.ToLower(fname) + // Only .ttf and .otf (skip .ttc for now β€” gopdf has limited .ttc support) + if !strings.HasSuffix(fnameLower, ".ttf") && !strings.HasSuffix(fnameLower, ".otf") { + continue + } + base := strings.TrimSuffix(strings.TrimSuffix(fnameLower, ".ttf"), ".otf") + + for _, variation := range nameVariations { + vl := strings.ToLower(variation) + // Exact match or prefix match + if base == vl || base == vl+"-regular" || base == vl+"regular" { + regular = filepath.Join(dir, fname) + } + if base == vl+"-bold" || base == vl+"bold" || base == vl+" bold" { + bold = filepath.Join(dir, fname) + } + } + } + } + + // Also try with the lowercase name directly + if regular == "" { + for _, dir := range systemFontDirs() { + for _, ext := range []string{".ttf", ".otf"} { + for _, variation := range nameVariations { + candidates := []string{ + filepath.Join(dir, variation+ext), + filepath.Join(dir, variation+"-Regular"+ext), + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + regular = c + break + } + } + if regular != "" { + break + } + } + if regular != "" { + break + } + } + if regular != "" { + break + } + } + } + + // If we found regular but not bold, check with Bold suffix + if regular != "" && bold == "" { + dir := filepath.Dir(regular) + base := strings.TrimSuffix(strings.TrimSuffix(filepath.Base(regular), ".ttf"), ".otf") + base = strings.TrimSuffix(base, "-Regular") + base = strings.TrimSuffix(base, "Regular") + base = strings.TrimSuffix(base, "-regular") + for _, ext := range []string{".ttf", ".otf"} { + candidates := []string{ + filepath.Join(dir, base+"-Bold"+ext), + filepath.Join(dir, base+"Bold"+ext), + filepath.Join(dir, base+" Bold"+ext), + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + bold = c + break + } + } + if bold != "" { + break + } + } + } + + if regular != "" { + log.Printf("[pdf] Found system font %q: regular=%s bold=%s", nameLower, regular, bold) + } + return +} + +// loadNamedFont loads a font by name into gopdf if not already loaded. +// Returns the gopdf font family name to use. +func loadNamedFont(pdf *gopdf.GoPdf, name string) (fontFamily string, hasBold bool) { + if name == "" || name == "default" { + return "default", fontRegistry["bold"] + } + + fontRegistryMu.Lock() + defer fontRegistryMu.Unlock() + + famName := "font_" + strings.ToLower(strings.ReplaceAll(name, " ", "_")) + famBold := famName + "_bold" + + if fontRegistry[famName] { + return famName, fontRegistry[famBold] + } + + regular, bold := findFontByName(name) + if regular == "" { + return "default", fontRegistry["bold"] + } + + if err := pdf.AddTTFFont(famName, regular); err != nil { + log.Printf("[pdf] Warning: could not load font %q (%s): %v", name, regular, err) + return "default", fontRegistry["bold"] + } + fontRegistry[famName] = true + log.Printf("[pdf] Loaded named font %q as %q from %s", name, famName, regular) + + if bold != "" { + if err := pdf.AddTTFFont(famBold, bold); err == nil { + fontRegistry[famBold] = true + hasBold = true + } + } + return famName, hasBold +} + +// ════════════════════════════════════════════════════ +// Utility functions +// ════════════════════════════════════════════════════ + +func mapKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// ════════════════════════════════════════════════════ +// Color parsing β€” hex, rgb(), rgba(), hsl(), hsla(), named +// ════════════════════════════════════════════════════ + +// blendColorWithWhite applies opacity by blending a color with white. +// On white paper, this produces the same visual result as PDF transparency. +// Returns a hex color string (e.g. "#ebebeb"). +func blendColorWithWhite(colorStr string, opacity float64) string { + if colorStr == "" || opacity >= 1 || opacity <= 0 { + return colorStr + } + r, g, b, _ := parseColor(colorStr) + rr := int(math.Round(255*(1-opacity) + float64(r)*opacity)) + gg := int(math.Round(255*(1-opacity) + float64(g)*opacity)) + bb := int(math.Round(255*(1-opacity) + float64(b)*opacity)) + return fmt.Sprintf("#%02x%02x%02x", rr, gg, bb) +} + +// parseColor parses any CSS color format and returns r, g, b (0-255) and alpha (0.0-1.0). +// Supports: #RGB, #RRGGBB, #RRGGBBAA, rgb(r,g,b), rgba(r,g,b,a), hsl(h,s%,l%), hsla(h,s%,l%,a), named colors. +func parseColor(color string) (r, g, b uint8, alpha float64) { + alpha = 1.0 + color = strings.TrimSpace(color) + if color == "" { + return 0, 0, 0, 1.0 + } + + // Named colors + if named, ok := namedColors[strings.ToLower(color)]; ok { + return named[0], named[1], named[2], 1.0 + } + + // Hex: #RGB, #RRGGBB, #RRGGBBAA + if strings.HasPrefix(color, "#") { + hex := color[1:] + switch len(hex) { + case 3: + hex = string(hex[0]) + string(hex[0]) + string(hex[1]) + string(hex[1]) + string(hex[2]) + string(hex[2]) + case 4: + hex = string(hex[0]) + string(hex[0]) + string(hex[1]) + string(hex[1]) + string(hex[2]) + string(hex[2]) + string(hex[3]) + string(hex[3]) + } + if len(hex) >= 6 { + rv, _ := strconv.ParseUint(hex[0:2], 16, 8) + gv, _ := strconv.ParseUint(hex[2:4], 16, 8) + bv, _ := strconv.ParseUint(hex[4:6], 16, 8) + r, g, b = uint8(rv), uint8(gv), uint8(bv) + if len(hex) == 8 { + av, _ := strconv.ParseUint(hex[6:8], 16, 8) + alpha = float64(av) / 255.0 + } + return + } + return 0, 0, 0, 1.0 + } + + // rgb(r,g,b) or rgba(r,g,b,a) + if strings.HasPrefix(color, "rgb") { + nums := extractNumbers(color) + if len(nums) >= 3 { + r = clampUint8(nums[0]) + g = clampUint8(nums[1]) + b = clampUint8(nums[2]) + if len(nums) >= 4 { + alpha = clampFloat(nums[3], 0, 1) + } + return + } + return 0, 0, 0, 1.0 + } + + // hsl(h,s%,l%) or hsla(h,s%,l%,a) + if strings.HasPrefix(color, "hsl") { + nums := extractNumbers(color) + if len(nums) >= 3 { + h := math.Mod(nums[0], 360) + if h < 0 { + h += 360 + } + s := clampFloat(nums[1], 0, 100) / 100 + l := clampFloat(nums[2], 0, 100) / 100 + r, g, b = hslToRGB(h, s, l) + if len(nums) >= 4 { + alpha = clampFloat(nums[3], 0, 1) + } + return + } + return 0, 0, 0, 1.0 + } + + return 0, 0, 0, 1.0 +} + +// hexToRGB is a convenience wrapper around parseColor (ignores alpha). +func hexToRGB(color string) (uint8, uint8, uint8) { + r, g, b, _ := parseColor(color) + return r, g, b +} + +var colorNumRe = regexp.MustCompile(`[\d.]+`) + +func extractNumbers(s string) []float64 { + matches := colorNumRe.FindAllString(s, -1) + nums := make([]float64, 0, len(matches)) + for _, m := range matches { + if v, err := strconv.ParseFloat(m, 64); err == nil { + nums = append(nums, v) + } + } + return nums +} + +func clampUint8(v float64) uint8 { + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return uint8(v) +} + +func clampFloat(v, min, max float64) float64 { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +// hslToRGB converts HSL (h in 0-360, s and l in 0-1) to RGB. +func hslToRGB(h, s, l float64) (uint8, uint8, uint8) { + if s == 0 { + v := clampUint8(l * 255) + return v, v, v + } + var q float64 + if l < 0.5 { + q = l * (1 + s) + } else { + q = l + s - l*s + } + p := 2*l - q + hk := h / 360 + tr := hk + 1.0/3.0 + tg := hk + tb := hk - 1.0/3.0 + return clampUint8(hueToRGB(p, q, tr) * 255), clampUint8(hueToRGB(p, q, tg) * 255), clampUint8(hueToRGB(p, q, tb) * 255) +} + +func hueToRGB(p, q, t float64) float64 { + if t < 0 { + t += 1 + } + if t > 1 { + t -= 1 + } + if t < 1.0/6.0 { + return p + (q-p)*6*t + } + if t < 0.5 { + return q + } + if t < 2.0/3.0 { + return p + (q-p)*(2.0/3.0-t)*6 + } + return p +} + +var namedColors = map[string][3]uint8{ + "black": {0, 0, 0}, + "white": {255, 255, 255}, + "red": {255, 0, 0}, + "green": {0, 128, 0}, + "blue": {0, 0, 255}, + "yellow": {255, 255, 0}, + "cyan": {0, 255, 255}, + "magenta": {255, 0, 255}, + "gray": {128, 128, 128}, + "grey": {128, 128, 128}, + "orange": {255, 165, 0}, + "purple": {128, 0, 128}, + "pink": {255, 192, 203}, + "brown": {165, 42, 42}, + "navy": {0, 0, 128}, + "teal": {0, 128, 128}, + "maroon": {128, 0, 0}, + "olive": {128, 128, 0}, + "silver": {192, 192, 192}, + "lime": {0, 255, 0}, + "aqua": {0, 255, 255}, + "fuchsia": {255, 0, 255}, + "transparent": {0, 0, 0}, +} + +// Ensure strconv is used +var _ = strconv.ParseUint diff --git a/pdf_upload.go b/pdf_upload.go new file mode 100644 index 0000000..602a700 --- /dev/null +++ b/pdf_upload.go @@ -0,0 +1,182 @@ +package main + +import ( + "bytes" + "fmt" + "image" + _ "image/png" // register PNG decoder + "log" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +// rasterizeUploadedPDF converts a PDF file into per-page monochrome PNG images. +// Tries pdftoppm first, falls back to Ghostscript. +// Returns the output directory and page count. +func rasterizeUploadedPDF(pdfPath string, dpi int) (outDir string, pageCount int, err error) { + outDir = filepath.Dir(pdfPath) + prefix := filepath.Join(outDir, "page") + + // Try pdftoppm first (from poppler-utils) + cmd := exec.Command("pdftoppm", "-mono", "-r", fmt.Sprintf("%d", dpi), "-png", pdfPath, prefix) + hideWindowCmd(cmd) + if out, err := cmd.CombinedOutput(); err == nil { + count := countPNGs(outDir) + if count > 0 { + log.Printf("[pdf-upload] pdftoppm rasterized %d pages at %d DPI", count, dpi) + return outDir, count, nil + } + log.Printf("[pdf-upload] pdftoppm produced 0 pages, output: %s", string(out)) + } else { + log.Printf("[pdf-upload] pdftoppm failed: %v, trying ghostscript", err) + } + + // Fallback to Ghostscript + gsCmd := exec.Command("gs", + "-dBATCH", "-dNOPAUSE", "-dQUIET", + "-sDEVICE=pngmono", + fmt.Sprintf("-r%d", dpi), + fmt.Sprintf("-sOutputFile=%s", filepath.Join(outDir, "page-%03d.png")), + pdfPath, + ) + hideWindowCmd(gsCmd) + if out, err := gsCmd.CombinedOutput(); err != nil { + return "", 0, fmt.Errorf("ghostscript failed: %v β€” %s", err, string(out)) + } + + count := countPNGs(outDir) + if count == 0 { + return "", 0, fmt.Errorf("no pages produced from PDF") + } + log.Printf("[pdf-upload] ghostscript rasterized %d pages at %d DPI", count, dpi) + return outDir, count, nil +} + +// countPNGs counts .png files in a directory. +func countPNGs(dir string) int { + entries, _ := os.ReadDir(dir) + count := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".png") { + count++ + } + } + return count +} + +// getUploadPagePath returns the path to a rasterized PNG page. +// Pages are numbered starting from 0. +func getUploadPagePath(uploadID string, pageIndex int) string { + uploadsDir := filepath.Join(configDir(), "uploads", uploadID) + + // List PNG files sorted by name + entries, err := os.ReadDir(uploadsDir) + if err != nil { + return "" + } + var pngs []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".png") { + pngs = append(pngs, e.Name()) + } + } + sort.Strings(pngs) + + if pageIndex >= len(pngs) || pageIndex < 0 { + return "" + } + return filepath.Join(uploadsDir, pngs[pageIndex]) +} + +// renderUploadedPageTSPL reads a rasterized PNG and generates TSPL bitmap commands. +func renderUploadedPageTSPL(uploadID string, pageIndex int, copies int) []byte { + pngPath := getUploadPagePath(uploadID, pageIndex) + if pngPath == "" { + return nil + } + + f, err := os.Open(pngPath) + if err != nil { + log.Printf("[pdf-upload] open page %d: %v", pageIndex, err) + return nil + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + log.Printf("[pdf-upload] decode page %d: %v", pageIndex, err) + return nil + } + + bounds := img.Bounds() + imgW := bounds.Dx() + imgH := bounds.Dy() + + // Calculate label size in mm from image dimensions at defaultDPI + labelWmm := float64(imgW) * 25.4 / float64(defaultDPI) + labelHmm := float64(imgH) * 25.4 / float64(defaultDPI) + + var buf bytes.Buffer + buf.Write([]byte{0x1b, 0x21, 0x52}) + buf.WriteString("\r\n") + buf.WriteString(fmt.Sprintf("SIZE %.1f mm, %.1f mm\r\n", labelWmm, labelHmm)) + buf.WriteString("GAP 3 mm, 0 mm\r\n") + buf.WriteString("DIRECTION 0,0\r\n") + buf.WriteString("SPEED 3\r\n") + buf.WriteString("DENSITY 10\r\n") + buf.WriteString("SET CUTTER OFF\r\n") + buf.WriteString("SET TEAR ON\r\n") + buf.WriteString("CLS\r\n") + + tsplWriteBitmap(&buf, img, 0, 0) + + if copies < 1 { + copies = 1 + } + buf.WriteString(fmt.Sprintf("PRINT %d\r\n", copies)) + + return buf.Bytes() +} + +// startUploadCleanup starts a goroutine that periodically removes old upload directories. +func startUploadCleanup() { + go func() { + for { + time.Sleep(30 * time.Minute) + cleanupUploads() + } + }() +} + +// cleanupUploads removes upload directories older than 1 hour. +func cleanupUploads() { + uploadsDir := filepath.Join(configDir(), "uploads") + entries, err := os.ReadDir(uploadsDir) + if err != nil { + return + } + cutoff := time.Now().Add(-1 * time.Hour) + for _, e := range entries { + if !e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + path := filepath.Join(uploadsDir, e.Name()) + os.RemoveAll(path) + log.Printf("[pdf-upload] Cleaned up old upload: %s", e.Name()) + } + } +} + +// rasterizeUploadedPDFThumbnails creates low-DPI thumbnails for the print dialog. +func rasterizeUploadedPDFThumbnails(pdfPath string) (string, int, error) { + return rasterizeUploadedPDF(pdfPath, 72) +} diff --git a/printer_crossbuild.go b/printer_crossbuild.go new file mode 100644 index 0000000..3e4a389 --- /dev/null +++ b/printer_crossbuild.go @@ -0,0 +1,93 @@ +//go:build !windows && crossbuild + +package main + +import ( + "fmt" + "os/exec" + "strings" +) + +// C_usb_device_exists always returns false in crossbuild (no libusb). +func C_usb_device_exists() bool { return false } + +// listLocalPrinters lists CUPS printers via lpstat (no USB detection). +func listLocalPrinters() ([]PrinterInfo, error) { + var printers []PrinterInfo + + // Parse CUPS printer status + statusMap := make(map[string]string) + if out, err := exec.Command("lpstat", "-p").Output(); err == nil { + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "printer ") && !strings.HasPrefix(line, "la impresora ") && !strings.HasPrefix(line, "impresora ") { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + var name string + if strings.HasPrefix(line, "la impresora ") || strings.HasPrefix(line, "impresora ") { + for _, f := range fields { + if f != "la" && f != "impresora" { + name = f + break + } + } + } else { + name = fields[1] + } + if name == "" { + continue + } + lower := strings.ToLower(line) + if strings.Contains(lower, "desactivad") || strings.Contains(lower, "disabled") { + statusMap[name] = "disabled" + } else if strings.Contains(lower, "imprimiendo") || strings.Contains(lower, "printing") { + statusMap[name] = "printing" + } else { + statusMap[name] = "idle" + } + } + } + + out, err := exec.Command("lpstat", "-a").Output() + if err == nil { + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + name := strings.Fields(line)[0] + status := statusMap[name] + online := status != "disabled" + printers = append(printers, PrinterInfo{ + Name: name, + Type: "cups", + Online: online, + Status: status, + }) + } + } + + return printers, nil +} + +// rawPrint sends data via CUPS lp command (no USB direct in crossbuild). +func rawPrint(printerName string, data []byte) error { + if strings.HasPrefix(printerName, "(simulated)") { + fmt.Printf("[simulate] Would print %d bytes to %s\n", len(data), printerName) + return nil + } + + cupsName := strings.TrimSuffix(printerName, "-USB") + cmd := exec.Command("lp", "-d", cupsName, "-o", "raw") + cmd.Stdin = strings.NewReader(string(data)) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lp -d %s failed: %v β€” %s", cupsName, err, string(output)) + } + fmt.Printf("[print-cups] Sent %d bytes via lp to %s\n", len(data), cupsName) + return nil +} diff --git a/printer_other.go b/printer_other.go index 96be383..48c7b9c 100644 --- a/printer_other.go +++ b/printer_other.go @@ -1,11 +1,10 @@ -//go:build !windows +//go:build !windows && !crossbuild package main /* -#cgo LDFLAGS: -lusb-1.0 -#cgo CFLAGS: -I/opt/homebrew/include/libusb-1.0 -#cgo LDFLAGS: -L/opt/homebrew/lib +#cgo CFLAGS: -I/opt/homebrew/include/libusb-1.0 -I/usr/local/include/libusb-1.0 +#cgo LDFLAGS: -L/opt/homebrew/lib -L/usr/local/lib -lusb-1.0 #include #include #include @@ -119,23 +118,65 @@ const ( tscProductID = 0x0133 // TDP-244 Plus ) -// listLocalPrinters checks for TSC USB devices and CUPS printers. +// C_usb_device_exists checks if the known TSC USB device is connected. +// Exported as a Go function so other files (driver_darwin.go) can use it without importing C. +func C_usb_device_exists() bool { + return C.usb_device_exists(C.int(tscVendorID), C.int(tscProductID)) == 0 +} + +// listLocalPrinters lists ALL printers: USB (direct) + all CUPS printers with status. func listLocalPrinters() ([]PrinterInfo, error) { var printers []PrinterInfo - // Check if TSC device is connected via libusb - if C.usb_device_exists(C.int(tscVendorID), C.int(tscProductID)) == 0 { - printers = append(printers, PrinterInfo{ - Name: "TSC-TDP-244-USB", - Type: "usb", - Model: "TDP-244 Plus", - }) + // Check if TSC USB device is physically connected via libusb + usbConnected := C.usb_device_exists(C.int(tscVendorID), C.int(tscProductID)) == 0 + + // Parse CUPS printer status: "lpstat -p" gives status of each printer + statusMap := make(map[string]string) // name -> "idle"|"disabled"|"printing" + if out, err := exec.Command("lpstat", "-p").Output(); err == nil { + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "la impresora ") && !strings.HasPrefix(line, "impresora ") && !strings.HasPrefix(line, "printer ") { + continue + } + // Parse: "la impresora NAME estΓ‘ inactiva" or "printer NAME is idle" + // or: "impresora NAME desactivada" + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + // Name is the 3rd field (after "la impresora" or "printer") + var name string + if strings.HasPrefix(line, "la impresora ") || strings.HasPrefix(line, "impresora ") { + // Spanish: "la impresora X estΓ‘ inactiva" or "impresora X desactivada" + for _, f := range fields { + if f != "la" && f != "impresora" { + name = f + break + } + } + } else { + // English: "printer X is idle" + name = fields[1] + } + if name == "" { + continue + } + + lower := strings.ToLower(line) + if strings.Contains(lower, "desactivad") || strings.Contains(lower, "disabled") { + statusMap[name] = "disabled" + } else if strings.Contains(lower, "imprimiendo") || strings.Contains(lower, "printing") { + statusMap[name] = "printing" + } else { + statusMap[name] = "idle" + } + } } - // Also check CUPS for any TSC printers + // List ALL CUPS printers (not just TSC) out, err := exec.Command("lpstat", "-a").Output() if err == nil { - tscKeywords := []string{"tsc", "tdp", "te2", "te3"} for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if line == "" { @@ -143,22 +184,52 @@ func listLocalPrinters() ([]PrinterInfo, error) { } name := strings.Fields(line)[0] lower := strings.ToLower(name) - for _, kw := range tscKeywords { + + status := statusMap[name] + online := status != "disabled" + + // Determine type β€” TSC printers may have USB direct access + pType := "cups" + isTSC := false + for _, kw := range []string{"tsc", "tdp", "ttp", "te2", "te3"} { if strings.Contains(lower, kw) { - printers = append(printers, PrinterInfo{ - Name: name, - Type: "cups", - }) + isTSC = true break } } + + if isTSC && usbConnected { + // TSC printer with USB device present β€” mark as USB and online + printers = append(printers, PrinterInfo{ + Name: name, + Type: "usb", + Model: "TDP-244 Plus", + Online: true, + Status: status, + }) + } else if isTSC && !usbConnected { + // TSC printer in CUPS but USB not connected + printers = append(printers, PrinterInfo{ + Name: name, + Type: "cups", + Online: false, + Status: "disconnected", + }) + } else { + printers = append(printers, PrinterInfo{ + Name: name, + Type: pType, + Online: online, + Status: status, + }) + } } } return printers, nil } -// rawPrint sends data directly to the printer via USB (bypassing CUPS). +// rawPrint sends data directly to the printer. Tries USB first, falls back to CUPS. func rawPrint(printerName string, data []byte) error { if strings.HasPrefix(printerName, "(simulated)") { fmt.Printf("[simulate] Would print %d bytes to %s\n", len(data), printerName) @@ -166,8 +237,9 @@ func rawPrint(printerName string, data []byte) error { return nil } - // Direct USB: bypass CUPS entirely - if strings.Contains(printerName, "USB") || strings.Contains(strings.ToLower(printerName), "tsc") { + // Try direct USB first for TSC printers + isTSC := strings.Contains(printerName, "USB") || strings.Contains(strings.ToLower(printerName), "tsc") + if isTSC { cData := C.CBytes(data) defer C.free(cData) @@ -178,31 +250,43 @@ func rawPrint(printerName string, data []byte) error { C.int(len(data)), ) - switch ret { - case -1: - return fmt.Errorf("libusb init failed") - case -2: - return fmt.Errorf("TSC printer not found on USB (vendor=%04x product=%04x)", tscVendorID, tscProductID) - case -3: - return fmt.Errorf("cannot claim USB interface β€” close other apps using the printer") - case -4: - return fmt.Errorf("USB transfer failed") - default: - if ret > 0 { - fmt.Printf("[print-usb] Sent %d/%d bytes directly via USB to %s\n", int(ret), len(data), printerName) - return nil + if ret > 0 { + fmt.Printf("[print-usb] Sent %d/%d bytes directly via USB to %s\n", int(ret), len(data), printerName) + return nil + } + // USB failed β€” fall through to CUPS + fmt.Printf("[print-usb] USB not available (ret=%d), falling back to CUPS for %s\n", int(ret), printerName) + } + + // CUPS fallback: resolve the actual CUPS printer name + cupsName := printerName + // Strip "-USB" suffix if present β€” CUPS doesn't use it + cupsName = strings.TrimSuffix(cupsName, "-USB") + + // If we still can't find the printer, search CUPS for any TSC printer + if isTSC { + if out, err := exec.Command("lpstat", "-a").Output(); err == nil { + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) == 0 { + continue + } + name := fields[0] + lower := strings.ToLower(name) + if strings.Contains(lower, "tsc") || strings.Contains(lower, "tdp") || strings.Contains(lower, "ttp") { + cupsName = name + break + } } - return fmt.Errorf("USB transfer returned %d", int(ret)) } } - // Fallback: CUPS lp command - cmd := exec.Command("lp", "-d", printerName, "-o", "raw") + cmd := exec.Command("lp", "-d", cupsName, "-o", "raw") cmd.Stdin = strings.NewReader(string(data)) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("lp failed: %v β€” %s", err, string(output)) + return fmt.Errorf("lp -d %s failed: %v β€” %s", cupsName, err, string(output)) } - fmt.Printf("[print-cups] Sent %d bytes via lp to %s\n", len(data), printerName) + fmt.Printf("[print-cups] Sent %d bytes via lp to %s\n", len(data), cupsName) return nil } diff --git a/printer_windows.go b/printer_windows.go index 50d2c05..b661883 100644 --- a/printer_windows.go +++ b/printer_windows.go @@ -3,6 +3,7 @@ package main import ( + "encoding/csv" "fmt" "os/exec" "strings" @@ -28,43 +29,80 @@ type docInfo1 struct { Datatype *uint16 } -// listLocalPrinters lists installed printers whose name contains "TSC" via Print Spooler. +// hideWindow sets CREATE_NO_WINDOW flag to prevent console flash on Windows. +func hideWindow(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } +} + +// hideWindowCmd is an alias used by cross-platform code (browser.go). +func hideWindowCmd(cmd *exec.Cmd) { hideWindow(cmd) } + +// listLocalPrinters lists ALL installed printers via Print Spooler with status. func listLocalPrinters() ([]PrinterInfo, error) { cmd := exec.Command("powershell", "-NoProfile", "-Command", - `Get-Printer | Where-Object {$_.Name -match 'TSC'} | Select-Object -ExpandProperty Name`) + `Get-Printer | Select-Object Name,PrinterStatus,PortName | ConvertTo-Csv -NoTypeInformation`) + hideWindow(cmd) out, err := cmd.Output() if err != nil { return []PrinterInfo{}, nil } var printers []PrinterInfo - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - name := strings.TrimSpace(line) - if name != "" { - printers = append(printers, PrinterInfo{ - Name: name, - Type: "spooler", - }) + reader := csv.NewReader(strings.NewReader(strings.TrimSpace(string(out)))) + records, csvErr := reader.ReadAll() + if csvErr != nil { + return []PrinterInfo{}, nil + } + for i, fields := range records { + if i == 0 { // skip CSV header + continue + } + if len(fields) < 2 { + continue } + name := strings.TrimSpace(fields[0]) + statusStr := strings.TrimSpace(fields[1]) + if name == "" { + continue + } + + // PrinterStatus: 0=Normal, 1=Paused, 2=Error, 3=PendingDeletion, 4=PaperJam, 5=PaperOut, 6=ManualFeed, 7=PaperProblem, etc. + online := statusStr == "0" || statusStr == "Normal" + status := "idle" + if !online { + status = "offline" + } + + printers = append(printers, PrinterInfo{ + Name: name, + Type: "spooler", + Online: online, + Status: status, + }) } return printers, nil } // rawPrint sends raw bytes to a printer via the Windows Print Spooler API. +// Data is sent in chunks to avoid overflowing the printer's input buffer +// (TSC TDP-244 and similar models have ~32KB buffers). func rawPrint(printerName string, data []byte) error { + fmt.Printf("[print-win] Opening printer %q (%d bytes to send)\n", printerName, len(data)) pName, err := syscall.UTF16PtrFromString(printerName) if err != nil { return fmt.Errorf("invalid printer name: %w", err) } var handle uintptr - ret, _, _ := openPrinterW.Call( + ret, _, errno := openPrinterW.Call( uintptr(unsafe.Pointer(pName)), uintptr(unsafe.Pointer(&handle)), 0, ) if ret == 0 { - return fmt.Errorf("OpenPrinterW failed for %q", printerName) + return fmt.Errorf("OpenPrinterW failed for %q: %v (errno %d)", printerName, errno, errno) } defer closePrinter.Call(handle) @@ -77,32 +115,49 @@ func rawPrint(printerName string, data []byte) error { Datatype: datatype, } - ret, _, _ = startDocPrinterW.Call( + ret, _, errno = startDocPrinterW.Call( handle, 1, uintptr(unsafe.Pointer(&di)), ) if ret == 0 { - return fmt.Errorf("StartDocPrinterW failed") + return fmt.Errorf("StartDocPrinterW failed: %v", errno) } defer endDocPrinter.Call(handle) - ret, _, _ = startPagePrinter.Call(handle) + ret, _, errno = startPagePrinter.Call(handle) if ret == 0 { - return fmt.Errorf("StartPagePrinter failed") + return fmt.Errorf("StartPagePrinter failed: %v", errno) } defer endPagePrinter.Call(handle) - var written uint32 - ret, _, _ = writePrinter.Call( - handle, - uintptr(unsafe.Pointer(&data[0])), - uintptr(len(data)), - uintptr(unsafe.Pointer(&written)), - ) - if ret == 0 { - return fmt.Errorf("WritePrinter failed") + // Send data in chunks to avoid overflowing the printer's input buffer. + // TSC thermal printers have limited buffers (~32KB); sending large + // TSPL streams in one shot causes the printer to fall out of command + // mode and print raw text/hex instead of interpreting commands. + const chunkSize = 4096 + totalWritten := 0 + for offset := 0; offset < len(data); offset += chunkSize { + end := offset + chunkSize + if end > len(data) { + end = len(data) + } + chunk := data[offset:end] + + var written uint32 + ret, _, errno = writePrinter.Call( + handle, + uintptr(unsafe.Pointer(&chunk[0])), + uintptr(len(chunk)), + uintptr(unsafe.Pointer(&written)), + ) + if ret == 0 { + return fmt.Errorf("WritePrinter failed at offset %d/%d: %v", offset, len(data), errno) + } + totalWritten += int(written) } + fmt.Printf("[print-win] Sent %d bytes in %d chunks to %s\n", + totalWritten, (len(data)+chunkSize-1)/chunkSize, printerName) return nil } diff --git a/share.go b/share.go index d8a980d..850bd34 100644 --- a/share.go +++ b/share.go @@ -27,13 +27,14 @@ var ( // ShareStatus describes the current state of the printer sharing service. type ShareStatus struct { - Enabled bool `json:"enabled"` - Running bool `json:"running"` - Port int `json:"port"` - Printer string `json:"printer"` - Address string `json:"address,omitempty"` - Connections int64 `json:"connections"` - JobsServed int64 `json:"jobs_served"` + Enabled bool `json:"enabled"` + Running bool `json:"running"` + Port int `json:"port"` + Printer string `json:"printer"` + Address string `json:"address,omitempty"` + Connections int64 `json:"connections"` + JobsServed int64 `json:"jobs_served"` + LocalAddresses []string `json:"local_addresses"` } // getShareStatus returns the current sharing status. @@ -43,14 +44,40 @@ func getShareStatus() ShareStatus { if shareRunning.Load() && shareListener != nil { addr = shareListener.Addr().String() } + + // Collect local IPv4 addresses from network interfaces + var localAddrs []string + ifaces, _ := net.Interfaces() + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok { + continue + } + ip4 := ipnet.IP.To4() + if ip4 == nil || (ip4[0] == 169 && ip4[1] == 254) { + continue + } + localAddrs = append(localAddrs, ip4.String()) + } + } + return ShareStatus{ - Enabled: cfg.ShareEnabled, - Running: shareRunning.Load(), - Port: cfg.SharePort, - Printer: cfg.SharePrinter, - Address: addr, - Connections: shareConnCount.Load(), - JobsServed: shareJobCount.Load(), + Enabled: cfg.ShareEnabled, + Running: shareRunning.Load(), + Port: cfg.SharePort, + Printer: cfg.SharePrinter, + Address: addr, + Connections: shareConnCount.Load(), + JobsServed: shareJobCount.Load(), + LocalAddresses: localAddrs, } } diff --git a/testdata/.gitkeep b/testdata/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tls.go b/tls.go index 4990f4a..4ddc9b0 100644 --- a/tls.go +++ b/tls.go @@ -4,6 +4,7 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" @@ -11,11 +12,13 @@ import ( "math/big" "net" "os" + "os/exec" "path/filepath" + "runtime" "time" ) -const defaultHostname = "myprinter.com" +const defaultHostname = "local.labelctl.dev" // certDir returns the directory for TLS certificates. func certDir() string { @@ -33,11 +36,16 @@ func ensureCerts(hostname string) (certFile, keyFile, caFile string, err error) certFile = filepath.Join(dir, "server.pem") keyFile = filepath.Join(dir, "server-key.pem") - // Check if certs already exist and are valid + // Check if certs already exist and are standards-compliant if _, e := os.Stat(certFile); e == nil { if _, e := os.Stat(keyFile); e == nil { - log.Printf("[tls] Certificates found in %s", dir) - return certFile, keyFile, caFile, nil + if certIsCompliant(certFile) { + log.Printf("[tls] Certificates found in %s", dir) + return certFile, keyFile, caFile, nil + } + log.Printf("[tls] Existing certificate is non-compliant (>398 days) β€” regenerating") + // Remove old certs and marker to force regeneration + CA re-install + os.Remove(filepath.Join(dir, ".ca-installed")) } } @@ -58,8 +66,10 @@ func ensureCerts(hostname string) (certFile, keyFile, caFile string, err error) NotBefore: time.Now(), NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), IsCA: true, - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, BasicConstraintsValid: true, + MaxPathLen: 0, + MaxPathLenZero: true, } caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) @@ -91,10 +101,10 @@ func ensureCerts(hostname string) (certFile, keyFile, caFile string, err error) Organization: []string{"TSC Bridge"}, CommonName: hostname, }, - DNSNames: []string{hostname, "localhost", "tsc-bridge", "myprinter.com"}, + DNSNames: []string{hostname, "localhost", "local.labelctl.dev"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, NotBefore: time.Now(), - NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), + NotAfter: time.Now().Add(397 * 24 * time.Hour), // Max 398 days for Apple/Chrome compliance KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } @@ -136,3 +146,104 @@ func writeECKeyPEM(file string, key *ecdsa.PrivateKey) error { } return pem.Encode(f, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) } + +// loadCertWithCA loads the server certificate + key and appends the CA cert to the chain. +// This ensures the TLS server sends the full chain so clients can verify trust. +func loadCertWithCA(certFile, keyFile, caFile string) (tls.Certificate, error) { + tlsCert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return tlsCert, err + } + // Append CA cert to the chain + caPEM, err := os.ReadFile(caFile) + if err != nil { + log.Printf("[tls] Could not read CA file for chain: %v β€” serving without chain", err) + return tlsCert, nil + } + caBlock, _ := pem.Decode(caPEM) + if caBlock != nil { + tlsCert.Certificate = append(tlsCert.Certificate, caBlock.Bytes) + } + return tlsCert, nil +} + +// certIsCompliant checks if an existing server certificate has <=398 day validity (Apple/Chrome requirement). +func certIsCompliant(certFile string) bool { + data, err := os.ReadFile(certFile) + if err != nil { + return false + } + block, _ := pem.Decode(data) + if block == nil { + return false + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false + } + validity := cert.NotAfter.Sub(cert.NotBefore) + return validity <= 399*24*time.Hour +} + +// installCACert installs the CA certificate into the OS trust store. +// On Windows: uses certutil to add to Root store (triggers UAC prompt). +// On macOS: uses security add-trusted-cert to add to login keychain. +// Skips silently if already installed (marker file). +func installCACert(caFile string) { + marker := filepath.Join(filepath.Dir(caFile), ".ca-installed") + if _, err := os.Stat(marker); err == nil { + log.Printf("[tls] CA already installed (marker exists)") + return + } + + switch runtime.GOOS { + case "windows": + // certutil -addstore Root β€” adds to Trusted Root CAs + // This triggers a UAC elevation prompt on Windows + cmd := exec.Command("certutil", "-addstore", "Root", caFile) + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("[tls] Failed to install CA on Windows: %v β€” %s", err, string(out)) + log.Printf("[tls] Users can manually install: certutil -addstore Root \"%s\"", caFile) + return + } + log.Printf("[tls] CA certificate installed in Windows trust store") + + case "darwin": + // Add CA to login keychain as trusted root β€” prompts for keychain password + cmd := exec.Command("security", "add-trusted-cert", "-r", "trustRoot", + "-k", filepath.Join(os.Getenv("HOME"), "Library", "Keychains", "login.keychain-db"), + caFile) + out, err := cmd.CombinedOutput() + if err != nil { + log.Printf("[tls] Failed to install CA on macOS: %v β€” %s", err, string(out)) + log.Printf("[tls] Install manually: security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db \"%s\"", caFile) + return + } + log.Printf("[tls] CA certificate installed in macOS login keychain") + + case "linux": + // Copy CA to system trust store and update + destDir := "/usr/local/share/ca-certificates" + dest := filepath.Join(destDir, "tsc-bridge-ca.crt") + cpCmd := exec.Command("sudo", "cp", caFile, dest) + if out, err := cpCmd.CombinedOutput(); err != nil { + log.Printf("[tls] Failed to copy CA on Linux: %v β€” %s", err, string(out)) + log.Printf("[tls] Install manually: sudo cp \"%s\" %s && sudo update-ca-certificates", caFile, dest) + return + } + updCmd := exec.Command("sudo", "update-ca-certificates") + if out, err := updCmd.CombinedOutput(); err != nil { + log.Printf("[tls] Failed to update CA store on Linux: %v β€” %s", err, string(out)) + return + } + log.Printf("[tls] CA certificate installed in Linux trust store") + + default: + log.Printf("[tls] Auto CA install not supported on %s β€” manually trust %s", runtime.GOOS, caFile) + return + } + + // Write marker so we don't re-install on every start + os.WriteFile(marker, []byte("installed"), 0644) +} diff --git a/tray.go b/tray.go new file mode 100644 index 0000000..3a41396 --- /dev/null +++ b/tray.go @@ -0,0 +1,253 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "fyne.io/systray" +) + +// runTray starts the system tray icon and blocks until the user selects "Salir". +// If autoOpen is true, it opens the dashboard via the webview abstraction. +// If systray fails, falls back to blocking forever so the HTTP service stays alive. +func runTray(dashURL string, autoOpen bool) { + defer func() { + if r := recover(); r != nil { + log.Printf("[tray] PANIC in systray: %v β€” falling back to headless mode", r) + select {} // keep service alive + } + }() + + systray.Run( + func() { onTrayReady(dashURL, autoOpen) }, + onTrayExit, + ) +} + +// trayPrinterInfoText builds the printer count + DPI summary string for the menu. +func trayPrinterInfoText() string { + printers, err := listAllPrinters() + if err != nil || len(printers) == 0 { + return "0 impresora(s)" + } + + count := len(printers) + + // Find the most common DPI among detected printers + dpiCounts := map[int]int{} + for _, p := range printers { + dpi := GetPrinterDPI(p.Name) + dpiCounts[dpi]++ + } + // Pick DPI with highest count + bestDPI := defaultDPI + bestCount := 0 + for dpi, c := range dpiCounts { + if c > bestCount { + bestDPI = dpi + bestCount = c + } + } + + return fmt.Sprintf("%d impresora(s) β€” %d DPI", count, bestDPI) +} + +// trayTooltipText builds the tooltip string with whitelabel branding. +func trayTooltipText() string { + cfg := getConfig() + if cfg.Whitelabel.Name != "" { + return fmt.Sprintf("TSC Bridge β€” %s", cfg.Whitelabel.Name) + } + return "TSC Bridge v" + version +} + +// sendTestPrint sends a basic TSPL test label to the given printer. +// Uses the default preset to generate the label layout. +func sendTestPrint(printerName string) error { + cfg := getConfig() + presetID := cfg.DefaultPreset + if presetID == "" { + presetID = "matrix-3x1-30x22" + } + + preset := getPresetByID(presetID, cfg.CustomPresets) + if preset == nil { + // Fallback to a simple test without preset + tspl := "SIZE 40 mm, 25 mm\r\nGAP 3 mm, 0 mm\r\nDIRECTION 0,0\r\nSPEED 4\r\nDENSITY 8\r\nSET TEAR ON\r\nCLS\r\n" + tspl += "TEXT 16,16,\"3\",0,1,1,\"TSC BRIDGE TEST\"\r\n" + tspl += fmt.Sprintf("TEXT 16,56,\"2\",0,1,1,\"v%s\"\r\n", version) + tspl += "BARCODE 16,96,\"128\",48,1,0,2,2,\"TSCBRIDGE\"\r\n" + tspl += "PRINT 1\r\n" + return sendToPrinter(printerName, []byte(tspl)) + } + + // Generate test label using preset (same logic as handleTestPrint) + tspl := generatePresetHeader(preset) + for i := 0; i < preset.Columns; i++ { + x := 8 + if i < len(preset.ColOffsets) { + x = preset.ColOffsets[i] + } + tspl += fmt.Sprintf("TEXT %d,16,\"3\",0,1,1,\"TEST COL %d\"\r\n", x, i+1) + tspl += fmt.Sprintf("BARCODE %d,48,\"128\",40,1,0,2,2,\"12345%d\"\r\n", x, i) + } + tspl += "PRINT 1\r\n" + + return sendToPrinter(printerName, []byte(tspl)) +} + +// sendToPrinter routes print data to the correct backend (network or local). +func sendToPrinter(printerName string, data []byte) error { + allPrinters, _ := listAllPrinters() + var targetPrinter *PrinterInfo + + if printerName == "" { + if len(allPrinters) > 0 { + targetPrinter = &allPrinters[0] + printerName = targetPrinter.Name + } + } else { + targetPrinter = findPrinter(printerName, allPrinters) + } + + if printerName == "" { + return fmt.Errorf("no printer available") + } + + if targetPrinter != nil && (targetPrinter.Type == "network" || targetPrinter.Type == "manual" || targetPrinter.Type == "raw") { + return networkRawPrint(targetPrinter.Address, data) + } + return rawPrint(printerName, data) +} + +func onTrayReady(dashURL string, autoOpen bool) { + systray.SetTooltip(trayTooltipText()) + // macOS menubar icons: 22px (standard), template mode for auto dark/light + trayIcon := generateAppIcon(22) + regularIcon := generateAppIcon(22) + systray.SetTemplateIcon(trayIcon, regularIcon) + + // --- Printer info (disabled) --- + mPrinterInfo := systray.AddMenuItem(trayPrinterInfoText(), "Impresoras detectadas") + mPrinterInfo.Disable() + + systray.AddSeparator() + + // --- Dashboard --- + mOpen := systray.AddMenuItem("Abrir Dashboard", "Abrir panel de control") + + // --- Re-detect printers --- + mRescan := systray.AddMenuItem("Re-detectar impresoras", "Buscar impresoras en la red") + + // --- Test Print --- + mTestPrint := systray.AddMenuItem("Test Print", "Imprimir etiqueta de prueba") + + systray.AddSeparator() + + // --- Auto-start toggle --- + mAutoStart := systray.AddMenuItemCheckbox( + "Iniciar con el sistema", + "Iniciar TSC Bridge al encender el equipo", + isAutoStartEnabled(), + ) + + systray.AddSeparator() + + // --- Port & version info (disabled) --- + cfg := getConfig() + mInfo := systray.AddMenuItem( + fmt.Sprintf("Puerto %d β€” v%s", cfg.Port, version), + "Informacion del servicio", + ) + mInfo.Disable() + + systray.AddSeparator() + + // --- Quit --- + mQuit := systray.AddMenuItem("Salir", "Detener servicio y salir") + + // Auto-open dashboard on first launch + if autoOpen { + go func() { + time.Sleep(2 * time.Second) // let HTTP server goroutine start accepting + showDashboard(dashURL) + }() + } + + // Background goroutine: update printer info every 10 seconds + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for range ticker.C { + text := trayPrinterInfoText() + mPrinterInfo.SetTitle(text) + mPrinterInfo.SetTooltip("Impresoras detectadas") + } + }() + + // Event loop + go func() { + for { + select { + case <-mOpen.ClickedCh: + go showDashboard(dashURL) + + case <-mRescan.ClickedCh: + go func() { + log.Printf("[tray] Re-detecting printers...") + mRescan.SetTitle("Escaneando...") + mRescan.Disable() + + refreshNetworkPrinters() + DetectAllPrinterDPIs() + + text := trayPrinterInfoText() + mPrinterInfo.SetTitle(text) + mRescan.SetTitle("Re-detectar impresoras") + mRescan.Enable() + log.Printf("[tray] Re-detection complete: %s", text) + }() + + case <-mTestPrint.ClickedCh: + go func() { + cfg := getConfig() + printerName := cfg.DefaultPrinter + log.Printf("[tray] Test print requested (printer=%q)", printerName) + if err := sendTestPrint(printerName); err != nil { + log.Printf("[tray] Test print failed: %v", err) + } else { + log.Printf("[tray] Test print sent successfully") + } + }() + + case <-mAutoStart.ClickedCh: + enabled := !isAutoStartEnabled() + if err := setAutoStart(enabled); err != nil { + log.Printf("[tray] autostart toggle error: %v", err) + } else { + if enabled { + mAutoStart.Check() + } else { + mAutoStart.Uncheck() + } + configMu.Lock() + appConfig.AutoStart = enabled + configMu.Unlock() + saveConfig() + log.Printf("[tray] autostart toggled: %v", enabled) + } + + case <-mQuit.ClickedCh: + systray.Quit() + } + } + }() +} + +func onTrayExit() { + log.Printf("System tray exit β€” shutting down") + destroyWebview() + os.Exit(0) +} diff --git a/tsc-bridge.iss b/tsc-bridge.iss new file mode 100644 index 0000000..ce0b7a3 --- /dev/null +++ b/tsc-bridge.iss @@ -0,0 +1,172 @@ +; TSC Bridge β€” InnoSetup Installer Script +; Compile with InnoSetup 6+ on Windows: iscc tsc-bridge.iss +; Or from macOS via Docker: docker run --rm -v $(pwd):/work amake/innosetup /work/tsc-bridge.iss + +#define MyAppName "TSC Bridge" +#define MyAppVersion "3.0.0" +#define MyAppPublisher "Abstrakt GT" +#define MyAppURL "https://myprinter.com" +#define MyAppExeName "tsc-bridge.exe" + +[Setup] +AppId={{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +DefaultDirName={localappdata}\tsc-bridge +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputBaseFilename=TSC-Bridge-{#MyAppVersion}-Setup +SetupIconFile=tsc-bridge.ico +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=admin +UninstallDisplayIcon={app}\{#MyAppExeName} +UninstallDisplayName={#MyAppName} +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir=. + +[Languages] +Name: "spanish"; MessagesFile: "compiler:Languages\Spanish.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[CustomMessages] +spanish.InstallingService=Instalando servicio TSC Bridge... +spanish.ConfiguringFirewall=Configurando reglas de firewall... +spanish.ConfiguringHosts=Configurando nombre de host local... +spanish.InstallingCertificate=Instalando certificado SSL... +spanish.StartingService=Iniciando servicio... +english.InstallingService=Installing TSC Bridge service... +english.ConfiguringFirewall=Configuring firewall rules... +english.ConfiguringHosts=Configuring local hostname... +english.InstallingCertificate=Installing SSL certificate... +english.StartingService=Starting service... + +[Tasks] +Name: "desktopicon"; Description: "Crear acceso directo en el Escritorio"; GroupDescription: "Accesos directos:" +Name: "autostart"; Description: "Iniciar TSC Bridge con Windows"; GroupDescription: "Inicio automΓ‘tico:"; Flags: checkedonce + +[Files] +Source: "tsc-bridge.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "tsc-bridge.ico"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\tsc-bridge.ico" +Name: "{group}\Desinstalar {#MyAppName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\tsc-bridge.ico"; Tasks: desktopicon +Name: "{commonstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "--headless"; IconFilename: "{app}\tsc-bridge.ico"; Tasks: autostart + +[Run] +; Start the service after install +Filename: "{app}\{#MyAppExeName}"; Description: "Iniciar TSC Bridge"; Flags: nowait postinstall skipifsilent + +[UninstallRun] +; Stop service before uninstall +Filename: "taskkill"; Parameters: "/IM {#MyAppExeName} /F"; Flags: runhidden + +[UninstallDelete] +Type: filesandordirs; Name: "{app}" + +[Code] +const + HOSTNAME = 'myprinter.com'; + +procedure ConfigureHostsFile(); +var + HostsPath: String; + HostsContent: AnsiString; +begin + HostsPath := ExpandConstant('{sys}\drivers\etc\hosts'); + if LoadStringFromFile(HostsPath, HostsContent) then + begin + if Pos(HOSTNAME, String(HostsContent)) = 0 then + begin + SaveStringToFile(HostsPath, #13#10 + '127.0.0.1 ' + HOSTNAME + #13#10, True); + Log('Added ' + HOSTNAME + ' to hosts file'); + end + else + Log(HOSTNAME + ' already in hosts file'); + end; +end; + +procedure ConfigureFirewall(); +var + ResultCode: Integer; +begin + // Remove old rules + Exec('netsh', 'advfirewall firewall delete rule name="TSC Bridge HTTP"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Exec('netsh', 'advfirewall firewall delete rule name="TSC Bridge HTTPS"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + // Add new rules (default ports) + Exec('netsh', 'advfirewall firewall add rule name="TSC Bridge HTTP" dir=in action=allow protocol=TCP localport=9638', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Exec('netsh', 'advfirewall firewall add rule name="TSC Bridge HTTPS" dir=in action=allow protocol=TCP localport=9639', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + // Also allow custom port 9271/9272 + Exec('netsh', 'advfirewall firewall add rule name="TSC Bridge HTTP Alt" dir=in action=allow protocol=TCP localport=9271', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Exec('netsh', 'advfirewall firewall add rule name="TSC Bridge HTTPS Alt" dir=in action=allow protocol=TCP localport=9272', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +procedure GenerateAndTrustCert(); +var + ResultCode: Integer; + CertDir, CaPath: String; +begin + CertDir := ExpandConstant('{userappdata}\tsc-bridge\certs'); + CaPath := CertDir + '\ca.pem'; + + // Run bridge briefly to generate certs + if not FileExists(CaPath) then + begin + Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--headless', '', SW_HIDE, ewNoWait, ResultCode); + Sleep(4000); + Exec('taskkill', '/IM {#MyAppExeName} /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(1000); + end; + + // Trust CA cert + if FileExists(CaPath) then + begin + Exec('certutil', '-addstore -f "Root" "' + CaPath + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Log('CA certificate installed'); + end; +end; + +procedure RemoveFirewallRules(); +var + ResultCode: Integer; +begin + Exec('netsh', 'advfirewall firewall delete rule name="TSC Bridge HTTP"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Exec('netsh', 'advfirewall firewall delete rule name="TSC Bridge HTTPS"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Exec('netsh', 'advfirewall firewall delete rule name="TSC Bridge HTTP Alt"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Exec('netsh', 'advfirewall firewall delete rule name="TSC Bridge HTTPS Alt"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + // Kill any running instance first + WizardForm.StatusLabel.Caption := ExpandConstant('{cm:InstallingService}'); + + // Configure hosts file + WizardForm.StatusLabel.Caption := ExpandConstant('{cm:ConfiguringHosts}'); + ConfigureHostsFile(); + + // Configure firewall + WizardForm.StatusLabel.Caption := ExpandConstant('{cm:ConfiguringFirewall}'); + ConfigureFirewall(); + + // Generate and trust SSL certificate + WizardForm.StatusLabel.Caption := ExpandConstant('{cm:InstallingCertificate}'); + GenerateAndTrustCert(); + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usPostUninstall then + begin + RemoveFirewallRules(); + end; +end; diff --git a/tspl_renderer.go b/tspl_renderer.go new file mode 100644 index 0000000..13999e0 --- /dev/null +++ b/tspl_renderer.go @@ -0,0 +1,1430 @@ +package main + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/draw" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "math" + "net/http" + "os" + "strings" + + goqrcode "github.com/skip2/go-qrcode" + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +// ════════════════════════════════════════════════════ +// TSPL2 Renderer β€” Converts pdfme schemas to TSPL2 +// commands for TSC TDP-244 Pro (203 DPI) and compatible TSPL2 printers. +// +// Two rendering modes: +// - Native: uses TSPL2 commands (TEXT, BAR, BOX, QRCODE, BARCODE, DIAGONAL, BITMAP) +// - Raster: renders entire page as monochrome bitmap (maximum fidelity, larger payload) +// +// The output includes the ESC !R init sequence so the printer switches to TSPL2 mode. +// IMPORTANT: do NOT run sanitizeTSPL() on the output β€” it corrupts binary BITMAP data. +// ════════════════════════════════════════════════════ + +const defaultDPI = 203 // TSC TDP-244 Pro = 203 DPI (8 dots/mm) + +// TSPL2 built-in monospace font metrics (width Γ— height in dots at 203 DPI). +// Can be scaled with multipliers 1–10. +var tsplFontMetrics = []struct { + Name string + Width int // char width in dots + Height int // char height in dots +}{ + {"1", 8, 12}, + {"2", 12, 20}, + {"3", 16, 24}, + {"4", 24, 32}, + {"5", 32, 48}, +} + +// ════════════════════════════════════════════════════ +// Unit conversions +// ════════════════════════════════════════════════════ + +func mmToDots(mm float64, dpi int) int { + return int(math.Round(mm * float64(dpi) / 25.4)) +} + +func ptToDots(pt float64, dpi int) int { + return int(math.Round(pt * float64(dpi) / 72.0)) +} + +// ════════════════════════════════════════════════════ +// Visibility checks (thermal = monochrome binary) +// ════════════════════════════════════════════════════ + +// isColorDarkEnough returns true if a color would print visibly on thermal paper. +func isColorDarkEnough(colorStr string) bool { + if colorStr == "" { + return true // default = black + } + r, g, b, a := parseColor(colorStr) + if a < 0.3 { + return false + } + lum := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b) + return lum < 200 +} + +// shouldRenderTSPL returns false for fields invisible on thermal (e.g. 15% opacity guilloche). +// shouldRenderTSPL filters decorative elements that don't translate to thermal printing. +// Same logic as PHP PdfmeToTsplConverter::isDecorativeElement (inverted: true = render). +func shouldRenderTSPL(field PdfmeField) bool { + // Low opacity elements are decorative (guilloche patterns are typically ~0.08 opacity) + if field.Opacity > 0 && field.Opacity < 0.5 { + return false + } + + // Rotated lines = diagonals, no TSPL native support + if field.Type == "line" && (field.Rotate > 1.0 || field.Rotate < -1.0) { + return false + } + + // Ellipses are decorative flourishes + if field.Type == "ellipse" { + return false + } + + return true +} + +// emitThermalGuilloche generates a decorative border pattern for thermal labels. +// Same logic as PHP PdfmeToTsplConverter::emitThermalGuilloche. +func emitThermalGuilloche(buf *bytes.Buffer, labelWmm, labelHmm float64, dpi int) { + w := mmToDots(labelWmm, dpi) + h := mmToDots(labelHmm, dpi) + m := 4 // margin in dots + + // Double frame + buf.WriteString(fmt.Sprintf("BOX %d,%d,%d,%d,1\r\n", m, m, w-m, h-m)) + buf.WriteString(fmt.Sprintf("BOX %d,%d,%d,%d,1\r\n", m+3, m+3, w-m-3, h-m-3)) + + // Corner tick marks + tickLen := 20 + tickOff := m + 6 + + // Top-left + buf.WriteString(fmt.Sprintf("BAR %d,%d,%d,1\r\n", tickOff, tickOff, tickLen)) + buf.WriteString(fmt.Sprintf("BAR %d,%d,1,%d\r\n", tickOff, tickOff, tickLen)) + // Top-right + tr := w - m - 6 + buf.WriteString(fmt.Sprintf("BAR %d,%d,%d,1\r\n", tr-tickLen, tickOff, tickLen)) + buf.WriteString(fmt.Sprintf("BAR %d,%d,1,%d\r\n", tr, tickOff, tickLen)) + // Bottom-left + bl := h - m - 6 + buf.WriteString(fmt.Sprintf("BAR %d,%d,%d,1\r\n", tickOff, bl, tickLen)) + buf.WriteString(fmt.Sprintf("BAR %d,%d,1,%d\r\n", tickOff, bl-tickLen, tickLen)) + // Bottom-right + buf.WriteString(fmt.Sprintf("BAR %d,%d,%d,1\r\n", tr-tickLen, bl, tickLen)) + buf.WriteString(fmt.Sprintf("BAR %d,%d,1,%d\r\n", tr, bl-tickLen, tickLen)) + + // Dashed lines along top/bottom edges + lineStart := m + 8 + lineEnd := w - m - 8 + for y := m + 1; y < m+3; y++ { + for x := lineStart; x < lineEnd; x += 16 { + dw := 8 + if lineEnd-x < dw { + dw = lineEnd - x + } + buf.WriteString(fmt.Sprintf("BAR %d,%d,%d,1\r\n", x, y, dw)) + } + } + bottomY := h - m - 2 + for y := bottomY; y < bottomY+2; y++ { + for x := lineStart; x < lineEnd; x += 16 { + dw := 8 + if lineEnd-x < dw { + dw = lineEnd - x + } + buf.WriteString(fmt.Sprintf("BAR %d,%d,%d,1\r\n", x, y, dw)) + } + } + + // Dashed lines along left/right edges + vStart := m + 8 + vEnd := h - m - 8 + for x := m + 1; x < m+3; x++ { + for y := vStart; y < vEnd; y += 16 { + dh := 8 + if vEnd-y < dh { + dh = vEnd - y + } + buf.WriteString(fmt.Sprintf("BAR %d,%d,1,%d\r\n", x, y, dh)) + } + } + rightX := w - m - 2 + for x := rightX; x < rightX+2; x++ { + for y := vStart; y < vEnd; y += 16 { + dh := 8 + if vEnd-y < dh { + dh = vEnd - y + } + buf.WriteString(fmt.Sprintf("BAR %d,%d,1,%d\r\n", x, y, dh)) + } + } +} + +// ════════════════════════════════════════════════════ +// Font sizing +// ════════════════════════════════════════════════════ + +type tsplFontChoice struct { + Font string + Mult int + CharW int // effective char width in dots + CharH int // effective char height in dots +} + +// pickTSPLFont returns the largest font+multiplier that fits within targetH dots. +func pickTSPLFont(targetH int) tsplFontChoice { + var best tsplFontChoice + for _, f := range tsplFontMetrics { + for mult := 1; mult <= 10; mult++ { + h := f.Height * mult + if h <= targetH && h > best.CharH { + best = tsplFontChoice{Font: f.Name, Mult: mult, CharW: f.Width * mult, CharH: h} + } + } + } + if best.Font == "" { + best = tsplFontChoice{Font: "1", Mult: 1, CharW: 8, CharH: 12} + } + return best +} + +// pickTSPLFontForSize converts a pdfme fontSize (points) to the best TSPL font. +func pickTSPLFontForSize(fontSize float64, dpi int) tsplFontChoice { + targetH := ptToDots(fontSize, dpi) + if targetH < 12 { + targetH = 12 + } + return pickTSPLFont(targetH) +} + +// pickTSPLFontDynamic finds the largest font+mult that lets text fit inside wΓ—h dots. +// fit: "horizontal" = text must fit in one line (shrinks font until no wrapping needed). +// fit: "vertical" (default) = word-wrap allowed, total height must fit within h. +func pickTSPLFontDynamic(text string, w, h int, minPt, maxPt float64, fit string, dpi int) tsplFontChoice { + maxH := ptToDots(maxPt, dpi) + minH := ptToDots(minPt, dpi) + if minH < 12 { + minH = 12 + } + + isHorizontal := fit == "horizontal" + + // Try from largest to smallest + for fi := len(tsplFontMetrics) - 1; fi >= 0; fi-- { + f := tsplFontMetrics[fi] + for mult := 10; mult >= 1; mult-- { + charH := f.Height * mult + charW := f.Width * mult + if charH > maxH || charH < minH { + continue + } + + if isHorizontal { + // Each paragraph must fit in one line (no wrapping) + fits := true + for _, para := range strings.Split(text, "\n") { + textW := len(para) * charW + if textW > w { + fits = false + break + } + } + if fits { + return tsplFontChoice{Font: f.Name, Mult: mult, CharW: charW, CharH: charH} + } + } else { + // Vertical: word-wrap and check total height fits + lines := tsplWordWrap(text, w, charW) + totalH := len(lines) * charH + if totalH <= h { + return tsplFontChoice{Font: f.Name, Mult: mult, CharW: charW, CharH: charH} + } + } + } + } + return pickTSPLFont(minH) +} + +// tsplWordWrap wraps text to fit within maxWidth dots using monospace charWidth. +func tsplWordWrap(text string, maxWidthDots, charWidthDots int) []string { + if charWidthDots <= 0 { + return []string{text} + } + maxChars := maxWidthDots / charWidthDots + if maxChars < 1 { + maxChars = 1 + } + + var result []string + for _, para := range strings.Split(text, "\n") { + if para == "" { + result = append(result, "") + continue + } + words := strings.Fields(para) + if len(words) == 0 { + result = append(result, "") + continue + } + cur := words[0] + for i := 1; i < len(words); i++ { + candidate := cur + " " + words[i] + if len(candidate) <= maxChars { + cur = candidate + } else { + result = append(result, cur) + cur = words[i] + } + } + result = append(result, cur) + } + return result +} + +// ════════════════════════════════════════════════════ +// Rotation helper: pdfme degrees β†’ TSPL 0/90/180/270 +// ════════════════════════════════════════════════════ + +func tsplRotation(degrees float64) int { + if degrees == 0 { + return 0 + } + r := math.Mod(degrees, 360) + if r < 0 { + r += 360 + } + if r >= 315 || r < 45 { + return 0 + } else if r >= 45 && r < 135 { + return 90 + } else if r >= 135 && r < 225 { + return 180 + } + return 270 +} + +// ════════════════════════════════════════════════════ +// TSPL string escaping +// ════════════════════════════════════════════════════ + +// escTSPLStr escapes a string for TSPL TEXT commands (truncates at 200 chars for safety). +func escTSPLStr(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "'") + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + if len(s) > 200 { + s = s[:197] + "..." + } + return s +} + +// escTSPLData escapes a string for TSPL data commands (QRCODE, BARCODE) without truncation. +func escTSPLData(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "'") + s = strings.ReplaceAll(s, "\r", "") + s = strings.ReplaceAll(s, "\n", "") + return s +} + +// ════════════════════════════════════════════════════ +// Main entry: RenderBulkTSPL (native commands mode) +// ════════════════════════════════════════════════════ + +// RenderBulkTSPL generates TSPL2 commands from a pdfme schema + data rows. +// Returns raw bytes ready to send to the printer (includes ESC !R init). +// Do NOT pass through sanitizeTSPL β€” may contain binary BITMAP data. +func RenderBulkTSPL(schema *PdfmeSchema, rows []map[string]string, dpi int, copies int) []byte { + if dpi <= 0 { + dpi = defaultDPI + } + if copies < 1 { + copies = 1 + } + + var buf bytes.Buffer + + // ── ESC !R: Force printer into TSPL2 mode ── + buf.Write([]byte{0x1b, 0x21, 0x52}) + buf.WriteString("\r\n") + + // ── Label setup from schema dimensions ── + labelW := schema.BasePdf.Width + labelH := schema.BasePdf.Height + + buf.WriteString(fmt.Sprintf("SIZE %.1f mm, %.1f mm\r\n", labelW, labelH)) + buf.WriteString("GAP 3 mm, 0 mm\r\n") + buf.WriteString("DIRECTION 0,0\r\n") + buf.WriteString("SPEED 4\r\n") + buf.WriteString("DENSITY 10\r\n") + buf.WriteString("SET CUTTER OFF\r\n") + buf.WriteString("SET TEAR ON\r\n") + + hasBitmap := schemaHasImages(schema) + if !hasBitmap { + buf.WriteString("CODEPAGE UTF-8\r\n") + } + + // Pre-scan: count decorative elements to decide if thermal guilloche is needed + decorativeCount := 0 + for _, pageFields := range schema.Schemas { + for _, field := range pageFields { + if !shouldRenderTSPL(field) { + decorativeCount++ + } + } + } + + for ri, row := range rows { + for pi, pageFields := range schema.Schemas { + buf.WriteString("CLS\r\n") + + // Emit thermal guilloche as background if decorative elements were filtered + if decorativeCount > 5 { + emitThermalGuilloche(&buf, labelW, labelH, dpi) + } + + for _, field := range pageFields { + if !shouldRenderTSPL(field) { + continue + } + + value := resolveFieldValue(field, row) + x := mmToDots(field.Position.X, dpi) + y := mmToDots(field.Position.Y, dpi) + w := mmToDots(field.Width, dpi) + h := mmToDots(field.Height, dpi) + + if ri == 0 && pi == 0 { + log.Printf("[tspl] Field %q type=%s value=%q x=%d y=%d w=%d h=%d", + field.Name, field.Type, truncate(value, 40), x, y, w, h) + } + + switch field.Type { + case "text", "multiVariableText": + if value != "" { + tsplRenderText(&buf, field, value, x, y, w, h, dpi) + } + case "qrcode": + if value == "" { + value = field.Content + } + if value != "" { + tsplRenderQR(&buf, field, value, x, y, w, h, dpi) + } + case "barcode", "code128", "code39", "ean13", "ean8": + if value != "" { + tsplRenderBarcode(&buf, field, value, x, y, w, h, dpi) + } + case "line": + tsplRenderLine(&buf, field, x, y, w, h, dpi) + case "rectangle": + tsplRenderRectangle(&buf, field, x, y, w, h, dpi) + case "ellipse": + tsplRenderEllipse(&buf, field, x, y, w, h, dpi) + case "image": + tsplRenderImage(&buf, field, row, x, y, w, h, dpi) + } + } + + buf.WriteString(fmt.Sprintf("PRINT %d\r\n", copies)) + } + } + + log.Printf("[tspl] Generated %d bytes for %d rows (mode=native)", buf.Len(), len(rows)) + return buf.Bytes() +} + +// renderSinglePageTSPL generates TSPL2 commands for a single page from one row. +// Returns raw bytes ready to send to the printer (includes ESC !R init + header). +// pageIndex selects which page within the pdfme schema to render. +func renderSinglePageTSPL(schema *PdfmeSchema, row map[string]string, pageIndex int, dpi int, copies int) []byte { + if dpi <= 0 { + dpi = defaultDPI + } + if copies < 1 { + copies = 1 + } + if pageIndex >= len(schema.Schemas) { + return nil + } + + var buf bytes.Buffer + + // ── ESC !R: Force printer into TSPL2 mode ── + buf.Write([]byte{0x1b, 0x21, 0x52}) + buf.WriteString("\r\n") + + // ── Label setup from schema dimensions ── + labelW := schema.BasePdf.Width + labelH := schema.BasePdf.Height + + buf.WriteString(fmt.Sprintf("SIZE %.1f mm, %.1f mm\r\n", labelW, labelH)) + buf.WriteString("GAP 3 mm, 0 mm\r\n") + buf.WriteString("DIRECTION 0,0\r\n") + buf.WriteString("SPEED 4\r\n") + buf.WriteString("DENSITY 10\r\n") + buf.WriteString("SET CUTTER OFF\r\n") + buf.WriteString("SET TEAR ON\r\n") + + hasBitmap := false + for _, f := range schema.Schemas[pageIndex] { + if f.Type == "image" { + hasBitmap = true + break + } + } + if !hasBitmap { + buf.WriteString("CODEPAGE UTF-8\r\n") + } + + // Decorative count for this page + decorativeCount := 0 + for _, field := range schema.Schemas[pageIndex] { + if !shouldRenderTSPL(field) { + decorativeCount++ + } + } + + buf.WriteString("CLS\r\n") + + if decorativeCount > 5 { + emitThermalGuilloche(&buf, labelW, labelH, dpi) + } + + for _, field := range schema.Schemas[pageIndex] { + if !shouldRenderTSPL(field) { + continue + } + + value := resolveFieldValue(field, row) + x := mmToDots(field.Position.X, dpi) + y := mmToDots(field.Position.Y, dpi) + w := mmToDots(field.Width, dpi) + h := mmToDots(field.Height, dpi) + + switch field.Type { + case "text", "multiVariableText": + if value != "" { + tsplRenderText(&buf, field, value, x, y, w, h, dpi) + } + case "qrcode": + if value == "" { + value = field.Content + } + if value != "" { + tsplRenderQR(&buf, field, value, x, y, w, h, dpi) + } + case "barcode", "code128", "code39", "ean13", "ean8": + if value != "" { + tsplRenderBarcode(&buf, field, value, x, y, w, h, dpi) + } + case "line": + tsplRenderLine(&buf, field, x, y, w, h, dpi) + case "rectangle": + tsplRenderRectangle(&buf, field, x, y, w, h, dpi) + case "ellipse": + tsplRenderEllipse(&buf, field, x, y, w, h, dpi) + case "image": + tsplRenderImage(&buf, field, row, x, y, w, h, dpi) + } + } + + buf.WriteString(fmt.Sprintf("PRINT %d\r\n", copies)) + return buf.Bytes() +} + +// schemaHasImages checks if any field in the schema is an image type. +func schemaHasImages(schema *PdfmeSchema) bool { + for _, page := range schema.Schemas { + for _, f := range page { + if f.Type == "image" { + return true + } + } + } + return false +} + +// ════════════════════════════════════════════════════ +// Text rendering +// ════════════════════════════════════════════════════ + +func tsplRenderText(buf *bytes.Buffer, field PdfmeField, value string, x, y, w, h, dpi int) { + if field.FontColor != "" && !isColorDarkEnough(field.FontColor) { + return + } + + fontSize := field.FontSize + if fontSize == 0 { + fontSize = 10 + } + + var fc tsplFontChoice + if field.DynamicFontSize != nil && field.DynamicFontSize.Max > 0 { + fc = pickTSPLFontDynamic(value, w, h, field.DynamicFontSize.Min, field.DynamicFontSize.Max, field.DynamicFontSize.Fit, dpi) + } else { + fc = pickTSPLFontForSize(fontSize, dpi) + } + + rotation := tsplRotation(field.Rotate) + + // Padding + padT := mmToDots(field.Padding.Top, dpi) + padR := mmToDots(field.Padding.Right, dpi) + padB := mmToDots(field.Padding.Bottom, dpi) + padL := mmToDots(field.Padding.Left, dpi) + px := x + padL + py := y + padT + pw := w - padL - padR + ph := h - padT - padB + if pw < fc.CharW { + pw = fc.CharW + } + if ph < fc.CharH { + ph = fc.CharH + } + + // Word wrap + lines := tsplWordWrap(value, pw, fc.CharW) + + // Vertical alignment + totalTextH := len(lines) * fc.CharH + textY := py + switch field.VerticalAlignment { + case "middle": + if totalTextH < ph { + textY = py + (ph-totalTextH)/2 + } + case "bottom": + if totalTextH < ph { + textY = py + ph - totalTextH + } + } + + for li, line := range lines { + lineY := textY + li*fc.CharH + if lineY+fc.CharH > y+h { + break + } + + lineX := px + lineWDots := len(line) * fc.CharW + + switch field.Alignment { + case "center": + if lineWDots < pw { + lineX = px + (pw-lineWDots)/2 + } + case "right": + if lineWDots < pw { + lineX = px + pw - lineWDots + } + } + + escaped := escTSPLStr(line) + buf.WriteString(fmt.Sprintf("TEXT %d,%d,\"%s\",%d,%d,%d,\"%s\"\r\n", + lineX, lineY, fc.Font, rotation, fc.Mult, fc.Mult, escaped)) + + // Poor man's bold: double-print with 1-dot X offset (TSPL fonts have no bold variant) + if field.FontWeight == "bold" { + buf.WriteString(fmt.Sprintf("TEXT %d,%d,\"%s\",%d,%d,%d,\"%s\"\r\n", + lineX+1, lineY, fc.Font, rotation, fc.Mult, fc.Mult, escaped)) + } + } +} + +// ════════════════════════════════════════════════════ +// QR code +// ════════════════════════════════════════════════════ + +func tsplRenderQR(buf *bytes.Buffer, field PdfmeField, value string, x, y, w, h, dpi int) { + qr, err := goqrcode.New(value, goqrcode.Medium) + if err != nil { + log.Printf("[tspl] QR error: %v", err) + return + } + qr.DisableBorder = true + bitmap := qr.Bitmap() + modules := len(bitmap) + if modules == 0 { + return + } + + dim := w + if h < w { + dim = h + } + cellSize := dim / modules + if cellSize < 1 { + cellSize = 1 + } + if cellSize > 10 { + cellSize = 10 + } + + // Center within field + qrTotal := cellSize * modules + qrX := x + (w-qrTotal)/2 + qrY := y + (h-qrTotal)/2 + if qrX < 0 { + qrX = 0 + } + if qrY < 0 { + qrY = 0 + } + + rotation := tsplRotation(field.Rotate) + + buf.WriteString(fmt.Sprintf("QRCODE %d,%d,M,%d,A,%d,\"%s\"\r\n", + qrX, qrY, cellSize, rotation, escTSPLData(value))) +} + +// ════════════════════════════════════════════════════ +// Barcode +// ════════════════════════════════════════════════════ + +func tsplRenderBarcode(buf *bytes.Buffer, field PdfmeField, value string, x, y, w, h, dpi int) { + if h < 20 { + h = 20 + } + + btype := "128" + switch field.Type { + case "code39": + btype = "39" + case "ean13": + btype = "EAN13" + case "ean8": + btype = "EAN8" + } + + // Fit narrow bar width to field width + totalMods := len(value)*11 + 35 + narrow := w / totalMods + if narrow < 1 { + narrow = 1 + } + if narrow > 4 { + narrow = 4 + } + + rotation := tsplRotation(field.Rotate) + + buf.WriteString(fmt.Sprintf("BARCODE %d,%d,\"%s\",%d,1,%d,%d,%d,\"%s\"\r\n", + x, y, btype, h, rotation, narrow, narrow, escTSPLData(value))) +} + +// ════════════════════════════════════════════════════ +// Line +// ════════════════════════════════════════════════════ + +func tsplRenderLine(buf *bytes.Buffer, field PdfmeField, x, y, w, h, dpi int) { + col := field.Color + if col == "" { + col = field.FontColor + } + if col != "" && !isColorDarkEnough(col) { + return + } + + thickness := h + if thickness < 1 { + thickness = 1 + } + + if field.Rotate == 0 { + // Horizontal: BAR x,y,width,height + buf.WriteString(fmt.Sprintf("BAR %d,%d,%d,%d\r\n", x, y, w, thickness)) + return + } + + // Rotated: DIAGONAL x1,y1,x2,y2,thickness + cx := float64(x) + float64(w)/2 + cy := float64(y) + float64(h)/2 + halfW := float64(w) / 2 + rad := field.Rotate * math.Pi / 180 + + x1 := int(math.Round(cx - halfW*math.Cos(rad))) + y1 := int(math.Round(cy - halfW*math.Sin(rad))) + x2 := int(math.Round(cx + halfW*math.Cos(rad))) + y2 := int(math.Round(cy + halfW*math.Sin(rad))) + + // Clamp to non-negative (TDP-244 requirement) + if x1 < 0 { + x1 = 0 + } + if y1 < 0 { + y1 = 0 + } + if x2 < 0 { + x2 = 0 + } + if y2 < 0 { + y2 = 0 + } + + buf.WriteString(fmt.Sprintf("DIAGONAL %d,%d,%d,%d,%d\r\n", x1, y1, x2, y2, thickness)) +} + +// ════════════════════════════════════════════════════ +// Rectangle +// ════════════════════════════════════════════════════ + +func tsplRenderRectangle(buf *bytes.Buffer, field PdfmeField, x, y, w, h, dpi int) { + fillColor := field.Color + if fillColor == "" { + fillColor = field.BackgroundColor + } + + hasFill := fillColor != "" && isColorDarkEnough(fillColor) + hasBorder := float64(field.BorderWidth) > 0 + + if hasFill { + // BAR = filled black rectangle + buf.WriteString(fmt.Sprintf("BAR %d,%d,%d,%d\r\n", x, y, w, h)) + } + + if hasBorder && !hasFill { + bw := mmToDots(float64(field.BorderWidth), dpi) + if bw < 1 { + bw = 1 + } + // BOX x,y,x_end,y_end,thickness + buf.WriteString(fmt.Sprintf("BOX %d,%d,%d,%d,%d\r\n", x, y, x+w, y+h, bw)) + } +} + +// ════════════════════════════════════════════════════ +// Ellipse +// ════════════════════════════════════════════════════ + +func tsplRenderEllipse(buf *bytes.Buffer, field PdfmeField, x, y, w, h, dpi int) { + thickness := 1 + if float64(field.BorderWidth) > 0 { + thickness = mmToDots(float64(field.BorderWidth), dpi) + if thickness < 1 { + thickness = 1 + } + } + // ELLIPSE x,y,width,height,thickness + buf.WriteString(fmt.Sprintf("ELLIPSE %d,%d,%d,%d,%d\r\n", x, y, w, h, thickness)) +} + +// ════════════════════════════════════════════════════ +// Image β†’ monochrome BITMAP +// ════════════════════════════════════════════════════ + +func tsplRenderImage(buf *bytes.Buffer, field PdfmeField, row map[string]string, x, y, w, h, dpi int) { + content := "" + if val, ok := row[field.Name]; ok && val != "" { + content = val + } + if content == "" { + for _, v := range field.Variables { + if val, ok := row[v]; ok && val != "" { + content = val + break + } + } + } + if content == "" { + content = field.Content + } + if content == "" { + return + } + + img := decodeImageForTSPL(content) + if img == nil { + log.Printf("[tspl] Could not decode image for field %q", field.Name) + return + } + + // Scale to target size + scaled := scaleImageNearest(img, w, h) + + // Convert to monochrome and write BITMAP command + tsplWriteBitmap(buf, scaled, x, y) +} + +// decodeImageForTSPL decodes an image from data URI, raw base64, URL, or file path. +func decodeImageForTSPL(content string) image.Image { + // Data URI: data:image/png;base64,iVBOR... + if strings.HasPrefix(content, "data:image/") { + idx := strings.Index(content, ",") + if idx < 0 { + return nil + } + data, err := base64.StdEncoding.DecodeString(content[idx+1:]) + if err != nil { + data, err = base64.RawStdEncoding.DecodeString(content[idx+1:]) + if err != nil { + return nil + } + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil + } + return img + } + + // Raw base64 (long string, not a URL or path) + if len(content) > 100 && !strings.HasPrefix(content, "http") && !strings.Contains(content[:20], "/") { + data, err := base64.StdEncoding.DecodeString(content) + if err != nil { + data, err = base64.RawStdEncoding.DecodeString(content) + if err != nil { + return nil + } + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil + } + return img + } + + // HTTP URL + if strings.HasPrefix(content, "http") { + resp, err := http.Get(content) + if err != nil { + return nil + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil + } + img, _, err := image.Decode(resp.Body) + if err != nil { + return nil + } + return img + } + + // Local file + f, err := os.Open(content) + if err != nil { + return nil + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return nil + } + return img +} + +// scaleImageNearest resizes using nearest-neighbor (fast, good for logos/text). +func scaleImageNearest(src image.Image, targetW, targetH int) image.Image { + srcB := src.Bounds() + srcW, srcH := srcB.Dx(), srcB.Dy() + if srcW == targetW && srcH == targetH { + return src + } + dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH)) + for dy := 0; dy < targetH; dy++ { + for dx := 0; dx < targetW; dx++ { + sx := dx * srcW / targetW + sy := dy * srcH / targetH + dst.Set(dx, dy, src.At(srcB.Min.X+sx, srcB.Min.Y+sy)) + } + } + return dst +} + +// tsplWriteBitmap converts an image to monochrome 1-bit and writes TSPL BITMAP command. +// TSPL format: BITMAP x,y,widthBytes,height,mode, +// Bit convention: 1 = black (print), 0 = white (no print). +func tsplWriteBitmap(buf *bytes.Buffer, img image.Image, x, y int) { + bounds := img.Bounds() + imgW, imgH := bounds.Dx(), bounds.Dy() + if imgW == 0 || imgH == 0 { + return + } + + widthBytes := (imgW + 7) / 8 + data := make([]byte, widthBytes*imgH) + + for row := 0; row < imgH; row++ { + for col := 0; col < imgW; col++ { + r, g, b, a := img.At(bounds.Min.X+col, bounds.Min.Y+row).RGBA() + r8, g8, b8, a8 := r>>8, g>>8, b>>8, a>>8 + lum := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8) + if lum < 128 && a8 > 128 { + idx := row*widthBytes + col/8 + bit := uint(7 - col%8) + data[idx] |= 1 << bit + } + } + } + + // BITMAP x,y,widthBytes,height,mode,data + buf.WriteString(fmt.Sprintf("BITMAP %d,%d,%d,%d,0,", x, y, widthBytes, imgH)) + buf.Write(data) + buf.WriteString("\r\n") + + log.Printf("[tspl] BITMAP %dx%d (%d bytes) at %d,%d", imgW, imgH, len(data), x, y) +} + +// ════════════════════════════════════════════════════ +// Full raster mode: render entire page as bitmap +// ════════════════════════════════════════════════════ + +// RenderBulkTSPLRaster renders each page as a full monochrome bitmap. +// Maximum fidelity β€” reproduces all visual elements exactly. +// Larger payload and slower printing than native mode. +func RenderBulkTSPLRaster(schema *PdfmeSchema, rows []map[string]string, dpi int, copies int) []byte { + if dpi <= 0 { + dpi = defaultDPI + } + if copies < 1 { + copies = 1 + } + + var buf bytes.Buffer + + // ESC !R init + buf.Write([]byte{0x1b, 0x21, 0x52}) + buf.WriteString("\r\n") + + labelW := schema.BasePdf.Width + labelH := schema.BasePdf.Height + + buf.WriteString(fmt.Sprintf("SIZE %.1f mm, %.1f mm\r\n", labelW, labelH)) + buf.WriteString("GAP 3 mm, 0 mm\r\n") + buf.WriteString("DIRECTION 0,0\r\n") + buf.WriteString("SPEED 3\r\n") + buf.WriteString("DENSITY 10\r\n") + buf.WriteString("SET CUTTER OFF\r\n") + buf.WriteString("SET TEAR ON\r\n") + + for _, row := range rows { + for pi := range schema.Schemas { + buf.WriteString("CLS\r\n") + img := rasterizePage(schema, row, pi, dpi) + tsplWriteBitmap(&buf, img, 0, 0) + buf.WriteString(fmt.Sprintf("PRINT %d\r\n", copies)) + } + } + + log.Printf("[tspl-raster] Generated %d bytes for %d rows", buf.Len(), len(rows)) + return buf.Bytes() +} + +// rasterizePage renders a single schema page as a monochrome *image.Gray. +func rasterizePage(schema *PdfmeSchema, row map[string]string, pageIndex int, dpi int) image.Image { + w := mmToDots(schema.BasePdf.Width, dpi) + h := mmToDots(schema.BasePdf.Height, dpi) + + img := image.NewGray(image.Rect(0, 0, w, h)) + draw.Draw(img, img.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) + + if pageIndex >= len(schema.Schemas) { + return img + } + + black := color.Gray{Y: 0} + + for _, field := range schema.Schemas[pageIndex] { + if field.Opacity > 0 && field.Opacity < 0.25 { + continue + } + + value := resolveFieldValue(field, row) + fx := mmToDots(field.Position.X, dpi) + fy := mmToDots(field.Position.Y, dpi) + fw := mmToDots(field.Width, dpi) + fh := mmToDots(field.Height, dpi) + + switch field.Type { + case "line": + rasterLine(img, field, fx, fy, fw, fh, black) + case "rectangle": + rasterRect(img, field, fx, fy, fw, fh, black) + case "qrcode": + if value == "" { + value = field.Content + } + if value != "" { + rasterQR(img, value, fx, fy, fw, fh, black) + } + case "image": + rasterImage(img, field, row, fx, fy, fw, fh) + case "text", "multiVariableText": + if value == "" { + value = field.Content + } + if value != "" { + rasterText(img, value, field, fx, fy, fw, fh, black, dpi) + } + } + } + + return img +} + +func rasterLine(img *image.Gray, field PdfmeField, x, y, w, h int, c color.Gray) { + col := field.Color + if col == "" { + col = field.FontColor + } + if col != "" && !isColorDarkEnough(col) { + return + } + + bounds := img.Bounds() + thick := h + if thick < 1 { + thick = 1 + } + + if field.Rotate == 0 { + for dy := 0; dy < thick; dy++ { + py := y + dy + if py < 0 || py >= bounds.Max.Y { + continue + } + for dx := 0; dx < w; dx++ { + px := x + dx + if px >= 0 && px < bounds.Max.X { + img.SetGray(px, py, c) + } + } + } + return + } + + // Rotated line + cx := float64(x) + float64(w)/2 + cy := float64(y) + float64(h)/2 + halfW := float64(w) / 2 + rad := field.Rotate * math.Pi / 180 + + x1 := cx - halfW*math.Cos(rad) + y1 := cy - halfW*math.Sin(rad) + x2 := cx + halfW*math.Cos(rad) + y2 := cy + halfW*math.Sin(rad) + + steps := int(math.Max(math.Abs(x2-x1), math.Abs(y2-y1))) + 1 + for i := 0; i <= steps; i++ { + t := float64(i) / float64(steps) + px := x1 + t*(x2-x1) + py := y1 + t*(y2-y1) + for d := -thick / 2; d <= thick/2; d++ { + ix := int(math.Round(px + float64(d)*math.Sin(rad))) + iy := int(math.Round(py - float64(d)*math.Cos(rad))) + if ix >= 0 && ix < bounds.Max.X && iy >= 0 && iy < bounds.Max.Y { + img.SetGray(ix, iy, c) + } + } + } +} + +// rasterText draws text onto the monochrome raster image using basicfont. +// Uses field.FontSize (in points) to determine scale, with word-wrapping. +func rasterText(img *image.Gray, text string, field PdfmeField, x, y, w, h int, c color.Gray, dpi int) { + col := field.FontColor + if col == "" { + col = field.Color + } + if col != "" && !isColorDarkEnough(col) { + return + } + + // Determine scale from pdfme font size (in points), NOT field height + baseFontH := 13 // basicfont.Face7x13 pixel height + baseFontW := 7 // basicfont.Face7x13 character width + + fontSize := field.FontSize + if fontSize == 0 && field.DynamicFontSize != nil { + fontSize = field.DynamicFontSize.Max + } + if fontSize == 0 { + fontSize = 13 // default ~13pt + } + + targetH := ptToDots(fontSize, dpi) + scale := targetH / baseFontH + if scale < 1 { + scale = 1 + } + if scale > 6 { + scale = 6 + } + + face := basicfont.Face7x13 + bounds := img.Bounds() + charW := baseFontW * scale + + // Word-wrap: split text into lines that fit within field width + maxCharsPerLine := w / charW + if maxCharsPerLine < 1 { + maxCharsPerLine = 1 + } + lines := wrapTextLines(text, maxCharsPerLine) + + lineH := (baseFontH + 2) * scale // line height with spacing + totalTextH := len(lines) * lineH + + // Vertical alignment + startY := y + valign := strings.ToLower(field.VerticalAlignment) + if valign == "middle" || valign == "" { + startY = y + (h-totalTextH)/2 + } else if valign == "bottom" { + startY = y + h - totalTextH + } + + for lineIdx, line := range lines { + if line == "" { + continue + } + lineY := startY + lineIdx*lineH + if lineY+lineH < y || lineY > y+h { + continue // skip lines outside field + } + + // Measure line width + adv := font.MeasureString(face, line) + textW := adv.Ceil() + if textW == 0 { + continue + } + + // Render line at 1x into temp image + tmpImg := image.NewGray(image.Rect(0, 0, textW, baseFontH+2)) + draw.Draw(tmpImg, tmpImg.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) + + d := &font.Drawer{ + Dst: tmpImg, + Src: image.NewUniform(c), + Face: face, + Dot: fixed.P(0, baseFontH), + } + d.DrawString(line) + + scaledW := textW * scale + + // Horizontal alignment + ox := x + align := strings.ToLower(field.Alignment) + if align == "center" { + ox = x + (w-scaledW)/2 + } else if align == "right" { + ox = x + w - scaledW + } + + // Blit scaled pixels, clipped to field bounds + for sy := 0; sy < baseFontH+2; sy++ { + for sx := 0; sx < textW; sx++ { + pixel := tmpImg.GrayAt(sx, sy) + if pixel.Y < 128 { // dark pixel + for dy := 0; dy < scale; dy++ { + for dx := 0; dx < scale; dx++ { + px := ox + sx*scale + dx + py := lineY + sy*scale + dy + if px >= x && px < x+w && py >= y && py < y+h && + px >= 0 && px < bounds.Max.X && py >= 0 && py < bounds.Max.Y { + img.SetGray(px, py, c) + } + } + } + } + } + } + } +} + +// wrapTextLines splits text into lines that fit within maxChars characters. +func wrapTextLines(text string, maxChars int) []string { + if len(text) <= maxChars { + return []string{text} + } + words := strings.Fields(text) + var lines []string + current := "" + for _, word := range words { + if current == "" { + current = word + } else if len(current)+1+len(word) <= maxChars { + current += " " + word + } else { + lines = append(lines, current) + current = word + } + // Break long words + for len(current) > maxChars { + lines = append(lines, current[:maxChars]) + current = current[maxChars:] + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func rasterRect(img *image.Gray, field PdfmeField, x, y, w, h int, c color.Gray) { + fillColor := field.Color + if fillColor == "" { + fillColor = field.BackgroundColor + } + bounds := img.Bounds() + + if fillColor != "" && isColorDarkEnough(fillColor) { + for dy := 0; dy < h; dy++ { + py := y + dy + if py < 0 || py >= bounds.Max.Y { + continue + } + for dx := 0; dx < w; dx++ { + px := x + dx + if px >= 0 && px < bounds.Max.X { + img.SetGray(px, py, c) + } + } + } + } + + bw := int(math.Round(float64(field.BorderWidth))) + if bw > 0 { + for dx := 0; dx < w; dx++ { + px := x + dx + if px < 0 || px >= bounds.Max.X { + continue + } + for t := 0; t < bw; t++ { + if y+t >= 0 && y+t < bounds.Max.Y { + img.SetGray(px, y+t, c) + } + if y+h-1-t >= 0 && y+h-1-t < bounds.Max.Y { + img.SetGray(px, y+h-1-t, c) + } + } + } + for dy := 0; dy < h; dy++ { + py := y + dy + if py < 0 || py >= bounds.Max.Y { + continue + } + for t := 0; t < bw; t++ { + if x+t >= 0 && x+t < bounds.Max.X { + img.SetGray(x+t, py, c) + } + if x+w-1-t >= 0 && x+w-1-t < bounds.Max.X { + img.SetGray(x+w-1-t, py, c) + } + } + } + } +} + +func rasterQR(img *image.Gray, value string, x, y, w, h int, c color.Gray) { + qr, err := goqrcode.New(value, goqrcode.Medium) + if err != nil { + return + } + qr.DisableBorder = true + bmap := qr.Bitmap() + modules := len(bmap) + if modules == 0 { + return + } + + dim := w + if h < w { + dim = h + } + cell := dim / modules + if cell < 1 { + cell = 1 + } + + bounds := img.Bounds() + ox := x + (w-cell*modules)/2 + oy := y + (h-cell*modules)/2 + + for mr := 0; mr < modules; mr++ { + for mc := 0; mc < modules; mc++ { + if !bmap[mr][mc] { + continue + } + for dy := 0; dy < cell; dy++ { + for dx := 0; dx < cell; dx++ { + px := ox + mc*cell + dx + py := oy + mr*cell + dy + if px >= 0 && px < bounds.Max.X && py >= 0 && py < bounds.Max.Y { + img.SetGray(px, py, c) + } + } + } + } + } +} + +func rasterImage(img *image.Gray, field PdfmeField, row map[string]string, x, y, w, h int) { + content := "" + if val, ok := row[field.Name]; ok && val != "" { + content = val + } + if content == "" { + for _, v := range field.Variables { + if val, ok := row[v]; ok && val != "" { + content = val + break + } + } + } + if content == "" { + content = field.Content + } + if content == "" { + return + } + + src := decodeImageForTSPL(content) + if src == nil { + return + } + + bounds := img.Bounds() + srcB := src.Bounds() + srcW, srcH := srcB.Dx(), srcB.Dy() + + for dy := 0; dy < h; dy++ { + for dx := 0; dx < w; dx++ { + px, py := x+dx, y+dy + if px < 0 || px >= bounds.Max.X || py < 0 || py >= bounds.Max.Y { + continue + } + sx := dx * srcW / w + sy := dy * srcH / h + r, g, b, a := src.At(srcB.Min.X+sx, srcB.Min.Y+sy).RGBA() + lum := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(b>>8) + if lum < 128 && (a>>8) > 128 { + img.SetGray(px, py, color.Gray{Y: 0}) + } + } + } +} diff --git a/webview.go b/webview.go new file mode 100644 index 0000000..a8c86d5 --- /dev/null +++ b/webview.go @@ -0,0 +1,102 @@ +//go:build !darwin && !crossbuild + +package main + +import ( + "fmt" + "log" + "sync" + + webview "github.com/webview/webview_go" +) + +var ( + wv webview.WebView + wvMu sync.Mutex + wvDashboardURL string +) + +// initWebview creates the native webview window. +// On Windows/Linux, uses webview_go library. +func initWebview(dashURL string) { + wvMu.Lock() + defer wvMu.Unlock() + wvDashboardURL = dashURL + + w := webview.New(false) + if w == nil { + log.Printf("[webview] failed to create native window β€” browser fallback active") + return + } + + cfg := getConfig() + title := "TSC Bridge" + if cfg.Whitelabel.Name != "" { + title = fmt.Sprintf("TSC Bridge β€” %s", cfg.Whitelabel.Name) + } + + w.SetTitle(title) + w.SetSize(1100, 750, webview.HintNone) + w.Navigate(dashURL) + + wv = w + log.Printf("[webview] native window created: %s", dashURL) +} + +// showDashboard opens or refocuses the native webview window. +func showDashboard(dashURL string) { + wvMu.Lock() + w := wv + wvMu.Unlock() + + if dashURL == "" { + dashURL = wvDashboardURL + } + + if w == nil { + log.Printf("[webview] no native window β€” opening browser: %s", dashURL) + openBrowser(dashURL) + return + } + + w.Dispatch(func() { + w.Navigate(dashURL) + }) +} + +// setWebviewTitle updates the window title with whitelabel branding. +func setWebviewTitle() { + wvMu.Lock() + w := wv + wvMu.Unlock() + + cfg := getConfig() + title := "TSC Bridge" + if cfg.Whitelabel.Name != "" { + title = fmt.Sprintf("TSC Bridge β€” %s", cfg.Whitelabel.Name) + } + + if w != nil { + w.Dispatch(func() { + w.SetTitle(title) + }) + } +} + +// isWebviewActive reports whether a native webview window exists. +func isWebviewActive() bool { + wvMu.Lock() + defer wvMu.Unlock() + return wv != nil +} + +// destroyWebview tears down the native webview window. +func destroyWebview() { + wvMu.Lock() + defer wvMu.Unlock() + if wv != nil { + log.Printf("[webview] destroying native window") + wv.Destroy() + wv = nil + } +} diff --git a/webview_darwin.go b/webview_darwin.go new file mode 100644 index 0000000..46c5574 --- /dev/null +++ b/webview_darwin.go @@ -0,0 +1,223 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa -framework WebKit + +#import +#import +#include + +// ─── Window delegate: hide on close (don't destroy) ─── +@interface BridgeWindowDelegate : NSObject +@end + +@implementation BridgeWindowDelegate +- (BOOL)windowShouldClose:(NSWindow *)sender { + // Hide the window instead of closing it + [sender orderOut:nil]; + // Switch back to menu-bar-only mode (no dock icon) + [[NSApplication sharedApplication] setActivationPolicy:NSApplicationActivationPolicyAccessory]; + return NO; +} +@end + +static NSWindow *bridgeWindow = nil; +static WKWebView *bridgeWebView = nil; +static BridgeWindowDelegate *bridgeDelegate = nil; + +// nativeCreateWindow creates an NSWindow with WKWebView and shows it. +// Safe to call from any thread β€” dispatches to main queue. +void nativeCreateWindow(const char* title, const char* url, int width, int height) { + NSString *nsTitle = [NSString stringWithUTF8String:title]; + NSString *nsURL = [NSString stringWithUTF8String:url]; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (bridgeWindow) { + // Window exists β€” just navigate and show + NSURL *u = [NSURL URLWithString:nsURL]; + [bridgeWebView loadRequest:[NSURLRequest requestWithURL:u]]; + [bridgeWindow setTitle:nsTitle]; + [[NSApplication sharedApplication] setActivationPolicy:NSApplicationActivationPolicyRegular]; + [bridgeWindow makeKeyAndOrderFront:nil]; + [[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; + return; + } + + NSRect frame = NSMakeRect(0, 0, width, height); + NSUInteger style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | + NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable; + + bridgeWindow = [[NSWindow alloc] initWithContentRect:frame + styleMask:style + backing:NSBackingStoreBuffered + defer:NO]; + [bridgeWindow setTitle:nsTitle]; + [bridgeWindow center]; + [bridgeWindow setReleasedWhenClosed:NO]; + + // Set delegate to handle close button (hide instead of destroy) + bridgeDelegate = [[BridgeWindowDelegate alloc] init]; + [bridgeWindow setDelegate:bridgeDelegate]; + + // Create WKWebView + WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; + bridgeWebView = [[WKWebView alloc] initWithFrame:frame configuration:config]; + [bridgeWebView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [bridgeWindow setContentView:bridgeWebView]; + + // Navigate + NSURL *u = [NSURL URLWithString:nsURL]; + [bridgeWebView loadRequest:[NSURLRequest requestWithURL:u]]; + + // Show window and activate app (shows dock icon) + [[NSApplication sharedApplication] setActivationPolicy:NSApplicationActivationPolicyRegular]; + [bridgeWindow makeKeyAndOrderFront:nil]; + [[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; + }); +} + +// nativeShowWindow re-shows a hidden window and optionally navigates. +void nativeShowWindow(const char* url) { + NSString *nsURL = url ? [NSString stringWithUTF8String:url] : nil; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (!bridgeWindow) return; + + if (nsURL) { + NSURL *u = [NSURL URLWithString:nsURL]; + [bridgeWebView loadRequest:[NSURLRequest requestWithURL:u]]; + } + + [[NSApplication sharedApplication] setActivationPolicy:NSApplicationActivationPolicyRegular]; + [bridgeWindow makeKeyAndOrderFront:nil]; + [[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; + }); +} + +// nativeSetWindowTitle updates the window title. +void nativeSetWindowTitle(const char* title) { + NSString *nsTitle = [NSString stringWithUTF8String:title]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (bridgeWindow) { + [bridgeWindow setTitle:nsTitle]; + } + }); +} + +// nativeDestroyWindow closes and releases the window. +void nativeDestroyWindow() { + dispatch_async(dispatch_get_main_queue(), ^{ + if (bridgeWindow) { + [bridgeWindow setDelegate:nil]; + [bridgeWindow close]; + bridgeWindow = nil; + bridgeWebView = nil; + bridgeDelegate = nil; + [[NSApplication sharedApplication] setActivationPolicy:NSApplicationActivationPolicyAccessory]; + } + }); +} + +// nativeIsWindowVisible returns 1 if the window exists and is visible. +int nativeIsWindowVisible() { + if (bridgeWindow && [bridgeWindow isVisible]) return 1; + return 0; +} + +// nativeIsWindowCreated returns 1 if the window has been created (visible or hidden). +int nativeIsWindowCreated() { + return bridgeWindow != nil ? 1 : 0; +} +*/ +import "C" + +import ( + "fmt" + "log" + "sync" + "unsafe" +) + +var ( + wvMu sync.Mutex + wvDashboardURL string + wvCreated bool +) + +// initWebview stores the dashboard URL. On macOS, the native window is created +// lazily in showDashboard() AFTER systray.Run() starts the Cocoa event loop. +// This avoids the NSApplication conflict between webview_go and systray. +func initWebview(dashURL string) { + wvMu.Lock() + defer wvMu.Unlock() + wvDashboardURL = dashURL + log.Printf("[webview] macOS native mode β€” window will be created on first show") +} + +// showDashboard creates or shows the native WKWebView window. +// Creates the window on first call, re-shows on subsequent calls. +func showDashboard(dashURL string) { + wvMu.Lock() + url := dashURL + if url == "" { + url = wvDashboardURL + } + created := wvCreated + wvMu.Unlock() + + if url == "" { + return + } + + cfg := getConfig() + title := "TSC Bridge" + if cfg.Whitelabel.Name != "" { + title = fmt.Sprintf("TSC Bridge β€” %s", cfg.Whitelabel.Name) + } + + cTitle := C.CString(title) + cURL := C.CString(url) + defer C.free(unsafe.Pointer(cTitle)) + defer C.free(unsafe.Pointer(cURL)) + + if !created { + log.Printf("[webview] creating native window: %s", url) + C.nativeCreateWindow(cTitle, cURL, 1100, 750) + wvMu.Lock() + wvCreated = true + wvMu.Unlock() + } else { + log.Printf("[webview] showing native window: %s", url) + C.nativeShowWindow(cURL) + } +} + +// setWebviewTitle updates the window title with whitelabel branding. +func setWebviewTitle() { + cfg := getConfig() + title := "TSC Bridge" + if cfg.Whitelabel.Name != "" { + title = fmt.Sprintf("TSC Bridge β€” %s", cfg.Whitelabel.Name) + } + + cTitle := C.CString(title) + defer C.free(unsafe.Pointer(cTitle)) + C.nativeSetWindowTitle(cTitle) +} + +// isWebviewActive reports whether the native window is visible. +func isWebviewActive() bool { + return C.nativeIsWindowCreated() == 1 +} + +// destroyWebview tears down the native window. +func destroyWebview() { + log.Printf("[webview] destroying native window") + C.nativeDestroyWindow() + wvMu.Lock() + wvCreated = false + wvMu.Unlock() +} diff --git a/webview_stub.go b/webview_stub.go new file mode 100644 index 0000000..ff4e883 --- /dev/null +++ b/webview_stub.go @@ -0,0 +1,46 @@ +//go:build !darwin && crossbuild + +package main + +import ( + "log" + "sync" +) + +var ( + wvMu sync.Mutex + wvDashboardURL string +) + +// initWebview stores the dashboard URL (browser-only fallback for cross-compiled builds). +func initWebview(dashURL string) { + wvMu.Lock() + defer wvMu.Unlock() + wvDashboardURL = dashURL + log.Printf("[webview] cross-build mode β€” browser fallback active") +} + +// showDashboard opens the dashboard in the system browser. +func showDashboard(dashURL string) { + wvMu.Lock() + url := dashURL + if url == "" { + url = wvDashboardURL + } + wvMu.Unlock() + + if url == "" { + return + } + log.Printf("[webview] opening browser: %s", url) + openBrowser(url) +} + +// setWebviewTitle is a no-op in browser mode. +func setWebviewTitle() {} + +// isWebviewActive always returns false in browser mode. +func isWebviewActive() bool { return false } + +// destroyWebview is a no-op in browser mode. +func destroyWebview() {}