diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 9f2df9a7..39802de8 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -524,5 +524,6 @@ func getCommonCommands() []*cobra.Command { chromaCmd, doctorCmd, configCmd, + dlCmd, } } diff --git a/core/cmd/dms/commands_download.go b/core/cmd/dms/commands_download.go new file mode 100644 index 00000000..e9caeb00 --- /dev/null +++ b/core/cmd/dms/commands_download.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" +) + +var dlOutput string +var dlUserAgent string +var dlTimeout int +var dlIPv4Only bool + +var dlCmd = &cobra.Command{ + Use: "dl ", + Short: "Download a URL to stdout or file", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runDownload(args[0]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, +} + +func init() { + dlCmd.Flags().StringVarP(&dlOutput, "output", "o", "", "Output file path (default: stdout)") + dlCmd.Flags().StringVar(&dlUserAgent, "user-agent", "", "Custom User-Agent header") + dlCmd.Flags().IntVar(&dlTimeout, "timeout", 10, "Request timeout in seconds") + dlCmd.Flags().BoolVarP(&dlIPv4Only, "ipv4", "4", false, "Force IPv4 only") +} + +func runDownload(url string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(dlTimeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("invalid request: %w", err) + } + + switch { + case dlUserAgent != "": + req.Header.Set("User-Agent", dlUserAgent) + default: + req.Header.Set("User-Agent", "DankMaterialShell/1.0 (Linux)") + } + + dialer := &net.Dialer{Timeout: 5 * time.Second} + transport := &http.Transport{DialContext: dialer.DialContext} + if dlIPv4Only { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp4", addr) + } + } + client := &http.Client{Transport: transport} + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + if dlOutput == "" { + _, err = io.Copy(os.Stdout, resp.Body) + return err + } + + if dir := filepath.Dir(dlOutput); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir failed: %w", err) + } + } + + f, err := os.Create(dlOutput) + if err != nil { + return fmt.Errorf("create failed: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + os.Remove(dlOutput) + return fmt.Errorf("write failed: %w", err) + } + + fmt.Println(dlOutput) + return nil +} diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go index 7aa546fa..abeac7de 100644 --- a/core/internal/matugen/matugen.go +++ b/core/internal/matugen/matugen.go @@ -652,16 +652,20 @@ func isDMSGTKActive(configDir string) bool { } func refreshGTK(mode ColorMode) { - exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", "").Run() - exec.Command("gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()).Run() + if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil { + log.Warnf("Failed to reset gtk-theme: %v", err) + } + if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil { + log.Warnf("Failed to set gtk-theme: %v", err) + } } func refreshGTK4() { - output, err := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme").Output() + output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme") if err != nil { return } - current := strings.Trim(strings.TrimSpace(string(output)), "'") + current := strings.Trim(output, "'") var toggle string if current == "prefer-dark" { @@ -670,17 +674,22 @@ func refreshGTK4() { toggle = "prefer-dark" } - if err := exec.Command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", toggle).Run(); err != nil { + if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil { + log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err) return } time.Sleep(50 * time.Millisecond) - exec.Command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", current).Run() + if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil { + log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err) + } } func refreshQt6ct() { confPath := filepath.Join(utils.XDGConfigHome(), "qt6ct", "qt6ct.conf") now := time.Now() - _ = os.Chtimes(confPath, now, now) + if err := os.Chtimes(confPath, now, now); err != nil { + log.Warnf("Failed to touch qt6ct.conf: %v", err) + } } func signalTerminals() { @@ -716,8 +725,8 @@ func syncColorScheme(mode ColorMode) { scheme = "default" } - if err := exec.Command("gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme).Run(); err != nil { - exec.Command("dconf", "write", "/org/gnome/desktop/interface/color-scheme", "'"+scheme+"'").Run() + if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil { + log.Warnf("Failed to sync color-scheme: %v", err) } } @@ -767,7 +776,9 @@ func closestAdwaitaAccent(primaryHex string) string { func syncAccentColor(primaryHex string) { accent := closestAdwaitaAccent(primaryHex) log.Infof("Setting GNOME accent color: %s", accent) - exec.Command("gsettings", "set", "org.gnome.desktop.interface", "accent-color", accent).Run() + if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil { + log.Warnf("Failed to set accent-color: %v", err) + } } type TemplateCheck struct { diff --git a/core/internal/screenshot/theme.go b/core/internal/screenshot/theme.go index 4ad5a687..a2bd5f6f 100644 --- a/core/internal/screenshot/theme.go +++ b/core/internal/screenshot/theme.go @@ -3,11 +3,11 @@ package screenshot import ( "encoding/json" "os" - "os/exec" "path/filepath" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) type ThemeColors struct { @@ -83,12 +83,11 @@ func getColorsFilePath() string { } func isLightMode() bool { - out, err := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme").Output() + scheme, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme") if err != nil { return false } - scheme := strings.TrimSpace(string(out)) switch scheme { case "'prefer-light'", "'default'": return true diff --git a/core/internal/server/freedesktop/actions.go b/core/internal/server/freedesktop/actions.go index c187e2ea..52515d18 100644 --- a/core/internal/server/freedesktop/actions.go +++ b/core/internal/server/freedesktop/actions.go @@ -1,11 +1,9 @@ package freedesktop import ( - "context" "fmt" - "os/exec" - "time" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/godbus/dbus/v5" ) @@ -107,22 +105,8 @@ func (m *Manager) GetUserIconFile(username string) (string, error) { } func (m *Manager) SetIconTheme(iconTheme string) error { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - check := exec.CommandContext(ctx, "gsettings", "writable", "org.gnome.desktop.interface", "icon-theme") - if err := check.Run(); err == nil { - cmd := exec.CommandContext(ctx, "gsettings", "set", "org.gnome.desktop.interface", "icon-theme", iconTheme) - if err := cmd.Run(); err != nil { - return fmt.Errorf("gsettings set failed: %w", err) - } - return nil + if err := utils.GsettingsSet("org.gnome.desktop.interface", "icon-theme", iconTheme); err != nil { + return fmt.Errorf("failed to set icon theme: %w", err) } - - checkDconf := exec.CommandContext(ctx, "dconf", "write", "/org/gnome/desktop/interface/icon-theme", fmt.Sprintf("'%s'", iconTheme)) - if err := checkDconf.Run(); err != nil { - return fmt.Errorf("both gsettings and dconf unavailable or failed: %w", err) - } - return nil } diff --git a/core/internal/utils/gsettings.go b/core/internal/utils/gsettings.go new file mode 100644 index 00000000..cc8d0843 --- /dev/null +++ b/core/internal/utils/gsettings.go @@ -0,0 +1,31 @@ +package utils + +import ( + "fmt" + "os/exec" + "strings" +) + +func dconfPath(schema, key string) string { + return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key +} + +// GsettingsGet reads a gsettings value, falling back to dconf read. +func GsettingsGet(schema, key string) (string, error) { + if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil { + return strings.TrimSpace(string(out)), nil + } + out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output() + if err != nil { + return "", fmt.Errorf("gsettings/dconf get failed for %s %s: %w", schema, key, err) + } + return strings.TrimSpace(string(out)), nil +} + +// GsettingsSet writes a gsettings value, falling back to dconf write. +func GsettingsSet(schema, key, value string) error { + if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil { + return nil + } + return exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run() +} diff --git a/quickshell/Modals/DankLauncherV2/Controller.qml b/quickshell/Modals/DankLauncherV2/Controller.qml index 10de89ca..0a266f95 100644 --- a/quickshell/Modals/DankLauncherV2/Controller.qml +++ b/quickshell/Modals/DankLauncherV2/Controller.qml @@ -853,7 +853,8 @@ Item { icon: "folder", priority: 4, items: fileItems, - collapsed: collapsedSections["files"] || false + collapsed: collapsedSections["files"] || false, + flatStartIndex: 0 }; var newSections; @@ -1284,7 +1285,11 @@ Item { }, actions: [], primaryAction: null, - _diskCached: true + _diskCached: true, + _hName: "", + _hSub: "", + _hRich: false, + _preScored: undefined }); } sectionsData.push({ @@ -1293,7 +1298,8 @@ Item { icon: s.icon || "", priority: s.priority || 0, items: items, - collapsed: false + collapsed: false, + flatStartIndex: 0 }); } return sectionsData; diff --git a/quickshell/Modals/DankLauncherV2/ItemTransformers.js b/quickshell/Modals/DankLauncherV2/ItemTransformers.js index 703da7a5..6472d82c 100644 --- a/quickshell/Modals/DankLauncherV2/ItemTransformers.js +++ b/quickshell/Modals/DankLauncherV2/ItemTransformers.js @@ -1,6 +1,6 @@ .pragma library -.import "ControllerUtils.js" as Utils + .import "ControllerUtils.js" as Utils function transformApp(app, override, defaultActions, primaryActionLabel) { var appId = app.id || app.execString || app.exec || ""; @@ -31,7 +31,11 @@ function transformApp(app, override, defaultActions, primaryActionLabel) { name: primaryActionLabel, icon: "open_in_new", action: "launch" - } + }, + _hName: "", + _hSub: "", + _hRich: false, + _preScored: undefined }; } @@ -66,7 +70,11 @@ function transformCoreApp(app, openLabel) { name: openLabel, icon: "open_in_new", action: "launch" - } + }, + _hName: "", + _hSub: "", + _hRich: false, + _preScored: undefined }; } @@ -100,7 +108,11 @@ function transformBuiltInLauncherItem(item, pluginId, openLabel) { name: openLabel, icon: "open_in_new", action: "execute" - } + }, + _hName: "", + _hSub: "", + _hRich: false, + _preScored: undefined }; } @@ -133,7 +145,11 @@ function transformFileResult(file, openLabel, openFolderLabel, copyPathLabel) { name: openLabel, icon: "open_in_new", action: "open" - } + }, + _hName: "", + _hSub: "", + _hRich: false, + _preScored: undefined }; } @@ -166,7 +182,11 @@ function transformPluginItem(item, pluginId, selectLabel) { name: selectLabel, icon: "check", action: "execute" - } + }, + _hName: "", + _hSub: "", + _hRich: false, + _preScored: undefined }; } @@ -188,7 +208,11 @@ function createCalculatorItem(calc, query, copyLabel) { name: copyLabel, icon: "content_copy", action: "copy" - } + }, + _hName: "", + _hSub: "", + _hRich: false, + _preScored: undefined }; } @@ -218,6 +242,10 @@ function createPluginBrowseItem(pluginId, plugin, trigger, isBuiltIn, isAllowed, name: browseLabel, icon: "arrow_forward", action: "browse_plugin" - } + }, + _hName: "", + _hSub: "", + _hRich: false, + _preScored: undefined }; } diff --git a/quickshell/Modals/DankLauncherV2/Scorer.js b/quickshell/Modals/DankLauncherV2/Scorer.js index 0e917e28..16033f03 100644 --- a/quickshell/Modals/DankLauncherV2/Scorer.js +++ b/quickshell/Modals/DankLauncherV2/Scorer.js @@ -187,7 +187,8 @@ function groupBySection(scoredItems, sectionOrder, sortAlphabetically, maxPerSec icon: sectionOrder[i].icon, priority: sectionOrder[i].priority, items: [], - collapsed: false + collapsed: false, + flatStartIndex: 0 } } @@ -220,6 +221,7 @@ function groupBySection(scoredItems, sectionOrder, sortAlphabetically, maxPerSec function flattenSections(sections) { var flat = [] + flat._sectionBounds = null var bounds = {} for (var i = 0; i < sections.length; i++) { diff --git a/quickshell/Modules/OSD/MediaPlaybackOSD.qml b/quickshell/Modules/OSD/MediaPlaybackOSD.qml index 5b908fb5..a5acdc53 100644 --- a/quickshell/Modules/OSD/MediaPlaybackOSD.qml +++ b/quickshell/Modules/OSD/MediaPlaybackOSD.qml @@ -1,5 +1,4 @@ import QtQuick -import QtQuick.Layouts import QtQuick.Effects import qs.Common import qs.Services @@ -37,9 +36,23 @@ DankOSD { } } + property bool _pendingShow: false + onPlayerChanged: { - if (!player) + if (!player) { + _pendingShow = false; hide(); + } + } + + Connections { + target: TrackArtService + function onLoadingChanged() { + if (!TrackArtService.loading && root._pendingShow) { + root._pendingShow = false; + root.show(); + } + } } Connections { @@ -48,10 +61,20 @@ DankOSD { function handleUpdate() { if (!root.player?.trackTitle) return; - if (SettingsData.osdMediaPlaybackEnabled) { - TrackArtService.loadArtwork(player.trackArtUrl); + if (!SettingsData.osdMediaPlaybackEnabled) + return; + + TrackArtService.loadArtwork(player.trackArtUrl); + + if (!player.trackArtUrl || player.trackArtUrl === "") { root.show(); + return; } + if (!TrackArtService.loading) { + root.show(); + return; + } + root._pendingShow = true; } function onTrackArtUrlChanged() { diff --git a/quickshell/Services/TrackArtService.qml b/quickshell/Services/TrackArtService.qml index 4ea05ef1..cb0e0c2f 100644 --- a/quickshell/Services/TrackArtService.qml +++ b/quickshell/Services/TrackArtService.qml @@ -3,62 +3,62 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick - -import Quickshell.Io import Quickshell.Services.Mpris +import qs.Common Singleton { id: root property string _lastArtUrl: "" property string _bgArtSource: "" - property string activeTrackArtFile: "" + property bool loading: false function loadArtwork(url) { - if (!url || url == "") { + if (!url || url === "") { _bgArtSource = ""; _lastArtUrl = ""; + loading = false; return; } - if (url == _lastArtUrl) + if (url === _lastArtUrl) return; _lastArtUrl = url; - if (url.startsWith("http://") || url.startsWith("https://")) { - const filename = "/tmp/.dankshell/trackart_" + Date.now() + ".jpg"; - activeTrackArtFile = filename; - cleanupProcess.command = ["sh", "-c", "mkdir -p /tmp/.dankshell && find /tmp/.dankshell -name 'trackart_*' ! -name '" + filename.split('/').pop() + "' -delete"]; - cleanupProcess.running = true; + _bgArtSource = ""; + loading = true; - imageDownloader.command = ["curl", "-L", "-s", "--user-agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", "-o", filename, url]; - imageDownloader.targetFile = filename; - imageDownloader.running = true; + if (!url.startsWith("http://") && !url.startsWith("https://")) { + const localUrl = url; + const filePath = url.startsWith("file://") ? url.substring(7) : url; + Proc.runCommand("trackart", ["test", "-f", filePath], (output, exitCode) => { + if (_lastArtUrl !== localUrl) + return; + if (exitCode === 0) + _bgArtSource = localUrl; + loading = false; + }, 200); return; } - // otherwise - _bgArtSource = url; + + const filename = "/tmp/.dankshell/trackart_" + Date.now() + ".jpg"; + activeTrackArtFile = filename; + + Proc.runCommand("trackart_cleanup", ["sh", "-c", "mkdir -p /tmp/.dankshell && find /tmp/.dankshell -name 'trackart_*' ! -name '" + filename.split('/').pop() + "' -delete"], null, 0); + + Proc.runCommand("trackart", ["dms", "dl", "-o", filename, "--user-agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", url], (output, exitCode) => { + const resultPath = output.trim(); + if (resultPath !== filename) + return; + if (exitCode === 0) + _bgArtSource = "file://" + resultPath; + loading = false; + }, 200); } property MprisPlayer activePlayer: MprisController.activePlayer onActivePlayerChanged: { - loadArtwork(activePlayer.trackArtUrl); - } - - Process { - id: imageDownloader - running: false - property string targetFile: "" - - onExited: exitCode => { - if (exitCode === 0 && targetFile) - _bgArtSource = "file://" + targetFile; - } - } - - Process { - id: cleanupProcess - running: false + loadArtwork(activePlayer?.trackArtUrl ?? ""); } } diff --git a/quickshell/Widgets/DankLocationSearch.qml b/quickshell/Widgets/DankLocationSearch.qml index 3d61c5a1..d58538a8 100644 --- a/quickshell/Widgets/DankLocationSearch.qml +++ b/quickshell/Widgets/DankLocationSearch.qml @@ -1,5 +1,4 @@ import QtQuick -import QtQuick.Controls import qs.Common import qs.Widgets @@ -13,7 +12,7 @@ Item { onActiveFocusChanged: { if (activeFocus) { - locationInput.forceActiveFocus() + locationInput.forceActiveFocus(); } } @@ -28,10 +27,10 @@ Item { signal locationSelected(string displayName, string coordinates) function resetSearchState() { - locationSearchTimer.stop() - dropdownHideTimer.stop() - isLoading = false - searchResultsModel.clear() + locationSearchTimer.stop(); + dropdownHideTimer.stop(); + isLoading = false; + searchResultsModel.clear(); } width: parent.width @@ -49,52 +48,49 @@ Item { repeat: false onTriggered: { if (locationInput.text.length > 2) { - searchResultsModel.clear() - root.isLoading = true - const searchLocation = locationInput.text - root.currentSearchText = searchLocation - const encodedLocation = encodeURIComponent(searchLocation) - const curlCommand = `curl -4 -s --connect-timeout 5 --max-time 10 'https://nominatim.openstreetmap.org/search?q=${encodedLocation}&format=json&limit=5&addressdetails=1'` - Proc.runCommand("locationSearch", ["bash", "-c", curlCommand], (output, exitCode) => { - root.isLoading = false + searchResultsModel.clear(); + root.isLoading = true; + const searchLocation = locationInput.text; + root.currentSearchText = searchLocation; + const encodedLocation = encodeURIComponent(searchLocation); + const searchUrl = "https://nominatim.openstreetmap.org/search?q=" + encodedLocation + "&format=json&limit=5&addressdetails=1"; + Proc.runCommand("locationSearch", ["dms", "dl", "-4", "--timeout", "10", searchUrl], (output, exitCode) => { + root.isLoading = false; if (exitCode !== 0) { - searchResultsModel.clear() - return + searchResultsModel.clear(); + return; } if (root.currentSearchText !== locationInput.text) - return - - const raw = output.trim() - searchResultsModel.clear() + return; + const raw = output.trim(); + searchResultsModel.clear(); if (!raw || raw[0] !== "[") { - return + return; } try { - const data = JSON.parse(raw) + const data = JSON.parse(raw); if (data.length === 0) { - return + return; } for (var i = 0; i < Math.min(data.length, 5); i++) { - const location = data[i] + const location = data[i]; if (location.display_name && location.lat && location.lon) { - const parts = location.display_name.split(', ') - let cleanName = parts[0] + const parts = location.display_name.split(', '); + let cleanName = parts[0]; if (parts.length > 1) { - const state = parts[parts.length - 2] + const state = parts[parts.length - 2]; if (state && state !== cleanName) - cleanName += `, ${state}` + cleanName += `, ${state}`; } - const query = `${location.lat},${location.lon}` + const query = `${location.lat},${location.lon}`; searchResultsModel.append({ - "name": cleanName, - "query": query - }) + "name": cleanName, + "query": query + }); } } - } catch (e) { - - } - }) + } catch (e) {} + }); } } } @@ -107,7 +103,7 @@ Item { repeat: false onTriggered: { if (!locationInput.getActiveFocus() && !searchDropdown.hovered) - root.resetSearchState() + root.resetSearchState(); } } @@ -132,23 +128,23 @@ Item { keyNavigationBacktab: root.keyNavigationBacktab onTextEdited: { if (root._internalChange) - return + return; if (getActiveFocus()) { if (text.length > 2) { - root.isLoading = true - locationSearchTimer.restart() + root.isLoading = true; + locationSearchTimer.restart(); } else { - root.resetSearchState() + root.resetSearchState(); } } } onFocusStateChanged: hasFocus => { - if (hasFocus) { - dropdownHideTimer.stop() - } else { - dropdownHideTimer.start() - } - } + if (hasFocus) { + dropdownHideTimer.stop(); + } else { + dropdownHideTimer.start(); + } + } } DankIcon { @@ -187,13 +183,13 @@ Item { anchors.fill: parent hoverEnabled: true onEntered: { - parent.hovered = true - dropdownHideTimer.stop() + parent.hovered = true; + dropdownHideTimer.stop(); } onExited: { - parent.hovered = false + parent.hovered = false; if (!locationInput.getActiveFocus()) - dropdownHideTimer.start() + dropdownHideTimer.start(); } acceptedButtons: Qt.NoButton } @@ -245,14 +241,14 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - root._internalChange = true - const selectedName = model.name - const selectedQuery = model.query - locationInput.text = selectedName - root.locationSelected(selectedName, selectedQuery) - root.resetSearchState() - locationInput.setFocus(false) - root._internalChange = false + root._internalChange = true; + const selectedName = model.name; + const selectedQuery = model.query; + locationInput.text = selectedName; + root.locationSelected(selectedName, selectedQuery); + root.resetSearchState(); + locationInput.setFocus(false); + root._internalChange = false; } } }