mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-15 08:42: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:
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user