From c91cb5d89a19aa0de10189fe99a7e4fc51ad7723 Mon Sep 17 00:00:00 2001 From: bbedward Date: Tue, 14 Jul 2026 10:21:44 -0400 Subject: [PATCH] core/cli: expose QR code CLI port 1.5 (cherry picked from commit e6504add7b54cb0f435ffdb40ab1af20440099e5) --- core/cmd/dms/commands_common.go | 1 + core/cmd/dms/commands_qr.go | 265 ++++++++++++++++++ core/internal/qrcode/qrcode.go | 282 ++++++++++++++++++++ core/internal/server/network/handlers.go | 18 ++ core/internal/server/network/manager.go | 4 + core/internal/server/network/wifi_qrcode.go | 4 +- 6 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 core/cmd/dms/commands_qr.go create mode 100644 core/internal/qrcode/qrcode.go diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index b85918510..48dec44d3 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -759,6 +759,7 @@ func getCommonCommands() []*cobra.Command { greeterCmd, setupCmd, colorCmd, + qrCmd, screenshotCmd, notifyActionCmd, notifyCmd, diff --git a/core/cmd/dms/commands_qr.go b/core/cmd/dms/commands_qr.go new file mode 100644 index 000000000..387763622 --- /dev/null +++ b/core/cmd/dms/commands_qr.go @@ -0,0 +1,265 @@ +package main + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" +) + +var ( + qrEcc string + qrVersion int + qrOutput string + qrStdout bool + qrClipboard bool + qrCopyText bool + qrRender bool + qrNoRender bool + qrInvert bool + qrQuietZone int + qrModuleSize int + qrFg string + qrBg string + qrTransparent bool + qrBorder int + qrShape string + qrLogo string + qrLogoScale int + + qrWifiPassword string + qrWifiSecurity string + qrWifiHidden bool +) + +var qrCmd = &cobra.Command{ + Use: "qr [text]", + Short: "Generate QR codes", + Long: `Generate a QR code from text, stdin, or a WiFi network. + +By default the code is rendered to the terminal when stdout is a TTY, or +written as PNG bytes to stdout when piped. Use flags to also copy to the +clipboard, save a PNG, or tune encoding and colors. + +Input: + dms qr "https://example.com" # encode an argument + echo -n "data" | dms qr # encode stdin + dms qr - # encode stdin explicitly + +Output (combine freely): + dms qr "text" --clipboard # copy PNG image to clipboard + dms qr "text" --copy-text # copy the source text to clipboard + dms qr "text" -o code.png # save a PNG file + dms qr "text" > code.png # PNG to stdout (piped) + dms qr "text" --render # force terminal render + +Encoding & style: + --ecc L|M|Q|H # error correction (default M) + --qr-version 10 # force symbol version (1-40) + --module-size 12 --fg '#000' ... # PNG sizing and colors + --shape circle # round modules + --logo icon.png # center logo (bumps --ecc to H) + --invert # flip colors for light terminals + +WiFi: + dms qr wifi MySSID -p secret # build from an explicit password + dms qr wifi MySSID # pull the saved secret from the shell`, + Args: cobra.ArbitraryArgs, + Run: runQR, +} + +var qrWifiCmd = &cobra.Command{ + Use: "wifi ", + Short: "Generate a WiFi QR code", + Long: `Generate a QR code that joins a WiFi network when scanned. + +With --password the code is built entirely offline. Without it, the saved +credentials are fetched from the running DMS shell (like the network panel).`, + Args: cobra.ExactArgs(1), + Run: runQRWifi, +} + +func init() { + qrCmd.PersistentFlags().StringVar(&qrEcc, "ecc", "", "Error correction level: L, M, Q, H (default M, or H with --logo)") + qrCmd.PersistentFlags().IntVar(&qrVersion, "qr-version", 0, "Force QR symbol version 1-40 (0 = auto)") + qrCmd.PersistentFlags().StringVarP(&qrOutput, "output", "o", "", "Write a PNG to this file") + qrCmd.PersistentFlags().BoolVar(&qrStdout, "stdout", false, "Write PNG bytes to stdout") + qrCmd.PersistentFlags().BoolVar(&qrClipboard, "clipboard", false, "Copy the PNG image to the clipboard") + qrCmd.PersistentFlags().BoolVar(&qrCopyText, "copy-text", false, "Copy the source text to the clipboard") + qrCmd.PersistentFlags().BoolVar(&qrRender, "render", false, "Force terminal rendering") + qrCmd.PersistentFlags().BoolVar(&qrNoRender, "no-render", false, "Never render to the terminal") + qrCmd.PersistentFlags().BoolVar(&qrInvert, "invert", false, "Swap colors (for light terminals)") + qrCmd.PersistentFlags().IntVar(&qrQuietZone, "quiet-zone", 2, "Terminal margin in modules") + qrCmd.PersistentFlags().IntVar(&qrModuleSize, "module-size", 0, "PNG pixels per module (0 = auto)") + qrCmd.PersistentFlags().StringVar(&qrFg, "fg", "", "Dark module color (#RGB or #RRGGBB)") + qrCmd.PersistentFlags().StringVar(&qrBg, "bg", "", "Light module color (#RGB or #RRGGBB)") + qrCmd.PersistentFlags().BoolVar(&qrTransparent, "transparent", false, "Transparent PNG background") + qrCmd.PersistentFlags().IntVar(&qrBorder, "border", -1, "PNG border in pixels (-1 = auto)") + qrCmd.PersistentFlags().StringVar(&qrShape, "shape", "square", "PNG module shape (square, circle)") + qrCmd.PersistentFlags().StringVar(&qrLogo, "logo", "", "Center a PNG/JPEG logo on the PNG output") + qrCmd.PersistentFlags().IntVar(&qrLogoScale, "logo-scale", 0, "Max logo size as 1/N of the code (0 = library default of 5)") + + qrWifiCmd.Flags().StringVarP(&qrWifiPassword, "password", "p", "", "WiFi password (offline build)") + qrWifiCmd.Flags().StringVar(&qrWifiSecurity, "security", "WPA", "Security type (WPA, WEP, nopass)") + qrWifiCmd.Flags().BoolVar(&qrWifiHidden, "hidden", false, "Mark the network as hidden") + + qrCmd.AddCommand(qrWifiCmd) +} + +func runQR(cmd *cobra.Command, args []string) { + text := strings.Join(args, " ") + if text == "" || text == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + fatalf("Error reading stdin: %v", err) + } + text = strings.TrimRight(string(data), "\n") + } + if text == "" { + fatalf("Error: no input (provide text, pipe stdin, or use a subcommand)") + } + emitQR(text) +} + +func runQRWifi(cmd *cobra.Command, args []string) { + ssid := args[0] + if qrWifiPassword != "" || strings.EqualFold(qrWifiSecurity, "nopass") { + emitQR(qrcode.WiFiString(qrWifiSecurity, ssid, qrWifiPassword, qrWifiHidden)) + return + } + + content, err := fetchWifiQRContent(ssid) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintln(os.Stderr, "Hint: pass --password to build the code without the shell.") + os.Exit(1) + } + if qrWifiHidden { + content = strings.TrimSuffix(content, ";") + "H:true;;" + } + emitQR(content) +} + +func emitQR(text string) { + renderTerm := shouldRenderTerminal() + pngToStdout := qrStdout || (!renderTerm && qrOutput == "" && !qrClipboard && !qrCopyText) + + if pngToStdout || qrOutput != "" || qrClipboard { + png, err := qrcode.RenderPNG(text, qrcode.ImageOptions{ + ECC: effectiveEcc(), + Version: qrVersion, + ModuleSize: qrModuleSize, + Fg: qrFg, + Bg: qrBg, + Transparent: qrTransparent, + Border: qrBorder, + Shape: qrShape, + Logo: qrLogo, + LogoScale: qrLogoScale, + }) + if err != nil { + fatalf("Error encoding QR: %v", err) + } + emitPNG(png, pngToStdout) + } + + if qrCopyText { + if err := clipboard.CopyText(text); err != nil { + fatalf("Error copying text: %v", err) + } + } + + if !renderTerm { + return + } + out, err := qrcode.RenderTerminal(text, qrcode.TermOptions{ + ECC: effectiveEcc(), + Version: qrVersion, + QuietZone: qrQuietZone, + Invert: qrInvert, + Fg: qrFg, + Bg: qrBg, + }) + if err != nil { + fatalf("Error rendering QR: %v", err) + } + dst := os.Stdout + if pngToStdout { + dst = os.Stderr + } + fmt.Fprint(dst, out) +} + +func emitPNG(png []byte, toStdout bool) { + if qrOutput != "" { + if err := os.WriteFile(qrOutput, png, 0o644); err != nil { + fatalf("Error writing file: %v", err) + } + fmt.Fprintln(os.Stderr, qrOutput) + } + if toStdout { + os.Stdout.Write(png) + } + if qrClipboard { + if err := clipboard.Copy(png, "image/png"); err != nil { + fatalf("Error copying image: %v", err) + } + } +} + +func shouldRenderTerminal() bool { + switch { + case qrNoRender: + return false + case qrRender: + return true + case qrStdout, qrOutput != "", qrClipboard, qrCopyText: + return false + default: + return isatty.IsTerminal(os.Stdout.Fd()) + } +} + +func effectiveEcc() string { + switch { + case qrEcc != "": + return qrEcc + case qrLogo != "": + return "H" + default: + return "M" + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} + +func fetchWifiQRContent(ssid string) (string, error) { + resp, err := sendServerRequest(models.Request{ + ID: 1, + Method: "network.qrcode-content", + Params: map[string]any{"ssid": ssid}, + }) + if err != nil { + return "", err + } + if resp.Error != "" { + return "", fmt.Errorf("%s", resp.Error) + } + if resp.Result == nil { + return "", fmt.Errorf("empty response") + } + content, ok := (*resp.Result).(string) + if !ok { + return "", fmt.Errorf("unexpected response format") + } + return content, nil +} diff --git a/core/internal/qrcode/qrcode.go b/core/internal/qrcode/qrcode.go new file mode 100644 index 000000000..af3913baa --- /dev/null +++ b/core/internal/qrcode/qrcode.go @@ -0,0 +1,282 @@ +package qrcode + +import ( + "bytes" + "fmt" + "image" + "image/color" + _ "image/jpeg" + _ "image/png" + "os" + "strings" + + qr "github.com/yeqown/go-qrcode/v2" + "github.com/yeqown/go-qrcode/writer/standard" +) + +type TermOptions struct { + ECC string + Version int + QuietZone int + Invert bool + Fg string + Bg string +} + +type ImageOptions struct { + ECC string + Version int + ModuleSize int + Fg string + Bg string + Transparent bool + Border int + Shape string + Logo string + LogoScale int +} + +var wifiEscaper = strings.NewReplacer(`\`, `\\`, `;`, `\;`, `,`, `\,`, `:`, `\:`, `"`, `\"`) + +func WiFiString(security, ssid, password string, hidden bool) string { + if security == "" { + security = "WPA" + } + var b strings.Builder + fmt.Fprintf(&b, "WIFI:T:%s;S:%s;", security, wifiEscaper.Replace(ssid)) + if !strings.EqualFold(security, "nopass") { + fmt.Fprintf(&b, "P:%s;", wifiEscaper.Replace(password)) + } + if hidden { + b.WriteString("H:true;") + } + b.WriteString(";") + return b.String() +} + +// Colors are painted explicitly on both halves of each ▀ cell so polarity +// does not depend on the terminal theme. +func RenderTerminal(text string, opt TermOptions) (string, error) { + fg, err := parseColor(opt.Fg, color.RGBA{A: 255}) + if err != nil { + return "", err + } + bg, err := parseColor(opt.Bg, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + if err != nil { + return "", err + } + if opt.Invert { + fg, bg = bg, fg + } + + mat, err := encode(text, opt.ECC, opt.Version) + if err != nil { + return "", err + } + grid := bitmapWithQuietZone(mat, opt.QuietZone) + + var b strings.Builder + for y := 0; y < len(grid); y += 2 { + for x := range grid[y] { + top := moduleColor(grid[y][x], fg, bg) + bottom := bg + if y+1 < len(grid) { + bottom = moduleColor(grid[y+1][x], fg, bg) + } + fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀", top.R, top.G, top.B, bottom.R, bottom.G, bottom.B) + } + b.WriteString("\x1b[0m\n") + } + return b.String(), nil +} + +func RenderPNG(text string, opt ImageOptions) ([]byte, error) { + encOpts, err := encodeOptions(opt.ECC, opt.Version) + if err != nil { + return nil, err + } + imgOpts, err := imageOptions(opt) + if err != nil { + return nil, err + } + + q, err := qr.NewWith(text, encOpts...) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + w := standard.NewWithWriter(nopCloser{&buf}, imgOpts...) + if err := q.Save(w); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func encodeOptions(ecc string, version int) ([]qr.EncodeOption, error) { + eccOpt, err := eccOption(ecc) + if err != nil { + return nil, err + } + opts := []qr.EncodeOption{eccOpt} + switch { + case version == 0: + case version >= 1 && version <= 40: + opts = append(opts, qr.WithVersion(version)) + default: + return nil, fmt.Errorf("QR version must be 1-40, got %d", version) + } + return opts, nil +} + +func eccOption(level string) (qr.EncodeOption, error) { + switch strings.ToUpper(level) { + case "", "M": + return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionMedium), nil + case "L": + return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionLow), nil + case "Q": + return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionQuart), nil + case "H": + return qr.WithErrorCorrectionLevel(qr.ErrorCorrectionHighest), nil + default: + return nil, fmt.Errorf("invalid error correction level %q (want L, M, Q, or H)", level) + } +} + +func imageOptions(opt ImageOptions) ([]standard.ImageOption, error) { + if opt.ModuleSize < 0 || opt.ModuleSize > 255 { + return nil, fmt.Errorf("module size must be 0-255, got %d", opt.ModuleSize) + } + + opts := []standard.ImageOption{standard.WithBuiltinImageEncoder(standard.PNG_FORMAT)} + if opt.ModuleSize > 0 { + opts = append(opts, standard.WithQRWidth(uint8(opt.ModuleSize))) + } + if opt.Border >= 0 { + opts = append(opts, standard.WithBorderWidth(opt.Border)) + } + + switch { + case opt.Transparent: + opts = append(opts, standard.WithBgTransparent()) + case opt.Bg != "": + c, err := parseColor(opt.Bg, color.RGBA{}) + if err != nil { + return nil, err + } + opts = append(opts, standard.WithBgColor(c)) + } + if opt.Fg != "" { + c, err := parseColor(opt.Fg, color.RGBA{}) + if err != nil { + return nil, err + } + opts = append(opts, standard.WithFgColor(c)) + } + + switch strings.ToLower(opt.Shape) { + case "", "square": + case "circle": + opts = append(opts, standard.WithCircleShape()) + default: + return nil, fmt.Errorf("invalid shape %q (want square or circle)", opt.Shape) + } + + if opt.Logo != "" { + img, err := loadImage(opt.Logo) + if err != nil { + return nil, err + } + opts = append(opts, standard.WithLogoImage(img)) + if opt.LogoScale > 0 { + opts = append(opts, standard.WithLogoSizeMultiplier(opt.LogoScale)) + } + } + return opts, nil +} + +func encode(text, ecc string, version int) (qr.Matrix, error) { + opts, err := encodeOptions(ecc, version) + if err != nil { + return qr.Matrix{}, err + } + q, err := qr.NewWith(text, opts...) + if err != nil { + return qr.Matrix{}, err + } + mw := &matrixWriter{} + if err := q.Save(mw); err != nil { + return qr.Matrix{}, err + } + return mw.mat, nil +} + +func bitmapWithQuietZone(mat qr.Matrix, quiet int) [][]bool { + if quiet < 0 { + quiet = 0 + } + src := mat.Bitmap() + h := len(src) + w := 0 + if h > 0 { + w = len(src[0]) + } + out := make([][]bool, h+quiet*2) + for y := range out { + out[y] = make([]bool, w+quiet*2) + } + for y := range h { + for x := range w { + out[y+quiet][x+quiet] = src[y][x] + } + } + return out +} + +func moduleColor(dark bool, fg, bg color.RGBA) color.RGBA { + if dark { + return fg + } + return bg +} + +func parseColor(hex string, def color.RGBA) (color.RGBA, error) { + hex = strings.TrimPrefix(strings.TrimSpace(hex), "#") + if hex == "" { + return def, nil + } + if len(hex) == 3 { + hex = fmt.Sprintf("%c%c%c%c%c%c", hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]) + } + var r, g, b int + if len(hex) != 6 { + return def, fmt.Errorf("invalid color %q (want #RGB or #RRGGBB)", hex) + } + if _, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b); err != nil { + return def, fmt.Errorf("invalid color %q (want #RGB or #RRGGBB)", hex) + } + return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}, nil +} + +func loadImage(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode %s: %w", path, err) + } + return img, nil +} + +type matrixWriter struct{ mat qr.Matrix } + +func (w *matrixWriter) Write(m qr.Matrix) error { w.mat = m; return nil } +func (w *matrixWriter) Close() error { return nil } + +type nopCloser struct{ *bytes.Buffer } + +func (nopCloser) Close() error { return nil } diff --git a/core/internal/server/network/handlers.go b/core/internal/server/network/handlers.go index 2b3e75e8a..2c023d63e 100644 --- a/core/internal/server/network/handlers.go +++ b/core/internal/server/network/handlers.go @@ -43,6 +43,8 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { handleGetNetworkInfo(conn, req, manager) case "network.qrcode": handleGetNetworkQRCode(conn, req, manager) + case "network.qrcode-content": + handleGetNetworkQRCodeContent(conn, req, manager) case "network.delete-qrcode": handleDeleteQRCode(conn, req, manager) case "network.ethernet.info": @@ -341,6 +343,22 @@ func handleGetNetworkQRCode(conn net.Conn, req models.Request, manager *Manager) models.Respond(conn, req.ID, content) } +func handleGetNetworkQRCodeContent(conn net.Conn, req models.Request, manager *Manager) { + ssid, err := params.String(req.Params, "ssid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + content, err := manager.GetWiFiQRContent(ssid) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, content) +} + func handleDeleteQRCode(conn net.Conn, req models.Request, _ *Manager) { path, err := params.String(req.Params, "path") if err != nil { diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index 7e10d9dd9..8a7806744 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -473,6 +473,10 @@ func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, err return m.backend.GetWiFiNetworkDetails(ssid) } +func (m *Manager) GetWiFiQRContent(ssid string) (string, error) { + return m.backend.GetWiFiQRCodeContent(ssid) +} + func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) { content, err := m.backend.GetWiFiQRCodeContent(ssid) if err != nil { diff --git a/core/internal/server/network/wifi_qrcode.go b/core/internal/server/network/wifi_qrcode.go index 680b6d1c8..874b201e3 100644 --- a/core/internal/server/network/wifi_qrcode.go +++ b/core/internal/server/network/wifi_qrcode.go @@ -5,12 +5,14 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/qrcode" ) const qrCodeTmpPrefix = "/tmp/dank-wifi-qrcode-" func FormatWiFiQRString(securityType, ssid, password string) string { - return fmt.Sprintf("WIFI:T:%s;S:%s;P:%s;;", securityType, ssid, password) + return qrcode.WiFiString(securityType, ssid, password, false) } func qrCodePaths(ssid string) (themed, normal string) {