1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-15 00:32:47 -04:00

app picker: extend App Picker to integrate with mime overrides

- Adds "DMS Opener" as an option (dms-open.desktop)
- Add mime type GO utils
- Add rememberance to App Picker modal
This commit is contained in:
bbedward
2026-05-14 13:06:22 -04:00
parent be4ea71756
commit 018795125e
15 changed files with 1467 additions and 427 deletions
+36 -97
View File
@@ -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)
}
+203
View File
@@ -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()
}
+311
View File
@@ -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
}
+233
View File
@@ -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
}
+226
View File
@@ -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
}
}
})
}
+80
View File
@@ -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
}
+2 -1
View File
@@ -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 {
+154
View File
@@ -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
}
+6
View File
@@ -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")
+2
View File
@@ -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;
+102 -24
View File
@@ -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) {
+2
View File
@@ -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, "'\\''") + "'";
@@ -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;
}
+66 -106
View File
@@ -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")
}
}
}
+43 -195
View File
@@ -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)
}