From a8e8a22e436220276f34ed2d2edce27b942ae5c5 Mon Sep 17 00:00:00 2001 From: bbedward Date: Fri, 18 Sep 2026 17:07:20 -0400 Subject: [PATCH] core: use trash and desktop from dankgo --- core/cmd/dms/commands_trash.go | 2 +- core/go.mod | 2 + core/go.sum | 2 - core/internal/desktop/entries.go | 203 ---------- core/internal/desktop/mime.go | 327 ---------------- core/internal/desktop/mime_test.go | 233 ----------- core/internal/desktop/mimeapps.go | 379 ------------------ core/internal/desktop/mimeapps_test.go | 210 ---------- core/internal/desktop/parser.go | 80 ---- core/internal/server/apppicker/handlers.go | 2 +- core/internal/server/mime/handlers.go | 2 +- core/internal/trash/mounts_freebsd.go | 29 -- core/internal/trash/mounts_linux.go | 30 -- core/internal/trash/trash.go | 436 --------------------- core/internal/trash/trash_test.go | 322 --------------- 15 files changed, 5 insertions(+), 2254 deletions(-) delete mode 100644 core/internal/desktop/entries.go delete mode 100644 core/internal/desktop/mime.go delete mode 100644 core/internal/desktop/mime_test.go delete mode 100644 core/internal/desktop/mimeapps.go delete mode 100644 core/internal/desktop/mimeapps_test.go delete mode 100644 core/internal/desktop/parser.go delete mode 100644 core/internal/trash/mounts_freebsd.go delete mode 100644 core/internal/trash/mounts_linux.go delete mode 100644 core/internal/trash/trash.go delete mode 100644 core/internal/trash/trash_test.go diff --git a/core/cmd/dms/commands_trash.go b/core/cmd/dms/commands_trash.go index a0cf5a28b..e99cfdbfc 100644 --- a/core/cmd/dms/commands_trash.go +++ b/core/cmd/dms/commands_trash.go @@ -6,7 +6,7 @@ import ( "os" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" - "github.com/AvengeMedia/DankMaterialShell/core/internal/trash" + "github.com/AvengeMedia/dankgo/trash" "github.com/spf13/cobra" ) diff --git a/core/go.mod b/core/go.mod index f9e0b8427..288fbbb60 100644 --- a/core/go.mod +++ b/core/go.mod @@ -110,3 +110,5 @@ require ( // v0.0.1 tag is missing a LICENSE file; master has it. // See: https://github.com/mattn/go-localereader/issues/2 replace github.com/mattn/go-localereader v0.0.1 => github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 + +replace github.com/AvengeMedia/dankgo => ../../dankgo diff --git a/core/go.sum b/core/go.sum index 062f81f93..8eef0acf2 100644 --- a/core/go.sum +++ b/core/go.sum @@ -1,7 +1,5 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/AvengeMedia/dankgo v1.6.3-0.20260917190930-d6fb7c12c518 h1:Yx9gzvAmZPkqwKl80lr7jwLUMPtkFURos0vKAqXIg/0= -github.com/AvengeMedia/dankgo v1.6.3-0.20260917190930-d6fb7c12c518/go.mod h1:H6yEdNZ+JJHpBSpvAEJVfXWKhIrttydVU3s2JLyTvQI= github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287 h1:wzQqP16zxfUcfFltJelKg6pTdRyiLY2l2NJLT0Par+Q= github.com/AvengeMedia/dgop v1.6.1-0.20260916132520-8a12dbc6e287/go.mod h1:lwzO5Y4BO5z0I7cLMp5A7sw/THliwGz1TNv6xoUehY4= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= diff --git a/core/internal/desktop/entries.go b/core/internal/desktop/entries.go deleted file mode 100644 index c07071165..000000000 --- a/core/internal/desktop/entries.go +++ /dev/null @@ -1,203 +0,0 @@ -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() -} diff --git a/core/internal/desktop/mime.go b/core/internal/desktop/mime.go deleted file mode 100644 index 84ffaf4fd..000000000 --- a/core/internal/desktop/mime.go +++ /dev/null @@ -1,327 +0,0 @@ -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 -} - -// 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 { - out[m] = GetDefault(m) - } - return out -} diff --git a/core/internal/desktop/mime_test.go b/core/internal/desktop/mime_test.go deleted file mode 100644 index 24b26a726..000000000 --- a/core/internal/desktop/mime_test.go +++ /dev/null @@ -1,233 +0,0 @@ -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 -} diff --git a/core/internal/desktop/mimeapps.go b/core/internal/desktop/mimeapps.go deleted file mode 100644 index 1feb56cb3..000000000 --- a/core/internal/desktop/mimeapps.go +++ /dev/null @@ -1,379 +0,0 @@ -package desktop - -import ( - "bufio" - "bytes" - "fmt" - "maps" - "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 - } - return associationsFromData(data), nil -} - -func associationsFromData(data []byte) *MimeAssociations { - 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 -} - -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 -} - -// isSafeIniField rejects values that would corrupt a key=value line in mimeapps.list -func isSafeIniField(s string) bool { - return !strings.ContainsAny(s, "\n\r[]") -} - -func writeUserMimeapps(update func(*MimeAssociations)) error { - mimeappsWriteMu.Lock() - defer mimeappsWriteMu.Unlock() - - path := mimeappsWritePath() - - original, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return err - } - - assoc := associationsFromData(original) - update(assoc) - - 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 { - out[k] = strings.Join(list, ";") + ";" - } - return out - } - - 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 -} - -// 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 - } - - 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 - } - - tmp, err := os.CreateTemp(dir, ".mimeapps-*.tmp") - if err != nil { - return err - } - 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 { - 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 - } - } - }) -} diff --git a/core/internal/desktop/mimeapps_test.go b/core/internal/desktop/mimeapps_test.go deleted file mode 100644 index 193c88484..000000000 --- a/core/internal/desktop/mimeapps_test.go +++ /dev/null @@ -1,210 +0,0 @@ -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/desktop/parser.go b/core/internal/desktop/parser.go deleted file mode 100644 index 2c9f363bd..000000000 --- a/core/internal/desktop/parser.go +++ /dev/null @@ -1,80 +0,0 @@ -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 -} diff --git a/core/internal/server/apppicker/handlers.go b/core/internal/server/apppicker/handlers.go index 3b0802227..31081cd91 100644 --- a/core/internal/server/apppicker/handlers.go +++ b/core/internal/server/apppicker/handlers.go @@ -1,9 +1,9 @@ package apppicker import ( - "github.com/AvengeMedia/DankMaterialShell/core/internal/desktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/dankgo/desktop" "github.com/AvengeMedia/dankgo/ipc" ) diff --git a/core/internal/server/mime/handlers.go b/core/internal/server/mime/handlers.go index 95fd11112..ed6be168c 100644 --- a/core/internal/server/mime/handlers.go +++ b/core/internal/server/mime/handlers.go @@ -3,8 +3,8 @@ package mime import ( "fmt" - "github.com/AvengeMedia/DankMaterialShell/core/internal/desktop" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/dankgo/desktop" "github.com/AvengeMedia/dankgo/ipc" "github.com/AvengeMedia/dankgo/ipc/params" ) diff --git a/core/internal/trash/mounts_freebsd.go b/core/internal/trash/mounts_freebsd.go deleted file mode 100644 index cfd3842ad..000000000 --- a/core/internal/trash/mounts_freebsd.go +++ /dev/null @@ -1,29 +0,0 @@ -package trash - -import "golang.org/x/sys/unix" - -// readMountPoints returns user-visible mount points via getfsstat(2), -// skipping pseudo and system filesystems. -func readMountPoints() []string { - n, err := unix.Getfsstat(nil, unix.MNT_NOWAIT) - if err != nil || n == 0 { - return nil - } - stats := make([]unix.Statfs_t, n) - n, err = unix.Getfsstat(stats, unix.MNT_NOWAIT) - if err != nil { - return nil - } - - var out []string - seen := map[string]bool{} - for _, st := range stats[:n] { - mp := unix.ByteSliceToString(st.Mntonname[:]) - if mp == "" || skipMountPoint(mp, seen) { - continue - } - seen[mp] = true - out = append(out, mp) - } - return out -} diff --git a/core/internal/trash/mounts_linux.go b/core/internal/trash/mounts_linux.go deleted file mode 100644 index 6935e460d..000000000 --- a/core/internal/trash/mounts_linux.go +++ /dev/null @@ -1,30 +0,0 @@ -package trash - -import ( - "os" - "strings" -) - -// readMountPoints returns user-visible mount points from /proc/self/mountinfo, -// skipping pseudo and system filesystems. -func readMountPoints() []string { - data, err := os.ReadFile("/proc/self/mountinfo") - if err != nil { - return nil - } - var out []string - seen := map[string]bool{} - for line := range strings.SplitSeq(string(data), "\n") { - fields := strings.Fields(line) - if len(fields) < 5 { - continue - } - mp := fields[4] - if skipMountPoint(mp, seen) { - continue - } - seen[mp] = true - out = append(out, mp) - } - return out -} diff --git a/core/internal/trash/trash.go b/core/internal/trash/trash.go deleted file mode 100644 index 3b0b4c0da..000000000 --- a/core/internal/trash/trash.go +++ /dev/null @@ -1,436 +0,0 @@ -// Package trash implements the FreeDesktop.org Trash specification 1.0. -// See: https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html -package trash - -import ( - "errors" - "fmt" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "syscall" - "time" -) - -const trashInfoExt = ".trashinfo" - -type Entry struct { - Name string `json:"name"` - OriginalPath string `json:"originalPath"` - DeletionDate string `json:"deletionDate"` - TrashDir string `json:"trashDir"` - FilesPath string `json:"filesPath"` - InfoPath string `json:"infoPath"` - Size int64 `json:"size"` - IsDir bool `json:"isDir"` -} - -func homeTrashDir() (string, error) { - xdg := os.Getenv("XDG_DATA_HOME") - if xdg == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - xdg = filepath.Join(home, ".local", "share") - } - return filepath.Join(xdg, "Trash"), nil -} - -func ensureTrashDirs(trashDir string) error { - if err := os.MkdirAll(filepath.Join(trashDir, "files"), 0o700); err != nil { - return err - } - return os.MkdirAll(filepath.Join(trashDir, "info"), 0o700) -} - -func fsDevice(path string) (uint64, error) { - var st syscall.Stat_t - if err := syscall.Lstat(path, &st); err != nil { - return 0, err - } - return uint64(st.Dev), nil -} - -func fsDeviceWalkUp(start string) (uint64, error) { - cur := start - for { - if dev, err := fsDevice(cur); err == nil { - return dev, nil - } - parent := filepath.Dir(cur) - if parent == cur { - return 0, fmt.Errorf("no existing ancestor for %s", start) - } - cur = parent - } -} - -func findTopDir(path string) (string, error) { - abs, err := filepath.Abs(path) - if err != nil { - return "", err - } - dev, err := fsDevice(abs) - if err != nil { - return "", err - } - cur := abs - for { - parent := filepath.Dir(cur) - if parent == cur { - return cur, nil - } - pdev, err := fsDevice(parent) - if err != nil { - return cur, nil - } - if pdev != dev { - return cur, nil - } - cur = parent - } -} - -// isValidSharedTrash enforces the spec's checks on $topdir/.Trash: -// must exist, must be a directory, must not be a symlink, must have sticky bit. -func isValidSharedTrash(p string) bool { - info, err := os.Lstat(p) - if err != nil { - return false - } - if info.Mode()&os.ModeSymlink != 0 { - return false - } - if !info.IsDir() { - return false - } - return info.Mode()&os.ModeSticky != 0 -} - -// trashDirForPath chooses the correct trash dir per spec and returns the value -// to store in the .trashinfo Path field (absolute for home, relative-to-topdir -// for per-mountpoint trash). -func trashDirForPath(absPath string) (trashDir string, storedPath string, err error) { - home, err := homeTrashDir() - if err != nil { - return "", "", err - } - - pathDev, err := fsDevice(absPath) - if err != nil { - return "", "", err - } - homeDev, err := fsDeviceWalkUp(home) - if err != nil { - return "", "", err - } - - if pathDev == homeDev { - return home, absPath, nil - } - - topDir, err := findTopDir(absPath) - if err != nil { - return "", "", err - } - - uid := strconv.Itoa(os.Getuid()) - stored, rerr := filepath.Rel(topDir, absPath) - if rerr != nil || strings.HasPrefix(stored, "..") { - stored = absPath - } - - shared := filepath.Join(topDir, ".Trash") - if isValidSharedTrash(shared) { - return filepath.Join(shared, uid), stored, nil - } - return filepath.Join(topDir, ".Trash-"+uid), stored, nil -} - -// uniqueName returns a basename in trashDir that does not collide with an -// existing entry in either files/ or info/. -func uniqueName(trashDir, basename string) (string, error) { - filesDir := filepath.Join(trashDir, "files") - infoDir := filepath.Join(trashDir, "info") - if !exists(filepath.Join(filesDir, basename)) && !exists(filepath.Join(infoDir, basename+trashInfoExt)) { - return basename, nil - } - ext := filepath.Ext(basename) - stem := strings.TrimSuffix(basename, ext) - for i := 2; i < 100000; i++ { - candidate := fmt.Sprintf("%s.%d%s", stem, i, ext) - if !exists(filepath.Join(filesDir, candidate)) && !exists(filepath.Join(infoDir, candidate+trashInfoExt)) { - return candidate, nil - } - } - return "", errors.New("could not find unique trash name") -} - -func exists(p string) bool { - _, err := os.Lstat(p) - return err == nil -} - -// pathEncode percent-escapes a POSIX path per RFC 2396, preserving "/". -func pathEncode(p string) string { - parts := strings.Split(p, "/") - for i, seg := range parts { - parts[i] = url.PathEscape(seg) - } - return strings.Join(parts, "/") -} - -func pathDecode(p string) string { - if d, err := url.PathUnescape(p); err == nil { - return d - } - return p -} - -func writeTrashInfo(infoPath, storedPath string, when time.Time) error { - body := "[Trash Info]\nPath=" + pathEncode(storedPath) + - "\nDeletionDate=" + when.Format("2006-01-02T15:04:05") + "\n" - f, err := os.OpenFile(infoPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return err - } - defer f.Close() - _, err = f.WriteString(body) - return err -} - -// Put trashes a single file or directory. -func Put(path string) (Entry, error) { - abs, err := filepath.Abs(path) - if err != nil { - return Entry{}, err - } - info, err := os.Lstat(abs) - if err != nil { - return Entry{}, err - } - - trashDir, storedPath, err := trashDirForPath(abs) - if err != nil { - return Entry{}, err - } - if err := ensureTrashDirs(trashDir); err != nil { - return Entry{}, fmt.Errorf("create trash dir %s: %w", trashDir, err) - } - - name, err := uniqueName(trashDir, filepath.Base(abs)) - if err != nil { - return Entry{}, err - } - - infoPath := filepath.Join(trashDir, "info", name+trashInfoExt) - when := time.Now() - if err := writeTrashInfo(infoPath, storedPath, when); err != nil { - return Entry{}, err - } - - target := filepath.Join(trashDir, "files", name) - if err := os.Rename(abs, target); err != nil { - os.Remove(infoPath) - return Entry{}, err - } - - return Entry{ - Name: name, - OriginalPath: storedPath, - DeletionDate: when.Format("2006-01-02T15:04:05"), - TrashDir: trashDir, - FilesPath: target, - InfoPath: infoPath, - Size: info.Size(), - IsDir: info.IsDir(), - }, nil -} - -// mountPoints is a variable so tests can keep the mount walk off the real -// filesystem; HOME and XDG_DATA_HOME alone do not redirect it. -var mountPoints = readMountPoints - -// allTrashDirs returns the home trash plus every per-mountpoint trash dir -// that exists (and passes the spec's safety checks for $topdir/.Trash). -func allTrashDirs() []string { - var dirs []string - if h, err := homeTrashDir(); err == nil { - dirs = append(dirs, h) - } - - uid := strconv.Itoa(os.Getuid()) - for _, mount := range mountPoints() { - shared := filepath.Join(mount, ".Trash") - if isValidSharedTrash(shared) { - candidate := filepath.Join(shared, uid) - if info, err := os.Stat(candidate); err == nil && info.IsDir() { - dirs = append(dirs, candidate) - } - } - candidate := filepath.Join(mount, ".Trash-"+uid) - if info, err := os.Lstat(candidate); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 { - dirs = append(dirs, candidate) - } - } - return dirs -} - -func skipMountPoint(mp string, seen map[string]bool) bool { - if mp == "/" || seen[mp] { - return true - } - for _, p := range []string{"/proc", "/sys", "/dev"} { - if mp == p || strings.HasPrefix(mp, p+"/") { - return true - } - } - return false -} - -func List() ([]Entry, error) { - var entries []Entry - for _, d := range allTrashDirs() { - es, _ := listOne(d) - entries = append(entries, es...) - } - return entries, nil -} - -func listOne(trashDir string) ([]Entry, error) { - infoDir := filepath.Join(trashDir, "info") - filesDir := filepath.Join(trashDir, "files") - dir, err := os.ReadDir(infoDir) - if err != nil { - return nil, err - } - var entries []Entry - for _, ent := range dir { - if !strings.HasSuffix(ent.Name(), trashInfoExt) { - continue - } - name := strings.TrimSuffix(ent.Name(), trashInfoExt) - infoPath := filepath.Join(infoDir, ent.Name()) - filesPath := filepath.Join(filesDir, name) - - body, err := os.ReadFile(infoPath) - if err != nil { - continue - } - - e := Entry{Name: name, TrashDir: trashDir, InfoPath: infoPath, FilesPath: filesPath} - for line := range strings.SplitSeq(string(body), "\n") { - if v, ok := strings.CutPrefix(line, "Path="); ok { - e.OriginalPath = pathDecode(v) - continue - } - if v, ok := strings.CutPrefix(line, "DeletionDate="); ok { - e.DeletionDate = v - } - } - if info, err := os.Lstat(filesPath); err == nil { - e.Size = info.Size() - e.IsDir = info.IsDir() - } - entries = append(entries, e) - } - return entries, nil -} - -func Count() (int, error) { - n := 0 - for _, d := range allTrashDirs() { - ents, err := os.ReadDir(filepath.Join(d, "info")) - if err != nil { - continue - } - for _, e := range ents { - if strings.HasSuffix(e.Name(), trashInfoExt) { - n++ - } - } - } - return n, nil -} - -func Empty() error { - var firstErr error - for _, d := range allTrashDirs() { - if err := emptyOne(d); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr -} - -func emptyOne(trashDir string) error { - var firstErr error - for _, sub := range []string{"files", "info"} { - path := filepath.Join(trashDir, sub) - ents, err := os.ReadDir(path) - if err != nil { - continue - } - for _, e := range ents { - if err := os.RemoveAll(filepath.Join(path, e.Name())); err != nil && firstErr == nil { - firstErr = err - } - } - } - os.Remove(filepath.Join(trashDir, "directorysizes")) - return firstErr -} - -// Restore returns a trashed item to its original location. -func Restore(name, trashDir string) error { - if trashDir == "" { - h, err := homeTrashDir() - if err != nil { - return err - } - trashDir = h - } - - infoPath := filepath.Join(trashDir, "info", name+trashInfoExt) - filesPath := filepath.Join(trashDir, "files", name) - - body, err := os.ReadFile(infoPath) - if err != nil { - return err - } - - var stored string - for line := range strings.SplitSeq(string(body), "\n") { - if v, ok := strings.CutPrefix(line, "Path="); ok { - stored = pathDecode(v) - break - } - } - if stored == "" { - return errors.New("invalid .trashinfo: missing Path") - } - - target := stored - if !filepath.IsAbs(stored) { - topDir := filepath.Dir(trashDir) - if filepath.Base(topDir) == ".Trash" { - topDir = filepath.Dir(topDir) - } - target = filepath.Join(topDir, stored) - } - - if exists(target) { - return fmt.Errorf("restore target already exists: %s", target) - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - if err := os.Rename(filesPath, target); err != nil { - return err - } - os.Remove(infoPath) - return nil -} diff --git a/core/internal/trash/trash_test.go b/core/internal/trash/trash_test.go deleted file mode 100644 index 04f3c9352..000000000 --- a/core/internal/trash/trash_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package trash - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func setupHomeTrash(t *testing.T) (homeRoot string, trashDir string) { - t.Helper() - homeRoot = t.TempDir() - xdg := filepath.Join(homeRoot, ".local", "share") - if err := os.MkdirAll(xdg, 0o700); err != nil { - t.Fatalf("mkdir xdg: %v", err) - } - t.Setenv("XDG_DATA_HOME", xdg) - t.Setenv("HOME", homeRoot) - - // Without this, Count() and Empty() reach the machine's real per-mount - // trash dirs, and Empty() deletes what it finds there. - prev := mountPoints - mountPoints = func() []string { return nil } - t.Cleanup(func() { mountPoints = prev }) - - trashDir = filepath.Join(xdg, "Trash") - return homeRoot, trashDir -} - -func writeFile(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } -} - -func TestPutHomeTrashAbsolutePath(t *testing.T) { - homeRoot, trashDir := setupHomeTrash(t) - - src := filepath.Join(homeRoot, "doc.txt") - writeFile(t, src, "hi") - - entry, err := Put(src) - if err != nil { - t.Fatalf("Put: %v", err) - } - - if entry.Name != "doc.txt" { - t.Errorf("name = %q, want doc.txt", entry.Name) - } - if entry.OriginalPath != src { - t.Errorf("originalPath = %q, want %q", entry.OriginalPath, src) - } - if entry.TrashDir != trashDir { - t.Errorf("trashDir = %q, want %q", entry.TrashDir, trashDir) - } - if _, err := os.Stat(src); !os.IsNotExist(err) { - t.Errorf("source still exists: %v", err) - } - - body, err := os.ReadFile(filepath.Join(trashDir, "info", "doc.txt.trashinfo")) - if err != nil { - t.Fatalf("read trashinfo: %v", err) - } - if !strings.HasPrefix(string(body), "[Trash Info]\n") { - t.Errorf("trashinfo missing header: %q", body) - } - if !strings.Contains(string(body), "Path="+src+"\n") { - t.Errorf("Path key missing or wrong: %q", body) - } - if !strings.Contains(string(body), "DeletionDate=") { - t.Errorf("DeletionDate missing: %q", body) - } -} - -func TestPutPercentEncodesPath(t *testing.T) { - homeRoot, trashDir := setupHomeTrash(t) - - name := "spaces & %.txt" - src := filepath.Join(homeRoot, name) - writeFile(t, src, "x") - - if _, err := Put(src); err != nil { - t.Fatalf("Put: %v", err) - } - - body, err := os.ReadFile(filepath.Join(trashDir, "info", name+".trashinfo")) - if err != nil { - t.Fatalf("read: %v", err) - } - want := "Path=" + filepath.Dir(src) + "/spaces%20&%20%25.txt" - if !strings.Contains(string(body), want) { - t.Errorf("expected %q in %q", want, body) - } -} - -func TestPutCollisionGetsUniqueName(t *testing.T) { - homeRoot, trashDir := setupHomeTrash(t) - - for i := range 3 { - src := filepath.Join(homeRoot, "dup.txt") - writeFile(t, src, "x") - if _, err := Put(src); err != nil { - t.Fatalf("Put #%d: %v", i, err) - } - } - - want := []string{"dup.txt", "dup.2.txt", "dup.3.txt"} - for _, n := range want { - if _, err := os.Stat(filepath.Join(trashDir, "files", n)); err != nil { - t.Errorf("expected %s in trash: %v", n, err) - } - if _, err := os.Stat(filepath.Join(trashDir, "info", n+".trashinfo")); err != nil { - t.Errorf("expected %s.trashinfo: %v", n, err) - } - } -} - -func TestListAndCount(t *testing.T) { - homeRoot, _ := setupHomeTrash(t) - - if n, _ := Count(); n != 0 { - t.Errorf("initial count = %d, want 0", n) - } - entries, _ := List() - if len(entries) != 0 { - t.Errorf("initial list len = %d, want 0", len(entries)) - } - - for _, n := range []string{"a.txt", "b.txt", "c.log"} { - src := filepath.Join(homeRoot, n) - writeFile(t, src, n) - if _, err := Put(src); err != nil { - t.Fatalf("Put %s: %v", n, err) - } - } - - got, _ := Count() - if got != 3 { - t.Errorf("count = %d, want 3", got) - } - entries, _ = List() - if len(entries) != 3 { - t.Errorf("list len = %d, want 3", len(entries)) - } - for _, e := range entries { - if e.OriginalPath == "" { - t.Errorf("entry %s: empty OriginalPath", e.Name) - } - if _, err := time.Parse("2006-01-02T15:04:05", e.DeletionDate); err != nil { - t.Errorf("entry %s: bad DeletionDate %q: %v", e.Name, e.DeletionDate, err) - } - } -} - -func TestEmptyClearsAll(t *testing.T) { - homeRoot, trashDir := setupHomeTrash(t) - - for _, n := range []string{"x", "y", "z"} { - src := filepath.Join(homeRoot, n) - writeFile(t, src, n) - if _, err := Put(src); err != nil { - t.Fatalf("Put: %v", err) - } - } - if n, _ := Count(); n != 3 { - t.Fatalf("pre-empty count = %d", n) - } - - if err := Empty(); err != nil { - t.Fatalf("Empty: %v", err) - } - - if n, _ := Count(); n != 0 { - t.Errorf("post-empty count = %d, want 0", n) - } - for _, sub := range []string{"files", "info"} { - ents, err := os.ReadDir(filepath.Join(trashDir, sub)) - if err != nil { - t.Fatalf("readdir %s: %v", sub, err) - } - if len(ents) != 0 { - t.Errorf("%s/ has %d entries, want 0", sub, len(ents)) - } - } -} - -func TestRestoreToOriginalPath(t *testing.T) { - homeRoot, trashDir := setupHomeTrash(t) - - src := filepath.Join(homeRoot, "sub", "dir", "thing.txt") - writeFile(t, src, "payload") - - entry, err := Put(src) - if err != nil { - t.Fatalf("Put: %v", err) - } - - os.RemoveAll(filepath.Join(homeRoot, "sub")) - - if err := Restore(entry.Name, trashDir); err != nil { - t.Fatalf("Restore: %v", err) - } - - body, err := os.ReadFile(src) - if err != nil { - t.Fatalf("read restored: %v", err) - } - if string(body) != "payload" { - t.Errorf("restored content = %q, want %q", body, "payload") - } - - if _, err := os.Stat(entry.InfoPath); !os.IsNotExist(err) { - t.Errorf("info file still present: %v", err) - } - if _, err := os.Stat(entry.FilesPath); !os.IsNotExist(err) { - t.Errorf("files entry still present: %v", err) - } -} - -func TestRestoreRefusesToOverwrite(t *testing.T) { - homeRoot, trashDir := setupHomeTrash(t) - - src := filepath.Join(homeRoot, "keep.txt") - writeFile(t, src, "v1") - - entry, err := Put(src) - if err != nil { - t.Fatalf("Put: %v", err) - } - - writeFile(t, src, "v2-blocking") - - err = Restore(entry.Name, trashDir) - if err == nil { - t.Fatalf("expected error on conflicting restore, got nil") - } - if !strings.Contains(err.Error(), "exists") { - t.Errorf("error %q does not mention conflict", err) - } - - body, _ := os.ReadFile(src) - if string(body) != "v2-blocking" { - t.Errorf("blocking file altered: %q", body) - } -} - -func TestPutDirectory(t *testing.T) { - homeRoot, trashDir := setupHomeTrash(t) - - dir := filepath.Join(homeRoot, "myfolder") - writeFile(t, filepath.Join(dir, "child.txt"), "inside") - - entry, err := Put(dir) - if err != nil { - t.Fatalf("Put dir: %v", err) - } - if !entry.IsDir { - t.Errorf("IsDir = false, want true") - } - - moved := filepath.Join(trashDir, "files", "myfolder", "child.txt") - body, err := os.ReadFile(moved) - if err != nil { - t.Fatalf("read moved child: %v", err) - } - if string(body) != "inside" { - t.Errorf("child content = %q", body) - } -} - -func TestIsValidSharedTrashRejectsSymlink(t *testing.T) { - tmp := t.TempDir() - target := filepath.Join(tmp, "real") - if err := os.MkdirAll(target, os.ModeSticky|0o777); err != nil { - t.Fatalf("mkdir target: %v", err) - } - - link := filepath.Join(tmp, ".Trash") - if err := os.Symlink(target, link); err != nil { - t.Fatalf("symlink: %v", err) - } - if isValidSharedTrash(link) { - t.Errorf("symlinked .Trash accepted; spec requires rejection") - } -} - -func TestIsValidSharedTrashRequiresStickyBit(t *testing.T) { - tmp := t.TempDir() - dir := filepath.Join(tmp, ".Trash") - if err := os.MkdirAll(dir, 0o777); err != nil { - t.Fatalf("mkdir: %v", err) - } - if isValidSharedTrash(dir) { - t.Errorf(".Trash without sticky bit accepted; spec requires rejection") - } - - if err := os.Chmod(dir, os.ModeSticky|0o777); err != nil { - t.Fatalf("chmod: %v", err) - } - if !isValidSharedTrash(dir) { - t.Errorf(".Trash with sticky bit rejected; spec accepts it") - } -} - -func TestPathEncodeRoundTrip(t *testing.T) { - cases := []string{ - "/home/u/file.txt", - "/path with spaces/and-symbols & %.txt", - "relative/path/é unicode.md", - } - for _, in := range cases { - got := pathDecode(pathEncode(in)) - if got != in { - t.Errorf("round-trip %q -> %q", in, got) - } - } -}