From 1b6d567451bcd9be32fd2352daa84b95f705c52f Mon Sep 17 00:00:00 2001 From: Jon Rogers <67245+devnullvoid@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:41:37 -0500 Subject: [PATCH] feat: Add browser picker modal for URL handling (#815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add browser picker for opening URLs - Introduce a QML modal allowing users to select a web browser to open a given URL. - Add a CLI command `dms open ` that sends a `browser.open` request to the DMS server. - Implement server‑side Browser manager, request handling, and subscription handling to propagate open events to clients. - Extend router and server initialization to register the new “browser” capability and include it in advertised capabilities. - Expose `openUrlRequested` signal in DMSService.qml and connect it to the modal for seamless UI activation. - Add a desktop entry for the Browser Picker and update the active subscriptions list to include the browser service. * fix(browser-picker): resolve QML errors in BrowserPickerModal and DMSShell * fix(browser-picker): fix socket discovery in dms open command * feat: add keyboard navigation and dynamic model to browser picker - Replace the static browsers array with a ListModel built from AppSearchService, ensuring robust iteration and future‑proofing of the browser list. - Introduce keyboard navigation (arrow keys and Enter) using selectedIndex and gridColumns, allowing users to select a browser without a mouse. - Reset URL, selected index, and navigation flag when the modal closes to avoid stale state. - Redesign the grid layout to compute cell width from columns, improve focus handling, and use AppLauncherGridDelegate for a consistent UI. - Enhance delegate behavior to update selection on hover and reset keyboard navigation state appropriately. * feat: add searchable list/grid view to browser picker - Introduce view mode setting (list or grid) saved in SettingsData for persistent user preference - Add search field with real‑time filtering to quickly locate a browser by name - Sort browsers by usage frequency from AppUsageHistoryData, falling back to alphabetical order - Provide UI toggle buttons to switch between list and grid layouts, updating the stored setting - Adjust keyboard navigation logic to support both layouts and improve focus handling - Refine modal dimensions and header layout for better visual consistency - Record launched browser usage to keep usage rankings up‑to‑date. * feat(browser-picker): improve UX with search, view persistence, and usage tracking Enhance BrowserPickerModal to match AppLauncher design and functionality: UI/UX Improvements: - Add search bar with DankTextField for filtering browsers - Move view mode switcher (list/grid) to header next to title - Persist view mode preference to SettingsData.browserPickerViewMode - Match AppLauncher dimensions (520x500) - Add proper spacing between list items - Improve URL display with truncation (single line, elide middle) - Remove redundant close button Functionality: - Implement separate browser usage tracking in SettingsData.browserUsageHistory - Sort browsers by most recently used (independent from app launcher stats) - Add keyboard navigation auto-scrolling for list and grid views - Track usage count, last used timestamp, and browser name - Filter browsers by search query Technical: - Add ensureVisible() functions to DankListView and DankGridView - Store browser usage with count, lastUsed, and name fields - Update browser list reactively on search query changes * feat(browser-picker): use appLauncherGridColumns setting for grid layout Make browser picker grid view respect the same column setting as the app launcher for consistent UI across both components. * refactor: make browser picker extensible for any MIME type/category Refactor browser picker into a generic, reusable application picker system that can handle any MIME type or application category, similar to Junction. This addresses the maintainer feedback about making the functionality "as re-usable as possible." Frontend (QML): - Create generic AppPickerModal component (~450 lines) - Configurable filtering by application categories - Customizable title, view modes, and usage tracking - Emits applicationSelected signal for flexibility - Refactor BrowserPickerModal as thin wrapper (473 → 46 lines) - Demonstrates how to create specialized pickers - Maintains all existing browser picker functionality Backend (Go): - Rename browser package to apppicker for clarity - Enhance event model to support: - MIME types (for future file associations) - Application categories (WebBrowser, Office, Graphics, etc.) - Request types (url, file, custom) - Maintain backward compatibility with browser.open method - Add new apppicker.open method for generic usage CLI: - Rename commands_browser.go to commands_open.go - Add extensibility flags: --mime/-m: Filter by MIME type --category/-c: Filter by category (repeatable) --type/-t: Specify request type - Examples: dms open file.pdf --category Office dms open image.png --category Graphics DMSService: - Add appPickerRequested signal for generic events - Smart routing between URL and generic app picker events - Fully backward compatible Benefits: - Easy to create new pickers (~15 lines of wrapper code) - Foundation for universal file handling system - Consistent UX across all picker types - Ready for MIME type associations Future extensions: - PDF picker, image viewer picker, text editor picker - Default application management - File association UI in settings - Multi-MIME type desktop file integration * fix(cli): remove all shorthands from open command flags for consistency Remove shorthands from --mime, --category, and --type flags to maintain consistency and avoid conflicts with global flags. Flags now (all long-form only): - --category: Application categories - --mime: MIME type - --type: Request type Global flags still available: - --config, -c: Config directory path * style: apply gofmt formatting to apppicker files Fix formatting issues caught by CI: - Align struct field spacing in OpenEvent - Align variable declaration spacing - Fix Args field alignment in cobra.Command * feat(apppicker): add generic file opener with auto MIME detection Implements Junction-style generic file opening capabilities: **Backend (Go):** - Enhanced CLI to parse file:// URIs and extract file paths - Auto-detect MIME types from file extensions using Go's mime package - Auto-map MIME types to desktop categories: - Images → Graphics, Viewer - Videos → Video, AudioVideo - Audio → Audio, AudioVideo - Text → TextEditor, Office (or WebBrowser for HTML) - PDFs → Office, Viewer - Office docs → Office - Archives → Archiving, Utility - Added debug logging to CLI and server handler for troubleshooting **Frontend (QML):** - Added generic AppPickerModal (filePickerModal) for file selection - Connected to DMSService.appPickerRequested signal - Implemented onApplicationSelected handler with desktop entry field code support: - %f/%F for file paths - %u/%U for file:// URIs - Fallback to appending path if no field codes - Separate usage tracking: filePickerUsageHistory **Desktop Integration:** - Updated dms-open.desktop to handle x-scheme-handler/file - Changed category from Network;WebBrowser to Utility (more generic) - Added text/html to MIME types **Usage:** Set DMS as default for specific MIME types in ~/.config/mimeapps.list: text/plain=dms-open.desktop image/png=dms-open.desktop application/pdf=dms-open.desktop Then use: xdg-open file.txt xdg-open image.png dms open document.pdf The picker will show appropriate apps based on auto-detected categories. Related to #815 * fix: resolve relative path handling by converting to absolute paths - Convert file:// URIs to absolute filesystem paths for reliable file resolution - Convert plain local file arguments to absolute paths to ensure consistent processing - Update log messages to display absolute paths, improving traceability - Retain request type detection while using absolute path extensions for MIME type inference * feat(app-picker): add Tab key view toggle and fix targetData binding - Add Tab key to toggle between grid and list views for better keyboard UX - Fix bug where targetData binding broke after first modal close - Removed targetData reset from onDialogClosed - Parent components (BrowserPickerModal, filePickerModal) now manage targetData - Fixes issue where URL/file path disappeared on subsequent opens * fix(app-picker): properly escape URLs and file paths for shell execution - Add shellEscape() function to wrap arguments in single quotes - Prevents shell interpretation of special characters (&, ?, =, spaces, etc.) - Fixes bug where URLs with query parameters were truncated at first & - Example: http://localhost:36275/vnc.html?autoconnect=true&reconnect=true now properly passes the full URL instead of cutting at first & - Applied to both BrowserPickerModal (URLs) and filePickerModal (file paths) * fix: check error return from InitializeAppPickerManager --- assets/dms-open.desktop | 10 + core/cmd/dms/commands_open.go | 224 ++++++++++ core/internal/server/apppicker/handlers.go | 64 +++ core/internal/server/apppicker/manager.go | 48 +++ core/internal/server/apppicker/models.go | 8 + core/internal/server/browser/handlers.go | 28 ++ core/internal/server/browser/manager.go | 49 +++ core/internal/server/browser/models.go | 5 + core/internal/server/router.go | 15 + core/internal/server/server.go | 64 +++ quickshell/Common/SettingsData.qml | 2 + quickshell/DMSShell.qml | 68 +++ quickshell/Modals/AppPickerModal.qml | 469 +++++++++++++++++++++ quickshell/Modals/BrowserPickerModal.qml | 51 +++ quickshell/Services/DMSService.qml | 28 +- 15 files changed, 1125 insertions(+), 8 deletions(-) create mode 100644 assets/dms-open.desktop create mode 100644 core/cmd/dms/commands_open.go create mode 100644 core/internal/server/apppicker/handlers.go create mode 100644 core/internal/server/apppicker/manager.go create mode 100644 core/internal/server/apppicker/models.go create mode 100644 core/internal/server/browser/handlers.go create mode 100644 core/internal/server/browser/manager.go create mode 100644 core/internal/server/browser/models.go create mode 100644 quickshell/Modals/AppPickerModal.qml create mode 100644 quickshell/Modals/BrowserPickerModal.qml diff --git a/assets/dms-open.desktop b/assets/dms-open.desktop new file mode 100644 index 00000000..22244c14 --- /dev/null +++ b/assets/dms-open.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=DMS Application Picker +Comment=Select an application to open links and files +Exec=dms open %u +Icon=danklogo +Terminal=false +NoDisplay=true +MimeType=x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/file;text/html; +Categories=Utility; diff --git a/core/cmd/dms/commands_open.go b/core/cmd/dms/commands_open.go new file mode 100644 index 00000000..ac98801b --- /dev/null +++ b/core/cmd/dms/commands_open.go @@ -0,0 +1,224 @@ +package main + +import ( + "encoding/json" + "fmt" + "mime" + "net" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/spf13/cobra" +) + +var ( + openMimeType string + openCategories []string + openRequestType string +) + +var openCmd = &cobra.Command{ + Use: "open [target]", + Short: "Open a file, URL, or resource with an application picker", + Long: `Open a target (URL, file, or other resource) using the DMS application picker. +By default, this opens URLs with the browser picker. You can customize the behavior +with flags to handle different MIME types or application categories. + +Examples: + dms open https://example.com # Open URL with browser picker + dms open file.pdf --mime application/pdf # Open PDF with compatible apps + dms open document.odt --category Office # Open with office applications + dms open --mime image/png image.png # Open image with image viewers`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runOpen(args[0]) + }, +} + +func init() { + rootCmd.AddCommand(openCmd) + openCmd.Flags().StringVar(&openMimeType, "mime", "", "MIME type for filtering applications") + openCmd.Flags().StringSliceVar(&openCategories, "category", []string{}, "Application categories to filter (e.g., WebBrowser, Office, Graphics)") + openCmd.Flags().StringVar(&openRequestType, "type", "url", "Request type (url, file, or custom)") +} + +// mimeTypeToCategories maps MIME types to desktop file categories +func mimeTypeToCategories(mimeType string) []string { + // Split MIME type to get the main type + parts := strings.Split(mimeType, "/") + if len(parts) < 1 { + return nil + } + + mainType := parts[0] + + switch mainType { + case "image": + return []string{"Graphics", "Viewer"} + case "video": + return []string{"Video", "AudioVideo"} + case "audio": + return []string{"Audio", "AudioVideo"} + case "text": + if strings.Contains(mimeType, "html") { + return []string{"WebBrowser"} + } + return []string{"TextEditor", "Office"} + case "application": + if strings.Contains(mimeType, "pdf") { + return []string{"Office", "Viewer"} + } + if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") || + strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") || + strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") || + strings.Contains(mimeType, "opendocument") { + return []string{"Office"} + } + if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || + strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") { + return []string{"Archiving", "Utility"} + } + return []string{"Office", "Viewer"} + } + + return nil +} + +func runOpen(target string) { + socketPath, err := server.FindSocket() + if err != nil { + log.Warnf("DMS socket not found: %v", err) + fmt.Println("DMS is not running. Please start DMS first.") + os.Exit(1) + } + + conn, err := net.Dial("unix", socketPath) + if err != nil { + log.Warnf("DMS socket connection failed: %v", err) + fmt.Println("DMS is not running. Please start DMS first.") + os.Exit(1) + } + defer conn.Close() + + buf := make([]byte, 1) + for { + _, err := conn.Read(buf) + if err != nil { + return + } + if buf[0] == '\n' { + break + } + } + + // Parse file:// URIs to extract the actual file path + actualTarget := target + detectedMimeType := openMimeType + detectedCategories := openCategories + detectedRequestType := openRequestType + + log.Infof("Processing target: %s", target) + + if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" { + // Extract file path from file:// URI and convert to absolute path + actualTarget = parsedURL.Path + if absPath, err := filepath.Abs(actualTarget); err == nil { + actualTarget = absPath + } + + if detectedRequestType == "url" || detectedRequestType == "" { + detectedRequestType = "file" + } + + log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget) + + // Auto-detect MIME type if not provided + if detectedMimeType == "" { + ext := filepath.Ext(actualTarget) + if ext != "" { + detectedMimeType = mime.TypeByExtension(ext) + log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType) + } + } + + // Auto-detect categories based on MIME type if not provided + if len(detectedCategories) == 0 && detectedMimeType != "" { + detectedCategories = mimeTypeToCategories(detectedMimeType) + log.Infof("Detected categories from MIME type: %v", detectedCategories) + } + } else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + // Handle HTTP(S) URLs + if detectedRequestType == "" { + detectedRequestType = "url" + } + log.Infof("Detected HTTP(S) URL") + } else if _, err := os.Stat(target); err == nil { + // Handle local file paths directly (not file:// URIs) + // Convert to absolute path + if absPath, err := filepath.Abs(target); err == nil { + actualTarget = absPath + } + + if detectedRequestType == "url" || detectedRequestType == "" { + detectedRequestType = "file" + } + + log.Infof("Detected local file path, converted to absolute: %s", actualTarget) + + // Auto-detect MIME type if not provided + if detectedMimeType == "" { + ext := filepath.Ext(actualTarget) + if ext != "" { + detectedMimeType = mime.TypeByExtension(ext) + log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType) + } + } + + // Auto-detect categories based on MIME type if not provided + if len(detectedCategories) == 0 && detectedMimeType != "" { + detectedCategories = mimeTypeToCategories(detectedMimeType) + log.Infof("Detected categories from MIME type: %v", detectedCategories) + } + } + + params := map[string]interface{}{ + "target": actualTarget, + } + + if detectedMimeType != "" { + params["mimeType"] = detectedMimeType + } + + if len(detectedCategories) > 0 { + params["categories"] = detectedCategories + } + + if detectedRequestType != "" { + params["requestType"] = detectedRequestType + } + + method := "apppicker.open" + if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://")) { + method = "browser.open" + params["url"] = target + } + + req := models.Request{ + ID: 1, + Method: method, + Params: params, + } + + log.Infof("Sending request - Method: %s, Params: %+v", method, params) + + if err := json.NewEncoder(conn).Encode(req); err != nil { + log.Fatalf("Failed to send request: %v", err) + } + + log.Infof("Request sent successfully") +} diff --git a/core/internal/server/apppicker/handlers.go b/core/internal/server/apppicker/handlers.go new file mode 100644 index 00000000..e79ce670 --- /dev/null +++ b/core/internal/server/apppicker/handlers.go @@ -0,0 +1,64 @@ +package apppicker + +import ( + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type Request struct { + ID int `json:"id"` + Method string `json:"method"` + Params map[string]interface{} `json:"params"` +} + +func HandleRequest(conn net.Conn, req Request, manager *Manager) { + switch req.Method { + case "apppicker.open", "browser.open": + handleOpen(conn, req, manager) + default: + models.RespondError(conn, req.ID, "unknown method") + } +} + +func handleOpen(conn net.Conn, req Request, manager *Manager) { + log.Infof("AppPicker: Received %s request with params: %+v", req.Method, req.Params) + + target, ok := req.Params["target"].(string) + if !ok { + target, ok = req.Params["url"].(string) + if !ok { + log.Warnf("AppPicker: Invalid target parameter in request") + models.RespondError(conn, req.ID, "invalid target parameter") + return + } + } + + event := OpenEvent{ + Target: target, + RequestType: "url", + } + + if mimeType, ok := req.Params["mimeType"].(string); ok { + event.MimeType = mimeType + } + + if categories, ok := req.Params["categories"].([]interface{}); ok { + event.Categories = make([]string, 0, len(categories)) + for _, cat := range categories { + if catStr, ok := cat.(string); ok { + event.Categories = append(event.Categories, catStr) + } + } + } + + if requestType, ok := req.Params["requestType"].(string); ok { + event.RequestType = requestType + } + + log.Infof("AppPicker: Broadcasting event: %+v", event) + manager.RequestOpen(event) + models.Respond(conn, req.ID, "ok") + log.Infof("AppPicker: Request handled successfully") +} diff --git a/core/internal/server/apppicker/manager.go b/core/internal/server/apppicker/manager.go new file mode 100644 index 00000000..bde0846a --- /dev/null +++ b/core/internal/server/apppicker/manager.go @@ -0,0 +1,48 @@ +package apppicker + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type Manager struct { + subscribers syncmap.Map[string, chan OpenEvent] + closeOnce sync.Once +} + +func NewManager() *Manager { + return &Manager{} +} + +func (m *Manager) Subscribe(id string) chan OpenEvent { + ch := make(chan OpenEvent, 16) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) RequestOpen(event OpenEvent) { + m.subscribers.Range(func(key string, ch chan OpenEvent) bool { + select { + case ch <- event: + default: + } + return true + }) +} + +func (m *Manager) Close() { + m.closeOnce.Do(func() { + m.subscribers.Range(func(key string, ch chan OpenEvent) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + }) +} diff --git a/core/internal/server/apppicker/models.go b/core/internal/server/apppicker/models.go new file mode 100644 index 00000000..88614613 --- /dev/null +++ b/core/internal/server/apppicker/models.go @@ -0,0 +1,8 @@ +package apppicker + +type OpenEvent struct { + Target string `json:"target"` + MimeType string `json:"mimeType,omitempty"` + Categories []string `json:"categories,omitempty"` + RequestType string `json:"requestType"` +} diff --git a/core/internal/server/browser/handlers.go b/core/internal/server/browser/handlers.go new file mode 100644 index 00000000..3962c7ab --- /dev/null +++ b/core/internal/server/browser/handlers.go @@ -0,0 +1,28 @@ +package browser + +import ( + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type Request struct { + ID int `json:"id"` + Method string `json:"method"` + Params map[string]interface{} `json:"params"` +} + +func HandleRequest(conn net.Conn, req Request, manager *Manager) { + switch req.Method { + case "browser.open": + url, ok := req.Params["url"].(string) + if !ok { + models.RespondError(conn, req.ID, "invalid url parameter") + return + } + manager.RequestOpen(url) + models.Respond(conn, req.ID, "ok") + default: + models.RespondError(conn, req.ID, "unknown method") + } +} diff --git a/core/internal/server/browser/manager.go b/core/internal/server/browser/manager.go new file mode 100644 index 00000000..5306759b --- /dev/null +++ b/core/internal/server/browser/manager.go @@ -0,0 +1,49 @@ +package browser + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type Manager struct { + subscribers syncmap.Map[string, chan OpenEvent] + closeOnce sync.Once +} + +func NewManager() *Manager { + return &Manager{} +} + +func (m *Manager) Subscribe(id string) chan OpenEvent { + ch := make(chan OpenEvent, 16) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) RequestOpen(url string) { + event := OpenEvent{URL: url} + m.subscribers.Range(func(key string, ch chan OpenEvent) bool { + select { + case ch <- event: + default: + } + return true + }) +} + +func (m *Manager) Close() { + m.closeOnce.Do(func() { + m.subscribers.Range(func(key string, ch chan OpenEvent) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + }) +} diff --git a/core/internal/server/browser/models.go b/core/internal/server/browser/models.go new file mode 100644 index 00000000..fc635193 --- /dev/null +++ b/core/internal/server/browser/models.go @@ -0,0 +1,5 @@ +package browser + +type OpenEvent struct { + URL string `json:"url"` +} diff --git a/core/internal/server/router.go b/core/internal/server/router.go index 07c5da65..67fdc791 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -5,6 +5,7 @@ import ( "net" "strings" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" @@ -96,6 +97,20 @@ func RouteRequest(conn net.Conn, req models.Request) { return } + if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") { + if appPickerManager == nil { + models.RespondError(conn, req.ID, "apppicker manager not initialized") + return + } + appPickerReq := apppicker.Request{ + ID: req.ID, + Method: req.Method, + Params: req.Params, + } + apppicker.HandleRequest(conn, appPickerReq, appPickerManager) + return + } + if strings.HasPrefix(req.Method, "cups.") { if cupsManager == nil { models.RespondError(conn, req.ID, "CUPS manager not initialized") diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 80c0f49a..d38fcea1 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -15,6 +15,7 @@ import ( "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" @@ -55,6 +56,7 @@ var loginctlManager *loginctl.Manager var freedesktopManager *freedesktop.Manager var waylandManager *wayland.Manager var bluezManager *bluez.Manager +var appPickerManager *apppicker.Manager var cupsManager *cups.Manager var dwlManager *dwl.Manager var extWorkspaceManager *extworkspace.Manager @@ -88,6 +90,21 @@ func GetSocketPath() string { return filepath.Join(getSocketDir(), fmt.Sprintf("danklinux-%d.sock", os.Getpid())) } +func FindSocket() (string, error) { + dir := getSocketDir() + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "danklinux-") && strings.HasSuffix(entry.Name(), ".sock") { + return filepath.Join(dir, entry.Name()), nil + } + } + return "", fmt.Errorf("no dms socket found") +} + func cleanupStaleSockets() { dir := getSocketDir() entries, err := os.ReadDir(dir) @@ -201,6 +218,13 @@ func InitializeBluezManager() error { return nil } +func InitializeAppPickerManager() error { + manager := apppicker.NewManager() + appPickerManager = manager + log.Info("AppPicker manager initialized") + return nil +} + func InitializeCupsManager() error { manager, err := cups.NewManager() if err != nil { @@ -357,6 +381,10 @@ func getCapabilities() Capabilities { caps = append(caps, "bluetooth") } + if appPickerManager != nil { + caps = append(caps, "browser") + } + if cupsManager != nil { caps = append(caps, "cups") } @@ -407,6 +435,10 @@ func getServerInfo() ServerInfo { caps = append(caps, "bluetooth") } + if appPickerManager != nil { + caps = append(caps, "browser") + } + if cupsManager != nil { caps = append(caps, "cups") } @@ -724,6 +756,31 @@ func handleSubscribe(conn net.Conn, req models.Request) { }() } + if shouldSubscribe("browser") && appPickerManager != nil { + wg.Add(1) + appPickerChan := appPickerManager.Subscribe(clientID + "-browser") + go func() { + defer wg.Done() + defer appPickerManager.Unsubscribe(clientID + "-browser") + + for { + select { + case event, ok := <-appPickerChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "browser.open_requested", Data: event}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + if shouldSubscribe("cups") { cupsSubscribers.Store(clientID+"-cups", true) count := cupsSubscriberCount.Add(1) @@ -1018,6 +1075,9 @@ func cleanupManagers() { if bluezManager != nil { bluezManager.Close() } + if appPickerManager != nil { + appPickerManager.Close() + } if cupsManager != nil { cupsManager.Close() } @@ -1256,6 +1316,10 @@ func Start(printDocs bool) error { } }() + if err := InitializeAppPickerManager(); err != nil { + log.Debugf("AppPicker manager unavailable: %v", err) + } + if err := InitializeDwlManager(); err != nil { log.Debugf("DWL manager unavailable: %v", err) } diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 1d45e09d..561e8749 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -180,6 +180,8 @@ Singleton { property string appLauncherViewMode: "list" property string spotlightModalViewMode: "list" + property string browserPickerViewMode: "grid" + property var browserUsageHistory: ({}) property bool sortAppsAlphabetically: false property int appLauncherGridColumns: 4 property bool spotlightCloseNiriOverview: true diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 0fe204c2..36e9ebf6 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -431,6 +431,74 @@ Item { } } + BrowserPickerModal { + id: browserPickerModal + } + + AppPickerModal { + id: filePickerModal + title: I18n.tr("Open with...") + + function shellEscape(str) { + return "'" + str.replace(/'/g, "'\\''") + "'" + } + + onApplicationSelected: (app, filePath) => { + if (!app) return + + let cmd = app.exec || "" + const escapedPath = shellEscape(filePath) + const escapedUri = shellEscape("file://" + filePath) + + let hasField = false + if (cmd.includes("%f")) { cmd = cmd.replace("%f", escapedPath); hasField = true } + else if (cmd.includes("%F")) { cmd = cmd.replace("%F", escapedPath); hasField = true } + else if (cmd.includes("%u")) { cmd = cmd.replace("%u", escapedUri); hasField = true } + else if (cmd.includes("%U")) { cmd = cmd.replace("%U", escapedUri); hasField = true } + + cmd = cmd.replace(/%[ikc]/g, "") + + if (!hasField) { + cmd += " " + escapedPath + } + + console.log("FilePicker: Launching", cmd) + + Quickshell.execDetached({ + command: ["sh", "-c", cmd] + }) + } + } + + Connections { + target: DMSService + function onOpenUrlRequested(url) { + browserPickerModal.url = url + browserPickerModal.open() + } + + function onAppPickerRequested(data) { + console.log("DMSShell: App picker requested with data:", JSON.stringify(data)) + + if (!data || !data.target) { + console.warn("DMSShell: Invalid app picker request data") + return + } + + filePickerModal.targetData = data.target + filePickerModal.targetDataLabel = data.requestType || "file" + + if (data.categories && data.categories.length > 0) { + filePickerModal.categoryFilter = data.categories + } else { + filePickerModal.categoryFilter = [] + } + + filePickerModal.usageHistoryKey = "filePickerUsageHistory" + filePickerModal.open() + } + } + DankColorPickerModal { id: colorPickerModal diff --git a/quickshell/Modals/AppPickerModal.qml b/quickshell/Modals/AppPickerModal.qml new file mode 100644 index 00000000..ca9b4e42 --- /dev/null +++ b/quickshell/Modals/AppPickerModal.qml @@ -0,0 +1,469 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Modals.Common +import qs.Widgets +import qs.Services + +DankModal { + id: root + + property string title: I18n.tr("Select Application") + property string targetData: "" + property string targetDataLabel: "" + property string searchQuery: "" + property int selectedIndex: 0 + property int gridColumns: SettingsData.appLauncherGridColumns + property bool keyboardNavigationActive: false + property string viewMode: "grid" + property var categoryFilter: [] + property var usageHistoryKey: "" + property bool showTargetData: true + + signal applicationSelected(var app, string targetData) + + shouldBeVisible: false + allowStacking: true + modalWidth: 520 + modalHeight: 500 + + onDialogClosed: { + searchQuery = "" + selectedIndex = 0 + keyboardNavigationActive: false + } + + onOpened: { + searchQuery = "" + updateApplicationList() + selectedIndex = 0 + Qt.callLater(() => { + if (contentLoader.item && contentLoader.item.searchField) { + contentLoader.item.searchField.text = "" + contentLoader.item.searchField.forceActiveFocus() + } + }) + } + + function updateApplicationList() { + applicationsModel.clear() + const apps = AppSearchService.applications + const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {} + let filteredApps = [] + + for (const app of apps) { + if (!app || !app.categories) continue + + let matchesCategory = categoryFilter.length === 0 + + if (categoryFilter.length > 0) { + try { + for (const cat of app.categories) { + if (categoryFilter.includes(cat)) { + matchesCategory = true + break + } + } + } catch (e) { + console.warn("AppPicker: Error iterating categories for", app.name, ":", e) + continue + } + } + + if (matchesCategory) { + const name = app.name || "" + const lowerName = name.toLowerCase() + const lowerQuery = searchQuery.toLowerCase() + + if (searchQuery === "" || lowerName.includes(lowerQuery)) { + filteredApps.push({ + name: name, + icon: app.icon || "application-x-executable", + exec: app.exec || app.execString || "", + startupClass: app.startupWMClass || "", + appData: app + }) + } + } + } + + filteredApps.sort((a, b) => { + const aId = a.appData.id || a.appData.execString || a.appData.exec || "" + const bId = b.appData.id || b.appData.execString || b.appData.exec || "" + const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0 + const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0 + if (aUsage !== bUsage) { + return bUsage - aUsage + } + return (a.name || "").localeCompare(b.name || "") + }) + + filteredApps.forEach(app => { + applicationsModel.append({ + name: app.name, + icon: app.icon, + exec: app.exec, + startupClass: app.startupClass, + appId: app.appData.id || app.appData.execString || app.appData.exec || "" + }) + }) + + console.log("AppPicker: Found " + filteredApps.length + " applications") + } + + onSearchQueryChanged: updateApplicationList() + + ListModel { + id: applicationsModel + } + + content: Component { + FocusScope { + id: appContent + + property alias searchField: searchField + + anchors.fill: parent + focus: true + + Keys.onEscapePressed: event => { + root.close() + event.accepted = true + } + + Keys.onPressed: event => { + if (applicationsModel.count === 0) return + + // Toggle view mode with Tab key + if (event.key === Qt.Key_Tab) { + root.viewMode = root.viewMode === "grid" ? "list" : "grid" + event.accepted = true + return + } + + if (root.viewMode === "grid") { + if (event.key === Qt.Key_Left) { + root.keyboardNavigationActive = true + root.selectedIndex = Math.max(0, root.selectedIndex - 1) + event.accepted = true + } else if (event.key === Qt.Key_Right) { + root.keyboardNavigationActive = true + root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1) + event.accepted = true + } else if (event.key === Qt.Key_Up) { + root.keyboardNavigationActive = true + root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns) + event.accepted = true + } else if (event.key === Qt.Key_Down) { + root.keyboardNavigationActive = true + root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns) + event.accepted = true + } + } else { + if (event.key === Qt.Key_Up) { + root.keyboardNavigationActive = true + root.selectedIndex = Math.max(0, root.selectedIndex - 1) + event.accepted = true + } else if (event.key === Qt.Key_Down) { + root.keyboardNavigationActive = true + root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1) + event.accepted = true + } + } + + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (root.selectedIndex >= 0 && root.selectedIndex < applicationsModel.count) { + const app = applicationsModel.get(root.selectedIndex) + launchApplication(app) + } + event.accepted = true + } + } + + Column { + width: parent.width - Theme.spacingS * 2 + height: parent.height - Theme.spacingS * 2 + x: Theme.spacingS + y: Theme.spacingS + spacing: Theme.spacingS + + Item { + width: parent.width + height: 40 + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: root.title + font.pixelSize: Theme.fontSizeLarge + 4 + font.weight: Font.Bold + color: Theme.surfaceText + } + + Row { + spacing: 4 + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + + DankActionButton { + buttonSize: 36 + circular: false + iconName: "view_list" + iconSize: 20 + iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText + backgroundColor: root.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + onClicked: { + root.viewMode = "list" + } + } + + DankActionButton { + buttonSize: 36 + circular: false + iconName: "grid_view" + iconSize: 20 + iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText + backgroundColor: root.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + onClicked: { + root.viewMode = "grid" + } + } + } + } + + DankTextField { + id: searchField + + width: parent.width - Theme.spacingS * 2 + anchors.horizontalCenter: parent.horizontalCenter + height: 52 + cornerRadius: Theme.cornerRadius + backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + normalBorderColor: Theme.outlineMedium + focusedBorderColor: Theme.primary + leftIconName: "search" + leftIconSize: Theme.iconSize + leftIconColor: Theme.surfaceVariantText + leftIconFocusedColor: Theme.primary + showClearButton: true + font.pixelSize: Theme.fontSizeLarge + enabled: root.shouldBeVisible + ignoreLeftRightKeys: root.viewMode !== "list" + ignoreTabKeys: true + keyForwardTargets: [appContent] + + onTextEdited: { + root.searchQuery = text + } + + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Escape) { + root.close() + event.accepted = true + return + } + + const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key) + const hasText = text.length > 0 + + if (isEnterKey && hasText) { + if (root.keyboardNavigationActive && applicationsModel.count > 0) { + const app = applicationsModel.get(root.selectedIndex) + launchApplication(app) + } else if (applicationsModel.count > 0) { + const app = applicationsModel.get(0) + launchApplication(app) + } + event.accepted = true + return + } + + const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab] + const isNavigationKey = navigationKeys.includes(event.key) + const isEmptyEnter = isEnterKey && !hasText + + event.accepted = !(isNavigationKey || isEmptyEnter) + } + + Connections { + function onShouldBeVisibleChanged() { + if (!root.shouldBeVisible) { + searchField.focus = false + } + } + + target: root + } + } + + Rectangle { + width: parent.width + height: { + let usedHeight = 40 + Theme.spacingS + usedHeight += 52 + Theme.spacingS + if (root.showTargetData) { + usedHeight += 36 + Theme.spacingS + } + return parent.height - usedHeight + } + radius: Theme.cornerRadius + color: "transparent" + + DankListView { + id: appList + + property int itemHeight: 60 + property int itemSpacing: Theme.spacingS + + function ensureVisible(index) { + if (index < 0 || index >= count) return + + const itemY = index * (itemHeight + itemSpacing) + const itemBottom = itemY + itemHeight + if (itemY < contentY) { + contentY = itemY + } else if (itemBottom > contentY + height) { + contentY = itemBottom - height + } + } + + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + anchors.bottomMargin: Theme.spacingS + + visible: root.viewMode === "list" + model: applicationsModel + currentIndex: root.selectedIndex + clip: true + spacing: itemSpacing + + onCurrentIndexChanged: { + root.selectedIndex = currentIndex + if (root.keyboardNavigationActive) { + ensureVisible(currentIndex) + } + } + + delegate: AppLauncherListDelegate { + listView: appList + itemHeight: 60 + iconSize: 40 + showDescription: false + + isCurrentItem: index === root.selectedIndex + keyboardNavigationActive: root.keyboardNavigationActive + hoverUpdatesSelection: true + + onItemClicked: (idx, modelData) => { + launchApplication(modelData) + } + + onKeyboardNavigationReset: { + root.keyboardNavigationActive = false + } + } + } + + DankGridView { + id: appGrid + + function ensureVisible(index) { + if (index < 0 || index >= count) return + + const itemY = Math.floor(index / root.gridColumns) * cellHeight + const itemBottom = itemY + cellHeight + if (itemY < contentY) { + contentY = itemY + } else if (itemBottom > contentY + height) { + contentY = itemBottom - height + } + } + + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + anchors.bottomMargin: Theme.spacingS + + visible: root.viewMode === "grid" + model: applicationsModel + cellWidth: width / root.gridColumns + cellHeight: 120 + clip: true + currentIndex: root.selectedIndex + + onCurrentIndexChanged: { + root.selectedIndex = currentIndex + if (root.keyboardNavigationActive) { + ensureVisible(currentIndex) + } + } + + delegate: AppLauncherGridDelegate { + gridView: appGrid + cellWidth: appGrid.cellWidth + cellHeight: appGrid.cellHeight + + currentIndex: root.selectedIndex + keyboardNavigationActive: root.keyboardNavigationActive + hoverUpdatesSelection: true + + onItemClicked: (idx, modelData) => { + launchApplication(modelData) + } + + onKeyboardNavigationReset: { + root.keyboardNavigationActive = false + } + } + } + } + + Rectangle { + width: parent.width + height: 36 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) + border.color: Theme.outlineMedium + border.width: 1 + visible: root.showTargetData && root.targetData.length > 0 + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: root.targetDataLabel.length > 0 ? root.targetDataLabel + ": " + root.targetData : root.targetData + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceTextMedium + elide: Text.ElideMiddle + wrapMode: Text.NoWrap + maximumLineCount: 1 + } + } + } + + function launchApplication(app) { + if (!app) return + + root.applicationSelected(app, root.targetData) + + if (usageHistoryKey && app.appId) { + const usageHistory = SettingsData[usageHistoryKey] || {} + const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0 + usageHistory[app.appId] = { + count: currentCount + 1, + lastUsed: Date.now(), + name: app.name + } + SettingsData.set(usageHistoryKey, usageHistory) + } + + root.close() + } + } + } +} diff --git a/quickshell/Modals/BrowserPickerModal.qml b/quickshell/Modals/BrowserPickerModal.qml new file mode 100644 index 00000000..e4afcb84 --- /dev/null +++ b/quickshell/Modals/BrowserPickerModal.qml @@ -0,0 +1,51 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Modals + +AppPickerModal { + id: root + + property string url: "" + + title: I18n.tr("Open with...") + targetData: url + targetDataLabel: "" + categoryFilter: ["WebBrowser", "X-WebBrowser"] + viewMode: SettingsData.browserPickerViewMode || "grid" + usageHistoryKey: "browserUsageHistory" + showTargetData: true + + function shellEscape(str) { + return "'" + str.replace(/'/g, "'\\''") + "'" + } + + onApplicationSelected: (app, url) => { + if (!app) return + + let cmd = app.exec || "" + const escapedUrl = shellEscape(url) + + let hasField = false + if (cmd.includes("%u")) { cmd = cmd.replace("%u", escapedUrl); hasField = true } + else if (cmd.includes("%U")) { cmd = cmd.replace("%U", escapedUrl); hasField = true } + else if (cmd.includes("%f")) { cmd = cmd.replace("%f", escapedUrl); hasField = true } + else if (cmd.includes("%F")) { cmd = cmd.replace("%F", escapedUrl); hasField = true } + + cmd = cmd.replace(/%[ikc]/g, "") + + if (!hasField) { + cmd += " " + escapedUrl + } + + console.log("BrowserPicker: Launching", cmd) + + Quickshell.execDetached({ + command: ["sh", "-c", cmd] + }) + } + + onViewModeChanged: { + SettingsData.set("browserPickerViewMode", viewMode) + } +} diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 44c64f9a..5847dad8 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -49,10 +49,12 @@ Singleton { signal extWorkspaceStateUpdate(var data) signal wlrOutputStateUpdate(var data) signal evdevStateUpdate(var data) + signal openUrlRequested(string url) + signal appPickerRequested(var data) property bool capsLockState: false - property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev"] + property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser"] Component.onCompleted: { if (socketPath && socketPath.length > 0) { @@ -265,9 +267,9 @@ Singleton { function removeSubscription(service) { if (activeSubscriptions.includes("all")) { - const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace"]; - const filtered = allServices.filter(s => s !== service); - subscribe(filtered); + const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser"] + const filtered = allServices.filter(s => s !== service) + subscribe(filtered) } else { const filtered = activeSubscriptions.filter(s => s !== service); if (filtered.length === 0) { @@ -287,9 +289,9 @@ Singleton { excludeServices = [excludeServices]; } - const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace"]; - const filtered = allServices.filter(s => !excludeServices.includes(s)); - subscribe(filtered); + const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser"] + const filtered = allServices.filter(s => !excludeServices.includes(s)) + subscribe(filtered) } function handleSubscriptionEvent(response) { @@ -353,7 +355,17 @@ Singleton { if (data.capsLock !== undefined) { capsLockState = data.capsLock; } - evdevStateUpdate(data); + evdevStateUpdate(data) + } else if (service === "browser.open_requested") { + if (data.target) { + if (data.requestType === "url" || !data.requestType) { + openUrlRequested(data.target) + } else { + appPickerRequested(data) + } + } else if (data.url) { + openUrlRequested(data.url) + } } }