1
0
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:
bbedward
2025-11-12 17:18:45 -05:00
parent 6013c994a6
commit 24e800501a
768 changed files with 76284 additions and 221 deletions

View 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
}
}

View 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)
}
}
}

View 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"
}
}

View 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
}
}
}

View 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"
}
}
}
}

View 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)
}
}
}
}
}
}

View 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])
}
}
}
}

View 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
}
}

View 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())
}
})
}
}

View 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)
}
}

View 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
}
}

View 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 ""
}
}

View 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
}
}

View 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"
}
}

View 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)
}
}

View 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)
}
}

View 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()
}
}
}

View 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
})
}
}

View 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
}
}
}

View 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
}

View 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)
}
}
}

File diff suppressed because it is too large Load Diff

View 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]
}
}

View 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
}
}
}

View 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)
}
}

View 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()
}
}

View 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()
}
}
}

View 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"
}
}
}

View 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"
}
}

View 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()
}
}
}

View 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..."
}
}
}

View 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()
}
}

View 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()
}
}

View 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)
}
}
}
}
}
}
}

View 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
}
}
})
}
}

View 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)
}
}

View 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" "";
}
}

View File

@@ -0,0 +1,4 @@
layer-rule {
match namespace="dms:blurwallpaper"
place-within-backdrop true
}