1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-30 17:42:06 -04:00

dock: add trash CLI, refine implementation

This commit is contained in:
bbedward
2026-04-27 11:14:22 -04:00
parent c9b38023d5
commit 36a7692da7
18 changed files with 1855 additions and 977 deletions

View File

@@ -0,0 +1,455 @@
// 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
}
// 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 readMountPoints() {
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
}
// 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
}
skipPrefixes := []string{"/proc", "/sys", "/dev"}
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 mp == "/" {
continue
}
skip := false
for _, p := range skipPrefixes {
if mp == p || strings.HasPrefix(mp, p+"/") {
skip = true
break
}
}
if skip || seen[mp] {
continue
}
seen[mp] = true
out = append(out, mp)
}
return out
}
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
}