mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-24 21:42:51 -05:00
switch hto monorepo structure
This commit is contained in:
379
quickshell/Services/AppSearchService.qml
Normal file
379
quickshell/Services/AppSearchService.qml
Normal file
@@ -0,0 +1,379 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import "../Common/fzf.js" as Fzf
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var applications: DesktopEntries.applications.values.filter(app => !app.noDisplay && !app.runInTerminal)
|
||||
|
||||
|
||||
|
||||
function searchApplications(query) {
|
||||
if (!query || query.length === 0) {
|
||||
return applications
|
||||
}
|
||||
if (applications.length === 0)
|
||||
return []
|
||||
|
||||
const queryLower = query.toLowerCase().trim()
|
||||
const scoredApps = []
|
||||
const usageRanking = AppUsageHistoryData.appUsageRanking || {}
|
||||
|
||||
for (const app of applications) {
|
||||
const name = (app.name || "").toLowerCase()
|
||||
const genericName = (app.genericName || "").toLowerCase()
|
||||
const comment = (app.comment || "").toLowerCase()
|
||||
const keywords = app.keywords ? app.keywords.map(k => k.toLowerCase()) : []
|
||||
|
||||
let score = 0
|
||||
let matched = false
|
||||
|
||||
const nameWords = name.trim().split(/\s+/).filter(w => w.length > 0)
|
||||
const containsAsWord = nameWords.includes(queryLower)
|
||||
const startsWithAsWord = nameWords.some(word => word.startsWith(queryLower))
|
||||
|
||||
if (name === queryLower) {
|
||||
score = 10000
|
||||
matched = true
|
||||
} else if (containsAsWord) {
|
||||
score = 9500 + (100 - Math.min(name.length, 100))
|
||||
matched = true
|
||||
} else if (name.startsWith(queryLower)) {
|
||||
score = 9000 + (100 - Math.min(name.length, 100))
|
||||
matched = true
|
||||
} else if (startsWithAsWord) {
|
||||
score = 8500 + (100 - Math.min(name.length, 100))
|
||||
matched = true
|
||||
} else if (name.includes(queryLower)) {
|
||||
score = 8000 + (100 - Math.min(name.length, 100))
|
||||
matched = true
|
||||
} else if (keywords.length > 0) {
|
||||
for (const keyword of keywords) {
|
||||
if (keyword === queryLower) {
|
||||
score = 6000
|
||||
matched = true
|
||||
break
|
||||
} else if (keyword.startsWith(queryLower)) {
|
||||
score = 5500
|
||||
matched = true
|
||||
break
|
||||
} else if (keyword.includes(queryLower)) {
|
||||
score = 5000
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matched && genericName.includes(queryLower)) {
|
||||
if (genericName === queryLower) {
|
||||
score = 9000
|
||||
} else if (genericName.startsWith(queryLower)) {
|
||||
score = 8500
|
||||
} else {
|
||||
const genericWords = genericName.trim().split(/\s+/).filter(w => w.length > 0)
|
||||
if (genericWords.includes(queryLower)) {
|
||||
score = 8000
|
||||
} else if (genericWords.some(word => word.startsWith(queryLower))) {
|
||||
score = 7500
|
||||
} else {
|
||||
score = 7000
|
||||
}
|
||||
}
|
||||
matched = true
|
||||
} else if (!matched && comment.includes(queryLower)) {
|
||||
score = 3000
|
||||
matched = true
|
||||
} else if (!matched) {
|
||||
const nameFinder = new Fzf.Finder([app], {
|
||||
"selector": a => a.name || "",
|
||||
"casing": "case-insensitive",
|
||||
"fuzzy": "v2"
|
||||
})
|
||||
const fuzzyResults = nameFinder.find(query)
|
||||
if (fuzzyResults.length > 0 && fuzzyResults[0].score > 0) {
|
||||
score = Math.min(fuzzyResults[0].score, 2000)
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
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) {
|
||||
const usageCount = usageData.usageCount || 0
|
||||
const lastUsed = usageData.lastUsed || 0
|
||||
const now = Date.now()
|
||||
const daysSinceUsed = (now - lastUsed) / (1000 * 60 * 60 * 24)
|
||||
|
||||
let usageBonus = 0
|
||||
usageBonus += Math.min(usageCount * 100, 2000)
|
||||
|
||||
if (daysSinceUsed < 1) {
|
||||
usageBonus += 1500
|
||||
} else if (daysSinceUsed < 7) {
|
||||
usageBonus += 1000
|
||||
} else if (daysSinceUsed < 30) {
|
||||
usageBonus += 500
|
||||
}
|
||||
|
||||
score += usageBonus
|
||||
}
|
||||
|
||||
scoredApps.push({
|
||||
"app": app,
|
||||
"score": score
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scoredApps.sort((a, b) => b.score - a.score)
|
||||
return scoredApps.slice(0, 50).map(item => item.app)
|
||||
}
|
||||
|
||||
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() {
|
||||
const categories = new Set([I18n.tr("All")])
|
||||
|
||||
for (const app of applications) {
|
||||
const appCategories = getCategoriesForApp(app)
|
||||
appCategories.forEach(cat => categories.add(cat))
|
||||
}
|
||||
|
||||
// Add plugin categories
|
||||
const pluginCategories = getPluginCategories()
|
||||
pluginCategories.forEach(cat => categories.add(cat))
|
||||
|
||||
const result = Array.from(categories).sort()
|
||||
return result
|
||||
}
|
||||
|
||||
function getAppsInCategory(category) {
|
||||
if (category === I18n.tr("All")) {
|
||||
return applications
|
||||
}
|
||||
|
||||
// Check if it's a plugin category
|
||||
const pluginItems = getPluginItems(category, "")
|
||||
if (pluginItems.length > 0) {
|
||||
return pluginItems
|
||||
}
|
||||
|
||||
return applications.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 []
|
||||
}
|
||||
|
||||
const component = PluginService.pluginLauncherComponents[pluginId]
|
||||
if (!component) return []
|
||||
|
||||
try {
|
||||
const instance = component.createObject(root, {
|
||||
"pluginService": PluginService
|
||||
})
|
||||
|
||||
if (instance && typeof instance.getItems === "function") {
|
||||
const items = instance.getItems(query || "")
|
||||
instance.destroy()
|
||||
return items || []
|
||||
}
|
||||
|
||||
if (instance) {
|
||||
instance.destroy()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("AppSearchService: Error getting items from plugin", pluginId, ":", e)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function executePluginItem(item, pluginId) {
|
||||
if (typeof PluginService === "undefined") return false
|
||||
|
||||
const component = PluginService.pluginLauncherComponents[pluginId]
|
||||
if (!component) return false
|
||||
|
||||
try {
|
||||
const instance = component.createObject(root, {
|
||||
"pluginService": PluginService
|
||||
})
|
||||
|
||||
if (instance && typeof instance.executeItem === "function") {
|
||||
instance.executeItem(item)
|
||||
instance.destroy()
|
||||
return true
|
||||
}
|
||||
|
||||
if (instance) {
|
||||
instance.destroy()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("AppSearchService: Error executing item from plugin", pluginId, ":", e)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
635
quickshell/Services/AudioService.qml
Normal file
635
quickshell/Services/AudioService.qml
Normal file
@@ -0,0 +1,635 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property PwNode sink: Pipewire.defaultAudioSink
|
||||
readonly property PwNode source: Pipewire.defaultAudioSource
|
||||
|
||||
property bool suppressOSD: true
|
||||
property bool soundsAvailable: false
|
||||
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 mediaDevices: null
|
||||
property var mediaDevicesConnections: null
|
||||
|
||||
signal micMuteChanged
|
||||
|
||||
Timer {
|
||||
id: startupTimer
|
||||
interval: 500
|
||||
repeat: false
|
||||
running: true
|
||||
onTriggered: root.suppressOSD = false
|
||||
}
|
||||
|
||||
function detectSoundsAvailability() {
|
||||
try {
|
||||
const testObj = Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import QtMultimedia
|
||||
Item {}
|
||||
`, root, "AudioService.TestComponent")
|
||||
if (testObj) {
|
||||
testObj.destroy()
|
||||
}
|
||||
soundsAvailable = true
|
||||
return true
|
||||
} catch (e) {
|
||||
soundsAvailable = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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; 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"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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: 1.0
|
||||
}
|
||||
}
|
||||
`, root, "AudioService.VolumeChangeSound")
|
||||
|
||||
const powerPlugPath = getSoundPath("power-plug")
|
||||
powerPlugSound = Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import QtMultimedia
|
||||
MediaPlayer {
|
||||
source: "${powerPlugPath}"
|
||||
audioOutput: AudioOutput {
|
||||
${deviceProperty}volume: 1.0
|
||||
}
|
||||
}
|
||||
`, root, "AudioService.PowerPlugSound")
|
||||
|
||||
const powerUnplugPath = getSoundPath("power-unplug")
|
||||
powerUnplugSound = Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import QtMultimedia
|
||||
MediaPlayer {
|
||||
source: "${powerUnplugPath}"
|
||||
audioOutput: AudioOutput {
|
||||
${deviceProperty}volume: 1.0
|
||||
}
|
||||
}
|
||||
`, root, "AudioService.PowerUnplugSound")
|
||||
|
||||
const messagePath = getSoundPath("message")
|
||||
normalNotificationSound = Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import QtMultimedia
|
||||
MediaPlayer {
|
||||
source: "${messagePath}"
|
||||
audioOutput: AudioOutput {
|
||||
${deviceProperty}volume: 1.0
|
||||
}
|
||||
}
|
||||
`, root, "AudioService.NormalNotificationSound")
|
||||
|
||||
const messageNewInstantPath = getSoundPath("message-new-instant")
|
||||
criticalNotificationSound = Qt.createQmlObject(`
|
||||
import QtQuick
|
||||
import QtMultimedia
|
||||
MediaPlayer {
|
||||
source: "${messageNewInstantPath}"
|
||||
audioOutput: AudioOutput {
|
||||
${deviceProperty}volume: 1.0
|
||||
}
|
||||
}
|
||||
`, root, "AudioService.CriticalNotificationSound")
|
||||
} catch (e) {
|
||||
console.warn("AudioService: Error creating sound players:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function playVolumeChangeSound() {
|
||||
if (soundsAvailable && volumeChangeSound) {
|
||||
volumeChangeSound.play()
|
||||
}
|
||||
}
|
||||
|
||||
function playPowerPlugSound() {
|
||||
if (soundsAvailable && powerPlugSound) {
|
||||
powerPlugSound.play()
|
||||
}
|
||||
}
|
||||
|
||||
function playPowerUnplugSound() {
|
||||
if (soundsAvailable && powerUnplugSound) {
|
||||
powerUnplugSound.play()
|
||||
}
|
||||
}
|
||||
|
||||
function playNormalNotificationSound() {
|
||||
if (soundsAvailable && normalNotificationSound && !SessionData.doNotDisturb) {
|
||||
normalNotificationSound.play()
|
||||
}
|
||||
}
|
||||
|
||||
function playCriticalNotificationSound() {
|
||||
if (soundsAvailable && criticalNotificationSound && !SessionData.doNotDisturb) {
|
||||
criticalNotificationSound.play()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: volumeSoundDebounce
|
||||
interval: 50
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (!root.suppressOSD && SettingsData.soundsEnabled && SettingsData.soundVolumeChanged) {
|
||||
root.playVolumeChangeSound()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.sink && root.sink.audio ? root.sink.audio : null
|
||||
enabled: root.sink && root.sink.audio
|
||||
ignoreUnknownSignals: true
|
||||
|
||||
function onVolumeChanged() {
|
||||
volumeSoundDebounce.restart()
|
||||
}
|
||||
}
|
||||
|
||||
function displayName(node) {
|
||||
if (!node) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (node.properties && node.properties["device.description"]) {
|
||||
return node.properties["device.description"]
|
||||
}
|
||||
|
||||
if (node.description && node.description !== node.name) {
|
||||
return node.description
|
||||
}
|
||||
|
||||
if (node.nickname && node.nickname !== node.name) {
|
||||
return node.nickname
|
||||
}
|
||||
|
||||
if (node.name.includes("analog-stereo")) {
|
||||
return "Built-in Speakers"
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
|
||||
function setVolume(percentage) {
|
||||
if (!root.sink?.audio) {
|
||||
return "No audio sink available"
|
||||
}
|
||||
|
||||
const clampedVolume = Math.max(0, Math.min(100, 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 currentVolume = Math.round(root.sink.audio.volume * 100)
|
||||
const stepValue = parseInt(step || "5")
|
||||
const newVolume = Math.max(0, Math.min(100, 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 currentVolume = Math.round(root.sink.audio.volume * 100)
|
||||
const stepValue = parseInt(step || "5")
|
||||
const newVolume = Math.max(0, Math.min(100, 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)" : ""
|
||||
result += `Output: ${volume}%${muteStatus}\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
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onUseSystemSoundThemeChanged() {
|
||||
reloadSounds()
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (!detectSoundsAvailability()) {
|
||||
console.warn("AudioService: QtMultimedia not available - sound effects disabled")
|
||||
} else {
|
||||
console.info("AudioService: Sound effects enabled")
|
||||
checkGsettings()
|
||||
Qt.callLater(createSoundPlayers)
|
||||
}
|
||||
}
|
||||
}
|
||||
228
quickshell/Services/BatteryService.qml
Normal file
228
quickshell/Services/BatteryService.qml
Normal file
@@ -0,0 +1,228 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.UPower
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool suppressSound: true
|
||||
property bool previousPluggedState: false
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
return Math.round((batteryEnergy * 100) / batteryCapacity)
|
||||
}
|
||||
readonly property bool isCharging: batteryAvailable && batteries.some(b => b.state === UPowerDeviceState.Charging)
|
||||
|
||||
// Is the system plugged in (none of the batteries are discharging or empty)
|
||||
readonly property bool isPluggedIn: batteryAvailable && batteries.every(b => b.state !== UPowerDeviceState.Discharging)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Aggregated battery status
|
||||
readonly property string batteryStatus: {
|
||||
if (!batteryAvailable) {
|
||||
return "No Battery"
|
||||
}
|
||||
|
||||
if (isCharging && !batteries.some(b => b.changeRate > 0)) return "Plugged In"
|
||||
|
||||
const states = batteries.map(b => b.state)
|
||||
if (states.every(s => s === states[0])) return UPowerDeviceState.toString(states[0])
|
||||
|
||||
return isCharging ? "Charging" : (isPluggedIn ? "Plugged In" : "Discharging")
|
||||
}
|
||||
|
||||
readonly property bool suggestPowerSaver: batteryAvailable && isLowBattery && UPower.onBattery && (typeof PowerProfiles !== "undefined" && PowerProfiles.profile !== PowerProfile.PowerSaver)
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
523
quickshell/Services/BluetoothService.qml
Normal file
523
quickshell/Services/BluetoothService.qml
Normal file
@@ -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 aHasRealName = aName.includes(" ") && aName.length > 3
|
||||
const bHasRealName = bName.includes(" ") && bName.length > 3
|
||||
|
||||
if (aHasRealName && !bHasRealName) {
|
||||
return -1
|
||||
}
|
||||
if (!aHasRealName && bHasRealName) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const aSignal = (a.signalStrength !== undefined && a.signalStrength > 0) ? a.signalStrength : 0
|
||||
const bSignal = (b.signalStrength !== undefined && b.signalStrength > 0) ? b.signalStrength : 0
|
||||
return bSignal - aSignal
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
316
quickshell/Services/CalendarService.qml
Normal file
316
quickshell/Services/CalendarService.qml
Normal file
@@ -0,0 +1,316 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
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(Qt.locale(), event['start-date'], root.khalDateFormat)
|
||||
} else {
|
||||
startDate = new Date()
|
||||
}
|
||||
if (event['end-date']) {
|
||||
endDate = Date.fromLocaleString(Qt.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
quickshell/Services/CavaService.qml
Normal file
58
quickshell/Services/CavaService.qml
Normal file
@@ -0,0 +1,58 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
cavaCheck.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: cavaProcess
|
||||
|
||||
running: root.cavaAvailable && root.refCount > 0
|
||||
command: ["sh", "-c", `printf '[general]\\nmode=normal\\nframerate=25\\nautosens=0\\nsensitivity=30\\nbars=6\\nlower_cutoff_freq=50\\nhigher_cutoff_freq=12000\\n[output]\\nmethod=raw\\nraw_target=/dev/stdout\\ndata_format=ascii\\nchannels=mono\\nmono_option=average\\n[smoothing]\\nnoise_reduction=35\\nintegral=90\\ngravity=95\\nignore=2\\nmonstercat=1.5' | cava -p /dev/stdin`]
|
||||
|
||||
onRunningChanged: {
|
||||
if (!running) {
|
||||
root.values = Array(6).fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: data => {
|
||||
if (root.refCount > 0 && data.trim()) {
|
||||
let points = data.split(";").map(p => {
|
||||
return parseInt(p.trim(), 10)
|
||||
}).filter(p => {
|
||||
return !isNaN(p)
|
||||
})
|
||||
if (points.length >= 6) {
|
||||
root.values = points.slice(0, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
482
quickshell/Services/CompositorService.qml
Normal file
482
quickshell/Services/CompositorService.qml
Normal file
@@ -0,0 +1,482 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
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 isLabwc: false
|
||||
property string compositor: "unknown"
|
||||
|
||||
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 labwcPid: Quickshell.env("LABWC_PID")
|
||||
property bool useNiriSorting: isNiri && NiriService
|
||||
|
||||
property var sortedToplevels: []
|
||||
property bool _sortScheduled: false
|
||||
|
||||
signal toplevelsChanged()
|
||||
|
||||
function getScreenScale(screen) {
|
||||
if (!screen) return 1
|
||||
|
||||
if (Quickshell.env("QT_WAYLAND_FORCE_DPI") || Quickshell.env("QT_SCALE_FACTOR")) {
|
||||
return screen.devicePixelRatio || 1
|
||||
}
|
||||
|
||||
if (WlrOutputService.wlrOutputAvailable && screen) {
|
||||
const wlrOutput = WlrOutputService.getOutput(screen.name)
|
||||
if (wlrOutput?.enabled && wlrOutput.scale !== undefined && wlrOutput.scale > 0) {
|
||||
return wlrOutput.scale
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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: {
|
||||
detectCompositor()
|
||||
scheduleSort()
|
||||
Qt.callLater(() => NiriService.generateNiriLayoutConfig())
|
||||
}
|
||||
|
||||
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 filterHyprlandCurrentWorkspaceSafe(toplevels, screenName) {
|
||||
if (!toplevels || toplevels.length === 0 || !Hyprland.toplevels) return toplevels
|
||||
|
||||
let currentWorkspaceId = null
|
||||
try {
|
||||
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"], "")
|
||||
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
|
||||
|
||||
// Map wayland → wsId snapshot
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
function detectCompositor() {
|
||||
if (hyprlandSignature && hyprlandSignature.length > 0 &&
|
||||
!niriSocket && !swaySocket && !labwcPid) {
|
||||
isHyprland = true
|
||||
isNiri = false
|
||||
isDwl = false
|
||||
isSway = 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
|
||||
isLabwc = false
|
||||
compositor = "niri"
|
||||
console.info("CompositorService: Detected Niri with socket:", niriSocket)
|
||||
NiriService.generateNiriBinds()
|
||||
NiriService.generateNiriBlurrule()
|
||||
}
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (swaySocket && swaySocket.length > 0) {
|
||||
Proc.runCommand("swaySocketCheck", ["test", "-S", swaySocket], (output, exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
isNiri = false
|
||||
isHyprland = false
|
||||
isDwl = false
|
||||
isSway = true
|
||||
isLabwc = false
|
||||
compositor = "sway"
|
||||
console.info("CompositorService: Detected Sway with socket:", swaySocket)
|
||||
}
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (labwcPid && labwcPid.length > 0) {
|
||||
isHyprland = false
|
||||
isNiri = false
|
||||
isDwl = false
|
||||
isSway = 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
|
||||
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
|
||||
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) { try { I3.dispatch("output * dpms off") } catch(_){} return }
|
||||
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) { try { I3.dispatch("output * dpms on") } catch(_){} return }
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
371
quickshell/Services/CupsService.qml
Normal file
371
quickshell/Services/CupsService.qml
Normal file
@@ -0,0 +1,371 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property int refCount: 0
|
||||
|
||||
property var printerNames: []
|
||||
property var printers: []
|
||||
property string selectedPrinter: ""
|
||||
|
||||
property bool cupsAvailable: false
|
||||
property bool stateInitialized: 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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] = {
|
||||
"state": printer.state,
|
||||
"stateReason": printer.stateReason,
|
||||
"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
890
quickshell/Services/DMSNetworkService.qml
Normal file
890
quickshell/Services/DMSNetworkService.qml
Normal file
@@ -0,0 +1,890 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
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 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) {
|
||||
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)
|
||||
|
||||
readonly property string socketPath: Quickshell.env("DMS_SOCKET")
|
||||
|
||||
Component.onCompleted: {
|
||||
root.userPreference = SettingsData.networkPreference
|
||||
lastConnectedVpnUuid = SettingsData.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 || ""
|
||||
|
||||
credentialsNeeded(credentialsToken, credentialsSSID, credentialsSetting, credentialsFields, credentialsHints, credentialsReason, connType, connName, vpnService)
|
||||
}
|
||||
|
||||
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 || ""
|
||||
|
||||
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 || ""
|
||||
|
||||
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
|
||||
SettingsData.set("vpnLastConnected", 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userPreference = state.preference || "auto"
|
||||
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 ") + 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"))
|
||||
} else {
|
||||
connectionError = ""
|
||||
connectionStatus = "connected"
|
||||
ToastService.showInfo(I18n.tr("Configuration activated"))
|
||||
}
|
||||
|
||||
isConnecting = false
|
||||
})
|
||||
}
|
||||
|
||||
function scanWifi() {
|
||||
if (!networkAvailable || isScanning || !wifiEnabled) return
|
||||
|
||||
isScanning = true
|
||||
DMSService.sendRequest("network.wifi.scan", null, 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 = "") {
|
||||
if (!networkAvailable || isConnecting) return
|
||||
|
||||
pendingConnectionSSID = ssid
|
||||
pendingConnectionStartTime = Date.now()
|
||||
connectionError = ""
|
||||
connectionStatus = "connecting"
|
||||
credentialsRequested = false
|
||||
|
||||
const params = { ssid: ssid }
|
||||
|
||||
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 ") + ssid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function disconnectWifi() {
|
||||
if (!networkAvailable || !wifiInterface) return
|
||||
|
||||
DMSService.sendRequest("network.wifi.disconnect", null, response => {
|
||||
if (response.error) {
|
||||
ToastService.showError(I18n.tr("Failed to disconnect WiFi"))
|
||||
} 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 ") + 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"))
|
||||
} 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 = "") {
|
||||
connectToWifi(ssid, password, username, anonymousIdentity, domainSuffixMatch)
|
||||
setNetworkPreference("wifi")
|
||||
}
|
||||
|
||||
function toggleNetworkConnection(type) {
|
||||
if (!networkAvailable) return
|
||||
|
||||
if (type === "ethernet") {
|
||||
if (networkStatus === "ethernet") {
|
||||
DMSService.sendRequest("network.ethernet.disconnect", null, null)
|
||||
} else {
|
||||
DMSService.sendRequest("network.ethernet.connect", null, 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 += "Inteface: " + info.iface + "\\n"
|
||||
details += "Driver: " + info.driver + "\\n"
|
||||
details += "MAC Addr: " + info.hwAddr + "\\n"
|
||||
details += "Speed: " + info.speed + " Mb/s\\n\\n"
|
||||
|
||||
details += "IPv4 informations:\\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 informations:\\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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 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"))
|
||||
} else {
|
||||
ToastService.showInfo(autoconnect ? I18n.tr("Autoconnect enabled") : I18n.tr("Autoconnect disabled"))
|
||||
Qt.callLater(() => getState())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
536
quickshell/Services/DMSService.qml
Normal file
536
quickshell/Services/DMSService.qml
Normal file
@@ -0,0 +1,536 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool dmsAvailable: false
|
||||
property var capabilities: []
|
||||
property int apiVersion: 0
|
||||
readonly property int expectedApiVersion: 1
|
||||
property var availablePlugins: []
|
||||
property var installedPlugins: []
|
||||
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 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 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)
|
||||
|
||||
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput"]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
console.log("DMSService: Request socket <<", line)
|
||||
|
||||
try {
|
||||
const response = JSON.parse(line)
|
||||
handleResponse(response)
|
||||
} catch (e) {
|
||||
console.warn("DMSService: Failed to parse request response:", line, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
console.log("DMSService: Subscribe socket <<", line)
|
||||
|
||||
try {
|
||||
const response = JSON.parse(line)
|
||||
handleSubscriptionEvent(response)
|
||||
} catch (e) {
|
||||
console.warn("DMSService: Failed to parse subscription event:", line, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")) {
|
||||
console.warn("DMSService: Cannot add specific subscription when subscribed to '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"]
|
||||
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", "bluetooth", "cups", "dwl", "brightness", "extworkspace"]
|
||||
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
|
||||
capabilities = data.capabilities || []
|
||||
|
||||
console.info("DMSService: Connected (API v" + apiVersion + ") -", 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
143
quickshell/Services/DSearchService.qml
Normal file
143
quickshell/Services/DSearchService.qml
Normal file
@@ -0,0 +1,143 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool dsearchAvailable: false
|
||||
property int searchIdCounter: 0
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.ext) {
|
||||
args.push("-e", params.ext)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
Proc.runCommand("dsearch-search", 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
|
||||
}
|
||||
}
|
||||
59
quickshell/Services/DesktopService.qml
Normal file
59
quickshell/Services/DesktopService.qml
Normal file
@@ -0,0 +1,59 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
function resolveIconPath(moddedAppId) {
|
||||
const entry = DesktopEntries.heuristicLookup(moddedAppId)
|
||||
const appIds = [moddedAppId, moddedAppId.toLowerCase()];
|
||||
|
||||
const lastPart = moddedAppId.split('.').pop();
|
||||
if (lastPart && lastPart !== moddedAppId) {
|
||||
appIds.push(lastPart);
|
||||
|
||||
const firstChar = lastPart.charAt(0);
|
||||
const rest = lastPart.slice(1);
|
||||
let toggled;
|
||||
|
||||
if (firstChar === firstChar.toLowerCase()) {
|
||||
toggled = firstChar.toUpperCase() + rest;
|
||||
} else {
|
||||
toggled = firstChar.toLowerCase() + rest;
|
||||
}
|
||||
|
||||
if (toggled !== lastPart) {
|
||||
appIds.push(toggled);
|
||||
}
|
||||
}
|
||||
for (const appId of appIds){
|
||||
let icon = Quickshell.iconPath(entry?.icon, true)
|
||||
if (icon && icon !== "") return icon
|
||||
|
||||
let execPath = entry?.execString?.replace(/\/bin.*/, "")
|
||||
if (!execPath) continue
|
||||
|
||||
//Check that the app is installed with nix/guix
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
699
quickshell/Services/DgopService.qml
Normal file
699
quickshell/Services/DgopService.qml
Normal file
@@ -0,0 +1,699 @@
|
||||
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
|
||||
|
||||
// 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 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 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) {
|
||||
// Increment reference count for this module
|
||||
const currentCount = moduleRefCounts[module] || 0
|
||||
moduleRefCounts[module] = currentCount + 1
|
||||
console.log("Adding ref for module:", module, "count:", moduleRefCounts[module])
|
||||
|
||||
// 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) {
|
||||
// Decrement reference count
|
||||
moduleRefCounts[module] = currentCount - 1
|
||||
console.log("Removing ref for module:", module, "count:", moduleRefCounts[module])
|
||||
} else if (currentCount === 1) {
|
||||
// Remove completely when count reaches 0
|
||||
delete moduleRefCounts[module]
|
||||
const index = enabledModules.indexOf(module)
|
||||
if (index > -1) {
|
||||
enabledModules.splice(index, 1)
|
||||
modulesChanged = true
|
||||
console.log("Disabling module:", module, "(no more refs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
console.log("Adding GPU PCI ID ref:", pciId, "count:", gpuPciIdRefCounts[pciId])
|
||||
// Force property change notification
|
||||
gpuPciIdRefCounts = Object.assign({}, gpuPciIdRefCounts)
|
||||
}
|
||||
|
||||
function removeGpuPciId(pciId) {
|
||||
const currentCount = gpuPciIdRefCounts[pciId] || 0
|
||||
if (currentCount > 1) {
|
||||
// Decrement reference count
|
||||
gpuPciIdRefCounts[pciId] = currentCount - 1
|
||||
console.log("Removing GPU PCI ID ref:", pciId, "count:", gpuPciIdRefCounts[pciId])
|
||||
} 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
|
||||
}
|
||||
|
||||
console.log("Removing GPU PCI ID completely:", pciId)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Load GPU metadata once at startup for basic info
|
||||
gpuInitProcess.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 = cpu.usage || 0
|
||||
cpuFrequency = cpu.frequency || 0
|
||||
cpuTemperature = 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
|
||||
|
||||
totalMemoryMB = totalKB / 1024
|
||||
availableMemoryMB = availableKB / 1024
|
||||
freeMemoryMB = freeKB / 1024
|
||||
usedMemoryMB = totalMemoryMB - availableMemoryMB
|
||||
memoryUsage = totalKB > 0 ? ((totalKB - availableKB) / totalKB) * 100 : 0
|
||||
|
||||
totalMemoryKB = totalKB
|
||||
usedMemoryKB = totalKB - availableKB
|
||||
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++
|
||||
|
||||
for (const proc of data.processes) {
|
||||
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 || "",
|
||||
"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 || ""
|
||||
}
|
||||
|
||||
if (data.hardware) {
|
||||
const hw = data.hardware
|
||||
hostname = hw.hostname || ""
|
||||
kernelVersion = hw.kernel || ""
|
||||
distribution = hw.distro || ""
|
||||
architecture = hw.arch || ""
|
||||
motherboard = (hw.bios && hw.bios.motherboard) || ""
|
||||
biosVersion = (hw.bios && hw.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 setSortBy(newSortBy) {
|
||||
if (newSortBy !== currentSort) {
|
||||
currentSort = newSortBy
|
||||
applySorting()
|
||||
}
|
||||
}
|
||||
|
||||
function applySorting() {
|
||||
if (!allProcesses || allProcesses.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const sorted = allProcesses.slice()
|
||||
sorted.sort((a, b) => {
|
||||
let valueA, valueB
|
||||
|
||||
switch (currentSort) {
|
||||
case "cpu":
|
||||
valueA = a.cpu || 0
|
||||
valueB = b.cpu || 0
|
||||
return valueB - valueA
|
||||
case "memory":
|
||||
valueA = a.memoryKB || 0
|
||||
valueB = b.memoryKB || 0
|
||||
return valueB - valueA
|
||||
case "name":
|
||||
valueA = (a.command || "").toLowerCase()
|
||||
valueB = (b.command || "").toLowerCase()
|
||||
return valueA.localeCompare(valueB)
|
||||
case "pid":
|
||||
valueA = a.pid || 0
|
||||
valueB = b.pid || 0
|
||||
return valueA - valueB
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
onCommandChanged: {
|
||||
|
||||
//console.log("DgopService command:", JSON.stringify(command))
|
||||
}
|
||||
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: dgopCheckProcess
|
||||
command: ["which", "dgop"]
|
||||
running: false
|
||||
onExited: exitCode => {
|
||||
dgopAvailable = (exitCode === 0)
|
||||
if (dgopAvailable) {
|
||||
initializeGpuMetadata()
|
||||
// Load persisted GPU PCI IDs from session state
|
||||
if (SessionData.enabledGpuPciIds && SessionData.enabledGpuPciIds.length > 0) {
|
||||
for (const pciId of SessionData.enabledGpuPciIds) {
|
||||
addGpuPciId(pciId)
|
||||
}
|
||||
// Trigger update if we already have active modules
|
||||
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
|
||||
}
|
||||
}
|
||||
936
quickshell/Services/DisplayService.qml
Normal file
936
quickshell/Services/DisplayService.qml
Normal file
@@ -0,0 +1,936 @@
|
||||
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 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
|
||||
|
||||
signal brightnessChanged(bool showOsd)
|
||||
signal deviceSwitched
|
||||
|
||||
property bool nightModeActive: nightModeEnabled
|
||||
|
||||
property bool nightModeEnabled: false
|
||||
property bool automationAvailable: false
|
||||
property bool gammaControlAvailable: false
|
||||
|
||||
function updateSingleDevice(device) {
|
||||
const deviceIndex = devices.findIndex(d => d.id === device.id)
|
||||
if (deviceIndex !== -1) {
|
||||
const newDevices = [...devices]
|
||||
const existingDevice = devices[deviceIndex]
|
||||
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) {
|
||||
displayValue = userSetValue
|
||||
} else {
|
||||
displayValue = linearToExponential(device.currentPercent, device.id)
|
||||
}
|
||||
}
|
||||
|
||||
const newBrightness = Object.assign({}, deviceBrightness)
|
||||
newBrightness[device.id] = displayValue
|
||||
deviceBrightness = newBrightness
|
||||
brightnessVersion++
|
||||
}
|
||||
|
||||
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 = {}
|
||||
for (const device of state.devices) {
|
||||
const isExponential = SessionData.getBrightnessExponential(device.id)
|
||||
const userSetValue = deviceBrightnessUserSet[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
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (!brightnessInitialized) {
|
||||
brightnessInitialized = 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
|
||||
}
|
||||
|
||||
const deviceInfo = getCurrentDeviceInfoByName(actualDevice)
|
||||
const isExponential = SessionData.getBrightnessExponential(actualDevice)
|
||||
|
||||
let minValue = 0
|
||||
let maxValue = 100
|
||||
|
||||
if (isExponential) {
|
||||
minValue = 1
|
||||
maxValue = 100
|
||||
} else {
|
||||
minValue = (deviceInfo && (deviceInfo.class === "backlight" || deviceInfo.class === "ddc")) ? 1 : 0
|
||||
maxValue = deviceInfo?.displayMax || 100
|
||||
}
|
||||
|
||||
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 newBrightness = Object.assign({}, deviceBrightness)
|
||||
newBrightness[actualDevice] = clampedValue
|
||||
deviceBrightness = newBrightness
|
||||
brightnessVersion++
|
||||
|
||||
if (isExponential) {
|
||||
const newUserSet = Object.assign({}, deviceBrightnessUserSet)
|
||||
newUserSet[actualDevice] = clampedValue
|
||||
deviceBrightnessUserSet = newUserSet
|
||||
SessionData.setBrightnessUserSetValue(actualDevice, clampedValue)
|
||||
}
|
||||
|
||||
if (!suppressOsd) {
|
||||
brightnessChanged(true)
|
||||
}
|
||||
|
||||
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("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 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("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("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": 6500
|
||||
}, response => {
|
||||
if (response.error) {
|
||||
console.error("DisplayService: Failed to set temperature:", response.error)
|
||||
ToastService.showError("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("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("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("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("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("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
|
||||
}
|
||||
|
||||
if (SessionData.nightModeAutoEnabled) {
|
||||
startAutomation()
|
||||
} else {
|
||||
applyNightModeDirectly()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: restartTimer
|
||||
property string nextAction: ""
|
||||
interval: 100
|
||||
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()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
nightModeEnabled = SessionData.nightModeEnabled
|
||||
deviceBrightnessUserSet = Object.assign({}, SessionData.brightnessUserSetValues)
|
||||
if (DMSService.isConnected) {
|
||||
checkGammaControlAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
|
||||
function onScreensChanged() {
|
||||
rescanDevices()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 targetDevice = device || ""
|
||||
|
||||
if (targetDevice && !root.devices.some(d => d.id === targetDevice)) {
|
||||
return "Device not found: " + targetDevice
|
||||
}
|
||||
|
||||
const deviceInfo = targetDevice ? root.getCurrentDeviceInfoByName(targetDevice) : null
|
||||
const minValue = (deviceInfo && (deviceInfo.class === "backlight" || deviceInfo.class === "ddc")) ? 1 : 0
|
||||
const clampedValue = Math.max(minValue, Math.min(100, value))
|
||||
|
||||
root.lastIpcDevice = targetDevice
|
||||
if (targetDevice && targetDevice !== root.currentDevice) {
|
||||
root.setCurrentDevice(targetDevice, false)
|
||||
}
|
||||
root.setBrightness(clampedValue, targetDevice, false)
|
||||
|
||||
if (targetDevice) {
|
||||
return "Brightness set to " + clampedValue + "% on " + targetDevice
|
||||
} else {
|
||||
return "Brightness set to " + clampedValue + "%"
|
||||
}
|
||||
}
|
||||
|
||||
function increment(step: string, device: string): string {
|
||||
if (!root.brightnessAvailable) {
|
||||
return "Brightness control not available"
|
||||
}
|
||||
|
||||
const targetDevice = device || ""
|
||||
const actualDevice = targetDevice === "" ? root.getDefaultDevice() : targetDevice
|
||||
|
||||
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 maxValue = 100
|
||||
if (isExponential) {
|
||||
maxValue = 100
|
||||
} else {
|
||||
maxValue = deviceInfo?.displayMax || 100
|
||||
}
|
||||
|
||||
const newBrightness = Math.min(maxValue, currentBrightness + stepValue)
|
||||
|
||||
root.setBrightness(newBrightness, actualDevice, false)
|
||||
|
||||
return "Brightness increased by " + stepValue + "%" + (targetDevice ? " on " + targetDevice : "")
|
||||
}
|
||||
|
||||
function decrement(step: string, device: string): string {
|
||||
if (!root.brightnessAvailable) {
|
||||
return "Brightness control not available"
|
||||
}
|
||||
|
||||
const targetDevice = device || ""
|
||||
const actualDevice = targetDevice === "" ? root.getDefaultDevice() : targetDevice
|
||||
|
||||
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
|
||||
if (isExponential) {
|
||||
minValue = 1
|
||||
} else {
|
||||
minValue = (deviceInfo && (deviceInfo.class === "backlight" || deviceInfo.class === "ddc")) ? 1 : 0
|
||||
}
|
||||
|
||||
const newBrightness = Math.max(minValue, currentBrightness - stepValue)
|
||||
|
||||
root.setBrightness(newBrightness, actualDevice, false)
|
||||
|
||||
return "Brightness decreased by " + stepValue + "%" + (targetDevice ? " on " + targetDevice : "")
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
// IPC Handler for night mode control
|
||||
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 {
|
||||
return root.nightModeEnabled ? "Night mode is enabled" : "Night mode is disabled"
|
||||
}
|
||||
|
||||
function temperature(value: string): string {
|
||||
if (!value) {
|
||||
return "Current temperature: " + SessionData.nightModeTemperature + "K"
|
||||
}
|
||||
|
||||
const temp = parseInt(value)
|
||||
if (isNaN(temp)) {
|
||||
return "Invalid temperature. Use a value between 2500 and 6000 (in steps of 500)"
|
||||
}
|
||||
|
||||
// Validate temperature is in valid range and steps
|
||||
if (temp < 2500 || temp > 6000) {
|
||||
return "Temperature must be between 2500K and 6000K"
|
||||
}
|
||||
|
||||
// Round to nearest 500
|
||||
const rounded = Math.round(temp / 500) * 500
|
||||
|
||||
SessionData.setNightModeTemperature(rounded)
|
||||
|
||||
// Restart night mode with new temperature if active
|
||||
if (root.nightModeEnabled) {
|
||||
if (SessionData.nightModeAutoEnabled) {
|
||||
root.startAutomation()
|
||||
} else {
|
||||
root.applyNightModeDirectly()
|
||||
}
|
||||
}
|
||||
|
||||
if (rounded !== temp) {
|
||||
return "Night mode temperature set to " + rounded + "K (rounded from " + temp + "K)"
|
||||
} else {
|
||||
return "Night mode temperature set to " + rounded + "K"
|
||||
}
|
||||
}
|
||||
|
||||
target: "night"
|
||||
}
|
||||
}
|
||||
259
quickshell/Services/DwlService.qml
Normal file
259
quickshell/Services/DwlService.qml
Normal file
@@ -0,0 +1,259 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool dwlAvailable: false
|
||||
property var outputs: ({})
|
||||
property var tagCount: 9
|
||||
property var layouts: []
|
||||
property string activeOutput: ""
|
||||
property var outputScales: ({})
|
||||
|
||||
signal stateChanged()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
259
quickshell/Services/ExtWorkspaceService.qml
Normal file
259
quickshell/Services/ExtWorkspaceService.qml
Normal file
@@ -0,0 +1,259 @@
|
||||
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) {
|
||||
extWorkspaceAvailable = true
|
||||
console.info("ExtWorkspaceService: ext-workspace capability detected")
|
||||
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)
|
||||
const visible = workspaces.filter(ws => !ws.hidden).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)
|
||||
}
|
||||
}
|
||||
196
quickshell/Services/IdleService.qml
Normal file
196
quickshell/Services/IdleService.qml
Normal file
@@ -0,0 +1,196 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
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 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 requestMonitorOff()
|
||||
signal requestMonitorOn()
|
||||
signal requestSuspend()
|
||||
|
||||
property var monitorOffMonitor: null
|
||||
property var lockMonitor: null
|
||||
property var suspendMonitor: null
|
||||
property var mediaInhibitor: null
|
||||
|
||||
function wake() {
|
||||
requestMonitorOn()
|
||||
}
|
||||
|
||||
function createMediaInhibitor() {
|
||||
if (!idleInhibitorAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mediaInhibitor) {
|
||||
mediaInhibitor.destroy()
|
||||
mediaInhibitor = null
|
||||
}
|
||||
|
||||
const inhibitorString = `
|
||||
import QtQuick
|
||||
import Quickshell.Wayland
|
||||
|
||||
IdleInhibitor {
|
||||
active: false
|
||||
}
|
||||
`
|
||||
|
||||
mediaInhibitor = Qt.createQmlObject(inhibitorString, root, "IdleService.MediaInhibitor")
|
||||
mediaInhibitor.active = Qt.binding(() => root.mediaPlaying)
|
||||
}
|
||||
|
||||
function destroyMediaInhibitor() {
|
||||
if (mediaInhibitor) {
|
||||
mediaInhibitor.destroy()
|
||||
mediaInhibitor = null
|
||||
}
|
||||
}
|
||||
|
||||
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.enabled = Qt.binding(() => root._enableGate && root.enabled && root.idleMonitorAvailable && root.monitorTimeout > 0)
|
||||
monitorOffMonitor.respectInhibitors = Qt.binding(() => root.respectInhibitors)
|
||||
monitorOffMonitor.timeout = Qt.binding(() => root.monitorTimeout)
|
||||
monitorOffMonitor.isIdleChanged.connect(function() {
|
||||
if (monitorOffMonitor.isIdle) {
|
||||
root.requestMonitorOff()
|
||||
} else {
|
||||
root.requestMonitorOn()
|
||||
}
|
||||
})
|
||||
|
||||
lockMonitor = Qt.createQmlObject(qmlString, root, "IdleService.LockMonitor")
|
||||
lockMonitor.enabled = Qt.binding(() => root._enableGate && root.enabled && root.idleMonitorAvailable && root.lockTimeout > 0)
|
||||
lockMonitor.respectInhibitors = Qt.binding(() => root.respectInhibitors)
|
||||
lockMonitor.timeout = Qt.binding(() => root.lockTimeout)
|
||||
lockMonitor.isIdleChanged.connect(function() {
|
||||
if (lockMonitor.isIdle) {
|
||||
root.lockRequested()
|
||||
}
|
||||
})
|
||||
|
||||
suspendMonitor = Qt.createQmlObject(qmlString, root, "IdleService.SuspendMonitor")
|
||||
suspendMonitor.enabled = Qt.binding(() => root._enableGate && root.enabled && root.idleMonitorAvailable && root.suspendTimeout > 0)
|
||||
suspendMonitor.respectInhibitors = Qt.binding(() => root.respectInhibitors)
|
||||
suspendMonitor.timeout = Qt.binding(() => root.suspendTimeout)
|
||||
suspendMonitor.isIdleChanged.connect(function() {
|
||||
if (suspendMonitor.isIdle) {
|
||||
root.requestSuspend()
|
||||
}
|
||||
})
|
||||
|
||||
if (SettingsData.preventIdleForMedia) {
|
||||
createMediaInhibitor()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("IdleService: Error creating IdleMonitors:", e)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onRequestMonitorOff() {
|
||||
CompositorService.powerOffMonitors()
|
||||
}
|
||||
|
||||
function onRequestMonitorOn() {
|
||||
CompositorService.powerOnMonitors()
|
||||
}
|
||||
|
||||
function onRequestSuspend() {
|
||||
SessionService.suspendWithBehavior(root.suspendBehavior)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SessionService
|
||||
function onPrepareForSleep() {
|
||||
if (SettingsData.lockBeforeSuspend) {
|
||||
root.lockRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: SettingsData
|
||||
function onPreventIdleForMediaChanged() {
|
||||
if (SettingsData.preventIdleForMedia) {
|
||||
createMediaInhibitor()
|
||||
} else {
|
||||
destroyMediaInhibitor()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
60
quickshell/Services/KeybindsService.qml
Normal file
60
quickshell/Services/KeybindsService.qml
Normal file
@@ -0,0 +1,60 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtCore
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string currentProvider: "hyprland"
|
||||
property var keybinds: ({"title": "", "provider": "", "binds": []})
|
||||
|
||||
Process {
|
||||
id: getKeybinds
|
||||
running: false
|
||||
command: ["dms", "keybinds", "show", root.currentProvider]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
root.keybinds = JSON.parse(text)
|
||||
} catch (e) {
|
||||
console.error("[KeybindsService] Error parsing keybinds:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (code) => {
|
||||
if (code !== 0 && code !== 15) {
|
||||
console.warn("[KeybindsService] Process exited with code:", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 500
|
||||
running: true
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
getKeybinds.running = true
|
||||
}
|
||||
}
|
||||
|
||||
function loadProvider(provider) {
|
||||
root.currentProvider = provider
|
||||
reload()
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (getKeybinds.running) {
|
||||
getKeybinds.running = false
|
||||
}
|
||||
Qt.callLater(function() {
|
||||
getKeybinds.running = true
|
||||
})
|
||||
}
|
||||
}
|
||||
987
quickshell/Services/LegacyNetworkService.qml
Normal file
987
quickshell/Services/LegacyNetworkService.qml
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
16
quickshell/Services/MprisController.qml
Normal file
16
quickshell/Services/MprisController.qml
Normal file
@@ -0,0 +1,16 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Mpris
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property list<MprisPlayer> availablePlayers: Mpris.players.values
|
||||
|
||||
property MprisPlayer activePlayer: availablePlayers.find(p => p.isPlaying) ?? availablePlayers.find(p => p.canControl && p.canPlay) ?? null
|
||||
}
|
||||
292
quickshell/Services/NetworkService.qml
Normal file
292
quickshell/Services/NetworkService.qml
Normal file
@@ -0,0 +1,292 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
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 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 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 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)
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
1009
quickshell/Services/NiriService.qml
Normal file
1009
quickshell/Services/NiriService.qml
Normal file
File diff suppressed because it is too large
Load Diff
432
quickshell/Services/NotepadStorageService.qml
Normal file
432
quickshell/Services/NotepadStorageService.qml
Normal file
@@ -0,0 +1,432 @@
|
||||
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 loadTabContent(tabIndex, callback) {
|
||||
if (tabIndex < 0 || tabIndex >= tabs.length) {
|
||||
callback("")
|
||||
return
|
||||
}
|
||||
|
||||
var tab = tabs[tabIndex]
|
||||
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) => {
|
||||
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 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 = 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]
|
||||
}
|
||||
}
|
||||
702
quickshell/Services/NotificationService.qml
Normal file
702
quickshell/Services/NotificationService.qml
Normal file
@@ -0,0 +1,702 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Notifications
|
||||
import Quickshell.Widgets
|
||||
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 list<NotifWrapper> notificationQueue: []
|
||||
property list<NotifWrapper> visibleNotifications: []
|
||||
property int maxVisibleNotifications: 3
|
||||
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 int maxStoredNotifications: 300
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
function _nowSec() {
|
||||
return Date.now() / 1000.0
|
||||
}
|
||||
|
||||
function _ingressAllowed(notif) {
|
||||
const t = _nowSec()
|
||||
if (t - _lastIngressSec >= 1.0) {
|
||||
_lastIngressSec = t
|
||||
_ingressCountThisSec = 0
|
||||
}
|
||||
_ingressCountThisSec += 1
|
||||
if (notif.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.notification && wrapper.notification.urgency === NotificationUrgency.Critical
|
||||
wrapper.isPersistent = isCritical || (timeoutMs === 0)
|
||||
}
|
||||
|
||||
function _trimStored() {
|
||||
if (notifications.length > maxStoredNotifications) {
|
||||
const overflow = notifications.length - maxStoredNotifications
|
||||
const toDrop = []
|
||||
for (var i = notifications.length - 1; i >= 0 && toDrop.length < overflow; --i) {
|
||||
const w = notifications[i]
|
||||
if (w && w.notification && w.urgency !== NotificationUrgency.Critical) {
|
||||
toDrop.push(w)
|
||||
}
|
||||
}
|
||||
for (var i = notifications.length - 1; i >= 0 && toDrop.length < overflow; --i) {
|
||||
const w = notifications[i]
|
||||
if (w && w.notification && toDrop.indexOf(w) === -1) {
|
||||
toDrop.push(w)
|
||||
}
|
||||
}
|
||||
for (const w of toDrop) {
|
||||
try {
|
||||
w.notification.dismiss()
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOverlayOpen() {
|
||||
popupsDisabled = true
|
||||
addGate.stop()
|
||||
addGateBusy = false
|
||||
|
||||
notificationQueue = []
|
||||
for (const w of visibleNotifications) {
|
||||
if (w) {
|
||||
w.popup = false
|
||||
}
|
||||
}
|
||||
visibleNotifications = []
|
||||
_recomputeGroupsLater()
|
||||
}
|
||||
|
||||
function onOverlayClose() {
|
||||
popupsDisabled = false
|
||||
processQueue()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: addGate
|
||||
interval: enterAnimMs + 50
|
||||
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
|
||||
|
||||
if (!_ingressAllowed(notif)) {
|
||||
if (notif.urgency !== NotificationUrgency.Critical) {
|
||||
try {
|
||||
notif.dismiss()
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (SettingsData.soundsEnabled && SettingsData.soundNewNotification) {
|
||||
if (notif.urgency === NotificationUrgency.Critical) {
|
||||
AudioService.playCriticalNotificationSound()
|
||||
} else {
|
||||
AudioService.playNormalNotificationSound()
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowPopup = !root.popupsDisabled && !SessionData.doNotDisturb
|
||||
const isTransient = notif.transient
|
||||
const wrapper = notifComponent.createObject(root, {
|
||||
"popup": shouldShowPopup,
|
||||
"notification": notif
|
||||
})
|
||||
|
||||
if (wrapper) {
|
||||
root.allWrappers.push(wrapper)
|
||||
if (!isTransient) {
|
||||
root.notifications.push(wrapper)
|
||||
_trimStored()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
onPopupChanged: {
|
||||
if (!popup) {
|
||||
removeFromVisibleNotifications(wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
readonly property Timer timer: Timer {
|
||||
interval: {
|
||||
if (!wrapper.notification) {
|
||||
return 5000
|
||||
}
|
||||
|
||||
switch (wrapper.notification.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)
|
||||
}
|
||||
|
||||
if (daysDiff === 1) {
|
||||
return `yesterday, ${formatTime(time)}`
|
||||
}
|
||||
|
||||
return `${daysDiff} days ago`
|
||||
}
|
||||
|
||||
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
|
||||
readonly property string body: notification.body
|
||||
readonly property string htmlBody: {
|
||||
if (body && (body.includes('<') && body.includes('>'))) {
|
||||
return body
|
||||
}
|
||||
return Markdown2Html.markdownToHtml(body)
|
||||
}
|
||||
readonly property string appIcon: notification.appIcon
|
||||
readonly property string appName: {
|
||||
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)
|
||||
}
|
||||
readonly property int urgency: notification.urgency
|
||||
readonly property list<NotificationAction> actions: notification.actions
|
||||
|
||||
readonly property Connections conn: Connections {
|
||||
target: wrapper.notification.Retainable
|
||||
|
||||
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 clearAllNotifications() {
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
|
||||
function processQueue() {
|
||||
if (addGateBusy) {
|
||||
return
|
||||
}
|
||||
if (popupsDisabled) {
|
||||
return
|
||||
}
|
||||
if (SessionData.doNotDisturb) {
|
||||
return
|
||||
}
|
||||
if (notificationQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const activePopupCount = visibleNotifications.filter(n => n && n.popup).length
|
||||
if (activePopupCount >= 4) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = notificationQueue.shift()
|
||||
|
||||
next.seq = ++seqCounter
|
||||
visibleNotifications = [...visibleNotifications, next]
|
||||
next.popup = true
|
||||
|
||||
if (next.timer.interval > 0) {
|
||||
next.timer.start()
|
||||
}
|
||||
|
||||
addGateBusy = true
|
||||
addGate.restart()
|
||||
}
|
||||
|
||||
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 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) 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) => {
|
||||
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) 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) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
631
quickshell/Services/PluginService.qml
Normal file
631
quickshell/Services/PluginService.qml
Normal file
@@ -0,0 +1,631 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtCore
|
||||
import QtQuick
|
||||
import Qt.labs.folderlistmodel
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var availablePlugins: ({})
|
||||
property var loadedPlugins: ({})
|
||||
property var pluginWidgetComponents: ({})
|
||||
property var pluginDaemonComponents: ({})
|
||||
property var pluginLauncherComponents: ({})
|
||||
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: ({})
|
||||
|
||||
signal pluginLoaded(string pluginId)
|
||||
signal pluginUnloaded(string pluginId)
|
||||
signal pluginLoadFailed(string pluginId, string error)
|
||||
signal pluginDataChanged(string pluginId)
|
||||
signal pluginListUpdated()
|
||||
signal globalVarChanged(string pluginId, string varName)
|
||||
|
||||
Timer {
|
||||
id: resyncDebounce
|
||||
interval: 120
|
||||
repeat: false
|
||||
onTriggered: resyncAll()
|
||||
}
|
||||
|
||||
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
|
||||
nameFilters: ["plugin.json"]
|
||||
|
||||
onCountChanged: resyncDebounce.restart()
|
||||
onStatusChanged: if (status === FolderListModel.Ready) resyncDebounce.restart()
|
||||
}
|
||||
|
||||
FolderListModel {
|
||||
id: systemWatcher
|
||||
showDirs: true
|
||||
showFiles: false
|
||||
showDotAndDotDot: false
|
||||
nameFilters: ["plugin.json"]
|
||||
|
||||
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]
|
||||
})
|
||||
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
|
||||
|
||||
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 }
|
||||
pluginListUpdated()
|
||||
const enabled = 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) {
|
||||
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 map = isDaemon ? pluginDaemonComponents : isLauncher ? pluginLauncherComponents : pluginWidgetComponents
|
||||
|
||||
const prevInstance = pluginInstances[pluginId]
|
||||
if (prevInstance) {
|
||||
prevInstance.destroy()
|
||||
const newInstances = Object.assign({}, pluginInstances)
|
||||
delete newInstances[pluginId]
|
||||
pluginInstances = newInstances
|
||||
}
|
||||
|
||||
try {
|
||||
const url = "file://" + plugin.componentPath
|
||||
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 instance = comp.createObject(root, { "pluginId": pluginId })
|
||||
if (!instance) {
|
||||
console.error("PluginService: failed to instantiate daemon:", pluginId, comp.errorString())
|
||||
pluginLoadFailed(pluginId, comp.errorString())
|
||||
return false
|
||||
}
|
||||
const newInstances = Object.assign({}, pluginInstances)
|
||||
newInstances[pluginId] = instance
|
||||
pluginInstances = newInstances
|
||||
|
||||
const newDaemons = Object.assign({}, pluginDaemonComponents)
|
||||
newDaemons[pluginId] = comp
|
||||
pluginDaemonComponents = newDaemons
|
||||
} else if (isLauncher) {
|
||||
const newLaunchers = Object.assign({}, pluginLauncherComponents)
|
||||
newLaunchers[pluginId] = comp
|
||||
pluginLauncherComponents = newLaunchers
|
||||
} else {
|
||||
const newComponents = Object.assign({}, pluginWidgetComponents)
|
||||
newComponents[pluginId] = comp
|
||||
pluginWidgetComponents = newComponents
|
||||
}
|
||||
|
||||
plugin.loaded = true
|
||||
loadedPlugins[pluginId] = plugin
|
||||
|
||||
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 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 (pluginWidgetComponents[pluginId]) {
|
||||
const newComponents = Object.assign({}, pluginWidgetComponents)
|
||||
delete newComponents[pluginId]
|
||||
pluginWidgetComponents = newComponents
|
||||
}
|
||||
|
||||
plugin.loaded = false
|
||||
delete loadedPlugins[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 getAvailablePlugins() {
|
||||
const result = []
|
||||
for (const key in availablePlugins) {
|
||||
result.push(availablePlugins[key])
|
||||
}
|
||||
return 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 leftWidgets = SettingsData.dankBarLeftWidgets
|
||||
const centerWidgets = SettingsData.dankBarCenterWidgets
|
||||
const rightWidgets = SettingsData.dankBarRightWidgets
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 saveAllPluginSettings() {
|
||||
SettingsData.savePluginSettings()
|
||||
}
|
||||
|
||||
function scanPlugins() {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
40
quickshell/Services/PolkitService.qml
Normal file
40
quickshell/Services/PolkitService.qml
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
295
quickshell/Services/PopoutService.qml
Normal file
295
quickshell/Services/PopoutService.qml
Normal file
@@ -0,0 +1,295 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var controlCenterPopout: null
|
||||
property var notificationCenterPopout: null
|
||||
property var appDrawerPopout: null
|
||||
property var processListPopout: null
|
||||
property var dankDashPopout: null
|
||||
property var batteryPopout: null
|
||||
property var vpnPopout: null
|
||||
property var systemUpdatePopout: null
|
||||
|
||||
property var settingsModal: null
|
||||
property var clipboardHistoryModal: null
|
||||
property var spotlightModal: null
|
||||
property var powerMenuModal: null
|
||||
property var processListModal: null
|
||||
property var colorPickerModal: null
|
||||
property var notificationModal: null
|
||||
property var wifiPasswordModal: null
|
||||
property var networkInfoModal: null
|
||||
|
||||
property var notepadSlideouts: []
|
||||
|
||||
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 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 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 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 toggleProcessList(x, y, width, section, screen) {
|
||||
if (processListPopout) {
|
||||
setPosition(processListPopout, x, y, width, section, screen)
|
||||
processListPopout.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
function openDankDash(tabIndex, x, y, width, section, screen) {
|
||||
if (dankDashPopout) {
|
||||
if (arguments.length >= 6) {
|
||||
setPosition(dankDashPopout, x, y, width, section, screen)
|
||||
}
|
||||
dankDashPopout.currentTabIndex = tabIndex || 0
|
||||
dankDashPopout.dashVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
function closeDankDash() {
|
||||
if (dankDashPopout) {
|
||||
dankDashPopout.dashVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDankDash(tabIndex, x, y, width, section, screen) {
|
||||
if (dankDashPopout) {
|
||||
if (arguments.length >= 6) {
|
||||
setPosition(dankDashPopout, x, y, width, section, screen)
|
||||
}
|
||||
if (dankDashPopout.dashVisible) {
|
||||
dankDashPopout.dashVisible = false
|
||||
} else {
|
||||
dankDashPopout.currentTabIndex = tabIndex || 0
|
||||
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 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 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 toggleSystemUpdate(x, y, width, section, screen) {
|
||||
if (systemUpdatePopout) {
|
||||
setPosition(systemUpdatePopout, x, y, width, section, screen)
|
||||
systemUpdatePopout.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
settingsModal?.show()
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
settingsModal?.close()
|
||||
}
|
||||
|
||||
function openClipboardHistory() {
|
||||
clipboardHistoryModal?.show()
|
||||
}
|
||||
|
||||
function closeClipboardHistory() {
|
||||
clipboardHistoryModal?.close()
|
||||
}
|
||||
|
||||
function openSpotlight() {
|
||||
spotlightModal?.show()
|
||||
}
|
||||
|
||||
function closeSpotlight() {
|
||||
spotlightModal?.close()
|
||||
}
|
||||
|
||||
function openPowerMenu() {
|
||||
powerMenuModal?.openCentered()
|
||||
}
|
||||
|
||||
function closePowerMenu() {
|
||||
powerMenuModal?.close()
|
||||
}
|
||||
|
||||
function togglePowerMenu() {
|
||||
if (powerMenuModal) {
|
||||
if (powerMenuModal.shouldBeVisible) {
|
||||
powerMenuModal.close()
|
||||
} else {
|
||||
powerMenuModal.openCentered()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showProcessListModal() {
|
||||
processListModal?.show()
|
||||
}
|
||||
|
||||
function hideProcessListModal() {
|
||||
processListModal?.hide()
|
||||
}
|
||||
|
||||
function toggleProcessListModal() {
|
||||
processListModal?.toggle()
|
||||
}
|
||||
|
||||
function showColorPicker() {
|
||||
colorPickerModal?.show()
|
||||
}
|
||||
|
||||
function hideColorPicker() {
|
||||
colorPickerModal?.close()
|
||||
}
|
||||
|
||||
function showNotificationModal() {
|
||||
notificationModal?.show()
|
||||
}
|
||||
|
||||
function hideNotificationModal() {
|
||||
notificationModal?.close()
|
||||
}
|
||||
|
||||
function showWifiPasswordModal() {
|
||||
wifiPasswordModal?.show()
|
||||
}
|
||||
|
||||
function hideWifiPasswordModal() {
|
||||
wifiPasswordModal?.close()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
323
quickshell/Services/PortalService.qml
Normal file
323
quickshell/Services/PortalService.qml
Normal file
@@ -0,0 +1,323 @@
|
||||
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: ") + 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
143
quickshell/Services/PrivacyService.qml
Normal file
143
quickshell/Services/PrivacyService.qml
Normal file
@@ -0,0 +1,143 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
|
||||
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.audio && !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 (!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/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 combined = appName + " " + nodeName
|
||||
return /xdg-desktop-portal|xdpw|screencast|screen|gnome shell|kwin|obs/.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"
|
||||
}
|
||||
}
|
||||
480
quickshell/Services/SessionService.qml
Normal file
480
quickshell/Services/SessionService.qml
Normal file
@@ -0,0 +1,480 @@
|
||||
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 bool hasPrimeRun: false
|
||||
|
||||
readonly property bool nativeInhibitorAvailable: {
|
||||
try {
|
||||
return typeof IdleInhibitor !== "undefined"
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
property bool loginctlAvailable: 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 prepareForSleep()
|
||||
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
|
||||
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: detectPrimeRunProcess
|
||||
running: false
|
||||
command: ["which", "prime-run"]
|
||||
|
||||
onExited: function (exitCode) {
|
||||
hasPrimeRun = (exitCode === 0)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// * Apps
|
||||
function launchDesktopEntry(desktopEntry, usePrimeRun) {
|
||||
let cmd = desktopEntry.command
|
||||
if (usePrimeRun && hasPrimeRun) {
|
||||
cmd = ["prime-run"].concat(cmd)
|
||||
}
|
||||
if (SettingsData.launchPrefix && SettingsData.launchPrefix.length > 0) {
|
||||
const launchPrefix = SettingsData.launchPrefix.trim().split(" ")
|
||||
cmd = launchPrefix.concat(cmd)
|
||||
}
|
||||
|
||||
Quickshell.execDetached({
|
||||
command: cmd,
|
||||
workingDirectory: desktopEntry.workingDirectory || Quickshell.env("HOME"),
|
||||
});
|
||||
}
|
||||
|
||||
function launchDesktopAction(desktopEntry, action, usePrimeRun) {
|
||||
let cmd = action.command
|
||||
if (usePrimeRun && hasPrimeRun) {
|
||||
cmd = ["prime-run"].concat(cmd)
|
||||
}
|
||||
if (SettingsData.launchPrefix && SettingsData.launchPrefix.length > 0) {
|
||||
const launchPrefix = SettingsData.launchPrefix.trim().split(" ")
|
||||
cmd = launchPrefix.concat(cmd)
|
||||
}
|
||||
|
||||
Quickshell.execDetached({
|
||||
command: cmd,
|
||||
workingDirectory: desktopEntry.workingDirectory || Quickshell.env("HOME"),
|
||||
});
|
||||
}
|
||||
|
||||
// * 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) {
|
||||
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() {
|
||||
if (SettingsData.customPowerActionHibernate.length === 0) {
|
||||
Quickshell.execDetached([isElogind ? "loginctl" : "systemctl", "hibernate"])
|
||||
} else {
|
||||
Quickshell.execDetached(["sh", "-c", SettingsData.customPowerActionHibernate])
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
sessionId = state.sessionId || ""
|
||||
sessionPath = state.sessionPath || ""
|
||||
locked = state.locked || false
|
||||
active = state.active || false
|
||||
idleHint = state.idleHint || false
|
||||
lockedHint = state.lockedHint || false
|
||||
sessionType = state.sessionType || ""
|
||||
userName = state.userName || ""
|
||||
seat = state.seat || ""
|
||||
display = state.display || ""
|
||||
|
||||
if (locked && !wasLocked) {
|
||||
sessionLocked()
|
||||
} else if (!locked && wasLocked) {
|
||||
sessionUnlocked()
|
||||
}
|
||||
|
||||
loginctlStateChanged()
|
||||
}
|
||||
|
||||
function handleLoginctlEvent(event) {
|
||||
if (event.event === "Lock") {
|
||||
locked = true
|
||||
lockedHint = true
|
||||
sessionLocked()
|
||||
} else if (event.event === "Unlock") {
|
||||
locked = false
|
||||
lockedHint = false
|
||||
sessionUnlocked()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
284
quickshell/Services/SystemUpdateService.qml
Normal file
284
quickshell/Services/SystemUpdateService.qml
Normal file
@@ -0,0 +1,284 @@
|
||||
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: ""
|
||||
|
||||
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: {
|
||||
"listUpdatesSettings": {
|
||||
"params": ["-Qu"],
|
||||
"correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates
|
||||
},
|
||||
"upgradeSettings": {
|
||||
"params": ["-Syu"],
|
||||
"requiresSudo": false
|
||||
},
|
||||
"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,
|
||||
"paru": archBasedPMSettings,
|
||||
"dnf": fedoraBasedPMSettings
|
||||
}
|
||||
readonly property list<string> supportedDistributions: ["arch", "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: 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 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 (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 "Updates complete! 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 "Updates complete! 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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
174
quickshell/Services/ToastService.qml
Normal file
174
quickshell/Services/ToastService.qml
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
88
quickshell/Services/UserInfoService.qml
Normal file
88
quickshell/Services/UserInfoService.qml
Normal file
@@ -0,0 +1,88 @@
|
||||
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 uptime: ""
|
||||
property string shortUptime: ""
|
||||
property string hostname: ""
|
||||
property bool profileAvailable: false
|
||||
|
||||
function getUserInfo() {
|
||||
Proc.runCommand("userInfo", ["bash", "-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)
|
||||
}
|
||||
|
||||
function getUptime() {
|
||||
Proc.runCommand("uptime", ["cat", "/proc/uptime"], (output, exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
root.uptime = "Unknown"
|
||||
return
|
||||
}
|
||||
const seconds = parseInt(output.split(" ")[0])
|
||||
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"}`)
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
root.uptime = `up ${parts.join(", ")}`
|
||||
} else {
|
||||
root.uptime = `up ${seconds} seconds`
|
||||
}
|
||||
|
||||
let shortUptime = "up"
|
||||
if (days > 0) {
|
||||
shortUptime += ` ${days}d`
|
||||
}
|
||||
if (hours > 0) {
|
||||
shortUptime += ` ${hours}h`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
shortUptime += ` ${minutes}m`
|
||||
}
|
||||
root.shortUptime = shortUptime
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function refreshUserInfo() {
|
||||
getUserInfo()
|
||||
getUptime()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
getUserInfo()
|
||||
getUptime()
|
||||
}
|
||||
}
|
||||
436
quickshell/Services/WallpaperCyclingService.qml
Normal file
436
quickshell/Services/WallpaperCyclingService.qml
Normal file
@@ -0,0 +1,436 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool cyclingActive: false
|
||||
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.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()
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
if (SessionData.wallpaperCyclingMode === "interval") {
|
||||
intervalTimer.interval = cachedCyclingInterval * 1000
|
||||
intervalTimer.start()
|
||||
cyclingActive = true
|
||||
} else if (SessionData.wallpaperCyclingMode === "time") {
|
||||
cyclingActive = true
|
||||
checkTimeBasedCycling()
|
||||
}
|
||||
}
|
||||
|
||||
function stopCycling() {
|
||||
intervalTimer.stop()
|
||||
cyclingActive = false
|
||||
}
|
||||
|
||||
function startMonitorCycling(screenName, settings) {
|
||||
if (settings.mode === "interval") {
|
||||
var timer = monitorTimers[screenName]
|
||||
if (!timer && monitorTimerComponent && monitorTimerComponent.status === Component.Ready) {
|
||||
var newTimers = Object.assign({}, monitorTimers)
|
||||
newTimers[screenName] = monitorTimerComponent.createObject(root)
|
||||
newTimers[screenName].targetScreen = screenName
|
||||
monitorTimers = newTimers
|
||||
timer = monitorTimers[screenName]
|
||||
}
|
||||
if (timer) {
|
||||
timer.interval = settings.interval * 1000
|
||||
timer.start()
|
||||
}
|
||||
} else if (settings.mode === "time") {
|
||||
var newChecks = Object.assign({}, monitorLastTimeChecks)
|
||||
newChecks[screenName] = ""
|
||||
monitorLastTimeChecks = newChecks
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
newProcesses[screenName] = monitorProcessComponent.createObject(root)
|
||||
monitorProcesses = newProcesses
|
||||
process = monitorProcesses[screenName]
|
||||
}
|
||||
|
||||
if (process) {
|
||||
process.command = ["sh", "-c", `ls -1 "${wallpaperDir}"/*.jpg "${wallpaperDir}"/*.jpeg "${wallpaperDir}"/*.png "${wallpaperDir}"/*.bmp "${wallpaperDir}"/*.gif "${wallpaperDir}"/*.webp 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", `ls -1 "${wallpaperDir}"/*.jpg "${wallpaperDir}"/*.jpeg "${wallpaperDir}"/*.png "${wallpaperDir}"/*.bmp "${wallpaperDir}"/*.gif "${wallpaperDir}"/*.webp 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)
|
||||
newProcesses[screenName] = monitorProcessComponent.createObject(root)
|
||||
monitorProcesses = newProcesses
|
||||
process = monitorProcesses[screenName]
|
||||
}
|
||||
|
||||
if (process) {
|
||||
process.command = ["sh", "-c", `ls -1 "${wallpaperDir}"/*.jpg "${wallpaperDir}"/*.jpeg "${wallpaperDir}"/*.png "${wallpaperDir}"/*.bmp "${wallpaperDir}"/*.gif "${wallpaperDir}"/*.webp 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", `ls -1 "${wallpaperDir}"/*.jpg "${wallpaperDir}"/*.jpeg "${wallpaperDir}"/*.png "${wallpaperDir}"/*.bmp "${wallpaperDir}"/*.gif "${wallpaperDir}"/*.webp 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() {
|
||||
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: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
651
quickshell/Services/WeatherService.qml
Normal file
651
quickshell/Services/WeatherService.qml
Normal file
@@ -0,0 +1,651 @@
|
||||
pragma Singleton
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property int refCount: 0
|
||||
|
||||
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": "Clear",
|
||||
"1": "Clear",
|
||||
"2": "Partly cloudy",
|
||||
"3": "Overcast",
|
||||
"45": "Fog",
|
||||
"48": "Fog",
|
||||
"51": "Drizzle",
|
||||
"53": "Drizzle",
|
||||
"55": "Drizzle",
|
||||
"56": "Freezing drizzle",
|
||||
"57": "Freezing drizzle",
|
||||
"61": "Light rain",
|
||||
"63": "Rain",
|
||||
"65": "Heavy rain",
|
||||
"66": "Light rain",
|
||||
"67": "Heavy rain",
|
||||
"71": "Light snow",
|
||||
"73": "Snow",
|
||||
"75": "Heavy snow",
|
||||
"77": "Snow",
|
||||
"80": "Light rain",
|
||||
"81": "Rain",
|
||||
"82": "Heavy rain",
|
||||
"85": "Light snow showers",
|
||||
"86": "Heavy snow showers",
|
||||
"95": "Thunderstorm",
|
||||
"96": "Thunderstorm with hail",
|
||||
"99": "Thunderstorm with hail"
|
||||
}
|
||||
return conditions[String(code)] || "Unknown"
|
||||
}
|
||||
|
||||
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 formatForecastDay(isoString, index) {
|
||||
if (!isoString) return "--"
|
||||
|
||||
try {
|
||||
const date = new Date(isoString)
|
||||
if (index === 0) return I18n.tr("Today")
|
||||
if (index === 1) return I18n.tr("Tomorrow")
|
||||
|
||||
const locale = Qt.locale()
|
||||
return locale.dayName(date.getDay(), Locale.ShortFormat)
|
||||
} catch (e) {
|
||||
return "--"
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"timezone=auto",
|
||||
"forecast_days=7"
|
||||
]
|
||||
|
||||
if (SettingsData.useFahrenheit) {
|
||||
params.push("temperature_unit=fahrenheit")
|
||||
}
|
||||
|
||||
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() {
|
||||
if (SettingsData.useAutoLocation) {
|
||||
getLocationFromIP()
|
||||
} else {
|
||||
const coords = SettingsData.weatherCoordinates
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cityName = SettingsData.weatherLocation
|
||||
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 getLocationFromIP() {
|
||||
ipLocationFetcher.running = true
|
||||
}
|
||||
|
||||
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: ipLocationFetcher
|
||||
command: lowPriorityCmd.concat(curlBaseCmd).concat(["http://ip-api.com/json/"])
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const raw = text.trim()
|
||||
if (!raw || raw[0] !== "{") {
|
||||
root.handleWeatherFailure()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
|
||||
if (data.status === "fail") {
|
||||
throw new Error("IP location lookup failed")
|
||||
}
|
||||
|
||||
const lat = parseFloat(data.lat)
|
||||
const lon = parseFloat(data.lon)
|
||||
const city = data.city
|
||||
|
||||
if (!city || isNaN(lat) || isNaN(lon)) {
|
||||
throw new Error("Missing or invalid location data")
|
||||
}
|
||||
|
||||
root.location = {
|
||||
city: city,
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
}
|
||||
fetchWeather()
|
||||
} catch (e) {
|
||||
root.handleWeatherFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode !== 0) {
|
||||
root.handleWeatherFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 || "Unknown",
|
||||
country: address.country || "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
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error("Required weather data fields missing")
|
||||
}
|
||||
|
||||
const current = data.current
|
||||
const daily = data.daily
|
||||
const currentUnits = data.current_units || {}
|
||||
|
||||
const tempC = current.temperature_2m || 0
|
||||
const tempF = SettingsData.useFahrenheit ? tempC : (tempC * 9/5 + 32)
|
||||
const feelsLikeC = current.apparent_temperature || tempC
|
||||
const feelsLikeF = SettingsData.useFahrenheit ? feelsLikeC : (feelsLikeC * 9/5 + 32)
|
||||
|
||||
const forecast = []
|
||||
if (daily.time && daily.time.length > 0) {
|
||||
for (let i = 0; i < Math.min(daily.time.length, 7); i++) {
|
||||
const tempMinC = daily.temperature_2m_min?.[i] || 0
|
||||
const tempMaxC = daily.temperature_2m_max?.[i] || 0
|
||||
const tempMinF = SettingsData.useFahrenheit ? tempMinC : (tempMinC * 9/5 + 32)
|
||||
const tempMaxF = SettingsData.useFahrenheit ? tempMaxC : (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]) : ""
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 || "Unknown",
|
||||
"country": root.location?.country || "Unknown",
|
||||
"wCode": current.weather_code || 0,
|
||||
"humidity": Math.round(current.relative_humidity_2m || 0),
|
||||
"wind": Math.round(current.wind_speed_10m || 0) + " " + (currentUnits.wind_speed_10m || 'm/s'),
|
||||
"sunrise": formatTime(daily.sunrise?.[0]) || "06:00",
|
||||
"sunset": formatTime(daily.sunset?.[0]) || "18:00",
|
||||
"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
|
||||
}
|
||||
|
||||
const displayTemp = SettingsData.useFahrenheit ? root.weather.tempF : root.weather.temp
|
||||
const unit = SettingsData.useFahrenheit ? "°F" : "°C"
|
||||
|
||||
root.handleWeatherSuccess()
|
||||
} catch (e) {
|
||||
root.handleWeatherFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode !== 0) {
|
||||
root.handleWeatherFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: updateTimer
|
||||
interval: nextInterval()
|
||||
running: root.refCount > 0 && SettingsData.weatherEnabled
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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.useFahrenheitChanged.connect(() => {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
275
quickshell/Services/WlrOutputService.qml
Normal file
275
quickshell/Services/WlrOutputService.qml
Normal file
@@ -0,0 +1,275 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
59
quickshell/Services/niri-binds.kdl
Normal file
59
quickshell/Services/niri-binds.kdl
Normal file
@@ -0,0 +1,59 @@
|
||||
binds {
|
||||
Mod+Space hotkey-overlay-title="Application Launcher" {
|
||||
spawn "dms" "ipc" "call" "spotlight" "toggle";
|
||||
}
|
||||
|
||||
Mod+V hotkey-overlay-title="Clipboard Manager" {
|
||||
spawn "dms" "ipc" "call" "clipboard" "toggle";
|
||||
}
|
||||
|
||||
Mod+M hotkey-overlay-title="Task Manager" {
|
||||
spawn "dms" "ipc" "call" "processlist" "toggle";
|
||||
}
|
||||
|
||||
Mod+Comma hotkey-overlay-title="Settings" {
|
||||
spawn "dms" "ipc" "call" "settings" "toggle";
|
||||
}
|
||||
|
||||
Mod+N hotkey-overlay-title="Notification Center" {
|
||||
spawn "dms" "ipc" "call" "notifications" "toggle";
|
||||
}
|
||||
|
||||
Mod+Y hotkey-overlay-title="Browse Wallpapers" {
|
||||
spawn "dms" "ipc" "call" "dankdash" "wallpaper";
|
||||
}
|
||||
|
||||
Mod+Shift+N hotkey-overlay-title="Notepad" {
|
||||
spawn "dms" "ipc" "call" "notepad" "toggle";
|
||||
}
|
||||
|
||||
Mod+Alt+L hotkey-overlay-title="Lock Screen" {
|
||||
spawn "dms" "ipc" "call" "lock" "lock";
|
||||
}
|
||||
|
||||
Ctrl+Alt+Delete hotkey-overlay-title="Task Manager" {
|
||||
spawn "dms" "ipc" "call" "processlist" "toggle";
|
||||
}
|
||||
|
||||
// Audio
|
||||
XF86AudioRaiseVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "audio" "increment" "3";
|
||||
}
|
||||
XF86AudioLowerVolume allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "audio" "decrement" "3";
|
||||
}
|
||||
XF86AudioMute allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "audio" "mute";
|
||||
}
|
||||
XF86AudioMicMute allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "audio" "micmute";
|
||||
}
|
||||
|
||||
// BL
|
||||
XF86MonBrightnessUp allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "brightness" "increment" "5" "";
|
||||
}
|
||||
XF86MonBrightnessDown allow-when-locked=true {
|
||||
spawn "dms" "ipc" "call" "brightness" "decrement" "5" "";
|
||||
}
|
||||
}
|
||||
4
quickshell/Services/niri-wpblur.kdl
Normal file
4
quickshell/Services/niri-wpblur.kdl
Normal file
@@ -0,0 +1,4 @@
|
||||
layer-rule {
|
||||
match namespace="dms:blurwallpaper"
|
||||
place-within-backdrop true
|
||||
}
|
||||
Reference in New Issue
Block a user