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:
|
Examples:
|
||||||
dms open https://example.com # Open URL with browser picker
|
dms open https://example.com # Open URL with browser picker
|
||||||
dms open file.pdf --mime application/pdf # Open PDF with compatible apps
|
dms open file.pdf # Open file (MIME auto-detected)
|
||||||
dms open document.odt --category Office # Open with office applications
|
dms open file.pdf --mime application/pdf # Override MIME detection
|
||||||
dms open --mime image/png image.png # Open image with image viewers`,
|
dms open document.odt --category Office # Open with office applications`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runOpen(args[0])
|
runOpen(args[0])
|
||||||
@@ -47,123 +47,58 @@ func init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// mimeTypeToCategories maps MIME types to desktop file categories
|
func detectMimeFromPath(path string) string {
|
||||||
func mimeTypeToCategories(mimeType string) []string {
|
ext := filepath.Ext(path)
|
||||||
// Split MIME type to get the main type
|
if ext == "" {
|
||||||
parts := strings.Split(mimeType, "/")
|
return ""
|
||||||
if len(parts) < 1 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return mime.TypeByExtension(ext)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runOpen(target string) {
|
func runOpen(target string) {
|
||||||
// Parse file:// URIs to extract the actual file path
|
|
||||||
actualTarget := target
|
actualTarget := target
|
||||||
detectedMimeType := openMimeType
|
detectedMimeType := openMimeType
|
||||||
detectedCategories := openCategories
|
|
||||||
detectedRequestType := openRequestType
|
detectedRequestType := openRequestType
|
||||||
|
|
||||||
log.Infof("Processing target: %s", target)
|
log.Infof("Processing target: %s", target)
|
||||||
|
|
||||||
if parsedURL, err := url.Parse(target); err == nil && parsedURL.Scheme == "file" {
|
switch {
|
||||||
// Extract file path from file:// URI and convert to absolute path
|
case isScheme(target, "file://"):
|
||||||
actualTarget = parsedURL.Path
|
parsedURL, err := url.Parse(target)
|
||||||
if absPath, err := filepath.Abs(actualTarget); err == nil {
|
if err == nil {
|
||||||
actualTarget = absPath
|
actualTarget = parsedURL.Path
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(actualTarget); err == nil {
|
||||||
|
actualTarget = abs
|
||||||
}
|
}
|
||||||
|
|
||||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||||
detectedRequestType = "file"
|
detectedRequestType = "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Detected file:// URI, extracted absolute path: %s", actualTarget)
|
|
||||||
|
|
||||||
// Auto-detect MIME type if not provided
|
|
||||||
if detectedMimeType == "" {
|
if detectedMimeType == "" {
|
||||||
ext := filepath.Ext(actualTarget)
|
detectedMimeType = detectMimeFromPath(actualTarget)
|
||||||
if ext != "" {
|
|
||||||
detectedMimeType = mime.TypeByExtension(ext)
|
|
||||||
log.Infof("Detected MIME type from extension %s: %s", ext, detectedMimeType)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
log.Infof("Detected file:// URI, absolute path: %s", actualTarget)
|
||||||
|
|
||||||
// Auto-detect categories based on MIME type if not provided
|
case isScheme(target, "http://"), isScheme(target, "https://"), isScheme(target, "dms://"):
|
||||||
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
|
|
||||||
if detectedRequestType == "" {
|
if detectedRequestType == "" {
|
||||||
detectedRequestType = "url"
|
detectedRequestType = "url"
|
||||||
}
|
}
|
||||||
log.Infof("Detected HTTP(S) URL")
|
log.Infof("Detected URL: %s", target)
|
||||||
} 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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
default:
|
||||||
|
if _, err := os.Stat(target); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(target); err == nil {
|
||||||
|
actualTarget = abs
|
||||||
|
}
|
||||||
if detectedRequestType == "url" || detectedRequestType == "" {
|
if detectedRequestType == "url" || detectedRequestType == "" {
|
||||||
detectedRequestType = "file"
|
detectedRequestType = "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Detected local file path, converted to absolute: %s", actualTarget)
|
|
||||||
|
|
||||||
// Auto-detect MIME type if not provided
|
|
||||||
if detectedMimeType == "" {
|
if detectedMimeType == "" {
|
||||||
ext := filepath.Ext(actualTarget)
|
detectedMimeType = detectMimeFromPath(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)
|
|
||||||
}
|
}
|
||||||
|
log.Infof("Detected local file path: %s", actualTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
params := map[string]any{
|
params := map[string]any{
|
||||||
@@ -174,8 +109,8 @@ func runOpen(target string) {
|
|||||||
params["mimeType"] = detectedMimeType
|
params["mimeType"] = detectedMimeType
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(detectedCategories) > 0 {
|
if len(openCategories) > 0 {
|
||||||
params["categories"] = detectedCategories
|
params["categories"] = openCategories
|
||||||
}
|
}
|
||||||
|
|
||||||
if detectedRequestType != "" {
|
if detectedRequestType != "" {
|
||||||
@@ -183,7 +118,7 @@ func runOpen(target string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
method := "apppicker.open"
|
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"
|
method = "browser.open"
|
||||||
params["url"] = target
|
params["url"] = target
|
||||||
}
|
}
|
||||||
@@ -203,3 +138,7 @@ func runOpen(target string) {
|
|||||||
|
|
||||||
log.Infof("Request sent successfully")
|
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 (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/desktop"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
)
|
)
|
||||||
@@ -32,7 +33,7 @@ func handleOpen(conn net.Conn, req models.Request, manager *Manager) {
|
|||||||
event := OpenEvent{
|
event := OpenEvent{
|
||||||
Target: target,
|
Target: target,
|
||||||
RequestType: models.GetOr(req, "requestType", "url"),
|
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 {
|
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/freedesktop"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
"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/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||||
@@ -92,6 +93,11 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(req.Method, "mime.") {
|
||||||
|
mime.HandleRequest(conn, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") {
|
if strings.HasPrefix(req.Method, "browser.") || strings.HasPrefix(req.Method, "apppicker.") {
|
||||||
if appPickerManager == nil {
|
if appPickerManager == nil {
|
||||||
models.RespondError(conn, req.ID, "apppicker manager not initialized")
|
models.RespondError(conn, req.ID, "apppicker manager not initialized")
|
||||||
|
|||||||
@@ -850,6 +850,8 @@ Item {
|
|||||||
|
|
||||||
filePickerModal.targetData = data.target;
|
filePickerModal.targetData = data.target;
|
||||||
filePickerModal.targetDataLabel = data.requestType || "file";
|
filePickerModal.targetDataLabel = data.requestType || "file";
|
||||||
|
filePickerModal.mimeType = data.mimeType || "";
|
||||||
|
filePickerModal.rememberMimeTypes = [];
|
||||||
|
|
||||||
if (data.categories && data.categories.length > 0) {
|
if (data.categories && data.categories.length > 0) {
|
||||||
filePickerModal.categoryFilter = data.categories;
|
filePickerModal.categoryFilter = data.categories;
|
||||||
|
|||||||
@@ -19,9 +19,19 @@ DankModal {
|
|||||||
property var categoryFilter: []
|
property var categoryFilter: []
|
||||||
property var usageHistoryKey: ""
|
property var usageHistoryKey: ""
|
||||||
property bool showTargetData: true
|
property bool showTargetData: true
|
||||||
|
property string mimeType: ""
|
||||||
|
property var rememberMimeTypes: []
|
||||||
|
property bool rememberChoice: false
|
||||||
|
property var mimeMatchedAppIds: []
|
||||||
|
|
||||||
signal applicationSelected(var app, string targetData)
|
signal applicationSelected(var app, string targetData)
|
||||||
|
|
||||||
|
function _normAppId(id) {
|
||||||
|
if (!id)
|
||||||
|
return "";
|
||||||
|
return id.replace(/\.desktop$/, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
shouldBeVisible: false
|
shouldBeVisible: false
|
||||||
allowStacking: true
|
allowStacking: true
|
||||||
modalWidth: 520
|
modalWidth: 520
|
||||||
@@ -37,6 +47,8 @@ DankModal {
|
|||||||
|
|
||||||
onOpened: {
|
onOpened: {
|
||||||
searchQuery = "";
|
searchQuery = "";
|
||||||
|
rememberChoice = false;
|
||||||
|
fetchMimeMatches();
|
||||||
updateApplicationList();
|
updateApplicationList();
|
||||||
selectedIndex = 0;
|
selectedIndex = 0;
|
||||||
Qt.callLater(() => {
|
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() {
|
function updateApplicationList() {
|
||||||
applicationsModel.clear();
|
applicationsModel.clear();
|
||||||
const apps = AppSearchService.applications;
|
const apps = AppSearchService.applications;
|
||||||
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {};
|
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 = [];
|
let filteredApps = [];
|
||||||
|
|
||||||
for (const app of apps) {
|
for (const app of apps) {
|
||||||
if (!app || !app.categories)
|
if (!app)
|
||||||
continue;
|
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 {
|
try {
|
||||||
for (const cat of app.categories) {
|
for (const cat of app.categories) {
|
||||||
if (categoryFilter.includes(cat)) {
|
if (categoryFilter.includes(cat)) {
|
||||||
matchesCategory = true;
|
categoryMatch = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,24 +117,28 @@ DankModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesCategory) {
|
const include = (!hasCategoryFilter && !hasMime) || mimeMatch || categoryMatch;
|
||||||
const name = app.name || "";
|
if (!include)
|
||||||
const lowerName = name.toLowerCase();
|
continue;
|
||||||
const lowerQuery = searchQuery.toLowerCase();
|
|
||||||
|
|
||||||
if (searchQuery === "" || lowerName.includes(lowerQuery)) {
|
const name = app.name || "";
|
||||||
filteredApps.push({
|
if (searchQuery !== "" && !name.toLowerCase().includes(lowerQuery))
|
||||||
name: name,
|
continue;
|
||||||
icon: app.icon || "application-x-executable",
|
|
||||||
exec: app.exec || app.execString || "",
|
filteredApps.push({
|
||||||
startupClass: app.startupWMClass || "",
|
name: name,
|
||||||
appData: app
|
icon: app.icon || "application-x-executable",
|
||||||
});
|
exec: app.exec || app.execString || "",
|
||||||
}
|
startupClass: app.startupWMClass || "",
|
||||||
}
|
appData: app,
|
||||||
|
mimeMatch: mimeMatch
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredApps.sort((a, b) => {
|
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 aId = a.appData.id || a.appData.execString || a.appData.exec || "";
|
||||||
const bId = b.appData.id || b.appData.execString || b.appData.exec || "";
|
const bId = b.appData.id || b.appData.execString || b.appData.exec || "";
|
||||||
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0;
|
const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0;
|
||||||
@@ -134,16 +183,15 @@ DankModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Keys.onPressed: event => {
|
Keys.onPressed: event => {
|
||||||
if (applicationsModel.count === 0)
|
if (event.key === Qt.Key_Tab && root.mimeType.length > 0) {
|
||||||
return;
|
root.rememberChoice = !root.rememberChoice;
|
||||||
|
|
||||||
// Toggle view mode with Tab key
|
|
||||||
if (event.key === Qt.Key_Tab) {
|
|
||||||
root.viewMode = root.viewMode === "grid" ? "list" : "grid";
|
|
||||||
event.accepted = true;
|
event.accepted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (applicationsModel.count === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
if (root.viewMode === "grid") {
|
if (root.viewMode === "grid") {
|
||||||
if (event.key === Qt.Key_Left) {
|
if (event.key === Qt.Key_Left) {
|
||||||
root.keyboardNavigationActive = true;
|
root.keyboardNavigationActive = true;
|
||||||
@@ -309,6 +357,9 @@ DankModal {
|
|||||||
if (root.showTargetData) {
|
if (root.showTargetData) {
|
||||||
usedHeight += 36 + Theme.spacingS;
|
usedHeight += 36 + Theme.spacingS;
|
||||||
}
|
}
|
||||||
|
if (root.mimeType && root.mimeType.length > 0) {
|
||||||
|
usedHeight += 36 + Theme.spacingS;
|
||||||
|
}
|
||||||
return parent.height - usedHeight;
|
return parent.height - usedHeight;
|
||||||
}
|
}
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
@@ -447,11 +498,38 @@ DankModal {
|
|||||||
maximumLineCount: 1
|
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) {
|
function launchApplication(app) {
|
||||||
if (!app)
|
if (!app)
|
||||||
return;
|
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);
|
root.applicationSelected(app, root.targetData);
|
||||||
|
|
||||||
if (usageHistoryKey && app.appId) {
|
if (usageHistoryKey && app.appId) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ AppPickerModal {
|
|||||||
viewMode: SettingsData.browserPickerViewMode || "grid"
|
viewMode: SettingsData.browserPickerViewMode || "grid"
|
||||||
usageHistoryKey: "browserUsageHistory"
|
usageHistoryKey: "browserUsageHistory"
|
||||||
showTargetData: true
|
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) {
|
function shellEscape(str) {
|
||||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||||
|
|||||||
@@ -244,8 +244,7 @@ Rectangle {
|
|||||||
"id": "default_apps",
|
"id": "default_apps",
|
||||||
"text": I18n.tr("Default Apps"),
|
"text": I18n.tr("Default Apps"),
|
||||||
"icon": "star",
|
"icon": "star",
|
||||||
"tabIndex": 34,
|
"tabIndex": 34
|
||||||
"gioOnly": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "running_apps",
|
"id": "running_apps",
|
||||||
@@ -364,8 +363,6 @@ Rectangle {
|
|||||||
return false;
|
return false;
|
||||||
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
|
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
|
||||||
return false;
|
return false;
|
||||||
if (item.gioOnly && !DesktopService.gioAvailable)
|
|
||||||
return false;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Item {
|
|||||||
PDFReader: 6,
|
PDFReader: 6,
|
||||||
Mail: 7,
|
Mail: 7,
|
||||||
Terminal: 8,
|
Terminal: 8,
|
||||||
Calendar: 9
|
Calendar: 9
|
||||||
})
|
})
|
||||||
|
|
||||||
property string currentWebBrowserAppId: ""
|
property string currentWebBrowserAppId: ""
|
||||||
@@ -35,63 +35,17 @@ Item {
|
|||||||
|
|
||||||
property var categoryModels: ({})
|
property var categoryModels: ({})
|
||||||
|
|
||||||
// A curated list of MIME types for each category.
|
// A curated list of MIME types for each category.
|
||||||
// The first one is used for fetching the apps list and current default,
|
// The first one is used for fetching the apps list and current default,
|
||||||
// the rest are for setting the default app.
|
// the rest are for setting the default app.
|
||||||
readonly property var mimeMapping: ({
|
readonly property var mimeMapping: ({
|
||||||
[root.appCategory.WebBrowser]: [
|
[root.appCategory.WebBrowser]: ["x-scheme-handler/https", "x-scheme-handler/http", "text/html", "application/xhtml+xml"],
|
||||||
"x-scheme-handler/https",
|
[root.appCategory.FileManager]: ["inode/directory", "x-scheme-handler/file"],
|
||||||
"x-scheme-handler/http",
|
[root.appCategory.TextEditor]: ["text/plain", "application/x-zerosize", "text/x-c++src", "text/x-csrc", "text/x-python", "text/x-shellscript", "application/json"],
|
||||||
"text/html",
|
[root.appCategory.ImageViewer]: ["image/png", "image/jpeg", "image/gif", "image/bmp", "image/webp", "image/avif", "image/svg+xml"],
|
||||||
"application/xhtml+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.FileManager]: [
|
[root.appCategory.PDFReader]: ["application/pdf", "application/x-ext-pdf", "application/x-bzpdf", "application/x-gzpdf", "application/vnd.comicbook-rar", "application/vnd.comicbook+zip"],
|
||||||
"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.Mail]: ["x-scheme-handler/mailto"],
|
||||||
[root.appCategory.Calendar]: ["x-scheme-handler/calendar"],
|
[root.appCategory.Calendar]: ["x-scheme-handler/calendar"],
|
||||||
[root.appCategory.Terminal]: ["terminal"] // Special
|
[root.appCategory.Terminal]: ["terminal"] // Special
|
||||||
@@ -111,11 +65,13 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAppDisplayName(appId) {
|
function getAppDisplayName(appId) {
|
||||||
|
if (appId === root.dmsChooserId || appId === "dms-open") {
|
||||||
|
return root.dmsChooserLabel;
|
||||||
|
}
|
||||||
let entry = DesktopEntries.heuristicLookup(appId);
|
let entry = DesktopEntries.heuristicLookup(appId);
|
||||||
if (entry && entry.name) {
|
if (entry && entry.name) {
|
||||||
return entry.name;
|
return entry.name;
|
||||||
}
|
}
|
||||||
// If the appname can't be found, show the appID
|
|
||||||
const withoutSuffix = appId.replace(/\.desktop$/, "");
|
const withoutSuffix = appId.replace(/\.desktop$/, "");
|
||||||
if (withoutSuffix !== appId) {
|
if (withoutSuffix !== appId) {
|
||||||
entry = DesktopEntries.heuristicLookup(withoutSuffix);
|
entry = DesktopEntries.heuristicLookup(withoutSuffix);
|
||||||
@@ -126,14 +82,28 @@ Item {
|
|||||||
return appId;
|
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) {
|
function loadCategoryModel(categoryKey, categorySearchName) {
|
||||||
const apps = loadAppSearchCategory(categorySearchName);
|
const apps = loadAppSearchCategory(categorySearchName);
|
||||||
const appIds = apps.map(app => app.id || app.execString || "").filter(id => id);
|
const appIds = apps.map(app => app.id || app.execString || "").filter(id => id);
|
||||||
let models = Object.assign({}, root.categoryModels);
|
let models = Object.assign({}, root.categoryModels);
|
||||||
models[categoryKey] = appIds.map(id => ({
|
const entries = appIds.map(id => ({
|
||||||
text: root.getAppDisplayName(id),
|
text: root.getAppDisplayName(id),
|
||||||
value: id
|
value: id
|
||||||
}));
|
}));
|
||||||
|
models[categoryKey] = categoryKey === root.appCategory.Terminal ? entries : root.withDmsChooser(entries);
|
||||||
root.categoryModels = models;
|
root.categoryModels = models;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,9 +117,9 @@ Item {
|
|||||||
loadCategoryModel(root.appCategory.Terminal, "TerminalEmulator");
|
loadCategoryModel(root.appCategory.Terminal, "TerminalEmulator");
|
||||||
getDefaultTerminal();
|
getDefaultTerminal();
|
||||||
break;
|
break;
|
||||||
case root.appCategory.WebBrowser:
|
case root.appCategory.WebBrowser:
|
||||||
// When using the MIME type, stuff like dms-run shows up.
|
// 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");
|
loadCategoryModel(root.appCategory.WebBrowser, "WebBrowser");
|
||||||
DesktopService.getDefaultApp(mimeMapping[category][0], category.toString());
|
DesktopService.getDefaultApp(mimeMapping[category][0], category.toString());
|
||||||
break;
|
break;
|
||||||
@@ -201,7 +171,7 @@ Item {
|
|||||||
Component {
|
Component {
|
||||||
id: xdgGetDefaultTerminal
|
id: xdgGetDefaultTerminal
|
||||||
Process {
|
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'`]
|
command: ["sh", "-c", `cat '${configPath}/xdg-terminals.list'`]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
@@ -231,30 +201,24 @@ Item {
|
|||||||
target: DesktopService
|
target: DesktopService
|
||||||
|
|
||||||
function onGetAppsForMimeResult(mimeType, appIds, callbackId) {
|
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 categoryIndex = parseInt(callbackId);
|
||||||
let models = Object.assign({}, root.categoryModels);
|
let models = Object.assign({}, root.categoryModels);
|
||||||
|
|
||||||
models[categoryIndex] = appIds.map(id => {
|
const entries = (appIds || []).map(id => ({
|
||||||
return {
|
text: root.getAppDisplayName(id),
|
||||||
text: root.getAppDisplayName(id),
|
value: id
|
||||||
value: id
|
}));
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
|
models[categoryIndex] = root.withDmsChooser(entries);
|
||||||
root.categoryModels = models;
|
root.categoryModels = models;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onGetDefaultAppResult(mimeType, desktopFileId, callbackId) {
|
function onGetDefaultAppResult(mimeType, desktopFileId, callbackId) {
|
||||||
if (!desktopFileId) {
|
if (!desktopFileId) {
|
||||||
log.info("No default app found for MIME type:", mimeType);
|
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)
|
options: (root.categoryModels[category] || []).map(opt => opt.text)
|
||||||
enabled: options.length > 0
|
enabled: options.length > 0
|
||||||
emptyText: options.length > 0 ? I18n.tr("Unset", "Unset") : ""
|
emptyText: options.length > 0 ? I18n.tr("Unset", "Unset") : ""
|
||||||
opacity: options.length > 0 ? 1 : 0.5
|
opacity: options.length > 0 ? 1 : 0.5
|
||||||
currentValue: {
|
currentValue: {
|
||||||
let id = root[propertyName(category)];
|
let id = root[propertyName(category)];
|
||||||
if (!id || id.length === 0) {
|
if (!id || id.length === 0) {
|
||||||
return ""
|
return "";
|
||||||
}
|
}
|
||||||
return root.getAppDisplayName(id);
|
return root.getAppDisplayName(id);
|
||||||
}
|
}
|
||||||
@@ -278,11 +242,7 @@ Item {
|
|||||||
if (category === root.appCategory.Terminal) {
|
if (category === root.appCategory.Terminal) {
|
||||||
root.setDefaultTerminal(found.value);
|
root.setDefaultTerminal(found.value);
|
||||||
} else {
|
} else {
|
||||||
// Set the default app for all MIME types in the category
|
DesktopService.setDefaultAppForMimes(root.mimeMapping[category], found.value, category.toString());
|
||||||
// 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());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,16 +269,16 @@ Item {
|
|||||||
|
|
||||||
AppSelector {
|
AppSelector {
|
||||||
text: I18n.tr("Web Browser", "Web Browser")
|
text: I18n.tr("Web Browser", "Web Browser")
|
||||||
tags: ["web", "browser", "internet"]
|
tags: ["web", "browser", "internet"]
|
||||||
category: root.appCategory.WebBrowser
|
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 {
|
AppSelector {
|
||||||
text: I18n.tr("Mail", "Mail")
|
text: I18n.tr("Mail", "Mail")
|
||||||
category: root.appCategory.Mail
|
category: root.appCategory.Mail
|
||||||
tags: ["mail", "email"]
|
tags: ["mail", "email"]
|
||||||
description: I18n.tr("Handles mailto links", "Handles mailto links")
|
description: I18n.tr("Handles mailto links", "Handles mailto links")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,21 +288,21 @@ Item {
|
|||||||
|
|
||||||
AppSelector {
|
AppSelector {
|
||||||
text: I18n.tr("File Manager", "File Manager")
|
text: I18n.tr("File Manager", "File Manager")
|
||||||
tags: ["file", "manager"]
|
tags: ["file", "manager"]
|
||||||
category: root.appCategory.FileManager
|
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 {
|
AppSelector {
|
||||||
text: I18n.tr("Terminal", "Terminal")
|
text: I18n.tr("Terminal", "Terminal")
|
||||||
category: root.appCategory.Terminal
|
category: root.appCategory.Terminal
|
||||||
tags: ["terminal", "console"]
|
tags: ["terminal", "console"]
|
||||||
description: I18n.tr("Used for xdg-terminal-exec", "Used for xdg-terminal-exec")
|
description: I18n.tr("Used for xdg-terminal-exec", "Used for xdg-terminal-exec")
|
||||||
}
|
}
|
||||||
AppSelector {
|
AppSelector {
|
||||||
text: I18n.tr("Calendar", "Calendar")
|
text: I18n.tr("Calendar", "Calendar")
|
||||||
category: root.appCategory.Calendar
|
category: root.appCategory.Calendar
|
||||||
tags: ["calendar", "events"]
|
tags: ["calendar", "events"]
|
||||||
description: I18n.tr("Manages calendar events", "Manages calendar events")
|
description: I18n.tr("Manages calendar events", "Manages calendar events")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,14 +313,14 @@ Item {
|
|||||||
AppSelector {
|
AppSelector {
|
||||||
text: I18n.tr("Text Editor", "Text Editor")
|
text: I18n.tr("Text Editor", "Text Editor")
|
||||||
category: root.appCategory.TextEditor
|
category: root.appCategory.TextEditor
|
||||||
tags: ["text", "editor"]
|
tags: ["text", "editor"]
|
||||||
description: I18n.tr("For editing plain text files", "For editing plain text files")
|
description: I18n.tr("For editing plain text files", "For editing plain text files")
|
||||||
}
|
}
|
||||||
AppSelector {
|
AppSelector {
|
||||||
text: I18n.tr("PDF Reader", "PDF Reader")
|
text: I18n.tr("PDF Reader", "PDF Reader")
|
||||||
category: root.appCategory.PDFReader
|
category: root.appCategory.PDFReader
|
||||||
tags: ["pdf", "reader"]
|
tags: ["pdf", "reader"]
|
||||||
description: I18n.tr("For reading PDF files", "For reading PDF files")
|
description: I18n.tr("For reading PDF files", "For reading PDF files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,20 +330,20 @@ Item {
|
|||||||
AppSelector {
|
AppSelector {
|
||||||
text: I18n.tr("Image Viewer", "Image Viewer")
|
text: I18n.tr("Image Viewer", "Image Viewer")
|
||||||
category: root.appCategory.ImageViewer
|
category: root.appCategory.ImageViewer
|
||||||
tags: ["image", "viewer"]
|
tags: ["image", "viewer"]
|
||||||
description: I18n.tr("Opens image files", "Opens image files")
|
description: I18n.tr("Opens image files", "Opens image files")
|
||||||
}
|
}
|
||||||
AppSelector {
|
AppSelector {
|
||||||
text: I18n.tr("Video Player", "Video Player")
|
text: I18n.tr("Video Player", "Video Player")
|
||||||
category: root.appCategory.VideoPlayer
|
category: root.appCategory.VideoPlayer
|
||||||
tags: ["video", "player"]
|
tags: ["video", "player"]
|
||||||
description: I18n.tr("Plays video files", "Plays video files")
|
description: I18n.tr("Plays video files", "Plays video files")
|
||||||
}
|
}
|
||||||
AppSelector {
|
AppSelector {
|
||||||
text: I18n.tr("Music Player", "Music Player")
|
text: I18n.tr("Music Player", "Music Player")
|
||||||
category: root.appCategory.MusicPlayer
|
category: root.appCategory.MusicPlayer
|
||||||
tags: ["music", "player"]
|
tags: ["music", "player"]
|
||||||
description: I18n.tr("Plays audio files", "Plays audio files")
|
description: I18n.tr("Plays audio files", "Plays audio files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,16 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
pragma ComponentBehavior: Bound
|
pragma ComponentBehavior: Bound
|
||||||
import Quickshell.Io
|
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property var log: Log.scoped("DesktopService")
|
||||||
property var _cache: ({})
|
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) {
|
function resolveIconPath(moddedAppId) {
|
||||||
if (!moddedAppId)
|
if (!moddedAppId)
|
||||||
@@ -39,18 +20,15 @@ Singleton {
|
|||||||
return _cache[moddedAppId];
|
return _cache[moddedAppId];
|
||||||
|
|
||||||
const result = (function () {
|
const result = (function () {
|
||||||
// 1. Try heuristic lookup (standard)
|
|
||||||
const entry = DesktopEntries.heuristicLookup(moddedAppId);
|
const entry = DesktopEntries.heuristicLookup(moddedAppId);
|
||||||
let icon = Quickshell.iconPath(entry?.icon, true);
|
let icon = Quickshell.iconPath(entry?.icon, true);
|
||||||
if (icon && icon !== "")
|
if (icon && icon !== "")
|
||||||
return icon;
|
return icon;
|
||||||
|
|
||||||
// 2. Try the appId itself as an icon name
|
|
||||||
icon = Quickshell.iconPath(moddedAppId, true);
|
icon = Quickshell.iconPath(moddedAppId, true);
|
||||||
if (icon && icon !== "")
|
if (icon && icon !== "")
|
||||||
return icon;
|
return icon;
|
||||||
|
|
||||||
// 3. Try variations of the appId (lowercase, last part)
|
|
||||||
const appIds = [moddedAppId.toLowerCase()];
|
const appIds = [moddedAppId.toLowerCase()];
|
||||||
const lastPart = moddedAppId.split('.').pop();
|
const lastPart = moddedAppId.split('.').pop();
|
||||||
if (lastPart && lastPart !== moddedAppId) {
|
if (lastPart && lastPart !== moddedAppId) {
|
||||||
@@ -64,8 +42,6 @@ Singleton {
|
|||||||
return icon;
|
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 strippedId = moddedAppId.replace(/-bin$/, "").toLowerCase();
|
||||||
const allEntries = DesktopEntries.applications.values;
|
const allEntries = DesktopEntries.applications.values;
|
||||||
for (let i = 0; i < allEntries.length; i++) {
|
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) {
|
for (const appId of appIds) {
|
||||||
let execPath = entry?.execString?.replace(/\/bin.*/, "");
|
let execPath = entry?.execString?.replace(/\/bin.*/, "");
|
||||||
if (!execPath)
|
if (!execPath)
|
||||||
@@ -112,180 +87,53 @@ Singleton {
|
|||||||
return result;
|
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
|
function setDefaultApp(mimeType, desktopFileId, callbackId = "") {
|
||||||
Component {
|
setDefaultAppForMimes([mimeType], desktopFileId, callbackId);
|
||||||
id: gioSetDefaultApp
|
}
|
||||||
|
|
||||||
Process {
|
function setDefaultAppForMimes(mimeTypes, desktopFileId, callbackId = "") {
|
||||||
property string targetMimeType: ""
|
if (!desktopFileId.endsWith(".desktop")) {
|
||||||
property string targetDesktopFileId: ""
|
desktopFileId += ".desktop";
|
||||||
property string callbackId: ""
|
}
|
||||||
|
const filtered = (mimeTypes || []).filter(m => m && m.length > 0);
|
||||||
// Check if the app actually supports the MIME type before setting it as default
|
if (filtered.length === 0)
|
||||||
// This uses a shell script
|
return;
|
||||||
command: ["sh", "-c", `
|
DMSService.sendRequest("mime.setDefaults", {
|
||||||
apps=$(gio mime "${targetMimeType}" 2>/dev/null | grep -v "^Default" | awk '{print $1}')
|
"mimeTypes": filtered,
|
||||||
if echo "$apps" | grep -Fxq "${targetDesktopFileId}"; then
|
"desktopId": desktopFileId
|
||||||
xdg-mime default "${targetDesktopFileId}" "${targetMimeType}"
|
}, response => {
|
||||||
gio mime "${targetMimeType}" "${targetDesktopFileId}"
|
if (response.error) {
|
||||||
fi
|
log.warn("DesktopService.setDefaultApp failed:", response.error, "mimes:", filtered, "app:", desktopFileId);
|
||||||
`]
|
|
||||||
|
|
||||||
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 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 = "") {
|
function getDefaultApp(mimeType, callbackId = "") {
|
||||||
const proc = xdgGetDefaultApp.createObject(root, {
|
DMSService.sendRequest("mime.getDefault", {
|
||||||
targetMimeType: mimeType,
|
"mimeType": mimeType
|
||||||
callbackId: callbackId,
|
}, response => {
|
||||||
command: ["xdg-mime", "query", "default", mimeType],
|
if (response.error) {
|
||||||
running: true
|
log.warn("DesktopService.getDefaultApp failed:", response.error, "mime:", mimeType);
|
||||||
})
|
return;
|
||||||
|
}
|
||||||
if (!proc) {
|
const result = response.result || {};
|
||||||
log.warn("DesktopService: couldn't create process for", mimeType)
|
root.getDefaultAppResult(mimeType, result.desktopId || "", callbackId);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
signal getDefaultAppResult(string mimeType, string desktopFileId, string callbackId)
|
function getAppsForMimeType(mimeType, callbackId = "") {
|
||||||
|
DMSService.sendRequest("mime.appsForMime", {
|
||||||
|
"mimeType": mimeType
|
||||||
|
}, response => {
|
||||||
// Get apps that support a MIME type
|
if (response.error) {
|
||||||
Component {
|
log.warn("DesktopService.getAppsForMimeType failed:", response.error, "mime:", mimeType);
|
||||||
id: gioGetAppsForMime
|
return;
|
||||||
|
}
|
||||||
Process {
|
const result = response.result || {};
|
||||||
property string targetMimeType: ""
|
root.getAppsForMimeResult(mimeType, result.desktopIds || [], callbackId);
|
||||||
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 = "") {
|
|
||||||
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