mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-15 00:32:47 -04:00
app picker: extend App Picker to integrate with mime overrides
- Adds "DMS Opener" as an option (dms-open.desktop) - Add mime type GO utils - Add rememberance to App Picker modal
This commit is contained in:
@@ -28,9 +28,9 @@ with flags to handle different MIME types or application categories.
|
||||
|
||||
Examples:
|
||||
dms open https://example.com # Open URL with browser picker
|
||||
dms open file.pdf --mime application/pdf # Open PDF with compatible apps
|
||||
dms open document.odt --category Office # Open with office applications
|
||||
dms open --mime image/png image.png # Open image with image viewers`,
|
||||
dms open file.pdf # Open file (MIME auto-detected)
|
||||
dms open file.pdf --mime application/pdf # Override MIME detection
|
||||
dms open document.odt --category Office # Open with office applications`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runOpen(args[0])
|
||||
@@ -47,123 +47,58 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// mimeTypeToCategories maps MIME types to desktop file categories
|
||||
func mimeTypeToCategories(mimeType string) []string {
|
||||
// Split MIME type to get the main type
|
||||
parts := strings.Split(mimeType, "/")
|
||||
if len(parts) < 1 {
|
||||
return nil
|
||||
func detectMimeFromPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
if ext == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
mainType := parts[0]
|
||||
|
||||
switch mainType {
|
||||
case "image":
|
||||
return []string{"Graphics", "Viewer"}
|
||||
case "video":
|
||||
return []string{"Video", "AudioVideo"}
|
||||
case "audio":
|
||||
return []string{"Audio", "AudioVideo"}
|
||||
case "text":
|
||||
if strings.Contains(mimeType, "html") {
|
||||
return []string{"WebBrowser"}
|
||||
}
|
||||
return []string{"TextEditor", "Office"}
|
||||
case "application":
|
||||
if strings.Contains(mimeType, "pdf") {
|
||||
return []string{"Office", "Viewer"}
|
||||
}
|
||||
if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "spreadsheet") ||
|
||||
strings.Contains(mimeType, "presentation") || strings.Contains(mimeType, "msword") ||
|
||||
strings.Contains(mimeType, "ms-excel") || strings.Contains(mimeType, "ms-powerpoint") ||
|
||||
strings.Contains(mimeType, "opendocument") {
|
||||
return []string{"Office"}
|
||||
}
|
||||
if strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") ||
|
||||
strings.Contains(mimeType, "gzip") || strings.Contains(mimeType, "compress") {
|
||||
return []string{"Archiving", "Utility"}
|
||||
}
|
||||
return []string{"Office", "Viewer"}
|
||||
}
|
||||
|
||||
return nil
|
||||
return mime.TypeByExtension(ext)
|
||||
}
|
||||
|
||||
func runOpen(target string) {
|
||||
// Parse file:// URIs to extract the actual file path
|
||||
actualTarget := target
|
||||
detectedMimeType := openMimeType
|
||||
detectedCategories := openCategories
|
||||
detectedRequestType := openRequestType
|
||||
|
||||
log.Infof("Processing target: %s", target)
|
||||
|
||||
if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" {
|
||||
// Extract file path from file:// URI and convert to absolute path
|
||||
actualTarget = parsedURL.Path
|
||||
if absPath, err := filepath.Abs(actualTarget); err == nil {
|
||||
actualTarget = absPath
|
||||
switch {
|
||||
case isScheme(target, "file://"):
|
||||
parsedURL, err := url.Parse(target)
|
||||
if err == nil {
|
||||
actualTarget = parsedURL.Path
|
||||
}
|
||||
if abs, err := filepath.Abs(actualTarget); err == nil {
|
||||
actualTarget = abs
|
||||
}
|
||||
|
||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||
detectedRequestType = "file"
|
||||
}
|
||||
|
||||
log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget)
|
||||
|
||||
// Auto-detect MIME type if not provided
|
||||
if detectedMimeType == "" {
|
||||
ext := filepath.Ext(actualTarget)
|
||||
if ext != "" {
|
||||
detectedMimeType = mime.TypeByExtension(ext)
|
||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
||||
}
|
||||
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||
}
|
||||
log.Infof("Detected file:// URI, absolute path: %s", actualTarget)
|
||||
|
||||
// Auto-detect categories based on MIME type if not provided
|
||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
||||
}
|
||||
} else if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
||||
// Handle HTTP(S) URLs
|
||||
case isScheme(target, "http://"), isScheme(target, "https://"), isScheme(target, "dms://"):
|
||||
if detectedRequestType == "" {
|
||||
detectedRequestType = "url"
|
||||
}
|
||||
log.Infof("Detected HTTP(S) URL")
|
||||
} else if strings.HasPrefix(target, "dms://") {
|
||||
// Handle DMS internal URLs (theme/plugin install, etc.)
|
||||
if detectedRequestType == "" {
|
||||
detectedRequestType = "url"
|
||||
}
|
||||
log.Infof("Detected DMS internal URL")
|
||||
} else if _, err := os.Stat(target); err == nil {
|
||||
// Handle local file paths directly (not file:// URIs)
|
||||
// Convert to absolute path
|
||||
if absPath, err := filepath.Abs(target); err == nil {
|
||||
actualTarget = absPath
|
||||
}
|
||||
log.Infof("Detected URL: %s", target)
|
||||
|
||||
default:
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
break
|
||||
}
|
||||
if abs, err := filepath.Abs(target); err == nil {
|
||||
actualTarget = abs
|
||||
}
|
||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||
detectedRequestType = "file"
|
||||
}
|
||||
|
||||
log.Infof("Detected local file path, converted to absolute: %s", actualTarget)
|
||||
|
||||
// Auto-detect MIME type if not provided
|
||||
if detectedMimeType == "" {
|
||||
ext := filepath.Ext(actualTarget)
|
||||
if ext != "" {
|
||||
detectedMimeType = mime.TypeByExtension(ext)
|
||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect categories based on MIME type if not provided
|
||||
if len(detectedCategories) == 0 && detectedMimeType != "" {
|
||||
detectedCategories = mimeTypeToCategories(detectedMimeType)
|
||||
log.Infof("Detected categories from MIME type: %v", detectedCategories)
|
||||
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||
}
|
||||
log.Infof("Detected local file path: %s", actualTarget)
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
@@ -174,8 +109,8 @@ func runOpen(target string) {
|
||||
params["mimeType"] = detectedMimeType
|
||||
}
|
||||
|
||||
if len(detectedCategories) > 0 {
|
||||
params["categories"] = detectedCategories
|
||||
if len(openCategories) > 0 {
|
||||
params["categories"] = openCategories
|
||||
}
|
||||
|
||||
if detectedRequestType != "" {
|
||||
@@ -183,7 +118,7 @@ func runOpen(target string) {
|
||||
}
|
||||
|
||||
method := "apppicker.open"
|
||||
if detectedMimeType == "" && len(detectedCategories) == 0 && (strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "dms://")) {
|
||||
if detectedMimeType == "" && len(openCategories) == 0 && (isScheme(target, "http://") || isScheme(target, "https://") || isScheme(target, "dms://")) {
|
||||
method = "browser.open"
|
||||
params["url"] = target
|
||||
}
|
||||
@@ -203,3 +138,7 @@ func runOpen(target string) {
|
||||
|
||||
log.Infof("Request sent successfully")
|
||||
}
|
||||
|
||||
func isScheme(target, prefix string) bool {
|
||||
return strings.HasPrefix(target, prefix)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
Path string
|
||||
Name string
|
||||
Exec string
|
||||
Icon string
|
||||
Categories []string
|
||||
MimeTypes []string
|
||||
NoDisplay bool
|
||||
Hidden bool
|
||||
Terminal bool
|
||||
}
|
||||
|
||||
type cachedEntry struct {
|
||||
entry *Entry
|
||||
modTime time.Time
|
||||
size int64
|
||||
}
|
||||
|
||||
var (
|
||||
entryCache = make(map[string]cachedEntry)
|
||||
entryCacheMu sync.Mutex
|
||||
|
||||
listingCache []*Entry
|
||||
listingExpires time.Time
|
||||
listingCacheMu sync.Mutex
|
||||
)
|
||||
|
||||
const listingTTL = 5 * time.Second
|
||||
|
||||
func applicationDirs() []string {
|
||||
seen := make(map[string]bool)
|
||||
var dirs []string
|
||||
|
||||
add := func(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if seen[abs] {
|
||||
return
|
||||
}
|
||||
seen[abs] = true
|
||||
dirs = append(dirs, abs)
|
||||
}
|
||||
|
||||
add(filepath.Join(utils.XDGDataHome(), "applications"))
|
||||
|
||||
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||
for d := range strings.SplitSeq(env, ":") {
|
||||
add(filepath.Join(strings.TrimSpace(d), "applications"))
|
||||
}
|
||||
} else {
|
||||
add("/usr/local/share/applications")
|
||||
add("/usr/share/applications")
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
add(filepath.Join(home, ".local", "share", "flatpak", "exports", "share", "applications"))
|
||||
}
|
||||
add("/var/lib/flatpak/exports/share/applications")
|
||||
add("/var/lib/snapd/desktop/applications")
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
func parseEntry(path string, id string) (*Entry, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entryCacheMu.Lock()
|
||||
if c, ok := entryCache[path]; ok && c.modTime.Equal(info.ModTime()) && c.size == info.Size() {
|
||||
entryCacheMu.Unlock()
|
||||
return c.entry, nil
|
||||
}
|
||||
entryCacheMu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := parseGroups(data)
|
||||
g, ok := groups["Desktop Entry"]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entry := &Entry{
|
||||
ID: id,
|
||||
Path: path,
|
||||
Name: g.keys["Name"],
|
||||
Exec: g.keys["Exec"],
|
||||
Icon: g.keys["Icon"],
|
||||
Categories: splitList(g.keys["Categories"]),
|
||||
MimeTypes: splitList(g.keys["MimeType"]),
|
||||
NoDisplay: parseBool(g.keys["NoDisplay"]),
|
||||
Hidden: parseBool(g.keys["Hidden"]),
|
||||
Terminal: parseBool(g.keys["Terminal"]),
|
||||
}
|
||||
|
||||
if t := g.keys["Type"]; t != "" && t != "Application" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entryCacheMu.Lock()
|
||||
entryCache[path] = cachedEntry{entry: entry, modTime: info.ModTime(), size: info.Size()}
|
||||
entryCacheMu.Unlock()
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func relativeID(root, path string) string {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return filepath.Base(path)
|
||||
}
|
||||
return strings.ReplaceAll(rel, string(filepath.Separator), "-")
|
||||
}
|
||||
|
||||
func AllEntries() []*Entry {
|
||||
listingCacheMu.Lock()
|
||||
if time.Now().Before(listingExpires) && listingCache != nil {
|
||||
out := listingCache
|
||||
listingCacheMu.Unlock()
|
||||
return out
|
||||
}
|
||||
listingCacheMu.Unlock()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var entries []*Entry
|
||||
|
||||
for _, dir := range applicationDirs() {
|
||||
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".desktop") {
|
||||
return nil
|
||||
}
|
||||
id := relativeID(dir, path)
|
||||
if seen[id] {
|
||||
return nil
|
||||
}
|
||||
seen[id] = true
|
||||
|
||||
entry, err := parseEntry(path, id)
|
||||
if err != nil || entry == nil {
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
listingCacheMu.Lock()
|
||||
listingCache = entries
|
||||
listingExpires = time.Now().Add(listingTTL)
|
||||
listingCacheMu.Unlock()
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func EntryByID(id string) *Entry {
|
||||
if !strings.HasSuffix(id, ".desktop") {
|
||||
id += ".desktop"
|
||||
}
|
||||
for _, entry := range AllEntries() {
|
||||
if entry.ID == id {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InvalidateCache() {
|
||||
entryCacheMu.Lock()
|
||||
entryCache = make(map[string]cachedEntry)
|
||||
entryCacheMu.Unlock()
|
||||
|
||||
listingCacheMu.Lock()
|
||||
listingCache = nil
|
||||
listingExpires = time.Time{}
|
||||
listingCacheMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
aliasMap map[string]string
|
||||
subclassMap map[string][]string
|
||||
aliasLoaded time.Time
|
||||
aliasReloadMu sync.Mutex
|
||||
|
||||
mimeCacheMap map[string][]string
|
||||
mimeCacheLoaded time.Time
|
||||
mimeCacheReloadMu sync.Mutex
|
||||
)
|
||||
|
||||
const aliasTTL = 60 * time.Second
|
||||
const mimeCacheTTL = 10 * time.Second
|
||||
|
||||
func mimeDataDirs() []string {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
add := func(p string) {
|
||||
if p == "" || seen[p] {
|
||||
return
|
||||
}
|
||||
seen[p] = true
|
||||
dirs = append(dirs, p)
|
||||
}
|
||||
|
||||
add(filepath.Join(utils.XDGDataHome(), "mime"))
|
||||
|
||||
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||
for d := range strings.SplitSeq(env, ":") {
|
||||
add(filepath.Join(strings.TrimSpace(d), "mime"))
|
||||
}
|
||||
} else {
|
||||
add("/usr/local/share/mime")
|
||||
add("/usr/share/mime")
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
func loadAliasTables() {
|
||||
aliases := make(map[string]string)
|
||||
subclasses := make(map[string][]string)
|
||||
|
||||
for _, dir := range mimeDataDirs() {
|
||||
readKV(filepath.Join(dir, "aliases"), func(k, v string) {
|
||||
if _, ok := aliases[k]; !ok {
|
||||
aliases[k] = v
|
||||
}
|
||||
})
|
||||
readKV(filepath.Join(dir, "subclasses"), func(k, v string) {
|
||||
subclasses[k] = append(subclasses[k], v)
|
||||
})
|
||||
}
|
||||
|
||||
aliasMap = aliases
|
||||
subclassMap = subclasses
|
||||
aliasLoaded = time.Now()
|
||||
}
|
||||
|
||||
func readKV(path string, fn func(k, v string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
sp := strings.IndexByte(line, ' ')
|
||||
if sp <= 0 {
|
||||
continue
|
||||
}
|
||||
fn(strings.TrimSpace(line[:sp]), strings.TrimSpace(line[sp+1:]))
|
||||
}
|
||||
}
|
||||
|
||||
func ensureAliasTables() {
|
||||
aliasReloadMu.Lock()
|
||||
defer aliasReloadMu.Unlock()
|
||||
|
||||
if aliasMap == nil || time.Since(aliasLoaded) > aliasTTL {
|
||||
loadAliasTables()
|
||||
}
|
||||
}
|
||||
|
||||
func loadMimeCache() {
|
||||
merged := make(map[string][]string)
|
||||
seen := make(map[string]map[string]bool)
|
||||
|
||||
for _, dir := range applicationDirs() {
|
||||
data, err := os.ReadFile(filepath.Join(dir, "mimeinfo.cache"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
groups := parseGroups(data)
|
||||
g := groups["MIME Cache"]
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
for mime, val := range g.keys {
|
||||
ids := splitList(val)
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
if seen[mime] == nil {
|
||||
seen[mime] = make(map[string]bool)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if seen[mime][id] {
|
||||
continue
|
||||
}
|
||||
seen[mime][id] = true
|
||||
merged[mime] = append(merged[mime], id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mimeCacheMap = merged
|
||||
mimeCacheLoaded = time.Now()
|
||||
}
|
||||
|
||||
func ensureMimeCache() {
|
||||
mimeCacheReloadMu.Lock()
|
||||
defer mimeCacheReloadMu.Unlock()
|
||||
|
||||
if mimeCacheMap == nil || time.Since(mimeCacheLoaded) > mimeCacheTTL {
|
||||
loadMimeCache()
|
||||
}
|
||||
}
|
||||
|
||||
func cacheAppsForMime(mimeType string) []string {
|
||||
ensureMimeCache()
|
||||
return mimeCacheMap[mimeType]
|
||||
}
|
||||
|
||||
func StripMimeParams(mimeType string) string {
|
||||
if semi := strings.IndexByte(mimeType, ';'); semi >= 0 {
|
||||
mimeType = mimeType[:semi]
|
||||
}
|
||||
return strings.TrimSpace(mimeType)
|
||||
}
|
||||
|
||||
func canonicalMime(mimeType string) string {
|
||||
ensureAliasTables()
|
||||
mimeType = StripMimeParams(mimeType)
|
||||
if target, ok := aliasMap[mimeType]; ok {
|
||||
return target
|
||||
}
|
||||
return mimeType
|
||||
}
|
||||
|
||||
func mimeChain(mimeType string) []string {
|
||||
ensureAliasTables()
|
||||
|
||||
root := canonicalMime(mimeType)
|
||||
visited := map[string]bool{root: true}
|
||||
chain := []string{root}
|
||||
|
||||
queue := []string{root}
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, parent := range subclassMap[cur] {
|
||||
if visited[parent] {
|
||||
continue
|
||||
}
|
||||
visited[parent] = true
|
||||
chain = append(chain, parent)
|
||||
queue = append(queue, parent)
|
||||
}
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
func entrySupportsMime(entry *Entry, chain []string) bool {
|
||||
for _, m := range entry.MimeTypes {
|
||||
canonical := canonicalMime(m)
|
||||
if slices.Contains(chain, canonical) || slices.Contains(chain, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetDefault(mimeType string) string {
|
||||
merged := mergedAssociations()
|
||||
chain := mimeChain(mimeType)
|
||||
|
||||
for _, m := range chain {
|
||||
if id, ok := merged.Defaults[m]; ok {
|
||||
if !slices.Contains(merged.Removed[m], id) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range chain {
|
||||
for _, id := range merged.Added[m] {
|
||||
if !slices.Contains(merged.Removed[m], id) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range chain {
|
||||
for _, id := range cacheAppsForMime(m) {
|
||||
if !slices.Contains(merged.Removed[m], id) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range chain {
|
||||
for _, entry := range AllEntries() {
|
||||
if entry.Hidden || entry.NoDisplay {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(merged.Removed[m], entry.ID) {
|
||||
continue
|
||||
}
|
||||
if entrySupportsMime(entry, []string{m}) {
|
||||
return entry.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func SetDefault(mimeType, desktopID string) error {
|
||||
return setDefaultAssociation(mimeType, desktopID)
|
||||
}
|
||||
|
||||
func SetDefaults(mimeTypes []string, desktopID string) error {
|
||||
return setDefaultAssociations(mimeTypes, desktopID)
|
||||
}
|
||||
|
||||
func AppsForMime(mimeType string) []string {
|
||||
merged := mergedAssociations()
|
||||
chain := mimeChain(mimeType)
|
||||
removed := make(map[string]bool)
|
||||
for _, m := range chain {
|
||||
for _, id := range merged.Removed[m] {
|
||||
removed[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
|
||||
add := func(id string) {
|
||||
if id == "" || removed[id] || seen[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
|
||||
for _, m := range chain {
|
||||
if id := merged.Defaults[m]; id != "" {
|
||||
add(id)
|
||||
}
|
||||
for _, id := range merged.Added[m] {
|
||||
add(id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range chain {
|
||||
for _, id := range cacheAppsForMime(m) {
|
||||
add(id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range AllEntries() {
|
||||
if entry.Hidden {
|
||||
continue
|
||||
}
|
||||
if entrySupportsMime(entry, chain) {
|
||||
add(entry.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func QueryDefaults(mimeTypes []string) map[string]string {
|
||||
out := make(map[string]string, len(mimeTypes))
|
||||
for _, m := range mimeTypes {
|
||||
out[m] = GetDefault(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setupFakeXDG(t *testing.T) (configHome, dataHome string) {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
configHome = filepath.Join(tmp, "config")
|
||||
dataHome = filepath.Join(tmp, "data")
|
||||
if err := os.MkdirAll(filepath.Join(dataHome, "applications"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(configHome, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("XDG_CONFIG_HOME", configHome)
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
t.Setenv("XDG_DATA_DIRS", dataHome)
|
||||
t.Setenv("XDG_CONFIG_DIRS", configHome)
|
||||
InvalidateCache()
|
||||
|
||||
mimeCacheReloadMu.Lock()
|
||||
mimeCacheMap = nil
|
||||
mimeCacheReloadMu.Unlock()
|
||||
aliasReloadMu.Lock()
|
||||
aliasMap = nil
|
||||
subclassMap = nil
|
||||
aliasReloadMu.Unlock()
|
||||
|
||||
return configHome, dataHome
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDesktopEntry(t *testing.T) {
|
||||
_, dataHome := setupFakeXDG(t)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "test.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Test
|
||||
Exec=test %f
|
||||
Icon=test
|
||||
MimeType=application/pdf;image/png;
|
||||
Categories=Office;Viewer;
|
||||
NoDisplay=false
|
||||
`)
|
||||
|
||||
entry := EntryByID("test.desktop")
|
||||
if entry == nil {
|
||||
t.Fatal("entry not found")
|
||||
}
|
||||
if entry.Name != "Test" {
|
||||
t.Errorf("Name = %q", entry.Name)
|
||||
}
|
||||
if len(entry.MimeTypes) != 2 || entry.MimeTypes[0] != "application/pdf" {
|
||||
t.Errorf("MimeTypes = %v", entry.MimeTypes)
|
||||
}
|
||||
if len(entry.Categories) != 2 || entry.Categories[1] != "Viewer" {
|
||||
t.Errorf("Categories = %v", entry.Categories)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetDefault(t *testing.T) {
|
||||
configHome, dataHome := setupFakeXDG(t)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "foo.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Foo
|
||||
MimeType=application/pdf;
|
||||
`)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "bar.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Bar
|
||||
MimeType=application/pdf;
|
||||
`)
|
||||
|
||||
if err := SetDefault("application/pdf", "bar.desktop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := GetDefault("application/pdf"); got != "bar.desktop" {
|
||||
t.Errorf("GetDefault = %q want bar.desktop", got)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(string(data), "application/pdf=bar.desktop") {
|
||||
t.Errorf("mimeapps.list missing default:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultBypassesMimeSupportCheck(t *testing.T) {
|
||||
configHome, dataHome := setupFakeXDG(t)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DMS
|
||||
MimeType=x-scheme-handler/http;
|
||||
`)
|
||||
|
||||
if err := SetDefault("application/pdf", "dms-open.desktop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := GetDefault("application/pdf"); got != "dms-open.desktop" {
|
||||
t.Errorf("GetDefault = %q, want dms-open.desktop (native impl must not enforce MimeType= check)", got)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||
if !contains(string(data), "application/pdf=dms-open.desktop") {
|
||||
t.Errorf("mimeapps.list missing override:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasResolution(t *testing.T) {
|
||||
_, dataHome := setupFakeXDG(t)
|
||||
writeFile(t, filepath.Join(dataHome, "mime", "aliases"), "text/javascript application/javascript\n")
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "editor.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Editor
|
||||
MimeType=application/javascript;
|
||||
`)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache]
|
||||
application/javascript=editor.desktop;
|
||||
`)
|
||||
|
||||
if got := GetDefault("text/javascript"); got != "editor.desktop" {
|
||||
t.Errorf("GetDefault(text/javascript) = %q want editor.desktop (alias resolution)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDefaultsBatch(t *testing.T) {
|
||||
configHome, dataHome := setupFakeXDG(t)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "dms-open.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DMS
|
||||
MimeType=x-scheme-handler/http;
|
||||
`)
|
||||
|
||||
mimes := []string{
|
||||
"text/plain", "text/x-csrc", "text/x-python",
|
||||
"text/x-shellscript", "application/json",
|
||||
}
|
||||
if err := SetDefaults(mimes, "dms-open.desktop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range mimes {
|
||||
if !contains(string(data), m+"=dms-open.desktop") {
|
||||
t.Errorf("missing %s default in mimeapps.list:\n%s", m, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSetDefaultNoCorruption(t *testing.T) {
|
||||
configHome, dataHome := setupFakeXDG(t)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "app.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=App
|
||||
`)
|
||||
|
||||
mimes := []string{
|
||||
"a/1", "a/2", "a/3", "a/4", "a/5", "a/6", "a/7",
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, m := range mimes {
|
||||
wg.Add(1)
|
||||
go func(m string) {
|
||||
defer wg.Done()
|
||||
if err := SetDefault(m, "app.desktop"); err != nil {
|
||||
t.Errorf("SetDefault(%s) failed: %v", m, err)
|
||||
}
|
||||
}(m)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(configHome, "mimeapps.list"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range mimes {
|
||||
if !contains(string(data), m+"=app.desktop") {
|
||||
t.Errorf("lost write for %s — concurrent writes corrupted file:\n%s", m, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMimeCacheOrdering(t *testing.T) {
|
||||
_, dataHome := setupFakeXDG(t)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "a.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=A
|
||||
MimeType=image/png;
|
||||
`)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "b.desktop"), `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=B
|
||||
MimeType=image/png;
|
||||
`)
|
||||
writeFile(t, filepath.Join(dataHome, "applications", "mimeinfo.cache"), `[MIME Cache]
|
||||
image/png=b.desktop;a.desktop;
|
||||
`)
|
||||
|
||||
if got := GetDefault("image/png"); got != "b.desktop" {
|
||||
t.Errorf("GetDefault should follow mimeinfo.cache order: got %q want b.desktop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
var mimeappsWriteMu sync.Mutex
|
||||
|
||||
const (
|
||||
groupDefaults = "Default Applications"
|
||||
groupAdded = "Added Associations"
|
||||
groupRemoved = "Removed Associations"
|
||||
)
|
||||
|
||||
type MimeAssociations struct {
|
||||
Defaults map[string]string
|
||||
Added map[string][]string
|
||||
Removed map[string][]string
|
||||
}
|
||||
|
||||
func newAssociations() *MimeAssociations {
|
||||
return &MimeAssociations{
|
||||
Defaults: make(map[string]string),
|
||||
Added: make(map[string][]string),
|
||||
Removed: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func mimeappsSearchPaths() []string {
|
||||
var paths []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
add := func(p string) {
|
||||
if p == "" || seen[p] {
|
||||
return
|
||||
}
|
||||
seen[p] = true
|
||||
paths = append(paths, p)
|
||||
}
|
||||
|
||||
add(filepath.Join(utils.XDGConfigHome(), "mimeapps.list"))
|
||||
|
||||
if env := os.Getenv("XDG_CONFIG_DIRS"); env != "" {
|
||||
for d := range strings.SplitSeq(env, ":") {
|
||||
add(filepath.Join(strings.TrimSpace(d), "mimeapps.list"))
|
||||
}
|
||||
} else {
|
||||
add("/etc/xdg/mimeapps.list")
|
||||
}
|
||||
|
||||
add(filepath.Join(utils.XDGDataHome(), "applications", "mimeapps.list"))
|
||||
|
||||
if env := os.Getenv("XDG_DATA_DIRS"); env != "" {
|
||||
for d := range strings.SplitSeq(env, ":") {
|
||||
add(filepath.Join(strings.TrimSpace(d), "applications", "mimeapps.list"))
|
||||
}
|
||||
} else {
|
||||
add("/usr/local/share/applications/mimeapps.list")
|
||||
add("/usr/share/applications/mimeapps.list")
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
func mimeappsWritePath() string {
|
||||
return filepath.Join(utils.XDGConfigHome(), "mimeapps.list")
|
||||
}
|
||||
|
||||
func readAssociations(path string) (*MimeAssociations, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := parseGroups(data)
|
||||
assoc := newAssociations()
|
||||
|
||||
if g := groups[groupDefaults]; g != nil {
|
||||
for mime, val := range g.keys {
|
||||
parts := splitList(val)
|
||||
if len(parts) > 0 {
|
||||
assoc.Defaults[mime] = parts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if g := groups[groupAdded]; g != nil {
|
||||
for mime, val := range g.keys {
|
||||
assoc.Added[mime] = splitList(val)
|
||||
}
|
||||
}
|
||||
|
||||
if g := groups[groupRemoved]; g != nil {
|
||||
for mime, val := range g.keys {
|
||||
assoc.Removed[mime] = splitList(val)
|
||||
}
|
||||
}
|
||||
|
||||
return assoc, nil
|
||||
}
|
||||
|
||||
func mergedAssociations() *MimeAssociations {
|
||||
merged := newAssociations()
|
||||
|
||||
for _, path := range mimeappsSearchPaths() {
|
||||
assoc, err := readAssociations(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for mime, app := range assoc.Defaults {
|
||||
if _, ok := merged.Defaults[mime]; !ok {
|
||||
merged.Defaults[mime] = app
|
||||
}
|
||||
}
|
||||
for mime, apps := range assoc.Added {
|
||||
merged.Added[mime] = append(merged.Added[mime], apps...)
|
||||
}
|
||||
for mime, apps := range assoc.Removed {
|
||||
merged.Removed[mime] = append(merged.Removed[mime], apps...)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func writeUserMimeapps(update func(*MimeAssociations)) error {
|
||||
mimeappsWriteMu.Lock()
|
||||
defer mimeappsWriteMu.Unlock()
|
||||
|
||||
path := mimeappsWritePath()
|
||||
|
||||
assoc, err := readAssociations(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
assoc = newAssociations()
|
||||
}
|
||||
|
||||
update(assoc)
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := bufio.NewWriter(&buf)
|
||||
|
||||
writeSection := func(name string, entries map[string]string) {
|
||||
fmt.Fprintf(w, "[%s]\n", name)
|
||||
keys := make([]string, 0, len(entries))
|
||||
for k := range entries {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(w, "%s=%s\n", k, entries[k])
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
flatten := func(m map[string][]string) map[string]string {
|
||||
out := make(map[string]string, len(m))
|
||||
for k, list := range m {
|
||||
out[k] = strings.Join(list, ";") + ";"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
writeSection(groupDefaults, assoc.Defaults)
|
||||
writeSection(groupAdded, flatten(assoc.Added))
|
||||
writeSection(groupRemoved, flatten(assoc.Removed))
|
||||
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, buf.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
func setDefaultAssociation(mimeType, desktopID string) error {
|
||||
return setDefaultAssociations([]string{mimeType}, desktopID)
|
||||
}
|
||||
|
||||
func setDefaultAssociations(mimeTypes []string, desktopID string) error {
|
||||
if !strings.HasSuffix(desktopID, ".desktop") {
|
||||
desktopID += ".desktop"
|
||||
}
|
||||
return writeUserMimeapps(func(assoc *MimeAssociations) {
|
||||
for _, mimeType := range mimeTypes {
|
||||
if mimeType == "" {
|
||||
continue
|
||||
}
|
||||
assoc.Defaults[mimeType] = desktopID
|
||||
existing := assoc.Added[mimeType]
|
||||
if !slices.Contains(existing, desktopID) {
|
||||
assoc.Added[mimeType] = append(existing, desktopID)
|
||||
}
|
||||
removed, ok := assoc.Removed[mimeType]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
filtered := removed[:0]
|
||||
for _, id := range removed {
|
||||
if id != desktopID {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(filtered) == 0:
|
||||
delete(assoc.Removed, mimeType)
|
||||
default:
|
||||
assoc.Removed[mimeType] = filtered
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type group struct {
|
||||
keys map[string]string
|
||||
}
|
||||
|
||||
func parseGroups(data []byte) map[string]*group {
|
||||
groups := make(map[string]*group)
|
||||
var current *group
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
|
||||
if line[0] == '[' && strings.HasSuffix(line, "]") {
|
||||
name := line[1 : len(line)-1]
|
||||
g, ok := groups[name]
|
||||
if !ok {
|
||||
g = &group{keys: make(map[string]string)}
|
||||
groups[name] = g
|
||||
}
|
||||
current = g
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
if bracket := strings.IndexByte(key, '['); bracket > 0 {
|
||||
key = key[:bracket]
|
||||
}
|
||||
if _, ok := current.keys[key]; ok {
|
||||
continue
|
||||
}
|
||||
current.keys[key] = strings.TrimSpace(line[eq+1:])
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func splitList(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ";")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package apppicker
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/desktop"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
)
|
||||
@@ -32,7 +33,7 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
|
||||
event := OpenEvent{
|
||||
Target: target,
|
||||
RequestType: models.GetOr(req, "requestType", "url"),
|
||||
MimeType: models.GetOr(req, "mimeType", ""),
|
||||
MimeType: desktop.StripMimeParams(models.GetOr(req, "mimeType", "")),
|
||||
}
|
||||
|
||||
if categories, ok := models.Get[[]any](req, "categories"); ok {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package mime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/desktop"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||
)
|
||||
|
||||
type defaultResult struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
DesktopID string `json:"desktopId"`
|
||||
}
|
||||
|
||||
type appsResult struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
DesktopIDs []string `json:"desktopIds"`
|
||||
}
|
||||
|
||||
type queryResult struct {
|
||||
Defaults map[string]string `json:"defaults"`
|
||||
}
|
||||
|
||||
func HandleRequest(conn net.Conn, req models.Request) {
|
||||
switch req.Method {
|
||||
case "mime.getDefault":
|
||||
handleGetDefault(conn, req)
|
||||
case "mime.setDefault":
|
||||
handleSetDefault(conn, req)
|
||||
case "mime.setDefaults":
|
||||
handleSetDefaults(conn, req)
|
||||
case "mime.appsForMime":
|
||||
handleAppsForMime(conn, req)
|
||||
case "mime.queryDefaults":
|
||||
handleQueryDefaults(conn, req)
|
||||
case "mime.invalidate":
|
||||
desktop.InvalidateCache()
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetDefault(conn net.Conn, req models.Request) {
|
||||
mimeType, err := mimeParam(req.Params, "mimeType")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, defaultResult{
|
||||
MimeType: mimeType,
|
||||
DesktopID: desktop.GetDefault(mimeType),
|
||||
})
|
||||
}
|
||||
|
||||
func handleSetDefault(conn net.Conn, req models.Request) {
|
||||
mimeType, err := mimeParam(req.Params, "mimeType")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
desktopID, err := params.StringNonEmpty(req.Params, "desktopId")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
if err := desktop.SetDefault(mimeType, desktopID); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||
}
|
||||
|
||||
func handleSetDefaults(conn net.Conn, req models.Request) {
|
||||
desktopID, err := params.StringNonEmpty(req.Params, "desktopId")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
mimeTypes, err := mimeListParam(req, "mimeTypes")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
if err := desktop.SetDefaults(mimeTypes, desktopID); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||
}
|
||||
|
||||
func handleAppsForMime(conn net.Conn, req models.Request) {
|
||||
mimeType, err := mimeParam(req.Params, "mimeType")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
ids := desktop.AppsForMime(mimeType)
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
models.Respond(conn, req.ID, appsResult{
|
||||
MimeType: mimeType,
|
||||
DesktopIDs: ids,
|
||||
})
|
||||
}
|
||||
|
||||
func handleQueryDefaults(conn net.Conn, req models.Request) {
|
||||
mimeTypes, err := mimeListParam(req, "mimeTypes")
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
models.Respond(conn, req.ID, queryResult{
|
||||
Defaults: desktop.QueryDefaults(mimeTypes),
|
||||
})
|
||||
}
|
||||
|
||||
func mimeParam(p map[string]any, key string) (string, error) {
|
||||
raw, err := params.StringNonEmpty(p, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
canonical := desktop.StripMimeParams(raw)
|
||||
if canonical == "" {
|
||||
return "", fmt.Errorf("invalid '%s' parameter", key)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func mimeListParam(req models.Request, key string) ([]string, error) {
|
||||
raw, ok := models.Get[[]any](req, key)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing or invalid '%s' parameter", key)
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
canonical := desktop.StripMimeParams(s)
|
||||
if canonical == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, canonical)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no valid mime types provided")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/mime"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||
@@ -92,6 +93,11 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "mime.") {
|
||||
mime.HandleRequest(conn, req)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") {
|
||||
if appPickerManager == nil {
|
||||
models.RespondError(conn, req.ID, "apppicker manager not initialized")
|
||||
|
||||
@@ -850,6 +850,8 @@ Item {
|
||||
|
||||
filePickerModal.targetData = data.target;
|
||||
filePickerModal.targetDataLabel = data.requestType || "file";
|
||||
filePickerModal.mimeType = data.mimeType || "";
|
||||
filePickerModal.rememberMimeTypes = [];
|
||||
|
||||
if (data.categories && data.categories.length > 0) {
|
||||
filePickerModal.categoryFilter = data.categories;
|
||||
|
||||
@@ -19,9 +19,19 @@ DankModal {
|
||||
property var categoryFilter: []
|
||||
property var usageHistoryKey: ""
|
||||
property bool showTargetData: true
|
||||
property string mimeType: ""
|
||||
property var rememberMimeTypes: []
|
||||
property bool rememberChoice: false
|
||||
property var mimeMatchedAppIds: []
|
||||
|
||||
signal applicationSelected(var app, string targetData)
|
||||
|
||||
function _normAppId(id) {
|
||||
if (!id)
|
||||
return "";
|
||||
return id.replace(/\.desktop$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
shouldBeVisible: false
|
||||
allowStacking: true
|
||||
modalWidth: 520
|
||||
@@ -37,6 +47,8 @@ DankModal {
|
||||
|
||||
onOpened: {
|
||||
searchQuery = "";
|
||||
rememberChoice = false;
|
||||
fetchMimeMatches();
|
||||
updateApplicationList();
|
||||
selectedIndex = 0;
|
||||
Qt.callLater(() => {
|
||||
@@ -47,22 +59,55 @@ DankModal {
|
||||
});
|
||||
}
|
||||
|
||||
function fetchMimeMatches() {
|
||||
mimeMatchedAppIds = [];
|
||||
const queriedMime = mimeType;
|
||||
if (queriedMime.length === 0)
|
||||
return;
|
||||
DMSService.sendRequest("mime.appsForMime", {
|
||||
"mimeType": queriedMime
|
||||
}, response => {
|
||||
if (queriedMime !== root.mimeType)
|
||||
return;
|
||||
if (response.error) {
|
||||
log.warn("mime.appsForMime failed:", response.error);
|
||||
return;
|
||||
}
|
||||
const ids = (response.result && response.result.desktopIds) || [];
|
||||
mimeMatchedAppIds = ids.map(_normAppId);
|
||||
updateApplicationList();
|
||||
});
|
||||
}
|
||||
|
||||
function _appMatchesMime(app, mime) {
|
||||
const list = app && (app.mimeTypes || app.mimeType);
|
||||
return !!list && !!list.includes && list.includes(mime);
|
||||
}
|
||||
|
||||
function updateApplicationList() {
|
||||
applicationsModel.clear();
|
||||
const apps = AppSearchService.applications;
|
||||
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {};
|
||||
const hasCategoryFilter = categoryFilter.length > 0;
|
||||
const hasMime = mimeType.length > 0;
|
||||
const hasMimeMatches = mimeMatchedAppIds.length > 0;
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
let filteredApps = [];
|
||||
|
||||
for (const app of apps) {
|
||||
if (!app || !app.categories)
|
||||
if (!app)
|
||||
continue;
|
||||
let matchesCategory = categoryFilter.length === 0;
|
||||
const appId = _normAppId(app.id || app.execString || app.exec || "");
|
||||
const mimeIdMatch = hasMimeMatches && mimeMatchedAppIds.includes(appId);
|
||||
const mimeFieldMatch = hasMime && _appMatchesMime(app, mimeType);
|
||||
const mimeMatch = mimeIdMatch || mimeFieldMatch;
|
||||
|
||||
if (categoryFilter.length > 0) {
|
||||
let categoryMatch = false;
|
||||
if (hasCategoryFilter && app.categories) {
|
||||
try {
|
||||
for (const cat of app.categories) {
|
||||
if (categoryFilter.includes(cat)) {
|
||||
matchesCategory = true;
|
||||
categoryMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -72,24 +117,28 @@ DankModal {
|
||||
}
|
||||
}
|
||||
|
||||
if (matchesCategory) {
|
||||
const name = app.name || "";
|
||||
const lowerName = name.toLowerCase();
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
const include = (!hasCategoryFilter && !hasMime) || mimeMatch || categoryMatch;
|
||||
if (!include)
|
||||
continue;
|
||||
|
||||
if (searchQuery === "" || lowerName.includes(lowerQuery)) {
|
||||
filteredApps.push({
|
||||
name: name,
|
||||
icon: app.icon || "application-x-executable",
|
||||
exec: app.exec || app.execString || "",
|
||||
startupClass: app.startupWMClass || "",
|
||||
appData: app
|
||||
});
|
||||
}
|
||||
}
|
||||
const name = app.name || "";
|
||||
if (searchQuery !== "" && !name.toLowerCase().includes(lowerQuery))
|
||||
continue;
|
||||
|
||||
filteredApps.push({
|
||||
name: name,
|
||||
icon: app.icon || "application-x-executable",
|
||||
exec: app.exec || app.execString || "",
|
||||
startupClass: app.startupWMClass || "",
|
||||
appData: app,
|
||||
mimeMatch: mimeMatch
|
||||
});
|
||||
}
|
||||
|
||||
filteredApps.sort((a, b) => {
|
||||
if (a.mimeMatch !== b.mimeMatch) {
|
||||
return a.mimeMatch ? -1 : 1;
|
||||
}
|
||||
const aId = a.appData.id || a.appData.execString || a.appData.exec || "";
|
||||
const bId = b.appData.id || b.appData.execString || b.appData.exec || "";
|
||||
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0;
|
||||
@@ -134,16 +183,15 @@ DankModal {
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (applicationsModel.count === 0)
|
||||
return;
|
||||
|
||||
// Toggle view mode with Tab key
|
||||
if (event.key === Qt.Key_Tab) {
|
||||
root.viewMode = root.viewMode === "grid" ? "list" : "grid";
|
||||
if (event.key === Qt.Key_Tab && root.mimeType.length > 0) {
|
||||
root.rememberChoice = !root.rememberChoice;
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (applicationsModel.count === 0)
|
||||
return;
|
||||
|
||||
if (root.viewMode === "grid") {
|
||||
if (event.key === Qt.Key_Left) {
|
||||
root.keyboardNavigationActive = true;
|
||||
@@ -309,6 +357,9 @@ DankModal {
|
||||
if (root.showTargetData) {
|
||||
usedHeight += 36 + Theme.spacingS;
|
||||
}
|
||||
if (root.mimeType && root.mimeType.length > 0) {
|
||||
usedHeight += 36 + Theme.spacingS;
|
||||
}
|
||||
return parent.height - usedHeight;
|
||||
}
|
||||
radius: Theme.cornerRadius
|
||||
@@ -447,11 +498,38 @@ DankModal {
|
||||
maximumLineCount: 1
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 36
|
||||
visible: root.mimeType.length > 0
|
||||
|
||||
DankToggle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.rememberChoice
|
||||
text: I18n.tr("Always use this app for %1").arg(root.mimeType)
|
||||
onToggled: checked => {
|
||||
root.rememberChoice = checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function launchApplication(app) {
|
||||
if (!app)
|
||||
return;
|
||||
|
||||
if (root.rememberChoice && app.appId) {
|
||||
const targets = (root.rememberMimeTypes && root.rememberMimeTypes.length > 0) ? root.rememberMimeTypes : (root.mimeType ? [root.mimeType] : []);
|
||||
if (targets.length > 0) {
|
||||
DesktopService.setDefaultAppForMimes(targets, app.appId);
|
||||
}
|
||||
}
|
||||
|
||||
root.applicationSelected(app, root.targetData);
|
||||
|
||||
if (usageHistoryKey && app.appId) {
|
||||
|
||||
@@ -17,6 +17,8 @@ AppPickerModal {
|
||||
viewMode: SettingsData.browserPickerViewMode || "grid"
|
||||
usageHistoryKey: "browserUsageHistory"
|
||||
showTargetData: true
|
||||
mimeType: url.startsWith("https://") ? "x-scheme-handler/https" : (url.startsWith("http://") ? "x-scheme-handler/http" : "")
|
||||
rememberMimeTypes: ["x-scheme-handler/http", "x-scheme-handler/https", "text/html", "application/xhtml+xml"]
|
||||
|
||||
function shellEscape(str) {
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
|
||||
@@ -244,8 +244,7 @@ Rectangle {
|
||||
"id": "default_apps",
|
||||
"text": I18n.tr("Default Apps"),
|
||||
"icon": "star",
|
||||
"tabIndex": 34,
|
||||
"gioOnly": true
|
||||
"tabIndex": 34
|
||||
},
|
||||
{
|
||||
"id": "running_apps",
|
||||
@@ -364,8 +363,6 @@ Rectangle {
|
||||
return false;
|
||||
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
|
||||
return false;
|
||||
if (item.gioOnly && !DesktopService.gioAvailable)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Item {
|
||||
PDFReader: 6,
|
||||
Mail: 7,
|
||||
Terminal: 8,
|
||||
Calendar: 9
|
||||
Calendar: 9
|
||||
})
|
||||
|
||||
property string currentWebBrowserAppId: ""
|
||||
@@ -35,63 +35,17 @@ Item {
|
||||
|
||||
property var categoryModels: ({})
|
||||
|
||||
// A curated list of MIME types for each category.
|
||||
// The first one is used for fetching the apps list and current default,
|
||||
// the rest are for setting the default app.
|
||||
// A curated list of MIME types for each category.
|
||||
// The first one is used for fetching the apps list and current default,
|
||||
// the rest are for setting the default app.
|
||||
readonly property var mimeMapping: ({
|
||||
[root.appCategory.WebBrowser]: [
|
||||
"x-scheme-handler/https",
|
||||
"x-scheme-handler/http",
|
||||
"text/html",
|
||||
"application/xhtml+xml"
|
||||
],
|
||||
[root.appCategory.FileManager]: [
|
||||
"inode/directory",
|
||||
"x-scheme-handler/file"
|
||||
],
|
||||
[root.appCategory.TextEditor]: [
|
||||
"text/plain",
|
||||
"application/x-zerosize",
|
||||
"text/x-c++src",
|
||||
"text/x-csrc",
|
||||
"text/x-python",
|
||||
"text/x-shellscript",
|
||||
"application/json"
|
||||
],
|
||||
[root.appCategory.ImageViewer]: [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/svg+xml"
|
||||
],
|
||||
[root.appCategory.VideoPlayer]: [
|
||||
"video/mp4",
|
||||
"video/x-matroska",
|
||||
"video/webm",
|
||||
"video/avi",
|
||||
"video/mpeg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo"
|
||||
],
|
||||
[root.appCategory.MusicPlayer]: [
|
||||
"audio/mpeg",
|
||||
"audio/x-flac",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/aac",
|
||||
"audio/webm"
|
||||
],
|
||||
[root.appCategory.PDFReader]: [
|
||||
"application/pdf",
|
||||
"application/x-ext-pdf",
|
||||
"application/x-bzpdf",
|
||||
"application/x-gzpdf",
|
||||
"application/vnd.comicbook-rar",
|
||||
"application/vnd.comicbook+zip"
|
||||
],
|
||||
[root.appCategory.WebBrowser]: ["x-scheme-handler/https", "x-scheme-handler/http", "text/html", "application/xhtml+xml"],
|
||||
[root.appCategory.FileManager]: ["inode/directory", "x-scheme-handler/file"],
|
||||
[root.appCategory.TextEditor]: ["text/plain", "application/x-zerosize", "text/x-c++src", "text/x-csrc", "text/x-python", "text/x-shellscript", "application/json"],
|
||||
[root.appCategory.ImageViewer]: ["image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp", "image/avif", "image/svg+xml"],
|
||||
[root.appCategory.VideoPlayer]: ["video/mp4", "video/x-matroska", "video/webm", "video/avi", "video/mpeg", "video/quicktime", "video/x-msvideo"],
|
||||
[root.appCategory.MusicPlayer]: ["audio/mpeg", "audio/x-flac", "audio/wav", "audio/ogg", "audio/aac", "audio/webm"],
|
||||
[root.appCategory.PDFReader]: ["application/pdf", "application/x-ext-pdf", "application/x-bzpdf", "application/x-gzpdf", "application/vnd.comicbook-rar", "application/vnd.comicbook+zip"],
|
||||
[root.appCategory.Mail]: ["x-scheme-handler/mailto"],
|
||||
[root.appCategory.Calendar]: ["x-scheme-handler/calendar"],
|
||||
[root.appCategory.Terminal]: ["terminal"] // Special
|
||||
@@ -111,11 +65,13 @@ Item {
|
||||
}
|
||||
|
||||
function getAppDisplayName(appId) {
|
||||
if (appId === root.dmsChooserId || appId === "dms-open") {
|
||||
return root.dmsChooserLabel;
|
||||
}
|
||||
let entry = DesktopEntries.heuristicLookup(appId);
|
||||
if (entry && entry.name) {
|
||||
return entry.name;
|
||||
}
|
||||
// If the appname can't be found, show the appID
|
||||
const withoutSuffix = appId.replace(/\.desktop$/, "");
|
||||
if (withoutSuffix !== appId) {
|
||||
entry = DesktopEntries.heuristicLookup(withoutSuffix);
|
||||
@@ -126,14 +82,28 @@ Item {
|
||||
return appId;
|
||||
}
|
||||
|
||||
readonly property string dmsChooserId: "dms-open.desktop"
|
||||
readonly property string dmsChooserLabel: I18n.tr("DMS Chooser")
|
||||
|
||||
function withDmsChooser(entries) {
|
||||
const filtered = (entries || []).filter(e => e.value !== root.dmsChooserId && e.value !== "dms-open");
|
||||
return [
|
||||
{
|
||||
text: root.dmsChooserLabel,
|
||||
value: root.dmsChooserId
|
||||
}
|
||||
].concat(filtered);
|
||||
}
|
||||
|
||||
function loadCategoryModel(categoryKey, categorySearchName) {
|
||||
const apps = loadAppSearchCategory(categorySearchName);
|
||||
const appIds = apps.map(app => app.id || app.execString || "").filter(id => id);
|
||||
let models = Object.assign({}, root.categoryModels);
|
||||
models[categoryKey] = appIds.map(id => ({
|
||||
text: root.getAppDisplayName(id),
|
||||
value: id
|
||||
}));
|
||||
const entries = appIds.map(id => ({
|
||||
text: root.getAppDisplayName(id),
|
||||
value: id
|
||||
}));
|
||||
models[categoryKey] = categoryKey === root.appCategory.Terminal ? entries : root.withDmsChooser(entries);
|
||||
root.categoryModels = models;
|
||||
}
|
||||
|
||||
@@ -147,9 +117,9 @@ Item {
|
||||
loadCategoryModel(root.appCategory.Terminal, "TerminalEmulator");
|
||||
getDefaultTerminal();
|
||||
break;
|
||||
case root.appCategory.WebBrowser:
|
||||
case root.appCategory.WebBrowser:
|
||||
// When using the MIME type, stuff like dms-run shows up.
|
||||
// It's probably better to use the category.
|
||||
// It's probably better to use the category.
|
||||
loadCategoryModel(root.appCategory.WebBrowser, "WebBrowser");
|
||||
DesktopService.getDefaultApp(mimeMapping[category][0], category.toString());
|
||||
break;
|
||||
@@ -201,7 +171,7 @@ Item {
|
||||
Component {
|
||||
id: xdgGetDefaultTerminal
|
||||
Process {
|
||||
property string configPath: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")
|
||||
property string configPath: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")
|
||||
|
||||
command: ["sh", "-c", `cat '${configPath}/xdg-terminals.list'`]
|
||||
stdout: StdioCollector {
|
||||
@@ -231,30 +201,24 @@ Item {
|
||||
target: DesktopService
|
||||
|
||||
function onGetAppsForMimeResult(mimeType, appIds, callbackId) {
|
||||
if (!appIds || appIds.length === 0) {
|
||||
log.info("No apps found for MIME type:", mimeType);
|
||||
return;
|
||||
}
|
||||
|
||||
let categoryIndex = parseInt(callbackId);
|
||||
let models = Object.assign({}, root.categoryModels);
|
||||
|
||||
models[categoryIndex] = appIds.map(id => {
|
||||
return {
|
||||
text: root.getAppDisplayName(id),
|
||||
value: id
|
||||
};
|
||||
});
|
||||
const entries = (appIds || []).map(id => ({
|
||||
text: root.getAppDisplayName(id),
|
||||
value: id
|
||||
}));
|
||||
|
||||
models[categoryIndex] = root.withDmsChooser(entries);
|
||||
root.categoryModels = models;
|
||||
}
|
||||
|
||||
function onGetDefaultAppResult(mimeType, desktopFileId, callbackId) {
|
||||
if (!desktopFileId) {
|
||||
log.info("No default app found for MIME type:", mimeType);
|
||||
return
|
||||
return;
|
||||
}
|
||||
root[propertyName(parseInt(callbackId))] = desktopFileId;
|
||||
root[propertyName(parseInt(callbackId))] = desktopFileId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,11 +227,11 @@ Item {
|
||||
options: (root.categoryModels[category] || []).map(opt => opt.text)
|
||||
enabled: options.length > 0
|
||||
emptyText: options.length > 0 ? I18n.tr("Unset", "Unset") : ""
|
||||
opacity: options.length > 0 ? 1 : 0.5
|
||||
opacity: options.length > 0 ? 1 : 0.5
|
||||
currentValue: {
|
||||
let id = root[propertyName(category)];
|
||||
if (!id || id.length === 0) {
|
||||
return ""
|
||||
return "";
|
||||
}
|
||||
return root.getAppDisplayName(id);
|
||||
}
|
||||
@@ -278,11 +242,7 @@ Item {
|
||||
if (category === root.appCategory.Terminal) {
|
||||
root.setDefaultTerminal(found.value);
|
||||
} else {
|
||||
// Set the default app for all MIME types in the category
|
||||
// If the app doesn't support a MIME type, it will be ignored
|
||||
root.mimeMapping[category].forEach(mimeType => {
|
||||
DesktopService.setDefaultApp(mimeType, found.value, category.toString());
|
||||
});
|
||||
DesktopService.setDefaultAppForMimes(root.mimeMapping[category], found.value, category.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,16 +269,16 @@ Item {
|
||||
|
||||
AppSelector {
|
||||
text: I18n.tr("Web Browser", "Web Browser")
|
||||
tags: ["web", "browser", "internet"]
|
||||
tags: ["web", "browser", "internet"]
|
||||
category: root.appCategory.WebBrowser
|
||||
description: I18n.tr("Handles links and opens HTML files", "Handles links and opens HTML files")
|
||||
description: I18n.tr("Handles links and opens HTML files", "Handles links and opens HTML files")
|
||||
}
|
||||
|
||||
AppSelector {
|
||||
text: I18n.tr("Mail", "Mail")
|
||||
category: root.appCategory.Mail
|
||||
tags: ["mail", "email"]
|
||||
description: I18n.tr("Handles mailto links", "Handles mailto links")
|
||||
tags: ["mail", "email"]
|
||||
description: I18n.tr("Handles mailto links", "Handles mailto links")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,21 +288,21 @@ Item {
|
||||
|
||||
AppSelector {
|
||||
text: I18n.tr("File Manager", "File Manager")
|
||||
tags: ["file", "manager"]
|
||||
tags: ["file", "manager"]
|
||||
category: root.appCategory.FileManager
|
||||
description: I18n.tr("Manages files and directories", "Manages files and directories")
|
||||
description: I18n.tr("Manages files and directories", "Manages files and directories")
|
||||
}
|
||||
AppSelector {
|
||||
text: I18n.tr("Terminal", "Terminal")
|
||||
category: root.appCategory.Terminal
|
||||
tags: ["terminal", "console"]
|
||||
description: I18n.tr("Used for xdg-terminal-exec", "Used for xdg-terminal-exec")
|
||||
tags: ["terminal", "console"]
|
||||
description: I18n.tr("Used for xdg-terminal-exec", "Used for xdg-terminal-exec")
|
||||
}
|
||||
AppSelector {
|
||||
AppSelector {
|
||||
text: I18n.tr("Calendar", "Calendar")
|
||||
category: root.appCategory.Calendar
|
||||
tags: ["calendar", "events"]
|
||||
description: I18n.tr("Manages calendar events", "Manages calendar events")
|
||||
tags: ["calendar", "events"]
|
||||
description: I18n.tr("Manages calendar events", "Manages calendar events")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,14 +313,14 @@ Item {
|
||||
AppSelector {
|
||||
text: I18n.tr("Text Editor", "Text Editor")
|
||||
category: root.appCategory.TextEditor
|
||||
tags: ["text", "editor"]
|
||||
description: I18n.tr("For editing plain text files", "For editing plain text files")
|
||||
tags: ["text", "editor"]
|
||||
description: I18n.tr("For editing plain text files", "For editing plain text files")
|
||||
}
|
||||
AppSelector {
|
||||
text: I18n.tr("PDF Reader", "PDF Reader")
|
||||
category: root.appCategory.PDFReader
|
||||
tags: ["pdf", "reader"]
|
||||
description: I18n.tr("For reading PDF files", "For reading PDF files")
|
||||
tags: ["pdf", "reader"]
|
||||
description: I18n.tr("For reading PDF files", "For reading PDF files")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,20 +330,20 @@ Item {
|
||||
AppSelector {
|
||||
text: I18n.tr("Image Viewer", "Image Viewer")
|
||||
category: root.appCategory.ImageViewer
|
||||
tags: ["image", "viewer"]
|
||||
description: I18n.tr("Opens image files", "Opens image files")
|
||||
tags: ["image", "viewer"]
|
||||
description: I18n.tr("Opens image files", "Opens image files")
|
||||
}
|
||||
AppSelector {
|
||||
text: I18n.tr("Video Player", "Video Player")
|
||||
category: root.appCategory.VideoPlayer
|
||||
tags: ["video", "player"]
|
||||
description: I18n.tr("Plays video files", "Plays video files")
|
||||
tags: ["video", "player"]
|
||||
description: I18n.tr("Plays video files", "Plays video files")
|
||||
}
|
||||
AppSelector {
|
||||
text: I18n.tr("Music Player", "Music Player")
|
||||
category: root.appCategory.MusicPlayer
|
||||
tags: ["music", "player"]
|
||||
description: I18n.tr("Plays audio files", "Plays audio files")
|
||||
tags: ["music", "player"]
|
||||
description: I18n.tr("Plays audio files", "Plays audio files")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
import Quickshell.Io
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("DesktopService")
|
||||
property var _cache: ({})
|
||||
property bool gioAvailable: false;
|
||||
// For the queue that setDefaultApp uses
|
||||
property var _setDefaultAppQueue: []
|
||||
property bool _isProcessingQueue: false
|
||||
|
||||
Component.onCompleted: {
|
||||
checkGioAndXdgMime.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: checkGioAndXdgMime
|
||||
command: ["sh", "-c", "which gio && which xdg-mime"]
|
||||
running: false
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
root.gioAvailable = true;
|
||||
} else {
|
||||
root.gioAvailable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIconPath(moddedAppId) {
|
||||
if (!moddedAppId)
|
||||
@@ -39,18 +20,15 @@ Singleton {
|
||||
return _cache[moddedAppId];
|
||||
|
||||
const result = (function () {
|
||||
// 1. Try heuristic lookup (standard)
|
||||
const entry = DesktopEntries.heuristicLookup(moddedAppId);
|
||||
let icon = Quickshell.iconPath(entry?.icon, true);
|
||||
if (icon && icon !== "")
|
||||
return icon;
|
||||
|
||||
// 2. Try the appId itself as an icon name
|
||||
icon = Quickshell.iconPath(moddedAppId, true);
|
||||
if (icon && icon !== "")
|
||||
return icon;
|
||||
|
||||
// 3. Try variations of the appId (lowercase, last part)
|
||||
const appIds = [moddedAppId.toLowerCase()];
|
||||
const lastPart = moddedAppId.split('.').pop();
|
||||
if (lastPart && lastPart !== moddedAppId) {
|
||||
@@ -64,8 +42,6 @@ Singleton {
|
||||
return icon;
|
||||
}
|
||||
|
||||
// 4. Deep search in all desktop entries (if the above fail)
|
||||
// This is slow-ish but only happens once for failed icons
|
||||
const strippedId = moddedAppId.replace(/-bin$/, "").toLowerCase();
|
||||
const allEntries = DesktopEntries.applications.values;
|
||||
for (let i = 0; i < allEntries.length; i++) {
|
||||
@@ -81,7 +57,6 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Nix/Guix specific store check (as a last resort)
|
||||
for (const appId of appIds) {
|
||||
let execPath = entry?.execString?.replace(/\/bin.*/, "");
|
||||
if (!execPath)
|
||||
@@ -112,180 +87,53 @@ Singleton {
|
||||
return result;
|
||||
}
|
||||
|
||||
signal getDefaultAppResult(string mimeType, string desktopFileId, string callbackId)
|
||||
signal getAppsForMimeResult(string mimeType, var appIds, string callbackId)
|
||||
|
||||
// Set default app for a MIME type
|
||||
Component {
|
||||
id: gioSetDefaultApp
|
||||
function setDefaultApp(mimeType, desktopFileId, callbackId = "") {
|
||||
setDefaultAppForMimes([mimeType], desktopFileId, callbackId);
|
||||
}
|
||||
|
||||
Process {
|
||||
property string targetMimeType: ""
|
||||
property string targetDesktopFileId: ""
|
||||
property string callbackId: ""
|
||||
|
||||
// Check if the app actually supports the MIME type before setting it as default
|
||||
// This uses a shell script
|
||||
command: ["sh", "-c", `
|
||||
apps=$(gio mime "${targetMimeType}" 2>/dev/null | grep -v "^Default" | awk '{print $1}')
|
||||
if echo "$apps" | grep -Fxq "${targetDesktopFileId}"; then
|
||||
xdg-mime default "${targetDesktopFileId}" "${targetMimeType}"
|
||||
gio mime "${targetMimeType}" "${targetDesktopFileId}"
|
||||
fi
|
||||
`]
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
const success = (exitCode === 0)
|
||||
if (!success) {
|
||||
log.error("DesktopService: failed to set default app for", targetMimeType, "to", targetDesktopFileId, "(exit code:", exitCode + ")")
|
||||
}
|
||||
root._processDefaultAppQueue()
|
||||
destroy()
|
||||
function setDefaultAppForMimes(mimeTypes, desktopFileId, callbackId = "") {
|
||||
if (!desktopFileId.endsWith(".desktop")) {
|
||||
desktopFileId += ".desktop";
|
||||
}
|
||||
const filtered = (mimeTypes || []).filter(m => m && m.length > 0);
|
||||
if (filtered.length === 0)
|
||||
return;
|
||||
DMSService.sendRequest("mime.setDefaults", {
|
||||
"mimeTypes": filtered,
|
||||
"desktopId": desktopFileId
|
||||
}, response => {
|
||||
if (response.error) {
|
||||
log.warn("DesktopService.setDefaultApp failed:", response.error, "mimes:", filtered, "app:", desktopFileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDefaultApp(mimeType, desktopFileId, callbackId = "") {
|
||||
// Add .desktop in case it's missing, xdg-mime needs it
|
||||
if (!desktopFileId.endsWith(".desktop")) {
|
||||
desktopFileId += ".desktop";
|
||||
}
|
||||
|
||||
// Queue the request to avoid race conditions
|
||||
_setDefaultAppQueue.push({
|
||||
mimeType: mimeType,
|
||||
desktopFileId: desktopFileId,
|
||||
callbackId: callbackId
|
||||
})
|
||||
|
||||
// Start processing the queue if not already running
|
||||
if (!_isProcessingQueue) {
|
||||
_processDefaultAppQueue()
|
||||
}
|
||||
}
|
||||
|
||||
function _processDefaultAppQueue() {
|
||||
if (_setDefaultAppQueue.length === 0) {
|
||||
_isProcessingQueue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_isProcessingQueue = true;
|
||||
const request = _setDefaultAppQueue.shift();
|
||||
|
||||
const proc = gioSetDefaultApp.createObject(root, {
|
||||
targetMimeType: request.mimeType,
|
||||
targetDesktopFileId: request.desktopFileId,
|
||||
callbackId: request.callbackId,
|
||||
running: true
|
||||
})
|
||||
|
||||
if (!proc) {
|
||||
log.warn("DesktopService: couldn't create process for", request.mimeType, request.desktopFileId)
|
||||
_processDefaultAppQueue()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Get default app for a MIME type
|
||||
Component {
|
||||
id: xdgGetDefaultApp
|
||||
|
||||
Process {
|
||||
property string targetMimeType: ""
|
||||
property string callbackId: ""
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const desktopFileId = text.trim();
|
||||
root.getDefaultAppResult(targetMimeType, desktopFileId, callbackId);
|
||||
}
|
||||
}
|
||||
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.trim().length > 0) {
|
||||
log.error("DesktopService: xdg-mime query error:", text, "mime:", targetMimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => { destroy() }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultApp(mimeType, callbackId = "") {
|
||||
const proc = xdgGetDefaultApp.createObject(root, {
|
||||
targetMimeType: mimeType,
|
||||
callbackId: callbackId,
|
||||
command: ["xdg-mime", "query", "default", mimeType],
|
||||
running: true
|
||||
})
|
||||
|
||||
if (!proc) {
|
||||
log.warn("DesktopService: couldn't create process for", mimeType)
|
||||
}
|
||||
DMSService.sendRequest("mime.getDefault", {
|
||||
"mimeType": mimeType
|
||||
}, response => {
|
||||
if (response.error) {
|
||||
log.warn("DesktopService.getDefaultApp failed:", response.error, "mime:", mimeType);
|
||||
return;
|
||||
}
|
||||
const result = response.result || {};
|
||||
root.getDefaultAppResult(mimeType, result.desktopId || "", callbackId);
|
||||
});
|
||||
}
|
||||
|
||||
signal getDefaultAppResult(string mimeType, string desktopFileId, string callbackId)
|
||||
|
||||
|
||||
|
||||
// Get apps that support a MIME type
|
||||
Component {
|
||||
id: gioGetAppsForMime
|
||||
|
||||
Process {
|
||||
property string targetMimeType: ""
|
||||
property string callbackId: ""
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.split("\n");
|
||||
let appIds = [];
|
||||
let seen = {};
|
||||
|
||||
for (let line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
trimmed &&
|
||||
trimmed.endsWith(".desktop") &&
|
||||
!trimmed.startsWith("Default") &&
|
||||
!trimmed.startsWith("default=")
|
||||
) {
|
||||
if (!seen[trimmed]) {
|
||||
seen[trimmed] = true;
|
||||
appIds.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
root.getAppsForMimeResult(targetMimeType, appIds, callbackId);
|
||||
}
|
||||
}
|
||||
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.trim().length > 0) {
|
||||
log.error("DesktopService: gio mime query error:", text, "command:", command, "mime:", targetMimeType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => { destroy() }
|
||||
}
|
||||
function getAppsForMimeType(mimeType, callbackId = "") {
|
||||
DMSService.sendRequest("mime.appsForMime", {
|
||||
"mimeType": mimeType
|
||||
}, response => {
|
||||
if (response.error) {
|
||||
log.warn("DesktopService.getAppsForMimeType failed:", response.error, "mime:", mimeType);
|
||||
return;
|
||||
}
|
||||
const result = response.result || {};
|
||||
root.getAppsForMimeResult(mimeType, result.desktopIds || [], callbackId);
|
||||
});
|
||||
}
|
||||
|
||||
function getAppsForMimeType(mimeType, callbackId = "") {
|
||||
const proc = gioGetAppsForMime.createObject(root, {
|
||||
targetMimeType: mimeType,
|
||||
callbackId: callbackId,
|
||||
command: ["gio", "mime", mimeType],
|
||||
running: true
|
||||
});
|
||||
|
||||
if (!proc) {
|
||||
log.warn("DesktopService: couldn't create process for", mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
signal getAppsForMimeResult(string mimeType, var appIds, string callbackId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user