diff --git a/core/cmd/dms/commands_open.go b/core/cmd/dms/commands_open.go index 8cc16cfd..91e780b3 100644 --- a/core/cmd/dms/commands_open.go +++ b/core/cmd/dms/commands_open.go @@ -28,9 +28,9 @@ 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`, + dms open file.pdf # Open file (MIME auto-detected) + dms open file.pdf --mime application/pdf # Override MIME detection + dms open document.odt --category Office # Open with office applications`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { runOpen(args[0]) @@ -47,123 +47,58 @@ func init() { }) } -// 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 +func detectMimeFromPath(path string) string { + ext := filepath.Ext(path) + if ext == "" { + return "" } - - 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 + return mime.TypeByExtension(ext) } func runOpen(target string) { - // 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 + switch { + case isScheme(target, "file://"): + parsedURL, err := url.Parse(target) + if err == nil { + actualTarget = parsedURL.Path + } + if abs, err := filepath.Abs(actualTarget); err == nil { + actualTarget = abs } - 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) - } + detectedMimeType = detectMimeFromPath(actualTarget) } + log.Infof("Detected file:// URI, absolute path: %s", actualTarget) - // 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 + case isScheme(target, "http://"), isScheme(target, "https://"), isScheme(target, "dms://"): if detectedRequestType == "" { detectedRequestType = "url" } - log.Infof("Detected HTTP(S) URL") - } else if strings.HasPrefix(target, "dms://") { - // Handle DMS internal URLs (theme/plugin install, etc.) - if detectedRequestType == "" { - detectedRequestType = "url" - } - log.Infof("Detected DMS internal 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 - } + log.Infof("Detected URL: %s", target) + default: + if _, err := os.Stat(target); err != nil { + break + } + if abs, err := filepath.Abs(target); err == nil { + actualTarget = abs + } 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) + detectedMimeType = detectMimeFromPath(actualTarget) } + log.Infof("Detected local file path: %s", actualTarget) } params := map[string]any{ @@ -174,8 +109,8 @@ func runOpen(target string) { params["mimeType"] = detectedMimeType } - if len(detectedCategories) > 0 { - params["categories"] = detectedCategories + if len(openCategories) > 0 { + params["categories"] = openCategories } if detectedRequestType != "" { @@ -183,7 +118,7 @@ func runOpen(target string) { } method := "apppicker.open" - if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) { + if detectedMimeType == "" && len(openCategories) == 0 && (isScheme(target, "http://") || isScheme(target, "https://") || isScheme(target, "dms://")) { method = "browser.open" params["url"] = target } @@ -203,3 +138,7 @@ func runOpen(target string) { log.Infof("Request sent successfully") } + +func isScheme(target, prefix string) bool { + return strings.HasPrefix(target, prefix) +} diff --git a/core/internal/desktop/entries.go b/core/internal/desktop/entries.go new file mode 100644 index 00000000..c0707116 --- /dev/null +++ b/core/internal/desktop/entries.go @@ -0,0 +1,203 @@ +package desktop + +import ( + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type Entry struct { + ID string + Path string + Name string + Exec string + Icon string + Categories []string + MimeTypes []string + NoDisplay bool + Hidden bool + Terminal bool +} + +type cachedEntry struct { + entry *Entry + modTime time.Time + size int64 +} + +var ( + entryCache = make(map[string]cachedEntry) + entryCacheMu sync.Mutex + + listingCache []*Entry + listingExpires time.Time + listingCacheMu sync.Mutex +) + +const listingTTL = 5 * time.Second + +func applicationDirs() []string { + seen := make(map[string]bool) + var dirs []string + + add := func(path string) { + if path == "" { + return + } + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + if seen[abs] { + return + } + seen[abs] = true + dirs = append(dirs, abs) + } + + add(filepath.Join(utils.XDGDataHome(), "applications")) + + if env := os.Getenv("XDG_DATA_DIRS"); env != "" { + for d := range strings.SplitSeq(env, ":") { + add(filepath.Join(strings.TrimSpace(d), "applications")) + } + } else { + add("/usr/local/share/applications") + add("/usr/share/applications") + } + + home, _ := os.UserHomeDir() + if home != "" { + add(filepath.Join(home, ".local", "share", "flatpak", "exports", "share", "applications")) + } + add("/var/lib/flatpak/exports/share/applications") + add("/var/lib/snapd/desktop/applications") + + return dirs +} + +func parseEntry(path string, id string) (*Entry, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + entryCacheMu.Lock() + if c, ok := entryCache[path]; ok && c.modTime.Equal(info.ModTime()) && c.size == info.Size() { + entryCacheMu.Unlock() + return c.entry, nil + } + entryCacheMu.Unlock() + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + groups := parseGroups(data) + g, ok := groups["Desktop Entry"] + if !ok { + return nil, nil + } + + entry := &Entry{ + ID: id, + Path: path, + Name: g.keys["Name"], + Exec: g.keys["Exec"], + Icon: g.keys["Icon"], + Categories: splitList(g.keys["Categories"]), + MimeTypes: splitList(g.keys["MimeType"]), + NoDisplay: parseBool(g.keys["NoDisplay"]), + Hidden: parseBool(g.keys["Hidden"]), + Terminal: parseBool(g.keys["Terminal"]), + } + + if t := g.keys["Type"]; t != "" && t != "Application" { + return nil, nil + } + + entryCacheMu.Lock() + entryCache[path] = cachedEntry{entry: entry, modTime: info.ModTime(), size: info.Size()} + entryCacheMu.Unlock() + + return entry, nil +} + +func relativeID(root, path string) string { + rel, err := filepath.Rel(root, path) + if err != nil { + return filepath.Base(path) + } + return strings.ReplaceAll(rel, string(filepath.Separator), "-") +} + +func AllEntries() []*Entry { + listingCacheMu.Lock() + if time.Now().Before(listingExpires) && listingCache != nil { + out := listingCache + listingCacheMu.Unlock() + return out + } + listingCacheMu.Unlock() + + seen := make(map[string]bool) + var entries []*Entry + + for _, dir := range applicationDirs() { + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".desktop") { + return nil + } + id := relativeID(dir, path) + if seen[id] { + return nil + } + seen[id] = true + + entry, err := parseEntry(path, id) + if err != nil || entry == nil { + return nil + } + entries = append(entries, entry) + return nil + }) + } + + listingCacheMu.Lock() + listingCache = entries + listingExpires = time.Now().Add(listingTTL) + listingCacheMu.Unlock() + + return entries +} + +func EntryByID(id string) *Entry { + if !strings.HasSuffix(id, ".desktop") { + id += ".desktop" + } + for _, entry := range AllEntries() { + if entry.ID == id { + return entry + } + } + return nil +} + +func InvalidateCache() { + entryCacheMu.Lock() + entryCache = make(map[string]cachedEntry) + entryCacheMu.Unlock() + + listingCacheMu.Lock() + listingCache = nil + listingExpires = time.Time{} + listingCacheMu.Unlock() +} diff --git a/core/internal/desktop/mime.go b/core/internal/desktop/mime.go new file mode 100644 index 00000000..dde950ce --- /dev/null +++ b/core/internal/desktop/mime.go @@ -0,0 +1,311 @@ +package desktop + +import ( + "bufio" + "bytes" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +var ( + aliasMap map[string]string + subclassMap map[string][]string + aliasLoaded time.Time + aliasReloadMu sync.Mutex + + mimeCacheMap map[string][]string + mimeCacheLoaded time.Time + mimeCacheReloadMu sync.Mutex +) + +const aliasTTL = 60 * time.Second +const mimeCacheTTL = 10 * time.Second + +func mimeDataDirs() []string { + var dirs []string + seen := make(map[string]bool) + + add := func(p string) { + if p == "" || seen[p] { + return + } + seen[p] = true + dirs = append(dirs, p) + } + + add(filepath.Join(utils.XDGDataHome(), "mime")) + + if env := os.Getenv("XDG_DATA_DIRS"); env != "" { + for d := range strings.SplitSeq(env, ":") { + add(filepath.Join(strings.TrimSpace(d), "mime")) + } + } else { + add("/usr/local/share/mime") + add("/usr/share/mime") + } + + return dirs +} + +func loadAliasTables() { + aliases := make(map[string]string) + subclasses := make(map[string][]string) + + for _, dir := range mimeDataDirs() { + readKV(filepath.Join(dir, "aliases"), func(k, v string) { + if _, ok := aliases[k]; !ok { + aliases[k] = v + } + }) + readKV(filepath.Join(dir, "subclasses"), func(k, v string) { + subclasses[k] = append(subclasses[k], v) + }) + } + + aliasMap = aliases + subclassMap = subclasses + aliasLoaded = time.Now() +} + +func readKV(path string, fn func(k, v string)) { + data, err := os.ReadFile(path) + if err != nil { + return + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || line[0] == '#' { + continue + } + sp := strings.IndexByte(line, ' ') + if sp <= 0 { + continue + } + fn(strings.TrimSpace(line[:sp]), strings.TrimSpace(line[sp+1:])) + } +} + +func ensureAliasTables() { + aliasReloadMu.Lock() + defer aliasReloadMu.Unlock() + + if aliasMap == nil || time.Since(aliasLoaded) > aliasTTL { + loadAliasTables() + } +} + +func loadMimeCache() { + merged := make(map[string][]string) + seen := make(map[string]map[string]bool) + + for _, dir := range applicationDirs() { + data, err := os.ReadFile(filepath.Join(dir, "mimeinfo.cache")) + if err != nil { + continue + } + groups := parseGroups(data) + g := groups["MIME Cache"] + if g == nil { + continue + } + for mime, val := range g.keys { + ids := splitList(val) + if len(ids) == 0 { + continue + } + if seen[mime] == nil { + seen[mime] = make(map[string]bool) + } + for _, id := range ids { + if seen[mime][id] { + continue + } + seen[mime][id] = true + merged[mime] = append(merged[mime], id) + } + } + } + + mimeCacheMap = merged + mimeCacheLoaded = time.Now() +} + +func ensureMimeCache() { + mimeCacheReloadMu.Lock() + defer mimeCacheReloadMu.Unlock() + + if mimeCacheMap == nil || time.Since(mimeCacheLoaded) > mimeCacheTTL { + loadMimeCache() + } +} + +func cacheAppsForMime(mimeType string) []string { + ensureMimeCache() + return mimeCacheMap[mimeType] +} + +func StripMimeParams(mimeType string) string { + if semi := strings.IndexByte(mimeType, ';'); semi >= 0 { + mimeType = mimeType[:semi] + } + return strings.TrimSpace(mimeType) +} + +func canonicalMime(mimeType string) string { + ensureAliasTables() + mimeType = StripMimeParams(mimeType) + if target, ok := aliasMap[mimeType]; ok { + return target + } + return mimeType +} + +func mimeChain(mimeType string) []string { + ensureAliasTables() + + root := canonicalMime(mimeType) + visited := map[string]bool{root: true} + chain := []string{root} + + queue := []string{root} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + for _, parent := range subclassMap[cur] { + if visited[parent] { + continue + } + visited[parent] = true + chain = append(chain, parent) + queue = append(queue, parent) + } + } + + return chain +} + +func entrySupportsMime(entry *Entry, chain []string) bool { + for _, m := range entry.MimeTypes { + canonical := canonicalMime(m) + if slices.Contains(chain, canonical) || slices.Contains(chain, m) { + return true + } + } + return false +} + +func GetDefault(mimeType string) string { + merged := mergedAssociations() + chain := mimeChain(mimeType) + + for _, m := range chain { + if id, ok := merged.Defaults[m]; ok { + if !slices.Contains(merged.Removed[m], id) { + return id + } + } + } + + for _, m := range chain { + for _, id := range merged.Added[m] { + if !slices.Contains(merged.Removed[m], id) { + return id + } + } + } + + for _, m := range chain { + for _, id := range cacheAppsForMime(m) { + if !slices.Contains(merged.Removed[m], id) { + return id + } + } + } + + for _, m := range chain { + for _, entry := range AllEntries() { + if entry.Hidden || entry.NoDisplay { + continue + } + if slices.Contains(merged.Removed[m], entry.ID) { + continue + } + if entrySupportsMime(entry, []string{m}) { + return entry.ID + } + } + } + + return "" +} + +func SetDefault(mimeType, desktopID string) error { + return setDefaultAssociation(mimeType, desktopID) +} + +func SetDefaults(mimeTypes []string, desktopID string) error { + return setDefaultAssociations(mimeTypes, desktopID) +} + +func AppsForMime(mimeType string) []string { + merged := mergedAssociations() + chain := mimeChain(mimeType) + removed := make(map[string]bool) + for _, m := range chain { + for _, id := range merged.Removed[m] { + removed[id] = true + } + } + + seen := make(map[string]bool) + var out []string + + add := func(id string) { + if id == "" || removed[id] || seen[id] { + return + } + seen[id] = true + out = append(out, id) + } + + for _, m := range chain { + if id := merged.Defaults[m]; id != "" { + add(id) + } + for _, id := range merged.Added[m] { + add(id) + } + } + + for _, m := range chain { + for _, id := range cacheAppsForMime(m) { + add(id) + } + } + + for _, entry := range AllEntries() { + if entry.Hidden { + continue + } + if entrySupportsMime(entry, chain) { + add(entry.ID) + } + } + + return out +} + +func QueryDefaults(mimeTypes []string) map[string]string { + out := make(map[string]string, len(mimeTypes)) + for _, m := range mimeTypes { + out[m] = GetDefault(m) + } + return out +} diff --git a/core/internal/desktop/mime_test.go b/core/internal/desktop/mime_test.go new file mode 100644 index 00000000..24b26a72 --- /dev/null +++ b/core/internal/desktop/mime_test.go @@ -0,0 +1,233 @@ +package desktop + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func setupFakeXDG(t *testing.T) (configHome, dataHome string) { + t.Helper() + tmp := t.TempDir() + configHome = filepath.Join(tmp, "config") + dataHome = filepath.Join(tmp, "data") + if err := os.MkdirAll(filepath.Join(dataHome, "applications"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(configHome, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_DATA_DIRS", dataHome) + t.Setenv("XDG_CONFIG_DIRS", configHome) + InvalidateCache() + + mimeCacheReloadMu.Lock() + mimeCacheMap = nil + mimeCacheReloadMu.Unlock() + aliasReloadMu.Lock() + aliasMap = nil + subclassMap = nil + aliasReloadMu.Unlock() + + return configHome, dataHome +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestParseDesktopEntry(t *testing.T) { + _, dataHome := setupFakeXDG(t) + writeFile(t, filepath.Join(dataHome, "applications", "test.desktop"), `[Desktop Entry] +Type=Application +Name=Test +Exec=test %f +Icon=test +MimeType=application/pdf;image/png; +Categories=Office;Viewer; +NoDisplay=false +`) + + entry := EntryByID("test.desktop") + if entry == nil { + t.Fatal("entry not found") + } + if entry.Name != "Test" { + t.Errorf("Name = %q", entry.Name) + } + if len(entry.MimeTypes) != 2 || entry.MimeTypes[0] != "application/pdf" { + t.Errorf("MimeTypes = %v", entry.MimeTypes) + } + if len(entry.Categories) != 2 || entry.Categories[1] != "Viewer" { + t.Errorf("Categories = %v", entry.Categories) + } +} + +func TestSetGetDefault(t *testing.T) { + configHome, dataHome := setupFakeXDG(t) + writeFile(t, filepath.Join(dataHome, "applications", "foo.desktop"), `[Desktop Entry] +Type=Application +Name=Foo +MimeType=application/pdf; +`) + writeFile(t, filepath.Join(dataHome, "applications", "bar.desktop"), `[Desktop Entry] +Type=Application +Name=Bar +MimeType=application/pdf; +`) + + if err := SetDefault("application/pdf", "bar.desktop"); err != nil { + t.Fatal(err) + } + + if got := GetDefault("application/pdf"); got != "bar.desktop" { + t.Errorf("GetDefault = %q want bar.desktop", got) + } + + data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list")) + if err != nil { + t.Fatal(err) + } + if !contains(string(data), "application/pdf=bar.desktop") { + t.Errorf("mimeapps.list missing default:\n%s", string(data)) + } +} + +func TestSetDefaultBypassesMimeSupportCheck(t *testing.T) { + configHome, dataHome := setupFakeXDG(t) + writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry] +Type=Application +Name=DMS +MimeType=x-scheme-handler/http; +`) + + if err := SetDefault("application/pdf", "dms-open.desktop"); err != nil { + t.Fatal(err) + } + + if got := GetDefault("application/pdf"); got != "dms-open.desktop" { + t.Errorf("GetDefault = %q, want dms-open.desktop (native impl must not enforce MimeType= check)", got) + } + + data, _ := os.ReadFile(filepath.Join(configHome, "mimeapps.list")) + if !contains(string(data), "application/pdf=dms-open.desktop") { + t.Errorf("mimeapps.list missing override:\n%s", string(data)) + } +} + +func TestAliasResolution(t *testing.T) { + _, dataHome := setupFakeXDG(t) + writeFile(t, filepath.Join(dataHome, "mime", "aliases"), "text/javascript application/javascript\n") + writeFile(t, filepath.Join(dataHome, "applications", "editor.desktop"), `[Desktop Entry] +Type=Application +Name=Editor +MimeType=application/javascript; +`) + writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache] +application/javascript=editor.desktop; +`) + + if got := GetDefault("text/javascript"); got != "editor.desktop" { + t.Errorf("GetDefault(text/javascript) = %q want editor.desktop (alias resolution)", got) + } +} + +func TestSetDefaultsBatch(t *testing.T) { + configHome, dataHome := setupFakeXDG(t) + writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry] +Type=Application +Name=DMS +MimeType=x-scheme-handler/http; +`) + + mimes := []string{ + "text/plain", "text/x-csrc", "text/x-python", + "text/x-shellscript", "application/json", + } + if err := SetDefaults(mimes, "dms-open.desktop"); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list")) + if err != nil { + t.Fatal(err) + } + for _, m := range mimes { + if !contains(string(data), m+"=dms-open.desktop") { + t.Errorf("missing %s default in mimeapps.list:\n%s", m, string(data)) + } + } +} + +func TestConcurrentSetDefaultNoCorruption(t *testing.T) { + configHome, dataHome := setupFakeXDG(t) + writeFile(t, filepath.Join(dataHome, "applications", "app.desktop"), `[Desktop Entry] +Type=Application +Name=App +`) + + mimes := []string{ + "a/1", "a/2", "a/3", "a/4", "a/5", "a/6", "a/7", + } + + var wg sync.WaitGroup + for _, m := range mimes { + wg.Add(1) + go func(m string) { + defer wg.Done() + if err := SetDefault(m, "app.desktop"); err != nil { + t.Errorf("SetDefault(%s) failed: %v", m, err) + } + }(m) + } + wg.Wait() + + data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list")) + if err != nil { + t.Fatal(err) + } + for _, m := range mimes { + if !contains(string(data), m+"=app.desktop") { + t.Errorf("lost write for %s — concurrent writes corrupted file:\n%s", m, string(data)) + } + } +} + +func TestMimeCacheOrdering(t *testing.T) { + _, dataHome := setupFakeXDG(t) + writeFile(t, filepath.Join(dataHome, "applications", "a.desktop"), `[Desktop Entry] +Type=Application +Name=A +MimeType=image/png; +`) + writeFile(t, filepath.Join(dataHome, "applications", "b.desktop"), `[Desktop Entry] +Type=Application +Name=B +MimeType=image/png; +`) + writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache] +image/png=b.desktop;a.desktop; +`) + + if got := GetDefault("image/png"); got != "b.desktop" { + t.Errorf("GetDefault should follow mimeinfo.cache order: got %q want b.desktop", got) + } +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/core/internal/desktop/mimeapps.go b/core/internal/desktop/mimeapps.go new file mode 100644 index 00000000..d208821c --- /dev/null +++ b/core/internal/desktop/mimeapps.go @@ -0,0 +1,226 @@ +package desktop + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +var mimeappsWriteMu sync.Mutex + +const ( + groupDefaults = "Default Applications" + groupAdded = "Added Associations" + groupRemoved = "Removed Associations" +) + +type MimeAssociations struct { + Defaults map[string]string + Added map[string][]string + Removed map[string][]string +} + +func newAssociations() *MimeAssociations { + return &MimeAssociations{ + Defaults: make(map[string]string), + Added: make(map[string][]string), + Removed: make(map[string][]string), + } +} + +func mimeappsSearchPaths() []string { + var paths []string + seen := make(map[string]bool) + + add := func(p string) { + if p == "" || seen[p] { + return + } + seen[p] = true + paths = append(paths, p) + } + + add(filepath.Join(utils.XDGConfigHome(), "mimeapps.list")) + + if env := os.Getenv("XDG_CONFIG_DIRS"); env != "" { + for d := range strings.SplitSeq(env, ":") { + add(filepath.Join(strings.TrimSpace(d), "mimeapps.list")) + } + } else { + add("/etc/xdg/mimeapps.list") + } + + add(filepath.Join(utils.XDGDataHome(), "applications", "mimeapps.list")) + + if env := os.Getenv("XDG_DATA_DIRS"); env != "" { + for d := range strings.SplitSeq(env, ":") { + add(filepath.Join(strings.TrimSpace(d), "applications", "mimeapps.list")) + } + } else { + add("/usr/local/share/applications/mimeapps.list") + add("/usr/share/applications/mimeapps.list") + } + + return paths +} + +func mimeappsWritePath() string { + return filepath.Join(utils.XDGConfigHome(), "mimeapps.list") +} + +func readAssociations(path string) (*MimeAssociations, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + groups := parseGroups(data) + assoc := newAssociations() + + if g := groups[groupDefaults]; g != nil { + for mime, val := range g.keys { + parts := splitList(val) + if len(parts) > 0 { + assoc.Defaults[mime] = parts[0] + } + } + } + + if g := groups[groupAdded]; g != nil { + for mime, val := range g.keys { + assoc.Added[mime] = splitList(val) + } + } + + if g := groups[groupRemoved]; g != nil { + for mime, val := range g.keys { + assoc.Removed[mime] = splitList(val) + } + } + + return assoc, nil +} + +func mergedAssociations() *MimeAssociations { + merged := newAssociations() + + for _, path := range mimeappsSearchPaths() { + assoc, err := readAssociations(path) + if err != nil { + continue + } + for mime, app := range assoc.Defaults { + if _, ok := merged.Defaults[mime]; !ok { + merged.Defaults[mime] = app + } + } + for mime, apps := range assoc.Added { + merged.Added[mime] = append(merged.Added[mime], apps...) + } + for mime, apps := range assoc.Removed { + merged.Removed[mime] = append(merged.Removed[mime], apps...) + } + } + + return merged +} + +func writeUserMimeapps(update func(*MimeAssociations)) error { + mimeappsWriteMu.Lock() + defer mimeappsWriteMu.Unlock() + + path := mimeappsWritePath() + + assoc, err := readAssociations(path) + if err != nil { + if !os.IsNotExist(err) { + return err + } + assoc = newAssociations() + } + + update(assoc) + + var buf bytes.Buffer + w := bufio.NewWriter(&buf) + + writeSection := func(name string, entries map[string]string) { + fmt.Fprintf(w, "[%s]\n", name) + keys := make([]string, 0, len(entries)) + for k := range entries { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(w, "%s=%s\n", k, entries[k]) + } + fmt.Fprintln(w) + } + + flatten := func(m map[string][]string) map[string]string { + out := make(map[string]string, len(m)) + for k, list := range m { + out[k] = strings.Join(list, ";") + ";" + } + return out + } + + writeSection(groupDefaults, assoc.Defaults) + writeSection(groupAdded, flatten(assoc.Added)) + writeSection(groupRemoved, flatten(assoc.Removed)) + + if err := w.Flush(); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0o644) +} + +func setDefaultAssociation(mimeType, desktopID string) error { + return setDefaultAssociations([]string{mimeType}, desktopID) +} + +func setDefaultAssociations(mimeTypes []string, desktopID string) error { + if !strings.HasSuffix(desktopID, ".desktop") { + desktopID += ".desktop" + } + return writeUserMimeapps(func(assoc *MimeAssociations) { + for _, mimeType := range mimeTypes { + if mimeType == "" { + continue + } + assoc.Defaults[mimeType] = desktopID + existing := assoc.Added[mimeType] + if !slices.Contains(existing, desktopID) { + assoc.Added[mimeType] = append(existing, desktopID) + } + removed, ok := assoc.Removed[mimeType] + if !ok { + continue + } + filtered := removed[:0] + for _, id := range removed { + if id != desktopID { + filtered = append(filtered, id) + } + } + switch { + case len(filtered) == 0: + delete(assoc.Removed, mimeType) + default: + assoc.Removed[mimeType] = filtered + } + } + }) +} diff --git a/core/internal/desktop/parser.go b/core/internal/desktop/parser.go new file mode 100644 index 00000000..2c9f363b --- /dev/null +++ b/core/internal/desktop/parser.go @@ -0,0 +1,80 @@ +package desktop + +import ( + "bufio" + "bytes" + "strings" +) + +type group struct { + keys map[string]string +} + +func parseGroups(data []byte) map[string]*group { + groups := make(map[string]*group) + var current *group + + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || line[0] == '#' { + continue + } + + if line[0] == '[' && strings.HasSuffix(line, "]") { + name := line[1 : len(line)-1] + g, ok := groups[name] + if !ok { + g = &group{keys: make(map[string]string)} + groups[name] = g + } + current = g + continue + } + + if current == nil { + continue + } + + eq := strings.IndexByte(line, '=') + if eq <= 0 { + continue + } + + key := strings.TrimSpace(line[:eq]) + if bracket := strings.IndexByte(key, '['); bracket > 0 { + key = key[:bracket] + } + if _, ok := current.keys[key]; ok { + continue + } + current.keys[key] = strings.TrimSpace(line[eq+1:]) + } + + return groups +} + +func splitList(value string) []string { + if value == "" { + return nil + } + parts := strings.Split(value, ";") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out +} + +func parseBool(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "yes": + return true + } + return false +} diff --git a/core/internal/server/apppicker/handlers.go b/core/internal/server/apppicker/handlers.go index 007ab3eb..6fa41632 100644 --- a/core/internal/server/apppicker/handlers.go +++ b/core/internal/server/apppicker/handlers.go @@ -3,6 +3,7 @@ package apppicker import ( "net" + "github.com/AvengeMedia/DankMaterialShell/core/internal/desktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" ) @@ -32,7 +33,7 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) { event := OpenEvent{ Target: target, RequestType: models.GetOr(req, "requestType", "url"), - MimeType: models.GetOr(req, "mimeType", ""), + MimeType: desktop.StripMimeParams(models.GetOr(req, "mimeType", "")), } if categories, ok := models.Get[[]any](req, "categories"); ok { diff --git a/core/internal/server/mime/handlers.go b/core/internal/server/mime/handlers.go new file mode 100644 index 00000000..de4d6296 --- /dev/null +++ b/core/internal/server/mime/handlers.go @@ -0,0 +1,154 @@ +package mime + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/desktop" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +type defaultResult struct { + MimeType string `json:"mimeType"` + DesktopID string `json:"desktopId"` +} + +type appsResult struct { + MimeType string `json:"mimeType"` + DesktopIDs []string `json:"desktopIds"` +} + +type queryResult struct { + Defaults map[string]string `json:"defaults"` +} + +func HandleRequest(conn net.Conn, req models.Request) { + switch req.Method { + case "mime.getDefault": + handleGetDefault(conn, req) + case "mime.setDefault": + handleSetDefault(conn, req) + case "mime.setDefaults": + handleSetDefaults(conn, req) + case "mime.appsForMime": + handleAppsForMime(conn, req) + case "mime.queryDefaults": + handleQueryDefaults(conn, req) + case "mime.invalidate": + desktop.InvalidateCache() + models.Respond(conn, req.ID, models.SuccessResult{Success: true}) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleGetDefault(conn net.Conn, req models.Request) { + mimeType, err := mimeParam(req.Params, "mimeType") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, defaultResult{ + MimeType: mimeType, + DesktopID: desktop.GetDefault(mimeType), + }) +} + +func handleSetDefault(conn net.Conn, req models.Request) { + mimeType, err := mimeParam(req.Params, "mimeType") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + desktopID, err := params.StringNonEmpty(req.Params, "desktopId") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + if err := desktop.SetDefault(mimeType, desktopID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true}) +} + +func handleSetDefaults(conn net.Conn, req models.Request) { + desktopID, err := params.StringNonEmpty(req.Params, "desktopId") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + mimeTypes, err := mimeListParam(req, "mimeTypes") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + if err := desktop.SetDefaults(mimeTypes, desktopID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true}) +} + +func handleAppsForMime(conn net.Conn, req models.Request) { + mimeType, err := mimeParam(req.Params, "mimeType") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + ids := desktop.AppsForMime(mimeType) + if ids == nil { + ids = []string{} + } + models.Respond(conn, req.ID, appsResult{ + MimeType: mimeType, + DesktopIDs: ids, + }) +} + +func handleQueryDefaults(conn net.Conn, req models.Request) { + mimeTypes, err := mimeListParam(req, "mimeTypes") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, queryResult{ + Defaults: desktop.QueryDefaults(mimeTypes), + }) +} + +func mimeParam(p map[string]any, key string) (string, error) { + raw, err := params.StringNonEmpty(p, key) + if err != nil { + return "", err + } + canonical := desktop.StripMimeParams(raw) + if canonical == "" { + return "", fmt.Errorf("invalid '%s' parameter", key) + } + return canonical, nil +} + +func mimeListParam(req models.Request, key string) ([]string, error) { + raw, ok := models.Get[[]any](req, key) + if !ok { + return nil, fmt.Errorf("missing or invalid '%s' parameter", key) + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + s, ok := v.(string) + if !ok { + continue + } + canonical := desktop.StripMimeParams(s) + if canonical == "" { + continue + } + out = append(out, canonical) + } + if len(out) == 0 { + return nil, fmt.Errorf("no valid mime types provided") + } + return out, nil +} diff --git a/core/internal/server/router.go b/core/internal/server/router.go index e0f97b80..988b8b28 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -16,6 +16,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/location" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/mime" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" @@ -92,6 +93,11 @@ func RouteRequest(conn net.Conn, req models.Request) { return } + if strings.HasPrefix(req.Method, "mime.") { + mime.HandleRequest(conn, req) + return + } + if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") { if appPickerManager == nil { models.RespondError(conn, req.ID, "apppicker manager not initialized") diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 4ece67af..eb586ae0 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -850,6 +850,8 @@ Item { filePickerModal.targetData = data.target; filePickerModal.targetDataLabel = data.requestType || "file"; + filePickerModal.mimeType = data.mimeType || ""; + filePickerModal.rememberMimeTypes = []; if (data.categories && data.categories.length > 0) { filePickerModal.categoryFilter = data.categories; diff --git a/quickshell/Modals/AppPickerModal.qml b/quickshell/Modals/AppPickerModal.qml index ba66acc0..899f0c85 100644 --- a/quickshell/Modals/AppPickerModal.qml +++ b/quickshell/Modals/AppPickerModal.qml @@ -19,9 +19,19 @@ DankModal { property var categoryFilter: [] property var usageHistoryKey: "" property bool showTargetData: true + property string mimeType: "" + property var rememberMimeTypes: [] + property bool rememberChoice: false + property var mimeMatchedAppIds: [] signal applicationSelected(var app, string targetData) + function _normAppId(id) { + if (!id) + return ""; + return id.replace(/\.desktop$/, "").toLowerCase(); + } + shouldBeVisible: false allowStacking: true modalWidth: 520 @@ -37,6 +47,8 @@ DankModal { onOpened: { searchQuery = ""; + rememberChoice = false; + fetchMimeMatches(); updateApplicationList(); selectedIndex = 0; Qt.callLater(() => { @@ -47,22 +59,55 @@ DankModal { }); } + function fetchMimeMatches() { + mimeMatchedAppIds = []; + const queriedMime = mimeType; + if (queriedMime.length === 0) + return; + DMSService.sendRequest("mime.appsForMime", { + "mimeType": queriedMime + }, response => { + if (queriedMime !== root.mimeType) + return; + if (response.error) { + log.warn("mime.appsForMime failed:", response.error); + return; + } + const ids = (response.result && response.result.desktopIds) || []; + mimeMatchedAppIds = ids.map(_normAppId); + updateApplicationList(); + }); + } + + function _appMatchesMime(app, mime) { + const list = app && (app.mimeTypes || app.mimeType); + return !!list && !!list.includes && list.includes(mime); + } + function updateApplicationList() { applicationsModel.clear(); const apps = AppSearchService.applications; const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {}; + const hasCategoryFilter = categoryFilter.length > 0; + const hasMime = mimeType.length > 0; + const hasMimeMatches = mimeMatchedAppIds.length > 0; + const lowerQuery = searchQuery.toLowerCase(); let filteredApps = []; for (const app of apps) { - if (!app || !app.categories) + if (!app) continue; - let matchesCategory = categoryFilter.length === 0; + const appId = _normAppId(app.id || app.execString || app.exec || ""); + const mimeIdMatch = hasMimeMatches && mimeMatchedAppIds.includes(appId); + const mimeFieldMatch = hasMime && _appMatchesMime(app, mimeType); + const mimeMatch = mimeIdMatch || mimeFieldMatch; - if (categoryFilter.length > 0) { + let categoryMatch = false; + if (hasCategoryFilter && app.categories) { try { for (const cat of app.categories) { if (categoryFilter.includes(cat)) { - matchesCategory = true; + categoryMatch = true; break; } } @@ -72,24 +117,28 @@ DankModal { } } - if (matchesCategory) { - const name = app.name || ""; - const lowerName = name.toLowerCase(); - const lowerQuery = searchQuery.toLowerCase(); + const include = (!hasCategoryFilter && !hasMime) || mimeMatch || categoryMatch; + if (!include) + continue; - 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 - }); - } - } + const name = app.name || ""; + if (searchQuery !== "" && !name.toLowerCase().includes(lowerQuery)) + continue; + + filteredApps.push({ + name: name, + icon: app.icon || "application-x-executable", + exec: app.exec || app.execString || "", + startupClass: app.startupWMClass || "", + appData: app, + mimeMatch: mimeMatch + }); } filteredApps.sort((a, b) => { + if (a.mimeMatch !== b.mimeMatch) { + return a.mimeMatch ? -1 : 1; + } 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; @@ -134,16 +183,15 @@ DankModal { } 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"; + if (event.key === Qt.Key_Tab && root.mimeType.length > 0) { + root.rememberChoice = !root.rememberChoice; event.accepted = true; return; } + if (applicationsModel.count === 0) + return; + if (root.viewMode === "grid") { if (event.key === Qt.Key_Left) { root.keyboardNavigationActive = true; @@ -309,6 +357,9 @@ DankModal { if (root.showTargetData) { usedHeight += 36 + Theme.spacingS; } + if (root.mimeType && root.mimeType.length > 0) { + usedHeight += 36 + Theme.spacingS; + } return parent.height - usedHeight; } radius: Theme.cornerRadius @@ -447,11 +498,38 @@ DankModal { maximumLineCount: 1 } } + + Item { + width: parent.width + height: 36 + visible: root.mimeType.length > 0 + + DankToggle { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + checked: root.rememberChoice + text: I18n.tr("Always use this app for %1").arg(root.mimeType) + onToggled: checked => { + root.rememberChoice = checked; + } + } + } } function launchApplication(app) { if (!app) return; + + if (root.rememberChoice && app.appId) { + const targets = (root.rememberMimeTypes && root.rememberMimeTypes.length > 0) ? root.rememberMimeTypes : (root.mimeType ? [root.mimeType] : []); + if (targets.length > 0) { + DesktopService.setDefaultAppForMimes(targets, app.appId); + } + } + root.applicationSelected(app, root.targetData); if (usageHistoryKey && app.appId) { diff --git a/quickshell/Modals/BrowserPickerModal.qml b/quickshell/Modals/BrowserPickerModal.qml index eee3bd42..7d2326f0 100644 --- a/quickshell/Modals/BrowserPickerModal.qml +++ b/quickshell/Modals/BrowserPickerModal.qml @@ -17,6 +17,8 @@ AppPickerModal { viewMode: SettingsData.browserPickerViewMode || "grid" usageHistoryKey: "browserUsageHistory" showTargetData: true + mimeType: url.startsWith("https://") ? "x-scheme-handler/https" : (url.startsWith("http://") ? "x-scheme-handler/http" : "") + rememberMimeTypes: ["x-scheme-handler/http", "x-scheme-handler/https", "text/html", "application/xhtml+xml"] function shellEscape(str) { return "'" + str.replace(/'/g, "'\\''") + "'"; diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 39b911fb..4c01b9cd 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -244,8 +244,7 @@ Rectangle { "id": "default_apps", "text": I18n.tr("Default Apps"), "icon": "star", - "tabIndex": 34, - "gioOnly": true + "tabIndex": 34 }, { "id": "running_apps", @@ -364,8 +363,6 @@ Rectangle { return false; if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable) return false; - if (item.gioOnly && !DesktopService.gioAvailable) - return false; return true; } diff --git a/quickshell/Modules/Settings/DefaultAppsTab.qml b/quickshell/Modules/Settings/DefaultAppsTab.qml index 7f619c2d..ae010aa3 100644 --- a/quickshell/Modules/Settings/DefaultAppsTab.qml +++ b/quickshell/Modules/Settings/DefaultAppsTab.qml @@ -19,7 +19,7 @@ Item { PDFReader: 6, Mail: 7, Terminal: 8, - Calendar: 9 + Calendar: 9 }) property string currentWebBrowserAppId: "" @@ -35,63 +35,17 @@ Item { property var categoryModels: ({}) - // A curated list of MIME types for each category. - // The first one is used for fetching the apps list and current default, - // the rest are for setting the default app. + // A curated list of MIME types for each category. + // The first one is used for fetching the apps list and current default, + // the rest are for setting the default app. readonly property var mimeMapping: ({ - [root.appCategory.WebBrowser]: [ - "x-scheme-handler/https", - "x-scheme-handler/http", - "text/html", - "application/xhtml+xml" - ], - [root.appCategory.FileManager]: [ - "inode/directory", - "x-scheme-handler/file" - ], - [root.appCategory.TextEditor]: [ - "text/plain", - "application/x-zerosize", - "text/x-c++src", - "text/x-csrc", - "text/x-python", - "text/x-shellscript", - "application/json" - ], - [root.appCategory.ImageViewer]: [ - "image/png", - "image/jpeg", - "image/gif", - "image/bmp", - "image/webp", - "image/avif", - "image/svg+xml" - ], - [root.appCategory.VideoPlayer]: [ - "video/mp4", - "video/x-matroska", - "video/webm", - "video/avi", - "video/mpeg", - "video/quicktime", - "video/x-msvideo" - ], - [root.appCategory.MusicPlayer]: [ - "audio/mpeg", - "audio/x-flac", - "audio/wav", - "audio/ogg", - "audio/aac", - "audio/webm" - ], - [root.appCategory.PDFReader]: [ - "application/pdf", - "application/x-ext-pdf", - "application/x-bzpdf", - "application/x-gzpdf", - "application/vnd.comicbook-rar", - "application/vnd.comicbook+zip" - ], + [root.appCategory.WebBrowser]: ["x-scheme-handler/https", "x-scheme-handler/http", "text/html", "application/xhtml+xml"], + [root.appCategory.FileManager]: ["inode/directory", "x-scheme-handler/file"], + [root.appCategory.TextEditor]: ["text/plain", "application/x-zerosize", "text/x-c++src", "text/x-csrc", "text/x-python", "text/x-shellscript", "application/json"], + [root.appCategory.ImageViewer]: ["image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp", "image/avif", "image/svg+xml"], + [root.appCategory.VideoPlayer]: ["video/mp4", "video/x-matroska", "video/webm", "video/avi", "video/mpeg", "video/quicktime", "video/x-msvideo"], + [root.appCategory.MusicPlayer]: ["audio/mpeg", "audio/x-flac", "audio/wav", "audio/ogg", "audio/aac", "audio/webm"], + [root.appCategory.PDFReader]: ["application/pdf", "application/x-ext-pdf", "application/x-bzpdf", "application/x-gzpdf", "application/vnd.comicbook-rar", "application/vnd.comicbook+zip"], [root.appCategory.Mail]: ["x-scheme-handler/mailto"], [root.appCategory.Calendar]: ["x-scheme-handler/calendar"], [root.appCategory.Terminal]: ["terminal"] // Special @@ -111,11 +65,13 @@ Item { } function getAppDisplayName(appId) { + if (appId === root.dmsChooserId || appId === "dms-open") { + return root.dmsChooserLabel; + } let entry = DesktopEntries.heuristicLookup(appId); if (entry && entry.name) { return entry.name; } - // If the appname can't be found, show the appID const withoutSuffix = appId.replace(/\.desktop$/, ""); if (withoutSuffix !== appId) { entry = DesktopEntries.heuristicLookup(withoutSuffix); @@ -126,14 +82,28 @@ Item { return appId; } + readonly property string dmsChooserId: "dms-open.desktop" + readonly property string dmsChooserLabel: I18n.tr("DMS Chooser") + + function withDmsChooser(entries) { + const filtered = (entries || []).filter(e => e.value !== root.dmsChooserId && e.value !== "dms-open"); + return [ + { + text: root.dmsChooserLabel, + value: root.dmsChooserId + } + ].concat(filtered); + } + function loadCategoryModel(categoryKey, categorySearchName) { const apps = loadAppSearchCategory(categorySearchName); const appIds = apps.map(app => app.id || app.execString || "").filter(id => id); let models = Object.assign({}, root.categoryModels); - models[categoryKey] = appIds.map(id => ({ - text: root.getAppDisplayName(id), - value: id - })); + const entries = appIds.map(id => ({ + text: root.getAppDisplayName(id), + value: id + })); + models[categoryKey] = categoryKey === root.appCategory.Terminal ? entries : root.withDmsChooser(entries); root.categoryModels = models; } @@ -147,9 +117,9 @@ Item { loadCategoryModel(root.appCategory.Terminal, "TerminalEmulator"); getDefaultTerminal(); break; - case root.appCategory.WebBrowser: + case root.appCategory.WebBrowser: // When using the MIME type, stuff like dms-run shows up. - // It's probably better to use the category. + // It's probably better to use the category. loadCategoryModel(root.appCategory.WebBrowser, "WebBrowser"); DesktopService.getDefaultApp(mimeMapping[category][0], category.toString()); break; @@ -201,7 +171,7 @@ Item { Component { id: xdgGetDefaultTerminal Process { - property string configPath: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config") + property string configPath: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config") command: ["sh", "-c", `cat '${configPath}/xdg-terminals.list'`] stdout: StdioCollector { @@ -231,30 +201,24 @@ Item { target: DesktopService function onGetAppsForMimeResult(mimeType, appIds, callbackId) { - if (!appIds || appIds.length === 0) { - log.info("No apps found for MIME type:", mimeType); - return; - } - let categoryIndex = parseInt(callbackId); let models = Object.assign({}, root.categoryModels); - models[categoryIndex] = appIds.map(id => { - return { - text: root.getAppDisplayName(id), - value: id - }; - }); + const entries = (appIds || []).map(id => ({ + text: root.getAppDisplayName(id), + value: id + })); + models[categoryIndex] = root.withDmsChooser(entries); root.categoryModels = models; } function onGetDefaultAppResult(mimeType, desktopFileId, callbackId) { if (!desktopFileId) { log.info("No default app found for MIME type:", mimeType); - return + return; } - root[propertyName(parseInt(callbackId))] = desktopFileId; + root[propertyName(parseInt(callbackId))] = desktopFileId; } } @@ -263,11 +227,11 @@ Item { options: (root.categoryModels[category] || []).map(opt => opt.text) enabled: options.length > 0 emptyText: options.length > 0 ? I18n.tr("Unset", "Unset") : "" - opacity: options.length > 0 ? 1 : 0.5 + opacity: options.length > 0 ? 1 : 0.5 currentValue: { let id = root[propertyName(category)]; if (!id || id.length === 0) { - return "" + return ""; } return root.getAppDisplayName(id); } @@ -278,11 +242,7 @@ Item { if (category === root.appCategory.Terminal) { root.setDefaultTerminal(found.value); } else { - // Set the default app for all MIME types in the category - // If the app doesn't support a MIME type, it will be ignored - root.mimeMapping[category].forEach(mimeType => { - DesktopService.setDefaultApp(mimeType, found.value, category.toString()); - }); + DesktopService.setDefaultAppForMimes(root.mimeMapping[category], found.value, category.toString()); } } } @@ -309,16 +269,16 @@ Item { AppSelector { text: I18n.tr("Web Browser", "Web Browser") - tags: ["web", "browser", "internet"] + tags: ["web", "browser", "internet"] category: root.appCategory.WebBrowser - description: I18n.tr("Handles links and opens HTML files", "Handles links and opens HTML files") + description: I18n.tr("Handles links and opens HTML files", "Handles links and opens HTML files") } AppSelector { text: I18n.tr("Mail", "Mail") category: root.appCategory.Mail - tags: ["mail", "email"] - description: I18n.tr("Handles mailto links", "Handles mailto links") + tags: ["mail", "email"] + description: I18n.tr("Handles mailto links", "Handles mailto links") } } @@ -328,21 +288,21 @@ Item { AppSelector { text: I18n.tr("File Manager", "File Manager") - tags: ["file", "manager"] + tags: ["file", "manager"] category: root.appCategory.FileManager - description: I18n.tr("Manages files and directories", "Manages files and directories") + description: I18n.tr("Manages files and directories", "Manages files and directories") } AppSelector { text: I18n.tr("Terminal", "Terminal") category: root.appCategory.Terminal - tags: ["terminal", "console"] - description: I18n.tr("Used for xdg-terminal-exec", "Used for xdg-terminal-exec") + tags: ["terminal", "console"] + description: I18n.tr("Used for xdg-terminal-exec", "Used for xdg-terminal-exec") } - AppSelector { + AppSelector { text: I18n.tr("Calendar", "Calendar") category: root.appCategory.Calendar - tags: ["calendar", "events"] - description: I18n.tr("Manages calendar events", "Manages calendar events") + tags: ["calendar", "events"] + description: I18n.tr("Manages calendar events", "Manages calendar events") } } @@ -353,14 +313,14 @@ Item { AppSelector { text: I18n.tr("Text Editor", "Text Editor") category: root.appCategory.TextEditor - tags: ["text", "editor"] - description: I18n.tr("For editing plain text files", "For editing plain text files") + tags: ["text", "editor"] + description: I18n.tr("For editing plain text files", "For editing plain text files") } AppSelector { text: I18n.tr("PDF Reader", "PDF Reader") category: root.appCategory.PDFReader - tags: ["pdf", "reader"] - description: I18n.tr("For reading PDF files", "For reading PDF files") + tags: ["pdf", "reader"] + description: I18n.tr("For reading PDF files", "For reading PDF files") } } @@ -370,20 +330,20 @@ Item { AppSelector { text: I18n.tr("Image Viewer", "Image Viewer") category: root.appCategory.ImageViewer - tags: ["image", "viewer"] - description: I18n.tr("Opens image files", "Opens image files") + tags: ["image", "viewer"] + description: I18n.tr("Opens image files", "Opens image files") } AppSelector { text: I18n.tr("Video Player", "Video Player") category: root.appCategory.VideoPlayer - tags: ["video", "player"] - description: I18n.tr("Plays video files", "Plays video files") + tags: ["video", "player"] + description: I18n.tr("Plays video files", "Plays video files") } AppSelector { text: I18n.tr("Music Player", "Music Player") category: root.appCategory.MusicPlayer - tags: ["music", "player"] - description: I18n.tr("Plays audio files", "Plays audio files") + tags: ["music", "player"] + description: I18n.tr("Plays audio files", "Plays audio files") } } } diff --git a/quickshell/Services/DesktopService.qml b/quickshell/Services/DesktopService.qml index bebcc520..34bcae77 100644 --- a/quickshell/Services/DesktopService.qml +++ b/quickshell/Services/DesktopService.qml @@ -1,35 +1,16 @@ pragma Singleton pragma ComponentBehavior: Bound -import Quickshell.Io import QtQuick import Quickshell +import qs.Common +import qs.Services Singleton { id: root + readonly property var log: Log.scoped("DesktopService") property var _cache: ({}) - property bool gioAvailable: false; - // For the queue that setDefaultApp uses - property var _setDefaultAppQueue: [] - property bool _isProcessingQueue: false - - Component.onCompleted: { - checkGioAndXdgMime.running = true; - } - - Process { - id: checkGioAndXdgMime - command: ["sh", "-c", "which gio && which xdg-mime"] - running: false - onExited: (exitCode) => { - if (exitCode === 0) { - root.gioAvailable = true; - } else { - root.gioAvailable = false; - } - } - } function resolveIconPath(moddedAppId) { if (!moddedAppId) @@ -39,18 +20,15 @@ Singleton { return _cache[moddedAppId]; const result = (function () { - // 1. Try heuristic lookup (standard) const entry = DesktopEntries.heuristicLookup(moddedAppId); let icon = Quickshell.iconPath(entry?.icon, true); if (icon && icon !== "") return icon; - // 2. Try the appId itself as an icon name icon = Quickshell.iconPath(moddedAppId, true); if (icon && icon !== "") return icon; - // 3. Try variations of the appId (lowercase, last part) const appIds = [moddedAppId.toLowerCase()]; const lastPart = moddedAppId.split('.').pop(); if (lastPart && lastPart !== moddedAppId) { @@ -64,8 +42,6 @@ Singleton { return icon; } - // 4. Deep search in all desktop entries (if the above fail) - // This is slow-ish but only happens once for failed icons const strippedId = moddedAppId.replace(/-bin$/, "").toLowerCase(); const allEntries = DesktopEntries.applications.values; for (let i = 0; i < allEntries.length; i++) { @@ -81,7 +57,6 @@ Singleton { } } - // 5. Nix/Guix specific store check (as a last resort) for (const appId of appIds) { let execPath = entry?.execString?.replace(/\/bin.*/, ""); if (!execPath) @@ -112,180 +87,53 @@ Singleton { return result; } + signal getDefaultAppResult(string mimeType, string desktopFileId, string callbackId) + signal getAppsForMimeResult(string mimeType, var appIds, string callbackId) - // Set default app for a MIME type - Component { - id: gioSetDefaultApp + function setDefaultApp(mimeType, desktopFileId, callbackId = "") { + setDefaultAppForMimes([mimeType], desktopFileId, callbackId); + } - Process { - property string targetMimeType: "" - property string targetDesktopFileId: "" - property string callbackId: "" - - // Check if the app actually supports the MIME type before setting it as default - // This uses a shell script - command: ["sh", "-c", ` - apps=$(gio mime "${targetMimeType}" 2>/dev/null | grep -v "^Default" | awk '{print $1}') - if echo "$apps" | grep -Fxq "${targetDesktopFileId}"; then - xdg-mime default "${targetDesktopFileId}" "${targetMimeType}" - gio mime "${targetMimeType}" "${targetDesktopFileId}" - fi - `] - - onExited: (exitCode, exitStatus) => { - const success = (exitCode === 0) - if (!success) { - log.error("DesktopService: failed to set default app for", targetMimeType, "to", targetDesktopFileId, "(exit code:", exitCode + ")") - } - root._processDefaultAppQueue() - destroy() + function setDefaultAppForMimes(mimeTypes, desktopFileId, callbackId = "") { + if (!desktopFileId.endsWith(".desktop")) { + desktopFileId += ".desktop"; + } + const filtered = (mimeTypes || []).filter(m => m && m.length > 0); + if (filtered.length === 0) + return; + DMSService.sendRequest("mime.setDefaults", { + "mimeTypes": filtered, + "desktopId": desktopFileId + }, response => { + if (response.error) { + log.warn("DesktopService.setDefaultApp failed:", response.error, "mimes:", filtered, "app:", desktopFileId); } - } - } - - function setDefaultApp(mimeType, desktopFileId, callbackId = "") { - // Add .desktop in case it's missing, xdg-mime needs it - if (!desktopFileId.endsWith(".desktop")) { - desktopFileId += ".desktop"; - } - - // Queue the request to avoid race conditions - _setDefaultAppQueue.push({ - mimeType: mimeType, - desktopFileId: desktopFileId, - callbackId: callbackId - }) - - // Start processing the queue if not already running - if (!_isProcessingQueue) { - _processDefaultAppQueue() - } - } - - function _processDefaultAppQueue() { - if (_setDefaultAppQueue.length === 0) { - _isProcessingQueue = false; - return; - } - - _isProcessingQueue = true; - const request = _setDefaultAppQueue.shift(); - - const proc = gioSetDefaultApp.createObject(root, { - targetMimeType: request.mimeType, - targetDesktopFileId: request.desktopFileId, - callbackId: request.callbackId, - running: true - }) - - if (!proc) { - log.warn("DesktopService: couldn't create process for", request.mimeType, request.desktopFileId) - _processDefaultAppQueue() - } - } - - - - // Get default app for a MIME type - Component { - id: xdgGetDefaultApp - - Process { - property string targetMimeType: "" - property string callbackId: "" - - stdout: StdioCollector { - onStreamFinished: { - const desktopFileId = text.trim(); - root.getDefaultAppResult(targetMimeType, desktopFileId, callbackId); - } - } - - stderr: StdioCollector { - onStreamFinished: { - if (text.trim().length > 0) { - log.error("DesktopService: xdg-mime query error:", text, "mime:", targetMimeType) - } - } - } - - onExited: (exitCode, exitStatus) => { destroy() } - } + }); } function getDefaultApp(mimeType, callbackId = "") { - const proc = xdgGetDefaultApp.createObject(root, { - targetMimeType: mimeType, - callbackId: callbackId, - command: ["xdg-mime", "query", "default", mimeType], - running: true - }) - - if (!proc) { - log.warn("DesktopService: couldn't create process for", mimeType) - } + DMSService.sendRequest("mime.getDefault", { + "mimeType": mimeType + }, response => { + if (response.error) { + log.warn("DesktopService.getDefaultApp failed:", response.error, "mime:", mimeType); + return; + } + const result = response.result || {}; + root.getDefaultAppResult(mimeType, result.desktopId || "", callbackId); + }); } - signal getDefaultAppResult(string mimeType, string desktopFileId, string callbackId) - - - - // Get apps that support a MIME type - Component { - id: gioGetAppsForMime - - Process { - property string targetMimeType: "" - property string callbackId: "" - - stdout: StdioCollector { - onStreamFinished: { - const lines = text.split("\n"); - let appIds = []; - let seen = {}; - - for (let line of lines) { - const trimmed = line.trim(); - if ( - trimmed && - trimmed.endsWith(".desktop") && - !trimmed.startsWith("Default") && - !trimmed.startsWith("default=") - ) { - if (!seen[trimmed]) { - seen[trimmed] = true; - appIds.push(trimmed); - } - } - } - root.getAppsForMimeResult(targetMimeType, appIds, callbackId); - } - } - - stderr: StdioCollector { - onStreamFinished: { - if (text.trim().length > 0) { - log.error("DesktopService: gio mime query error:", text, "command:", command, "mime:", targetMimeType); - } - } - } - - onExited: (exitCode, exitStatus) => { destroy() } - } + function getAppsForMimeType(mimeType, callbackId = "") { + DMSService.sendRequest("mime.appsForMime", { + "mimeType": mimeType + }, response => { + if (response.error) { + log.warn("DesktopService.getAppsForMimeType failed:", response.error, "mime:", mimeType); + return; + } + const result = response.result || {}; + root.getAppsForMimeResult(mimeType, result.desktopIds || [], callbackId); + }); } - - function getAppsForMimeType(mimeType, callbackId = "") { - const proc = gioGetAppsForMime.createObject(root, { - targetMimeType: mimeType, - callbackId: callbackId, - command: ["gio", "mime", mimeType], - running: true - }); - - if (!proc) { - log.warn("DesktopService: couldn't create process for", mimeType) - } - } - - signal getAppsForMimeResult(string mimeType, var appIds, string callbackId) }