default apps: add mime-type options for file browser

This commit is contained in:
bbedward
2026-08-14 18:09:39 -04:00
parent 209cfe664f
commit fb9f00a616
6 changed files with 466 additions and 39 deletions
+16
View File
@@ -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 {
+173 -37
View File
@@ -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 {
+210
View File
@@ -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)
}
}
}
+34
View File
@@ -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 {
+19 -2
View File
@@ -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")
}
+14
View File
@@ -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