Files

425 lines
17 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Modules.Settings.Widgets
Item {
id: root
readonly property var log: Log.scoped("DefaultAppsTab")
readonly property var appCategory: ({
WebBrowser: 0,
FileManager: 1,
TextEditor: 2,
ImageViewer: 3,
VideoPlayer: 4,
MusicPlayer: 5,
PDFReader: 6,
Mail: 7,
Terminal: 8,
Calendar: 9,
Maps: 10,
WordProcessor: 11,
Spreadsheet: 12,
Presentation: 13,
ArchiveManager: 14
})
property string currentWebBrowserAppId: ""
property string currentFileManagerAppId: ""
property string currentTextEditorAppId: ""
property string currentImageViewerAppId: ""
property string currentVideoPlayerAppId: ""
property string currentMusicPlayerAppId: ""
property string currentPDFReaderAppId: ""
property string currentMailAppId: ""
property string currentTerminalAppId: ""
property string currentCalendarAppId: ""
property string currentMapsAppId: ""
property string currentWordProcessorAppId: ""
property string currentSpreadsheetAppId: ""
property string currentPresentationAppId: ""
property string currentArchiveManagerAppId: ""
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.
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", "x-scheme-handler/sftp"],
[root.appCategory.TextEditor]: ["text/plain", "text/markdown", "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", "image/tiff", "image/heic", "image/heif", "image/jxl"],
[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]: ["text/calendar", "x-scheme-handler/calendar", "x-scheme-handler/webcal", "x-scheme-handler/webcals"],
[root.appCategory.Maps]: ["x-scheme-handler/geo"],
[root.appCategory.WordProcessor]: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/msword", "application/vnd.oasis.opendocument.text", "application/rtf"],
[root.appCategory.Spreadsheet]: ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel", "application/vnd.oasis.opendocument.spreadsheet", "text/csv"],
[root.appCategory.Presentation]: ["application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.ms-powerpoint", "application/vnd.oasis.opendocument.presentation"],
[root.appCategory.ArchiveManager]: ["application/zip", "application/x-tar", "application/x-7z-compressed", "application/x-rar", "application/gzip", "application/x-bzip2", "application/x-xz"],
[root.appCategory.Terminal]: ["terminal"] // Special
})
function propertyName(type) {
const names = Object.keys(root.appCategory);
return "current" + names[type] + "AppId";
}
function loadAppSearchCategory(categoryName) {
const apps = AppSearchService.getVisibleApplications() || [];
return apps.filter(app => {
const categories = app.categories || [];
return categories.includes(categoryName);
});
}
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;
}
const withoutSuffix = appId.replace(/\.desktop$/, "");
if (withoutSuffix !== appId) {
entry = DesktopEntries.heuristicLookup(withoutSuffix);
if (entry && entry.name) {
return entry.name;
}
}
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 [
{
name: root.dmsChooserLabel,
value: root.dmsChooserId,
icon: "material:apps"
}
].concat(filtered);
}
function getAppIcon(appId) {
const entry = DesktopEntries.heuristicLookup(appId) || DesktopEntries.heuristicLookup(stripDesktopSuffix(appId));
return entry?.icon || "";
}
function stripDesktopSuffix(id) {
return (id || "").replace(/\.desktop$/, "");
}
function labeled(entries) {
const counts = {};
entries.forEach(e => counts[e.name] = (counts[e.name] || 0) + 1);
return entries.map(e => Object.assign({}, e, {
text: counts[e.name] > 1 ? `${e.name} (${stripDesktopSuffix(e.value)})` : e.name
}));
}
function labelFor(category, id) {
const wanted = stripDesktopSuffix(id);
const found = (root.categoryModels[category] || []).find(opt => stripDesktopSuffix(opt.value) === wanted);
return found ? found.text : root.getAppDisplayName(id);
}
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);
const entries = appIds.map(id => ({
name: root.getAppDisplayName(id),
value: id,
icon: root.getAppIcon(id)
}));
models[categoryKey] = labeled(categoryKey === root.appCategory.Terminal ? entries : root.withDmsChooser(entries));
root.categoryModels = models;
}
Component.onCompleted: {
const categories = Object.values(root.appCategory);
categories.forEach(category => {
switch (category) {
case root.appCategory.Terminal:
// Terminals don't have a MIME type
loadCategoryModel(root.appCategory.Terminal, "TerminalEmulator");
getDefaultTerminal();
break;
case root.appCategory.WebBrowser:
// When using the MIME type, stuff like dms-run shows up.
// It's probably better to use the category.
loadCategoryModel(root.appCategory.WebBrowser, "WebBrowser");
DesktopService.getDefaultApp(mimeMapping[category][0], category.toString());
break;
case root.appCategory.FileManager:
// Use categories for file managers instead,
// you don't want Kate as your file manager just because it can open folders
loadCategoryModel(root.appCategory.FileManager, "FileManager");
DesktopService.getDefaultApp(mimeMapping[category][0], category.toString());
DesktopService.getHandlersForMimeType(mimeMapping[category][0], category.toString());
break;
default:
const mimeType = mimeMapping[category][0];
DesktopService.getDefaultApp(mimeType, category.toString());
DesktopService.getAppsForMimeType(mimeType, category.toString());
break;
}
});
}
function getDefaultTerminal() {
// Run xdg-terminal-exec to get the default terminal
const proc = xdgGetDefaultTerminal.createObject(root, {
running: true
});
}
function setDefaultTerminal(terminalId) {
// Write to xdg-terminals.list
const proc = xdgSetDefaultTerminal.createObject(root, {
terminalId: terminalId,
running: true
});
}
Component {
id: xdgSetDefaultTerminal
Process {
property string terminalId: ""
property string configPath: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")
command: ["sh", "-c", `echo "${terminalId}.desktop" > "${configPath}/xdg-terminals.list"`]
onExited: (exitCode, exitStatus) => {
if (exitCode != 0) {
log.error("Failed to write xdg-terminals.list, exit code:", exitCode);
}
destroy();
}
}
}
Component {
id: xdgGetDefaultTerminal
Process {
property string configPath: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")
command: ["sh", "-c", `cat '${configPath}/xdg-terminals.list'`]
stdout: StdioCollector {
onStreamFinished: {
const defaultTerminal = text.trim();
if (defaultTerminal) {
root.currentTerminalAppId = defaultTerminal;
} else {
log.warn("No default terminal found");
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim().length > 0) {
log.error("Error getting default terminal:", text);
}
}
}
onExited: (exitCode, exitStatus) => {
destroy();
}
}
}
Connections {
target: DesktopService
function onGetAppsForMimeResult(mimeType, appIds, callbackId) {
let categoryIndex = parseInt(callbackId);
let models = Object.assign({}, root.categoryModels);
const entries = (appIds || []).map(id => ({
name: root.getAppDisplayName(id),
value: id,
icon: root.getAppIcon(id)
}));
models[categoryIndex] = root.labeled(root.withDmsChooser(entries));
root.categoryModels = models;
}
function onGetHandlersForMimeResult(mimeType, apps, callbackId) {
const categoryIndex = parseInt(callbackId);
let models = Object.assign({}, root.categoryModels);
const existing = models[categoryIndex] || root.withDmsChooser([]);
const known = new Set(existing.map(opt => root.stripDesktopSuffix(opt.value)));
const extra = (apps || []).filter(app => app.id && !known.has(root.stripDesktopSuffix(app.id))).map(app => ({
name: app.name || root.getAppDisplayName(app.id),
value: app.id,
icon: app.icon || root.getAppIcon(app.id)
}));
if (extra.length === 0) {
return;
}
models[categoryIndex] = root.labeled(existing.concat(extra));
root.categoryModels = models;
}
function onGetDefaultAppResult(mimeType, desktopFileId, callbackId) {
if (!desktopFileId) {
log.info("No default app found for MIME type:", mimeType);
return;
}
root[propertyName(parseInt(callbackId))] = desktopFileId;
}
}
component AppSelector: SettingsDropdownRow {
property int category: -1
options: (root.categoryModels[category] || []).map(opt => opt.text)
optionImages: (root.categoryModels[category] || []).map(opt => opt.icon || "")
enabled: options.length > 0
emptyText: options.length > 0 ? I18n.tr("Unset", "Unset") : ""
opacity: options.length > 0 ? 1 : 0.5
currentValue: {
const id = root[propertyName(category)];
if (!id) {
return "";
}
return root.labelFor(category, id);
}
onValueChanged: val => {
let model = root.categoryModels[category] || [];
let found = model.find(opt => opt.text === val);
if (found) {
if (category === root.appCategory.Terminal) {
root.setDefaultTerminal(found.value);
} else {
DesktopService.setDefaultAppForMimes(root.mimeMapping[category], found.value, category.toString());
}
}
}
}
// Dropdowns
SettingsPage {
id: mainColumn
SettingsCard {
settingKey: "defaultAppsInternet"
tags: ["browser", "mail", "email", "web"]
title: I18n.tr("Internet", "Internet")
iconName: "public"
AppSelector {
text: I18n.tr("Web browser")
tags: ["web", "browser", "internet", "links", "html"]
category: root.appCategory.WebBrowser
}
AppSelector {
text: I18n.tr("Mail", "Mail")
category: root.appCategory.Mail
tags: ["mail", "email", "mailto"]
}
AppSelector {
text: I18n.tr("Maps", "Maps")
category: root.appCategory.Maps
tags: ["maps", "geo", "location"]
}
}
SettingsCard {
settingKey: "defaultAppsUtilities"
tags: ["file", "manager", "terminal", "editor", "archive", "zip"]
title: I18n.tr("Utilities", "Utilities")
iconName: "terminal"
AppSelector {
text: I18n.tr("File manager")
tags: ["file", "manager", "directory", "sftp"]
category: root.appCategory.FileManager
}
AppSelector {
text: I18n.tr("Terminal", "Terminal")
category: root.appCategory.Terminal
tags: ["terminal", "console", "xdg-terminal-exec"]
}
AppSelector {
text: I18n.tr("Calendar", "Calendar")
category: root.appCategory.Calendar
tags: ["calendar", "events"]
}
AppSelector {
text: I18n.tr("Archive manager", "default app selector label for archive manager program for zip and tar files")
category: root.appCategory.ArchiveManager
tags: ["archive", "zip", "tar", "compress"]
}
}
SettingsCard {
settingKey: "defaultAppsDocuments"
tags: ["pdf", "text", "reader", "office", "word", "spreadsheet", "presentation"]
title: I18n.tr("Documents", "Documents")
iconName: "edit_document"
AppSelector {
text: I18n.tr("Text editor")
category: root.appCategory.TextEditor
tags: ["text", "editor"]
}
AppSelector {
text: I18n.tr("PDF reader")
category: root.appCategory.PDFReader
tags: ["pdf", "reader"]
}
AppSelector {
text: I18n.tr("Word processor", "default app selector label for word processor program")
category: root.appCategory.WordProcessor
tags: ["word", "document", "docs", "office", "writer"]
}
AppSelector {
text: I18n.tr("Spreadsheet", "default app selector label for spreadsheet program")
category: root.appCategory.Spreadsheet
tags: ["spreadsheet", "excel", "calc", "csv", "office"]
}
AppSelector {
text: I18n.tr("Presentation", "default app selector label for slide presentation program, not presentation mode")
category: root.appCategory.Presentation
tags: ["presentation", "powerpoint", "ppt", "slides", "office"]
}
}
SettingsCard {
settingKey: "defaultAppsMultimedia"
tags: ["image", "video", "music", "viewer", "player"]
title: I18n.tr("Multimedia", "Multimedia")
iconName: "movie"
AppSelector {
text: I18n.tr("Image viewer")
category: root.appCategory.ImageViewer
tags: ["image", "viewer"]
}
AppSelector {
text: I18n.tr("Video player")
category: root.appCategory.VideoPlayer
tags: ["video", "player"]
}
AppSelector {
text: I18n.tr("Music player")
category: root.appCategory.MusicPlayer
tags: ["music", "player", "audio"]
}
}
}
}