From 0033e3f0e0fab91658056d28b31e6d96b8747f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Thi=E1=BB=87n=20L=E1=BB=99c?= Date: Fri, 31 Jul 2026 03:33:36 +0700 Subject: [PATCH] feat: add QR Generator launcher (#2941) * feat: add QR Generator modal with auto-generate on input debounce - QRGeneratorModal with dual-buffer sequential fade animation - Auto-generate QR code on typing stop (200ms debounce) - Clear text button on input field - Escape key closes modal - Register modal in PopoutService and AppSearchService - Go backend for text QR code generation * fix: add QR Generator toggle to Launcher DMS settings * qr: also add qrg built in launcher plugin --------- Co-authored-by: bbedward --- core/internal/server/network/handlers.go | 18 ++ core/internal/server/network/qr_generator.go | 64 +++++ core/internal/server/network/wifi_qrcode.go | 2 +- quickshell/DMSShell.qml | 17 ++ quickshell/Modals/QRGeneratorModal.qml | 285 +++++++++++++++++++ quickshell/Modules/Settings/LauncherTab.qml | 2 +- quickshell/Services/AppSearchService.qml | 54 +++- quickshell/Services/PopoutService.qml | 9 + 8 files changed, 440 insertions(+), 11 deletions(-) create mode 100644 core/internal/server/network/qr_generator.go create mode 100644 quickshell/Modals/QRGeneratorModal.qml diff --git a/core/internal/server/network/handlers.go b/core/internal/server/network/handlers.go index f588f9c2c..e5d97b1e0 100644 --- a/core/internal/server/network/handlers.go +++ b/core/internal/server/network/handlers.go @@ -43,6 +43,8 @@ func HandleRequest(conn *models.Conn, req models.Request, manager *Manager) { handleGetNetworkQRCode(conn, req, manager) case "network.qrcode-content": handleGetNetworkQRCodeContent(conn, req, manager) + case "network.generate-qrcode": + handleGenerateQRCode(conn, req) case "network.delete-qrcode": handleDeleteQRCode(conn, req, manager) case "network.ethernet.info": @@ -365,6 +367,22 @@ func handleGetNetworkQRCodeContent(conn *models.Conn, req models.Request, manage models.Respond(conn, req.ID, content) } +func handleGenerateQRCode(conn *models.Conn, req models.Request) { + text, err := params.String(req.Params, "text") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + paths, err := generateTextQRCode(text) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, paths) +} + func handleDeleteQRCode(conn *models.Conn, req models.Request, _ *Manager) { path, err := params.String(req.Params, "path") if err != nil { diff --git a/core/internal/server/network/qr_generator.go b/core/internal/server/network/qr_generator.go new file mode 100644 index 000000000..58ca58993 --- /dev/null +++ b/core/internal/server/network/qr_generator.go @@ -0,0 +1,64 @@ +package network + +import ( + "crypto/sha256" + "fmt" + "os" + "time" + + "github.com/yeqown/go-qrcode/v2" + "github.com/yeqown/go-qrcode/writer/standard" +) + +const textQRCodeTmpPrefix = "/tmp/dank-text-qrcode-" + +func generateTextQRCode(text string) ([2]string, error) { + qrc, err := qrcode.New(text) + if err != nil { + return [2]string{}, fmt.Errorf("failed to create QR code for text: %w", err) + } + + pathThemed, pathNormal := textQRCodePaths(text) + + if err := saveQRCodePNG(qrc, pathThemed, standard.WithBgTransparent(), standard.WithFgColorRGBHex("#ffffff")); err != nil { + return [2]string{}, err + } + if err := saveQRCodePNG(qrc, pathNormal); err != nil { + return [2]string{}, err + } + + return [2]string{pathThemed, pathNormal}, nil +} + +// Write to a temp file and rename into place so the shell's Image never +// observes a partially written PNG. +func saveQRCodePNG(qrc *qrcode.QRCode, path string, opts ...standard.ImageOption) error { + tmpPath := path + ".tmp" + opts = append(opts, standard.WithBuiltinImageEncoder(standard.PNG_FORMAT)) + + w, err := standard.New(tmpPath, opts...) + if err != nil { + return fmt.Errorf("failed to create QR code writer: %w", err) + } + if err := qrc.Save(w); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to save QR code: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to move QR code into place: %w", err) + } + return nil +} + +// Paths are unique per generation, not per text: the library's mask selection +// is non-deterministic, so regenerating the same text produces different +// bytes, and reusing a path lets the shell's URL-keyed pixmap cache serve a +// stale pattern over the new file. +func textQRCodePaths(text string) (themed, normal string) { + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(text)))[:8] + nonce := time.Now().UnixNano() + themed = fmt.Sprintf("%s%s-%d-themed.png", textQRCodeTmpPrefix, hash, nonce) + normal = fmt.Sprintf("%s%s-%d-normal.png", textQRCodeTmpPrefix, hash, nonce) + return +} diff --git a/core/internal/server/network/wifi_qrcode.go b/core/internal/server/network/wifi_qrcode.go index 874b201e3..9d8c372ac 100644 --- a/core/internal/server/network/wifi_qrcode.go +++ b/core/internal/server/network/wifi_qrcode.go @@ -24,7 +24,7 @@ func qrCodePaths(ssid string) (themed, normal string) { func isValidQRCodePath(path string) bool { clean := filepath.Clean(path) - return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png") + return (strings.HasPrefix(clean, qrCodeTmpPrefix) || strings.HasPrefix(clean, textQRCodeTmpPrefix)) && strings.HasSuffix(clean, ".png") } var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`) diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index da78a151a..a768cada1 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -445,6 +445,23 @@ Item { } } + LazyLoader { + id: qrGeneratorModalLoader + active: false + + Component.onCompleted: { + PopoutService.qrGeneratorModalLoader = qrGeneratorModalLoader; + } + + QRGeneratorModal { + id: qrGeneratorModalItem + + Component.onCompleted: { + PopoutService.qrGeneratorModal = qrGeneratorModalItem; + } + } + } + LazyLoader { id: polkitAuthModalLoader active: false diff --git a/quickshell/Modals/QRGeneratorModal.qml b/quickshell/Modals/QRGeneratorModal.qml new file mode 100644 index 000000000..76bd9f5e3 --- /dev/null +++ b/quickshell/Modals/QRGeneratorModal.qml @@ -0,0 +1,285 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Effects +import Quickshell +import Quickshell.Io +import qs.Modals.Common +import qs.Modals.FileBrowser +import qs.Common +import qs.Services +import qs.Widgets + +DankModal { + id: root + visible: false + layerNamespace: "dms:qr-generator" + + property bool disablePopupTransparency: true + property bool generating: false + property string themedQrCodePath: "" + property string normalQrCodePath: "" + property string initialText: "" + property string _pendingPayload: "" + property string _generatingPayload: "" + property string _displayedPayload: "" + modalWidth: 420 + modalHeight: 440 + onBackgroundClicked: hide() + onOpened: { + Qt.callLater(() => { + modalFocusScope.forceActiveFocus(); + const item = contentLoader.item; + if (!item) + return; + item.saveBrowserLoader = saveBrowserLoader; + if (item.textInput) { + item.textInput.text = initialText; + item.textInput.forceActiveFocus(); + } + if (initialText.length > 0) { + _pendingPayload = initialText; + generateQR(initialText); + } + }); + } + + function show(text) { + generating = false; + initialText = text || ""; + _pendingPayload = ""; + _generatingPayload = ""; + _displayedPayload = ""; + themedQrCodePath = ""; + normalQrCodePath = ""; + open(); + } + + function hide() { + deleteQrCodeFiles(themedQrCodePath, normalQrCodePath); + themedQrCodePath = ""; + normalQrCodePath = ""; + close(); + } + + function deleteQrCodeFiles(themed, normal) { + if (themed.length > 0) + DMSService.sendRequest("network.delete-qrcode", { + path: themed + }); + if (normal.length > 0) + DMSService.sendRequest("network.delete-qrcode", { + path: normal + }); + } + + Timer { + id: genTimer + interval: 200 + repeat: false + onTriggered: root.generateQR(root._pendingPayload) + } + + function generateQR(text) { + const trimmed = (text || "").trim(); + if (trimmed.length === 0 || trimmed === _displayedPayload || generating) + return; + + _generatingPayload = trimmed; + generating = true; + + DMSService.sendRequest("network.generate-qrcode", { + text: trimmed + }, response => { + root.generating = false; + if (response.error) { + ToastService.showError(I18n.tr("Failed to generate QR code: %1").arg(JSON.stringify(response.error))); + return; + } + if (!response.result) + return; + if (root._generatingPayload !== root._pendingPayload.trim()) { + root.deleteQrCodeFiles(response.result[0], response.result[1]); + genTimer.restart(); + return; + } + + const oldThemed = root.themedQrCodePath; + const oldNormal = root.normalQrCodePath; + root._displayedPayload = root._generatingPayload; + root.themedQrCodePath = response.result[0]; + root.normalQrCodePath = response.result[1]; + root.deleteQrCodeFiles(oldThemed, oldNormal); + }); + } + + function onTextChanged(text) { + _pendingPayload = text; + const trimmed = text.trim(); + if (trimmed.length === 0 || trimmed === _displayedPayload) { + genTimer.stop(); + return; + } + genTimer.restart(); + } + + LazyLoader { + id: saveBrowserLoader + active: false + + FileBrowserSurfaceModal { + id: saveBrowser + + browserTitle: I18n.tr("Save QR Code") + browserIcon: "qr_code" + browserType: "default" + fileExtensions: ["*.png"] + allowStacking: true + saveMode: true + defaultFileName: "qrcode.png" + onFileSelected: path => { + const cleanPath = decodeURI(path.toString().replace(/^file:\/\//, '')); + copyQrCodeProcess.exec(["cp", "-f", root.normalQrCodePath, cleanPath]); + } + + Process { + id: copyQrCodeProcess + + stdout: StdioCollector { + onStreamFinished: { + saveBrowser.close(); + } + } + } + } + } + + content: Component { + Item { + id: contentItem + + property alias textInput: textInput + property var saveBrowserLoader: null + + anchors.fill: parent + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + RowLayout { + id: modalTitle + width: parent.width + + StyledText { + text: I18n.tr("QR Generator") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Bold + Layout.alignment: Qt.AlignLeft + Layout.fillWidth: true + } + + DankActionButton { + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: root.hide() + Layout.alignment: Qt.AlignRight + } + } + + DankTextField { + id: textInput + width: parent.width + placeholderText: I18n.tr("Enter text to encode") + showClearButton: true + focus: true + onTextEdited: root.onTextChanged(text) + Keys.onEscapePressed: event => { + event.accepted = true; + root.hide(); + } + } + + Item { + id: qrContainer + height: Math.min(parent.height - parent.spacing - modalTitle.height - textInput.height - parent.spacing * 4, 260) + width: height + anchors.horizontalCenter: parent.horizontalCenter + opacity: 1 + + Behavior on opacity { + NumberAnimation { + duration: 80 + easing.type: Easing.OutCubic + } + } + + Image { + id: qrCodeImg + anchors.fill: parent + source: root.themedQrCodePath + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + visible: false + + onSourceChanged: qrContainer.opacity = 0 + onStatusChanged: { + if (status === Image.Ready) + qrContainer.opacity = 1; + } + } + + MultiEffect { + source: qrCodeImg + anchors.fill: qrCodeImg + colorization: 1.0 + colorizationColor: Theme.primary + } + } + + RowLayout { + width: parent.width + visible: root.themedQrCodePath.length > 0 + Layout.alignment: Qt.AlignHCenter + + Item { + Layout.fillWidth: true + } + + DankButton { + text: I18n.tr("Save") + iconName: "save" + backgroundColor: Theme.surfaceContainer + textColor: Theme.surfaceText + onClicked: { + contentItem.saveBrowserLoader.active = true; + if (contentItem.saveBrowserLoader.item) { + contentItem.saveBrowserLoader.item.open(); + } + } + } + + DankButton { + text: I18n.tr("Copy") + iconName: "content_copy" + backgroundColor: Theme.primary + textColor: Theme.onPrimary + onClicked: { + if (root.normalQrCodePath.length > 0) + DMSService.sendRequest("clipboard.copyFile", { + filePath: root.normalQrCodePath + }); + } + } + + Item { + Layout.fillWidth: true + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/LauncherTab.qml b/quickshell/Modules/Settings/LauncherTab.qml index 4c52393d7..cc560c2a6 100644 --- a/quickshell/Modules/Settings/LauncherTab.qml +++ b/quickshell/Modules/Settings/LauncherTab.qml @@ -822,7 +822,7 @@ Item { spacing: Theme.spacingS Repeater { - model: ["dms_settings", "dms_notepad", "dms_sysmon", "dms_settings_search", "dms_clipboard_search", "dms_colorpicker"] + model: ["dms_settings", "dms_notepad", "dms_sysmon", "dms_settings_search", "dms_clipboard_search", "dms_colorpicker", "dms_qr_generator"] delegate: Rectangle { id: pluginDelegate diff --git a/quickshell/Services/AppSearchService.qml b/quickshell/Services/AppSearchService.qml index 22c2a5073..bdf8b7b1a 100644 --- a/quickshell/Services/AppSearchService.qml +++ b/quickshell/Services/AppSearchService.qml @@ -210,6 +210,19 @@ Singleton { defaultTrigger: "", isLauncher: false }, + "dms_qr_generator": { + id: "dms_qr_generator", + name: I18n.tr("QR Generator"), + icon: "svg+corner:" + dmsLogoPath + "|qr_code", + cornerIcon: "qr_code", + comment: "DMS", + action: "ipc:qr-generator", + categories: ["Utility"], + defaultTrigger: "qrg", + isLauncher: true, + viewMode: "list", + viewModeEnforced: true + }, "dms_settings_search": { id: "dms_settings_search", name: I18n.tr("Settings Search"), @@ -244,7 +257,7 @@ Singleton { if (!SettingsData.getBuiltInPluginSetting(pluginId, "enabled", true)) continue; const plugin = builtInPlugins[pluginId]; - if (plugin.isLauncher) + if (plugin.isLauncher && !plugin.action) continue; apps.push({ name: plugin.name, @@ -309,6 +322,20 @@ Singleton { })); } + if (pluginId === "dms_qr_generator") { + const text = (query || "").toString().trim(); + return [ + { + name: text.length > 0 ? text : I18n.tr("Enter text to encode"), + icon: "material:qr_code", + comment: I18n.tr("QR Generator"), + action: "qr_generate:" + text, + isBuiltInLauncher: true, + builtInPluginId: pluginId + } + ]; + } + if (pluginId !== "dms_settings_search") return []; @@ -340,14 +367,20 @@ Singleton { return false; const parts = item.action.split(":"); - if (parts[0] !== "settings_nav") - return false; - - const tabIndex = parseInt(parts[1]); - const section = parts.slice(2).join(":"); - SettingsSearchService.navigateToSection(section); - PopoutService.openSettingsWithTabIndex(tabIndex); - return true; + switch (parts[0]) { + case "settings_nav": + { + const tabIndex = parseInt(parts[1]); + const section = parts.slice(2).join(":"); + SettingsSearchService.navigateToSection(section); + PopoutService.openSettingsWithTabIndex(tabIndex); + return true; + } + case "qr_generate": + PopoutService.showQRGeneratorModal(parts.slice(1).join(":")); + return true; + } + return false; } function getCoreApps(query) { @@ -378,6 +411,9 @@ Singleton { case "color-picker": PopoutService.showColorPicker(); return true; + case "qr-generator": + PopoutService.showQRGeneratorModal(); + return true; } return false; } diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml index d0326d8d7..c47686649 100644 --- a/quickshell/Services/PopoutService.qml +++ b/quickshell/Services/PopoutService.qml @@ -47,6 +47,8 @@ Singleton { property var wifiPasswordModalLoader: null property var wifiQRCodeModal: null property var wifiQRCodeModalLoader: null + property var qrGeneratorModal: null + property var qrGeneratorModalLoader: null property var polkitAuthModal: null property var polkitAuthModalLoader: null property var bluetoothPairingModal: null @@ -891,6 +893,13 @@ Singleton { wifiQRCodeModal.show(ssid); } + function showQRGeneratorModal(initialText) { + if (qrGeneratorModalLoader) + qrGeneratorModalLoader.active = true; + if (qrGeneratorModal) + qrGeneratorModal.show(initialText || ""); + } + function showHiddenNetworkModal() { if (wifiPasswordModalLoader) wifiPasswordModalLoader.active = true;