diff options
| author | Nippy <nippy@rp1.hu> | 2026-05-08 19:50:49 +0200 |
|---|---|---|
| committer | Nippy <nippy@rp1.hu> | 2026-05-08 19:50:49 +0200 |
| commit | 3462bcc46ecf74f576ea4397f16492c16bd75b10 (patch) | |
| tree | c5ab7aef4498647e057bda8d49c11e5f9dda74c9 /raveos-hyprland-theme/theme-data/dms/Services | |
| parent | 56616f693b4f263cbf0d47da43b67424efa6022d (diff) | |
| download | RaveOS-PKGBUILD-3462bcc46ecf74f576ea4397f16492c16bd75b10.tar.gz RaveOS-PKGBUILD-3462bcc46ecf74f576ea4397f16492c16bd75b10.zip | |
raveos update
Diffstat (limited to 'raveos-hyprland-theme/theme-data/dms/Services')
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 +} |