launcher: open files through the mime handler so terminal apps get a terminal

fixes #3518
This commit is contained in:
bbedward
2026-09-21 19:01:22 -04:00
parent f3af870d93
commit b41f0f2c4e
7 changed files with 248 additions and 12 deletions
+134
View File
@@ -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())
}
+58
View File
@@ -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)
}
}
}
+16 -1
View File
@@ -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 {
+5
View File
@@ -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);
+1 -4
View File
@@ -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;
@@ -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) {
+32 -2
View File
@@ -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);