diff --git a/core/go.mod b/core/go.mod index 895b0347..0db881de 100644 --- a/core/go.mod +++ b/core/go.mod @@ -16,6 +16,8 @@ require ( github.com/sblinch/kdl-go v0.0.0-20260121213736-8b7053306ca6 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + github.com/yeqown/go-qrcode/v2 v2.2.5 + github.com/yeqown/go-qrcode/writer/standard v1.3.0 github.com/yuin/goldmark v1.7.16 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc go.etcd.io/bbolt v1.4.3 @@ -32,15 +34,19 @@ require ( github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/fogleman/gg v1.3.0 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v6 v6.0.0-20260209124918-37866f83c2d3 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/yeqown/reedsolomon v1.0.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect ) diff --git a/core/go.sum b/core/go.sum index ebc72da2..4817d4f3 100644 --- a/core/go.sum +++ b/core/go.sum @@ -58,6 +58,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -75,6 +77,8 @@ github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/ github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -115,6 +119,8 @@ github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3 github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -142,6 +148,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk= +github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw= +github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34= +github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ= +github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= +github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= diff --git a/core/internal/mocks/network/mock_Backend.go b/core/internal/mocks/network/mock_Backend.go index a4db08b9..d7eb2df2 100644 --- a/core/internal/mocks/network/mock_Backend.go +++ b/core/internal/mocks/network/mock_Backend.go @@ -1062,6 +1062,62 @@ func (_c *MockBackend_GetWiFiNetworkDetails_Call) RunAndReturn(run func(string) return _c } +// GetWiFiQRCodeContent provides a mock function with given fields: ssid +func (_m *MockBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + ret := _m.Called(ssid) + + if len(ret) == 0 { + panic("no return value specified for GetWiFiQRCodeContent") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(ssid) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(ssid) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(ssid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockBackend_GetWiFiQRCodeContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWiFiQRCodeContent' +type MockBackend_GetWiFiQRCodeContent_Call struct { + *mock.Call +} + +// GetWiFiQRCodeContent is a helper method to define mock.On call +// - ssid string +func (_e *MockBackend_Expecter) GetWiFiQRCodeContent(ssid interface{}) *MockBackend_GetWiFiQRCodeContent_Call { + return &MockBackend_GetWiFiQRCodeContent_Call{Call: _e.mock.On("GetWiFiQRCodeContent", ssid)} +} + +func (_c *MockBackend_GetWiFiQRCodeContent_Call) Run(run func(ssid string)) *MockBackend_GetWiFiQRCodeContent_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockBackend_GetWiFiQRCodeContent_Call) Return(_a0 string, _a1 error) *MockBackend_GetWiFiQRCodeContent_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockBackend_GetWiFiQRCodeContent_Call) RunAndReturn(run func(string) (string, error)) *MockBackend_GetWiFiQRCodeContent_Call { + _c.Call.Return(run) + return _c +} + // GetWiredConnections provides a mock function with no fields func (_m *MockBackend) GetWiredConnections() ([]network.WiredConnection, error) { ret := _m.Called() diff --git a/core/internal/server/network/backend.go b/core/internal/server/network/backend.go index 3aa53b33..f1bb04d9 100644 --- a/core/internal/server/network/backend.go +++ b/core/internal/server/network/backend.go @@ -10,6 +10,7 @@ type Backend interface { ScanWiFi() error ScanWiFiDevice(device string) error GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) + GetWiFiQRCodeContent(ssid string) (string, error) GetWiFiDevices() []WiFiDevice ConnectWiFi(req ConnectionRequest) error diff --git a/core/internal/server/network/backend_hybrid_iwd_networkd.go b/core/internal/server/network/backend_hybrid_iwd_networkd.go index f578a983..ce883ba2 100644 --- a/core/internal/server/network/backend_hybrid_iwd_networkd.go +++ b/core/internal/server/network/backend_hybrid_iwd_networkd.go @@ -111,6 +111,10 @@ func (b *HybridIwdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkI return b.wifi.GetWiFiNetworkDetails(ssid) } +func (b *HybridIwdNetworkdBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + return b.wifi.GetWiFiQRCodeContent(ssid) +} + func (b *HybridIwdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error { if err := b.wifi.ConnectWiFi(req); err != nil { return err diff --git a/core/internal/server/network/backend_iwd_unimplemented.go b/core/internal/server/network/backend_iwd_unimplemented.go index 7ca1e8df..f5a8b033 100644 --- a/core/internal/server/network/backend_iwd_unimplemented.go +++ b/core/internal/server/network/backend_iwd_unimplemented.go @@ -1,6 +1,9 @@ package network -import "fmt" +import ( + "fmt" + "os" +) func (b *IWDBackend) GetWiredConnections() ([]WiredConnection, error) { return nil, fmt.Errorf("wired connections not supported by iwd") @@ -112,3 +115,19 @@ func (b *IWDBackend) getWiFiDevicesLocked() []WiFiDevice { Networks: b.state.WiFiNetworks, }} } + +func (b *IWDBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + path := iwdConfigPath(ssid) + + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("no saved iwd config for `%s`: %w", ssid, err) + } + + passphrase, err := parseIWDPassphrase(string(data)) + if err != nil { + return "", fmt.Errorf("failed to read passphrase for `%s`: %w", ssid, err) + } + + return FormatWiFiQRString("WPA", ssid, passphrase), nil +} diff --git a/core/internal/server/network/backend_networkd_unimplemented.go b/core/internal/server/network/backend_networkd_unimplemented.go index c90f015c..695c3c5f 100644 --- a/core/internal/server/network/backend_networkd_unimplemented.go +++ b/core/internal/server/network/backend_networkd_unimplemented.go @@ -18,6 +18,10 @@ func (b *SystemdNetworkdBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInf return nil, fmt.Errorf("WiFi details not supported by networkd backend") } +func (b *SystemdNetworkdBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + return "", fmt.Errorf("WiFi QR Code not supported by networkd backend") +} + func (b *SystemdNetworkdBackend) ConnectWiFi(req ConnectionRequest) error { return fmt.Errorf("WiFi connect not supported by networkd backend") } diff --git a/core/internal/server/network/backend_networkmanager_wifi.go b/core/internal/server/network/backend_networkmanager_wifi.go index 1a8ef422..4eeeda0b 100644 --- a/core/internal/server/network/backend_networkmanager_wifi.go +++ b/core/internal/server/network/backend_networkmanager_wifi.go @@ -196,6 +196,65 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo }, nil } +func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error) { + conn, err := b.findConnection(ssid) + if err != nil { + return "", fmt.Errorf("no saved connection for `%s`: %w", ssid, err) + } + + connSettings, err := conn.GetSettings() + if err != nil { + return "", fmt.Errorf("failed to get settings for `%s`: %w", ssid, err) + } + + secSettings, ok := connSettings["802-11-wireless-security"] + if !ok { + return "", fmt.Errorf("network `%s` has no security settings", ssid) + } + + keyMgmt, ok := secSettings["key-mgmt"].(string) + if !ok { + return "", fmt.Errorf("failed to identify security type of network `%s`", ssid) + } + + var securityType string + switch keyMgmt { + case "none": + authAlg, _ := secSettings["auth-alg"].(string) + switch authAlg { + case "open": + securityType = "nopass" + default: + securityType = "WEP" + } + case "ieee8021x": + securityType = "WEP" + default: + securityType = "WPA" + } + + if securityType != "WPA" { + return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType) + } + + secrets, err := conn.GetSecrets("802-11-wireless-security") + if err != nil { + return "", fmt.Errorf("failed to retrieve connection secrets for `%s`: %w", ssid, err) + } + + secSecrets, ok := secrets["802-11-wireless-security"] + if !ok { + return "", fmt.Errorf("failed to retrieve password for `%s`", ssid) + } + + psk, ok := secSecrets["psk"].(string) + if !ok { + return "", fmt.Errorf("failed to retrieve password for `%s`", ssid) + } + + return FormatWiFiQRString(securityType, ssid, psk), nil +} + func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error { devInfo, err := b.getWifiDeviceForConnection(req.Device) if err != nil { diff --git a/core/internal/server/network/handlers.go b/core/internal/server/network/handlers.go index b0a632db..2b3e75e8 100644 --- a/core/internal/server/network/handlers.go +++ b/core/internal/server/network/handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net" + "os" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" @@ -40,6 +41,10 @@ func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { handleSetPreference(conn, req, manager) case "network.info": handleGetNetworkInfo(conn, req, manager) + case "network.qrcode": + handleGetNetworkQRCode(conn, req, manager) + case "network.delete-qrcode": + handleDeleteQRCode(conn, req, manager) case "network.ethernet.info": handleGetWiredNetworkInfo(conn, req, manager) case "network.subscribe": @@ -320,6 +325,42 @@ func handleGetNetworkInfo(conn net.Conn, req models.Request, manager *Manager) { models.Respond(conn, req.ID, network) } +func handleGetNetworkQRCode(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.GetNetworkQRCode(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 { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if !isValidQRCodePath(path) { + models.RespondError(conn, req.ID, "invalid QR code path") + return + } + + if err := os.Remove(path); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "QR code file deleted"}) +} + func handleGetWiredNetworkInfo(conn net.Conn, req models.Request, manager *Manager) { uuid, err := params.String(req.Params, "uuid") if err != nil { diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index bb39e9a5..5c6657ef 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -6,6 +6,8 @@ import ( "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/yeqown/go-qrcode/v2" + "github.com/yeqown/go-qrcode/writer/standard" ) func NewManager() (*Manager, error) { @@ -438,6 +440,43 @@ func (m *Manager) GetNetworkInfoDetailed(ssid string) (*NetworkInfoResponse, err return m.backend.GetWiFiNetworkDetails(ssid) } +func (m *Manager) GetNetworkQRCode(ssid string) ([2]string, error) { + content, err := m.backend.GetWiFiQRCodeContent(ssid) + if err != nil { + return [2]string{}, err + } + + qrc, err := qrcode.New(content) + if err != nil { + return [2]string{}, fmt.Errorf("failed to create QR code for `%s`: %w", ssid, err) + } + + pathThemed, pathNormal := qrCodePaths(ssid) + + wThemed, err := standard.New( + pathThemed, + standard.WithBuiltinImageEncoder(standard.PNG_FORMAT), + standard.WithBgTransparent(), + standard.WithFgColorRGBHex("#ffffff"), + ) + if err != nil { + return [2]string{}, fmt.Errorf("failed to create QR code writer: %w", err) + } + if err := qrc.Save(wThemed); err != nil { + return [2]string{}, fmt.Errorf("failed to save QR code for `%s`: %w", ssid, err) + } + + wNormal, err := standard.New(pathNormal, standard.WithBuiltinImageEncoder(standard.PNG_FORMAT)) + if err != nil { + return [2]string{}, fmt.Errorf("failed to create QR code writer: %w", err) + } + if err := qrc.Save(wNormal); err != nil { + return [2]string{}, fmt.Errorf("failed to save QR code for `%s`: %w", ssid, err) + } + + return [2]string{pathThemed, pathNormal}, nil +} + func (m *Manager) ToggleWiFi() error { enabled, err := m.backend.GetWiFiEnabled() if err != nil { diff --git a/core/internal/server/network/wifi_qrcode.go b/core/internal/server/network/wifi_qrcode.go new file mode 100644 index 00000000..680b6d1c --- /dev/null +++ b/core/internal/server/network/wifi_qrcode.go @@ -0,0 +1,59 @@ +package network + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +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) +} + +func qrCodePaths(ssid string) (themed, normal string) { + safe := sanitizeSSIDForPath(ssid) + themed = fmt.Sprintf("%s%s-themed.png", qrCodeTmpPrefix, safe) + normal = fmt.Sprintf("%s%s-normal.png", qrCodeTmpPrefix, safe) + return +} + +func isValidQRCodePath(path string) bool { + clean := filepath.Clean(path) + return strings.HasPrefix(clean, qrCodeTmpPrefix) && strings.HasSuffix(clean, ".png") +} + +var safePathChar = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + +func sanitizeSSIDForPath(ssid string) string { + return safePathChar.ReplaceAllString(ssid, "_") +} + +var iwdVerbatimSSID = regexp.MustCompile(`^[a-zA-Z0-9 _-]+$`) + +func iwdConfigPath(ssid string) string { + switch { + case iwdVerbatimSSID.MatchString(ssid): + return fmt.Sprintf("/var/lib/iwd/%s.psk", ssid) + default: + return fmt.Sprintf("/var/lib/iwd/=%x.psk", []byte(ssid)) + } +} + +func parseIWDPassphrase(data string) (string, error) { + inSecurity := false + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + switch { + case line == "[Security]": + inSecurity = true + case strings.HasPrefix(line, "["): + inSecurity = false + case inSecurity && strings.HasPrefix(line, "Passphrase="): + return strings.TrimPrefix(line, "Passphrase="), nil + } + } + return "", fmt.Errorf("no passphrase found in iwd config") +} diff --git a/flake.nix b/flake.nix index c77d9dfd..66f8ad73 100644 --- a/flake.nix +++ b/flake.nix @@ -182,6 +182,7 @@ with pkgs; [ go_1_25 + go-mockery_2 gopls delve go-tools diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 059e43ab..cf11ba1e 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -341,6 +341,23 @@ Item { } } + LazyLoader { + id: wifiQRCodeModalLoader + active: false + + Component.onCompleted: { + PopoutService.wifiQRCodeModalLoader = wifiQRCodeModalLoader; + } + + WifiQRCodeModal { + id: wifiQRCodeModalItem + + Component.onCompleted: { + PopoutService.wifiQRCodeModal = wifiQRCodeModalItem; + } + } + } + LazyLoader { id: polkitAuthModalLoader active: false diff --git a/quickshell/Modals/WifiQRCodeModal.qml b/quickshell/Modals/WifiQRCodeModal.qml new file mode 100644 index 00000000..06ffab7c --- /dev/null +++ b/quickshell/Modals/WifiQRCodeModal.qml @@ -0,0 +1,170 @@ +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:wifi-qrcode" + + property bool disablePopupTransparency: true + property string wifiSSID: "" + property string themedQrCodePath: "" + property string normalQrCodePath: "" + modalWidth: 420 + modalHeight: 480 + onBackgroundClicked: hide() + onOpened: { + Qt.callLater(() => { + modalFocusScope.forceActiveFocus(); + contentLoader.item.wifiSSID = wifiSSID; + contentLoader.item.themedQrCodePath = themedQrCodePath; + contentLoader.item.saveBrowserLoader = saveBrowserLoader; + }); + } + + function show(ssid) { + wifiSSID = ssid; + fetchNetworkQRCode(ssid); + } + + function hide() { + if (themedQrCodePath !== "") { + deleteQRCodeFile(themedQrCodePath); + } + if (normalQrCodePath !== "") { + deleteQRCodeFile(normalQrCodePath); + } + close(); + } + + function fetchNetworkQRCode(ssid) { + // TODO: Add loading UI? + + DMSService.sendRequest("network.qrcode", { + ssid: ssid + }, response => { + if (response.error) { + ToastService.showError("Failed to fetch network QR code: ", JSON.stringify(response.error)); + } else if (response.result) { + themedQrCodePath = response.result[0]; + normalQrCodePath = response.result[1]; + open(); + } + }); + } + + function deleteQRCodeFile(path) { + DMSService.sendRequest("network.delete-qrcode", { + path: path + }, response => { + if (response.error) { + ToastService.showError(`Failed to remove QR code at ${path}: `, JSON.stringify(response.error)); + } + }) + } + + 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: `${root.wifiSSID ?? "wifi-qrcode"}.png` + onFileSelected: path => { + const cleanPath = decodeURI(path.toString().replace(/^file:\/\//, '')); + const fileName = cleanPath.split('/').pop(); + const fileUrl = "file://" + cleanPath; + + copyQrCodeProcess.exec(["cp", root.normalQrCodePath, cleanPath, "-f"]) + } + + Process { + id: copyQrCodeProcess + stdout: StdioCollector { + onStreamFinished: { + saveBrowser.close(); + } + } + } + } + } + + content: Component { + Item { + id: theItem + property alias themedQrCodePath: qrCodeImg.source + property var saveBrowserLoader: null + property string wifiSSID: "" + anchors.fill: parent + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + RowLayout { + id: modalTitle + width: parent.width + + StyledText { + text: I18n.tr("WiFi QR code for ") + theItem.wifiSSID + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Bold + Layout.alignment: Qt.AlignLeft + } + + DankActionButton { + iconName: "save" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: { + saveBrowserLoader.active = true; + if (saveBrowserLoader.item) { + saveBrowserLoader.item.open(); + } + } + Layout.alignment: Qt.AlignRight + } + + DankActionButton { + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: root.hide() + Layout.alignment: Qt.AlignRight + } + } + + Image { + id: qrCodeImg + height: parent.height - parent.spacing - modalTitle.height + width: height + anchors.horizontalCenter: parent.horizontalCenter + + MultiEffect { + source: qrCodeImg + anchors.fill: source + colorization: 1.0 + colorizationColor: Theme.primary + } + } + } + } + } +} diff --git a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml index 5301a0ab..defcb6eb 100644 --- a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml @@ -651,6 +651,7 @@ Rectangle { } Rectangle { + id: pinButton anchors.right: parent.right anchors.rightMargin: optionsButton.width + Theme.spacingM + Theme.spacingS anchors.verticalCenter: parent.verticalCenter @@ -711,6 +712,19 @@ Rectangle { } } + DankActionButton { + id: qrCodeButton + visible: modelData.secured && modelData.saved + anchors.right: parent.right + anchors.rightMargin: optionsButton.width + pinWifiRow.width + 3 * Theme.spacingM + Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: "qr_code" + buttonSize: 28 + onClicked: { + PopoutService.showWifiQRCodeModal(modelData.ssid); + } + } + DankRipple { id: wifiRipple cornerRadius: parent.radius @@ -719,7 +733,7 @@ Rectangle { MouseArea { id: networkMouseArea anchors.fill: parent - anchors.rightMargin: optionsButton.width + Theme.spacingM + Theme.spacingS + pinWifiRow.width + Theme.spacingS * 4 + anchors.rightMargin: optionsButton.width + pinWifiRow.width + (qrCodeButton.visible ? qrCodeButton.width : 0) + Theme.spacingS * 5 + Theme.spacingM hoverEnabled: true cursorShape: Qt.PointingHandCursor onPressed: mouse => wifiRipple.trigger(mouse.x, mouse.y) diff --git a/quickshell/Modules/Settings/NetworkTab.qml b/quickshell/Modules/Settings/NetworkTab.qml index fd84aedb..1662328f 100644 --- a/quickshell/Modules/Settings/NetworkTab.qml +++ b/quickshell/Modules/Settings/NetworkTab.qml @@ -1281,6 +1281,15 @@ Item { } } + DankActionButton { + iconName: "qr_code" + buttonSize: 28 + visible: modelData.secured && modelData.saved + onClicked: { + PopoutService.showWifiQRCodeModal(modelData.ssid); + } + } + DankActionButton { iconName: isPinned ? "push_pin" : "push_pin" buttonSize: 28 diff --git a/quickshell/Services/PopoutService.qml b/quickshell/Services/PopoutService.qml index d14522a1..9a8fc699 100644 --- a/quickshell/Services/PopoutService.qml +++ b/quickshell/Services/PopoutService.qml @@ -31,6 +31,8 @@ Singleton { property var notificationModal: null property var wifiPasswordModal: null property var wifiPasswordModalLoader: null + property var wifiQRCodeModal: null + property var wifiQRCodeModalLoader: null property var polkitAuthModal: null property var polkitAuthModalLoader: null property var bluetoothPairingModal: null @@ -581,6 +583,13 @@ Singleton { wifiPasswordModal.show(ssid); } + function showWifiQRCodeModal(ssid) { + if (wifiQRCodeModalLoader) + wifiQRCodeModalLoader.active = true; + if (wifiQRCodeModal) + wifiQRCodeModal.show(ssid); + } + function showHiddenNetworkModal() { if (wifiPasswordModalLoader) wifiPasswordModalLoader.active = true;