commit
6970196048
13 changed files with 720 additions and 0 deletions
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
.idea/ |
||||
dist/ |
||||
*.sh |
||||
vendor/ |
||||
old.txt |
||||
new.txt |
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
# desktop |
||||
[](https://godoc.org/git.sr.ht/~tslocum/desktop) |
||||
[](https://builds.sr.ht/~tslocum/desktop) |
||||
[](https://liberapay.com/rocketnine.space) |
||||
|
||||
Desktop application library |
||||
|
||||
## Warning: Experimental |
||||
|
||||
Linux is the only supported platform. Windows support is planned. |
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
package desktop |
||||
|
||||
import ( |
||||
"os" |
||||
"path" |
||||
"path/filepath" |
||||
"strings" |
||||
) |
||||
|
||||
func DataDirs() []string { |
||||
var dataDirs []string |
||||
|
||||
homeDir := os.Getenv("HOME") |
||||
if strings.TrimSpace(homeDir) == "" { |
||||
homeDir = "~/" |
||||
} |
||||
dataHomeSetting := os.Getenv("XDG_DATA_HOME") |
||||
if dataHomeSetting == "" { |
||||
dataHomeSetting = path.Join(homeDir, ".local/share") |
||||
} |
||||
dataDirs = append(dataDirs, filepath.Join(dataHomeSetting, "applications")) |
||||
|
||||
dataDirsSetting := strings.Split(os.Getenv("XDG_DATA_DIRS"), ":") |
||||
for _, dataDir := range dataDirsSetting { |
||||
dataDir = strings.TrimSpace(dataDir) |
||||
if dataDir == "" { |
||||
continue |
||||
} |
||||
|
||||
dataDirs = append(dataDirs, filepath.Join(dataDir, "applications")) |
||||
} |
||||
if len(dataDirs) == 1 { |
||||
dataDirs = append(dataDirs, "/usr/local/share/applications", "/usr/share/applications") |
||||
} |
||||
|
||||
return dataDirs |
||||
} |
@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
|
||||
package desktop |
||||
|
||||
import ( |
||||
"bufio" |
||||
"bytes" |
||||
"fmt" |
||||
"io" |
||||
"strings" |
||||
|
||||
"github.com/pkg/errors" |
||||
) |
||||
|
||||
var ( |
||||
entryHeader = []byte("[desktop entry]") |
||||
entryName = []byte("name=") |
||||
entryGenericName = []byte("genericname=") |
||||
entryComment = []byte("comment=") |
||||
entryIcon = []byte("icon=") |
||||
entryPath = []byte("path=") |
||||
entryExec = []byte("exec=") |
||||
entryTerminal = []byte("terminal=true") |
||||
entryNoDisplay = []byte("nodisplay=true") |
||||
entryHidden = []byte("hidden=true") |
||||
) |
||||
|
||||
var quotes = map[string]string{ |
||||
`%%`: `%`, |
||||
`\\\\ `: `\\ `, |
||||
`\\\\` + "`": `\\` + "`", |
||||
`\\\\$`: `\\$`, |
||||
`\\\\(`: `\\(`, |
||||
`\\\\)`: `\\)`, |
||||
`\\\\\`: `\\\`, |
||||
`\\\\\\\\`: `\\\\`, |
||||
} |
||||
|
||||
func UnquoteExec(ex string) string { |
||||
for qs, qr := range quotes { |
||||
ex = strings.ReplaceAll(ex, qs, qr) |
||||
} |
||||
|
||||
return ex |
||||
} |
||||
|
||||
type Entry struct { |
||||
Name string |
||||
GenericName string |
||||
Comment string |
||||
Icon string |
||||
Path string |
||||
Exec string |
||||
Terminal bool |
||||
} |
||||
|
||||
func (e *Entry) String() string { |
||||
name := "" |
||||
if e.Name != "" { |
||||
name = e.Name |
||||
} |
||||
if e.GenericName != "" { |
||||
if name != "" { |
||||
name += " / " |
||||
} |
||||
name += e.GenericName |
||||
} |
||||
|
||||
comment := "no comment" |
||||
if e.Comment != "" { |
||||
comment = e.Comment |
||||
} |
||||
|
||||
return fmt.Sprintf("{%s - %s}", name, comment) |
||||
} |
||||
|
||||
func (e *Entry) ExpandExec(args string) string { |
||||
ex := e.Exec |
||||
|
||||
ex = strings.ReplaceAll(ex, "%F", args) |
||||
ex = strings.ReplaceAll(ex, "%f", args) |
||||
ex = strings.ReplaceAll(ex, "%U", args) |
||||
ex = strings.ReplaceAll(ex, "%u", args) |
||||
|
||||
return ex |
||||
} |
||||
|
||||
func Parse(content io.Reader) (*Entry, error) { |
||||
var ( |
||||
scanner = bufio.NewScanner(content) |
||||
scannedBytes []byte |
||||
scannedBytesLen int |
||||
|
||||
entry = &Entry{} |
||||
foundHeader bool |
||||
) |
||||
|
||||
for scanner.Scan() { |
||||
scannedBytes = bytes.TrimSpace(scanner.Bytes()) |
||||
scannedBytesLen = len(scannedBytes) |
||||
|
||||
if scannedBytesLen == 0 || scannedBytes[0] == byte('#') { |
||||
continue |
||||
} else if scannedBytes[0] == byte('[') { |
||||
if !foundHeader { |
||||
if scannedBytesLen < 15 || !bytes.EqualFold(scannedBytes[0:15], entryHeader) { |
||||
return nil, errors.New("invalid desktop entry: section header not found") |
||||
} |
||||
|
||||
foundHeader = true |
||||
} else { |
||||
break // Start of new section
|
||||
} |
||||
} else if scannedBytesLen >= 6 && bytes.EqualFold(scannedBytes[0:5], entryName) { |
||||
entry.Name = string(scannedBytes[5:]) |
||||
} else if scannedBytesLen >= 13 && bytes.EqualFold(scannedBytes[0:12], entryGenericName) { |
||||
entry.GenericName = string(scannedBytes[12:]) |
||||
} else if scannedBytesLen >= 9 && bytes.EqualFold(scannedBytes[0:8], entryComment) { |
||||
entry.Comment = string(scannedBytes[8:]) |
||||
} else if scannedBytesLen >= 6 && bytes.EqualFold(scannedBytes[0:5], entryIcon) { |
||||
entry.Icon = string(scannedBytes[5:]) |
||||
} else if scannedBytesLen >= 6 && bytes.EqualFold(scannedBytes[0:5], entryPath) { |
||||
entry.Path = string(scannedBytes[5:]) |
||||
} else if scannedBytesLen >= 6 && bytes.EqualFold(scannedBytes[0:5], entryExec) { |
||||
entry.Exec = UnquoteExec(string(scannedBytes[5:])) |
||||
} else if scannedBytesLen == 13 && bytes.EqualFold(scannedBytes, entryTerminal) { |
||||
entry.Terminal = true |
||||
} else if (scannedBytesLen == 14 && bytes.EqualFold(scannedBytes, entryNoDisplay)) || (scannedBytesLen == 11 && bytes.EqualFold(scannedBytes, entryHidden)) { |
||||
return nil, nil |
||||
} |
||||
} |
||||
if err := scanner.Err(); err != nil { |
||||
return nil, errors.Wrap(err, "failed to parse desktop entry") |
||||
} else if !foundHeader { |
||||
return nil, errors.Wrap(err, "invalid desktop entry") |
||||
} |
||||
|
||||
return entry, nil |
||||
} |
@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
|
||||
package desktop |
||||
|
||||
import ( |
||||
"io" |
||||
"os" |
||||
"testing" |
||||
) |
||||
|
||||
type testData struct { |
||||
Filename string |
||||
Entry *Entry |
||||
} |
||||
|
||||
var testCases = []*testData{ |
||||
{Filename: "alacritty.desktop", Entry: &Entry{Name: "Alacritty", GenericName: "Terminal", Comment: "A cross-platform, GPU enhanced terminal emulator", Icon: "Alacritty", Path: "/home/test", Exec: "alacritty", Terminal: false}}, |
||||
{Filename: "vim.desktop", Entry: &Entry{Name: "Vim", GenericName: "Text Editor", Comment: "Edit text files", Icon: "gvim", Exec: "vim %F", Terminal: true}}, |
||||
{Filename: "nodisplay.desktop", Entry: nil}, |
||||
} |
||||
|
||||
func TestParse(t *testing.T) { |
||||
for _, c := range testCases { |
||||
expected := c.Entry |
||||
|
||||
f, err := os.OpenFile("test/"+c.Filename, os.O_RDONLY, 0644) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
entry, err := Parse(f) |
||||
f.Close() |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
if entry == nil || expected == nil { |
||||
if entry != expected { |
||||
t.Fatalf("%s: entry incorrect: got %#v, want %#v", f.Name(), entry, expected) |
||||
} |
||||
|
||||
continue |
||||
} |
||||
|
||||
if entry.Name != expected.Name { |
||||
t.Fatalf("%s: name incorrect: got %s, want %s", f.Name(), entry.Name, expected.Name) |
||||
} |
||||
|
||||
if entry.GenericName != expected.GenericName { |
||||
t.Fatalf("%s: generic name incorrect: got %s, want %s", f.Name(), entry.GenericName, expected.GenericName) |
||||
} |
||||
|
||||
if entry.Comment != expected.Comment { |
||||
t.Fatalf("%s: comment incorrect: got %s, want %s", f.Name(), entry.Comment, expected.Comment) |
||||
} |
||||
|
||||
if entry.Icon != expected.Icon { |
||||
t.Fatalf("%s: icon incorrect: got %s, want %s", f.Name(), entry.Icon, expected.Icon) |
||||
} |
||||
|
||||
if entry.Path != expected.Path { |
||||
t.Fatalf("%s: Path incorrect: got %s, want %s", f.Name(), entry.Path, expected.Path) |
||||
} |
||||
|
||||
if entry.Exec != expected.Exec { |
||||
t.Fatalf("%s: Exec incorrect: got %s, want %s", f.Name(), entry.Exec, expected.Exec) |
||||
} |
||||
|
||||
if entry.Terminal != expected.Terminal { |
||||
t.Fatalf("%s: terminal incorrect: got %v, want %v", f.Name(), entry.Terminal, expected.Terminal) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func BenchmarkParse(b *testing.B) { |
||||
var files []*os.File |
||||
defer func() { |
||||
for _, f := range files { |
||||
f.Close() |
||||
} |
||||
}() |
||||
|
||||
for _, c := range testCases { |
||||
f, err := os.OpenFile("test/"+c.Filename, os.O_RDONLY, 0644) |
||||
if err != nil { |
||||
b.Fatal(err) |
||||
} |
||||
|
||||
files = append(files, f) |
||||
} |
||||
|
||||
b.StopTimer() |
||||
b.ResetTimer() |
||||
for i := 0; i < b.N; i++ { |
||||
for _, f := range files { |
||||
b.StartTimer() |
||||
_, err := Parse(f) |
||||
if err != nil { |
||||
b.Fatal(err) |
||||
} |
||||
b.StopTimer() |
||||
|
||||
_, err = f.Seek(0, io.SeekStart) |
||||
if err != nil { |
||||
b.Fatal(err) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
module git.sr.ht/~tslocum/desktop |
||||
|
||||
go 1.12 |
||||
|
||||
require github.com/pkg/errors v0.8.1 |
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= |
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= |
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
package desktop |
||||
|
||||
import ( |
||||
"fmt" |
||||
"io/ioutil" |
||||
"os" |
||||
) |
||||
|
||||
func RunScript(ex string) (string, error) { |
||||
runScript, err := ioutil.TempFile("", "run-*") |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
_, err = runScript.WriteString("#!/bin/sh\n") |
||||
if err != nil { |
||||
runScript.Close() |
||||
return "", err |
||||
} |
||||
_, err = runScript.WriteString(fmt.Sprintf("rm %s\n", runScript.Name())) |
||||
if err != nil { |
||||
runScript.Close() |
||||
return "", err |
||||
} |
||||
_, err = runScript.WriteString("exec " + ex + "\n") |
||||
if err != nil { |
||||
runScript.Close() |
||||
return "", err |
||||
} |
||||
|
||||
err = runScript.Close() |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
err = os.Chmod(runScript.Name(), 0744) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return runScript.Name(), nil |
||||
} |
@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
|
||||
package desktop |
||||
|
||||
import ( |
||||
"os" |
||||
"path/filepath" |
||||
"runtime" |
||||
"strings" |
||||
"sync" |
||||
) |
||||
|
||||
type scan struct { |
||||
e map[int][]*Entry |
||||
errs chan error |
||||
in chan *scanEntry |
||||
sync.Mutex |
||||
sync.WaitGroup |
||||
} |
||||
|
||||
type scanEntry struct { |
||||
i int |
||||
f *os.File |
||||
} |
||||
|
||||
func Scan(dirs []string) (map[int][]*Entry, error) { |
||||
s := &scan{e: make(map[int][]*Entry), errs: make(chan error), in: make(chan *scanEntry)} |
||||
|
||||
for i := 0; i < runtime.GOMAXPROCS(-1); i++ { |
||||
go scanner(s) |
||||
} |
||||
|
||||
for i, dir := range dirs { |
||||
i, dir := i, dir |
||||
|
||||
s.Add(1) |
||||
go scanDir(i, dir, s) |
||||
} |
||||
|
||||
done := make(chan bool, 1) |
||||
go func() { |
||||
s.Wait() |
||||
close(s.in) |
||||
|
||||
done <- true |
||||
}() |
||||
|
||||
select { |
||||
case err := <-s.errs: |
||||
return nil, err |
||||
case <-done: |
||||
return s.e, nil |
||||
} |
||||
} |
||||
|
||||
func scanner(s *scan) { |
||||
var ( |
||||
scanEntry *scanEntry |
||||
entry *Entry |
||||
err error |
||||
) |
||||
|
||||
for scanEntry = range s.in { |
||||
entry, err = Parse(scanEntry.f) |
||||
scanEntry.f.Close() |
||||
if err != nil { |
||||
s.errs <- err |
||||
s.Done() |
||||
return |
||||
} else if entry == nil { |
||||
s.Done() |
||||
continue |
||||
} |
||||
|
||||
s.Lock() |
||||
s.e[scanEntry.i] = append(s.e[scanEntry.i], entry) |
||||
s.Unlock() |
||||
|
||||
s.Done() |
||||
} |
||||
} |
||||
|
||||
func scanFunc(i int, s *scan) filepath.WalkFunc { |
||||
return func(path string, f os.FileInfo, err error) error { |
||||
s.Add(1) |
||||
|
||||
go func() { |
||||
if err != nil { |
||||
s.errs <- err |
||||
s.Done() |
||||
return |
||||
} |
||||
|
||||
if f == nil || f.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".desktop") { |
||||
s.Done() |
||||
return |
||||
} |
||||
|
||||
f, err := os.OpenFile(path, os.O_RDONLY, 0644) |
||||
if err != nil { |
||||
s.errs <- err |
||||
s.Done() |
||||
return |
||||
} |
||||
|
||||
s.in <- &scanEntry{i: i, f: f} |
||||
}() |
||||
|
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func scanDir(i int, dir string, s *scan) { |
||||
defer s.Done() |
||||
|
||||
err := filepath.Walk(dir, scanFunc(i, s)) |
||||
if err != nil { |
||||
s.errs <- err |
||||
} |
||||
} |
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
package desktop |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestScan(t *testing.T) { |
||||
dirs := DataDirs() |
||||
|
||||
_, err := Scan(dirs) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
} |
||||
|
||||
func BenchmarkScan(b *testing.B) { |
||||
var ( |
||||
dirs = DataDirs() |
||||
err error |
||||
) |
||||
|
||||
b.ResetTimer() |
||||
for i := 0; i < b.N; i++ { |
||||
_, err = Scan(dirs) |
||||
if err != nil { |
||||
b.Fatal(err) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
[Desktop Entry] |
||||
Type=Application |
||||
TryExec=alacritty |
||||
Path=/home/test |
||||
Exec=alacritty |
||||
Icon=Alacritty |
||||
Terminal=false |
||||
Categories=System;TerminalEmulator; |
||||
|
||||
Name=Alacritty |
||||
GenericName=Terminal |
||||
Comment=A cross-platform, GPU enhanced terminal emulator |
||||
StartupWMClass=Alacritty |
||||
Actions=New; |
||||
|
||||
[Desktop Action New] |
||||
Name=New Terminal |
||||
Exec=alacritty |
Loading…
Reference in new issue