diff --git a/core/internal/desktop/mime.go b/core/internal/desktop/mime.go index dde950ce1..84ffaf4fd 100644 --- a/core/internal/desktop/mime.go +++ b/core/internal/desktop/mime.go @@ -302,6 +302,22 @@ func AppsForMime(mimeType string) []string { return out } +// HandlersForMime lists launchable handler entries for a mime type, +// excluding Terminal=true and NoDisplay=true desktop entries so pickers +// only offer graphical applications. +func HandlersForMime(mimeType string) []*Entry { + ids := AppsForMime(mimeType) + out := make([]*Entry, 0, len(ids)) + for _, id := range ids { + entry := EntryByID(id) + if entry == nil || entry.Hidden || entry.NoDisplay || entry.Terminal { + continue + } + out = append(out, entry) + } + return out +} + func QueryDefaults(mimeTypes []string) map[string]string { out := make(map[string]string, len(mimeTypes)) for _, m := range mimeTypes { diff --git a/core/internal/desktop/mimeapps.go b/core/internal/desktop/mimeapps.go index 6896ac72b..1feb56cb3 100644 --- a/core/internal/desktop/mimeapps.go +++ b/core/internal/desktop/mimeapps.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "fmt" + "maps" "os" "path/filepath" "slices" @@ -81,7 +82,10 @@ func readAssociations(path string) (*MimeAssociations, error) { if err != nil { return nil, err } + return associationsFromData(data), nil +} +func associationsFromData(data []byte) *MimeAssociations { groups := parseGroups(data) assoc := newAssociations() @@ -106,7 +110,7 @@ func readAssociations(path string) (*MimeAssociations, error) { } } - return assoc, nil + return assoc } func mergedAssociations() *MimeAssociations { @@ -144,40 +148,22 @@ func writeUserMimeapps(update func(*MimeAssociations)) error { path := mimeappsWritePath() - assoc, err := readAssociations(path) - if err != nil { - if !os.IsNotExist(err) { - return err - } - assoc = newAssociations() + original, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err } + assoc := associationsFromData(original) update(assoc) - var buf bytes.Buffer - w := bufio.NewWriter(&buf) - - var writeErr error - 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 { - v := entries[k] - if !isSafeIniField(k) || !isSafeIniField(v) { - if writeErr == nil { - writeErr = fmt.Errorf("invalid mimeapps.list field %q=%q", k, v) - } - continue - } - fmt.Fprintf(w, "%s=%s\n", k, v) - } - fmt.Fprintln(w) + rendered, err := renderMimeapps(original, assoc) + if err != nil { + return err } + return atomicWriteFile(path, rendered) +} +func managedGroups(assoc *MimeAssociations) (map[string]map[string]string, error) { flatten := func(m map[string][]string) map[string]string { out := make(map[string]string, len(m)) for k, list := range m { @@ -186,22 +172,172 @@ func writeUserMimeapps(update func(*MimeAssociations)) error { return out } - writeSection(groupDefaults, assoc.Defaults) - writeSection(groupAdded, flatten(assoc.Added)) - writeSection(groupRemoved, flatten(assoc.Removed)) + groups := map[string]map[string]string{ + groupDefaults: maps.Clone(assoc.Defaults), + groupAdded: flatten(assoc.Added), + groupRemoved: flatten(assoc.Removed), + } + for name, entries := range groups { + for k, v := range entries { + if isSafeIniField(k) && isSafeIniField(v) { + continue + } + return nil, fmt.Errorf("invalid mimeapps.list field in [%s]: %q=%q", name, k, v) + } + } + return groups, nil +} - if writeErr != nil { - return writeErr +// renderMimeapps rewrites only the three association groups defined by the +// mime-apps spec (https://specifications.freedesktop.org/mime-apps-spec/), +// keeping comments, blank lines, and unrelated groups verbatim. +func renderMimeapps(original []byte, assoc *MimeAssociations) ([]byte, error) { + managed, err := managedGroups(assoc) + if err != nil { + return nil, err } - if err := w.Flush(); err != nil { + written := map[string]map[string]bool{ + groupDefaults: {}, + groupAdded: {}, + groupRemoved: {}, + } + + var out []string + + flushMissing := func(group string) { + entries, ok := managed[group] + if !ok { + return + } + keys := make([]string, 0, len(entries)) + for k := range entries { + if !written[group][k] { + keys = append(keys, k) + } + } + if len(keys) == 0 { + return + } + sort.Strings(keys) + + insertAt := len(out) + for insertAt > 0 && strings.TrimSpace(out[insertAt-1]) == "" { + insertAt-- + } + lines := make([]string, 0, len(keys)) + for _, k := range keys { + written[group][k] = true + lines = append(lines, k+"="+entries[k]) + } + tail := append([]string(nil), out[insertAt:]...) + out = append(out[:insertAt], append(lines, tail...)...) + } + + current := "" + scanner := bufio.NewScanner(bytes.NewReader(original)) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + flushMissing(current) + current = trimmed[1 : len(trimmed)-1] + out = append(out, line) + continue + } + + entries, isManaged := managed[current] + if !isManaged { + out = append(out, line) + continue + } + + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + out = append(out, line) + continue + } + + eq := strings.IndexByte(trimmed, '=') + if eq <= 0 { + out = append(out, line) + continue + } + + key := strings.TrimSpace(trimmed[:eq]) + val, keep := entries[key] + if !keep || written[current][key] { + continue + } + written[current][key] = true + out = append(out, key+"="+val) + } + if err := scanner.Err(); err != nil { + return nil, err + } + flushMissing(current) + + for _, group := range []string{groupDefaults, groupAdded, groupRemoved} { + entries := managed[group] + pending := make([]string, 0, len(entries)) + for k := range entries { + if !written[group][k] { + pending = append(pending, k) + } + } + if len(pending) == 0 { + continue + } + sort.Strings(pending) + + if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" { + out = append(out, "") + } + out = append(out, "["+group+"]") + for _, k := range pending { + written[group][k] = true + out = append(out, k+"="+entries[k]) + } + } + + if len(out) == 0 { + return nil, nil + } + return []byte(strings.Join(out, "\n") + "\n"), nil +} + +func atomicWriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + tmp, err := os.CreateTemp(dir, ".mimeapps-*.tmp") + if err != nil { return err } - return os.WriteFile(path, buf.Bytes(), 0o644) + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Chmod(0o644); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return err + } + return nil } func setDefaultAssociation(mimeType, desktopID string) error { diff --git a/core/internal/desktop/mimeapps_test.go b/core/internal/desktop/mimeapps_test.go new file mode 100644 index 000000000..193c88484 --- /dev/null +++ b/core/internal/desktop/mimeapps_test.go @@ -0,0 +1,210 @@ +package desktop + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSetDefaultsPreservesUnrelatedContent(t *testing.T) { + configHome, _ := setupFakeXDG(t) + path := filepath.Join(configHome, "mimeapps.list") + writeFile(t, path, `# managed by hand +[Default Applications] +text/html=firefox.desktop +# pinned image viewer +image/png=eog.desktop + +[Added Associations] +text/html=firefox.desktop; + +[Custom Section] +foo=bar +`) + + if err := SetDefaults([]string{"inode/directory", "x-scheme-handler/sftp"}, "org.kde.dolphin.desktop"); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(data) + + preserved := []string{ + "# managed by hand", + "# pinned image viewer", + "text/html=firefox.desktop", + "image/png=eog.desktop", + "[Custom Section]", + "foo=bar", + } + for _, want := range preserved { + if !strings.Contains(got, want) { + t.Errorf("rewrite lost %q:\n%s", want, got) + } + } + + written := []string{ + "inode/directory=org.kde.dolphin.desktop", + "x-scheme-handler/sftp=org.kde.dolphin.desktop", + } + for _, want := range written { + if !strings.Contains(got, want) { + t.Errorf("rewrite missing %q:\n%s", want, got) + } + } + + if got := GetDefault("inode/directory"); got != "org.kde.dolphin.desktop" { + t.Errorf("GetDefault(inode/directory) = %q", got) + } + if got := GetDefault("x-scheme-handler/sftp"); got != "org.kde.dolphin.desktop" { + t.Errorf("GetDefault(x-scheme-handler/sftp) = %q", got) + } +} + +func TestSetDefaultReplacesKeyInPlace(t *testing.T) { + configHome, _ := setupFakeXDG(t) + path := filepath.Join(configHome, "mimeapps.list") + writeFile(t, path, `[Default Applications] +inode/directory=foot.desktop +text/html=firefox.desktop +`) + + if err := SetDefault("inode/directory", "org.gnome.Nautilus.desktop"); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(data) + + if n := strings.Count(got, "inode/directory=org.gnome.Nautilus.desktop\n"); n != 1 { + t.Errorf("expected exactly one replaced default line, got %d:\n%s", n, got) + } + if strings.Contains(got, "foot.desktop") { + t.Errorf("old default still present:\n%s", got) + } + if !strings.Contains(got, "text/html=firefox.desktop") { + t.Errorf("unrelated default lost:\n%s", got) + } +} + +func TestSetDefaultsCreatesFileWhenAbsent(t *testing.T) { + configHome, _ := setupFakeXDG(t) + path := filepath.Join(configHome, "mimeapps.list") + + if err := SetDefaults([]string{"inode/directory", "x-scheme-handler/sftp"}, "org.kde.dolphin"); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(data) + + expected := []string{ + "[Default Applications]", + "inode/directory=org.kde.dolphin.desktop", + "x-scheme-handler/sftp=org.kde.dolphin.desktop", + "[Added Associations]", + } + for _, want := range expected { + if !strings.Contains(got, want) { + t.Errorf("new file missing %q:\n%s", want, got) + } + } + + entries, err := os.ReadDir(configHome) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Errorf("leftover temp file %q", e.Name()) + } + } +} + +func TestSetDefaultClearsRemovedAssociation(t *testing.T) { + configHome, _ := setupFakeXDG(t) + path := filepath.Join(configHome, "mimeapps.list") + writeFile(t, path, `[Removed Associations] +inode/directory=org.kde.dolphin.desktop;other.desktop; +`) + + if err := SetDefault("inode/directory", "org.kde.dolphin.desktop"); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(data) + + if !strings.Contains(got, "inode/directory=other.desktop;") { + t.Errorf("unrelated removed entry lost:\n%s", got) + } + if strings.Contains(got, "org.kde.dolphin.desktop;other.desktop") { + t.Errorf("chosen app still in removed list:\n%s", got) + } + if got := GetDefault("inode/directory"); got != "org.kde.dolphin.desktop" { + t.Errorf("GetDefault = %q", got) + } +} + +func TestHandlersForMimeExcludesTerminalAndNoDisplay(t *testing.T) { + _, dataHome := setupFakeXDG(t) + apps := filepath.Join(dataHome, "applications") + writeFile(t, filepath.Join(apps, "org.kde.dolphin.desktop"), `[Desktop Entry] +Type=Application +Name=Dolphin +Icon=system-file-manager +MimeType=inode/directory; +`) + writeFile(t, filepath.Join(apps, "ghost-term.desktop"), `[Desktop Entry] +Type=Application +Name=Ghost Terminal +Terminal=true +MimeType=inode/directory; +`) + writeFile(t, filepath.Join(apps, "hidden-fm.desktop"), `[Desktop Entry] +Type=Application +Name=Hidden FM +NoDisplay=true +MimeType=inode/directory; +`) + writeFile(t, filepath.Join(apps, "mimeinfo.cache"), `[MIME Cache] +inode/directory=ghost-term.desktop;org.kde.dolphin.desktop;hidden-fm.desktop;stale.desktop; +`) + + handlers := HandlersForMime("inode/directory") + byID := make(map[string]*Entry, len(handlers)) + for _, h := range handlers { + byID[h.ID] = h + } + + dolphin := byID["org.kde.dolphin.desktop"] + if dolphin == nil { + t.Fatal("org.kde.dolphin.desktop missing from handlers") + } + if dolphin.Name != "Dolphin" { + t.Errorf("Name = %q", dolphin.Name) + } + if dolphin.Icon != "system-file-manager" { + t.Errorf("Icon = %q", dolphin.Icon) + } + + excluded := []string{"ghost-term.desktop", "hidden-fm.desktop", "stale.desktop"} + for _, id := range excluded { + if byID[id] != nil { + t.Errorf("%s should be excluded from handlers", id) + } + } +} diff --git a/core/internal/server/mime/handlers.go b/core/internal/server/mime/handlers.go index 4c58c51ed..ab195c90f 100644 --- a/core/internal/server/mime/handlers.go +++ b/core/internal/server/mime/handlers.go @@ -18,6 +18,17 @@ type appsResult struct { DesktopIDs []string `json:"desktopIds"` } +type handlerApp struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon,omitempty"` +} + +type handlersResult struct { + MimeType string `json:"mimeType"` + Apps []handlerApp `json:"apps"` +} + type queryResult struct { Defaults map[string]string `json:"defaults"` } @@ -32,6 +43,8 @@ func HandleRequest(conn *models.Conn, req models.Request) { handleSetDefaults(conn, req) case "mime.appsForMime": handleAppsForMime(conn, req) + case "mime.handlersForMime": + handleHandlersForMime(conn, req) case "mime.queryDefaults": handleQueryDefaults(conn, req) case "mime.invalidate": @@ -106,6 +119,27 @@ func handleAppsForMime(conn *models.Conn, req models.Request) { }) } +func handleHandlersForMime(conn *models.Conn, req models.Request) { + mimeType, err := mimeParam(req.Params, "mimeType") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + entries := desktop.HandlersForMime(mimeType) + apps := make([]handlerApp, 0, len(entries)) + for _, entry := range entries { + apps = append(apps, handlerApp{ + ID: entry.ID, + Name: entry.Name, + Icon: entry.Icon, + }) + } + models.Respond(conn, req.ID, handlersResult{ + MimeType: mimeType, + Apps: apps, + }) +} + func handleQueryDefaults(conn *models.Conn, req models.Request) { mimeTypes, err := mimeListParam(req, "mimeTypes") if err != nil { diff --git a/quickshell/Modules/Settings/DefaultAppsTab.qml b/quickshell/Modules/Settings/DefaultAppsTab.qml index 73894aa9d..aa5920fd3 100644 --- a/quickshell/Modules/Settings/DefaultAppsTab.qml +++ b/quickshell/Modules/Settings/DefaultAppsTab.qml @@ -42,7 +42,7 @@ Item { // 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.FileManager]: ["inode/directory", "x-scheme-handler/file", "x-scheme-handler/sftp"], [root.appCategory.TextEditor]: ["text/plain", "text/markdown", "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"], @@ -131,6 +131,7 @@ Item { // you don't want Kate as your file manager just because it can open folders loadCategoryModel(root.appCategory.FileManager, "FileManager"); DesktopService.getDefaultApp(mimeMapping[category][0], category.toString()); + DesktopService.getHandlersForMimeType(mimeMapping[category][0], category.toString()); break; default: const mimeType = mimeMapping[category][0]; @@ -216,6 +217,22 @@ Item { root.categoryModels = models; } + function onGetHandlersForMimeResult(mimeType, apps, callbackId) { + const categoryIndex = parseInt(callbackId); + let models = Object.assign({}, root.categoryModels); + const existing = models[categoryIndex] || root.withDmsChooser([]); + const known = new Set(existing.map(opt => opt.value)); + const extra = (apps || []).filter(app => app.id && !known.has(app.id)).map(app => ({ + text: app.name || root.getAppDisplayName(app.id), + value: app.id + })); + if (extra.length === 0) { + return; + } + models[categoryIndex] = existing.concat(extra); + root.categoryModels = models; + } + function onGetDefaultAppResult(mimeType, desktopFileId, callbackId) { if (!desktopFileId) { log.info("No default app found for MIME type:", mimeType); @@ -302,7 +319,7 @@ Item { AppSelector { text: I18n.tr("File Manager", "File Manager") - tags: ["file", "manager"] + tags: ["file", "manager", "directory", "sftp"] category: root.appCategory.FileManager description: I18n.tr("Manages files and directories", "Manages files and directories") } diff --git a/quickshell/Services/DesktopService.qml b/quickshell/Services/DesktopService.qml index 23b832655..64b1dc94c 100644 --- a/quickshell/Services/DesktopService.qml +++ b/quickshell/Services/DesktopService.qml @@ -127,6 +127,7 @@ Singleton { signal getDefaultAppResult(string mimeType, string desktopFileId, string callbackId) signal getAppsForMimeResult(string mimeType, var appIds, string callbackId) + signal getHandlersForMimeResult(string mimeType, var apps, string callbackId) function setDefaultApp(mimeType, desktopFileId, callbackId = "") { setDefaultAppForMimes([mimeType], desktopFileId, callbackId); @@ -162,6 +163,19 @@ Singleton { }); } + function getHandlersForMimeType(mimeType, callbackId = "") { + DMSService.sendRequest("mime.handlersForMime", { + "mimeType": mimeType + }, response => { + if (response.error) { + log.warn("DesktopService.getHandlersForMimeType failed:", response.error, "mime:", mimeType); + return; + } + const result = response.result || {}; + root.getHandlersForMimeResult(mimeType, result.apps || [], callbackId); + }); + } + function getAppsForMimeType(mimeType, callbackId = "") { DMSService.sendRequest("mime.appsForMime", { "mimeType": mimeType