mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-23 19:54:02 -04:00
core: use trash and desktop from dankgo
This commit is contained in:
1 parent
a3a9fe13eb
commit
a8e8a22e43
15 files changed
+5
-2254
No files matched your search
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user