summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/dms/Services
diff options
context:
space:
mode:
authorNippy <nippy@rp1.hu>2026-05-08 19:50:49 +0200
committerNippy <nippy@rp1.hu>2026-05-08 19:50:49 +0200
commit3462bcc46ecf74f576ea4397f16492c16bd75b10 (patch)
treec5ab7aef4498647e057bda8d49c11e5f9dda74c9 /raveos-hyprland-theme/theme-data/dms/Services
parent56616f693b4f263cbf0d47da43b67424efa6022d (diff)
downloadRaveOS-PKGBUILD-3462bcc46ecf74f576ea4397f16492c16bd75b10.tar.gz
RaveOS-PKGBUILD-3462bcc46ecf74f576ea4397f16492c16bd75b10.zip
raveos update
Diffstat (limited to 'raveos-hyprland-theme/theme-data/dms/Services')
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/AppSearchService.qml985
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/AudioService.qml1075
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/BarWidgetService.qml171
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/BatteryService.qml271
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/BluetoothService.qml523
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/BlurService.qml113
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/CalendarService.qml317
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/CavaService.qml76
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/ChangelogService.qml108
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/ClipboardService.qml275
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/CompositorService.qml677
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/CupsService.qml860
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/DMSNetworkService.qml944
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/DMSService.qml757
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/DSearchService.qml184
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/DesktopService.qml94
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/DesktopWidgetRegistry.qml232
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/DgopService.qml778
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/DisplayService.qml1170
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/DwlService.qml454
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/ExtWorkspaceService.qml277
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/FirstLaunchService.qml111
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/HyprlandService.qml338
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/IdleService.qml191
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/KeybindsService.qml528
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/LegacyNetworkService.qml987
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/LocationService.qml55
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/MprisController.qml78
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/MultimediaService.qml35
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/MuxService.qml230
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/NetworkService.qml313
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/NiriService.qml1494
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/NotepadStorageService.qml473
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/NotificationService.qml1237
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/PluginService.qml876
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/PolkitService.qml40
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/PopoutService.qml709
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/PortalService.qml316
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/PowerProfileWatcher.qml36
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/PrivacyService.qml155
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/SessionService.qml619
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/SettingsSearchService.qml240
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/SystemUpdateService.qml396
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/ToastService.qml174
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/TrackArtService.qml49
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/UserInfoService.qml35
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/VPNService.qml221
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/WallpaperCyclingService.qml495
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/WeatherService.qml914
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/WlrOutputService.qml346
-rw-r--r--raveos-hyprland-theme/theme-data/dms/Services/niri-wpblur.kdl9
51 files changed, 22041 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/AppSearchService.qml b/raveos-hyprland-theme/theme-data/dms/Services/AppSearchService.qml
new file mode 100644
index 0000000..1c8413f
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/AppSearchService.qml
@@ -0,0 +1,985 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import qs.Common
+
+Singleton {
+ id: root
+
+ property var applications: []
+ property var _cachedCategories: null
+ property var _cachedVisibleApps: null
+ property var _hiddenAppsSet: new Set()
+
+ property var _transformCache: ({})
+ property var _cachedDefaultSections: []
+ property var _cachedDefaultFlatModel: []
+ property bool _defaultCacheValid: false
+ property int cacheVersion: 0
+
+ readonly property int maxResults: 10
+ readonly property int frecencySampleSize: 10
+
+ readonly property var timeBuckets: [
+ {
+ "maxDays": 4,
+ "weight": 100
+ },
+ {
+ "maxDays": 14,
+ "weight": 70
+ },
+ {
+ "maxDays": 31,
+ "weight": 50
+ },
+ {
+ "maxDays": 90,
+ "weight": 30
+ },
+ {
+ "maxDays": 99999,
+ "weight": 10
+ }
+ ]
+
+ function refreshApplications() {
+ applications = DesktopEntries.applications.values;
+ _cachedCategories = null;
+ _cachedVisibleApps = null;
+ invalidateLauncherCache();
+ }
+
+ function invalidateLauncherCache() {
+ _transformCache = {};
+ _defaultCacheValid = false;
+ _cachedDefaultSections = [];
+ _cachedDefaultFlatModel = [];
+ cacheVersion++;
+ }
+
+ function getOrTransformApp(app, transformFn) {
+ const id = app.id || app.execString || app.exec || "";
+ if (!id)
+ return transformFn(app);
+ const cached = _transformCache[id];
+ if (cached) {
+ const currentIcon = app.icon || "";
+ const cachedSourceIcon = cached._sourceIcon || "";
+ if (currentIcon === cachedSourceIcon)
+ return cached;
+ }
+ const transformed = transformFn(app);
+ transformed._sourceIcon = app.icon || "";
+ _transformCache[id] = transformed;
+ return transformed;
+ }
+
+ function getCachedDefaultSections() {
+ if (!_defaultCacheValid)
+ return null;
+ return _cachedDefaultSections;
+ }
+
+ function setCachedDefaultSections(sections, flatModel) {
+ _cachedDefaultSections = sections.map(function (s) {
+ return Object.assign({}, s, {
+ items: s.items ? s.items.slice() : []
+ });
+ });
+ _cachedDefaultFlatModel = flatModel.slice();
+ _defaultCacheValid = true;
+ }
+
+ function isCacheValid() {
+ return _defaultCacheValid;
+ }
+
+ function _rebuildHiddenSet() {
+ _hiddenAppsSet = new Set(SessionData.hiddenApps || []);
+ _cachedVisibleApps = null;
+ }
+
+ function isAppHidden(app) {
+ if (!app)
+ return false;
+ const appId = app.id || app.execString || app.exec || "";
+ return _hiddenAppsSet.has(appId);
+ }
+
+ function getVisibleApplications() {
+ if (_cachedVisibleApps === null) {
+ const seen = new Set();
+ _cachedVisibleApps = applications.filter(app => {
+ if (isAppHidden(app))
+ return false;
+ const id = app.id;
+ if (id && seen.has(id))
+ return false;
+ if (id)
+ seen.add(id);
+ return true;
+ });
+ }
+ return _cachedVisibleApps.map(app => applyAppOverride(app));
+ }
+
+ Connections {
+ target: SessionData
+ function onHiddenAppsChanged() {
+ root._rebuildHiddenSet();
+ root.invalidateLauncherCache();
+ }
+ function onAppOverridesChanged() {
+ root._cachedVisibleApps = null;
+ root.invalidateLauncherCache();
+ }
+ }
+
+ Connections {
+ target: AppUsageHistoryData
+ function onAppUsageRankingChanged() {
+ root.invalidateLauncherCache();
+ }
+ }
+
+ function applyAppOverride(app) {
+ if (!app)
+ return app;
+ const appId = app.id || app.execString || app.exec || "";
+ const override = SessionData.getAppOverride(appId);
+ if (!override)
+ return app;
+ return Object.assign({}, app, {
+ name: override.name || app.name,
+ icon: override.icon || app.icon,
+ comment: override.comment || app.comment,
+ _override: override
+ });
+ }
+
+ readonly property string dmsLogoPath: Qt.resolvedUrl("../assets/danklogo2.svg")
+
+ readonly property var builtInPlugins: ({
+ "dms_settings": {
+ id: "dms_settings",
+ name: I18n.tr("Settings", "settings window title"),
+ icon: "svg+corner:" + dmsLogoPath + "|settings",
+ cornerIcon: "settings",
+ comment: "DMS",
+ action: "ipc:settings",
+ categories: ["Settings", "System"],
+ defaultTrigger: "",
+ isLauncher: false
+ },
+ "dms_notepad": {
+ id: "dms_notepad",
+ name: I18n.tr("Notepad", "Notepad"),
+ icon: "svg+corner:" + dmsLogoPath + "|description",
+ cornerIcon: "description",
+ comment: "DMS",
+ action: "ipc:notepad",
+ categories: ["Office", "Utility"],
+ defaultTrigger: "",
+ isLauncher: false
+ },
+ "dms_sysmon": {
+ id: "dms_sysmon",
+ name: I18n.tr("System Monitor", "sysmon window title"),
+ icon: "svg+corner:" + dmsLogoPath + "|monitor_heart",
+ cornerIcon: "monitor_heart",
+ comment: "DMS",
+ action: "ipc:processlist",
+ categories: ["System", "Monitor"],
+ defaultTrigger: "",
+ isLauncher: false
+ },
+ "dms_colorpicker": {
+ id: "dms_colorpicker",
+ name: I18n.tr("Color Picker"),
+ icon: "svg+corner:" + dmsLogoPath + "|palette",
+ cornerIcon: "palette",
+ comment: "DMS",
+ action: "ipc:color-picker",
+ categories: ["Graphics", "Utility"],
+ defaultTrigger: "",
+ isLauncher: false
+ },
+ "dms_settings_search": {
+ id: "dms_settings_search",
+ name: I18n.tr("Settings", "settings window title"),
+ cornerIcon: "search",
+ comment: "DMS",
+ defaultTrigger: "?",
+ isLauncher: true
+ }
+ })
+
+ function getBuiltInPluginTrigger(pluginId) {
+ const plugin = builtInPlugins[pluginId];
+ if (!plugin)
+ return null;
+ return SettingsData.getBuiltInPluginSetting(pluginId, "trigger", plugin.defaultTrigger);
+ }
+
+ readonly property var coreApps: {
+ SettingsData.builtInPluginSettings;
+ const apps = [];
+ for (const pluginId in builtInPlugins) {
+ if (!SettingsData.getBuiltInPluginSetting(pluginId, "enabled", true))
+ continue;
+ const plugin = builtInPlugins[pluginId];
+ if (plugin.isLauncher)
+ continue;
+ apps.push({
+ name: plugin.name,
+ icon: plugin.icon,
+ comment: plugin.comment,
+ action: plugin.action,
+ categories: plugin.categories,
+ isCore: true,
+ builtInPluginId: pluginId,
+ cornerIcon: plugin.cornerIcon
+ });
+ }
+ return apps;
+ }
+
+ function getBuiltInLauncherPlugins() {
+ const result = {};
+ for (const pluginId in builtInPlugins) {
+ const plugin = builtInPlugins[pluginId];
+ if (!plugin.isLauncher)
+ continue;
+ if (!SettingsData.getBuiltInPluginSetting(pluginId, "enabled", true))
+ continue;
+ result[pluginId] = plugin;
+ }
+ return result;
+ }
+
+ function getBuiltInLauncherTriggers() {
+ const triggers = {};
+ const launchers = getBuiltInLauncherPlugins();
+ for (const pluginId in launchers) {
+ const trigger = getBuiltInPluginTrigger(pluginId);
+ if (trigger && trigger.trim() !== "")
+ triggers[trigger] = pluginId;
+ }
+ return triggers;
+ }
+
+ function getBuiltInLauncherPluginsWithEmptyTrigger() {
+ const result = [];
+ const launchers = getBuiltInLauncherPlugins();
+ for (const pluginId in launchers) {
+ const trigger = getBuiltInPluginTrigger(pluginId);
+ if (!trigger || trigger.trim() === "")
+ result.push(pluginId);
+ }
+ return result;
+ }
+
+ function getBuiltInLauncherItems(pluginId, query) {
+ if (pluginId !== "dms_settings_search")
+ return [];
+
+ SettingsSearchService.search(query);
+ const results = SettingsSearchService.results;
+ const items = [];
+ for (let i = 0; i < results.length; i++) {
+ const r = results[i];
+ items.push({
+ name: r.label,
+ icon: "material:" + r.icon,
+ comment: r.category,
+ action: "settings_nav:" + r.tabIndex + ":" + r.section,
+ categories: ["Settings"],
+ isCore: true,
+ isBuiltInLauncher: true,
+ builtInPluginId: pluginId
+ });
+ }
+ return items;
+ }
+
+ function executeBuiltInLauncherItem(item) {
+ if (!item?.action)
+ return false;
+
+ const parts = item.action.split(":");
+ if (parts[0] !== "settings_nav")
+ return false;
+
+ const tabIndex = parseInt(parts[1]);
+ const section = parts.slice(2).join(":");
+ SettingsSearchService.navigateToSection(section);
+ PopoutService.openSettingsWithTabIndex(tabIndex);
+ return true;
+ }
+
+ function getCoreApps(query) {
+ if (!query || query.length === 0)
+ return coreApps;
+ const lowerQuery = query.toLowerCase();
+ return coreApps.filter(app => app.name.toLowerCase().includes(lowerQuery) || app.comment.toLowerCase().includes(lowerQuery));
+ }
+
+ function executeCoreApp(app) {
+ if (!app?.action)
+ return false;
+
+ const parts = app.action.split(":");
+ if (parts[0] !== "ipc")
+ return false;
+
+ switch (parts[1]) {
+ case "settings":
+ PopoutService.focusOrToggleSettings();
+ return true;
+ case "notepad":
+ PopoutService.toggleNotepad();
+ return true;
+ case "processlist":
+ PopoutService.toggleProcessListModal();
+ return true;
+ case "color-picker":
+ PopoutService.showColorPicker();
+ return true;
+ }
+ return false;
+ }
+
+ Connections {
+ target: DesktopEntries
+ function onApplicationsChanged() {
+ root.refreshApplications();
+ }
+ }
+
+ Component.onCompleted: {
+ _rebuildHiddenSet();
+ refreshApplications();
+ }
+
+ function tokenize(text) {
+ return text.toLowerCase().trim().split(/[\s\-_]+/).filter(w => w.length > 0);
+ }
+
+ function wordBoundaryMatch(text, query) {
+ const textWords = tokenize(text);
+ const queryWords = tokenize(query);
+
+ if (queryWords.length === 0)
+ return false;
+ if (queryWords.length > textWords.length)
+ return false;
+
+ for (var i = 0; i <= textWords.length - queryWords.length; i++) {
+ let allMatch = true;
+ for (var j = 0; j < queryWords.length; j++) {
+ if (!textWords[i + j].startsWith(queryWords[j])) {
+ allMatch = false;
+ break;
+ }
+ }
+ if (allMatch)
+ return true;
+ }
+ return false;
+ }
+
+ function levenshteinDistance(s1, s2) {
+ const len1 = s1.length;
+ const len2 = s2.length;
+ const matrix = [];
+
+ for (var i = 0; i <= len1; i++) {
+ matrix[i] = [i];
+ }
+ for (var j = 0; j <= len2; j++) {
+ matrix[0][j] = j;
+ }
+
+ for (var i = 1; i <= len1; i++) {
+ for (var j = 1; j <= len2; j++) {
+ const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
+ }
+ }
+ return matrix[len1][len2];
+ }
+
+ function fuzzyMatchScore(text, query) {
+ const queryLower = query.toLowerCase();
+ const maxDistance = query.length <= 2 ? 0 : query.length === 3 ? 1 : query.length <= 6 ? 2 : 3;
+
+ let bestScore = 0;
+
+ const distance = levenshteinDistance(text.toLowerCase(), queryLower);
+ if (distance <= maxDistance) {
+ const maxLen = Math.max(text.length, query.length);
+ bestScore = 1 - (distance / maxLen);
+ }
+
+ const words = tokenize(text);
+ for (const word of words) {
+ const wordDistance = levenshteinDistance(word, queryLower);
+ if (wordDistance <= maxDistance) {
+ const maxLen = Math.max(word.length, query.length);
+ const score = 1 - (wordDistance / maxLen);
+ bestScore = Math.max(bestScore, score);
+ }
+ }
+
+ return bestScore;
+ }
+
+ function calculateFrecency(app) {
+ const usageRanking = AppUsageHistoryData.appUsageRanking || {};
+ const appId = app.id || (app.execString || app.exec || "");
+ const idVariants = [appId, appId.replace(".desktop", ""), app.id, app.id ? app.id.replace(".desktop", "") : null].filter(id => id);
+
+ let usageData = null;
+ for (const variant of idVariants) {
+ if (usageRanking[variant]) {
+ usageData = usageRanking[variant];
+ break;
+ }
+ }
+
+ if (!usageData || !usageData.usageCount) {
+ return {
+ "frecency": 0,
+ "daysSinceUsed": 999999
+ };
+ }
+
+ const usageCount = usageData.usageCount || 0;
+ const lastUsed = usageData.lastUsed || 0;
+ const now = Date.now();
+ const daysSinceUsed = (now - lastUsed) / (1000 * 60 * 60 * 24);
+
+ let timeBucketWeight = 10;
+ for (const bucket of timeBuckets) {
+ if (daysSinceUsed <= bucket.maxDays) {
+ timeBucketWeight = bucket.weight;
+ break;
+ }
+ }
+
+ const contextBonus = 100;
+ const sampleSize = Math.min(usageCount, frecencySampleSize);
+ const frecency = (timeBucketWeight * contextBonus * sampleSize) / 100;
+
+ return {
+ "frecency": frecency,
+ "daysSinceUsed": daysSinceUsed
+ };
+ }
+
+ function searchApplications(query) {
+ if (!query || query.length === 0)
+ return getVisibleApplications();
+ if (applications.length === 0)
+ return [];
+
+ const queryLower = query.toLowerCase().trim();
+ const scoredApps = [];
+ const results = [];
+ const visibleApps = getVisibleApplications();
+
+ for (const app of visibleApps) {
+ const name = (app.name || "").toLowerCase();
+ const genericName = (app.genericName || "").toLowerCase();
+ const comment = (app.comment || "").toLowerCase();
+ const id = (app.id || "").toLowerCase();
+ const keywords = app.keywords ? app.keywords.map(k => k.toLowerCase()) : [];
+
+ let textScore = 0;
+ let matchType = "none";
+
+ if (name === queryLower) {
+ textScore = 10000;
+ matchType = "exact";
+ } else if (name.startsWith(queryLower)) {
+ textScore = 5000;
+ matchType = "prefix";
+ } else if (wordBoundaryMatch(name, queryLower)) {
+ textScore = 3000;
+ matchType = "word_boundary";
+ } else if (name.includes(queryLower)) {
+ textScore = 500;
+ matchType = "substring";
+ } else if (genericName && genericName.startsWith(queryLower)) {
+ textScore = 800;
+ matchType = "generic_prefix";
+ } else if (genericName && genericName.includes(queryLower)) {
+ textScore = 400;
+ matchType = "generic";
+ } else if (id && id.includes(queryLower)) {
+ textScore = 350;
+ matchType = "id";
+ }
+
+ if (matchType === "none" && keywords.length > 0) {
+ for (const keyword of keywords) {
+ if (keyword.startsWith(queryLower)) {
+ textScore = 300;
+ matchType = "keyword_prefix";
+ break;
+ } else if (keyword.includes(queryLower)) {
+ textScore = 150;
+ matchType = "keyword";
+ break;
+ }
+ }
+ }
+
+ if (matchType === "none" && comment && comment.includes(queryLower)) {
+ textScore = 50;
+ matchType = "comment";
+ }
+
+ if (matchType === "none") {
+ const fuzzyScore = fuzzyMatchScore(name, queryLower);
+ if (fuzzyScore > 0) {
+ textScore = fuzzyScore * 100;
+ matchType = "fuzzy";
+ }
+ }
+
+ if (matchType !== "none") {
+ const frecencyData = calculateFrecency(app);
+
+ results.push({
+ "app": app,
+ "textScore": textScore,
+ "frecency": frecencyData.frecency,
+ "daysSinceUsed": frecencyData.daysSinceUsed,
+ "matchType": matchType
+ });
+ }
+ }
+
+ for (const result of results) {
+ const frecencyBonus = result.frecency > 0 ? Math.min(result.frecency, 2000) : 0;
+ const recencyBonus = result.daysSinceUsed < 1 ? 1500 : result.daysSinceUsed < 7 ? 1000 : result.daysSinceUsed < 30 ? 500 : 0;
+
+ const finalScore = result.textScore + frecencyBonus + recencyBonus;
+
+ scoredApps.push({
+ "app": result.app,
+ "score": finalScore
+ });
+ }
+
+ if (SessionData.searchAppActions) {
+ const actionResults = searchAppActions(queryLower, visibleApps);
+ for (const actionResult of actionResults) {
+ scoredApps.push({
+ app: actionResult.app,
+ score: actionResult.score
+ });
+ }
+ }
+
+ scoredApps.sort((a, b) => b.score - a.score);
+ return scoredApps.slice(0, maxResults).map(item => item.app);
+ }
+
+ function searchAppActions(query, apps) {
+ const results = [];
+ for (const app of apps) {
+ if (!app.actions || app.actions.length === 0)
+ continue;
+ for (const action of app.actions) {
+ const actionName = (action.name || "").toLowerCase();
+ if (!actionName)
+ continue;
+
+ let score = 0;
+ if (actionName === query) {
+ score = 8000;
+ } else if (actionName.startsWith(query)) {
+ score = 4000;
+ } else if (actionName.includes(query)) {
+ score = 400;
+ }
+
+ if (score > 0) {
+ results.push({
+ app: {
+ name: action.name,
+ icon: action.icon || app.icon,
+ comment: app.name,
+ categories: app.categories || [],
+ isAction: true,
+ parentApp: app,
+ actionData: action
+ },
+ score: score
+ });
+ }
+ }
+ }
+ return results;
+ }
+
+ function getCategoriesForApp(app) {
+ if (!app?.categories)
+ return [];
+
+ const categoryMap = {
+ "AudioVideo": I18n.tr("Media"),
+ "Audio": I18n.tr("Media"),
+ "Video": I18n.tr("Media"),
+ "Development": I18n.tr("Development"),
+ "TextEditor": I18n.tr("Development"),
+ "IDE": I18n.tr("Development"),
+ "Education": I18n.tr("Education"),
+ "Game": I18n.tr("Games"),
+ "Graphics": I18n.tr("Graphics"),
+ "Photography": I18n.tr("Graphics"),
+ "Network": I18n.tr("Internet"),
+ "WebBrowser": I18n.tr("Internet"),
+ "Email": I18n.tr("Internet"),
+ "Office": I18n.tr("Office"),
+ "WordProcessor": I18n.tr("Office"),
+ "Spreadsheet": I18n.tr("Office"),
+ "Presentation": I18n.tr("Office"),
+ "Science": I18n.tr("Science"),
+ "Settings": I18n.tr("Settings"),
+ "System": I18n.tr("System"),
+ "Utility": I18n.tr("Utilities"),
+ "Accessories": I18n.tr("Utilities"),
+ "FileManager": I18n.tr("Utilities"),
+ "TerminalEmulator": I18n.tr("Utilities")
+ };
+
+ const mappedCategories = new Set();
+
+ for (const cat of app.categories) {
+ if (categoryMap[cat])
+ mappedCategories.add(categoryMap[cat]);
+ }
+
+ return Array.from(mappedCategories);
+ }
+
+ property var categoryIcons: ({
+ "All": "apps",
+ "Media": "music_video",
+ "Development": "code",
+ "Games": "sports_esports",
+ "Graphics": "photo_library",
+ "Internet": "web",
+ "Office": "content_paste",
+ "Settings": "settings",
+ "System": "host",
+ "Utilities": "build"
+ })
+
+ function getCategoryIcon(category) {
+ // Check if it's a plugin category
+ const pluginIcon = getPluginCategoryIcon(category);
+ if (pluginIcon) {
+ return pluginIcon;
+ }
+ return categoryIcons[category] || "folder";
+ }
+
+ function getAllCategories() {
+ if (_cachedCategories)
+ return _cachedCategories;
+
+ const categories = new Set([I18n.tr("All")]);
+ for (const app of applications) {
+ const appCategories = getCategoriesForApp(app);
+ appCategories.forEach(cat => categories.add(cat));
+ }
+
+ // Include categories from core apps (e.g. DMS Settings)
+ for (const app of coreApps) {
+ const appCategories = getCategoriesForApp(app);
+ appCategories.forEach(cat => categories.add(cat));
+ }
+
+ const pluginCategories = getPluginCategories();
+ pluginCategories.forEach(cat => categories.add(cat));
+
+ _cachedCategories = Array.from(categories).sort();
+ return _cachedCategories;
+ }
+
+ function getAppsInCategory(category) {
+ const visibleApps = getVisibleApplications();
+ if (category === I18n.tr("All"))
+ return visibleApps;
+
+ const pluginItems = getPluginItems(category, "");
+ if (pluginItems.length > 0)
+ return pluginItems;
+
+ return visibleApps.filter(app => {
+ const appCategories = getCategoriesForApp(app);
+ return appCategories.includes(category);
+ });
+ }
+
+ // Plugin launcher support functions
+ function getPluginCategories() {
+ if (typeof PluginService === "undefined") {
+ return [];
+ }
+
+ const categories = [];
+ const launchers = PluginService.getLauncherPlugins();
+
+ for (const pluginId in launchers) {
+ const plugin = launchers[pluginId];
+ const categoryName = plugin.name || pluginId;
+ categories.push(categoryName);
+ }
+
+ return categories;
+ }
+
+ function getPluginCategoryIcon(category) {
+ if (typeof PluginService === "undefined")
+ return null;
+
+ const launchers = PluginService.getLauncherPlugins();
+ for (const pluginId in launchers) {
+ const plugin = launchers[pluginId];
+ if ((plugin.name || pluginId) === category) {
+ return plugin.icon || "extension";
+ }
+ }
+ return null;
+ }
+
+ function getAllPluginItems() {
+ if (typeof PluginService === "undefined") {
+ return [];
+ }
+
+ let allItems = [];
+ const launchers = PluginService.getLauncherPlugins();
+
+ for (const pluginId in launchers) {
+ const categoryName = launchers[pluginId].name || pluginId;
+ const items = getPluginItems(categoryName, "");
+ allItems = allItems.concat(items);
+ }
+
+ return allItems;
+ }
+
+ function getPluginItems(category, query) {
+ if (typeof PluginService === "undefined")
+ return [];
+
+ const launchers = PluginService.getLauncherPlugins();
+ for (const pluginId in launchers) {
+ const plugin = launchers[pluginId];
+ if ((plugin.name || pluginId) === category) {
+ return getPluginItemsForPlugin(pluginId, query);
+ }
+ }
+ return [];
+ }
+
+ function getPluginItemsForPlugin(pluginId, query) {
+ if (typeof PluginService === "undefined") {
+ return [];
+ }
+
+ let instance = PluginService.pluginInstances[pluginId];
+ let isPersistent = true;
+
+ if (!instance) {
+ const component = PluginService.pluginLauncherComponents[pluginId];
+ if (!component)
+ return [];
+
+ try {
+ instance = component.createObject(root, {
+ "pluginService": PluginService
+ });
+ isPersistent = false;
+ } catch (e) {
+ console.warn("AppSearchService: Error creating temporary plugin instance", pluginId, ":", e);
+ return [];
+ }
+ }
+
+ if (!instance)
+ return [];
+
+ try {
+ if (typeof instance.getItems === "function") {
+ const items = instance.getItems(query || "");
+ if (!isPersistent)
+ instance.destroy();
+ return items || [];
+ }
+
+ if (!isPersistent) {
+ instance.destroy();
+ }
+ } catch (e) {
+ console.warn("AppSearchService: Error getting items from plugin", pluginId, ":", e);
+ if (!isPersistent)
+ instance.destroy();
+ }
+
+ return [];
+ }
+
+ function executePluginItem(item, pluginId) {
+ if (typeof PluginService === "undefined")
+ return false;
+
+ let instance = PluginService.pluginInstances[pluginId];
+ let isPersistent = true;
+
+ if (!instance) {
+ const component = PluginService.pluginLauncherComponents[pluginId];
+ if (!component)
+ return false;
+
+ try {
+ instance = component.createObject(root, {
+ "pluginService": PluginService
+ });
+ isPersistent = false;
+ } catch (e) {
+ console.warn("AppSearchService: Error creating temporary plugin instance for execution", pluginId, ":", e);
+ return false;
+ }
+ }
+
+ if (!instance)
+ return false;
+
+ try {
+ if (typeof instance.executeItem === "function") {
+ instance.executeItem(item);
+ if (!isPersistent)
+ instance.destroy();
+ return true;
+ }
+
+ if (!isPersistent) {
+ instance.destroy();
+ }
+ } catch (e) {
+ console.warn("AppSearchService: Error executing item from plugin", pluginId, ":", e);
+ if (!isPersistent)
+ instance.destroy();
+ }
+
+ return false;
+ }
+
+ function getPluginPasteText(pluginId, item) {
+ if (typeof PluginService === "undefined")
+ return null;
+
+ const instance = PluginService.pluginInstances[pluginId];
+ if (!instance)
+ return null;
+
+ if (typeof instance.getPasteText === "function") {
+ return instance.getPasteText(item);
+ }
+
+ return null;
+ }
+
+ function getPluginPasteArgs(pluginId, item) {
+ if (typeof PluginService === "undefined")
+ return null;
+
+ const instance = PluginService.pluginInstances[pluginId];
+ if (!instance)
+ return null;
+
+ if (typeof instance.getPasteArgs === "function")
+ return instance.getPasteArgs(item);
+
+ if (typeof instance.getPasteText === "function") {
+ const text = instance.getPasteText(item);
+ if (text)
+ return ["dms", "cl", "copy", text];
+ }
+
+ return null;
+ }
+
+ function searchPluginItems(query) {
+ if (typeof PluginService === "undefined")
+ return [];
+
+ let allItems = [];
+ const launchers = PluginService.getLauncherPlugins();
+
+ for (const pluginId in launchers) {
+ const items = getPluginItemsForPlugin(pluginId, query);
+ allItems = allItems.concat(items);
+ }
+
+ return allItems;
+ }
+
+ function getPluginLauncherCategories(pluginId) {
+ if (typeof PluginService === "undefined")
+ return [];
+
+ const instance = PluginService.pluginInstances[pluginId];
+ if (!instance)
+ return [];
+
+ if (typeof instance.getCategories !== "function")
+ return [];
+
+ try {
+ return instance.getCategories() || [];
+ } catch (e) {
+ console.warn("AppSearchService: Error getting categories from plugin", pluginId, ":", e);
+ return [];
+ }
+ }
+
+ function setPluginLauncherCategory(pluginId, categoryId) {
+ if (typeof PluginService === "undefined")
+ return;
+
+ const instance = PluginService.pluginInstances[pluginId];
+ if (!instance)
+ return;
+
+ if (typeof instance.setCategory !== "function")
+ return;
+
+ try {
+ instance.setCategory(categoryId);
+ } catch (e) {
+ console.warn("AppSearchService: Error setting category on plugin", pluginId, ":", e);
+ }
+ }
+
+ function pluginHasCategories(pluginId) {
+ if (typeof PluginService === "undefined")
+ return false;
+
+ const instance = PluginService.pluginInstances[pluginId];
+ if (!instance)
+ return false;
+
+ return typeof instance.getCategories === "function";
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/AudioService.qml b/raveos-hyprland-theme/theme-data/dms/Services/AudioService.qml
new file mode 100644
index 0000000..015fedc
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/AudioService.qml
@@ -0,0 +1,1075 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtCore
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Pipewire
+import qs.Common
+import qs.Services
+
+Singleton {
+ id: root
+
+ readonly property PwNode sink: Pipewire.defaultAudioSink
+ readonly property PwNode source: Pipewire.defaultAudioSource
+
+ readonly property bool soundsAvailable: MultimediaService.available
+ property bool gsettingsAvailable: false
+ property var availableSoundThemes: []
+ property string currentSoundTheme: ""
+ property var soundFilePaths: ({})
+
+ property var volumeChangeSound: null
+ property var powerPlugSound: null
+ property var powerUnplugSound: null
+ property var normalNotificationSound: null
+ property var criticalNotificationSound: null
+ property var loginSound: null
+ property real notificationsVolume: 1.0
+ property bool notificationsAudioMuted: false
+
+ property var mediaDevices: null
+ property var mediaDevicesConnections: null
+
+ property var deviceAliases: ({})
+ property string wireplumberConfigPath: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/wireplumber/wireplumber.conf.d/51-dms-audio-aliases.conf"
+ property bool wireplumberReloading: false
+
+ readonly property int sinkMaxVolume: {
+ const name = sink?.name ?? "";
+ if (!name)
+ return 100;
+ return SessionData.deviceMaxVolumes[name] ?? 100;
+ }
+
+ signal micMuteChanged
+ signal audioOutputCycled(string deviceName, string deviceIcon)
+ signal deviceAliasChanged(string nodeName, string newAlias)
+ signal wireplumberReloadStarted
+ signal wireplumberReloadCompleted(bool success)
+
+ function getMaxVolumePercent(node) {
+ if (!node?.name)
+ return 100;
+ return SessionData.deviceMaxVolumes[node.name] ?? 100;
+ }
+
+ Connections {
+ target: SessionData
+ function onDeviceMaxVolumesChanged() {
+ if (!root.sink?.audio)
+ return;
+ const maxVol = root.sinkMaxVolume;
+ const currentPercent = Math.round(root.sink.audio.volume * 100);
+ if (currentPercent > maxVol)
+ root.sink.audio.volume = maxVol / 100;
+ }
+ }
+
+ // Used in playLoginSoundIfApplicable()
+ Process {
+ id: loginSoundChecker
+ onExited: (exitCode) => {
+ if (exitCode === 0) {
+ playLoginSound();
+ }
+ }
+}
+
+ function getAvailableSinks() {
+ const hidden = SessionData.hiddenOutputDeviceNames ?? [];
+ return Pipewire.nodes.values.filter(node => node.audio && node.isSink && !node.isStream && !hidden.includes(node.name));
+ }
+
+ function cycleAudioOutput() {
+ const sinks = getAvailableSinks();
+ if (sinks.length < 2)
+ return null;
+
+ const currentName = root.sink?.name ?? "";
+ const currentIndex = sinks.findIndex(s => s.name === currentName);
+ const nextIndex = (currentIndex + 1) % sinks.length;
+ const nextSink = sinks[nextIndex];
+ Pipewire.preferredDefaultAudioSink = nextSink;
+ const name = displayName(nextSink);
+ audioOutputCycled(name, sinkIcon(nextSink));
+ return name;
+ }
+
+ function getDeviceAlias(nodeName) {
+ if (!nodeName)
+ return null;
+ return deviceAliases[nodeName] || null;
+ }
+
+ function hasDeviceAlias(nodeName) {
+ if (!nodeName)
+ return false;
+ return deviceAliases.hasOwnProperty(nodeName) && deviceAliases[nodeName] !== null && deviceAliases[nodeName] !== "";
+ }
+
+ function setDeviceAlias(nodeName, customAlias) {
+ if (!nodeName) {
+ console.error("AudioService: Cannot set alias - nodeName is empty");
+ return false;
+ }
+
+ if (!customAlias || customAlias.trim() === "") {
+ return removeDeviceAlias(nodeName);
+ }
+
+ const trimmedAlias = customAlias.trim();
+
+ const updated = Object.assign({}, deviceAliases);
+ updated[nodeName] = trimmedAlias;
+ deviceAliases = updated;
+
+ writeWireplumberConfig();
+ deviceAliasChanged(nodeName, trimmedAlias);
+ return true;
+ }
+
+ function removeDeviceAlias(nodeName) {
+ if (!nodeName)
+ return false;
+
+ if (!hasDeviceAlias(nodeName))
+ return false;
+
+ const updated = Object.assign({}, deviceAliases);
+ delete updated[nodeName];
+ deviceAliases = updated;
+
+ writeWireplumberConfig();
+ deviceAliasChanged(nodeName, "");
+ return true;
+ }
+
+ function writeWireplumberConfig() {
+ const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/wireplumber/wireplumber.conf.d";
+ const configContent = generateWireplumberConfig();
+
+ const shellCmd = `mkdir -p "${configDir}" && cat > "${wireplumberConfigPath}" << 'EOFCONFIG'
+${configContent}
+EOFCONFIG
+`;
+
+ Proc.runCommand("writeWireplumberConfig", ["sh", "-c", shellCmd], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.error("AudioService: Failed to write WirePlumber config. Exit code:", exitCode);
+ console.error("AudioService: Error output:", output);
+ ToastService.showError(I18n.tr("Failed to save audio config"), output || "");
+ return;
+ }
+
+ reloadWireplumberConfig();
+ }, 0);
+ }
+
+ function generateWireplumberConfig() {
+ let config = "# Generated by DankMaterialShell - Audio Device Aliases\n";
+ config += "# Do not edit manually - changes will be overwritten\n";
+ config += "# Last updated: " + new Date().toISOString() + "\n\n";
+
+ const aliasKeys = Object.keys(deviceAliases);
+ if (aliasKeys.length === 0) {
+ config += "# No device aliases configured\n";
+ return config;
+ }
+
+ const alsaAliases = [];
+ const bluezAliases = [];
+ const otherAliases = [];
+
+ for (const nodeName of aliasKeys) {
+ const alias = deviceAliases[nodeName];
+ if (!alias)
+ continue;
+
+ const rule = {
+ nodeName: nodeName,
+ alias: alias
+ };
+
+ if (nodeName.includes("alsa")) {
+ alsaAliases.push(rule);
+ } else if (nodeName.includes("bluez")) {
+ bluezAliases.push(rule);
+ } else {
+ otherAliases.push(rule);
+ }
+ }
+
+ if (alsaAliases.length > 0) {
+ config += "monitor.alsa.rules = [\n";
+ for (let i = 0; i < alsaAliases.length; i++) {
+ const rule = alsaAliases[i];
+ config += " {\n";
+ config += ` matches = [ { "node.name" = "${rule.nodeName}" } ]\n`;
+ config += ` actions = { update-props = { "node.description" = "${rule.alias}" } }\n`;
+ config += " }";
+ if (i < alsaAliases.length - 1)
+ config += ",";
+ config += "\n";
+ }
+ config += "]\n\n";
+ }
+
+ if (bluezAliases.length > 0) {
+ config += "monitor.bluez.rules = [\n";
+ for (let i = 0; i < bluezAliases.length; i++) {
+ const rule = bluezAliases[i];
+ config += " {\n";
+ config += ` matches = [ { "node.name" = "${rule.nodeName}" } ]\n`;
+ config += ` actions = { update-props = { "node.description" = "${rule.alias}" } }\n`;
+ config += " }";
+ if (i < bluezAliases.length - 1)
+ config += ",";
+ config += "\n";
+ }
+ config += "]\n\n";
+ }
+
+ if (otherAliases.length > 0) {
+ config += "# Other device aliases (RAOP, USB, and other devices)\n";
+ config += "wireplumber.rules = [\n";
+ for (let i = 0; i < otherAliases.length; i++) {
+ const rule = otherAliases[i];
+ config += " {\n";
+ config += ` matches = [\n`;
+ config += ` { "node.name" = "${rule.nodeName}" }\n`;
+ config += ` ]\n`;
+ config += ` actions = {\n`;
+ config += ` update-props = {\n`;
+ config += ` "node.description" = "${rule.alias}"\n`;
+ config += ` "node.nick" = "${rule.alias}"\n`;
+ config += ` "device.description" = "${rule.alias}"\n`;
+ config += ` }\n`;
+ config += ` }\n`;
+ config += " }";
+ if (i < otherAliases.length - 1)
+ config += ",";
+ config += "\n";
+ }
+ config += "]\n";
+ }
+
+ return config;
+ }
+
+ function reloadWireplumberConfig() {
+ if (wireplumberReloading) {
+ return;
+ }
+
+ wireplumberReloading = true;
+ wireplumberReloadStarted();
+
+ Proc.runCommand("restartWireplumber", ["systemctl", "--user", "restart", "wireplumber"], (output, exitCode) => {
+ wireplumberReloading = false;
+
+ if (exitCode === 0) {
+ ToastService.showInfo(I18n.tr("Audio system restarted"), I18n.tr("Device names updated"));
+ wireplumberReloadCompleted(true);
+ } else {
+ console.error("AudioService: Failed to restart WirePlumber:", output);
+ ToastService.showError(I18n.tr("Failed to restart audio system"), output);
+ wireplumberReloadCompleted(false);
+ }
+ }, 5000);
+ }
+
+ function loadDeviceAliases() {
+ const configPath = wireplumberConfigPath;
+
+ Proc.runCommand("readWireplumberConfig", ["cat", configPath], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.log("AudioService: No existing WirePlumber config found");
+ return;
+ }
+
+ const aliases = {};
+ const lines = output.split('\n');
+ let currentNodeName = null;
+
+ for (const line of lines) {
+ const nodeNameMatch = line.match(/"node\.name"\s*=\s*"([^"]+)"/);
+ if (nodeNameMatch) {
+ currentNodeName = nodeNameMatch[1];
+ }
+
+ const descriptionMatch = line.match(/"node\.description"\s*=\s*"([^"]+)"/);
+ if (descriptionMatch && currentNodeName) {
+ aliases[currentNodeName] = descriptionMatch[1];
+ currentNodeName = null;
+ }
+ }
+
+ if (Object.keys(aliases).length > 0) {
+ deviceAliases = aliases;
+ console.log("AudioService: Loaded", Object.keys(aliases).length, "device aliases");
+ }
+ }, 0);
+ }
+
+ Connections {
+ target: root.sink?.audio ?? null
+
+ function onVolumeChanged() {
+ if (SessionData.suppressOSD)
+ return;
+ root.playVolumeChangeSoundIfEnabled();
+ }
+ }
+
+ function checkGsettings() {
+ Proc.runCommand("checkGsettings", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null"], (output, exitCode) => {
+ gsettingsAvailable = (exitCode === 0);
+ if (gsettingsAvailable) {
+ scanSoundThemes();
+ getCurrentSoundTheme();
+ }
+ }, 0);
+ }
+
+ function scanSoundThemes() {
+ const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS");
+ const searchPaths = xdgDataDirs && xdgDataDirs.trim() !== "" ? xdgDataDirs.split(":").concat(Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation))) : ["/usr/share", "/usr/local/share", Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation))];
+
+ const basePaths = searchPaths.map(p => p + "/sounds").join(" ");
+ const script = `
+ for base_dir in ${basePaths}; do
+ [ -d "$base_dir" ] || continue
+ for theme_dir in "$base_dir"/*; do
+ [ -d "$theme_dir/stereo" ] || continue
+ basename "$theme_dir"
+ done
+ done | sort -u
+ `;
+
+ Proc.runCommand("scanSoundThemes", ["sh", "-c", script], (output, exitCode) => {
+ if (exitCode === 0 && output.trim()) {
+ const themes = output.trim().split('\n').filter(t => t && t.length > 0);
+ availableSoundThemes = themes;
+ } else {
+ availableSoundThemes = [];
+ }
+ }, 0);
+ }
+
+ function getCurrentSoundTheme() {
+ Proc.runCommand("getCurrentSoundTheme", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null | sed \"s/'//g\""], (output, exitCode) => {
+ if (exitCode === 0 && output.trim()) {
+ currentSoundTheme = output.trim();
+ console.log("AudioService: Current system sound theme:", currentSoundTheme);
+ if (SettingsData.useSystemSoundTheme) {
+ discoverSoundFiles(currentSoundTheme);
+ }
+ } else {
+ currentSoundTheme = "";
+ console.log("AudioService: No system sound theme found");
+ }
+ }, 0);
+ }
+
+ function setSoundTheme(themeName) {
+ if (!themeName || themeName === currentSoundTheme) {
+ return;
+ }
+
+ Proc.runCommand("setSoundTheme", ["sh", "-c", `gsettings set org.gnome.desktop.sound theme-name '${themeName}'`], (output, exitCode) => {
+ if (exitCode === 0) {
+ currentSoundTheme = themeName;
+ if (SettingsData.useSystemSoundTheme) {
+ discoverSoundFiles(themeName);
+ }
+ }
+ }, 0);
+ }
+
+ function discoverSoundFiles(themeName) {
+ if (!themeName) {
+ soundFilePaths = {};
+ if (soundsAvailable) {
+ destroySoundPlayers();
+ createSoundPlayers();
+ }
+ return;
+ }
+
+ const xdgDataDirs = Quickshell.env("XDG_DATA_DIRS");
+ const searchPaths = xdgDataDirs && xdgDataDirs.trim() !== "" ? xdgDataDirs.split(":").concat(Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation))) : ["/usr/share", "/usr/local/share", Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation))];
+
+ const extensions = ["oga", "ogg", "wav", "mp3", "flac"];
+ const themesToSearch = themeName !== "freedesktop" ? `${themeName} freedesktop` : themeName;
+
+ const script = `
+ for event_key in audio-volume-change power-plug power-unplug message message-new-instant desktop-login; do
+ found=0
+
+ case "$event_key" in
+ message)
+ names="dialog-information message message-lowpriority bell"
+ ;;
+ message-new-instant)
+ names="dialog-warning message-new-instant message-highlight"
+ ;;
+ *)
+ names="$event_key"
+ ;;
+ esac
+
+ for theme in ${themesToSearch}; do
+ for event_name in $names; do
+ for base_path in ${searchPaths.join(" ")}; do
+ sounds_path="$base_path/sounds"
+ for ext in ${extensions.join(" ")}; do
+ file_path="$sounds_path/$theme/stereo/$event_name.$ext"
+ if [ -f "$file_path" ]; then
+ echo "$event_key=$file_path"
+ found=1
+ break
+ fi
+ done
+ [ $found -eq 1 ] && break
+ done
+ [ $found -eq 1 ] && break
+ done
+ [ $found -eq 1 ] && break
+ done
+ done
+ `;
+
+ Proc.runCommand("discoverSoundFiles", ["sh", "-c", script], (output, exitCode) => {
+ const paths = {};
+ if (exitCode === 0 && output.trim()) {
+ const lines = output.trim().split('\n');
+ for (let line of lines) {
+ const parts = line.split('=');
+ if (parts.length === 2) {
+ paths[parts[0]] = "file://" + parts[1];
+ }
+ }
+ }
+ soundFilePaths = paths;
+
+ if (soundsAvailable) {
+ destroySoundPlayers();
+ createSoundPlayers();
+ }
+ }, 0);
+ }
+
+ function getSoundPath(soundEvent) {
+ const soundMap = {
+ "audio-volume-change": "../assets/sounds/freedesktop/audio-volume-change.wav",
+ "power-plug": "../assets/sounds/plasma/power-plug.wav",
+ "power-unplug": "../assets/sounds/plasma/power-unplug.wav",
+ "message": "../assets/sounds/freedesktop/message.wav",
+ "message-new-instant": "../assets/sounds/freedesktop/message-new-instant.wav",
+ "desktop-login": "../assets/sounds/freedesktop/desktop-login.wav"
+ };
+
+ const specialConditions = {
+ "smooth": ["audio-volume-change"]
+ };
+
+ const themeLower = currentSoundTheme.toLowerCase();
+ if (SettingsData.useSystemSoundTheme && specialConditions[themeLower]?.includes(soundEvent)) {
+ const bundledPath = Qt.resolvedUrl(soundMap[soundEvent] || "../assets/sounds/freedesktop/message.wav");
+ console.log("AudioService: Using bundled sound (special condition) for", soundEvent, ":", bundledPath);
+ return bundledPath;
+ }
+
+ if (SettingsData.useSystemSoundTheme && soundFilePaths[soundEvent]) {
+ console.log("AudioService: Using system sound for", soundEvent, ":", soundFilePaths[soundEvent]);
+ return soundFilePaths[soundEvent];
+ }
+
+ const bundledPath = Qt.resolvedUrl(soundMap[soundEvent] || "../assets/sounds/freedesktop/message.wav");
+ console.log("AudioService: Using bundled sound for", soundEvent, ":", bundledPath);
+ return bundledPath;
+ }
+
+ function reloadSounds() {
+ console.log("AudioService: Reloading sounds, useSystemSoundTheme:", SettingsData.useSystemSoundTheme, "currentSoundTheme:", currentSoundTheme);
+ if (SettingsData.useSystemSoundTheme && currentSoundTheme) {
+ discoverSoundFiles(currentSoundTheme);
+ } else {
+ soundFilePaths = {};
+ if (soundsAvailable) {
+ destroySoundPlayers();
+ createSoundPlayers();
+ }
+ }
+ }
+
+ function setupMediaDevices() {
+ if (!soundsAvailable || mediaDevices) {
+ return;
+ }
+
+ try {
+ mediaDevices = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ MediaDevices {
+ id: devices
+ Component.onCompleted: {
+ console.log("AudioService: MediaDevices initialized, default output:", defaultAudioOutput?.description)
+ }
+ }
+ `, root, "AudioService.MediaDevices");
+
+ if (mediaDevices) {
+ mediaDevicesConnections = Qt.createQmlObject(`
+ import QtQuick
+ Connections {
+ target: root.mediaDevices
+ function onDefaultAudioOutputChanged() {
+ console.log("AudioService: Default audio output changed, recreating sound players")
+ root.destroySoundPlayers()
+ root.createSoundPlayers()
+ }
+ }
+ `, root, "AudioService.MediaDevicesConnections");
+ }
+ } catch (e) {
+ console.log("AudioService: MediaDevices not available, using default audio output");
+ mediaDevices = null;
+ }
+ }
+
+ function destroySoundPlayers() {
+ if (volumeChangeSound) {
+ volumeChangeSound.destroy();
+ volumeChangeSound = null;
+ }
+ if (powerPlugSound) {
+ powerPlugSound.destroy();
+ powerPlugSound = null;
+ }
+ if (powerUnplugSound) {
+ powerUnplugSound.destroy();
+ powerUnplugSound = null;
+ }
+ if (normalNotificationSound) {
+ normalNotificationSound.destroy();
+ normalNotificationSound = null;
+ }
+ if (criticalNotificationSound) {
+ criticalNotificationSound.destroy();
+ criticalNotificationSound = null;
+ }
+ if (loginSound) {
+ loginSound.destroy();
+ loginSound = null;
+ }
+ }
+
+ function createSoundPlayers() {
+ if (!soundsAvailable) {
+ return;
+ }
+
+ setupMediaDevices();
+
+ try {
+ const deviceProperty = mediaDevices ? `device: root.mediaDevices.defaultAudioOutput\n ` : "";
+
+ const volumeChangePath = getSoundPath("audio-volume-change");
+ volumeChangeSound = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ MediaPlayer {
+ source: "${volumeChangePath}"
+ audioOutput: AudioOutput {
+ ${deviceProperty}volume: notificationsVolume
+ }
+ }
+ `, root, "AudioService.VolumeChangeSound");
+
+ const powerPlugPath = getSoundPath("power-plug");
+ powerPlugSound = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ MediaPlayer {
+ source: "${powerPlugPath}"
+ audioOutput: AudioOutput {
+ ${deviceProperty}volume: notificationsVolume
+ }
+ }
+ `, root, "AudioService.PowerPlugSound");
+
+ const powerUnplugPath = getSoundPath("power-unplug");
+ powerUnplugSound = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ MediaPlayer {
+ source: "${powerUnplugPath}"
+ audioOutput: AudioOutput {
+ ${deviceProperty}volume: notificationsVolume
+ }
+ }
+ `, root, "AudioService.PowerUnplugSound");
+
+ const messagePath = getSoundPath("message");
+ normalNotificationSound = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ MediaPlayer {
+ source: "${messagePath}"
+ audioOutput: AudioOutput {
+ ${deviceProperty}volume: notificationsVolume
+ }
+ }
+ `, root, "AudioService.NormalNotificationSound");
+
+ const messageNewInstantPath = getSoundPath("message-new-instant");
+ criticalNotificationSound = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ MediaPlayer {
+ source: "${messageNewInstantPath}"
+ audioOutput: AudioOutput {
+ ${deviceProperty}volume: notificationsVolume
+ }
+ }
+ `, root, "AudioService.CriticalNotificationSound");
+
+ const loginPath = getSoundPath("desktop-login");
+ loginSound = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ MediaPlayer {
+ source: "${loginPath}"
+ audioOutput: AudioOutput {
+ ${deviceProperty}volume: notificationsVolume
+ }
+ }
+ `, root, "AudioService.LoginSound");
+
+ } catch (e) {
+ console.warn("AudioService: Error creating sound players:", e);
+ }
+ }
+
+ function isMediaPlaying() {
+ return MprisController.activePlayer?.isPlaying ?? false;
+ }
+
+ function playVolumeChangeSound() {
+ if (!soundsAvailable || !volumeChangeSound || notificationsAudioMuted || isMediaPlaying())
+ return;
+ volumeChangeSound.play();
+ }
+
+ function playPowerPlugSound() {
+ if (!soundsAvailable || !powerPlugSound || notificationsAudioMuted || isMediaPlaying())
+ return;
+ powerPlugSound.play();
+ }
+
+ function playPowerUnplugSound() {
+ if (!soundsAvailable || !powerUnplugSound || notificationsAudioMuted || isMediaPlaying())
+ return;
+ powerUnplugSound.play();
+ }
+
+ function playNormalNotificationSound() {
+ if (!soundsAvailable || !normalNotificationSound || SessionData.doNotDisturb || notificationsAudioMuted || isMediaPlaying())
+ return;
+ normalNotificationSound.play();
+ }
+
+ function playCriticalNotificationSound() {
+ if (!soundsAvailable || !criticalNotificationSound || SessionData.doNotDisturb || notificationsAudioMuted || isMediaPlaying())
+ return;
+ criticalNotificationSound.play();
+ }
+
+ function playLoginSound() {
+ if (!soundsAvailable || !loginSound || notificationsAudioMuted || isMediaPlaying()) {
+ return;
+ }
+ loginSound.play();
+ }
+
+ function playLoginSoundIfApplicable() {
+ if (SettingsData.soundsEnabled && SettingsData.soundLogin && !notificationsAudioMuted) {
+ // plays login sound on session start, but only if a specific file doesn't exist,
+ // to prevent it from playing on every DMS restart during the session
+ const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR");
+ const sessionId = Quickshell.env("XDG_SESSION_ID") || "0";
+
+ if (!runtimeDir) return;
+
+ const loginFile = `${runtimeDir}/danklinux.login-${sessionId}`;
+
+ // if file doesn't exist, touch it (0)
+ // If it exists, do nothing (1)
+ loginSoundChecker.command = ["sh", "-c", `[ ! -f ${loginFile} ] && touch ${loginFile}`];
+ loginSoundChecker.running = true;
+ }
+ }
+
+ function playVolumeChangeSoundIfEnabled() {
+ if (SettingsData.soundsEnabled && SettingsData.soundVolumeChanged && !notificationsAudioMuted) {
+ playVolumeChangeSound();
+ }
+ }
+
+ function sinkIcon(node) {
+ if (!node)
+ return "speaker";
+
+ const props = node.properties || {};
+ const formFactor = (props["device.form-factor"] || "").toLowerCase();
+
+ switch (formFactor) {
+ case "headphone":
+ case "headset":
+ case "hands-free":
+ case "handset":
+ return "headset";
+ case "tv":
+ case "monitor":
+ return "tv";
+ case "speaker":
+ case "computer":
+ case "hifi":
+ case "portable":
+ case "car":
+ return "speaker";
+ }
+
+ const bus = (props["device.bus"] || "").toLowerCase();
+ if (bus === "bluetooth")
+ return "headset";
+
+ const name = (node.name || "").toLowerCase();
+ if (name.includes("hdmi"))
+ return "tv";
+ if (name.includes("iec958") || name.includes("spdif"))
+ return "speaker";
+
+ if (bus === "usb")
+ return "headset";
+
+ return "speaker";
+ }
+
+ function displayName(node) {
+ if (!node) {
+ return "";
+ }
+
+ // FIRST: Check if we have a custom alias in our deviceAliases map
+ // This ensures we always show the user's custom name, regardless of
+ // whether WirePlumber has applied it to the node properties yet
+ if (node.name && deviceAliases[node.name]) {
+ return deviceAliases[node.name];
+ }
+
+ // Check node.properties["node.description"] for WirePlumber-applied aliases
+ // This is the live property updated by WirePlumber rules
+ if (node.properties && node.properties["node.description"]) {
+ const desc = node.properties["node.description"];
+ if (desc !== node.name) {
+ return desc;
+ }
+ }
+
+ // Check cached description as fallback
+ if (node.description && node.description !== node.name) {
+ return node.description;
+ }
+
+ // Fallback to device description property
+ if (node.properties && node.properties["device.description"]) {
+ return node.properties["device.description"];
+ }
+
+ // Fallback to nickname
+ if (node.nickname && node.nickname !== node.name) {
+ return node.nickname;
+ }
+
+ // Fallback to friendly names based on node name patterns
+ if (node.name.includes("analog-stereo")) {
+ return "Built-in Audio Analog Stereo";
+ }
+ if (node.name.includes("bluez")) {
+ return "Bluetooth Audio";
+ }
+ if (node.name.includes("usb")) {
+ return "USB Audio";
+ }
+ if (node.name.includes("hdmi")) {
+ return "HDMI Audio";
+ }
+
+ return node.name;
+ }
+
+ function originalName(node) {
+ if (!node) {
+ return "";
+ }
+
+ // Get the original name without checking for custom aliases
+ // Check pattern-based friendly names FIRST (before device.description)
+ // This ensures we show user-friendly names like "Built-in Audio Analog Stereo"
+ // instead of hardware chip names like "ALC274 Analog"
+ if (node.name.includes("analog-stereo")) {
+ return "Built-in Audio Analog Stereo";
+ }
+ if (node.name.includes("bluez")) {
+ return "Bluetooth Audio";
+ }
+ if (node.name.includes("usb")) {
+ return "USB Audio";
+ }
+ if (node.name.includes("hdmi")) {
+ return "HDMI Audio";
+ }
+ if (node.name.includes("raop_sink")) {
+ // Extract friendly name from RAOP node name
+ const match = node.name.match(/raop_sink\.([^.]+)/);
+ if (match) {
+ return match[1].replace(/-/g, " ");
+ }
+ }
+
+ // Fallback to device.description property
+ if (node.properties && node.properties["device.description"]) {
+ return node.properties["device.description"];
+ }
+
+ // Fallback to nickname
+ if (node.nickname && node.nickname !== node.name) {
+ return node.nickname;
+ }
+
+ return node.name;
+ }
+
+ function subtitle(name) {
+ if (!name) {
+ return "";
+ }
+
+ if (name.includes('usb-')) {
+ if (name.includes('SteelSeries')) {
+ return "USB Gaming Headset";
+ }
+ if (name.includes('Generic')) {
+ return "USB Audio Device";
+ }
+ return "USB Audio";
+ }
+
+ if (name.includes('pci-')) {
+ if (name.includes('01_00.1') || name.includes('01:00.1')) {
+ return "NVIDIA GPU Audio";
+ }
+ return "PCI Audio";
+ }
+
+ if (name.includes('bluez')) {
+ return "Bluetooth Audio";
+ }
+ if (name.includes('analog')) {
+ return "Built-in Audio";
+ }
+ if (name.includes('hdmi')) {
+ return "HDMI Audio";
+ }
+
+ return "";
+ }
+
+ PwObjectTracker {
+ objects: Pipewire.nodes.values.filter(node => node.audio && !node.isStream)
+ }
+
+ Connections {
+ target: Pipewire
+ function onDefaultAudioSinkChanged() {
+ if (soundsAvailable) {
+ Qt.callLater(root.destroySoundPlayers);
+ Qt.callLater(root.createSoundPlayers);
+ }
+ }
+ }
+
+ function setVolume(percentage) {
+ if (!root.sink?.audio)
+ return "No audio sink available";
+
+ const maxVol = root.sinkMaxVolume;
+ const clampedVolume = Math.max(0, Math.min(maxVol, percentage));
+ root.sink.audio.volume = clampedVolume / 100;
+ return `Volume set to ${clampedVolume}%`;
+ }
+
+ function toggleMute() {
+ if (!root.sink?.audio) {
+ return "No audio sink available";
+ }
+
+ root.sink.audio.muted = !root.sink.audio.muted;
+ return root.sink.audio.muted ? "Audio muted" : "Audio unmuted";
+ }
+
+ function setMicVolume(percentage) {
+ if (!root.source?.audio) {
+ return "No audio source available";
+ }
+
+ const clampedVolume = Math.max(0, Math.min(100, percentage));
+ root.source.audio.volume = clampedVolume / 100;
+ return `Microphone volume set to ${clampedVolume}%`;
+ }
+
+ function toggleMicMute() {
+ if (!root.source?.audio) {
+ return "No audio source available";
+ }
+
+ root.source.audio.muted = !root.source.audio.muted;
+ return root.source.audio.muted ? "Microphone muted" : "Microphone unmuted";
+ }
+
+ IpcHandler {
+ target: "audio"
+
+ function setvolume(percentage: string): string {
+ return root.setVolume(parseInt(percentage));
+ }
+
+ function increment(step: string): string {
+ if (!root.sink?.audio)
+ return "No audio sink available";
+
+ if (root.sink.audio.muted)
+ root.sink.audio.muted = false;
+
+ const maxVol = root.sinkMaxVolume;
+ const currentVolume = Math.round(root.sink.audio.volume * 100);
+ const stepValue = parseInt(step || "5");
+ const newVolume = Math.max(0, Math.min(maxVol, currentVolume + stepValue));
+
+ root.sink.audio.volume = newVolume / 100;
+ return `Volume increased to ${newVolume}%`;
+ }
+
+ function decrement(step: string): string {
+ if (!root.sink?.audio)
+ return "No audio sink available";
+
+ if (root.sink.audio.muted)
+ root.sink.audio.muted = false;
+
+ const maxVol = root.sinkMaxVolume;
+ const currentVolume = Math.round(root.sink.audio.volume * 100);
+ const stepValue = parseInt(step || "5");
+ const newVolume = Math.max(0, Math.min(maxVol, currentVolume - stepValue));
+
+ root.sink.audio.volume = newVolume / 100;
+ return `Volume decreased to ${newVolume}%`;
+ }
+
+ function mute(): string {
+ return root.toggleMute();
+ }
+
+ function setmic(percentage: string): string {
+ return root.setMicVolume(parseInt(percentage));
+ }
+
+ function micmute(): string {
+ const result = root.toggleMicMute();
+ root.micMuteChanged();
+ return result;
+ }
+
+ function status(): string {
+ let result = "Audio Status:\n";
+
+ if (root.sink?.audio) {
+ const volume = Math.round(root.sink.audio.volume * 100);
+ const muteStatus = root.sink.audio.muted ? " (muted)" : "";
+ const maxVol = root.sinkMaxVolume;
+ result += `Output: ${volume}%${muteStatus} (max: ${maxVol}%)\n`;
+ } else {
+ result += "Output: No sink available\n";
+ }
+
+ if (root.source?.audio) {
+ const micVolume = Math.round(root.source.audio.volume * 100);
+ const muteStatus = root.source.audio.muted ? " (muted)" : "";
+ result += `Input: ${micVolume}%${muteStatus}`;
+ } else {
+ result += "Input: No source available";
+ }
+
+ return result;
+ }
+
+ function getmaxvolume(): string {
+ return `${root.sinkMaxVolume}`;
+ }
+
+ function setmaxvolume(percent: string): string {
+ if (!root.sink?.name)
+ return "No audio sink available";
+ const val = parseInt(percent);
+ if (isNaN(val))
+ return "Invalid percentage";
+ SessionData.setDeviceMaxVolume(root.sink.name, val);
+ return `Max volume set to ${SessionData.getDeviceMaxVolume(root.sink.name)}%`;
+ }
+
+ function getmaxvolumefor(nodeName: string): string {
+ if (!nodeName)
+ return "No node name specified";
+ return `${SessionData.getDeviceMaxVolume(nodeName)}`;
+ }
+
+ function setmaxvolumefor(nodeName: string, percent: string): string {
+ if (!nodeName)
+ return "No node name specified";
+ const val = parseInt(percent);
+ if (isNaN(val))
+ return "Invalid percentage";
+ SessionData.setDeviceMaxVolume(nodeName, val);
+ return `Max volume for ${nodeName} set to ${SessionData.getDeviceMaxVolume(nodeName)}%`;
+ }
+
+ function cycleoutput(): string {
+ const result = root.cycleAudioOutput();
+ if (!result)
+ return "Only one audio output available";
+ return `Switched to: ${result}`;
+ }
+ }
+
+ Connections {
+ target: SettingsData
+ function onUseSystemSoundThemeChanged() {
+ reloadSounds();
+ }
+ }
+
+ Component.onCompleted: {
+ if (soundsAvailable) {
+ checkGsettings();
+ Qt.callLater(createSoundPlayers);
+ }
+
+ loadDeviceAliases();
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/BarWidgetService.qml b/raveos-hyprland-theme/theme-data/dms/Services/BarWidgetService.qml
new file mode 100644
index 0000000..0ae2450
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/BarWidgetService.qml
@@ -0,0 +1,171 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Hyprland
+import Quickshell.I3
+
+Singleton {
+ id: root
+
+ property var widgetRegistry: ({})
+ property var dankBarRepeater: null
+
+ signal widgetRegistered(string widgetId, string screenName)
+ signal widgetUnregistered(string widgetId, string screenName)
+
+ function registerWidget(widgetId, screenName, widgetRef) {
+ if (!widgetId || !screenName || !widgetRef)
+ return;
+ if (typeof widgetRegistry !== "object" || widgetRegistry === null)
+ widgetRegistry = ({});
+
+ if (!widgetRegistry[widgetId])
+ widgetRegistry[widgetId] = {};
+
+ widgetRegistry[widgetId][screenName] = widgetRef;
+ widgetRegistered(widgetId, screenName);
+ }
+
+ function unregisterWidget(widgetId, screenName) {
+ if (!widgetId || !screenName)
+ return;
+ if (typeof widgetRegistry !== "object" || widgetRegistry === null)
+ return;
+ if (!widgetRegistry[widgetId])
+ return;
+
+ delete widgetRegistry[widgetId][screenName];
+ if (Object.keys(widgetRegistry[widgetId]).length === 0)
+ delete widgetRegistry[widgetId];
+
+ widgetUnregistered(widgetId, screenName);
+ }
+
+ function getWidget(widgetId, screenName) {
+ if (!widgetRegistry[widgetId])
+ return null;
+ if (screenName)
+ return widgetRegistry[widgetId][screenName] || null;
+
+ const screens = Object.keys(widgetRegistry[widgetId]);
+ return screens.length > 0 ? widgetRegistry[widgetId][screens[0]] : null;
+ }
+
+ function getWidgetOnFocusedScreen(widgetId) {
+ if (!widgetRegistry[widgetId])
+ return null;
+
+ const focusedScreen = getFocusedScreenName();
+ if (focusedScreen && widgetRegistry[widgetId][focusedScreen])
+ return widgetRegistry[widgetId][focusedScreen];
+
+ const screens = Object.keys(widgetRegistry[widgetId]);
+ return screens.length > 0 ? widgetRegistry[widgetId][screens[0]] : null;
+ }
+
+ function getFocusedScreenName() {
+ if (CompositorService.isHyprland && Hyprland.focusedWorkspace?.monitor)
+ return Hyprland.focusedWorkspace.monitor.name;
+ if (CompositorService.isNiri && NiriService.currentOutput)
+ return NiriService.currentOutput;
+ if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
+ const focusedWs = I3.workspaces?.values?.find(ws => ws.focused === true);
+ return focusedWs?.monitor?.name || "";
+ }
+ return "";
+ }
+
+ function getRegisteredWidgetIds() {
+ return Object.keys(widgetRegistry);
+ }
+
+ function hasWidget(widgetId) {
+ return widgetRegistry[widgetId] && Object.keys(widgetRegistry[widgetId]).length > 0;
+ }
+
+ function triggerWidgetPopout(widgetId) {
+ const widget = getWidgetOnFocusedScreen(widgetId);
+ if (!widget)
+ return false;
+
+ if (typeof widget.triggerPopout === "function") {
+ widget.triggerPopout();
+ return true;
+ }
+
+ const signalMap = {
+ "battery": "toggleBatteryPopup",
+ "vpn": "toggleVpnPopup",
+ "layout": "toggleLayoutPopup",
+ "clock": "clockClicked",
+ "cpuUsage": "cpuClicked",
+ "memUsage": "ramClicked",
+ "cpuTemp": "cpuTempClicked",
+ "gpuTemp": "gpuTempClicked"
+ };
+
+ const signalName = signalMap[widgetId];
+ if (signalName && typeof widget[signalName] === "function") {
+ widget[signalName]();
+ return true;
+ }
+
+ if (typeof widget.clicked === "function") {
+ widget.clicked();
+ return true;
+ }
+
+ if (widget.popoutTarget?.toggle) {
+ widget.popoutTarget.toggle();
+ return true;
+ }
+
+ return false;
+ }
+
+ function getBarWindowForScreen(screenName) {
+ if (!dankBarRepeater)
+ return null;
+
+ for (var i = 0; i < dankBarRepeater.count; i++) {
+ const loader = dankBarRepeater.itemAt(i);
+ if (!loader?.item)
+ continue;
+
+ const barItem = loader.item;
+ if (!barItem.barVariants?.instances)
+ continue;
+
+ for (var j = 0; j < barItem.barVariants.instances.length; j++) {
+ const barInstance = barItem.barVariants.instances[j];
+ if (barInstance.modelData?.name === screenName)
+ return barInstance;
+ }
+ }
+ return null;
+ }
+
+ function getBarWindowOnFocusedScreen() {
+ const focusedScreen = getFocusedScreenName();
+ if (!focusedScreen)
+ return getFirstBarWindow();
+ return getBarWindowForScreen(focusedScreen) || getFirstBarWindow();
+ }
+
+ function getFirstBarWindow() {
+ if (!dankBarRepeater || dankBarRepeater.count === 0)
+ return null;
+
+ const loader = dankBarRepeater.itemAt(0);
+ if (!loader?.item)
+ return null;
+
+ const barItem = loader.item;
+ if (!barItem.barVariants?.instances || barItem.barVariants.instances.length === 0)
+ return null;
+
+ return barItem.barVariants.instances[0];
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/BatteryService.qml b/raveos-hyprland-theme/theme-data/dms/Services/BatteryService.qml
new file mode 100644
index 0000000..e7cea56
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/BatteryService.qml
@@ -0,0 +1,271 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.UPower
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool suppressSound: true
+ property bool previousPluggedState: false
+
+ readonly property var scale: 100 / SettingsData.batteryChargeLimit
+
+ Timer {
+ id: startupTimer
+ interval: 500
+ repeat: false
+ running: true
+ onTriggered: root.suppressSound = false
+ }
+
+ readonly property string preferredBatteryOverride: Quickshell.env("DMS_PREFERRED_BATTERY")
+
+ // List of laptop batteries
+ readonly property var batteries: UPower.devices.values.filter(dev => dev.isLaptopBattery)
+
+ readonly property bool usePreferred: preferredBatteryOverride && preferredBatteryOverride.length > 0
+
+ // Main battery (for backward compatibility)
+ readonly property UPowerDevice device: {
+ var preferredDev;
+ if (usePreferred) {
+ preferredDev = batteries.find(dev => dev.nativePath.toLowerCase().includes(preferredBatteryOverride.toLowerCase()));
+ }
+ return preferredDev || batteries[0] || null;
+ }
+ // Whether at least one battery is available
+ readonly property bool batteryAvailable: batteries.length > 0
+ // Aggregated charge level (percentage)
+ readonly property real batteryLevel: {
+ if (!batteryAvailable)
+ return 0;
+ if (batteryCapacity === 0) {
+ if (usePreferred && device && device.ready)
+ return Math.round(device.percentage * 100 * scale);
+ const validBatteries = batteries.filter(b => b.ready && b.percentage >= 0);
+ if (validBatteries.length === 0)
+ return 0;
+ const avgPercentage = validBatteries.reduce((sum, b) => sum + b.percentage, 0) / validBatteries.length;
+ return Math.round(avgPercentage * 100 * scale);
+ }
+ return Math.round((batteryEnergy * 100) / batteryCapacity * scale);
+ }
+ readonly property bool isCharging: batteryAvailable && batteries.some(b => b.state === UPowerDeviceState.Charging)
+
+ // Is the system plugged in (Is not running on battery)
+ readonly property bool isPluggedIn: !UPower.onBattery
+ readonly property bool isLowBattery: batteryAvailable && batteryLevel <= 20
+
+ onIsPluggedInChanged: {
+ if (suppressSound || !batteryAvailable) {
+ previousPluggedState = isPluggedIn;
+ return;
+ }
+
+ if (SettingsData.soundsEnabled && SettingsData.soundPluggedIn) {
+ if (isPluggedIn && !previousPluggedState) {
+ AudioService.playPowerPlugSound();
+ } else if (!isPluggedIn && previousPluggedState) {
+ AudioService.playPowerUnplugSound();
+ }
+ }
+
+ const profileValue = BatteryService.isPluggedIn ? SettingsData.acProfileName : SettingsData.batteryProfileName;
+
+ if (profileValue !== "") {
+ const targetProfile = parseInt(profileValue);
+ if (!isNaN(targetProfile) && PowerProfiles.profile !== targetProfile) {
+ PowerProfiles.profile = targetProfile;
+ }
+ }
+
+ previousPluggedState = isPluggedIn;
+ }
+
+ // Aggregated charge/discharge rate
+ readonly property real changeRate: {
+ if (!batteryAvailable)
+ return 0;
+ if (usePreferred && device && device.ready)
+ return device.changeRate;
+ return batteries.length > 0 ? batteries.reduce((sum, b) => sum + b.changeRate, 0) : 0;
+ }
+
+ // Aggregated battery health
+ readonly property string batteryHealth: {
+ if (!batteryAvailable)
+ return "N/A";
+
+ // If a preferred battery is selected and ready
+ if (usePreferred && device && device.ready && device.healthSupported)
+ return `${Math.round(device.healthPercentage)}%`;
+
+ // Otherwise, calculate the average health of all laptop batteries
+ const validBatteries = batteries.filter(b => b.healthSupported && b.healthPercentage > 0);
+ if (validBatteries.length === 0)
+ return "N/A";
+
+ const avgHealth = validBatteries.reduce((sum, b) => sum + b.healthPercentage, 0) / validBatteries.length;
+ return `${Math.round(avgHealth)}%`;
+ }
+
+ readonly property real batteryEnergy: {
+ if (!batteryAvailable)
+ return 0;
+ if (usePreferred && device && device.ready)
+ return device.energy;
+ return batteries.length > 0 ? batteries.reduce((sum, b) => sum + b.energy, 0) : 0;
+ }
+
+ // Total battery capacity (Wh)
+ readonly property real batteryCapacity: {
+ if (!batteryAvailable)
+ return 0;
+ if (usePreferred && device && device.ready)
+ return device.energyCapacity;
+ return batteries.length > 0 ? batteries.reduce((sum, b) => sum + b.energyCapacity, 0) : 0;
+ }
+
+ function translateBatteryState(state) {
+ switch (state) {
+ case UPowerDeviceState.Charging:
+ return I18n.tr("Charging", "battery status");
+ case UPowerDeviceState.Discharging:
+ return I18n.tr("Discharging", "battery status");
+ case UPowerDeviceState.Empty:
+ return I18n.tr("Empty", "battery status");
+ case UPowerDeviceState.FullyCharged:
+ return I18n.tr("Fully Charged", "battery status");
+ case UPowerDeviceState.PendingCharge:
+ return I18n.tr("Pending Charge", "battery status");
+ case UPowerDeviceState.PendingDischarge:
+ return I18n.tr("Pending Discharge", "battery status");
+ default:
+ return I18n.tr("Unknown", "battery status");
+ }
+ }
+
+ // Aggregated battery status
+ readonly property string batteryStatus: {
+ if (!batteryAvailable) {
+ return I18n.tr("No Battery", "battery status");
+ }
+
+ if (isCharging && !batteries.some(b => b.changeRate > 0))
+ return I18n.tr("Plugged In", "battery status");
+
+ const states = batteries.map(b => b.state);
+ if (states.every(s => s === states[0]))
+ return translateBatteryState(states[0]);
+
+ return isCharging ? I18n.tr("Charging", "battery status") : (isPluggedIn ? I18n.tr("Plugged In", "battery status") : I18n.tr("Discharging", "battery status"));
+ }
+
+ readonly property bool suggestPowerSaver: false
+
+ readonly property var bluetoothDevices: {
+ const btDevices = [];
+ const bluetoothTypes = [UPowerDeviceType.BluetoothGeneric, UPowerDeviceType.Headphones, UPowerDeviceType.Headset, UPowerDeviceType.Keyboard, UPowerDeviceType.Mouse, UPowerDeviceType.Speakers];
+
+ for (var i = 0; i < UPower.devices.count; i++) {
+ const dev = UPower.devices.get(i);
+ if (dev && dev.ready && bluetoothTypes.includes(dev.type)) {
+ btDevices.push({
+ "name": dev.model || UPowerDeviceType.toString(dev.type),
+ "percentage": Math.round(dev.percentage * 100),
+ "type": dev.type
+ });
+ }
+ }
+ return btDevices;
+ }
+
+ // Format time remaining for charge/discharge
+ function formatTimeRemaining() {
+ if (!batteryAvailable) {
+ return "Unknown";
+ }
+
+ let totalTime = 0;
+ totalTime = (isCharging) ? ((batteryCapacity - batteryEnergy) / changeRate) : (batteryEnergy / changeRate);
+ const avgTime = Math.abs(totalTime * 3600);
+ if (!avgTime || avgTime <= 0 || avgTime > 86400)
+ return "Unknown";
+
+ const hours = Math.floor(avgTime / 3600);
+ const minutes = Math.floor((avgTime % 3600) / 60);
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+ }
+
+ function getBatteryIcon() {
+ if (!batteryAvailable) {
+ return "power";
+ }
+
+ if (isCharging) {
+ if (batteryLevel >= 90) {
+ return "battery_charging_full";
+ }
+ if (batteryLevel >= 80) {
+ return "battery_charging_90";
+ }
+ if (batteryLevel >= 60) {
+ return "battery_charging_80";
+ }
+ if (batteryLevel >= 50) {
+ return "battery_charging_60";
+ }
+ if (batteryLevel >= 30) {
+ return "battery_charging_50";
+ }
+ if (batteryLevel >= 20) {
+ return "battery_charging_30";
+ }
+ return "battery_charging_20";
+ }
+ if (isPluggedIn) {
+ if (batteryLevel >= 90) {
+ return "battery_charging_full";
+ }
+ if (batteryLevel >= 80) {
+ return "battery_charging_90";
+ }
+ if (batteryLevel >= 60) {
+ return "battery_charging_80";
+ }
+ if (batteryLevel >= 50) {
+ return "battery_charging_60";
+ }
+ if (batteryLevel >= 30) {
+ return "battery_charging_50";
+ }
+ if (batteryLevel >= 20) {
+ return "battery_charging_30";
+ }
+ return "battery_charging_20";
+ }
+ if (batteryLevel >= 95) {
+ return "battery_full";
+ }
+ if (batteryLevel >= 85) {
+ return "battery_6_bar";
+ }
+ if (batteryLevel >= 70) {
+ return "battery_5_bar";
+ }
+ if (batteryLevel >= 55) {
+ return "battery_4_bar";
+ }
+ if (batteryLevel >= 40) {
+ return "battery_3_bar";
+ }
+ if (batteryLevel >= 25) {
+ return "battery_2_bar";
+ }
+ return "battery_1_bar";
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/BluetoothService.qml b/raveos-hyprland-theme/theme-data/dms/Services/BluetoothService.qml
new file mode 100644
index 0000000..4de300e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/BluetoothService.qml
@@ -0,0 +1,523 @@
+pragma Singleton
+
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Bluetooth
+import qs.Services
+
+Singleton {
+ id: root
+
+ readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
+ readonly property bool available: adapter !== null
+ readonly property bool enabled: (adapter && adapter.enabled) ?? false
+ readonly property bool discovering: (adapter && adapter.discovering) ?? false
+ readonly property var devices: adapter ? adapter.devices : null
+ readonly property bool enhancedPairingAvailable: DMSService.dmsAvailable && DMSService.apiVersion >= 9 && DMSService.capabilities.includes("bluetooth")
+ readonly property bool connected: {
+ if (!adapter || !adapter.devices) {
+ return false
+ }
+
+ let isConnected = false
+ adapter.devices.values.forEach(dev => { if (dev.connected) isConnected = true })
+ return isConnected
+ }
+ readonly property var pairedDevices: {
+ if (!adapter || !adapter.devices) {
+ return []
+ }
+
+ return adapter.devices.values.filter(dev => {
+ return dev && (dev.paired || dev.trusted)
+ })
+ }
+ readonly property var allDevicesWithBattery: {
+ if (!adapter || !adapter.devices) {
+ return []
+ }
+
+ return adapter.devices.values.filter(dev => {
+ return dev && dev.batteryAvailable && dev.battery > 0
+ })
+ }
+
+ function sortDevices(devices) {
+ return devices.sort((a, b) => {
+ const aName = a.name || a.deviceName || ""
+ const bName = b.name || b.deviceName || ""
+ const aAddr = a.address || ""
+ const bAddr = b.address || ""
+
+ const aHasRealName = aName.includes(" ") && aName.length > 3
+ const bHasRealName = bName.includes(" ") && bName.length > 3
+
+ if (aHasRealName && !bHasRealName) return -1
+ if (!aHasRealName && bHasRealName) return 1
+
+ if (aHasRealName && bHasRealName) {
+ return aName.localeCompare(bName)
+ }
+
+ return aAddr.localeCompare(bAddr)
+ })
+ }
+
+ function getDeviceIcon(device) {
+ if (!device) {
+ return "bluetooth"
+ }
+
+ const name = (device.name || device.deviceName || "").toLowerCase()
+ const icon = (device.icon || "").toLowerCase()
+
+ const audioKeywords = ["headset", "audio", "headphone", "airpod", "arctis"]
+ if (audioKeywords.some(keyword => icon.includes(keyword) || name.includes(keyword))) {
+ return "headset"
+ }
+
+ if (icon.includes("mouse") || name.includes("mouse")) {
+ return "mouse"
+ }
+
+ if (icon.includes("keyboard") || name.includes("keyboard")) {
+ return "keyboard"
+ }
+
+ const phoneKeywords = ["phone", "iphone", "android", "samsung"]
+ if (phoneKeywords.some(keyword => icon.includes(keyword) || name.includes(keyword))) {
+ return "smartphone"
+ }
+
+ if (icon.includes("watch") || name.includes("watch")) {
+ return "watch"
+ }
+
+ if (icon.includes("speaker") || name.includes("speaker")) {
+ return "speaker"
+ }
+
+ if (icon.includes("display") || name.includes("tv")) {
+ return "tv"
+ }
+
+ return "bluetooth"
+ }
+
+ function canConnect(device) {
+ if (!device) {
+ return false
+ }
+
+ return !device.paired && !device.pairing && !device.blocked
+ }
+
+ function getSignalStrength(device) {
+ if (!device || device.signalStrength === undefined || device.signalStrength <= 0) {
+ return "Unknown"
+ }
+
+ const signal = device.signalStrength
+ if (signal >= 80) {
+ return "Excellent"
+ }
+ if (signal >= 60) {
+ return "Good"
+ }
+ if (signal >= 40) {
+ return "Fair"
+ }
+ if (signal >= 20) {
+ return "Poor"
+ }
+
+ return "Very Poor"
+ }
+
+ function getSignalIcon(device) {
+ if (!device || device.signalStrength === undefined || device.signalStrength <= 0) {
+ return "signal_cellular_null"
+ }
+
+ const signal = device.signalStrength
+ if (signal >= 80) {
+ return "signal_cellular_4_bar"
+ }
+ if (signal >= 60) {
+ return "signal_cellular_3_bar"
+ }
+ if (signal >= 40) {
+ return "signal_cellular_2_bar"
+ }
+ if (signal >= 20) {
+ return "signal_cellular_1_bar"
+ }
+
+ return "signal_cellular_0_bar"
+ }
+
+ function isDeviceBusy(device) {
+ if (!device) {
+ return false
+ }
+ return device.pairing || device.state === BluetoothDeviceState.Disconnecting || device.state === BluetoothDeviceState.Connecting
+ }
+
+ function connectDeviceWithTrust(device) {
+ if (!device) {
+ return
+ }
+
+ device.trusted = true
+ device.connect()
+ }
+
+ function pairDevice(device, callback) {
+ if (!device) {
+ if (callback) callback({error: "Invalid device"})
+ return
+ }
+
+ // The DMS backend actually implements a bluez agent, so we can pair anything
+ if (enhancedPairingAvailable) {
+ const devicePath = getDevicePath(device)
+ DMSService.bluetoothPair(devicePath, callback)
+ return
+ }
+
+ // Quickshell does not implement a bluez agent, so we can try to pair but only with devices that don't require a passcode
+ device.trusted = true
+ device.connect()
+ if (callback) callback({success: true})
+ }
+
+ function getCardName(device) {
+ if (!device) {
+ return ""
+ }
+ return `bluez_card.${device.address.replace(/:/g, "_")}`
+ }
+
+ function getDevicePath(device) {
+ if (!device || !device.address) {
+ return ""
+ }
+ const adapterPath = adapter ? "/org/bluez/hci0" : "/org/bluez/hci0"
+ return `${adapterPath}/dev_${device.address.replace(/:/g, "_")}`
+ }
+
+ function isAudioDevice(device) {
+ if (!device) {
+ return false
+ }
+ const icon = getDeviceIcon(device)
+ return icon === "headset" || icon === "speaker"
+ }
+
+ function getCodecInfo(codecName) {
+ const codec = codecName.replace(/-/g, "_").toUpperCase()
+
+ const codecMap = {
+ "LDAC": {
+ "name": "LDAC",
+ "description": "Highest quality • Higher battery usage",
+ "qualityColor": "#4CAF50"
+ },
+ "APTX_HD": {
+ "name": "aptX HD",
+ "description": "High quality • Balanced battery",
+ "qualityColor": "#FF9800"
+ },
+ "APTX": {
+ "name": "aptX",
+ "description": "Good quality • Low latency",
+ "qualityColor": "#FF9800"
+ },
+ "AAC": {
+ "name": "AAC",
+ "description": "Balanced quality and battery",
+ "qualityColor": "#2196F3"
+ },
+ "SBC_XQ": {
+ "name": "SBC-XQ",
+ "description": "Enhanced SBC • Better compatibility",
+ "qualityColor": "#2196F3"
+ },
+ "SBC": {
+ "name": "SBC",
+ "description": "Basic quality • Universal compatibility",
+ "qualityColor": "#9E9E9E"
+ },
+ "MSBC": {
+ "name": "mSBC",
+ "description": "Modified SBC • Optimized for speech",
+ "qualityColor": "#9E9E9E"
+ },
+ "CVSD": {
+ "name": "CVSD",
+ "description": "Basic speech codec • Legacy compatibility",
+ "qualityColor": "#9E9E9E"
+ }
+ }
+
+ return codecMap[codec] || {
+ "name": codecName,
+ "description": "Unknown codec",
+ "qualityColor": "#9E9E9E"
+ }
+ }
+
+ property var deviceCodecs: ({})
+
+ function updateDeviceCodec(deviceAddress, codec) {
+ deviceCodecs[deviceAddress] = codec
+ deviceCodecsChanged()
+ }
+
+ function refreshDeviceCodec(device) {
+ if (!device || !device.connected || !isAudioDevice(device)) {
+ return
+ }
+
+ const cardName = getCardName(device)
+ codecQueryProcess.cardName = cardName
+ codecQueryProcess.deviceAddress = device.address
+ codecQueryProcess.availableCodecs = []
+ codecQueryProcess.parsingTargetCard = false
+ codecQueryProcess.detectedCodec = ""
+ codecQueryProcess.running = true
+ }
+
+ function getCurrentCodec(device, callback) {
+ if (!device || !device.connected || !isAudioDevice(device)) {
+ callback("")
+ return
+ }
+
+ const cardName = getCardName(device)
+ codecQueryProcess.cardName = cardName
+ codecQueryProcess.callback = callback
+ codecQueryProcess.availableCodecs = []
+ codecQueryProcess.parsingTargetCard = false
+ codecQueryProcess.detectedCodec = ""
+ codecQueryProcess.running = true
+ }
+
+ function getAvailableCodecs(device, callback) {
+ if (!device || !device.connected || !isAudioDevice(device)) {
+ callback([], "")
+ return
+ }
+
+ const cardName = getCardName(device)
+ codecFullQueryProcess.cardName = cardName
+ codecFullQueryProcess.callback = callback
+ codecFullQueryProcess.availableCodecs = []
+ codecFullQueryProcess.parsingTargetCard = false
+ codecFullQueryProcess.detectedCodec = ""
+ codecFullQueryProcess.running = true
+ }
+
+ function switchCodec(device, profileName, callback) {
+ if (!device || !isAudioDevice(device)) {
+ callback(false, "Invalid device")
+ return
+ }
+
+ const cardName = getCardName(device)
+ codecSwitchProcess.cardName = cardName
+ codecSwitchProcess.profile = profileName
+ codecSwitchProcess.callback = callback
+ codecSwitchProcess.running = true
+ }
+
+ Process {
+ id: codecQueryProcess
+
+ property string cardName: ""
+ property string deviceAddress: ""
+ property var callback: null
+ property bool parsingTargetCard: false
+ property string detectedCodec: ""
+ property var availableCodecs: []
+
+ command: ["pactl", "list", "cards"]
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode === 0 && detectedCodec) {
+ if (deviceAddress) {
+ root.updateDeviceCodec(deviceAddress, detectedCodec)
+ }
+ if (callback) {
+ callback(detectedCodec)
+ }
+ } else if (callback) {
+ callback("")
+ }
+
+ parsingTargetCard = false
+ detectedCodec = ""
+ availableCodecs = []
+ deviceAddress = ""
+ callback = null
+ }
+
+ stdout: SplitParser {
+ splitMarker: "\n"
+ onRead: data => {
+ let line = data.trim()
+
+ if (line.includes(`Name: ${codecQueryProcess.cardName}`)) {
+ codecQueryProcess.parsingTargetCard = true
+ return
+ }
+
+ if (codecQueryProcess.parsingTargetCard && line.startsWith("Name: ") && !line.includes(codecQueryProcess.cardName)) {
+ codecQueryProcess.parsingTargetCard = false
+ return
+ }
+
+ if (codecQueryProcess.parsingTargetCard) {
+ if (line.startsWith("Active Profile:")) {
+ let profile = line.split(": ")[1] || ""
+ let activeCodec = codecQueryProcess.availableCodecs.find(c => {
+ return c.profile === profile
+ })
+ if (activeCodec) {
+ codecQueryProcess.detectedCodec = activeCodec.name
+ }
+ return
+ }
+ if (line.includes("codec") && line.includes("available: yes")) {
+ let parts = line.split(": ")
+ if (parts.length >= 2) {
+ let profile = parts[0].trim()
+ let description = parts[1]
+ let codecMatch = description.match(/codec ([^\)\s]+)/i)
+ let codecName = codecMatch ? codecMatch[1].toUpperCase() : "UNKNOWN"
+ let codecInfo = root.getCodecInfo(codecName)
+ if (codecInfo && !codecQueryProcess.availableCodecs.some(c => {
+ return c.profile === profile
+ })) {
+ let newCodecs = codecQueryProcess.availableCodecs.slice()
+ newCodecs.push({
+ "name": codecInfo.name,
+ "profile": profile,
+ "description": codecInfo.description,
+ "qualityColor": codecInfo.qualityColor
+ })
+ codecQueryProcess.availableCodecs = newCodecs
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: codecFullQueryProcess
+
+ property string cardName: ""
+ property var callback: null
+ property bool parsingTargetCard: false
+ property string detectedCodec: ""
+ property var availableCodecs: []
+
+ command: ["pactl", "list", "cards"]
+
+ onExited: function (exitCode, exitStatus) {
+ if (callback) {
+ callback(exitCode === 0 ? availableCodecs : [], exitCode === 0 ? detectedCodec : "")
+ }
+ parsingTargetCard = false
+ detectedCodec = ""
+ availableCodecs = []
+ callback = null
+ }
+
+ stdout: SplitParser {
+ splitMarker: "\n"
+ onRead: data => {
+ let line = data.trim()
+
+ if (line.includes(`Name: ${codecFullQueryProcess.cardName}`)) {
+ codecFullQueryProcess.parsingTargetCard = true
+ return
+ }
+
+ if (codecFullQueryProcess.parsingTargetCard && line.startsWith("Name: ") && !line.includes(codecFullQueryProcess.cardName)) {
+ codecFullQueryProcess.parsingTargetCard = false
+ return
+ }
+
+ if (codecFullQueryProcess.parsingTargetCard) {
+ if (line.startsWith("Active Profile:")) {
+ let profile = line.split(": ")[1] || ""
+ let activeCodec = codecFullQueryProcess.availableCodecs.find(c => {
+ return c.profile === profile
+ })
+ if (activeCodec) {
+ codecFullQueryProcess.detectedCodec = activeCodec.name
+ }
+ return
+ }
+ if (line.includes("codec") && line.includes("available: yes")) {
+ let parts = line.split(": ")
+ if (parts.length >= 2) {
+ let profile = parts[0].trim()
+ let description = parts[1]
+ let codecMatch = description.match(/codec ([^\)\s]+)/i)
+ let codecName = codecMatch ? codecMatch[1].toUpperCase() : "UNKNOWN"
+ let codecInfo = root.getCodecInfo(codecName)
+ if (codecInfo && !codecFullQueryProcess.availableCodecs.some(c => {
+ return c.profile === profile
+ })) {
+ let newCodecs = codecFullQueryProcess.availableCodecs.slice()
+ newCodecs.push({
+ "name": codecInfo.name,
+ "profile": profile,
+ "description": codecInfo.description,
+ "qualityColor": codecInfo.qualityColor
+ })
+ codecFullQueryProcess.availableCodecs = newCodecs
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: codecSwitchProcess
+
+ property string cardName: ""
+ property string profile: ""
+ property var callback: null
+
+ command: ["pactl", "set-card-profile", cardName, profile]
+
+ onExited: function (exitCode, exitStatus) {
+ if (callback) {
+ callback(exitCode === 0, exitCode === 0 ? "Codec switched successfully" : "Failed to switch codec")
+ }
+
+ // If successful, refresh the codec for this device
+ if (exitCode === 0) {
+ if (root.adapter && root.adapter.devices) {
+ root.adapter.devices.values.forEach(device => {
+ if (device && root.getCardName(device) === cardName) {
+ Qt.callLater(() => root.refreshDeviceCodec(device))
+ }
+ })
+ }
+ }
+
+ callback = null
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/BlurService.qml b/raveos-hyprland-theme/theme-data/dms/Services/BlurService.qml
new file mode 100644
index 0000000..40a3aeb
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/BlurService.qml
@@ -0,0 +1,113 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland // ! Import is needed despite what qmlls says
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool quickshellSupported: false
+ property bool compositorSupported: false
+ property bool available: quickshellSupported && compositorSupported
+ readonly property bool enabled: available && (SettingsData.blurEnabled ?? false)
+
+ readonly property color borderColor: {
+ if (!enabled)
+ return "transparent";
+ const opacity = SettingsData.blurBorderOpacity ?? 0.5;
+ switch (SettingsData.blurBorderColor ?? "outline") {
+ case "primary":
+ return Theme.withAlpha(Theme.primary, opacity);
+ case "secondary":
+ return Theme.withAlpha(Theme.secondary, opacity);
+ case "surfaceText":
+ return Theme.withAlpha(Theme.surfaceText, opacity);
+ case "custom":
+ return Theme.withAlpha(SettingsData.blurBorderCustomColor ?? "#ffffff", opacity);
+ default:
+ return Theme.withAlpha(Theme.outline, opacity);
+ }
+ }
+ readonly property int borderWidth: enabled ? 1 : 0
+
+ function hoverColor(baseColor, hoverAlpha) {
+ if (!enabled)
+ return baseColor;
+ return Theme.withAlpha(baseColor, hoverAlpha ?? 0.15);
+ }
+
+ function createBlurRegion(targetWindow) {
+ if (!available)
+ return null;
+
+ try {
+ const region = Qt.createQmlObject(`
+ import Quickshell
+ Region {}
+ `, targetWindow, "BlurRegion");
+ targetWindow.BackgroundEffect.blurRegion = region;
+ return region;
+ } catch (e) {
+ console.warn("BlurService: Failed to create blur region:", e);
+ return null;
+ }
+ }
+
+ function reapplyBlurRegion(targetWindow, region) {
+ if (!region || !available)
+ return;
+ try {
+ targetWindow.BackgroundEffect.blurRegion = region;
+ region.changed();
+ } catch (e) {}
+ }
+
+ function destroyBlurRegion(targetWindow, region) {
+ if (!region)
+ return;
+ try {
+ targetWindow.BackgroundEffect.blurRegion = null;
+ } catch (e) {}
+ region.destroy();
+ }
+
+ Process {
+ id: blurProbe
+ running: false
+ command: ["dms", "blur", "check"]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.compositorSupported = text.trim() === "supported";
+ if (root.compositorSupported)
+ console.info("BlurService: Compositor supports ext-background-effect-v1");
+ else
+ console.info("BlurService: Compositor does not support ext-background-effect-v1");
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0)
+ console.warn("BlurService: blur probe failed with code:", exitCode);
+ }
+ }
+
+ Component.onCompleted: {
+ try {
+ const test = Qt.createQmlObject(`
+ import Quickshell
+ Region { radius: 0 }
+ `, root, "BlurAvailabilityTest");
+ test.destroy();
+ quickshellSupported = true;
+ console.info("BlurService: Quickshell blur support available");
+ blurProbe.running = true;
+ } catch (e) {
+ console.info("BlurService: BackgroundEffect not available - blur disabled. Requires a newer version of Quickshell.");
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/CalendarService.qml b/raveos-hyprland-theme/theme-data/dms/Services/CalendarService.qml
new file mode 100644
index 0000000..1146f7b
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/CalendarService.qml
@@ -0,0 +1,317 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool khalAvailable: false
+ property var eventsByDate: ({})
+ property bool isLoading: false
+ property string lastError: ""
+ property date lastStartDate
+ property date lastEndDate
+ property string khalDateFormat: "MM/dd/yyyy"
+
+ function checkKhalAvailability() {
+ if (!khalCheckProcess.running)
+ khalCheckProcess.running = true
+ }
+
+ function detectKhalDateFormat() {
+ if (!khalFormatProcess.running)
+ khalFormatProcess.running = true
+ }
+
+ function parseKhalDateFormat(formatExample) {
+ let qtFormat = formatExample.replace("12", "MM").replace("21", "dd").replace("2013", "yyyy")
+ return { format: qtFormat, parser: null }
+ }
+
+
+ function loadCurrentMonth() {
+ if (!root.khalAvailable)
+ return
+
+ let today = new Date()
+ let firstDay = new Date(today.getFullYear(), today.getMonth(), 1)
+ let lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0)
+ // Add padding
+ let startDate = new Date(firstDay)
+ startDate.setDate(startDate.getDate() - firstDay.getDay() - 7)
+ let endDate = new Date(lastDay)
+ endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()) + 7)
+ loadEvents(startDate, endDate)
+ }
+
+ function loadEvents(startDate, endDate) {
+ if (!root.khalAvailable) {
+ return
+ }
+ if (eventsProcess.running) {
+ return
+ }
+ // Store last requested date range for refresh timer
+ root.lastStartDate = startDate
+ root.lastEndDate = endDate
+ root.isLoading = true
+ // Format dates for khal using detected format
+ let startDateStr = Qt.formatDate(startDate, root.khalDateFormat)
+ let endDateStr = Qt.formatDate(endDate, root.khalDateFormat)
+ eventsProcess.requestStartDate = startDate
+ eventsProcess.requestEndDate = endDate
+ eventsProcess.command = ["khal", "list", "--json", "title", "--json", "description", "--json", "start-date", "--json", "start-time", "--json", "end-date", "--json", "end-time", "--json", "all-day", "--json", "location", "--json", "url", startDateStr, endDateStr]
+ eventsProcess.running = true
+ }
+
+ function getEventsForDate(date) {
+ let dateKey = Qt.formatDate(date, "yyyy-MM-dd")
+ return root.eventsByDate[dateKey] || []
+ }
+
+ function hasEventsForDate(date) {
+ let events = getEventsForDate(date)
+ return events.length > 0
+ }
+
+ // Initialize on component completion
+ Component.onCompleted: {
+ detectKhalDateFormat()
+ }
+
+ // Process for detecting khal date format
+ Process {
+ id: khalFormatProcess
+
+ command: ["khal", "printformats"]
+ running: false
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ checkKhalAvailability()
+ }
+ }
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ let lines = text.split('\n')
+ for (let line of lines) {
+ if (line.startsWith('dateformat:')) {
+ let formatExample = line.substring(line.indexOf(':') + 1).trim()
+ let formatInfo = parseKhalDateFormat(formatExample)
+ root.khalDateFormat = formatInfo.format
+ break
+ }
+ }
+ checkKhalAvailability()
+ }
+ }
+ }
+
+ // Process for checking khal configuration
+ Process {
+ id: khalCheckProcess
+
+ command: ["khal", "list", "today"]
+ running: false
+ onExited: exitCode => {
+ root.khalAvailable = (exitCode === 0)
+ if (exitCode === 0) {
+ loadCurrentMonth()
+ }
+ }
+ }
+
+ // Process for loading events
+ Process {
+ id: eventsProcess
+
+ property date requestStartDate
+ property date requestEndDate
+ property string rawOutput: ""
+
+ running: false
+ onExited: exitCode => {
+ root.isLoading = false
+ if (exitCode !== 0) {
+ root.lastError = "Failed to load events (exit code: " + exitCode + ")"
+ return
+ }
+ try {
+ let newEventsByDate = {}
+ let lines = eventsProcess.rawOutput.split('\n')
+ for (let line of lines) {
+ line = line.trim()
+ if (!line || line === "[]")
+ continue
+
+ // Parse JSON line
+ let dayEvents = JSON.parse(line)
+ // Process each event in this day's array
+ for (let event of dayEvents) {
+ if (!event.title)
+ continue
+
+ // Parse start and end dates using detected format
+ let startDate, endDate
+ if (event['start-date']) {
+ startDate = Date.fromLocaleString(I18n.locale(), event['start-date'], root.khalDateFormat)
+ } else {
+ startDate = new Date()
+ }
+ if (event['end-date']) {
+ endDate = Date.fromLocaleString(I18n.locale(), event['end-date'], root.khalDateFormat)
+ } else {
+ endDate = new Date(startDate)
+ }
+ // Create start/end times
+ let startTime = new Date(startDate)
+ let endTime = new Date(endDate)
+ if (event['start-time']
+ && event['all-day'] !== "True") {
+ // Parse time if available and not all-day
+ let timeStr = event['start-time']
+ if (timeStr) {
+ // Match time with optional seconds and AM/PM
+ let timeParts = timeStr.match(/(\d+):(\d+)(?::\d+)?\s*(AM|PM)?/i)
+ if (timeParts) {
+ let hours = parseInt(timeParts[1])
+ let minutes = parseInt(timeParts[2])
+
+ // Handle AM/PM conversion if present
+ if (timeParts[3]) {
+ let period = timeParts[3].toUpperCase()
+ if (period === 'PM' && hours !== 12) {
+ hours += 12
+ } else if (period === 'AM' && hours === 12) {
+ hours = 0
+ }
+ }
+
+ startTime.setHours(hours, minutes)
+ if (event['end-time']) {
+ let endTimeParts = event['end-time'].match(
+ /(\d+):(\d+)(?::\d+)?\s*(AM|PM)?/i)
+ if (endTimeParts) {
+ let endHours = parseInt(endTimeParts[1])
+ let endMinutes = parseInt(endTimeParts[2])
+
+ // Handle AM/PM conversion if present
+ if (endTimeParts[3]) {
+ let endPeriod = endTimeParts[3].toUpperCase()
+ if (endPeriod === 'PM' && endHours !== 12) {
+ endHours += 12
+ } else if (endPeriod === 'AM' && endHours === 12) {
+ endHours = 0
+ }
+ }
+
+ endTime.setHours(endHours, endMinutes)
+ }
+ } else {
+ // Default to 1 hour duration on same day
+ endTime = new Date(startTime)
+ endTime.setHours(
+ startTime.getHours() + 1)
+ }
+ }
+ }
+ }
+ // Create unique ID for this event (to track multi-day events)
+ let eventId = event.title + "_" + event['start-date']
+ + "_" + (event['start-time'] || 'allday')
+ // Create event object template
+ let extractedUrl = ""
+ if (!event.url && event.description) {
+ let urlMatch = event.description.match(/https?:\/\/[^\s]+/)
+ if (urlMatch) {
+ extractedUrl = urlMatch[0]
+ }
+ }
+ let eventTemplate = {
+ "id": eventId,
+ "title": event.title || "Untitled Event",
+ "start": startTime,
+ "end": endTime,
+ "location": event.location || "",
+ "description": event.description || "",
+ "url": event.url || extractedUrl,
+ "calendar": "",
+ "color": "",
+ "allDay": event['all-day'] === "True",
+ "isMultiDay": startDate.toDateString(
+ ) !== endDate.toDateString()
+ }
+ // Add event to each day it spans
+ let currentDate = new Date(startDate)
+ while (currentDate <= endDate) {
+ let dateKey = Qt.formatDate(currentDate,
+ "yyyy-MM-dd")
+ if (!newEventsByDate[dateKey])
+ newEventsByDate[dateKey] = []
+
+ // Check if this exact event is already added to this date (prevent duplicates)
+ let existingEvent = newEventsByDate[dateKey].find(
+ e => {
+ return e.id === eventId
+ })
+ if (existingEvent) {
+ // Move to next day without adding duplicate
+ currentDate.setDate(currentDate.getDate() + 1)
+ continue
+ }
+ // Create a copy of the event for this date
+ let dayEvent = Object.assign({}, eventTemplate)
+ // For multi-day events, adjust the display time for this specific day
+ if (currentDate.getTime() === startDate.getTime()) {
+ // First day - use original start time
+ dayEvent.start = new Date(startTime)
+ } else {
+ // Subsequent days - start at beginning of day for all-day events
+ dayEvent.start = new Date(currentDate)
+ if (!dayEvent.allDay)
+ dayEvent.start.setHours(0, 0, 0, 0)
+ }
+ if (currentDate.getTime() === endDate.getTime()) {
+ // Last day - use original end time
+ dayEvent.end = new Date(endTime)
+ } else {
+ // Earlier days - end at end of day for all-day events
+ dayEvent.end = new Date(currentDate)
+ if (!dayEvent.allDay)
+ dayEvent.end.setHours(23, 59, 59, 999)
+ }
+ newEventsByDate[dateKey].push(dayEvent)
+ // Move to next day
+ currentDate.setDate(currentDate.getDate() + 1)
+ }
+ }
+ }
+ // Sort events by start time within each date
+ for (let dateKey in newEventsByDate) {
+ newEventsByDate[dateKey].sort((a, b) => {
+ return a.start.getTime(
+ ) - b.start.getTime()
+ })
+ }
+ root.eventsByDate = newEventsByDate
+ root.lastError = ""
+ } catch (error) {
+ root.lastError = "Failed to parse events JSON: " + error.toString()
+ root.eventsByDate = {}
+ }
+ // Reset for next run
+ eventsProcess.rawOutput = ""
+ }
+
+ stdout: SplitParser {
+ splitMarker: "\n"
+ onRead: data => {
+ eventsProcess.rawOutput += data + "\n"
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/CavaService.qml b/raveos-hyprland-theme/theme-data/dms/Services/CavaService.qml
new file mode 100644
index 0000000..d3e5717
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/CavaService.qml
@@ -0,0 +1,76 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+Singleton {
+ id: root
+
+ property list<int> values: Array(6)
+ property int refCount: 0
+ property bool cavaAvailable: false
+
+ Process {
+ id: cavaCheck
+
+ command: ["which", "cava"]
+ running: false
+ onExited: exitCode => {
+ root.cavaAvailable = exitCode === 0 && Quickshell.env("DMS_DISABLE_CAVA") !== "1";
+ }
+ }
+
+ Component.onCompleted: {
+ cavaCheck.running = true;
+ }
+
+ Process {
+ id: cavaProcess
+
+ running: root.cavaAvailable && root.refCount > 0
+ command: ["sh", "-c", `cat <<'CAVACONF' | cava -p /dev/stdin
+[general]
+framerate=25
+bars=6
+autosens=0
+sensitivity=30
+lower_cutoff_freq=50
+higher_cutoff_freq=12000
+
+[output]
+method=raw
+raw_target=/dev/stdout
+data_format=ascii
+channels=mono
+mono_option=average
+
+[smoothing]
+noise_reduction=35
+integral=90
+gravity=95
+ignore=2
+monstercat=1.5
+CAVACONF`]
+
+ onRunningChanged: {
+ if (!running) {
+ root.values = Array(6).fill(0);
+ }
+ }
+
+ stdout: SplitParser {
+ splitMarker: "\n"
+ onRead: data => {
+ if (root.refCount > 0 && data.length > 0) {
+ const parts = data.split(";");
+ if (parts.length >= 6) {
+ const points = [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10), parseInt(parts[3], 10), parseInt(parts[4], 10), parseInt(parts[5], 10)];
+ root.values = points;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/ChangelogService.qml b/raveos-hyprland-theme/theme-data/dms/Services/ChangelogService.qml
new file mode 100644
index 0000000..562a513
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/ChangelogService.qml
@@ -0,0 +1,108 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtCore
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property string currentVersion: "1.4"
+ readonly property bool changelogEnabled: false
+
+ readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell"
+ readonly property string changelogMarkerPath: configDir + "/.changelog-" + currentVersion
+
+ property bool checkComplete: false
+ property bool changelogDismissed: false
+
+ readonly property bool shouldShowChangelog: {
+ if (!checkComplete)
+ return false;
+ if (!changelogEnabled)
+ return false;
+ if (changelogDismissed)
+ return false;
+ if (typeof FirstLaunchService !== "undefined" && FirstLaunchService.isFirstLaunch)
+ return false;
+ return true;
+ }
+
+ signal changelogRequested
+ signal changelogCompleted
+
+ Component.onCompleted: {
+ if (!changelogEnabled)
+ return;
+ if (FirstLaunchService.checkComplete)
+ handleFirstLaunchResult();
+ }
+
+ function handleFirstLaunchResult() {
+ if (FirstLaunchService.isFirstLaunch) {
+ checkComplete = true;
+ changelogDismissed = true;
+ touchMarkerProcess.running = true;
+ } else {
+ changelogCheckProcess.running = true;
+ }
+ }
+
+ Connections {
+ target: FirstLaunchService
+
+ function onCheckCompleteChanged() {
+ if (FirstLaunchService.checkComplete && root.changelogEnabled && !root.checkComplete)
+ root.handleFirstLaunchResult();
+ }
+ }
+
+ function showChangelog() {
+ changelogRequested();
+ }
+
+ function dismissChangelog() {
+ changelogDismissed = true;
+ touchMarkerProcess.running = true;
+ changelogCompleted();
+ }
+
+ Process {
+ id: changelogCheckProcess
+
+ command: ["sh", "-c", "[ -f '" + changelogMarkerPath + "' ] && echo 'seen' || echo 'show'"]
+ running: false
+
+ stdout: SplitParser {
+ onRead: data => {
+ const result = data.trim();
+ root.checkComplete = true;
+
+ switch (result) {
+ case "seen":
+ root.changelogDismissed = true;
+ break;
+ case "show":
+ root.changelogRequested();
+ break;
+ }
+ }
+ }
+ }
+
+ Process {
+ id: touchMarkerProcess
+
+ command: ["sh", "-c", "mkdir -p '" + configDir + "' && touch '" + changelogMarkerPath + "'"]
+ running: false
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ console.warn("ChangelogService: Failed to create changelog marker");
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/ClipboardService.qml b/raveos-hyprland-theme/theme-data/dms/Services/ClipboardService.qml
new file mode 100644
index 0000000..4c09255
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/ClipboardService.qml
@@ -0,0 +1,275 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property int longTextThreshold: 200
+
+ readonly property bool clipboardAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("clipboard"))
+ readonly property bool wtypeAvailable: SessionService.wtypeAvailable
+
+ property var internalEntries: []
+ property var clipboardEntries: []
+ property var unpinnedEntries: []
+ property var pinnedEntries: []
+ property int pinnedCount: 0
+ property int totalCount: 0
+ property string searchText: ""
+ property int selectedIndex: 0
+ property bool keyboardNavigationActive: false
+ property int refCount: 0
+
+ signal historyCopied
+ signal historyCleared
+
+ Process {
+ id: wtypeProcess
+ command: ["wtype", "-M", "ctrl", "-P", "v", "-p", "v", "-m", "ctrl"]
+ running: false
+ }
+
+ Timer {
+ id: pasteTimer
+ interval: 200
+ repeat: false
+ onTriggered: wtypeProcess.running = true
+ }
+
+ function updateFilteredModel() {
+ const query = searchText.trim();
+ let filtered = [];
+
+ if (query.length === 0) {
+ filtered = internalEntries;
+ } else {
+ const lowerQuery = query.toLowerCase();
+ filtered = internalEntries.filter(entry => entry.preview.toLowerCase().includes(lowerQuery));
+ }
+
+ filtered.sort((a, b) => {
+ if (a.pinned !== b.pinned)
+ return b.pinned ? 1 : -1;
+ return b.id - a.id;
+ });
+
+ clipboardEntries = filtered;
+ unpinnedEntries = filtered.filter(e => !e.pinned);
+ totalCount = clipboardEntries.length;
+
+ if (unpinnedEntries.length === 0) {
+ keyboardNavigationActive = false;
+ selectedIndex = 0;
+ return;
+ }
+ if (selectedIndex >= unpinnedEntries.length) {
+ selectedIndex = unpinnedEntries.length - 1;
+ }
+ }
+
+ function refresh() {
+ if (!clipboardAvailable) {
+ return;
+ }
+ DMSService.sendRequest("clipboard.getHistory", null, function (response) {
+ if (response.error) {
+ console.warn("ClipboardService: Failed to get history:", response.error);
+ return;
+ }
+ internalEntries = response.result || [];
+ pinnedEntries = internalEntries.filter(e => e.pinned);
+ pinnedCount = pinnedEntries.length;
+ updateFilteredModel();
+ });
+ }
+
+ function reset() {
+ searchText = "";
+ selectedIndex = 0;
+ keyboardNavigationActive = false;
+ internalEntries = [];
+ clipboardEntries = [];
+ unpinnedEntries = [];
+ }
+
+ function copyEntry(entry, closeCallback) {
+ DMSService.sendRequest("clipboard.copyEntry", {
+ "id": entry.id
+ }, function (response) {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to copy entry"));
+ return;
+ }
+ ToastService.showInfo(entry.isImage ? I18n.tr("Image copied to clipboard") : I18n.tr("Copied to clipboard"));
+ historyCopied();
+ if (closeCallback) {
+ closeCallback();
+ }
+ });
+ }
+
+ function pasteEntry(entry, closeCallback) {
+ if (!wtypeAvailable) {
+ ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
+ return;
+ }
+ DMSService.sendRequest("clipboard.copyEntry", {
+ "id": entry.id
+ }, function (response) {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to copy entry"));
+ return;
+ }
+ if (closeCallback) {
+ closeCallback();
+ }
+ pasteTimer.start();
+ });
+ }
+
+ function pasteSelected(closeCallback) {
+ if (!keyboardNavigationActive || clipboardEntries.length === 0 || selectedIndex < 0 || selectedIndex >= clipboardEntries.length) {
+ return;
+ }
+ pasteEntry(clipboardEntries[selectedIndex], closeCallback);
+ }
+
+ function deleteEntry(entry) {
+ DMSService.sendRequest("clipboard.deleteEntry", {
+ "id": entry.id
+ }, function (response) {
+ if (response.error) {
+ console.warn("ClipboardService: Failed to delete entry:", response.error);
+ return;
+ }
+ internalEntries = internalEntries.filter(e => e.id !== entry.id);
+ updateFilteredModel();
+ if (clipboardEntries.length === 0) {
+ keyboardNavigationActive = false;
+ selectedIndex = 0;
+ return;
+ }
+ if (selectedIndex >= clipboardEntries.length) {
+ selectedIndex = clipboardEntries.length - 1;
+ }
+ });
+ }
+
+ function deletePinnedEntry(entry, confirmDialog) {
+ if (!confirmDialog) {
+ return;
+ }
+ confirmDialog.show(I18n.tr("Delete Saved Item?"), I18n.tr("This will permanently remove this saved clipboard item. This action cannot be undone."), function () {
+ DMSService.sendRequest("clipboard.deleteEntry", {
+ "id": entry.id
+ }, function (response) {
+ if (response.error) {
+ console.warn("ClipboardService: Failed to delete entry:", response.error);
+ return;
+ }
+ internalEntries = internalEntries.filter(e => e.id !== entry.id);
+ updateFilteredModel();
+ ToastService.showInfo(I18n.tr("Saved item deleted"));
+ });
+ }, function () {});
+ }
+
+ function pinEntry(entry) {
+ DMSService.sendRequest("clipboard.getPinnedCount", null, function (countResponse) {
+ if (countResponse.error) {
+ ToastService.showError(I18n.tr("Failed to check pin limit"));
+ return;
+ }
+
+ const maxPinned = 25;
+ if (countResponse.result.count >= maxPinned) {
+ ToastService.showError(I18n.tr("Maximum pinned entries reached") + " (" + maxPinned + ")");
+ return;
+ }
+
+ DMSService.sendRequest("clipboard.pinEntry", {
+ "id": entry.id
+ }, function (response) {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to pin entry"));
+ return;
+ }
+ ToastService.showInfo(I18n.tr("Entry pinned"));
+ refresh();
+ });
+ });
+ }
+
+ function unpinEntry(entry) {
+ DMSService.sendRequest("clipboard.unpinEntry", {
+ "id": entry.id
+ }, function (response) {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to unpin entry"));
+ return;
+ }
+ ToastService.showInfo(I18n.tr("Entry unpinned"));
+ refresh();
+ });
+ }
+
+ function clearAll() {
+ const hasPinned = pinnedCount > 0;
+ const savedCount = pinnedCount;
+ DMSService.sendRequest("clipboard.clearHistory", null, function (response) {
+ if (response.error) {
+ console.warn("ClipboardService: Failed to clear history:", response.error);
+ return;
+ }
+ refresh();
+ historyCleared();
+ if (hasPinned) {
+ ToastService.showInfo(I18n.tr("History cleared. %1 pinned entries kept.").arg(savedCount));
+ }
+ });
+ }
+
+ function getEntryPreview(entry) {
+ return entry.preview || "";
+ }
+
+ function getEntryType(entry) {
+ if (entry.isImage) {
+ return "image";
+ }
+ if (entry.size > longTextThreshold) {
+ return "long_text";
+ }
+ return "text";
+ }
+
+ function hashedPinnedEntry(entryHash) {
+ if (!entryHash) {
+ return false;
+ }
+ return pinnedEntries.some(pinnedEntry => pinnedEntry.hash === entryHash);
+ }
+
+ onClipboardAvailableChanged: {
+ if (!clipboardAvailable || refCount <= 0)
+ return;
+ refresh();
+ }
+
+ Connections {
+ target: DMSService
+ enabled: root.refCount > 0
+ function onClipboardStateUpdate(data) {
+ const newHistory = data.history || [];
+ internalEntries = newHistory;
+ pinnedEntries = newHistory.filter(e => e.pinned);
+ pinnedCount = pinnedEntries.length;
+ updateFilteredModel();
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/CompositorService.qml b/raveos-hyprland-theme/theme-data/dms/Services/CompositorService.qml
new file mode 100644
index 0000000..d327ea2
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/CompositorService.qml
@@ -0,0 +1,677 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.I3
+import Quickshell.Wayland
+import Quickshell.Hyprland
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool isHyprland: false
+ property bool isNiri: false
+ property bool isDwl: false
+ property bool isSway: false
+ property bool isScroll: false
+ property bool isMiracle: false
+ property bool isLabwc: false
+ property string compositor: "unknown"
+ readonly property bool useHyprlandFocusGrab: isHyprland && Quickshell.env("DMS_HYPRLAND_EXCLUSIVE_FOCUS") !== "1"
+
+ readonly property string hyprlandSignature: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE")
+ readonly property string niriSocket: Quickshell.env("NIRI_SOCKET")
+ readonly property string swaySocket: Quickshell.env("SWAYSOCK")
+ readonly property string scrollSocket: Quickshell.env("SWAYSOCK")
+ readonly property string miracleSocket: Quickshell.env("MIRACLESOCK")
+ readonly property string labwcPid: Quickshell.env("LABWC_PID")
+ property bool useNiriSorting: isNiri && NiriService
+
+ property var randrScales: ({})
+ property bool randrReady: false
+ signal randrDataReady
+
+ property var sortedToplevels: []
+ property bool _sortScheduled: false
+
+ signal toplevelsChanged
+
+ function fetchRandrData() {
+ Proc.runCommand("randr", ["dms", "randr", "--json"], (output, exitCode) => {
+ if (exitCode === 0 && output) {
+ try {
+ const data = JSON.parse(output.trim());
+ if (data.outputs && Array.isArray(data.outputs)) {
+ const scales = {};
+ for (const out of data.outputs) {
+ if (out.name && out.scale > 0)
+ scales[out.name] = out.scale;
+ }
+ randrScales = scales;
+ }
+ } catch (e) {
+ console.warn("CompositorService: failed to parse randr data:", e);
+ }
+ }
+ randrReady = true;
+ randrDataReady();
+ }, 0, 3000);
+ }
+
+ function getScreenScale(screen) {
+ if (!screen)
+ return 1;
+
+ if (Quickshell.env("QT_WAYLAND_FORCE_DPI") || Quickshell.env("QT_SCALE_FACTOR")) {
+ return screen.devicePixelRatio || 1;
+ }
+
+ const randrScale = randrScales[screen.name];
+ if (randrScale !== undefined && randrScale > 0)
+ return Math.round(randrScale * 20) / 20;
+
+ if (WlrOutputService.wlrOutputAvailable && screen) {
+ const wlrOutput = WlrOutputService.getOutput(screen.name);
+ if (wlrOutput?.enabled && wlrOutput.scale !== undefined && wlrOutput.scale > 0) {
+ return Math.round(wlrOutput.scale * 20) / 20;
+ }
+ }
+
+ if (isNiri && screen) {
+ const niriScale = NiriService.displayScales[screen.name];
+ if (niriScale !== undefined)
+ return niriScale;
+ }
+
+ if (isHyprland && screen) {
+ const hyprlandMonitor = Hyprland.monitors.values.find(m => m.name === screen.name);
+ if (hyprlandMonitor?.scale !== undefined)
+ return hyprlandMonitor.scale;
+ }
+
+ if (isDwl && screen) {
+ const dwlScale = DwlService.getOutputScale(screen.name);
+ if (dwlScale !== undefined && dwlScale > 0)
+ return dwlScale;
+ }
+
+ return screen?.devicePixelRatio || 1;
+ }
+
+ function getFocusedScreen() {
+ let screenName = "";
+ if (isHyprland && Hyprland.focusedWorkspace?.monitor)
+ screenName = Hyprland.focusedWorkspace.monitor.name;
+ else if (isNiri && NiriService.currentOutput)
+ screenName = NiriService.currentOutput;
+ else if (isSway || isScroll || isMiracle) {
+ const focusedWs = I3.workspaces?.values?.find(ws => ws.focused === true);
+ screenName = focusedWs?.monitor?.name || "";
+ } else if (isDwl && DwlService.activeOutput)
+ screenName = DwlService.activeOutput;
+
+ if (!screenName)
+ return Quickshell.screens.length > 0 ? Quickshell.screens[0] : null;
+
+ for (let i = 0; i < Quickshell.screens.length; i++) {
+ if (Quickshell.screens[i].name === screenName)
+ return Quickshell.screens[i];
+ }
+ return Quickshell.screens.length > 0 ? Quickshell.screens[0] : null;
+ }
+
+ Timer {
+ id: sortDebounceTimer
+ interval: 100
+ repeat: false
+ onTriggered: {
+ _sortScheduled = false;
+ sortedToplevels = computeSortedToplevels();
+ toplevelsChanged();
+ }
+ }
+
+ function scheduleSort() {
+ if (_sortScheduled)
+ return;
+ _sortScheduled = true;
+ sortDebounceTimer.restart();
+ }
+
+ Connections {
+ target: ToplevelManager.toplevels
+ function onValuesChanged() {
+ root.scheduleSort();
+ }
+ }
+ Connections {
+ target: isHyprland ? Hyprland : null
+ enabled: isHyprland
+
+ function onRawEvent(event) {
+ if (event.name === "openwindow" || event.name === "closewindow" || event.name === "movewindow" || event.name === "movewindowv2" || event.name === "workspace" || event.name === "workspacev2" || event.name === "focusedmon" || event.name === "focusedmonv2" || event.name === "activewindow" || event.name === "activewindowv2" || event.name === "changefloatingmode" || event.name === "fullscreen" || event.name === "moveintogroup" || event.name === "moveoutofgroup") {
+ try {
+ Hyprland.refreshToplevels();
+ } catch (e) {}
+ root.scheduleSort();
+ }
+ }
+ }
+ Connections {
+ target: NiriService
+ function onWindowsChanged() {
+ root.scheduleSort();
+ }
+ }
+
+ Component.onCompleted: {
+ fetchRandrData();
+ detectCompositor();
+ scheduleSort();
+ Qt.callLater(() => {
+ NiriService.generateNiriLayoutConfig();
+ HyprlandService.generateLayoutConfig();
+ DwlService.generateLayoutConfig();
+ });
+ }
+
+ Connections {
+ target: DwlService
+ function onStateChanged() {
+ if (isDwl && !isHyprland && !isNiri) {
+ scheduleSort();
+ }
+ }
+ }
+
+ function computeSortedToplevels() {
+ if (!ToplevelManager.toplevels || !ToplevelManager.toplevels.values)
+ return [];
+
+ if (useNiriSorting)
+ return NiriService.sortToplevels(ToplevelManager.toplevels.values);
+
+ if (isHyprland)
+ return sortHyprlandToplevelsSafe();
+
+ return Array.from(ToplevelManager.toplevels.values);
+ }
+
+ function _get(o, path, fallback) {
+ try {
+ let v = o;
+ for (let i = 0; i < path.length; i++) {
+ if (v === null || v === undefined)
+ return fallback;
+ v = v[path[i]];
+ }
+ return (v === undefined || v === null) ? fallback : v;
+ } catch (e) {
+ return fallback;
+ }
+ }
+
+ function sortHyprlandToplevelsSafe() {
+ if (!Hyprland.toplevels || !Hyprland.toplevels.values)
+ return [];
+
+ const items = Array.from(Hyprland.toplevels.values);
+
+ function _get(o, path, fb) {
+ try {
+ let v = o;
+ for (let k of path) {
+ if (v == null)
+ return fb;
+ v = v[k];
+ }
+ return (v == null) ? fb : v;
+ } catch (e) {
+ return fb;
+ }
+ }
+
+ let snap = [];
+ for (let i = 0; i < items.length; i++) {
+ const t = items[i];
+ if (!t)
+ continue;
+ const addr = t.address || "";
+ if (!addr)
+ continue;
+ const li = t.lastIpcObject || null;
+
+ const monName = _get(li, ["monitor"], null) ?? _get(t, ["monitor", "name"], "");
+ const monX = _get(t, ["monitor", "x"], Number.MAX_SAFE_INTEGER);
+ const monY = _get(t, ["monitor", "y"], Number.MAX_SAFE_INTEGER);
+
+ const wsId = _get(li, ["workspace", "id"], null) ?? _get(t, ["workspace", "id"], Number.MAX_SAFE_INTEGER);
+
+ const at = _get(li, ["at"], null);
+ let atX = (at !== null && at !== undefined && typeof at[0] === "number") ? at[0] : 1e9;
+ let atY = (at !== null && at !== undefined && typeof at[1] === "number") ? at[1] : 1e9;
+
+ const relX = Number.isFinite(monX) ? (atX - monX) : atX;
+ const relY = Number.isFinite(monY) ? (atY - monY) : atY;
+
+ snap.push({
+ monKey: String(monName),
+ monOrderX: Number.isFinite(monX) ? monX : Number.MAX_SAFE_INTEGER,
+ monOrderY: Number.isFinite(monY) ? monY : Number.MAX_SAFE_INTEGER,
+ wsId: (typeof wsId === "number") ? wsId : Number.MAX_SAFE_INTEGER,
+ x: relX,
+ y: relY,
+ title: t.title || "",
+ address: addr,
+ wayland: t.wayland
+ });
+ }
+
+ const groups = new Map();
+ for (const it of snap) {
+ const key = it.monKey + "::" + it.wsId;
+ if (!groups.has(key))
+ groups.set(key, []);
+ groups.get(key).push(it);
+ }
+
+ let groupList = [];
+ for (const [key, arr] of groups) {
+ const repr = arr[0];
+ groupList.push({
+ key,
+ monKey: repr.monKey,
+ monOrderX: repr.monOrderX,
+ monOrderY: repr.monOrderY,
+ wsId: repr.wsId,
+ items: arr
+ });
+ }
+
+ groupList.sort((a, b) => {
+ if (a.monOrderX !== b.monOrderX)
+ return a.monOrderX - b.monOrderX;
+ if (a.monOrderY !== b.monOrderY)
+ return a.monOrderY - b.monOrderY;
+ if (a.monKey !== b.monKey)
+ return a.monKey.localeCompare(b.monKey);
+ if (a.wsId !== b.wsId)
+ return a.wsId - b.wsId;
+ return 0;
+ });
+
+ const COLUMN_THRESHOLD = 48;
+ const JITTER_Y = 6;
+
+ let ordered = [];
+ for (const g of groupList) {
+ const arr = g.items;
+
+ const xs = arr.map(it => it.x).filter(x => Number.isFinite(x)).sort((a, b) => a - b);
+ let colCenters = [];
+ if (xs.length > 0) {
+ for (const x of xs) {
+ if (colCenters.length === 0) {
+ colCenters.push(x);
+ } else {
+ const last = colCenters[colCenters.length - 1];
+ if (x - last >= COLUMN_THRESHOLD) {
+ colCenters.push(x);
+ }
+ }
+ }
+ } else {
+ colCenters = [0];
+ }
+
+ for (const it of arr) {
+ let bestCol = 0;
+ let bestDist = Number.POSITIVE_INFINITY;
+ for (let ci = 0; ci < colCenters.length; ci++) {
+ const d = Math.abs(it.x - colCenters[ci]);
+ if (d < bestDist) {
+ bestDist = d;
+ bestCol = ci;
+ }
+ }
+ it._col = bestCol;
+ }
+
+ arr.sort((a, b) => {
+ if (a._col !== b._col)
+ return a._col - b._col;
+
+ const dy = a.y - b.y;
+ if (Math.abs(dy) > JITTER_Y)
+ return dy;
+
+ if (a.title !== b.title)
+ return a.title.localeCompare(b.title);
+ if (a.address !== b.address)
+ return a.address.localeCompare(b.address);
+ return 0;
+ });
+
+ ordered.push.apply(ordered, arr);
+ }
+
+ return ordered.map(x => x.wayland).filter(w => w !== null && w !== undefined);
+ }
+
+ function filterCurrentWorkspace(toplevels, screen) {
+ if (useNiriSorting)
+ return NiriService.filterCurrentWorkspace(toplevels, screen);
+ if (isHyprland)
+ return filterHyprlandCurrentWorkspaceSafe(toplevels, screen);
+ return toplevels;
+ }
+
+ function filterCurrentDisplay(toplevels, screenName) {
+ if (!toplevels || toplevels.length === 0 || !screenName)
+ return toplevels;
+ if (useNiriSorting)
+ return NiriService.filterCurrentDisplay(toplevels, screenName);
+ if (isHyprland)
+ return filterHyprlandCurrentDisplaySafe(toplevels, screenName);
+ return toplevels;
+ }
+
+ function filterHyprlandCurrentDisplaySafe(toplevels, screenName) {
+ if (!toplevels || toplevels.length === 0 || !Hyprland.toplevels)
+ return toplevels;
+
+ let monitorWindows = new Set();
+ try {
+ const hy = Array.from(Hyprland.toplevels.values);
+ for (const t of hy) {
+ const mon = _get(t, ["monitor", "name"], "");
+ if (mon === screenName && t.wayland)
+ monitorWindows.add(t.wayland);
+ }
+ } catch (e) {}
+
+ return toplevels.filter(w => monitorWindows.has(w));
+ }
+
+ function filterHyprlandCurrentWorkspaceSafe(toplevels, screenName) {
+ if (!toplevels || toplevels.length === 0 || !Hyprland.toplevels)
+ return toplevels;
+
+ let currentWorkspaceId = null;
+ try {
+ if (Hyprland.monitors) {
+ const monitor = Hyprland.monitors.values.find(m => m.name === screenName);
+ if (monitor)
+ currentWorkspaceId = _get(monitor, ["activeWorkspace", "id"], null);
+ }
+
+ if (currentWorkspaceId === null) {
+ const hy = Array.from(Hyprland.toplevels.values);
+ for (const t of hy) {
+ const mon = _get(t, ["monitor", "name"], "");
+ const wsId = _get(t, ["workspace", "id"], null);
+ const active = !!_get(t, ["activated"], false);
+ if (mon === screenName && wsId !== null) {
+ if (active) {
+ currentWorkspaceId = wsId;
+ break;
+ }
+ if (currentWorkspaceId === null)
+ currentWorkspaceId = wsId;
+ }
+ }
+ }
+
+ if (currentWorkspaceId === null && Hyprland.workspaces) {
+ const wss = Array.from(Hyprland.workspaces.values);
+ const focusedId = _get(Hyprland, ["focusedWorkspace", "id"], null);
+ for (const ws of wss) {
+ const monName = _get(ws, ["monitor", "name"], "");
+ const wsId = _get(ws, ["id"], null);
+ if (monName === screenName && wsId !== null) {
+ if (focusedId !== null && wsId === focusedId) {
+ currentWorkspaceId = wsId;
+ break;
+ }
+ if (currentWorkspaceId === null)
+ currentWorkspaceId = wsId;
+ }
+ }
+ }
+ } catch (e) {
+ console.warn("CompositorService: workspace snapshot failed:", e);
+ }
+
+ if (currentWorkspaceId === null)
+ return toplevels;
+
+ let map = new Map();
+ try {
+ const hy = Array.from(Hyprland.toplevels.values);
+ for (const t of hy) {
+ const wsId = _get(t, ["workspace", "id"], null);
+ if (t && t.wayland && wsId !== null)
+ map.set(t.wayland, wsId);
+ }
+ } catch (e) {}
+
+ return toplevels.filter(w => map.get(w) === currentWorkspaceId);
+ }
+
+ Timer {
+ id: compositorInitTimer
+ interval: 100
+ running: true
+ repeat: false
+ onTriggered: {
+ detectCompositor();
+ Qt.callLater(() => {
+ NiriService.generateNiriLayoutConfig();
+ HyprlandService.generateLayoutConfig();
+ DwlService.generateLayoutConfig();
+ });
+ }
+ }
+
+ function detectCompositor() {
+ if (hyprlandSignature && hyprlandSignature.length > 0 && !niriSocket && !swaySocket && !scrollSocket && !miracleSocket && !labwcPid) {
+ isHyprland = true;
+ isNiri = false;
+ isDwl = false;
+ isSway = false;
+ isScroll = false;
+ isMiracle = false;
+ isLabwc = false;
+ compositor = "hyprland";
+ console.info("CompositorService: Detected Hyprland");
+ return;
+ }
+
+ if (niriSocket && niriSocket.length > 0) {
+ Proc.runCommand("niriSocketCheck", ["test", "-S", niriSocket], (output, exitCode) => {
+ if (exitCode === 0) {
+ isNiri = true;
+ isHyprland = false;
+ isDwl = false;
+ isSway = false;
+ isScroll = false;
+ isMiracle = false;
+ isLabwc = false;
+ compositor = "niri";
+ console.info("CompositorService: Detected Niri with socket:", niriSocket);
+ NiriService.generateNiriBlurrule();
+ }
+ }, 0);
+ return;
+ }
+
+ if (swaySocket && swaySocket.length > 0 && !scrollSocket && scrollSocket.length == 0 && !miracleSocket) {
+ Proc.runCommand("swaySocketCheck", ["test", "-S", swaySocket], (output, exitCode) => {
+ if (exitCode === 0) {
+ isNiri = false;
+ isHyprland = false;
+ isDwl = false;
+ isSway = true;
+ isScroll = false;
+ isMiracle = false;
+ isLabwc = false;
+ compositor = "sway";
+ console.info("CompositorService: Detected Sway with socket:", swaySocket);
+ }
+ }, 0);
+ return;
+ }
+
+ if (miracleSocket && miracleSocket.length > 0) {
+ Proc.runCommand("miracleSocketCheck", ["test", "-S", miracleSocket], (output, exitCode) => {
+ if (exitCode === 0) {
+ isNiri = false;
+ isHyprland = false;
+ isDwl = false;
+ isSway = false;
+ isScroll = false;
+ isMiracle = true;
+ isLabwc = false;
+ compositor = "miracle";
+ console.info("CompositorService: Detected Miracle WM with socket:", miracleSocket);
+ }
+ }, 0);
+ return;
+ }
+
+ if (scrollSocket && scrollSocket.length > 0 && !miracleSocket) {
+ Proc.runCommand("scrollSocketCheck", ["test", "-S", scrollSocket], (output, exitCode) => {
+ if (exitCode === 0) {
+ isNiri = false;
+ isHyprland = false;
+ isDwl = false;
+ isSway = false;
+ isScroll = true;
+ isMiracle = false;
+ isLabwc = false;
+ compositor = "scroll";
+ console.info("CompositorService: Detected Scroll with socket:", scrollSocket);
+ }
+ }, 0);
+ return;
+ }
+
+ if (labwcPid && labwcPid.length > 0) {
+ isHyprland = false;
+ isNiri = false;
+ isDwl = false;
+ isSway = false;
+ isScroll = false;
+ isMiracle = false;
+ isLabwc = true;
+ compositor = "labwc";
+ console.info("CompositorService: Detected LabWC with PID:", labwcPid);
+ return;
+ }
+
+ if (DMSService.dmsAvailable) {
+ Qt.callLater(checkForDwl);
+ } else {
+ isHyprland = false;
+ isNiri = false;
+ isDwl = false;
+ isSway = false;
+ isScroll = false;
+ isMiracle = false;
+ isLabwc = false;
+ compositor = "unknown";
+ console.warn("CompositorService: No compositor detected");
+ }
+ }
+
+ Connections {
+ target: DMSService
+ function onCapabilitiesReceived() {
+ if (!isHyprland && !isNiri && !isDwl && !isLabwc) {
+ checkForDwl();
+ }
+ }
+ }
+
+ function checkForDwl() {
+ if (DMSService.apiVersion >= 12 && DMSService.capabilities.includes("dwl")) {
+ isHyprland = false;
+ isNiri = false;
+ isDwl = true;
+ isSway = false;
+ isScroll = false;
+ isMiracle = false;
+ isLabwc = false;
+ compositor = "dwl";
+ console.info("CompositorService: Detected DWL via DMS capability");
+ }
+ }
+
+ function powerOffMonitors() {
+ if (isNiri)
+ return NiriService.powerOffMonitors();
+ if (isHyprland)
+ return Hyprland.dispatch("dpms off");
+ if (isDwl)
+ return _dwlPowerOffMonitors();
+ if (isSway || isScroll || isMiracle) {
+ try {
+ I3.dispatch("output * dpms off");
+ } catch (_) {}
+ return;
+ }
+ if (isLabwc) {
+ Quickshell.execDetached(["dms", "dpms", "off"]);
+ }
+ console.warn("CompositorService: Cannot power off monitors, unknown compositor");
+ }
+
+ function powerOnMonitors() {
+ if (isNiri)
+ return NiriService.powerOnMonitors();
+ if (isHyprland)
+ return Hyprland.dispatch("dpms on");
+ if (isDwl)
+ return _dwlPowerOnMonitors();
+ if (isSway || isScroll || isMiracle) {
+ try {
+ I3.dispatch("output * dpms on");
+ } catch (_) {}
+ return;
+ }
+ if (isLabwc) {
+ Quickshell.execDetached(["dms", "dpms", "on"]);
+ }
+ console.warn("CompositorService: Cannot power on monitors, unknown compositor");
+ }
+
+ function _dwlPowerOffMonitors() {
+ if (!Quickshell.screens || Quickshell.screens.length === 0) {
+ console.warn("CompositorService: No screens available for DWL power off");
+ return;
+ }
+
+ for (let i = 0; i < Quickshell.screens.length; i++) {
+ const screen = Quickshell.screens[i];
+ if (screen && screen.name) {
+ Quickshell.execDetached(["mmsg", "-d", "disable_monitor," + screen.name]);
+ }
+ }
+ }
+
+ function _dwlPowerOnMonitors() {
+ if (!Quickshell.screens || Quickshell.screens.length === 0) {
+ console.warn("CompositorService: No screens available for DWL power on");
+ return;
+ }
+
+ for (let i = 0; i < Quickshell.screens.length; i++) {
+ const screen = Quickshell.screens[i];
+ if (screen && screen.name) {
+ Quickshell.execDetached(["mmsg", "-d", "enable_monitor," + screen.name]);
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/CupsService.qml b/raveos-hyprland-theme/theme-data/dms/Services/CupsService.qml
new file mode 100644
index 0000000..a88655d
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/CupsService.qml
@@ -0,0 +1,860 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import qs.Common
+
+Singleton {
+ id: root
+
+ property int refCount: 0
+
+ onRefCountChanged: {
+ if (refCount > 0) {
+ ensureSubscription();
+ } else if (refCount === 0 && DMSService.activeSubscriptions.includes("cups")) {
+ DMSService.removeSubscription("cups");
+ }
+ }
+
+ function ensureSubscription() {
+ if (refCount <= 0)
+ return;
+ if (!DMSService.isConnected)
+ return;
+ if (DMSService.activeSubscriptions.includes("cups"))
+ return;
+ if (DMSService.activeSubscriptions.includes("all"))
+ return;
+ DMSService.addSubscription("cups");
+ if (cupsAvailable) {
+ getState();
+ }
+ }
+
+ property var printerNames: []
+ property var printers: []
+ property string selectedPrinter: ""
+ property string expandedPrinter: ""
+
+ property bool cupsAvailable: false
+ property bool stateInitialized: false
+
+ property var devices: []
+ property var ppds: []
+ property var printerClasses: []
+
+ readonly property var filteredDevices: {
+ if (!devices || devices.length === 0)
+ return [];
+ const bareProtocols = ["ipp", "ipps", "http", "https", "lpd", "socket", "beh", "dnssd", "mdns", "smb", "file", "cups-brf"];
+
+ // First pass: filter out invalid/bare protocol entries
+ const validDevices = devices.filter(d => {
+ if (!d.uri)
+ return false;
+ const uriLower = d.uri.toLowerCase();
+ for (let proto of bareProtocols) {
+ if (uriLower === proto || uriLower === proto + ":")
+ return false;
+ }
+ if (d.class === "network" && d.info === "Backend Error Handler")
+ return false;
+ return true;
+ });
+
+ // Second pass: prefer IPP over LPD for the same printer
+ // _printer._tcp (LPD) doesn't work well with driverless printing
+ // _ipp._tcp or _ipps._tcp (IPP) should be preferred
+ const ippDeviceHosts = new Set();
+ for (const d of validDevices) {
+ if (!d.uri)
+ continue;
+ // Extract hostname from dnssd URIs like dnssd://Name%20[mac]._ipp._tcp.local
+ const ippMatch = d.uri.match(/dnssd:\/\/[^/]*\._ipps?\._tcp/);
+ if (ippMatch) {
+ // Extract the unique identifier (usually MAC address in brackets)
+ const macMatch = d.uri.match(/\[([a-f0-9]+)\]/i);
+ if (macMatch)
+ ippDeviceHosts.add(macMatch[1].toLowerCase());
+ }
+ }
+
+ // Filter out _printer._tcp devices when we have _ipp._tcp for the same printer
+ return validDevices.filter(d => {
+ if (!d.uri)
+ return true;
+ // If this is an LPD device, check if we have an IPP alternative
+ if (d.uri.includes("._printer._tcp")) {
+ const macMatch = d.uri.match(/\[([a-f0-9]+)\]/i);
+ if (macMatch && ippDeviceHosts.has(macMatch[1].toLowerCase())) {
+ return false; // Skip LPD device, we have IPP
+ }
+ }
+ return true;
+ });
+ }
+
+ function decodeUri(str) {
+ if (!str)
+ return "";
+ try {
+ return decodeURIComponent(str.replace(/\+/g, " "));
+ } catch (e) {
+ return str;
+ }
+ }
+
+ function getDeviceDisplayName(device) {
+ if (!device)
+ return "";
+ let name = "";
+ if (device.info && device.info.length > 0) {
+ name = decodeUri(device.info);
+ } else if (device.makeModel && device.makeModel.length > 0) {
+ name = decodeUri(device.makeModel);
+ } else {
+ return decodeUri(device.uri);
+ }
+ if (device.ip)
+ return name + " (" + device.ip + ")";
+ return name;
+ }
+
+ function getDeviceSubtitle(device) {
+ if (!device)
+ return "";
+ const parts = [];
+ switch (device.class) {
+ case "direct":
+ parts.push(I18n.tr("Local"));
+ break;
+ case "network":
+ parts.push(I18n.tr("Network"));
+ break;
+ case "file":
+ parts.push(I18n.tr("File"));
+ break;
+ default:
+ if (device.class)
+ parts.push(device.class);
+ }
+ if (device.location)
+ parts.push(decodeUri(device.location));
+ return parts.join(" • ");
+ }
+
+ function suggestPrinterName(device) {
+ if (!device)
+ return "";
+ let name = device.info || device.makeModel || "";
+ name = name.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
+ return name.substring(0, 32) || "Printer";
+ }
+
+ function getMatchingPPDs(device) {
+ if (!device || !ppds || ppds.length === 0)
+ return [];
+ const isDnssd = device.uri && (device.uri.startsWith("dnssd://") || device.uri.startsWith("ipp://") || device.uri.startsWith("ipps://"));
+ if (isDnssd) {
+ const driverless = ppds.filter(p => p.name === "driverless" || p.name === "everywhere" || (p.makeModel && p.makeModel.toLowerCase().includes("driverless")));
+ if (driverless.length > 0)
+ return driverless;
+ }
+ if (!device.makeModel)
+ return [];
+ const makeModelLower = device.makeModel.toLowerCase();
+ const words = makeModelLower.split(/[\s_-]+/).filter(w => w.length > 2);
+ return ppds.filter(p => {
+ if (!p.makeModel)
+ return false;
+ const ppdLower = p.makeModel.toLowerCase();
+ return words.some(w => ppdLower.includes(w));
+ }).slice(0, 10);
+ }
+
+ property bool loadingDevices: false
+ property bool loadingPPDs: false
+ property bool loadingClasses: false
+ property bool creatingPrinter: false
+
+ signal cupsStateUpdate
+
+ readonly property string socketPath: Quickshell.env("DMS_SOCKET")
+
+ Component.onCompleted: {
+ if (socketPath && socketPath.length > 0) {
+ checkDMSCapabilities();
+ }
+ }
+
+ Connections {
+ target: DMSService
+
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkDMSCapabilities();
+ ensureSubscription();
+ }
+ }
+ }
+
+ Connections {
+ target: DMSService
+ enabled: DMSService.isConnected
+
+ function onCupsStateUpdate(data) {
+ console.log("CupsService: Subscription update received");
+ getState();
+ }
+
+ function onCapabilitiesChanged() {
+ checkDMSCapabilities();
+ }
+ }
+
+ function checkDMSCapabilities() {
+ if (!DMSService.isConnected)
+ return;
+ if (DMSService.capabilities.length === 0)
+ return;
+ cupsAvailable = DMSService.capabilities.includes("cups");
+
+ if (cupsAvailable && !stateInitialized) {
+ stateInitialized = true;
+ getState();
+ }
+ }
+
+ function getState() {
+ if (!cupsAvailable)
+ return;
+ DMSService.sendRequest("cups.getPrinters", null, response => {
+ if (response.result) {
+ updatePrinters(response.result);
+ fetchAllJobs();
+ }
+ });
+ }
+
+ function updatePrinters(printersData) {
+ printerNames = printersData.map(p => p.name);
+
+ let printersObj = {};
+ for (var i = 0; i < printersData.length; i++) {
+ let printer = printersData[i];
+ printersObj[printer.name] = {
+ "name": printer.name,
+ "uri": printer.uri || "",
+ "state": printer.state,
+ "stateReason": printer.stateReason,
+ "location": printer.location || "",
+ "info": printer.info || "",
+ "makeModel": printer.makeModel || "",
+ "accepting": printer.accepting !== false,
+ "jobs": []
+ };
+ }
+ printers = printersObj;
+
+ if (printerNames.length > 0) {
+ if (selectedPrinter.length > 0) {
+ if (!printerNames.includes(selectedPrinter)) {
+ selectedPrinter = printerNames[0];
+ }
+ } else {
+ selectedPrinter = printerNames[0];
+ }
+ }
+ }
+
+ function fetchAllJobs() {
+ for (var i = 0; i < printerNames.length; i++) {
+ fetchJobsForPrinter(printerNames[i]);
+ }
+ }
+
+ function fetchJobsForPrinter(printerName) {
+ const params = {
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.getJobs", params, response => {
+ if (response.result && printers[printerName]) {
+ let updatedPrinters = Object.assign({}, printers);
+ updatedPrinters[printerName].jobs = response.result;
+ printers = updatedPrinters;
+ }
+ });
+ }
+
+ function getSelectedPrinter() {
+ return selectedPrinter;
+ }
+
+ function setSelectedPrinter(printerName) {
+ if (printerNames.length > 0) {
+ if (printerNames.includes(printerName)) {
+ selectedPrinter = printerName;
+ } else {
+ selectedPrinter = printerNames[0];
+ }
+ }
+ }
+
+ function getPrintersNum() {
+ if (!cupsAvailable)
+ return 0;
+
+ return printerNames.length;
+ }
+
+ function getPrintersNames() {
+ if (!cupsAvailable)
+ return [];
+
+ return printerNames;
+ }
+
+ function getTotalJobsNum() {
+ if (!cupsAvailable)
+ return 0;
+
+ var result = 0;
+ for (var i = 0; i < printerNames.length; i++) {
+ var printerName = printerNames[i];
+ if (printers[printerName] && printers[printerName].jobs) {
+ result += printers[printerName].jobs.length;
+ }
+ }
+ return result;
+ }
+
+ function getCurrentPrinterState() {
+ if (!cupsAvailable || !selectedPrinter)
+ return "";
+
+ var printer = printers[selectedPrinter];
+ return printer.state;
+ }
+
+ function getCurrentPrinterStatePrettyShort() {
+ if (!cupsAvailable || !selectedPrinter)
+ return "";
+
+ var printer = printers[selectedPrinter];
+ return getPrinterStateTranslation(printer.state) + " (" + getPrinterStateReasonTranslation(printer.stateReason) + ")";
+ }
+
+ function getCurrentPrinterStatePretty() {
+ if (!cupsAvailable || !selectedPrinter)
+ return "";
+
+ var printer = printers[selectedPrinter];
+ return getPrinterStateTranslation(printer.state) + " (" + I18n.tr("Reason") + ": " + getPrinterStateReasonTranslation(printer.stateReason) + ")";
+ }
+
+ function getCurrentPrinterJobs() {
+ if (!cupsAvailable || !selectedPrinter)
+ return [];
+
+ return getJobs(selectedPrinter);
+ }
+
+ function getJobs(printerName) {
+ if (!cupsAvailable)
+ return "";
+
+ var printer = printers[printerName];
+ return printer.jobs;
+ }
+
+ function getJobsNum(printerName) {
+ if (!cupsAvailable)
+ return 0;
+
+ var printer = printers[printerName];
+ return printer.jobs.length;
+ }
+
+ function pausePrinter(printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.pausePrinter", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to pause printer"), response.error);
+ } else {
+ getState();
+ }
+ });
+ }
+
+ function resumePrinter(printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.resumePrinter", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to resume printer"), response.error);
+ } else {
+ getState();
+ }
+ });
+ }
+
+ function cancelJob(printerName, jobID) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName,
+ "jobID": jobID
+ };
+
+ DMSService.sendRequest("cups.cancelJob", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to cancel selected job"), response.error);
+ } else {
+ fetchJobsForPrinter(printerName);
+ }
+ });
+ }
+
+ function purgeJobs(printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.purgeJobs", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to cancel all jobs"), response.error);
+ } else {
+ fetchJobsForPrinter(printerName);
+ }
+ });
+ }
+
+ function getDevices() {
+ if (!cupsAvailable)
+ return;
+ loadingDevices = true;
+ DMSService.sendRequest("cups.getDevices", null, response => {
+ loadingDevices = false;
+ if (response.result) {
+ devices = response.result;
+ }
+ });
+ }
+
+ function getPPDs() {
+ if (!cupsAvailable)
+ return;
+ loadingPPDs = true;
+ DMSService.sendRequest("cups.getPPDs", null, response => {
+ loadingPPDs = false;
+ if (response.result) {
+ ppds = response.result;
+ }
+ });
+ }
+
+ function getClasses() {
+ if (!cupsAvailable)
+ return;
+ loadingClasses = true;
+ DMSService.sendRequest("cups.getClasses", null, response => {
+ loadingClasses = false;
+ if (response.result) {
+ printerClasses = response.result;
+ }
+ });
+ }
+
+ function testConnection(host, port, protocol, callback) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "host": host,
+ "port": port,
+ "protocol": protocol
+ };
+
+ DMSService.sendRequest("cups.testConnection", params, response => {
+ if (callback)
+ callback(response);
+ });
+ }
+
+ function createPrinter(name, deviceURI, ppd, options) {
+ if (!cupsAvailable)
+ return;
+ creatingPrinter = true;
+ const params = {
+ "name": name,
+ "deviceURI": deviceURI,
+ "ppd": ppd
+ };
+ if (options) {
+ if (options.shared !== undefined)
+ params.shared = options.shared;
+ if (options.location)
+ params.location = options.location;
+ if (options.information)
+ params.information = options.information;
+ if (options.errorPolicy)
+ params.errorPolicy = options.errorPolicy;
+ }
+
+ DMSService.sendRequest("cups.createPrinter", params, response => {
+ creatingPrinter = false;
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to create printer"), response.error);
+ } else {
+ ToastService.showInfo(I18n.tr("Printer created successfully"));
+ getState();
+ }
+ });
+ }
+
+ function deletePrinter(printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.deletePrinter", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to delete printer"), response.error);
+ } else {
+ ToastService.showInfo(I18n.tr("Printer deleted"));
+ if (selectedPrinter === printerName) {
+ selectedPrinter = "";
+ }
+ getState();
+ }
+ });
+ }
+
+ function acceptJobs(printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.acceptJobs", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to enable job acceptance"), response.error);
+ } else {
+ getState();
+ }
+ });
+ }
+
+ function rejectJobs(printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.rejectJobs", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to disable job acceptance"), response.error);
+ } else {
+ getState();
+ }
+ });
+ }
+
+ function setPrinterShared(printerName, shared) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName,
+ "shared": shared
+ };
+
+ DMSService.sendRequest("cups.setPrinterShared", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to update sharing"), response.error);
+ } else {
+ getState();
+ }
+ });
+ }
+
+ function setPrinterLocation(printerName, location) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName,
+ "location": location
+ };
+
+ DMSService.sendRequest("cups.setPrinterLocation", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to update location"), response.error);
+ } else {
+ getState();
+ }
+ });
+ }
+
+ function setPrinterInfo(printerName, info) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName,
+ "info": info
+ };
+
+ DMSService.sendRequest("cups.setPrinterInfo", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to update description"), response.error);
+ } else {
+ getState();
+ }
+ });
+ }
+
+ function printTestPage(printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.printTestPage", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to print test page"), response.error);
+ } else {
+ ToastService.showInfo(I18n.tr("Test page sent to printer"));
+ fetchJobsForPrinter(printerName);
+ }
+ });
+ }
+
+ function moveJob(jobID, destPrinter) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "jobID": jobID,
+ "destPrinter": destPrinter
+ };
+
+ DMSService.sendRequest("cups.moveJob", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to move job"), response.error);
+ } else {
+ fetchAllJobs();
+ }
+ });
+ }
+
+ function restartJob(jobID) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "jobID": jobID
+ };
+
+ DMSService.sendRequest("cups.restartJob", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to restart job"), response.error);
+ } else {
+ fetchAllJobs();
+ }
+ });
+ }
+
+ function holdJob(jobID, holdUntil) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "jobID": jobID
+ };
+ if (holdUntil) {
+ params.holdUntil = holdUntil;
+ }
+
+ DMSService.sendRequest("cups.holdJob", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to hold job"), response.error);
+ } else {
+ fetchAllJobs();
+ }
+ });
+ }
+
+ function addPrinterToClass(className, printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "className": className,
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.addPrinterToClass", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to add printer to class"), response.error);
+ } else {
+ getClasses();
+ }
+ });
+ }
+
+ function removePrinterFromClass(className, printerName) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "className": className,
+ "printerName": printerName
+ };
+
+ DMSService.sendRequest("cups.removePrinterFromClass", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to remove printer from class"), response.error);
+ } else {
+ getClasses();
+ }
+ });
+ }
+
+ function deleteClass(className) {
+ if (!cupsAvailable)
+ return;
+ const params = {
+ "className": className
+ };
+
+ DMSService.sendRequest("cups.deleteClass", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to delete class"), response.error);
+ } else {
+ getClasses();
+ }
+ });
+ }
+
+ function getPrinterData(printerName) {
+ if (!printers || !printers[printerName])
+ return null;
+ return printers[printerName];
+ }
+
+ function getJobStateTranslation(state) {
+ switch (state) {
+ case "pending":
+ return I18n.tr("Pending");
+ case "pending-held":
+ return I18n.tr("Held");
+ case "processing":
+ return I18n.tr("Processing");
+ case "processing-stopped":
+ return I18n.tr("Stopped");
+ case "canceled":
+ return I18n.tr("Canceled");
+ case "aborted":
+ return I18n.tr("Aborted");
+ case "completed":
+ return I18n.tr("Completed");
+ default:
+ return state;
+ }
+ }
+
+ readonly property var states: ({
+ "idle": I18n.tr("Idle"),
+ "processing": I18n.tr("Processing"),
+ "stopped": I18n.tr("Stopped")
+ })
+
+ readonly property var reasonsGeneral: ({
+ "none": I18n.tr("None"),
+ "other": I18n.tr("Other")
+ })
+
+ readonly property var reasonsSupplies: ({
+ "toner-low": I18n.tr("Toner Low"),
+ "toner-empty": I18n.tr("Toner Empty"),
+ "marker-supply-low": I18n.tr("Marker Supply Low"),
+ "marker-supply-empty": I18n.tr("Marker Supply Empty"),
+ "marker-waste-almost-full": I18n.tr("Marker Waste Almost Full"),
+ "marker-waste-full": I18n.tr("Marker Waste Full")
+ })
+
+ readonly property var reasonsMedia: ({
+ "media-low": I18n.tr("Media Low"),
+ "media-empty": I18n.tr("Media Empty"),
+ "media-needed": I18n.tr("Media Needed"),
+ "media-jam": I18n.tr("Media Jam")
+ })
+
+ readonly property var reasonsParts: ({
+ "cover-open": I18n.tr("Cover Open"),
+ "door-open": I18n.tr("Door Open"),
+ "interlock-open": I18n.tr("Interlock Open"),
+ "output-tray-missing": I18n.tr("Output Tray Missing"),
+ "output-area-almost-full": I18n.tr("Output Area Almost Full"),
+ "output-area-full": I18n.tr("Output Area Full")
+ })
+
+ readonly property var reasonsErrors: ({
+ "paused": I18n.tr("Paused"),
+ "shutdown": I18n.tr("Shutdown"),
+ "connecting-to-device": I18n.tr("Connecting to Device"),
+ "timed-out": I18n.tr("Timed Out"),
+ "stopping": I18n.tr("Stopping"),
+ "stopped-partly": I18n.tr("Stopped Partly")
+ })
+
+ readonly property var reasonsService: ({
+ "spool-area-full": I18n.tr("Spool Area Full"),
+ "cups-missing-filter-warning": I18n.tr("CUPS Missing Filter Warning"),
+ "cups-insecure-filter-warning": I18n.tr("CUPS Insecure Filter Warning")
+ })
+
+ readonly property var reasonsConnectivity: ({
+ "offline-report": I18n.tr("Offline Report"),
+ "moving-to-paused": I18n.tr("Moving to Paused")
+ })
+
+ readonly property var severitySuffixes: ({
+ "-error": I18n.tr("Error"),
+ "-warning": I18n.tr("Warning"),
+ "-report": I18n.tr("Report")
+ })
+
+ function getPrinterStateTranslation(state) {
+ return states[state] || state;
+ }
+
+ function getPrinterStateReasonTranslation(reason) {
+ let allReasons = Object.assign({}, reasonsGeneral, reasonsSupplies, reasonsMedia, reasonsParts, reasonsErrors, reasonsService, reasonsConnectivity);
+
+ let basReason = reason;
+ let suffix = "";
+
+ for (let s in severitySuffixes) {
+ if (reason.endsWith(s)) {
+ basReason = reason.slice(0, -s.length);
+ suffix = severitySuffixes[s];
+ break;
+ }
+ }
+
+ let translation = allReasons[basReason] || basReason;
+ return suffix ? translation + " (" + suffix + ")" : translation;
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/DMSNetworkService.qml b/raveos-hyprland-theme/theme-data/dms/Services/DMSNetworkService.qml
new file mode 100644
index 0000000..e234cf4
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/DMSNetworkService.qml
@@ -0,0 +1,944 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool networkAvailable: false
+ property string backend: ""
+
+ property string networkStatus: "disconnected"
+ property string primaryConnection: ""
+
+ property string ethernetIP: ""
+ property string ethernetInterface: ""
+ property bool ethernetConnected: false
+ property string ethernetConnectionUuid: ""
+ property var ethernetDevices: []
+
+ property var wiredConnections: []
+
+ property string wifiIP: ""
+ property string wifiInterface: ""
+ property bool wifiConnected: false
+ property bool wifiEnabled: true
+ property string wifiConnectionUuid: ""
+ property string wifiDevicePath: ""
+ property string activeAccessPointPath: ""
+ property var wifiDevices: []
+ property string wifiDeviceOverride: SessionData.wifiDeviceOverride || ""
+ property string connectingDevice: ""
+
+ property string currentWifiSSID: ""
+ property int wifiSignalStrength: 0
+ property var wifiNetworks: []
+ property var savedConnections: []
+ property var ssidToConnectionName: ({})
+ property var wifiSignalIcon: {
+ if (!wifiConnected) {
+ return "wifi_off";
+ }
+ if (wifiSignalStrength >= 50) {
+ return "wifi";
+ }
+ if (wifiSignalStrength >= 25) {
+ return "wifi_2_bar";
+ }
+ return "wifi_1_bar";
+ }
+
+ property string userPreference: "auto"
+ property bool isConnecting: false
+ property string connectingSSID: ""
+ property string connectionError: ""
+
+ property bool isScanning: false
+ property bool autoScan: false
+
+ property bool wifiAvailable: true
+ property bool wifiToggling: false
+ property bool changingPreference: false
+ property string targetPreference: ""
+ property var savedWifiNetworks: []
+ property string connectionStatus: ""
+ property string lastConnectionError: ""
+ property bool passwordDialogShouldReopen: false
+ property bool autoRefreshEnabled: false
+ property string wifiPassword: ""
+ property string forgetSSID: ""
+
+ property var vpnProfiles: []
+ property var vpnActive: []
+ property bool vpnAvailable: false
+ property bool vpnIsBusy: false
+ property string lastConnectedVpnUuid: ""
+ property string pendingVpnUuid: ""
+ property var vpnBusyStartTime: 0
+
+ property alias profiles: root.vpnProfiles
+ property alias activeConnections: root.vpnActive
+ property var activeUuids: vpnActive.map(v => v.uuid).filter(u => !!u)
+ property var activeNames: vpnActive.map(v => v.name).filter(n => !!n)
+ property string activeUuid: activeUuids.length > 0 ? activeUuids[0] : ""
+ property string activeName: activeNames.length > 0 ? activeNames[0] : ""
+ property string activeDevice: vpnActive.length > 0 ? (vpnActive[0].device || "") : ""
+ property string activeState: vpnActive.length > 0 ? (vpnActive[0].state || "") : ""
+ property bool vpnConnected: activeUuids.length > 0
+ property alias available: root.vpnAvailable
+ property alias isBusy: root.vpnIsBusy
+ property alias connected: root.vpnConnected
+
+ property string networkInfoSSID: ""
+ property string networkInfoDetails: ""
+ property bool networkInfoLoading: false
+
+ property string networkWiredInfoUUID: ""
+ property string networkWiredInfoDetails: ""
+ property bool networkWiredInfoLoading: false
+
+ property int refCount: 0
+ property bool stateInitialized: false
+
+ property string credentialsToken: ""
+ property string credentialsSSID: ""
+ property string credentialsSetting: ""
+ property var credentialsFields: []
+ property var credentialsHints: []
+ property string credentialsReason: ""
+ property bool credentialsRequested: false
+
+ property string pendingConnectionSSID: ""
+ property var pendingConnectionStartTime: 0
+ property bool wasConnecting: false
+
+ signal networksUpdated
+ signal connectionChanged
+ signal credentialsNeeded(string token, string ssid, string setting, var fields, var hints, string reason, string connType, string connName, string vpnService, var fieldsInfo)
+
+ readonly property string socketPath: Quickshell.env("DMS_SOCKET")
+
+ readonly property string effectiveWifiDevice: {
+ if (!wifiDeviceOverride)
+ return "";
+ const deviceExists = wifiDevices.some(d => d.name === wifiDeviceOverride);
+ return deviceExists ? wifiDeviceOverride : "";
+ }
+
+ Component.onCompleted: {
+ root.userPreference = SettingsData.networkPreference;
+ lastConnectedVpnUuid = SessionData.vpnLastConnected || "";
+ if (socketPath && socketPath.length > 0) {
+ checkDMSCapabilities();
+ }
+ }
+
+ Connections {
+ target: DMSService
+
+ function onNetworkStateUpdate(data) {
+ const networksCount = data.wifiNetworks?.length ?? "null";
+ console.log("DMSNetworkService: Subscription update received, networks:", networksCount);
+ updateState(data);
+ }
+ }
+
+ Connections {
+ target: DMSService
+
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkDMSCapabilities();
+ }
+ }
+ }
+
+ Connections {
+ target: DMSService
+ enabled: DMSService.isConnected
+
+ function onCapabilitiesChanged() {
+ checkDMSCapabilities();
+ }
+
+ function onCredentialsRequest(data) {
+ handleCredentialsRequest(data);
+ }
+ }
+
+ function checkDMSCapabilities() {
+ if (!DMSService.isConnected) {
+ return;
+ }
+
+ if (DMSService.capabilities.length === 0) {
+ return;
+ }
+
+ networkAvailable = DMSService.capabilities.includes("network");
+
+ if (networkAvailable && !stateInitialized) {
+ stateInitialized = true;
+ getState();
+ }
+ }
+
+ function handleCredentialsRequest(data) {
+ credentialsToken = data.token || "";
+ credentialsSSID = data.ssid || "";
+ credentialsSetting = data.setting || "802-11-wireless-security";
+ credentialsFields = data.fields || ["psk"];
+ credentialsHints = data.hints || [];
+ credentialsReason = data.reason || "Credentials required";
+ credentialsRequested = true;
+
+ const connType = data.connType || "";
+ const connName = data.name || data.connectionId || "";
+ const vpnService = data.vpnService || "";
+ const fInfo = data.fieldsInfo || [];
+
+ credentialsNeeded(credentialsToken, credentialsSSID, credentialsSetting, credentialsFields, credentialsHints, credentialsReason, connType, connName, vpnService, fInfo);
+ }
+
+ function addRef() {
+ refCount++;
+ if (refCount === 1 && networkAvailable) {
+ startAutoScan();
+ }
+ }
+
+ function removeRef() {
+ refCount = Math.max(0, refCount - 1);
+ if (refCount === 0) {
+ stopAutoScan();
+ }
+ }
+
+ property bool initialStateFetched: false
+
+ function getState() {
+ if (!networkAvailable)
+ return;
+ DMSService.sendRequest("network.getState", null, response => {
+ if (response.result) {
+ updateState(response.result);
+ if (!initialStateFetched && response.result.wifiEnabled && (!response.result.wifiNetworks || response.result.wifiNetworks.length === 0)) {
+ initialStateFetched = true;
+ Qt.callLater(() => scanWifi());
+ }
+ }
+ });
+ }
+
+ function updateState(state) {
+ const previousConnecting = isConnecting;
+ const previousConnectingSSID = connectingSSID;
+
+ backend = state.backend || "";
+ vpnAvailable = networkAvailable && backend === "networkmanager";
+ networkStatus = state.networkStatus || "disconnected";
+ primaryConnection = state.primaryConnection || "";
+
+ ethernetIP = state.ethernetIP || "";
+ ethernetInterface = state.ethernetDevice || "";
+ ethernetConnected = state.ethernetConnected || false;
+ ethernetConnectionUuid = state.ethernetConnectionUuid || "";
+ ethernetDevices = state.ethernetDevices || [];
+
+ wiredConnections = state.wiredConnections || [];
+
+ wifiIP = state.wifiIP || "";
+ wifiInterface = state.wifiDevice || "";
+ wifiConnected = state.wifiConnected || false;
+ wifiEnabled = state.wifiEnabled !== undefined ? state.wifiEnabled : true;
+ wifiConnectionUuid = state.wifiConnectionUuid || "";
+ wifiDevicePath = state.wifiDevicePath || "";
+ activeAccessPointPath = state.activeAccessPointPath || "";
+ wifiDevices = state.wifiDevices || [];
+ connectingDevice = state.connectingDevice || "";
+
+ currentWifiSSID = state.wifiSSID || "";
+ wifiSignalStrength = state.wifiSignal || 0;
+
+ if (state.wifiNetworks) {
+ wifiNetworks = state.wifiNetworks;
+
+ const saved = [];
+ const mapping = {};
+ for (const network of state.wifiNetworks) {
+ if (network.saved) {
+ saved.push({
+ ssid: network.ssid,
+ saved: true
+ });
+ mapping[network.ssid] = network.ssid;
+ }
+ }
+ savedConnections = saved;
+ savedWifiNetworks = saved;
+ ssidToConnectionName = mapping;
+
+ networksUpdated();
+ }
+
+ if (state.vpnProfiles) {
+ vpnProfiles = state.vpnProfiles;
+ }
+
+ const previousVpnActive = vpnActive;
+ vpnActive = state.vpnActive || [];
+
+ if (vpnConnected && activeUuid) {
+ lastConnectedVpnUuid = activeUuid;
+ SessionData.setVpnLastConnected(activeUuid);
+ }
+
+ if (vpnIsBusy) {
+ const busyDuration = Date.now() - vpnBusyStartTime;
+ const timeout = 30000;
+
+ if (busyDuration > timeout) {
+ console.warn("DMSNetworkService: VPN operation timed out after", timeout, "ms");
+ vpnIsBusy = false;
+ pendingVpnUuid = "";
+ vpnBusyStartTime = 0;
+ } else if (pendingVpnUuid) {
+ const isPendingVpnActive = activeUuids.includes(pendingVpnUuid);
+ if (isPendingVpnActive) {
+ vpnIsBusy = false;
+ pendingVpnUuid = "";
+ vpnBusyStartTime = 0;
+ }
+ } else {
+ const previousCount = previousVpnActive ? previousVpnActive.length : 0;
+ const currentCount = vpnActive ? vpnActive.length : 0;
+
+ if (previousCount !== currentCount) {
+ vpnIsBusy = false;
+ vpnBusyStartTime = 0;
+ }
+ }
+ }
+
+ isConnecting = state.isConnecting || false;
+ connectingSSID = state.connectingSSID || "";
+ connectionError = state.lastError || "";
+ lastConnectionError = state.lastError || "";
+
+ if (pendingConnectionSSID) {
+ if (wifiConnected && currentWifiSSID === pendingConnectionSSID && wifiIP) {
+ const elapsed = Date.now() - pendingConnectionStartTime;
+ console.info("DMSNetworkService: Successfully connected to", pendingConnectionSSID, "in", elapsed, "ms");
+ ToastService.showInfo(`Connected to ${pendingConnectionSSID}`);
+
+ if (userPreference === "wifi" || userPreference === "auto") {
+ setConnectionPriority("wifi");
+ }
+
+ pendingConnectionSSID = "";
+ connectionStatus = "connected";
+ } else if (previousConnecting && !isConnecting && !wifiConnected) {
+ const isCancellationError = connectionError === "user-canceled";
+ const isBadCredentials = connectionError === "bad-credentials";
+
+ if (isCancellationError) {
+ connectionStatus = "cancelled";
+ pendingConnectionSSID = "";
+ } else if (isBadCredentials) {
+ connectionStatus = "invalid_password";
+ pendingConnectionSSID = "";
+ } else {
+ if (connectionError) {
+ ToastService.showError(I18n.tr("Failed to connect to %1").arg(pendingConnectionSSID));
+ }
+ connectionStatus = "failed";
+ pendingConnectionSSID = "";
+ }
+ }
+ }
+
+ wasConnecting = isConnecting;
+
+ connectionChanged();
+ }
+
+ function connectToSpecificWiredConfig(uuid) {
+ if (!networkAvailable || isConnecting)
+ return;
+ isConnecting = true;
+ connectionError = "";
+ connectionStatus = "connecting";
+
+ const params = {
+ uuid: uuid
+ };
+
+ DMSService.sendRequest("network.ethernet.connect.config", params, response => {
+ if (response.error) {
+ connectionError = response.error;
+ lastConnectionError = response.error;
+ connectionStatus = "failed";
+ ToastService.showError(I18n.tr("Failed to activate configuration"), response.error);
+ } else {
+ connectionError = "";
+ connectionStatus = "connected";
+ ToastService.showInfo(I18n.tr("Configuration activated"));
+ }
+
+ isConnecting = false;
+ });
+ }
+
+ function scanWifi() {
+ if (!networkAvailable || isScanning || !wifiEnabled)
+ return;
+ isScanning = true;
+ const params = effectiveWifiDevice ? {
+ device: effectiveWifiDevice
+ } : null;
+ DMSService.sendRequest("network.wifi.scan", params, response => {
+ isScanning = false;
+ if (response.error) {
+ console.warn("DMSNetworkService: WiFi scan failed:", response.error);
+ } else {
+ Qt.callLater(() => getState());
+ }
+ });
+ }
+
+ function scanWifiNetworks() {
+ scanWifi();
+ }
+
+ function connectToWifi(ssid, password = "", username = "", anonymousIdentity = "", domainSuffixMatch = "", hidden = false) {
+ if (!networkAvailable || isConnecting)
+ return;
+ pendingConnectionSSID = ssid;
+ pendingConnectionStartTime = Date.now();
+ connectionError = "";
+ connectionStatus = "connecting";
+ credentialsRequested = false;
+
+ const params = {
+ ssid: ssid
+ };
+ if (effectiveWifiDevice)
+ params.device = effectiveWifiDevice;
+ if (hidden)
+ params.hidden = true;
+
+ if (DMSService.apiVersion >= 7) {
+ if (password || username) {
+ params.password = password;
+ if (username)
+ params.username = username;
+ if (anonymousIdentity)
+ params.anonymousIdentity = anonymousIdentity;
+ if (domainSuffixMatch)
+ params.domainSuffixMatch = domainSuffixMatch;
+ params.interactive = false;
+ } else {
+ params.interactive = true;
+ }
+ } else {
+ if (password)
+ params.password = password;
+ if (username)
+ params.username = username;
+ if (anonymousIdentity)
+ params.anonymousIdentity = anonymousIdentity;
+ if (domainSuffixMatch)
+ params.domainSuffixMatch = domainSuffixMatch;
+ }
+
+ DMSService.sendRequest("network.wifi.connect", params, response => {
+ if (response.error) {
+ if (connectionStatus === "cancelled")
+ return;
+ connectionError = response.error;
+ lastConnectionError = response.error;
+ pendingConnectionSSID = "";
+ connectionStatus = "failed";
+ ToastService.showError(I18n.tr("Failed to start connection to %1").arg(ssid));
+ }
+ });
+ }
+
+ function disconnectWifi() {
+ if (!networkAvailable || !wifiInterface)
+ return;
+ const params = effectiveWifiDevice ? {
+ device: effectiveWifiDevice
+ } : null;
+ DMSService.sendRequest("network.wifi.disconnect", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to disconnect WiFi"), response.error);
+ } else {
+ ToastService.showInfo(I18n.tr("Disconnected from WiFi"));
+ currentWifiSSID = "";
+ connectionStatus = "";
+ }
+ });
+ }
+
+ function submitCredentials(token, secrets, save) {
+ console.log("submitCredentials: networkAvailable=" + networkAvailable + " apiVersion=" + DMSService.apiVersion);
+
+ if (!networkAvailable || DMSService.apiVersion < 7) {
+ console.warn("submitCredentials: Aborting - networkAvailable=" + networkAvailable + " apiVersion=" + DMSService.apiVersion);
+ return;
+ }
+
+ const params = {
+ token: token,
+ secrets: secrets,
+ save: save || false
+ };
+
+ credentialsRequested = false;
+
+ DMSService.sendRequest("network.credentials.submit", params, response => {
+ if (response.error) {
+ console.warn("DMSNetworkService: Failed to submit credentials:", response.error);
+ }
+ });
+ }
+
+ function cancelCredentials(token) {
+ if (!networkAvailable || DMSService.apiVersion < 7)
+ return;
+ const params = {
+ token: token
+ };
+
+ credentialsRequested = false;
+ pendingConnectionSSID = "";
+ connectionStatus = "cancelled";
+
+ DMSService.sendRequest("network.credentials.cancel", params, response => {
+ if (response.error) {
+ console.warn("DMSNetworkService: Failed to cancel credentials:", response.error);
+ }
+ });
+ }
+
+ function forgetWifiNetwork(ssid) {
+ if (!networkAvailable)
+ return;
+ forgetSSID = ssid;
+ DMSService.sendRequest("network.wifi.forget", {
+ ssid: ssid
+ }, response => {
+ if (response.error) {
+ console.warn("Failed to forget network:", response.error);
+ } else {
+ ToastService.showInfo(I18n.tr("Forgot network %1").arg(ssid));
+
+ savedConnections = savedConnections.filter(s => s.ssid !== ssid);
+ savedWifiNetworks = savedWifiNetworks.filter(s => s.ssid !== ssid);
+
+ const updated = [...wifiNetworks];
+ for (const network of updated) {
+ if (network.ssid === ssid) {
+ network.saved = false;
+ if (network.connected) {
+ network.connected = false;
+ currentWifiSSID = "";
+ }
+ }
+ }
+ wifiNetworks = updated;
+ networksUpdated();
+ }
+ forgetSSID = "";
+ });
+ }
+
+ function toggleWifiRadio() {
+ if (!networkAvailable || wifiToggling)
+ return;
+ wifiToggling = true;
+ DMSService.sendRequest("network.wifi.toggle", null, response => {
+ wifiToggling = false;
+
+ if (response.error) {
+ console.warn("Failed to toggle WiFi:", response.error);
+ } else if (response.result) {
+ wifiEnabled = response.result.enabled;
+ ToastService.showInfo(wifiEnabled ? I18n.tr("WiFi enabled") : I18n.tr("WiFi disabled"));
+ }
+ });
+ }
+
+ function enableWifiDevice() {
+ if (!networkAvailable)
+ return;
+ DMSService.sendRequest("network.wifi.enable", null, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to enable WiFi"), response.error);
+ } else {
+ ToastService.showInfo(I18n.tr("WiFi enabled"));
+ }
+ });
+ }
+
+ function setNetworkPreference(preference) {
+ if (!networkAvailable)
+ return;
+ userPreference = preference;
+ changingPreference = true;
+ targetPreference = preference;
+ SettingsData.set("networkPreference", preference);
+
+ DMSService.sendRequest("network.preference.set", {
+ preference: preference
+ }, response => {
+ changingPreference = false;
+ targetPreference = "";
+
+ if (response.error) {
+ console.warn("Failed to set network preference:", response.error);
+ }
+ });
+ }
+
+ function setConnectionPriority(type) {
+ if (type === "wifi") {
+ setNetworkPreference("wifi");
+ } else if (type === "ethernet") {
+ setNetworkPreference("ethernet");
+ }
+ }
+
+ function connectToWifiAndSetPreference(ssid, password, username = "", anonymousIdentity = "", domainSuffixMatch = "", hidden = false) {
+ connectToWifi(ssid, password, username, anonymousIdentity, domainSuffixMatch, hidden);
+ setNetworkPreference("wifi");
+ }
+
+ function toggleNetworkConnection(type) {
+ if (!networkAvailable)
+ return;
+ if (type === "ethernet") {
+ if (ethernetConnected) {
+ DMSService.sendRequest("network.ethernet.disconnect", null, null);
+ } else {
+ DMSService.sendRequest("network.ethernet.connect", null, null);
+ }
+ }
+ }
+
+ function disconnectEthernetDevice(deviceName) {
+ if (!networkAvailable)
+ return;
+ DMSService.sendRequest("network.ethernet.disconnect", {
+ device: deviceName
+ }, null);
+ }
+
+ function startAutoScan() {
+ autoScan = true;
+ autoRefreshEnabled = true;
+ if (networkAvailable && wifiEnabled) {
+ scanWifi();
+ }
+ }
+
+ function stopAutoScan() {
+ autoScan = false;
+ autoRefreshEnabled = false;
+ }
+
+ function fetchWiredNetworkInfo(uuid) {
+ if (!networkAvailable)
+ return;
+ networkWiredInfoUUID = uuid;
+ networkWiredInfoLoading = true;
+ networkWiredInfoDetails = "Loading network information...";
+
+ DMSService.sendRequest("network.ethernet.info", {
+ uuid: uuid
+ }, response => {
+ networkWiredInfoLoading = false;
+
+ if (response.error) {
+ networkWiredInfoDetails = "Failed to fetch network information";
+ } else if (response.result) {
+ formatWiredNetworkInfo(response.result);
+ }
+ });
+ }
+
+ function formatWiredNetworkInfo(info) {
+ let details = "";
+
+ if (!info) {
+ details = "Network information not found or network not available.";
+ } else {
+ details += "Interface: " + info.iface + "\\n";
+ details += "Driver: " + info.driver + "\\n";
+ details += "MAC Addr: " + info.hwAddr + "\\n";
+ details += "Speed: " + info.speed + " Mb/s\\n\\n";
+
+ details += "IPv4 information:\\n";
+
+ for (const ip4 of info.IPv4s.ips) {
+ details += " IPv4 address: " + ip4 + "\\n";
+ }
+ details += " Gateway: " + info.IPv4s.gateway + "\\n";
+ details += " DNS: " + info.IPv4s.dns + "\\n";
+
+ if (info.IPv6s.ips) {
+ details += "\\nIPv6 information:\\n";
+
+ for (const ip6 of info.IPv6s.ips) {
+ details += " IPv6 address: " + ip6 + "\\n";
+ }
+ if (info.IPv6s.gateway.length > 0) {
+ details += " Gateway: " + info.IPv6s.gateway + "\\n";
+ }
+ if (info.IPv6s.dns.length > 0) {
+ details += " DNS: " + info.IPv6s.dns + "\\n";
+ }
+ }
+ }
+
+ networkWiredInfoDetails = details;
+ }
+
+ function fetchNetworkInfo(ssid) {
+ if (!networkAvailable)
+ return;
+ networkInfoSSID = ssid;
+ networkInfoLoading = true;
+ networkInfoDetails = "Loading network information...";
+
+ DMSService.sendRequest("network.info", {
+ ssid: ssid
+ }, response => {
+ networkInfoLoading = false;
+
+ if (response.error) {
+ networkInfoDetails = "Failed to fetch network information";
+ } else if (response.result) {
+ formatNetworkInfo(response.result);
+ }
+ });
+ }
+
+ function formatNetworkInfo(info) {
+ let details = "";
+
+ if (!info || !info.bands || info.bands.length === 0) {
+ details = "Network information not found or network not available.";
+ } else {
+ for (const band of info.bands) {
+ const freqGHz = band.frequency / 1000;
+ let bandName = "Unknown";
+ if (band.frequency >= 2400 && band.frequency <= 2500) {
+ bandName = "2.4 GHz";
+ } else if (band.frequency >= 5000 && band.frequency <= 6000) {
+ bandName = "5 GHz";
+ } else if (band.frequency >= 6000) {
+ bandName = "6 GHz";
+ }
+
+ const statusPrefix = band.connected ? "● " : " ";
+ const statusSuffix = band.connected ? " (Connected)" : "";
+
+ details += statusPrefix + bandName + statusSuffix + " - " + band.signal + "%\\n";
+ details += " Channel " + band.channel + " (" + freqGHz.toFixed(1) + " GHz) • " + band.rate + " Mbit/s\\n";
+ details += " BSSID: " + band.bssid + "\\n";
+ details += " Mode: " + band.mode + "\\n";
+ details += " Security: " + (band.secured ? "Secured" : "Open") + "\\n";
+ if (band.saved) {
+ details += " Status: Saved network\\n";
+ }
+ details += "\\n";
+ }
+ }
+
+ networkInfoDetails = details;
+ }
+
+ function getNetworkInfo(ssid) {
+ const network = wifiNetworks.find(n => n.ssid === ssid);
+ if (!network) {
+ return null;
+ }
+
+ return {
+ "ssid": network.ssid,
+ "signal": network.signal,
+ "secured": network.secured,
+ "saved": network.saved,
+ "connected": network.connected,
+ "bssid": network.bssid
+ };
+ }
+
+ function getWiredNetworkInfo(uuid) {
+ const network = wiredConnections.find(n => n.uuid === uuid);
+ if (!network) {
+ return null;
+ }
+
+ return {
+ "uuid": uuid
+ };
+ }
+
+ function refreshVpnProfiles() {
+ if (!vpnAvailable)
+ return;
+ DMSService.sendRequest("network.vpn.profiles", null, response => {
+ if (response.result) {
+ vpnProfiles = response.result;
+ }
+ });
+ }
+
+ function refreshVpnActive() {
+ if (!vpnAvailable)
+ return;
+ DMSService.sendRequest("network.vpn.active", null, response => {
+ if (response.result) {
+ vpnActive = response.result;
+ }
+ });
+ }
+
+ function connectVpn(uuidOrName, singleActive = false) {
+ if (!vpnAvailable || vpnIsBusy)
+ return;
+ vpnIsBusy = true;
+ pendingVpnUuid = uuidOrName;
+ vpnBusyStartTime = Date.now();
+
+ const params = {
+ uuidOrName: uuidOrName,
+ singleActive: singleActive
+ };
+
+ DMSService.sendRequest("network.vpn.connect", params, response => {
+ if (response.error) {
+ vpnIsBusy = false;
+ pendingVpnUuid = "";
+ vpnBusyStartTime = 0;
+ ToastService.showError(I18n.tr("Failed to connect VPN"), response.error);
+ }
+ });
+ }
+
+ function connect(uuidOrName, singleActive = false) {
+ connectVpn(uuidOrName, singleActive);
+ }
+
+ function disconnectVpn(uuidOrName) {
+ if (!vpnAvailable || vpnIsBusy)
+ return;
+ vpnIsBusy = true;
+ pendingVpnUuid = "";
+ vpnBusyStartTime = Date.now();
+
+ const params = {
+ uuidOrName: uuidOrName
+ };
+
+ DMSService.sendRequest("network.vpn.disconnect", params, response => {
+ if (response.error) {
+ vpnIsBusy = false;
+ vpnBusyStartTime = 0;
+ ToastService.showError(I18n.tr("Failed to disconnect VPN"), response.error);
+ }
+ });
+ }
+
+ function disconnect(uuidOrName) {
+ disconnectVpn(uuidOrName);
+ }
+
+ function disconnectAllVpns() {
+ if (!vpnAvailable || vpnIsBusy)
+ return;
+ vpnIsBusy = true;
+ pendingVpnUuid = "";
+
+ DMSService.sendRequest("network.vpn.disconnectAll", null, response => {
+ if (response.error) {
+ vpnIsBusy = false;
+ ToastService.showError(I18n.tr("Failed to disconnect VPNs"), response.error);
+ }
+ });
+ }
+
+ function disconnectAllActive() {
+ disconnectAllVpns();
+ }
+
+ function toggleVpn(uuid) {
+ if (uuid) {
+ if (isActiveVpnUuid(uuid)) {
+ disconnectVpn(uuid);
+ } else {
+ connectVpn(uuid);
+ }
+ return;
+ }
+
+ if (vpnConnected) {
+ disconnectAllVpns();
+ return;
+ }
+
+ const targetUuid = lastConnectedVpnUuid || (vpnProfiles.length > 0 ? vpnProfiles[0].uuid : "");
+ if (targetUuid) {
+ connectVpn(targetUuid);
+ }
+ }
+
+ function toggle(uuid) {
+ toggleVpn(uuid);
+ }
+
+ function isActiveVpnUuid(uuid) {
+ return activeUuids && activeUuids.indexOf(uuid) !== -1;
+ }
+
+ function isActiveUuid(uuid) {
+ return isActiveVpnUuid(uuid);
+ }
+
+ function refreshNetworkState() {
+ if (networkAvailable) {
+ getState();
+ }
+ }
+
+ function setWifiDeviceOverride(deviceName) {
+ SessionData.setWifiDeviceOverride(deviceName || "");
+ if (networkAvailable && wifiEnabled) {
+ scanWifi();
+ }
+ }
+
+ function setWifiAutoconnect(ssid, autoconnect) {
+ if (!networkAvailable || DMSService.apiVersion <= 13)
+ return;
+ const params = {
+ ssid: ssid,
+ autoconnect: autoconnect
+ };
+
+ DMSService.sendRequest("network.wifi.setAutoconnect", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to update autoconnect"), response.error);
+ } else {
+ ToastService.showInfo(autoconnect ? I18n.tr("Autoconnect enabled") : I18n.tr("Autoconnect disabled"));
+ Qt.callLater(() => getState());
+ }
+ });
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/DMSService.qml b/raveos-hyprland-theme/theme-data/dms/Services/DMSService.qml
new file mode 100644
index 0000000..217b96a
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/DMSService.qml
@@ -0,0 +1,757 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool dmsAvailable: false
+ property var capabilities: []
+ property int apiVersion: 0
+ property string cliVersion: ""
+ readonly property int expectedApiVersion: 1
+ property var availablePlugins: []
+ property var installedPlugins: []
+ property var availableThemes: []
+ property var installedThemes: []
+ property bool isConnected: false
+ property bool isConnecting: false
+ property bool subscribeConnected: false
+ readonly property bool forceExtWorkspace: false
+
+ readonly property string socketPath: Quickshell.env("DMS_SOCKET")
+
+ property var pendingRequests: ({})
+ property var clipboardRequestIds: ({})
+ property int requestIdCounter: 0
+ property bool shownOutdatedError: false
+ property string updateCommand: "dms update"
+ property bool checkingUpdateCommand: false
+
+ signal pluginsListReceived(var plugins)
+ signal installedPluginsReceived(var plugins)
+ signal searchResultsReceived(var plugins)
+ signal themesListReceived(var themes)
+ signal installedThemesReceived(var themes)
+ signal themeSearchResultsReceived(var themes)
+ signal operationSuccess(string message)
+ signal operationError(string error)
+ signal connectionStateChanged
+
+ signal networkStateUpdate(var data)
+ signal cupsStateUpdate(var data)
+ signal loginctlStateUpdate(var data)
+ signal loginctlEvent(var event)
+ signal capabilitiesReceived
+ signal credentialsRequest(var data)
+ signal bluetoothPairingRequest(var data)
+ signal dwlStateUpdate(var data)
+ signal brightnessStateUpdate(var data)
+ signal brightnessDeviceUpdate(var device)
+ signal extWorkspaceStateUpdate(var data)
+ signal wlrOutputStateUpdate(var data)
+ signal evdevStateUpdate(var data)
+ signal gammaStateUpdate(var data)
+ signal themeAutoStateUpdate(var data)
+ signal openUrlRequested(string url)
+ signal appPickerRequested(var data)
+ signal screensaverStateUpdate(var data)
+ signal clipboardStateUpdate(var data)
+ signal locationStateUpdate(var data)
+
+ property bool capsLockState: false
+ property bool screensaverInhibited: false
+ property var screensaverInhibitors: []
+
+ property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location"]
+
+ Component.onCompleted: {
+ if (socketPath && socketPath.length > 0) {
+ detectUpdateCommand();
+ }
+ }
+
+ function detectUpdateCommand() {
+ checkingUpdateCommand = true;
+ checkAurHelper.running = true;
+ }
+
+ function startSocketConnection() {
+ if (socketPath && socketPath.length > 0) {
+ testProcess.running = true;
+ }
+ }
+
+ Process {
+ id: checkAurHelper
+ command: ["sh", "-c", "command -v paru || command -v yay"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const helper = text.trim();
+ if (helper.includes("paru")) {
+ checkDmsPackage.helper = "paru";
+ checkDmsPackage.running = true;
+ } else if (helper.includes("yay")) {
+ checkDmsPackage.helper = "yay";
+ checkDmsPackage.running = true;
+ } else {
+ updateCommand = "dms update";
+ checkingUpdateCommand = false;
+ startSocketConnection();
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ updateCommand = "dms update";
+ checkingUpdateCommand = false;
+ startSocketConnection();
+ }
+ }
+ }
+
+ Process {
+ id: checkDmsPackage
+ property string helper: ""
+ command: ["sh", "-c", "pacman -Qi dms-shell-git 2>/dev/null || pacman -Qi dms-shell-bin 2>/dev/null"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.includes("dms-shell-git")) {
+ updateCommand = checkDmsPackage.helper + " -S dms-shell-git";
+ } else if (text.includes("dms-shell-bin")) {
+ updateCommand = checkDmsPackage.helper + " -S dms-shell-bin";
+ } else {
+ updateCommand = "dms update";
+ }
+ checkingUpdateCommand = false;
+ startSocketConnection();
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ updateCommand = "dms update";
+ checkingUpdateCommand = false;
+ startSocketConnection();
+ }
+ }
+ }
+
+ Process {
+ id: testProcess
+ command: ["test", "-S", root.socketPath]
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ root.dmsAvailable = true;
+ connectSocket();
+ } else {
+ root.dmsAvailable = false;
+ }
+ }
+ }
+
+ function connectSocket() {
+ if (!dmsAvailable || isConnected || isConnecting) {
+ return;
+ }
+
+ isConnecting = true;
+ requestSocket.connected = true;
+ }
+
+ DankSocket {
+ id: requestSocket
+ path: root.socketPath
+ connected: false
+
+ onConnectionStateChanged: {
+ if (connected) {
+ root.isConnected = true;
+ root.isConnecting = false;
+ root.connectionStateChanged();
+ subscribeSocket.connected = true;
+ } else {
+ root.isConnected = false;
+ root.isConnecting = false;
+ root.apiVersion = 0;
+ root.capabilities = [];
+ root.connectionStateChanged();
+ }
+ }
+
+ parser: SplitParser {
+ onRead: line => {
+ if (!line || line.length === 0)
+ return;
+
+ let response;
+ try {
+ response = JSON.parse(line);
+ } catch (e) {
+ console.warn("DMSService: Failed to parse request response:", line.substring(0, 100));
+ return;
+ }
+ const isClipboard = clipboardRequestIds[response.id];
+ if (isClipboard)
+ delete clipboardRequestIds[response.id];
+ else
+ console.log("DMSService: Request socket <<", line);
+ handleResponse(response);
+ }
+ }
+ }
+
+ DankSocket {
+ id: subscribeSocket
+ path: root.socketPath
+ connected: false
+
+ onConnectionStateChanged: {
+ root.subscribeConnected = connected;
+ if (connected) {
+ sendSubscribeRequest();
+ }
+ }
+
+ parser: SplitParser {
+ onRead: line => {
+ if (!line || line.length === 0)
+ return;
+
+ let response;
+ try {
+ response = JSON.parse(line);
+ } catch (e) {
+ console.warn("DMSService: Failed to parse subscription event:", line.substring(0, 100));
+ return;
+ }
+ if (!line.includes("clipboard"))
+ console.log("DMSService: Subscribe socket <<", line);
+ handleSubscriptionEvent(response);
+ }
+ }
+ }
+
+ function sendSubscribeRequest() {
+ const request = {
+ "method": "subscribe"
+ };
+
+ if (activeSubscriptions.length > 0) {
+ request.params = {
+ "services": activeSubscriptions
+ };
+ console.log("DMSService: Subscribing to services:", JSON.stringify(activeSubscriptions));
+ } else {
+ console.log("DMSService: Subscribing to all services");
+ }
+
+ subscribeSocket.send(request);
+ }
+
+ function subscribe(services) {
+ if (!Array.isArray(services)) {
+ services = [services];
+ }
+
+ activeSubscriptions = services;
+
+ if (subscribeConnected) {
+ subscribeSocket.connected = false;
+ Qt.callLater(() => {
+ subscribeSocket.connected = true;
+ });
+ }
+ }
+
+ function addSubscription(service) {
+ if (activeSubscriptions.includes("all"))
+ return;
+ if (!activeSubscriptions.includes(service)) {
+ const newSubs = [...activeSubscriptions, service];
+ subscribe(newSubs);
+ }
+ }
+
+ function removeSubscription(service) {
+ if (activeSubscriptions.includes("all")) {
+ const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser", "location"];
+ const filtered = allServices.filter(s => s !== service);
+ subscribe(filtered);
+ } else {
+ const filtered = activeSubscriptions.filter(s => s !== service);
+ if (filtered.length === 0) {
+ console.warn("DMSService: Cannot remove last subscription");
+ return;
+ }
+ subscribe(filtered);
+ }
+ }
+
+ function subscribeAll() {
+ subscribe(["all"]);
+ }
+
+ function subscribeAllExcept(excludeServices) {
+ if (!Array.isArray(excludeServices)) {
+ excludeServices = [excludeServices];
+ }
+
+ const allServices = ["network", "loginctl", "freedesktop", "gamma", "theme.auto", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus", "location"];
+ const filtered = allServices.filter(s => !excludeServices.includes(s));
+ subscribe(filtered);
+ }
+
+ function handleSubscriptionEvent(response) {
+ if (response.error) {
+ if (response.error.includes("unknown method") && response.error.includes("subscribe")) {
+ if (!shownOutdatedError) {
+ console.error("DMSService: Server does not support subscribe method");
+ ToastService.showError(I18n.tr("DMS out of date"), I18n.tr("To update, run the following command:"), updateCommand);
+ shownOutdatedError = true;
+ }
+ }
+ return;
+ }
+
+ if (!response.result) {
+ return;
+ }
+
+ const service = response.result.service;
+ const data = response.result.data;
+
+ if (service === "server") {
+ apiVersion = data.apiVersion || 0;
+ cliVersion = data.cliVersion || "";
+ capabilities = data.capabilities || [];
+
+ console.info("DMSService: Connected (API v" + apiVersion + ", CLI " + cliVersion + ") -", JSON.stringify(capabilities));
+
+ if (apiVersion < expectedApiVersion) {
+ ToastService.showError("DMS server is outdated (API v" + apiVersion + ", expected v" + expectedApiVersion + ")");
+ }
+
+ capabilitiesReceived();
+ } else if (service === "network") {
+ networkStateUpdate(data);
+ } else if (service === "network.credentials") {
+ credentialsRequest(data);
+ } else if (service === "loginctl") {
+ if (data.event) {
+ loginctlEvent(data);
+ } else {
+ loginctlStateUpdate(data);
+ }
+ } else if (service === "bluetooth.pairing") {
+ bluetoothPairingRequest(data);
+ } else if (service === "cups") {
+ cupsStateUpdate(data);
+ } else if (service === "dwl") {
+ dwlStateUpdate(data);
+ } else if (service === "brightness") {
+ brightnessStateUpdate(data);
+ } else if (service === "brightness.update") {
+ if (data.device) {
+ brightnessDeviceUpdate(data.device);
+ }
+ } else if (service === "extworkspace") {
+ extWorkspaceStateUpdate(data);
+ } else if (service === "wlroutput") {
+ wlrOutputStateUpdate(data);
+ } else if (service === "evdev") {
+ if (data.capsLock !== undefined) {
+ capsLockState = data.capsLock;
+ }
+ evdevStateUpdate(data);
+ } else if (service === "gamma") {
+ gammaStateUpdate(data);
+ } else if (service === "theme.auto") {
+ themeAutoStateUpdate(data);
+ } else if (service === "browser.open_requested") {
+ if (data.target) {
+ if (data.requestType === "url" || !data.requestType) {
+ openUrlRequested(data.target);
+ } else {
+ appPickerRequested(data);
+ }
+ } else if (data.url) {
+ openUrlRequested(data.url);
+ }
+ } else if (service === "freedesktop.screensaver") {
+ screensaverInhibited = data.inhibited || false;
+ screensaverInhibitors = data.inhibitors || [];
+ screensaverStateUpdate(data);
+ } else if (service === "dbus") {
+ dbusSignalReceived(data.subscriptionId || "", data);
+ } else if (service === "clipboard") {
+ clipboardStateUpdate(data);
+ } else if (service === "location") {
+ locationStateUpdate(data);
+ }
+ }
+
+ function sendRequest(method, params, callback) {
+ if (!isConnected) {
+ console.warn("DMSService.sendRequest: Not connected, method:", method);
+ if (callback) {
+ callback({
+ "error": "not connected to DMS socket"
+ });
+ }
+ return;
+ }
+
+ requestIdCounter++;
+ const id = Date.now() + requestIdCounter;
+ const request = {
+ "id": id,
+ "method": method
+ };
+
+ if (params) {
+ request.params = params;
+ }
+
+ if (callback)
+ pendingRequests[id] = callback;
+
+ if (method.startsWith("clipboard")) {
+ clipboardRequestIds[id] = true;
+ } else {
+ console.log("DMSService.sendRequest: Sending request id=" + id + " method=" + method);
+ }
+ requestSocket.send(request);
+ }
+
+ function handleResponse(response) {
+ const callback = pendingRequests[response.id];
+
+ if (callback) {
+ delete pendingRequests[response.id];
+ callback(response);
+ }
+ }
+
+ function ping(callback) {
+ sendRequest("ping", null, callback);
+ }
+
+ function listPlugins(callback) {
+ sendRequest("plugins.list", null, response => {
+ if (response.result) {
+ availablePlugins = response.result;
+ pluginsListReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function listInstalled(callback) {
+ sendRequest("plugins.listInstalled", null, response => {
+ if (response.result) {
+ installedPlugins = response.result;
+ installedPluginsReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function search(query, category, compositor, capability, callback) {
+ const params = {
+ "query": query
+ };
+ if (category) {
+ params.category = category;
+ }
+ if (compositor) {
+ params.compositor = compositor;
+ }
+ if (capability) {
+ params.capability = capability;
+ }
+
+ sendRequest("plugins.search", params, response => {
+ if (response.result) {
+ searchResultsReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function install(pluginName, callback) {
+ sendRequest("plugins.install", {
+ "name": pluginName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalled();
+ }
+ });
+ }
+
+ function uninstall(pluginName, callback) {
+ sendRequest("plugins.uninstall", {
+ "name": pluginName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalled();
+ }
+ });
+ }
+
+ function update(pluginName, callback) {
+ sendRequest("plugins.update", {
+ "name": pluginName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalled();
+ }
+ });
+ }
+
+ function listThemes(callback) {
+ sendRequest("themes.list", null, response => {
+ if (response.result) {
+ availableThemes = response.result;
+ themesListReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function listInstalledThemes(callback) {
+ sendRequest("themes.listInstalled", null, response => {
+ if (response.result) {
+ installedThemes = response.result;
+ installedThemesReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function searchThemes(query, callback) {
+ sendRequest("themes.search", {
+ "query": query
+ }, response => {
+ if (response.result) {
+ themeSearchResultsReceived(response.result);
+ }
+ if (callback) {
+ callback(response);
+ }
+ });
+ }
+
+ function installTheme(themeName, callback) {
+ sendRequest("themes.install", {
+ "name": themeName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalledThemes();
+ }
+ });
+ }
+
+ function uninstallTheme(themeName, callback) {
+ sendRequest("themes.uninstall", {
+ "name": themeName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalledThemes();
+ }
+ });
+ }
+
+ function updateTheme(themeName, callback) {
+ sendRequest("themes.update", {
+ "name": themeName
+ }, response => {
+ if (callback) {
+ callback(response);
+ }
+ if (!response.error) {
+ listInstalledThemes();
+ }
+ });
+ }
+
+ function lockSession(callback) {
+ sendRequest("loginctl.lock", null, callback);
+ }
+
+ function unlockSession(callback) {
+ sendRequest("loginctl.unlock", null, callback);
+ }
+
+ function bluetoothPair(devicePath, callback) {
+ sendRequest("bluetooth.pair", {
+ "device": devicePath
+ }, callback);
+ }
+
+ function bluetoothConnect(devicePath, callback) {
+ sendRequest("bluetooth.connect", {
+ "device": devicePath
+ }, callback);
+ }
+
+ function bluetoothDisconnect(devicePath, callback) {
+ sendRequest("bluetooth.disconnect", {
+ "device": devicePath
+ }, callback);
+ }
+
+ function bluetoothRemove(devicePath, callback) {
+ sendRequest("bluetooth.remove", {
+ "device": devicePath
+ }, callback);
+ }
+
+ function bluetoothTrust(devicePath, callback) {
+ sendRequest("bluetooth.trust", {
+ "device": devicePath
+ }, callback);
+ }
+
+ function bluetoothSubmitPairing(token, secrets, accept, callback) {
+ sendRequest("bluetooth.pairing.submit", {
+ "token": token,
+ "secrets": secrets,
+ "accept": accept
+ }, callback);
+ }
+
+ function bluetoothCancelPairing(token, callback) {
+ sendRequest("bluetooth.pairing.cancel", {
+ "token": token
+ }, callback);
+ }
+
+ signal dbusSignalReceived(string subscriptionId, var data)
+
+ property var dbusSubscriptions: ({})
+
+ function dbusCall(bus, dest, path, iface, method, args, callback) {
+ sendRequest("dbus.call", {
+ "bus": bus,
+ "dest": dest,
+ "path": path,
+ "interface": iface,
+ "method": method,
+ "args": args || []
+ }, callback);
+ }
+
+ function dbusGetProperty(bus, dest, path, iface, property, callback) {
+ sendRequest("dbus.getProperty", {
+ "bus": bus,
+ "dest": dest,
+ "path": path,
+ "interface": iface,
+ "property": property
+ }, callback);
+ }
+
+ function dbusSetProperty(bus, dest, path, iface, property, value, callback) {
+ sendRequest("dbus.setProperty", {
+ "bus": bus,
+ "dest": dest,
+ "path": path,
+ "interface": iface,
+ "property": property,
+ "value": value
+ }, callback);
+ }
+
+ function dbusGetAllProperties(bus, dest, path, iface, callback) {
+ sendRequest("dbus.getAllProperties", {
+ "bus": bus,
+ "dest": dest,
+ "path": path,
+ "interface": iface
+ }, callback);
+ }
+
+ function dbusIntrospect(bus, dest, path, callback) {
+ sendRequest("dbus.introspect", {
+ "bus": bus,
+ "dest": dest,
+ "path": path || "/"
+ }, callback);
+ }
+
+ function dbusListNames(bus, callback) {
+ sendRequest("dbus.listNames", {
+ "bus": bus
+ }, callback);
+ }
+
+ function dbusSubscribe(bus, sender, path, iface, member, callback) {
+ sendRequest("dbus.subscribe", {
+ "bus": bus,
+ "sender": sender || "",
+ "path": path || "",
+ "interface": iface || "",
+ "member": member || ""
+ }, response => {
+ if (!response.error && response.result?.subscriptionId) {
+ dbusSubscriptions[response.result.subscriptionId] = true;
+ }
+ if (callback)
+ callback(response);
+ });
+ }
+
+ function dbusUnsubscribe(subscriptionId, callback) {
+ sendRequest("dbus.unsubscribe", {
+ "subscriptionId": subscriptionId
+ }, response => {
+ if (!response.error) {
+ delete dbusSubscriptions[subscriptionId];
+ }
+ if (callback)
+ callback(response);
+ });
+ }
+
+ function renameWorkspace(name, callback) {
+ sendRequest("extworkspace.renameWorkspace", {
+ "name": name
+ }, callback);
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/DSearchService.qml b/raveos-hyprland-theme/theme-data/dms/Services/DSearchService.qml
new file mode 100644
index 0000000..63efcb0
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/DSearchService.qml
@@ -0,0 +1,184 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool dsearchAvailable: false
+ property int searchIdCounter: 0
+ property int indexVersion: 0
+ property bool supportsTypeFilter: false
+ property bool versionChecked: false
+
+ signal searchResultsReceived(var results)
+ signal statsReceived(var stats)
+ signal errorOccurred(string error)
+
+ Process {
+ id: checkProcess
+ command: ["sh", "-c", "command -v dsearch"]
+ running: true
+
+ stdout: SplitParser {
+ onRead: line => {
+ if (line && line.trim().length > 0) {
+ root.dsearchAvailable = true;
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ root.dsearchAvailable = false;
+ } else {
+ root._checkVersion();
+ }
+ }
+ }
+
+ function _checkVersion() {
+ Proc.runCommand("dsearch-version", ["dsearch", "version", "--json"], (stdout, exitCode) => {
+ root.versionChecked = true;
+ if (exitCode !== 0)
+ return;
+ const response = JSON.parse(stdout);
+ root.indexVersion = response.index_schema || 0;
+ root.supportsTypeFilter = root.indexVersion >= 2;
+ });
+ }
+
+ function ping(callback) {
+ if (!dsearchAvailable) {
+ if (callback) {
+ callback({
+ "error": "dsearch not available"
+ });
+ }
+ return;
+ }
+
+ Proc.runCommand("dsearch-ping", ["dsearch", "ping", "--json"], (stdout, exitCode) => {
+ if (callback) {
+ if (exitCode === 0) {
+ try {
+ const response = JSON.parse(stdout);
+ callback({
+ "result": response
+ });
+ } catch (e) {
+ callback({
+ "error": "failed to parse ping response"
+ });
+ }
+ } else {
+ callback({
+ "error": "ping failed"
+ });
+ }
+ }
+ });
+ }
+
+ function search(query, params, callback) {
+ if (!query || query.length === 0) {
+ if (callback) {
+ callback({
+ "error": "query is required"
+ });
+ }
+ return;
+ }
+
+ if (!dsearchAvailable) {
+ if (callback) {
+ callback({
+ "error": "dsearch not available"
+ });
+ }
+ return;
+ }
+
+ const args = ["dsearch", "search", query, "--json"];
+
+ if (params) {
+ if (params.limit !== undefined) {
+ args.push("-n", String(params.limit));
+ }
+ if (params.type) {
+ args.push("-t", params.type);
+ }
+ if (params.ext) {
+ args.push("-e", params.ext);
+ }
+ if (params.folder) {
+ args.push("--folder", params.folder);
+ }
+ if (params.field) {
+ args.push("-f", params.field);
+ }
+ if (params.fuzzy) {
+ args.push("--fuzzy");
+ }
+ if (params.sort) {
+ args.push("--sort", params.sort);
+ }
+ if (params.desc !== undefined) {
+ args.push("--desc=" + (params.desc ? "true" : "false"));
+ }
+ if (params.minSize !== undefined) {
+ args.push("--min-size", String(params.minSize));
+ }
+ if (params.maxSize !== undefined) {
+ args.push("--max-size", String(params.maxSize));
+ }
+ }
+
+ const procId = "dsearch-search-" + (params?.type || "all");
+ Proc.runCommand(procId, args, (stdout, exitCode) => {
+ if (exitCode === 0) {
+ try {
+ const response = JSON.parse(stdout);
+ searchResultsReceived(response);
+ if (callback) {
+ callback({
+ "result": response
+ });
+ }
+ } catch (e) {
+ const error = "failed to parse search response";
+ errorOccurred(error);
+ if (callback) {
+ callback({
+ "error": error
+ });
+ }
+ }
+ } else if (exitCode === 124) {
+ const error = "search timed out";
+ errorOccurred(error);
+ if (callback) {
+ callback({
+ "error": error
+ });
+ }
+ } else {
+ const error = "search failed";
+ errorOccurred(error);
+ if (callback) {
+ callback({
+ "error": error
+ });
+ }
+ }
+ }, 100, 5000);
+ }
+
+ function rediscover() {
+ checkProcess.running = true;
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/DesktopService.qml b/raveos-hyprland-theme/theme-data/dms/Services/DesktopService.qml
new file mode 100644
index 0000000..c9913e9
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/DesktopService.qml
@@ -0,0 +1,94 @@
+pragma Singleton
+
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+Singleton {
+ id: root
+
+ property var _cache: ({})
+
+ function resolveIconPath(moddedAppId) {
+ if (!moddedAppId)
+ return "";
+
+ if (_cache[moddedAppId] !== undefined)
+ return _cache[moddedAppId];
+
+ const result = (function() {
+ // 1. Try heuristic lookup (standard)
+ const entry = DesktopEntries.heuristicLookup(moddedAppId);
+ let icon = Quickshell.iconPath(entry?.icon, true);
+ if (icon && icon !== "")
+ return icon;
+
+ // 2. Try the appId itself as an icon name
+ icon = Quickshell.iconPath(moddedAppId, true);
+ if (icon && icon !== "")
+ return icon;
+
+ // 3. Try variations of the appId (lowercase, last part)
+ const appIds = [moddedAppId.toLowerCase()];
+ const lastPart = moddedAppId.split('.').pop();
+ if (lastPart && lastPart !== moddedAppId) {
+ appIds.push(lastPart);
+ appIds.push(lastPart.toLowerCase());
+ }
+
+ for (const id of appIds) {
+ icon = Quickshell.iconPath(id, true);
+ if (icon && 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 allEntries = DesktopEntries.applications.values;
+ for (let i = 0; i < allEntries.length; i++) {
+ const e = allEntries[i];
+ const eId = (e.id || "").toLowerCase();
+ const eName = (e.name || "").toLowerCase();
+ const eExec = (e.execString || "").toLowerCase();
+
+ if (eId.includes(strippedId) || eName.includes(strippedId) || eExec.includes(strippedId)) {
+ icon = Quickshell.iconPath(e.icon, true);
+ if (icon && icon !== "")
+ return icon;
+ }
+ }
+
+ // 5. Nix/Guix specific store check (as a last resort)
+ for (const appId of appIds) {
+ let execPath = entry?.execString?.replace(/\/bin.*/, "");
+ if (!execPath)
+ continue;
+
+ if (execPath.startsWith("/nix/store/") || execPath.startsWith("/gnu/store/")) {
+ const basePath = execPath;
+ const sizes = ["256x256", "128x128", "64x64", "48x48", "32x32", "24x24", "16x16"];
+
+ let iconPath = `${basePath}/share/icons/hicolor/scalable/apps/${appId}.svg`;
+ icon = Quickshell.iconPath(iconPath, true);
+ if (icon && icon !== "")
+ return icon;
+
+ for (const size of sizes) {
+ iconPath = `${basePath}/share/icons/hicolor/${size}/apps/${appId}.png`;
+ icon = Quickshell.iconPath(iconPath, true);
+ if (icon && icon !== "")
+ return icon;
+ }
+ }
+ }
+
+ return "";
+ })();
+
+ _cache[moddedAppId] = result;
+ return result;
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/DesktopWidgetRegistry.qml b/raveos-hyprland-theme/theme-data/dms/Services/DesktopWidgetRegistry.qml
new file mode 100644
index 0000000..0e2ea6f
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/DesktopWidgetRegistry.qml
@@ -0,0 +1,232 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import qs.Common
+
+Singleton {
+ id: root
+
+ property var registeredWidgets: ({})
+ property var registeredWidgetsList: []
+
+ signal registryChanged
+
+ Component.onCompleted: {
+ registerBuiltins();
+ Qt.callLater(syncPluginWidgets);
+ }
+
+ Connections {
+ target: PluginService
+ function onPluginLoaded(pluginId) {
+ const plugin = PluginService.availablePlugins[pluginId];
+ if (plugin?.type === "desktop")
+ syncPluginWidgets();
+ }
+ function onPluginUnloaded(pluginId) {
+ syncPluginWidgets();
+ }
+ function onPluginListUpdated() {
+ syncPluginWidgets();
+ }
+ }
+
+ function registerBuiltins() {
+ registerWidget({
+ id: "desktopClock",
+ name: I18n.tr("Desktop Clock", "Desktop clock widget name"),
+ icon: "schedule",
+ description: I18n.tr("Analog, digital, or stacked clock display", "Desktop clock widget description"),
+ type: "builtin",
+ component: "qs.Modules.BuiltinDesktopPlugins.DesktopClockWidget",
+ settingsComponent: "qs.Modules.Settings.DesktopWidgetSettings.ClockSettings",
+ defaultConfig: getDefaultClockConfig(),
+ defaultSize: {
+ width: 280,
+ height: 180
+ }
+ });
+
+ registerWidget({
+ id: "systemMonitor",
+ name: I18n.tr("System Monitor", "System monitor widget name"),
+ icon: "monitoring",
+ description: I18n.tr("CPU, memory, network, and disk monitoring", "System monitor widget description"),
+ type: "builtin",
+ component: "qs.Modules.BuiltinDesktopPlugins.SystemMonitorWidget",
+ settingsComponent: "qs.Modules.Settings.DesktopWidgetSettings.SystemMonitorSettings",
+ defaultConfig: getDefaultSystemMonitorConfig(),
+ defaultSize: {
+ width: 320,
+ height: 480
+ }
+ });
+ }
+
+ function getDefaultClockConfig() {
+ return {
+ style: "analog",
+ transparency: 0.8,
+ colorMode: "primary",
+ customColor: "#ffffff",
+ showDate: true,
+ showAnalogNumbers: false,
+ showAnalogSeconds: true,
+ displayPreferences: ["all"]
+ };
+ }
+
+ function getDefaultSystemMonitorConfig() {
+ return {
+ showHeader: true,
+ transparency: 0.8,
+ colorMode: "primary",
+ customColor: "#ffffff",
+ showCpu: true,
+ showCpuGraph: true,
+ showCpuTemp: true,
+ showGpuTemp: false,
+ gpuPciId: "",
+ showMemory: true,
+ showMemoryGraph: true,
+ showNetwork: true,
+ showNetworkGraph: true,
+ showDisk: true,
+ showTopProcesses: false,
+ topProcessCount: 3,
+ topProcessSortBy: "cpu",
+ layoutMode: "auto",
+ graphInterval: 60,
+ displayPreferences: ["all"]
+ };
+ }
+
+ function registerWidget(widgetDef) {
+ if (!widgetDef?.id)
+ return;
+
+ const newMap = Object.assign({}, registeredWidgets);
+ newMap[widgetDef.id] = widgetDef;
+ registeredWidgets = newMap;
+ _updateWidgetsList();
+ registryChanged();
+ }
+
+ function unregisterWidget(widgetId) {
+ if (!registeredWidgets[widgetId])
+ return;
+
+ const newMap = Object.assign({}, registeredWidgets);
+ delete newMap[widgetId];
+ registeredWidgets = newMap;
+ _updateWidgetsList();
+ registryChanged();
+ }
+
+ function getWidget(widgetType) {
+ return registeredWidgets[widgetType] ?? null;
+ }
+
+ function getDefaultConfig(widgetType) {
+ const widget = getWidget(widgetType);
+ if (!widget)
+ return {};
+
+ if (widget.type === "builtin") {
+ switch (widgetType) {
+ case "desktopClock":
+ return getDefaultClockConfig();
+ case "systemMonitor":
+ return getDefaultSystemMonitorConfig();
+ default:
+ return widget.defaultConfig ?? {};
+ }
+ }
+
+ return widget.defaultConfig ?? {};
+ }
+
+ function getDefaultSize(widgetType) {
+ const widget = getWidget(widgetType);
+ return widget?.defaultSize ?? {
+ width: 200,
+ height: 200
+ };
+ }
+
+ function syncPluginWidgets() {
+ const desktopPlugins = PluginService.pluginDesktopComponents;
+ const availablePlugins = PluginService.availablePlugins;
+ const currentPluginIds = [];
+
+ for (const pluginId in desktopPlugins) {
+ currentPluginIds.push(pluginId);
+ const plugin = availablePlugins[pluginId];
+ if (!plugin)
+ continue;
+
+ if (registeredWidgets[pluginId]?.type === "plugin")
+ continue;
+
+ registerWidget({
+ id: pluginId,
+ name: plugin.name || pluginId,
+ icon: plugin.icon || "extension",
+ description: plugin.description || "",
+ type: "plugin",
+ component: null,
+ settingsComponent: plugin.settingsPath || null,
+ defaultConfig: {
+ displayPreferences: ["all"]
+ },
+ defaultSize: {
+ width: 200,
+ height: 200
+ },
+ pluginInfo: plugin
+ });
+ }
+
+ const toRemove = [];
+ for (const widgetId in registeredWidgets) {
+ const widget = registeredWidgets[widgetId];
+ if (widget.type !== "plugin")
+ continue;
+ if (!currentPluginIds.includes(widgetId))
+ toRemove.push(widgetId);
+ }
+
+ for (const widgetId of toRemove) {
+ unregisterWidget(widgetId);
+ }
+ }
+
+ function _updateWidgetsList() {
+ const result = [];
+ for (const key in registeredWidgets) {
+ result.push(registeredWidgets[key]);
+ }
+ result.sort((a, b) => {
+ if (a.type === "builtin" && b.type !== "builtin")
+ return -1;
+ if (a.type !== "builtin" && b.type === "builtin")
+ return 1;
+ return (a.name || "").localeCompare(b.name || "");
+ });
+ registeredWidgetsList = result;
+ }
+
+ function getBuiltinWidgets() {
+ return registeredWidgetsList.filter(w => w.type === "builtin");
+ }
+
+ function getPluginWidgets() {
+ return registeredWidgetsList.filter(w => w.type === "plugin");
+ }
+
+ function getAllWidgets() {
+ return registeredWidgetsList;
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/DgopService.qml b/raveos-hyprland-theme/theme-data/dms/Services/DgopService.qml
new file mode 100644
index 0000000..e63c5c5
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/DgopService.qml
@@ -0,0 +1,778 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property int refCount: 0
+ property int updateInterval: refCount > 0 ? 3000 : 30000
+ property bool isUpdating: false
+ property bool dgopAvailable: false
+
+ property var moduleRefCounts: ({})
+ property var enabledModules: []
+ property var gpuPciIds: []
+ property var gpuPciIdRefCounts: ({})
+ property int processLimit: 20
+ property string processSort: "cpu"
+ property bool noCpu: false
+ property int dgopProcessPid: 0
+
+ // Cursor data for accurate CPU calculations
+ property string cpuCursor: ""
+ property string procCursor: ""
+ property int cpuSampleCount: 0
+ property int processSampleCount: 0
+
+ property real cpuUsage: 0
+ property real cpuFrequency: 0
+ property real cpuTemperature: 0
+ property int cpuCores: 1
+ property string cpuModel: ""
+ property var perCoreCpuUsage: []
+
+ property real memoryUsage: 0
+ property real totalMemoryMB: 0
+ property real usedMemoryMB: 0
+ property real freeMemoryMB: 0
+ property real availableMemoryMB: 0
+ property int totalMemoryKB: 0
+ property int usedMemoryKB: 0
+ property int totalSwapKB: 0
+ property int usedSwapKB: 0
+
+ property real networkRxRate: 0
+ property real networkTxRate: 0
+ property var lastNetworkStats: null
+ property var networkInterfaces: []
+
+ property real diskReadRate: 0
+ property real diskWriteRate: 0
+ property var lastDiskStats: null
+ property var diskMounts: []
+ property var diskDevices: []
+
+ property var processes: []
+ property var allProcesses: []
+ property string currentSort: "cpu"
+ property bool sortAscending: false
+ property var availableGpus: []
+
+ property string kernelVersion: ""
+ property string distribution: ""
+ property string hostname: ""
+ property string architecture: ""
+ property string loadAverage: ""
+ property int processCount: 0
+ property int threadCount: 0
+ property string bootTime: ""
+ property string motherboard: ""
+ property string biosVersion: ""
+ property string uptime: ""
+ property string shortUptime: ""
+
+ property int historySize: 60
+ property var cpuHistory: []
+ property var memoryHistory: []
+ property var networkHistory: ({
+ "rx": [],
+ "tx": []
+ })
+ property var diskHistory: ({
+ "read": [],
+ "write": []
+ })
+
+ function addRef(modules = null) {
+ refCount++;
+ let modulesChanged = false;
+
+ if (modules) {
+ const modulesToAdd = Array.isArray(modules) ? modules : [modules];
+ for (const module of modulesToAdd) {
+ const currentCount = moduleRefCounts[module] || 0;
+ moduleRefCounts[module] = currentCount + 1;
+
+ // Add to enabled modules if not already there
+ if (enabledModules.indexOf(module) === -1) {
+ enabledModules.push(module);
+ modulesChanged = true;
+ }
+ }
+ }
+
+ if (modulesChanged || refCount === 1) {
+ enabledModules = enabledModules.slice(); // Force property change
+ moduleRefCounts = Object.assign({}, moduleRefCounts); // Force property change
+ updateAllStats();
+ } else if (gpuPciIds.length > 0 && refCount > 0) {
+ // If we have GPU PCI IDs and active modules, make sure to update
+ // This handles the case where PCI IDs were loaded after modules were added
+ updateAllStats();
+ }
+ }
+
+ function removeRef(modules = null) {
+ refCount = Math.max(0, refCount - 1);
+ let modulesChanged = false;
+
+ if (modules) {
+ const modulesToRemove = Array.isArray(modules) ? modules : [modules];
+ for (const module of modulesToRemove) {
+ const currentCount = moduleRefCounts[module] || 0;
+ if (currentCount > 1) {
+ moduleRefCounts[module] = currentCount - 1;
+ } else if (currentCount === 1) {
+ delete moduleRefCounts[module];
+ const index = enabledModules.indexOf(module);
+ if (index > -1) {
+ enabledModules.splice(index, 1);
+ modulesChanged = true;
+ }
+ }
+ }
+ }
+
+ if (modulesChanged) {
+ enabledModules = enabledModules.slice(); // Force property change
+ moduleRefCounts = Object.assign({}, moduleRefCounts); // Force property change
+
+ // Clear cursor data when CPU or process modules are no longer active
+ if (!enabledModules.includes("cpu")) {
+ cpuCursor = "";
+ cpuSampleCount = 0;
+ }
+ if (!enabledModules.includes("processes")) {
+ procCursor = "";
+ processSampleCount = 0;
+ }
+ }
+ }
+
+ function setGpuPciIds(pciIds) {
+ gpuPciIds = Array.isArray(pciIds) ? pciIds : [];
+ }
+
+ function addGpuPciId(pciId) {
+ const currentCount = gpuPciIdRefCounts[pciId] || 0;
+ gpuPciIdRefCounts[pciId] = currentCount + 1;
+
+ // Add to gpuPciIds array if not already there
+ if (!gpuPciIds.includes(pciId)) {
+ gpuPciIds = gpuPciIds.concat([pciId]);
+ }
+
+ gpuPciIdRefCounts = Object.assign({}, gpuPciIdRefCounts);
+ }
+
+ function removeGpuPciId(pciId) {
+ const currentCount = gpuPciIdRefCounts[pciId] || 0;
+ if (currentCount > 1) {
+ gpuPciIdRefCounts[pciId] = currentCount - 1;
+ } else if (currentCount === 1) {
+ // Remove completely when count reaches 0
+ delete gpuPciIdRefCounts[pciId];
+ const index = gpuPciIds.indexOf(pciId);
+ if (index > -1) {
+ gpuPciIds = gpuPciIds.slice();
+ gpuPciIds.splice(index, 1);
+ }
+
+ // Clear temperature data for this GPU when no longer monitored
+ if (availableGpus && availableGpus.length > 0) {
+ const updatedGpus = availableGpus.slice();
+ for (var i = 0; i < updatedGpus.length; i++) {
+ if (updatedGpus[i].pciId === pciId) {
+ updatedGpus[i] = Object.assign({}, updatedGpus[i], {
+ "temperature": 0
+ });
+ }
+ }
+ availableGpus = updatedGpus;
+ }
+ }
+
+ // Force property change notification
+ gpuPciIdRefCounts = Object.assign({}, gpuPciIdRefCounts);
+ }
+
+ function setProcessOptions(limit = 20, sort = "cpu", disableCpu = false) {
+ processLimit = limit;
+ processSort = sort;
+ noCpu = disableCpu;
+ }
+
+ function updateAllStats() {
+ if (dgopAvailable && refCount > 0 && enabledModules.length > 0) {
+ isUpdating = true;
+ dgopProcess.running = true;
+ } else {
+ isUpdating = false;
+ }
+ }
+
+ function initializeGpuMetadata() {
+ if (!dgopAvailable)
+ return;
+ gpuInitProcess.running = true;
+ }
+
+ function initializeSystemMetadata() {
+ if (!dgopAvailable)
+ return;
+ systemInitProcess.running = true;
+ }
+
+ function buildDgopCommand() {
+ const cmd = ["dgop", "meta", "--json"];
+
+ if (enabledModules.length === 0) {
+ // Don't run if no modules are needed
+ return [];
+ }
+
+ // Replace 'gpu' with 'gpu-temp' when we have PCI IDs to monitor
+ const finalModules = [];
+ for (const module of enabledModules) {
+ if (module === "gpu" && gpuPciIds.length > 0) {
+ finalModules.push("gpu-temp");
+ } else if (module !== "gpu") {
+ finalModules.push(module);
+ }
+ }
+
+ // Add gpu-temp module automatically when we have PCI IDs to monitor
+ if (gpuPciIds.length > 0 && finalModules.indexOf("gpu-temp") === -1) {
+ finalModules.push("gpu-temp");
+ }
+
+ if (enabledModules.indexOf("all") !== -1) {
+ cmd.push("--modules", "all");
+ } else if (finalModules.length > 0) {
+ const moduleList = finalModules.join(",");
+ cmd.push("--modules", moduleList);
+ } else {
+ return [];
+ }
+
+ // Add cursor data if available for accurate CPU percentages
+ if ((enabledModules.includes("cpu") || enabledModules.includes("all")) && cpuCursor) {
+ cmd.push("--cpu-cursor", cpuCursor);
+ }
+ if ((enabledModules.includes("processes") || enabledModules.includes("all")) && procCursor) {
+ cmd.push("--proc-cursor", procCursor);
+ }
+
+ if (gpuPciIds.length > 0) {
+ cmd.push("--gpu-pci-ids", gpuPciIds.join(","));
+ }
+
+ if (enabledModules.indexOf("processes") !== -1 || enabledModules.indexOf("all") !== -1) {
+ cmd.push("--limit", "100"); // Get more data for client sorting
+ cmd.push("--sort", "cpu"); // Always get CPU sorted data
+ if (noCpu) {
+ cmd.push("--no-cpu");
+ }
+ }
+
+ return cmd;
+ }
+
+ function parseData(data) {
+ if (data.cpu) {
+ const cpu = data.cpu;
+ cpuSampleCount++;
+
+ cpuUsage = Math.round((cpu.usage || 0) * 10) / 10;
+ cpuFrequency = Math.round(cpu.frequency || 0);
+ cpuTemperature = Math.round(cpu.temperature || 0);
+ cpuCores = cpu.count || 1;
+ cpuModel = cpu.model || "";
+ perCoreCpuUsage = cpu.coreUsage || [];
+ addToHistory(cpuHistory, cpuUsage);
+
+ if (cpu.cursor) {
+ cpuCursor = cpu.cursor;
+ }
+ }
+
+ if (data.memory) {
+ const mem = data.memory;
+ const totalKB = mem.total || 0;
+ const availableKB = mem.available || 0;
+ const freeKB = mem.free || 0;
+ const usedKB = mem.used !== undefined ? mem.used : (totalKB - availableKB);
+
+ totalMemoryMB = Math.round(totalKB / 1024);
+ availableMemoryMB = Math.round(availableKB / 1024);
+ freeMemoryMB = Math.round(freeKB / 1024);
+ usedMemoryMB = Math.round(usedKB / 1024);
+ const rawMemUsage = mem.usedPercent !== undefined ? mem.usedPercent : (totalKB > 0 ? ((totalKB - availableKB) / totalKB) * 100 : 0);
+ memoryUsage = Math.round(rawMemUsage * 10) / 10;
+
+ totalMemoryKB = totalKB;
+ usedMemoryKB = usedKB;
+ totalSwapKB = mem.swaptotal || 0;
+ usedSwapKB = (mem.swaptotal || 0) - (mem.swapfree || 0);
+
+ addToHistory(memoryHistory, memoryUsage);
+ }
+
+ if (data.network && Array.isArray(data.network)) {
+ networkInterfaces = data.network;
+
+ let totalRx = 0;
+ let totalTx = 0;
+ for (const iface of data.network) {
+ totalRx += iface.rx || 0;
+ totalTx += iface.tx || 0;
+ }
+
+ if (lastNetworkStats) {
+ const timeDiff = updateInterval / 1000;
+ const rxDiff = totalRx - lastNetworkStats.rx;
+ const txDiff = totalTx - lastNetworkStats.tx;
+ networkRxRate = Math.max(0, rxDiff / timeDiff);
+ networkTxRate = Math.max(0, txDiff / timeDiff);
+ addToHistory(networkHistory.rx, networkRxRate / 1024);
+ addToHistory(networkHistory.tx, networkTxRate / 1024);
+ }
+ lastNetworkStats = {
+ "rx": totalRx,
+ "tx": totalTx
+ };
+ }
+
+ if (data.disk && Array.isArray(data.disk)) {
+ diskDevices = data.disk;
+
+ let totalRead = 0;
+ let totalWrite = 0;
+ for (const disk of data.disk) {
+ totalRead += (disk.read || 0) * 512;
+ totalWrite += (disk.write || 0) * 512;
+ }
+
+ if (lastDiskStats) {
+ const timeDiff = updateInterval / 1000;
+ const readDiff = totalRead - lastDiskStats.read;
+ const writeDiff = totalWrite - lastDiskStats.write;
+ diskReadRate = Math.max(0, readDiff / timeDiff);
+ diskWriteRate = Math.max(0, writeDiff / timeDiff);
+ addToHistory(diskHistory.read, diskReadRate / (1024 * 1024));
+ addToHistory(diskHistory.write, diskWriteRate / (1024 * 1024));
+ }
+ lastDiskStats = {
+ "read": totalRead,
+ "write": totalWrite
+ };
+ }
+
+ if (data.diskmounts) {
+ diskMounts = data.diskmounts || [];
+ }
+
+ if (data.processes && Array.isArray(data.processes)) {
+ const newProcesses = [];
+ processSampleCount++;
+ const ourPid = dgopProcessPid;
+
+ for (const proc of data.processes) {
+ if (ourPid > 0 && proc.pid === ourPid)
+ continue;
+
+ const cpuUsage = processSampleCount >= 2 ? (proc.cpu || 0) : 0;
+
+ newProcesses.push({
+ "pid": proc.pid || 0,
+ "ppid": proc.ppid || 0,
+ "cpu": cpuUsage,
+ "memoryPercent": proc.memoryPercent || proc.pssPercent || 0,
+ "memoryKB": proc.memoryKB || proc.pssKB || 0,
+ "command": proc.command || "",
+ "fullCommand": proc.fullCommand || "",
+ "username": proc.username || "",
+ "displayName": (proc.command && proc.command.length > 15) ? proc.command.substring(0, 15) + "..." : (proc.command || "")
+ });
+ }
+ allProcesses = newProcesses;
+ applySorting();
+
+ if (data.cursor) {
+ procCursor = data.cursor;
+ }
+ }
+
+ const gpuData = (data.gpu && data.gpu.gpus) || data.gpus;
+ if (gpuData && Array.isArray(gpuData)) {
+ // Check if this is temperature update data (has PCI IDs being monitored)
+ if (gpuPciIds.length > 0 && availableGpus && availableGpus.length > 0) {
+ // This is temperature data - merge with existing GPU metadata
+ const updatedGpus = availableGpus.slice();
+ for (var i = 0; i < updatedGpus.length; i++) {
+ const existingGpu = updatedGpus[i];
+ const tempGpu = gpuData.find(g => g.pciId === existingGpu.pciId);
+ // Only update temperature if this GPU's PCI ID is being monitored
+ if (tempGpu && gpuPciIds.includes(existingGpu.pciId)) {
+ updatedGpus[i] = Object.assign({}, existingGpu, {
+ "temperature": tempGpu.temperature || 0
+ });
+ }
+ }
+ availableGpus = updatedGpus;
+ } else {
+ // This is initial GPU metadata - set the full list
+ const gpuList = [];
+ for (const gpu of gpuData) {
+ let displayName = gpu.displayName || gpu.name || "Unknown GPU";
+ let fullName = gpu.fullName || gpu.name || "Unknown GPU";
+
+ gpuList.push({
+ "driver": gpu.driver || "",
+ "vendor": gpu.vendor || "",
+ "displayName": displayName,
+ "fullName": fullName,
+ "pciId": gpu.pciId || "",
+ "temperature": gpu.temperature || 0
+ });
+ }
+ availableGpus = gpuList;
+ }
+ }
+
+ if (data.system) {
+ const sys = data.system;
+ loadAverage = sys.loadavg || "";
+ processCount = sys.processes || 0;
+ threadCount = sys.threads || 0;
+ bootTime = sys.boottime || "";
+ updateUptime();
+ }
+
+ const hwData = data.hardware || (data.hostname || data.kernel || data.distro || data.arch) ? data : null;
+ if (hwData) {
+ hostname = hwData.hostname || "";
+ kernelVersion = hwData.kernel || "";
+ distribution = hwData.distro || "";
+ architecture = hwData.arch || "";
+ motherboard = (hwData.bios && hwData.bios.motherboard) || "";
+ biosVersion = (hwData.bios && hwData.bios.version) || "";
+ }
+
+ isUpdating = false;
+ }
+
+ function addToHistory(array, value) {
+ array.push(value);
+ if (array.length > historySize) {
+ array.shift();
+ }
+ }
+
+ function getProcessIcon(command) {
+ const cmd = command.toLowerCase();
+ if (cmd.includes("firefox") || cmd.includes("chrome") || cmd.includes("browser") || cmd.includes("chromium")) {
+ return "web";
+ }
+ if (cmd.includes("code") || cmd.includes("editor") || cmd.includes("vim")) {
+ return "code";
+ }
+ if (cmd.includes("terminal") || cmd.includes("bash") || cmd.includes("zsh")) {
+ return "terminal";
+ }
+ if (cmd.includes("music") || cmd.includes("audio") || cmd.includes("spotify")) {
+ return "music_note";
+ }
+ if (cmd.includes("video") || cmd.includes("vlc") || cmd.includes("mpv")) {
+ return "play_circle";
+ }
+ if (cmd.includes("systemd") || cmd.includes("elogind") || cmd.includes("kernel") || cmd.includes("kthread") || cmd.includes("kworker")) {
+ return "settings";
+ }
+ return "memory";
+ }
+
+ function formatCpuUsage(cpu) {
+ return (cpu || 0).toFixed(1) + "%";
+ }
+
+ function formatMemoryUsage(memoryKB) {
+ const mem = memoryKB || 0;
+ if (mem < 1024) {
+ return mem.toFixed(0) + " KB";
+ } else if (mem < 1024 * 1024) {
+ return (mem / 1024).toFixed(1) + " MB";
+ } else {
+ return (mem / (1024 * 1024)).toFixed(1) + " GB";
+ }
+ }
+
+ function formatSystemMemory(memoryKB) {
+ const mem = memoryKB || 0;
+ if (mem === 0) {
+ return "--";
+ }
+ if (mem < 1024 * 1024) {
+ return (mem / 1024).toFixed(0) + " MB";
+ } else {
+ return (mem / (1024 * 1024)).toFixed(1) + " GB";
+ }
+ }
+
+ function killProcess(pid) {
+ if (pid > 0) {
+ Quickshell.execDetached("kill", [pid.toString()]);
+ }
+ }
+
+ function updateUptime() {
+ if (!bootTime) {
+ uptime = "";
+ shortUptime = "";
+ return;
+ }
+
+ const bootDate = new Date(bootTime.replace(" ", "T"));
+ if (isNaN(bootDate.getTime())) {
+ uptime = "";
+ shortUptime = "";
+ return;
+ }
+
+ const now = new Date();
+ const seconds = Math.floor((now - bootDate) / 1000);
+ const days = Math.floor(seconds / 86400);
+ const hours = Math.floor((seconds % 86400) / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+
+ const parts = [];
+ if (days > 0)
+ parts.push(`${days} day${days === 1 ? "" : "s"}`);
+ if (hours > 0)
+ parts.push(`${hours} hour${hours === 1 ? "" : "s"}`);
+ if (minutes > 0)
+ parts.push(`${minutes} minute${minutes === 1 ? "" : "s"}`);
+
+ uptime = parts.length > 0 ? `up ${parts.join(", ")}` : `up ${seconds} seconds`;
+
+ var shortStr = "up";
+ if (days > 0)
+ shortStr += ` ${days}d`;
+ if (hours > 0)
+ shortStr += ` ${hours}h`;
+ if (minutes > 0)
+ shortStr += ` ${minutes}m`;
+ shortUptime = shortStr;
+ }
+
+ function setSortBy(newSortBy) {
+ if (newSortBy !== currentSort) {
+ currentSort = newSortBy;
+ sortAscending = false;
+ applySorting();
+ }
+ }
+
+ function toggleSort(column) {
+ if (column === currentSort) {
+ sortAscending = !sortAscending;
+ } else {
+ currentSort = column;
+ sortAscending = false;
+ }
+ applySorting();
+ }
+
+ function applySorting() {
+ if (!allProcesses || allProcesses.length === 0)
+ return;
+
+ const asc = sortAscending;
+ const sorted = allProcesses.slice();
+ sorted.sort((a, b) => {
+ let valueA, valueB, result;
+
+ switch (currentSort) {
+ case "cpu":
+ valueA = a.cpu || 0;
+ valueB = b.cpu || 0;
+ result = valueB - valueA;
+ break;
+ case "memory":
+ valueA = a.memoryKB || 0;
+ valueB = b.memoryKB || 0;
+ result = valueB - valueA;
+ break;
+ case "name":
+ valueA = (a.command || "").toLowerCase();
+ valueB = (b.command || "").toLowerCase();
+ result = valueA.localeCompare(valueB);
+ break;
+ case "pid":
+ valueA = a.pid || 0;
+ valueB = b.pid || 0;
+ result = valueA - valueB;
+ break;
+ default:
+ return 0;
+ }
+ return asc ? -result : result;
+ });
+
+ processes = sorted.slice(0, processLimit);
+ }
+
+ Timer {
+ id: updateTimer
+ interval: root.updateInterval
+ running: root.dgopAvailable && root.refCount > 0 && root.enabledModules.length > 0
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: root.updateAllStats()
+ }
+
+ Process {
+ id: dgopProcess
+ command: root.buildDgopCommand()
+ running: false
+ onStarted: dgopProcessPid = processId ?? 0
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ console.warn("Dgop process failed with exit code:", exitCode);
+ isUpdating = false;
+ }
+ }
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ try {
+ const data = JSON.parse(text.trim());
+ parseData(data);
+ } catch (e) {
+ console.warn("Failed to parse dgop JSON:", e);
+ console.warn("Raw text was:", text.substring(0, 200));
+ isUpdating = false;
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: gpuInitProcess
+ command: ["dgop", "gpu", "--json"]
+ running: false
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ console.warn("GPU init process failed with exit code:", exitCode);
+ }
+ }
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ try {
+ const data = JSON.parse(text.trim());
+ parseData(data);
+ } catch (e) {
+ console.warn("Failed to parse GPU init JSON:", e);
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: systemInitProcess
+ command: ["dgop", "hardware", "--json"]
+ running: false
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ console.warn("System init process failed with exit code:", exitCode);
+ }
+ }
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ try {
+ const data = JSON.parse(text.trim());
+ parseData(data);
+ } catch (e) {
+ console.warn("Failed to parse system init JSON:", e);
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: dgopCheckProcess
+ command: ["which", "dgop"]
+ running: false
+ onExited: exitCode => {
+ dgopAvailable = (exitCode === 0);
+ if (dgopAvailable) {
+ initializeGpuMetadata();
+ initializeSystemMetadata();
+ if (SessionData.enabledGpuPciIds && SessionData.enabledGpuPciIds.length > 0) {
+ for (const pciId of SessionData.enabledGpuPciIds) {
+ addGpuPciId(pciId);
+ }
+ if (refCount > 0 && enabledModules.length > 0) {
+ updateAllStats();
+ }
+ }
+ } else {
+ console.warn("dgop is not installed or not in PATH");
+ }
+ }
+ }
+
+ Process {
+ id: osReleaseProcess
+ command: ["cat", "/etc/os-release"]
+ running: false
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ console.warn("Failed to read /etc/os-release");
+ }
+ }
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.trim()) {
+ try {
+ const lines = text.trim().split('\n');
+ let prettyName = "";
+ let name = "";
+
+ for (const line of lines) {
+ const trimmedLine = line.trim();
+ if (trimmedLine.startsWith('PRETTY_NAME=')) {
+ prettyName = trimmedLine.substring(12).replace(/^["']|["']$/g, '');
+ } else if (trimmedLine.startsWith('NAME=')) {
+ name = trimmedLine.substring(5).replace(/^["']|["']$/g, '');
+ }
+ }
+
+ // Prefer PRETTY_NAME, fallback to NAME
+ const distroName = prettyName || name || "Linux";
+ distribution = distroName;
+ console.info("Detected distribution:", distroName);
+ } catch (e) {
+ console.warn("Failed to parse /etc/os-release:", e);
+ distribution = "Linux";
+ }
+ }
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ dgopCheckProcess.running = true;
+ osReleaseProcess.running = true;
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/DisplayService.qml b/raveos-hyprland-theme/theme-data/dms/Services/DisplayService.qml
new file mode 100644
index 0000000..553dd26
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/DisplayService.qml
@@ -0,0 +1,1170 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool brightnessAvailable: devices.length > 0
+ property var devices: []
+ property var deviceBrightness: ({})
+ property var deviceBrightnessUserSet: ({})
+ property var deviceMaxCache: ({})
+ property var userControlledDevices: ({})
+ property var pendingOsdDevices: ({})
+ property int brightnessVersion: 0
+ property string currentDevice: ""
+ property string lastIpcDevice: ""
+ property int brightnessLevel: {
+ brightnessVersion;
+ const deviceToUse = lastIpcDevice === "" ? getDefaultDevice() : (lastIpcDevice || currentDevice);
+ if (!deviceToUse) {
+ return 50;
+ }
+
+ return getDeviceBrightness(deviceToUse);
+ }
+ property int maxBrightness: 100
+ property bool brightnessInitialized: false
+ property bool suppressOsd: true
+
+ signal brightnessChanged(bool showOsd)
+ signal deviceSwitched
+
+ property bool nightModeActive: nightModeEnabled
+
+ property bool nightModeEnabled: false
+ property bool automationAvailable: false
+ property bool gammaControlAvailable: false
+
+ property var gammaState: ({})
+ property int gammaCurrentTemp: gammaState?.currentTemp ?? 0
+ property string gammaNextTransition: gammaState?.nextTransition ?? ""
+ property string gammaSunriseTime: gammaState?.sunriseTime ?? ""
+ property string gammaSunsetTime: gammaState?.sunsetTime ?? ""
+ property string gammaDawnTime: gammaState?.dawnTime ?? ""
+ property string gammaNightTime: gammaState?.nightTime ?? ""
+ property bool gammaIsDay: gammaState?.isDay ?? true
+ property real gammaSunPosition: gammaState?.sunPosition ?? 0
+ property int gammaLowTemp: gammaState?.config?.LowTemp ?? 0
+ property int gammaHighTemp: gammaState?.config?.HighTemp ?? 0
+
+ function markDeviceUserControlled(deviceId) {
+ const newControlled = Object.assign({}, userControlledDevices);
+ newControlled[deviceId] = Date.now();
+ userControlledDevices = newControlled;
+ }
+
+ function isDeviceUserControlled(deviceId) {
+ const controlTime = userControlledDevices[deviceId];
+ if (!controlTime) {
+ return false;
+ }
+ return (Date.now() - controlTime) < 1000;
+ }
+
+ function clearDeviceUserControlled(deviceId) {
+ const newControlled = Object.assign({}, userControlledDevices);
+ delete newControlled[deviceId];
+ userControlledDevices = newControlled;
+ }
+
+ function markDevicePendingOsd(deviceId) {
+ const newPending = Object.assign({}, pendingOsdDevices);
+ newPending[deviceId] = true;
+ pendingOsdDevices = newPending;
+ }
+
+ function clearDevicePendingOsd(deviceId) {
+ const newPending = Object.assign({}, pendingOsdDevices);
+ delete newPending[deviceId];
+ pendingOsdDevices = newPending;
+ }
+
+ function updateSingleDevice(device) {
+ if (device.class === "leds") {
+ return;
+ }
+
+ const isUserControlled = isDeviceUserControlled(device.id);
+ if (isUserControlled) {
+ return;
+ }
+
+ const deviceIndex = devices.findIndex(d => d.id === device.id);
+ if (deviceIndex !== -1) {
+ const newDevices = [...devices];
+ const cachedMax = deviceMaxCache[device.id];
+
+ let displayMax = cachedMax || (device.class === "ddc" ? device.max : 100);
+ if (displayMax > 0 && !cachedMax) {
+ const newCache = Object.assign({}, deviceMaxCache);
+ newCache[device.id] = displayMax;
+ deviceMaxCache = newCache;
+ }
+
+ newDevices[deviceIndex] = {
+ "id": device.id,
+ "name": device.id,
+ "class": device.class,
+ "current": device.current,
+ "percentage": device.currentPercent,
+ "max": device.max,
+ "backend": device.backend,
+ "displayMax": displayMax
+ };
+ devices = newDevices;
+ }
+
+ const isExponential = SessionData.getBrightnessExponential(device.id);
+ const userSetValue = deviceBrightnessUserSet[device.id];
+
+ let displayValue = device.currentPercent;
+ if (isExponential) {
+ if (userSetValue !== undefined) {
+ const exponent = SessionData.getBrightnessExponent(device.id);
+ const expectedHardware = Math.round(Math.pow(userSetValue / 100.0, exponent) * 100.0);
+ if (Math.abs(device.currentPercent - expectedHardware) > 2) {
+ const newUserSet = Object.assign({}, deviceBrightnessUserSet);
+ delete newUserSet[device.id];
+ deviceBrightnessUserSet = newUserSet;
+ SessionData.clearBrightnessUserSetValue(device.id);
+ displayValue = linearToExponential(device.currentPercent, device.id);
+ } else {
+ displayValue = userSetValue;
+ }
+ } else {
+ displayValue = linearToExponential(device.currentPercent, device.id);
+ }
+ }
+
+ const oldValue = deviceBrightness[device.id];
+ const newBrightness = Object.assign({}, deviceBrightness);
+ newBrightness[device.id] = displayValue;
+ deviceBrightness = newBrightness;
+ brightnessVersion++;
+
+ const isPendingOsd = pendingOsdDevices[device.id] === true;
+ if (isPendingOsd) {
+ clearDevicePendingOsd(device.id);
+ if (!suppressOsd) {
+ brightnessChanged(true);
+ }
+ return;
+ }
+
+ if (!brightnessInitialized || oldValue === displayValue) {
+ return;
+ }
+ if (suppressOsd) {
+ return;
+ }
+ brightnessChanged(true);
+ }
+
+ function updateFromBrightnessState(state) {
+ if (!state || !state.devices) {
+ return;
+ }
+
+ const newMaxCache = Object.assign({}, deviceMaxCache);
+ devices = state.devices.map(d => {
+ const cachedMax = deviceMaxCache[d.id];
+ let displayMax = cachedMax || (d.class === "ddc" ? d.max : 100);
+ if (displayMax > 0 && !cachedMax) {
+ newMaxCache[d.id] = displayMax;
+ }
+ return {
+ "id": d.id,
+ "name": d.id,
+ "class": d.class,
+ "current": d.current,
+ "percentage": d.currentPercent,
+ "max": d.max,
+ "backend": d.backend,
+ "displayMax": displayMax
+ };
+ });
+ deviceMaxCache = newMaxCache;
+
+ const newBrightness = {};
+ let anyDeviceBrightnessChanged = false;
+
+ for (const device of state.devices) {
+ const isExponential = SessionData.getBrightnessExponential(device.id);
+ const userSetValue = deviceBrightnessUserSet[device.id];
+ const oldValue = deviceBrightness[device.id];
+
+ if (isExponential) {
+ if (userSetValue !== undefined) {
+ newBrightness[device.id] = userSetValue;
+ } else {
+ newBrightness[device.id] = linearToExponential(device.currentPercent, device.id);
+ }
+ } else {
+ newBrightness[device.id] = device.currentPercent;
+ }
+
+ const newValue = newBrightness[device.id];
+ if (oldValue !== undefined && oldValue !== newValue) {
+ anyDeviceBrightnessChanged = true;
+ }
+ }
+ deviceBrightness = newBrightness;
+ brightnessVersion++;
+
+ brightnessAvailable = devices.length > 0;
+
+ if (devices.length > 0 && !currentDevice) {
+ const lastDevice = SessionData.lastBrightnessDevice || "";
+ const deviceExists = devices.some(d => d.id === lastDevice);
+ if (deviceExists) {
+ setCurrentDevice(lastDevice, false);
+ } else {
+ const backlight = devices.find(d => d.class === "backlight");
+ const nonKbdDevice = devices.find(d => !d.id.includes("kbd"));
+ const defaultDevice = backlight || nonKbdDevice || devices[0];
+ setCurrentDevice(defaultDevice.id, false);
+ }
+ }
+
+ const shouldShowOsd = brightnessInitialized && anyDeviceBrightnessChanged;
+
+ if (!brightnessInitialized) {
+ brightnessInitialized = true;
+ }
+
+ if (shouldShowOsd) {
+ brightnessChanged(true);
+ }
+ }
+
+ function setBrightness(percentage, device, suppressOsd) {
+ const actualDevice = device === "" ? getDefaultDevice() : (device || currentDevice || getDefaultDevice());
+ if (!actualDevice) {
+ console.warn("DisplayService: No device selected for brightness change");
+ return;
+ }
+
+ if (actualDevice !== lastIpcDevice) {
+ lastIpcDevice = actualDevice;
+ }
+
+ const deviceInfo = getCurrentDeviceInfoByName(actualDevice);
+ const isExponential = SessionData.getBrightnessExponential(actualDevice);
+
+ let minValue = 0;
+ let maxValue = 100;
+
+ switch (true) {
+ case isExponential:
+ minValue = 1;
+ maxValue = 100;
+ break;
+ default:
+ minValue = (deviceInfo && (deviceInfo.class === "backlight" || deviceInfo.class === "ddc")) ? 1 : 0;
+ maxValue = deviceInfo?.displayMax || 100;
+ break;
+ }
+
+ if (maxValue <= 0) {
+ console.warn("DisplayService: Invalid max value for device", actualDevice, "- skipping brightness change");
+ return;
+ }
+
+ const clampedValue = Math.max(minValue, Math.min(maxValue, percentage));
+
+ if (!DMSService.isConnected) {
+ console.warn("DisplayService: Not connected to DMS");
+ return;
+ }
+
+ const isLedDevice = deviceInfo?.class === "leds";
+
+ if (suppressOsd) {
+ markDeviceUserControlled(actualDevice);
+ } else if (!isLedDevice) {
+ markDevicePendingOsd(actualDevice);
+ }
+
+ const newBrightness = Object.assign({}, deviceBrightness);
+ newBrightness[actualDevice] = clampedValue;
+ deviceBrightness = newBrightness;
+ brightnessVersion++;
+
+ if (isLedDevice && !suppressOsd) {
+ brightnessChanged(true);
+ }
+
+ if (isExponential) {
+ const newUserSet = Object.assign({}, deviceBrightnessUserSet);
+ newUserSet[actualDevice] = clampedValue;
+ deviceBrightnessUserSet = newUserSet;
+ SessionData.setBrightnessUserSetValue(actualDevice, clampedValue);
+ }
+
+ const params = {
+ "device": actualDevice,
+ "percent": clampedValue
+ };
+ if (isExponential) {
+ params.exponential = true;
+ params.exponent = SessionData.getBrightnessExponent(actualDevice);
+ }
+
+ DMSService.sendRequest("brightness.setBrightness", params, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to set brightness:", response.error);
+ ToastService.showError(I18n.tr("Failed to set brightness"), response.error, "", "brightness");
+ } else {
+ ToastService.dismissCategory("brightness");
+ }
+ });
+ }
+
+ function setCurrentDevice(deviceName, saveToSession = false) {
+ if (currentDevice === deviceName) {
+ return;
+ }
+
+ currentDevice = deviceName;
+ lastIpcDevice = deviceName;
+
+ if (saveToSession) {
+ SessionData.setLastBrightnessDevice(deviceName);
+ }
+
+ deviceSwitched();
+ }
+
+ function getDeviceBrightness(deviceName) {
+ if (!deviceName) {
+ return 50;
+ }
+
+ if (deviceName in deviceBrightness) {
+ return deviceBrightness[deviceName];
+ }
+
+ return 50;
+ }
+
+ function linearToExponential(linearPercent, deviceName) {
+ const exponent = SessionData.getBrightnessExponent(deviceName);
+ const hardwarePercent = linearPercent / 100.0;
+ const normalizedPercent = Math.pow(hardwarePercent, 1.0 / exponent);
+ return Math.round(normalizedPercent * 100.0);
+ }
+
+ function getDefaultDevice() {
+ for (const device of devices) {
+ if (device.class === "backlight") {
+ return device.id;
+ }
+ }
+ return devices.length > 0 ? devices[0].id : "";
+ }
+
+ function getPinnedDeviceForFocusedScreen() {
+ const focusedScreen = CompositorService.getFocusedScreen();
+ if (!focusedScreen)
+ return "";
+
+ const pins = SettingsData.brightnessDevicePins || {};
+ const screenKey = SettingsData.getScreenDisplayName(focusedScreen);
+ if (!screenKey)
+ return "";
+
+ const pinnedDevice = pins[screenKey];
+ if (!pinnedDevice)
+ return "";
+
+ const deviceExists = devices.some(d => d.id === pinnedDevice);
+ if (!deviceExists)
+ return "";
+
+ return pinnedDevice;
+ }
+
+ function getPreferredDevice() {
+ const pinned = getPinnedDeviceForFocusedScreen();
+ if (pinned)
+ return pinned;
+
+ return getDefaultDevice();
+ }
+
+ function getCurrentDeviceInfo() {
+ const deviceToUse = lastIpcDevice === "" ? getDefaultDevice() : (lastIpcDevice || currentDevice);
+ if (!deviceToUse) {
+ return null;
+ }
+
+ for (const device of devices) {
+ if (device.id === deviceToUse) {
+ return device;
+ }
+ }
+ return null;
+ }
+
+ function isCurrentDeviceReady() {
+ const deviceToUse = lastIpcDevice === "" ? getDefaultDevice() : (lastIpcDevice || currentDevice);
+ return deviceToUse !== "";
+ }
+
+ function getCurrentDeviceInfoByName(deviceName) {
+ if (!deviceName) {
+ return null;
+ }
+
+ for (const device of devices) {
+ if (device.id === deviceName) {
+ return device;
+ }
+ }
+ return null;
+ }
+
+ function getDeviceMax(deviceName) {
+ const deviceInfo = getCurrentDeviceInfoByName(deviceName);
+ if (!deviceInfo) {
+ return 100;
+ }
+ return deviceInfo.displayMax || 100;
+ }
+
+ // Night Mode Functions - Simplified
+ function enableNightMode() {
+ if (!gammaControlAvailable) {
+ ToastService.showWarning("Night mode failed: DMS gamma control not available");
+ return;
+ }
+
+ nightModeEnabled = true;
+ SessionData.setNightModeEnabled(true);
+
+ DMSService.sendRequest("wayland.gamma.setEnabled", {
+ "enabled": true
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to enable gamma control:", response.error);
+ ToastService.showError(I18n.tr("Failed to enable night mode"), response.error, "", "night-mode");
+ nightModeEnabled = false;
+ SessionData.setNightModeEnabled(false);
+ return;
+ }
+ ToastService.dismissCategory("night-mode");
+
+ if (SessionData.nightModeAutoEnabled) {
+ startAutomation();
+ } else {
+ applyNightModeDirectly();
+ }
+ });
+ }
+
+ function disableNightMode() {
+ nightModeEnabled = false;
+ SessionData.setNightModeEnabled(false);
+
+ if (!gammaControlAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("wayland.gamma.setEnabled", {
+ "enabled": false
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to disable gamma control:", response.error);
+ ToastService.showError(I18n.tr("Failed to disable night mode"), response.error, "", "night-mode");
+ } else {
+ ToastService.dismissCategory("night-mode");
+ }
+ });
+ }
+
+ function toggleNightMode() {
+ if (nightModeEnabled) {
+ disableNightMode();
+ } else {
+ enableNightMode();
+ }
+ }
+
+ function applyNightModeDirectly() {
+ const temperature = SessionData.nightModeTemperature || 4000;
+
+ DMSService.sendRequest("wayland.gamma.setManualTimes", {
+ "sunrise": null,
+ "sunset": null
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to clear manual times:", response.error);
+ return;
+ }
+
+ DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
+ "use": false
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to disable IP location:", response.error);
+ return;
+ }
+
+ DMSService.sendRequest("wayland.gamma.setTemperature", {
+ "low": temperature,
+ "high": temperature
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to set temperature:", response.error);
+ ToastService.showError(I18n.tr("Failed to set night mode temperature"), response.error, "", "night-mode");
+ } else {
+ ToastService.dismissCategory("night-mode");
+ }
+ });
+ });
+ });
+ }
+
+ function startAutomation() {
+ if (!automationAvailable) {
+ return;
+ }
+
+ const mode = SessionData.nightModeAutoMode || "time";
+
+ switch (mode) {
+ case "time":
+ startTimeBasedMode();
+ break;
+ case "location":
+ startLocationBasedMode();
+ break;
+ }
+ }
+
+ function startTimeBasedMode() {
+ const temperature = SessionData.nightModeTemperature || 4000;
+ const highTemp = SessionData.nightModeHighTemperature || 6500;
+ const sunriseHour = SessionData.nightModeEndHour;
+ const sunriseMinute = SessionData.nightModeEndMinute;
+ const sunsetHour = SessionData.nightModeStartHour;
+ const sunsetMinute = SessionData.nightModeStartMinute;
+
+ const sunrise = `${String(sunriseHour).padStart(2, '0')}:${String(sunriseMinute).padStart(2, '0')}`;
+ const sunset = `${String(sunsetHour).padStart(2, '0')}:${String(sunsetMinute).padStart(2, '0')}`;
+
+ DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
+ "use": false
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to disable IP location:", response.error);
+ return;
+ }
+
+ DMSService.sendRequest("wayland.gamma.setTemperature", {
+ "low": temperature,
+ "high": highTemp
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to set temperature:", response.error);
+ ToastService.showError(I18n.tr("Failed to set night mode temperature"), response.error, "", "night-mode");
+ return;
+ }
+
+ DMSService.sendRequest("wayland.gamma.setManualTimes", {
+ "sunrise": sunrise,
+ "sunset": sunset
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to set manual times:", response.error);
+ ToastService.showError(I18n.tr("Failed to set night mode schedule"), response.error, "", "night-mode");
+ } else {
+ ToastService.dismissCategory("night-mode");
+ }
+ });
+ });
+ });
+ }
+
+ function startLocationBasedMode() {
+ const temperature = SessionData.nightModeTemperature || 4000;
+ const highTemp = SessionData.nightModeHighTemperature || 6500;
+
+ DMSService.sendRequest("wayland.gamma.setManualTimes", {
+ "sunrise": null,
+ "sunset": null
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to clear manual times:", response.error);
+ return;
+ }
+
+ DMSService.sendRequest("wayland.gamma.setTemperature", {
+ "low": temperature,
+ "high": highTemp
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to set temperature:", response.error);
+ ToastService.showError(I18n.tr("Failed to set night mode temperature"), response.error, "", "night-mode");
+ return;
+ }
+
+ if (SessionData.nightModeUseIPLocation) {
+ DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
+ "use": true
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to enable IP location:", response.error);
+ ToastService.showError(I18n.tr("Failed to enable IP location"), response.error, "", "night-mode");
+ } else {
+ ToastService.dismissCategory("night-mode");
+ }
+ });
+ } else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) {
+ DMSService.sendRequest("wayland.gamma.setUseIPLocation", {
+ "use": false
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to disable IP location:", response.error);
+ return;
+ }
+
+ DMSService.sendRequest("wayland.gamma.setLocation", {
+ "latitude": SessionData.latitude,
+ "longitude": SessionData.longitude
+ }, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to set location:", response.error);
+ ToastService.showError(I18n.tr("Failed to set night mode location"), response.error, "", "night-mode");
+ } else {
+ ToastService.dismissCategory("night-mode");
+ }
+ });
+ });
+ } else {
+ console.warn("DisplayService: Location mode selected but no coordinates set and IP location disabled");
+ }
+ });
+ });
+ }
+
+ function setNightModeAutomationMode(mode) {
+ SessionData.setNightModeAutoMode(mode);
+ }
+
+ function evaluateNightMode() {
+ if (!nightModeEnabled) {
+ return;
+ }
+
+ if (SessionData.nightModeAutoEnabled) {
+ restartTimer.nextAction = "automation";
+ restartTimer.start();
+ } else {
+ restartTimer.nextAction = "direct";
+ restartTimer.start();
+ }
+ }
+
+ function checkGammaControlAvailability() {
+ if (!DMSService.isConnected) {
+ return;
+ }
+
+ if (DMSService.apiVersion < 6) {
+ gammaControlAvailable = false;
+ automationAvailable = false;
+ return;
+ }
+
+ if (!DMSService.capabilities.includes("gamma")) {
+ gammaControlAvailable = false;
+ automationAvailable = false;
+ return;
+ }
+
+ DMSService.sendRequest("wayland.gamma.getState", null, response => {
+ if (response.error) {
+ gammaControlAvailable = false;
+ automationAvailable = false;
+ console.error("DisplayService: Gamma control not available:", response.error);
+ } else {
+ gammaControlAvailable = true;
+ automationAvailable = true;
+
+ if (nightModeEnabled) {
+ DMSService.sendRequest("wayland.gamma.setEnabled", {
+ "enabled": true
+ }, enableResponse => {
+ if (enableResponse.error) {
+ console.error("DisplayService: Failed to enable gamma control on startup:", enableResponse.error);
+ return;
+ }
+
+ evaluateNightMode();
+ });
+ }
+ }
+ });
+ }
+
+ Timer {
+ id: restartTimer
+ property string nextAction: ""
+ interval: 250
+ repeat: false
+
+ onTriggered: {
+ if (nextAction === "automation") {
+ startAutomation();
+ } else if (nextAction === "direct") {
+ applyNightModeDirectly();
+ }
+ nextAction = "";
+ }
+ }
+
+ function rescanDevices() {
+ if (!DMSService.isConnected) {
+ return;
+ }
+
+ DMSService.sendRequest("brightness.rescan", null, response => {
+ if (response.error) {
+ console.error("DisplayService: Failed to rescan brightness devices:", response.error);
+ }
+ });
+ }
+
+ function updateDeviceBrightnessDisplay(deviceName) {
+ brightnessVersion++;
+ brightnessChanged();
+ }
+
+ Timer {
+ id: osdSuppressTimer
+ interval: 2000
+ running: true
+ onTriggered: suppressOsd = false
+ }
+
+ Component.onCompleted: {
+ nightModeEnabled = SessionData.nightModeEnabled;
+ deviceBrightnessUserSet = Object.assign({}, SessionData.brightnessUserSetValues);
+ if (DMSService.isConnected) {
+ checkGammaControlAvailability();
+ }
+ }
+
+ Timer {
+ id: screenChangeRescanTimer
+ property int rescanAttempt: 0
+ interval: 3000
+ repeat: false
+ onTriggered: {
+ rescanDevices();
+ rescanAttempt++;
+ if (rescanAttempt < 3) {
+ interval = rescanAttempt === 1 ? 5000 : 8000;
+ restart();
+ } else {
+ rescanAttempt = 0;
+ interval = 3000;
+ }
+ }
+ }
+
+ Connections {
+ target: Quickshell
+
+ function onScreensChanged() {
+ screenChangeRescanTimer.rescanAttempt = 0;
+ screenChangeRescanTimer.interval = 3000;
+ screenChangeRescanTimer.restart();
+ }
+ }
+
+ Connections {
+ target: DMSService
+
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkGammaControlAvailability();
+ } else {
+ brightnessAvailable = false;
+ gammaControlAvailable = false;
+ automationAvailable = false;
+ }
+ }
+
+ function onCapabilitiesReceived() {
+ checkGammaControlAvailability();
+ }
+
+ function onBrightnessStateUpdate(data) {
+ updateFromBrightnessState(data);
+ }
+
+ function onBrightnessDeviceUpdate(device) {
+ updateSingleDevice(device);
+ }
+
+ function onLoginctlEvent(event) {
+ if (event.event === "unlock" || event.event === "resume") {
+ suppressOsd = true;
+ osdSuppressTimer.restart();
+ evaluateNightMode();
+ }
+ }
+
+ function onGammaStateUpdate(data) {
+ root.gammaState = data;
+ }
+ }
+
+ // Session Data Connections
+ Connections {
+ target: SessionData
+
+ function onNightModeEnabledChanged() {
+ nightModeEnabled = SessionData.nightModeEnabled;
+ evaluateNightMode();
+ }
+
+ function onNightModeAutoEnabledChanged() {
+ evaluateNightMode();
+ }
+ function onNightModeAutoModeChanged() {
+ evaluateNightMode();
+ }
+ function onNightModeStartHourChanged() {
+ evaluateNightMode();
+ }
+ function onNightModeStartMinuteChanged() {
+ evaluateNightMode();
+ }
+ function onNightModeEndHourChanged() {
+ evaluateNightMode();
+ }
+ function onNightModeEndMinuteChanged() {
+ evaluateNightMode();
+ }
+ function onNightModeTemperatureChanged() {
+ evaluateNightMode();
+ }
+ function onNightModeHighTemperatureChanged() {
+ evaluateNightMode();
+ }
+ function onLatitudeChanged() {
+ evaluateNightMode();
+ }
+ function onLongitudeChanged() {
+ evaluateNightMode();
+ }
+ function onNightModeUseIPLocationChanged() {
+ evaluateNightMode();
+ }
+ }
+
+ // IPC Handler for external control
+ IpcHandler {
+ function set(percentage: string, device: string): string {
+ if (!root.brightnessAvailable)
+ return "Brightness control not available";
+
+ const value = parseInt(percentage);
+ if (isNaN(value))
+ return "Invalid brightness value: " + percentage;
+
+ const actualDevice = device || root.getPreferredDevice();
+
+ if (actualDevice && !root.devices.some(d => d.id === actualDevice))
+ return "Device not found: " + actualDevice;
+
+ const deviceInfo = actualDevice ? root.getCurrentDeviceInfoByName(actualDevice) : null;
+ const minValue = (deviceInfo && (deviceInfo.class === "backlight" || deviceInfo.class === "ddc")) ? 1 : 0;
+ const clampedValue = Math.max(minValue, Math.min(100, value));
+
+ root.lastIpcDevice = actualDevice;
+ if (actualDevice && actualDevice !== root.currentDevice)
+ root.setCurrentDevice(actualDevice, false);
+
+ root.setBrightness(clampedValue, actualDevice);
+
+ return actualDevice ? "Brightness set to " + clampedValue + "% on " + actualDevice : "Brightness set to " + clampedValue + "%";
+ }
+
+ function increment(step: string, device: string): string {
+ if (!root.brightnessAvailable)
+ return "Brightness control not available";
+
+ const actualDevice = device || root.getPreferredDevice();
+
+ if (actualDevice && !root.devices.some(d => d.id === actualDevice))
+ return "Device not found: " + actualDevice;
+
+ const stepValue = parseInt(step || "5");
+
+ root.lastIpcDevice = actualDevice;
+ if (actualDevice && actualDevice !== root.currentDevice)
+ root.setCurrentDevice(actualDevice, false);
+
+ const isExponential = SessionData.getBrightnessExponential(actualDevice);
+ const currentBrightness = root.getDeviceBrightness(actualDevice);
+ const deviceInfo = root.getCurrentDeviceInfoByName(actualDevice);
+
+ const maxValue = isExponential ? 100 : (deviceInfo?.displayMax || 100);
+ const newBrightness = Math.min(maxValue, currentBrightness + stepValue);
+
+ root.setBrightness(newBrightness, actualDevice);
+
+ return "Brightness increased by " + stepValue + "%" + (device ? " on " + actualDevice : "");
+ }
+
+ function decrement(step: string, device: string): string {
+ if (!root.brightnessAvailable)
+ return "Brightness control not available";
+
+ const actualDevice = device || root.getPreferredDevice();
+
+ if (actualDevice && !root.devices.some(d => d.id === actualDevice))
+ return "Device not found: " + actualDevice;
+
+ const stepValue = parseInt(step || "5");
+
+ root.lastIpcDevice = actualDevice;
+ if (actualDevice && actualDevice !== root.currentDevice)
+ root.setCurrentDevice(actualDevice, false);
+
+ const isExponential = SessionData.getBrightnessExponential(actualDevice);
+ const currentBrightness = root.getDeviceBrightness(actualDevice);
+ const deviceInfo = root.getCurrentDeviceInfoByName(actualDevice);
+
+ let minValue = 0;
+ switch (true) {
+ case isExponential:
+ minValue = 1;
+ break;
+ case deviceInfo && (deviceInfo.class === "backlight" || deviceInfo.class === "ddc"):
+ minValue = 1;
+ break;
+ default:
+ minValue = 0;
+ break;
+ }
+
+ const newBrightness = Math.max(minValue, currentBrightness - stepValue);
+
+ root.setBrightness(newBrightness, actualDevice);
+
+ return "Brightness decreased by " + stepValue + "%" + (device ? " on " + actualDevice : "");
+ }
+
+ function status(): string {
+ if (!root.brightnessAvailable) {
+ return "Brightness control not available";
+ }
+
+ return "Device: " + root.currentDevice + " - Brightness: " + root.brightnessLevel + "%";
+ }
+
+ function list(): string {
+ if (!root.brightnessAvailable) {
+ return "No brightness devices available";
+ }
+
+ let result = "Available devices:\n";
+ for (const device of root.devices) {
+ const isExp = SessionData.getBrightnessExponential(device.id);
+ result += device.id + " (" + device.class + ")" + (isExp ? " [exponential]" : "") + "\n";
+ }
+ return result;
+ }
+
+ function enableExponential(device: string): string {
+ const targetDevice = device || root.currentDevice;
+ if (!targetDevice) {
+ return "No device specified";
+ }
+
+ if (!root.devices.some(d => d.id === targetDevice)) {
+ return "Device not found: " + targetDevice;
+ }
+
+ SessionData.setBrightnessExponential(targetDevice, true);
+ return "Exponential mode enabled for " + targetDevice;
+ }
+
+ function disableExponential(device: string): string {
+ const targetDevice = device || root.currentDevice;
+ if (!targetDevice) {
+ return "No device specified";
+ }
+
+ if (!root.devices.some(d => d.id === targetDevice)) {
+ return "Device not found: " + targetDevice;
+ }
+
+ SessionData.setBrightnessExponential(targetDevice, false);
+ return "Exponential mode disabled for " + targetDevice;
+ }
+
+ function toggleExponential(device: string): string {
+ const targetDevice = device || root.currentDevice;
+ if (!targetDevice) {
+ return "No device specified";
+ }
+
+ if (!root.devices.some(d => d.id === targetDevice)) {
+ return "Device not found: " + targetDevice;
+ }
+
+ const currentState = SessionData.getBrightnessExponential(targetDevice);
+ SessionData.setBrightnessExponential(targetDevice, !currentState);
+ return "Exponential mode " + (!currentState ? "enabled" : "disabled") + " for " + targetDevice;
+ }
+
+ target: "brightness"
+ }
+
+ IpcHandler {
+ function toggle(): string {
+ root.toggleNightMode();
+ return root.nightModeEnabled ? "Night mode enabled" : "Night mode disabled";
+ }
+
+ function enable(): string {
+ root.enableNightMode();
+ return "Night mode enabled";
+ }
+
+ function disable(): string {
+ root.disableNightMode();
+ return "Night mode disabled";
+ }
+
+ function status(): string {
+ if (!root.gammaControlAvailable)
+ return "Night mode: unavailable (no gamma control)";
+
+ const parts = ["Night mode: " + (root.nightModeEnabled ? "enabled" : "disabled")];
+
+ if (root.gammaCurrentTemp > 0)
+ parts.push("Current temperature: " + root.gammaCurrentTemp + "K");
+
+ parts.push("Target night temperature: " + SessionData.nightModeTemperature + "K");
+
+ if (SessionData.nightModeAutoEnabled) {
+ parts.push("Target day temperature: " + SessionData.nightModeHighTemperature + "K");
+ parts.push("Automation: " + SessionData.nightModeAutoMode);
+ parts.push("Period: " + (root.gammaIsDay ? "day" : "night"));
+
+ if (root.gammaNextTransition)
+ parts.push("Next transition: " + root.gammaNextTransition);
+ if (root.gammaSunriseTime)
+ parts.push("Sunrise: " + root.gammaSunriseTime);
+ if (root.gammaSunsetTime)
+ parts.push("Sunset: " + root.gammaSunsetTime);
+ }
+
+ return parts.join("\n");
+ }
+
+ function getCurrentTemp(): string {
+ if (!root.gammaControlAvailable)
+ return "Gamma control not available";
+ if (root.gammaCurrentTemp <= 0)
+ return "No current temperature reported";
+ return root.gammaCurrentTemp.toString();
+ }
+
+ function getTargetTemp(): string {
+ return SessionData.nightModeTemperature.toString();
+ }
+
+ function getDayTemp(): string {
+ return SessionData.nightModeHighTemperature.toString();
+ }
+
+ function setTargetTemp(value: string): string {
+ if (!value)
+ return "Usage: night setTargetTemp <2500-6000>";
+
+ const temp = parseInt(value);
+ if (isNaN(temp))
+ return "Invalid temperature: " + value;
+ if (temp < 2500 || temp > 6000)
+ return "Temperature must be between 2500K and 6000K";
+
+ const rounded = Math.round(temp / 500) * 500;
+ SessionData.setNightModeTemperature(rounded);
+
+ if (root.nightModeEnabled) {
+ switch (true) {
+ case SessionData.nightModeAutoEnabled:
+ root.startAutomation();
+ break;
+ default:
+ root.applyNightModeDirectly();
+ break;
+ }
+ }
+
+ if (rounded !== temp)
+ return "Night temperature set to " + rounded + "K (rounded from " + temp + "K)";
+ return "Night temperature set to " + rounded + "K";
+ }
+
+ function setDayTemp(value: string): string {
+ if (!value)
+ return "Usage: night setDayTemp <2500-6500>";
+
+ const temp = parseInt(value);
+ if (isNaN(temp))
+ return "Invalid temperature: " + value;
+ if (temp < 2500 || temp > 6500)
+ return "Temperature must be between 2500K and 6500K";
+
+ const rounded = Math.round(temp / 500) * 500;
+ SessionData.setNightModeHighTemperature(rounded);
+
+ if (root.nightModeEnabled && SessionData.nightModeAutoEnabled)
+ root.startAutomation();
+
+ if (rounded !== temp)
+ return "Day temperature set to " + rounded + "K (rounded from " + temp + "K)";
+ return "Day temperature set to " + rounded + "K";
+ }
+
+ function getSchedule(): string {
+ if (!SessionData.nightModeAutoEnabled)
+ return "Automation disabled";
+
+ const parts = ["Mode: " + SessionData.nightModeAutoMode];
+ parts.push("Period: " + (root.gammaIsDay ? "day" : "night"));
+
+ if (root.gammaDawnTime)
+ parts.push("Dawn: " + root.gammaDawnTime);
+ if (root.gammaSunriseTime)
+ parts.push("Sunrise: " + root.gammaSunriseTime);
+ if (root.gammaSunsetTime)
+ parts.push("Sunset: " + root.gammaSunsetTime);
+ if (root.gammaNightTime)
+ parts.push("Night: " + root.gammaNightTime);
+ if (root.gammaNextTransition)
+ parts.push("Next transition: " + root.gammaNextTransition);
+ if (root.gammaSunPosition > 0)
+ parts.push("Sun position: " + root.gammaSunPosition.toFixed(2) + "°");
+
+ return parts.join("\n");
+ }
+
+ target: "night"
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/DwlService.qml b/raveos-hyprland-theme/theme-data/dms/Services/DwlService.qml
new file mode 100644
index 0000000..564e561
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/DwlService.qml
@@ -0,0 +1,454 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtCore
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation))
+ readonly property string mangoDmsDir: configDir + "/mango/dms"
+ readonly property string outputsPath: mangoDmsDir + "/outputs.conf"
+ readonly property string layoutPath: mangoDmsDir + "/layout.conf"
+ readonly property string cursorPath: mangoDmsDir + "/cursor.conf"
+
+ property int _lastGapValue: -1
+
+ property bool dwlAvailable: false
+ property var outputs: ({})
+ property var tagCount: 9
+ property var layouts: []
+ property string activeOutput: ""
+ property var outputScales: ({})
+ property string currentKeyboardLayout: {
+ if (!outputs || !activeOutput)
+ return "";
+ const output = outputs[activeOutput];
+ return (output && output.kbLayout) || "";
+ }
+
+ signal stateChanged
+
+ Connections {
+ target: SettingsData
+ function onBarConfigsChanged() {
+ if (!CompositorService.isDwl)
+ return;
+ const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4));
+ if (newGaps === root._lastGapValue)
+ return;
+ root._lastGapValue = newGaps;
+ generateLayoutConfig();
+ }
+ }
+
+ Connections {
+ target: CompositorService
+ function onIsDwlChanged() {
+ if (CompositorService.isDwl)
+ generateLayoutConfig();
+ }
+ }
+
+ Connections {
+ target: DMSService
+ function onCapabilitiesReceived() {
+ checkCapabilities();
+ }
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkCapabilities();
+ } else {
+ dwlAvailable = false;
+ }
+ }
+ function onDwlStateUpdate(data) {
+ if (dwlAvailable) {
+ handleStateUpdate(data);
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ if (DMSService.dmsAvailable)
+ checkCapabilities();
+ if (dwlAvailable)
+ refreshOutputScales();
+ if (CompositorService.isDwl)
+ Qt.callLater(generateLayoutConfig);
+ }
+
+ function checkCapabilities() {
+ if (!DMSService.capabilities || !Array.isArray(DMSService.capabilities)) {
+ dwlAvailable = false;
+ return;
+ }
+
+ const hasDwl = DMSService.capabilities.includes("dwl");
+ if (hasDwl && !dwlAvailable) {
+ dwlAvailable = true;
+ console.info("DwlService: DWL capability detected");
+ requestState();
+ refreshOutputScales();
+ } else if (!hasDwl) {
+ dwlAvailable = false;
+ }
+ }
+
+ function requestState() {
+ if (!DMSService.isConnected || !dwlAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("dwl.getState", null, response => {
+ if (response.result) {
+ handleStateUpdate(response.result);
+ }
+ });
+ }
+
+ function handleStateUpdate(state) {
+ outputs = state.outputs || {};
+ tagCount = state.tagCount || 9;
+ layouts = state.layouts || [];
+ activeOutput = state.activeOutput || "";
+ stateChanged();
+ }
+
+ function setTags(outputName, tagmask, toggleTagset) {
+ if (!DMSService.isConnected || !dwlAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("dwl.setTags", {
+ "output": outputName,
+ "tagmask": tagmask,
+ "toggleTagset": toggleTagset
+ }, response => {
+ if (response.error) {
+ console.warn("DwlService: setTags error:", response.error);
+ }
+ });
+ }
+
+ function setClientTags(outputName, andTags, xorTags) {
+ if (!DMSService.isConnected || !dwlAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("dwl.setClientTags", {
+ "output": outputName,
+ "andTags": andTags,
+ "xorTags": xorTags
+ }, response => {
+ if (response.error) {
+ console.warn("DwlService: setClientTags error:", response.error);
+ }
+ });
+ }
+
+ function setLayout(outputName, index) {
+ if (!DMSService.isConnected || !dwlAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("dwl.setLayout", {
+ "output": outputName,
+ "index": index
+ }, response => {
+ if (response.error) {
+ console.warn("DwlService: setLayout error:", response.error);
+ }
+ });
+ }
+
+ function getOutputState(outputName) {
+ if (!outputs || !outputs[outputName]) {
+ return null;
+ }
+ return outputs[outputName];
+ }
+
+ function getActiveTags(outputName) {
+ const output = getOutputState(outputName);
+ if (!output || !output.tags) {
+ return [];
+ }
+ return output.tags.filter(tag => tag.state === 1).map(tag => tag.tag);
+ }
+
+ function getTagsWithClients(outputName) {
+ const output = getOutputState(outputName);
+ if (!output || !output.tags) {
+ return [];
+ }
+ return output.tags.filter(tag => tag.clients > 0).map(tag => tag.tag);
+ }
+
+ function getUrgentTags(outputName) {
+ const output = getOutputState(outputName);
+ if (!output || !output.tags) {
+ return [];
+ }
+ return output.tags.filter(tag => tag.state === 2).map(tag => tag.tag);
+ }
+
+ function switchToTag(outputName, tagIndex) {
+ const tagmask = 1 << tagIndex;
+ setTags(outputName, tagmask, 0);
+ }
+
+ function toggleTag(outputName, tagIndex) {
+ const output = getOutputState(outputName);
+ if (!output || !output.tags) {
+ console.log("toggleTag: no output or tags for", outputName);
+ return;
+ }
+
+ let currentMask = 0;
+ output.tags.forEach(tag => {
+ if (tag.state === 1) {
+ currentMask |= (1 << tag.tag);
+ }
+ });
+
+ const clickedMask = 1 << tagIndex;
+ const newMask = currentMask ^ clickedMask;
+
+ console.log("toggleTag:", outputName, "tag:", tagIndex, "currentMask:", currentMask.toString(2), "clickedMask:", clickedMask.toString(2), "newMask:", newMask.toString(2));
+
+ if (newMask === 0) {
+ console.log("toggleTag: newMask is 0, switching to tag", tagIndex);
+ setTags(outputName, 1 << tagIndex, 0);
+ } else {
+ console.log("toggleTag: setting combined mask", newMask);
+ setTags(outputName, newMask, 0);
+ }
+ }
+
+ function quit() {
+ Quickshell.execDetached(["mmsg", "-d", "quit"]);
+ }
+
+ Process {
+ id: scaleQueryProcess
+ command: ["mmsg", "-A"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ const newScales = {};
+ const lines = text.trim().split('\n');
+ for (const line of lines) {
+ const parts = line.trim().split(/\s+/);
+ if (parts.length >= 3 && parts[1] === "scale_factor") {
+ const outputName = parts[0];
+ const scale = parseFloat(parts[2]);
+ if (!isNaN(scale)) {
+ newScales[outputName] = scale;
+ }
+ }
+ }
+ outputScales = newScales;
+ } catch (e) {
+ console.warn("DwlService: Failed to parse mmsg output:", e);
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ console.warn("DwlService: mmsg failed with exit code:", exitCode);
+ }
+ }
+ }
+
+ function refreshOutputScales() {
+ if (!dwlAvailable)
+ return;
+ scaleQueryProcess.running = true;
+ }
+
+ function getOutputScale(outputName) {
+ return outputScales[outputName];
+ }
+
+ function getVisibleTags(outputName) {
+ const output = getOutputState(outputName);
+ if (!output || !output.tags) {
+ return [];
+ }
+
+ const visibleTags = new Set();
+
+ output.tags.forEach(tag => {
+ if (tag.state === 1 || tag.clients > 0) {
+ visibleTags.add(tag.tag);
+ }
+ });
+
+ return Array.from(visibleTags).sort((a, b) => a - b);
+ }
+
+ function generateOutputsConfig(outputsData) {
+ if (!outputsData || Object.keys(outputsData).length === 0)
+ return;
+ let lines = ["# Auto-generated by DMS - do not edit manually", ""];
+
+ for (const outputName in outputsData) {
+ const output = outputsData[outputName];
+ if (!output)
+ continue;
+ let width = 1920;
+ let height = 1080;
+ let refreshRate = 60;
+ if (output.modes && output.current_mode !== undefined) {
+ const mode = output.modes[output.current_mode];
+ if (mode) {
+ width = mode.width || 1920;
+ height = mode.height || 1080;
+ refreshRate = Math.round((mode.refresh_rate || 60000) / 1000);
+ }
+ }
+
+ const x = output.logical?.x ?? 0;
+ const y = output.logical?.y ?? 0;
+ const scale = output.logical?.scale ?? 1.0;
+ const transform = transformToMango(output.logical?.transform ?? "Normal");
+ const vrr = output.vrr_enabled ? 1 : 0;
+
+ const rule = ["name:" + outputName, "width:" + width, "height:" + height, "refresh:" + refreshRate, "x:" + x, "y:" + y, "scale:" + scale, "rr:" + transform, "vrr:" + vrr].join(",");
+
+ lines.push("monitorrule=" + rule);
+ }
+
+ lines.push("");
+
+ const content = lines.join("\n");
+
+ Proc.runCommand("mango-write-outputs", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("DwlService: Failed to write outputs config:", output);
+ return;
+ }
+ console.info("DwlService: Generated outputs config at", outputsPath);
+ if (CompositorService.isDwl)
+ reloadConfig();
+ });
+ }
+
+ function reloadConfig() {
+ Proc.runCommand("mango-reload", ["mmsg", "-d", "reload_config"], (output, exitCode) => {
+ if (exitCode !== 0)
+ console.warn("DwlService: mmsg reload_config failed:", output);
+ });
+ }
+
+ function generateLayoutConfig() {
+ if (!CompositorService.isDwl)
+ return;
+
+ const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12;
+ const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4;
+ const defaultBorderSize = 2;
+
+ const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutRadiusOverride >= 0) ? SettingsData.mangoLayoutRadiusOverride : defaultRadius;
+ const gaps = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutGapsOverride >= 0) ? SettingsData.mangoLayoutGapsOverride : defaultGaps;
+ const borderSize = (typeof SettingsData !== "undefined" && SettingsData.mangoLayoutBorderSize >= 0) ? SettingsData.mangoLayoutBorderSize : defaultBorderSize;
+
+ let content = `# Auto-generated by DMS - do not edit manually
+border_radius=${cornerRadius}
+gappih=${gaps}
+gappiv=${gaps}
+gappoh=${gaps}
+gappov=${gaps}
+borderpx=${borderSize}
+`;
+
+ Proc.runCommand("mango-write-layout", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${layoutPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("DwlService: Failed to write layout config:", output);
+ return;
+ }
+ console.info("DwlService: Generated layout config at", layoutPath);
+ reloadConfig();
+ });
+ }
+
+ function transformToMango(transform) {
+ switch (transform) {
+ case "Normal":
+ return 0;
+ case "90":
+ return 1;
+ case "180":
+ return 2;
+ case "270":
+ return 3;
+ case "Flipped":
+ return 4;
+ case "Flipped90":
+ return 5;
+ case "Flipped180":
+ return 6;
+ case "Flipped270":
+ return 7;
+ default:
+ return 0;
+ }
+ }
+
+ function generateCursorConfig() {
+ if (!CompositorService.isDwl)
+ return;
+
+ console.log("DwlService: Generating cursor config...");
+
+ const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null;
+ if (!settings) {
+ Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => {
+ if (exitCode !== 0)
+ console.warn("DwlService: Failed to write cursor config:", output);
+ });
+ return;
+ }
+
+ const themeName = settings.theme === "System Default" ? (SettingsData.systemDefaultCursorTheme || "") : settings.theme;
+ const size = settings.size || 24;
+ const hideTimeout = settings.dwl?.cursorHideTimeout || 0;
+
+ const isDefaultConfig = !themeName && size === 24 && hideTimeout === 0;
+ if (isDefaultConfig) {
+ Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => {
+ if (exitCode !== 0)
+ console.warn("DwlService: Failed to write cursor config:", output);
+ });
+ return;
+ }
+
+ let content = `# Auto-generated by DMS - do not edit manually
+cursor_size=${size}`;
+
+ if (themeName)
+ content += `\ncursor_theme=${themeName}`;
+
+ if (hideTimeout > 0)
+ content += `\ncursor_hide_timeout=${hideTimeout}`;
+
+ content += `\n`;
+
+ Proc.runCommand("mango-write-cursor", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${cursorPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("DwlService: Failed to write cursor config:", output);
+ return;
+ }
+ console.info("DwlService: Generated cursor config at", cursorPath);
+ reloadConfig();
+ });
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/ExtWorkspaceService.qml b/raveos-hyprland-theme/theme-data/dms/Services/ExtWorkspaceService.qml
new file mode 100644
index 0000000..48a8f9a
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/ExtWorkspaceService.qml
@@ -0,0 +1,277 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+
+Singleton {
+ id: root
+
+ property bool extWorkspaceAvailable: false
+ property var groups: []
+ property var _cachedWorkspaces: ({})
+
+ signal stateChanged
+
+ Connections {
+ target: DMSService
+ function onCapabilitiesReceived() {
+ checkCapabilities();
+ }
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkCapabilities();
+ } else {
+ extWorkspaceAvailable = false;
+ }
+ }
+ function onExtWorkspaceStateUpdate(data) {
+ if (extWorkspaceAvailable) {
+ handleStateUpdate(data);
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ if (DMSService.dmsAvailable) {
+ checkCapabilities();
+ }
+ }
+
+ function checkCapabilities() {
+ if (!DMSService.capabilities || !Array.isArray(DMSService.capabilities)) {
+ extWorkspaceAvailable = false;
+ return;
+ }
+
+ const hasExtWorkspace = DMSService.capabilities.includes("extworkspace");
+ if (hasExtWorkspace && !extWorkspaceAvailable) {
+ if (typeof CompositorService !== "undefined") {
+ const useExtWorkspace = DMSService.forceExtWorkspace || (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isDwl && !CompositorService.isSway && !CompositorService.isScroll && !CompositorService.isMiracle);
+ if (!useExtWorkspace) {
+ console.info("ExtWorkspaceService: ext-workspace available but compositor has native support");
+ extWorkspaceAvailable = false;
+ return;
+ }
+ }
+ extWorkspaceAvailable = true;
+ console.info("ExtWorkspaceService: ext-workspace capability detected");
+ DMSService.addSubscription("extworkspace");
+ requestState();
+ } else if (!hasExtWorkspace) {
+ extWorkspaceAvailable = false;
+ }
+ }
+
+ function requestState() {
+ if (!DMSService.isConnected || !extWorkspaceAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("extworkspace.getState", null, response => {
+ if (response.result) {
+ handleStateUpdate(response.result);
+ }
+ });
+ }
+
+ function handleStateUpdate(state) {
+ groups = state.groups || [];
+ if (groups.length === 0) {
+ console.warn("ExtWorkspaceService: Received empty workspace groups from backend");
+ } else {
+ console.log("ExtWorkspaceService: Updated with", groups.length, "workspace groups");
+ }
+ stateChanged();
+ }
+
+ function activateWorkspace(workspaceID, groupID = "") {
+ if (!DMSService.isConnected || !extWorkspaceAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("extworkspace.activateWorkspace", {
+ "workspaceID": workspaceID,
+ "groupID": groupID
+ }, response => {
+ if (response.error) {
+ console.warn("ExtWorkspaceService: activateWorkspace error:", response.error);
+ }
+ });
+ }
+
+ function deactivateWorkspace(workspaceID, groupID = "") {
+ if (!DMSService.isConnected || !extWorkspaceAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("extworkspace.deactivateWorkspace", {
+ "workspaceID": workspaceID,
+ "groupID": groupID
+ }, response => {
+ if (response.error) {
+ console.warn("ExtWorkspaceService: deactivateWorkspace error:", response.error);
+ }
+ });
+ }
+
+ function removeWorkspace(workspaceID, groupID = "") {
+ if (!DMSService.isConnected || !extWorkspaceAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("extworkspace.removeWorkspace", {
+ "workspaceID": workspaceID,
+ "groupID": groupID
+ }, response => {
+ if (response.error) {
+ console.warn("ExtWorkspaceService: removeWorkspace error:", response.error);
+ }
+ });
+ }
+
+ function createWorkspace(groupID, name) {
+ if (!DMSService.isConnected || !extWorkspaceAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("extworkspace.createWorkspace", {
+ "groupID": groupID,
+ "name": name
+ }, response => {
+ if (response.error) {
+ console.warn("ExtWorkspaceService: createWorkspace error:", response.error);
+ }
+ });
+ }
+
+ function getGroupForOutput(outputName) {
+ for (const group of groups) {
+ if (group.outputs && group.outputs.includes(outputName)) {
+ return group;
+ }
+ }
+ return null;
+ }
+
+ function getWorkspacesForOutput(outputName) {
+ const group = getGroupForOutput(outputName);
+ return group ? (group.workspaces || []) : [];
+ }
+
+ function getActiveWorkspaces() {
+ const active = [];
+ for (const group of groups) {
+ if (!group.workspaces)
+ continue;
+ for (const ws of group.workspaces) {
+ if (ws.active) {
+ active.push({
+ workspace: ws,
+ group: group,
+ outputs: group.outputs || []
+ });
+ }
+ }
+ }
+ return active;
+ }
+
+ function getActiveWorkspaceForOutput(outputName) {
+ const group = getGroupForOutput(outputName);
+ if (!group || !group.workspaces)
+ return null;
+
+ for (const ws of group.workspaces) {
+ if (ws.active) {
+ return ws;
+ }
+ }
+ return null;
+ }
+
+ function getVisibleWorkspaces(outputName) {
+ const workspaces = getWorkspacesForOutput(outputName);
+ let visible = workspaces.filter(ws => !ws.hidden);
+
+ const hasValidCoordinates = visible.some(ws => ws.coordinates && ws.coordinates.length > 0);
+ if (hasValidCoordinates) {
+ visible = visible.sort((a, b) => {
+ const coordsA = a.coordinates || [0, 0];
+ const coordsB = b.coordinates || [0, 0];
+ if (coordsA[0] !== coordsB[0])
+ return coordsA[0] - coordsB[0];
+ return coordsA[1] - coordsB[1];
+ });
+ }
+
+ const cacheKey = outputName;
+ if (!_cachedWorkspaces[cacheKey]) {
+ _cachedWorkspaces[cacheKey] = {
+ workspaces: [],
+ lastNames: []
+ };
+ }
+
+ const cache = _cachedWorkspaces[cacheKey];
+ const currentNames = visible.map(ws => ws.name || ws.id);
+ const namesChanged = JSON.stringify(cache.lastNames) !== JSON.stringify(currentNames);
+
+ if (namesChanged || cache.workspaces.length !== visible.length) {
+ cache.workspaces = visible.map(ws => ({
+ id: ws.id,
+ name: ws.name,
+ coordinates: ws.coordinates,
+ state: ws.state,
+ active: ws.active,
+ urgent: ws.urgent,
+ hidden: ws.hidden
+ }));
+ cache.lastNames = currentNames;
+ return cache.workspaces;
+ }
+
+ for (let i = 0; i < visible.length; i++) {
+ const src = visible[i];
+ const dst = cache.workspaces[i];
+ dst.id = src.id;
+ dst.name = src.name;
+ dst.coordinates = src.coordinates;
+ dst.state = src.state;
+ dst.active = src.active;
+ dst.urgent = src.urgent;
+ dst.hidden = src.hidden;
+ }
+
+ return cache.workspaces;
+ }
+
+ function getUrgentWorkspaces() {
+ const urgent = [];
+ for (const group of groups) {
+ if (!group.workspaces)
+ continue;
+ for (const ws of group.workspaces) {
+ if (ws.urgent) {
+ urgent.push({
+ workspace: ws,
+ group: group,
+ outputs: group.outputs || []
+ });
+ }
+ }
+ }
+ return urgent;
+ }
+
+ function switchToWorkspace(outputName, workspaceName) {
+ const workspaces = getWorkspacesForOutput(outputName);
+ for (const ws of workspaces) {
+ if (ws.name === workspaceName || ws.id === workspaceName) {
+ activateWorkspace(ws.name || ws.id);
+ return;
+ }
+ }
+ console.warn("ExtWorkspaceService: workspace not found:", workspaceName);
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/FirstLaunchService.qml b/raveos-hyprland-theme/theme-data/dms/Services/FirstLaunchService.qml
new file mode 100644
index 0000000..8b7acd3
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/FirstLaunchService.qml
@@ -0,0 +1,111 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtCore
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell"
+ readonly property string settingsPath: configDir + "/settings.json"
+ readonly property string firstLaunchMarkerPath: configDir + "/.firstlaunch"
+
+ property bool isFirstLaunch: false
+ property bool checkComplete: false
+ property bool greeterDismissed: false
+ property int requestedStartPage: 0
+
+ readonly property bool shouldShowGreeter: checkComplete && isFirstLaunch && !greeterDismissed
+
+ signal greeterRequested
+ signal greeterCompleted
+
+ function showGreeter(startPage) {
+ requestedStartPage = startPage || 0;
+ greeterRequested();
+ }
+
+ function showWelcome() {
+ showGreeter(0);
+ }
+
+ function showDoctor() {
+ showGreeter(1);
+ }
+
+ Component.onCompleted: {
+ checkFirstLaunch();
+ }
+
+ function checkFirstLaunch() {
+ firstLaunchCheckProcess.running = true;
+ }
+
+ function markFirstLaunchComplete() {
+ greeterDismissed = true;
+ touchMarkerProcess.running = true;
+ greeterCompleted();
+ }
+
+ function dismissGreeter() {
+ greeterDismissed = true;
+ }
+
+ Process {
+ id: firstLaunchCheckProcess
+
+ command: ["sh", "-c", `
+ SETTINGS='` + settingsPath + `'
+ MARKER='` + firstLaunchMarkerPath + `'
+ if [ -f "$MARKER" ]; then
+ echo 'skip'
+ elif [ -f "$SETTINGS" ]; then
+ echo 'existing_user'
+ else
+ echo 'first'
+ fi
+ `]
+ running: false
+
+ stdout: SplitParser {
+ onRead: data => {
+ const result = data.trim();
+
+ if (result === "first") {
+ root.isFirstLaunch = true;
+ console.info("FirstLaunchService: First launch detected, greeter will be shown");
+ } else if (result === "existing_user") {
+ root.isFirstLaunch = false;
+ console.info("FirstLaunchService: Existing user detected, silently creating marker");
+ touchMarkerProcess.running = true;
+ } else {
+ root.isFirstLaunch = false;
+ }
+
+ root.checkComplete = true;
+
+ if (root.isFirstLaunch)
+ root.greeterRequested();
+ }
+ }
+ }
+
+ Process {
+ id: touchMarkerProcess
+
+ command: ["sh", "-c", "mkdir -p '" + configDir + "' && touch '" + firstLaunchMarkerPath + "'"]
+ running: false
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ console.info("FirstLaunchService: First launch marker created");
+ } else {
+ console.warn("FirstLaunchService: Failed to create first launch marker");
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/HyprlandService.qml b/raveos-hyprland-theme/theme-data/dms/Services/HyprlandService.qml
new file mode 100644
index 0000000..20096ef
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/HyprlandService.qml
@@ -0,0 +1,338 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtCore
+import QtQuick
+import Quickshell
+import Quickshell.Hyprland
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation))
+ readonly property string hyprDmsDir: configDir + "/hypr/dms"
+ readonly property string outputsPath: hyprDmsDir + "/outputs.conf"
+ readonly property string layoutPath: hyprDmsDir + "/layout.conf"
+ readonly property string cursorPath: hyprDmsDir + "/cursor.conf"
+ readonly property string windowrulesPath: hyprDmsDir + "/windowrules.conf"
+
+ property int _lastGapValue: -1
+
+ Component.onCompleted: {
+ if (CompositorService.isHyprland) {
+ Qt.callLater(generateLayoutConfig);
+ ensureWindowrulesConfig();
+ }
+ }
+
+ function ensureWindowrulesConfig() {
+ Proc.runCommand("hypr-ensure-windowrules", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && [ ! -f "${windowrulesPath}" ] && touch "${windowrulesPath}" || true`], (output, exitCode) => {
+ if (exitCode !== 0)
+ console.warn("HyprlandService: Failed to ensure windowrules.conf:", output);
+ });
+ }
+
+ Connections {
+ target: SettingsData
+ function onBarConfigsChanged() {
+ if (!CompositorService.isHyprland)
+ return;
+ const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4));
+ if (newGaps === root._lastGapValue)
+ return;
+ root._lastGapValue = newGaps;
+ generateLayoutConfig();
+ }
+ }
+
+ Connections {
+ target: CompositorService
+ function onIsHyprlandChanged() {
+ if (CompositorService.isHyprland)
+ generateLayoutConfig();
+ }
+ }
+
+ function getOutputIdentifier(output, outputName) {
+ if (SettingsData.displayNameMode === "model" && output.make && output.model)
+ return "desc:" + output.make + " " + output.model + " " + (output.serial || "Unknown");
+ return outputName;
+ }
+
+ function generateOutputsConfig(outputsData, hyprlandSettings) {
+ if (!outputsData || Object.keys(outputsData).length === 0)
+ return;
+
+ const settings = hyprlandSettings || SettingsData.hyprlandOutputSettings;
+ let lines = ["# Auto-generated by DMS - do not edit manually", ""];
+ let monitorv2Blocks = [];
+
+ for (const outputName in outputsData) {
+ const output = outputsData[outputName];
+ if (!output)
+ continue;
+
+ const identifier = getOutputIdentifier(output, outputName);
+ const outputSettings = settings[identifier] || {};
+
+ if (outputSettings.disabled) {
+ lines.push("monitor = " + identifier + ", disable");
+ continue;
+ }
+
+ let resolution = "preferred";
+ if (output.modes && output.current_mode !== undefined) {
+ const mode = output.modes[output.current_mode];
+ if (mode)
+ resolution = mode.width + "x" + mode.height + "@" + (mode.refresh_rate / 1000).toFixed(3);
+ }
+
+ const x = output.logical?.x ?? 0;
+ const y = output.logical?.y ?? 0;
+ const position = x + "x" + y;
+ const scale = output.logical?.scale ?? 1.0;
+
+ let monitorLine = "monitor = " + identifier + ", " + resolution + ", " + position + ", " + scale;
+
+ const transform = transformToHyprland(output.logical?.transform ?? "Normal");
+ if (transform !== 0)
+ monitorLine += ", transform, " + transform;
+
+ if (output.vrr_supported) {
+ const vrrMode = outputSettings.vrrFullscreenOnly ? 2 : (output.vrr_enabled ? 1 : 0);
+ monitorLine += ", vrr, " + vrrMode;
+ }
+
+ if (output.mirror && output.mirror.length > 0)
+ monitorLine += ", mirror, " + output.mirror;
+
+ if (outputSettings.bitdepth && outputSettings.bitdepth !== 8)
+ monitorLine += ", bitdepth, " + outputSettings.bitdepth;
+
+ if (outputSettings.colorManagement && outputSettings.colorManagement !== "auto")
+ monitorLine += ", cm, " + outputSettings.colorManagement;
+
+ if (outputSettings.sdrBrightness !== undefined && outputSettings.sdrBrightness !== 1.0)
+ monitorLine += ", sdrbrightness, " + outputSettings.sdrBrightness;
+
+ if (outputSettings.sdrSaturation !== undefined && outputSettings.sdrSaturation !== 1.0)
+ monitorLine += ", sdrsaturation, " + outputSettings.sdrSaturation;
+
+ lines.push(monitorLine);
+
+ const needsMonitorv2 = outputSettings.supportsHdr || outputSettings.supportsWideColor || outputSettings.sdrMinLuminance !== undefined || outputSettings.sdrMaxLuminance !== undefined || outputSettings.minLuminance !== undefined || outputSettings.maxLuminance !== undefined || outputSettings.maxAvgLuminance !== undefined;
+
+ if (needsMonitorv2) {
+ let block = "monitorv2 {\n";
+ block += " output = " + identifier + "\n";
+
+ if (outputSettings.supportsWideColor)
+ block += " supports_wide_color = true\n";
+ if (outputSettings.supportsHdr)
+ block += " supports_hdr = true\n";
+ if (outputSettings.sdrMinLuminance !== undefined)
+ block += " sdr_min_luminance = " + outputSettings.sdrMinLuminance + "\n";
+ if (outputSettings.sdrMaxLuminance !== undefined)
+ block += " sdr_max_luminance = " + outputSettings.sdrMaxLuminance + "\n";
+ if (outputSettings.minLuminance !== undefined)
+ block += " min_luminance = " + outputSettings.minLuminance + "\n";
+ if (outputSettings.maxLuminance !== undefined)
+ block += " max_luminance = " + outputSettings.maxLuminance + "\n";
+ if (outputSettings.maxAvgLuminance !== undefined)
+ block += " max_avg_luminance = " + outputSettings.maxAvgLuminance + "\n";
+
+ block += "}";
+ monitorv2Blocks.push(block);
+ }
+ }
+
+ if (monitorv2Blocks.length > 0) {
+ lines.push("");
+ for (const block of monitorv2Blocks)
+ lines.push(block);
+ }
+
+ lines.push("");
+
+ const content = lines.join("\n");
+
+ Proc.runCommand("hypr-write-outputs", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("HyprlandService: Failed to write outputs config:", output);
+ return;
+ }
+ console.info("HyprlandService: Generated outputs config at", outputsPath);
+ if (CompositorService.isHyprland)
+ reloadConfig();
+ });
+ }
+
+ function reloadConfig() {
+ Proc.runCommand("hyprctl-reload", ["hyprctl", "reload"], (output, exitCode) => {
+ if (exitCode !== 0)
+ console.warn("HyprlandService: hyprctl reload failed:", output);
+ });
+ }
+
+ function generateLayoutConfig() {
+ if (!CompositorService.isHyprland)
+ return;
+
+ const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12;
+ const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4;
+ const defaultBorderSize = 2;
+
+ const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutRadiusOverride >= 0) ? SettingsData.hyprlandLayoutRadiusOverride : defaultRadius;
+ const gaps = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutGapsOverride >= 0) ? SettingsData.hyprlandLayoutGapsOverride : defaultGaps;
+ const borderSize = (typeof SettingsData !== "undefined" && SettingsData.hyprlandLayoutBorderSize >= 0) ? SettingsData.hyprlandLayoutBorderSize : defaultBorderSize;
+
+ let content = `# Auto-generated by DMS - do not edit manually
+
+general {
+ gaps_in = ${gaps}
+ gaps_out = ${gaps}
+ border_size = ${borderSize}
+}
+
+decoration {
+ rounding = ${cornerRadius}
+}
+`;
+
+ Proc.runCommand("hypr-write-layout", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${layoutPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("HyprlandService: Failed to write layout config:", output);
+ return;
+ }
+ console.info("HyprlandService: Generated layout config at", layoutPath);
+ reloadConfig();
+ });
+ }
+
+ function transformToHyprland(transform) {
+ switch (transform) {
+ case "Normal":
+ return 0;
+ case "90":
+ return 1;
+ case "180":
+ return 2;
+ case "270":
+ return 3;
+ case "Flipped":
+ return 4;
+ case "Flipped90":
+ return 5;
+ case "Flipped180":
+ return 6;
+ case "Flipped270":
+ return 7;
+ default:
+ return 0;
+ }
+ }
+
+ function hyprlandToTransform(value) {
+ switch (value) {
+ case 0:
+ return "Normal";
+ case 1:
+ return "90";
+ case 2:
+ return "180";
+ case 3:
+ return "270";
+ case 4:
+ return "Flipped";
+ case 5:
+ return "Flipped90";
+ case 6:
+ return "Flipped180";
+ case 7:
+ return "Flipped270";
+ default:
+ return "Normal";
+ }
+ }
+
+ function generateCursorConfig() {
+ if (!CompositorService.isHyprland)
+ return;
+
+ const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null;
+ if (!settings) {
+ Proc.runCommand("hypr-write-cursor", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => {
+ if (exitCode !== 0)
+ console.warn("HyprlandService: Failed to write cursor config:", output);
+ });
+ return;
+ }
+
+ const themeName = settings.theme === "System Default" ? (SettingsData.systemDefaultCursorTheme || "") : settings.theme;
+ const size = settings.size || 24;
+ const hideOnKeyPress = settings.hyprland?.hideOnKeyPress || false;
+ const hideOnTouch = settings.hyprland?.hideOnTouch || false;
+ const inactiveTimeout = settings.hyprland?.inactiveTimeout || 0;
+
+ const hasTheme = themeName && themeName.length > 0;
+ const hasNonDefaultSize = size !== 24;
+ const hasCursorSettings = hideOnKeyPress || hideOnTouch || inactiveTimeout > 0;
+
+ if (!hasTheme && !hasNonDefaultSize && !hasCursorSettings) {
+ Proc.runCommand("hypr-write-cursor", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && : > "${cursorPath}"`], (output, exitCode) => {
+ if (exitCode !== 0)
+ console.warn("HyprlandService: Failed to write cursor config:", output);
+ });
+ return;
+ }
+
+ let lines = ["# Auto-generated by DMS - do not edit manually", ""];
+
+ if (hasTheme) {
+ lines.push(`env = HYPRCURSOR_THEME,${themeName}`);
+ lines.push(`env = XCURSOR_THEME,${themeName}`);
+ }
+ lines.push(`env = HYPRCURSOR_SIZE,${size}`);
+ lines.push(`env = XCURSOR_SIZE,${size}`);
+
+ if (hasCursorSettings) {
+ lines.push("");
+ lines.push("cursor {");
+ if (hideOnKeyPress)
+ lines.push(" hide_on_key_press = true");
+ if (hideOnTouch)
+ lines.push(" hide_on_touch = true");
+ if (inactiveTimeout > 0)
+ lines.push(` inactive_timeout = ${inactiveTimeout}`);
+ lines.push("}");
+ }
+
+ lines.push("");
+ const content = lines.join("\n");
+
+ Proc.runCommand("hypr-write-cursor", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${cursorPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("HyprlandService: Failed to write cursor config:", output);
+ return;
+ }
+ if (hasTheme)
+ Proc.runCommand("hyprctl-setcursor", ["hyprctl", "setcursor", themeName, String(size)], () => {});
+ reloadConfig();
+ });
+ }
+
+ function renameWorkspace(newName) {
+ if (!Hyprland.focusedWorkspace)
+ return;
+ const wsId = Hyprland.focusedWorkspace.id;
+ if (!wsId)
+ return;
+ const fullName = wsId + " " + newName;
+ Proc.runCommand("hyprland-rename-ws", ["hyprctl", "dispatch", "renameworkspace", String(wsId), fullName], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("HyprlandService: Failed to rename workspace:", output);
+ }
+ });
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/IdleService.qml b/raveos-hyprland-theme/theme-data/dms/Services/IdleService.qml
new file mode 100644
index 0000000..9965e47
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/IdleService.qml
@@ -0,0 +1,191 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property bool idleMonitorAvailable: {
+ try {
+ return typeof IdleMonitor !== "undefined";
+ } catch (e) {
+ return false;
+ }
+ }
+
+ readonly property bool idleInhibitorAvailable: {
+ try {
+ return typeof IdleInhibitor !== "undefined";
+ } catch (e) {
+ return false;
+ }
+ }
+
+ property bool enabled: true
+ property bool respectInhibitors: true
+ property bool _enableGate: true
+
+ readonly property bool externalInhibitActive: DMSService.screensaverInhibited
+
+ readonly property bool isOnBattery: BatteryService.batteryAvailable && !BatteryService.isPluggedIn
+ readonly property int monitorTimeout: isOnBattery ? SettingsData.batteryMonitorTimeout : SettingsData.acMonitorTimeout
+ readonly property int lockTimeout: isOnBattery ? SettingsData.batteryLockTimeout : SettingsData.acLockTimeout
+ readonly property int suspendTimeout: isOnBattery ? SettingsData.batterySuspendTimeout : SettingsData.acSuspendTimeout
+ readonly property int suspendBehavior: isOnBattery ? SettingsData.batterySuspendBehavior : SettingsData.acSuspendBehavior
+
+ readonly property bool mediaPlaying: MprisController.activePlayer !== null && MprisController.activePlayer.isPlaying
+
+ onMonitorTimeoutChanged: _rearmIdleMonitors()
+ onLockTimeoutChanged: _rearmIdleMonitors()
+ onSuspendTimeoutChanged: _rearmIdleMonitors()
+
+ function _rearmIdleMonitors() {
+ _enableGate = false;
+ Qt.callLater(() => {
+ _enableGate = true;
+ });
+ }
+
+ signal lockRequested
+ signal fadeToLockRequested
+ signal cancelFadeToLock
+ signal fadeToDpmsRequested
+ signal cancelFadeToDpms
+ signal requestMonitorOff
+ signal requestMonitorOn
+ signal requestSuspend
+
+ property var monitorOffMonitor: null
+ property var lockMonitor: null
+ property var suspendMonitor: null
+ property var lockComponent: null
+ property bool monitorsOff: false
+ property bool isShellLocked: false
+
+ function wake() {
+ requestMonitorOn();
+ }
+
+ function reapplyDpmsIfNeeded() {
+ if (monitorsOff)
+ CompositorService.powerOffMonitors();
+ }
+
+ function createIdleMonitors() {
+ if (!idleMonitorAvailable) {
+ console.info("IdleService: IdleMonitor not available, skipping creation");
+ return;
+ }
+
+ try {
+ const qmlString = `
+ import QtQuick
+ import Quickshell.Wayland
+
+ IdleMonitor {
+ enabled: false
+ respectInhibitors: true
+ timeout: 0
+ }
+ `;
+
+ monitorOffMonitor = Qt.createQmlObject(qmlString, root, "IdleService.MonitorOffMonitor");
+ monitorOffMonitor.timeout = Qt.binding(() => root.monitorTimeout > 0 ? root.monitorTimeout : 86400);
+ monitorOffMonitor.respectInhibitors = Qt.binding(() => root.respectInhibitors);
+ monitorOffMonitor.enabled = Qt.binding(() => root._enableGate && root.enabled && root.idleMonitorAvailable && root.monitorTimeout > 0);
+ monitorOffMonitor.isIdleChanged.connect(function () {
+ if (monitorOffMonitor.isIdle) {
+ if (SettingsData.fadeToDpmsEnabled) {
+ root.fadeToDpmsRequested();
+ } else {
+ root.requestMonitorOff();
+ }
+ } else {
+ if (SettingsData.fadeToDpmsEnabled) {
+ root.cancelFadeToDpms();
+ }
+ root.requestMonitorOn();
+ }
+ });
+
+ lockMonitor = Qt.createQmlObject(qmlString, root, "IdleService.LockMonitor");
+ lockMonitor.timeout = Qt.binding(() => root.lockTimeout > 0 ? root.lockTimeout : 86400);
+ lockMonitor.respectInhibitors = Qt.binding(() => root.respectInhibitors);
+ lockMonitor.enabled = Qt.binding(() => root._enableGate && root.enabled && root.idleMonitorAvailable && root.lockTimeout > 0);
+ lockMonitor.isIdleChanged.connect(function () {
+ if (lockMonitor.isIdle) {
+ if (SettingsData.fadeToLockEnabled) {
+ root.fadeToLockRequested();
+ } else {
+ root.lockRequested();
+ }
+ } else {
+ if (SettingsData.fadeToLockEnabled) {
+ root.cancelFadeToLock();
+ }
+ }
+ });
+
+ suspendMonitor = Qt.createQmlObject(qmlString, root, "IdleService.SuspendMonitor");
+ suspendMonitor.timeout = Qt.binding(() => root.suspendTimeout > 0 ? root.suspendTimeout : 86400);
+ suspendMonitor.respectInhibitors = Qt.binding(() => root.respectInhibitors);
+ suspendMonitor.enabled = Qt.binding(() => root._enableGate && root.enabled && root.idleMonitorAvailable && root.suspendTimeout > 0);
+ suspendMonitor.isIdleChanged.connect(function () {
+ if (suspendMonitor.isIdle) {
+ root.requestSuspend();
+ }
+ });
+ } catch (e) {
+ console.warn("IdleService: Error creating IdleMonitors:", e);
+ }
+ }
+
+ Connections {
+ target: root
+ function onRequestMonitorOff() {
+ monitorsOff = true;
+ CompositorService.powerOffMonitors();
+ }
+
+ function onRequestMonitorOn() {
+ monitorsOff = false;
+ CompositorService.powerOnMonitors();
+ }
+
+ function onRequestSuspend() {
+ SessionService.suspendWithBehavior(root.suspendBehavior);
+ }
+ }
+
+ onExternalInhibitActiveChanged: {
+ if (externalInhibitActive) {
+ const apps = DMSService.screensaverInhibitors.map(i => i.appName).join(", ");
+ console.info("IdleService: External idle inhibit active from:", apps || "unknown");
+ SessionService.idleInhibited = true;
+ SessionService.inhibitReason = "External app: " + (apps || "unknown");
+ } else {
+ console.info("IdleService: External idle inhibit released");
+ SessionService.idleInhibited = false;
+ SessionService.inhibitReason = "Keep system awake";
+ }
+ }
+
+ Component.onCompleted: {
+ if (!idleMonitorAvailable) {
+ console.warn("IdleService: IdleMonitor not available - power management disabled. This requires a newer version of Quickshell.");
+ } else {
+ console.info("IdleService: Initialized with idle monitoring support");
+ createIdleMonitors();
+ }
+
+ if (externalInhibitActive) {
+ const apps = DMSService.screensaverInhibitors.map(i => i.appName).join(", ");
+ SessionService.idleInhibited = true;
+ SessionService.inhibitReason = "External app: " + (apps || "unknown");
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/KeybindsService.qml b/raveos-hyprland-theme/theme-data/dms/Services/KeybindsService.qml
new file mode 100644
index 0000000..470831b
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/KeybindsService.qml
@@ -0,0 +1,528 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtCore
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+// ! Even though qmlls says this is unused, it is wrong
+import qs.Common
+import "../Common/KeybindActions.js" as Actions
+
+Singleton {
+ id: root
+
+ Component.onCompleted: {
+ if (!shortcutInhibitorAvailable) {
+ console.warn("[KeybindsService] ShortcutInhibitor is not available in this environment, keybinds editor disabled.");
+ }
+ }
+
+ readonly property bool shortcutInhibitorAvailable: {
+ try {
+ return typeof ShortcutInhibitor !== "undefined";
+ } catch (e) {
+ return false;
+ }
+ }
+
+ property bool available: (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isDwl) && shortcutInhibitorAvailable
+ property string currentProvider: {
+ if (CompositorService.isNiri)
+ return "niri";
+ if (CompositorService.isHyprland)
+ return "hyprland";
+ if (CompositorService.isDwl)
+ return "mangowc";
+ return "";
+ }
+
+ readonly property string cheatsheetProvider: {
+ if (CompositorService.isNiri)
+ return "niri";
+ if (CompositorService.isHyprland)
+ return "hyprland";
+ if (CompositorService.isDwl)
+ return "mangowc";
+ return "";
+ }
+ property bool cheatsheetAvailable: cheatsheetProvider !== ""
+ property bool cheatsheetLoading: false
+ property var cheatsheet: ({})
+
+ property bool loading: false
+ property bool saving: false
+ property bool fixing: false
+ property string lastError: ""
+ property bool dmsBindsIncluded: true
+
+ property var dmsStatus: ({
+ "exists": true,
+ "included": true,
+ "includePosition": -1,
+ "totalIncludes": 0,
+ "bindsAfterDms": 0,
+ "effective": true,
+ "overriddenBy": 0,
+ "statusMessage": ""
+ })
+
+ property var _rawData: null
+ property var keybinds: ({})
+ property var _allBinds: ({})
+ property var _categories: []
+ property var _flatCache: []
+ property var displayList: []
+ property int _dataVersion: 0
+ property string _pendingSavedKey: ""
+
+ readonly property var categoryOrder: Actions.getCategoryOrder()
+ readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation))
+ readonly property string compositorConfigDir: {
+ switch (currentProvider) {
+ case "niri":
+ return configDir + "/niri";
+ case "hyprland":
+ return configDir + "/hypr";
+ case "mangowc":
+ return configDir + "/mango";
+ default:
+ return "";
+ }
+ }
+ readonly property string dmsBindsPath: {
+ switch (currentProvider) {
+ case "niri":
+ return compositorConfigDir + "/dms/binds.kdl";
+ case "hyprland":
+ case "mangowc":
+ return compositorConfigDir + "/dms/binds.conf";
+ default:
+ return "";
+ }
+ }
+ readonly property string mainConfigPath: {
+ switch (currentProvider) {
+ case "niri":
+ return compositorConfigDir + "/config.kdl";
+ case "hyprland":
+ return compositorConfigDir + "/hyprland.conf";
+ case "mangowc":
+ return compositorConfigDir + "/config.conf";
+ default:
+ return "";
+ }
+ }
+ readonly property var actionTypes: Actions.getActionTypes()
+ readonly property var dmsActions: getDmsActions()
+
+ signal bindsLoaded
+ signal bindSaved(string key)
+ signal bindSaveCompleted(bool success)
+ signal bindRemoved(string key)
+ signal dmsBindsFixed
+ signal cheatsheetLoaded
+
+ Connections {
+ target: CompositorService
+ function onCompositorChanged() {
+ if (!CompositorService.isNiri)
+ return;
+ Qt.callLater(root.loadBinds);
+ }
+ }
+
+ Connections {
+ target: NiriService
+ enabled: CompositorService.isNiri
+ function onConfigReloaded() {
+ Qt.callLater(root.loadBinds, false);
+ }
+ }
+
+ Process {
+ id: cheatsheetProcess
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ root.cheatsheet = JSON.parse(text);
+ } catch (e) {
+ console.error("[KeybindsService] Failed to parse cheatsheet:", e);
+ root.cheatsheet = {};
+ }
+ root.cheatsheetLoading = false;
+ root.cheatsheetLoaded();
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode === 0)
+ return;
+ console.warn("[KeybindsService] Cheatsheet load failed with code:", exitCode);
+ root.cheatsheetLoading = false;
+ }
+ }
+
+ Process {
+ id: loadProcess
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ root._rawData = JSON.parse(text);
+ root._processData();
+ } catch (e) {
+ console.error("[KeybindsService] Failed to parse binds:", e);
+ }
+ root.loading = false;
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ console.warn("[KeybindsService] Load process failed with code:", exitCode);
+ root.loading = false;
+ }
+ }
+ }
+
+ Process {
+ id: saveProcess
+ running: false
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (!text.trim())
+ return;
+ root.lastError = text.trim();
+ ToastService.showError(I18n.tr("Failed to save keybind"), "", root.lastError, "keybinds");
+ }
+ }
+
+ onExited: exitCode => {
+ root.saving = false;
+ if (exitCode !== 0) {
+ console.error("[KeybindsService] Save failed with code:", exitCode);
+ root.bindSaveCompleted(false);
+ return;
+ }
+ root.lastError = "";
+ root.bindSaveCompleted(true);
+ root.loadBinds(false);
+ }
+ }
+
+ Process {
+ id: removeProcess
+ running: false
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (!text.trim())
+ return;
+ root.lastError = text.trim();
+ ToastService.showError(I18n.tr("Failed to remove keybind"), "", root.lastError, "keybinds");
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ console.error("[KeybindsService] Remove failed with code:", exitCode);
+ return;
+ }
+ root.lastError = "";
+ root.loadBinds(false);
+ }
+ }
+
+ Process {
+ id: fixProcess
+ running: false
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ if (!text.trim())
+ return;
+ root.lastError = text.trim();
+ ToastService.showError(I18n.tr("Failed to add binds include"), "", root.lastError, "keybinds");
+ }
+ }
+
+ onExited: exitCode => {
+ root.fixing = false;
+ if (exitCode !== 0) {
+ console.error("[KeybindsService] Fix failed with code:", exitCode);
+ return;
+ }
+ root.lastError = "";
+ root.dmsBindsIncluded = true;
+ root.dmsBindsFixed();
+ const bindsFile = root.currentProvider === "niri" ? "dms/binds.kdl" : "dms/binds.conf";
+ ToastService.showInfo(I18n.tr("Binds include added"), I18n.tr("%1 is now included in config").arg(bindsFile), "", "keybinds");
+ Qt.callLater(root.forceReload);
+ }
+ }
+
+ function fixDmsBindsInclude() {
+ if (fixing || dmsBindsIncluded || !compositorConfigDir)
+ return;
+ fixing = true;
+ const timestamp = Math.floor(Date.now() / 1000);
+ const backupPath = `${mainConfigPath}.dmsbackup${timestamp}`;
+ let script;
+ switch (currentProvider) {
+ case "niri":
+ script = `mkdir -p "${compositorConfigDir}/dms" && touch "${compositorConfigDir}/dms/binds.kdl" && cp "${mainConfigPath}" "${backupPath}" && echo 'include "dms/binds.kdl"' >> "${mainConfigPath}"`;
+ break;
+ case "hyprland":
+ script = `mkdir -p "${compositorConfigDir}/dms" && touch "${compositorConfigDir}/dms/binds.conf" && cp "${mainConfigPath}" "${backupPath}" && echo 'source = ./dms/binds.conf' >> "${mainConfigPath}"`;
+ break;
+ case "mangowc":
+ script = `mkdir -p "${compositorConfigDir}/dms" && touch "${compositorConfigDir}/dms/binds.conf" && cp "${mainConfigPath}" "${backupPath}" && echo 'source = ./dms/binds.conf' >> "${mainConfigPath}"`;
+ break;
+ default:
+ fixing = false;
+ return;
+ }
+ fixProcess.command = ["sh", "-c", script];
+ fixProcess.running = true;
+ }
+
+ function forceReload() {
+ _allBinds = {};
+ _flatCache = [];
+ _categories = [];
+ loadBinds(true);
+ }
+
+ function loadCheatsheet(provider) {
+ if (cheatsheetProcess.running)
+ return;
+ const target = provider || cheatsheetProvider;
+ if (!target)
+ return;
+ cheatsheetLoading = true;
+ cheatsheetProcess.command = ["dms", "keybinds", "show", target];
+ cheatsheetProcess.running = true;
+ }
+
+ function loadBinds(showLoading) {
+ if (loadProcess.running || !available)
+ return;
+ const hasData = Object.keys(_allBinds).length > 0;
+ loading = showLoading !== false && !hasData;
+ loadProcess.command = ["dms", "keybinds", "show", currentProvider];
+ loadProcess.running = true;
+ }
+
+ function _processData() {
+ keybinds = _rawData || {};
+ dmsBindsIncluded = _rawData?.dmsBindsIncluded ?? true;
+ const status = _rawData?.dmsStatus;
+ if (status) {
+ dmsStatus = {
+ "exists": status.exists ?? true,
+ "included": status.included ?? true,
+ "includePosition": status.includePosition ?? -1,
+ "totalIncludes": status.totalIncludes ?? 0,
+ "bindsAfterDms": status.bindsAfterDms ?? 0,
+ "effective": status.effective ?? true,
+ "overriddenBy": status.overriddenBy ?? 0,
+ "statusMessage": status.statusMessage ?? ""
+ };
+ }
+
+ if (!_rawData?.binds) {
+ _allBinds = {};
+ _categories = [];
+ _flatCache = [];
+ displayList = [];
+ _dataVersion++;
+ bindsLoaded();
+ if (_pendingSavedKey) {
+ bindSaved(_pendingSavedKey);
+ _pendingSavedKey = "";
+ }
+ return;
+ }
+
+ const processed = {};
+ const bindsData = _rawData.binds;
+ for (const cat in bindsData) {
+ const binds = bindsData[cat];
+ for (var i = 0; i < binds.length; i++) {
+ const bind = binds[i];
+ const targetCat = Actions.isDmsAction(bind.action) ? "DMS" : cat;
+ if (!processed[targetCat])
+ processed[targetCat] = [];
+ processed[targetCat].push(bind);
+ }
+ }
+
+ const sortedCats = Object.keys(processed).sort((a, b) => {
+ const ai = categoryOrder.indexOf(a);
+ const bi = categoryOrder.indexOf(b);
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
+ });
+
+ const grouped = [];
+ const actionMap = {};
+ for (var ci = 0; ci < sortedCats.length; ci++) {
+ const category = sortedCats[ci];
+ const binds = processed[category];
+ if (!binds)
+ continue;
+ for (var i = 0; i < binds.length; i++) {
+ const bind = binds[i];
+ const action = bind.action || "";
+ const keyData = {
+ "key": bind.key || "",
+ "source": bind.source || "config",
+ "isOverride": bind.source === "dms",
+ "cooldownMs": bind.cooldownMs || 0,
+ "flags": bind.flags || "",
+ "allowWhenLocked": bind.allowWhenLocked || false,
+ "allowInhibiting": bind.allowInhibiting,
+ "repeat": bind.repeat
+ };
+ if (actionMap[action]) {
+ actionMap[action].keys.push(keyData);
+ if (!actionMap[action].desc && bind.desc)
+ actionMap[action].desc = bind.desc;
+ if (!actionMap[action].conflict && bind.conflict)
+ actionMap[action].conflict = bind.conflict;
+ } else {
+ const entry = {
+ "category": category,
+ "action": action,
+ "desc": bind.desc || "",
+ "keys": [keyData],
+ "conflict": bind.conflict || null
+ };
+ actionMap[action] = entry;
+ grouped.push(entry);
+ }
+ }
+ }
+
+ const list = [];
+ for (const cat of sortedCats) {
+ list.push({
+ "id": "cat:" + cat,
+ "type": "category",
+ "name": cat
+ });
+ const binds = processed[cat];
+ if (!binds)
+ continue;
+ for (const bind of binds)
+ list.push({
+ "id": "bind:" + bind.key,
+ "type": "bind",
+ "key": bind.key,
+ "desc": bind.desc
+ });
+ }
+
+ _allBinds = processed;
+ _categories = sortedCats;
+ _flatCache = grouped;
+ displayList = list;
+ _dataVersion++;
+ bindsLoaded();
+ if (_pendingSavedKey) {
+ bindSaved(_pendingSavedKey);
+ _pendingSavedKey = "";
+ }
+ }
+
+ function getCategories() {
+ return _categories;
+ }
+
+ function getFlatBinds() {
+ return _flatCache;
+ }
+
+ function saveBind(originalKey, bindData) {
+ if (!bindData.key || !Actions.isValidAction(bindData.action))
+ return;
+ saving = true;
+ const cmd = ["dms", "keybinds", "set", currentProvider, bindData.key, bindData.action, "--desc", bindData.desc || ""];
+ if (originalKey && originalKey !== bindData.key)
+ cmd.push("--replace-key", originalKey);
+ if (bindData.cooldownMs > 0)
+ cmd.push("--cooldown-ms", String(bindData.cooldownMs));
+ if (bindData.allowWhenLocked)
+ cmd.push("--allow-when-locked");
+ if (bindData.repeat === false)
+ cmd.push("--no-repeat");
+ if (bindData.allowInhibiting === false)
+ cmd.push("--no-inhibiting");
+ if (bindData.flags)
+ cmd.push("--flags", bindData.flags);
+ saveProcess.command = cmd;
+ saveProcess.running = true;
+ _pendingSavedKey = bindData.key;
+ }
+
+ function removeBind(key) {
+ if (!key)
+ return;
+ removeProcess.command = ["dms", "keybinds", "remove", currentProvider, key];
+ removeProcess.running = true;
+ bindRemoved(key);
+ }
+
+ function isDmsAction(action) {
+ return Actions.isDmsAction(action);
+ }
+
+ function isValidAction(action) {
+ return Actions.isValidAction(action);
+ }
+
+ function getActionType(action) {
+ return Actions.getActionType(action);
+ }
+
+ function getActionLabel(action) {
+ return Actions.getActionLabel(action, currentProvider);
+ }
+
+ function getCompositorCategories() {
+ return Actions.getCompositorCategories(currentProvider);
+ }
+
+ function getCompositorActions(category) {
+ return Actions.getCompositorActions(currentProvider, category);
+ }
+
+ function getDmsActions() {
+ return Actions.getDmsActions(CompositorService.isNiri, CompositorService.isHyprland);
+ }
+
+ function buildSpawnAction(command, args) {
+ return Actions.buildSpawnAction(command, args);
+ }
+
+ function buildShellAction(shellCmd, shell) {
+ return Actions.buildShellAction(currentProvider, shellCmd, shell);
+ }
+
+ function getShellFromAction(action) {
+ return Actions.getShellFromAction(action);
+ }
+
+ function parseSpawnCommand(action) {
+ return Actions.parseSpawnCommand(action);
+ }
+
+ function parseShellCommand(action) {
+ return Actions.parseShellCommand(action);
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/LegacyNetworkService.qml b/raveos-hyprland-theme/theme-data/dms/Services/LegacyNetworkService.qml
new file mode 100644
index 0000000..97d8745
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/LegacyNetworkService.qml
@@ -0,0 +1,987 @@
+pragma Singleton
+
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool isActive: false
+ property string networkStatus: "disconnected"
+ property string primaryConnection: ""
+
+ property string ethernetIP: ""
+ property string ethernetInterface: ""
+ property bool ethernetConnected: false
+ property string ethernetConnectionUuid: ""
+
+ property var wiredConnections: []
+
+ property string wifiIP: ""
+ property string wifiInterface: ""
+ property bool wifiConnected: false
+ property bool wifiEnabled: true
+ property string wifiConnectionUuid: ""
+ property string wifiDevicePath: ""
+ property string activeAccessPointPath: ""
+
+ property string currentWifiSSID: ""
+ property int wifiSignalStrength: 0
+ property var wifiNetworks: []
+ property var savedConnections: []
+ property var ssidToConnectionName: {
+
+ }
+ property var wifiSignalIcon: {
+ if (!wifiConnected || networkStatus !== "wifi") {
+ return "wifi_off"
+ }
+ if (wifiSignalStrength >= 50) {
+ return "wifi"
+ }
+ if (wifiSignalStrength >= 25) {
+ return "wifi_2_bar"
+ }
+ return "wifi_1_bar"
+ }
+
+ property string userPreference: "auto" // "auto", "wifi", "ethernet"
+ property bool isConnecting: false
+ property string connectingSSID: ""
+ property string connectionError: ""
+
+ property bool isScanning: false
+ property bool wifiAvailable: true
+ property bool wifiToggling: false
+ property bool changingPreference: false
+ property string targetPreference: ""
+ property var savedWifiNetworks: []
+ property string connectionStatus: ""
+ property string lastConnectionError: ""
+ property bool passwordDialogShouldReopen: false
+ property string wifiPassword: ""
+ property string forgetSSID: ""
+
+ readonly property var lowPriorityCmd: ["nice", "-n", "19", "ionice", "-c3"]
+
+ property string networkInfoSSID: ""
+ property string networkInfoDetails: ""
+ property bool networkInfoLoading: false
+
+ property string networkWiredInfoUUID: ""
+ property string networkWiredInfoDetails: ""
+ property bool networkWiredInfoLoading: false
+
+ signal networksUpdated
+ signal connectionChanged
+
+ function splitNmcliFields(line) {
+ const parts = []
+ let cur = ""
+ let escape = false
+ for (var i = 0; i < line.length; i++) {
+ const ch = line[i]
+ if (escape) {
+ cur += ch
+ escape = false
+ } else if (ch === '\\') {
+ escape = true
+ } else if (ch === ':') {
+ parts.push(cur)
+ cur = ""
+ } else {
+ cur += ch
+ }
+ }
+ parts.push(cur)
+ return parts
+ }
+
+ Component.onCompleted: {
+ root.userPreference = SettingsData.networkPreference
+ }
+
+ function activate() {
+ if (!isActive) {
+ isActive = true
+ console.info("LegacyNetworkService: Activating...")
+ doRefreshNetworkState()
+ }
+ }
+
+
+ function doRefreshNetworkState() {
+ updatePrimaryConnection()
+ updateDeviceStates()
+ updateActiveConnections()
+ updateWifiState()
+ }
+
+ function updatePrimaryConnection() {
+ primaryConnectionQuery.running = true
+ }
+
+ Process {
+ id: primaryConnectionQuery
+ command: lowPriorityCmd.concat(["gdbus", "call", "--system", "--dest", "org.freedesktop.NetworkManager", "--object-path", "/org/freedesktop/NetworkManager", "--method", "org.freedesktop.DBus.Properties.Get", "org.freedesktop.NetworkManager", "PrimaryConnection"])
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const match = text.match(/objectpath '([^']+)'/)
+ if (match && match[1] !== '/') {
+ root.primaryConnection = match[1]
+ getPrimaryConnectionType.running = true
+ } else {
+ root.primaryConnection = ""
+ root.networkStatus = "disconnected"
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getPrimaryConnectionType
+ command: root.primaryConnection ? lowPriorityCmd.concat(["gdbus", "call", "--system", "--dest", "org.freedesktop.NetworkManager", "--object-path", root.primaryConnection, "--method", "org.freedesktop.DBus.Properties.Get", "org.freedesktop.NetworkManager.Connection.Active", "Type"]) : []
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.includes("802-3-ethernet")) {
+ root.networkStatus = "ethernet"
+ } else if (text.includes("802-11-wireless")) {
+ root.networkStatus = "wifi"
+ }
+ root.connectionChanged()
+ }
+ }
+ }
+
+ function updateDeviceStates() {
+ getEthernetDevice.running = true
+ getWifiDevice.running = true
+ }
+
+ Process {
+ id: getEthernetDevice
+ command: lowPriorityCmd.concat(["nmcli", "-t", "-f", "DEVICE,TYPE", "device"])
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const lines = text.trim().split('\n')
+ let ethernetInterface = ""
+
+ for (const line of lines) {
+ const splitParts = line.split(':')
+ const device = splitParts[0]
+ const type = splitParts.length > 1 ? splitParts[1] : ""
+ if (type === "ethernet") {
+ ethernetInterface = device
+ break
+ }
+ }
+
+ if (ethernetInterface) {
+ root.ethernetInterface = ethernetInterface
+ getEthernetDevicePath.command = lowPriorityCmd.concat(["gdbus", "call", "--system", "--dest", "org.freedesktop.NetworkManager", "--object-path", "/org/freedesktop/NetworkManager", "--method", "org.freedesktop.NetworkManager.GetDeviceByIpIface", ethernetInterface])
+ getEthernetDevicePath.running = true
+ } else {
+ root.ethernetInterface = ""
+ root.ethernetConnected = false
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getEthernetDevicePath
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const match = text.match(/objectpath '([^']+)'/)
+ if (match && match[1] !== '/') {
+ checkEthernetState.command = lowPriorityCmd.concat(["gdbus", "call", "--system", "--dest", "org.freedesktop.NetworkManager", "--object-path", match[1], "--method", "org.freedesktop.DBus.Properties.Get", "org.freedesktop.NetworkManager.Device", "State"])
+ checkEthernetState.running = true
+ } else {
+ root.ethernetInterface = ""
+ root.ethernetConnected = false
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ root.ethernetInterface = ""
+ root.ethernetConnected = false
+ }
+ }
+ }
+
+ Process {
+ id: checkEthernetState
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const isConnected = text.includes("uint32 100")
+ root.ethernetConnected = isConnected
+ if (isConnected) {
+ getEthernetIP.running = true
+ } else {
+ root.ethernetIP = ""
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getEthernetIP
+ command: root.ethernetInterface ? lowPriorityCmd.concat(["ip", "-4", "addr", "show", root.ethernetInterface]) : []
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const match = text.match(/inet (\d+\.\d+\.\d+\.\d+)/)
+ if (match) {
+ root.ethernetIP = match[1]
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getWifiDevice
+ command: lowPriorityCmd.concat(["nmcli", "-t", "-f", "DEVICE,TYPE", "device"])
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const lines = text.trim().split('\n')
+ let wifiInterface = ""
+
+ for (const line of lines) {
+ const splitParts = line.split(':')
+ const device = splitParts[0]
+ const type = splitParts.length > 1 ? splitParts[1] : ""
+ if (type === "wifi") {
+ wifiInterface = device
+ break
+ }
+ }
+
+ if (wifiInterface) {
+ root.wifiInterface = wifiInterface
+ getWifiDevicePath.command = lowPriorityCmd.concat(["gdbus", "call", "--system", "--dest", "org.freedesktop.NetworkManager", "--object-path", "/org/freedesktop/NetworkManager", "--method", "org.freedesktop.NetworkManager.GetDeviceByIpIface", wifiInterface])
+ getWifiDevicePath.running = true
+ } else {
+ root.wifiInterface = ""
+ root.wifiConnected = false
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getWifiDevicePath
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const match = text.match(/objectpath '([^']+)'/)
+ if (match && match[1] !== '/') {
+ root.wifiDevicePath = match[1]
+ checkWifiState.command = lowPriorityCmd.concat(["gdbus", "call", "--system", "--dest", "org.freedesktop.NetworkManager", "--object-path", match[1], "--method", "org.freedesktop.DBus.Properties.Get", "org.freedesktop.NetworkManager.Device", "State"])
+ checkWifiState.running = true
+ } else {
+ root.wifiInterface = ""
+ root.wifiConnected = false
+ root.wifiDevicePath = ""
+ root.activeAccessPointPath = ""
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ root.wifiInterface = ""
+ root.wifiConnected = false
+ }
+ }
+ }
+
+ Process {
+ id: checkWifiState
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.wifiConnected = text.includes("uint32 100")
+ if (root.wifiConnected) {
+ getWifiIP.running = true
+ getCurrentWifiInfo.running = true
+ getActiveAccessPoint.running = true
+ if (root.currentWifiSSID === "") {
+ if (root.wifiConnectionUuid) {
+ resolveWifiSSID.running = true
+ }
+ if (root.wifiInterface) {
+ resolveWifiSSIDFromDevice.running = true
+ }
+ }
+ } else {
+ root.wifiIP = ""
+ root.currentWifiSSID = ""
+ root.wifiSignalStrength = 0
+ root.activeAccessPointPath = ""
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getWifiIP
+ command: root.wifiInterface ? lowPriorityCmd.concat(["ip", "-4", "addr", "show", root.wifiInterface]) : []
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const match = text.match(/inet (\d+\.\d+\.\d+\.\d+)/)
+ if (match) {
+ root.wifiIP = match[1]
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getActiveAccessPoint
+ command: root.wifiDevicePath ? lowPriorityCmd.concat(["gdbus", "call", "--system", "--dest", "org.freedesktop.NetworkManager", "--object-path", root.wifiDevicePath, "--method", "org.freedesktop.DBus.Properties.Get", "org.freedesktop.NetworkManager.Device.Wireless", "ActiveAccessPoint"]) : []
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const match = text.match(/objectpath '([^']+)'/)
+ if (match && match[1] !== '/') {
+ root.activeAccessPointPath = match[1]
+ } else {
+ root.activeAccessPointPath = ""
+ }
+ }
+ }
+ }
+
+ Process {
+ id: getCurrentWifiInfo
+ command: root.wifiInterface ? lowPriorityCmd.concat(["nmcli", "-t", "-f", "ACTIVE,SIGNAL,SSID", "device", "wifi", "list", "ifname", root.wifiInterface, "--rescan", "no"]) : []
+ running: false
+
+ stdout: SplitParser {
+ splitMarker: "\n"
+ onRead: line => {
+ if (line.startsWith("yes:")) {
+ const rest = line.substring(4)
+ const parts = root.splitNmcliFields(rest)
+ if (parts.length >= 2) {
+ const signal = parseInt(parts[0])
+ console.log("Current WiFi signal strength:", signal)
+ root.wifiSignalStrength = isNaN(signal) ? 0 : signal
+ root.currentWifiSSID = parts[1]
+ console.log("Current WiFi SSID:", root.currentWifiSSID)
+ }
+ return
+ }
+ }
+ }
+ }
+
+ function updateActiveConnections() {
+ getActiveConnections.running = true
+ }
+
+ Process {
+ id: getActiveConnections
+ command: lowPriorityCmd.concat(["nmcli", "-t", "-f", "UUID,TYPE,DEVICE,STATE", "connection", "show", "--active"])
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const lines = text.trim().split('\n')
+ for (const line of lines) {
+ const parts = line.split(':')
+ if (parts.length >= 4) {
+ const uuid = parts[0]
+ const type = parts[1]
+ const device = parts[2]
+ const state = parts[3]
+ if (type === "802-3-ethernet" && state === "activated") {
+ root.ethernetConnectionUuid = uuid
+ } else if (type === "802-11-wireless" && state === "activated") {
+ root.wifiConnectionUuid = uuid
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Resolve SSID from active WiFi connection UUID when scans don't mark any row as ACTIVE.
+ Process {
+ id: resolveWifiSSID
+ command: root.wifiConnectionUuid ? lowPriorityCmd.concat(["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", "uuid", root.wifiConnectionUuid]) : []
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const ssid = text.trim()
+ if (ssid) {
+ root.currentWifiSSID = ssid
+ }
+ }
+ }
+ }
+
+ // Fallback 2: Resolve SSID from device info (GENERAL.CONNECTION usually matches SSID for WiFi)
+ Process {
+ id: resolveWifiSSIDFromDevice
+ command: root.wifiInterface ? lowPriorityCmd.concat(["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", root.wifiInterface]) : []
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (!root.currentWifiSSID) {
+ const name = text.trim()
+ if (name) {
+ root.currentWifiSSID = name
+ }
+ }
+ }
+ }
+ }
+
+ function updateWifiState() {
+ checkWifiEnabled.running = true
+ }
+
+ Process {
+ id: checkWifiEnabled
+ command: lowPriorityCmd.concat(["gdbus", "call", "--system", "--dest", "org.freedesktop.NetworkManager", "--object-path", "/org/freedesktop/NetworkManager", "--method", "org.freedesktop.DBus.Properties.Get", "org.freedesktop.NetworkManager", "WirelessEnabled"])
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.wifiEnabled = text.includes("true")
+ root.wifiAvailable = true // Always available if we can check it
+ }
+ }
+ }
+
+ function scanWifi() {
+ if (root.isScanning || !root.wifiEnabled) {
+ return
+ }
+
+ root.isScanning = true
+ requestWifiScan.running = true
+ }
+
+ Process {
+ id: requestWifiScan
+ command: root.wifiInterface ? lowPriorityCmd.concat(["nmcli", "dev", "wifi", "rescan", "ifname", root.wifiInterface]) : []
+ running: false
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ scanWifiNetworks()
+ } else {
+ console.warn("WiFi scan request failed")
+ root.isScanning = false
+ }
+ }
+ }
+
+ function scanWifiNetworks() {
+ if (!root.wifiInterface) {
+ root.isScanning = false
+ return
+ }
+
+ getWifiNetworks.running = true
+ getSavedConnections.running = true
+ }
+
+ Process {
+ id: getWifiNetworks
+ command: lowPriorityCmd.concat(["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,BSSID", "dev", "wifi", "list", "ifname", root.wifiInterface])
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const networks = []
+ const lines = text.trim().split('\n')
+ const seen = new Set()
+
+ for (const line of lines) {
+ const parts = root.splitNmcliFields(line)
+ if (parts.length >= 4 && parts[0]) {
+ const ssid = parts[0]
+ if (!seen.has(ssid)) {
+ seen.add(ssid)
+ const signal = parseInt(parts[1]) || 0
+
+ networks.push({
+ "ssid": ssid,
+ "signal": signal,
+ "secured": parts[2] !== "",
+ "bssid": parts[3],
+ "connected": ssid === root.currentWifiSSID,
+ "saved": false
+ })
+ }
+ }
+ }
+
+ networks.sort((a, b) => b.signal - a.signal)
+ root.wifiNetworks = networks
+ root.isScanning = false
+ root.networksUpdated()
+ }
+ }
+ }
+
+ Process {
+ id: getSavedConnections
+ command: lowPriorityCmd.concat(["bash", "-c", "nmcli -t -f NAME,TYPE connection show | grep ':802-11-wireless$' | cut -d: -f1 | while read name; do ssid=$(nmcli -g 802-11-wireless.ssid connection show \"$name\"); echo \"$ssid:$name\"; done"])
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const saved = []
+ const mapping = {}
+ const lines = text.trim().split('\n')
+
+ for (const line of lines) {
+ const parts = line.trim().split(':')
+ if (parts.length >= 2) {
+ const ssid = parts[0]
+ const connectionName = parts[1]
+ if (ssid && ssid.length > 0 && connectionName && connectionName.length > 0) {
+ saved.push({
+ "ssid": ssid,
+ "saved": true
+ })
+ mapping[ssid] = connectionName
+ }
+ }
+ }
+
+ root.savedConnections = saved
+ root.savedWifiNetworks = saved
+ root.ssidToConnectionName = mapping
+
+ const updated = [...root.wifiNetworks]
+ for (const network of updated) {
+ network.saved = saved.some(s => s.ssid === network.ssid)
+ }
+ root.wifiNetworks = updated
+ }
+ }
+ }
+
+ function connectToWifi(ssid, password = "", username = "") {
+ if (root.isConnecting) {
+ return
+ }
+
+ root.isConnecting = true
+ root.connectingSSID = ssid
+ root.connectionError = ""
+ root.connectionStatus = "connecting"
+
+ if (!password && root.ssidToConnectionName[ssid]) {
+ const connectionName = root.ssidToConnectionName[ssid]
+ wifiConnector.command = lowPriorityCmd.concat(["nmcli", "connection", "up", connectionName])
+ } else if (password) {
+ wifiConnector.command = lowPriorityCmd.concat(["nmcli", "dev", "wifi", "connect", ssid, "password", password])
+ } else {
+ wifiConnector.command = lowPriorityCmd.concat(["nmcli", "dev", "wifi", "connect", ssid])
+ }
+ wifiConnector.running = true
+ }
+
+ Process {
+ id: wifiConnector
+ running: false
+
+ property bool connectionSucceeded: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text.includes("successfully")) {
+ wifiConnector.connectionSucceeded = true
+ ToastService.showInfo(`Connected to ${root.connectingSSID}`)
+ root.connectionError = ""
+ root.connectionStatus = "connected"
+
+ if (root.userPreference === "wifi" || root.userPreference === "auto") {
+ setConnectionPriority("wifi")
+ }
+ }
+ }
+ }
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ root.connectionError = text
+ root.lastConnectionError = text
+ if (!wifiConnector.connectionSucceeded && text.trim() !== "") {
+ if (text.includes("password") || text.includes("authentication")) {
+ root.connectionStatus = "invalid_password"
+ root.passwordDialogShouldReopen = true
+ } else {
+ root.connectionStatus = "failed"
+ }
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode === 0 || wifiConnector.connectionSucceeded) {
+ if (!wifiConnector.connectionSucceeded) {
+ ToastService.showInfo(`Connected to ${root.connectingSSID}`)
+ root.connectionStatus = "connected"
+ }
+ } else {
+ if (root.connectionStatus === "") {
+ root.connectionStatus = "failed"
+ }
+ if (root.connectionStatus === "invalid_password") {
+ ToastService.showError(`Invalid password for ${root.connectingSSID}`)
+ } else {
+ ToastService.showError(`Failed to connect to ${root.connectingSSID}`)
+ }
+ }
+
+ wifiConnector.connectionSucceeded = false
+ root.isConnecting = false
+ root.connectingSSID = ""
+ doRefreshNetworkState()
+ }
+ }
+
+ function disconnectWifi() {
+ if (!root.wifiInterface) {
+ return
+ }
+
+ wifiDisconnector.command = lowPriorityCmd.concat(["nmcli", "dev", "disconnect", root.wifiInterface])
+ wifiDisconnector.running = true
+ }
+
+ Process {
+ id: wifiDisconnector
+ running: false
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ ToastService.showInfo("Disconnected from WiFi")
+ root.currentWifiSSID = ""
+ root.connectionStatus = ""
+ }
+ doRefreshNetworkState()
+ }
+ }
+
+ function forgetWifiNetwork(ssid) {
+ root.forgetSSID = ssid
+ const connectionName = root.ssidToConnectionName[ssid] || ssid
+ networkForgetter.command = lowPriorityCmd.concat(["nmcli", "connection", "delete", connectionName])
+ networkForgetter.running = true
+ }
+
+ Process {
+ id: networkForgetter
+ running: false
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ ToastService.showInfo(`Forgot network ${root.forgetSSID}`)
+
+ root.savedConnections = root.savedConnections.filter(s => s.ssid !== root.forgetSSID)
+ root.savedWifiNetworks = root.savedWifiNetworks.filter(s => s.ssid !== root.forgetSSID)
+
+ const updated = [...root.wifiNetworks]
+ for (const network of updated) {
+ if (network.ssid === root.forgetSSID) {
+ network.saved = false
+ if (network.connected) {
+ network.connected = false
+ root.currentWifiSSID = ""
+ }
+ }
+ }
+ root.wifiNetworks = updated
+ root.networksUpdated()
+ doRefreshNetworkState()
+ }
+ root.forgetSSID = ""
+ }
+ }
+
+ function toggleWifiRadio() {
+ if (root.wifiToggling) {
+ return
+ }
+
+ root.wifiToggling = true
+ const targetState = root.wifiEnabled ? "off" : "on"
+ wifiRadioToggler.targetState = targetState
+ wifiRadioToggler.command = lowPriorityCmd.concat(["nmcli", "radio", "wifi", targetState])
+ wifiRadioToggler.running = true
+ }
+
+ Process {
+ id: wifiRadioToggler
+ running: false
+
+ property string targetState: ""
+
+ onExited: exitCode => {
+ root.wifiToggling = false
+ if (exitCode === 0) {
+ ToastService.showInfo(targetState === "on" ? "WiFi enabled" : "WiFi disabled")
+ }
+ doRefreshNetworkState()
+ }
+ }
+
+ function setNetworkPreference(preference) {
+ root.userPreference = preference
+ root.changingPreference = true
+ root.targetPreference = preference
+ SettingsData.set("networkPreference", preference)
+
+ if (preference === "wifi") {
+ setConnectionPriority("wifi")
+ } else if (preference === "ethernet") {
+ setConnectionPriority("ethernet")
+ }
+ }
+
+ function setConnectionPriority(type) {
+ if (type === "wifi") {
+ setRouteMetrics.command = lowPriorityCmd.concat(["bash", "-c", "nmcli -t -f NAME,TYPE connection show | grep 802-11-wireless | cut -d: -f1 | " + "xargs -I {} bash -c 'nmcli connection modify \"{}\" ipv4.route-metric 50 ipv6.route-metric 50'; " + "nmcli -t -f NAME,TYPE connection show | grep 802-3-ethernet | cut -d: -f1 | " + "xargs -I {} bash -c 'nmcli connection modify \"{}\" ipv4.route-metric 100 ipv6.route-metric 100'"])
+ } else if (type === "ethernet") {
+ setRouteMetrics.command = lowPriorityCmd.concat(["bash", "-c", "nmcli -t -f NAME,TYPE connection show | grep 802-3-ethernet | cut -d: -f1 | " + "xargs -I {} bash -c 'nmcli connection modify \"{}\" ipv4.route-metric 50 ipv6.route-metric 50'; " + "nmcli -t -f NAME,TYPE connection show | grep 802-11-wireless | cut -d: -f1 | " + "xargs -I {} bash -c 'nmcli connection modify \"{}\" ipv4.route-metric 100 ipv6.route-metric 100'"])
+ }
+ setRouteMetrics.running = true
+ }
+
+ Process {
+ id: setRouteMetrics
+ running: false
+
+ onExited: exitCode => {
+ console.log("Set route metrics process exited with code:", exitCode)
+ if (exitCode === 0) {
+ restartConnections.running = true
+ }
+ }
+ }
+
+ Process {
+ id: restartConnections
+ command: lowPriorityCmd.concat(["bash", "-c", "nmcli -t -f UUID,TYPE connection show --active | " + "grep -E '802-11-wireless|802-3-ethernet' | cut -d: -f1 | " + "xargs -I {} sh -c 'nmcli connection down {} && nmcli connection up {}'"])
+ running: false
+
+ onExited: {
+ root.changingPreference = false
+ root.targetPreference = ""
+ doRefreshNetworkState()
+ }
+ }
+
+
+ function fetchNetworkInfo(ssid) {
+ root.networkInfoSSID = ssid
+ root.networkInfoLoading = true
+ root.networkInfoDetails = "Loading network information..."
+ wifiInfoFetcher.running = true
+ }
+
+ Process {
+ id: wifiInfoFetcher
+ command: lowPriorityCmd.concat(["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,FREQ,RATE,MODE,CHAN,WPA-FLAGS,RSN-FLAGS,ACTIVE,BSSID", "dev", "wifi", "list"])
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ let details = ""
+ if (text.trim()) {
+ const lines = text.trim().split('\n')
+ const bands = []
+
+ for (const line of lines) {
+ const parts = line.split(':')
+ if (parts.length >= 11 && parts[0] === root.networkInfoSSID) {
+ const signal = parts[1] || "0"
+ const security = parts[2] || "Open"
+ const freq = parts[3] || "Unknown"
+ const rate = parts[4] || "Unknown"
+ const channel = parts[6] || "Unknown"
+ const isActive = parts[9] === "yes"
+ let colonCount = 0
+ let bssidStart = -1
+ for (var i = 0; i < line.length; i++) {
+ if (line[i] === ':') {
+ colonCount++
+ if (colonCount === 10) {
+ bssidStart = i + 1
+ break
+ }
+ }
+ }
+ const bssid = bssidStart >= 0 ? line.substring(bssidStart).replace(/\\:/g, ":") : ""
+
+ let band = "Unknown"
+ const freqNum = parseInt(freq)
+ if (freqNum >= 2400 && freqNum <= 2500) {
+ band = "2.4 GHz"
+ } else if (freqNum >= 5000 && freqNum <= 6000) {
+ band = "5 GHz"
+ } else if (freqNum >= 6000) {
+ band = "6 GHz"
+ }
+
+ bands.push({
+ "band": band,
+ "freq": freq,
+ "channel": channel,
+ "signal": signal,
+ "rate": rate,
+ "security": security,
+ "isActive": isActive,
+ "bssid": bssid
+ })
+ }
+ }
+
+ if (bands.length > 0) {
+ bands.sort((a, b) => {
+ if (a.isActive && !b.isActive) {
+ return -1
+ }
+ if (!a.isActive && b.isActive) {
+ return 1
+ }
+ return parseInt(b.signal) - parseInt(a.signal)
+ })
+
+ for (var i = 0; i < bands.length; i++) {
+ const b = bands[i]
+ if (b.isActive) {
+ details += "● " + b.band + " (Connected) - " + b.signal + "%\\n"
+ } else {
+ details += " " + b.band + " - " + b.signal + "%\\n"
+ }
+ details += " Channel " + b.channel + " (" + b.freq + " MHz) • " + b.rate + " Mbit/s\\n"
+ details += " " + b.bssid
+ if (i < bands.length - 1) {
+ details += "\\n\\n"
+ }
+ }
+ }
+ }
+
+ if (details === "") {
+ details = "Network information not found or network not available."
+ }
+
+ root.networkInfoDetails = details
+ root.networkInfoLoading = false
+ }
+ }
+
+ onExited: exitCode => {
+ root.networkInfoLoading = false
+ if (exitCode !== 0) {
+ root.networkInfoDetails = "Failed to fetch network information"
+ }
+ }
+ }
+
+ function enableWifiDevice() {
+ wifiDeviceEnabler.running = true
+ }
+
+ Process {
+ id: wifiDeviceEnabler
+ command: lowPriorityCmd.concat(["sh", "-c", "WIFI_DEV=$(nmcli -t -f DEVICE,TYPE device | grep wifi | cut -d: -f1 | head -1); if [ -n \"$WIFI_DEV\" ]; then nmcli device connect \"$WIFI_DEV\"; else echo \"No WiFi device found\"; exit 1; fi"])
+ running: false
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ ToastService.showInfo("WiFi enabled")
+ } else {
+ ToastService.showError("Failed to enable WiFi")
+ }
+ doRefreshNetworkState()
+ }
+ }
+
+ function connectToWifiAndSetPreference(ssid, password) {
+ connectToWifi(ssid, password)
+ setNetworkPreference("wifi")
+ }
+
+ function toggleNetworkConnection(type) {
+ if (type === "ethernet") {
+ if (root.networkStatus === "ethernet") {
+ ethernetDisconnector.running = true
+ } else {
+ ethernetConnector.running = true
+ }
+ }
+ }
+
+ Process {
+ id: ethernetDisconnector
+ command: lowPriorityCmd.concat(["sh", "-c", "nmcli device disconnect $(nmcli -t -f DEVICE,TYPE device | grep ethernet | cut -d: -f1 | head -1)"])
+ running: false
+
+ onExited: function (exitCode) {
+ doRefreshNetworkState()
+ }
+ }
+
+ Process {
+ id: ethernetConnector
+ command: lowPriorityCmd.concat(["sh", "-c", "ETH_DEV=$(nmcli -t -f DEVICE,TYPE device | grep ethernet | cut -d: -f1 | head -1); if [ -n \"$ETH_DEV\" ]; then nmcli device connect \"$ETH_DEV\"; else echo \"No ethernet device found\"; exit 1; fi"])
+ running: false
+
+ onExited: function (exitCode) {
+ doRefreshNetworkState()
+ }
+ }
+
+ function getNetworkInfo(ssid) {
+ const network = root.wifiNetworks.find(n => n.ssid === ssid)
+ if (!network) {
+ return null
+ }
+
+ return {
+ "ssid": network.ssid,
+ "signal": network.signal,
+ "secured": network.secured,
+ "saved": network.saved,
+ "connected": network.connected,
+ "bssid": network.bssid
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/LocationService.qml b/raveos-hyprland-theme/theme-data/dms/Services/LocationService.qml
new file mode 100644
index 0000000..0fde587
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/LocationService.qml
@@ -0,0 +1,55 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property bool locationAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("location"))
+ readonly property bool valid: latitude !== 0 || longitude !== 0
+
+ property var latitude: 0.0
+ property var longitude: 0.0
+
+ signal locationChanged(var data)
+
+ onLocationAvailableChanged: {
+ if (locationAvailable && !valid)
+ getState();
+ }
+
+ Connections {
+ target: DMSService
+
+ function onLocationStateUpdate(data) {
+ if (!locationAvailable)
+ return;
+ handleStateUpdate(data);
+ }
+ }
+
+ function handleStateUpdate(data) {
+ const lat = data.latitude;
+ const lon = data.longitude;
+ if (lat === 0 && lon === 0)
+ return;
+
+ root.latitude = lat;
+ root.longitude = lon;
+ root.locationChanged(data);
+ }
+
+ function getState() {
+ if (!locationAvailable)
+ return;
+
+ DMSService.sendRequest("location.getState", null, response => {
+ if (response.result)
+ handleStateUpdate(response.result);
+ });
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/MprisController.qml b/raveos-hyprland-theme/theme-data/dms/Services/MprisController.qml
new file mode 100644
index 0000000..44ad135
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/MprisController.qml
@@ -0,0 +1,78 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.Mpris
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property list<MprisPlayer> availablePlayers: Mpris.players.values
+ property MprisPlayer activePlayer: null
+
+ onAvailablePlayersChanged: _resolveActivePlayer()
+ Component.onCompleted: _resolveActivePlayer()
+
+ Instantiator {
+ model: root.availablePlayers
+ delegate: Connections {
+ required property MprisPlayer modelData
+ target: modelData
+ function onIsPlayingChanged() {
+ if (modelData.isPlaying)
+ root._resolveActivePlayer();
+ }
+ }
+ }
+
+ function _resolveActivePlayer(): void {
+ const playing = availablePlayers.find(p => p.isPlaying);
+ if (playing) {
+ activePlayer = playing;
+ _persistIdentity(playing.identity);
+ return;
+ }
+ if (activePlayer && availablePlayers.indexOf(activePlayer) >= 0)
+ return;
+ const savedId = SessionData.lastPlayerIdentity;
+ if (savedId) {
+ const match = availablePlayers.find(p => p.identity === savedId);
+ if (match) {
+ activePlayer = match;
+ return;
+ }
+ }
+ activePlayer = availablePlayers.find(p => p.canControl && p.canPlay) ?? null;
+ if (activePlayer)
+ _persistIdentity(activePlayer.identity);
+ }
+
+ function setActivePlayer(player: MprisPlayer): void {
+ activePlayer = player;
+ if (player)
+ _persistIdentity(player.identity);
+ }
+
+ function _persistIdentity(identity: string): void {
+ if (identity && SessionData.lastPlayerIdentity !== identity)
+ SessionData.set("lastPlayerIdentity", identity);
+ }
+
+ Timer {
+ interval: 1000
+ running: root.activePlayer?.playbackState === MprisPlaybackState.Playing
+ repeat: true
+ onTriggered: root.activePlayer?.positionChanged()
+ }
+
+ function previousOrRewind(): void {
+ if (!activePlayer)
+ return;
+ if (activePlayer.position > 8 && activePlayer.canSeek)
+ activePlayer.position = 0;
+ else if (activePlayer.canGoPrevious)
+ activePlayer.previous();
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/MultimediaService.qml b/raveos-hyprland-theme/theme-data/dms/Services/MultimediaService.qml
new file mode 100644
index 0000000..f570c72
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/MultimediaService.qml
@@ -0,0 +1,35 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+
+Singleton {
+ id: root
+
+ property bool available: false
+
+ function detectAvailability() {
+ try {
+ const testObj = Qt.createQmlObject(`
+ import QtQuick
+ import QtMultimedia
+ Item {}
+ `, root, "MultimediaService.TestComponent");
+ if (testObj) {
+ testObj.destroy();
+ }
+ available = true;
+ return true;
+ } catch (e) {
+ available = false;
+ return false;
+ }
+ }
+
+ Component.onCompleted: {
+ if (!detectAvailability()) {
+ console.warn("MultimediaService: QtMultimedia not available");
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/MuxService.qml b/raveos-hyprland-theme/theme-data/dms/Services/MuxService.qml
new file mode 100644
index 0000000..c2f941c
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/MuxService.qml
@@ -0,0 +1,230 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property var sessions: []
+ property bool loading: false
+
+ property bool tmuxAvailable: false
+ property bool zellijAvailable: false
+ readonly property bool currentMuxAvailable: muxType === "zellij" ? zellijAvailable : tmuxAvailable
+
+ readonly property string muxType: SettingsData.muxType
+ readonly property string displayName: muxType === "zellij" ? "Zellij" : "Tmux"
+
+ readonly property var terminalFlags: ({
+ "ghostty": ["-e"],
+ "kitty": ["-e"],
+ "alacritty": ["-e"],
+ "foot": [],
+ "wezterm": ["start", "--"],
+ "gnome-terminal": ["--"],
+ "xterm": ["-e"],
+ "konsole": ["-e"],
+ "st": ["-e"],
+ "terminator": ["-e"],
+ "xfce4-terminal": ["-e"]
+ })
+
+ function getTerminalFlag(terminal) {
+ return terminalFlags[terminal] ?? ["-e"]
+ }
+
+ readonly property string terminal: Quickshell.env("TERMINAL") || "ghostty"
+
+ function _terminalPrefix() {
+ return [terminal].concat(getTerminalFlag(terminal))
+ }
+
+ Process {
+ id: tmuxCheckProcess
+ command: ["which", "tmux"]
+ running: false
+ onExited: (code) => { root.tmuxAvailable = (code === 0) }
+ }
+
+ Process {
+ id: zellijCheckProcess
+ command: ["which", "zellij"]
+ running: false
+ onExited: (code) => { root.zellijAvailable = (code === 0) }
+ }
+
+ function checkAvailability() {
+ tmuxCheckProcess.running = true
+ zellijCheckProcess.running = true
+ }
+
+ Component.onCompleted: checkAvailability()
+
+ Process {
+ id: listProcess
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ if (root.muxType === "zellij")
+ root._parseZellijSessions(text)
+ else
+ root._parseTmuxSessions(text)
+ } catch (e) {
+ console.error("[MuxService] Error parsing sessions:", e)
+ root.sessions = []
+ }
+ root.loading = false
+ }
+ }
+
+ stderr: SplitParser {
+ onRead: (line) => {
+ if (line.trim())
+ console.error("[MuxService] stderr:", line)
+ }
+ }
+
+ onExited: (code) => {
+ if (code !== 0 && code !== 1) {
+ console.warn("[MuxService] Process exited with code:", code)
+ root.sessions = []
+ }
+ root.loading = false
+ }
+ }
+
+ function refreshSessions() {
+ if (!root.currentMuxAvailable) {
+ root.sessions = []
+ return
+ }
+
+ root.loading = true
+
+ if (listProcess.running)
+ listProcess.running = false
+
+ if (root.muxType === "zellij")
+ listProcess.command = ["zellij", "list-sessions", "--no-formatting"]
+ else
+ listProcess.command = ["tmux", "list-sessions", "-F", "#{session_name}|#{session_windows}|#{session_attached}"]
+
+ Qt.callLater(function () {
+ listProcess.running = true
+ })
+ }
+
+ function _isSessionExcluded(name) {
+ var filter = SettingsData.muxSessionFilter.trim()
+ if (filter.length === 0)
+ return false
+ var parts = filter.split(",")
+ for (var i = 0; i < parts.length; i++) {
+ var pattern = parts[i].trim()
+ if (pattern.length === 0)
+ continue
+ if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) {
+ try {
+ var re = new RegExp(pattern.slice(1, -1))
+ if (re.test(name))
+ return true
+ } catch (e) {}
+ } else {
+ if (name.toLowerCase() === pattern.toLowerCase())
+ return true
+ }
+ }
+ return false
+ }
+
+ function _parseTmuxSessions(output) {
+ var sessionList = []
+ var lines = output.trim().split('\n')
+
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim()
+ if (line.length === 0)
+ continue
+
+ var parts = line.split('|')
+ if (parts.length >= 3 && !_isSessionExcluded(parts[0])) {
+ sessionList.push({
+ name: parts[0],
+ windows: parts[1],
+ attached: parts[2] === "1"
+ })
+ }
+ }
+
+ root.sessions = sessionList
+ }
+
+ function _parseZellijSessions(output) {
+ var sessionList = []
+ var lines = output.trim().split('\n')
+
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim()
+ if (line.length === 0)
+ continue
+
+ var exited = line.includes("(EXITED")
+ var bracketIdx = line.indexOf(" [")
+ var name = (bracketIdx > 0 ? line.substring(0, bracketIdx) : line).trim()
+
+ if (!_isSessionExcluded(name)) {
+ sessionList.push({
+ name: name,
+ windows: "N/A",
+ attached: !exited
+ })
+ }
+ }
+
+ root.sessions = sessionList
+ }
+
+ function attachToSession(name) {
+ if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) {
+ Quickshell.execDetached([SettingsData.muxCustomCommand, name])
+ } else if (root.muxType === "zellij") {
+ Quickshell.execDetached(_terminalPrefix().concat(["zellij", "attach", name]))
+ } else {
+ Quickshell.execDetached(_terminalPrefix().concat(["tmux", "attach", "-t", name]))
+ }
+ }
+
+ function createSession(name) {
+ if (SettingsData.muxUseCustomCommand && SettingsData.muxCustomCommand) {
+ Quickshell.execDetached([SettingsData.muxCustomCommand, name])
+ } else if (root.muxType === "zellij") {
+ Quickshell.execDetached(_terminalPrefix().concat(["zellij", "-s", name]))
+ } else {
+ Quickshell.execDetached(_terminalPrefix().concat(["tmux", "new-session", "-s", name]))
+ }
+ }
+
+ readonly property bool supportsRename: muxType !== "zellij"
+
+ function renameSession(oldName, newName) {
+ if (root.muxType === "zellij")
+ return
+ Quickshell.execDetached(["tmux", "rename-session", "-t", oldName, newName])
+ Qt.callLater(refreshSessions)
+ }
+
+ function killSession(name) {
+ if (root.muxType === "zellij") {
+ Quickshell.execDetached(["zellij", "kill-session", name])
+ } else {
+ Quickshell.execDetached(["tmux", "kill-session", "-t", name])
+ }
+ Qt.callLater(refreshSessions)
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/NetworkService.qml b/raveos-hyprland-theme/theme-data/dms/Services/NetworkService.qml
new file mode 100644
index 0000000..38383d2
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/NetworkService.qml
@@ -0,0 +1,313 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+
+Singleton {
+ id: root
+
+ property bool networkAvailable: activeService !== null
+ property string backend: activeService?.backend ?? ""
+ property string networkStatus: activeService?.networkStatus ?? "disconnected"
+ property string primaryConnection: activeService?.primaryConnection ?? ""
+
+ property string ethernetIP: activeService?.ethernetIP ?? ""
+ property string ethernetInterface: activeService?.ethernetInterface ?? ""
+ property bool ethernetConnected: activeService?.ethernetConnected ?? false
+ property string ethernetConnectionUuid: activeService?.ethernetConnectionUuid ?? ""
+ property var ethernetDevices: activeService?.ethernetDevices ?? []
+
+ property var wiredConnections: activeService?.wiredConnections ?? []
+
+ property string wifiIP: activeService?.wifiIP ?? ""
+ property string wifiInterface: activeService?.wifiInterface ?? ""
+ property bool wifiConnected: activeService?.wifiConnected ?? false
+ property bool wifiEnabled: activeService?.wifiEnabled ?? true
+ property string wifiConnectionUuid: activeService?.wifiConnectionUuid ?? ""
+ property string wifiDevicePath: activeService?.wifiDevicePath ?? ""
+ property string activeAccessPointPath: activeService?.activeAccessPointPath ?? ""
+ property var wifiDevices: activeService?.wifiDevices ?? []
+ property string wifiDeviceOverride: activeService?.wifiDeviceOverride ?? ""
+ property string connectingDevice: activeService?.connectingDevice ?? ""
+
+ property string currentWifiSSID: activeService?.currentWifiSSID ?? ""
+ property int wifiSignalStrength: activeService?.wifiSignalStrength ?? 0
+ property var wifiNetworks: activeService?.wifiNetworks ?? []
+ property var savedConnections: activeService?.savedConnections ?? []
+ property var ssidToConnectionName: activeService?.ssidToConnectionName ?? ({})
+ property var wifiSignalIcon: activeService?.wifiSignalIcon ?? "wifi_off"
+
+ property string userPreference: activeService?.userPreference ?? "auto"
+ property bool isConnecting: activeService?.isConnecting ?? false
+ property string connectingSSID: activeService?.connectingSSID ?? ""
+ property string connectionError: activeService?.connectionError ?? ""
+
+ property bool isScanning: activeService?.isScanning ?? false
+ property bool autoScan: activeService?.autoScan ?? false
+
+ property bool wifiAvailable: activeService?.wifiAvailable ?? true
+ property bool wifiToggling: activeService?.wifiToggling ?? false
+ property bool changingPreference: activeService?.changingPreference ?? false
+ property string targetPreference: activeService?.targetPreference ?? ""
+ property var savedWifiNetworks: activeService?.savedWifiNetworks ?? []
+ property string connectionStatus: activeService?.connectionStatus ?? ""
+ property string lastConnectionError: activeService?.lastConnectionError ?? ""
+ property bool passwordDialogShouldReopen: activeService?.passwordDialogShouldReopen ?? false
+ property bool autoRefreshEnabled: activeService?.autoRefreshEnabled ?? false
+ property string wifiPassword: activeService?.wifiPassword ?? ""
+ property string forgetSSID: activeService?.forgetSSID ?? ""
+
+ property string networkInfoSSID: activeService?.networkInfoSSID ?? ""
+ property string networkInfoDetails: activeService?.networkInfoDetails ?? ""
+ property bool networkInfoLoading: activeService?.networkInfoLoading ?? false
+
+ property string networkWiredInfoUUID: activeService?.networkWiredInfoUUID ?? ""
+ property string networkWiredInfoDetails: activeService?.networkWiredInfoDetails ?? ""
+ property bool networkWiredInfoLoading: activeService?.networkWiredInfoLoading ?? false
+
+ property int refCount: activeService?.refCount ?? 0
+ property bool stateInitialized: activeService?.stateInitialized ?? false
+
+ property bool subscriptionConnected: activeService?.subscriptionConnected ?? false
+
+ property var vpnProfiles: activeService?.vpnProfiles ?? []
+ property var vpnActive: activeService?.vpnActive ?? []
+ property bool vpnAvailable: activeService?.vpnAvailable ?? false
+ property bool vpnIsBusy: activeService?.vpnIsBusy ?? false
+ property bool vpnConnected: activeService?.vpnConnected ?? false
+ property string vpnActiveUuid: activeService?.activeUuid ?? ""
+ property string vpnActiveName: activeService?.activeName ?? ""
+
+ property string credentialsToken: activeService?.credentialsToken ?? ""
+ property string credentialsSSID: activeService?.credentialsSSID ?? ""
+ property string credentialsSetting: activeService?.credentialsSetting ?? ""
+ property var credentialsFields: activeService?.credentialsFields ?? []
+ property var credentialsHints: activeService?.credentialsHints ?? []
+ property string credentialsReason: activeService?.credentialsReason ?? ""
+ property bool credentialsRequested: activeService?.credentialsRequested ?? false
+
+ signal networksUpdated
+ signal connectionChanged
+ signal credentialsNeeded(string token, string ssid, string setting, var fields, var hints, string reason, string connType, string connName, string vpnService, var fieldsInfo)
+
+ property bool usingLegacy: false
+ property var activeService: null
+
+ readonly property string socketPath: Quickshell.env("DMS_SOCKET")
+
+ Component.onCompleted: {
+ console.info("NetworkService: Initializing...");
+ if (!socketPath || socketPath.length === 0) {
+ console.info("NetworkService: DMS_SOCKET not set, using LegacyNetworkService");
+ useLegacyService();
+ } else {
+ console.log("NetworkService: DMS_SOCKET found, waiting for capabilities...");
+ }
+ }
+
+ Connections {
+ target: DMSNetworkService
+
+ function onNetworkAvailableChanged() {
+ if (!activeService && DMSNetworkService.networkAvailable) {
+ console.info("NetworkService: Network capability detected, using DMSNetworkService");
+ activeService = DMSNetworkService;
+ usingLegacy = false;
+ console.info("NetworkService: Switched to DMSNetworkService, networkAvailable:", networkAvailable);
+ connectSignals();
+ } else if (!activeService && !DMSNetworkService.networkAvailable && socketPath && socketPath.length > 0) {
+ console.info("NetworkService: Network capability not available in DMS, using LegacyNetworkService");
+ useLegacyService();
+ }
+ }
+ }
+
+ function useLegacyService() {
+ activeService = LegacyNetworkService;
+ usingLegacy = true;
+ console.info("NetworkService: Switched to LegacyNetworkService, networkAvailable:", networkAvailable);
+ if (LegacyNetworkService.activate) {
+ LegacyNetworkService.activate();
+ }
+ connectSignals();
+ }
+
+ function connectSignals() {
+ if (activeService) {
+ if (activeService.networksUpdated) {
+ activeService.networksUpdated.connect(root.networksUpdated);
+ }
+ if (activeService.connectionChanged) {
+ activeService.connectionChanged.connect(root.connectionChanged);
+ }
+ if (activeService.credentialsNeeded) {
+ activeService.credentialsNeeded.connect(root.credentialsNeeded);
+ }
+ }
+ }
+
+ function addRef() {
+ if (activeService && activeService.addRef) {
+ activeService.addRef();
+ }
+ }
+
+ function removeRef() {
+ if (activeService && activeService.removeRef) {
+ activeService.removeRef();
+ }
+ }
+
+ function getState() {
+ if (activeService && activeService.getState) {
+ activeService.getState();
+ }
+ }
+
+ function scanWifi() {
+ if (activeService && activeService.scanWifi) {
+ activeService.scanWifi();
+ }
+ }
+
+ function scanWifiNetworks() {
+ if (activeService && activeService.scanWifiNetworks) {
+ activeService.scanWifiNetworks();
+ }
+ }
+
+ function connectToWifi(ssid, password = "", username = "", anonymousIdentity = "", domainSuffixMatch = "") {
+ if (activeService && activeService.connectToWifi) {
+ activeService.connectToWifi(ssid, password, username, anonymousIdentity, domainSuffixMatch);
+ }
+ }
+
+ function disconnectWifi() {
+ if (activeService && activeService.disconnectWifi) {
+ activeService.disconnectWifi();
+ }
+ }
+
+ function forgetWifiNetwork(ssid) {
+ if (activeService && activeService.forgetWifiNetwork) {
+ activeService.forgetWifiNetwork(ssid);
+ }
+ }
+
+ function toggleWifiRadio() {
+ if (activeService && activeService.toggleWifiRadio) {
+ activeService.toggleWifiRadio();
+ }
+ }
+
+ function enableWifiDevice() {
+ if (activeService && activeService.enableWifiDevice) {
+ activeService.enableWifiDevice();
+ }
+ }
+
+ function setNetworkPreference(preference) {
+ if (activeService && activeService.setNetworkPreference) {
+ activeService.setNetworkPreference(preference);
+ }
+ }
+
+ function setConnectionPriority(type) {
+ if (activeService && activeService.setConnectionPriority) {
+ activeService.setConnectionPriority(type);
+ }
+ }
+
+ function connectToWifiAndSetPreference(ssid, password, username = "", anonymousIdentity = "", domainSuffixMatch = "") {
+ if (activeService && activeService.connectToWifiAndSetPreference) {
+ activeService.connectToWifiAndSetPreference(ssid, password, username, anonymousIdentity, domainSuffixMatch);
+ }
+ }
+
+ function toggleNetworkConnection(type) {
+ if (activeService && activeService.toggleNetworkConnection) {
+ activeService.toggleNetworkConnection(type);
+ }
+ }
+
+ function disconnectEthernetDevice(deviceName) {
+ if (activeService && activeService.disconnectEthernetDevice) {
+ activeService.disconnectEthernetDevice(deviceName);
+ }
+ }
+
+ function startAutoScan() {
+ if (activeService && activeService.startAutoScan) {
+ activeService.startAutoScan();
+ }
+ }
+
+ function stopAutoScan() {
+ if (activeService && activeService.stopAutoScan) {
+ activeService.stopAutoScan();
+ }
+ }
+
+ function fetchNetworkInfo(ssid) {
+ if (activeService && activeService.fetchNetworkInfo) {
+ activeService.fetchNetworkInfo(ssid);
+ }
+ }
+
+ function fetchWiredNetworkInfo(uuid) {
+ if (activeService && activeService.fetchWiredNetworkInfo) {
+ activeService.fetchWiredNetworkInfo(uuid);
+ }
+ }
+
+ function getNetworkInfo(ssid) {
+ if (activeService && activeService.getNetworkInfo) {
+ return activeService.getNetworkInfo(ssid);
+ }
+ return null;
+ }
+
+ function getWiredNetworkInfo(uuid) {
+ if (activeService && activeService.getWiredNetworkInfo) {
+ return activeService.getWiredNetworkInfo(uuid);
+ }
+ return null;
+ }
+
+ function refreshNetworkState() {
+ if (activeService && activeService.refreshNetworkState) {
+ activeService.refreshNetworkState();
+ }
+ }
+
+ function connectToSpecificWiredConfig(uuid) {
+ if (activeService && activeService.connectToSpecificWiredConfig) {
+ activeService.connectToSpecificWiredConfig(uuid);
+ }
+ }
+
+ function submitCredentials(token, secrets, save) {
+ if (activeService && activeService.submitCredentials) {
+ activeService.submitCredentials(token, secrets, save);
+ }
+ }
+
+ function cancelCredentials(token) {
+ if (activeService && activeService.cancelCredentials) {
+ activeService.cancelCredentials(token);
+ }
+ }
+
+ function setWifiAutoconnect(ssid, autoconnect) {
+ if (activeService && activeService.setWifiAutoconnect) {
+ activeService.setWifiAutoconnect(ssid, autoconnect);
+ }
+ }
+
+ function setWifiDeviceOverride(deviceName) {
+ if (activeService && activeService.setWifiDeviceOverride) {
+ activeService.setWifiDeviceOverride(deviceName);
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/NiriService.qml b/raveos-hyprland-theme/theme-data/dms/Services/NiriService.qml
new file mode 100644
index 0000000..49748eb
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/NiriService.qml
@@ -0,0 +1,1494 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtCore
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property string socketPath: Quickshell.env("NIRI_SOCKET")
+
+ property var workspaces: ({})
+ property var allWorkspaces: []
+ property int focusedWorkspaceIndex: 0
+ property string focusedWorkspaceId: ""
+ property var currentOutputWorkspaces: []
+ property string currentOutput: ""
+
+ property var outputs: ({})
+ property var windows: []
+ property var displayScales: ({})
+
+ property var _realOutputs: ({})
+
+ property bool inOverview: false
+
+ property var casts: []
+ property bool hasCasts: casts.length > 0
+ property bool hasActiveCast: casts.some(c => c.is_active)
+
+ property int currentKeyboardLayoutIndex: 0
+ property var keyboardLayoutNames: []
+
+ property string configValidationOutput: ""
+ property bool hasInitialConnection: false
+ property bool suppressConfigToast: true
+ property bool suppressNextConfigToast: false
+ property bool matugenSuppression: false
+ property bool configGenerationPending: false
+
+ readonly property string screenshotsDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.PicturesLocation)) + "/Screenshots"
+ property string pendingScreenshotPath: ""
+
+ signal windowUrgentChanged
+ signal configReloaded
+
+ function setWorkspaces(newMap) {
+ root.workspaces = newMap;
+ root.allWorkspaces = Object.values(newMap).sort((a, b) => a.idx - b.idx);
+ }
+
+ function validate() {
+ validateProcess.running = true;
+ }
+
+ Component.onCompleted: fetchOutputs()
+
+ Timer {
+ id: suppressToastTimer
+ interval: 3000
+ onTriggered: root.suppressConfigToast = false
+ }
+
+ Timer {
+ id: suppressResetTimer
+ interval: 5000
+ onTriggered: root.matugenSuppression = false
+ }
+
+ Timer {
+ id: configGenerationDebounce
+ interval: 100
+ onTriggered: root.doGenerateNiriLayoutConfig()
+ }
+
+ property int _lastGapValue: -1
+
+ Connections {
+ target: SettingsData
+ function onBarConfigsChanged() {
+ const newGaps = Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4));
+ if (newGaps === root._lastGapValue)
+ return;
+ root._lastGapValue = newGaps;
+ generateNiriLayoutConfig();
+ }
+ }
+
+ Process {
+ id: validateProcess
+ command: ["niri", "validate"]
+ running: false
+
+ stderr: StdioCollector {
+ onStreamFinished: {
+ const lines = text.split('\n');
+ const trimmedLines = lines.map(line => line.replace(/\s+$/, '')).filter(line => line.length > 0);
+ configValidationOutput = trimmedLines.join('\n').trim();
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ configValidationOutput = "";
+ } else if (hasInitialConnection && configValidationOutput.length > 0) {
+ ToastService.showError("niri: failed to load config", configValidationOutput, "", "niri-config");
+ }
+ }
+ }
+
+ Process {
+ id: writeConfigProcess
+ property string configContent: ""
+ property string configPath: ""
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ console.info("NiriService: Generated layout config at", configPath);
+ return;
+ }
+ console.warn("NiriService: Failed to write layout config, exit code:", exitCode);
+ }
+ }
+
+ Process {
+ id: writeAlttabProcess
+ property string alttabContent: ""
+ property string alttabPath: ""
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ console.info("NiriService: Generated alttab config at", alttabPath);
+ return;
+ }
+ console.warn("NiriService: Failed to write alttab config, exit code:", exitCode);
+ }
+ }
+
+ Process {
+ id: writeBlurruleProcess
+ property string blurrulePath: ""
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ console.info("NiriService: Generated wpblur config at", blurrulePath);
+ return;
+ }
+ console.warn("NiriService: Failed to write wpblur config, exit code:", exitCode);
+ }
+ }
+
+ Process {
+ id: writeCursorProcess
+ property string cursorContent: ""
+ property string cursorPath: ""
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ console.info("NiriService: Generated cursor config at", cursorPath);
+ return;
+ }
+ console.warn("NiriService: Failed to write cursor config, exit code:", exitCode);
+ }
+ }
+
+ DankSocket {
+ id: eventStreamSocket
+ path: root.socketPath
+ connected: CompositorService.isNiri
+
+ onConnectionStateChanged: {
+ if (connected) {
+ send('"EventStream"');
+ fetchOutputs();
+ }
+ }
+
+ parser: SplitParser {
+ onRead: line => {
+ try {
+ const event = JSON.parse(line);
+ handleNiriEvent(event);
+ } catch (e) {
+ console.warn("NiriService: Failed to parse event:", line, e);
+ }
+ }
+ }
+ }
+
+ DankSocket {
+ id: requestSocket
+ path: root.socketPath
+ connected: CompositorService.isNiri
+ }
+
+ function fetchOutputs() {
+ if (!CompositorService.isNiri)
+ return;
+ Proc.runCommand("niri-fetch-outputs", ["niri", "msg", "-j", "outputs"], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("NiriService: Failed to fetch outputs, exit code:", exitCode);
+ return;
+ }
+ try {
+ const outputsData = JSON.parse(output);
+ outputs = outputsData;
+ console.info("NiriService: Loaded", Object.keys(outputsData).length, "outputs");
+ updateDisplayScales();
+ if (windows.length > 0) {
+ windows = sortWindowsByLayout(windows);
+ }
+ } catch (e) {
+ console.warn("NiriService: Failed to parse outputs:", e);
+ }
+ });
+ }
+
+ function updateDisplayScales() {
+ if (!outputs || Object.keys(outputs).length === 0)
+ return;
+ const scales = {};
+ for (const outputName in outputs) {
+ const output = outputs[outputName];
+ if (output.logical && output.logical.scale !== undefined) {
+ scales[outputName] = output.logical.scale;
+ }
+ }
+
+ displayScales = scales;
+ }
+
+ function sortWindowsByLayout(windowList) {
+ const enriched = windowList.map(w => {
+ const ws = workspaces[w.workspace_id];
+ if (!ws) {
+ return {
+ "window": w,
+ "outputX": 999999,
+ "outputY": 999999,
+ "wsIdx": 999999,
+ "col": 999999,
+ "row": 999999
+ };
+ }
+
+ const outputInfo = outputs[ws.output];
+ const outputX = (outputInfo && outputInfo.logical) ? outputInfo.logical.x : 999999;
+ const outputY = (outputInfo && outputInfo.logical) ? outputInfo.logical.y : 999999;
+
+ const pos = w.layout?.pos_in_scrolling_layout;
+ const col = (pos && pos.length >= 2) ? pos[0] : 999999;
+ const row = (pos && pos.length >= 2) ? pos[1] : 999999;
+
+ return {
+ "window": w,
+ "outputX": outputX,
+ "outputY": outputY,
+ "wsIdx": ws.idx,
+ "col": col,
+ "row": row
+ };
+ });
+
+ enriched.sort((a, b) => {
+ if (a.outputX !== b.outputX)
+ return a.outputX - b.outputX;
+ if (a.outputY !== b.outputY)
+ return a.outputY - b.outputY;
+ if (a.wsIdx !== b.wsIdx)
+ return a.wsIdx - b.wsIdx;
+ if (a.col !== b.col)
+ return a.col - b.col;
+ if (a.row !== b.row)
+ return a.row - b.row;
+ return a.window.id - b.window.id;
+ });
+
+ return enriched.map(e => e.window);
+ }
+
+ function handleNiriEvent(event) {
+ const eventType = Object.keys(event)[0];
+
+ switch (eventType) {
+ case 'WorkspacesChanged':
+ handleWorkspacesChanged(event.WorkspacesChanged);
+ break;
+ case 'WorkspaceActivated':
+ handleWorkspaceActivated(event.WorkspaceActivated);
+ break;
+ case 'WorkspaceActiveWindowChanged':
+ handleWorkspaceActiveWindowChanged(event.WorkspaceActiveWindowChanged);
+ break;
+ case 'WindowFocusChanged':
+ handleWindowFocusChanged(event.WindowFocusChanged);
+ break;
+ case 'WindowsChanged':
+ handleWindowsChanged(event.WindowsChanged);
+ break;
+ case 'WindowClosed':
+ handleWindowClosed(event.WindowClosed);
+ break;
+ case 'WindowOpenedOrChanged':
+ handleWindowOpenedOrChanged(event.WindowOpenedOrChanged);
+ break;
+ case 'WindowLayoutsChanged':
+ handleWindowLayoutsChanged(event.WindowLayoutsChanged);
+ break;
+ case 'OutputsChanged':
+ handleOutputsChanged(event.OutputsChanged);
+ break;
+ case 'OverviewOpenedOrClosed':
+ handleOverviewChanged(event.OverviewOpenedOrClosed);
+ break;
+ case 'ConfigLoaded':
+ handleConfigLoaded(event.ConfigLoaded);
+ break;
+ case 'KeyboardLayoutsChanged':
+ handleKeyboardLayoutsChanged(event.KeyboardLayoutsChanged);
+ break;
+ case 'KeyboardLayoutSwitched':
+ handleKeyboardLayoutSwitched(event.KeyboardLayoutSwitched);
+ break;
+ case 'WorkspaceUrgencyChanged':
+ handleWorkspaceUrgencyChanged(event.WorkspaceUrgencyChanged);
+ break;
+ case 'WindowUrgencyChanged':
+ handleWindowUrgencyChanged(event.WindowUrgencyChanged);
+ break;
+ case 'ScreenshotCaptured':
+ handleScreenshotCaptured(event.ScreenshotCaptured);
+ break;
+ case 'CastsChanged':
+ handleCastsChanged(event.CastsChanged);
+ break;
+ case 'CastStartedOrChanged':
+ handleCastStartedOrChanged(event.CastStartedOrChanged);
+ break;
+ case 'CastStopped':
+ handleCastStopped(event.CastStopped);
+ break;
+ }
+ }
+
+ function handleWorkspacesChanged(data) {
+ const newWorkspaces = {};
+
+ for (const ws of data.workspaces) {
+ const oldWs = root.workspaces[ws.id];
+ newWorkspaces[ws.id] = ws;
+ if (oldWs && oldWs.active_window_id !== undefined) {
+ newWorkspaces[ws.id].active_window_id = oldWs.active_window_id;
+ }
+ }
+
+ setWorkspaces(newWorkspaces);
+
+ focusedWorkspaceIndex = allWorkspaces.findIndex(w => w.is_focused);
+ if (focusedWorkspaceIndex >= 0) {
+ const focusedWs = allWorkspaces[focusedWorkspaceIndex];
+ focusedWorkspaceId = focusedWs.id;
+ currentOutput = focusedWs.output || "";
+ } else {
+ focusedWorkspaceIndex = 0;
+ focusedWorkspaceId = "";
+ }
+
+ updateCurrentOutputWorkspaces();
+ }
+
+ function handleWorkspaceActivated(data) {
+ const ws = root.workspaces[data.id];
+ if (!ws) {
+ return;
+ }
+ const output = ws.output;
+
+ const updatedWorkspaces = {};
+
+ for (const id in root.workspaces) {
+ const workspace = root.workspaces[id];
+ const got_activated = workspace.id === data.id;
+
+ const updatedWs = {};
+ for (let prop in workspace) {
+ updatedWs[prop] = workspace[prop];
+ }
+
+ if (workspace.output === output) {
+ updatedWs.is_active = got_activated;
+ }
+
+ if (data.focused) {
+ updatedWs.is_focused = got_activated;
+ }
+
+ updatedWorkspaces[id] = updatedWs;
+ }
+
+ setWorkspaces(updatedWorkspaces);
+
+ focusedWorkspaceId = data.id;
+ focusedWorkspaceIndex = allWorkspaces.findIndex(w => w.id === data.id);
+
+ if (focusedWorkspaceIndex >= 0) {
+ currentOutput = allWorkspaces[focusedWorkspaceIndex].output || "";
+ }
+
+ updateCurrentOutputWorkspaces();
+ }
+
+ function handleWindowFocusChanged(data) {
+ const focusedWindowId = data.id;
+
+ let focusedWindow = null;
+ const updatedWindows = [];
+
+ for (var i = 0; i < windows.length; i++) {
+ const w = windows[i];
+ const updatedWindow = {};
+
+ for (let prop in w) {
+ updatedWindow[prop] = w[prop];
+ }
+
+ updatedWindow.is_focused = (w.id === focusedWindowId);
+ if (updatedWindow.is_focused) {
+ focusedWindow = updatedWindow;
+ }
+
+ updatedWindows.push(updatedWindow);
+ }
+
+ windows = updatedWindows;
+
+ if (focusedWindow) {
+ const ws = root.workspaces[focusedWindow.workspace_id];
+ if (ws && ws.active_window_id !== focusedWindowId) {
+ const updatedWs = {};
+ for (let prop in ws) {
+ updatedWs[prop] = ws[prop];
+ }
+ updatedWs.active_window_id = focusedWindowId;
+
+ const updatedWorkspaces = {};
+ for (const id in root.workspaces) {
+ updatedWorkspaces[id] = id === focusedWindow.workspace_id ? updatedWs : root.workspaces[id];
+ }
+ setWorkspaces(updatedWorkspaces);
+ }
+ }
+ }
+
+ function handleWorkspaceActiveWindowChanged(data) {
+ const ws = root.workspaces[data.workspace_id];
+ if (ws) {
+ const updatedWs = {};
+ for (let prop in ws) {
+ updatedWs[prop] = ws[prop];
+ }
+ updatedWs.active_window_id = data.active_window_id;
+
+ const updatedWorkspaces = {};
+ for (const id in root.workspaces) {
+ updatedWorkspaces[id] = id === data.workspace_id ? updatedWs : root.workspaces[id];
+ }
+ setWorkspaces(updatedWorkspaces);
+ }
+
+ const updatedWindows = [];
+
+ for (var i = 0; i < windows.length; i++) {
+ const w = windows[i];
+ const updatedWindow = {};
+
+ for (let prop in w) {
+ updatedWindow[prop] = w[prop];
+ }
+
+ if (data.active_window_id !== null && data.active_window_id !== undefined) {
+ updatedWindow.is_focused = (w.id == data.active_window_id);
+ } else {
+ updatedWindow.is_focused = w.workspace_id == data.workspace_id ? false : w.is_focused;
+ }
+
+ updatedWindows.push(updatedWindow);
+ }
+
+ windows = updatedWindows;
+ }
+
+ function handleWindowsChanged(data) {
+ windows = sortWindowsByLayout(data.windows);
+ }
+
+ function handleWindowClosed(data) {
+ windows = windows.filter(w => w.id !== data.id);
+ }
+
+ function handleWindowOpenedOrChanged(data) {
+ if (!data.window)
+ return;
+ const window = data.window;
+ const existingIndex = windows.findIndex(w => w.id === window.id);
+
+ if (existingIndex >= 0) {
+ const updatedWindows = [...windows];
+ updatedWindows[existingIndex] = window;
+ windows = sortWindowsByLayout(updatedWindows);
+ } else {
+ windows = sortWindowsByLayout([...windows, window]);
+ }
+ }
+
+ function handleWindowLayoutsChanged(data) {
+ if (!data.changes)
+ return;
+ const updatedWindows = [...windows];
+ let hasChanges = false;
+
+ for (const change of data.changes) {
+ const windowId = change[0];
+ const layoutData = change[1];
+
+ const windowIndex = updatedWindows.findIndex(w => w.id === windowId);
+ if (windowIndex < 0)
+ continue;
+ const updatedWindow = {};
+ for (var prop in updatedWindows[windowIndex]) {
+ updatedWindow[prop] = updatedWindows[windowIndex][prop];
+ }
+ updatedWindow.layout = layoutData;
+ updatedWindows[windowIndex] = updatedWindow;
+ hasChanges = true;
+ }
+
+ if (!hasChanges)
+ return;
+ windows = sortWindowsByLayout(updatedWindows);
+ }
+
+ function handleOutputsChanged(data) {
+ if (!data.outputs)
+ return;
+ outputs = data.outputs;
+ updateDisplayScales();
+ windows = sortWindowsByLayout(windows);
+ }
+
+ function handleOverviewChanged(data) {
+ inOverview = data.is_open;
+ }
+
+ function handleConfigLoaded(data) {
+ if (data.failed) {
+ validateProcess.running = true;
+ return;
+ }
+
+ configValidationOutput = "";
+ ToastService.dismissCategory("niri-config");
+ fetchOutputs();
+ configReloaded();
+
+ if (hasInitialConnection && !suppressConfigToast && !suppressNextConfigToast && !matugenSuppression) {
+ ToastService.showInfo("niri: config reloaded", "", "", "niri-config");
+ } else if (suppressNextConfigToast) {
+ suppressNextConfigToast = false;
+ suppressResetTimer.stop();
+ }
+
+ if (!hasInitialConnection) {
+ hasInitialConnection = true;
+ suppressToastTimer.start();
+ }
+ }
+
+ function handleKeyboardLayoutsChanged(data) {
+ keyboardLayoutNames = data.keyboard_layouts.names;
+ currentKeyboardLayoutIndex = data.keyboard_layouts.current_idx;
+ }
+
+ function handleKeyboardLayoutSwitched(data) {
+ currentKeyboardLayoutIndex = data.idx;
+ }
+
+ function handleWorkspaceUrgencyChanged(data) {
+ const ws = root.workspaces[data.id];
+ if (!ws)
+ return;
+ const updatedWs = {};
+ for (let prop in ws) {
+ updatedWs[prop] = ws[prop];
+ }
+ updatedWs.is_urgent = data.urgent;
+
+ const updatedWorkspaces = {};
+ for (const id in root.workspaces) {
+ updatedWorkspaces[id] = id === data.id ? updatedWs : root.workspaces[id];
+ }
+ setWorkspaces(updatedWorkspaces);
+
+ windowUrgentChanged();
+ }
+
+ function handleWindowUrgencyChanged(data) {
+ const windowIndex = windows.findIndex(w => w.id === data.id);
+ if (windowIndex < 0)
+ return;
+ const updatedWindows = [...windows];
+ const updatedWindow = {};
+ for (let prop in updatedWindows[windowIndex]) {
+ updatedWindow[prop] = updatedWindows[windowIndex][prop];
+ }
+ updatedWindow.is_urgent = data.urgent;
+ updatedWindows[windowIndex] = updatedWindow;
+ windows = updatedWindows;
+
+ windowUrgentChanged();
+ }
+
+ function handleScreenshotCaptured(data) {
+ if (!data.path)
+ return;
+ if (pendingScreenshotPath && data.path === pendingScreenshotPath) {
+ const editor = Quickshell.env("DMS_SCREENSHOT_EDITOR");
+ let command;
+ if (editor === "satty" || !editor) {
+ command = ["satty", "-f", data.path];
+ } else if (editor === "swappy") {
+ command = ["swappy", "-f", data.path];
+ } else {
+ // Custom command with %path% placeholder
+ command = editor.split(" ").map(arg => arg === "%path%" ? data.path : arg);
+ }
+ Quickshell.execDetached({
+ "command": command
+ });
+ pendingScreenshotPath = "";
+ }
+ }
+
+ function handleCastsChanged(data) {
+ casts = data.casts || [];
+ }
+
+ function handleCastStartedOrChanged(data) {
+ if (!data.cast)
+ return;
+ const cast = data.cast;
+ const existingIndex = casts.findIndex(c => c.stream_id === cast.stream_id);
+ if (existingIndex >= 0) {
+ const updatedCasts = [...casts];
+ updatedCasts[existingIndex] = cast;
+ casts = updatedCasts;
+ } else {
+ casts = [...casts, cast];
+ }
+ }
+
+ function handleCastStopped(data) {
+ casts = casts.filter(c => c.stream_id !== data.stream_id);
+ }
+
+ function updateCurrentOutputWorkspaces() {
+ if (!currentOutput) {
+ currentOutputWorkspaces = allWorkspaces;
+ return;
+ }
+
+ const outputWs = allWorkspaces.filter(w => w.output === currentOutput);
+ currentOutputWorkspaces = outputWs;
+ }
+
+ function send(request) {
+ if (!CompositorService.isNiri || !requestSocket.connected)
+ return false;
+ requestSocket.send(request);
+ return true;
+ }
+
+ function doScreenTransition() {
+ return send({
+ "Action": {
+ "DoScreenTransition": {
+ "delay_ms": 0
+ }
+ }
+ });
+ }
+
+ function toggleOverview() {
+ return send({
+ "Action": {
+ "ToggleOverview": {}
+ }
+ });
+ }
+
+ function moveColumnLeft() {
+ return send({
+ "Action": {
+ "FocusColumnLeft": {}
+ }
+ });
+ }
+
+ function moveColumnRight() {
+ return send({
+ "Action": {
+ "FocusColumnRight": {}
+ }
+ });
+ }
+
+ function moveWorkspaceDown() {
+ return send({
+ "Action": {
+ "FocusWorkspaceDown": {}
+ }
+ });
+ }
+
+ function moveWorkspaceUp() {
+ return send({
+ "Action": {
+ "FocusWorkspaceUp": {}
+ }
+ });
+ }
+
+ function switchToWorkspace(workspaceIndex) {
+ return send({
+ "Action": {
+ "FocusWorkspace": {
+ "reference": {
+ "Index": workspaceIndex
+ }
+ }
+ }
+ });
+ }
+
+ function focusWindow(windowId) {
+ return send({
+ "Action": {
+ "FocusWindow": {
+ "id": windowId
+ }
+ }
+ });
+ }
+
+ function powerOffMonitors() {
+ return send({
+ "Action": {
+ "PowerOffMonitors": {}
+ }
+ });
+ }
+
+ function powerOnMonitors() {
+ return send({
+ "Action": {
+ "PowerOnMonitors": {}
+ }
+ });
+ }
+
+ function cycleKeyboardLayout() {
+ return send({
+ "Action": {
+ "SwitchLayout": {
+ "layout": "Next"
+ }
+ }
+ });
+ }
+
+ function quit() {
+ return send({
+ "Action": {
+ "Quit": {
+ "skip_confirmation": true
+ }
+ }
+ });
+ }
+
+ function screenshot() {
+ pendingScreenshotPath = "";
+ const timestamp = Date.now();
+ const path = `${screenshotsDir}/dms-screenshot-${timestamp}.png`;
+ pendingScreenshotPath = path;
+
+ return send({
+ "Action": {
+ "Screenshot": {
+ "show_pointer": true,
+ "path": path
+ }
+ }
+ });
+ }
+
+ function screenshotScreen() {
+ pendingScreenshotPath = "";
+ const timestamp = Date.now();
+ const path = `${screenshotsDir}/dms-screenshot-${timestamp}.png`;
+ pendingScreenshotPath = path;
+
+ return send({
+ "Action": {
+ "ScreenshotScreen": {
+ "write_to_disk": true,
+ "show_pointer": true,
+ "path": path
+ }
+ }
+ });
+ }
+
+ function screenshotWindow() {
+ pendingScreenshotPath = "";
+ const timestamp = Date.now();
+ const path = `${screenshotsDir}/dms-screenshot-${timestamp}.png`;
+ pendingScreenshotPath = path;
+
+ return send({
+ "Action": {
+ "ScreenshotWindow": {
+ "write_to_disk": true,
+ "show_pointer": true,
+ "path": path
+ }
+ }
+ });
+ }
+
+ function getCurrentOutputWorkspaceNumbers() {
+ return currentOutputWorkspaces.map(w => w.idx + 1);
+ }
+
+ function getCurrentOutputWorkspaces() {
+ return currentOutputWorkspaces.slice();
+ }
+
+ function getCurrentWorkspaceNumber() {
+ if (focusedWorkspaceIndex >= 0 && focusedWorkspaceIndex < allWorkspaces.length) {
+ return allWorkspaces[focusedWorkspaceIndex].idx;
+ }
+ return 1;
+ }
+
+ function getCurrentKeyboardLayoutName() {
+ if (currentKeyboardLayoutIndex >= 0 && currentKeyboardLayoutIndex < keyboardLayoutNames.length) {
+ return keyboardLayoutNames[currentKeyboardLayoutIndex];
+ }
+ return "";
+ }
+
+ function suppressNextToast() {
+ matugenSuppression = true;
+ suppressResetTimer.restart();
+ }
+
+ function findNiriWindow(toplevel) {
+ if (!toplevel.appId)
+ return null;
+
+ for (var j = 0; j < windows.length; j++) {
+ const niriWindow = windows[j];
+ if (niriWindow.app_id === toplevel.appId) {
+ if (!niriWindow.title || niriWindow.title === toplevel.title) {
+ return {
+ "niriIndex": j,
+ "niriWindow": niriWindow
+ };
+ }
+ }
+ }
+ return null;
+ }
+
+ function sortToplevels(toplevels) {
+ if (!toplevels || toplevels.length === 0 || !CompositorService.isNiri || windows.length === 0) {
+ return [...toplevels];
+ }
+
+ const usedToplevels = new Set();
+ const enrichedToplevels = [];
+
+ for (const niriWindow of sortWindowsByLayout(windows)) {
+ let bestMatch = null;
+ let bestScore = -1;
+
+ for (const toplevel of toplevels) {
+ if (usedToplevels.has(toplevel))
+ continue;
+ if (toplevel.appId === niriWindow.app_id) {
+ let score = 1;
+
+ if (niriWindow.title && toplevel.title) {
+ if (toplevel.title === niriWindow.title) {
+ score = 3;
+ } else if (toplevel.title.includes(niriWindow.title) || niriWindow.title.includes(toplevel.title)) {
+ score = 2;
+ }
+ }
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestMatch = toplevel;
+ if (score === 3)
+ break;
+ }
+ }
+ }
+
+ if (!bestMatch)
+ continue;
+ usedToplevels.add(bestMatch);
+
+ const workspace = workspaces[niriWindow.workspace_id];
+ const isFocused = niriWindow.is_focused ?? (workspace && workspace.active_window_id === niriWindow.id) ?? false;
+
+ const enrichedToplevel = {
+ "appId": bestMatch.appId,
+ "title": bestMatch.title,
+ "activated": isFocused,
+ "niriWindowId": niriWindow.id,
+ "niriWorkspaceId": niriWindow.workspace_id,
+ "activate": function () {
+ return NiriService.focusWindow(niriWindow.id);
+ },
+ "close": function () {
+ if (bestMatch.close) {
+ return bestMatch.close();
+ }
+ return false;
+ }
+ };
+
+ for (let prop in bestMatch) {
+ if (!(prop in enrichedToplevel)) {
+ enrichedToplevel[prop] = bestMatch[prop];
+ }
+ }
+
+ enrichedToplevels.push(enrichedToplevel);
+ }
+
+ for (const toplevel of toplevels) {
+ if (!usedToplevels.has(toplevel)) {
+ enrichedToplevels.push(toplevel);
+ }
+ }
+
+ return enrichedToplevels;
+ }
+
+ function _matchAndEnrichToplevels(toplevels, niriWindows) {
+ const usedToplevels = new Set();
+ const result = [];
+
+ for (const niriWindow of niriWindows) {
+ let bestMatch = null;
+ let bestScore = -1;
+
+ for (const toplevel of toplevels) {
+ if (usedToplevels.has(toplevel))
+ continue;
+ if (toplevel.appId !== niriWindow.app_id)
+ continue;
+
+ let score = 1;
+ if (niriWindow.title && toplevel.title) {
+ if (toplevel.title === niriWindow.title) {
+ score = 3;
+ } else if (toplevel.title.includes(niriWindow.title) || niriWindow.title.includes(toplevel.title)) {
+ score = 2;
+ }
+ }
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestMatch = toplevel;
+ if (score === 3)
+ break;
+ }
+ }
+
+ if (!bestMatch)
+ continue;
+ usedToplevels.add(bestMatch);
+
+ const workspace = workspaces[niriWindow.workspace_id];
+ const isFocused = niriWindow.is_focused ?? (workspace && workspace.active_window_id === niriWindow.id) ?? false;
+
+ const enrichedToplevel = {
+ "appId": bestMatch.appId,
+ "title": bestMatch.title,
+ "activated": isFocused,
+ "niriWindowId": niriWindow.id,
+ "niriWorkspaceId": niriWindow.workspace_id,
+ "activate": function () {
+ return NiriService.focusWindow(niriWindow.id);
+ },
+ "close": function () {
+ if (bestMatch.close)
+ return bestMatch.close();
+ return false;
+ }
+ };
+
+ for (let prop in bestMatch) {
+ if (!(prop in enrichedToplevel))
+ enrichedToplevel[prop] = bestMatch[prop];
+ }
+
+ result.push(enrichedToplevel);
+ }
+
+ return result;
+ }
+
+ function filterCurrentWorkspace(toplevels, screenName) {
+ let currentWorkspaceId = null;
+
+ for (var i = 0; i < allWorkspaces.length; i++) {
+ const ws = allWorkspaces[i];
+ if (ws.output === screenName && ws.is_active) {
+ currentWorkspaceId = ws.id;
+ break;
+ }
+ }
+
+ if (currentWorkspaceId === null)
+ return toplevels;
+
+ if (toplevels.length > 0 && toplevels[0].niriWorkspaceId !== undefined)
+ return toplevels.filter(t => t.niriWorkspaceId === currentWorkspaceId);
+
+ return _matchAndEnrichToplevels(toplevels, windows.filter(nw => nw.workspace_id === currentWorkspaceId));
+ }
+
+ function filterCurrentDisplay(toplevels, screenName) {
+ if (!toplevels || toplevels.length === 0 || !screenName)
+ return toplevels;
+
+ const outputWorkspaceIds = new Set();
+ for (var i = 0; i < allWorkspaces.length; i++) {
+ const ws = allWorkspaces[i];
+ if (ws.output === screenName)
+ outputWorkspaceIds.add(ws.id);
+ }
+
+ if (outputWorkspaceIds.size === 0)
+ return toplevels;
+
+ if (toplevels.length > 0 && toplevels[0].niriWorkspaceId !== undefined)
+ return toplevels.filter(t => outputWorkspaceIds.has(t.niriWorkspaceId));
+
+ return _matchAndEnrichToplevels(toplevels, windows.filter(nw => outputWorkspaceIds.has(nw.workspace_id)));
+ }
+
+ function generateNiriLayoutConfig() {
+ if (!CompositorService.isNiri || configGenerationPending)
+ return;
+ suppressNextToast();
+ configGenerationPending = true;
+ configGenerationDebounce.restart();
+ }
+
+ function doGenerateNiriLayoutConfig() {
+ console.log("NiriService: Generating layout config...");
+
+ const defaultRadius = typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12;
+ const defaultGaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4;
+ const defaultBorderSize = 2;
+
+ const cornerRadius = (typeof SettingsData !== "undefined" && SettingsData.niriLayoutRadiusOverride >= 0) ? SettingsData.niriLayoutRadiusOverride : defaultRadius;
+ const gaps = (typeof SettingsData !== "undefined" && SettingsData.niriLayoutGapsOverride >= 0) ? SettingsData.niriLayoutGapsOverride : defaultGaps;
+ const borderSize = (typeof SettingsData !== "undefined" && SettingsData.niriLayoutBorderSize >= 0) ? SettingsData.niriLayoutBorderSize : defaultBorderSize;
+
+ const dmsWarning = `// ! DO NOT EDIT !
+ // ! AUTO-GENERATED BY DMS !
+ // ! CHANGES WILL BE OVERWRITTEN !
+ // ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
+
+ `;
+
+ const configContent = dmsWarning + `layout {
+ gaps ${gaps}
+
+ border {
+ width ${borderSize}
+ }
+
+ focus-ring {
+ width ${borderSize}
+ }
+ }
+ window-rule {
+ geometry-corner-radius ${cornerRadius}
+ clip-to-geometry true
+ tiled-state true
+ draw-border-with-background false
+ }`;
+
+ const alttabContent = dmsWarning + `recent-windows {
+ highlight {
+ corner-radius ${cornerRadius}
+ }
+ }`;
+
+ const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
+ const niriDmsDir = configDir + "/niri/dms";
+ const configPath = niriDmsDir + "/layout.kdl";
+ const alttabPath = niriDmsDir + "/alttab.kdl";
+
+ writeConfigProcess.configContent = configContent;
+ writeConfigProcess.configPath = configPath;
+ writeConfigProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && cat > "${configPath}" << 'EOF'\n${configContent}\nEOF`];
+ writeConfigProcess.running = true;
+
+ writeAlttabProcess.alttabContent = alttabContent;
+ writeAlttabProcess.alttabPath = alttabPath;
+ writeAlttabProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && cat > "${alttabPath}" << 'EOF'\n${alttabContent}\nEOF`];
+ writeAlttabProcess.running = true;
+
+ for (const name of ["outputs", "binds", "cursor", "windowrules", "colors", "alttab", "layout"]) {
+ const path = niriDmsDir + "/" + name + ".kdl";
+ Proc.runCommand("niri-ensure-" + name, ["sh", "-c", `mkdir -p "${niriDmsDir}" && [ ! -f "${path}" ] && touch "${path}" || true`], (output, exitCode) => {
+ if (exitCode !== 0)
+ console.warn("NiriService: Failed to ensure " + name + ".kdl, exit code:", exitCode);
+ });
+ }
+
+ configGenerationPending = false;
+ }
+
+ function generateNiriBlurrule() {
+ console.log("NiriService: Generating wpblur config...");
+
+ const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
+ const niriDmsDir = configDir + "/niri/dms";
+ const blurrulePath = niriDmsDir + "/wpblur.kdl";
+ const sourceBlurrulePath = Paths.strip(Qt.resolvedUrl("niri-wpblur.kdl"));
+
+ writeBlurruleProcess.blurrulePath = blurrulePath;
+ writeBlurruleProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && cp --no-preserve=mode "${sourceBlurrulePath}" "${blurrulePath}"`];
+ writeBlurruleProcess.running = true;
+ }
+
+ function generateNiriCursorConfig() {
+ if (!CompositorService.isNiri)
+ return;
+
+ console.log("NiriService: Generating cursor config...");
+
+ const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
+ const niriDmsDir = configDir + "/niri/dms";
+ const cursorPath = niriDmsDir + "/cursor.kdl";
+
+ const settings = typeof SettingsData !== "undefined" ? SettingsData.cursorSettings : null;
+ if (!settings) {
+ writeCursorProcess.cursorContent = "";
+ writeCursorProcess.cursorPath = cursorPath;
+ writeCursorProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && : > "${cursorPath}"`];
+ writeCursorProcess.running = true;
+ return;
+ }
+
+ const themeName = settings.theme === "System Default" ? (SettingsData.systemDefaultCursorTheme || "") : settings.theme;
+ const size = settings.size || 24;
+ const hideWhenTyping = settings.niri?.hideWhenTyping || false;
+ const hideAfterMs = settings.niri?.hideAfterInactiveMs || 0;
+
+ const isDefaultConfig = !themeName && size === 24 && !hideWhenTyping && hideAfterMs === 0;
+ if (isDefaultConfig) {
+ writeCursorProcess.cursorContent = "";
+ writeCursorProcess.cursorPath = cursorPath;
+ writeCursorProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && : > "${cursorPath}"`];
+ writeCursorProcess.running = true;
+ return;
+ }
+
+ const dmsWarning = `// ! DO NOT EDIT !
+// ! AUTO-GENERATED BY DMS !
+// ! CHANGES WILL BE OVERWRITTEN !
+// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
+
+`;
+
+ let cursorContent = dmsWarning + `cursor {\n`;
+
+ if (themeName)
+ cursorContent += ` xcursor-theme "${themeName}"\n`;
+
+ cursorContent += ` xcursor-size ${size}\n`;
+
+ if (hideWhenTyping)
+ cursorContent += ` hide-when-typing\n`;
+
+ if (hideAfterMs > 0)
+ cursorContent += ` hide-after-inactive-ms ${hideAfterMs}\n`;
+
+ cursorContent += `}`;
+
+ writeCursorProcess.cursorContent = cursorContent;
+ writeCursorProcess.cursorPath = cursorPath;
+
+ const escapedCursorContent = cursorContent.replace(/'/g, "'\\''");
+
+ writeCursorProcess.command = ["sh", "-c", `mkdir -p "${niriDmsDir}" && printf '%s' '${escapedCursorContent}' > "${cursorPath}"`];
+ writeCursorProcess.running = true;
+ }
+
+ function updateOutputPosition(outputName, x, y) {
+ if (!outputs || !outputs[outputName])
+ return;
+ const updatedOutputs = {};
+ for (const name in outputs) {
+ const output = outputs[name];
+ if (name === outputName && output.logical) {
+ updatedOutputs[name] = JSON.parse(JSON.stringify(output));
+ updatedOutputs[name].logical.x = x;
+ updatedOutputs[name].logical.y = y;
+ } else {
+ updatedOutputs[name] = output;
+ }
+ }
+ outputs = updatedOutputs;
+ }
+
+ function applyOutputConfig(outputName, config, callback) {
+ if (!CompositorService.isNiri || !outputName) {
+ if (callback)
+ callback(false, "Invalid config");
+ return;
+ }
+
+ const commands = [];
+
+ if (config.position !== undefined) {
+ commands.push(`niri msg output "${outputName}" position ${config.position.x} ${config.position.y}`);
+ }
+
+ if (config.mode !== undefined) {
+ commands.push(`niri msg output "${outputName}" mode ${config.mode}`);
+ }
+
+ if (config.vrr !== undefined) {
+ commands.push(`niri msg output "${outputName}" vrr ${config.vrr ? "on" : "off"}`);
+ }
+
+ if (config.scale !== undefined) {
+ commands.push(`niri msg output "${outputName}" scale ${config.scale}`);
+ }
+
+ if (config.transform !== undefined) {
+ commands.push(`niri msg output "${outputName}" transform "${config.transform}"`);
+ }
+
+ if (commands.length === 0) {
+ if (callback)
+ callback(true, "No changes");
+ return;
+ }
+
+ const fullCommand = commands.join(" && ");
+ Proc.runCommand("niri-output-config", ["sh", "-c", fullCommand], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("NiriService: Failed to apply output config:", output);
+ if (callback)
+ callback(false, output);
+ return;
+ }
+ console.info("NiriService: Applied output config for", outputName);
+ fetchOutputs();
+ if (callback)
+ callback(true, "Success");
+ });
+ }
+
+ function getOutputIdentifier(output, outputName) {
+ if (SettingsData.displayNameMode === "model" && output.make && output.model) {
+ const serial = output.serial || "Unknown";
+ return output.make + " " + output.model + " " + serial;
+ }
+ return outputName;
+ }
+
+ function generateOutputsConfig(outputsData) {
+ const data = outputsData || outputs;
+ if (!data || Object.keys(data).length === 0)
+ return;
+ let kdlContent = `// Auto-generated by DMS - do not edit manually\n\n`;
+
+ const sortedNames = Object.keys(data).sort((a, b) => {
+ const la = data[a].logical || {};
+ const lb = data[b].logical || {};
+ return (la.x ?? 0) - (lb.x ?? 0) || (la.y ?? 0) - (lb.y ?? 0);
+ });
+ for (const outputName of sortedNames) {
+ const output = data[outputName];
+ const identifier = getOutputIdentifier(output, outputName);
+ const niriSettings = SettingsData.getNiriOutputSettings(identifier);
+
+ kdlContent += `output "${identifier}" {\n`;
+
+ if (niriSettings.disabled) {
+ kdlContent += ` off\n`;
+ }
+
+ if (output.current_mode !== undefined && output.modes && output.modes[output.current_mode]) {
+ const mode = output.modes[output.current_mode];
+ kdlContent += ` mode "${mode.width}x${mode.height}@${(mode.refresh_rate / 1000).toFixed(3)}"\n`;
+ }
+
+ if (output.logical) {
+ kdlContent += ` scale ${output.logical.scale || 1.0}\n`;
+
+ if (output.logical.transform && output.logical.transform !== "Normal") {
+ const transformMap = {
+ "Normal": "normal",
+ "90": "90",
+ "180": "180",
+ "270": "270",
+ "Flipped": "flipped",
+ "Flipped90": "flipped-90",
+ "Flipped180": "flipped-180",
+ "Flipped270": "flipped-270"
+ };
+ kdlContent += ` transform "${transformMap[output.logical.transform] || "normal"}"\n`;
+ }
+
+ if (output.logical.x !== undefined && output.logical.y !== undefined) {
+ kdlContent += ` position x=${output.logical.x} y=${output.logical.y}\n`;
+ }
+ }
+
+ if (output.vrr_enabled || niriSettings.vrrOnDemand) {
+ const vrrOnDemand = niriSettings.vrrOnDemand ?? false;
+ kdlContent += vrrOnDemand ? ` variable-refresh-rate on-demand=true\n` : ` variable-refresh-rate\n`;
+ }
+
+ if (niriSettings.focusAtStartup) {
+ kdlContent += ` focus-at-startup\n`;
+ }
+
+ if (niriSettings.backdropColor) {
+ kdlContent += ` backdrop-color "${niriSettings.backdropColor}"\n`;
+ }
+
+ kdlContent += generateHotCornersBlock(niriSettings);
+ kdlContent += generateLayoutBlock(niriSettings);
+
+ kdlContent += `}\n\n`;
+ }
+
+ const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation));
+ const niriDmsDir = configDir + "/niri/dms";
+ const outputsPath = niriDmsDir + "/outputs.kdl";
+
+ Proc.runCommand("niri-write-outputs", ["sh", "-c", `mkdir -p "${niriDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${kdlContent}EOF`], (output, exitCode) => {
+ if (exitCode !== 0) {
+ console.warn("NiriService: Failed to write outputs config:", output);
+ return;
+ }
+ console.info("NiriService: Generated outputs config at", outputsPath);
+ });
+ }
+
+ function generateHotCornersBlock(niriSettings) {
+ if (!niriSettings.hotCorners)
+ return "";
+ const hc = niriSettings.hotCorners;
+ if (hc.off)
+ return ` hot-corners {\n off\n }\n`;
+ const corners = hc.corners || [];
+ if (corners.length === 0)
+ return "";
+ let block = ` hot-corners {\n`;
+ for (const corner of corners) {
+ block += ` ${corner}\n`;
+ }
+ block += ` }\n`;
+ return block;
+ }
+
+ function generateLayoutBlock(niriSettings) {
+ if (!niriSettings.layout)
+ return "";
+ const layout = niriSettings.layout;
+ const hasSettings = layout.gaps !== undefined || layout.defaultColumnWidth || layout.presetColumnWidths || layout.alwaysCenterSingleColumn !== undefined;
+ if (!hasSettings)
+ return "";
+ let block = ` layout {\n`;
+ if (layout.gaps !== undefined)
+ block += ` gaps ${layout.gaps}\n`;
+ if (layout.defaultColumnWidth?.type === "proportion") {
+ const val = layout.defaultColumnWidth.value;
+ const formatted = Number.isInteger(val) ? val.toFixed(1) : val.toString();
+ block += ` default-column-width { proportion ${formatted}; }\n`;
+ }
+ if (layout.presetColumnWidths && layout.presetColumnWidths.length > 0) {
+ block += ` preset-column-widths {\n`;
+ for (const preset of layout.presetColumnWidths) {
+ if (preset.type === "proportion") {
+ const val = preset.value;
+ const formatted = Number.isInteger(val) ? val.toFixed(1) : val.toString();
+ block += ` proportion ${formatted}\n`;
+ }
+ }
+ block += ` }\n`;
+ }
+ if (layout.alwaysCenterSingleColumn !== undefined)
+ block += layout.alwaysCenterSingleColumn ? ` always-center-single-column\n` : ` always-center-single-column false\n`;
+ block += ` }\n`;
+ return block;
+ }
+
+ function renameWorkspace(name) {
+ if (!name || name.trim() === "") {
+ return send({
+ "Action": {
+ "UnsetWorkspaceName": {
+ "workspace": null
+ }
+ }
+ });
+ }
+ return send({
+ "Action": {
+ "SetWorkspaceName": {
+ "name": name,
+ "workspace": null
+ }
+ }
+ });
+ }
+
+ function moveWorkspaceToIndex(workspaceIdx, targetIndex) {
+ return send({
+ "Action": {
+ "MoveWorkspaceToIndex": {
+ "index": targetIndex,
+ "reference": {
+ "Index": workspaceIdx
+ }
+ }
+ }
+ });
+ }
+
+ IpcHandler {
+ function screenshot(): string {
+ if (!CompositorService.isNiri) {
+ return "NIRI_NOT_AVAILABLE";
+ }
+ if (NiriService.screenshot()) {
+ return "SCREENSHOT_SUCCESS";
+ }
+ return "SCREENSHOT_FAILED";
+ }
+
+ function screenshotScreen(): string {
+ if (!CompositorService.isNiri) {
+ return "NIRI_NOT_AVAILABLE";
+ }
+ if (NiriService.screenshotScreen()) {
+ return "SCREENSHOT_SCREEN_SUCCESS";
+ }
+ return "SCREENSHOT_SCREEN_FAILED";
+ }
+
+ function screenshotWindow(): string {
+ if (!CompositorService.isNiri) {
+ return "NIRI_NOT_AVAILABLE";
+ }
+ if (NiriService.screenshotWindow()) {
+ return "SCREENSHOT_WINDOW_SUCCESS";
+ }
+ return "SCREENSHOT_WINDOW_FAILED";
+ }
+
+ target: "niri"
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/NotepadStorageService.qml b/raveos-hyprland-theme/theme-data/dms/Services/NotepadStorageService.qml
new file mode 100644
index 0000000..3a6257a
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/NotepadStorageService.qml
@@ -0,0 +1,473 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtCore
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property int refCount: 0
+
+ readonly property string baseDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/DankMaterialShell")
+ readonly property string filesDir: baseDir + "/notepad-files"
+ readonly property string metadataPath: baseDir + "/notepad-session.json"
+
+ property var tabs: []
+ property int currentTabIndex: 0
+ property var tabsBeingCreated: ({})
+ property bool metadataLoaded: false
+
+ Component.onCompleted: {
+ ensureDirectories()
+ }
+
+ FileView {
+ id: metadataFile
+ path: root.refCount > 0 ? root.metadataPath : ""
+ blockWrites: true
+ atomicWrites: true
+
+ onLoaded: {
+ try {
+ var data = JSON.parse(text())
+ root.tabs = data.tabs || []
+ root.currentTabIndex = data.currentTabIndex || 0
+ root.metadataLoaded = true
+ root.validateTabs()
+ } catch(e) {
+ console.warn("Failed to parse notepad metadata:", e)
+ root.createDefaultTab()
+ }
+ }
+
+ onLoadFailed: {
+ root.createDefaultTab()
+ }
+ }
+
+ onRefCountChanged: {
+ if (refCount === 1 && !metadataLoaded) {
+ metadataFile.path = ""
+ metadataFile.path = root.metadataPath
+ }
+ }
+
+ function ensureDirectories() {
+ mkdirProcess.running = true
+ }
+
+ function loadMetadata() {
+ metadataFile.path = ""
+ metadataFile.path = root.metadataPath
+ }
+
+ function createDefaultTab() {
+ var id = Date.now()
+ var filePath = "notepad-files/untitled-" + id + ".txt"
+ var fullPath = baseDir + "/" + filePath
+
+ var newTabsBeingCreated = Object.assign({}, tabsBeingCreated)
+ newTabsBeingCreated[id] = true
+ tabsBeingCreated = newTabsBeingCreated
+
+ root.createEmptyFile(fullPath, function() {
+ root.tabs = [{
+ id: id,
+ title: I18n.tr("Untitled"),
+ filePath: filePath,
+ isTemporary: true,
+ lastModified: new Date().toISOString(),
+ cursorPosition: 0,
+ scrollPosition: 0
+ }]
+ root.currentTabIndex = 0
+
+ var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated)
+ delete updatedTabsBeingCreated[id]
+ tabsBeingCreated = updatedTabsBeingCreated
+ root.saveMetadata()
+ })
+ }
+
+ function saveMetadata() {
+ var metadata = {
+ version: 1,
+ currentTabIndex: currentTabIndex,
+ tabs: tabs
+ }
+ metadataFile.setText(JSON.stringify(metadata, null, 2))
+ }
+
+ function getTabById(tabId) {
+ for (var i = 0; i < tabs.length; i++) {
+ if (tabs[i].id === tabId)
+ return tabs[i]
+ }
+ return null
+ }
+
+ function loadTabContent(tabIndex, callback) {
+ if (tabIndex < 0 || tabIndex >= tabs.length) {
+ callback("")
+ return
+ }
+
+ var tab = tabs[tabIndex]
+ var requestTabId = tab.id
+ var fullPath = tab.isTemporary
+ ? baseDir + "/" + tab.filePath
+ : tab.filePath
+
+ if (tabsBeingCreated[tab.id]) {
+ Qt.callLater(() => {
+ loadTabContent(tabIndex, callback)
+ })
+ return
+ }
+
+ var fileChecker = fileExistsComponent.createObject(root, {
+ path: fullPath,
+ callback: (exists) => {
+ var currentTab = root.getTabById(requestTabId)
+ var currentPath = currentTab
+ ? (currentTab.isTemporary ? baseDir + "/" + currentTab.filePath : currentTab.filePath)
+ : ""
+
+ if (!currentTab || currentPath !== fullPath) {
+ callback("")
+ return
+ }
+
+ if (exists) {
+ var loader = tabFileLoaderComponent.createObject(root, {
+ path: fullPath,
+ callback: callback
+ })
+ } else {
+ console.warn("Tab file does not exist:", fullPath)
+ callback("")
+ }
+ }
+ })
+ }
+
+ function saveTabContent(tabIndex, content) {
+ if (tabIndex < 0 || tabIndex >= tabs.length) return
+
+ var tab = tabs[tabIndex]
+ var fullPath = tab.isTemporary
+ ? baseDir + "/" + tab.filePath
+ : tab.filePath
+
+ var saver = tabFileSaverComponent.createObject(root, {
+ path: fullPath,
+ content: content,
+ tabIndex: tabIndex
+ })
+ }
+
+ function createNewTab() {
+ var id = Date.now()
+ var filePath = "notepad-files/untitled-" + id + ".txt"
+ var fullPath = baseDir + "/" + filePath
+
+ var newTab = {
+ id: id,
+ title: I18n.tr("Untitled"),
+ filePath: filePath,
+ isTemporary: true,
+ lastModified: new Date().toISOString(),
+ cursorPosition: 0,
+ scrollPosition: 0
+ }
+
+ var newTabsBeingCreated = Object.assign({}, tabsBeingCreated)
+ newTabsBeingCreated[id] = true
+ tabsBeingCreated = newTabsBeingCreated
+ createEmptyFile(fullPath, function() {
+ var newTabs = tabs.slice()
+ newTabs.push(newTab)
+ tabs = newTabs
+ currentTabIndex = tabs.length - 1
+
+ var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated)
+ delete updatedTabsBeingCreated[id]
+ tabsBeingCreated = updatedTabsBeingCreated
+ saveMetadata()
+ })
+
+ return newTab
+ }
+
+ function closeTab(tabIndex) {
+ if (tabIndex < 0 || tabIndex >= tabs.length) return
+
+ var newTabs = tabs.slice()
+
+ if (newTabs.length <= 1) {
+ var id = Date.now()
+ var filePath = "notepad-files/untitled-" + id + ".txt"
+
+ var newTabsBeingCreated = Object.assign({}, tabsBeingCreated)
+ newTabsBeingCreated[id] = true
+ tabsBeingCreated = newTabsBeingCreated
+ createEmptyFile(baseDir + "/" + filePath, function() {
+ newTabs[0] = {
+ id: id,
+ title: I18n.tr("Untitled"),
+ filePath: filePath,
+ isTemporary: true,
+ lastModified: new Date().toISOString(),
+ cursorPosition: 0,
+ scrollPosition: 0
+ }
+ currentTabIndex = 0
+ tabs = newTabs
+
+ var updatedTabsBeingCreated = Object.assign({}, tabsBeingCreated)
+ delete updatedTabsBeingCreated[id]
+ tabsBeingCreated = updatedTabsBeingCreated
+ saveMetadata()
+ })
+ return
+ } else {
+ var tabToDelete = newTabs[tabIndex]
+ if (tabToDelete && tabToDelete.isTemporary) {
+ deleteFile(baseDir + "/" + tabToDelete.filePath)
+ }
+
+ newTabs.splice(tabIndex, 1)
+ if (currentTabIndex >= newTabs.length) {
+ currentTabIndex = newTabs.length - 1
+ } else if (currentTabIndex > tabIndex) {
+ currentTabIndex -= 1
+ }
+ }
+
+ tabs = newTabs
+ saveMetadata()
+
+ }
+
+ function switchToTab(tabIndex) {
+ if (tabIndex < 0 || tabIndex >= tabs.length) return
+
+ currentTabIndex = tabIndex
+ saveMetadata()
+ }
+
+ function reorderTab(fromIndex, toIndex) {
+ if (fromIndex < 0 || fromIndex >= tabs.length || toIndex < 0 || toIndex >= tabs.length)
+ return
+ if (fromIndex === toIndex)
+ return
+
+ var newTabs = tabs.slice()
+ var moved = newTabs.splice(fromIndex, 1)[0]
+ newTabs.splice(toIndex, 0, moved)
+ tabs = newTabs
+
+ if (currentTabIndex === fromIndex) {
+ currentTabIndex = toIndex
+ } else if (fromIndex < currentTabIndex && toIndex >= currentTabIndex) {
+ currentTabIndex--
+ } else if (fromIndex > currentTabIndex && toIndex <= currentTabIndex) {
+ currentTabIndex++
+ }
+
+ saveMetadata()
+ }
+
+ function saveTabAs(tabIndex, userPath) {
+ if (tabIndex < 0 || tabIndex >= tabs.length) return
+
+ var tab = tabs[tabIndex]
+ var fileName = userPath.split('/').pop()
+
+ if (tab.isTemporary) {
+ var tempPath = baseDir + "/" + tab.filePath
+ copyFile(tempPath, userPath)
+ deleteFile(tempPath)
+ }
+
+ var newTabs = tabs.slice()
+ newTabs[tabIndex] = Object.assign({}, tab, {
+ title: fileName,
+ filePath: userPath,
+ isTemporary: false,
+ lastModified: new Date().toISOString()
+ })
+ tabs = newTabs
+ saveMetadata()
+
+ }
+
+ function updateTabMetadata(tabIndex, properties) {
+ if (tabIndex < 0 || tabIndex >= tabs.length) return
+
+ var newTabs = tabs.slice()
+ var updatedTab = Object.assign({}, newTabs[tabIndex], properties)
+ updatedTab.lastModified = new Date().toISOString()
+ newTabs[tabIndex] = updatedTab
+ tabs = newTabs
+ saveMetadata()
+
+ }
+
+ function validateTabs() {
+ var validTabs = []
+ for (var i = 0; i < tabs.length; i++) {
+ var tab = tabs[i]
+ validTabs.push(tab)
+ }
+ tabs = validTabs
+
+ if (tabs.length === 0) {
+ root.createDefaultTab()
+ }
+ }
+
+ Component {
+ id: tabFileLoaderComponent
+ FileView {
+ property var callback
+ blockLoading: true
+ preload: true
+
+ onLoaded: {
+ callback(text())
+ destroy()
+ }
+
+ onLoadFailed: {
+ callback("")
+ destroy()
+ }
+ }
+ }
+
+ Component {
+ id: fileExistsComponent
+ Process {
+ property string path
+ property var callback
+ command: ["test", "-f", path]
+
+ Component.onCompleted: running = true
+
+ onExited: (exitCode) => {
+ callback(exitCode === 0)
+ destroy()
+ }
+ }
+ }
+
+ Component {
+ id: tabFileSaverComponent
+ FileView {
+ property string content
+ property int tabIndex
+ property var creationCallback
+
+ blockWrites: false
+ atomicWrites: true
+
+ Component.onCompleted: setText(content)
+
+ onSaved: {
+ if (tabIndex >= 0) {
+ root.updateTabMetadata(tabIndex, {})
+ }
+ if (creationCallback) {
+ creationCallback()
+ }
+ destroy()
+ }
+
+ onSaveFailed: {
+ console.error("Failed to save tab content")
+ if (creationCallback) {
+ creationCallback()
+ }
+ destroy()
+ }
+ }
+ }
+
+ function createEmptyFile(path, callback) {
+ var cleanPath = decodeURI(path.toString())
+
+ if (!cleanPath.startsWith("/")) {
+ cleanPath = baseDir + "/" + cleanPath
+ }
+
+ var creator = fileCreatorComponent.createObject(root, {
+ filePath: cleanPath,
+ creationCallback: callback
+ })
+ }
+
+ function copyFile(source, destination) {
+ copyProcess.source = source
+ copyProcess.destination = destination
+ copyProcess.running = true
+ }
+
+ function deleteFile(path) {
+ deleteProcess.filePath = path
+ deleteProcess.running = true
+ }
+
+ Component {
+ id: fileCreatorComponent
+ QtObject {
+ property string filePath
+ property var creationCallback
+
+ Component.onCompleted: {
+ var touchProcess = touchProcessComponent.createObject(this, {
+ filePath: filePath,
+ callback: creationCallback
+ })
+ }
+ }
+ }
+
+ Component {
+ id: touchProcessComponent
+ Process {
+ property string filePath
+ property var callback
+ command: ["touch", filePath]
+
+ Component.onCompleted: running = true
+
+ onExited: (exitCode) => {
+ if (callback) callback()
+ destroy()
+ }
+ }
+ }
+
+ Process {
+ id: copyProcess
+ property string source
+ property string destination
+ command: ["cp", source, destination]
+ }
+
+ Process {
+ id: deleteProcess
+ property string filePath
+ command: ["rm", "-f", filePath]
+ }
+
+ Process {
+ id: mkdirProcess
+ command: ["mkdir", "-p", root.baseDir, root.filesDir]
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/NotificationService.qml b/raveos-hyprland-theme/theme-data/dms/Services/NotificationService.qml
new file mode 100644
index 0000000..0615b58
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/NotificationService.qml
@@ -0,0 +1,1237 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Notifications
+import qs.Common
+import "../Common/markdown2html.js" as Markdown2Html
+
+Singleton {
+ id: root
+
+ readonly property list<NotifWrapper> notifications: []
+ readonly property list<NotifWrapper> allWrappers: []
+ readonly property list<NotifWrapper> popups: allWrappers.filter(n => n && n.popup)
+
+ property var historyList: []
+ readonly property string historyFile: Paths.strip(Paths.cache) + "/notification_history.json"
+ readonly property string imageCacheDir: Paths.strip(Paths.cache) + "/notification_images"
+ property bool historyLoaded: false
+ property int historyEntryCounter: 0
+
+ property list<NotifWrapper> notificationQueue: []
+ property list<NotifWrapper> visibleNotifications: []
+ property int maxVisibleNotifications: 4
+ property bool addGateBusy: false
+ property int enterAnimMs: 400
+ property int seqCounter: 0
+ property bool bulkDismissing: false
+
+ property int maxQueueSize: 32
+ property int maxIngressPerSecond: 20
+ property double _lastIngressSec: 0
+ property int _ingressCountThisSec: 0
+
+ property var _dismissQueue: []
+ property int _dismissBatchSize: 8
+ property int _dismissTickMs: 8
+ property bool _suspendGrouping: false
+ property var _groupCache: ({
+ "notifications": [],
+ "popups": []
+ })
+ property bool _groupsDirty: false
+
+ Component.onCompleted: {
+ _recomputeGroups();
+ Quickshell.execDetached(["mkdir", "-p", Paths.strip(Paths.cache)]);
+ Quickshell.execDetached(["mkdir", "-p", imageCacheDir]);
+ }
+
+ FileView {
+ id: historyFileView
+ path: root.historyFile
+ printErrors: false
+ onLoaded: root.loadHistory()
+ onLoadFailed: error => {
+ if (error === 2) {
+ root.historyLoaded = true;
+ historyFileView.writeAdapter();
+ }
+ }
+
+ JsonAdapter {
+ id: historyAdapter
+ property var notifications: []
+ }
+ }
+
+ Timer {
+ id: historySaveTimer
+ interval: 200
+ onTriggered: root.performSaveHistory()
+ }
+
+ function _makeHistoryEntryId(sourceId, timestamp) {
+ historyEntryCounter += 1;
+ const safeSource = sourceId && sourceId !== "" ? sourceId : "notification";
+ return safeSource + "_" + (timestamp || Date.now()) + "_" + historyEntryCounter;
+ }
+
+ function getImageCachePath(wrapper) {
+ const ts = wrapper.time ? wrapper.time.getTime() : Date.now();
+ const id = wrapper.notification?.id?.toString() || "0";
+ return imageCacheDir + "/notif_" + ts + "_" + id + ".png";
+ }
+
+ function updateHistoryImage(wrapperId, imagePath) {
+ const idx = historyList.findIndex(n => n.sourceNotificationId === wrapperId || n.id === wrapperId);
+ if (idx < 0)
+ return;
+ const item = historyList[idx];
+ const updated = {
+ id: item.id,
+ sourceNotificationId: item.sourceNotificationId || item.id,
+ summary: item.summary,
+ body: item.body,
+ htmlBody: item.htmlBody,
+ appName: item.appName,
+ appIcon: item.appIcon,
+ image: "file://" + imagePath,
+ urgency: item.urgency,
+ timestamp: item.timestamp,
+ desktopEntry: item.desktopEntry
+ };
+ const newList = historyList.slice();
+ newList[idx] = updated;
+ historyList = newList;
+ saveHistory();
+ }
+
+ function addToHistory(wrapper) {
+ if (!wrapper)
+ return;
+ const urg = typeof wrapper.urgency === "number" ? wrapper.urgency : 1;
+ const imageUrl = wrapper.image || "";
+ let persistableImage = "";
+ if (wrapper.persistedImagePath) {
+ persistableImage = "file://" + wrapper.persistedImagePath;
+ } else if (imageUrl && !imageUrl.startsWith("image://qsimage/")) {
+ persistableImage = imageUrl;
+ }
+ const sourceNotificationId = wrapper.notification?.id?.toString() || "";
+ const timestamp = wrapper.time.getTime();
+ const data = {
+ id: _makeHistoryEntryId(sourceNotificationId, timestamp),
+ sourceNotificationId: sourceNotificationId,
+ summary: wrapper.summary || "",
+ body: wrapper.body || "",
+ htmlBody: wrapper.htmlBody || wrapper.body || "",
+ appName: wrapper.appName || "",
+ appIcon: wrapper.appIcon || "",
+ image: persistableImage,
+ urgency: urg,
+ timestamp: timestamp,
+ desktopEntry: wrapper.desktopEntry || ""
+ };
+ let newList = [data, ...historyList];
+ if (newList.length > SettingsData.notificationHistoryMaxCount) {
+ newList = newList.slice(0, SettingsData.notificationHistoryMaxCount);
+ }
+ historyList = newList;
+ saveHistory();
+ }
+
+ function saveHistory() {
+ historySaveTimer.restart();
+ }
+
+ function performSaveHistory() {
+ try {
+ historyAdapter.notifications = historyList;
+ historyFileView.writeAdapter();
+ } catch (e) {
+ console.warn("NotificationService: save history failed:", e);
+ }
+ }
+
+ function loadHistory() {
+ try {
+ const maxAgeDays = SettingsData.notificationHistoryMaxAgeDays;
+ const now = Date.now();
+ const maxAgeMs = maxAgeDays > 0 ? maxAgeDays * 24 * 60 * 60 * 1000 : 0;
+ const loaded = [];
+ const seenIds = {};
+ let needsRewrite = false;
+
+ for (const item of historyAdapter.notifications || []) {
+ if (maxAgeMs > 0 && (now - item.timestamp) > maxAgeMs)
+ continue;
+ const urg = typeof item.urgency === "number" ? item.urgency : 1;
+ const body = item.body || "";
+ let htmlBody = item.htmlBody || _resolveHtmlBody(body);
+ if (htmlBody) {
+ htmlBody = htmlBody.replace(/<img\b[^>]*>/gi, "");
+ }
+ const sourceNotificationId = (item.sourceNotificationId || item.id || "").toString();
+ let historyId = (item.id || "").toString();
+ if (!historyId || seenIds[historyId]) {
+ historyId = _makeHistoryEntryId(sourceNotificationId, item.timestamp || now);
+ needsRewrite = true;
+ }
+ if (!item.sourceNotificationId)
+ needsRewrite = true;
+ seenIds[historyId] = true;
+ loaded.push({
+ id: historyId,
+ sourceNotificationId: sourceNotificationId,
+ summary: item.summary || "",
+ body: body,
+ htmlBody: htmlBody,
+ appName: item.appName || "",
+ appIcon: item.appIcon || "",
+ image: item.image || "",
+ urgency: urg,
+ timestamp: item.timestamp || 0,
+ desktopEntry: item.desktopEntry || ""
+ });
+ }
+ historyList = loaded;
+ historyLoaded = true;
+ if ((maxAgeMs > 0 && loaded.length !== (historyAdapter.notifications || []).length) || needsRewrite)
+ saveHistory();
+ } catch (e) {
+ console.warn("NotificationService: load history failed:", e);
+ historyLoaded = true;
+ }
+ }
+
+ function _deleteCachedImage(imagePath) {
+ if (!imagePath || !imagePath.startsWith("file://"))
+ return;
+ const filePath = imagePath.replace("file://", "");
+ if (filePath.startsWith(imageCacheDir)) {
+ Quickshell.execDetached(["rm", "-f", filePath]);
+ }
+ }
+
+ function removeFromHistory(notificationId) {
+ const idx = historyList.findIndex(n => n.id === notificationId);
+ if (idx >= 0) {
+ _deleteCachedImage(historyList[idx].image);
+ historyList = historyList.filter((_, i) => i !== idx);
+ saveHistory();
+ return true;
+ }
+ return false;
+ }
+
+ function clearHistory() {
+ for (const item of historyList) {
+ _deleteCachedImage(item.image);
+ }
+ historyList = [];
+ saveHistory();
+ }
+
+ function getHistoryTimeRange(timestamp) {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const itemDate = new Date(timestamp);
+ const itemDay = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate());
+ const diffDays = Math.floor((today - itemDay) / (1000 * 60 * 60 * 24));
+ if (diffDays === 0)
+ return 0;
+ if (diffDays === 1)
+ return 1;
+ return 2;
+ }
+
+ function getHistoryCountForRange(range) {
+ if (range === -1)
+ return historyList.length;
+ return historyList.filter(n => getHistoryTimeRange(n.timestamp) === range).length;
+ }
+
+ function formatHistoryTime(timestamp) {
+ root.timeUpdateTick;
+ root.clockFormatChanged;
+ const now = new Date();
+ const date = new Date(timestamp);
+ const diff = now.getTime() - timestamp;
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ if (hours < 1) {
+ if (minutes < 1)
+ return I18n.tr("now");
+ return I18n.tr("%1m ago").arg(minutes);
+ }
+ const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const itemDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ const daysDiff = Math.floor((nowDate - itemDate) / (1000 * 60 * 60 * 24));
+ const timeStr = SettingsData.use24HourClock ? date.toLocaleTimeString(Qt.locale(), "HH:mm") : date.toLocaleTimeString(Qt.locale(), "h:mm AP");
+ if (daysDiff === 0)
+ return timeStr;
+ try {
+ const localeName = (typeof I18n !== "undefined" && I18n.locale) ? I18n.locale().name : "en-US";
+ const weekday = date.toLocaleDateString(localeName, {
+ weekday: "long"
+ });
+ return weekday + ", " + timeStr;
+ } catch (e) {
+ return timeStr;
+ }
+ }
+
+ function _nowSec() {
+ return Date.now() / 1000.0;
+ }
+
+ function _ingressAllowed(urgency) {
+ const t = _nowSec();
+ if (t - _lastIngressSec >= 1.0) {
+ _lastIngressSec = t;
+ _ingressCountThisSec = 0;
+ }
+ _ingressCountThisSec += 1;
+ if (urgency === NotificationUrgency.Critical) {
+ return true;
+ }
+ return _ingressCountThisSec <= maxIngressPerSecond;
+ }
+
+ function _enqueuePopup(wrapper) {
+ if (notificationQueue.length >= maxQueueSize) {
+ const gk = getGroupKey(wrapper);
+ let idx = notificationQueue.findIndex(w => w && getGroupKey(w) === gk && w.urgency !== NotificationUrgency.Critical);
+ if (idx === -1) {
+ idx = notificationQueue.findIndex(w => w && w.urgency !== NotificationUrgency.Critical);
+ }
+ if (idx === -1) {
+ idx = 0;
+ }
+ const victim = notificationQueue[idx];
+ if (victim) {
+ victim.popup = false;
+ }
+ notificationQueue.splice(idx, 1);
+ }
+ notificationQueue = [...notificationQueue, wrapper];
+ }
+
+ function _initWrapperPersistence(wrapper) {
+ const timeoutMs = wrapper.timer ? wrapper.timer.interval : 5000;
+ const isCritical = wrapper && wrapper.urgency === NotificationUrgency.Critical;
+ wrapper.isPersistent = isCritical || (timeoutMs === 0);
+ }
+
+ function _shouldSaveToHistory(urgency, forceDisable) {
+ if (forceDisable === true)
+ return false;
+ if (!SettingsData.notificationHistoryEnabled)
+ return false;
+ switch (urgency) {
+ case NotificationUrgency.Low:
+ return SettingsData.notificationHistorySaveLow;
+ case NotificationUrgency.Critical:
+ return SettingsData.notificationHistorySaveCritical;
+ default:
+ return SettingsData.notificationHistorySaveNormal;
+ }
+ }
+
+ function _resolveAppNameForRule(notif) {
+ if (!notif)
+ return "";
+ if (notif.appName && notif.appName !== "")
+ return notif.appName;
+ const entry = DesktopEntries.heuristicLookup(notif.desktopEntry);
+ if (entry && entry.name)
+ return entry.name;
+ return "";
+ }
+
+ function _ruleFieldValue(field, info) {
+ switch ((field || "").toString()) {
+ case "desktopEntry":
+ return info.desktopEntry;
+ case "summary":
+ return info.summary;
+ case "body":
+ return info.body;
+ case "appName":
+ default:
+ return info.appName;
+ }
+ }
+
+ function _coerceRuleUrgency(value, fallbackUrgency) {
+ if (typeof value === "number" && value >= NotificationUrgency.Low && value <= NotificationUrgency.Critical)
+ return value;
+
+ const mapped = (value || "default").toString().toLowerCase();
+ switch (mapped) {
+ case "low":
+ return NotificationUrgency.Low;
+ case "normal":
+ return NotificationUrgency.Normal;
+ case "critical":
+ return NotificationUrgency.Critical;
+ default:
+ return fallbackUrgency;
+ }
+ }
+
+ function _matchesNotificationRule(rule, info) {
+ if (!rule)
+ return false;
+ if (rule.enabled === false)
+ return false;
+
+ const pattern = (rule.pattern || "").toString();
+ if (!pattern.trim())
+ return false;
+
+ const value = (_ruleFieldValue(rule.field, info) || "").toString();
+ const matchType = (rule.matchType || "contains").toString().toLowerCase();
+
+ if (matchType === "exact")
+ return value.toLowerCase() === pattern.toLowerCase();
+ if (matchType === "regex") {
+ try {
+ return new RegExp(pattern, "i").test(value);
+ } catch (e) {
+ console.warn("NotificationService: invalid notification rule regex:", pattern);
+ return false;
+ }
+ }
+
+ return value.toLowerCase().includes(pattern.toLowerCase());
+ }
+
+ function _evaluateNotificationPolicy(notif) {
+ const baseUrgency = typeof notif.urgency === "number" ? notif.urgency : NotificationUrgency.Normal;
+ const policy = {
+ "drop": false,
+ "disablePopup": false,
+ "hideFromCenter": false,
+ "disableHistory": false,
+ "urgency": baseUrgency
+ };
+
+ const rules = SettingsData.notificationRules || [];
+ if (!rules.length)
+ return policy;
+
+ const info = {
+ "appName": _resolveAppNameForRule(notif),
+ "desktopEntry": notif.desktopEntry || "",
+ "summary": notif.summary || "",
+ "body": notif.body || ""
+ };
+
+ for (const rule of rules) {
+ if (!_matchesNotificationRule(rule, info))
+ continue;
+
+ const action = (rule.action || "default").toString().toLowerCase();
+ switch (action) {
+ case "ignore":
+ policy.drop = true;
+ break;
+ case "mute":
+ policy.disablePopup = true;
+ break;
+ case "popup_only":
+ policy.hideFromCenter = true;
+ policy.disableHistory = true;
+ break;
+ case "no_history":
+ policy.disableHistory = true;
+ break;
+ default:
+ break;
+ }
+
+ policy.urgency = _coerceRuleUrgency(rule.urgency, policy.urgency);
+ return policy;
+ }
+
+ return policy;
+ }
+
+ function pruneHistory() {
+ const maxAgeDays = SettingsData.notificationHistoryMaxAgeDays;
+ if (maxAgeDays <= 0)
+ return;
+
+ const now = Date.now();
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
+ const toRemove = historyList.filter(item => (now - item.timestamp) > maxAgeMs);
+ const pruned = historyList.filter(item => (now - item.timestamp) <= maxAgeMs);
+
+ if (pruned.length !== historyList.length) {
+ for (const item of toRemove) {
+ _deleteCachedImage(item.image);
+ }
+ historyList = pruned;
+ saveHistory();
+ }
+ }
+
+ function deleteHistory() {
+ for (const item of historyList) {
+ _deleteCachedImage(item.image);
+ }
+ historyList = [];
+ historyAdapter.notifications = [];
+ historyFileView.writeAdapter();
+ }
+
+ function onOverlayOpen() {
+ popupsDisabled = true;
+ addGate.stop();
+ addGateBusy = false;
+
+ notificationQueue = [];
+ for (const w of visibleNotifications) {
+ if (w) {
+ w.popup = false;
+ }
+ }
+ visibleNotifications = [];
+ _recomputeGroupsLater();
+ pruneHistory();
+ }
+
+ function onOverlayClose() {
+ popupsDisabled = false;
+ processQueue();
+ }
+
+ Timer {
+ id: addGate
+ interval: 80
+ running: false
+ repeat: false
+ onTriggered: {
+ addGateBusy = false;
+ processQueue();
+ }
+ }
+
+ Timer {
+ id: timeUpdateTimer
+ interval: 30000
+ repeat: true
+ running: root.allWrappers.length > 0 || visibleNotifications.length > 0
+ triggeredOnStart: false
+ onTriggered: {
+ root.timeUpdateTick = !root.timeUpdateTick;
+ }
+ }
+
+ Timer {
+ id: dismissPump
+ interval: _dismissTickMs
+ repeat: true
+ running: false
+ onTriggered: {
+ let n = Math.min(_dismissBatchSize, _dismissQueue.length);
+ for (var i = 0; i < n; ++i) {
+ const w = _dismissQueue.pop();
+ try {
+ if (w && w.notification) {
+ w.notification.dismiss();
+ }
+ } catch (e) {}
+ }
+ if (_dismissQueue.length === 0) {
+ dismissPump.stop();
+ _suspendGrouping = false;
+ bulkDismissing = false;
+ popupsDisabled = false;
+ _recomputeGroupsLater();
+ }
+ }
+ }
+
+ Timer {
+ id: groupsDebounce
+ interval: 16
+ repeat: false
+ onTriggered: _recomputeGroups()
+ }
+
+ property bool timeUpdateTick: false
+ property bool clockFormatChanged: false
+
+ readonly property var groupedNotifications: _groupCache.notifications
+ readonly property var groupedPopups: _groupCache.popups
+
+ property var expandedGroups: ({})
+ property var expandedMessages: ({})
+ property bool popupsDisabled: false
+
+ NotificationServer {
+ id: server
+
+ keepOnReload: false
+ actionsSupported: true
+ actionIconsSupported: true
+ bodyHyperlinksSupported: true
+ bodyImagesSupported: true
+ bodyMarkupSupported: true
+ imageSupported: true
+ inlineReplySupported: true
+ persistenceSupported: true
+
+ onNotification: notif => {
+ notif.tracked = true;
+
+ const policy = _evaluateNotificationPolicy(notif);
+ if (policy.drop) {
+ try {
+ notif.dismiss();
+ } catch (e) {}
+ return;
+ }
+
+ if (!_ingressAllowed(policy.urgency)) {
+ if (policy.urgency !== NotificationUrgency.Critical) {
+ try {
+ notif.dismiss();
+ } catch (e) {}
+ return;
+ }
+ }
+
+ if (SettingsData.soundsEnabled && SettingsData.soundNewNotification) {
+ if (policy.urgency === NotificationUrgency.Critical) {
+ AudioService.playCriticalNotificationSound();
+ } else {
+ AudioService.playNormalNotificationSound();
+ }
+ }
+
+ const shouldShowPopup = !root.popupsDisabled && !SessionData.doNotDisturb && !policy.disablePopup;
+ const isTransient = notif.transient;
+ const shouldKeepInCenter = !isTransient && !policy.hideFromCenter;
+
+ if (!shouldShowPopup && !shouldKeepInCenter) {
+ try {
+ notif.dismiss();
+ } catch (e) {}
+ return;
+ }
+
+ const wrapper = notifComponent.createObject(root, {
+ "popup": shouldShowPopup,
+ "notification": notif,
+ "urgencyOverride": policy.urgency
+ });
+
+ if (wrapper) {
+ root.allWrappers.push(wrapper);
+ if (shouldKeepInCenter) {
+ root.notifications.push(wrapper);
+ if (_shouldSaveToHistory(wrapper.urgency, policy.disableHistory)) {
+ root.addToHistory(wrapper);
+ }
+ }
+ Qt.callLater(() => {
+ _initWrapperPersistence(wrapper);
+ });
+
+ if (shouldShowPopup) {
+ _enqueuePopup(wrapper);
+ processQueue();
+ }
+ }
+
+ _recomputeGroupsLater();
+ }
+ }
+
+ component NotifWrapper: QtObject {
+ id: wrapper
+
+ property bool popup: false
+ property bool removedByLimit: false
+ property bool isPersistent: true
+ property int seq: 0
+ property string persistedImagePath: ""
+
+ onPopupChanged: {
+ if (!popup) {
+ removeFromVisibleNotifications(wrapper);
+ }
+ }
+
+ readonly property Timer timer: Timer {
+ interval: {
+ if (!wrapper.notification)
+ return 5000;
+ switch (wrapper.urgency) {
+ case NotificationUrgency.Low:
+ return SettingsData.notificationTimeoutLow;
+ case NotificationUrgency.Critical:
+ return SettingsData.notificationTimeoutCritical;
+ default:
+ return SettingsData.notificationTimeoutNormal;
+ }
+ }
+ repeat: false
+ running: false
+ onTriggered: {
+ if (interval > 0) {
+ wrapper.popup = false;
+ }
+ }
+ }
+
+ readonly property date time: new Date()
+ readonly property string timeStr: {
+ root.timeUpdateTick;
+ root.clockFormatChanged;
+
+ const now = new Date();
+ const diff = now.getTime() - time.getTime();
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+
+ if (hours < 1) {
+ if (minutes < 1) {
+ return "now";
+ }
+ return `${minutes}m ago`;
+ }
+
+ const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const timeDate = new Date(time.getFullYear(), time.getMonth(), time.getDate());
+ const daysDiff = Math.floor((nowDate - timeDate) / (1000 * 60 * 60 * 24));
+
+ if (daysDiff === 0) {
+ return formatTime(time);
+ }
+
+ try {
+ const localeName = (typeof I18n !== "undefined" && I18n.locale) ? I18n.locale().name : "en-US";
+ const weekday = time.toLocaleDateString(localeName, {
+ weekday: "long"
+ });
+ return `${weekday}, ${formatTime(time)}`;
+ } catch (e) {
+ return formatTime(time);
+ }
+ }
+
+ function formatTime(date) {
+ let use24Hour = true;
+ try {
+ if (typeof SettingsData !== "undefined" && SettingsData.use24HourClock !== undefined) {
+ use24Hour = SettingsData.use24HourClock;
+ }
+ } catch (e) {
+ use24Hour = true;
+ }
+
+ if (use24Hour) {
+ return date.toLocaleTimeString(Qt.locale(), "HH:mm");
+ } else {
+ return date.toLocaleTimeString(Qt.locale(), "h:mm AP");
+ }
+ }
+
+ required property Notification notification
+ readonly property string summary: (notification?.summary ?? "").replace(/<img\b[^>]*>/gi, "")
+ readonly property string body: (notification?.body ?? "").replace(/<img\b[^>]*>/gi, "")
+ readonly property string htmlBody: root._resolveHtmlBody(body)
+ readonly property string appIcon: notification?.appIcon ?? ""
+ readonly property string appName: {
+ if (!notification)
+ return "app";
+ if (notification.appName == "") {
+ const entry = DesktopEntries.heuristicLookup(notification.desktopEntry);
+ if (entry && entry.name)
+ return entry.name.toLowerCase();
+ }
+ return notification.appName || "app";
+ }
+ readonly property string desktopEntry: notification?.desktopEntry ?? ""
+ readonly property string image: notification?.image ?? ""
+ readonly property string cleanImage: {
+ if (!image)
+ return "";
+ return Paths.strip(image);
+ }
+ property int urgencyOverride: notification?.urgency ?? NotificationUrgency.Normal
+ readonly property int urgency: urgencyOverride
+ readonly property list<NotificationAction> actions: notification?.actions ?? []
+
+ readonly property Connections conn: Connections {
+ target: wrapper.notification?.Retainable ?? null
+
+ function onDropped(): void {
+ root.allWrappers = root.allWrappers.filter(w => w !== wrapper);
+ root.notifications = root.notifications.filter(w => w !== wrapper);
+
+ if (root.bulkDismissing) {
+ return;
+ }
+
+ const groupKey = getGroupKey(wrapper);
+ const remainingInGroup = root.notifications.filter(n => getGroupKey(n) === groupKey);
+
+ if (remainingInGroup.length <= 1) {
+ clearGroupExpansionState(groupKey);
+ }
+
+ cleanupExpansionStates();
+ root._recomputeGroupsLater();
+ }
+
+ function onAboutToDestroy(): void {
+ wrapper.destroy();
+ }
+ }
+ }
+
+ Component {
+ id: notifComponent
+ NotifWrapper {}
+ }
+
+ function dismissAllPopups() {
+ for (const w of visibleNotifications) {
+ if (w) {
+ w.popup = false;
+ }
+ }
+ visibleNotifications = [];
+ notificationQueue = [];
+ }
+
+ function clearAllNotifications() {
+ if (!notifications.length) {
+ return;
+ }
+ bulkDismissing = true;
+ popupsDisabled = true;
+ addGate.stop();
+ addGateBusy = false;
+ notificationQueue = [];
+
+ for (const w of allWrappers) {
+ if (w) {
+ w.popup = false;
+ }
+ }
+ visibleNotifications = [];
+
+ _dismissQueue = notifications.slice();
+ if (notifications.length) {
+ notifications = [];
+ }
+ expandedGroups = {};
+ expandedMessages = {};
+
+ _suspendGrouping = true;
+
+ if (!dismissPump.running && _dismissQueue.length) {
+ dismissPump.start();
+ }
+ }
+
+ function dismissNotification(wrapper) {
+ if (!wrapper || !wrapper.notification) {
+ return;
+ }
+ wrapper.popup = false;
+ wrapper.notification.dismiss();
+ }
+
+ function disablePopups(disable) {
+ popupsDisabled = disable;
+ if (disable) {
+ notificationQueue = [];
+ for (const notif of visibleNotifications) {
+ notif.popup = false;
+ }
+ visibleNotifications = [];
+ }
+ }
+
+ property bool _processingQueue: false
+
+ function processQueue() {
+ if (addGateBusy || _processingQueue)
+ return;
+ if (popupsDisabled)
+ return;
+ if (SessionData.doNotDisturb)
+ return;
+ if (notificationQueue.length === 0)
+ return;
+
+ _processingQueue = true;
+
+ const next = notificationQueue.shift();
+ if (!next) {
+ _processingQueue = false;
+ return;
+ }
+
+ next.seq = ++seqCounter;
+
+ const activePopups = visibleNotifications.filter(n => n && n.popup);
+ let evicted = null;
+ if (activePopups.length >= maxVisibleNotifications) {
+ const unhovered = activePopups.filter(n => n.timer?.running);
+ const pool = unhovered.length > 0 ? unhovered : activePopups;
+ evicted = pool.reduce((min, n) => (n.seq < min.seq) ? n : min, pool[0]);
+ if (evicted)
+ evicted.removedByLimit = true;
+ }
+
+ if (evicted) {
+ visibleNotifications = [...visibleNotifications.filter(n => n !== evicted), next];
+ } else {
+ visibleNotifications = [...visibleNotifications, next];
+ }
+
+ if (evicted)
+ evicted.popup = false;
+ next.popup = true;
+
+ if (next.timer.interval > 0)
+ next.timer.start();
+
+ addGateBusy = true;
+ addGate.restart();
+ _processingQueue = false;
+ }
+
+ function removeFromVisibleNotifications(wrapper) {
+ visibleNotifications = visibleNotifications.filter(n => n !== wrapper);
+ processQueue();
+ }
+
+ function releaseWrapper(w) {
+ visibleNotifications = visibleNotifications.filter(n => n !== w);
+ notificationQueue = notificationQueue.filter(n => n !== w);
+
+ if (w && w.destroy && !w.isPersistent && notifications.indexOf(w) === -1) {
+ Qt.callLater(() => {
+ try {
+ w.destroy();
+ } catch (e) {}
+ });
+ }
+ }
+
+ function _decodeEntities(s) {
+ s = s.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)));
+ s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)));
+ return s.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name) => {
+ switch (name) {
+ case "amp":
+ return "&";
+ case "lt":
+ return "<";
+ case "gt":
+ return ">";
+ case "quot":
+ return "\"";
+ case "apos":
+ return "'";
+ case "nbsp":
+ return "\u00A0";
+ case "ndash":
+ return "\u2013";
+ case "mdash":
+ return "\u2014";
+ case "lsquo":
+ return "\u2018";
+ case "rsquo":
+ return "\u2019";
+ case "ldquo":
+ return "\u201C";
+ case "rdquo":
+ return "\u201D";
+ case "bull":
+ return "\u2022";
+ case "hellip":
+ return "\u2026";
+ case "trade":
+ return "\u2122";
+ case "copy":
+ return "\u00A9";
+ case "reg":
+ return "\u00AE";
+ case "deg":
+ return "\u00B0";
+ case "plusmn":
+ return "\u00B1";
+ case "times":
+ return "\u00D7";
+ case "divide":
+ return "\u00F7";
+ case "micro":
+ return "\u00B5";
+ case "middot":
+ return "\u00B7";
+ case "laquo":
+ return "\u00AB";
+ case "raquo":
+ return "\u00BB";
+ case "larr":
+ return "\u2190";
+ case "rarr":
+ return "\u2192";
+ case "uarr":
+ return "\u2191";
+ case "darr":
+ return "\u2193";
+ default:
+ return match;
+ }
+ });
+ }
+
+ function _resolveHtmlBody(body) {
+ if (!body)
+ return "";
+
+ let result = body;
+
+ if (/<\/?[a-z][\s\S]*>/i.test(body)) {
+ result = body;
+ } else {
+ // Decode percent-encoded URLs (e.g. https%3A%2F%2F → https://)
+ let processed = body.replace(/\bhttps?%3A%2F%2F[^\s]+/gi, match => {
+ try {
+ return decodeURIComponent(match);
+ } catch (e) {
+ return match;
+ }
+ });
+
+ if (/&(#\d+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/.test(processed)) {
+ const decoded = _decodeEntities(processed);
+ if (/<\/?[a-z][\s\S]*>/i.test(decoded))
+ result = decoded;
+ else
+ result = Markdown2Html.markdownToHtml(decoded);
+ } else {
+ result = Markdown2Html.markdownToHtml(processed);
+ }
+ }
+
+ // Strip out image tags to prevent IP tracking
+ return result.replace(/<img\b[^>]*>/gi, "");
+ }
+
+ function getGroupKey(wrapper) {
+ if (wrapper.desktopEntry && wrapper.desktopEntry !== "") {
+ return wrapper.desktopEntry.toLowerCase();
+ }
+
+ return wrapper.appName.toLowerCase();
+ }
+
+ function _recomputeGroups() {
+ if (_suspendGrouping) {
+ _groupsDirty = true;
+ return;
+ }
+ _groupCache = {
+ "notifications": _calcGroupedNotifications(),
+ "popups": _calcGroupedPopups()
+ };
+ _groupsDirty = false;
+ }
+
+ function _recomputeGroupsLater() {
+ _groupsDirty = true;
+ if (!groupsDebounce.running) {
+ groupsDebounce.start();
+ }
+ }
+
+ function _calcGroupedNotifications() {
+ const groups = {};
+
+ for (const notif of notifications) {
+ if (!notif || !notif.notification)
+ continue;
+ const groupKey = getGroupKey(notif);
+ if (!groups[groupKey]) {
+ groups[groupKey] = {
+ "key": groupKey,
+ "appName": notif.appName,
+ "notifications": [],
+ "latestNotification": null,
+ "count": 0,
+ "hasInlineReply": false
+ };
+ }
+
+ groups[groupKey].notifications.unshift(notif);
+ groups[groupKey].latestNotification = groups[groupKey].notifications[0];
+ groups[groupKey].count = groups[groupKey].notifications.length;
+
+ if (notif.notification?.hasInlineReply)
+ groups[groupKey].hasInlineReply = true;
+ }
+
+ return Object.values(groups).sort((a, b) => {
+ if (!a.latestNotification || !b.latestNotification)
+ return 0;
+ const aUrgency = a.latestNotification.urgency ?? NotificationUrgency.Low;
+ const bUrgency = b.latestNotification.urgency ?? NotificationUrgency.Low;
+ if (aUrgency !== bUrgency) {
+ return bUrgency - aUrgency;
+ }
+ return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
+ });
+ }
+
+ function _calcGroupedPopups() {
+ const groups = {};
+
+ for (const notif of popups) {
+ if (!notif || !notif.notification)
+ continue;
+ const groupKey = getGroupKey(notif);
+ if (!groups[groupKey]) {
+ groups[groupKey] = {
+ "key": groupKey,
+ "appName": notif.appName,
+ "notifications": [],
+ "latestNotification": null,
+ "count": 0,
+ "hasInlineReply": false
+ };
+ }
+
+ groups[groupKey].notifications.unshift(notif);
+ groups[groupKey].latestNotification = groups[groupKey].notifications[0];
+ groups[groupKey].count = groups[groupKey].notifications.length;
+
+ if (notif.notification?.hasInlineReply)
+ groups[groupKey].hasInlineReply = true;
+ }
+
+ return Object.values(groups).sort((a, b) => {
+ if (!a.latestNotification || !b.latestNotification)
+ return 0;
+ return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
+ });
+ }
+
+ function toggleGroupExpansion(groupKey) {
+ let newExpandedGroups = {};
+ for (const key in expandedGroups) {
+ newExpandedGroups[key] = expandedGroups[key];
+ }
+ newExpandedGroups[groupKey] = !newExpandedGroups[groupKey];
+ expandedGroups = newExpandedGroups;
+ }
+
+ function dismissGroup(groupKey) {
+ const group = groupedNotifications.find(g => g.key === groupKey);
+ if (group) {
+ for (const notif of group.notifications) {
+ if (notif && notif.notification) {
+ notif.notification.dismiss();
+ }
+ }
+ } else {
+ for (const notif of allWrappers) {
+ if (notif && notif.notification && getGroupKey(notif) === groupKey) {
+ notif.notification.dismiss();
+ }
+ }
+ }
+ }
+
+ function clearGroupExpansionState(groupKey) {
+ let newExpandedGroups = {};
+ for (const key in expandedGroups) {
+ if (key !== groupKey && expandedGroups[key]) {
+ newExpandedGroups[key] = true;
+ }
+ }
+ expandedGroups = newExpandedGroups;
+ }
+
+ function cleanupExpansionStates() {
+ const currentGroupKeys = new Set(groupedNotifications.map(g => g.key));
+ const currentMessageIds = new Set();
+ for (const group of groupedNotifications) {
+ for (const notif of group.notifications) {
+ if (notif && notif.notification) {
+ currentMessageIds.add(notif.notification.id);
+ }
+ }
+ }
+ let newExpandedGroups = {};
+ for (const key in expandedGroups) {
+ if (currentGroupKeys.has(key) && expandedGroups[key]) {
+ newExpandedGroups[key] = true;
+ }
+ }
+ expandedGroups = newExpandedGroups;
+ let newExpandedMessages = {};
+ for (const messageId in expandedMessages) {
+ if (currentMessageIds.has(messageId) && expandedMessages[messageId]) {
+ newExpandedMessages[messageId] = true;
+ }
+ }
+ expandedMessages = newExpandedMessages;
+ }
+
+ function toggleMessageExpansion(messageId) {
+ let newExpandedMessages = {};
+ for (const key in expandedMessages) {
+ newExpandedMessages[key] = expandedMessages[key];
+ }
+ newExpandedMessages[messageId] = !newExpandedMessages[messageId];
+ expandedMessages = newExpandedMessages;
+ }
+
+ Connections {
+ target: SessionData
+ function onDoNotDisturbChanged() {
+ if (SessionData.doNotDisturb) {
+ // Hide all current popups when DND is enabled
+ for (const notif of visibleNotifications) {
+ notif.popup = false;
+ }
+ visibleNotifications = [];
+ notificationQueue = [];
+ } else {
+ // Re-enable popup processing when DND is disabled
+ processQueue();
+ }
+ }
+ }
+
+ Connections {
+ target: typeof SettingsData !== "undefined" ? SettingsData : null
+ function onUse24HourClockChanged() {
+ root.clockFormatChanged = !root.clockFormatChanged;
+ }
+ function onNotificationHistoryMaxAgeDaysChanged() {
+ root.pruneHistory();
+ }
+ function onNotificationHistoryEnabledChanged() {
+ if (!SettingsData.notificationHistoryEnabled) {
+ root.deleteHistory();
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/PluginService.qml b/raveos-hyprland-theme/theme-data/dms/Services/PluginService.qml
new file mode 100644
index 0000000..03f9be6
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/PluginService.qml
@@ -0,0 +1,876 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtCore
+import QtQuick
+import Qt.labs.folderlistmodel
+import Quickshell
+import qs.Common
+import qs.Services
+
+Singleton {
+ id: root
+
+ property var availablePlugins: ({})
+ property var loadedPlugins: ({})
+ property var pluginWidgetComponents: ({})
+ property var pluginDaemonComponents: ({})
+ property var pluginLauncherComponents: ({})
+ property var pluginDesktopComponents: ({})
+ property var availablePluginsList: []
+ property string pluginDirectory: {
+ var configDir = StandardPaths.writableLocation(StandardPaths.ConfigLocation);
+ var configDirStr = configDir.toString();
+ if (configDirStr.startsWith("file://")) {
+ configDirStr = configDirStr.substring(7);
+ }
+ return configDirStr + "/DankMaterialShell/plugins";
+ }
+ property string systemPluginDirectory: "/etc/xdg/quickshell/dms-plugins"
+
+ property var knownManifests: ({})
+ property var pathToPluginId: ({})
+ property var pluginInstances: ({})
+ property var globalVars: ({})
+
+ property var _stateCache: ({})
+ property var _stateLoaded: ({})
+ property var _stateWriters: ({})
+ property var _stateDirtyPlugins: ({})
+ property bool _stateDirCreated: false
+
+ signal pluginLoaded(string pluginId)
+ signal pluginUnloaded(string pluginId)
+ signal pluginLoadFailed(string pluginId, string error)
+ signal pluginDataChanged(string pluginId)
+ signal pluginStateChanged(string pluginId)
+ signal pluginListUpdated
+ signal globalVarChanged(string pluginId, string varName)
+ signal requestLauncherUpdate(string pluginId)
+
+ Timer {
+ id: resyncDebounce
+ interval: 120
+ repeat: false
+ onTriggered: resyncAll()
+ }
+
+ Timer {
+ id: _stateWriteTimer
+ interval: 150
+ repeat: false
+ onTriggered: root._flushDirtyStates()
+ }
+
+ Component.onCompleted: {
+ userWatcher.folder = Paths.toFileUrl(root.pluginDirectory);
+ systemWatcher.folder = Paths.toFileUrl(root.systemPluginDirectory);
+ Qt.callLater(resyncAll);
+ }
+
+ FolderListModel {
+ id: userWatcher
+ showDirs: true
+ showFiles: false
+ showDotAndDotDot: false
+
+ onCountChanged: resyncDebounce.restart()
+ onStatusChanged: {
+ if (status === FolderListModel.Ready)
+ resyncDebounce.restart();
+ }
+ }
+
+ FolderListModel {
+ id: systemWatcher
+ showDirs: true
+ showFiles: false
+ showDotAndDotDot: false
+
+ onCountChanged: resyncDebounce.restart()
+ onStatusChanged: {
+ if (status === FolderListModel.Ready)
+ resyncDebounce.restart();
+ }
+ }
+
+ function snapshotModel(model, sourceTag) {
+ const out = [];
+ const n = model.count;
+ const baseDir = sourceTag === "user" ? pluginDirectory : systemPluginDirectory;
+ for (let i = 0; i < n; i++) {
+ let dirPath = model.get(i, "filePath");
+ if (dirPath.startsWith("file://")) {
+ dirPath = dirPath.substring(7);
+ }
+ if (!dirPath.startsWith(baseDir)) {
+ continue;
+ }
+ const manifestPath = dirPath + "/plugin.json";
+ out.push({
+ path: manifestPath,
+ source: sourceTag
+ });
+ }
+ return out;
+ }
+
+ function resyncAll() {
+ const userList = snapshotModel(userWatcher, "user");
+ const sysList = snapshotModel(systemWatcher, "system");
+ const seenPaths = {};
+
+ function consider(entry) {
+ const key = entry.path;
+ seenPaths[key] = true;
+ const prev = knownManifests[key];
+ if (!prev) {
+ loadPluginManifestFile(entry.path, entry.source, Date.now());
+ }
+ }
+ for (let i = 0; i < userList.length; i++)
+ consider(userList[i]);
+ for (let i = 0; i < sysList.length; i++)
+ consider(sysList[i]);
+
+ const removed = [];
+ for (const path in knownManifests) {
+ if (!seenPaths[path])
+ removed.push(path);
+ }
+ if (removed.length) {
+ removed.forEach(function (path) {
+ const pid = pathToPluginId[path];
+ if (pid) {
+ unregisterPluginByPath(path, pid);
+ }
+ delete knownManifests[path];
+ delete pathToPluginId[path];
+ });
+ _updateAvailablePluginsList();
+ pluginListUpdated();
+ }
+ }
+
+ function loadPluginManifestFile(manifestPathNoScheme, sourceTag, mtimeEpochMs) {
+ const manifestId = "m_" + Math.random().toString(36).slice(2);
+ const qml = `
+ import QtQuick
+ import Quickshell.Io
+ FileView {
+ id: fv
+ property string absPath: ""
+ onLoaded: {
+ try {
+ let raw = text()
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
+ const manifest = JSON.parse(raw)
+ root._onManifestParsed(absPath, manifest, "${sourceTag}", ${mtimeEpochMs})
+ } catch (e) {
+ console.error("PluginService: bad manifest", absPath, e.message)
+ knownManifests[absPath] = { mtime: ${mtimeEpochMs}, source: "${sourceTag}", bad: true }
+ }
+ fv.destroy()
+ }
+ onLoadFailed: (err) => {
+ console.warn("PluginService: manifest load failed", absPath, err)
+ fv.destroy()
+ }
+ }
+ `;
+
+ const loader = Qt.createQmlObject(qml, root, "mf_" + manifestId);
+ loader.absPath = manifestPathNoScheme;
+ loader.path = manifestPathNoScheme;
+ }
+
+ function _onManifestParsed(absPath, manifest, sourceTag, mtimeEpochMs) {
+ if (!manifest || !manifest.id || !manifest.name || !manifest.component) {
+ console.error("PluginService: invalid manifest fields:", absPath);
+ knownManifests[absPath] = {
+ mtime: mtimeEpochMs,
+ source: sourceTag,
+ bad: true
+ };
+ return;
+ }
+
+ const dir = absPath.substring(0, absPath.lastIndexOf('/'));
+ let comp = manifest.component;
+ if (comp.startsWith("./"))
+ comp = comp.slice(2);
+ let settings = manifest.settings;
+ if (settings && settings.startsWith("./"))
+ settings = settings.slice(2);
+
+ const info = {};
+ for (const k in manifest)
+ info[k] = manifest[k];
+
+ let perms = manifest.permissions;
+ if (typeof perms === "string") {
+ perms = perms.split(/\s*,\s*/);
+ }
+ if (!Array.isArray(perms)) {
+ perms = [];
+ }
+ info.permissions = perms.map(p => String(p).trim());
+
+ info.manifestPath = absPath;
+ info.pluginDirectory = dir;
+ info.componentPath = dir + "/" + comp;
+ info.settingsPath = settings ? (dir + "/" + settings) : null;
+ info.loaded = isPluginLoaded(manifest.id);
+ info.type = manifest.type || "widget";
+ info.source = sourceTag;
+ info.requires_dms = manifest.requires_dms || null;
+
+ const existing = availablePlugins[manifest.id];
+ const shouldReplace = (!existing) || (existing && existing.source === "system" && sourceTag === "user");
+
+ if (shouldReplace) {
+ if (existing && existing.loaded && existing.source !== sourceTag) {
+ unloadPlugin(manifest.id);
+ }
+ const newMap = Object.assign({}, availablePlugins);
+ newMap[manifest.id] = info;
+ availablePlugins = newMap;
+ pathToPluginId[absPath] = manifest.id;
+ knownManifests[absPath] = {
+ mtime: mtimeEpochMs,
+ source: sourceTag
+ };
+ _updateAvailablePluginsList();
+ pluginListUpdated();
+ const enabled = info.type === "desktop" || SettingsData.getPluginSetting(manifest.id, "enabled", false);
+ if (enabled && !info.loaded)
+ loadPlugin(manifest.id);
+ } else {
+ knownManifests[absPath] = {
+ mtime: mtimeEpochMs,
+ source: sourceTag,
+ shadowedBy: existing.source
+ };
+ pathToPluginId[absPath] = manifest.id;
+ }
+ }
+
+ function unregisterPluginByPath(absPath, pluginId) {
+ const current = availablePlugins[pluginId];
+ if (current && current.manifestPath === absPath) {
+ if (current.loaded)
+ unloadPlugin(pluginId);
+ const newMap = Object.assign({}, availablePlugins);
+ delete newMap[pluginId];
+ availablePlugins = newMap;
+ }
+ }
+
+ function loadPlugin(pluginId, bustCache) {
+ const plugin = availablePlugins[pluginId];
+ if (!plugin) {
+ console.error("PluginService: Plugin not found:", pluginId);
+ pluginLoadFailed(pluginId, "Plugin not found");
+ return false;
+ }
+
+ if (plugin.loaded) {
+ return true;
+ }
+
+ const isDaemon = plugin.type === "daemon";
+ const isLauncher = plugin.type === "launcher" || (plugin.capabilities && plugin.capabilities.includes("launcher"));
+ const isDesktop = plugin.type === "desktop";
+
+ const prevInstance = pluginInstances[pluginId];
+ if (prevInstance) {
+ prevInstance.destroy();
+ const newInstances = Object.assign({}, pluginInstances);
+ delete newInstances[pluginId];
+ pluginInstances = newInstances;
+ }
+
+ try {
+ let url = "file://" + plugin.componentPath;
+ if (bustCache)
+ url += "?t=" + Date.now();
+ const comp = Qt.createComponent(url, Component.PreferSynchronous);
+ if (comp.status === Component.Error) {
+ console.error("PluginService: component error", pluginId, comp.errorString());
+ pluginLoadFailed(pluginId, comp.errorString());
+ return false;
+ }
+
+ if (isDaemon) {
+ const newDaemons = Object.assign({}, pluginDaemonComponents);
+ newDaemons[pluginId] = comp;
+ pluginDaemonComponents = newDaemons;
+ } else if (isLauncher) {
+ const instance = comp.createObject(root, {
+ "pluginService": root
+ });
+ if (!instance) {
+ console.error("PluginService: failed to instantiate plugin:", pluginId, comp.errorString());
+ pluginLoadFailed(pluginId, comp.errorString());
+ return false;
+ }
+ const newInstances = Object.assign({}, pluginInstances);
+ newInstances[pluginId] = instance;
+ pluginInstances = newInstances;
+
+ const newLaunchers = Object.assign({}, pluginLauncherComponents);
+ newLaunchers[pluginId] = comp;
+ pluginLauncherComponents = newLaunchers;
+ } else if (isDesktop) {
+ const newDesktop = Object.assign({}, pluginDesktopComponents);
+ newDesktop[pluginId] = comp;
+ pluginDesktopComponents = newDesktop;
+ } else {
+ const newComponents = Object.assign({}, pluginWidgetComponents);
+ newComponents[pluginId] = comp;
+ pluginWidgetComponents = newComponents;
+ }
+
+ plugin.loaded = true;
+ const newLoaded = Object.assign({}, loadedPlugins);
+ newLoaded[pluginId] = plugin;
+ loadedPlugins = newLoaded;
+
+ pluginLoaded(pluginId);
+ return true;
+ } catch (e) {
+ console.error("PluginService: Error loading plugin:", pluginId, e.message);
+ pluginLoadFailed(pluginId, e.message);
+ return false;
+ }
+ }
+
+ function unloadPlugin(pluginId) {
+ const plugin = loadedPlugins[pluginId];
+ if (!plugin) {
+ console.warn("PluginService: Plugin not loaded:", pluginId);
+ return false;
+ }
+
+ try {
+ const isDaemon = plugin.type === "daemon";
+ const isLauncher = plugin.type === "launcher" || (plugin.capabilities && plugin.capabilities.includes("launcher"));
+ const isDesktop = plugin.type === "desktop";
+
+ const instance = pluginInstances[pluginId];
+ if (instance) {
+ instance.destroy();
+ const newInstances = Object.assign({}, pluginInstances);
+ delete newInstances[pluginId];
+ pluginInstances = newInstances;
+ }
+
+ if (isDaemon && pluginDaemonComponents[pluginId]) {
+ const newDaemons = Object.assign({}, pluginDaemonComponents);
+ delete newDaemons[pluginId];
+ pluginDaemonComponents = newDaemons;
+ } else if (isLauncher && pluginLauncherComponents[pluginId]) {
+ const newLaunchers = Object.assign({}, pluginLauncherComponents);
+ delete newLaunchers[pluginId];
+ pluginLauncherComponents = newLaunchers;
+ } else if (isDesktop && pluginDesktopComponents[pluginId]) {
+ const newDesktop = Object.assign({}, pluginDesktopComponents);
+ delete newDesktop[pluginId];
+ pluginDesktopComponents = newDesktop;
+ } else if (pluginWidgetComponents[pluginId]) {
+ const newComponents = Object.assign({}, pluginWidgetComponents);
+ delete newComponents[pluginId];
+ pluginWidgetComponents = newComponents;
+ }
+
+ plugin.loaded = false;
+ const newLoaded = Object.assign({}, loadedPlugins);
+ delete newLoaded[pluginId];
+ loadedPlugins = newLoaded;
+
+ _cleanupPluginStateWriter(pluginId);
+ pluginUnloaded(pluginId);
+ return true;
+ } catch (error) {
+ console.error("PluginService: Error unloading plugin:", pluginId, "Error:", error.message);
+ return false;
+ }
+ }
+
+ function getWidgetComponents() {
+ return pluginWidgetComponents;
+ }
+
+ function getDaemonComponents() {
+ return pluginDaemonComponents;
+ }
+
+ function getDesktopComponents() {
+ return pluginDesktopComponents;
+ }
+
+ function getAvailablePlugins() {
+ return availablePluginsList;
+ }
+
+ function _updateAvailablePluginsList() {
+ const result = [];
+ for (const key in availablePlugins) {
+ result.push(availablePlugins[key]);
+ }
+ availablePluginsList = result;
+ }
+
+ function getPluginVariants(pluginId) {
+ const plugin = availablePlugins[pluginId];
+ if (!plugin) {
+ return [];
+ }
+ const variants = SettingsData.getPluginSetting(pluginId, "variants", []);
+ return variants;
+ }
+
+ function getAllPluginVariants() {
+ const result = [];
+ for (const pluginId in availablePlugins) {
+ const plugin = availablePlugins[pluginId];
+ if (plugin.type !== "widget") {
+ continue;
+ }
+ const variants = getPluginVariants(pluginId);
+ if (variants.length === 0) {
+ result.push({
+ pluginId: pluginId,
+ variantId: null,
+ fullId: pluginId,
+ name: plugin.name,
+ icon: plugin.icon || "extension",
+ description: plugin.description || "Plugin widget",
+ loaded: plugin.loaded
+ });
+ } else {
+ for (let i = 0; i < variants.length; i++) {
+ const variant = variants[i];
+ result.push({
+ pluginId: pluginId,
+ variantId: variant.id,
+ fullId: pluginId + ":" + variant.id,
+ name: plugin.name + " - " + variant.name,
+ icon: variant.icon || plugin.icon || "extension",
+ description: variant.description || plugin.description || "Plugin widget variant",
+ loaded: plugin.loaded
+ });
+ }
+ }
+ }
+ return result;
+ }
+
+ function createPluginVariant(pluginId, variantName, variantConfig) {
+ const variants = getPluginVariants(pluginId);
+ const variantId = "variant_" + Date.now();
+ const newVariant = Object.assign({}, variantConfig, {
+ id: variantId,
+ name: variantName
+ });
+ variants.push(newVariant);
+ SettingsData.setPluginSetting(pluginId, "variants", variants);
+ pluginDataChanged(pluginId);
+ return variantId;
+ }
+
+ function removePluginVariant(pluginId, variantId) {
+ const variants = getPluginVariants(pluginId);
+ const newVariants = variants.filter(function (v) {
+ return v.id !== variantId;
+ });
+ SettingsData.setPluginSetting(pluginId, "variants", newVariants);
+
+ const fullId = pluginId + ":" + variantId;
+ removeWidgetFromDankBar(fullId);
+
+ pluginDataChanged(pluginId);
+ }
+
+ function removeWidgetFromDankBar(widgetId) {
+ function filterWidget(widget) {
+ const id = typeof widget === "string" ? widget : widget.id;
+ return id !== widgetId;
+ }
+
+ const defaultBar = SettingsData.barConfigs[0] || SettingsData.getBarConfig("default");
+ if (!defaultBar)
+ return;
+ const leftWidgets = defaultBar.leftWidgets || [];
+ const centerWidgets = defaultBar.centerWidgets || [];
+ const rightWidgets = defaultBar.rightWidgets || [];
+
+ const newLeft = leftWidgets.filter(filterWidget);
+ const newCenter = centerWidgets.filter(filterWidget);
+ const newRight = rightWidgets.filter(filterWidget);
+
+ if (newLeft.length !== leftWidgets.length) {
+ SettingsData.setDankBarLeftWidgets(newLeft);
+ }
+ if (newCenter.length !== centerWidgets.length) {
+ SettingsData.setDankBarCenterWidgets(newCenter);
+ }
+ if (newRight.length !== rightWidgets.length) {
+ SettingsData.setDankBarRightWidgets(newRight);
+ }
+ }
+
+ function updatePluginVariant(pluginId, variantId, variantConfig) {
+ const variants = getPluginVariants(pluginId);
+ for (let i = 0; i < variants.length; i++) {
+ if (variants[i].id === variantId) {
+ variants[i] = Object.assign({}, variants[i], variantConfig);
+ break;
+ }
+ }
+ SettingsData.setPluginSetting(pluginId, "variants", variants);
+ pluginDataChanged(pluginId);
+ }
+
+ function getPluginVariantData(pluginId, variantId) {
+ const variants = getPluginVariants(pluginId);
+ for (let i = 0; i < variants.length; i++) {
+ if (variants[i].id === variantId) {
+ return variants[i];
+ }
+ }
+ return null;
+ }
+
+ function getLoadedPlugins() {
+ const result = [];
+ for (const key in loadedPlugins) {
+ result.push(loadedPlugins[key]);
+ }
+ return result;
+ }
+
+ function isPluginLoaded(pluginId) {
+ return loadedPlugins[pluginId] !== undefined;
+ }
+
+ function enablePlugin(pluginId) {
+ SettingsData.setPluginSetting(pluginId, "enabled", true);
+ return loadPlugin(pluginId);
+ }
+
+ function disablePlugin(pluginId) {
+ SettingsData.setPluginSetting(pluginId, "enabled", false);
+ return unloadPlugin(pluginId);
+ }
+
+ function reloadPlugin(pluginId) {
+ if (isPluginLoaded(pluginId))
+ unloadPlugin(pluginId);
+ return loadPlugin(pluginId, true);
+ }
+
+ function togglePlugin(pluginId) {
+ let instance = pluginInstances[pluginId];
+
+ // Lazy instantiate daemon plugins on first toggle
+ // This respects the daemon lifecycle (not instantiated on load)
+ // while supporting toggle functionality for slideout-capable daemons
+ if (!instance && pluginDaemonComponents[pluginId]) {
+ const comp = pluginDaemonComponents[pluginId];
+ const newInstance = comp.createObject(root, {
+ "pluginId": pluginId,
+ "pluginService": root
+ });
+ if (newInstance) {
+ const newInstances = Object.assign({}, pluginInstances);
+ newInstances[pluginId] = newInstance;
+ pluginInstances = newInstances;
+ instance = newInstance;
+ }
+ }
+
+ if (instance && typeof instance.toggle === "function") {
+ instance.toggle();
+ return true;
+ }
+ return false;
+ }
+
+ function savePluginData(pluginId, key, value) {
+ SettingsData.setPluginSetting(pluginId, key, value);
+ pluginDataChanged(pluginId);
+ return true;
+ }
+
+ function loadPluginData(pluginId, key, defaultValue) {
+ return SettingsData.getPluginSetting(pluginId, key, defaultValue);
+ }
+
+ function getPluginPath(pluginId) {
+ const plugin = availablePlugins[pluginId];
+ if (!plugin)
+ return "";
+ return plugin.pluginDirectory || "";
+ }
+
+ function saveAllPluginSettings() {
+ SettingsData.savePluginSettings();
+ }
+
+ function getPluginStatePath(pluginId) {
+ return Paths.strip(Paths.state) + "/plugins/" + pluginId + "_state.json";
+ }
+
+ function loadPluginState(pluginId, key, defaultValue) {
+ if (!_stateLoaded[pluginId])
+ _loadStateFromDisk(pluginId);
+ const state = _stateCache[pluginId];
+ if (!state)
+ return defaultValue;
+ return state[key] !== undefined ? state[key] : defaultValue;
+ }
+
+ function savePluginState(pluginId, key, value) {
+ if (!_stateLoaded[pluginId])
+ _loadStateFromDisk(pluginId);
+ if (!_stateCache[pluginId])
+ _stateCache[pluginId] = {};
+ _stateCache[pluginId][key] = value;
+ _stateDirtyPlugins[pluginId] = true;
+ _stateWriteTimer.restart();
+ pluginStateChanged(pluginId);
+ }
+
+ function clearPluginState(pluginId) {
+ _stateCache[pluginId] = {};
+ _stateLoaded[pluginId] = true;
+ _flushStateToDisk(pluginId);
+ pluginStateChanged(pluginId);
+ }
+
+ function removePluginStateKey(pluginId, key) {
+ if (!_stateCache[pluginId])
+ return;
+ delete _stateCache[pluginId][key];
+ _stateDirtyPlugins[pluginId] = true;
+ _stateWriteTimer.restart();
+ pluginStateChanged(pluginId);
+ }
+
+ function _ensureStateDir() {
+ if (_stateDirCreated)
+ return;
+ _stateDirCreated = true;
+ Paths.mkdir(Paths.state + "/plugins");
+ }
+
+ function _loadStateFromDisk(pluginId) {
+ _stateLoaded[pluginId] = true;
+ _ensureStateDir();
+ const path = getPluginStatePath(pluginId);
+ const escapedPath = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ try {
+ const qml = 'import QtQuick; import Quickshell.Io; FileView { path: "' + escapedPath + '"; blockLoading: true; blockWrites: true; atomicWrites: true }';
+ const fv = Qt.createQmlObject(qml, root, "sf_" + pluginId);
+ const raw = fv.text();
+ if (raw && raw.trim()) {
+ _stateCache[pluginId] = JSON.parse(raw);
+ } else {
+ _stateCache[pluginId] = {};
+ }
+ _stateWriters[pluginId] = fv;
+ } catch (e) {
+ _stateCache[pluginId] = {};
+ }
+ }
+
+ function _flushStateToDisk(pluginId) {
+ _ensureStateDir();
+ const content = JSON.stringify(_stateCache[pluginId] || {}, null, 2);
+ if (_stateWriters[pluginId]) {
+ _stateWriters[pluginId].setText(content);
+ return;
+ }
+ const path = getPluginStatePath(pluginId);
+ const escapedPath = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ try {
+ const qml = 'import QtQuick; import Quickshell.Io; FileView { path: "' + escapedPath + '"; blockWrites: true; atomicWrites: true }';
+ const fv = Qt.createQmlObject(qml, root, "sw_" + pluginId);
+ _stateWriters[pluginId] = fv;
+ fv.loaded.connect(function () {
+ fv.setText(content);
+ });
+ fv.loadFailed.connect(function () {
+ fv.setText(content);
+ });
+ } catch (e) {
+ console.warn("PluginService: Failed to write state for", pluginId, e.message);
+ }
+ }
+
+ function _flushDirtyStates() {
+ const dirty = _stateDirtyPlugins;
+ _stateDirtyPlugins = {};
+ for (const pluginId in dirty)
+ _flushStateToDisk(pluginId);
+ }
+
+ function _cleanupPluginStateWriter(pluginId) {
+ if (!_stateWriters[pluginId])
+ return;
+ _stateWriters[pluginId].destroy();
+ delete _stateWriters[pluginId];
+ }
+
+ function scanPlugins() {
+ const userUrl = Paths.toFileUrl(root.pluginDirectory);
+ const systemUrl = Paths.toFileUrl(root.systemPluginDirectory);
+ userWatcher.folder = "";
+ userWatcher.folder = userUrl;
+ systemWatcher.folder = "";
+ systemWatcher.folder = systemUrl;
+ resyncDebounce.restart();
+ }
+
+ function forceRescanPlugin(pluginId) {
+ const plugin = availablePlugins[pluginId];
+ if (plugin && plugin.manifestPath) {
+ const manifestPath = plugin.manifestPath;
+ const source = plugin.source || "user";
+ delete knownManifests[manifestPath];
+ const newMap = Object.assign({}, availablePlugins);
+ delete newMap[pluginId];
+ availablePlugins = newMap;
+ loadPluginManifestFile(manifestPath, source, Date.now());
+ }
+ }
+
+ function createPluginDirectory() {
+ const mkdirProcess = Qt.createComponent("data:text/plain,import Quickshell.Io; Process { }");
+ if (mkdirProcess.status === Component.Ready) {
+ const process = mkdirProcess.createObject(root);
+ process.command = ["mkdir", "-p", pluginDirectory];
+ process.exited.connect(function (exitCode) {
+ if (exitCode !== 0) {
+ console.error("PluginService: Failed to create plugin directory, exit code:", exitCode);
+ }
+ process.destroy();
+ });
+ process.running = true;
+ return true;
+ } else {
+ console.error("PluginService: Failed to create mkdir process");
+ return false;
+ }
+ }
+
+ // Launcher plugin helper functions
+ function getLauncherPlugins() {
+ const launchers = {};
+
+ // Check plugins that have launcher components
+ for (const pluginId in pluginLauncherComponents) {
+ const plugin = availablePlugins[pluginId];
+ if (plugin && plugin.loaded) {
+ launchers[pluginId] = plugin;
+ }
+ }
+ return launchers;
+ }
+
+ function getLauncherPlugin(pluginId) {
+ const plugin = availablePlugins[pluginId];
+ if (plugin && plugin.loaded && pluginLauncherComponents[pluginId]) {
+ return plugin;
+ }
+ return null;
+ }
+
+ function getPluginTrigger(pluginId) {
+ const plugin = getLauncherPlugin(pluginId);
+ if (plugin) {
+ // Check if noTrigger is set (always active mode)
+ const noTrigger = SettingsData.getPluginSetting(pluginId, "noTrigger", false);
+ if (noTrigger) {
+ return "";
+ }
+ // Otherwise load the custom trigger, defaulting to plugin manifest trigger
+ const customTrigger = SettingsData.getPluginSetting(pluginId, "trigger", plugin.trigger || "!");
+ return customTrigger;
+ }
+ return null;
+ }
+
+ function getAllPluginTriggers() {
+ const triggers = {};
+ const launchers = getLauncherPlugins();
+
+ for (const pluginId in launchers) {
+ const trigger = getPluginTrigger(pluginId);
+ if (trigger && trigger.trim() !== "") {
+ triggers[trigger] = pluginId;
+ }
+ }
+ return triggers;
+ }
+
+ function getPluginsWithEmptyTrigger() {
+ const plugins = [];
+ const launchers = getLauncherPlugins();
+
+ for (const pluginId in launchers) {
+ const trigger = getPluginTrigger(pluginId);
+ if (!trigger || trigger.trim() === "") {
+ plugins.push(pluginId);
+ }
+ }
+ return plugins;
+ }
+
+ function getPluginViewPreference(pluginId) {
+ const plugin = availablePlugins[pluginId];
+ if (!plugin)
+ return null;
+
+ return {
+ mode: plugin.viewMode || null,
+ enforced: plugin.viewModeEnforced === true
+ };
+ }
+
+ function getGlobalVar(pluginId, varName, defaultValue) {
+ if (globalVars[pluginId] && varName in globalVars[pluginId]) {
+ return globalVars[pluginId][varName];
+ }
+ return defaultValue;
+ }
+
+ function setGlobalVar(pluginId, varName, value) {
+ const newGlobals = Object.assign({}, globalVars);
+ if (!newGlobals[pluginId]) {
+ newGlobals[pluginId] = {};
+ }
+ newGlobals[pluginId] = Object.assign({}, newGlobals[pluginId]);
+ newGlobals[pluginId][varName] = value;
+ globalVars = newGlobals;
+ globalVarChanged(pluginId, varName);
+ }
+
+ function checkPluginCompatibility(requiresDms) {
+ if (!requiresDms)
+ return true;
+ return SystemUpdateService.checkVersionRequirement(requiresDms, SystemUpdateService.getParsedShellVersion());
+ }
+
+ function getIncompatiblePlugins() {
+ const result = [];
+ for (const pluginId in availablePlugins) {
+ const plugin = availablePlugins[pluginId];
+ if (plugin.loaded && plugin.requires_dms && !checkPluginCompatibility(plugin.requires_dms)) {
+ result.push(plugin);
+ }
+ }
+ return result;
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/PolkitService.qml b/raveos-hyprland-theme/theme-data/dms/Services/PolkitService.qml
new file mode 100644
index 0000000..5238d04
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/PolkitService.qml
@@ -0,0 +1,40 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+
+Singleton {
+ id: root
+
+ readonly property bool disablePolkitIntegration: Quickshell.env("DMS_DISABLE_POLKIT") === "1"
+
+ property bool polkitAvailable: false
+ property var agent: null
+
+ function createPolkitAgent() {
+ try {
+ const qmlString = `
+ import QtQuick
+ import Quickshell.Services.Polkit
+
+ PolkitAgent {
+ }
+ `
+
+ agent = Qt.createQmlObject(qmlString, root, "PolkitService.Agent")
+ polkitAvailable = true
+ console.info("PolkitService: Initialized successfully")
+ } catch (e) {
+ polkitAvailable = false
+ console.warn("PolkitService: Polkit not available - authentication prompts disabled. This requires a newer version of Quickshell.")
+ }
+ }
+
+ Component.onCompleted: {
+ if (disablePolkitIntegration) {
+ return
+ }
+ createPolkitAgent()
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/PopoutService.qml b/raveos-hyprland-theme/theme-data/dms/Services/PopoutService.qml
new file mode 100644
index 0000000..c6e8a09
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/PopoutService.qml
@@ -0,0 +1,709 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import qs.Common
+
+Singleton {
+ id: root
+
+ property var controlCenterPopout: null
+ property var controlCenterLoader: null
+ property var notificationCenterPopout: null
+ property var notificationCenterLoader: null
+ property var appDrawerPopout: null
+ property var appDrawerLoader: null
+ property var processListPopout: null
+ property var processListPopoutLoader: null
+ property var dankDashPopout: null
+ property var dankDashPopoutLoader: null
+ property var batteryPopout: null
+ property var batteryPopoutLoader: null
+ property var vpnPopout: null
+ property var vpnPopoutLoader: null
+ property var systemUpdatePopout: null
+ property var systemUpdateLoader: null
+ property var layoutPopout: null
+ property var layoutPopoutLoader: null
+ property var clipboardHistoryPopout: null
+ property var clipboardHistoryPopoutLoader: null
+
+ property var settingsModal: null
+ property var settingsModalLoader: null
+ property var clipboardHistoryModal: null
+ property var dankLauncherV2Modal: null
+ property var dankLauncherV2ModalLoader: null
+ property var powerMenuModal: null
+ property var processListModal: null
+ property var processListModalLoader: null
+ property var colorPickerModal: null
+ property var notificationModal: null
+ property var wifiPasswordModal: null
+ property var wifiPasswordModalLoader: null
+ property var wifiQRCodeModal: null
+ property var wifiQRCodeModalLoader: null
+ property var polkitAuthModal: null
+ property var polkitAuthModalLoader: null
+ property var bluetoothPairingModal: null
+ property var networkInfoModal: null
+ property var windowRuleModalLoader: null
+
+ property var notepadSlideouts: []
+
+ property string pendingThemeInstall: ""
+ property string pendingPluginInstall: ""
+
+ function setPosition(popout, x, y, width, section, screen) {
+ if (popout && popout.setTriggerPosition && arguments.length >= 6) {
+ popout.setTriggerPosition(x, y, width, section, screen);
+ }
+ }
+
+ function openControlCenter(x, y, width, section, screen) {
+ if (controlCenterPopout) {
+ setPosition(controlCenterPopout, x, y, width, section, screen);
+ controlCenterPopout.open();
+ }
+ }
+
+ function closeControlCenter() {
+ controlCenterPopout?.close();
+ }
+
+ function unloadControlCenter() {
+ if (!controlCenterLoader)
+ return;
+ controlCenterPopout = null;
+ controlCenterLoader.active = false;
+ }
+
+ function toggleControlCenter(x, y, width, section, screen) {
+ if (controlCenterPopout) {
+ setPosition(controlCenterPopout, x, y, width, section, screen);
+ controlCenterPopout.toggle();
+ }
+ }
+
+ function openNotificationCenter(x, y, width, section, screen) {
+ if (notificationCenterPopout) {
+ setPosition(notificationCenterPopout, x, y, width, section, screen);
+ notificationCenterPopout.open();
+ }
+ }
+
+ function closeNotificationCenter() {
+ notificationCenterPopout?.close();
+ }
+
+ function unloadNotificationCenter() {
+ if (!notificationCenterLoader)
+ return;
+ notificationCenterPopout = null;
+ notificationCenterLoader.active = false;
+ }
+
+ function toggleNotificationCenter(x, y, width, section, screen) {
+ if (notificationCenterPopout) {
+ setPosition(notificationCenterPopout, x, y, width, section, screen);
+ notificationCenterPopout.toggle();
+ }
+ }
+
+ function openAppDrawer(x, y, width, section, screen) {
+ if (appDrawerPopout) {
+ setPosition(appDrawerPopout, x, y, width, section, screen);
+ appDrawerPopout.open();
+ }
+ }
+
+ function closeAppDrawer() {
+ appDrawerPopout?.close();
+ }
+
+ function unloadAppDrawer() {
+ if (!appDrawerLoader)
+ return;
+ appDrawerPopout = null;
+ appDrawerLoader.active = false;
+ }
+
+ function toggleAppDrawer(x, y, width, section, screen) {
+ if (appDrawerPopout) {
+ setPosition(appDrawerPopout, x, y, width, section, screen);
+ appDrawerPopout.toggle();
+ }
+ }
+
+ function openProcessList(x, y, width, section, screen) {
+ if (processListPopout) {
+ setPosition(processListPopout, x, y, width, section, screen);
+ processListPopout.open();
+ }
+ }
+
+ function closeProcessList() {
+ processListPopout?.close();
+ }
+
+ function unloadProcessListPopout() {
+ if (!processListPopoutLoader)
+ return;
+ processListPopout = null;
+ processListPopoutLoader.active = false;
+ }
+
+ function toggleProcessList(x, y, width, section, screen) {
+ if (processListPopout) {
+ setPosition(processListPopout, x, y, width, section, screen);
+ processListPopout.toggle();
+ }
+ }
+
+ property bool _dankDashWantsOpen: false
+ property bool _dankDashWantsToggle: false
+ property int _dankDashPendingTab: 0
+ property real _dankDashPendingX: 0
+ property real _dankDashPendingY: 0
+ property real _dankDashPendingWidth: 0
+ property string _dankDashPendingSection: ""
+ property var _dankDashPendingScreen: null
+ property bool _dankDashHasPosition: false
+
+ function _storeDankDashPosition(x, y, width, section, screen, hasPos) {
+ _dankDashPendingX = x;
+ _dankDashPendingY = y;
+ _dankDashPendingWidth = width;
+ _dankDashPendingSection = section;
+ _dankDashPendingScreen = screen;
+ _dankDashHasPosition = hasPos;
+ }
+
+ function openDankDash(tabIndex, x, y, width, section, screen) {
+ _dankDashPendingTab = tabIndex || 0;
+ if (dankDashPopout) {
+ if (arguments.length >= 6)
+ setPosition(dankDashPopout, x, y, width, section, screen);
+ dankDashPopout.currentTabIndex = _dankDashPendingTab;
+ dankDashPopout.dashVisible = true;
+ return;
+ }
+ if (!dankDashPopoutLoader)
+ return;
+ _storeDankDashPosition(x, y, width, section, screen, arguments.length >= 6);
+ _dankDashWantsOpen = true;
+ _dankDashWantsToggle = false;
+ dankDashPopoutLoader.active = true;
+ }
+
+ function closeDankDash() {
+ if (dankDashPopout)
+ dankDashPopout.dashVisible = false;
+ }
+
+ function unloadDankDash() {
+ if (!dankDashPopoutLoader)
+ return;
+ dankDashPopout = null;
+ dankDashPopoutLoader.active = false;
+ }
+
+ function toggleDankDash(tabIndex, x, y, width, section, screen) {
+ _dankDashPendingTab = tabIndex || 0;
+ if (dankDashPopout) {
+ if (arguments.length >= 6)
+ setPosition(dankDashPopout, x, y, width, section, screen);
+ if (dankDashPopout.dashVisible) {
+ dankDashPopout.dashVisible = false;
+ } else {
+ dankDashPopout.currentTabIndex = _dankDashPendingTab;
+ dankDashPopout.dashVisible = true;
+ }
+ return;
+ }
+ if (!dankDashPopoutLoader)
+ return;
+ _storeDankDashPosition(x, y, width, section, screen, arguments.length >= 6);
+ _dankDashWantsToggle = true;
+ _dankDashWantsOpen = false;
+ dankDashPopoutLoader.active = true;
+ }
+
+ function _onDankDashPopoutLoaded() {
+ if (!dankDashPopout)
+ return;
+
+ if (_dankDashHasPosition)
+ setPosition(dankDashPopout, _dankDashPendingX, _dankDashPendingY, _dankDashPendingWidth, _dankDashPendingSection, _dankDashPendingScreen);
+
+ if (_dankDashWantsOpen) {
+ _dankDashWantsOpen = false;
+ dankDashPopout.currentTabIndex = _dankDashPendingTab;
+ dankDashPopout.dashVisible = true;
+ return;
+ }
+ if (_dankDashWantsToggle) {
+ _dankDashWantsToggle = false;
+ if (dankDashPopout.dashVisible) {
+ dankDashPopout.dashVisible = false;
+ } else {
+ dankDashPopout.currentTabIndex = _dankDashPendingTab;
+ dankDashPopout.dashVisible = true;
+ }
+ }
+ }
+
+ function openBattery(x, y, width, section, screen) {
+ if (batteryPopout) {
+ setPosition(batteryPopout, x, y, width, section, screen);
+ batteryPopout.open();
+ }
+ }
+
+ function closeBattery() {
+ batteryPopout?.close();
+ }
+
+ function unloadBattery() {
+ if (!batteryPopoutLoader)
+ return;
+ batteryPopout = null;
+ batteryPopoutLoader.active = false;
+ }
+
+ function toggleBattery(x, y, width, section, screen) {
+ if (batteryPopout) {
+ setPosition(batteryPopout, x, y, width, section, screen);
+ batteryPopout.toggle();
+ }
+ }
+
+ function openVpn(x, y, width, section, screen) {
+ if (vpnPopout) {
+ setPosition(vpnPopout, x, y, width, section, screen);
+ vpnPopout.open();
+ }
+ }
+
+ function closeVpn() {
+ vpnPopout?.close();
+ }
+
+ function unloadVpn() {
+ if (!vpnPopoutLoader)
+ return;
+ vpnPopout = null;
+ vpnPopoutLoader.active = false;
+ }
+
+ function toggleVpn(x, y, width, section, screen) {
+ if (vpnPopout) {
+ setPosition(vpnPopout, x, y, width, section, screen);
+ vpnPopout.toggle();
+ }
+ }
+
+ function openSystemUpdate(x, y, width, section, screen) {
+ if (systemUpdatePopout) {
+ setPosition(systemUpdatePopout, x, y, width, section, screen);
+ systemUpdatePopout.open();
+ }
+ }
+
+ function closeSystemUpdate() {
+ systemUpdatePopout?.close();
+ }
+
+ function unloadSystemUpdate() {
+ if (!systemUpdateLoader)
+ return;
+ systemUpdatePopout = null;
+ systemUpdateLoader.active = false;
+ }
+
+ function toggleSystemUpdate(x, y, width, section, screen) {
+ if (systemUpdatePopout) {
+ setPosition(systemUpdatePopout, x, y, width, section, screen);
+ systemUpdatePopout.toggle();
+ }
+ }
+
+ property bool _settingsWantsOpen: false
+ property bool _settingsWantsToggle: false
+
+ property string _settingsPendingTab: ""
+ property int _settingsPendingTabIndex: -1
+
+ function openSettings() {
+ if (settingsModal) {
+ settingsModal.show();
+ } else if (settingsModalLoader) {
+ _settingsWantsOpen = true;
+ _settingsWantsToggle = false;
+ settingsModalLoader.activeAsync = true;
+ }
+ }
+
+ function openSettingsWithTab(tabName: string) {
+ if (settingsModal) {
+ settingsModal.showWithTabName(tabName);
+ return;
+ }
+ if (settingsModalLoader) {
+ _settingsPendingTab = tabName;
+ _settingsWantsOpen = true;
+ _settingsWantsToggle = false;
+ settingsModalLoader.activeAsync = true;
+ }
+ }
+
+ function openSettingsWithTabIndex(tabIndex: int) {
+ if (settingsModal) {
+ settingsModal.showWithTab(tabIndex);
+ return;
+ }
+ if (settingsModalLoader) {
+ _settingsPendingTabIndex = tabIndex;
+ _settingsWantsOpen = true;
+ _settingsWantsToggle = false;
+ settingsModalLoader.activeAsync = true;
+ }
+ }
+
+ function closeSettings() {
+ settingsModal?.close();
+ }
+
+ function toggleSettings() {
+ if (settingsModal) {
+ settingsModal.toggle();
+ } else if (settingsModalLoader) {
+ _settingsWantsToggle = true;
+ _settingsWantsOpen = false;
+ settingsModalLoader.activeAsync = true;
+ }
+ }
+
+ function toggleSettingsWithTab(tabName: string) {
+ if (settingsModal) {
+ var idx = settingsModal.resolveTabIndex(tabName);
+ if (idx >= 0)
+ settingsModal.currentTabIndex = idx;
+ settingsModal.toggle();
+ return;
+ }
+ if (settingsModalLoader) {
+ _settingsPendingTab = tabName;
+ _settingsWantsToggle = true;
+ _settingsWantsOpen = false;
+ settingsModalLoader.activeAsync = true;
+ }
+ }
+
+ function focusOrToggleSettings() {
+ if (settingsModal?.visible) {
+ const settingsTitle = I18n.tr("Settings", "settings window title");
+ for (const toplevel of ToplevelManager.toplevels.values) {
+ if (toplevel.title !== "Settings" && toplevel.title !== settingsTitle)
+ continue;
+ if (toplevel.activated) {
+ settingsModal.hide();
+ return;
+ }
+ toplevel.activate();
+ return;
+ }
+ }
+ openSettings();
+ }
+
+ function focusOrToggleSettingsWithTab(tabName: string) {
+ if (settingsModal?.visible) {
+ const settingsTitle = I18n.tr("Settings", "settings window title");
+ for (const toplevel of ToplevelManager.toplevels.values) {
+ if (toplevel.title !== "Settings" && toplevel.title !== settingsTitle)
+ continue;
+ if (toplevel.activated) {
+ settingsModal.hide();
+ return;
+ }
+ var idx = settingsModal.resolveTabIndex(tabName);
+ if (idx >= 0)
+ settingsModal.currentTabIndex = idx;
+ toplevel.activate();
+ return;
+ }
+ }
+ openSettingsWithTab(tabName);
+ }
+
+ function unloadSettings() {
+ if (settingsModalLoader) {
+ settingsModal = null;
+ settingsModalLoader.active = false;
+ }
+ }
+
+ function _onSettingsModalLoaded() {
+ if (_settingsWantsOpen) {
+ _settingsWantsOpen = false;
+ if (_settingsPendingTabIndex >= 0) {
+ settingsModal?.showWithTab(_settingsPendingTabIndex);
+ _settingsPendingTabIndex = -1;
+ } else if (_settingsPendingTab) {
+ settingsModal?.showWithTabName(_settingsPendingTab);
+ _settingsPendingTab = "";
+ } else {
+ settingsModal?.show();
+ }
+ return;
+ }
+ if (_settingsWantsToggle) {
+ _settingsWantsToggle = false;
+ if (_settingsPendingTabIndex >= 0) {
+ settingsModal.currentTabIndex = _settingsPendingTabIndex;
+ _settingsPendingTabIndex = -1;
+ } else if (_settingsPendingTab) {
+ var idx = settingsModal?.resolveTabIndex(_settingsPendingTab) ?? -1;
+ if (idx >= 0)
+ settingsModal.currentTabIndex = idx;
+ _settingsPendingTab = "";
+ }
+ settingsModal?.toggle();
+ }
+ }
+
+ function openClipboardHistory() {
+ clipboardHistoryModal?.show();
+ }
+
+ function closeClipboardHistory() {
+ clipboardHistoryModal?.close();
+ }
+
+ function unloadClipboardHistoryPopout() {
+ if (!clipboardHistoryPopoutLoader)
+ return;
+ clipboardHistoryPopout = null;
+ clipboardHistoryPopoutLoader.active = false;
+ }
+
+ function unloadLayoutPopout() {
+ if (!layoutPopoutLoader)
+ return;
+ layoutPopout = null;
+ layoutPopoutLoader.active = false;
+ }
+
+ property bool _dankLauncherV2WantsOpen: false
+ property bool _dankLauncherV2WantsToggle: false
+ property string _dankLauncherV2PendingQuery: ""
+ property string _dankLauncherV2PendingMode: ""
+
+ function openDankLauncherV2() {
+ if (dankLauncherV2Modal) {
+ dankLauncherV2Modal.show();
+ } else if (dankLauncherV2ModalLoader) {
+ _dankLauncherV2WantsOpen = true;
+ _dankLauncherV2WantsToggle = false;
+ dankLauncherV2ModalLoader.active = true;
+ }
+ }
+
+ function openDankLauncherV2WithQuery(query: string) {
+ if (dankLauncherV2Modal) {
+ dankLauncherV2Modal.showWithQuery(query);
+ } else if (dankLauncherV2ModalLoader) {
+ _dankLauncherV2PendingQuery = query;
+ _dankLauncherV2WantsOpen = true;
+ _dankLauncherV2WantsToggle = false;
+ dankLauncherV2ModalLoader.active = true;
+ }
+ }
+
+ function openDankLauncherV2WithMode(mode: string) {
+ if (dankLauncherV2Modal) {
+ dankLauncherV2Modal.showWithMode(mode);
+ } else if (dankLauncherV2ModalLoader) {
+ _dankLauncherV2PendingMode = mode;
+ _dankLauncherV2WantsOpen = true;
+ _dankLauncherV2WantsToggle = false;
+ dankLauncherV2ModalLoader.active = true;
+ }
+ }
+
+ function closeDankLauncherV2() {
+ dankLauncherV2Modal?.hide();
+ }
+
+ function unloadDankLauncherV2() {
+ if (dankLauncherV2ModalLoader) {
+ dankLauncherV2Modal = null;
+ dankLauncherV2ModalLoader.active = false;
+ }
+ }
+
+ function toggleDankLauncherV2() {
+ if (dankLauncherV2Modal) {
+ dankLauncherV2Modal.toggle();
+ } else if (dankLauncherV2ModalLoader) {
+ _dankLauncherV2WantsToggle = true;
+ _dankLauncherV2WantsOpen = false;
+ dankLauncherV2ModalLoader.active = true;
+ }
+ }
+
+ function toggleDankLauncherV2WithMode(mode: string) {
+ if (dankLauncherV2Modal) {
+ dankLauncherV2Modal.toggleWithMode(mode);
+ } else if (dankLauncherV2ModalLoader) {
+ _dankLauncherV2PendingMode = mode;
+ _dankLauncherV2WantsToggle = true;
+ _dankLauncherV2WantsOpen = false;
+ dankLauncherV2ModalLoader.active = true;
+ }
+ }
+
+ function toggleDankLauncherV2WithQuery(query: string) {
+ if (dankLauncherV2Modal) {
+ dankLauncherV2Modal.toggleWithQuery(query);
+ } else if (dankLauncherV2ModalLoader) {
+ _dankLauncherV2PendingQuery = query;
+ _dankLauncherV2WantsOpen = true;
+ _dankLauncherV2WantsToggle = false;
+ dankLauncherV2ModalLoader.active = true;
+ }
+ }
+
+ function _onDankLauncherV2ModalLoaded() {
+ if (_dankLauncherV2WantsOpen) {
+ _dankLauncherV2WantsOpen = false;
+ if (_dankLauncherV2PendingQuery) {
+ dankLauncherV2Modal?.showWithQuery(_dankLauncherV2PendingQuery);
+ _dankLauncherV2PendingQuery = "";
+ } else if (_dankLauncherV2PendingMode) {
+ dankLauncherV2Modal?.showWithMode(_dankLauncherV2PendingMode);
+ _dankLauncherV2PendingMode = "";
+ } else {
+ dankLauncherV2Modal?.show();
+ }
+ return;
+ }
+ if (_dankLauncherV2WantsToggle) {
+ _dankLauncherV2WantsToggle = false;
+ if (_dankLauncherV2PendingMode) {
+ dankLauncherV2Modal?.toggleWithMode(_dankLauncherV2PendingMode);
+ _dankLauncherV2PendingMode = "";
+ } else {
+ dankLauncherV2Modal?.toggle();
+ }
+ }
+ }
+
+ function openPowerMenu() {
+ powerMenuModal?.openCentered();
+ }
+
+ function closePowerMenu() {
+ powerMenuModal?.close();
+ }
+
+ function togglePowerMenu() {
+ if (powerMenuModal) {
+ if (powerMenuModal.shouldBeVisible) {
+ powerMenuModal.close();
+ } else {
+ powerMenuModal.openCentered();
+ }
+ }
+ }
+
+ function showProcessListModal() {
+ if (processListModal) {
+ processListModal.show();
+ } else if (processListModalLoader) {
+ processListModalLoader.active = true;
+ Qt.callLater(() => processListModal?.show());
+ }
+ }
+
+ function hideProcessListModal() {
+ processListModal?.hide();
+ }
+
+ function toggleProcessListModal() {
+ if (processListModal) {
+ processListModal.toggle();
+ } else if (processListModalLoader) {
+ processListModalLoader.active = true;
+ Qt.callLater(() => processListModal?.show());
+ }
+ }
+
+ function showColorPicker() {
+ colorPickerModal?.show();
+ }
+
+ function hideColorPicker() {
+ colorPickerModal?.close();
+ }
+
+ function showNotificationModal() {
+ notificationModal?.show();
+ }
+
+ function hideNotificationModal() {
+ notificationModal?.close();
+ }
+
+ function showWifiPasswordModal(ssid) {
+ if (wifiPasswordModalLoader)
+ wifiPasswordModalLoader.active = true;
+ if (wifiPasswordModal)
+ wifiPasswordModal.show(ssid);
+ }
+
+ function showWifiQRCodeModal(ssid) {
+ if (wifiQRCodeModalLoader)
+ wifiQRCodeModalLoader.active = true;
+ if (wifiQRCodeModal)
+ wifiQRCodeModal.show(ssid);
+ }
+
+ function showHiddenNetworkModal() {
+ if (wifiPasswordModalLoader)
+ wifiPasswordModalLoader.active = true;
+ if (wifiPasswordModal)
+ wifiPasswordModal.showHidden();
+ }
+
+ function hideWifiPasswordModal() {
+ wifiPasswordModal?.hide();
+ }
+
+ function showNetworkInfoModal() {
+ networkInfoModal?.show();
+ }
+
+ function hideNetworkInfoModal() {
+ networkInfoModal?.close();
+ }
+
+ function openNotepad() {
+ if (notepadSlideouts.length > 0) {
+ notepadSlideouts[0]?.show();
+ }
+ }
+
+ function closeNotepad() {
+ if (notepadSlideouts.length > 0) {
+ notepadSlideouts[0]?.hide();
+ }
+ }
+
+ function toggleNotepad() {
+ if (notepadSlideouts.length > 0) {
+ notepadSlideouts[0]?.toggle();
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/PortalService.qml b/raveos-hyprland-theme/theme-data/dms/Services/PortalService.qml
new file mode 100644
index 0000000..73c0a9f
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/PortalService.qml
@@ -0,0 +1,316 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool accountsServiceAvailable: false
+ property string systemProfileImage: ""
+ property string profileImage: ""
+ property bool settingsPortalAvailable: false
+ property int systemColorScheme: 0
+
+ property bool freedeskAvailable: false
+ property string colorSchemeCommand: ""
+ property string pendingProfileImage: ""
+
+ readonly property string socketPath: Quickshell.env("DMS_SOCKET")
+
+ function init() {
+ }
+
+ function getSystemProfileImage() {
+ if (!freedeskAvailable)
+ return;
+ const username = Quickshell.env("USER");
+ if (!username)
+ return;
+ DMSService.sendRequest("freedesktop.accounts.getUserIconFile", {
+ "username": username
+ }, response => {
+ if (response.result && response.result.success) {
+ const iconFile = response.result.value || "";
+ if (iconFile && iconFile !== "" && iconFile !== "/var/lib/AccountsService/icons/") {
+ systemProfileImage = iconFile;
+ if (!profileImage || profileImage === "") {
+ profileImage = iconFile;
+ }
+ }
+ }
+ });
+ }
+
+ function getUserProfileImage(username) {
+ if (!username) {
+ profileImage = "";
+ return;
+ }
+ if (Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true") {
+ profileImage = "";
+ return;
+ }
+
+ if (!freedeskAvailable) {
+ profileImage = "";
+ return;
+ }
+
+ DMSService.sendRequest("freedesktop.accounts.getUserIconFile", {
+ "username": username
+ }, response => {
+ if (response.result && response.result.success) {
+ const icon = response.result.value || "";
+ if (icon && icon !== "" && icon !== "/var/lib/AccountsService/icons/") {
+ profileImage = icon;
+ } else {
+ profileImage = "";
+ }
+ } else {
+ profileImage = "";
+ }
+ });
+ }
+
+ function setProfileImage(imagePath) {
+ if (accountsServiceAvailable) {
+ pendingProfileImage = imagePath;
+ setSystemProfileImage(imagePath || "");
+ } else {
+ profileImage = imagePath;
+ }
+ }
+
+ function getSystemColorScheme() {
+ if (typeof SettingsData !== "undefined" && SettingsData.syncModeWithPortal === false) {
+ return;
+ }
+ if (!freedeskAvailable)
+ return;
+ DMSService.sendRequest("freedesktop.settings.getColorScheme", null, response => {
+ if (response.result) {
+ systemColorScheme = response.result.value || 0;
+ }
+ });
+ }
+
+ function setLightMode(isLightMode) {
+ if (typeof SettingsData !== "undefined" && SettingsData.syncModeWithPortal === false) {
+ return;
+ }
+ setSystemColorScheme(isLightMode);
+ }
+
+ function setSystemColorScheme(isLightMode) {
+ if (typeof SettingsData !== "undefined" && SettingsData.syncModeWithPortal === false) {
+ return;
+ }
+
+ const targetScheme = isLightMode ? "default" : "prefer-dark";
+
+ if (colorSchemeCommand === "gsettings") {
+ Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", targetScheme]);
+ }
+ if (colorSchemeCommand === "dconf") {
+ Quickshell.execDetached(["dconf", "write", "/org/gnome/desktop/interface/color-scheme", `'${targetScheme}'`]);
+ }
+ }
+
+ function setSystemIconTheme(themeName) {
+ if (!settingsPortalAvailable || !freedeskAvailable)
+ return;
+ DMSService.sendRequest("freedesktop.settings.setIconTheme", {
+ "iconTheme": themeName
+ }, response => {
+ if (response.error) {
+ console.warn("PortalService: Failed to set icon theme:", response.error);
+ }
+ });
+ }
+
+ function setSystemProfileImage(imagePath) {
+ if (!accountsServiceAvailable || !freedeskAvailable)
+ return;
+ DMSService.sendRequest("freedesktop.accounts.setIconFile", {
+ "path": imagePath || ""
+ }, response => {
+ if (response.error) {
+ console.warn("PortalService: Failed to set icon file:", response.error);
+
+ const errorMsg = response.error.toString();
+ let userMessage = I18n.tr("Failed to set profile image");
+
+ if (errorMsg.includes("too large")) {
+ userMessage = I18n.tr("Profile image is too large. Please use a smaller image.");
+ } else if (errorMsg.includes("permission")) {
+ userMessage = I18n.tr("Permission denied to set profile image.");
+ } else if (errorMsg.includes("not found") || errorMsg.includes("does not exist")) {
+ userMessage = I18n.tr("Selected image file not found.");
+ } else {
+ userMessage = I18n.tr("Failed to set profile image: %1").arg(errorMsg.split(":").pop().trim());
+ }
+
+ Quickshell.execDetached(["notify-send", "-u", "normal", "-a", "DMS", "-i", "error", I18n.tr("Profile Image Error"), userMessage]);
+
+ pendingProfileImage = "";
+ } else {
+ profileImage = pendingProfileImage;
+ pendingProfileImage = "";
+ Qt.callLater(() => getSystemProfileImage());
+ }
+ });
+ }
+
+ Component.onCompleted: {
+ if (socketPath && socketPath.length > 0) {
+ checkDMSCapabilities();
+ } else {
+ console.info("PortalService: DMS_SOCKET not set");
+ }
+ colorSchemeDetector.running = true;
+ }
+
+ Connections {
+ target: DMSService
+
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkDMSCapabilities();
+ }
+ }
+ }
+
+ Connections {
+ target: DMSService
+ enabled: DMSService.isConnected
+
+ function onCapabilitiesChanged() {
+ checkDMSCapabilities();
+ }
+ }
+
+ function checkDMSCapabilities() {
+ if (!DMSService.isConnected) {
+ return;
+ }
+
+ if (DMSService.capabilities.length === 0) {
+ return;
+ }
+
+ freedeskAvailable = DMSService.capabilities.includes("freedesktop");
+ if (freedeskAvailable) {
+ checkAccountsService();
+ checkSettingsPortal();
+ } else {
+ console.info("PortalService: freedesktop capability not available in DMS");
+ }
+ }
+
+ function checkAccountsService() {
+ if (!freedeskAvailable)
+ return;
+ DMSService.sendRequest("freedesktop.getState", null, response => {
+ if (response.result && response.result.accounts) {
+ accountsServiceAvailable = response.result.accounts.available || false;
+ if (accountsServiceAvailable) {
+ getSystemProfileImage();
+ }
+ }
+ });
+ }
+
+ function checkSettingsPortal() {
+ if (!freedeskAvailable)
+ return;
+ DMSService.sendRequest("freedesktop.getState", null, response => {
+ if (response.result && response.result.settings) {
+ settingsPortalAvailable = response.result.settings.available || false;
+ if (settingsPortalAvailable && SettingsData.syncModeWithPortal) {
+ getSystemColorScheme();
+ }
+ }
+ });
+ }
+
+ function getGreeterUserProfileImage(username) {
+ if (!username) {
+ profileImage = "";
+ return;
+ }
+ userProfileCheckProcess.command = ["bash", "-c", `uid=$(id -u ${username} 2>/dev/null) && [ -n "$uid" ] && dbus-send --system --print-reply --dest=org.freedesktop.Accounts /org/freedesktop/Accounts/User$uid org.freedesktop.DBus.Properties.Get string:org.freedesktop.Accounts.User string:IconFile 2>/dev/null | grep -oP 'string "\\K[^"]+' || echo ""`];
+ userProfileCheckProcess.running = true;
+ }
+
+ Process {
+ id: userProfileCheckProcess
+ command: []
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const trimmed = text.trim();
+ if (trimmed && trimmed !== "" && !trimmed.includes("Error") && trimmed !== "/var/lib/AccountsService/icons/") {
+ root.profileImage = trimmed;
+ } else {
+ root.profileImage = "";
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ root.profileImage = "";
+ }
+ }
+ }
+
+ Process {
+ id: colorSchemeDetector
+ command: ["bash", "-c", "command -v gsettings || command -v dconf"]
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const cmd = text.trim();
+ if (cmd.includes("gsettings")) {
+ root.colorSchemeCommand = "gsettings";
+ } else if (cmd.includes("dconf")) {
+ root.colorSchemeCommand = "dconf";
+ }
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "profile"
+
+ function getImage(): string {
+ return root.profileImage;
+ }
+
+ function setImage(path: string): string {
+ if (!path) {
+ return "ERROR: No path provided";
+ }
+
+ const absolutePath = path.startsWith("/") ? path : `${StandardPaths.writableLocation(StandardPaths.HomeLocation)}/${path}`;
+
+ try {
+ root.setProfileImage(absolutePath);
+ return "SUCCESS: Profile image set to " + absolutePath;
+ } catch (e) {
+ return "ERROR: Failed to set profile image: " + e.toString();
+ }
+ }
+
+ function clearImage(): string {
+ root.setProfileImage("");
+ return "SUCCESS: Profile image cleared";
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/PowerProfileWatcher.qml b/raveos-hyprland-theme/theme-data/dms/Services/PowerProfileWatcher.qml
new file mode 100644
index 0000000..bab3517
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/PowerProfileWatcher.qml
@@ -0,0 +1,36 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Services.UPower
+
+Singleton {
+ id: root
+
+ property int currentProfile: -1
+ property int previousProfile: -1
+
+ signal profileChanged(int profile)
+
+ Connections {
+ target: typeof PowerProfiles !== "undefined" ? PowerProfiles : null
+
+ function onProfileChanged() {
+ if (typeof PowerProfiles !== "undefined") {
+ root.previousProfile = root.currentProfile;
+ root.currentProfile = PowerProfiles.profile;
+ if (root.previousProfile !== -1) {
+ root.profileChanged(root.currentProfile);
+ }
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ if (typeof PowerProfiles !== "undefined") {
+ root.currentProfile = PowerProfiles.profile;
+ root.previousProfile = PowerProfiles.profile;
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/PrivacyService.qml b/raveos-hyprland-theme/theme-data/dms/Services/PrivacyService.qml
new file mode 100644
index 0000000..4908862
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/PrivacyService.qml
@@ -0,0 +1,155 @@
+pragma Singleton
+
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Pipewire
+import qs.Services
+
+Singleton {
+ id: root
+
+ readonly property bool microphoneActive: {
+ if (!Pipewire.ready || !Pipewire.nodes?.values) {
+ return false
+ }
+
+ for (let i = 0; i < Pipewire.nodes.values.length; i++) {
+ const node = Pipewire.nodes.values[i]
+ if (!node) {
+ continue
+ }
+
+ if ((node.type & PwNodeType.AudioInStream) === PwNodeType.AudioInStream) {
+ if (!looksLikeSystemVirtualMic(node)) {
+ if (node.audio && node.audio.muted) {
+ return false
+ }
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ PwObjectTracker {
+ objects: Pipewire.nodes.values.filter(node => !node.isStream)
+ }
+
+ readonly property bool cameraActive: {
+ if (!Pipewire.ready || !Pipewire.nodes?.values) {
+ return false
+ }
+
+ for (let i = 0; i < Pipewire.nodes.values.length; i++) {
+ const node = Pipewire.nodes.values[i]
+ if (!node || !node.ready) {
+ continue
+ }
+
+ if (node.properties && node.properties["media.class"] === "Stream/Input/Video") {
+ if (node.properties["stream.is-live"] === "true") {
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ readonly property bool screensharingActive: {
+ if (CompositorService.isNiri && NiriService.hasActiveCast) {
+ return true
+ }
+
+ if (!Pipewire.ready || !Pipewire.nodes?.values) {
+ return false
+ }
+
+ for (let i = 0; i < Pipewire.nodes.values.length; i++) {
+ const node = Pipewire.nodes.values[i]
+ if (!node || !node.ready) {
+ continue
+ }
+
+ if ((node.type & PwNodeType.VideoSource) === PwNodeType.VideoSource) {
+ if (looksLikeScreencast(node)) {
+ return true
+ }
+ }
+
+ if (node.properties && node.properties["media.class"] === "Stream/Output/Video") {
+ if (looksLikeScreencast(node)) {
+ return true
+ }
+ }
+
+ if (node.properties && node.properties["media.class"] === "Stream/Input/Audio") {
+ const mediaName = (node.properties["media.name"] || "").toLowerCase()
+ const appName = (node.properties["application.name"] || "").toLowerCase()
+
+ if (mediaName.includes("desktop") || appName.includes("screen") || appName === "obs") {
+ if (node.properties["stream.is-live"] === "true") {
+ if (node.audio && node.audio.muted) {
+ return false
+ }
+ return true
+ }
+ }
+ }
+ }
+ return false
+ }
+
+ readonly property bool anyPrivacyActive: microphoneActive || cameraActive || screensharingActive
+
+ function looksLikeSystemVirtualMic(node) {
+ if (!node) {
+ return false
+ }
+ const name = (node.name || "").toLowerCase()
+ const mediaName = (node.properties && node.properties["media.name"] || "").toLowerCase()
+ const appName = (node.properties && node.properties["application.name"] || "").toLowerCase()
+ const combined = name + " " + mediaName + " " + appName
+ return /cava|monitor|system/.test(combined)
+ }
+
+ function looksLikeScreencast(node) {
+ if (!node) {
+ return false
+ }
+ const appName = (node.properties && node.properties["application.name"] || "").toLowerCase()
+ const nodeName = (node.name || "").toLowerCase()
+ const mediaName = (node.properties && node.properties["media.name"] || "").toLowerCase()
+ const combined = appName + " " + nodeName + " " + mediaName
+ return /xdg-desktop-portal|xdpw|screencast|screen-cast|screen|gnome shell|kwin|obs|niri/.test(combined)
+ }
+
+ function getMicrophoneStatus() {
+ return microphoneActive ? "active" : "inactive"
+ }
+
+ function getCameraStatus() {
+ return cameraActive ? "active" : "inactive"
+ }
+
+ function getScreensharingStatus() {
+ return screensharingActive ? "active" : "inactive"
+ }
+
+ function getPrivacySummary() {
+ const active = []
+ if (microphoneActive) {
+ active.push("microphone")
+ }
+ if (cameraActive) {
+ active.push("camera")
+ }
+ if (screensharingActive) {
+ active.push("screensharing")
+ }
+
+ return active.length > 0 ? `Privacy active: ${active.join(", ")}` : "No privacy concerns detected"
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/SessionService.qml b/raveos-hyprland-theme/theme-data/dms/Services/SessionService.qml
new file mode 100644
index 0000000..7f5a1ed
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/SessionService.qml
@@ -0,0 +1,619 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Hyprland
+import Quickshell.I3
+import Quickshell.Wayland
+import qs.Common
+
+Singleton {
+ id: root
+
+ property bool hasUwsm: false
+ property bool isElogind: false
+ property bool hibernateSupported: false
+ property bool inhibitorAvailable: true
+ property bool idleInhibited: false
+ property string inhibitReason: "Keep system awake"
+ property string nvidiaCommand: ""
+
+ readonly property bool nativeInhibitorAvailable: {
+ try {
+ return typeof IdleInhibitor !== "undefined";
+ } catch (e) {
+ return false;
+ }
+ }
+
+ property bool loginctlAvailable: false
+ property bool wtypeAvailable: false
+ property string sessionId: ""
+ property string sessionPath: ""
+ property bool locked: false
+ property bool active: false
+ property bool idleHint: false
+ property bool lockedHint: false
+ property bool preparingForSleep: false
+ property string sessionType: ""
+ property string userName: ""
+ property string seat: ""
+ property string display: ""
+
+ signal sessionLocked
+ signal sessionUnlocked
+ signal sessionResumed
+ signal loginctlStateChanged
+
+ property bool stateInitialized: false
+
+ readonly property string socketPath: Quickshell.env("DMS_SOCKET")
+
+ Timer {
+ id: sessionInitTimer
+ interval: 200
+ running: true
+ repeat: false
+ onTriggered: {
+ detectElogindProcess.running = true;
+ detectHibernateProcess.running = true;
+ detectPrimeRunProcess.running = true;
+ detectWtypeProcess.running = true;
+ console.info("SessionService: Native inhibitor available:", nativeInhibitorAvailable);
+ if (!SettingsData.loginctlLockIntegration) {
+ console.log("SessionService: loginctl lock integration disabled by user");
+ return;
+ }
+ if (socketPath && socketPath.length > 0) {
+ checkDMSCapabilities();
+ } else {
+ console.log("SessionService: DMS_SOCKET not set");
+ }
+ }
+ }
+
+ Process {
+ id: detectUwsmProcess
+ running: false
+ command: ["which", "uwsm"]
+
+ onExited: function (exitCode) {
+ hasUwsm = (exitCode === 0);
+ }
+ }
+
+ Process {
+ id: detectElogindProcess
+ running: false
+ command: ["sh", "-c", "ps -eo comm= | grep -E '^(elogind|elogind-daemon)$'"]
+
+ onExited: function (exitCode) {
+ console.log("SessionService: Elogind detection exited with code", exitCode);
+ isElogind = (exitCode === 0);
+ }
+ }
+
+ Process {
+ id: detectHibernateProcess
+ running: false
+ command: ["grep", "-q", "disk", "/sys/power/state"]
+
+ onExited: function (exitCode) {
+ hibernateSupported = (exitCode === 0);
+ }
+ }
+
+ Process {
+ id: hibernateProcess
+ running: false
+
+ property string errorOutput: ""
+
+ stderr: SplitParser {
+ splitMarker: "\n"
+ onRead: data => hibernateProcess.errorOutput += data.trim()
+ }
+
+ onExited: function (exitCode) {
+ if (exitCode === 0) {
+ errorOutput = "";
+ return;
+ }
+ ToastService.showError("Hibernate failed", errorOutput);
+ errorOutput = "";
+ }
+ }
+
+ Process {
+ id: detectWtypeProcess
+ running: false
+ command: ["which", "wtype"]
+ onExited: exitCode => {
+ wtypeAvailable = (exitCode === 0);
+ }
+ }
+
+ Process {
+ id: detectPrimeRunProcess
+ running: false
+ command: ["which", "prime-run"]
+
+ onExited: function (exitCode) {
+ if (exitCode === 0) {
+ nvidiaCommand = "prime-run";
+ } else {
+ detectNvidiaOffloadProcess.running = true;
+ }
+ }
+ }
+
+ Process {
+ id: detectNvidiaOffloadProcess
+ running: false
+ command: ["which", "nvidia-offload"]
+
+ onExited: function (exitCode) {
+ if (exitCode === 0) {
+ nvidiaCommand = "nvidia-offload";
+ }
+ }
+ }
+
+ Process {
+ id: uwsmLogout
+ command: ["uwsm", "stop"]
+ running: false
+
+ stdout: SplitParser {
+ splitMarker: "\n"
+ onRead: data => {
+ if (data.trim().toLowerCase().includes("not running")) {
+ _logout();
+ }
+ }
+ }
+
+ onExited: function (exitCode) {
+ if (exitCode === 0) {
+ return;
+ }
+ _logout();
+ }
+ }
+
+ function escapeShellArg(arg) {
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
+ }
+
+ function needsShellExecution(prefix) {
+ if (!prefix || prefix.length === 0)
+ return false;
+ return /[;&|<>()$`\\"']/.test(prefix);
+ }
+
+ function parseEnvVars(envVarsStr) {
+ if (!envVarsStr || envVarsStr.trim().length === 0)
+ return {};
+ const envObj = {};
+ const pairs = envVarsStr.trim().split(/\s+/);
+ for (const pair of pairs) {
+ const eqIndex = pair.indexOf("=");
+ if (eqIndex > 0) {
+ const key = pair.substring(0, eqIndex);
+ const value = pair.substring(eqIndex + 1);
+ envObj[key] = value;
+ }
+ }
+ return envObj;
+ }
+
+ function launchDesktopEntry(desktopEntry, useNvidia) {
+ let cmd = desktopEntry.command;
+
+ const appId = desktopEntry.id || desktopEntry.execString || desktopEntry.exec || "";
+ const override = SessionData.getAppOverride(appId);
+
+ const dgpu = useNvidia || (override?.launchOnDgpu && nvidiaCommand);
+ if (dgpu && nvidiaCommand)
+ cmd = [nvidiaCommand].concat(cmd);
+
+ if (override?.extraFlags) {
+ const extraArgs = override.extraFlags.trim().split(/\s+/).filter(arg => arg.length > 0);
+ cmd = cmd.concat(extraArgs);
+ }
+
+ const userPrefix = SettingsData.launchPrefix?.trim() || "";
+ const defaultPrefix = Quickshell.env("DMS_DEFAULT_LAUNCH_PREFIX") || "";
+ const prefix = userPrefix.length > 0 ? userPrefix : defaultPrefix;
+ const workDir = desktopEntry.workingDirectory || Quickshell.env("HOME");
+ const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {};
+
+ const overrideEnv = override?.envVars ? parseEnvVars(override.envVars) : {};
+ const finalEnv = Object.assign({}, cursorEnv, overrideEnv);
+
+ if (desktopEntry.runInTerminal) {
+ const terminal = Quickshell.env("TERMINAL") || "xterm";
+ const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" ");
+ const shellCmd = prefix.length > 0 ? `${prefix} ${escapedCmd}` : escapedCmd;
+ Quickshell.execDetached({
+ command: [terminal, "-e", "sh", "-c", shellCmd],
+ workingDirectory: workDir,
+ environment: finalEnv
+ });
+ return;
+ }
+
+ if (prefix.length > 0 && needsShellExecution(prefix)) {
+ const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" ");
+ Quickshell.execDetached({
+ command: ["sh", "-c", `${prefix} ${escapedCmd}`],
+ workingDirectory: workDir,
+ environment: finalEnv
+ });
+ return;
+ }
+
+ if (prefix.length > 0)
+ cmd = prefix.split(" ").concat(cmd);
+
+ Quickshell.execDetached({
+ command: cmd,
+ workingDirectory: workDir,
+ environment: finalEnv
+ });
+ }
+
+ function launchDesktopAction(desktopEntry, action, useNvidia) {
+ let cmd = action.command;
+
+ const appId = desktopEntry.id || desktopEntry.execString || desktopEntry.exec || "";
+ const override = SessionData.getAppOverride(appId);
+ const dgpu = useNvidia || (override?.launchOnDgpu && nvidiaCommand);
+ if (dgpu && nvidiaCommand)
+ cmd = [nvidiaCommand].concat(cmd);
+
+ const userPrefix = SettingsData.launchPrefix?.trim() || "";
+ const defaultPrefix = Quickshell.env("DMS_DEFAULT_LAUNCH_PREFIX") || "";
+ const prefix = userPrefix.length > 0 ? userPrefix : defaultPrefix;
+ const workDir = desktopEntry.workingDirectory || Quickshell.env("HOME");
+ const cursorEnv = typeof SettingsData.getCursorEnvironment === "function" ? SettingsData.getCursorEnvironment() : {};
+
+ if (prefix.length > 0 && needsShellExecution(prefix)) {
+ const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" ");
+ Quickshell.execDetached({
+ command: ["sh", "-c", `${prefix} ${escapedCmd}`],
+ workingDirectory: workDir,
+ environment: cursorEnv
+ });
+ return;
+ }
+
+ if (prefix.length > 0)
+ cmd = prefix.split(" ").concat(cmd);
+
+ Quickshell.execDetached({
+ command: cmd,
+ workingDirectory: workDir,
+ environment: cursorEnv
+ });
+ }
+
+ // * Session management
+ function logout() {
+ if (hasUwsm) {
+ uwsmLogout.running = true;
+ }
+ _logout();
+ }
+
+ function _logout() {
+ if (SettingsData.customPowerActionLogout.length === 0) {
+ if (CompositorService.isNiri) {
+ NiriService.quit();
+ return;
+ }
+
+ if (CompositorService.isDwl) {
+ DwlService.quit();
+ return;
+ }
+
+ if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
+ try {
+ I3.dispatch("exit");
+ } catch (_) {}
+ return;
+ }
+
+ Hyprland.dispatch("exit");
+ } else {
+ Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionLogout]);
+ }
+ }
+
+ function suspend() {
+ if (SettingsData.customPowerActionSuspend.length === 0) {
+ Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "suspend"]);
+ } else {
+ Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionSuspend]);
+ }
+ }
+
+ function hibernate() {
+ hibernateProcess.errorOutput = "";
+ if (SettingsData.customPowerActionHibernate.length > 0) {
+ hibernateProcess.command = ["sh", "-c", SettingsData.customPowerActionHibernate];
+ } else {
+ hibernateProcess.command = [isElogind ? "loginctl" : "systemctl", "hibernate"];
+ }
+ hibernateProcess.running = true;
+ }
+
+ function suspendThenHibernate() {
+ Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "suspend-then-hibernate"]);
+ }
+
+ function suspendWithBehavior(behavior) {
+ if (behavior === SettingsData.SuspendBehavior.Hibernate) {
+ hibernate();
+ } else if (behavior === SettingsData.SuspendBehavior.SuspendThenHibernate) {
+ suspendThenHibernate();
+ } else {
+ suspend();
+ }
+ }
+
+ function reboot() {
+ if (SettingsData.customPowerActionReboot.length === 0) {
+ Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "reboot"]);
+ } else {
+ Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionReboot]);
+ }
+ }
+
+ function poweroff() {
+ if (SettingsData.customPowerActionPowerOff.length === 0) {
+ Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "poweroff"]);
+ } else {
+ Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionPowerOff]);
+ }
+ }
+
+ // * Idle Inhibitor
+ signal inhibitorChanged
+
+ function enableIdleInhibit() {
+ if (idleInhibited) {
+ return;
+ }
+ console.log("SessionService: Enabling idle inhibit (native:", nativeInhibitorAvailable, ")");
+ idleInhibited = true;
+ inhibitorChanged();
+ }
+
+ function disableIdleInhibit() {
+ if (!idleInhibited) {
+ return;
+ }
+ console.log("SessionService: Disabling idle inhibit (native:", nativeInhibitorAvailable, ")");
+ idleInhibited = false;
+ inhibitorChanged();
+ }
+
+ function toggleIdleInhibit() {
+ if (idleInhibited) {
+ disableIdleInhibit();
+ } else {
+ enableIdleInhibit();
+ }
+ }
+
+ function setInhibitReason(reason) {
+ inhibitReason = reason;
+
+ if (idleInhibited && !nativeInhibitorAvailable) {
+ const wasActive = idleInhibited;
+ idleInhibited = false;
+
+ Qt.callLater(() => {
+ if (wasActive) {
+ idleInhibited = true;
+ }
+ });
+ }
+ }
+
+ Process {
+ id: idleInhibitProcess
+
+ command: {
+ if (!idleInhibited || nativeInhibitorAvailable) {
+ return ["true"];
+ }
+
+ console.log("SessionService: Starting systemd/elogind inhibit process");
+ return [isElogind ? "elogind-inhibit" : "systemd-inhibit", "--what=idle", "--who=quickshell", `--why=${inhibitReason}`, "--mode=block", "sleep", "infinity"];
+ }
+
+ running: idleInhibited && !nativeInhibitorAvailable
+
+ onRunningChanged: {
+ console.log("SessionService: Inhibit process running:", running, "(native:", nativeInhibitorAvailable, ")");
+ }
+
+ onExited: function (exitCode) {
+ if (idleInhibited && exitCode !== 0 && !nativeInhibitorAvailable) {
+ console.warn("SessionService: Inhibitor process crashed with exit code:", exitCode);
+ idleInhibited = false;
+ ToastService.showWarning("Idle inhibitor failed");
+ }
+ }
+ }
+
+ Connections {
+ target: DMSService
+
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkDMSCapabilities();
+ }
+ }
+
+ function onCapabilitiesReceived() {
+ syncSleepInhibitor();
+ }
+ }
+
+ Connections {
+ target: DMSService
+ enabled: DMSService.isConnected
+
+ function onCapabilitiesChanged() {
+ checkDMSCapabilities();
+ }
+ }
+
+ Connections {
+ target: SettingsData
+
+ function onLoginctlLockIntegrationChanged() {
+ if (SettingsData.loginctlLockIntegration) {
+ if (socketPath && socketPath.length > 0 && loginctlAvailable) {
+ if (!stateInitialized) {
+ stateInitialized = true;
+ getLoginctlState();
+ syncLockBeforeSuspend();
+ }
+ }
+ } else {
+ stateInitialized = false;
+ }
+ syncSleepInhibitor();
+ }
+
+ function onLockBeforeSuspendChanged() {
+ if (SettingsData.loginctlLockIntegration) {
+ syncLockBeforeSuspend();
+ }
+ syncSleepInhibitor();
+ }
+ }
+
+ Connections {
+ target: DMSService
+ enabled: SettingsData.loginctlLockIntegration
+
+ function onLoginctlStateUpdate(data) {
+ updateLoginctlState(data);
+ }
+
+ function onLoginctlEvent(event) {
+ handleLoginctlEvent(event);
+ }
+ }
+
+ function checkDMSCapabilities() {
+ if (!DMSService.isConnected) {
+ return;
+ }
+
+ if (DMSService.capabilities.length === 0) {
+ return;
+ }
+
+ if (DMSService.capabilities.includes("loginctl")) {
+ loginctlAvailable = true;
+ if (SettingsData.loginctlLockIntegration && !stateInitialized) {
+ stateInitialized = true;
+ getLoginctlState();
+ syncLockBeforeSuspend();
+ }
+ } else {
+ loginctlAvailable = false;
+ console.log("SessionService: loginctl capability not available in DMS");
+ }
+ }
+
+ function getLoginctlState() {
+ if (!loginctlAvailable)
+ return;
+ DMSService.sendRequest("loginctl.getState", null, response => {
+ if (response.result) {
+ updateLoginctlState(response.result);
+ }
+ });
+ }
+
+ function syncLockBeforeSuspend() {
+ if (!loginctlAvailable)
+ return;
+ DMSService.sendRequest("loginctl.setLockBeforeSuspend", {
+ enabled: SettingsData.lockBeforeSuspend
+ }, response => {
+ if (response.error) {
+ console.warn("SessionService: Failed to sync lock before suspend:", response.error);
+ } else {
+ console.log("SessionService: Synced lock before suspend:", SettingsData.lockBeforeSuspend);
+ }
+ });
+ }
+
+ function syncSleepInhibitor() {
+ if (!loginctlAvailable)
+ return;
+ if (!DMSService.apiVersion || DMSService.apiVersion < 4)
+ return;
+ DMSService.sendRequest("loginctl.setSleepInhibitorEnabled", {
+ enabled: SettingsData.loginctlLockIntegration && SettingsData.lockBeforeSuspend
+ }, response => {
+ if (response.error) {
+ console.warn("SessionService: Failed to sync sleep inhibitor:", response.error);
+ } else {
+ console.log("SessionService: Synced sleep inhibitor:", SettingsData.loginctlLockIntegration);
+ }
+ });
+ }
+
+ function updateLoginctlState(state) {
+ const wasLocked = locked;
+ const wasSleeping = preparingForSleep;
+
+ sessionId = state.sessionId || "";
+ sessionPath = state.sessionPath || "";
+ locked = state.locked || false;
+ active = state.active || false;
+ idleHint = state.idleHint || false;
+ lockedHint = state.lockedHint || false;
+ preparingForSleep = state.preparingForSleep || false;
+ sessionType = state.sessionType || "";
+ userName = state.userName || "";
+ seat = state.seat || "";
+ display = state.display || "";
+
+ if (locked && !wasLocked) {
+ sessionLocked();
+ } else if (!locked && wasLocked) {
+ sessionUnlocked();
+ }
+
+ if (wasSleeping && !preparingForSleep) {
+ sessionResumed();
+ }
+
+ loginctlStateChanged();
+ }
+
+ function handleLoginctlEvent(event) {
+ if (event.event === "Lock") {
+ locked = true;
+ lockedHint = true;
+ sessionLocked();
+ } else if (event.event === "Unlock") {
+ locked = false;
+ lockedHint = false;
+ sessionUnlocked();
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/SettingsSearchService.qml b/raveos-hyprland-theme/theme-data/dms/Services/SettingsSearchService.qml
new file mode 100644
index 0000000..97e5d11
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/SettingsSearchService.qml
@@ -0,0 +1,240 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property string query: ""
+ property var results: []
+ property string targetSection: ""
+ property string highlightSection: ""
+ property var registeredCards: ({})
+ property var settingsIndex: []
+ property bool indexLoaded: false
+ property var _translatedCache: []
+
+ readonly property var conditionMap: ({
+ "isNiri": () => CompositorService.isNiri,
+ "isHyprland": () => CompositorService.isHyprland,
+ "isDwl": () => CompositorService.isDwl,
+ "keybindsAvailable": () => KeybindsService.available,
+ "soundsAvailable": () => AudioService.soundsAvailable,
+ "cupsAvailable": () => CupsService.cupsAvailable,
+ "networkNotLegacy": () => !NetworkService.usingLegacy,
+ "dmsConnected": () => DMSService.isConnected && DMSService.apiVersion >= 23,
+ "matugenAvailable": () => Theme.matugenAvailable
+ })
+
+ Component.onCompleted: indexFile.reload()
+
+ FileView {
+ id: indexFile
+ path: Qt.resolvedUrl("../translations/settings_search_index.json")
+ onLoaded: {
+ try {
+ root.settingsIndex = JSON.parse(text());
+ root.indexLoaded = true;
+ root._rebuildTranslationCache();
+ } catch (e) {
+ console.warn("SettingsSearchService: Failed to parse index:", e);
+ root.settingsIndex = [];
+ root._translatedCache = [];
+ }
+ }
+ onLoadFailed: error => console.warn("SettingsSearchService: Failed to load index:", error)
+ }
+
+ function registerCard(settingKey, item, flickable) {
+ if (!settingKey)
+ return;
+ registeredCards[settingKey] = {
+ item: item,
+ flickable: flickable
+ };
+ if (targetSection === settingKey)
+ scrollTimer.restart();
+ }
+
+ function unregisterCard(settingKey) {
+ if (!settingKey)
+ return;
+ let cards = registeredCards;
+ delete cards[settingKey];
+ registeredCards = cards;
+ }
+
+ function navigateToSection(section) {
+ targetSection = section;
+ if (registeredCards[section])
+ scrollTimer.restart();
+ }
+
+ function scrollToTarget() {
+ if (!targetSection)
+ return;
+ const entry = registeredCards[targetSection];
+ if (!entry || !entry.item || !entry.flickable)
+ return;
+ const flickable = entry.flickable;
+ const item = entry.item;
+ const contentItem = flickable.contentItem;
+
+ if (!contentItem)
+ return;
+ const mapped = item.mapToItem(contentItem, 0, 0);
+ const maxY = Math.max(0, flickable.contentHeight - flickable.height);
+ const targetY = Math.min(maxY, Math.max(0, mapped.y - 16));
+ flickable.contentY = targetY;
+
+ highlightSection = targetSection;
+ targetSection = "";
+ highlightTimer.restart();
+ }
+
+ function clearHighlight() {
+ highlightSection = "";
+ }
+
+ Timer {
+ id: scrollTimer
+ interval: 50
+ onTriggered: root.scrollToTarget()
+ }
+
+ Timer {
+ id: highlightTimer
+ interval: 2500
+ onTriggered: root.highlightSection = ""
+ }
+
+ function checkCondition(item) {
+ if (!item.conditionKey)
+ return true;
+ const condFn = conditionMap[item.conditionKey];
+ if (!condFn)
+ return true;
+ return condFn();
+ }
+
+ function translateItem(item) {
+ return {
+ section: item.section,
+ label: I18n.tr(item.label),
+ tabIndex: item.tabIndex,
+ category: I18n.tr(item.category),
+ keywords: item.keywords || [],
+ icon: item.icon || "settings",
+ description: item.description ? I18n.tr(item.description) : "",
+ conditionKey: item.conditionKey
+ };
+ }
+
+ function _rebuildTranslationCache() {
+ var cache = [];
+ for (var i = 0; i < settingsIndex.length; i++) {
+ var item = settingsIndex[i];
+ var t = translateItem(item);
+ cache.push({
+ section: t.section,
+ label: t.label,
+ tabIndex: t.tabIndex,
+ category: t.category,
+ keywords: t.keywords,
+ icon: t.icon,
+ description: t.description,
+ conditionKey: t.conditionKey,
+ labelLower: t.label.toLowerCase(),
+ categoryLower: t.category.toLowerCase()
+ });
+ }
+ _translatedCache = cache;
+ }
+
+ function search(text) {
+ query = text;
+ if (!text) {
+ results = [];
+ return;
+ }
+
+ var queryLower = text.toLowerCase().trim();
+ var queryWords = queryLower.split(/\s+/).filter(w => w.length > 0);
+ var scored = [];
+ var cache = _translatedCache;
+
+ for (var i = 0; i < cache.length; i++) {
+ var entry = cache[i];
+ if (!checkCondition(entry))
+ continue;
+
+ var labelLower = entry.labelLower;
+ var categoryLower = entry.categoryLower;
+ var score = 0;
+
+ if (labelLower === queryLower) {
+ score = 10000;
+ } else if (labelLower.startsWith(queryLower)) {
+ score = 5000;
+ } else if (labelLower.includes(queryLower)) {
+ score = 1000;
+ } else if (categoryLower.includes(queryLower)) {
+ score = 500;
+ }
+
+ if (score === 0) {
+ var keywords = entry.keywords;
+ for (var k = 0; k < keywords.length; k++) {
+ if (keywords[k].startsWith(queryLower)) {
+ score = 800;
+ break;
+ }
+ if (keywords[k].includes(queryLower) && score < 400) {
+ score = 400;
+ }
+ }
+ }
+
+ if (score === 0 && queryWords.length > 1) {
+ var allMatch = true;
+ for (var w = 0; w < queryWords.length; w++) {
+ var word = queryWords[w];
+ if (labelLower.includes(word))
+ continue;
+ var inKeywords = false;
+ for (var k = 0; k < entry.keywords.length; k++) {
+ if (entry.keywords[k].includes(word)) {
+ inKeywords = true;
+ break;
+ }
+ }
+ if (!inKeywords && !categoryLower.includes(word)) {
+ allMatch = false;
+ break;
+ }
+ }
+ if (allMatch)
+ score = 300;
+ }
+
+ if (score > 0) {
+ scored.push({
+ item: entry,
+ score: score
+ });
+ }
+ }
+
+ scored.sort((a, b) => b.score - a.score);
+ results = scored.slice(0, 15).map(s => s.item);
+ }
+
+ function clear() {
+ query = "";
+ results = [];
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/SystemUpdateService.qml b/raveos-hyprland-theme/theme-data/dms/Services/SystemUpdateService.qml
new file mode 100644
index 0000000..53ca7ad
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/SystemUpdateService.qml
@@ -0,0 +1,396 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+
+Singleton {
+ id: root
+
+ property int refCount: 0
+ property var availableUpdates: []
+ property bool isChecking: false
+ property bool hasError: false
+ property string errorMessage: ""
+ property string updChecker: ""
+ property string pkgManager: ""
+ property string distribution: ""
+ property bool distributionSupported: false
+ property string shellVersion: ""
+ property string shellCodename: ""
+ property string semverVersion: ""
+
+ function getParsedShellVersion() {
+ return parseVersion(semverVersion);
+ }
+
+ readonly property var archBasedUCSettings: {
+ "listUpdatesSettings": {
+ "params": [],
+ "correctExitCodes": [0, 2] // Exit code 0 = updates available, 2 = no updates
+ },
+ "parserSettings": {
+ "lineRegex": /^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/,
+ "entryProducer": function (match) {
+ return {
+ "name": match[1],
+ "currentVersion": match[2],
+ "newVersion": match[3],
+ "description": `${match[1]} ${match[2]} → ${match[3]}`
+ };
+ }
+ }
+ }
+
+ readonly property var archBasedPMSettings: function(requiresSudo) {
+ return {
+ "listUpdatesSettings": {
+ "params": ["-Qu"],
+ "correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates
+ },
+ "upgradeSettings": {
+ "params": ["-Syu"],
+ "requiresSudo": requiresSudo
+ },
+ "parserSettings": {
+ "lineRegex": /^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/,
+ "entryProducer": function (match) {
+ return {
+ "name": match[1],
+ "currentVersion": match[2],
+ "newVersion": match[3],
+ "description": `${match[1]} ${match[2]} → ${match[3]}`
+ };
+ }
+ }
+ }
+ }
+
+ readonly property var fedoraBasedPMSettings: {
+ "listUpdatesSettings": {
+ "params": ["list", "--upgrades", "--quiet", "--color=never"],
+ "correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates
+ },
+ "upgradeSettings": {
+ "params": ["upgrade"],
+ "requiresSudo": true
+ },
+ "parserSettings": {
+ "lineRegex": /^([^\s]+)\s+([^\s]+)\s+.*$/,
+ "entryProducer": function (match) {
+ return {
+ "name": match[1],
+ "currentVersion": "",
+ "newVersion": match[2],
+ "description": `${match[1]} → ${match[2]}`
+ };
+ }
+ }
+ }
+
+ readonly property var updateCheckerParams: {
+ "checkupdates": archBasedUCSettings
+ }
+ readonly property var packageManagerParams: {
+ "yay": archBasedPMSettings(false),
+ "paru": archBasedPMSettings(false),
+ "pacman": archBasedPMSettings(true),
+ "dnf": fedoraBasedPMSettings
+ }
+ readonly property list<string> supportedDistributions: ["arch", "artix", "cachyos", "manjaro", "endeavouros", "fedora"]
+ readonly property int updateCount: availableUpdates.length
+ readonly property bool helperAvailable: pkgManager !== "" && distributionSupported
+
+ Process {
+ id: distributionDetection
+ command: ["sh", "-c", "cat /etc/os-release | grep '^ID=' | cut -d'=' -f2 | tr -d '\"'"]
+ running: true
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ distribution = stdout.text.trim().toLowerCase();
+ distributionSupported = supportedDistributions.includes(distribution);
+
+ if (distributionSupported) {
+ updateFinderDetection.running = true;
+ pkgManagerDetection.running = true;
+ checkForUpdates();
+ } else {
+ console.warn("SystemUpdate: Unsupported distribution:", distribution);
+ }
+ } else {
+ console.warn("SystemUpdate: Failed to detect distribution");
+ }
+ }
+
+ stdout: StdioCollector {}
+
+ Component.onCompleted: {
+ versionDetection.running = true;
+ }
+ }
+
+ Process {
+ id: versionDetection
+ command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -d .git ]; then echo "(git) $(git rev-parse --short HEAD)"; elif [ -f VERSION ]; then cat VERSION; fi`]
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ shellVersion = text.trim();
+ }
+ }
+ }
+
+ Process {
+ id: semverDetection
+ command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f VERSION ]; then cat VERSION; fi`]
+ running: true
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ semverVersion = text.trim();
+ }
+ }
+ }
+
+ Process {
+ id: codenameDetection
+ command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f CODENAME ]; then cat CODENAME; fi`]
+ running: true
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ shellCodename = text.trim();
+ }
+ }
+ }
+
+ Process {
+ id: updateFinderDetection
+ command: ["sh", "-c", "which checkupdates"]
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ const exeFound = stdout.text.trim();
+ updChecker = exeFound.split('/').pop();
+ } else {
+ console.warn("SystemUpdate: No update checker found. Will use package manager.");
+ }
+ }
+
+ stdout: StdioCollector {}
+ }
+
+ Process {
+ id: pkgManagerDetection
+ command: ["sh", "-c", "which paru || which yay || which pacman || which dnf"]
+
+ onExited: exitCode => {
+ if (exitCode === 0) {
+ const exeFound = stdout.text.trim();
+ pkgManager = exeFound.split('/').pop();
+ } else {
+ console.warn("SystemUpdate: No package manager found");
+ }
+ }
+
+ stdout: StdioCollector {}
+ }
+
+ Process {
+ id: updateChecker
+
+ onExited: exitCode => {
+ isChecking = false;
+ const correctExitCodes = updChecker.length > 0 ? [updChecker].concat(updateCheckerParams[updChecker].listUpdatesSettings.correctExitCodes) : [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.correctExitCodes);
+ if (correctExitCodes.includes(exitCode)) {
+ parseUpdates(stdout.text);
+ hasError = false;
+ errorMessage = "";
+ } else {
+ hasError = true;
+ errorMessage = "Failed to check for updates";
+ console.warn("SystemUpdate: Update check failed with code:", exitCode);
+ }
+ }
+
+ stdout: StdioCollector {}
+ }
+
+ Process {
+ id: updater
+ onExited: exitCode => {
+ checkForUpdates();
+ }
+ }
+
+ function checkForUpdates() {
+ if (!distributionSupported || (!pkgManager && !updChecker) || isChecking)
+ return;
+ isChecking = true;
+ hasError = false;
+ if (pkgManager === "paru" || pkgManager === "yay") {
+ const repoCmd = updChecker.length > 0 ? updChecker : `${pkgManager} -Qu`;
+ updateChecker.command = ["sh", "-c", `(${repoCmd} 2>/dev/null; ${pkgManager} -Qua 2>/dev/null) || true`];
+ } else if (updChecker.length > 0) {
+ updateChecker.command = [updChecker].concat(updateCheckerParams[updChecker].listUpdatesSettings.params);
+ } else {
+ updateChecker.command = [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.params);
+ }
+ updateChecker.running = true;
+ }
+
+ function parseUpdates(output) {
+ const lines = output.trim().split('\n').filter(line => line.trim());
+ const updates = [];
+
+ const regex = packageManagerParams[pkgManager].parserSettings.lineRegex;
+ const entryProducer = packageManagerParams[pkgManager].parserSettings.entryProducer;
+
+ for (const line of lines) {
+ const match = line.match(regex);
+ if (match) {
+ updates.push(entryProducer(match));
+ }
+ }
+
+ availableUpdates = updates;
+ }
+
+ function runUpdates() {
+ if (!distributionSupported || !pkgManager || updateCount === 0)
+ return;
+ const terminal = Quickshell.env("TERMINAL") || "xterm";
+
+ if (SettingsData.updaterUseCustomCommand && SettingsData.updaterCustomCommand.length > 0) {
+ const updateCommand = `${SettingsData.updaterCustomCommand} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
+ const termClass = SettingsData.updaterTerminalAdditionalParams;
+
+ var finalCommand = [terminal];
+ if (termClass.length > 0) {
+ finalCommand = finalCommand.concat(termClass.split(" "));
+ }
+ finalCommand.push("-e");
+ finalCommand.push("sh");
+ finalCommand.push("-c");
+ finalCommand.push(updateCommand);
+ updater.command = finalCommand;
+ } else {
+ const params = packageManagerParams[pkgManager].upgradeSettings.params.join(" ");
+ const sudo = packageManagerParams[pkgManager].upgradeSettings.requiresSudo ? "sudo" : "";
+ const updateCommand = `${sudo} ${pkgManager} ${params} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
+
+ updater.command = [terminal, "-e", "sh", "-c", updateCommand];
+ }
+ updater.running = true;
+ }
+
+ Timer {
+ interval: 30 * 60 * 1000
+ repeat: true
+ running: refCount > 0 && distributionSupported && (pkgManager || updChecker)
+ onTriggered: checkForUpdates()
+ }
+
+ IpcHandler {
+ target: "systemupdater"
+
+ function updatestatus(): string {
+ if (root.isChecking) {
+ return "ERROR: already checking";
+ }
+ if (!distributionSupported) {
+ return "ERROR: distribution not supported";
+ }
+ if (!pkgManager && !updChecker) {
+ return "ERROR: update checker not available";
+ }
+ root.checkForUpdates();
+ return "SUCCESS: Now checking...";
+ }
+ }
+
+ function parseVersion(versionStr) {
+ if (!versionStr || typeof versionStr !== "string")
+ return {
+ major: 0,
+ minor: 0,
+ patch: 0
+ };
+
+ let v = versionStr.trim();
+ if (v.startsWith("v"))
+ v = v.substring(1);
+
+ const dashIdx = v.indexOf("-");
+ if (dashIdx !== -1)
+ v = v.substring(0, dashIdx);
+
+ const plusIdx = v.indexOf("+");
+ if (plusIdx !== -1)
+ v = v.substring(0, plusIdx);
+
+ const parts = v.split(".");
+ return {
+ major: parseInt(parts[0], 10) || 0,
+ minor: parseInt(parts[1], 10) || 0,
+ patch: parseInt(parts[2], 10) || 0
+ };
+ }
+
+ function compareVersions(v1, v2) {
+ if (v1.major !== v2.major)
+ return v1.major - v2.major;
+ if (v1.minor !== v2.minor)
+ return v1.minor - v2.minor;
+ return v1.patch - v2.patch;
+ }
+
+ function checkVersionRequirement(requirementStr, currentVersion) {
+ if (!requirementStr || typeof requirementStr !== "string")
+ return true;
+
+ const req = requirementStr.trim();
+ let operator = "";
+ let versionPart = req;
+
+ if (req.startsWith(">=")) {
+ operator = ">=";
+ versionPart = req.substring(2);
+ } else if (req.startsWith("<=")) {
+ operator = "<=";
+ versionPart = req.substring(2);
+ } else if (req.startsWith(">")) {
+ operator = ">";
+ versionPart = req.substring(1);
+ } else if (req.startsWith("<")) {
+ operator = "<";
+ versionPart = req.substring(1);
+ } else if (req.startsWith("=")) {
+ operator = "=";
+ versionPart = req.substring(1);
+ } else {
+ operator = ">=";
+ }
+
+ const reqVersion = parseVersion(versionPart);
+ const cmp = compareVersions(currentVersion, reqVersion);
+
+ switch (operator) {
+ case ">=":
+ return cmp >= 0;
+ case ">":
+ return cmp > 0;
+ case "<=":
+ return cmp <= 0;
+ case "<":
+ return cmp < 0;
+ case "=":
+ return cmp === 0;
+ default:
+ return cmp >= 0;
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/ToastService.qml b/raveos-hyprland-theme/theme-data/dms/Services/ToastService.qml
new file mode 100644
index 0000000..7684f90
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/ToastService.qml
@@ -0,0 +1,174 @@
+pragma Singleton
+
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+
+Singleton {
+ id: root
+
+ readonly property int levelInfo: 0
+ readonly property int levelWarn: 1
+ readonly property int levelError: 2
+ property string currentMessage: ""
+ property int currentLevel: levelInfo
+ property bool toastVisible: false
+ property var toastQueue: []
+ property string currentDetails: ""
+ property string currentCommand: ""
+ property bool hasDetails: false
+ property string wallpaperErrorStatus: ""
+ property int maxQueueSize: 3
+ property var lastErrorTime: ({})
+ property int errorThrottleMs: 1000
+ property string currentCategory: ""
+
+ function showToast(message, level = levelInfo, details = "", command = "", category = "") {
+ const now = Date.now()
+ const messageKey = message + level
+
+ if (level === levelError) {
+ const lastTime = lastErrorTime[messageKey] || 0
+ if (now - lastTime < errorThrottleMs) {
+ return
+ }
+ lastErrorTime[messageKey] = now
+ }
+
+ if (category && level === levelError) {
+ if (currentCategory === category && toastVisible && currentLevel === levelError) {
+ currentMessage = message
+ currentDetails = details || ""
+ currentCommand = command || ""
+ hasDetails = currentDetails.length > 0 || currentCommand.length > 0
+ resetToastState()
+ if (hasDetails) {
+ toastTimer.interval = 8000
+ } else {
+ toastTimer.interval = 5000
+ }
+ toastTimer.restart()
+ return
+ }
+
+ toastQueue = toastQueue.filter(t => t.category !== category)
+ }
+
+ const isDuplicate = toastQueue.some(toast =>
+ toast.message === message && toast.level === level
+ )
+ if (isDuplicate) {
+ return
+ }
+
+ if (toastQueue.length >= maxQueueSize) {
+ if (level === levelError) {
+ toastQueue = toastQueue.filter(t => t.level !== levelError).slice(0, maxQueueSize - 1)
+ } else {
+ return
+ }
+ }
+
+ toastQueue.push({
+ "message": message,
+ "level": level,
+ "details": details,
+ "command": command,
+ "category": category
+ })
+ if (!toastVisible) {
+ processQueue()
+ }
+ }
+
+ function showInfo(message, details = "", command = "", category = "") {
+ showToast(message, levelInfo, details, command, category)
+ }
+
+ function showWarning(message, details = "", command = "", category = "") {
+ showToast(message, levelWarn, details, command, category)
+ }
+
+ function showError(message, details = "", command = "", category = "") {
+ showToast(message, levelError, details, command, category)
+ }
+
+ function dismissCategory(category) {
+ if (!category) {
+ return
+ }
+
+ if (currentCategory === category && toastVisible) {
+ hideToast()
+ return
+ }
+
+ toastQueue = toastQueue.filter(t => t.category !== category)
+ }
+
+ function hideToast() {
+ toastVisible = false
+ currentMessage = ""
+ currentDetails = ""
+ currentCommand = ""
+ currentCategory = ""
+ hasDetails = false
+ currentLevel = levelInfo
+ toastTimer.stop()
+ resetToastState()
+ if (toastQueue.length > 0) {
+ processQueue()
+ }
+ }
+
+ function processQueue() {
+ if (toastQueue.length === 0) {
+ return
+ }
+
+ const toast = toastQueue.shift()
+ currentMessage = toast.message
+ currentLevel = toast.level
+ currentDetails = toast.details || ""
+ currentCommand = toast.command || ""
+ currentCategory = toast.category || ""
+ hasDetails = currentDetails.length > 0 || currentCommand.length > 0
+ toastVisible = true
+ resetToastState()
+
+ if (toast.level === levelError && hasDetails) {
+ toastTimer.interval = 8000
+ toastTimer.start()
+ } else {
+ toastTimer.interval = toast.level === levelError ? 5000 : toast.level === levelWarn ? 3000 : 1500
+ toastTimer.start()
+ }
+ }
+
+ signal resetToastState
+
+ function stopTimer() {
+ toastTimer.stop()
+ }
+
+ function restartTimer() {
+ if (hasDetails && currentLevel === levelError) {
+ toastTimer.interval = 8000
+ toastTimer.restart()
+ }
+ }
+
+ function clearWallpaperError() {
+ wallpaperErrorStatus = ""
+ }
+
+ Timer {
+ id: toastTimer
+
+ interval: 5000
+ running: false
+ repeat: false
+ onTriggered: hideToast()
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/TrackArtService.qml b/raveos-hyprland-theme/theme-data/dms/Services/TrackArtService.qml
new file mode 100644
index 0000000..e2c6ea5
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/TrackArtService.qml
@@ -0,0 +1,49 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import Quickshell
+import QtQuick
+import Quickshell.Services.Mpris
+import qs.Common
+
+Singleton {
+ id: root
+
+ property string _lastArtUrl: ""
+ property string _bgArtSource: ""
+ property bool loading: false
+
+ function loadArtwork(url) {
+ if (!url || url === "") {
+ _bgArtSource = "";
+ _lastArtUrl = "";
+ loading = false;
+ return;
+ }
+ if (url === _lastArtUrl)
+ return;
+ _lastArtUrl = url;
+
+ if (url.startsWith("http://") || url.startsWith("https://")) {
+ _bgArtSource = url;
+ loading = false;
+ return;
+ }
+
+ loading = true;
+ const localUrl = url;
+ const filePath = url.startsWith("file://") ? url.substring(7) : url;
+ Proc.runCommand("trackart", ["test", "-f", filePath], (output, exitCode) => {
+ if (_lastArtUrl !== localUrl)
+ return;
+ _bgArtSource = exitCode === 0 ? localUrl : "";
+ loading = false;
+ }, 200);
+ }
+
+ property MprisPlayer activePlayer: MprisController.activePlayer
+
+ onActivePlayerChanged: {
+ loadArtwork(activePlayer?.trackArtUrl ?? "");
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/UserInfoService.qml b/raveos-hyprland-theme/theme-data/dms/Services/UserInfoService.qml
new file mode 100644
index 0000000..8378e01
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/UserInfoService.qml
@@ -0,0 +1,35 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import qs.Common
+
+Singleton {
+ id: root
+
+ property string username: ""
+ property string fullName: ""
+ property string profilePicture: ""
+ property string hostname: ""
+ property bool profileAvailable: false
+
+ function getUserInfo() {
+ Proc.runCommand("userInfo", ["sh", "-c", "echo \"$USER|$(getent passwd $USER | cut -d: -f5 | cut -d, -f1)|$(hostname)\""], (output, exitCode) => {
+ if (exitCode !== 0) {
+ root.username = "User";
+ root.fullName = "User";
+ root.hostname = "System";
+ return;
+ }
+ const parts = output.trim().split("|");
+ if (parts.length >= 3) {
+ root.username = parts[0] || "";
+ root.fullName = parts[1] || parts[0] || "";
+ root.hostname = parts[2] || "";
+ }
+ }, 0);
+ }
+
+ Component.onCompleted: getUserInfo()
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/VPNService.qml b/raveos-hyprland-theme/theme-data/dms/Services/VPNService.qml
new file mode 100644
index 0000000..effc54e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/VPNService.qml
@@ -0,0 +1,221 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import qs.Common
+
+Singleton {
+ id: root
+
+ readonly property bool available: DMSNetworkService.vpnAvailable
+
+ property var plugins: []
+ property var allExtensions: []
+ property bool importing: false
+ property string importError: ""
+
+ property var editConfig: null
+ property bool configLoading: false
+
+ property bool pluginsLoading: false
+
+ signal importComplete(string uuid, string name)
+ signal configLoaded(var config)
+ signal configUpdated
+ signal vpnDeleted(string uuid)
+
+ Component.onCompleted: {
+ if (available) {
+ fetchPlugins();
+ }
+ }
+
+ Connections {
+ target: DMSNetworkService
+ function onVpnAvailableChanged() {
+ if (DMSNetworkService.vpnAvailable && plugins.length === 0) {
+ fetchPlugins();
+ }
+ }
+ }
+
+ function fetchPlugins() {
+ if (!available || pluginsLoading)
+ return;
+ pluginsLoading = true;
+
+ DMSService.sendRequest("network.vpn.plugins", null, response => {
+ pluginsLoading = false;
+ if (response.error) {
+ console.warn("VPNService: Failed to fetch plugins:", response.error);
+ return;
+ }
+ if (!response.result)
+ return;
+ plugins = response.result;
+ const extSet = new Set();
+ for (const plugin of response.result) {
+ for (const ext of plugin.fileExtensions || []) {
+ extSet.add(ext);
+ }
+ }
+ allExtensions = Array.from(extSet);
+ });
+ }
+
+ function importVpn(filePath, name = "") {
+ if (!available || importing)
+ return;
+ importing = true;
+ importError = "";
+
+ const params = {
+ file: filePath
+ };
+ if (name)
+ params.name = name;
+
+ DMSService.sendRequest("network.vpn.import", params, response => {
+ importing = false;
+
+ if (response.error) {
+ importError = response.error;
+ ToastService.showError(I18n.tr("Failed to import VPN"), response.error);
+ return;
+ }
+
+ if (!response.result)
+ return;
+ if (response.result.success) {
+ ToastService.showInfo(I18n.tr("VPN imported: %1").arg(response.result.name || ""));
+ DMSNetworkService.refreshVpnProfiles();
+ importComplete(response.result.uuid || "", response.result.name || "");
+ return;
+ }
+
+ importError = response.result.error || "Import failed";
+ ToastService.showError(I18n.tr("Failed to import VPN"), importError);
+ });
+ }
+
+ function getConfig(uuidOrName) {
+ if (!available)
+ return;
+ configLoading = true;
+ editConfig = null;
+
+ DMSService.sendRequest("network.vpn.getConfig", {
+ uuid: uuidOrName
+ }, response => {
+ configLoading = false;
+
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to load VPN config"), response.error);
+ return;
+ }
+
+ if (response.result) {
+ editConfig = response.result;
+ configLoaded(response.result);
+ }
+ });
+ }
+
+ function updateConfig(uuid, updates) {
+ if (!available)
+ return;
+ const params = {
+ uuid: uuid
+ };
+ if (updates.name !== undefined)
+ params.name = updates.name;
+ if (updates.autoconnect !== undefined)
+ params.autoconnect = updates.autoconnect;
+ if (updates.data !== undefined)
+ params.data = updates.data;
+
+ DMSService.sendRequest("network.vpn.updateConfig", params, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to update VPN"), response.error);
+ return;
+ }
+ ToastService.showInfo(I18n.tr("VPN configuration updated"));
+ DMSNetworkService.refreshVpnProfiles();
+ getConfig(uuid);
+ configUpdated();
+ });
+ }
+
+ function deleteVpn(uuidOrName) {
+ if (!available)
+ return;
+ DMSService.sendRequest("network.vpn.delete", {
+ uuid: uuidOrName
+ }, response => {
+ if (response.error) {
+ ToastService.showError(I18n.tr("Failed to delete VPN"), response.error);
+ return;
+ }
+ ToastService.showInfo(I18n.tr("VPN deleted"));
+ DMSNetworkService.refreshVpnProfiles();
+ vpnDeleted(uuidOrName);
+ });
+ }
+
+ function getFileFilter() {
+ if (allExtensions.length === 0) {
+ return ["*.ovpn", "*.conf"];
+ }
+ return allExtensions.map(e => "*" + e);
+ }
+
+ function getExtensionsForPlugin(serviceType) {
+ const plugin = plugins.find(p => p.serviceType === serviceType);
+ if (!plugin)
+ return ["*.conf"];
+ return (plugin.fileExtensions || [".conf"]).map(e => "*" + e);
+ }
+
+ function getPluginName(serviceType) {
+ if (!serviceType)
+ return "VPN";
+
+ const plugin = plugins.find(p => p.serviceType === serviceType);
+ if (plugin)
+ return plugin.name;
+
+ const svc = serviceType.toLowerCase();
+ if (svc.includes("openvpn"))
+ return "OpenVPN";
+ if (svc.includes("wireguard"))
+ return "WireGuard";
+ if (svc.includes("openconnect"))
+ return "OpenConnect";
+ if (svc.includes("fortissl") || svc.includes("forti"))
+ return "Fortinet";
+ if (svc.includes("strongswan"))
+ return "IPsec (strongSwan)";
+ if (svc.includes("libreswan"))
+ return "IPsec (Libreswan)";
+ if (svc.includes("l2tp"))
+ return "L2TP/IPsec";
+ if (svc.includes("pptp"))
+ return "PPTP";
+ if (svc.includes("vpnc"))
+ return "Cisco (vpnc)";
+ if (svc.includes("sstp"))
+ return "SSTP";
+
+ const parts = serviceType.split('.');
+ return parts[parts.length - 1] || "VPN";
+ }
+
+ function getVpnTypeFromProfile(profile) {
+ if (!profile)
+ return "VPN";
+ if (profile.type === "wireguard")
+ return "WireGuard";
+ return getPluginName(profile.serviceType);
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/WallpaperCyclingService.qml b/raveos-hyprland-theme/theme-data/dms/Services/WallpaperCyclingService.qml
new file mode 100644
index 0000000..a07c563
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/WallpaperCyclingService.qml
@@ -0,0 +1,495 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import qs.Common
+import qs.Services
+
+Singleton {
+ id: root
+
+ property bool cyclingActive: false
+ readonly property bool fullscreenShowing: {
+ if (!ToplevelManager.toplevels?.values)
+ return false;
+ for (const toplevel of ToplevelManager.toplevels.values) {
+ if (toplevel.fullscreen && toplevel.activated)
+ return true;
+ }
+ return false;
+ }
+ readonly property bool shouldPauseCycling: fullscreenShowing || SessionService.locked
+ property string cachedCyclingTime: SessionData.wallpaperCyclingTime
+ property int cachedCyclingInterval: SessionData.wallpaperCyclingInterval
+ property string lastTimeCheck: ""
+ property var monitorTimers: ({})
+ property var monitorLastTimeChecks: ({})
+ property var monitorProcesses: ({})
+
+ Component {
+ id: monitorTimerComponent
+ Timer {
+ property string targetScreen: ""
+ running: false
+ repeat: true
+ onTriggered: {
+ if (typeof WallpaperCyclingService !== "undefined" && targetScreen !== "" && !WallpaperCyclingService.shouldPauseCycling) {
+ WallpaperCyclingService.cycleNextForMonitor(targetScreen);
+ }
+ }
+ }
+ }
+
+ Component {
+ id: monitorProcessComponent
+ Process {
+ property string targetScreenName: ""
+ property string currentWallpaper: ""
+ property bool goToPrevious: false
+ running: false
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text && text.trim()) {
+ const files = text.trim().split('\n').filter(file => file.length > 0);
+ if (files.length <= 1)
+ return;
+ const wallpaperList = files.sort();
+ const currentPath = currentWallpaper;
+ let currentIndex = wallpaperList.findIndex(path => path === currentPath);
+ if (currentIndex === -1)
+ currentIndex = 0;
+ let targetIndex;
+ if (goToPrevious) {
+ targetIndex = currentIndex === 0 ? wallpaperList.length - 1 : currentIndex - 1;
+ } else {
+ targetIndex = (currentIndex + 1) % wallpaperList.length;
+ }
+ const targetWallpaper = wallpaperList[targetIndex];
+ if (targetWallpaper && targetWallpaper !== currentPath) {
+ if (targetScreenName) {
+ SessionData.setMonitorWallpaper(targetScreenName, targetWallpaper);
+ } else {
+ SessionData.setWallpaper(targetWallpaper);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Connections {
+ target: SessionData
+
+ function onWallpaperCyclingEnabledChanged() {
+ updateCyclingState();
+ }
+
+ function onWallpaperCyclingModeChanged() {
+ updateCyclingState();
+ }
+
+ function onWallpaperCyclingIntervalChanged() {
+ cachedCyclingInterval = SessionData.wallpaperCyclingInterval;
+ if (SessionData.wallpaperCyclingMode === "interval") {
+ updateCyclingState();
+ }
+ }
+
+ function onWallpaperCyclingTimeChanged() {
+ cachedCyclingTime = SessionData.wallpaperCyclingTime;
+ if (SessionData.wallpaperCyclingMode === "time") {
+ updateCyclingState();
+ }
+ }
+
+ function onPerMonitorWallpaperChanged() {
+ updateCyclingState();
+ }
+
+ function onMonitorCyclingSettingsChanged() {
+ updateCyclingState();
+ }
+ }
+
+ Connections {
+ target: SessionService
+
+ function onSessionUnlocked() {
+ if (SessionData.wallpaperCyclingEnabled || SessionData.perMonitorWallpaper) {
+ updateCyclingState();
+ }
+ }
+ }
+
+ function updateCyclingState() {
+ if (SessionData.perMonitorWallpaper) {
+ stopCycling();
+ updatePerMonitorCycling();
+ } else if (SessionData.wallpaperCyclingEnabled && SessionData.wallpaperPath) {
+ startCycling();
+ stopAllMonitorCycling();
+ } else {
+ stopCycling();
+ stopAllMonitorCycling();
+ }
+ }
+
+ function updatePerMonitorCycling() {
+ if (typeof Quickshell === "undefined")
+ return;
+ var screens = Quickshell.screens;
+ for (var i = 0; i < screens.length; i++) {
+ var screenName = screens[i].name;
+ var settings = SessionData.getMonitorCyclingSettings(screenName);
+ var wallpaper = SessionData.getMonitorWallpaper(screenName);
+
+ if (settings.enabled && wallpaper && !wallpaper.startsWith("#")) {
+ startMonitorCycling(screenName, settings);
+ } else {
+ stopMonitorCycling(screenName);
+ }
+ }
+ }
+
+ function stopAllMonitorCycling() {
+ var screenNames = Object.keys(monitorTimers);
+ for (var i = 0; i < screenNames.length; i++) {
+ stopMonitorCycling(screenNames[i]);
+ }
+ }
+
+ function startCycling() {
+ switch (SessionData.wallpaperCyclingMode) {
+ case "interval":
+ lastTimeCheck = "";
+ intervalTimer.interval = cachedCyclingInterval * 1000;
+ intervalTimer.start();
+ cyclingActive = true;
+ break;
+ case "time":
+ intervalTimer.stop();
+ cyclingActive = true;
+ checkTimeBasedCycling();
+ break;
+ }
+ }
+
+ function stopCycling() {
+ intervalTimer.stop();
+ cyclingActive = false;
+ }
+
+ function startMonitorCycling(screenName, settings) {
+ switch (settings.mode) {
+ case "interval":
+ {
+ var newChecks = Object.assign({}, monitorLastTimeChecks);
+ delete newChecks[screenName];
+ monitorLastTimeChecks = newChecks;
+
+ var timer = monitorTimers[screenName];
+ if (!timer && monitorTimerComponent && monitorTimerComponent.status === Component.Ready) {
+ var newTimers = Object.assign({}, monitorTimers);
+ var newTimer = monitorTimerComponent.createObject(root);
+ newTimer.targetScreen = screenName;
+ newTimers[screenName] = newTimer;
+ monitorTimers = newTimers;
+ timer = newTimer;
+ }
+ if (timer) {
+ timer.interval = settings.interval * 1000;
+ timer.start();
+ }
+ break;
+ }
+ case "time":
+ {
+ var existingTimer = monitorTimers[screenName];
+ if (existingTimer) {
+ existingTimer.stop();
+ existingTimer.destroy();
+ var newTimers = Object.assign({}, monitorTimers);
+ delete newTimers[screenName];
+ monitorTimers = newTimers;
+ }
+
+ var newChecks = Object.assign({}, monitorLastTimeChecks);
+ newChecks[screenName] = "";
+ monitorLastTimeChecks = newChecks;
+ break;
+ }
+ }
+ }
+
+ function stopMonitorCycling(screenName) {
+ var timer = monitorTimers[screenName];
+ if (timer) {
+ timer.stop();
+ timer.destroy();
+ var newTimers = Object.assign({}, monitorTimers);
+ delete newTimers[screenName];
+ monitorTimers = newTimers;
+ }
+
+ var process = monitorProcesses[screenName];
+ if (process) {
+ process.destroy();
+ var newProcesses = Object.assign({}, monitorProcesses);
+ delete newProcesses[screenName];
+ monitorProcesses = newProcesses;
+ }
+
+ var newChecks = Object.assign({}, monitorLastTimeChecks);
+ delete newChecks[screenName];
+ monitorLastTimeChecks = newChecks;
+ }
+
+ function cycleToNextWallpaper(screenName, wallpaperPath) {
+ const currentWallpaper = wallpaperPath || SessionData.wallpaperPath;
+ if (!currentWallpaper)
+ return;
+ const wallpaperDir = currentWallpaper.substring(0, currentWallpaper.lastIndexOf('/'));
+
+ if (screenName && monitorProcessComponent && monitorProcessComponent.status === Component.Ready) {
+ // Use per-monitor process
+ var process = monitorProcesses[screenName];
+ if (!process) {
+ var newProcesses = Object.assign({}, monitorProcesses);
+ var newProcess = monitorProcessComponent.createObject(root);
+ newProcesses[screenName] = newProcess;
+ monitorProcesses = newProcesses;
+ process = newProcess;
+ }
+
+ if (process) {
+ process.command = ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`];
+ process.targetScreenName = screenName;
+ process.currentWallpaper = currentWallpaper;
+ process.goToPrevious = false;
+ process.running = true;
+ }
+ } else {
+ // Use global process for fallback
+ cyclingProcess.command = ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`];
+ cyclingProcess.targetScreenName = screenName || "";
+ cyclingProcess.currentWallpaper = currentWallpaper;
+ cyclingProcess.running = true;
+ }
+ }
+
+ function cycleToPrevWallpaper(screenName, wallpaperPath) {
+ const currentWallpaper = wallpaperPath || SessionData.wallpaperPath;
+ if (!currentWallpaper)
+ return;
+ const wallpaperDir = currentWallpaper.substring(0, currentWallpaper.lastIndexOf('/'));
+
+ if (screenName && monitorProcessComponent && monitorProcessComponent.status === Component.Ready) {
+ // Use per-monitor process (same as next, but with prev flag)
+ var process = monitorProcesses[screenName];
+ if (!process) {
+ var newProcesses = Object.assign({}, monitorProcesses);
+ var newProcess = monitorProcessComponent.createObject(root);
+ newProcesses[screenName] = newProcess;
+ monitorProcesses = newProcesses;
+ process = newProcess;
+ }
+
+ if (process) {
+ process.command = ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`];
+ process.targetScreenName = screenName;
+ process.currentWallpaper = currentWallpaper;
+ process.goToPrevious = true;
+ process.running = true;
+ }
+ } else {
+ // Use global process for fallback
+ prevCyclingProcess.command = ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`];
+ prevCyclingProcess.targetScreenName = screenName || "";
+ prevCyclingProcess.currentWallpaper = currentWallpaper;
+ prevCyclingProcess.running = true;
+ }
+ }
+
+ function cycleNextManually() {
+ if (SessionData.wallpaperPath) {
+ cycleToNextWallpaper();
+ // Restart timers if cycling is active
+ if (cyclingActive && SessionData.wallpaperCyclingEnabled) {
+ if (SessionData.wallpaperCyclingMode === "interval") {
+ intervalTimer.interval = cachedCyclingInterval * 1000;
+ intervalTimer.restart();
+ }
+ }
+ }
+ }
+
+ function cyclePrevManually() {
+ if (SessionData.wallpaperPath) {
+ cycleToPrevWallpaper();
+ // Restart timers if cycling is active
+ if (cyclingActive && SessionData.wallpaperCyclingEnabled) {
+ if (SessionData.wallpaperCyclingMode === "interval") {
+ intervalTimer.interval = cachedCyclingInterval * 1000;
+ intervalTimer.restart();
+ }
+ }
+ }
+ }
+
+ function cycleNextForMonitor(screenName) {
+ if (!screenName)
+ return;
+ var currentWallpaper = SessionData.getMonitorWallpaper(screenName);
+ if (currentWallpaper) {
+ cycleToNextWallpaper(screenName, currentWallpaper);
+ }
+ }
+
+ function cyclePrevForMonitor(screenName) {
+ if (!screenName)
+ return;
+ var currentWallpaper = SessionData.getMonitorWallpaper(screenName);
+ if (currentWallpaper) {
+ cycleToPrevWallpaper(screenName, currentWallpaper);
+ }
+ }
+
+ function checkTimeBasedCycling() {
+ if (shouldPauseCycling)
+ return;
+ const currentTime = Qt.formatTime(systemClock.date, "hh:mm");
+
+ if (!SessionData.perMonitorWallpaper) {
+ if (currentTime === cachedCyclingTime && currentTime !== lastTimeCheck) {
+ lastTimeCheck = currentTime;
+ cycleToNextWallpaper();
+ } else if (currentTime !== cachedCyclingTime) {
+ lastTimeCheck = "";
+ }
+ } else {
+ checkPerMonitorTimeBasedCycling(currentTime);
+ }
+ }
+
+ function checkPerMonitorTimeBasedCycling(currentTime) {
+ if (typeof Quickshell === "undefined")
+ return;
+ var screens = Quickshell.screens;
+ for (var i = 0; i < screens.length; i++) {
+ var screenName = screens[i].name;
+ var settings = SessionData.getMonitorCyclingSettings(screenName);
+ var wallpaper = SessionData.getMonitorWallpaper(screenName);
+
+ if (settings.enabled && settings.mode === "time" && wallpaper && !wallpaper.startsWith("#")) {
+ var lastCheck = monitorLastTimeChecks[screenName] || "";
+
+ if (currentTime === settings.time && currentTime !== lastCheck) {
+ var newChecks = Object.assign({}, monitorLastTimeChecks);
+ newChecks[screenName] = currentTime;
+ monitorLastTimeChecks = newChecks;
+ cycleNextForMonitor(screenName);
+ } else if (currentTime !== settings.time) {
+ var newChecks = Object.assign({}, monitorLastTimeChecks);
+ newChecks[screenName] = "";
+ monitorLastTimeChecks = newChecks;
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: intervalTimer
+ interval: cachedCyclingInterval * 1000
+ running: false
+ repeat: true
+ onTriggered: {
+ if (shouldPauseCycling)
+ return;
+ cycleToNextWallpaper();
+ }
+ }
+
+ SystemClock {
+ id: systemClock
+ precision: SystemClock.Minutes
+ onDateChanged: {
+ if ((SessionData.wallpaperCyclingMode === "time" && cyclingActive) || SessionData.perMonitorWallpaper) {
+ checkTimeBasedCycling();
+ }
+ }
+ }
+
+ Process {
+ id: cyclingProcess
+
+ property string targetScreenName: ""
+ property string currentWallpaper: ""
+
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text && text.trim()) {
+ const files = text.trim().split('\n').filter(file => file.length > 0);
+ if (files.length <= 1)
+ return;
+ const wallpaperList = files.sort();
+ const currentPath = cyclingProcess.currentWallpaper;
+ let currentIndex = wallpaperList.findIndex(path => path === currentPath);
+ if (currentIndex === -1)
+ currentIndex = 0;
+
+ const nextIndex = (currentIndex + 1) % wallpaperList.length;
+ const nextWallpaper = wallpaperList[nextIndex];
+
+ if (nextWallpaper && nextWallpaper !== currentPath) {
+ if (cyclingProcess.targetScreenName) {
+ SessionData.setMonitorWallpaper(cyclingProcess.targetScreenName, nextWallpaper);
+ } else {
+ SessionData.setWallpaper(nextWallpaper);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Process {
+ id: prevCyclingProcess
+
+ property string targetScreenName: ""
+ property string currentWallpaper: ""
+
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (text && text.trim()) {
+ const files = text.trim().split('\n').filter(file => file.length > 0);
+ if (files.length <= 1)
+ return;
+ const wallpaperList = files.sort();
+ const currentPath = prevCyclingProcess.currentWallpaper;
+ let currentIndex = wallpaperList.findIndex(path => path === currentPath);
+ if (currentIndex === -1)
+ currentIndex = 0;
+
+ const prevIndex = currentIndex === 0 ? wallpaperList.length - 1 : currentIndex - 1;
+ const prevWallpaper = wallpaperList[prevIndex];
+
+ if (prevWallpaper && prevWallpaper !== currentPath) {
+ if (prevCyclingProcess.targetScreenName) {
+ SessionData.setMonitorWallpaper(prevCyclingProcess.targetScreenName, prevWallpaper);
+ } else {
+ SessionData.setWallpaper(prevWallpaper);
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/WeatherService.qml b/raveos-hyprland-theme/theme-data/dms/Services/WeatherService.qml
new file mode 100644
index 0000000..134789e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/WeatherService.qml
@@ -0,0 +1,914 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import qs.Common
+import qs.Modules.Greetd
+import "../Common/suncalc.js" as SunCalc
+
+Singleton {
+ id: root
+
+ property int refCount: 0
+
+ property var selectedDate: new Date()
+ property var weather: ({
+ "available": false,
+ "loading": true,
+ "temp": 0,
+ "tempF": 0,
+ "feelsLike": 0,
+ "feelsLikeF": 0,
+ "city": "",
+ "country": "",
+ "wCode": 0,
+ "humidity": 0,
+ "wind": "",
+ "sunrise": "06:00",
+ "sunset": "18:00",
+ "uv": 0,
+ "pressure": 0,
+ "precipitationProbability": 0,
+ "isDay": true,
+ "forecast": []
+ })
+
+ property var location: null
+ property int updateInterval: 900000 // 15 minutes
+ property int retryAttempts: 0
+ property int maxRetryAttempts: 3
+ property int retryDelay: 30000
+ property int lastFetchTime: 0
+ property int minFetchInterval: 30000
+ property int persistentRetryCount: 0
+
+ readonly property var lowPriorityCmd: ["nice", "-n", "19", "ionice", "-c3"]
+ readonly property var curlBaseCmd: ["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "100k", "--compressed"]
+
+ property var weatherIcons: ({
+ "0": "clear_day",
+ "1": "clear_day",
+ "2": "partly_cloudy_day",
+ "3": "cloud",
+ "45": "foggy",
+ "48": "foggy",
+ "51": "rainy",
+ "53": "rainy",
+ "55": "rainy",
+ "56": "rainy",
+ "57": "rainy",
+ "61": "rainy",
+ "63": "rainy",
+ "65": "rainy",
+ "66": "rainy",
+ "67": "rainy",
+ "71": "cloudy_snowing",
+ "73": "cloudy_snowing",
+ "75": "snowing_heavy",
+ "77": "cloudy_snowing",
+ "80": "rainy",
+ "81": "rainy",
+ "82": "rainy",
+ "85": "cloudy_snowing",
+ "86": "snowing_heavy",
+ "95": "thunderstorm",
+ "96": "thunderstorm",
+ "99": "thunderstorm"
+ })
+
+ property var nightWeatherIcons: ({
+ "0": "clear_night",
+ "1": "clear_night",
+ "2": "partly_cloudy_night",
+ "3": "cloud",
+ "45": "foggy",
+ "48": "foggy",
+ "51": "rainy",
+ "53": "rainy",
+ "55": "rainy",
+ "56": "rainy",
+ "57": "rainy",
+ "61": "rainy",
+ "63": "rainy",
+ "65": "rainy",
+ "66": "rainy",
+ "67": "rainy",
+ "71": "cloudy_snowing",
+ "73": "cloudy_snowing",
+ "75": "snowing_heavy",
+ "77": "cloudy_snowing",
+ "80": "rainy",
+ "81": "rainy",
+ "82": "rainy",
+ "85": "cloudy_snowing",
+ "86": "snowing_heavy",
+ "95": "thunderstorm",
+ "96": "thunderstorm",
+ "99": "thunderstorm"
+ })
+
+ function getWeatherIcon(code, isDay) {
+ if (typeof isDay === "undefined") {
+ isDay = weather.isDay;
+ }
+ const iconMap = isDay ? weatherIcons : nightWeatherIcons;
+ return iconMap[String(code)] || "cloud";
+ }
+
+ function getWeatherCondition(code) {
+ const conditions = {
+ "0": I18n.tr("Clear Sky"),
+ "1": I18n.tr("Clear Sky"),
+ "2": I18n.tr("Partly Cloudy"),
+ "3": I18n.tr("Overcast"),
+ "45": I18n.tr("Fog"),
+ "48": I18n.tr("Fog"),
+ "51": I18n.tr("Drizzle"),
+ "53": I18n.tr("Drizzle"),
+ "55": I18n.tr("Drizzle"),
+ "56": I18n.tr("Freezing Drizzle"),
+ "57": I18n.tr("Freezing Drizzle"),
+ "61": I18n.tr("Light Rain"),
+ "63": I18n.tr("Rain"),
+ "65": I18n.tr("Heavy Rain"),
+ "66": I18n.tr("Light Rain"),
+ "67": I18n.tr("Heavy Rain"),
+ "71": I18n.tr("Light Snow"),
+ "73": I18n.tr("Snow"),
+ "75": I18n.tr("Heavy Snow"),
+ "77": I18n.tr("Snow"),
+ "80": I18n.tr("Light Rain"),
+ "81": I18n.tr("Rain"),
+ "82": I18n.tr("Heavy Rain"),
+ "85": I18n.tr("Light Snow Showers"),
+ "86": I18n.tr("Heavy Snow Showers"),
+ "95": I18n.tr("Thunderstorm"),
+ "96": I18n.tr("Thunderstorm with Hail"),
+ "99": I18n.tr("Thunderstorm with Hail")
+ };
+ return conditions[String(code)] || I18n.tr("Unknown");
+ }
+
+ property var moonPhaseNames: ["moon_new", "moon_waxing_crescent", "moon_first_quarter", "moon_waxing_gibbous", "moon_full", "moon_waning_gibbous", "moon_last_quarter", "moon_waning_crescent"]
+
+ function getMoonPhase(date) {
+ const phases = moonPhaseNames;
+ const iconCount = phases.length;
+ const moon = SunCalc.getMoonIllumination(date);
+ const index = ((Math.floor(moon.phase * iconCount + 0.5) % iconCount) + iconCount) % iconCount;
+
+ return phases[index];
+ }
+
+ function getMoonAngle(date) {
+ if (!location) {
+ return;
+ }
+ const pos = SunCalc.getMoonPosition(date, location.latitude, location.longitude);
+ return pos.parralacticAngle;
+ }
+
+ function getLocation() {
+ return location;
+ }
+
+ function getSunDeclination(date) {
+ return SunCalc.sunCoords(SunCalc.toDays(date)).dec * 180 / Math.PI;
+ }
+
+ function getSunTimes(date) {
+ if (!location) {
+ return null;
+ }
+ return SunCalc.getTimes(date, location.latitude, location.longitude, location.elevation);
+ }
+
+ function getEcliptic(date, points = 60) {
+ if (!location) {
+ return null;
+ }
+ const lat = location.latitude;
+ const lon = location.longitude;
+ const times = SunCalc.getTimes(date, lat, lon);
+ const solarNoon = times.solarNoon;
+
+ const eclipticPoints = [];
+
+ const sunIsNorth = getSunDeclination(date) > lat;
+ const transitAzimuth = sunIsNorth ? 0 : Math.PI;
+
+ for (let i = 0; i <= points; i++) {
+ const t = new Date(solarNoon.getTime() + (i / points) * 24 * 60 * 60 * 1000);
+ const pos = SunCalc.getPosition(t, lat, lon);
+
+ let h = (((pos.azimuth - transitAzimuth) / (2 * Math.PI)) + 1) % 1;
+ h = Math.max(0, Math.min(1, h));
+ let v = Math.sin(pos.altitude);
+ v = Math.max(-1, Math.min(1, v));
+
+ eclipticPoints.push({
+ h,
+ v
+ });
+ }
+
+ const sortedEntries = eclipticPoints.sort((a, b) => a.h - b.h);
+ return sortedEntries;
+ }
+
+ function getCurrentSunTime(date) {
+ const times = getSunTimes(date);
+ if (!times) {
+ return;
+ }
+ const dateObj = new Date(date);
+
+ const periods = [
+ {
+ name: I18n.tr("Dawn (Astronomical Twilight)"),
+ start: new Date(times.nightEnd),
+ end: new Date(times.nauticalDawn)
+ },
+ {
+ name: I18n.tr("Dawn (Nautical Twilight)"),
+ start: new Date(times.nauticalDawn),
+ end: new Date(times.dawn)
+ },
+ {
+ name: I18n.tr("Dawn (Civil Twilight)"),
+ start: new Date(times.dawn),
+ end: new Date(times.sunrise)
+ },
+ {
+ name: I18n.tr("Sunrise"),
+ start: new Date(times.sunrise),
+ end: new Date(times.sunriseEnd)
+ },
+ {
+ name: I18n.tr("Golden Hour"),
+ start: new Date(times.sunriseEnd),
+ end: new Date(times.goldenHourEnd)
+ },
+ {
+ name: I18n.tr("Morning"),
+ start: new Date(times.goldenHourEnd),
+ end: new Date(times.solarNoon)
+ },
+ {
+ name: I18n.tr("Afternoon"),
+ start: new Date(times.solarNoon),
+ end: new Date(times.goldenHour)
+ },
+ {
+ name: I18n.tr("Golden Hour"),
+ start: new Date(times.goldenHour),
+ end: new Date(times.sunsetStart)
+ },
+ {
+ name: I18n.tr("Sunset"),
+ start: new Date(times.sunsetStart),
+ end: new Date(times.sunset)
+ },
+ {
+ name: I18n.tr("Dusk (Civil Twighlight)"),
+ start: new Date(times.sunset),
+ end: new Date(times.dusk)
+ },
+ {
+ name: I18n.tr("Dusk (Nautical Twilight)"),
+ start: new Date(times.dusk),
+ end: new Date(times.nauticalDusk)
+ },
+ {
+ name: I18n.tr("Dusk (Astronomical Twilight)"),
+ start: new Date(times.nauticalDusk),
+ end: new Date(times.night)
+ },
+ ];
+
+ const sunrise = new Date(times.nightEnd);
+ const sunset = new Date(times.night);
+ const dayPercent = dateObj > sunrise && dateObj < sunset ? (dateObj - sunrise) / (sunset - sunrise) : 0;
+
+ for (let i = 0; i < periods.length; i++) {
+ const {
+ name,
+ start,
+ end
+ } = periods[i];
+ if (dateObj >= start && dateObj < end) {
+ const percent = (dateObj - start) / (end - start);
+ return {
+ period: name,
+ periodIndex: i,
+ periodPercent: Math.min(Math.max(percent, 0), 1),
+ dayPercent: dayPercent
+ };
+ }
+ }
+
+ return {
+ period: I18n.tr("Night"),
+ periodIndex: 0,
+ periodPercent: 0,
+ dayPercent: dayPercent
+ };
+ }
+
+ function getSkyArcPosition(date, isSun) {
+ if (!location) {
+ return null;
+ }
+ const lat = location.latitude;
+ const lon = location.longitude;
+
+ const pos = isSun ? SunCalc.getPosition(date, lat, lon) : SunCalc.getMoonPosition(date, lat, lon);
+
+ const sunIsNorth = getSunDeclination(date) > lat;
+ const transitAzimuth = sunIsNorth ? 0 : Math.PI;
+
+ let h = (((pos.azimuth - transitAzimuth) / (2 * Math.PI)) + 1) % 1;
+ h = Math.max(0, Math.min(1, h));
+ // let v = pos.altitude / (Math.PI/2)
+ let v = Math.sin(pos.altitude);
+ v = Math.max(-1, Math.min(1, v));
+
+ return {
+ h,
+ v
+ };
+ }
+
+ function formatTemp(celsius, includeUnits = true, unitsShort = true) {
+ if (celsius == null) {
+ return null;
+ }
+ const value = SettingsData.useFahrenheit ? Math.round(celsius * (9 / 5) + 32) : celsius;
+ const unit = unitsShort ? "°" : (SettingsData.useFahrenheit ? "°F" : "°C");
+ return includeUnits ? value + unit : value;
+ }
+
+ function formatSpeed(kmh, includeUnits = true) {
+ if (kmh == null) {
+ return null;
+ }
+ if (SettingsData.useFahrenheit) {
+ const value = Math.round(kmh * 0.621371);
+ return includeUnits ? value + " mph" : value;
+ }
+ if (SettingsData.windSpeedUnit === "ms") {
+ const value = (kmh / 3.6).toFixed(1);
+ return includeUnits ? value + " m/s" : value;
+ }
+ return includeUnits ? kmh + " km/h" : kmh;
+ }
+
+ function formatPressure(hpa, includeUnits = true) {
+ if (hpa == null) {
+ return null;
+ }
+ const value = SettingsData.useFahrenheit ? (hpa * 0.02953).toFixed(2) : hpa;
+ const unit = SettingsData.useFahrenheit ? "inHg" : "hPa";
+ return includeUnits ? value + " " + unit : value;
+ }
+
+ function formatPercent(percent, includeUnits = true) {
+ if (percent == null) {
+ return null;
+ }
+ const value = percent;
+ const unit = "%";
+ return includeUnits ? value + unit : value;
+ }
+
+ function formatVisibility(distance) {
+ if (distance == null) {
+ return null;
+ }
+ var value;
+ var unit;
+ if (SettingsData.useFahrenheit) {
+ value = (distance / 1609.344).toFixed(1);
+ unit = "mi";
+ if (value < 1) {
+ value = Math.round(value * 5280 / 50) * 50;
+ unit = "ft";
+ }
+ } else {
+ value = distance;
+ unit = "m";
+ if (value > 1000) {
+ value = (value / 1000).toFixed(1);
+ unit = "km";
+ }
+ }
+
+ return value + " " + unit;
+ }
+
+ function formatTime(isoString) {
+ if (!isoString)
+ return "--";
+
+ try {
+ const date = new Date(isoString);
+ const format = SettingsData.use24HourClock ? "HH:mm" : "h:mm AP";
+ return date.toLocaleTimeString(Qt.locale(), format);
+ } catch (e) {
+ return "--";
+ }
+ }
+
+ function calendarDayDifference(date1, date2) {
+ const d1 = Date.UTC(date1.getFullYear(), date1.getMonth(), date1.getDate());
+ const d2 = Date.UTC(date2.getFullYear(), date2.getMonth(), date2.getDate());
+ return Math.floor((d2 - d1) / (1000 * 60 * 60 * 24));
+ }
+
+ function calendarHourDifference(date1, date2) {
+ const d1 = Date.UTC(date1.getFullYear(), date1.getMonth(), date1.getDate(), date1.getHours());
+ const d2 = Date.UTC(date2.getFullYear(), date2.getMonth(), date2.getDate(), date2.getHours());
+ return Math.floor((d2 - d1) / (1000 * 60 * 60));
+ }
+
+ function formatForecastDay(isoString, index) {
+ if (!isoString)
+ return "--";
+
+ if (index === 0)
+ return I18n.tr("Today");
+ if (index === 1)
+ return I18n.tr("Tomorrow");
+
+ const date = new Date();
+ date.setDate(date.getDate() + index);
+ const locale = I18n.locale();
+ return locale.dayName(date.getDay(), Locale.ShortFormat);
+ }
+
+ function getWeatherApiUrl() {
+ if (!location) {
+ return null;
+ }
+
+ const params = ["latitude=" + location.latitude, "longitude=" + location.longitude, "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,surface_pressure,wind_speed_10m", "daily=sunrise,sunset,temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max", "hourly=temperature_2m,weather_code,precipitation_probability,wind_speed_10m,apparent_temperature,relative_humidity_2m,surface_pressure,visibility,cloud_cover", "timezone=auto", "forecast_days=7"];
+
+ return "https://api.open-meteo.com/v1/forecast?" + params.join('&');
+ }
+
+ function getGeocodingUrl(query) {
+ return "https://geocoding-api.open-meteo.com/v1/search?name=" + encodeURIComponent(query) + "&count=1&language=en&format=json";
+ }
+
+ function addRef() {
+ refCount++;
+
+ if (refCount === 1 && !weather.available && SettingsData.weatherEnabled) {
+ fetchWeather();
+ }
+ }
+
+ function removeRef() {
+ refCount = Math.max(0, refCount - 1);
+ }
+
+ function updateLocation() {
+ const useAuto = SessionData.isGreeterMode ? GreetdSettings.useAutoLocation : SettingsData.useAutoLocation;
+ const coords = SessionData.isGreeterMode ? GreetdSettings.weatherCoordinates : SettingsData.weatherCoordinates;
+ const cityName = SessionData.isGreeterMode ? GreetdSettings.weatherLocation : SettingsData.weatherLocation;
+
+ if (useAuto) {
+ getLocationFromService();
+ return;
+ }
+
+ if (coords) {
+ const parts = coords.split(",");
+ if (parts.length === 2) {
+ const lat = parseFloat(parts[0]);
+ const lon = parseFloat(parts[1]);
+ if (!isNaN(lat) && !isNaN(lon)) {
+ getLocationFromCoords(lat, lon);
+ return;
+ }
+ }
+ }
+
+ if (cityName)
+ getLocationFromCity(cityName);
+ }
+
+ function getLocationFromCoords(lat, lon) {
+ const url = "https://nominatim.openstreetmap.org/reverse?lat=" + lat + "&lon=" + lon + "&format=json&addressdetails=1&accept-language=en";
+ reverseGeocodeFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat(["-H", "User-Agent: DankMaterialShell Weather Widget", url]);
+ reverseGeocodeFetcher.running = true;
+ }
+
+ function getLocationFromCity(city) {
+ cityGeocodeFetcher.command = lowPriorityCmd.concat(curlBaseCmd).concat([getGeocodingUrl(city)]);
+ cityGeocodeFetcher.running = true;
+ }
+
+ function getLocationFromService() {
+ if (!LocationService.valid)
+ return;
+ getLocationFromCoords(LocationService.latitude, LocationService.longitude);
+ }
+
+ function fetchWeather() {
+ if (root.refCount === 0 || !SettingsData.weatherEnabled) {
+ return;
+ }
+
+ if (!location) {
+ updateLocation();
+ return;
+ }
+
+ if (weatherFetcher.running) {
+ return;
+ }
+
+ const now = Date.now();
+ if (now - root.lastFetchTime < root.minFetchInterval) {
+ return;
+ }
+
+ const apiUrl = getWeatherApiUrl();
+ if (!apiUrl) {
+ return;
+ }
+
+ root.lastFetchTime = now;
+ root.weather.loading = true;
+ const weatherCmd = lowPriorityCmd.concat(["curl", "-sS", "--fail", "--connect-timeout", "3", "--max-time", "6", "--limit-rate", "150k", "--compressed"]);
+ weatherFetcher.command = weatherCmd.concat([apiUrl]);
+ weatherFetcher.running = true;
+ }
+
+ function forceRefresh() {
+ root.lastFetchTime = 0; // Reset throttle
+ fetchWeather();
+ }
+
+ function nextInterval() {
+ const jitter = Math.floor(Math.random() * 15000) - 7500;
+ return Math.max(60000, root.updateInterval + jitter);
+ }
+
+ function handleWeatherSuccess() {
+ root.retryAttempts = 0;
+ root.persistentRetryCount = 0;
+ if (persistentRetryTimer.running) {
+ persistentRetryTimer.stop();
+ }
+ if (updateTimer.interval !== root.updateInterval) {
+ updateTimer.interval = root.updateInterval;
+ }
+ }
+
+ function handleWeatherFailure() {
+ root.retryAttempts++;
+ if (root.retryAttempts < root.maxRetryAttempts) {
+ retryTimer.start();
+ } else {
+ root.retryAttempts = 0;
+ if (!root.weather.available) {
+ root.weather.loading = false;
+ }
+ const backoffDelay = Math.min(60000 * Math.pow(2, persistentRetryCount), 300000);
+ persistentRetryCount++;
+ persistentRetryTimer.interval = backoffDelay;
+ persistentRetryTimer.start();
+ }
+ }
+
+ Process {
+ id: reverseGeocodeFetcher
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const raw = text.trim();
+ if (!raw || raw[0] !== "{") {
+ root.handleWeatherFailure();
+ return;
+ }
+
+ try {
+ const data = JSON.parse(raw);
+ const address = data.address || {};
+
+ root.location = {
+ city: address.hamlet || address.city || address.town || address.village || I18n.tr("Unknown"),
+ country: address.country || I18n.tr("Unknown"),
+ latitude: parseFloat(data.lat),
+ longitude: parseFloat(data.lon)
+ };
+
+ fetchWeather();
+ } catch (e) {
+ root.handleWeatherFailure();
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ root.handleWeatherFailure();
+ }
+ }
+ }
+
+ Process {
+ id: cityGeocodeFetcher
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const raw = text.trim();
+ if (!raw || raw[0] !== "{") {
+ root.handleWeatherFailure();
+ return;
+ }
+
+ try {
+ const data = JSON.parse(raw);
+ const results = data.results;
+
+ if (!results || results.length === 0) {
+ throw new Error("No results found");
+ }
+
+ const result = results[0];
+
+ root.location = {
+ city: result.name,
+ country: result.country,
+ latitude: result.latitude,
+ longitude: result.longitude,
+ elevation: data.elevation
+ };
+
+ fetchWeather();
+ } catch (e) {
+ root.handleWeatherFailure();
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ root.handleWeatherFailure();
+ }
+ }
+ }
+
+ Process {
+ id: weatherFetcher
+ running: false
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const raw = text.trim();
+ if (!raw || raw[0] !== "{") {
+ root.handleWeatherFailure();
+ return;
+ }
+
+ try {
+ const data = JSON.parse(raw);
+
+ if (!data.current || !data.daily || !data.hourly) {
+ throw new Error("Required weather data fields missing");
+ }
+
+ const hourly = data.hourly;
+ const hourly_forecast = [];
+ if (hourly.time && hourly.time.length > 0) {
+ for (let i = 0; i < hourly.time.length; i++) {
+ const tempMinC = hourly.temperature_2m_min?.[i] || 0;
+ const tempMaxC = hourly.temperature_2m_max?.[i] || 0;
+ const tempMinF = tempMinC * 9 / 5 + 32;
+ const tempMaxF = tempMaxC * 9 / 5 + 32;
+
+ const tempC = hourly.temperature_2m?.[i] || 0;
+ const tempF = tempC * 9 / 5 + 32;
+ const feelsLikeC = hourly.apparent_temperature?.[i] || tempC;
+ const feelsLikeF = feelsLikeC * 9 / 5 + 32;
+
+ const sunrise = new Date(data.daily.sunrise?.[Math.floor(i / 24)]);
+ const sunset = new Date(data.daily.sunset?.[Math.floor(i / 24)]);
+ const time = new Date(hourly.time[i]);
+ const isDay = sunrise < time && time < sunset;
+
+ hourly_forecast.push({
+ "time": formatTime(hourly.time[i]),
+ "rawTime": hourly.time[i],
+ "temp": Math.round(tempC),
+ "tempF": Math.round(tempF),
+ "feelsLike": Math.round(feelsLikeC),
+ "feelsLikeF": Math.round(feelsLikeF),
+ "wCode": hourly.weather_code?.[i] || 0,
+ "humidity": Math.round(hourly.relative_humidity_2m?.[i] || 0),
+ "wind": Math.round(hourly.wind_speed_10m?.[i] || 0),
+ "pressure": Math.round(hourly.surface_pressure?.[i] || 0),
+ "precipitationProbability": Math.round(hourly.precipitation_probability?.[i] || 0),
+ "visibility": Math.round(hourly.visibility?.[i] || 0),
+ "isDay": isDay
+ });
+ }
+ }
+
+ const daily = data.daily;
+ const forecast = [];
+ if (daily.time && daily.time.length > 0) {
+ for (let i = 0; i < daily.time.length; i++) {
+ const tempMinC = daily.temperature_2m_min?.[i] || 0;
+ const tempMaxC = daily.temperature_2m_max?.[i] || 0;
+ const tempMinF = (tempMinC * 9 / 5 + 32);
+ const tempMaxF = (tempMaxC * 9 / 5 + 32);
+
+ forecast.push({
+ "day": formatForecastDay(daily.time[i], i),
+ "wCode": daily.weather_code?.[i] || 0,
+ "tempMin": Math.round(tempMinC),
+ "tempMax": Math.round(tempMaxC),
+ "tempMinF": Math.round(tempMinF),
+ "tempMaxF": Math.round(tempMaxF),
+ "precipitationProbability": Math.round(daily.precipitation_probability_max?.[i] || 0),
+ "sunrise": daily.sunrise?.[i] ? formatTime(daily.sunrise[i]) : "",
+ "sunset": daily.sunset?.[i] ? formatTime(daily.sunset[i]) : "",
+ "rawSunrise": daily.sunrise?.[i] || "",
+ "rawSunset": daily.sunset?.[i] || ""
+ });
+ }
+ }
+
+ const current = data.current;
+ const currentUnits = data.current_units || {};
+
+ const tempC = current.temperature_2m || 0;
+ const tempF = tempC * 9 / 5 + 32;
+ const feelsLikeC = current.apparent_temperature || tempC;
+ const feelsLikeF = feelsLikeC * 9 / 5 + 32;
+
+ root.weather = {
+ "available": true,
+ "loading": false,
+ "temp": Math.round(tempC),
+ "tempF": Math.round(tempF),
+ "feelsLike": Math.round(feelsLikeC),
+ "feelsLikeF": Math.round(feelsLikeF),
+ "city": root.location?.city || I18n.tr("Unknown"),
+ "country": root.location?.country || I18n.tr("Unknown"),
+ "wCode": current.weather_code || 0,
+ "humidity": Math.round(current.relative_humidity_2m || 0),
+ "wind": Math.round(current.wind_speed_10m || 0),
+ "sunrise": formatTime(daily.sunrise?.[0]) || "06:00",
+ "sunset": formatTime(daily.sunset?.[0]) || "18:00",
+ "rawSunrise": daily.sunrise?.[0] || "",
+ "rawSunset": daily.sunset?.[0] || "",
+ "uv": 0,
+ "pressure": Math.round(current.surface_pressure || 0),
+ "precipitationProbability": Math.round(daily.precipitation_probability_max?.[0] || 0),
+ "isDay": Boolean(current.is_day),
+ "forecast": forecast,
+ "hourlyForecast": hourly_forecast
+ };
+ root.handleWeatherSuccess();
+ } catch (e) {
+ root.handleWeatherFailure();
+ }
+ }
+ }
+
+ onExited: exitCode => {
+ if (exitCode !== 0) {
+ root.handleWeatherFailure();
+ }
+ }
+ }
+
+ Timer {
+ id: updateTimer
+ interval: nextInterval()
+ running: root.refCount > 0 && SettingsData.weatherEnabled && !SessionData.isGreeterMode
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: {
+ root.fetchWeather();
+ interval = nextInterval();
+ }
+ }
+
+ Timer {
+ id: retryTimer
+ interval: root.retryDelay
+ running: false
+ repeat: false
+ onTriggered: {
+ root.fetchWeather();
+ }
+ }
+
+ Timer {
+ id: persistentRetryTimer
+ interval: 60000
+ running: false
+ repeat: false
+ onTriggered: {
+ if (!root.weather.available) {
+ root.weather.loading = true;
+ }
+ root.fetchWeather();
+ }
+ }
+
+ Connections {
+ target: LocationService
+
+ function onLocationChanged(data) {
+ if (!SettingsData.useAutoLocation)
+ return;
+ if (data.latitude === 0 && data.longitude === 0)
+ return;
+ root.getLocationFromCoords(data.latitude, data.longitude);
+ }
+ }
+
+ Component.onCompleted: {
+ SettingsData.weatherCoordinatesChanged.connect(() => {
+ root.location = null;
+ root.weather = {
+ "available": false,
+ "loading": true,
+ "temp": 0,
+ "tempF": 0,
+ "feelsLike": 0,
+ "feelsLikeF": 0,
+ "city": "",
+ "country": "",
+ "wCode": 0,
+ "humidity": 0,
+ "wind": "",
+ "sunrise": "06:00",
+ "sunset": "18:00",
+ "uv": 0,
+ "pressure": 0,
+ "precipitationProbability": 0,
+ "isDay": true,
+ "forecast": []
+ };
+ root.lastFetchTime = 0;
+ root.forceRefresh();
+ });
+
+ SettingsData.weatherLocationChanged.connect(() => {
+ root.location = null;
+ root.lastFetchTime = 0;
+ root.forceRefresh();
+ });
+
+ SettingsData.useAutoLocationChanged.connect(() => {
+ root.location = null;
+ root.weather = {
+ "available": false,
+ "loading": true,
+ "temp": 0,
+ "tempF": 0,
+ "feelsLike": 0,
+ "feelsLikeF": 0,
+ "city": "",
+ "country": "",
+ "wCode": 0,
+ "humidity": 0,
+ "wind": "",
+ "sunrise": "06:00",
+ "sunset": "18:00",
+ "uv": 0,
+ "pressure": 0,
+ "precipitationProbability": 0,
+ "isDay": true,
+ "forecast": []
+ };
+ root.lastFetchTime = 0;
+ root.forceRefresh();
+ });
+
+ SettingsData.weatherEnabledChanged.connect(() => {
+ if (SettingsData.weatherEnabled && root.refCount > 0 && !root.weather.available) {
+ root.forceRefresh();
+ } else if (!SettingsData.weatherEnabled) {
+ updateTimer.stop();
+ retryTimer.stop();
+ persistentRetryTimer.stop();
+ if (weatherFetcher.running) {
+ weatherFetcher.running = false;
+ }
+ }
+ });
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/WlrOutputService.qml b/raveos-hyprland-theme/theme-data/dms/Services/WlrOutputService.qml
new file mode 100644
index 0000000..655a485
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/WlrOutputService.qml
@@ -0,0 +1,346 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+
+Singleton {
+ id: root
+
+ property bool wlrOutputAvailable: false
+ property var outputs: []
+ property int serial: 0
+
+ signal stateChanged
+ signal configurationApplied(bool success, string message)
+
+ Connections {
+ target: DMSService
+
+ function onCapabilitiesReceived() {
+ checkCapabilities();
+ }
+
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ checkCapabilities();
+ return;
+ }
+ wlrOutputAvailable = false;
+ }
+
+ function onWlrOutputStateUpdate(data) {
+ if (!wlrOutputAvailable) {
+ return;
+ }
+ handleStateUpdate(data);
+ }
+ }
+
+ Component.onCompleted: {
+ if (!DMSService.dmsAvailable) {
+ return;
+ }
+ checkCapabilities();
+ }
+
+ function checkCapabilities() {
+ if (!DMSService.capabilities || !Array.isArray(DMSService.capabilities)) {
+ wlrOutputAvailable = false;
+ return;
+ }
+
+ const hasWlrOutput = DMSService.capabilities.includes("wlroutput");
+ if (hasWlrOutput && !wlrOutputAvailable) {
+ wlrOutputAvailable = true;
+ console.info("WlrOutputService: wlr-output-management capability detected");
+ requestState();
+ return;
+ }
+
+ if (!hasWlrOutput) {
+ wlrOutputAvailable = false;
+ }
+ }
+
+ function requestState() {
+ if (!DMSService.isConnected || !wlrOutputAvailable) {
+ return;
+ }
+
+ DMSService.sendRequest("wlroutput.getState", null, response => {
+ if (!response.result) {
+ return;
+ }
+ handleStateUpdate(response.result);
+ });
+ }
+
+ function handleStateUpdate(state) {
+ outputs = state.outputs || [];
+ serial = state.serial || 0;
+
+ if (outputs.length === 0) {
+ console.warn("WlrOutputService: Received empty outputs list");
+ } else {
+ console.log("WlrOutputService: Updated with", outputs.length, "outputs, serial:", serial);
+ outputs.forEach((output, index) => {
+ console.log("WlrOutputService: Output", index, "-", output.name, "enabled:", output.enabled, "mode:", output.currentMode ? output.currentMode.width + "x" + output.currentMode.height + "@" + (output.currentMode.refresh / 1000) + "Hz" : "none");
+ });
+ }
+ stateChanged();
+ }
+
+ function getOutput(name) {
+ for (const output of outputs) {
+ if (output.name === name) {
+ return output;
+ }
+ }
+ return null;
+ }
+
+ function getEnabledOutputs() {
+ return outputs.filter(output => output.enabled);
+ }
+
+ function applyConfiguration(heads, callback) {
+ if (!DMSService.isConnected || !wlrOutputAvailable) {
+ if (callback) {
+ callback(false, "Not connected");
+ }
+ return;
+ }
+
+ console.log("WlrOutputService: Applying configuration for", heads.length, "outputs");
+ heads.forEach((head, index) => {
+ console.log("WlrOutputService: Head", index, "- name:", head.name, "enabled:", head.enabled, "modeId:", head.modeId, "customMode:", JSON.stringify(head.customMode), "position:", JSON.stringify(head.position), "scale:", head.scale, "transform:", head.transform, "adaptiveSync:", head.adaptiveSync);
+ });
+
+ DMSService.sendRequest("wlroutput.applyConfiguration", {
+ "heads": heads
+ }, response => {
+ const success = !response.error;
+ const message = response.error || response.result?.message || "";
+
+ if (response.error) {
+ console.warn("WlrOutputService: applyConfiguration error:", response.error);
+ } else {
+ console.log("WlrOutputService: Configuration applied successfully");
+ }
+
+ configurationApplied(success, message);
+ if (callback) {
+ callback(success, message);
+ }
+ });
+ }
+
+ function testConfiguration(heads, callback) {
+ if (!DMSService.isConnected || !wlrOutputAvailable) {
+ if (callback) {
+ callback(false, "Not connected");
+ }
+ return;
+ }
+
+ console.log("WlrOutputService: Testing configuration for", heads.length, "outputs");
+
+ DMSService.sendRequest("wlroutput.testConfiguration", {
+ "heads": heads
+ }, response => {
+ const success = !response.error;
+ const message = response.error || response.result?.message || "";
+
+ if (response.error) {
+ console.warn("WlrOutputService: testConfiguration error:", response.error);
+ } else {
+ console.log("WlrOutputService: Configuration test passed");
+ }
+
+ if (callback) {
+ callback(success, message);
+ }
+ });
+ }
+
+ function setOutputEnabled(outputName, enabled, callback) {
+ const output = getOutput(outputName);
+ if (!output) {
+ console.warn("WlrOutputService: Output not found:", outputName);
+ if (callback) {
+ callback(false, "Output not found");
+ }
+ return;
+ }
+
+ const heads = [
+ {
+ "name": outputName,
+ "enabled": enabled
+ }
+ ];
+
+ if (enabled && output.currentMode) {
+ heads[0].modeId = output.currentMode.id;
+ }
+
+ applyConfiguration(heads, callback);
+ }
+
+ function setOutputMode(outputName, modeId, callback) {
+ const heads = [
+ {
+ "name": outputName,
+ "enabled": true,
+ "modeId": modeId
+ }
+ ];
+
+ applyConfiguration(heads, callback);
+ }
+
+ function setOutputCustomMode(outputName, width, height, refresh, callback) {
+ const heads = [
+ {
+ "name": outputName,
+ "enabled": true,
+ "customMode": {
+ "width": width,
+ "height": height,
+ "refresh": refresh
+ }
+ }
+ ];
+
+ applyConfiguration(heads, callback);
+ }
+
+ function setOutputPosition(outputName, x, y, callback) {
+ const heads = [
+ {
+ "name": outputName,
+ "enabled": true,
+ "position": {
+ "x": x,
+ "y": y
+ }
+ }
+ ];
+
+ applyConfiguration(heads, callback);
+ }
+
+ function setOutputScale(outputName, scale, callback) {
+ const heads = [
+ {
+ "name": outputName,
+ "enabled": true,
+ "scale": scale
+ }
+ ];
+
+ applyConfiguration(heads, callback);
+ }
+
+ function setOutputTransform(outputName, transform, callback) {
+ const heads = [
+ {
+ "name": outputName,
+ "enabled": true,
+ "transform": transform
+ }
+ ];
+
+ applyConfiguration(heads, callback);
+ }
+
+ function setOutputAdaptiveSync(outputName, state, callback) {
+ const heads = [
+ {
+ "name": outputName,
+ "enabled": true,
+ "adaptiveSync": state
+ }
+ ];
+
+ applyConfiguration(heads, callback);
+ }
+
+ function configureOutput(config, callback) {
+ const heads = [config];
+ applyConfiguration(heads, callback);
+ }
+
+ function configureMultipleOutputs(configs, callback) {
+ applyConfiguration(configs, callback);
+ }
+
+ // High-level apply matching the generateOutputsConfig() pattern used by
+ // NiriService, HyprlandService and DwlService. Instead of writing a
+ // config file, the changes are applied directly via the
+ // wlr-output-management protocol.
+ function applyOutputsConfig(outputsData, connectedOutputs) {
+ if (!wlrOutputAvailable)
+ return;
+ const heads = [];
+ for (const name in outputsData) {
+ if (!connectedOutputs[name])
+ continue;
+ const output = outputsData[name];
+ const mode = (output.modes && output.current_mode >= 0) ? output.modes[output.current_mode] : null;
+ const enabled = !!mode;
+ const head = {
+ "name": name,
+ "enabled": enabled
+ };
+
+ if (enabled) {
+ if (mode.id !== undefined)
+ head.modeId = mode.id;
+ else
+ head.customMode = {
+ "width": mode.width,
+ "height": mode.height,
+ "refresh": mode.refresh_rate
+ };
+
+ if (output.logical) {
+ head.position = {
+ "x": output.logical.x ?? 0,
+ "y": output.logical.y ?? 0
+ };
+ head.scale = output.logical.scale ?? 1.0;
+ head.transform = transformFromName(output.logical.transform);
+ }
+ }
+ heads.push(head);
+ }
+
+ if (heads.length > 0)
+ applyConfiguration(heads);
+ }
+
+ function transformFromName(name) {
+ switch (name) {
+ case "Normal":
+ return 0;
+ case "90":
+ return 1;
+ case "180":
+ return 2;
+ case "270":
+ return 3;
+ case "Flipped":
+ return 4;
+ case "Flipped90":
+ return 5;
+ case "Flipped180":
+ return 6;
+ case "Flipped270":
+ return 7;
+ default:
+ return 0;
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/dms/Services/niri-wpblur.kdl b/raveos-hyprland-theme/theme-data/dms/Services/niri-wpblur.kdl
new file mode 100644
index 0000000..3d58802
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/dms/Services/niri-wpblur.kdl
@@ -0,0 +1,9 @@
+// ! DO NOT EDIT !
+// ! AUTO-GENERATED BY DMS !
+// ! CHANGES WILL BE OVERWRITTEN !
+// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE !
+
+layer-rule {
+ match namespace="dms:blurwallpaper"
+ place-within-backdrop true
+}