mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 21:42:51 -05:00
feat: Add browser picker modal for URL handling (#815)
* 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 <url>` 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
This commit is contained in:
64
core/internal/server/apppicker/handlers.go
Normal file
64
core/internal/server/apppicker/handlers.go
Normal file
@@ -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")
|
||||
}
|
||||
48
core/internal/server/apppicker/manager.go
Normal file
48
core/internal/server/apppicker/manager.go
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
8
core/internal/server/apppicker/models.go
Normal file
8
core/internal/server/apppicker/models.go
Normal file
@@ -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"`
|
||||
}
|
||||
28
core/internal/server/browser/handlers.go
Normal file
28
core/internal/server/browser/handlers.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
49
core/internal/server/browser/manager.go
Normal file
49
core/internal/server/browser/manager.go
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
5
core/internal/server/browser/models.go
Normal file
5
core/internal/server/browser/models.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package browser
|
||||
|
||||
type OpenEvent struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user