mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-01 19:18:28 -04:00
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 <bbedward@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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_-]`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,15 +367,21 @@ Singleton {
|
||||
return false;
|
||||
|
||||
const parts = item.action.split(":");
|
||||
if (parts[0] !== "settings_nav")
|
||||
return false;
|
||||
|
||||
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) {
|
||||
if (!query || query.length === 0)
|
||||
@@ -378,6 +411,9 @@ Singleton {
|
||||
case "color-picker":
|
||||
PopoutService.showColorPicker();
|
||||
return true;
|
||||
case "qr-generator":
|
||||
PopoutService.showQRGeneratorModal();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user