diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07c9c2adc..d37d760fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -497,3 +497,41 @@ jobs: exit 0 fi bash scripts/notify-issues.sh "$PREVIOUS_TAG" "$TAG" + + # Pin the satellite repos to this release's version. Each dispatch target + # decides for itself whether to tag: X.Y.0 always tags, X.Y.Z skips when the + # repo has no commits of its own since its last tag. + fanout: + name: Pin ${{ matrix.repo }} + runs-on: ubuntu-latest + needs: [release] + if: ${{ !contains(inputs.tag, '-') }} + strategy: + fail-fast: false + matrix: + repo: + - dgop + - dankgo + - dank-qml-common + - dankcalendar + - danksearch + - dank-greeter + steps: + - name: Create GitHub App token + id: app_token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ matrix.repo }} + + - name: Dispatch pin-release + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + REPO: ${{ github.repository_owner }}/${{ matrix.repo }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + gh workflow run pin-release.yml --repo "$REPO" -f version="$TAG" + echo "dispatched pin-release ${TAG} to ${REPO}" >> "$GITHUB_STEP_SUMMARY" diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index bef386f2d..d035d915e 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -62,7 +62,7 @@ func greeterPackageInstallHint() string { case distros.FamilyFedora: return "Install with 'sudo dnf install dms-greeter' (requires COPR: sudo dnf copr enable avengemedia/danklinux)" case distros.FamilyArch: - return "Install from AUR with 'paru -S greetd-dms-greeter-git' or 'yay -S greetd-dms-greeter-git'" + return "Install from AUR with 'paru -S greetd-dms-greeter-bin' or 'yay -S greetd-dms-greeter-bin'" case distros.FamilyVoid: return "Install with 'sudo xbps-install -S dms-greeter' (requires DMS XBPS repo: echo 'repository=https://void.danklinux.com/dms/current' | sudo tee /etc/xbps.d/dms.conf)" default: diff --git a/quickshell/Common/CacheData.qml b/quickshell/Common/CacheData.qml index 9dde50de8..51806e42b 100644 --- a/quickshell/Common/CacheData.qml +++ b/quickshell/Common/CacheData.qml @@ -13,6 +13,8 @@ Singleton { readonly property int cacheConfigVersion: 3 + readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" + readonly property string _stateUrl: StandardPaths.writableLocation(StandardPaths.GenericCacheLocation) readonly property string _stateDir: Paths.strip(_stateUrl) @@ -88,6 +90,8 @@ Singleton { }) Component.onCompleted: { + if (isGreeterMode) + return; loadCache(); } @@ -281,7 +285,7 @@ Singleton { FileView { id: launcherCacheFile - path: _stateDir + "/DankMaterialShell/launcher_cache.json" + path: isGreeterMode ? "" : _stateDir + "/DankMaterialShell/launcher_cache.json" blockLoading: true blockWrites: true atomicWrites: true @@ -291,15 +295,19 @@ Singleton { FileView { id: cacheFile - path: _stateDir + "/DankMaterialShell/cache.json" + path: isGreeterMode ? "" : _stateDir + "/DankMaterialShell/cache.json" blockLoading: true blockWrites: true atomicWrites: true - watchChanges: true + watchChanges: !isGreeterMode onLoaded: { + if (isGreeterMode) + return; parseCache(cacheFile.text()); } onLoadFailed: error => { + if (isGreeterMode) + return; log.info("No cache file found, starting fresh"); } } diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index 85317871b..fd351ae09 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -16,6 +16,8 @@ Singleton { readonly property int sessionConfigVersion: 4 + readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" + signal loaded signal brightnessDisplayHintChanged(string deviceName) signal loadErrorOccurred(string file, string message) @@ -257,6 +259,11 @@ Singleton { _hasUnsavedChanges = false; _pendingMigration = null; + if (isGreeterMode) { + parseSettings(greeterSessionFile.text()); + return; + } + try { const txt = settingsFile.text(); let obj = (txt && txt.trim()) ? JSON.parse(txt) : null; @@ -294,7 +301,7 @@ Singleton { _loadedSessionSnapshot = getCurrentSessionJson(); _hasLoaded = true; - if (typeof Theme !== "undefined") + if (!isGreeterMode && typeof Theme !== "undefined") Theme.generateSystemThemesFromCurrentTheme(); loaded(); @@ -375,7 +382,7 @@ Singleton { _loadedSessionSnapshot = getCurrentSessionJson(); _hasLoaded = true; - if (typeof Theme !== "undefined") + if (!isGreeterMode && typeof Theme !== "undefined") Theme.generateSystemThemesFromCurrentTheme(); loaded(); @@ -398,7 +405,7 @@ Singleton { } function saveSettings() { - if (_parseError || !_hasLoaded) + if (isGreeterMode || _parseError || !_hasLoaded) return; settingsFile.setText(getCurrentSessionJson()); if (_isReadOnly) @@ -1588,12 +1595,14 @@ Singleton { FileView { id: settingsFile - path: StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/DankMaterialShell/session.json" + path: isGreeterMode ? "" : StandardPaths.writableLocation(StandardPaths.GenericStateLocation) + "/DankMaterialShell/session.json" blockLoading: true blockWrites: true atomicWrites: true - watchChanges: true + watchChanges: !isGreeterMode onLoaded: { + if (isGreeterMode) + return; _hasUnsavedChanges = false; parseSettings(settingsFile.text()); } @@ -1603,6 +1612,39 @@ Singleton { } } + readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter" + + property string greeterSessionBaseDir: root._greeterCacheDir + + function setGreeterSessionBaseDir(dir) { + const next = dir || root._greeterCacheDir; + if (greeterSessionBaseDir === next) + return; + greeterSessionBaseDir = next; + if (isGreeterMode) + greeterSessionFile.reload(); + } + + function resetGreeterSessionBaseDir() { + setGreeterSessionBaseDir(root._greeterCacheDir); + } + + FileView { + id: greeterSessionFile + + path: root.greeterSessionBaseDir ? (root.greeterSessionBaseDir + "/session.json") : "" + preload: isGreeterMode + blockLoading: false + blockWrites: true + watchChanges: false + printErrors: true + onLoaded: { + if (isGreeterMode) { + parseSettings(greeterSessionFile.text()); + } + } + } + Process { id: sessionWritableCheckProcess diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 98d408f5e..f8bc4ffbe 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -17,6 +17,8 @@ Singleton { readonly property int settingsConfigVersion: 16 + readonly property bool isGreeterMode: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" + enum Position { Top, Bottom, @@ -1357,10 +1359,14 @@ Singleton { signal notificationPopupsInvalidated function refreshAuthAvailability() { + if (isGreeterMode) + return; Processes.detectAuthCapabilities(); } Component.onCompleted: { + if (isGreeterMode) + return; Processes.settingsRoot = root; loadSettings(); initializeListModels(); @@ -1423,6 +1429,8 @@ Singleton { } function checkIconThemeDrift() { + if (isGreeterMode) + return; if (resolveIconTheme() === "System Default") return; if (!SessionData.lastAppliedIconTheme) @@ -1583,6 +1591,8 @@ Singleton { } function scheduleAuthApply() { + if (isGreeterMode) + return; Qt.callLater(() => { Processes.settingsRoot = root; Processes.scheduleAuthApply(); @@ -1590,6 +1600,8 @@ Singleton { } function scheduleGreeterAutoLoginSync() { + if (isGreeterMode) + return; Qt.callLater(() => { Processes.settingsRoot = root; Processes.scheduleGreeterAutoLoginSync(); @@ -1597,6 +1609,8 @@ Singleton { } function markGreeterSyncPending(who, key, oldValue) { + if (isGreeterMode) + return; if (!(key in SessionData.greeterSyncBaseline)) { var baseline = Object.assign({}, SessionData.greeterSyncBaseline); baseline[key] = oldValue; @@ -3343,11 +3357,11 @@ Singleton { FileView { id: settingsFile - path: StandardPaths.writableLocation(StandardPaths.ConfigLocation) + "/DankMaterialShell/settings.json" + path: isGreeterMode ? "" : StandardPaths.writableLocation(StandardPaths.ConfigLocation) + "/DankMaterialShell/settings.json" blockLoading: true blockWrites: true atomicWrites: true - watchChanges: true + watchChanges: !isGreeterMode onFileChanged: { if (_selfWrite) { _selfWrite = false; @@ -3356,6 +3370,8 @@ Singleton { settingsFileReloadDebounce.restart(); } onLoaded: { + if (isGreeterMode) + return; const wasLoaded = _hasLoaded; const prevFrameEnabled = frameEnabled; const prevFrameMode = frameMode; @@ -3398,6 +3414,8 @@ Singleton { updateFrameCompositorLayout(); } onLoadFailed: error => { + if (isGreeterMode) + return; applyStoredTheme(); } onSaveFailed: error => { @@ -3409,16 +3427,20 @@ Singleton { FileView { id: pluginSettingsFile - path: pluginSettingsPath + path: isGreeterMode ? "" : pluginSettingsPath blockLoading: true blockWrites: true atomicWrites: true printErrors: false - watchChanges: true + watchChanges: !isGreeterMode onLoaded: { + if (isGreeterMode) + return; parsePluginSettings(pluginSettingsFile.text()); } onLoadFailed: error => { + if (isGreeterMode) + return; const msg = String(error || ""); if (!_isMissingPluginSettingsError(error)) log.warn("Failed to load plugin_settings.json. Error:", msg); diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index e39367726..e0c4bdaf8 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -8,6 +8,7 @@ import Quickshell.Io import qs.Common import qs.DankCommon.Common as DankCommon import qs.Services +import qs.Modules.Greetd import "StockThemes.js" as StockThemes Singleton { @@ -145,11 +146,13 @@ Singleton { Quickshell.execDetached(["mkdir", "-p", stateDir]); // shellDir may be an embedded-UI extraction, which is read-only and // unexecutable (dankgo shellapp/shellfs makeReadOnly chmods 0444) - Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "assets", "", shellDir]); + if (typeof SessionData === "undefined" || !SessionData.isGreeterMode) + Quickshell.execDetached(["bash", shellDir + "/scripts/gtk.sh", configDir, "assets", "", shellDir]); Proc.runCommand("matugenCheck", ["sh", "-c", "command -v matugen"], (output, code) => { matugenAvailable = (code === 0) && !envDisableMatugen; + const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); - if (!matugenAvailable) { + if (!matugenAvailable || isGreeterMode) { return; } @@ -1130,11 +1133,26 @@ Singleton { return presetMap[SettingsData.modalAnimationSpeed] ?? 150; } - property real cornerRadius: typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12 + property real cornerRadius: { + if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") { + return GreetdSettings.cornerRadius; + } + return typeof SettingsData !== "undefined" ? SettingsData.cornerRadius : 12; + } - property string fontFamily: typeof SettingsData !== "undefined" ? resolvedFontFamily(SettingsData.fontFamily) : DankCommon.Fonts.sans + property string fontFamily: { + if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") { + return resolvedFontFamily(GreetdSettings.getEffectiveFontFamily()); + } + return typeof SettingsData !== "undefined" ? resolvedFontFamily(SettingsData.fontFamily) : DankCommon.Fonts.sans; + } - property string monoFontFamily: typeof SettingsData !== "undefined" ? resolvedMonoFontFamily(SettingsData.monoFontFamily) : DankCommon.Fonts.mono + property string monoFontFamily: { + if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") { + return resolvedMonoFontFamily(GreetdSettings.monoFontFamily); + } + return typeof SettingsData !== "undefined" ? resolvedMonoFontFamily(SettingsData.monoFontFamily) : DankCommon.Fonts.mono; + } function resolvedFontFamily(family) { if (family === defaultFontFamily) @@ -1148,9 +1166,19 @@ Singleton { return family; } - property int fontWeight: typeof SettingsData !== "undefined" ? SettingsData.fontWeight : Font.Normal + property int fontWeight: { + if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") { + return GreetdSettings.fontWeight; + } + return typeof SettingsData !== "undefined" ? SettingsData.fontWeight : Font.Normal; + } - property real fontScale: typeof SettingsData !== "undefined" ? SettingsData.fontScale : 1.0 + property real fontScale: { + if (typeof SessionData !== "undefined" && SessionData.isGreeterMode && typeof GreetdSettings !== "undefined") { + return GreetdSettings.fontScale; + } + return typeof SettingsData !== "undefined" ? SettingsData.fontScale : 1.0; + } property real spacingXXS: 2 property real spacingXS: 4 @@ -1208,12 +1236,22 @@ Singleton { currentThemeCategory = "generic"; } } - if (savePrefs && typeof SettingsData !== "undefined") { + const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); + if (savePrefs && typeof SettingsData !== "undefined" && !isGreeterMode) { SettingsData.set("currentThemeCategory", currentThemeCategory); SettingsData.set("currentThemeName", currentTheme); } - generateSystemThemesFromCurrentTheme(); + if (!isGreeterMode) { + generateSystemThemesFromCurrentTheme(); + } + } + + function applyGreeterTheme(themeName) { + switchTheme(themeName, false, false); + if (themeName === dynamic && dynamicColorsFileView.path) { + dynamicColorsFileView.reload(); + } } function setLightMode(light, savePrefs = true, enableTransition = false) { @@ -1230,14 +1268,17 @@ Singleton { return; } - if (savePrefs && typeof SessionData !== "undefined") { + const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); + if (savePrefs && typeof SessionData !== "undefined" && !isGreeterMode) { SessionData.setLightMode(light); } - if (typeof SettingsData !== "undefined") { - SettingsData.updateCosmicThemeMode(light); + if (!isGreeterMode) { + if (typeof SettingsData !== "undefined") { + SettingsData.updateCosmicThemeMode(light); + } + generateSystemThemesFromCurrentTheme(); } - generateSystemThemesFromCurrentTheme(); } function toggleLightMode(savePrefs = true) { @@ -1275,7 +1316,8 @@ Singleton { if (themeData.variants.type === "multi" && themeData.variants.flavors && themeData.variants.accents) { const defaults = themeData.variants.defaults || {}; const modeDefaults = defaults[colorMode] || defaults.dark || {}; - const stored = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, modeDefaults, colorMode) : modeDefaults; + const isGreeterMode = typeof SessionData !== "undefined" && SessionData.isGreeterMode; + const stored = isGreeterMode ? (GreetdSettings.registryThemeVariants[themeId]?.[colorMode] || modeDefaults) : (typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeMultiVariant(themeId, modeDefaults, colorMode) : modeDefaults); var flavorId = stored.flavor || modeDefaults.flavor || ""; const accentId = stored.accent || modeDefaults.accent || ""; var flavor = findVariant(themeData.variants.flavors, flavorId); @@ -1301,7 +1343,8 @@ Singleton { } if (themeData.variants.options && themeData.variants.options.length > 0) { - const selectedVariantId = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeVariant(themeId, themeData.variants.default) : themeData.variants.default; + const isGreeterMode = typeof SessionData !== "undefined" && SessionData.isGreeterMode; + const selectedVariantId = isGreeterMode ? (typeof GreetdSettings.registryThemeVariants[themeId] === "string" ? GreetdSettings.registryThemeVariants[themeId] : themeData.variants.default) : (typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeVariant(themeId, themeData.variants.default) : themeData.variants.default); const variant = findVariant(themeData.variants.options, selectedVariantId); if (variant) { const variantColors = variant[colorMode] || variant.dark || variant.light || {}; @@ -1642,7 +1685,8 @@ Singleton { } function generateSystemThemesFromCurrentTheme() { - if (!matugenAvailable) + const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); + if (!matugenAvailable || isGreeterMode) return; _lastGenerateMs = Date.now(); @@ -2132,11 +2176,32 @@ Singleton { } } + readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter" + + property string greeterColorsBaseDir: root._greeterCacheDir + + function setGreeterColorsBaseDir(dir) { + const next = dir || root._greeterCacheDir; + if (greeterColorsBaseDir === next) + return; + greeterColorsBaseDir = next; + if (typeof SessionData !== "undefined" && SessionData.isGreeterMode) + dynamicColorsFileView.reload(); + } + + function resetGreeterColorsBaseDir() { + setGreeterColorsBaseDir(root._greeterCacheDir); + } + FileView { id: dynamicColorsFileView - path: stateDir + "/dms-colors.json" + path: { + if (SessionData.isGreeterMode) + return root.greeterColorsBaseDir ? (root.greeterColorsBaseDir + "/colors.json") : ""; + return stateDir + "/dms-colors.json"; + } blockLoading: false - watchChanges: true + watchChanges: !SessionData.isGreeterMode function parseAndLoadColors() { try { @@ -2178,6 +2243,9 @@ Singleton { if (currentTheme !== dynamic) return; + if (SessionData.isGreeterMode) + return; + if (workerRunning) { colorsReloadRetry.restart(); return; diff --git a/quickshell/Common/settings/Processes.qml b/quickshell/Common/settings/Processes.qml index 0ef9cf35a..a260cdb8a 100644 --- a/quickshell/Common/settings/Processes.qml +++ b/quickshell/Common/settings/Processes.qml @@ -15,14 +15,14 @@ Singleton { property var settingsRoot: null onSettingsRootChanged: { - if (settingsRoot) + if (settingsRoot && !settingsRoot.isGreeterMode) consumeGreeterAutoLoginPendingSync(); } readonly property string greeterAutoLoginPendingSyncPath: (Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending" function consumeGreeterAutoLoginPendingSync() { - if (!settingsRoot) + if (!settingsRoot || settingsRoot.isGreeterMode) return; greeterAutoLoginPendingCheckProcess.running = true; } @@ -289,7 +289,7 @@ Singleton { property string authApplyTerminalFallbackStderr: "" function scheduleAuthApply() { - if (!settingsRoot) + if (!settingsRoot || settingsRoot.isGreeterMode) return; authApplyQueued = true; @@ -302,7 +302,7 @@ Singleton { } function beginAuthApply() { - if (!authApplyQueued || authApplyRunning || !settingsRoot) + if (!authApplyQueued || authApplyRunning || !settingsRoot || settingsRoot.isGreeterMode) return; authApplyQueued = false; @@ -341,7 +341,7 @@ Singleton { property string greeterAutoLoginSyncStderr: "" function scheduleGreeterAutoLoginSync() { - if (!settingsRoot) + if (!settingsRoot || settingsRoot.isGreeterMode) return; greeterAutoLoginSyncQueued = true; @@ -354,7 +354,7 @@ Singleton { } function beginGreeterAutoLoginSync() { - if (!greeterAutoLoginSyncQueued || greeterAutoLoginSyncRunning || !settingsRoot) + if (!greeterAutoLoginSyncQueued || greeterAutoLoginSyncRunning || !settingsRoot || settingsRoot.isGreeterMode) return; greeterAutoLoginSyncQueued = false; diff --git a/quickshell/DMSGreeter.qml b/quickshell/DMSGreeter.qml new file mode 100644 index 000000000..f3068f5dd --- /dev/null +++ b/quickshell/DMSGreeter.qml @@ -0,0 +1,9 @@ +import QtQuick +import Quickshell +import qs.Modules.Greetd + +Scope { + id: root + + GreeterSurface {} +} diff --git a/quickshell/Modules/Greetd/GreetdEnv.js b/quickshell/Modules/Greetd/GreetdEnv.js new file mode 100644 index 000000000..fcce181e5 --- /dev/null +++ b/quickshell/Modules/Greetd/GreetdEnv.js @@ -0,0 +1,20 @@ +.pragma library + +function readBoolOverride(envReader, names, fallbackValue) { + for (let i = 0; i < names.length; i++) { + const name = names[i]; + const raw = envReader(name); + if (raw === undefined || raw === null || raw === "") + continue; + + const normalized = String(raw).trim().toLowerCase(); + if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") + return true; + if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") + return false; + + console.warn("Invalid boolean override for", name + ":", raw, "- trying next override/fallback"); + } + + return fallbackValue; +} diff --git a/quickshell/Modules/Greetd/GreetdMemory.qml b/quickshell/Modules/Greetd/GreetdMemory.qml new file mode 100644 index 000000000..54de73bc7 --- /dev/null +++ b/quickshell/Modules/Greetd/GreetdMemory.qml @@ -0,0 +1,162 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import "GreetdEnv.js" as GreetdEnv +import qs.Services + +Singleton { + id: root + readonly property var log: Log.scoped("GreetdMemory") + + readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter" + readonly property string sessionConfigPath: greetCfgDir + "/session.json" + readonly property string memoryFile: greetCfgDir + "/.local/state/memory.json" + readonly property bool rememberLastSession: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_SESSION", "DMS_SAVE_SESSION"], true) + readonly property bool rememberLastUser: GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_USER", "DMS_SAVE_USERNAME"], true) + + property string lastSessionId: "" + property string lastSessionDesktopId: "" + property string lastSessionExec: "" + property string lastSuccessfulUser: "" + property bool memoryReady: false + property bool isLightMode: false + property bool nightModeEnabled: false + + Component.onCompleted: { + loadMemory(); + loadSessionConfig(); + } + + function loadMemory() { + parseMemory(memoryFileView.text()); + } + + function loadSessionConfig() { + parseSessionConfig(sessionConfigFileView.text()); + } + + function parseSessionConfig(content) { + try { + if (content && content.trim()) { + const config = JSON.parse(content); + isLightMode = config.isLightMode !== undefined ? config.isLightMode : false; + nightModeEnabled = config.nightModeEnabled !== undefined ? config.nightModeEnabled : false; + } + } catch (e) { + log.warn("Failed to parse greeter session config:", e); + } + } + + function parseMemory(content) { + try { + if (!content || !content.trim()) + return; + const memory = JSON.parse(content); + lastSessionId = rememberLastSession ? (memory.lastSessionId || "") : ""; + lastSessionDesktopId = rememberLastSession ? (memory.lastSessionDesktopId || "") : ""; + lastSessionExec = rememberLastSession ? (memory.lastSessionExec || "") : ""; + lastSuccessfulUser = rememberLastUser ? (memory.lastSuccessfulUser || "") : ""; + if (!rememberLastSession || !rememberLastUser) + saveMemory(); + } catch (e) { + log.warn("Failed to parse greetd memory:", e); + } + } + + function saveMemory() { + let memory = {}; + if (rememberLastSession && lastSessionId) + memory.lastSessionId = lastSessionId; + if (rememberLastSession && lastSessionDesktopId) + memory.lastSessionDesktopId = lastSessionDesktopId; + if (rememberLastUser && lastSuccessfulUser) + memory.lastSuccessfulUser = lastSuccessfulUser; + memoryFileView.setText(JSON.stringify(memory, null, 2)); + } + + function setLastSession(id, desktopId) { + if (!rememberLastSession) { + if (lastSessionId !== "" || lastSessionDesktopId !== "" || lastSessionExec !== "") { + lastSessionId = ""; + lastSessionDesktopId = ""; + lastSessionExec = ""; + saveMemory(); + } + return; + } + lastSessionId = id || ""; + lastSessionDesktopId = desktopId || ""; + lastSessionExec = ""; + if (!lastSessionId) + lastSessionDesktopId = ""; + saveMemory(); + } + + function setLastSessionId(id) { + setLastSession(id, lastSessionDesktopId); + } + + function setLastSessionDesktopId(id) { + setLastSession(lastSessionId, id); + } + + function setLastSessionExec(exec) { + if (!rememberLastSession) { + if (lastSessionExec !== "") { + lastSessionExec = ""; + saveMemory(); + } + return; + } + if (lastSessionExec !== "") { + lastSessionExec = ""; + saveMemory(); + } + } + + function setLastSuccessfulUser(username) { + if (!rememberLastUser) { + if (lastSuccessfulUser !== "") { + lastSuccessfulUser = ""; + saveMemory(); + } + return; + } + lastSuccessfulUser = username || ""; + saveMemory(); + } + + FileView { + id: memoryFileView + path: root.memoryFile + blockLoading: false + blockWrites: false + atomicWrites: true + watchChanges: false + printErrors: false + onLoaded: { + parseMemory(memoryFileView.text()); + root.memoryReady = true; + } + onLoadFailed: { + root.memoryReady = true; + } + } + + FileView { + id: sessionConfigFileView + path: root.sessionConfigPath + blockLoading: false + blockWrites: true + atomicWrites: false + watchChanges: false + printErrors: false + onLoaded: { + parseSessionConfig(sessionConfigFileView.text()); + } + onLoadFailed: {} + } +} diff --git a/quickshell/Modules/Greetd/GreetdSettings.qml b/quickshell/Modules/Greetd/GreetdSettings.qml new file mode 100644 index 000000000..9b83fc413 --- /dev/null +++ b/quickshell/Modules/Greetd/GreetdSettings.qml @@ -0,0 +1,230 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Services +import "GreetdEnv.js" as GreetdEnv + +Singleton { + id: root + readonly property var log: Log.scoped("GreetdSettings") + + readonly property string _greeterCacheDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter" + + property string configBaseDir: root._greeterCacheDir + readonly property string configPath: root.configBaseDir ? (root.configBaseDir + "/settings.json") : "" + readonly property string greeterWallpaperOverridePath: root.configBaseDir ? (root.configBaseDir + "/greeter_wallpaper_override.jpg") : "" + + function setConfigBaseDir(dir) { + const next = dir || root._greeterCacheDir; + if (configBaseDir === next) + return; + configBaseDir = next; + settingsLoaded = false; + settingsFile.reload(); + } + + function resetConfigBaseDir() { + setConfigBaseDir(root._greeterCacheDir); + } + + property string currentThemeName: "purple" + property bool settingsLoaded: false + property string customThemeFile: "" + property var registryThemeVariants: ({}) + property string matugenScheme: "scheme-tonal-spot" + property string clockFormat: "auto" + readonly property bool localeUses24Hour: { + const fmt = Qt.locale().timeFormat(Locale.ShortFormat).replace(/'[^']*'/g, ""); + return !/[aA]/.test(fmt); + } + readonly property bool use24HourClock: clockFormat === "24h" ? true : (clockFormat === "12h" ? false : localeUses24Hour) + property bool showSeconds: false + property bool padHours12Hour: false + property string greeterLockDateFormat: "" + property string greeterFontFamily: "" + property string greeterWallpaperFillMode: "" + property bool useFahrenheit: false + property bool nightModeEnabled: false + property string weatherLocation: "New York, NY" + property string weatherCoordinates: "40.7128,-74.0060" + property bool useAutoLocation: false + property bool weatherEnabled: true + property string iconTheme: "System Default" + property bool useOSLogo: false + property string osLogoColorOverride: "" + property real osLogoBrightness: 0.5 + property real osLogoContrast: 1 + property string fontFamily: "Inter Variable" + property string monoFontFamily: "Fira Code" + property int fontWeight: Font.Normal + property real fontScale: 1.0 + property real cornerRadius: 12 + property string widgetBackgroundColor: "sch" + property string lockDateFormat: "" + property bool lockScreenShowPowerActions: true + property bool lockScreenShowProfileImage: true + property bool rememberLastSession: true + property bool rememberLastUser: true + property bool greeterAutoLogin: false + property bool greeterEnableFprint: false + property bool greeterEnableU2f: false + property string greeterWallpaperPath: "" + property bool powerActionConfirm: true + property real powerActionHoldDuration: 0.5 + property var powerMenuActions: ["reboot", "logout", "poweroff", "lock", "suspend", "restart"] + property string powerMenuDefaultAction: "logout" + property bool powerMenuGridLayout: false + property var screenPreferences: ({}) + property int animationSpeed: 2 + property string wallpaperFillMode: "Fill" + property string wallpaperBackgroundColorMode: "black" + property string wallpaperBackgroundCustomColor: "#000000" + readonly property color effectiveWallpaperBackgroundColor: { + switch (wallpaperBackgroundColorMode) { + case "black": + return "#000000"; + case "white": + return "#ffffff"; + case "primary": + return (typeof Theme !== "undefined") ? Theme.primary : "#000000"; + case "surface": + return (typeof Theme !== "undefined") ? Theme.surfaceContainer : "#000000"; + case "custom": + return wallpaperBackgroundCustomColor; + default: + return "#000000"; + } + } + + function parseSettings(content) { + try { + let settings = {}; + if (content && content.trim()) { + settings = JSON.parse(content); + } + + const envRememberLastSession = GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_SESSION", "DMS_SAVE_SESSION"], undefined); + const envRememberLastUser = GreetdEnv.readBoolOverride(Quickshell.env, ["DMS_GREET_REMEMBER_LAST_USER", "DMS_SAVE_USERNAME"], undefined); + + currentThemeName = settings.currentThemeName !== undefined ? settings.currentThemeName : "purple"; + customThemeFile = settings.customThemeFile !== undefined ? settings.customThemeFile : ""; + registryThemeVariants = settings.registryThemeVariants !== undefined ? settings.registryThemeVariants : ({}); + matugenScheme = settings.matugenScheme !== undefined ? settings.matugenScheme : "scheme-tonal-spot"; + clockFormat = settings.clockFormat !== undefined ? settings.clockFormat : (settings.use24HourClock !== undefined ? (settings.use24HourClock ? "24h" : "12h") : "auto"); + showSeconds = settings.showSeconds !== undefined ? settings.showSeconds : false; + padHours12Hour = settings.padHours12Hour !== undefined ? settings.padHours12Hour : false; + greeterLockDateFormat = settings.greeterLockDateFormat !== undefined ? settings.greeterLockDateFormat : ""; + greeterFontFamily = settings.greeterFontFamily !== undefined ? settings.greeterFontFamily : ""; + greeterWallpaperFillMode = settings.greeterWallpaperFillMode !== undefined ? settings.greeterWallpaperFillMode : ""; + useFahrenheit = settings.useFahrenheit !== undefined ? settings.useFahrenheit : false; + nightModeEnabled = settings.nightModeEnabled !== undefined ? settings.nightModeEnabled : false; + weatherLocation = settings.weatherLocation !== undefined ? settings.weatherLocation : "New York, NY"; + weatherCoordinates = settings.weatherCoordinates !== undefined ? settings.weatherCoordinates : "40.7128,-74.0060"; + useAutoLocation = settings.useAutoLocation !== undefined ? settings.useAutoLocation : false; + weatherEnabled = settings.weatherEnabled !== undefined ? settings.weatherEnabled : true; + iconTheme = settings.iconTheme !== undefined ? settings.iconTheme : "System Default"; + useOSLogo = settings.useOSLogo !== undefined ? settings.useOSLogo : false; + osLogoColorOverride = settings.osLogoColorOverride !== undefined ? settings.osLogoColorOverride : ""; + osLogoBrightness = settings.osLogoBrightness !== undefined ? settings.osLogoBrightness : 0.5; + osLogoContrast = settings.osLogoContrast !== undefined ? settings.osLogoContrast : 1; + fontFamily = settings.fontFamily !== undefined ? settings.fontFamily : Theme.defaultFontFamily; + monoFontFamily = settings.monoFontFamily !== undefined ? settings.monoFontFamily : Theme.defaultMonoFontFamily; + fontWeight = settings.fontWeight !== undefined ? settings.fontWeight : Font.Normal; + fontScale = settings.fontScale !== undefined ? settings.fontScale : 1.0; + cornerRadius = settings.cornerRadius !== undefined ? settings.cornerRadius : 12; + widgetBackgroundColor = settings.widgetBackgroundColor !== undefined ? settings.widgetBackgroundColor : "sch"; + lockDateFormat = settings.lockDateFormat !== undefined ? settings.lockDateFormat : ""; + lockScreenShowPowerActions = settings.lockScreenShowPowerActions !== undefined ? settings.lockScreenShowPowerActions : true; + lockScreenShowProfileImage = settings.lockScreenShowProfileImage !== undefined ? settings.lockScreenShowProfileImage : true; + if (envRememberLastSession !== undefined) { + rememberLastSession = envRememberLastSession; + } else { + rememberLastSession = settings.greeterRememberLastSession !== undefined ? settings.greeterRememberLastSession : settings.rememberLastSession !== undefined ? settings.rememberLastSession : true; + } + if (envRememberLastUser !== undefined) { + rememberLastUser = envRememberLastUser; + } else { + rememberLastUser = settings.greeterRememberLastUser !== undefined ? settings.greeterRememberLastUser : settings.rememberLastUser !== undefined ? settings.rememberLastUser : true; + } + if (configBaseDir === root._greeterCacheDir) { + greeterAutoLogin = settings.greeterAutoLogin !== undefined ? settings.greeterAutoLogin : false; + } + greeterEnableFprint = settings.greeterEnableFprint !== undefined ? settings.greeterEnableFprint : false; + greeterEnableU2f = settings.greeterEnableU2f !== undefined ? settings.greeterEnableU2f : false; + greeterWallpaperPath = settings.greeterWallpaperPath !== undefined ? settings.greeterWallpaperPath : ""; + powerActionConfirm = settings.powerActionConfirm !== undefined ? settings.powerActionConfirm : true; + powerActionHoldDuration = settings.powerActionHoldDuration !== undefined ? settings.powerActionHoldDuration : 0.5; + powerMenuActions = settings.powerMenuActions !== undefined ? settings.powerMenuActions : ["reboot", "logout", "poweroff", "lock", "suspend", "restart"]; + powerMenuDefaultAction = settings.powerMenuDefaultAction !== undefined ? settings.powerMenuDefaultAction : "logout"; + powerMenuGridLayout = settings.powerMenuGridLayout !== undefined ? settings.powerMenuGridLayout : false; + screenPreferences = settings.screenPreferences !== undefined ? settings.screenPreferences : ({}); + animationSpeed = settings.animationSpeed !== undefined ? settings.animationSpeed : 2; + wallpaperFillMode = settings.wallpaperFillMode !== undefined ? settings.wallpaperFillMode : "Fill"; + wallpaperBackgroundColorMode = settings.wallpaperBackgroundColorMode !== undefined ? settings.wallpaperBackgroundColorMode : "black"; + wallpaperBackgroundCustomColor = settings.wallpaperBackgroundCustomColor !== undefined ? settings.wallpaperBackgroundCustomColor : "#000000"; + + if (typeof Theme !== "undefined") { + if (currentThemeName === "custom" && customThemeFile) { + Theme.loadCustomThemeFromFile(customThemeFile); + } + Theme.applyGreeterTheme(currentThemeName); + } + } catch (e) { + log.warn("Failed to parse greetd settings:", e); + } finally { + settingsLoaded = true; + } + } + + function getEffectiveTimeFormat() { + const use24 = use24HourClock; + const secs = showSeconds; + const pad = padHours12Hour; + if (use24) + return secs ? "hh:mm:ss" : "hh:mm"; + if (pad) + return secs ? "hh:mm:ss AP" : "hh:mm AP"; + return secs ? "h:mm:ss AP" : "h:mm AP"; + } + + function getEffectiveLockDateFormat() { + const fmt = (greeterLockDateFormat !== undefined && greeterLockDateFormat !== "") ? greeterLockDateFormat : lockDateFormat; + return fmt && fmt.length > 0 ? fmt : Locale.LongFormat; + } + + function getEffectiveWallpaperFillMode() { + return (greeterWallpaperFillMode && greeterWallpaperFillMode !== "") ? greeterWallpaperFillMode : wallpaperFillMode; + } + + function getEffectiveFontFamily() { + return (greeterFontFamily && greeterFontFamily !== "") ? greeterFontFamily : fontFamily; + } + + function getFilteredScreens(componentId) { + const prefs = screenPreferences && screenPreferences[componentId] || ["all"]; + if (prefs.includes("all")) { + return Quickshell.screens; + } + return Quickshell.screens.filter(screen => prefs.includes(screen.name)); + } + + FileView { + id: settingsFile + path: root.configPath + blockLoading: false + blockWrites: true + atomicWrites: false + watchChanges: false + printErrors: false + onLoaded: { + parseSettings(settingsFile.text()); + } + onLoadFailed: { + root.parseSettings(""); + } + } +} diff --git a/quickshell/Modules/Greetd/GreeterContent.qml b/quickshell/Modules/Greetd/GreeterContent.qml new file mode 100644 index 000000000..3345240b5 --- /dev/null +++ b/quickshell/Modules/Greetd/GreeterContent.qml @@ -0,0 +1,2138 @@ +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Qt.labs.folderlistmodel +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Services.Greetd +import qs.Common +import qs.Services +import qs.Widgets +import qs.DankCommon.Session +import "../../DankCommon/Common/LayoutCodes.js" as LayoutCodes + +Item { + id: root + + function encodeFileUrl(path) { + if (!path) + return ""; + return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/'); + } + + function desktopIdFromPath(path) { + if (!path) + return ""; + const parts = path.split("/"); + const id = parts.length > 0 ? parts[parts.length - 1] : path; + return id || ""; + } + + readonly property string xdgDataDirs: Quickshell.env("XDG_DATA_DIRS") + property string screenName: "" + property string hyprlandCurrentLayout: "" + property string hyprlandKeyboard: "" + property int hyprlandLayoutCount: 0 + property bool isPrimaryScreen: !Quickshell.screens?.length || screenName === Quickshell.screens[0]?.name + + signal launchRequested + + property bool weatherInitialized: false + property bool awaitingExternalAuth: false + property bool pendingPasswordResponse: false + property bool passwordSubmitRequested: false + property bool cancelingExternalAuthForPassword: false + property int defaultAuthTimeoutMs: 10000 + property int externalAuthTimeoutMs: 30000 + property int memoryFlushDelayMs: 120 + property string pendingLaunchCommand: "" + property var pendingLaunchEnv: [] + property int passwordFailureCount: 0 + property int passwordAttemptLimitHint: 0 + property string authFeedbackMessage: "" + property string authSuccessMessage: "" + property string greetdPamText: "" + property string systemAuthPamText: "" + property string commonAuthPamText: "" + property string passwordAuthPamText: "" + property string systemLoginPamText: "" + property string systemLocalLoginPamText: "" + property string commonAuthPcPamText: "" + property string loginPamText: "" + property string faillockConfigText: "" + property bool greeterWallpaperOverrideExists: false + property string externalAuthAutoStartedForUser: "" + property int passwordSessionTransitionRetryCount: 0 + property int maxPasswordSessionTransitionRetries: 2 + property bool fprintdProbeComplete: false + property bool fprintdHasDevice: false + property bool autoLoginOnSuccess: false + readonly property bool greeterPamStackHasFprint: greeterPamStackHasModule("pam_fprintd") + // Falls back to PAM-only detection until the fprintd D-Bus probe completes. + readonly property bool greeterPamHasFprint: greeterPamStackHasFprint && (!fprintdProbeComplete || fprintdHasDevice) + readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f") + readonly property bool greeterExternalAuthAvailable: (greeterPamHasFprint && GreetdSettings.greeterEnableFprint) || (greeterPamHasU2f && GreetdSettings.greeterEnableU2f) + readonly property bool greeterPamHasExternalAuth: greeterPamHasFprint || greeterPamHasU2f + readonly property bool externalAuthInProgress: awaitingExternalAuth || (Greetd.state !== GreetdState.Inactive && passwordSubmitRequested && greeterPamHasExternalAuth && !pendingPasswordResponse) + readonly property string externalAuthStatusMessage: { + if (!externalAuthInProgress) + return ""; + if (greeterPamHasFprint && greeterPamHasU2f) + return I18n.tr("Awaiting fingerprint or security key authentication"); + if (greeterPamHasFprint) + return I18n.tr("Awaiting fingerprint authentication"); + return I18n.tr("Awaiting security key authentication"); + } + readonly property string authDisplayMessage: authFeedbackMessage || authSuccessMessage || externalAuthStatusMessage + readonly property bool autoLoginAvailable: GreetdSettings.rememberLastUser && GreetdSettings.rememberLastSession + readonly property bool multipleUsersAvailable: GreeterUsersService.loaded && GreeterUsersService.users.length > 1 + // Single-user systems get the picker too when auto-login is available, so the + // auto-login toggle lives inside the dropdown instead of floating on its own. + readonly property bool pickerAvailable: multipleUsersAvailable || (GreeterUsersService.loaded && GreeterUsersService.users.length === 1 && autoLoginAvailable) + readonly property bool showUserPicker: pickerAvailable && !GreeterState.showPasswordInput && !manualUsernameEntry + readonly property bool showAccountSwitchLink: pickerAvailable && manualUsernameEntry && !GreeterState.showPasswordInput && !GreeterState.unlocking + readonly property int userPickerMaxHeight: Math.min(400, Math.max(120, height * 0.35)) + property bool userListOpen: false + property bool manualUsernameEntry: false + property bool skipAutoSelectUser: false + property string pickerThemeUsername: "" + + function initWeatherService() { + if (weatherInitialized) + return; + if (!GreetdSettings.settingsLoaded) + return; + if (!GreetdSettings.weatherEnabled) + return; + weatherInitialized = true; + WeatherService.addRef(); + WeatherService.forceRefresh(); + } + + function stripPamComment(line) { + if (!line) + return ""; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) + return ""; + const hashIdx = trimmed.indexOf("#"); + if (hashIdx >= 0) + return trimmed.substring(0, hashIdx).trim(); + return trimmed; + } + + function pamModuleEnabled(pamText, moduleName) { + if (!pamText || !moduleName) + return false; + const lines = pamText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripPamComment(lines[i]); + if (!line) + continue; + if (line.includes(moduleName)) + return true; + } + return false; + } + + function pamTextIncludesFile(pamText, filename) { + if (!pamText || !filename) + return false; + const lines = pamText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripPamComment(lines[i]); + if (!line) + continue; + if (line.includes(filename) && (line.includes("include") || line.includes("substack") || line.startsWith("@include"))) + return true; + } + return false; + } + + function greeterPamStackHasModule(moduleName) { + if (pamModuleEnabled(greetdPamText, moduleName)) + return true; + const includedPamStacks = [["system-auth", systemAuthPamText], ["common-auth", commonAuthPamText], ["password-auth", passwordAuthPamText], ["system-login", systemLoginPamText], ["system-local-login", systemLocalLoginPamText], ["common-auth-pc", commonAuthPcPamText], ["login", loginPamText]]; + for (let i = 0; i < includedPamStacks.length; i++) { + const stack = includedPamStacks[i]; + if (pamTextIncludesFile(greetdPamText, stack[0]) && pamModuleEnabled(stack[1], moduleName)) + return true; + } + return false; + } + + function usesPamLockoutPolicy(pamText) { + if (!pamText) + return false; + const lines = pamText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripPamComment(lines[i]); + if (!line) + continue; + if (line.includes("pam_faillock.so") || line.includes("pam_tally2.so") || line.includes("pam_tally.so")) + return true; + } + return false; + } + + function parsePamLineDenyValue(pamText) { + if (!pamText) + return -1; + const lines = pamText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripPamComment(lines[i]); + if (!line) + continue; + if (!line.includes("pam_faillock.so") && !line.includes("pam_tally2.so") && !line.includes("pam_tally.so")) + continue; + const denyMatch = line.match(/\bdeny\s*=\s*(\d+)\b/i); + if (!denyMatch) + continue; + const parsed = parseInt(denyMatch[1], 10); + if (!isNaN(parsed)) + return parsed; + } + return -1; + } + + function parseFaillockDenyValue(configText) { + if (!configText) + return -1; + const lines = configText.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripPamComment(lines[i]); + if (!line) + continue; + const denyMatch = line.match(/^deny\s*=\s*(\d+)\s*$/i); + if (!denyMatch) + continue; + const parsed = parseInt(denyMatch[1], 10); + if (!isNaN(parsed)) + return parsed; + } + return -1; + } + + function refreshPasswordAttemptPolicyHint() { + const pamSources = [greetdPamText, systemAuthPamText, commonAuthPamText, passwordAuthPamText, systemLoginPamText, systemLocalLoginPamText, commonAuthPcPamText, loginPamText]; + let lockoutConfigured = false; + let denyFromPam = -1; + for (let i = 0; i < pamSources.length; i++) { + const source = pamSources[i]; + if (!source) + continue; + if (usesPamLockoutPolicy(source)) + lockoutConfigured = true; + const denyValue = parsePamLineDenyValue(source); + if (denyValue >= 0 && (denyFromPam < 0 || denyValue < denyFromPam)) + denyFromPam = denyValue; + } + + if (!lockoutConfigured) { + passwordAttemptLimitHint = 0; + return; + } + + const denyFromConfig = parseFaillockDenyValue(faillockConfigText); + if (denyFromConfig >= 0) { + passwordAttemptLimitHint = denyFromConfig; + return; + } + + if (denyFromPam >= 0) { + passwordAttemptLimitHint = denyFromPam; + return; + } + + // pam_faillock default deny value when no explicit config is set. + passwordAttemptLimitHint = 3; + } + + function isLikelyLockoutMessage(message) { + const lower = (message || "").toLowerCase(); + return lower.includes("account is locked") || lower.includes("too many") || lower.includes("maximum number of"); + } + + function currentAuthMessage() { + if (GreeterState.pamState === "error") + return I18n.tr("Authentication error - try again"); + if (GreeterState.pamState === "max") + return I18n.tr("Too many failed attempts - account may be locked"); + if (GreeterState.pamState === "fail") { + if (passwordAttemptLimitHint > 0) { + const attempt = Math.max(1, Math.min(passwordFailureCount, passwordAttemptLimitHint)); + const remaining = Math.max(passwordAttemptLimitHint - attempt, 0); + if (remaining > 0) { + return I18n.tr("Authentication failed - attempt %1 of %2").arg(attempt).arg(passwordAttemptLimitHint); + } + return I18n.tr("Authentication failed - lockout can occur"); + } + return I18n.tr("Authentication failed - try again"); + } + return ""; + } + + function clearAuthFeedback() { + GreeterState.pamState = ""; + authFeedbackMessage = ""; + authSuccessMessage = ""; + } + + function resetPasswordSessionTransition(clearSubmitRequest) { + cancelingExternalAuthForPassword = false; + passwordSessionTransitionRetryCount = 0; + if (clearSubmitRequest) + passwordSubmitRequested = false; + } + + Connections { + target: GreetdSettings + function onSettingsLoadedChanged() { + if (GreetdSettings.settingsLoaded) { + initWeatherService(); + if (isPrimaryScreen) { + applyLastSuccessfulUser(); + finalizeSessionSelection(); + } + } + } + + function onRememberLastUserChanged() { + if (!isPrimaryScreen) + return; + if (!GreetdSettings.rememberLastUser && GreetdMemory.lastSuccessfulUser) { + GreetdMemory.setLastSuccessfulUser(""); + } + applyLastSuccessfulUser(); + } + + function onRememberLastSessionChanged() { + if (!isPrimaryScreen) + return; + if (!GreetdSettings.rememberLastSession && (GreetdMemory.lastSessionId || GreetdMemory.lastSessionDesktopId || GreetdMemory.lastSessionExec)) { + GreetdMemory.setLastSession("", ""); + } + finalizeSessionSelection(); + } + } + + FileView { + id: greetdPamWatcher + path: "/etc/pam.d/greetd" + printErrors: false + onLoaded: { + root.greetdPamText = text(); + root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); + } + onLoadFailed: { + root.greetdPamText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + FileView { + id: systemAuthPamWatcher + path: "/etc/pam.d/system-auth" + printErrors: false + onLoaded: { + root.systemAuthPamText = text(); + root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); + } + onLoadFailed: { + root.systemAuthPamText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + FileView { + id: commonAuthPamWatcher + path: "/etc/pam.d/common-auth" + printErrors: false + onLoaded: { + root.commonAuthPamText = text(); + root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); + } + onLoadFailed: { + root.commonAuthPamText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + FileView { + id: passwordAuthPamWatcher + path: "/etc/pam.d/password-auth" + printErrors: false + onLoaded: { + root.passwordAuthPamText = text(); + root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); + } + onLoadFailed: { + root.passwordAuthPamText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + FileView { + id: systemLoginPamWatcher + path: "/etc/pam.d/system-login" + printErrors: false + onLoaded: { + root.systemLoginPamText = text(); + root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); + } + onLoadFailed: { + root.systemLoginPamText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + FileView { + id: systemLocalLoginPamWatcher + path: "/etc/pam.d/system-local-login" + printErrors: false + onLoaded: { + root.systemLocalLoginPamText = text(); + root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); + } + onLoadFailed: { + root.systemLocalLoginPamText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + FileView { + id: commonAuthPcPamWatcher + path: "/etc/pam.d/common-auth-pc" + printErrors: false + onLoaded: { + root.commonAuthPcPamText = text(); + root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); + } + onLoadFailed: { + root.commonAuthPcPamText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + FileView { + id: loginPamWatcher + path: "/etc/pam.d/login" + printErrors: false + onLoaded: { + root.loginPamText = text(); + root.refreshPasswordAttemptPolicyHint(); + root.maybeAutoStartExternalAuth(); + } + onLoadFailed: { + root.loginPamText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + FileView { + id: faillockConfigWatcher + path: "/etc/security/faillock.conf" + printErrors: false + onLoaded: { + root.faillockConfigText = text(); + root.refreshPasswordAttemptPolicyHint(); + } + onLoadFailed: { + root.faillockConfigText = ""; + root.refreshPasswordAttemptPolicyHint(); + } + } + + Component.onCompleted: { + initWeatherService(); + refreshPasswordAttemptPolicyHint(); + + if (isPrimaryScreen) + applyLastSuccessfulUser(); + + if (CompositorService.isHyprland) + updateHyprlandLayout(); + + fprintdDeviceProbe.running = true; + } + + function applyPickerPreviewTheme() { + let previewUser = (pickerThemeUsername || "").trim(); + if (!previewUser && GreetdSettings.rememberLastUser) + previewUser = (GreetdMemory.lastSuccessfulUser || "").trim(); + if (previewUser) + GreeterUserTheme.applyForUser(previewUser); + else + GreeterUserTheme.applyDefault(); + } + + function applyLastSuccessfulUser() { + if (root.skipAutoSelectUser) + return; + if (!GreetdSettings.settingsLoaded || !GreetdSettings.rememberLastUser) + return; + const lastUser = GreetdMemory.lastSuccessfulUser; + if (lastUser && !GreeterState.showPasswordInput && !GreeterState.username) { + selectUser(lastUser, true); + } + } + + function enterManualUsernameEntry() { + if (!root.pickerAvailable || GreeterState.showPasswordInput) + return; + root.manualUsernameEntry = true; + root.userListOpen = false; + GreeterState.username = ""; + GreeterState.usernameInput = ""; + GreeterState.selectedUserIndex = -1; + inputField.text = ""; + root.applyPickerPreviewTheme(); + Qt.callLater(() => inputField.forceActiveFocus()); + } + + function returnToUserListFromManualEntry() { + if (!root.pickerAvailable) + return; + root.manualUsernameEntry = false; + root.userListOpen = true; + GreeterState.username = ""; + GreeterState.usernameInput = ""; + inputField.text = ""; + root.applyPickerPreviewTheme(); + } + + function returnToUserPicker() { + if (!root.pickerAvailable || GreeterState.unlocking) + return; + root.manualUsernameEntry = false; + root.skipAutoSelectUser = true; + awaitingExternalAuth = false; + pendingPasswordResponse = false; + passwordSubmitRequested = false; + resetPasswordSessionTransition(true); + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.stop(); + clearAuthFeedback(); + passwordFailureCount = 0; + externalAuthAutoStartedForUser = ""; + if (Greetd.state !== GreetdState.Inactive) + Greetd.cancelSession(); + const previousUser = GreeterState.username; + GreeterState.reset(); + inputField.text = ""; + PortalService.profileImage = ""; + if (previousUser) + root.pickerThemeUsername = previousUser; + root.applyPickerPreviewTheme(); + root.userListOpen = true; + } + + function selectUser(rawValue, skipDropdownUpdate) { + const user = (rawValue || "").trim(); + if (!user) + return; + root.manualUsernameEntry = false; + root.skipAutoSelectUser = false; + submitUsername(user, skipDropdownUpdate === true); + } + + function submitUsername(rawValue, skipDropdownUpdate) { + const user = (rawValue || "").trim(); + if (!user) + return; + if (GreeterState.username !== user) { + passwordFailureCount = 0; + clearAuthFeedback(); + externalAuthAutoStartedForUser = ""; + } + root.pickerThemeUsername = user; + GreeterState.username = user; + GreeterState.usernameInput = user; + GreeterState.showPasswordInput = true; + if (!skipDropdownUpdate && typeof GreeterUsersService !== "undefined") { + const idx = GreeterUsersService.usernames.indexOf(user); + GreeterState.selectedUserIndex = idx; + } + root.userListOpen = false; + PortalService.getGreeterUserProfileImage(user); + GreeterState.passwordBuffer = ""; + pendingPasswordResponse = false; + resetPasswordSessionTransition(true); + maybeAutoStartExternalAuth(); + } + + function submitBufferedPassword() { + pendingPasswordResponse = false; + resetPasswordSessionTransition(true); + awaitingExternalAuth = false; + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.restart(); + // Some PAM stacks expect an explicit empty response to advance U2F/fprint or fail normally. + Greetd.respond(GreeterState.passwordBuffer || ""); + GreeterState.passwordBuffer = ""; + inputField.text = ""; + return true; + } + + function requestPasswordSessionTransition() { + const hasPasswordBuffer = GreeterState.passwordBuffer && GreeterState.passwordBuffer.length > 0; + if (!passwordSubmitRequested && !hasPasswordBuffer) + return; + if (cancelingExternalAuthForPassword) + return; + if (passwordSessionTransitionRetryCount >= maxPasswordSessionTransitionRetries) { + pendingPasswordResponse = false; + awaitingExternalAuth = false; + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.stop(); + resetPasswordSessionTransition(true); + GreeterState.pamState = "error"; + authFeedbackMessage = currentAuthMessage(); + placeholderDelay.restart(); + Greetd.cancelSession(); + return; + } + cancelingExternalAuthForPassword = true; + passwordSessionTransitionRetryCount = passwordSessionTransitionRetryCount + 1; + awaitingExternalAuth = false; + pendingPasswordResponse = false; + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.stop(); + Greetd.cancelSession(); + } + + function startAuthSession(submitPassword) { + submitPassword = submitPassword === true; + if (!GreeterState.showPasswordInput || !GreeterState.username) + return; + if (GreeterState.unlocking) + return; + const hasPasswordBuffer = GreeterState.passwordBuffer && GreeterState.passwordBuffer.length > 0; + if (Greetd.state !== GreetdState.Inactive) { + if (pendingPasswordResponse && submitPassword) + submitBufferedPassword(); + else if (submitPassword) + passwordSubmitRequested = true; + return; + } + if (cancelingExternalAuthForPassword) { + if (submitPassword) + passwordSubmitRequested = true; + return; + } + if (!submitPassword && !hasPasswordBuffer && !root.greeterExternalAuthAvailable) + return; + pendingPasswordResponse = false; + passwordSubmitRequested = submitPassword; + awaitingExternalAuth = !submitPassword && !hasPasswordBuffer && root.greeterExternalAuthAvailable; + // Let the effective PAM stack finish external authentication. + const waitingOnPamExternalBeforePassword = submitPassword && root.greeterPamHasExternalAuth; + authTimeout.interval = (awaitingExternalAuth || waitingOnPamExternalBeforePassword) ? externalAuthTimeoutMs : defaultAuthTimeoutMs; + authTimeout.restart(); + Greetd.createSession(GreeterState.username); + } + + function maybeAutoStartExternalAuth() { + if (!GreeterState.showPasswordInput || !GreeterState.username) + return; + if (!root.greeterExternalAuthAvailable) + return; + if (GreeterState.unlocking || Greetd.state !== GreetdState.Inactive) + return; + if (passwordSubmitRequested || cancelingExternalAuthForPassword) + return; + if (GreeterState.passwordBuffer && GreeterState.passwordBuffer.length > 0) + return; + if (externalAuthAutoStartedForUser === GreeterState.username) + return; + + externalAuthAutoStartedForUser = GreeterState.username; + startAuthSession(false); + } + + function isExternalAuthPrompt(message, responseRequired) { + // Non-response PAM messages commonly represent waiting states (fprint/U2F/token touch). + return !responseRequired; + } + + Component.onDestruction: { + if (weatherInitialized) + WeatherService.removeRef(); + } + + function updateHyprlandLayout() { + if (CompositorService.isHyprland) { + hyprlandLayoutProcess.running = true; + } + } + + Process { + id: greeterAutoLoginPendingProcess + command: ["sh", "-c", "mkdir -p $(dirname " + JSON.stringify((Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending") + ") && touch " + JSON.stringify((Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter") + "/.local/state/auto-login-sync-pending")] + running: false + } + + Process { + id: hyprlandLayoutProcess + running: false + command: ["hyprctl", "-j", "devices"] + stdout: StdioCollector { + onStreamFinished: { + try { + const data = JSON.parse(text); + const mainKeyboard = data.keyboards.find(kb => kb.main === true); + if (!mainKeyboard) { + hyprlandCurrentLayout = ""; + hyprlandLayoutCount = 0; + return; + } + hyprlandKeyboard = mainKeyboard.name; + if (mainKeyboard.active_keymap) { + hyprlandCurrentLayout = LayoutCodes.layoutCode(mainKeyboard.active_keymap); + } else { + hyprlandCurrentLayout = ""; + } + hyprlandLayoutCount = mainKeyboard.layout ? mainKeyboard.layout.split(",").length : 0; + } catch (e) { + hyprlandCurrentLayout = ""; + hyprlandLayoutCount = 0; + } + } + } + } + + // Probe fprintd D-Bus for physically enrolled scanners to eliminate PAM stack false-positives. + Process { + id: fprintdDeviceProbe + running: false + // sh wrapper: emits PROBE_UNAVAILABLE if gdbus is absent or fprintd unreachable, + // keeping the PAM-only fallback active in those cases. + command: ["sh", "-c", "command -v gdbus >/dev/null 2>&1 || { echo PROBE_UNAVAILABLE; exit 0; }; " + "gdbus call --system " + "--dest net.reactivated.Fprint " + "--object-path /net/reactivated/Fprint/Manager " + "--method net.reactivated.Fprint.Manager.GetDevices 2>/dev/null " + "|| echo PROBE_UNAVAILABLE"] + stdout: StdioCollector { + onStreamFinished: { + if (text.includes("PROBE_UNAVAILABLE")) + return; // PAM-only fallback stays active + root.fprintdHasDevice = text.includes("objectpath"); + root.fprintdProbeComplete = true; + root.maybeAutoStartExternalAuth(); + } + } + onExited: function (exitCode, exitStatus) { + if (!root.fprintdProbeComplete) + root.maybeAutoStartExternalAuth(); // PAM-only fallback stays active + } + } + + Connections { + target: CompositorService.isHyprland ? Hyprland : null + enabled: CompositorService.isHyprland + + function onRawEvent(event) { + if (event.name === "activelayout") + updateHyprlandLayout(); + } + } + + Connections { + target: GreetdMemory + enabled: isPrimaryScreen + function onLastSuccessfulUserChanged() { + applyLastSuccessfulUser(); + } + function onMemoryReadyChanged() { + finalizeSessionSelection(); + } + } + + Connections { + target: GreeterUsersService + function onLoadedChanged() { + if (GreeterUsersService.loaded && isPrimaryScreen) + applyPickerPreviewTheme(); + } + function onSyncedThemePathsChanged() { + if (!isPrimaryScreen) + return; + if (GreeterState.username) + GreeterUserTheme.applyForUser(GreeterState.username); + else if (root.showUserPicker || root.userListOpen) + applyPickerPreviewTheme(); + } + } + + Connections { + target: GreeterState + function onUsernameChanged() { + if (GreeterState.username) { + root.pickerThemeUsername = GreeterState.username; + GreeterUserTheme.applyForUser(GreeterState.username); + PortalService.getGreeterUserProfileImage(GreeterState.username); + } else if (root.showUserPicker || root.userListOpen) { + applyPickerPreviewTheme(); + } + } + function onShowPasswordInputChanged() { + if (GreeterState.showPasswordInput) + root.userListOpen = false; + } + } + + onShowUserPickerChanged: { + if (showUserPicker && !GreeterState.username) + applyPickerPreviewTheme(); + if (!showUserPicker) + userListOpen = false; + } + + FileView { + id: greeterWallpaperOverrideFile + path: GreetdSettings.greeterWallpaperOverridePath + printErrors: false + watchChanges: true + onLoaded: root.greeterWallpaperOverrideExists = true + onLoadFailed: root.greeterWallpaperOverrideExists = false + } + + Connections { + target: GreetdSettings + function onGreeterWallpaperOverridePathChanged() { + if (!GreetdSettings.greeterWallpaperOverridePath) { + root.greeterWallpaperOverrideExists = false; + return; + } + greeterWallpaperOverrideFile.reload(); + } + function onGreeterWallpaperPathChanged() { + if (!GreetdSettings.greeterWallpaperPath) { + root.greeterWallpaperOverrideExists = false; + return; + } + greeterWallpaperOverrideFile.reload(); + } + } + + Rectangle { + anchors.fill: parent + color: GreetdSettings.effectiveWallpaperBackgroundColor + } + + DankBackdrop { + anchors.fill: parent + screenName: root.screenName + visible: { + if (GreetdSettings.greeterWallpaperPath !== "" && root.greeterWallpaperOverrideExists) + return false; + var _ = SessionData.perMonitorWallpaper; + var __ = SessionData.monitorWallpapers; + var currentWallpaper = SessionData.getMonitorWallpaper(screenName); + return !currentWallpaper || currentWallpaper === "" || (currentWallpaper && currentWallpaper.startsWith("#")); + } + } + + Image { + id: wallpaperBackground + + anchors.fill: parent + source: { + if (GreetdSettings.greeterWallpaperPath !== "" && root.greeterWallpaperOverrideExists) + return encodeFileUrl(GreetdSettings.greeterWallpaperOverridePath); + var _ = SessionData.perMonitorWallpaper; + var __ = SessionData.monitorWallpapers; + var currentWallpaper = SessionData.getMonitorWallpaper(screenName); + return (currentWallpaper && !currentWallpaper.startsWith("#")) ? encodeFileUrl(currentWallpaper) : ""; + } + fillMode: Theme.getFillMode(GreetdSettings.getEffectiveWallpaperFillMode()) + smooth: true + asynchronous: false + cache: true + visible: source !== "" + layer.enabled: true + + layer.effect: MultiEffect { + autoPaddingEnabled: false + blurEnabled: true + blur: 0.8 + blurMax: 32 + blurMultiplier: 1 + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + } + + Rectangle { + anchors.fill: parent + color: "black" + opacity: 0.4 + } + + SystemClock { + id: systemClock + precision: SystemClock.Seconds + } + + Rectangle { + anchors.fill: parent + color: "transparent" + + MouseArea { + anchors.fill: parent + enabled: root.userListOpen + visible: root.userListOpen + onClicked: root.userListOpen = false + } + + Column { + id: greeterMainColumn + + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + width: 380 + + Item { + id: clockContainer + + width: parent.width + height: clockText.implicitHeight + + Row { + id: clockText + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: 0 + + property string fullTimeStr: { + const format = GreetdSettings.getEffectiveTimeFormat(); + return systemClock.date.toLocaleTimeString(I18n.locale(), format); + } + property var timeParts: fullTimeStr.split(':') + property string hours: timeParts[0] || "" + property string minutes: timeParts[1] || "" + property string secondsWithAmPm: timeParts.length > 2 ? timeParts[2] : "" + property string seconds: secondsWithAmPm.replace(/\s*(AM|PM|am|pm)$/i, '') + property string ampm: { + const match = fullTimeStr.match(/\s*(AM|PM|am|pm)$/i); + return match ? match[0].trim() : ""; + } + property bool hasSeconds: timeParts.length > 2 + + StyledText { + width: 75 + text: clockText.hours.length > 1 ? clockText.hours[0] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + width: 75 + text: clockText.hours.length > 1 ? clockText.hours[1] : clockText.hours.length > 0 ? clockText.hours[0] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + text: ":" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + } + + StyledText { + width: 75 + text: clockText.minutes.length > 0 ? clockText.minutes[0] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + width: 75 + text: clockText.minutes.length > 1 ? clockText.minutes[1] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + text: clockText.hasSeconds ? ":" : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + visible: clockText.hasSeconds + } + + StyledText { + width: 75 + text: clockText.hasSeconds && clockText.seconds.length > 0 ? clockText.seconds[0] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + visible: clockText.hasSeconds + } + + StyledText { + width: 75 + text: clockText.hasSeconds && clockText.seconds.length > 1 ? clockText.seconds[1] : "" + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + horizontalAlignment: Text.AlignHCenter + visible: clockText.hasSeconds + } + + StyledText { + width: 20 + text: " " + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + visible: clockText.ampm !== "" + } + + StyledText { + text: clockText.ampm + font.pixelSize: 120 + font.weight: Font.Light + color: "white" + visible: clockText.ampm !== "" + } + } + } + + StyledText { + id: dateText + + anchors.horizontalCenter: parent.horizontalCenter + text: systemClock.date.toLocaleDateString(I18n.locale(), GreetdSettings.getEffectiveLockDateFormat()) + font.pixelSize: Theme.fontSizeXLarge + color: "white" + opacity: 0.9 + } + + ColumnLayout { + id: authColumn + + width: parent.width + spacing: Theme.spacingM + + RowLayout { + spacing: Theme.spacingL + Layout.fillWidth: true + + Item { + Layout.preferredWidth: 60 + Layout.preferredHeight: 60 + visible: GreetdSettings.lockScreenShowProfileImage || root.pickerAvailable + + DankCircularImage { + anchors.fill: parent + imageSource: { + const displayUser = GreeterState.username || root.pickerThemeUsername; + if (displayUser) { + const cachedPath = GreeterUsersService.profileImagePath(displayUser); + if (cachedPath) + return encodeFileUrl(cachedPath); + } + if (PortalService.profileImage === "") + return ""; + if (PortalService.profileImage.startsWith("/")) + return encodeFileUrl(PortalService.profileImage); + return PortalService.profileImage; + } + fallbackIcon: "person" + } + + Rectangle { + anchors.fill: parent + radius: width / 2 + color: "transparent" + border.color: Theme.primary + border.width: (avatarPickerArea.containsMouse || root.userListOpen) && !GreeterState.showPasswordInput ? 2 : 0 + visible: root.pickerAvailable + Behavior on border.width { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + + // Switch-user affordance: hover scrim over the selected user's avatar. + Rectangle { + anchors.fill: parent + radius: width / 2 + color: Qt.rgba(0, 0, 0, 0.55) + opacity: (root.pickerAvailable && GreeterState.showPasswordInput && avatarPickerArea.containsMouse) ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + DankIcon { + anchors.centerIn: parent + name: "switch_account" + size: 24 + color: "white" + } + } + + MouseArea { + id: avatarPickerArea + + anchors.fill: parent + visible: root.pickerAvailable + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (GreeterState.showPasswordInput) + root.returnToUserPicker(); + else if (root.manualUsernameEntry) + root.returnToUserListFromManualEntry(); + else + root.userListOpen = !root.userListOpen; + } + } + } + + Rectangle { + property bool showPassword: false + + Layout.fillWidth: true + Layout.preferredHeight: root.showUserPicker && root.userListOpen ? Math.max(60, userPicker.implicitHeight + Theme.spacingM * 2) : 60 + + clip: true + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainer, 0.9) + border.color: inputField.activeFocus ? Theme.primary : Qt.rgba(1, 1, 1, 0.3) + border.width: inputField.activeFocus ? 2 : 1 + + GreeterUserPicker { + id: userPicker + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: root.userListOpen ? undefined : parent.verticalCenter + anchors.top: root.userListOpen ? parent.top : undefined + anchors.margins: Theme.spacingM + maxExpandedHeight: root.userPickerMaxHeight + visible: root.showUserPicker && !GreeterState.showPasswordInput + expanded: root.userListOpen + autoLoginVisible: root.autoLoginAvailable + autoLoginChecked: root.autoLoginOnSuccess + manualEntryVisible: true + onUserSelected: username => root.selectUser(username, false) + onToggleRequested: root.userListOpen = !root.userListOpen + onAutoLoginToggled: root.autoLoginOnSuccess = !root.autoLoginOnSuccess + onManualEntryRequested: root.enterManualUsernameEntry() + } + + DankIcon { + id: lockIcon + + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + name: GreeterState.showPasswordInput ? "lock" : "person" + size: 20 + color: inputField.activeFocus ? Theme.primary : Theme.surfaceVariantText + visible: !root.showUserPicker + } + + TextInput { + id: inputField + + property bool syncingFromState: false + + anchors.fill: parent + anchors.leftMargin: lockIcon.width + Theme.spacingM * 2 + anchors.rightMargin: { + let margin = Theme.spacingM; + if (GreeterState.showPasswordInput && revealButton.visible) { + margin += revealButton.width; + } + if (externalAuthButton.visible) { + margin += externalAuthButton.width; + } + if (virtualKeyboardButton.visible) { + margin += virtualKeyboardButton.width; + } + if (enterButton.visible) { + margin += enterButton.width + 2; + } + return margin; + } + enabled: !root.showUserPicker || GreeterState.showPasswordInput + opacity: 0 + focus: !root.showUserPicker || GreeterState.showPasswordInput + echoMode: GreeterState.showPasswordInput ? (parent.showPassword ? TextInput.Normal : TextInput.Password) : TextInput.Normal + onTextChanged: { + if (syncingFromState) + return; + if (GreeterState.showPasswordInput) { + GreeterState.passwordBuffer = text; + if (!text || text.length === 0) + root.passwordSubmitRequested = false; + } else { + GreeterState.usernameInput = text; + } + } + onAccepted: { + if (GreeterState.showPasswordInput) { + root.startAuthSession(true); + } else { + if (text.trim()) { + root.submitUsername(text); + syncingFromState = true; + text = ""; + syncingFromState = false; + } + } + } + + Component.onCompleted: { + syncingFromState = true; + text = GreeterState.showPasswordInput ? GreeterState.passwordBuffer : GreeterState.usernameInput; + syncingFromState = false; + if (isPrimaryScreen && !powerMenu.isVisible) + forceActiveFocus(); + } + onVisibleChanged: { + if (visible && isPrimaryScreen && !powerMenu.isVisible) + forceActiveFocus(); + } + } + + KeyboardController { + id: keyboard_controller + target: inputField + rootObject: root + } + + StyledText { + id: placeholder + + anchors.left: lockIcon.right + anchors.leftMargin: Theme.spacingM + anchors.right: (GreeterState.showPasswordInput && revealButton.visible ? revealButton.left : (externalAuthButton.visible ? externalAuthButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right)))) + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + text: { + if (GreeterState.unlocking) { + return I18n.tr("Logging in..."); + } + if (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse) { + return I18n.tr("Authenticating..."); + } + if (GreeterState.showPasswordInput) { + return I18n.tr("Password..."); + } + if (root.showUserPicker) { + return ""; + } + return I18n.tr("Username..."); + } + color: (GreeterState.unlocking || (Greetd.state !== GreetdState.Inactive && !awaitingExternalAuth && !pendingPasswordResponse)) ? Theme.primary : Theme.outline + font.pixelSize: Theme.fontSizeMedium + opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length === 0 : (root.showUserPicker ? false : GreeterState.usernameInput.length === 0)) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + + StyledText { + anchors.left: lockIcon.right + anchors.leftMargin: Theme.spacingM + anchors.right: (GreeterState.showPasswordInput && revealButton.visible ? revealButton.left : (externalAuthButton.visible ? externalAuthButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right)))) + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + text: { + if (GreeterState.showPasswordInput) { + if (parent.showPassword) { + return GreeterState.passwordBuffer; + } + return "•".repeat(GreeterState.passwordBuffer.length); + } + return GreeterState.usernameInput; + } + color: Theme.surfaceText + font.pixelSize: (GreeterState.showPasswordInput && !parent.showPassword) ? Theme.fontSizeLarge : Theme.fontSizeMedium + opacity: (GreeterState.showPasswordInput ? GreeterState.passwordBuffer.length > 0 : (root.showUserPicker ? false : GreeterState.usernameInput.length > 0)) ? 1 : 0 + clip: true + elide: Text.ElideNone + horizontalAlignment: implicitWidth > width ? Text.AlignRight : Text.AlignLeft + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + } + + DankActionButton { + id: revealButton + + anchors.right: externalAuthButton.visible ? externalAuthButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right)) + anchors.rightMargin: 0 + anchors.verticalCenter: parent.verticalCenter + iconName: parent.showPassword ? "visibility_off" : "visibility" + buttonSize: 32 + visible: GreeterState.showPasswordInput && GreeterState.passwordBuffer.length > 0 && (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking + enabled: visible + onClicked: parent.showPassword = !parent.showPassword + } + DankActionButton { + id: externalAuthButton + + anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (enterButton.visible ? enterButton.left : parent.right) + anchors.rightMargin: 0 + anchors.verticalCenter: parent.verticalCenter + iconName: root.greeterPamHasFprint ? "fingerprint" : "key" + buttonSize: 32 + visible: GreeterState.showPasswordInput && root.greeterExternalAuthAvailable && GreeterState.passwordBuffer.length === 0 && (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking + enabled: visible + onClicked: root.startAuthSession(false) + } + DankActionButton { + id: virtualKeyboardButton + + anchors.right: enterButton.visible ? enterButton.left : parent.right + anchors.rightMargin: enterButton.visible ? 0 : Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: "keyboard" + buttonSize: 32 + visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking && (!root.showUserPicker || GreeterState.showPasswordInput) + enabled: visible + onClicked: { + if (keyboard_controller.isKeyboardActive) { + keyboard_controller.hide(); + } else { + keyboard_controller.show(); + } + } + } + + DankActionButton { + id: enterButton + + anchors.right: parent.right + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + iconName: "keyboard_return" + buttonSize: 36 + visible: (Greetd.state === GreetdState.Inactive || awaitingExternalAuth || pendingPasswordResponse) && !GreeterState.unlocking && (!root.showUserPicker || GreeterState.showPasswordInput) + enabled: true + onClicked: { + if (GreeterState.showPasswordInput) { + root.startAuthSession(true); + } else { + if (inputField.text.trim()) { + root.submitUsername(inputField.text); + inputField.text = ""; + } + } + } + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + + Behavior on border.color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Behavior on Layout.preferredHeight { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: root.showAccountSwitchLink ? 28 : 0 + visible: root.showAccountSwitchLink + + StyledText { + id: accountSwitchLabel + + anchors.horizontalCenter: parent.horizontalCenter + text: I18n.tr("Back to user list", "greeter link to return from manual username entry to user picker") + color: Theme.primary + font.pixelSize: Theme.fontSizeSmall + font.underline: accountSwitchMouse.containsMouse + } + + MouseArea { + id: accountSwitchMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.returnToUserListFromManualEntry() + } + } + + StyledText { + Layout.fillWidth: true + Layout.preferredHeight: root.authDisplayMessage !== "" ? 38 : 0 + Layout.topMargin: -Theme.spacingS + Layout.bottomMargin: -Theme.spacingS + text: root.authDisplayMessage + color: root.authFeedbackMessage !== "" ? Theme.error : (root.authSuccessMessage !== "" ? Theme.success : Theme.surfaceVariantText) + font.pixelSize: Theme.fontSizeSmall + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + maximumLineCount: 2 + opacity: root.authDisplayMessage !== "" ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + + Row { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Theme.spacingXL + spacing: Theme.spacingL + + Item { + width: keyboardLayoutRow.width + height: keyboardLayoutRow.height + anchors.verticalCenter: parent.verticalCenter + visible: { + if (CompositorService.isNiri) { + return NiriService.keyboardLayoutNames.length > 1; + } else if (CompositorService.isHyprland) { + return hyprlandLayoutCount > 1; + } + return false; + } + + Row { + id: keyboardLayoutRow + spacing: Theme.spacingXS + + Item { + width: Theme.iconSize + height: Theme.iconSize + + DankIcon { + name: "keyboard" + size: Theme.iconSize + color: "white" + anchors.centerIn: parent + } + } + + Item { + width: childrenRect.width + height: Theme.iconSize + + StyledText { + text: { + if (CompositorService.isNiri) { + return LayoutCodes.layoutCode(NiriService.getCurrentKeyboardLayoutName()); + } else if (CompositorService.isHyprland) { + return hyprlandCurrentLayout; + } + return ""; + } + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Light + color: "white" + anchors.verticalCenter: parent.verticalCenter + } + } + } + + MouseArea { + id: keyboardLayoutArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (CompositorService.isNiri) { + NiriService.cycleKeyboardLayout(); + } else if (CompositorService.isHyprland) { + Quickshell.execDetached(["hyprctl", "switchxkblayout", hyprlandKeyboard, "next"]); + updateHyprlandLayout(); + } + } + } + } + + Rectangle { + width: 1 + height: 24 + color: Qt.rgba(255, 255, 255, 0.2) + anchors.verticalCenter: parent.verticalCenter + visible: { + const keyboardVisible = (CompositorService.isNiri && NiriService.keyboardLayoutNames.length > 1) || (CompositorService.isHyprland && hyprlandLayoutCount > 1); + return keyboardVisible && GreetdSettings.weatherEnabled && WeatherService.weather.available; + } + } + + Row { + spacing: Theme.spacingXS + visible: GreetdSettings.weatherEnabled && WeatherService.weather.available + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + name: WeatherService.getWeatherIcon(WeatherService.weather.wCode) + size: Theme.iconSize + color: "white" + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: (GreetdSettings.useFahrenheit ? WeatherService.weather.tempF : WeatherService.weather.temp) + "°" + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Light + color: "white" + anchors.verticalCenter: parent.verticalCenter + } + } + + Rectangle { + width: 1 + height: 24 + color: Qt.rgba(255, 255, 255, 0.2) + anchors.verticalCenter: parent.verticalCenter + visible: GreetdSettings.weatherEnabled && WeatherService.weather.available && (NetworkService.networkStatus !== "disconnected" || BluetoothService.enabled || (AudioService.sink && AudioService.sink.audio) || BatteryService.batteryAvailable) + } + + Row { + spacing: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + visible: NetworkService.networkStatus !== "disconnected" || (BluetoothService.available && BluetoothService.enabled) || (AudioService.sink && AudioService.sink.audio) + + DankIcon { + name: NetworkService.networkStatus === "ethernet" ? "lan" : NetworkService.wifiSignalIcon + size: Theme.iconSize - 2 + color: NetworkService.networkStatus !== "disconnected" ? "white" : Qt.rgba(255, 255, 255, 0.5) + anchors.verticalCenter: parent.verticalCenter + visible: NetworkService.networkStatus !== "disconnected" + } + + DankIcon { + name: "bluetooth" + size: Theme.iconSize - 2 + color: "white" + anchors.verticalCenter: parent.verticalCenter + visible: BluetoothService.available && BluetoothService.enabled + } + + DankIcon { + name: { + if (!AudioService.sink?.audio) { + return "volume_up"; + } + if (AudioService.sink.audio.muted) + return "volume_off"; + if (AudioService.sink.audio.volume === 0) + return "volume_mute"; + if (AudioService.sink.audio.volume * 100 < 33) { + return "volume_down"; + } + return "volume_up"; + } + size: Theme.iconSize - 2 + color: (AudioService.sink && AudioService.sink.audio && (AudioService.sink.audio.muted || AudioService.sink.audio.volume === 0)) ? Qt.rgba(255, 255, 255, 0.5) : "white" + anchors.verticalCenter: parent.verticalCenter + visible: AudioService.sink && AudioService.sink.audio + } + } + + Rectangle { + width: 1 + height: 24 + color: Qt.rgba(255, 255, 255, 0.2) + anchors.verticalCenter: parent.verticalCenter + visible: BatteryService.batteryAvailable && (NetworkService.networkStatus !== "disconnected" || BluetoothService.enabled || (AudioService.sink && AudioService.sink.audio)) + } + + Row { + spacing: Theme.spacingXS + visible: BatteryService.batteryAvailable + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + name: { + if (BatteryService.isCharging) { + if (BatteryService.batteryLevel >= 90) { + return "battery_charging_full"; + } + + if (BatteryService.batteryLevel >= 80) { + return "battery_charging_90"; + } + + if (BatteryService.batteryLevel >= 60) { + return "battery_charging_80"; + } + + if (BatteryService.batteryLevel >= 50) { + return "battery_charging_60"; + } + + if (BatteryService.batteryLevel >= 30) { + return "battery_charging_50"; + } + + if (BatteryService.batteryLevel >= 20) { + return "battery_charging_30"; + } + + return "battery_charging_20"; + } + if (BatteryService.isPluggedIn) { + if (BatteryService.batteryLevel >= 90) { + return "battery_charging_full"; + } + + if (BatteryService.batteryLevel >= 80) { + return "battery_charging_90"; + } + + if (BatteryService.batteryLevel >= 60) { + return "battery_charging_80"; + } + + if (BatteryService.batteryLevel >= 50) { + return "battery_charging_60"; + } + + if (BatteryService.batteryLevel >= 30) { + return "battery_charging_50"; + } + + if (BatteryService.batteryLevel >= 20) { + return "battery_charging_30"; + } + + return "battery_charging_20"; + } + if (BatteryService.batteryLevel >= 95) { + return "battery_full"; + } + + if (BatteryService.batteryLevel >= 85) { + return "battery_6_bar"; + } + + if (BatteryService.batteryLevel >= 70) { + return "battery_5_bar"; + } + + if (BatteryService.batteryLevel >= 55) { + return "battery_4_bar"; + } + + if (BatteryService.batteryLevel >= 40) { + return "battery_3_bar"; + } + + if (BatteryService.batteryLevel >= 25) { + return "battery_2_bar"; + } + + return "battery_1_bar"; + } + size: Theme.iconSize + color: { + if (BatteryService.isLowBattery && !BatteryService.isCharging) { + return Theme.error; + } + + if (BatteryService.isCharging || BatteryService.isPluggedIn) { + return Theme.primary; + } + + return "white"; + } + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: BatteryService.batteryLevel + "%" + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Light + color: "white" + anchors.verticalCenter: parent.verticalCenter + } + } + } + + DankActionButton { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Theme.spacingXL + visible: GreetdSettings.lockScreenShowPowerActions + iconName: "power_settings_new" + iconColor: Theme.error + buttonSize: 40 + onClicked: powerMenu.show() + } + + Item { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: Theme.spacingXL + width: Math.max(200, currentSessionMetrics.width + 80) + height: 60 + + StyledTextMetrics { + id: currentSessionMetrics + text: root.currentSessionName + } + + property real longestSessionWidth: { + let maxWidth = 0; + for (var i = 0; i < sessionMetricsRepeater.count; i++) { + const item = sessionMetricsRepeater.itemAt(i); + if (item && item.width > maxWidth) { + maxWidth = item.width; + } + } + return maxWidth; + } + + Repeater { + id: sessionMetricsRepeater + model: GreeterState.sessionList + delegate: StyledTextMetrics { + text: modelData + } + } + + DankDropdown { + id: sessionDropdown + anchors.fill: parent + text: "" + description: "" + currentValue: root.currentSessionName + options: GreeterState.sessionList + enableFuzzySearch: GreeterState.sessionList.length > 5 + popupWidthOffset: 0 + popupWidth: Math.max(250, parent.longestSessionWidth + 100) + openUpwards: true + alignPopupRight: true + onValueChanged: value => { + const idx = GreeterState.sessionList.indexOf(value); + if (idx < 0) + return; + GreeterState.sessionManuallySelected = true; + GreeterState.currentSessionIndex = idx; + GreeterState.selectedSession = GreeterState.sessionExecs[idx]; + GreeterState.selectedSessionPath = GreeterState.sessionPaths[idx]; + GreeterState.selectedSessionDesktopId = GreeterState.sessionDesktopIds[idx]; + } + } + } + } + + property string currentSessionName: GreeterState.sessionList[GreeterState.currentSessionIndex] || "" + + function finalizeSessionSelection() { + if (GreeterState.sessionManuallySelected) + return; + if (GreeterState.sessionList.length === 0) + return; + if (!GreetdMemory.memoryReady) + return; + if (!GreetdSettings.settingsLoaded) + return; + + const savedSession = GreetdSettings.rememberLastSession ? GreetdMemory.lastSessionId : ""; + const savedDesktopId = GreetdSettings.rememberLastSession ? (GreetdMemory.lastSessionDesktopId || desktopIdFromPath(GreetdMemory.lastSessionId)) : ""; + if ((savedSession || savedDesktopId) && GreetdSettings.rememberLastSession) { + for (var i = 0; i < GreeterState.sessionPaths.length; i++) { + if ((savedDesktopId && GreeterState.sessionDesktopIds[i] === savedDesktopId) || (savedSession && GreeterState.sessionPaths[i] === savedSession)) { + GreeterState.currentSessionIndex = i; + GreeterState.selectedSession = GreeterState.sessionExecs[i] || ""; + GreeterState.selectedSessionPath = GreeterState.sessionPaths[i]; + GreeterState.selectedSessionDesktopId = GreeterState.sessionDesktopIds[i] || ""; + return; + } + } + } + + GreeterState.currentSessionIndex = 0; + GreeterState.selectedSession = GreeterState.sessionExecs[0] || ""; + GreeterState.selectedSessionPath = GreeterState.sessionPaths[0] || ""; + GreeterState.selectedSessionDesktopId = GreeterState.sessionDesktopIds[0] || ""; + } + + property var sessionDirs: { + const homeDir = Quickshell.env("HOME") || ""; + const dirs = ["/usr/share/wayland-sessions", "/usr/share/xsessions", "/usr/local/share/wayland-sessions", "/usr/local/share/xsessions"]; + + if (homeDir) { + dirs.push(homeDir + "/.local/share/wayland-sessions"); + dirs.push(homeDir + "/.local/share/xsessions"); + } + + if (xdgDataDirs) { + xdgDataDirs.split(":").forEach(dir => { + if (dir) { + dirs.push(dir + "/wayland-sessions"); + dirs.push(dir + "/xsessions"); + } + }); + } + + // _addSession guards against a session name already existing + // so we have to load from the user directories first so they + // correctly override a system configuration + return dirs.reverse(); + } + + property var _pendingFiles: ({}) + property int _pendingCount: 0 + + function _addSession(path, name, exec) { + if (!name || !exec || GreeterState.sessionList.includes(name)) + return; + GreeterState.sessionList = GreeterState.sessionList.concat([name]); + GreeterState.sessionExecs = GreeterState.sessionExecs.concat([exec]); + GreeterState.sessionPaths = GreeterState.sessionPaths.concat([path]); + GreeterState.sessionDesktopIds = GreeterState.sessionDesktopIds.concat([desktopIdFromPath(path)]); + } + + function _parseDesktopFile(content, path) { + let name = ""; + let exec = ""; + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!name && line.startsWith("Name=")) + name = line.substring(5).trim(); + else if (!exec && line.startsWith("Exec=")) + exec = line.substring(5).trim(); + if (name && exec) + break; + } + _addSession(path, name, exec); + } + + function _loadDesktopFile(filePath) { + if (_pendingFiles[filePath]) + return; + _pendingFiles[filePath] = true; + _pendingCount++; + + const loader = desktopFileLoader.createObject(root, { + "filePath": filePath + }); + } + + function _onFileLoaded(filePath) { + _pendingCount--; + if (_pendingCount === 0) + Qt.callLater(finalizeSessionSelection); + } + + Component { + id: desktopFileLoader + + FileView { + id: fv + property string filePath: "" + path: filePath + + onLoaded: { + root._parseDesktopFile(text(), filePath); + root._onFileLoaded(filePath); + fv.destroy(); + } + + onLoadFailed: { + root._onFileLoaded(filePath); + fv.destroy(); + } + } + } + + Repeater { + model: isPrimaryScreen ? sessionDirs : [] + + Item { + required property string modelData + + FolderListModel { + folder: encodeFileUrl(modelData) + nameFilters: ["*.desktop"] + showDirs: false + showDotAndDotDot: false + + onStatusChanged: { + if (status !== FolderListModel.Ready) + return; + for (let i = 0; i < count; i++) { + let fp = get(i, "filePath"); + if (fp.startsWith("file://")) + fp = fp.substring(7); + root._loadDesktopFile(fp); + } + } + } + } + } + + Connections { + target: Greetd + enabled: isPrimaryScreen + + function onAuthMessage(message, error, responseRequired, echoResponse) { + if (responseRequired) { + cancelingExternalAuthForPassword = false; + passwordSessionTransitionRetryCount = 0; + awaitingExternalAuth = false; + pendingPasswordResponse = true; + const hasPasswordBuffer = GreeterState.passwordBuffer && GreeterState.passwordBuffer.length > 0; + if (!passwordSubmitRequested && hasPasswordBuffer) + passwordSubmitRequested = true; + if (passwordSubmitRequested && !root.submitBufferedPassword()) + passwordSubmitRequested = false; + if (passwordSubmitRequested || hasPasswordBuffer) { + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.restart(); + } else { + authTimeout.stop(); + } + return; + } + pendingPasswordResponse = false; + const externalPrompt = root.isExternalAuthPrompt(message, responseRequired); + if (!passwordSubmitRequested) + awaitingExternalAuth = root.greeterExternalAuthAvailable && externalPrompt; + if (awaitingExternalAuth || (passwordSubmitRequested && externalPrompt && root.greeterPamHasExternalAuth)) + authTimeout.interval = externalAuthTimeoutMs; + else + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.restart(); + Greetd.respond(""); + } + + function onStateChanged() { + if (Greetd.state === GreetdState.Inactive) { + const resumePasswordSubmit = cancelingExternalAuthForPassword && passwordSubmitRequested; + awaitingExternalAuth = false; + pendingPasswordResponse = false; + cancelingExternalAuthForPassword = false; + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.stop(); + if (resumePasswordSubmit) { + Qt.callLater(function () { + root.startAuthSession(true); + }); + return; + } + resetPasswordSessionTransition(true); + } + } + + function onReadyToLaunch() { + awaitingExternalAuth = false; + pendingPasswordResponse = false; + resetPasswordSessionTransition(true); + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.stop(); + passwordFailureCount = 0; + clearAuthFeedback(); + authSuccessMessage = I18n.tr("Authenticated!"); + const sessionCmd = GreeterState.selectedSession || GreeterState.sessionExecs[GreeterState.currentSessionIndex]; + const sessionPath = GreeterState.selectedSessionPath || GreeterState.sessionPaths[GreeterState.currentSessionIndex]; + const sessionDesktopId = GreeterState.selectedSessionDesktopId || GreeterState.sessionDesktopIds[GreeterState.currentSessionIndex] || desktopIdFromPath(sessionPath); + if (!sessionCmd) { + GreeterState.pamState = "error"; + authFeedbackMessage = currentAuthMessage(); + placeholderDelay.restart(); + return; + } + + GreeterState.unlocking = true; + launchTimeout.restart(); + if (GreetdSettings.rememberLastSession) { + GreetdMemory.setLastSession(sessionPath, sessionDesktopId); + } else if (GreetdMemory.lastSessionId || GreetdMemory.lastSessionDesktopId || GreetdMemory.lastSessionExec) { + GreetdMemory.setLastSession("", ""); + } + if (GreetdSettings.rememberLastUser) { + GreetdMemory.setLastSuccessfulUser(GreeterState.username); + } else if (GreetdMemory.lastSuccessfulUser) { + GreetdMemory.setLastSuccessfulUser(""); + } + if (root.autoLoginOnSuccess) + greeterAutoLoginPendingProcess.running = true; + pendingLaunchCommand = sessionCmd; + pendingLaunchEnv = ["XDG_SESSION_TYPE=wayland"]; + if (Quickshell.env("DMS_VOID") === "1") + pendingLaunchEnv.push("LIBSEAT_BACKEND=logind"); + memoryFlushTimer.restart(); + } + + function onAuthFailure(message) { + awaitingExternalAuth = false; + pendingPasswordResponse = false; + resetPasswordSessionTransition(true); + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.stop(); + launchTimeout.stop(); + GreeterState.unlocking = false; + if (isLikelyLockoutMessage(message)) { + GreeterState.pamState = "max"; + } else { + GreeterState.pamState = "fail"; + passwordFailureCount = passwordFailureCount + 1; + } + authFeedbackMessage = currentAuthMessage(); + GreeterState.passwordBuffer = ""; + inputField.text = ""; + placeholderDelay.restart(); + Greetd.cancelSession(); + } + + function onError(error) { + awaitingExternalAuth = false; + pendingPasswordResponse = false; + resetPasswordSessionTransition(true); + authTimeout.interval = defaultAuthTimeoutMs; + authTimeout.stop(); + launchTimeout.stop(); + GreeterState.unlocking = false; + GreeterState.pamState = "error"; + authFeedbackMessage = currentAuthMessage(); + GreeterState.passwordBuffer = ""; + inputField.text = ""; + placeholderDelay.restart(); + Greetd.cancelSession(); + } + } + + Timer { + id: memoryFlushTimer + interval: memoryFlushDelayMs + onTriggered: { + if (!pendingLaunchCommand) + return; + const sessionCommand = pendingLaunchCommand; + const launchEnv = pendingLaunchEnv; + pendingLaunchCommand = ""; + pendingLaunchEnv = []; + const sessionArgs = sessionCommand.trim().split(/\s+/); + const needsVoidDbusSession = Quickshell.env("DMS_VOID") === "1" && !Quickshell.env("DBUS_SESSION_BUS_ADDRESS") && sessionArgs[0] !== "dbus-run-session"; + const launchArgs = needsVoidDbusSession ? ["dbus-run-session"].concat(sessionArgs) : sessionArgs; + Greetd.launch(launchArgs, launchEnv); + } + } + + Timer { + id: authTimeout + interval: defaultAuthTimeoutMs + onTriggered: { + if (GreeterState.unlocking || Greetd.state === GreetdState.Inactive) + return; + awaitingExternalAuth = false; + pendingPasswordResponse = false; + resetPasswordSessionTransition(true); + authTimeout.interval = defaultAuthTimeoutMs; + GreeterState.pamState = "error"; + authFeedbackMessage = currentAuthMessage(); + GreeterState.passwordBuffer = ""; + inputField.text = ""; + placeholderDelay.restart(); + Greetd.cancelSession(); + } + } + + Timer { + id: launchTimeout + interval: 8000 + onTriggered: { + if (!GreeterState.unlocking) + return; + pendingPasswordResponse = false; + resetPasswordSessionTransition(true); + GreeterState.unlocking = false; + GreeterState.pamState = "error"; + authFeedbackMessage = currentAuthMessage(); + placeholderDelay.restart(); + Greetd.cancelSession(); + } + } + + Timer { + id: placeholderDelay + interval: 4000 + onTriggered: clearAuthFeedback() + } + + LockPowerMenu { + id: powerMenu + showLogout: false + powerActionConfirmOverride: GreetdSettings.powerActionConfirm + powerActionHoldDurationOverride: GreetdSettings.powerActionHoldDuration + powerMenuActionsOverride: GreetdSettings.powerMenuActions + powerMenuDefaultActionOverride: GreetdSettings.powerMenuDefaultAction + powerMenuGridLayoutOverride: GreetdSettings.powerMenuGridLayout + requiredActions: ["poweroff"] + onClosed: { + if (isPrimaryScreen && inputField && inputField.forceActiveFocus) { + Qt.callLater(() => inputField.forceActiveFocus()); + } + } + } +} diff --git a/quickshell/Modules/Greetd/GreeterState.qml b/quickshell/Modules/Greetd/GreeterState.qml new file mode 100644 index 000000000..af8621ae2 --- /dev/null +++ b/quickshell/Modules/Greetd/GreeterState.qml @@ -0,0 +1,36 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell + +Singleton { + id: root + + property string passwordBuffer: "" + property string username: "" + property string usernameInput: "" + property bool showPasswordInput: false + property string selectedSession: "" + property string selectedSessionPath: "" + property string selectedSessionDesktopId: "" + property string pamState: "" + property bool unlocking: false + + property var sessionList: [] + property var sessionExecs: [] + property var sessionPaths: [] + property var sessionDesktopIds: [] + property int currentSessionIndex: 0 + property bool sessionManuallySelected: false + property var availableUsers: [] + property int selectedUserIndex: -1 + + function reset() { + showPasswordInput = false; + username = ""; + usernameInput = ""; + passwordBuffer = ""; + pamState = ""; + selectedUserIndex = -1; + } +} diff --git a/quickshell/Modules/Greetd/GreeterSurface.qml b/quickshell/Modules/Greetd/GreeterSurface.qml new file mode 100644 index 000000000..0d21f5bdf --- /dev/null +++ b/quickshell/Modules/Greetd/GreeterSurface.qml @@ -0,0 +1,33 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Common + +Variants { + model: Quickshell.screens + + PanelWindow { + id: root + + property var modelData + + screen: modelData + anchors { + left: true + right: true + top: true + bottom: true + } + exclusionMode: ExclusionMode.Normal + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + color: "transparent" + + GreeterContent { + anchors.fill: parent + screenName: root.screen?.name ?? "" + } + } +} diff --git a/quickshell/Modules/Greetd/GreeterUserPicker.qml b/quickshell/Modules/Greetd/GreeterUserPicker.qml new file mode 100644 index 000000000..b2b166681 --- /dev/null +++ b/quickshell/Modules/Greetd/GreeterUserPicker.qml @@ -0,0 +1,240 @@ +import QtQuick +import QtQuick.Layouts +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + property bool expanded: false + property int maxExpandedHeight: 400 + property bool autoLoginVisible: false + property bool autoLoginChecked: false + property bool manualEntryVisible: false + + signal userSelected(string username) + signal toggleRequested + signal autoLoginToggled + signal manualEntryRequested + + readonly property int rowHeight: 52 + readonly property int collapsedBarHeight: 36 + readonly property int actionRowHeight: 44 + + readonly property int userListFullHeight: { + const count = GreeterUsersService.users.length; + if (count === 0) + return 0; + return count * rowHeight + Math.max(0, count - 1) * Theme.spacingXS; + } + readonly property int manualEntryBlockHeight: manualEntryVisible ? actionRowHeight + Theme.spacingXS : 0 + readonly property int autoLoginBlockHeight: autoLoginVisible ? actionRowHeight + Theme.spacingXS : 0 + readonly property int expandedContentHeight: { + if (!expanded) + return 0; + if (GreeterUsersService.users.length === 0 && !autoLoginVisible && !manualEntryVisible) + return 0; + return Math.min(maxExpandedHeight, userListFullHeight + manualEntryBlockHeight + autoLoginBlockHeight); + } + + function encodeFileUrl(path) { + if (!path) + return ""; + return "file://" + path.split("/").map(s => encodeURIComponent(s)).join("/"); + } + + function profileImageSource(username) { + const path = GreeterUsersService.profileImagePath(username); + if (path) + return encodeFileUrl(path); + return ""; + } + + implicitHeight: expanded ? expandedContentHeight : collapsedBarHeight + implicitWidth: parent ? parent.width : 320 + + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: collapsedBarHeight + visible: !expanded + + RowLayout { + anchors.fill: parent + spacing: Theme.spacingM + + StyledText { + Layout.fillWidth: true + text: GreeterState.username ? GreeterUsersService.optionLabel(GreeterState.username) : I18n.tr("Select user...", "greeter user picker placeholder") + color: GreeterState.username ? Theme.surfaceText : Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeMedium + elide: Text.ElideRight + } + + DankIcon { + Layout.alignment: Qt.AlignVCenter + name: "expand_more" + size: 20 + color: Theme.surfaceVariantText + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleRequested() + } + } + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: root.expandedContentHeight + visible: expanded + spacing: Theme.spacingXS + + DankListView { + id: userListView + + width: parent.width + height: parent.height - root.manualEntryBlockHeight - root.autoLoginBlockHeight + clip: true + interactive: contentHeight > height + spacing: Theme.spacingXS + model: GreeterUsersService.users + + delegate: Rectangle { + id: userRow + + required property var modelData + required property int index + + width: userListView.width + height: root.rowHeight + radius: Theme.cornerRadius + color: userRowMouse.containsMouse ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0) + border.color: GreeterState.username === userRow.modelData.username ? Theme.primary : Theme.withAlpha(Theme.primary, 0) + border.width: GreeterState.username === userRow.modelData.username ? 1 : 0 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingM + + Item { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + + DankCircularImage { + anchors.fill: parent + imageSource: root.profileImageSource(userRow.modelData.username) + fallbackIcon: "person" + } + } + + StyledText { + Layout.fillWidth: true + text: GreeterUsersService.optionLabel(userRow.modelData.username) + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + elide: Text.ElideRight + } + } + + MouseArea { + id: userRowMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.userSelected(userRow.modelData.username) + } + } + } + + Rectangle { + width: parent.width + height: root.actionRowHeight + visible: root.manualEntryVisible + radius: Theme.cornerRadius + color: manualEntryRowMouse.containsMouse ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingM + + DankIcon { + Layout.alignment: Qt.AlignVCenter + name: "person_add" + size: 20 + color: Theme.surfaceVariantText + } + + StyledText { + Layout.fillWidth: true + text: I18n.tr("Not listed?", "greeter link to switch to manual username entry") + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + elide: Text.ElideRight + } + } + + MouseArea { + id: manualEntryRowMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.manualEntryRequested() + } + } + + Rectangle { + width: parent.width + height: root.actionRowHeight + visible: root.autoLoginVisible + radius: Theme.cornerRadius + color: autoLoginRowMouse.containsMouse ? Theme.surfacePressed : Theme.withAlpha(Theme.surfacePressed, 0) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingM + + DankIcon { + Layout.alignment: Qt.AlignVCenter + name: root.autoLoginChecked ? "check_box" : "check_box_outline_blank" + size: 20 + color: root.autoLoginChecked ? Theme.primary : Theme.surfaceVariantText + } + + StyledText { + Layout.fillWidth: true + text: I18n.tr("Auto-login") + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + elide: Text.ElideRight + } + } + + MouseArea { + id: autoLoginRowMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.autoLoginToggled() + } + } + } +} diff --git a/quickshell/Modules/Greetd/GreeterUserTheme.qml b/quickshell/Modules/Greetd/GreeterUserTheme.qml new file mode 100644 index 000000000..9ee9ae011 --- /dev/null +++ b/quickshell/Modules/Greetd/GreeterUserTheme.qml @@ -0,0 +1,51 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services + +Singleton { + id: root + + readonly property var log: Log.scoped("GreeterUserTheme") + readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter" + + property string activeUsername: "" + + function userCacheDir(username) { + if (!username) + return ""; + return greetCfgDir + "/users/" + username; + } + + function applyForUser(username) { + const name = (username || "").trim(); + activeUsername = name; + if (!name) { + applyDefault(); + return; + } + const dir = userCacheDir(name); + if (typeof GreeterUsersService !== "undefined" && GreeterUsersService.hasSyncedTheme(name)) { + Theme.setGreeterColorsBaseDir(dir); + SessionData.setGreeterSessionBaseDir(dir); + GreetdSettings.setConfigBaseDir(dir); + return; + } + applyDefault(); + } + + function applyDefault() { + activeUsername = ""; + Theme.resetGreeterColorsBaseDir(); + SessionData.resetGreeterSessionBaseDir(); + GreetdSettings.resetConfigBaseDir(); + } + + readonly property string activeWallpaperOverridePath: { + const base = activeUsername && typeof GreeterUsersService !== "undefined" && GreeterUsersService.hasSyncedTheme(activeUsername) ? userCacheDir(activeUsername) : greetCfgDir; + return base ? base + "/greeter_wallpaper_override.jpg" : ""; + } +} diff --git a/quickshell/Modules/Greetd/README.md b/quickshell/Modules/Greetd/README.md new file mode 100644 index 000000000..accc46ff2 --- /dev/null +++ b/quickshell/Modules/Greetd/README.md @@ -0,0 +1,7 @@ +# Embedded Greetd module + +TODO: remove when dank-greeter is available in the Arch extra repos. + +The greeter moved to [dank-greeter](https://github.com/AvengeMedia/dank-greeter). This embedded copy exists only for archinstall compatibility: the `niri - DankMaterialShell` archinstall profile writes an `/etc/greetd/config.toml` that launches `Modules/Greetd/assets/dms-greeter` from the packaged DMS tree, and dank-greeter is not installable from the official Arch repos yet. + +Do not extend this module. New greeter work goes to dank-greeter. Installing greetd-dms-greeter-bin from the AUR and running `dms-greeter sync` migrates a system off this embedded copy. diff --git a/quickshell/Modules/Greetd/assets/dms-greeter b/quickshell/Modules/Greetd/assets/dms-greeter new file mode 100755 index 000000000..65a3e6fd1 --- /dev/null +++ b/quickshell/Modules/Greetd/assets/dms-greeter @@ -0,0 +1,503 @@ +#!/bin/bash + +set -e + +COMPOSITOR="" +COMPOSITOR_CONFIG="" +DMS_PATH="dms-greeter" +CACHE_DIR="/var/cache/dms-greeter" +REMEMBER_LAST_SESSION="" +REMEMBER_LAST_USER="" +DEBUG_MODE=0 + +show_help() { + cat << EOF +dms-greeter - DankMaterialShell greeter launcher + +Usage: dms-greeter --command COMPOSITOR [OPTIONS] + +Required: + --command COMPOSITOR Compositor to use (niri, hyprland, sway, scroll, miracle, mango, or labwc) + +Options: + -C, --config PATH Custom compositor config file + -p, --path PATH DMS path (config name or absolute path) + (default: dms-greeter) + --cache-dir PATH Cache directory for greeter data + (default: /var/cache/dms-greeter) + --remember-last-session BOOL + Persist selected session to greeter memory + (BOOL: true/false, default: from settings.json) + --remember-last-user BOOL + Persist last successful username to greeter memory + (BOOL: true/false, default: from settings.json) + --no-save-session Alias for --remember-last-session false + --no-save-username Alias for --remember-last-user false + --debug Enable verbose startup logging to stderr + -h, --help Show this help message + +Examples: + dms-greeter --command niri + dms-greeter --command hyprland -C /etc/greetd/custom-hypr.conf + dms-greeter --command sway -p /home/user/.config/quickshell/custom-dms + dms-greeter --command scroll -p /home/user/.config/quickshell/custom-dms + dms-greeter --command niri --cache-dir /tmp/dmsgreeter + dms-greeter --command niri --no-save-session --no-save-username + dms-greeter --command mango + dms-greeter --command labwc +EOF +} + +require_command() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Error: required command '$cmd' was not found in PATH" >&2 + exit 1 + fi +} + +normalize_bool_flag() { + local flag_name="$1" + local value="$2" + local normalized="${value,,}" + + case "$normalized" in + 1|true|yes|on) + echo "1" + ;; + 0|false|no|off) + echo "0" + ;; + *) + echo "Error: $flag_name must be true/false (or 1/0, yes/no, on/off)" >&2 + exit 1 + ;; + esac +} + +is_hyprland_lua_config() { + local config_file="$1" + case "$config_file" in + *.lua) + return 0 + ;; + esac + if [[ -f "$config_file" ]] && grep -qE '(^|[^[:alnum:]_])hl\.' "$config_file"; then + return 0 + fi + return 1 +} + +clear_vt() { + local clear_sequence=$'\033[2J\033[H\033[3J\033[?25l' + + # Clear retained console text on the controlling VT. + if printf '%s' "$clear_sequence" >/dev/tty 2>/dev/null; then + return + fi + + if [[ -t 1 ]]; then + printf '%s' "$clear_sequence" + fi +} + +exec_compositor() { + local log_tag="$1" + shift + + if [[ "$DEBUG_MODE" == "1" ]]; then + exec "$@" + fi + + clear_vt + + if command -v systemd-cat >/dev/null 2>&1; then + exec "$@" > >(systemd-cat -t "dms-greeter/$log_tag" -p info 2>/dev/null) 2>&1 + fi + + # Preserve diagnostics on systems without journald. + exec "$@" >>"$CACHE_DIR/$log_tag.log" 2>&1 +} + +while [[ $# -gt 0 ]]; do + case $1 in + --command) + COMPOSITOR="$2" + shift 2 + ;; + -C|--config) + COMPOSITOR_CONFIG="$2" + shift 2 + ;; + -p|--path) + DMS_PATH="$2" + shift 2 + ;; + --cache-dir) + CACHE_DIR="$2" + shift 2 + ;; + --remember-last-session) + REMEMBER_LAST_SESSION="$2" + shift 2 + ;; + --remember-last-user) + REMEMBER_LAST_USER="$2" + shift 2 + ;; + --no-save-session) + REMEMBER_LAST_SESSION="0" + shift + ;; + --no-save-username) + REMEMBER_LAST_USER="0" + shift + ;; + --debug) + DEBUG_MODE=1 + shift + ;; + -h|--help) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + show_help + exit 1 + ;; + esac +done + +if [[ -z "$COMPOSITOR" ]]; then + echo "Error: --command COMPOSITOR is required" >&2 + show_help + exit 1 +fi + +locate_dms_config() { + local config_name="$1" + local search_paths=() + + local config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + search_paths+=("$config_home/quickshell/$config_name") + + search_paths+=("/usr/share/quickshell/$config_name") + + local config_dirs="${XDG_CONFIG_DIRS:-/etc/xdg}" + IFS=':' read -ra dirs <<< "$config_dirs" + for dir in "${dirs[@]}"; do + if [[ -n "$dir" ]]; then + search_paths+=("$dir/quickshell/$config_name") + fi + done + + for path in "${search_paths[@]}"; do + if [[ -f "$path/shell.qml" ]]; then + echo "$path" + return 0 + fi + done + + return 1 +} + +export XDG_SESSION_TYPE=wayland +export QT_QPA_PLATFORM=wayland +export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 +export EGL_PLATFORM=gbm +export DMS_RUN_GREETER=1 + +if [[ ! -d "$CACHE_DIR" ]]; then + echo "Error: cache directory '$CACHE_DIR' does not exist." >&2 + echo " Run 'dms greeter sync' to initialize it, or pass --cache-dir to an existing directory." >&2 + exit 1 +fi + +export DMS_GREET_CFG_DIR="$CACHE_DIR" + +if [[ -n "$REMEMBER_LAST_SESSION" ]]; then + DMS_GREET_REMEMBER_LAST_SESSION=$(normalize_bool_flag "--remember-last-session" "$REMEMBER_LAST_SESSION") + export DMS_GREET_REMEMBER_LAST_SESSION + if [[ "$DMS_GREET_REMEMBER_LAST_SESSION" == "1" ]]; then + DMS_SAVE_SESSION=true + else + DMS_SAVE_SESSION=false + fi + export DMS_SAVE_SESSION +fi + +if [[ -n "$REMEMBER_LAST_USER" ]]; then + DMS_GREET_REMEMBER_LAST_USER=$(normalize_bool_flag "--remember-last-user" "$REMEMBER_LAST_USER") + export DMS_GREET_REMEMBER_LAST_USER + if [[ "$DMS_GREET_REMEMBER_LAST_USER" == "1" ]]; then + DMS_SAVE_USERNAME=true + else + DMS_SAVE_USERNAME=false + fi + export DMS_SAVE_USERNAME +fi + +export HOME="$CACHE_DIR" +export XDG_STATE_HOME="$CACHE_DIR/.local/state" +export XDG_DATA_HOME="$CACHE_DIR/.local/share" +export XDG_CACHE_HOME="$CACHE_DIR/.cache" + +# Fallback runtime dir for systems without logind/pam_rundir (e.g. Void+seatd), +# where Wayland compositors abort with "RuntimeDirNotSet". Guarded so logind +# systems keep their own. +if [[ -z "${XDG_RUNTIME_DIR:-}" || ! -d "${XDG_RUNTIME_DIR:-}" ]]; then + export XDG_RUNTIME_DIR="$CACHE_DIR/run" + mkdir -p "$XDG_RUNTIME_DIR" + chmod 700 "$XDG_RUNTIME_DIR" +fi + + +# Keep greeter VT clean by default; callers can override via env or --debug. +if [[ -z "${RUST_LOG:-}" ]]; then + export RUST_LOG=warn +fi +if [[ -z "${NIRI_LOG:-}" ]]; then + export NIRI_LOG=warn +fi + +# Export the user's configured cursor (from synced settings.json) for the +# compositor and greeter shell, but only if that theme is installed system-wide. +apply_user_cursor() { + command -v jq >/dev/null 2>&1 || return 0 + local settings="$DMS_GREET_CFG_DIR/settings.json" + [[ -f "$settings" ]] || return 0 + + local theme size + theme=$(jq -r '.cursorSettings.theme // empty' "$settings" 2>/dev/null) + size=$(jq -r '.cursorSettings.size // empty' "$settings" 2>/dev/null) + [[ -n "$theme" && "$theme" != "System Default" ]] || return 0 + + local dirs=() + IFS=':' read -ra data_dirs <<< "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" + local d + for d in "${data_dirs[@]}"; do + [[ -n "$d" ]] && dirs+=("$d/icons") + done + dirs+=(/run/current-system/sw/share/icons /usr/share/icons /usr/local/share/icons) + + for d in "${dirs[@]}"; do + if [[ -d "$d/$theme/cursors" ]]; then + export XCURSOR_PATH="$d${XCURSOR_PATH:+:$XCURSOR_PATH}" + export XCURSOR_THEME="$theme" + [[ -n "$size" ]] && export XCURSOR_SIZE="$size" + return 0 + fi + done +} +apply_user_cursor + +if command -v qs >/dev/null 2>&1; then + QS_BIN="qs" +elif command -v quickshell >/dev/null 2>&1; then + QS_BIN="quickshell" +else + echo "Error: neither 'qs' nor 'quickshell' was found in PATH" >&2 + exit 1 +fi + +QS_CMD="$QS_BIN" +if [[ "$DMS_PATH" == /* ]]; then + QS_CMD="$QS_BIN -p $DMS_PATH" +else + RESOLVED_PATH=$(locate_dms_config "$DMS_PATH") + if [[ $? -eq 0 && -n "$RESOLVED_PATH" ]]; then + if [[ "$DEBUG_MODE" == "1" ]]; then + echo "Located DMS config at: $RESOLVED_PATH" >&2 + fi + QS_CMD="$QS_BIN -p $RESOLVED_PATH" + else + echo "Error: Could not find DMS config '$DMS_PATH' (shell.qml) in any valid config path" >&2 + exit 1 + fi +fi + +case "$COMPOSITOR" in + niri) + require_command "niri" + if [[ -z "$COMPOSITOR_CONFIG" ]]; then + TEMP_CONFIG=$(mktemp) + # base/default config + cat > "$TEMP_CONFIG" << NIRI_EOF +hotkey-overlay { + skip-at-startup +} + +environment { + DMS_RUN_GREETER "1" +} + +gestures { + hot-corners { + off + } +} + +layout { + background-color "#000000" +} +NIRI_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + else + TEMP_CONFIG=$(mktemp) + cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG" + fi + # Append includes if override_files exist + OVERRIDE_FILES=( + /usr/share/greetd/niri_overrides.kdl # for building into a system image + /etc/greetd/niri_overrides.kdl # for local overrides + ) + for override_file in "${OVERRIDE_FILES[@]}"; do + if [[ -e "$override_file" ]]; then + # TODO: maybe move to optional=true when Niri Next drops, so we can avoid these checks ;-) + cat >> "$TEMP_CONFIG" << NIRI_EOF + +include "$override_file" +NIRI_EOF + fi + done + # Append spawn command + cat >> "$TEMP_CONFIG" << NIRI_EOF + +spawn-at-startup "sh" "-c" "$QS_CMD; niri msg action quit --skip-confirmation" +NIRI_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + exec_compositor "niri" niri -c "$COMPOSITOR_CONFIG" + ;; + + hyprland) + if ! command -v start-hyprland >/dev/null 2>&1 && ! command -v Hyprland >/dev/null 2>&1; then + echo "Error: neither 'start-hyprland' nor 'Hyprland' was found in PATH" >&2 + exit 1 + fi + if [[ -n "$COMPOSITOR_CONFIG" ]] && is_hyprland_lua_config "$COMPOSITOR_CONFIG"; then + TEMP_CONFIG=$(mktemp --suffix=.lua) + cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG" + cat >> "$TEMP_CONFIG" << HYPRLAND_LUA_EOF + +hl.on("hyprland.start", function() + hl.exec_cmd('sh -c "$QS_CMD; hyprctl dispatch exit"') +end) +HYPRLAND_LUA_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + elif [[ -z "$COMPOSITOR_CONFIG" ]]; then + TEMP_CONFIG=$(mktemp --suffix=.lua) + cat > "$TEMP_CONFIG" << HYPRLAND_LUA_EOF +hl.env("DMS_RUN_GREETER", "1") + +hl.config({ + misc = { + disable_hyprland_logo = true, + }, +}) + +hl.on("hyprland.start", function() + hl.exec_cmd('sh -c "$QS_CMD; hyprctl dispatch exit"') +end) +HYPRLAND_LUA_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + else + TEMP_CONFIG=$(mktemp) + cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG" + cat >> "$TEMP_CONFIG" << HYPRLAND_EOF + +exec-once = sh -c "$QS_CMD; hyprctl dispatch exit" +HYPRLAND_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + fi + if command -v start-hyprland >/dev/null 2>&1; then + exec_compositor "hyprland" start-hyprland -- --config "$COMPOSITOR_CONFIG" + else + exec_compositor "hyprland" Hyprland -c "$COMPOSITOR_CONFIG" + fi + ;; + + sway) + require_command "sway" + if [[ -z "$COMPOSITOR_CONFIG" ]]; then + TEMP_CONFIG=$(mktemp) + cat > "$TEMP_CONFIG" << SWAY_EOF +exec "$QS_CMD; swaymsg exit" +SWAY_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + else + TEMP_CONFIG=$(mktemp) + cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG" + cat >> "$TEMP_CONFIG" << SWAY_EOF + +exec "$QS_CMD; swaymsg exit" +SWAY_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + fi + exec_compositor "sway" sway --unsupported-gpu -c "$COMPOSITOR_CONFIG" + ;; + + scroll) + require_command "scroll" + if [[ -z "$COMPOSITOR_CONFIG" ]]; then + TEMP_CONFIG=$(mktemp) + cat > "$TEMP_CONFIG" << SCROLL_EOF +exec "$QS_CMD; scrollmsg exit" +SCROLL_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + else + TEMP_CONFIG=$(mktemp) + cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG" + cat >> "$TEMP_CONFIG" << SCROLL_EOF + +exec "$QS_CMD; scrollmsg exit" +SCROLL_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + fi + exec_compositor "scroll" scroll -c "$COMPOSITOR_CONFIG" + ;; + + miracle|miracle-wm) + require_command "miracle-wm" + if [[ -z "$COMPOSITOR_CONFIG" ]]; then + TEMP_CONFIG=$(mktemp) + cat > "$TEMP_CONFIG" << MIRACLE_EOF +exec "$QS_CMD; miraclemsg exit" +MIRACLE_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + else + TEMP_CONFIG=$(mktemp) + cat "$COMPOSITOR_CONFIG" > "$TEMP_CONFIG" + cat >> "$TEMP_CONFIG" << MIRACLE_EOF + +exec "$QS_CMD; miraclemsg exit" +MIRACLE_EOF + COMPOSITOR_CONFIG="$TEMP_CONFIG" + fi + exec_compositor "miracle" miracle-wm -c "$COMPOSITOR_CONFIG" + ;; + + labwc) + require_command "labwc" + if [[ -n "$COMPOSITOR_CONFIG" ]]; then + exec_compositor "labwc" labwc --config "$COMPOSITOR_CONFIG" --session "$QS_CMD" + else + exec_compositor "labwc" labwc --session "$QS_CMD" + fi + ;; + + mango|mangowc) + require_command "mango" + if [[ -n "$COMPOSITOR_CONFIG" ]]; then + exec_compositor "mango" mango -c "$COMPOSITOR_CONFIG" -s "$QS_CMD && mmsg dispatch quit" + else + exec_compositor "mango" mango -s "$QS_CMD && mmsg dispatch quit" + fi + ;; + + *) + echo "Error: Unsupported compositor: $COMPOSITOR" >&2 + echo "Supported compositors: niri, hyprland, sway, scroll, miracle, mango, labwc" >&2 + exit 1 + ;; +esac diff --git a/quickshell/Modules/Greetd/assets/dms-hypr.conf b/quickshell/Modules/Greetd/assets/dms-hypr.conf new file mode 100644 index 000000000..03f6063ff --- /dev/null +++ b/quickshell/Modules/Greetd/assets/dms-hypr.conf @@ -0,0 +1,4 @@ +# Deprecated: greetd expects Hyprland 0.55+ Lua; use `/etc/greetd/dms-hypr.lua` instead. +env = DMS_RUN_GREETER,1 + +exec = sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit" diff --git a/quickshell/Modules/Greetd/assets/dms-hypr.lua b/quickshell/Modules/Greetd/assets/dms-hypr.lua new file mode 100644 index 000000000..f875d6370 --- /dev/null +++ b/quickshell/Modules/Greetd/assets/dms-hypr.lua @@ -0,0 +1,8 @@ +-- Minimal Hyprland (Lua) session for greetd — replace _DMS_PATH_ with your DMS checkout. +-- Copy to `/etc/greetd/dms-hypr.lua` alongside `greet-hyprland.sh`. + +hl.env("DMS_RUN_GREETER", "1") + +hl.on("hyprland.start", function() + hl.exec_cmd('sh -c "qs -p _DMS_PATH_; hyprctl dispatch exit"') +end) diff --git a/quickshell/Modules/Greetd/assets/dms-niri.kdl b/quickshell/Modules/Greetd/assets/dms-niri.kdl new file mode 100644 index 000000000..2af568ec2 --- /dev/null +++ b/quickshell/Modules/Greetd/assets/dms-niri.kdl @@ -0,0 +1,19 @@ +hotkey-overlay { + skip-at-startup +} + +environment { + DMS_RUN_GREETER "1" +} + +spawn-at-startup "sh" "-c" "qs -p _DMS_PATH_; niri msg action quit --skip-confirmation" + +gestures { + hot-corners { + off + } +} + +layout { + background-color "#000000" +} diff --git a/quickshell/Modules/Greetd/assets/greet-hyprland.sh b/quickshell/Modules/Greetd/assets/greet-hyprland.sh new file mode 100755 index 000000000..58fab7fb1 --- /dev/null +++ b/quickshell/Modules/Greetd/assets/greet-hyprland.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +export XDG_SESSION_TYPE=wayland +export QT_QPA_PLATFORM=wayland +export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 +export EGL_PLATFORM=gbm +if command -v start-hyprland >/dev/null 2>&1; then + exec start-hyprland -- -c /etc/greetd/dms-hypr.lua +else + exec Hyprland -c /etc/greetd/dms-hypr.lua +fi diff --git a/quickshell/Modules/Greetd/assets/greet-niri.sh b/quickshell/Modules/Greetd/assets/greet-niri.sh new file mode 100755 index 000000000..d31db65db --- /dev/null +++ b/quickshell/Modules/Greetd/assets/greet-niri.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +export XDG_SESSION_TYPE=wayland +export QT_QPA_PLATFORM=wayland +export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 +export EGL_PLATFORM=gbm + +exec niri -c /etc/greetd/dms-niri.kdl diff --git a/quickshell/Modules/Settings/GreeterTab.qml b/quickshell/Modules/Settings/GreeterTab.qml index 3165a4316..260fd21cd 100644 --- a/quickshell/Modules/Settings/GreeterTab.qml +++ b/quickshell/Modules/Settings/GreeterTab.qml @@ -81,7 +81,8 @@ Item { property bool greeterTerminalFallbackFromPrecheck: false property bool greeterBinaryExists: false property bool greeterEnabled: false - readonly property bool greeterInstalled: greeterBinaryExists || greeterEnabled + property bool embeddedGreeterConfigured: false + readonly property bool embeddedGreeterOnly: embeddedGreeterConfigured && !greeterBinaryExists readonly property string greeterAction: greeterBinaryExists && !greeterEnabled ? "activate" : "" readonly property bool greeterActionAvailable: greeterAction !== "" @@ -92,6 +93,7 @@ Item { function checkGreeterInstallState() { greetdEnabledCheckProcess.running = true; greeterBinaryCheckProcess.running = true; + embeddedGreeterCheckProcess.running = true; } function runGreeterStatus() { @@ -124,6 +126,8 @@ Item { } function runGreeterSync() { + if (!greeterBinaryExists) + return; greeterSyncStdout = ""; greeterSyncStderr = ""; greeterSudoProbeStderr = ""; @@ -164,8 +168,17 @@ Item { onExited: exitCode => { root.greeterBinaryExists = (exitCode === 0); - if (exitCode !== 0 && root.greeterStatusText === "") - root.greeterStatusText = I18n.tr("Failed to run 'dms-greeter status'. Ensure the dms-greeter package is installed.", "greeter status error"); + } + } + + Process { + id: embeddedGreeterCheckProcess + // archinstall's DMS profile points greetd at this launcher inside the packaged DMS tree + command: ["sh", "-c", "grep -q 'Modules/Greetd/assets/dms-greeter' /etc/greetd/config.toml 2>/dev/null"] + running: false + + onExited: exitCode => { + root.embeddedGreeterConfigured = (exitCode === 0); } } @@ -396,6 +409,10 @@ Item { return I18n.tr("Checking...", "greeter status loading"); if (root.greeterStatusText !== "") return root.greeterStatusText; + if (root.embeddedGreeterOnly) + return I18n.tr("The greeter bundled with DMS is active (archinstall setup). It keeps working as is, but syncing theme and settings needs the standalone greeter. Install greetd-dms-greeter-bin from the AUR, then run Sync to migrate the login screen.", "embedded greeter status"); + if (!root.greeterBinaryExists && root.greeterEnabled) + return I18n.tr("dms-greeter is not installed. Install the dms-greeter package to manage the greeter.", "greeter status placeholder"); return I18n.tr("Click Refresh to check status.", "greeter status placeholder"); } font.pixelSize: Theme.fontSizeSmall @@ -437,7 +454,7 @@ Item { iconName: "sync" horizontalPadding: Theme.spacingL onClicked: root.runGreeterSync() - enabled: root.greeterInstalled && !root.greeterSyncRunning && !root.greeterInstallActionRunning + enabled: root.greeterBinaryExists && !root.greeterSyncRunning && !root.greeterInstallActionRunning } } } @@ -661,7 +678,7 @@ Item { Rectangle { id: syncPendingPill - readonly property bool shown: SessionData.greeterSyncPending && root.greeterInstalled + readonly property bool shown: SessionData.greeterSyncPending && root.greeterBinaryExists anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom diff --git a/quickshell/Services/GreeterUsersService.qml b/quickshell/Services/GreeterUsersService.qml new file mode 100644 index 000000000..274d899b0 --- /dev/null +++ b/quickshell/Services/GreeterUsersService.qml @@ -0,0 +1,162 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Common + +Singleton { + id: root + + readonly property var log: Log.scoped("GreeterUsersService") + + readonly property string greetCfgDir: Quickshell.env("DMS_GREET_CFG_DIR") || "/var/cache/dms-greeter" + readonly property string usersCacheDir: greetCfgDir + "/users" + + property var users: [] + property var usernames: [] + property var profileImageMap: ({}) + property bool loaded: false + property bool refreshing: false + + Component.onCompleted: refresh() + + function refresh() { + if (refreshing) + return; + refreshing = true; + _loadUsers(); + } + + function displayName(username) { + const u = _findUser(username); + if (!u) + return username || ""; + const gecos = (u.gecos || "").trim(); + return gecos.length > 0 ? gecos : username; + } + + function optionLabel(username) { + const label = displayName(username); + return label !== username ? label : username; + } + + function usernameFromOptionLabel(label) { + for (let i = 0; i < users.length; i++) { + if (root.optionLabel(users[i].username) === label) + return users[i].username; + } + return label; + } + + function hasSyncedTheme(username) { + if (!username) + return false; + return syncedThemePaths[username] === true; + } + + property var syncedThemePaths: ({}) + + function userCacheDir(username) { + if (!username) + return ""; + return usersCacheDir + "/" + username; + } + + function syncedSettingsPath(username) { + const dir = userCacheDir(username); + return dir ? dir + "/settings.json" : ""; + } + + function _findUser(name) { + for (let i = 0; i < users.length; i++) { + if (users[i].username === name) + return users[i]; + } + return null; + } + + function _loadUsers() { + Proc.runCommand("greeterUsersService-loadUsers", ["sh", "-c", "getent passwd | awk -F: '$3>=1000 && $3<60000 && $1!=\"nobody\" && $7!~/(nologin|false)$/ && $6!=\"/var/empty\" {print $1\":\"$3\":\"$5\":\"$6\":\"$7}'"], (output, exitCode) => { + const lines = (output || "").trim().split("\n").filter(l => l.length > 0); + const list = []; + const names = []; + for (let i = 0; i < lines.length; i++) { + const parts = lines[i].split(":"); + if (parts.length < 5) + continue; + const username = parts[0]; + list.push({ + username, + uid: parseInt(parts[1], 10), + gecos: (parts[2] || "").split(",")[0], + home: parts[3] || "", + shell: parts[4] || "" + }); + names.push(username); + } + list.sort((a, b) => a.username.localeCompare(b.username)); + names.sort((a, b) => a.localeCompare(b)); + root.users = list; + root.usernames = names; + root.loaded = true; + root.refreshing = false; + _refreshSyncedThemeFlags(); + _loadProfileIcons(); + }, 0); + } + + function _refreshSyncedThemeFlags() { + if (usernames.length === 0) { + syncedThemePaths = ({}); + return; + } + const checks = usernames.map(u => `[ -f "${syncedSettingsPath(u)}" ] && echo "${u}:1" || echo "${u}:0"`).join("; "); + Proc.runCommand("greeterUsersService-syncedThemes", ["sh", "-c", checks], (output, exitCode) => { + const map = {}; + const lines = (output || "").trim().split("\n").filter(l => l.length > 0); + for (let i = 0; i < lines.length; i++) { + const parts = lines[i].split(":"); + if (parts.length >= 2) + map[parts[0]] = parts[1] === "1"; + } + root.syncedThemePaths = map; + }, 0); + } + + function profileImagePath(username) { + if (!username) + return ""; + return profileImageMap[username] || ""; + } + + function _loadProfileIcons() { + if (users.length === 0) { + profileImageMap = ({}); + return; + } + const script = users.map(u => { + const safeUser = u.username.replace(/'/g, "'\\''"); + const safeHome = (u.home || "").replace(/'/g, "'\\''"); + const cacheDir = usersCacheDir + "/" + u.username; + return `( icon=""; for f in "${cacheDir}/profile.jpg" "${cacheDir}/profile.jpeg" "${cacheDir}/profile.png" "${cacheDir}/profile.webp" "/var/lib/AccountsService/icons/${safeUser}" "${safeHome}/.face" "${safeHome}/.face.icon"; do if [ -f "$f" ] && [ -r "$f" ]; then icon="$f"; break; fi; done; echo "${u.username}:$icon" )`; + }).join("; "); + Proc.runCommand("greeterUsersService-profileIcons", ["sh", "-c", script], (output, exitCode) => { + const map = {}; + const lines = (output || "").trim().split("\n").filter(l => l.length > 0); + for (let i = 0; i < lines.length; i++) { + const idx = lines[i].indexOf(":"); + if (idx <= 0) + continue; + const user = lines[i].substring(0, idx); + const icon = lines[i].substring(idx + 1).trim(); + map[user] = icon && icon.length > 0 ? icon : ""; + } + for (let j = 0; j < users.length; j++) { + const u = users[j].username; + if (!(u in map)) + map[u] = ""; + } + root.profileImageMap = map; + }, 0); + } +} diff --git a/quickshell/Services/PortalService.qml b/quickshell/Services/PortalService.qml index 2b86cddf4..c0f7876de 100644 --- a/quickshell/Services/PortalService.qml +++ b/quickshell/Services/PortalService.qml @@ -53,6 +53,10 @@ Singleton { profileImage = ""; return; } + if (Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true") { + profileImage = ""; + return; + } if (!freedeskAvailable) { profileImage = ""; @@ -298,6 +302,52 @@ Singleton { }); } + property string pendingGreeterProfileUser: "" + + function getGreeterUserProfileImage(username) { + if (!username) { + profileImage = ""; + pendingGreeterProfileUser = ""; + return; + } + if (typeof GreeterUsersService !== "undefined") { + const cachedPath = GreeterUsersService.profileImagePath(username); + if (cachedPath) { + profileImage = cachedPath; + pendingGreeterProfileUser = ""; + return; + } + } + pendingGreeterProfileUser = username; + 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 = ""; + } + root.pendingGreeterProfileUser = ""; + } + } + + onExited: exitCode => { + if (exitCode !== 0 && root.pendingGreeterProfileUser !== "") { + root.profileImage = ""; + root.pendingGreeterProfileUser = ""; + } + } + } + Process { id: colorSchemeDetector command: ["bash", "-c", "command -v gsettings || command -v dconf"] diff --git a/quickshell/Services/WeatherService.qml b/quickshell/Services/WeatherService.qml index 65cb8c694..37f127e53 100644 --- a/quickshell/Services/WeatherService.qml +++ b/quickshell/Services/WeatherService.qml @@ -5,6 +5,7 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Common +import qs.Modules.Greetd import "../Common/suncalc.js" as SunCalc Singleton { @@ -470,7 +471,7 @@ Singleton { } function getConfiguredLocationName() { - return SettingsData.weatherLocation; + return SessionData.isGreeterMode ? SessionData.weatherLocation : SettingsData.weatherLocation; } function setLocation(lat, lon, city, country) { @@ -523,9 +524,9 @@ Singleton { } function updateLocation() { - const useAuto = SettingsData.useAutoLocation; - const coords = SettingsData.weatherCoordinates; - const cityName = SettingsData.weatherLocation; + const useAuto = SessionData.isGreeterMode ? GreetdSettings.useAutoLocation : SettingsData.useAutoLocation; + const coords = SessionData.isGreeterMode ? SessionData.weatherCoordinates : SettingsData.weatherCoordinates; + const cityName = SessionData.isGreeterMode ? SessionData.weatherLocation : SettingsData.weatherLocation; if (useAuto) { getLocationFromService(); @@ -1047,7 +1048,7 @@ Singleton { Timer { id: updateTimer interval: nextInterval() - running: root.refCount > 0 && SettingsData.weatherEnabled + running: root.refCount > 0 && SettingsData.weatherEnabled && !SessionData.isGreeterMode repeat: true triggeredOnStart: true onTriggered: { diff --git a/quickshell/scripts/qmllint-entrypoints.sh b/quickshell/scripts/qmllint-entrypoints.sh index 24c53616f..11b24c129 100755 --- a/quickshell/scripts/qmllint-entrypoints.sh +++ b/quickshell/scripts/qmllint-entrypoints.sh @@ -103,6 +103,7 @@ fi targets=( "${quickshell_dir}/shell.qml" "${quickshell_dir}/DMSShell.qml" + "${quickshell_dir}/DMSGreeter.qml" ) qmllint_args=( diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 690d8a219..592c1c11b 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -17,6 +17,7 @@ import qs.Services ShellRoot { id: entrypoint + readonly property bool runGreeter: Quickshell.env("DMS_RUN_GREETER") === "1" || Quickshell.env("DMS_RUN_GREETER") === "true" readonly property bool disableHotReload: Quickshell.env("DMS_DISABLE_HOT_RELOAD") === "1" || Quickshell.env("DMS_DISABLE_HOT_RELOAD") === "true" Component.onCompleted: { @@ -28,12 +29,15 @@ ShellRoot { DC.Log.backend = Log; DC.Host.session = SessionService; DC.Host.cache = CacheData; + if (entrypoint.runGreeter) + return; // Build the polkit agent here, outside incubation: first-touching it from a Connections target during DMSShell's async load crashed QQmlConnections::connectSignalsToMethods. void PolkitService.agent; } Loader { id: wallpaperLoader + active: !entrypoint.runGreeter asynchronous: false sourceComponent: Scope { @@ -49,6 +53,7 @@ ShellRoot { Loader { id: shellCoreLoader + active: !entrypoint.runGreeter asynchronous: true source: "ShellCore.qml" onLoaded: dmsShellLoader.setSource("DMSShell.qml", { @@ -60,4 +65,11 @@ ShellRoot { id: dmsShellLoader asynchronous: true } + + Loader { + id: dmsGreeterLoader + active: entrypoint.runGreeter + asynchronous: false + source: "DMSGreeter.qml" + } } diff --git a/quickshell/translations/extract_translations.py b/quickshell/translations/extract_translations.py index 83d30fa5d..76a53bad4 100755 --- a/quickshell/translations/extract_translations.py +++ b/quickshell/translations/extract_translations.py @@ -87,6 +87,12 @@ def extract_qstr_strings(root_dir): if relative_path.parts[0] == 'PLUGINS': continue + # Modules/Greetd terms are owned by the dank-greeter repo (tagged + # dms-greeter in POEditor); the embedded copy exists only for + # archinstall compatibility and must not upload untagged duplicates. + if relative_path.parts[:2] == ('Modules', 'Greetd'): + continue + with open(qml_file, 'r', encoding='utf-8') as f: for line_num, line in enumerate(f, 1): for pattern, quote in qstr_patterns: