From b41f0f2c4ea101d810143ea82f0dda40fd4f4eeb Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 21 Sep 2026 19:01:22 -0400 Subject: [PATCH] launcher: open files through the mime handler so terminal apps get a terminal fixes #3518 --- core/internal/server/mime/detect.go | 134 ++++++++++++++++++ core/internal/server/mime/detect_test.go | 58 ++++++++ core/internal/server/mime/handlers.go | 17 ++- quickshell/DMSShell.qml | 5 + quickshell/DMSShellIPC.qml | 5 +- .../Modals/DankLauncherV2/Controller.qml | 7 +- quickshell/Services/SessionService.qml | 34 ++++- 7 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 core/internal/server/mime/detect.go create mode 100644 core/internal/server/mime/detect_test.go diff --git a/core/internal/server/mime/detect.go b/core/internal/server/mime/detect.go new file mode 100644 index 000000000..bd3cd2daa --- /dev/null +++ b/core/internal/server/mime/detect.go @@ -0,0 +1,134 @@ +package mime + +import ( + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/AvengeMedia/dankgo/paths" +) + +const noGlobs = "__NOGLOBS__" + +type globRule struct { + weight int + mimeType string + pattern string + caseSensitive bool +} + +func (r globRule) literal() bool { + return !strings.ContainsAny(r.pattern, "*?[") +} + +func (r globRule) matches(name string) bool { + pattern, candidate := r.pattern, name + if !r.caseSensitive { + pattern, candidate = strings.ToLower(pattern), strings.ToLower(name) + } + if r.literal() { + return pattern == candidate + } + ok, err := path.Match(pattern, candidate) + return err == nil && ok +} + +func (r globRule) outranks(other globRule) bool { + if r.literal() != other.literal() { + return r.literal() + } + if r.weight != other.weight { + return r.weight > other.weight + } + return len(r.pattern) > len(other.pattern) +} + +func mimeDataDirs() []string { + dirs := []string{filepath.Join(paths.XDGDataHome(), "mime")} + env := os.Getenv("XDG_DATA_DIRS") + if env == "" { + env = "/usr/local/share:/usr/share" + } + for d := range strings.SplitSeq(env, ":") { + d = strings.TrimSpace(d) + if d == "" { + continue + } + dirs = append(dirs, filepath.Join(d, "mime")) + } + return dirs +} + +func parseGlobLine(line string) (globRule, bool) { + line = strings.TrimSpace(line) + if line == "" || line[0] == '#' { + return globRule{}, false + } + fields := strings.SplitN(line, ":", 4) + if len(fields) < 3 || fields[1] == "" || fields[2] == "" { + return globRule{}, false + } + weight, err := strconv.Atoi(fields[0]) + if err != nil { + return globRule{}, false + } + return globRule{ + weight: weight, + mimeType: fields[1], + pattern: fields[2], + caseSensitive: len(fields) == 4 && fields[3] == "cs", + }, true +} + +func loadGlobRules() []globRule { + var rules []globRule + cleared := map[string]bool{} + for _, dir := range mimeDataDirs() { + data, err := os.ReadFile(filepath.Join(dir, "globs2")) + if err != nil { + continue + } + clearedHere := map[string]bool{} + for line := range strings.Lines(string(data)) { + rule, ok := parseGlobLine(line) + if !ok || cleared[rule.mimeType] { + continue + } + if rule.pattern == noGlobs { + clearedHere[rule.mimeType] = true + continue + } + rules = append(rules, rule) + } + for mimeType := range clearedHere { + cleared[mimeType] = true + } + } + return rules +} + +func mimeTypeForName(name string, rules []globRule) string { + var best *globRule + for i := range rules { + rule := &rules[i] + if !rule.matches(name) { + continue + } + if best == nil || rule.outranks(*best) { + best = rule + } + } + if best == nil { + return "" + } + return best.mimeType +} + +func MimeTypeForPath(filePath string) string { + if info, err := os.Stat(filePath); err == nil && info.IsDir() { + return "inode/directory" + } + return mimeTypeForName(filepath.Base(filePath), loadGlobRules()) +} diff --git a/core/internal/server/mime/detect_test.go b/core/internal/server/mime/detect_test.go new file mode 100644 index 000000000..ea22ed95e --- /dev/null +++ b/core/internal/server/mime/detect_test.go @@ -0,0 +1,58 @@ +package mime + +import ( + "os" + "path/filepath" + "testing" +) + +func writeGlobs(t *testing.T, dataDir, content string) { + t.Helper() + dir := filepath.Join(dataDir, "mime") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "globs2"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestMimeTypeForPathGlobRanking(t *testing.T) { + home := t.TempDir() + system := t.TempDir() + t.Setenv("XDG_DATA_HOME", home) + t.Setenv("XDG_DATA_DIRS", system) + writeGlobs(t, system, `# generated +50:text/x-makefile:makefile +50:application/gzip:*.gz +50:application/x-compressed-tar:*.tar.gz +50:text/x-c++src:*.C:cs +50:text/x-csrc:*.c +55:text/x-diff:*.patch +50:text/x-patch:*.patch +50:text/plain:*.txt +50:application/json:*.json +`) + writeGlobs(t, home, `0:application/json:__NOGLOBS__ +50:text/x-myjson:*.json +`) + + dir := t.TempDir() + cases := []struct{ path, want string }{ + {"/home/u/test.txt", "text/plain"}, + {"/home/u/Makefile", "text/x-makefile"}, + {"/home/u/data.tar.gz", "application/x-compressed-tar"}, + {"/home/u/main.C", "text/x-c++src"}, + {"/home/u/main.c", "text/x-csrc"}, + {"/home/u/fix.patch", "text/x-diff"}, + {"/home/u/NOTES.TXT", "text/plain"}, + {"/home/u/config.json", "text/x-myjson"}, + {"/home/u/unknown.zzz", ""}, + {dir, "inode/directory"}, + } + for _, c := range cases { + if got := MimeTypeForPath(c.path); got != c.want { + t.Errorf("%s: got %q, want %q", c.path, got, c.want) + } + } +} diff --git a/core/internal/server/mime/handlers.go b/core/internal/server/mime/handlers.go index ed6be168c..a6f48bf76 100644 --- a/core/internal/server/mime/handlers.go +++ b/core/internal/server/mime/handlers.go @@ -57,7 +57,7 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request) { } func handleGetDefault(conn *ipc.ConnWriter, req ipc.Request) { - mimeType, err := mimeParam(req.Params, "mimeType") + mimeType, err := requestedMime(req.Params) if err != nil { models.RespondError(conn, req.ID, err.Error()) return @@ -152,6 +152,21 @@ func handleQueryDefaults(conn *ipc.ConnWriter, req ipc.Request) { }) } +func requestedMime(p map[string]any) (string, error) { + if _, ok := p["mimeType"]; ok { + return mimeParam(p, "mimeType") + } + filePath, err := params.StringNonEmpty(p, "path") + if err != nil { + return "", fmt.Errorf("missing 'mimeType' or 'path' parameter") + } + mimeType := MimeTypeForPath(filePath) + if mimeType == "" { + return "", fmt.Errorf("no mime type matches %q", filePath) + } + return mimeType, nil +} + func mimeParam(p map[string]any, key string) (string, error) { raw, err := params.StringNonEmpty(p, key) if err != nil { diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 21f4ee6c2..0db35fd65 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -845,6 +845,11 @@ Item { onApplicationSelected: (app, filePath) => { if (!app) return; + const entry = SessionService.resolveDesktopId(app.appId); + if (entry) { + SessionService.launchDesktopEntry(entry, false, [filePath]); + return; + } let cmd = app.exec || ""; const escapedPath = shellEscape(filePath); const escapedUri = shellEscape("file://" + filePath); diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 0acdcd3d3..b92308bac 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -79,10 +79,7 @@ Item { return false; } - let entry = DesktopEntries.heuristicLookup(desktopId); - if (!entry && desktopId.endsWith(".desktop")) { - entry = DesktopEntries.heuristicLookup(desktopId.slice(0, -8)); - } + const entry = SessionService.resolveDesktopId(desktopId); if (!entry) { log.warn("Default app desktop entry not found:", desktopId, "for:", appName); return false; diff --git a/quickshell/Modals/DankLauncherV2/Controller.qml b/quickshell/Modals/DankLauncherV2/Controller.qml index 172387932..f98ddc5e0 100644 --- a/quickshell/Modals/DankLauncherV2/Controller.qml +++ b/quickshell/Modals/DankLauncherV2/Controller.qml @@ -2307,16 +2307,13 @@ Item { } function openFile(path) { - if (!path) - return; - Qt.openUrlExternally("file://" + path); + SessionService.openPath(path); } function openFolder(path) { if (!path) return; - var folder = path.substring(0, path.lastIndexOf("/")); - Qt.openUrlExternally("file://" + folder); + SessionService.openPath(path.substring(0, path.lastIndexOf("/"))); } function openTerminal(path) { diff --git a/quickshell/Services/SessionService.qml b/quickshell/Services/SessionService.qml index 09c033a13..362dde09d 100644 --- a/quickshell/Services/SessionService.qml +++ b/quickshell/Services/SessionService.qml @@ -293,11 +293,41 @@ Singleton { return env; } - function launchDesktopEntry(desktopEntry, useNvidia) { + function resolveDesktopId(desktopId) { + if (!desktopId) + return null; + const entry = DesktopEntries.heuristicLookup(desktopId); + if (entry || !desktopId.endsWith(".desktop")) + return entry; + return DesktopEntries.heuristicLookup(desktopId.slice(0, -8)); + } + + // xdg-open's generic opener ignores Terminal=true, so resolve the handler here and launch it ourselves + function openPath(path) { + if (!path) + return; + const fallback = () => Qt.openUrlExternally("file://" + path); + if (!DMSService.isConnected) { + fallback(); + return; + } + DMSService.sendRequest("mime.getDefault", { + "path": path + }, response => { + const entry = resolveDesktopId(response?.result?.desktopId); + if (!entry) { + fallback(); + return; + } + launchDesktopEntry(entry, false, [path]); + }); + } + + function launchDesktopEntry(desktopEntry, useNvidia, args) { if (!desktopEntry || !desktopEntry.command) return; CompositorService.closeNiriOverviewOnWindowFocus(); - let cmd = desktopEntry.command; + let cmd = args?.length ? [...desktopEntry.command, ...args] : desktopEntry.command; const appId = desktopEntry.id || desktopEntry.execString || desktopEntry.exec || ""; const override = SessionData.getAppOverride(appId);