mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-06-08 04:09:15 -04:00
feat(settings): Added Settings Tab Autostart App (XDG Autostart) (#2535)
* feat(Autostart): add Autostart tab and application selection popup * fix(AutoStartTab): update systemdUserDir property to use XDG_CONFIG_HOME * fix(AutoStartTab): update autostartDir and systemdUserDir to use StandardPaths for config home * refactor(AutoStartTab): use FileView & FolderListModel * refactor(AutoStartTab): implement systemd override generation for autostart applications using FileView * feat(AutoStartTab): add systemd check to determine environment and update tray icon visibility * feat(SettingsSidebar, AutoStartTab, DesktopService): add autostart functionality and systemd checks * feat(AutoStartTab): add hidden property support for desktop entries and toggle functionality * feat(AutoStartTab): add initialize autostart directory and add toast if writer failed * add(AutoStartTab): logging for scoped log tracking ---------
This commit is contained in:
@@ -570,5 +570,22 @@ FocusScope {
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: autoStartLoader
|
||||
anchors.fill: parent
|
||||
active: root.currentIndex === 36
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: AutoStartTab {
|
||||
parentModal: root.parentModal
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
Qt.callLater(() => item.forceActiveFocus());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,13 @@ Rectangle {
|
||||
"icon": "app_registration",
|
||||
"tabIndex": 19,
|
||||
"hyprlandNiriOnly": true
|
||||
},
|
||||
{
|
||||
"id": "autostart",
|
||||
"text": I18n.tr("Autostart Apps"),
|
||||
"icon": "line_start",
|
||||
"tabIndex": 36,
|
||||
"autostartOnly": true
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -369,6 +376,8 @@ Rectangle {
|
||||
return false;
|
||||
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
|
||||
return false;
|
||||
if (item.autostartOnly && !DesktopService.autostartAvailable)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import Qt.labs.folderlistmodel
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import qs.Modules.Settings.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("AutoStartTab")
|
||||
property var parentModal: null
|
||||
property var entries: []
|
||||
property var desktopApps: []
|
||||
property string newEntryType: "desktop"
|
||||
property string newEntryName: ""
|
||||
property string newEntryExec: ""
|
||||
property string newEntryDesktopId: ""
|
||||
property string newEntryCommandWrapper: "%command%"
|
||||
|
||||
readonly property string autostartDir: {
|
||||
const configHome = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
|
||||
return configHome + "/autostart";
|
||||
}
|
||||
|
||||
function lookupDesktopIcon(name, exec, fileName) {
|
||||
const appId = fileName ? fileName.replace(/\.desktop$/, "") : "";
|
||||
let entry = appId ? DesktopEntries.heuristicLookup(appId) : null;
|
||||
if (entry && entry.icon) return entry.icon;
|
||||
if (exec) {
|
||||
const cmdBase = exec.split(" ")[0].split("/").pop();
|
||||
for (let i = 0; i < root.desktopApps.length; i++) {
|
||||
const app = root.desktopApps[i];
|
||||
if (app.icon) {
|
||||
const appExec = (app.exec || app.execString || "").split(" ")[0].split("/").pop();
|
||||
if (appExec === cmdBase) return app.icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseDesktopFile(content, filePath) {
|
||||
if (!content || content.length === 0) return null;
|
||||
const lines = content.split("\n");
|
||||
let name = "";
|
||||
let execCmd = "";
|
||||
let icon = "";
|
||||
let hidden = false;
|
||||
let isDesktopEntry = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line === "[Desktop Entry]") {
|
||||
isDesktopEntry = true;
|
||||
} else if (isDesktopEntry) {
|
||||
if (line.startsWith("[")) break;
|
||||
const nameMatch = line.match(/^Name=(.+)$/);
|
||||
if (nameMatch) name = nameMatch[1];
|
||||
const execMatch = line.match(/^Exec=(.+)$/);
|
||||
if (execMatch) execCmd = execMatch[1];
|
||||
const iconMatch = line.match(/^Icon=(.+)$/);
|
||||
if (iconMatch) icon = iconMatch[1];
|
||||
const hiddenMatch = line.match(/^Hidden=(true|false)$/);
|
||||
if (hiddenMatch) hidden = hiddenMatch[1] === "true";
|
||||
}
|
||||
}
|
||||
if (!isDesktopEntry || !name || !execCmd) return null;
|
||||
const fileName = filePath.split("/").pop();
|
||||
if (!icon) icon = root.lookupDesktopIcon(name, execCmd, fileName);
|
||||
return { name: name, exec: execCmd, icon: icon, hidden: hidden, filePath: filePath, fileName: fileName, content: content };
|
||||
}
|
||||
|
||||
function addEntry() {
|
||||
if (newEntryType === "desktop") {
|
||||
if (!newEntryDesktopId) return;
|
||||
const app = desktopApps.find(a => (a.id || a.execString) === newEntryDesktopId);
|
||||
if (!app) return;
|
||||
const entryName = app.name || newEntryDesktopId;
|
||||
const appExec = app.exec || app.execString || "";
|
||||
const execCmd = root.newEntryCommandWrapper.replace("%command%", appExec);
|
||||
const appIcon = app.icon || "";
|
||||
const fileName = entryName.toLowerCase().replace(/[^a-z0-9]/g, "-") + ".desktop";
|
||||
writeDesktopFile(fileName, entryName, execCmd, appIcon);
|
||||
} else {
|
||||
if (!newEntryName || !newEntryExec) return;
|
||||
const fileName = newEntryName.toLowerCase().replace(/[^a-z0-9]/g, "-") + ".desktop";
|
||||
writeDesktopFile(fileName, newEntryName, newEntryExec, "");
|
||||
}
|
||||
}
|
||||
|
||||
function writeDesktopFile(fileName, name, execCmd, icon) {
|
||||
let content = "[Desktop Entry]\nType=Application\nName=" + name + "\nExec=" + execCmd + "\n";
|
||||
if (icon) content += "Icon=" + icon + "\n";
|
||||
writerFileView.path = root.autostartDir + "/" + fileName;
|
||||
writerFileView.setText(content);
|
||||
root.resetNewEntry();
|
||||
}
|
||||
|
||||
function setHidden(entry, hidden) {
|
||||
if (!entry || !entry.content) return;
|
||||
const lines = entry.content.split("\n");
|
||||
const hiddenValue = hidden ? "true" : "false";
|
||||
let found = false;
|
||||
const merged = lines.map(line => {
|
||||
const m = line.match(/^Hidden=(true|false)\s*$/);
|
||||
if (m) {
|
||||
found = true;
|
||||
return "Hidden=" + hiddenValue;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!found) {
|
||||
const idx = merged.findIndex(l => l.trim() === "[Desktop Entry]");
|
||||
if (idx >= 0)
|
||||
merged.splice(idx + 1, 0, "Hidden=" + hiddenValue);
|
||||
else
|
||||
merged.unshift("Hidden=" + hiddenValue);
|
||||
}
|
||||
writerFileView.path = entry.filePath;
|
||||
writerFileView.setText(merged.join("\n"));
|
||||
}
|
||||
|
||||
function removeEntry(filePath) {
|
||||
const proc = removeFileComponent.createObject(root, {
|
||||
targetPath: filePath,
|
||||
running: true
|
||||
});
|
||||
}
|
||||
|
||||
function resetNewEntry() {
|
||||
newEntryType = "desktop";
|
||||
newEntryName = "";
|
||||
newEntryExec = "";
|
||||
newEntryDesktopId = "";
|
||||
newEntryCommandWrapper = "%command%";
|
||||
}
|
||||
|
||||
function addOrUpdateEntry(entry) {
|
||||
var list = root.entries.slice();
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].filePath === entry.filePath) {
|
||||
list[i] = entry;
|
||||
root.entries = list;
|
||||
return;
|
||||
}
|
||||
}
|
||||
list.push(entry);
|
||||
list.sort((a, b) => a.fileName.localeCompare(b.fileName));
|
||||
root.entries = list;
|
||||
}
|
||||
|
||||
function removeEntryByPath(filePath) {
|
||||
var list = root.entries.filter(e => e.filePath !== filePath);
|
||||
root.entries = list;
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: writerFileView
|
||||
blockLoading: true
|
||||
atomicWrites: true
|
||||
onSaveFailed: error => {
|
||||
ToastService.showError(I18n.tr("Failed to write autostart entry"))
|
||||
log.warn("Failed to write autostart entry to " + writerFileView.path + ": " + error);
|
||||
}
|
||||
}
|
||||
|
||||
FolderListModel {
|
||||
id: folderModel
|
||||
nameFilters: ["*.desktop"]
|
||||
showDirs: false
|
||||
showDotAndDotDot: false
|
||||
showHidden: false
|
||||
sortField: FolderListModel.Name
|
||||
|
||||
onStatusChanged: {
|
||||
if (status !== FolderListModel.Ready) return;
|
||||
// rebuild entries
|
||||
const validPaths = new Set();
|
||||
for (let i = 0; i < folderModel.count; i++) {
|
||||
const fp = folderModel.get(i, "filePath") || "";
|
||||
validPaths.add(fp.startsWith("file://") ? fp.substring(7) : fp);
|
||||
}
|
||||
const filtered = root.entries.filter(e => validPaths.has(e.filePath));
|
||||
if (filtered.length !== root.entries.length) {
|
||||
root.entries = filtered;
|
||||
}
|
||||
}
|
||||
|
||||
onCountChanged: {
|
||||
fileReaderRepeater.model = count;
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: fileReaderRepeater
|
||||
model: 0
|
||||
|
||||
Item {
|
||||
required property int index
|
||||
|
||||
readonly property string filePath: {
|
||||
const fp = folderModel.get(index, "filePath") || "";
|
||||
return fp.startsWith("file://") ? fp.substring(7) : fp;
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: fileView
|
||||
path: filePath ? "file://" + filePath : ""
|
||||
watchChanges: true
|
||||
|
||||
onLoaded: {
|
||||
const entry = root.parseDesktopFile(fileView.text(), filePath);
|
||||
if (entry) {
|
||||
root.addOrUpdateEntry(entry);
|
||||
} else {
|
||||
root.removeEntryByPath(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
onFileChanged: reload()
|
||||
|
||||
onLoadFailed: {
|
||||
root.removeEntryByPath(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: removeFileComponent
|
||||
Process {
|
||||
property string targetPath: ""
|
||||
command: ["rm", "-f", targetPath]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.removeEntryByPath(targetPath);
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateTrayIconFixSystemdOverride() {
|
||||
const configHome = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
|
||||
const dir = configHome + "/systemd/user/app-@autostart.service.d";
|
||||
const proc = systemdOverrideMkDirComp.createObject(root, { targetPath: dir, running: true });
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: systemdOverrideWriter
|
||||
atomicWrites: true
|
||||
|
||||
// make sure we don't overwrite an existing override with a default one, in case the user has already customized it
|
||||
function buildOverrideContent(existing) {
|
||||
if (!existing) return "[Unit]\nAfter=dms.service\n";
|
||||
const lines = existing.split("\n");
|
||||
const hasAfter = lines.some(l => l.trim() === "After=dms.service");
|
||||
if (hasAfter) return existing;
|
||||
const unitIdx = lines.findIndex(l => l.trim() === "[Unit]");
|
||||
if (unitIdx >= 0) {
|
||||
lines.splice(unitIdx + 1, 0, "After=dms.service");
|
||||
} else {
|
||||
lines.push("[Unit]", "After=dms.service");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
onLoaded: {
|
||||
const merged = buildOverrideContent(text());
|
||||
if (merged !== text()) setText(merged);
|
||||
ToastService.showInfo(I18n.tr("Systemd Override generated"));
|
||||
}
|
||||
|
||||
onLoadFailed: {
|
||||
setText("[Unit]\nAfter=dms.service\n");
|
||||
ToastService.showInfo(I18n.tr("Systemd Override generated"));
|
||||
}
|
||||
|
||||
onSaveFailed: error => {
|
||||
ToastService.showError(I18n.tr("Failed to generate systemd override"));
|
||||
log.warn("Failed to write systemd override to " + systemdOverrideWriter.path + ": " + error);
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: systemdOverrideMkDirComp
|
||||
Process {
|
||||
property string targetPath: ""
|
||||
command: ["mkdir", "-p", targetPath]
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
systemdOverrideWriter.path = targetPath + "/override.conf";
|
||||
} else {
|
||||
ToastService.showError(I18n.tr("Failed to generate systemd override"));
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: autostartInitMkDirComp
|
||||
Process {
|
||||
command: ["mkdir", "-p", root.autostartDir]
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
folderModel.folder = "file://" + root.autostartDir;
|
||||
}
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
desktopApps = AppSearchService.getVisibleApplications() || [];
|
||||
autostartInitMkDirComp.createObject(root, { running: true });
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
desktopApps = [];
|
||||
}
|
||||
|
||||
DankFlickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentHeight: mainColumn.height + Theme.spacingXL
|
||||
contentWidth: width
|
||||
|
||||
AppBrowserPopup {
|
||||
id: appBrowserPopup
|
||||
appsModel: root.desktopApps
|
||||
parentModal: root.parentModal
|
||||
onAppSelected: appId => root.newEntryDesktopId = appId
|
||||
}
|
||||
|
||||
Column {
|
||||
id: mainColumn
|
||||
topPadding: 4
|
||||
width: Math.min(550, parent.width - Theme.spacingL * 2)
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXL
|
||||
visible: DesktopService.autostartAvailable
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "add_circle"
|
||||
title: I18n.tr("Add Entry")
|
||||
|
||||
SettingsDropdownRow {
|
||||
width: parent.width
|
||||
text: I18n.tr("Entry Type")
|
||||
description: I18n.tr("Choose whether to launch a desktop app or a command")
|
||||
currentValue: root.newEntryType === "desktop" ? I18n.tr("Desktop Application") : I18n.tr("Command Line")
|
||||
options: [I18n.tr("Desktop Application"), I18n.tr("Command Line")]
|
||||
onValueChanged: val => {
|
||||
root.newEntryType = val === I18n.tr("Desktop Application") ? "desktop" : "command";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.newEntryType === "desktop"
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: appLabelColumn.height
|
||||
|
||||
Column {
|
||||
id: appLabelColumn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Application")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Select a desktop application")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledRect {
|
||||
height: 40
|
||||
radius: Theme.cornerRadius
|
||||
color: root.newEntryDesktopId ? Theme.surfaceContainerHigh : Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
|
||||
LayoutMirroring.enabled: I18n.isRtl
|
||||
LayoutMirroring.childrenInherit: true
|
||||
|
||||
readonly property string selectedName: {
|
||||
if (!root.newEntryDesktopId) return "";
|
||||
const app = root.desktopApps.find(a => (a.id || a.execString) === root.newEntryDesktopId);
|
||||
return app ? (app.name || app.id || "") : root.newEntryDesktopId;
|
||||
}
|
||||
|
||||
width: parent.width - browseButton.width - Theme.spacingM
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingM
|
||||
visible: root.newEntryDesktopId !== ""
|
||||
|
||||
Image {
|
||||
width: 24
|
||||
height: 24
|
||||
source: {
|
||||
const app = root.desktopApps.find(a => (a.id || a.execString) === root.newEntryDesktopId);
|
||||
return Paths.resolveIconUrl(app?.icon || "application-x-executable");
|
||||
}
|
||||
sourceSize.width: 24
|
||||
sourceSize.height: 24
|
||||
fillMode: Image.PreserveAspectFit
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error)
|
||||
source = "image://icon/application-x-executable";
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: parent.parent.selectedName
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: I18n.tr("No application selected")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
visible: root.newEntryDesktopId === ""
|
||||
}
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: browseButton
|
||||
text: I18n.tr("Browse")
|
||||
iconName: "search"
|
||||
onClicked: appBrowserPopup.show()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: wrapperLabelColumn.height
|
||||
|
||||
Column {
|
||||
id: wrapperLabelColumn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Command")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Wrap the app command. %command% is replaced with the actual executable")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
placeholderText: I18n.tr("%command%")
|
||||
text: root.newEntryCommandWrapper
|
||||
onTextChanged: root.newEntryCommandWrapper = text
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.newEntryType === "command"
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: labelColumn.height
|
||||
|
||||
Column {
|
||||
id: labelColumn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Name")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Display name for this entry")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
placeholderText: I18n.tr("e.g. My Script")
|
||||
text: root.newEntryName
|
||||
onTextChanged: root.newEntryName = text
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: labelColumn2.height
|
||||
|
||||
Column {
|
||||
id: labelColumn2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Command")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Full command to execute")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
width: parent.width
|
||||
placeholderText: I18n.tr("e.g. /usr/bin/my-script --flag")
|
||||
text: root.newEntryExec
|
||||
onTextChanged: root.newEntryExec = text
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("These add entries to the XDG autostart directory (~/.config/autostart/*.desktop)")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Theme.spacingM
|
||||
}
|
||||
|
||||
DankButton {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: I18n.tr("Add to Autostart")
|
||||
iconName: "add"
|
||||
enabled: {
|
||||
if (root.newEntryType === "desktop") return root.newEntryDesktopId !== "";
|
||||
return root.newEntryName !== "" && root.newEntryExec !== "";
|
||||
}
|
||||
onClicked: root.addEntry()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
id: entriesCard
|
||||
width: parent.width
|
||||
iconName: "line_start"
|
||||
title: I18n.tr("Autostart Entries")
|
||||
settingKey: "autostartEntries"
|
||||
collapsible: true
|
||||
expanded: true
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
width: parent.width - clearAllButton.width - Theme.spacingM
|
||||
text: I18n.tr("Applications and commands to start automatically when you log in")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: clearAllButton
|
||||
iconName: "delete_sweep"
|
||||
iconSize: Theme.iconSize - 2
|
||||
iconColor: Theme.error
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: {
|
||||
for (let i = 0; i < root.entries.length; i++) {
|
||||
root.removeEntry(root.entries[i].filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: entriesList
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
|
||||
Repeater {
|
||||
model: root.entries
|
||||
|
||||
delegate: Rectangle {
|
||||
width: entriesList.width
|
||||
height: 48
|
||||
radius: Theme.cornerRadius
|
||||
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.3)
|
||||
border.width: 0
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: (index + 1).toString()
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
color: Theme.primary
|
||||
width: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Image {
|
||||
width: 24
|
||||
height: 24
|
||||
source: Paths.resolveIconUrl(modelData.icon || "application-x-executable")
|
||||
sourceSize.width: 24
|
||||
sourceSize.height: 24
|
||||
fillMode: Image.PreserveAspectFit
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error)
|
||||
source = "image://icon/application-x-executable";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - 20 - Theme.spacingM - 24 - Theme.spacingM - Theme.spacingM - 60 - Theme.spacingM - 32 - Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: modelData.name
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: modelData.hidden ? Theme.surfaceVariantText : Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
opacity: modelData.hidden ? 0.6 : 1.0
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: modelData.hidden ? I18n.tr("Disabled") : modelData.exec
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
DankToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: !modelData.hidden
|
||||
onToggled: checked => root.setHidden(modelData, !checked)
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "close"
|
||||
iconSize: 16
|
||||
buttonSize: 32
|
||||
circular: true
|
||||
iconColor: Theme.error
|
||||
onClicked: root.removeEntry(modelData.filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("No autostart entries")
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceVariantText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
visible: root.entries.length === 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: parent.width
|
||||
iconName: "system_tray"
|
||||
title: I18n.tr("Tray Icon Fix")
|
||||
visible: DesktopService.isSystemd
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: I18n.tr("If autostart app icons don't appear in the system tray, generate a systemd override to ensure DMS starts before autostart apps")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
DankButton {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: I18n.tr("Generate Override")
|
||||
iconName: "build"
|
||||
onClicked: root.generateTrayIconFixSystemdOverride()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
FloatingWindow {
|
||||
id: root
|
||||
|
||||
property bool disablePopupTransparency: true
|
||||
property string searchQuery: ""
|
||||
property var filteredApps: []
|
||||
property int selectedIndex: -1
|
||||
property bool keyboardNavigationActive: false
|
||||
property var appsModel: []
|
||||
property var parentModal: null
|
||||
parentWindow: parentModal
|
||||
|
||||
signal appSelected(string appId)
|
||||
|
||||
objectName: "appBrowserPopup"
|
||||
title: I18n.tr("Select Application")
|
||||
minimumSize: Qt.size(400, 350)
|
||||
implicitWidth: 500
|
||||
implicitHeight: 550
|
||||
color: "transparent"
|
||||
visible: false
|
||||
|
||||
WindowBlur {
|
||||
targetWindow: root
|
||||
blurX: 0
|
||||
blurY: 0
|
||||
blurWidth: root.visible ? root.width : 0
|
||||
blurHeight: root.visible ? root.height : 0
|
||||
blurRadius: Theme.cornerRadius
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.withAlpha(Theme.surfaceContainer, 0.95)
|
||||
border.color: Theme.outlineMedium
|
||||
border.width: Theme.layerOutlineWidth
|
||||
}
|
||||
|
||||
FocusScope {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
|
||||
Keys.onPressed: event => {
|
||||
switch (event.key) {
|
||||
case Qt.Key_Escape:
|
||||
root.hide();
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_Down:
|
||||
root.selectNext();
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_Up:
|
||||
root.selectPrevious();
|
||||
event.accepted = true;
|
||||
return;
|
||||
case Qt.Key_Return:
|
||||
case Qt.Key_Enter:
|
||||
if (root.keyboardNavigationActive) {
|
||||
root.selectApp();
|
||||
} else if (root.filteredApps.length > 0) {
|
||||
root.selectAppByIndex(0);
|
||||
}
|
||||
event.accepted = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 48
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: windowControls.tryStartMove()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingL
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankIcon {
|
||||
name: "add_circle"
|
||||
size: Theme.iconSize
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Select Application")
|
||||
font.pixelSize: Theme.fontSizeXLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
DankActionButton {
|
||||
iconName: "close"
|
||||
iconSize: Theme.iconSize - 4
|
||||
iconColor: Theme.surfaceText
|
||||
onClicked: root.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - 48
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankTextField {
|
||||
id: searchField
|
||||
width: parent.width
|
||||
height: 48
|
||||
cornerRadius: Theme.cornerRadius
|
||||
backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, 0.8)
|
||||
normalBorderColor: Theme.outlineMedium
|
||||
focusedBorderColor: Theme.primary
|
||||
leftIconName: "search"
|
||||
leftIconSize: Theme.iconSize
|
||||
leftIconColor: Theme.surfaceVariantText
|
||||
leftIconFocusedColor: Theme.primary
|
||||
showClearButton: true
|
||||
textColor: Theme.surfaceText
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
placeholderText: I18n.tr("Search applications...")
|
||||
text: root.searchQuery
|
||||
onTextEdited: {
|
||||
root.searchQuery = text;
|
||||
root.updateFilteredApps();
|
||||
}
|
||||
}
|
||||
|
||||
DankListView {
|
||||
id: appList
|
||||
width: parent.width
|
||||
height: parent.height - searchField.height - Theme.spacingM
|
||||
spacing: Theme.spacingS
|
||||
model: root.filteredApps
|
||||
clip: true
|
||||
|
||||
delegate: Rectangle {
|
||||
width: appList.width
|
||||
height: 60
|
||||
radius: Theme.cornerRadius
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isSelected: root.keyboardNavigationActive && index === root.selectedIndex
|
||||
|
||||
color: isSelected ? Theme.withAlpha(Theme.primary, 0.16) : appArea.containsMouse ? Theme.withAlpha(Theme.primary, 0.08) : Theme.withAlpha(Theme.surfaceVariant, 0.3)
|
||||
border.color: isSelected ? Theme.primary : Theme.outlineMedium
|
||||
border.width: isSelected ? 2 : Theme.layerOutlineWidth
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingM
|
||||
spacing: Theme.spacingM
|
||||
|
||||
Image {
|
||||
width: 28
|
||||
height: 28
|
||||
source: Paths.resolveIconUrl(modelData.icon || "application-x-executable")
|
||||
sourceSize.width: 28
|
||||
sourceSize.height: 28
|
||||
fillMode: Image.PreserveAspectFit
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error)
|
||||
source = "image://icon/application-x-executable";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
width: parent.width - 28 - Theme.spacingM * 3 - 24
|
||||
|
||||
StyledText {
|
||||
text: modelData.name || modelData.id || ""
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
font.weight: Font.Medium
|
||||
color: Theme.surfaceText
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.comment || modelData.genericName || ""
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.outline
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
name: "add"
|
||||
size: Theme.iconSize - 4
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: appArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
const appId = modelData.id || modelData.execString || "";
|
||||
root.appSelected(appId);
|
||||
root.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Theme.shortDuration
|
||||
easing.type: Theme.standardEasing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingWindowControls {
|
||||
id: windowControls
|
||||
targetWindow: root
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilteredApps() {
|
||||
const allApps = root.appsModel || [];
|
||||
var filtered = [];
|
||||
if (!searchQuery || searchQuery.length === 0) {
|
||||
filtered = allApps.slice();
|
||||
} else {
|
||||
var query = searchQuery.toLowerCase();
|
||||
for (var i = 0; i < allApps.length; i++) {
|
||||
var app = allApps[i];
|
||||
var name = (app.name || "").toLowerCase();
|
||||
var id = (app.id || "").toLowerCase();
|
||||
var comment = (app.comment || app.genericName || "").toLowerCase();
|
||||
if (name.indexOf(query) !== -1 || id.indexOf(query) !== -1 || comment.indexOf(query) !== -1)
|
||||
filtered.push(app);
|
||||
}
|
||||
}
|
||||
filteredApps = filtered;
|
||||
selectedIndex = -1;
|
||||
keyboardNavigationActive = false;
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
if (filteredApps.length === 0) return;
|
||||
keyboardNavigationActive = true;
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredApps.length - 1);
|
||||
}
|
||||
|
||||
function selectPrevious() {
|
||||
if (filteredApps.length === 0) return;
|
||||
keyboardNavigationActive = true;
|
||||
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||
if (selectedIndex === -1) keyboardNavigationActive = false;
|
||||
}
|
||||
|
||||
function selectApp() {
|
||||
if (selectedIndex < 0 || selectedIndex >= filteredApps.length) return;
|
||||
selectAppByIndex(selectedIndex);
|
||||
}
|
||||
|
||||
function selectAppByIndex(idx) {
|
||||
const app = filteredApps[idx];
|
||||
if (!app) return;
|
||||
root.appSelected(app.id || app.execString || "");
|
||||
hide();
|
||||
}
|
||||
|
||||
function show() {
|
||||
updateFilteredApps();
|
||||
visible = true;
|
||||
Qt.callLater(() => searchField.forceActiveFocus());
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible = false;
|
||||
searchQuery = "";
|
||||
filteredApps = [];
|
||||
selectedIndex = -1;
|
||||
keyboardNavigationActive = false;
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (!visible) {
|
||||
searchQuery = "";
|
||||
filteredApps = [];
|
||||
selectedIndex = -1;
|
||||
keyboardNavigationActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
@@ -12,6 +13,40 @@ Singleton {
|
||||
readonly property var log: Log.scoped("DesktopService")
|
||||
property var _cache: ({})
|
||||
|
||||
property bool isSystemd: false
|
||||
property bool systemdAutostartTargetActive: false
|
||||
property bool systemdAutostartTargetChecked: false
|
||||
readonly property bool autostartAvailable: root.systemdAutostartTargetChecked && (!root.isSystemd || root.systemdAutostartTargetActive)
|
||||
|
||||
Component.onCompleted: initSystemCheckProcess.running = true
|
||||
|
||||
Process {
|
||||
id: initSystemCheckProcess
|
||||
command: ["sh", "-c", "cat /proc/1/comm 2>/dev/null | tr -d '\\n'"]
|
||||
running: false
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.isSystemd = (text || "").trim() === "systemd";
|
||||
if (!root.isSystemd)
|
||||
root.systemdAutostartTargetChecked = true;
|
||||
else
|
||||
systemdAutostartTargetCheck.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: systemdAutostartTargetCheck
|
||||
command: ["systemctl", "--user", "is-active", "xdg-desktop-autostart.target"]
|
||||
running: false
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.systemdAutostartTargetActive = (text || "").trim() === "active";
|
||||
root.systemdAutostartTargetChecked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIconPath(moddedAppId) {
|
||||
if (!moddedAppId)
|
||||
return "";
|
||||
|
||||
Reference in New Issue
Block a user